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
2 changes: 1 addition & 1 deletion .claude/skills/debug-standalone-agent-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The harness:

- stages the `dor` CLI and sidecar proxy
- starts the standalone Node sidecar directly
- starts a localhost HTTP/SSE bridge for browser-side `PlatformAdapter` calls
- starts a localhost HTTP/SSE bridge for browser-side `PlatformAdapter` calls, gated by a per-run token it bakes into the bridge URL the page is built against (`VITE_DORMOUSE_BROWSER_DEV_HOST`) — not into the page's own address, so there is no `?t=` in the address bar to look for. Nothing to pass yourself, but the bridge answers `404` to anything without it, so drive the app through `agent-browser` at the Vite port and not the bridge port. To poke the bridge by hand, use the `bridge token:` and ready-made `curl` the harness prints at startup.
- starts Vite with `VITE_DORMOUSE_BROWSER_DEV_HOST`
- opens the app in `agent-browser`
- mirrors browser console logs as `[browser log] ...` in the harness terminal
Expand Down
6 changes: 6 additions & 0 deletions .github/audit/application-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
**Scope — these sections, and no others:**

`## Remote Control`
`## Loopback Listeners`

**Output file:** `audit-application.md`

Expand All @@ -15,6 +16,11 @@ the code they point at — `server-lib-common/src/security/`, `server/src/`,
`lib/src/remote/`, `lib/src/host/remote/`, `vscode-ext/src/remote-host*.ts`,
`scripts/csp-defaults.mjs`, and `deploy/local/install-macos.sh`.

For `## Loopback Listeners`, read `lib/src/host/loopback-guard.ts` first — it
states the rule — then each listener it names. Derive the set of listeners by
searching the shipped trees yourself; the section's own list is a description of
today's tree, not the scope.

## Qualitative pass

Be adversarial, and go past the `FAIL IF` list. Ask specifically:
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,42 @@ jobs:
exit 1
fi

# zsh is not on the ubuntu runner image, and it is the default shell on
# macOS — our primary platform. Without it, `standalone/sidecar`'s
# shell-integration suite silently covered only bash, which is half of
# what stands behind an emit-side security fix (SECURITY.md's OSC 633
# rules; the emitters are the boundary, since the parser cannot defend
# against a terminator that arrives inside a directory name). The suite
# names the shells it covered on every run, so a future image change that
# drops one is visible rather than silent.
# `update` first: the runner image's apt lists are baked at image build, so
# once the archive rotates zsh's version the cached Packages entry 404s.
# `timeout` because a degraded Azure mirror dribbles bytes rather than
# failing — see the fuller treatment in Standalone Smoketest below, which
# also rotates mirrors. That much machinery is not worth it for one
# package; the retry here covers the common case without turning a
# `Build & Test` red for a reason unrelated to the diff.
# 13 minutes, not 6: the budget has to outlast the schedule it wraps, or
# Actions kills the step mid-attempt and the ::error:: below — the line
# that says what the failure costs — never prints. Worst case is
# 3 x (120 update + 120 install) + 2 x 15 sleep = 750s, and the last sleep
# is guarded to keep that `2 x` true: unguarded it is 3 x 15 = 765s, which
# halves the headroom for pure dead time before a step that is already
# failing.
- name: Install zsh (shell-integration tests)
timeout-minutes: 13
run: |
for i in 1 2 3; do
if sudo timeout 120 apt-get update -q \
&& sudo timeout 120 apt-get install -y -q --no-install-recommends zsh; then
exit 0
fi
echo "::warning::apt attempt $i for zsh failed or timed out"
if [ "$i" -lt 3 ]; then sleep 15; fi
done
echo "::error::could not install zsh; the shell-integration suite would silently cover only bash"
exit 1

- name: Test
run: pnpm test

Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Specs are written ahead of the code on purpose: a new component's spec starts as

The mechanically checkable parts of these conventions are enforced by `scripts/spec-lint.mjs` (`pnpm lint:specs`, also the first step of the root `pnpm test`): every spec indexed here, `## Future` last, relative links/anchors resolving, and backticked repo paths existing on disk.

