code-browser — architecture #
A read-only code browser built for reviewing changes. It's a hybrid of a browser and a code editor. It never edits code, and it never runs code from the browsed repository outside a sandbox. It writes nothing to a repository: reviews are kept in a local store (see Reviews).
Principles #
- Full files by default, not diffs. A review opens the whole file at the head revision. Changed lines are marked in the gutter and tinted. Removed code can be shown inline on demand, and a changed-files filter is one click away. A classic side-by-side diff is not the default view.
- Forge-agnostic. The unit of review is
(repository, base ref, head ref). Repositories are local paths or any git remote URL (ssh/https). GitHub, GitLab and similar can be added later as sources that resolve to that same tuple. - The browsed code is untrusted. Nothing from the repo runs on the host. That includes git hooks, filters, textconv drivers, fsmonitor, language servers, build scripts and tests.
- Execution happens only in sandboxes, behind a provider interface. Local sandboxes use Linux VMs on macOS Virtualization.framework. Remote sandboxes implement the same interface over the network.
- Reviews are local. They live in one SQLite store in the app's data directory, covering every repository, and nothing about them is written to git. A submitted review can be exported to a forge, and what happens there is fetched back, read-only, through adapters. The store is specified in REVIEW-FORMAT.md, exporting and fetching in integrations/README.md.
Process layout #
┌──────────────────────────── Electron ─────────────────────────────┐
│ main (Node) │
│ ├─ git/ read-only git plumbing (hardened invocations) │
│ ├─ repos/ local + remote repo registry, bare clone cache │
│ ├─ sandbox/ SandboxProvider interface + providers │
│ │ ├─ vz → native/ (Rust, napi-rs, Virtualization.framework)│
│ │ └─ remote → (future) gRPC/WebSocket to a sandbox service │
│ └─ ipc.ts typed IPC surface (the only renderer entry point) │
│ │
│ preload contextBridge → window.api (typed, narrow) │
│ │
│ renderer React UI, CodeMirror 6 read-only viewer │
│ sandbox:true, contextIsolation, strict CSP │
│ │
│ (future) preview WebContentsView per sandbox, own session │
│ partition, only reaches the sandbox's forwarded port │
└───────────────────────────────────────────────────────────────────┘
Windows and instances #
- One process per profile. Electron's single-instance lock keys on the
data directory.
- A second launch (Dock,
open -n,code-browser <path>) hands its arguments to the running instance, which opens a window, and exits in ~0.1 s. - Two processes would each keep a warm pool and race on the snapshot and registry caches and scratch disks.
--profile=<name>runs a fully separate instance, with its own data directory, lock, VMs and caches.- Because only one instance owns a data directory, startup can safely delete scratch disks and half-built snapshots left behind by a crash.
- A second launch (Dock,
- Each window is an independent review (File → New Window, ⌘N).
- Sandboxes belong to the window that created them, keyed by webContents id.
- The main process checks ownership on every sandbox call, so one window can't exec in, kill or dispose another window's sandbox.
- A window's sandboxes stop when it closes, crashes or reloads.
- All windows share the warm pool and the snapshot and registry caches.
- At most 6 sandboxes run at once across windows.
Git hardening #
The app reads repositories only through git plumbing, and never through the working tree:
ls-tree,cat-file,diff-tree/diffbetween two commits,merge-base,for-each-ref,rev-parse.- Every call passes
--no-ext-diff --no-textconvwhere it applies, and runs with-c core.fsmonitor= -c core.hooksPath=/dev/null -c diff.external=so that repo-local.git/configcan't make git execute anything. - Remote repos are cloned bare (
--bare --filter=blob:none) into the app's cache directory. Nothing is checked out, and submodules are never recursed. GIT_TERMINAL_PROMPT=0stops git from hanging on credential prompts. Auth uses the user's existing ssh agent or credential helper (host config, which we trust).- A cache clone is of the URL the app was given (for a pull request, the
https://URL it chose). Aurl.<base>.insteadOfrewrite in the user's config is honoured first; when the rewritten URL fails (say an ssh agent that refuses the app), the clone or fetch is tried once more with the URL as given (aGIT_CONFIG_*entry mapping it to itself, never an argument), and once that works the clone's own config pins its URL to itself, so later fetches and lazy blob fetches use it too. No token of the app's is used for git; the user's own credential helpers, or none, apply. - The main process writes to git only to clone and fetch (
src/main/repos.ts): the cache clones, their remote-tracking refs and their own config (the fetch refspec, and the URL pin above). Never branches, tags,HEAD, the index, the working tree or a checkout's config, and never anything about reviews. The one thing the app writes about reviews is its own store (see Reviews). - Opening a pull request URL fetches its head and base into remote-tracking
refs:
refs/remotes/<remote>/pull/<n>andrefs/remotes/<remote>/<base>, in an open repository with a remote on that GitHub repository, else in a cache clone. Fetch fetches both again for a subject labelled<remote>/pull/<n>(a local checkout too) and opens the new head, so a draft on the old one gets the head-moved choice. A clone's sync fetches the pull request refs it has along with its branches, since--prunewould otherwise delete them. - Forge credentials (a GitHub token) never come from a repository: no git
credential helpers and no repository-local config. The tools that supply
them (
gh,security,secret-tool) are found only outside every repository and run in a fixed directory with an allowlisted environment. A token is used only for its exact host, stays in the main process, and is scrubbed from everything stored, logged or sent to the renderer (integrations/README.md §8).
Subject model #
A window looks through a Subject: the repository, base and head that a
review is about. "Review" is kept for the stored record described in
Reviews.
Opening a repository opens a subject with base = head at the current branch
(the default branch for a clone): the code, with nothing to compare. A draft
recorded there is an audit (REVIEW-FORMAT.md §2).
Compare (Compare.tsx) sets another base or head over the code and Stop
comparing returns to base = head at the same commit; each subject keeps its
own draft. The omnibar shows base = head as a repo address and anything else as a review address. Branch
counts for Compare (commits ahead of the default branch, files changed since
the merge-base) come from branchStats in src/main/git/repo.ts, tree-level
so a blobless clone fetches nothing. The reviews list is only shown when asked
for (⇧⌘R, or from the welcome screen).
Subject = { repo, base, head } base defaults to merge-base(defaultBranch, head)
changedFiles = diff --name-status -M base head
fileView(p) = blob(head:p) + hunks(base:p → head:p, -U0)
oldView(p) = blob(base:p) (used for inline "show removed")
The renderer gets the head file content plus a compact list of hunks. Gutter
markers (added, modified, deleted-here) are computed from the hunks, and the
inline removed-code view uses @codemirror/merge's unified view with editing
controls disabled.
Structure: tree-sitter (src/renderer/src/syntax/) #
The review's structure comes from tree-sitter, parsed on the host with no sandbox needed, for TS/TSX, JS/JSX, Python, Go and Rust.
- Where it runs: a module Web Worker in the renderer, using
web-tree-sitter. Grammars are the officialtree-sitter-*packages' prebuilt WebAssembly (their native-build postinstalls stay blocked), loaded per language on first use (0.2–1.4 MB each). Repository content is untrusted, and this keeps parsing off the UI thread and inside the wasm heap, with a 2 MB size cap and a 2 s parse budget. The CSP gains'wasm-unsafe-eval', which allows compiling WebAssembly, noteval. - Symbols come from the grammars' upstream
tags.scmqueries (TypeScript extends JavaScript's), plus a few additions (TS type aliases and enums, Rust impl blocks, enums and aliases). Nesting follows ranges; names are qualified by their containers (Store.get, Rust methods by the impl's type, Go methods by the receiver). - Changes by symbol (sidebar Symbols tab): each changed file is parsed
at base and head, and every changed line (from the
-U0hunks) belongs to its innermost enclosing definition, or to the top level (blank lines don't count). Symbols match across base/head by kind + qualified name:- unmatched → added / removed (only the outermost of a nested run), or renamed when the body is token-identical apart from the name;
- matched and touched → reformatted when the tokens are identical (whitespace isn't a token), comments when they're identical without comments, else modified;
- a file whose tokens are identical overall is flagged formatting only; new and deleted files are one row, expandable.
- Outline (sidebar Outline tab) lists the open file's definitions and
highlights those a change touches. The viewer's scope header shows the
definitions enclosing the top visible line (
function Viewer › method jumpChange); each part is clickable. analysis.tsis pure over parsed trees, tested underbun testwith the real grammars and realgit diff -U0hunks.
Settings #
Settings are TOML in two layers over built-in defaults (src/main/settings/):
default ← user: ~/.config/code-browser/config.toml (written by the app and by hand)
← repo: .code-browser/config.toml at the trusted branch's tip (read only)
- One schema (
schema.ts) lists every key: type, default, section, where it may be set (scopes), and whether a repository may lock it. The settings page, the consumers and the checks all read it. - The user's file (
store.ts).--profile=<name>usesconfig.<name>.toml, so profiles stay as separate as their data directories. Writes touch only the lines of the keys they change, so comments and layout survive (toml.ts: every edit is parsed back and must equal the intended document, else the file is regenerated). The file is watched: an edit by hand applies at once (the egress policy, for one). A file that doesn't parse leaves defaults in effect and refuses writes. - Per-clone settings sit in the user's file under
[repos."<location>"], where the location is a local repository's working-tree path or a remote's URL: the trusted remote and branch, primary remote (repository.*), toolchain versions. - The repository's file is read with
cat-filefrom the tip of the trusted branch (SETTINGS.md,repoLayer.ts), never HEAD, the working tree or the head under review, so a change can't alter the settings it's reviewed under. The app never writes it: the page's Repository tab is read-only and says to change it with a commit.[locks] keys = [...]enforces a value, for keys the schema marks lockable (todaysandbox.auto_start). - User-only keys (
scopes: ['user']) are never taken from a repository, whatever its file says: egress mode and allow-list, GitHub auth, the trusted branch. The page shows such a key as ignored. - Precedence is the repository's over the user's, except where the
schema says
precedence: 'user'(no key does today). A lock always wins. - The page is the design system's
SettingsPage(Settings.tsx), at the omnibar addresssettings user/<section>orsettings <repo>/<section>, or ⌘, from the menu.SettingsServicebuilds its rows and saves to the user's file only, all or nothing, each value checked against the schema. - Migration. Earlier versions kept settings in
<userData>JSON. The first launch without a user config movesegress.jsoninto it (the original kept asegress.migrated.json),project-overrides.jsonentries move per repository when it's first opened, and the sandbox card's oldlocalStorageswitch moves on first load.
Reviews #
A browsing session is ephemeral: a lens, navigation, a sandbox. A review
is a session you decide to keep. It adds an author (your global git
user.name, as a display name), threads anchored to lines and symbols,
coverage (what you viewed, keyed by blob), cited sandbox evidence and a
verdict. A draft starts with the first mark (comment, viewed tick, flag); a
session with no marks leaves nothing behind. A review with base = head is an
audit: findings, no verdict.
The store is specified in REVIEW-FORMAT.md ("format §N" below). Exporting and fetching are specified in integrations/README.md ("integrations §N"), with one document per adapter (integrations/github.md). The reasons behind the main choices:
- Local, in one SQLite file that only the app writes, not in the reviewed repository. This gives up multi-machine access, sharing without a forge and signed verdicts (format §1.1).
- Identified by root commit (format §3.3), not by where a clone lives, so reviews survive moving, re-cloning or deleting a clone.
- Columns, and Markdown bodies. Structure lives in columns. Bodies are Markdown for display and are never parsed for structure.
- Forge-neutral core. A change is a pair of commits, however it was proposed. Forges appear only through adapters. An export is an explicit action, once per review, lossy by design, and shows what it drops before sending. What is fetched is kept beside the local review, never merged into it (integrations §4).
generated_bylets content declare that a model wrote it. It describes the content, never the author, and never affects standing or verdicts.
The store (src/main/reviews/store/) #
- Location (
open.ts, format §4.1):reviews.sqlitein<userData>, one store per profile for every repository. A build that isn't packaged (bun run dev) uses<userData>/dev/instead, so a branch never migrates or rotates the real store;CODE_BROWSER_REAL_STORE=1opts in to the real one. - Opening (format §4.2) checks the application id, sets the pragmas, and
runs
quick_checkafter an unclean shutdown. A store that fails is not written: the app offers the newest good backup, or moves the file aside. - Bindings (
db.ts, format §15): one smallDbinterface overnode:sqlitein the app (db-node.ts) andbun:sqliteunderbun test(db-bun.ts), chosen at run time.bun run test:store-noderuns a smoke subset under Electron's Node, so the production backend is tested too. - Migrations (
migrate.ts,migrations/, format §4.4) are numbered, hashed and checked against the expected schema. A store written by a newer version, or by another branch, is opened read-only. - Backups (
backup.ts, format §4.6): a routine backup inbackups/on the first write of each day and on quit after a write, keeping seven; abefore-<level>copy before migrating; restore from either. - Writes:
drafts.tsandreviews.ts(drafts, submitting, evidence and coverage),entries.ts(replies, resolves, edits and retractions, format §10),move.ts(a draft onto a new head, format §6.5).anchors.tschecks anchors against git and computes their context and excerpt.views.tsreads what the renderer shows. - Around it:
identity.ts(root commits),folding.ts(entries, standing, verdicts and timelines, format §10.4, §11; pure),integration.ts(a review's destination, export, links and fetched items), andindex.ts, theReviewServicebehind thereview:*IPC channels.
The main process is the only writer, through one connection (format §4.5).
Transactions are synchronous and never span an await: git reads and network
calls happen outside them, and a draft's rev refuses a change computed from
a stale read.
Evidence is written by the main process, not the renderer. SandboxManager
keeps a window's last 32 finished sandbox:exec runs: the command, the exit
code, stdout and stderr in the order they arrived (the last 1 MiB, with the
full length), the snapshot's repository and commit, and what the command
could reach. A draft cites a run by its exec id, and only a run of the same
window whose commit belongs to the draft's repository. Anchors are checked
the same way: blob must be path at commit, the lines must fall inside
it, and the context and excerpt (format §7.3, §7.4) are computed from the
blob. Any the renderer sends are ignored.
Export and fetch (src/main/reviews/sync/) #
The adapter-neutral engines of integrations §5 to §8. Adapters never see the store: they get plain records and return plain values.
adapter.tsis the contract: an adapter names and checks destinations, declares its mappings, plans and sends exports step by step, fetches, and classifies its failures.destinations.ts: destinations by system, host and locator, their confirmation, pauses per host after a rate limit.links.ts: the record that a review or thread was sent, written once and never changed.fetch.ts: theFetcher. A fetch is read-only and complete: it reads the whole destination and upserts what is there as remote entries; an item missing from two complete fetches is marked deleted. It runs when a linked review opens, when asked, when planning, after an export, and periodically (at most once a minute per destination).plan.ts: planning runs a fetch, asks the adapter for a plan and its loss report, and keeps it until the user confirms exactly that plan.export.tsandtransitions.ts: the export state machine. A review is exported once. Each step is recorded, so a crash resumes. A send whose confirmation was lost is checked against markers before anything is sent again. Failures are classified, a rate limit pauses the host, and nothing retries by itself.markers.ts(the hidden last line naming the local item, body digests),urls.ts(URLs from fetched data or the renderer are parsed and allowed only on the destination's host),scrub.ts(no credentials in errors, data or progress),bounds.ts(fetched values fitted to the store) andlocks.ts(one fetch or export per destination at a time).
GitHub (src/main/github/) #
The adapter of integrations/github.md, and opening a pull request from its URL.
adapter.tsregisters the adapter with the engines.export.tsmaps a review onto one pull request review (github.md §2) and sends it: a pending review, a thread per comment, then submit with the verdict (github.md §3).fetch.tsandqueries.tsread the whole pull request over GraphQL (github.md §4).suggest.tssuggests pull requests from the clone's GitHub remotes, those whose head is the review's head first. A suggestion is shown in full; the user picks.api.tsbuilds every request from the host and checked ids, never from a URL GitHub returned, and follows redirects only within the same origin.- The token (
auth.ts,credentials.ts,keychain.ts) comes from thegithub.authsetting:gh auth token --hostname <host>, or a token saved in the OS keychain for that host (github.md §5). It stays in the main process; the renderer sees only the account it belongs to. index.ts: opening a pull request for review, the account, and the pull requests of a review's head.
Renderer #
useReview.ts holds the reader's review of the open subject and every review
of it; reviewModel.ts derives what is shown from ReviewView (pure,
tested); Review.tsx has the panel, inline threads, the composer and dialogs,
built from the design system's components (design-system/).
- The reviews list (
ReviewsList.tsx, ⇧⌘R) shows every review of the repository.reviewList.ts(pure, tested) turns the store's summary rows into rows and groups them into timelines by format §11.2 only:supersedeschains, then an equal change key, never overlapping files. A row opens its own base and head, so an earlier round opens at its old head. "Needs my reply" is an open thread the reader started or commented on whose last comment is someone else's. - The welcome screen lists recent repositories (
src/main/recent.ts,<userData>/recent-repos.json) and what's waiting on the reader in each. It opens them offline and reads their reviews from the store; it never fetches. - A draft is written on the first mark: a comment, a viewed file, a verdict or a summary. Saves are queued, and each names its subject, so a debounced summary never lands in another subject's draft.
- Threads are CodeMirror block widgets under their last line. Each widget's
DOM is a container React portals into, so a half-typed reply survives
redraws. Any line of the file can be commented on (click its number, or
select and press
c). - Viewed files are coverage records, keyed by blob. A file whose content changed is unviewed again without anyone clearing it.
- Replies and resolves on submitted reviews are pending entries (format §10.3): those written in a draft post when it is submitted, the others wait for "Post N replies". They stay local; to answer on a forge, the user goes there.
- When the head moves under a draft, a dry run of
moveDraftgives the notice's counts, and "Review " re-anchors by blob, then by context (format §7.5). Symbol re-anchoring isn't done yet. ExportDialog.tsx: choose a destination, see the plan and its loss report, confirm; then the export's state and the destination's.RemoteItems.tsxshows what was fetched beside the local review, marked remote, and a linked thread's state there beside its local one.- Everything in the store is displayed as untrusted (format §5.3):
Body.tsxandmarkdown.tsrender Markdown without raw HTML, remote images or links other thanhttp,httpsandmailto, and logs as text with control characters visible. A fetched link opens only through the main process's check for the destination's host.
Costs we accept:
- The store is on one machine. Moving it means restoring a backup on the other one.
- Two code-browser users see each other's reviews only through a destination both can reach.
- Nothing is signed. A verdict at a destination is that account's review, trusted as far as the destination is.
External systems overlay (cb-dv5) #
A change with an attachment (integrations §3.1) shows its destination; one
without shows nothing, and takes no space. The core stays forge-agnostic: the
overlay is drawn only from DestinationView, its remote entries and checks,
and the adapter's own names (the system's name, pr or mr). The design is
on the canvas "External Systems Overlay" (boards 1, 2, D and E).
- The chip. Between the omnibar and the SandboxBadge: the system, the
locator's short form (
#1287,!342), the destination's state and a failed-check count (✕ 1). It also says fetching, no token, fetch failed or that the destination's head is past the subject's (chipPartsinsrc/renderer/src/destination.ts; no token is a fetch refused or not found without one,DestinationView.needsToken). It opens the destination's tab (DestinationChip,src/renderer/src/DestinationPanel.tsx). - The destination tab. A third kind of tab beside
fileandrun(src/renderer/src/tabs.ts),destination:<id>, titled with the adapter's kind (pr), the locator's short form and the destination's title (DestinationTab.tsx). It shows the description, the checks for the destination's head, the reviews there with their verdicts, and the conversation, with a "Start with the first file" action. Opening a destination's URL opens this tab and shows it, so a pull request opens on its description instead of an empty canvas. Attaching from a change review opens nothing; resuming a review restores its tabs as they were. - The right panel gets tabs: Review, the destination (only while attached) and Sandbox, which now holds the SandboxCard that sat under the review. The destination's panel tab is the compact form for reading code (state, checks, description, reviews, conversation), with Open as tab, Fetch, Open on the destination and Detach. While the destination's own tab is showing, the panel shows Review. The SandboxBadge opens the Sandbox tab. Detaching closes the destination's tab.
- Attach is on a change review's panel while nothing is attached
(
AttachDialog): a destination suggested from the remotes, the one whose head is the review's preselected, or one pasted by its URL. - Remote threads stay where RemoteItems shows them: in the code, and in
the Review tab under the destination's name. For an attached destination
(cb-dv5.7), what isn't linked to a local item (
unlinkedTree) is listed under Review, and a thread is shown under its lines while the destination's head is the one shown and it isn't outdated (remotePlacement,src/renderer/src/reviewModel.ts). - Everything shown from a destination is remote and untrusted (format §5.3), and is marked so. Check logs are not fetched: a check links to its page at the destination.
- One path for every system. Opening a URL, the tab, the chip and the
panel tab take everything system-specific from the adapter's declarations
(integrations §3): URL syntax, canonical URL, refs and names, as a
UrlOpening(src/shared/systems.ts; GitHub's isgithubOpeninginsrc/shared/github.ts).DestinationOpener(src/main/opening.ts) asks the system for the title and base branch, andRepoRegistry.openDestinationfetches the declared refs; a subject's labels (<remote>/<tracking>) lead back to its destination for Fetch and its change key. A GitLab adapter then adds its declarations and its fetch mapping, and no UI.
Sandbox model #
interface SandboxProvider {
id: 'vz' | 'remote' | string
create(spec: SandboxSpec): Promise<Sandbox>
}
interface Sandbox {
exec(cmd, opts): ExecHandle // streaming stdout/stderr, exit code
forwardPort(guestPort): Promise<{ hostPort }>
lsp(languageId): Promise<Duplex> // JSON-RPC stream to an LSP in the guest
dispose(): Promise<void>
}
The repository reaches a sandbox as a snapshot, not as a live host mount.
The main process uses git archive to write the head tree into a disk image or
a virtio-fs share that's read-only in the guest. A writable overlay (tmpfs
or scratch disk) inside the guest lets builds write without touching the host.
Local: Virtualization.framework (native/, guest/) #
- Host:
native/src/vz.rswraps VZ throughobjc2-virtualization. Every VM call runs on that VM's own serial dispatch queue, so the wrapper works the same from Electron and from thevzbenchCLI. - Guest: built by
guest/build.shin Podman.- Kernel: 6.18 LTS from
allnoconfigplusguest/kernel.config. No modules, no EFI, uncompressedImage. - Initramfs: uncompressed, containing
cb-agentas/initand static busybox. - Build-time check: fails if Kconfig silently drops a requested option.
- Kernel: 6.18 LTS from
- Repo snapshot:
git archive <head> | tar -xinto a directory cached per commit. It's attached as a read-only virtio-fs share (tagrepo) and mounted at/mnt/repowhen the host sendsattach. An overlay at/work, whose upper layer lives in guest tmpfs, lets builds write without anything reaching the host.- Symlinks in the repo resolve inside the guest. A link to a host path
doesn't escape; this was tested with absolute and
..links.
- Symlinks in the repo resolve inside the guest. A link to a host path
doesn't escape; this was tested with absolute and
- Agent protocol: the host connects to vsock port 1024 and sends one JSON
request per connection (
ping/attach/exec). The agent streams stdout, stderr and the exit code back as length-prefixed frames. Closing the connection kills the command's whole process group. - Networking: off (no network device).
- Entitlement: starting a VM needs
com.apple.security.virtualization.vzbenchis ad-hoc signed with it.
Measured (M4 Pro, macOS 26, native/target/release/vzbench) #
| Scenario | Ready (median) |
|---|---|
| Cold boot, 1 vCPU / 256 MiB | 104 ms |
| Cold boot, 2 vCPU / 512 MiB | 111 ms |
| Cold boot, 4 vCPU / 2 GiB | 121 ms |
| 4 VMs booting concurrently | 155 ms each |
| 8 VMs booting concurrently | 184 ms each |
| Restore saved state, 512 MiB | 131 ms (slower than cold) |
| Restore saved state, 2 GiB | 256 ms (slower than cold) |
| Namespaced process spawn inside a running VM | ~0.2 ms |
Where a cold boot's time goes:
- ~62 ms in
VZVirtualMachine.start()on the host. This is fixed overhead; nothing inside the guest can speed it up. - 30–40 ms of guest kernel and init.
- The rest is the vsock connect.
Memory: an idle VM costs 46 MB of host footprint at 512 MiB guest RAM, and 78 MB at 2 GiB. The guest itself uses ~15 MB at idle.
Takeaways:
- Cold boot is already fast enough; don't snapshot. Restore time grows with guest RAM, and saved state is encrypted to this host. Snapshots only pay off once there's expensive userspace state to preserve, such as a warm language-server index.
- Hide the ~62 ms VZ floor with a warm pool. This is done (
src/main/sandbox/pool.ts).- One VM is booted with an empty
repodevice when the app starts. - Creating a sandbox takes that VM, points the share at the snapshot
(
setShareon the running device, ~0.1 ms), sendsattach, and refills the pool in the background. - Measured in the app: 6 ms from "Start VM" to a usable sandbox.
- Every VM is stopped on quit.
- One VM is booted with an empty
- Toolchains, not boot, will set time-to-useful. Ship them as read-only erofs images shared across VMs, where host page cache makes repeat use cheap.
Topology: one VM per trust domain #
A trust domain is one review, meaning one repository at one head commit.
- Each domain gets its own VM.
- Workloads inside that domain (LSP, tests, dev server) run as processes or containers in the same VM, which gives resource limits and a clean restart for each workload.
- One shared VM for everything would save ~50 MB per extra VM and allow shared caches. But it would put untrusted code from different repositories behind a single kernel. A hostile build script in review A could then read review B's (possibly private) code, or tamper with B's LSP answers and mislead the reviewer.
- Boot at ~110 ms and ~50 MB per VM makes the stronger boundary cheap, and the same model maps onto remote sandboxes (for example Firecracker microVMs).
Devcontainers (implemented) #
The repo's .devcontainer runs inside the review's VM. The devcontainer
config is untrusted repo content. The host never interprets it; the whole
devcontainer toolchain runs in the VM:
VM (one per review) guest/rootfs, Debian trixie
├─ dockerd 26 + buildx + compose data-root on the per-VM scratch disk (/dev/vdb)
├─ @devcontainers/cli (as uid 1000 "cb") `cb-devcontainer-up` → devcontainer up --workspace-folder /work
│ └─ features, Dockerfile builds, compose, lifecycle hooks
│ (initializeCommand, postCreateCommand, …) all run in the VM
└─ devcontainer ← commands run via `docker exec -u <remoteUser> -w <workspace>`
Anything host-flavoured in the config resolves against the VM, where it's harmless:
initializeCommand- bind mounts of
~/.sshordocker.sock ${localEnv:…}--privilegedWe don't forward host env, credentials or the ssh agent.
Measured: devcontainer up for an Alpine base image with a postCreateCommand
takes 15 s end to end from a fresh VM. That covers pulling from MCR, the
CLI's UID layer build, and apk add inside the container.
How it works:
- Root filesystem:
rootfs.erofs(lz4hc, ~440 MB) is attached read-only as/dev/vda.- The initramfs agent mounts it under a tmpfs overlay, moves into it and re-execs itself as PID 1.
- Cold boot to agent-ready with the rootfs is ~170 ms.
- The kernel adds cgroup-v2 BPF, nf_tables with xt compat, bridge and veth, for Docker networking inside the VM.
- UID mapping like a Linux host: the CLI maps the container's remote user
to the invoking user's UID.
- The CLI runs as uid 1000, and
/workis chowned to it. The overlay usesmetacopy=on, so only metadata is copied up, never file contents. - Running it as root would remap
vscodeto uid 0.
- The CLI runs as uid 1000, and
- Clock: VZ gives Linux guests no RTC. The host sets the guest clock
(
set-time) when it claims a VM and every 5 minutes after. - TLS-inspecting endpoints (Aikido, WARP, Zscaler, …): the host's
admin-installed root CAs from the System keychain are installed in the VM
when it's pooled.
- That covers
update-ca-certificatesin the VM andNODE_EXTRA_CA_CERTSfor the CLI. At container runtime, the VM's CA bundle is bind-mounted over the container's (on Debian, Ubuntu and Alpine layouts), andSSL_CERT_FILE,REQUESTS_CA_BUNDLE,PIP_CERT,CARGO_HTTP_CAINFOandNODE_EXTRA_CA_CERTSpoint at it for tools that ignore the system store (pip's vendored certifi, for one). - Limitation:
RUNsteps during image builds (Dockerfile, features) still see the image's own CA bundle.
- That covers
Egress: allow-list proxy over vsock #
The VM has no network device. The only way out:
guest process / container ──HTTP(S)_PROXY──▶ cb-agent :3128 (guest)
cb-agent ──"dial N" on proxy-control──▶ host
host ──vsock connect, proxy-accept N──▶ cb-agent splices the two
host: parse CONNECT / absolute-URI → allow-list → DNS on host →
refuse private/loopback/link-local IPs → connect → pipe
- Proxy settings are injected everywhere: agent exec env, dockerd
proxies(pulls), and the docker client config (containers and builds, via172.17.0.1:3128). - Policy (
src/main/sandbox/egress.ts):- hostnames only, matched exactly or as
*.suffix - ports 80/443 only
- an allow-listed name that resolves to a private address is refused, so nothing reaches the LAN or localhost
- hostnames only, matched exactly or as
- The defaults cover container registries, GitHub, OS mirrors, language
registries and
containers.dev. - Modes (app-wide,
egress-settings.ts,[sandbox.egress]in the user's config; see Settings):- Allow-list (enforce, the default): the policy above.
- Monitor: everything is allowed, and each connection the allow-list would have blocked is reported as "would block". This is for building a list: run the build once, then add what it needed.
- Allow all: everything is allowed.
- In every mode, only public addresses are dialled. Private, link-local and loopback addresses stay blocked (DNS is resolved on the host), so a sandbox never reaches the LAN, the Mac or cloud metadata endpoints. Garbage hostnames are refused too.
- Outside enforce, the sandbox bar shows a warning badge.
- User additions (hostnames or
*.domain) live in the same file; the oldegress-allowlist.jsonis migrated. The network panel (click thenetcounter) lists every host a sandbox reached, with Allow / Allow *.parent buttons for blocked or would-block ones, plus an add box. - One policy object is shared by every sandbox and the registry cache, so changes apply to the next connection of running sandboxes. No restart and no image rebuild: the allow-list lives on the host, not in the guest.
- Every decision is streamed to the sandbox panel (
[net] …lines and an allowed/blocked counter). The registry cache also reports the CDN hosts it follows redirects to (Docker Hub moved blobs toproduction.cloudfront.docker.com, which is how that gap showed up).
Registry cache (src/main/sandbox/registry-cache.ts) #
A host-side cache shared by every sandbox, in all windows. For known registry hosts only (Docker Hub, GHCR, MCR, Quay, ECR Public, GCR, registry.k8s.io):
- TLS: the egress proxy terminates TLS with a per-install P-256 CA.
- The CA is trusted inside VMs only, and its X.509 name constraints permit just those hostnames. Even with its key, it can't vouch for anything else; a test enforces this.
- The key never leaves the host (
0600).
- What's cached: only content-addressed objects, meaning blobs and
manifests requested by digest.
- Each object is hashed while it streams and stored only if it matches the sha256 in its URL, so a hostile sandbox can't poison it.
- The registry's redirect to its CDN is followed on the host, and credentials are never sent to the CDN host.
- Access check on a hit: before serving from cache, the proxy sends a
HEADupstream with the requester's own credentials and serves only if the registry allows it. Cached private layers can't leak to a sandbox without access. - Everything else (tags, token flows, ranges) passes straight through.
- One fetch per object: a request for an object already being fetched waits for that fetch and is then served as a hit (with its own access check). On a cold start the template VM and the review's VM pull the same images at once, and two concurrent fetches of one layer had the CDN send one of them at ~12 KB/s. The CDN sometimes trickles a lone fetch too, so a request waits at most 20 s (healthy waits are 6–16 s) and fetches the object itself if the other fetch isn't done by then or fails. Whichever of the two stores the object first, the other drops its upstream and sends its client the rest from the stored copy (both logged).
- Stalls: an upstream request that goes 30 s without data (no response
yet, or mid-body) is dropped, and so is the sandbox's connection, so its
docker retries instead of hanging on it. Time a slow sandbox holds the
response back doesn't count. Each drop is logged (
[registry-cache] …). - Eviction: LRU at 20 GiB.
Measured with the same devcontainer on a fresh VM:
- Empty cache: 16 s (3 layers, 252 MB stored).
- Warm cache: 10 s (all 3 hits).
The rest is layer extraction, the CLI's UID layer and
postCreateCommand.
Next #
- Cache built layers (features, Dockerfile steps), keyed per repository at most since they derive from untrusted input.
- CA injection for build-time
RUNsteps (a BuildKit CA mount or a rewritten build). - Start
devcontainer upin the background when a review opens. Route LSP/tests into the container, andforwardPortsonto the preview pane.
Code intelligence (src/main/intel/) #
Definition (⌘-click, F12 or gd), references (Shift+F12 or gr), both also
in the viewer's right-click menu, and hover for
TypeScript, JavaScript, Python, Go and Rust, per project, from two sources
tried in order (SCIP, then LSP).
A list of references (or of several definitions) opens in the sidebar, grouped by file. F4 / ⇧F4 step through it in that order, across files and wrapping, with the current one highlighted; Esc from the code or the list closes it.
Projects (projects.ts, versions.ts). The host maps the commit's tree
into projects before anything runs: one toolchain version, one dependency set,
one language server and one index each.
- Every manifest directory is a project (
pyproject.toml/setup.py/requirements*.txt,package.json,go.mod,Cargo.toml), except that workspaces fold their members into one: Cargo[workspace],go.work, npm/yarn/bunworkspaces,pnpm-workspace.yaml, uv[tool.uv.workspace]. Vendored and generated trees are skipped; loose TS/JS or Python files get an implicit repo-root project. - A file belongs to the innermost project of its ecosystem containing it.
- Dependencies between projects come from local path/workspace deps in
their manifests: uv
[tool.uv.sources]and Poetrypath =,-e ../xin requirements files, Nodefile:/link:/portal:/workspace:, Goreplace … => ../x, Cargopath =. A target counts when it is another project's root or workspace member (ProjectInfo.dependsOn); the card shows "depends on" / "used by". - Versions, nearest file wins, walking up to the repo root:
- Python:
.python-version,runtime.txt,mise.toml,.tool-versions, Pipfile, elserequires-python/ Poetrypython/python_requires; - Node:
.nvmrc,.node-version, mise,.tool-versions,volta, elseengines.node; Go:go.mod/go.work(toolchain, elsego); Rust:rust-toolchain(.toml). - A pin further up is used only if the project's own range allows it. A range picks the VM's version when allowed (no download), else the newest supported match. Unmatched ranges fall back to the default, flagged.
- Overrides per project, from the sandbox card, are kept in the user's
config under
[repos."<location>".versions](never in the repo).
- Python:
- Manifests are untrusted: size-capped, parsed with
smol-toml/JSON with failures ignored, and they only ever produce display strings and the plan.
Plan. cb-intel (Python, in the VM) gets the projects as a plan and, for
each in order: dependencies, then its index, reporting PROJECT <id> <phase>
lines that the sandbox card shows live. Each project runs in the first of:
the devcontainer (when it has the toolchain), its language container
(below), the VM (Python and Node only); WHERE <id> … tells the host, whose language server for
the project then runs in the same place (restarted there if a query started
it elsewhere first; one already there, as when a query started the same
language container, is kept). By default the plan is the projects
the review changes, then (up to 8 in all) the projects depending on a changed
one, the ones a changed one depends on, and the rest; those left over can be
indexed from the card.
The host reads structured lines as they stream, not from a capped log tail.
- SCIP (
scip.ts), built per project in the sandbox:- Dependencies first, so imports resolve. Python gets a uv venv per
project with its interpreter version (the language container's; in
the VM uv fetches e.g. CPython 3.8 on demand),
/scratch/venvs/…; editable path dependencies are installed in setuptools' compat mode (.pth), because the default import-hook finders can't be followed statically. Lifecycle scripts are skipped where the package manager allows: npm/pnpm/yarn/bun with--ignore-scripts(picked frompackageManageror the lockfile), uv fromuv.lock,requirements*.txtorpyproject.tomlinto a venv,go mod download,cargo fetch(rustup installs whateverrust-toolchain.tomlnames). Building a Python sdist can still run its setup code, inside the VM. - Then the indexers, one per top-most project directory, to depth 3:
scip-typescript(tsconfig/jsconfig/package.json; JavaScript-only projects get an inferred config),scip-python(installed packages are described to it directly, so it needs no pip),scip-go(each go.mod) andrust-analyzer scip(each Cargo.toml). - Indexers write paths relative to their project; the host maps
metadata.project_rootto a repo prefix. - Each index is read back over vsock, parsed on the host and cached per
commit in
<userData>/scip/<sha>/. Queries are in-memory lookups: about 1 ms, with no VM needed, and they survive restarts. - Across indexes: a symbol an index only references (another
project's, or another
go.workmodule's) is looked up by name in the commit's other indexes, ignoring the package version (scip-python gives the defining project the commit, a dependent the installed version). Definitions and hover come from the first index that has them, references from all;localsymbols never leave their document. - The decoder is a small hand-written protobuf reader with hard limits (bytes, documents, occurrences), tests for truncated input, and path validation. Document paths are only used as keys into git.
- Dependencies first, so imports resolve. Python gets a uv venv per
project with its interpreter version (the language container's; in
the VM uv fetches e.g. CPython 3.8 on demand),
- LSP (
lsp.ts) is the live fallback when there's no index or the index has no answer.- Language servers run over an agent
pipe(stdio ↔ vsock), started when a file opens so the first ⌘-click is fast, and restarted aftercb-intelso they see the fetched dependencies. - One server per project, rooted at it (at most 6 per sandbox; the least
recently used stops), where the project was indexed:
typescript-language-server(TS and JS),pyrightwithpython.pythonPathset to the project's venv (not in a devcontainer),gopls,rust-analyzer. Paths map relative to the repository, not the project, so a jump fromservices/billingintolibs/commonstays a repo file. - Queries try the owning project's index first.
- Documents are opened with the head content from git, not the VM's disk, which builds may have changed.
- While a server reports work in progress (
$/progress, rust-analyzer'sserverStatus), an empty answer is retried once it's idle; LSP ContentModified errors are retried as the spec asks. - Dependencies: a location outside the commit's tree (
node_modules, Go module cache and stdlib, crates, Ruststd, site-packages, typeshed) opens read-only, read from the sandbox (VM, devcontainer or language container, where the server saw it) and labelled by package, e.g.github.com/google/uuid@v1.6.0/version4.go.
- Language servers run over an agent
Language containers (images.ts): without a devcontainer covering it, a
project runs in an official image for its version, pinned by digest:
python:<3.8–3.14>-slim, node:<18–26>-slim, and one golang and one
rust:slim (GOTOOLCHAIN=auto and rustup fetch whatever go.mod /
rust-toolchain.toml ask for). Refresh the pins with
bun scripts/pin-images.ts.
- Projects with the same image share its container (
cb-env-<eco>-<version>,sleep infinity, started bycb-env-up).cb-intelstarts them in the background:dockerd, then all pulls in parallel (through the shared registry cache;cb-pullgives each attempt 150 s and retries once), while projects whose container is ready go first. A language server opened beforecb-intelgot to its project starts the project's container itself (Go/Rust have nowhere else to run). /work,/scratchand/opt/cb-toolsare mounted at the VM's paths, so caches, venvs and indexes are shared with the VM and nothing is translated.cb-env-run(the containers'cb-run) puts/opt/cb-tools/binfirst, points caches at/scratchand the TLS stacks at the VM's CA bundle (mounted over the image's)./opt/cb-toolsholds everything we bring:gopls,scip-go,rust-analyzer, uv, bun, and under its own Node (an official glibc build, offPATH) pyright, scip-python, the TypeScript server and indexer and corepack. The TypeScript tools and corepack run on the image'snodewhen it has one, so the project's Node version is the one used.- Any failure (pull, start, no index) falls back to the VM for a Python or Node project; a Go or Rust project then fails, saying it needs its language container or a devcontainer with the toolchain.
- Scratch template: each VM's docker would otherwise unpack its images
again (4–15 s each). So the provider builds, once per guest image and image
list, a scratch disk with the default-version images (Python 3.13, Node 20,
golang, rust) already pulled: a VM runs
cb-prepullthrough the registry cache, stops docker and unmounts, ~25 s in the background on first start, ~2.4 GB in<userData>/sandbox/scratch-templates/. Every pooled VM's scratch disk is an APFS clone of it (cp -c, ~3 ms, copy-on-write), anddockerdis started while the VM is pooled, in the background (a VM is handed out without waiting for it;cb-docker-up's lock makes the first user of docker wait).cb-docker-upstarts containerd itself: a dockerd that starts its own waits a second for it (ready in 1.4 s against 0.45 s). Other versions still pull on demand, in parallel with the work that doesn't need them.
Toolchains in the image (the VM fallback): Python 3 (uv fetches other
versions), Node, gcc for build scripts; no Go or Rust (−814 MB, rootfs.erofs
1.25 GB → 840 MB). Python and Node stay because cb-intel and the
devcontainer CLI need them anyway.
All writes go to the per-VM scratch disk (cb-scratch, mounted at
/scratch while the VM is pooled): the /work overlay's upper layer,
node_modules, module and crate caches, venvs and docker's data root.
Guest RAM holds none of it.
With a devcontainer: Go, Rust and Python are indexed and served inside
it when it has go/cargo/python3, using the project's own toolchain
versions, system libraries (e.g. for -sys crates) and installed packages
(e.g. from postCreateCommand); the VM fetches no dependencies for them.
The image's static gopls, scip-go, rust-analyzer and cb-scip-index
are mounted at /opt/cb-tools, and indexes come back through a mounted
scratch directory. Python only runs in the devcontainer when our Node runs
there, so Alpine-based ones don't. TS/JS runs in its language container,
which sees the same /work (including a node_modules from
postCreateCommand).
Automatic flow: opening a review starts its sandbox from the warm pool,
runs devcontainer up when there's a .devcontainer, then cb-intel. It's
on by default, with an "auto" toggle.
- Measured, Review to ready indexes: a polyglot fixture (Go + Rust + JS +
Python, each with a dependency) 6.6 s; the same with a Go devcontainer
(image pull included) 15–17 s; this repo (bun install of 143 packages, TS
plus two Rust crates) 14.3 s; a monorepo of six projects (Python 3.8 and
3.12 services with uv fetching both interpreters, a shared lib, an npm
workspace, a
go.workof two modules, a Cargo workspace) 12–14 s. From "index" to ready indexes on that monorepo, same driver: 10.8–11.3 s in the VM, 11.8–12.3 s in six language containers (four from the scratch template, ready 0.7 s in; Python 3.8 and 3.12 pulled meanwhile). - SCIP answers in ~1 ms; a warm language server in ~5–300 ms; a cold rust-analyzer waits ~1 s for its workspace to load.
Trust: both sources come from tools running over untrusted code, so their output is a hint, not proof.
- Only locations inside the repo are accepted, and hover is rendered as plain text.
- A hostile repo can at worst mislead navigation within its own review.
- The SCIP index is also computed from
/workas it was when indexing ran, after anypostCreateCommand.
Pitfalls found and fixed:
- Rust's
io::copyusessplice(2)when one side is a pipe, which silently stalls on AF_VSOCK. The agent uses plain read/write loops. - scip-python asks pip for installed packages; it gets a generated
--environmentinstead.
Rendered Markdown (src/main/markdown/, src/renderer/src/markdown/) #
Markdown is repository content, so it's parsed in the review's VM, not on the host.
- A Markdown file opens as source. Opening one starts the review's VM if it isn't running. The file head's Render button (⇧⌘V) is disabled until the VM is ready. Once a file has been rendered, the button becomes a Source / Rendered toggle.
- Parsing (
render.ts):- The host reads the file at head from git. It sends markdown-it's own UMD
build and the text to the VM's
nodeover an agentpipe, as one JSON line. The pipe has no half-close, so the guest answers and then exits. - The answer is one HTML fragment per top-level block, with the source lines it came from. YAML front matter is kept as a code block.
- The host checks the shape and clamps the lines. It caps the input at 2 MiB, the output at 16 MiB, and the run at 15 s.
- The request is refused unless the sandbox holds that repository at that commit, so one review's text never reaches another review's VM.
- The host reads the file at head from git. It sends markdown-it's own UMD
build and the text to the VM's
- The HTML is untrusted (
safe.ts):- The renderer parses each fragment with
DOMParser, which gives an inert document: no script runs and nothing loads. - Only allow-listed elements and attributes are kept, as plain data that
becomes React elements. Nothing is assigned as
innerHTML. - If a block contains
script,iframe,svg, form controls, media and the like, the whole block is shown as source.
- The renderer parses each fragment with
- Links:
http(s)andmailtolinks leave through the window-open handler.- Relative links open the file in the repository.
#anchorsscroll to GitHub-style heading ids.- Everything else,
javascript:included, is not followed.
- Images:
- Images stored in the repository come from git as
data:URLs (subject:image, 10 MiB cap). - Remote images are fetched by
curlin the review's VM (markdown:remoteImage,images.ts), so they go through the egress proxy: the allow-list applies, redirects included, and the egress log shows them. The bytes come back as adata:URL, typed by sniffing (the server's Content-Type isn't trusted), capped at 10 MiB. - Hosts that aren't on the allow-list are never asked, whatever the
egress mode, because an image request tells its host that someone is
reading the file. A notice lists those hosts, with "Allow " and
"Show egress log". The renderer never loads a remote URL itself, and
CSP
img-srcforbids it anyway.
- Images stored in the repository come from git as
- Review:
- Each block keeps its first line number in the gutter.
- Changed blocks get the add ground and a +; blocks with removed lines
get a −.
[and]step through the changed blocks. - Removed blocks inline (the same switch as removed lines): the
base is rendered too (
markdown:renderwith sidebase, still in the head's VM, since it is the same review's content). A base block shows, on the del ground with a −, if any of its lines is in a hunk's old range (removed.ts). It goes just before the head block where that hunk lands, so a rewritten paragraph reads − then +. The pair's changed words take add-emph / del-emph (words.ts). This is a word diff (LCS) over the text of the two sanitized trees, and the marks go inside the text leaves, so links and emphasis keep working. Code blocks are skipped, and so are pairs with under 40% of their words in common, because those read as replaced rather than edited. - Math (
$…$,$$…$$,```math) is set by MathJax in the same guest program:guest/math.js, bundled at build time into a string (theguestBundlesVite plugin,bundle.ts), sent only when the file has a$or a math fence. TeX becomes standalone SVG (paths, no fonts, no DOM), shown as adata:<img>sized in ex and aligned to the text's baseline. MathJax'scurrentColoris replaced with the theme's ink, since an<img>can't inherit it. The TeX packages that could link, style or load more code (html,require,autoload) are left out. - Mermaid (
```mermaid) is drawn in the guest too, without a browser:guest/mermaid.jsruns Mermaid on svgdom, a pure-JS SVG DOM whosegetBBoxmeasures text with real font metrics (fontkit). Text is measured with the design system's Instrument Sans, and the same font is embedded in each SVG, since an<img>can't reach the page's fonts.- Shims cover what Mermaid expects of a page: a
<body>,parentElement,offsetWidth(680, the text column),CSSStyleSheet, and the extra height of wrapped label rows, which svgdom's text box leaves out. - Mermaid runs with
securityLevel: "loose", because DOMPurify can't run on svgdom. For the same reason the bundle swaps DOMPurify itself for a pass-through (guest/dompurify.js): Mermaid runs some markup, such as architecture icons, through it whatever the level, and the real one would return nothing. That's acceptable only because the SVG is never shown except as an<img>, which runs no script and follows no link. - Mindmaps and architecture diagrams are laid out by cytoscape, which
Mermaid hands a hidden
<div>to draw in. The bundle swaps inguest/cytoscape.js(both swaps arebundle.ts's stand-ins). It runs cytoscape headless with styles on, and adds the base renderer's geometry: the edge endpoints and midpoints Mermaid reads. It leaves out drawing, input and the render loop, and measures labels with the same font. Mindmap circles, squares and hexagons get their labels centred with CSS, because Mermaid leaves that to HTML labels. - Diagrams are drawn for the app's theme (the render request carries it, and the cache is keyed on it), up to 50 distinct diagrams per file. The guest program is async for this.
- Checked on Debian trixie's Node 20: flowchart, sequence, class, state, ER, Gantt, pie and gitGraph (gitGraph's rotated commit labels are rough), mindmap (every node shape) and architecture. Any diagram Mermaid can't parse stays as code.
- Cost: a 4.5 MB bundle, sent only for files with a mermaid fence; each SVG is ~130 KB with its font.
- Shims cover what Mermaid expects of a page: a
- Fenced code is highlighted with the viewer's own style, using the CodeMirror language named by the fence (Lezer, in the renderer).
- The Outline lists the file's headings, read from the source text
(
headings.ts; fenced code and front matter are skipped). A click scrolls to the heading, in either view. - Comment anchors to the block's source lines: a removed block's comment anchors to base lines. Threads show under the block, on their side, that holds their line.
Remote #
The same Sandbox interface over an authenticated WebSocket or gRPC
connection. The snapshot is uploaded (or fetched by the remote from the git
remote at the exact commit), and exec, ports and LSP are multiplexed over the
connection.
Roadmap #
- ✅ Shell: Electron + React + CodeMirror, open local/remote repo, pick base/head, file tree with change markers, full-file view with gutter marks and a toggle for inline removed code.
- ✅ Sandbox interface + VZ native addon: VMs boot from the app (napi, dev-signed Electron), commands stream to a panel, warm pool.
- ✅ Devcontainers in the VM (guest rootfs, allow-list egress proxy), shared digest-verified registry cache, multi-window/instance.
- ✅ Code intelligence for TS/JS, Python, Go and Rust: dependencies fetched in the sandbox, SCIP indexes cached per commit, plus LSP in the VM or devcontainer (definition, references, hover). Next: cross-repo symbols, a shared dependency cache across VMs.
- ✅ Structure via tree-sitter (worker + WebAssembly): changes by symbol (added / removed / renamed / modified / formatting-only / comments-only), outline, sticky scope header. Next: search-based navigation fallback from tags queries (no sandbox needed), more languages.
- Preview pane via port forwarding.
- Remote sandbox provider.
- ✅ Reviews in a local SQLite store with backups
(REVIEW-FORMAT.md); export to a GitHub pull request
and read-only fetch of what happens there
(integrations/). Next: sending replies and
resolutions, more adapters (integrations/README.md §9), optional forge sources
(GitHub/GitLab) that resolve to
(repo, base, head).