Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/audit/local.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ For `## Network policy`, read `docs/specs/remote-network.md` -> "Policy", then `

For `## Terminal context directory actions`, read `docs/specs/terminal-context.md` and `docs/specs/layout.md` -> "Workspace names"; for `## Dor Tool configuration`, `docs/specs/dor-tool.md` -> "Declaring tools", "Trust", and "Serving"; then the code each section's `Source of truth` names.

For the rest of `docs/specs/security-local.md`, read each section's owner first — `docs/specs/terminal-escapes.md` and the owners its rows name, `docs/specs/dor-tool.md` -> "Reaping", "Terminal links", and "Opening local files", `docs/specs/dor-browser.md` -> "Automated Browser", "Iframe Renderer", and "Iframe Host Capability And CSP", `docs/specs/dor-cli.md` -> "Spawning External Binaries" and "Host Plumbing", `docs/specs/vscode.md` -> "Webview message authentication", `docs/specs/standalone.md` -> "Persistence", `docs/compatible-agents.md` -> "Recovery contract (maintainers)" — then the parser, the iframe shim, the control-socket code, the persistence paths they point at, and the files the checklist below names.
For the rest of `docs/specs/security-local.md`, read each section's owner first — `docs/specs/terminal-escapes.md` and the owners its rows name, `docs/specs/dor-tool.md` -> "Reaping", "Terminal links", and "Opening local files", `docs/specs/dor-browser.md` -> "Automated Browser", "Iframe Renderer", and "Iframe Host Capability And CSP", `docs/specs/dor-cli.md` -> "Spawning External Binaries" and "Host Plumbing", `docs/specs/vscode.md` -> "Webview message authentication" and "Burrow: a service in the extension host", `docs/specs/standalone.md` -> "Persistence", `docs/compatible-agents.md` -> "Recovery contract (maintainers)" — then the parser, the iframe shim, the control-socket code, the persistence paths they point at, and the files the checklist below names.

## Work streams

Delegate only by these streams (`.github/audit/_preamble.md` -> "Work streams"). Each holds every rule under the headings it names, subheadings included:

- `local-terminal` — `docs/specs/security-local.md`: "Terminal output", "Browser panes", "The dor control socket", "Persisted state", "Terminal context directory actions", "Spawned programs", "Dor Tool configuration".
- `local-terminal` — `docs/specs/security-local.md`: "Terminal output", "Browser panes", "The dor control socket", "The VS Code peer link", "Persisted state", "Terminal context directory actions", "Spawned programs", "Dor Tool configuration".
- `local-listeners` — `docs/specs/security-local.md`: "Loopback Listeners", "Local-file viewer", "Network policy".
- `qualitative` — the qualitative pass below, once, over the whole of this domain's scope.

