Skip to content

Commit 4d64f88

Browse files
authored
Publish a fresh peer-link pipe name per bind on Windows (#1252)
2 parents 90d84e3 + cea1a5b commit 4d64f88

12 files changed

Lines changed: 637 additions & 88 deletions

File tree

‎.github/audit/local.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ For `## Network policy`, read `docs/specs/remote-network.md` -> "Policy", then `
1414

1515
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.
1616

17-
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.
17+
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.
1818

1919
## Work streams
2020

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

23-
- `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".
23+
- `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".
2424
- `local-listeners` — `docs/specs/security-local.md`: "Loopback Listeners", "Local-file viewer", "Network policy".
2525
- `qualitative` — the qualitative pass below, once, over the whole of this domain's scope.
2626

‎docs/specs/security-local.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ The socket bounds other local accounts, never a process running as the user (`do
8383
- **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`.
8484
- **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).
8585

86+
## The VS Code peer link
87+
88+
| Guarantee | Feature | Pinned by |
89+
| --- | --- | --- |
90+
| **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` |
91+
92+
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").
93+
94+
- **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`.
95+
8696
## Loopback Listeners
8797

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

165175
**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").
166176

167-
**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).
177+
**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).
168178

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

‎docs/specs/security.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo
4444

4545
- **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)).
4646
- **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)).
47-
- **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)).
47+
- *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)).
4848
- **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)).
4949
- **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)).
5050
- **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)).
@@ -72,7 +72,7 @@ Gaps rather than accepted risks: we intend to close them.
7272

7373
- **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)).
7474
- **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)).
75-
- **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)).
75+
- **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)).
7676
- *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)).
7777
- *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)).
7878
- *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)).

‎docs/specs/vscode.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,16 +135,22 @@ The shared `BurrowService` (`docs/specs/relay.md` → "Burrow side", which owns
135135

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

138-
**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.
138+
**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.
139139

140140
- **Roles never flip downward**: no `onRole(false)` after a `true` (rationale).
141141
- **Contend on broker death, not on a timer**: `bind` picks exactly one; no TTL, heartbeat file, or watcher.
142142
- **Never unlink on the first refusal; a reclaimed bind is re-checked (`stillOurs`) before it serves** (rationale).
143143
- **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).
144144

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

147-
*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").
147+
- **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.
148+
- **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.
149+
- **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.
150+
151+
**Trust.** Neither the socket path nor a listed pipe name is secret, so two layers stand between them and this installation's terminals.
152+
153+
*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").
148154

149155
*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:
150156

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

161167
**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.
162168

163-
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`.
169+
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`.
164170

165171
**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.
166172

‎docs/specs/vscode.rationale.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@
6666

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

69+
**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.
70+
71+
**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.
72+
73+
**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").
74+
75+
6976
**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.
7077

7178
**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.

‎scripts/security-rule-ids.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"next": 274,
2+
"next": 275,
33
"retired": [
44
265
55
]

‎scripts/spec-word-budgets.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"docs/specs/security-audit.md": 4750,
2828
"docs/specs/security-ci.md": 3400,
2929
"docs/specs/security-hosted.md": 3050,
30-
"docs/specs/security-local.md": 5900,
30+
"docs/specs/security-local.md": 6100,
3131
"docs/specs/security-remote.md": 7200,
3232
"docs/specs/security-supply-chain.md": 1650,
3333
"docs/specs/security.md": 2750,
@@ -40,7 +40,7 @@
4040
"docs/specs/tiling-engine.md": 2500,
4141
"docs/specs/transport.md": 4450,
4242
"docs/specs/tutorial.md": 1750,
43-
"docs/specs/vscode.md": 4300,
43+
"docs/specs/vscode.md": 4500,
4444
"docs/specs/webgl-text.md": 750,
4545
"docs/specs/website-docs.md": 3100
4646
}

0 commit comments

Comments
 (0)