Two sibling lints run alongside it in `pnpm test`, each enforcing one invariant a spec states in prose: `scripts/xterm-lint.mjs` (`pnpm lint:xterm`) for the `@xterm/*` version lockstep in `docs/specs/webgl-text.md`, and `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) for the rule in `SECURITY.md` -> "Loopback Listeners" that a loopback bind is not an access control — a new listener must reference a guard module or be allowlisted with a reason.

## Design

See [PRODUCT.md](PRODUCT.md) for users, brand personality, and aesthetic direction (including the anti-references), and [DESIGN.md](DESIGN.md) for the full design system — tokens, named rules, and component vocabulary. Key principles:
Expand Down
16 changes: 15 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Audited automatically.** This spec is checked against the repository by [`security-audit.yaml`](.github/workflows/security-audit.yaml) on a 24-hour schedule (04:21 UTC) and as a required gate before every VS Code release. The audit runs as three scoped subagents — supply chain, CI and secrets, and application security — merged into one verdict; see [CI Validation Contract](#ci-validation-contract). Each failure is filed as an issue labeled [`security-audit-failure`](https://github.com/diffplug/dormouse/issues?q=is%3Aissue+label%3Asecurity-audit-failure) — open ones are live, closed ones are the historical record of what tripped past audits and what changed to clear them.

Dormouse is a terminal, so users trust it with shells, source trees, credentials, and local files. Two things sit on that security boundary, and this document covers both. The **dependency graph and release pipeline** decide what code reaches a user's machine. **Remote control** — pairing a phone with a laptop — is the one feature that accepts input from the network, and an authorized phone is equivalent to a person at the keyboard.
Dormouse is a terminal, so users trust it with shells, source trees, credentials, and local files. Three things sit on that security boundary. The **dependency graph and release pipeline** decide what code reaches a user's machine. **Remote control** — pairing a phone with a laptop — is the one feature that accepts input from the network, and an authorized phone is equivalent to a person at the keyboard. And the **loopback listeners** Dormouse binds for its own surfaces accept input from any page in the user's browser, which is a boundary precisely because it does not look like one.

## Remote Control

Expand Down Expand Up @@ -124,6 +124,20 @@ Nothing in this subsection is implemented; it exists so the boundary is stated b

- FAIL IF the Server begins admitting an `accountId` other than `SELFHOST_ACCOUNT_ID` (`server-lib-common/src/remote/wire.ts`), or gains a self-serve signup path, while this subsection is still staged. The cloud boundary has to be analyzed here before the code that needs it ships.

## Loopback Listeners

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, and it reaches loopback exactly as easily as our webview does. An ephemeral port is not a secret either — the range scans in seconds. Two properties of the browser make this sharper than it looks: a POST with a simple content-type needs no preflight, so it *executes* even when the attacker cannot read the reply; and WebSockets are not subject to CORS at all, so a socket that connects is a socket that can be read.

The rule is about **privilege, not admission**: no listener may grant an unrecognized caller anything it could not already obtain by reaching the upstream directly. Every such listener answers two questions on every request — **was I addressed by my own loopback name**, and **do I recognize this caller** — but what it *does* with the second answer differs by listener. Two refuse the request outright. The iframe proxy deliberately admits everyone and instead declines to **vouch**: vouching for a stranger is what turns a transparent proxy into an amplifier, and refusing outright would be worse, because forwarding the caller's real `Origin` lets the upstream apply its own policy. The shared rule and the two shared predicates live in [`lib/src/host/loopback-guard.ts`](lib/src/host/loopback-guard.ts).

The mechanism for "do I recognize this caller" differs per listener because their URLs differ, and the differences are forced, not stylistic: the iframe proxy cannot use a URL token because it would land in `location.pathname` and break client-side routers — and would not survive onto root-relative sub-resource requests at all — while the browser-dev harness can, because it owns the page's URL.

- 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* is another, and is not a violation. `scripts/loopback-lint.mjs` (`pnpm test`) makes the cheap half of this deterministic — a new loopback bind that does not reference a guard module fails the build — but it can only see that a file *knows* a guard exists, never that the guard is called on every request, so this bullet still has to be read. Derive the set by searching the shipped trees for `createServer` and `.listen(` rather than trusting this list — an enumeration goes stale the moment someone adds a listener, which is the same failure mode that once left `.vscode/` owned by nobody. Today the set is three: the iframe proxy (`lib/src/host/iframe-proxy.ts`), the VS Code agent-browser stream relay (`vscode-ext/src/agent-browser-host.ts`), and the browser-dev bridge (`standalone/scripts/dev-agent-browser.mjs`). A Unix-domain socket or named pipe is not in scope — no browser can reach one — which is why the `dor` control channel is bounded by socket permissions instead.
- FAIL IF the iframe proxy rewrites `Origin` to the upstream's own origin for a caller whose inbound `Origin` is not the proxy's own — in `handleRequest` **or** `handleUpgrade`. The upgrade path is the one that matters most: a laundered `Origin` there does not merely let a stranger write, it hands them a readable socket to a dev server or `openvscode-server` that would have refused their real origin. A foreign `Origin` must be forwarded untouched rather than blocked, so the upstream sees the truth and applies its own policy.
- FAIL IF the iframe proxy stops checking that `Host` names 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 this is what makes DNS rebinding fail.
- FAIL IF the stream relay's grant stops being single-use, TTL-bounded, and pinned to one target port, or if it begins rewriting `Origin` rather than dropping it. It needs no `Host` check while the token holds: rebinding exists to make same-origin-looking requests to loopback, which buys nothing against a listener demanding an unguessable one-shot secret.
- FAIL IF the browser-dev bridge drops any of its four gates — the per-run token, the loopback `Host` check, the `application/json` content-type required of every non-GET, or the exact-origin `access-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 dispatches `pty_spawn` with caller-supplied `shell`, `args`, `cwd` and `env`, so reaching it is arbitrary command execution on a maintainer or CI-agent machine — the machines the [Automated Maintainer](#automated-maintainer-tend) threat model is about. The content-type rule is a security control, not tidiness: without it the endpoint is CORS-simple and needs no preflight to survive.

## Dependency Supply Chain

Dormouse keeps its runtime dependency surface intentionally small. We add dependencies only when they are necessary, and we expect dependency changes to justify their value against their supply-chain risk. We use maturity gating inside our pnpm configuration and also inside our [Renovate configuration](.github/renovate.json).
Expand Down
24 changes: 23 additions & 1 deletion docs/specs/dor-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,14 +595,36 @@ frame-src http://127.0.0.1:* http://localhost:*

Security boundaries:

- proxy binds loopback only,
- proxy binds loopback only — which is a mitigation, **not** the boundary; see
the two gates below,
- `Host` must name the grant's own loopback port, on the request and upgrade
paths alike, so DNS rebinding fails,
- the `Origin` rewrite applies only to a caller the proxy itself served,
- each grant fronts exactly one upstream,
- no user script is injected,
- link-local/cloud-metadata ranges are blocked,
- every other user-supplied `http://` target is trusted as the user's command
and framed with its frame-blocking headers stripped (the embed is the user's
own, not third-party clickjacking).

**Why the `Origin` rewrite is conditional.** Presenting a request as coming from
the upstream's own origin is the proxy *vouching* for it, and that is what
origin-aware dev servers rely on. The per-grant ephemeral port is not a secret —
the range scans in seconds — so vouching unconditionally would let any page in
the user's browser POST here and have its `Origin: https://evil.example`
relabelled as the upstream's own, defeating exactly the check the rewrite exists
to satisfy. It matters most on `handleUpgrade`: WebSockets are not subject to
CORS, so a laundered `Origin` yields a *readable* socket to a dev server or
`openvscode-server` that would have refused the real one. A foreign `Origin` is
forwarded untouched rather than blocked, which leaves the upstream to apply its
own policy and means the proxy grants nothing that hitting the upstream's port
directly would not. An absent `Origin` stays absent — that is an ordinary
top-level navigation or same-origin GET. `Referer` needs no such test: it only
substitutes the proxy's own origin, so a foreign referer already passes through.
The shared rule for all of Dormouse's loopback listeners lives in
`lib/src/host/loopback-guard.ts`, and `SECURITY.md` → "Loopback Listeners" audits
it.

Source of truth: `lib/src/lib/platform/types.ts`,
`lib/src/lib/platform/vscode-adapter.ts`, `vscode-ext/src/message-types.ts`,
`vscode-ext/src/message-router.ts`, `vscode-ext/src/webview-html.ts`,
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/mouse-and-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ Platform is detected at startup from `navigator.userAgentData.platform` (preferr

When the inside program has opted in via `\e[?2004h` (tracked as the `bracketedPaste` field on the per-terminal mouse-selection state), the terminal writes `\e[200~`, then the clipboard content, then `\e[201~`, to the PTY. Otherwise the content is written without brackets. This is standard xterm behavior; it allows shells and TUIs to distinguish pasted content from typed input.

**The bracketed payload is filtered: every `\e` in it is replaced with a visible U+241B before wrapping.** Without that, clipboard content containing `\e[201~` closes the bracket early and everything after it reaches the shell as ordinary typed input — newlines included, which submit — so anything that can write the clipboard could run a command the user never pasted. Brackets are the *only* defense here, since §8.6 puts multi-line paste confirmation out of scope. The filter is byte-for-byte xterm's own `bracketTextForPaste`, repeated because `writePasteToPty` calls `writePty` directly and so never reaches xterm's paste path; it covers file-path pastes (§8.6 tiers 1 and 3) as well, because they share that writer. The unbracketed branch is deliberately unfiltered: the inside program has not asked to tell pasted bytes from typed ones, so there is no boundary left to protect, and filtering would break a deliberate paste of an escape sequence. `Source of truth:` `defangPasteEscapes` in `lib/src/lib/clipboard.ts`.

The bracketed-paste mode is read at paste time from the per-terminal mouse-selection state's `bracketedPaste` field, which `lib/src/lib/mouse-mode-observer.ts` keeps in sync with xterm's public `terminal.modes.bracketedPasteMode` via a parser hook on `CSI ? ... h`/`l`.

### 8.6 Paste Content
Expand Down
7 changes: 7 additions & 0 deletions docs/specs/terminal-escapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ A binary on `PATH` only has to be **found**, so it injects via one env var (`DOR

Injection is wired in `resolveSpawnConfig` (`standalone/sidecar/pty-core.js`) and applies to both distributions (the standalone sidecar and the VS Code pty-host both spawn through it). The integration scripts are static files under `standalone/sidecar/shell-integration/`; the directory is resolved from `DORMOUSE_SHELL_INTEGRATION_DIR` (set by the host, mirroring `DORMOUSE_CLI_BIN`) and falls back to the sidecar's own directory. Standalone ships them via the tauri `../sidecar/**/*` resources glob; the VS Code build copies them into `dist/shell-integration`. If the scripts are missing, injection is skipped and the shell spawns exactly as before — injection is fail-safe.

**Emitted fields are filtered before they are written, and that is a security boundary, not tidiness.** A POSIX path component may hold any byte but `/` and NUL, and a command line may hold anything at all, so an attacker-chosen directory name or command can carry an OSC terminator — BEL, `ESC \`, or the C1 ST `U+009C` (all three are what `findOscTerminator` scans for). The parser cannot defend against this: the terminator scan runs on raw bytes, so by the time the parser sees them the `633` sequence is already over and the remainder arrives as a fresh, fully-trusted OSC. It would forge notifications, command lines, or titles in the shell's own voice — `OSC 9` most damagingly, since an alert latches a ring, persists, is spoken aloud, and is pushed to the paired phone. The injected bytes are consumed by the parser, so nothing appears on screen, and a poisoned directory re-fires for anyone who enters it, outliving the process that planted it. The boundary therefore has to be on the *emit* side, in the scripts Dormouse ships:

- **`E` (command line)** is escaped by `__dormouse_633_escape`, which now covers BEL, ESC and the C1 ST alongside the existing `\`, `;`, LF and CR. Escaping costs nothing here because the parser decodes `\xNN` back, so the command line still reports verbatim.
- **`Cwd=`** cannot be escaped — the parser reads it verbatim, with no `\xNN` decoding, precisely so a Windows path's backslashes arrive intact. `__dormouse_633_safe_cwd` therefore *removes* control characters rather than escaping them. Backslashes and semicolons are deliberately preserved. Under `LC_ALL=C` the C1 ST is two ordinary bytes that `[[:cntrl:]]` does not match, so the shell scripts strip it explicitly first.

`Source of truth:` `__dormouse_633_escape` and `__dormouse_633_safe_cwd` in each of `standalone/sidecar/shell-integration/bash/shellIntegration.bash`, `standalone/sidecar/shell-integration/zsh/.zshrc`, and `standalone/sidecar/shell-integration/pwsh/shellIntegration.ps1`. Because the injection is emit-side, the tests run the real shells: `standalone/sidecar/shell-integration.test.js`.

### Keystroke fallback

When injection isn't possible (cmd.exe, an unknown shell, or scripts not present) or simply doesn't take, Dormouse falls back to its keystroke heuristic: it reads the submitted command off the rendered prompt line and synthesizes `commandStart{source:'user_input'}`. This fallback has no real exit codes and only a best-effort idle transition. The fallback rules — prompt-shape learning, submit parsing, and the per-pane promotion that retires the heuristic on the first authentic OSC boundary (which is what makes it fire "only if injection fails") — are owned by [terminal-state.md](terminal-state.md#keystroke-fallback).
Expand Down
Loading