Expand Down
12 changes: 11 additions & 1 deletion docs/specs/security-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ The socket bounds other local accounts, never a process running as the user (`do
- **FAIL IF** `S139` `ensureControlDir` in `standalone/sidecar/dor-control-server.js` stops requiring all four of a real directory, not a symlink, owned by this uid, at exactly mode `0700`, or `resolveControlSocketPath` stops refusing to name a socket when that predicate fails or derives a socket or pipe name from anything but fresh random bytes, the PID included. Pinned by `standalone/sidecar/dor-control-server.test.js`.
- **FAIL IF** `S140` the raw token reaches a socket, or either side compares a proof with anything but the SHA-256-then-`timingSafeEqual` of `proofMatches`. The construction is hand-mirrored between `standalone/sidecar/dor-control-server.js` and `dor/src/control-client.ts`; only the two proof domains are pinned across the copies, by `lib/src/lib/mirrored-constants.test.ts` (rationale).

## The VS Code peer link

| Guarantee | Feature | Pinned by |
| --- | --- | --- |
| **Another Windows account cannot keep VS Code's windows from their Burrow by taking a pipe name first.** Each broker binds a name nobody can predict and publishes it only in your own storage, and a name that cannot prove the shared secret is abandoned. | One-time link, Hosted phone, Self-hosted Relay | `vscode-ext/test/peer-endpoint.test.ts` |

The attacker is another local account; the link carries the Burrow's commands and every window's terminals between VS Code windows (`docs/specs/vscode.md` -> "Burrow: a service in the extension host").

- **FAIL IF** `S274` on Windows a broker binds a pipe name not minted per bind from fresh CSPRNG bytes (`freshEndpointName` in `vscode-ext/src/peer-endpoint.ts`), a window learns the broker's name other than from `burrow.peer-endpoint` in the extension's global storage, a window keeps dialing a published name whose holder refused, failed to prove the token, or stayed silent after its publishing pid died, or replaces on silence alone a broker whose pid is alive. `attemptPublished` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/peer-endpoint.test.ts`.

## Loopback Listeners

| Guarantee | Feature | Pinned by |
Expand Down Expand Up @@ -164,7 +174,7 @@ The attacker is another local account reading disk; what the remote stack leaves

**VS Code persists pane structure in VS Code's own storage** — `workspaceState` and `vscode.setState()` — so the modes there are VS Code's, not ours, and no transcript reaches either (`docs/specs/vscode.md` -> "Serialization and restore"). Dormouse also writes `recovery.json` in extension storage, mode `0600` on Unix: one rebuilt agent-resume invocation per Surface, no buffer, unlinked as it is read (`docs/compatible-agents.md` -> "Recovery record").

**The VS Code peer-link token is a local credential at rest** — `burrow.peer-token` in the extension's global storage, written mode `0600`, its socket directory re-checked on every contention round. Neither control does anything on Windows, and Dormouse applies no Windows DACL to the peer-link token, `recovery.json`, or the `tool-trust` receipts (`docs/specs/security.md` -> "Known gaps"; rationale).
**The VS Code peer-link token is a local credential at rest** — `burrow.peer-token` in the extension's global storage, written mode `0600`, its socket directory re-checked on every contention round; a Windows broker's `burrow.peer-endpoint` beside it is written the same way. Neither control does anything on Windows, and Dormouse applies no Windows DACL to the peer-link token or endpoint, `recovery.json`, or the `tool-trust` receipts (`docs/specs/security.md` -> "Known gaps"; rationale).

No standalone log call (`docs/specs/standalone.md` -> "Logging") carries PTY bytes, but the log records the `dor` socket path (rationale).

Expand Down
4 changes: 2 additions & 2 deletions docs/specs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo

- **A process running as you.** `dor`, its socket, and every file mode bound other local accounts, never a program already running under your own account; an agent holding `dor` has exactly the power of the person at the keyboard, and the Tool trust prompt is no boundary against it ([The dor control socket](./security-local.md#the-dor-control-socket)).
- **The Windows `dor` pipe carries no ACL of ours.** A named pipe has no directory to harden, so an unguessable name and the token handshake are the whole of it ([The dor control socket](./security-local.md#the-dor-control-socket)).
- **Another Windows account can take the VS Code peer-link pipe's name first.** Every window must bind the same name, so it is derived, not random, and Windows lists every pipe to every account; whoever creates it first keeps remote control and peer surfaces down, though the token handshake serves them nothing ([Persisted state](./security-local.md#persisted-state)).
- *One-time link, Hosted phone, Self-hosted Relay.* **Another Windows account can hold a dead VS Code broker's pipe name silently while one of its processes reuses that broker's pid**, which keeps windows waiting for a broker that is gone ([The VS Code peer link](./security-local.md#the-vs-code-peer-link)).
- **A local HTML or SVG document you open, or confirm from a document link, can send its contents off the machine.** The file viewer's content policy confines what the page loads, not where its scripts navigate ([Local-file viewer](./security-local.md#local-file-viewer)).
- **A mermaid sanitizer bypass in a Markdown document you open.** Its diagrams reach the editor page through mermaid's strict sanitizer, not the editor's allowlist, so a bypass would run script holding the editor's save, image, and document-open routes; an open of a page that runs its own scripts still waits on Dormouse's own dialog ([Local-file viewer](./security-local.md#local-file-viewer), [Terminal output](./security-local.md#terminal-output)).
- **A Tool trust grant on an upstream URL trusts the URL a checkout claims.** Any directory whose own `.git/config` claims an already-granted upstream shares that grant; a folder-only grant is not shared ([Dor Tool configuration](./security-local.md#dor-tool-configuration)).
Expand Down Expand Up @@ -72,7 +72,7 @@ Gaps rather than accepted risks: we intend to close them.

- **Browser-pane scripts share loopback cookies across grant ports.** HTTP and WebSocket cookie headers are stripped, but `document.cookie` remains shared; cookie-authenticated iframe pages are unsupported ([Loopback Listeners](./security-local.md#loopback-listeners)).
- **Windows screenshots and pasted clipboard images are only as private as the temp directory they land in:** private by default, exposed if it is shared or loosened ([Browser panes](./security-local.md#browser-panes)).
- **Neither VS Code's peer-link token, its Tool trust receipts, nor the `recovery.json` beside them carries a Windows ACL applied by Dormouse.** They are written owner-only by unix mode, which Windows makes a no-op; standalone locks its state directory instead ([Persisted state](./security-local.md#persisted-state)).
- **Neither VS Code's peer-link token and endpoint, its Tool trust receipts, nor the `recovery.json` beside them carries a Windows ACL applied by Dormouse.** They are written owner-only by unix mode, which Windows makes a no-op; standalone locks its state directory instead ([Persisted state](./security-local.md#persisted-state)).
- *One-time link, Hosted phone, Self-hosted Relay.* **No independent third party has reviewed remote control's cryptography.** Dormouse's Noise integration, passkey (WebAuthn) channel binding, key storage, and push sealing are checked only by this project's own tests, lints, and nightly audits, which AI agents run ([Noise suite](./remote-security-model.md#noise-suite)).
- *Self-hosted Relay.* **Anyone who can reach your Relay can hold off sign-in by flooding it**: unauthenticated requests share the capped pool of sign-in challenges, evicting a real one before its passkey prompt is answered. The default install admits only your tailnet ([Network posture (self-hosted)](./security-remote.md#network-posture-self-hosted)).
- *Hosted phone, Self-hosted Relay.* **A user-verification demand raised after enrollment can be lost if this computer cannot save it.** The Burrow keeps the raise in memory and retries the save at each start, but a relaunch while saving keeps failing starts it at presence until the Relay repeats the demand ([Trust boundary](./security-remote.md#trust-boundary)).
Expand Down
14 changes: 10 additions & 4 deletions docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,16 +135,22 @@ The shared `BurrowService` (`docs/specs/relay.md` → "Burrow side", which owns

Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, `markOneTimeServing` and `contendIfServing` in `vscode-ext/src/burrow.ts`.

**Which window: bind-as-lease.** **The bind is the lease**, so the windows' extension hosts never fight over the enrollment's one `/ws/burrow` socket (rationale). Every contending window binds one fixed path — `<hash>.sock` in a per-user `dormouse-peer-<uid>` directory in the temp dir, or `\\.\pipe\dormouse-peer-<hash>` on Windows — **the hash is the first 12 hex characters of SHA-256 over `context.globalStorageUri.fsPath`**, so every window, whatever its build, computes the same path (hashed for the platform path cap; rationale). The winner is the broker and runs the service; every other window connects as a client once the broker's welcome verifies.
**Which window: bind-as-lease.** **The bind is the lease**, so the windows' extension hosts never fight over the enrollment's one `/ws/burrow` socket (rationale). On unix every contending window binds one fixed path, `<hash>.sock` in a per-user `dormouse-peer-<uid>` directory in the temp dir; **the hash is the first 12 hex characters of SHA-256 over `context.globalStorageUri.fsPath`**, so every window, whatever its build, computes the same path (hashed for the platform path cap; rationale). The winner is the broker and runs the service; every other window connects as a client once the broker's welcome verifies.

- **Roles never flip downward**: no `onRole(false)` after a `true` (rationale).
- **Contend on broker death, not on a timer**: `bind` picks exactly one; no TTL, heartbeat file, or watcher.
- **Never unlink on the first refusal; a reclaimed bind is re-checked (`stillOurs`) before it serves** (rationale).
- **A bind is not a role until it is believed**: **an unverified bind reads as unsettled**, and a command landing mid-verification is held for the verdict (rationale).

**Trust.** The socket path is derived, not secret, so two layers stand between it and this installation's terminals.
**Windows: a published endpoint**, since another account could create a derived pipe name first (rationale):

*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows pipes have no directory and skip this layer (`docs/specs/security.md` → "What is not defended").
- **Every bind takes a fresh `\\.\pipe\dormouse-peer-<32 random hex>`**, published with the broker's pid by an exclusive `wx` create of `burrow.peer-endpoint` beside the token. **Windows find the broker only through that file**; one that loses the create closes its pipe and dials the winner.
- **Every published bind is provisional**, believed only if the file still names it after `RECLAIM_VERIFY_MS`: a window retiring a stale file can remove a fresh one.
- **A published name that twice fails to yield a verified welcome, a jittered re-read apart, is retired**, only while the file still names it — unless both dials met only silence and the publishing pid is alive, since a stalled broker is waited for (`docs/specs/security-local.md` → "The VS Code peer link"; rationale). A closing broker retracts its own the same way.

**Trust.** Neither the socket path nor a listed pipe name is secret, so two layers stand between them and this installation's terminals.

*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows pipes have no directory; the published endpoint stands in for it (`docs/specs/security-local.md` → "The VS Code peer link").

*The handshake.* The shared secret is a mode-0600 `burrow.peer-token` in `globalStorageUri`, **created once with an exclusive `wx` write rather than a rename** so two windows starting together agree on one token. **Treat an empty read as *not yet written*, never as the token**, waiting it out with a bounded retry (rationale); **exhausting that wait latches the same permanent stand-down** as an unsafe directory. The token **never crosses the socket**; three frames prove mutual knowledge of it:

Expand All @@ -160,7 +166,7 @@ Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, `

**A window with no Burrow at all still answers the read-only commands (`idleAnswer`) exactly as an idle service would**; the policy is read and never saved. The idle `status` reads the installer's offer file, so the one-click card renders on a machine no window has a Burrow for. **Everything else refuses with an error**, so the console hook fails fast.

Source of truth: `vscode-ext/src/burrow.ts`, `ensurePeerNet` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/burrow.test.ts` and `vscode-ext/test/peer-link.test.ts`.
Source of truth: `vscode-ext/src/burrow.ts`, `ensurePeerNet` and `attemptPublished` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/burrow.test.ts`, `vscode-ext/test/peer-link.test.ts`, and `vscode-ext/test/peer-endpoint.test.ts`.

**The webview bridge.** A webview reaches the service over `BurrowLink`, implemented in `vscode-adapter.ts` on three messages, each wrapping the shared client's shape in a `payload`: `burrow:command { payload: BurrowCommand }` out, `burrow:result { payload: BurrowResult }` and `burrow:event { payload }` back. **Everything else is the shared client** in `lib/src/host/remote/link-client.ts` (`docs/specs/transport.md` → Message protocol). Results are **broadcast to every webview in the window** — safe only because the shared client mints globally unique ids — and one correlation id serves both the in-window fan-out and the cross-window forward.

Expand Down
7 changes: 7 additions & 0 deletions docs/specs/vscode.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@

**What squatting the socket path buys.** One HMAC over a nonce the squatter chose — which is not the token — and nothing else.

**Why Windows publishes its endpoint.** With a derived pipe name, another account could create it before any window bound it after a restart, and every window's bind then failed: remote control and peer surfaces stayed down. Keying the name by the token does not help: the token and storage path are permanent, so the keyed name is fixed after its first bind, and Windows lists it to every account. A name minted per bind cannot be created in advance, and once bound, libuv's first-instance flag and the pipe's default DACL keep another account from adding an instance of it.

**Why a published name is retired on more than a refusal.** A dead broker's name is free again and was listed while it lived, so another account can bind it as the broker goes. Under unix's refusal-only rule every window would then retry it forever: the same denial, moved from boot to the first broker restart.

**Why silence needs the pid.** A broker stalled behind a busy extension host (a paused debugger, another extension's synchronous work) accepts at the OS level and then says nothing, exactly like a squatter that holds the name silently. Replacing it would be permanent: a broker never stands down and nothing makes it re-read the file, so once it caught up two Burrows would displace each other on the Relay until a window reloaded. A live pid tells the two apart; the residual is a squatter that also gets a process at the dead broker's pid, which keeps windows waiting (`docs/specs/security.md` -> "What is not defended").


**Why a mid-contention command waits rather than being refused.** While the contention runs the window is neither broker nor client, and a bind plus a handshake is not instant. Refusing there would tell an enrolled machine's webview it has no Burrow seconds before it gets one, and the gates that arm on that answer (`enrolled-gate.ts`) would stay down.

**Where the `WebSocket` boundary falls.** `globalThis.WebSocket` arrived in Node 22, and VS Code 1.92 — the floor `engines.vscode` declares — shipped Node 20.14 (its release notes, July 2024), so an older extension host has no global to use.
Expand Down
2 changes: 1 addition & 1 deletion scripts/security-rule-ids.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"next": 274,
"next": 275,
"retired": [
265
]
Expand Down
4 changes: 2 additions & 2 deletions scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"docs/specs/security-audit.md": 4750,
"docs/specs/security-ci.md": 3400,
"docs/specs/security-hosted.md": 3050,
"docs/specs/security-local.md": 5900,
"docs/specs/security-local.md": 6100,
"docs/specs/security-remote.md": 7200,
"docs/specs/security-supply-chain.md": 1650,
"docs/specs/security.md": 2750,
Expand All @@ -40,7 +40,7 @@
"docs/specs/tiling-engine.md": 2500,
"docs/specs/transport.md": 4450,
"docs/specs/tutorial.md": 1750,
"docs/specs/vscode.md": 4300,
"docs/specs/vscode.md": 4500,
"docs/specs/webgl-text.md": 750,
"docs/specs/website-docs.md": 3100
}
Loading
Loading