See
docs/specs/glossary.mdfor Pane, Session, and the Surface model; this spec uses them bare. Owns the boundaries a user of the local application has: terminal output, browser panes,dor, loopback listeners, and what persists on disk. Defers every mechanism to the spec named at its rule, and the network boundary todocs/specs/security-remote.md. Readdocs/specs/security.mdfirst;docs/specs/security-audit.mdsays how theFAIL IFlines here are run.
The attacker is any program writing to a PTY.
Must bound retained output by representation: TerminalProtocolParser semantic values by code points and control stripping, an incomplete semantic OSC at 16,384 code units, and ImageAddon data by encoded bytes, decoded pixels, and FIFO storage (docs/specs/terminal-escapes.md -> "Parsing location", "Inline graphics").
Never let untrusted PTY output write the clipboard or access a file: consume OSC 52, OSC 50, and unsupported OSC 1337. Inline images carry their own bytes: no path is resolved, ImageAddon dropping any non-inline=1 transfer (docs/specs/terminal-escapes.md -> "Inline graphics").
An OSC 8 hyperlink opens only after a confirmation dialog; a target whose
display text names a different host gets no open action at all — close and
copy only, copy holding initial focus
(docs/specs/terminal-escapes.md -> "OSC 8 hyperlinks"). Nothing opens without
a second pass through normalizeExternalUri (VS Code's in the extension
host); the dialog is consent, not the boundary.
Unsupported escape sequences must fail inertly — consumed or ignored, with
no visible garbage, clipboard, file, focus, or privilege effect
(docs/specs/terminal-escapes.md -> "iTerm2 identity"; rationale).
Notification text is untrusted terminal output: sanitized at protocol-parse
time, rendered as plain text and never as markup, re-bounded by a second pass
before speech or push (docs/specs/alert.md -> "Text And Security").
The OSC 633 terminator escape is emit-side, in the shipped
shell-integration scripts — the parser scans raw bytes and cannot defend it
(docs/specs/terminal-escapes.md -> "Shell-integration injection"; rationale).
Must confine output to the screen, Session state, and bounded terminal reports — rendered text/images, alerts, titles, prompt/command boundaries, CWD, and OSC 8. The PTY-boundary parser writes exactly three answer families: OSC 10/11/12 ; ? color, OSC 99 capability, CSI > q device. xterm.js and ImageAddon answer cursor, device, focus, size, and graphics reports
(docs/specs/terminal-escapes.md -> "Report filtering on the input side").
- FAIL IF
isKnownUnsupportedIterm2Oscinlib/src/lib/terminal-protocol.tsstops consumingOSC 52, or a parse site stops runningTerminalProtocolParserbeforepty:dataleaves it (rationale). Pinned bylib/src/lib/terminal-protocol.test.ts. - FAIL IF a value the parser retains stops being bounded and control-stripped before storage, or a new one arrives without a limit —
TITLE_LIMIT,BODY_LIMIT,COMMAND_LINE_LIMITandsanitizeTextinlib/src/lib/terminal-protocol.ts,MAX_CWD_LENGTHandboundedCwdValueinlib/src/lib/terminal-state.ts.COMMAND_LINE_LIMITbinds after the\xNNunescape, a 4x bound before it (rationale). - FAIL IF an
OSC 8activation reaches an adapter'sopenExternalwithout the confirmation dialog, or the dialog renders an open action for a deceptive verdict:linkHandlerinlib/src/lib/terminal-lifecycle.ts,classifyDisplayMatchinlib/src/lib/external-links.ts, the render branches inlib/src/components/ExternalLinkModal.tsx. Pinned bylib/src/lib/external-links.test.tsandlib/src/components/ExternalLinkModalHost.test.tsx; the host also rejects a deceptive confirmation (rationale).
The attacker is the page inside a browser pane.
Every listener the webview realm exposes to a framed page checks the sender's
origin before it acts — IframePanel against its own panel's proxy origin, the
Wall's leader channel against any live grant (docs/specs/dor-browser.md ->
"Iframe Shim"). That separates a proxied frame from any other, never the
injected shim from the page it runs in (rationale).
A framed page cannot forge a host message. The VS Code webview
authenticates every host→webview message with a per-boot token minted at serve
time into the nonce-gated boot script, unreadable cross-origin, and the guard
fails closed when no token was injected (docs/specs/vscode.md -> "Webview
message authentication"). The standalone adapters have no forgeable inbox:
host events arrive over Tauri IPC, never window.postMessage.
Each injected shim hop must address only its proxy origin and the embedder
chain's innermost origin, never '*'. Nested frames relay the three pane-level
messages through same-origin parents; their document-level locations stop there.
With no usable chain the proxy injects
nothing and strips no framing header (docs/specs/dor-browser.md -> "Iframe
Host Capability And CSP"). What it grants a caller is Loopback
Listeners's business.
- FAIL IF an injected shim targets anything but its proxy origin or the embedder chain's innermost origin, relays a nested
location, a foreign-origin message, or an unregistered message, or the proxy uses a chain it did not validate in full:iframeShimandnormalizeEmbedderOriginsinlib/src/host/iframe-proxy-rewrite.ts, applied inlib/src/host/iframe-proxy.ts. Pinned bylib/src/host/iframe-proxy-rewrite.test.tsandlib/src/host/iframe-proxy.test.ts. - FAIL IF a
VSCodeAdapterhost-channel listener acts on a message beforeisHostMessage(lib/src/lib/vscode-message-token.ts) accepts it, or the token stops being minted per serve and attached only byWebviewChannel.postinvscode-ext/src/webview-messaging.ts:dor:controlRequestis one of the shapes a framed page could otherwise claim. The proxy-origin listeners above are guarded by origin, not the token. Pinned by thehost message authenticationblock inlib/src/lib/platform/vscode-adapter.test.ts.
Source of truth: isProxyOrigin in lib/src/lib/iframe-proxy-registry.ts, the
per-panel check in lib/src/components/wall/IframePanel.tsx.
The attacker is another local account. The channel carries the whole Surface API
— keystrokes into any Pane, its screen and scrollback back out, dor kill — and
an app restart behind the running-work confirmation (docs/specs/dor-cli.md ->
"Control-channel security", "dor app").
A process running as the user is the user. The socket bounds other local
accounts, never the user's own: an agent holding dor has the power of the
person at the keyboard, the local mirror of the remote rule
(docs/specs/security-remote.md -> "Remote Control"; rationale).
The server picks the path unguessably and hardens its directory before it
binds. POSIX: <tmpdir>/dormouse-dor-<uid>/<8 random bytes>.sock, inside a
per-user directory lstated before the bind; one of ours that is merely loose is
tightened, anything else stands the channel down. Windows has a named pipe and
no directory to harden, and Dormouse applies no ACL there, so the name and the
handshake are the whole of it. Neither spelling may derive from the PID.
The token never crosses the wire in either direction — 24 CSPRNG bytes per host process, never written to disk, proven by HMAC-SHA256 over the peer's nonce under a per-direction domain and compared in constant time. The server challenges first and proves its own half before the client sends any request; a peer that fails its half is hung up on with no reply.
A lost bind stands the channel down rather than weakening it: both hosts delete the two control variables at startup and re-attach them to spawned shells only once the bind reports ready.
- FAIL IF
ensureControlDirinstandalone/sidecar/dor-control-server.jsstops requiring all four of a real directory, not a symlink, owned by this uid, at exactly mode0700, orresolveControlSocketPathstops refusing to name a socket when that predicate fails. Pinned bystandalone/sidecar/dor-control-server.test.js. - FAIL IF the raw token reaches a socket, or either side compares a proof with anything but the SHA-256-then-
timingSafeEqualofproofMatches. The construction is hand-mirrored betweenstandalone/sidecar/dor-control-server.jsanddor/src/control-client.ts, and only the two proof domains are pinned (rationale).
Dormouse binds loopback HTTP and WebSocket servers to render its own surfaces.
A loopback bind is not an access control. 127.0.0.1 keeps out the network, but
the attacker that matters is a page open in the user's own browser, which reaches
loopback exactly as easily as our webview does; an ephemeral port is not a secret
either (rationale).
Never grant an unrecognized caller anything it could not obtain directly from the upstream. Listeners check their loopback name and recognize callers; the iframe proxy admits strangers but declines to vouch. Use URL tokens only where the listener owns the page URL, as the browser-dev harness does; iframe proxies cannot preserve them through upstream routing and subresources (rationale).
The replacement policy must allow exactly 'self' plus the full validated
ancestor chain the webview supplies with each proxy URL request. 'self' allows
same-grant nesting; any foreign ancestor fails. No request header identifies the
embedder, and the browser checks the whole chain (rationale).
- FAIL IF any loopback HTTP or WebSocket listener grants an unrecognized caller a privilege it could not obtain by reaching the upstream directly. Refusing the request is one way; the iframe proxy's admits all, vouches for none, names its embedder is another, and is not a violation (rationale).
scripts/loopback-lint.mjs(pnpm test) scans all tracked JavaScript and TypeScript and prints every bind it recognizes, with tests and its own fixtures separate from non-test listeners. A new non-test listener without a guard reference fails the build, but the lint cannot tell whether every request calls that guard. Adding a server dependency means adding its bind spelling toBIND_FORMS, each form pinned byscripts/loopback-lint-selftest.mjs; a host built at runtime is invisible to a regex in any spelling. Search the same files forcreateServer,.listen(,serve(andWebSocketto cover that ceiling. The Relay is separate: no foreign browser origin may drive its API, whatever interface it binds (docs/specs/security-remote.md-> "Cross-origin access"). A Unix-domain socket or named pipe is out of scope — no browser can reach one — which is why thedorcontrol channel is bounded by socket permissions instead. - FAIL IF the iframe proxy rewrites
Originto the upstream's own origin for a caller whose inboundOriginis not the proxy's own — inhandleRequestorhandleUpgrade. A foreignOriginmust be forwarded untouched rather than blocked, so the upstream sees the truth and applies its own policy (rationale). - FAIL IF the iframe proxy forwards
Cookieupstream orSet-Cookiedownstream on HTTP or WebSocket handshakes, including refused upgrades. Pinned bylib/src/host/iframe-proxy.test.ts(rationale). - FAIL IF the iframe proxy stops checking that
Hostnames its own grant port, on either path. Its per-grant ephemeral port and one-fixed-upstream binding are real mitigations but neither is a secret, so theHostcheck is what makes DNS rebinding fail. - FAIL IF the iframe proxy drops upstream
X-Frame-Options/ CSPframe-ancestorswithout replacing them with exactlyframe-ancestors 'self' <validated embedder chain>, admits another source, or targets the shim anywhere but its own proxy origin and that chain's innermost origin. With no usable chain it must preserve the headers and inject nothing (rationale). - FAIL IF a request bearing a foreign
Originrefreshes a grant's idle timer: a grant holds a live upstream binding, and a stranger polling it keeps a closed pane's binding open. An absentOriginmust keep refreshing it — that is what a live frame's own navigations and sub-resources send. - FAIL IF the stream relay's grant stops being single-use, TTL-bounded, and pinned to one target port, or if it begins rewriting
Originrather than dropping it. It needs noHostcheck while the token holds (rationale). - FAIL IF the browser-dev bridge drops any of its four gates — the per-run token, the loopback
Hostcheck, theapplication/jsoncontent-type required of every non-GET, and the exact-originaccess-control-allow-origin. The first three live together in the gate that runs before routing, so a route that never reads a body is covered by all of them. It is dev-only and ships in nothing, but it dispatchespty_spawnwith caller-suppliedshell,args,cwdandenv— arbitrary command execution on a maintainer or CI-agent machine (docs/specs/security-ci.md-> "Automated Maintainer (tend)"). The content-type rule is a security control, not tidiness (rationale). - FAIL IF the browser-dev Vite server permits cross-origin reads of token-bearing modules or disables its DNS-rebinding Host check. Pinned by
standalone/scripts/dev-agent-browser.test.mjs(rationale).
Cookie-authenticated iframe pages are unsupported. Header stripping does not isolate document.cookie: proxied scripts still share the loopback hostname's non-HttpOnly cookies across grant ports. This remains a browser-pane isolation gap (rationale).
Source of truth: the shared rule and predicates — isLoopbackHost, isOwnOrigin,
isForeignOrigin — in lib/src/host/loopback-guard.ts;
startDevVite in standalone/scripts/dev-run.mjs.
FAIL IF dor/src/file-viewer.ts serves any request without the fresh 256-bit URL capability, its own case-insensitive loopback Host, an absent or same-listener Origin, and a GET/HEAD method. Compare capability prefixes by SHA-256 then timingSafeEqual, including malformed lengths. allowsFileViewerRequest in dor/src/file-viewer-loopback-guard.ts gates every route. Never grant CORS access to foreign origins, cache responses, or send the capability as a referrer.
FAIL IF the local-file viewer exposes directory listings, arbitrary path reads, writes, or a file outside its opened-document grant. Grant construction permits only regular files, rejects symlinks escaping the canonical document directory, bounds static dependency discovery, and retains descriptors so later path replacement cannot widen the grant. Viewer resource loads are restricted by CSP to its own origin plus inline scripts/styles and data images, including through the iframe proxy; escaped text previews execute no document markup. The viewer opts into the proxy's upstream-policy preservation (docs/specs/dor-browser.md → Iframe Renderer).
Must not describe the viewer CSP as confining active documents' navigation. HTML/SVG scripts can navigate their frame to external URLs, including with granted contents; the resource policy is not a no-egress boundary. (rationale)
Source of truth: startFileViewer in dor/src/file-viewer.ts; allowsFileViewerRequest in dor/src/file-viewer-loopback-guard.ts; sanitizeResponseHeaders in lib/src/host/iframe-proxy.ts. Tests: dor/test/file-viewer.test.mjs, lib/src/host/file-viewer-proxy.test.ts.
The attacker is another local account reading disk; what the remote stack leaves
behind is docs/specs/security-remote.md -> "Credentials at rest".
Session snapshots are owner-only before any bytes are written. Standalone
persists one window's structure per file as <state root>/sessions/<label>.json
— panes, cwds, titles, doors, layout, TODO flags, never terminal text
(docs/specs/standalone.md -> "Persistence"), plus a
<label>.geometry.json sibling holding that window's box and nothing else, and
arrivals.json, the Workspaces mid-transfer between windows, written the same way
(docs/specs/standalone.md -> "Arrival queue").
restrict_to_owner locks the directory and, first, the temp file renamed into
it, applying a protected single-ACE DACL on Windows where a unix mode is a
silent no-op. The same helper
locks the whole standalone app-data directory before the sidecar spawns.
No writer persists scrollback (docs/specs/transport.md -> "What is
persisted"): normalizeSessionV3 strips it on read, so the first save after an
upgrade rewrites the snapshot without it, and a boot sweep deletes orphaned
*.json.tmp files no save would ever overwrite. Snapshots older versions left
behind do carry transcripts (rationale).
Standalone writes recovery.json beside its sessions directory, under the
state root, owner-only: one rebuilt agent-resume invocation per Surface, never a
buffer, unlinked as it is read (docs/specs/standalone.md -> "Agent recovery").
The notepad archive is the one store holding terminal text on purpose —
excerpts the user explicitly captured, their colors, the Surface title and kind,
and the CWD at closure, appended only by a Surface closing
(docs/specs/notepad.md -> "Archive"). Standalone keeps it as
<app_data_dir>/notepad-archive-v1.json, owner-only and shared by builds with the
same Tauri identifier; the dev wrapper uses a per-worktree identifier. VS Code keeps it in
<globalStorageUri>/notepad-archive.json, mode 0600 on Unix and inheriting VS Code's directory ACL on Windows. Migration and Settings Sync follow docs/specs/notepad.md -> "VS Code lifecycle". Its live half never reaches disk.
VS Code persists pane structure in VS Code's own storage — workspaceState
under dormouse.session, and vscode.setState(), a WebviewPanel's only store —
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 under the extension's storage directory, owner-only and
temp-then-rename: one rebuilt agent-resume invocation per Surface, no buffer,
unlinked as it is read (docs/specs/vscode.md -> "Capturing agent recovery").
The VS Code peer-link token is a local credential at rest —
burrow.peer-token in the extension's global storage, written mode 0600
with wx, its socket directory re-checked on every contention round. Neither
control does anything on Windows (rationale).
The standalone log is unprotected and names the control socket.
$DORMOUSE_LOG_FILE, else %LOCALAPPDATA%\Dormouse Terminal\dormouse.log, else
<tmpdir>/dormouse.log, created and appended with no mode and no ACL, so it
lands at the umask — readable by another local account wherever <tmpdir> is
shared (rationale). No log call carries PTY bytes; the dor control socket path
does. A gap, not an accepted risk.
- FAIL IF
write_file_atomicallyinstandalone/src-tauri/src/lib.rsstops restricting the directory and the file it writes to the owning user on every platformrestrict_to_ownerhas an arm for —0700/0600on unix, and on Windows a DACL protected from inheritance carrying exactly one ACE for the current user, asserted byrestrict_to_owner_leaves_one_owner_only_ace— or if any of its three callers —write_session_to(the session snapshot), the window-geometry sibling beside it, andwrite_notepad_archive_to(the notepad archive) — stops going through it.session_write_tightens_directory_and_existing_temp_filepins unix modes;session_permission_failures_preserve_previous_snapshot_without_writing_bytespins both failure gates. The mode reaches the temp file before any bytes are written (rationale). - FAIL IF the VS Code notepad archive key —
NOTEPAD_ARCHIVE_KEYinvscode-ext/src/notepad-archive-store.ts— is passed tocontext.globalState.setKeysForSync, directly or as part of any list. It holds captured terminal excerpts, their CWDs, and their Surface titles, and Settings Sync would copy them to every machine the account signs into. Nothing in the extension calls that API today, so the rule is kept by that call not existing; a call added for anything else must exclude this key.
Source of truth: SESSION_STATE_KEY in vscode-ext/src/session-state.ts,
ensureToken in vscode-ext/src/peer-link.ts, default_log_path in
standalone/src-tauri/src/lib.rs.
Must validate context directory arguments as existing absolute directories and pass the canonical path as one process argument without shell interpretation. Keep this capability separate from the external-URL allowlist. VS Code per-terminal context requests and helper ownership updates remain scoped to the owning router.
Source of truth: context in standalone/sidecar/pty-core.js; attachRouter in vscode-ext/src/message-router.ts. Test: standalone/sidecar/helper-terminal.test.js.
Must keep repo-local named Tools inert until the user grants trust through Dormouse chrome. The control socket exposes lookup and launch, never a trust-grant verb. Pending approval spawns neither its terminal nor a helper. Approval workflow belongs to docs/specs/dor-tool.md → Trust.
Must keep named-tool inputs as argv until the renderer quotes them for the target shell. User configuration is the local user's authority; a project name cannot replace a user Tool during user-only lookup. Resolution belongs to docs/specs/dor-tool.md → Declaring tools.
Must reject C0 and DEL characters in Tool argv, substituted argv, and local-file targets before launch, including controls exposed by canonicalizing symlinks. Shell quotes do not protect terminal editing keys. String run remains explicit shell code. Source of truth: hasShellInputControls in dor/src/commands/shell-quote.ts; resolveToolInput in lib/src/host/tool-input.ts; useDorControl in lib/src/components/wall/use-dor-control.ts. Tests: lib/src/host/tool-input.test.ts, lib/src/components/Wall.test.tsx.
Must derive the grant key in the host, using the canonical upstream URL or project-root folder; a renderer request cannot supply an arbitrary grant URL. Must bound config reads and refuse repo-config symlinks on every host. The user config may follow a dotfiles symlink; its opened descriptor must still be a bounded regular file.
An upstream grant trusts the claimed URL, not authenticated checkout provenance. A supplied directory containing its own .git/config can claim an already-granted upstream; folder-only grants limit this sharing. Must not describe the chrome gesture as a boundary against other processes running as the user; the local account model is The dor control socket above.
Must restrict announced ports to the designated command's Session process tree (docs/specs/dor-tool.md → Serving). Process output may select among that tree's discovered ports; it cannot turn an ordinary terminal into a Tool.
Source of truth: createToolHost in lib/src/host/tool-host.ts; lookupTool / FileToolTrustStore in lib/src/host/tool-trust.ts; useToolServing in lib/src/components/wall/use-tool-serving.ts. Tests: lib/src/host/tool-host.test.ts, lib/src/host/tool-trust.test.ts, lib/src/components/wall/use-tool-serving.test.tsx.