diff --git a/.github/audit/_preamble.md b/.github/audit/_preamble.md index 646b099b..88009831 100644 --- a/.github/audit/_preamble.md +++ b/.github/audit/_preamble.md @@ -1,14 +1,18 @@ # Shared preamble — every audit subagent -Read `SECURITY.md` first. Your scope is exactly the sections named in your own -file — ignore every other section, including its `FAIL IF` lines; another agent -owns them. +Read `docs/specs/security.md` first: it states the guarantees, what is not +defended, and the known gaps, and names the spec each domain audits. Your scope +is exactly the spec files listed under **Scope** in your own file — ignore every +other spec's `FAIL IF` lines; another agent owns them. `docs/specs/security-audit.md` +is the contract this run executes. For each `FAIL IF` in your scope, run the mechanical check (`gh api`, grep, file read, or a script) and record PASS or FAIL with concrete evidence: file path and line number, API response excerpt, or command output. A `FAIL IF` bullet may assert several properties in one sentence; **each clause gets its -own verdict and its own evidence**. Never satisfy a bullet in bulk. +own verdict and its own evidence**. Never satisfy a bullet in bulk. A `FAIL IF` +that ends `(rationale)` has its evidence in the paired `.rationale.md` +under the same heading; read it when the rule alone is not enough to judge. Then do the qualitative pass described for your domain, rating findings BLOCKER / WARNING / INFO. Report what you can prove. Use `UNVERIFIABLE` only @@ -16,6 +20,10 @@ for a check you could not determine — a transient network error, or an area yo ran out of room to reach — and say which it was. It is never a substitute for a check you could have run. +Where `docs/specs/security.md` says a risk is accepted ("What is not defended") +or a gap is known ("Known gaps"), do not re-report it as a finding — report +only if the situation has changed or is worse than described. + Write your findings to the file named in your own prompt. **Its very first line must be literally `VERDICT: PASS` or `VERDICT: FAIL`** — nothing else on that line. The reporting step greps for it, so it is the one part of your diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index 2de67ac2..d7169564 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -1,14 +1,15 @@ # Domain: application-security -**Scope — these sections, and no others:** +**Scope — these specs, and no others:** -`## Remote Control` -`## Loopback Listeners` +- `docs/specs/security-local.md` +- `docs/specs/security-remote.md` **Output file:** `audit-application.md` -This is a code-and-specs audit of the product's own remote control stack. You -need no GitHub API access and no PAT — do not use one. +This is a code-and-specs audit of the product's own boundaries — the remote +control stack, and the local application. You need no GitHub API access and no +PAT — do not use one. Read, at minimum: `docs/specs/remote-security-model.md` **and its paired `docs/specs/remote-security-model.rationale.md`**, `docs/specs/server.md`, @@ -42,6 +43,14 @@ 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. +For the rest of `docs/specs/security-local.md`, read each section's owner first +— `docs/specs/terminal-escapes.md`, `docs/specs/dor-browser.md`, +`docs/specs/dor-cli.md`, `docs/specs/vscode.md` -> "Webview message +authentication", `docs/specs/standalone.md` -> "Persistence" — then the parser, +the iframe shim, the control-socket code, and the persistence path they point +at. The attacker there is a program printing to the terminal, a page in a +browser pane, or another local account, never the network. + ## Qualitative pass Be adversarial, and go past the `FAIL IF` list. Ask specifically: @@ -103,13 +112,9 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: and `server.md`'s Relay and E2E framing. `scripts/e2e-lint.mjs` mechanizes the structural half of that ("one suite, no negotiation, no plaintext path, no legacy discriminant") — check that each of its rules still names a real - `SECURITY.md` line and that `scripts/e2e-lint-selftest.mjs` still proves every - rule load-bearing, then look for what a *textual* lint cannot see. - -Where the section says a risk is accepted (the setup password's hardening) or a -gap is known (revocation, the audit trail, the two `workflow-audit` window -evasions), do not re-report it as a finding — report only if the situation has -changed or is worse than described. + `docs/specs/security-remote.md` line and that + `scripts/e2e-lint-selftest.mjs` still proves every rule load-bearing, then + look for what a *textual* lint cannot see. You are also the **catch-all** domain, and this is defined by subtraction, not by a list: you own everything in the repository that `supply-chain.md` and diff --git a/.github/audit/ci-and-secrets.md b/.github/audit/ci-and-secrets.md index 9d37f0d4..048ad38c 100644 --- a/.github/audit/ci-and-secrets.md +++ b/.github/audit/ci-and-secrets.md @@ -1,13 +1,10 @@ # Domain: ci-and-secrets -**Scope — these sections, and no others:** +**Scope — these specs, and no others:** -`## GitHub Actions Policies` -`## Automated Maintainer (tend)` -`## VS Code Extension Releases` -`## Desktop Releases` -`## Reporting a Vulnerability` -`## CI Validation Contract` +- `docs/specs/security.md` +- `docs/specs/security-ci.md` +- `docs/specs/security-audit.md` **Output file:** `audit-ci-secrets.md` @@ -24,7 +21,7 @@ GH_TOKEN=$AUDIT_PAT gh api repos/$GITHUB_REPOSITORY/rulesets/16757376 `$AUDIT_PAT` is a fine-grained, read-only PAT covering Administration + Secrets + Environments, guaranteed present by an earlier step. If a prefixed call still returns 403, record FAIL with the note "PAT scope drifted from -SECURITY.md". +docs/specs/security-audit.md". **Check effective permissions, not declared ones.** A job with no `permissions:` block inherits the repository default, so read diff --git a/.github/audit/orchestrator.md b/.github/audit/orchestrator.md index 5278af26..1976b317 100644 --- a/.github/audit/orchestrator.md +++ b/.github/audit/orchestrator.md @@ -1,9 +1,9 @@ # Security audit — orchestrator You are the orchestrator of this repository's nightly security audit. -`SECURITY.md` is the document you audit against: its `FAIL IF` lines are -concrete mechanical checks, and it also says that list is not exhaustive, so -each domain gets a qualitative pass too. +The security specs (`docs/specs/security*.md`) are what you audit against: +their `FAIL IF` lines are concrete mechanical checks, and `docs/specs/security-audit.md` +says that list is not exhaustive, so each domain gets a qualitative pass too. **Audit nothing yourself.** Fan the work out to three subagents with disjoint scopes, then merge what they return. The domains are genuinely different diff --git a/.github/audit/supply-chain.md b/.github/audit/supply-chain.md index 6ed9de67..c3910d42 100644 --- a/.github/audit/supply-chain.md +++ b/.github/audit/supply-chain.md @@ -1,8 +1,8 @@ # Domain: supply-chain -**Scope — these sections, and no others:** +**Scope — these specs, and no others:** -`## Dependency Supply Chain` +- `docs/specs/security-supply-chain.md` **Output file:** `audit-supply-chain.md` diff --git a/.github/renovate.json b/.github/renovate.json index 6a30d24b..f176710c 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -9,7 +9,7 @@ "Security fixes must not wait for the Monday window. Everything else here is batched to one day a week, which is right for routine bumps and wrong for a known-vulnerable dependency, so this overrides the schedule only.", "`minimumReleaseAge` is set here EXPLICITLY, and must stay that way. Renovate\u0027s own default for this block is `minimumReleaseAge: null`, force-applied before lookup — so omitting it does not inherit the cooldown from packageRules, it DROPS the cooldown entirely. Stating it is the only way to keep it.", "Keeping it is the deliberate choice: the cooldown guards the opposite threat — a compromised release that gets yanked within a day — and a reviewer reading a Renovate diff cannot detect a supply-chain compromise the way the ecosystem\u0027s own yank process can. Nothing here auto-merges, and the Dependabot alert already makes the vulnerability visible the moment it is published, so what the cooldown costs is a day before the remediation PR appears, not a day before anyone knows.", - "See SECURITY.md, Dependency Supply Chain." + "See docs/specs/security-supply-chain.md, Cooldown and alerts." ], "enabled": true, "schedule": [], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fadd0360..3c7ce092 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # The disclosed dependency snapshots are a SECURITY.md `FAIL IF` - # (Dependency Supply Chain), but until this step existed the only thing + # The disclosed dependency snapshots are a `FAIL IF` in docs/specs/security-supply-chain.md + # (Disclosure), but until this step existed the only thing # that ever ran the generator was the nightly security audit — strictly # after the fact, and only if it reached that check. Two prod-dependency # bumps shipped undisclosed that way (`ws` via vscode-ext, `hono` via a @@ -48,7 +48,7 @@ jobs: # 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 + # what stands behind an emit-side security fix (docs/specs/terminal-escapes.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 diff --git a/.github/workflows/security-audit.yaml b/.github/workflows/security-audit.yaml index 6cd72cd9..d61533b0 100644 --- a/.github/workflows/security-audit.yaml +++ b/.github/workflows/security-audit.yaml @@ -1,6 +1,6 @@ name: security-audit -# Audits this repo against SECURITY.md. Runs nightly via the schedule +# Audits this repo against the security specs (docs/specs/security*.md). Runs nightly via the schedule # trigger and on-demand via workflow_dispatch. release.yml dispatches it # on the release tag and gates publishing on the result — it dispatches # rather than calling this workflow with `uses:` because a tag `push` @@ -72,11 +72,11 @@ jobs: run: | [ -n "$AUDIT_PAT" ] && exit 0 echo "FAIL" > audit-status.txt - echo "**FAIL** — \`AUDIT_PAT\` is not present in the \`security-audit\` environment. See [SECURITY.md > CI Validation Contract](https://github.com/$GITHUB_REPOSITORY/blob/main/SECURITY.md#ci-validation-contract) for provisioning." > audit-report.md + echo "**FAIL** — \`AUDIT_PAT\` is not present in the \`security-audit\` environment. See [security-audit.md > Environment and AUDIT_PAT](https://github.com/$GITHUB_REPOSITORY/blob/main/docs/specs/security-audit.md#environment-and-audit_pat) for provisioning." > audit-report.md echo "::error::AUDIT_PAT secret is not set." exit 1 - - name: Audit against SECURITY.md + - name: Audit against the security specs uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1 env: # `claude-code-action` resets `GH_TOKEN` to its own internal @@ -133,16 +133,17 @@ jobs: claude_args: >- --allowed-tools "Read,Write,Edit,Bash,Grep,Glob,Task,Agent" --disallowed-tools "Workflow" - --agents '{"supply-chain":{"description":"Audits the Dependency Supply Chain section of SECURITY.md.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/supply-chain.md`, and follow them exactly."},"ci-and-secrets":{"description":"Audits the CI, tend, release, and audit-contract sections of SECURITY.md.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/ci-and-secrets.md`, and follow them exactly."},"application-security":{"description":"Audits the Remote Control section of SECURITY.md, and sweeps the rest of the product code.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/application-security.md`, and follow them exactly.","model":"opus"}}' + --agents '{"supply-chain":{"description":"Audits docs/specs/security-supply-chain.md.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/supply-chain.md`, and follow them exactly."},"ci-and-secrets":{"description":"Audits docs/specs/security.md, docs/specs/security-ci.md, and docs/specs/security-audit.md.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/ci-and-secrets.md`, and follow them exactly."},"application-security":{"description":"Audits docs/specs/security-local.md and docs/specs/security-remote.md, and sweeps the rest of the product code.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/application-security.md`, and follow them exactly.","model":"opus"}}' # The prompts live in `.github/audit/`, not inline here: they # are long enough to need real diffs in review, they must be # runnable locally against the same text CI uses - # (`scripts/security-audit-local.sh`), and the `FAIL IF` that - # requires every `## ` heading to be owned by exactly one - # domain is only a grep when those headings sit in markdown. - # Inline, YAML block-scalar wrapping split - # `## Automated Maintainer (tend)` across two lines and the - # check matched nothing. + # (`scripts/security-audit-local.sh`), and each domain's scope + # — the bullet list of security specs under its `**Scope` line + # — is what `scripts/spec-lint.mjs` (check 16) reads to prove + # every `docs/specs/security*.md` has exactly one owner. That + # lint cannot read YAML block scalars, and when the scopes were + # inline here the ownership check was a grep that wrapping + # once broke silently. prompt: | You are the orchestrator of this repository's nightly security audit. @@ -200,7 +201,7 @@ jobs: TRANSCRIPT="$RUNNER_TEMP/claude-execution-output.json" # Every sink that the archive step below publishes has to be in # the argument list, and the fail-closed `rm -f` has to delete - # the same set — SECURITY.md names `audit-report.md`, the three + # the same set — docs/specs/security-audit.md names `audit-report.md`, the three # per-domain fragments, and the transcript. The fragments are # agent output like any other: a subagent that shells out and # prints an environment lands the value in its own fragment, @@ -308,7 +309,7 @@ jobs: # write no status file at all in that case — which lands on the # MISSING branch below, not on FAIL — but a prompt is not a # control: without this check the matching `FAIL IF` in - # SECURITY.md is enforced only by the document being audited, + # docs/specs/security-audit.md is enforced only by the document being audited, # which is circular. Downgrade to MISSING rather than FAIL for the same # reason the three outcomes exist above — this is an audit that # did not finish, not a security finding — and MISSING already @@ -457,7 +458,7 @@ jobs: HEADLINE="Audit failed at $DATE." else TITLE="[security-audit] INCONCLUSIVE on $(date -u +%Y-%m-%d)" - HEADLINE="Audit reached no usable verdict at $DATE." + HEADLINE="Audit reached no usable verdict at $DATE. This is not a security finding: the run ended without deciding." fi { @@ -483,7 +484,7 @@ jobs: } > audit-comment.md rm -f "$NOTES" - # Truncate before posting, non-fatally. See SECURITY.md. + # Truncate before posting, non-fatally. See docs/specs/security-audit.md -> "Outcomes and reporting". node scripts/clamp-issue-body.mjs audit-comment.md \ --note "The untruncated \`audit-report.md\` is in this run's \`audit-transcript\` artifact${TRANSCRIPT_URL:+ ([download]($TRANSCRIPT_URL))}." \ || echo "clamp-issue-body.mjs failed; posting audit-comment.md unclamped." >&2 diff --git a/.github/workflows/workflow-audit.yaml b/.github/workflows/workflow-audit.yaml index bba88075..45f15523 100644 --- a/.github/workflows/workflow-audit.yaml +++ b/.github/workflows/workflow-audit.yaml @@ -33,7 +33,7 @@ name: workflow-audit # # Both classifiers fail open: any error, ambiguity, or unparseable input # reports the commit. A silent run is the healthy steady state and keeps -# the SECURITY.md 48-hour liveness check green — that check keys on a +# the 48-hour liveness check in docs/specs/security-ci.md green — that check keys on a # successful *run*, not on an issue existing. # # Deliberately not deduped by (branch, file-set): that would let a @@ -85,7 +85,7 @@ jobs: echo "Auditing commits since: $SINCE" # .github/audit/ is in the window because it holds the security - # audit's own prompts — which domain owns which SECURITY.md section, + # audit's own prompts — which domain owns which security spec, # what each one is told to check, and the rule that keeps the # orchestrator from ending its turn. A bot that edits those changes # what gets audited without touching a single workflow file, which is @@ -100,14 +100,14 @@ jobs: # folder — the same persistence-on-checkout shape a workflow gives, # reached without touching .github/. # - # SECURITY.md is deliberately NOT in the window, though it was - # briefly. Two reasons, and the second is why the first is not enough + # The security specs (docs/specs/security*.md) are deliberately NOT in + # the window, though SECURITY.md was briefly. Two reasons, and the second is why the first is not enough # on its own. What this job watches is code that *executes from any # branch*: a workflow runs on a bot-pushed branch, and a folderOpen - # task runs when someone checks one out. SECURITY.md is inert until + # task runs when someone checks one out. A security spec is inert until # it is merged to main, which is admin-gated — so the branch-wide - # watch buys nothing PR review does not already give. And it changes - # in nearly every security commit, so including it would report a + # watch buys nothing PR review does not already give. And they change + # in nearly every security commit, so including them would report a # commit on almost every such PR; a control that cries wolf on # routine work is one people learn to skim. # @@ -175,7 +175,7 @@ jobs: # still repoint `actions/checkout` at `evil/action`, which is why the # action name is compared. The residual is a ref selected by Renovate # within that action's own repo — the same trust Renovate bumps already - # rest on (see "GitHub Actions Policies" in SECURITY.md). + # rest on (see docs/specs/security-ci.md -> "Automated Maintainer (tend)"). is_renovate_pin_bump() { local sha="$1" login pr_authors diff changed removed added diff --git a/AGENTS.md b/AGENTS.md index fca8057e..beb1d375 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,9 +55,15 @@ One implementation map per spec: an exhaustive `Files` / `Code Map` section or s - **`docs/specs/remote-security-model.md`** — Remote-control trust model: one Noise channel per ceremony, passkeys proving presence inside it, per-Host Client statics, the Host (not the Server) authorizing the pair. Read first for anything remote. - **`docs/specs/remote-api.md`** — What an authorized Client speaks: the shipped terminal-only **protocol-v1** and the staged remainder. - **`docs/specs/server.md`** — The selfhost coordinating server and shared Host-service runtime: env config, JSON-file state, WebAuthn without a library, HTTP API, relay flow, enrollment, running it end to end. -- **`SELF_HOST.md`** (repo root) — Self-host deployment: the assistant-run install runbook plus the Installer contract that `SECURITY.md`'s `FAIL IF` lines and `scripts/deploy-lint.mjs` audit. +- **`SELF_HOST.md`** (repo root) — Self-host deployment: the assistant-run install runbook plus the Installer contract that `docs/specs/security-remote.md`'s `FAIL IF` lines and `scripts/deploy-lint.mjs` audit. - **`docs/specs/pocket-app.md`** — Pocket: the remote session is a `PlatformAdapter` (`RemotePtyAdapter`), so Pocket is auth screens plus the mobile composition; owns the same-origin deployment rule. - **`docs/specs/deploy.md`** — Release process: artifact matrix, release checklist, two-stage sign-and-release pipeline, updater manifest, changelog flow. +- **`docs/specs/security.md`** — The guarantees Dormouse makes, what it does not defend, the known gaps, and how it is all checked; published at `/docs/security`, rows split by audience. Read first for anything security. Root `SECURITY.md` is the GitHub policy pointer at it. +- **`docs/specs/security-local.md`** — The boundaries a user of the local application has: terminal output, browser panes, the `dor` control socket, loopback listeners, persisted state. +- **`docs/specs/security-remote.md`** — The audited checks on remote control: trust boundary, relay allowlist, credentials at rest, the setup password, network posture, what crosses the boundary, revocation. +- **`docs/specs/security-supply-chain.md`** — Disclosure of everything that reaches a user's machine, the bundled runtime pin, dependency cooldown and alerts. +- **`docs/specs/security-ci.md`** — GitHub Actions, the tend bot, and the two release paths: what each identity can reach and what stays admin-gated. +- **`docs/specs/security-audit.md`** — The nightly audit contract: schedule and release gate, the domains and their prompts in `.github/audit/`, orchestration, outcomes, reporting, `AUDIT_PAT`. `scripts/security-audit-local.sh` runs it locally. When code covered by a spec changes, change the spec. Where two specs overlap (pane header elements), layout.md owns placement and sizing, alert.md behavior and visual states. `docs/stories/pairing.mdx` narrates the remote setup and owns nothing; when a remote spec changes, check whether it needs the same edit — the specs win where they disagree. @@ -66,7 +72,7 @@ When code covered by a spec changes, change the spec. Where two specs overlap (p - **Keying.** Each `## X` in `.rationale.md` is a heading in `.md`, at any level, including one under `## Future`. A rule whose evidence moved gets a trailing `(rationale)` marker, alone or as the last item of its parenthetical (`(…; rationale)`) — a hint, not a link — and the marker replaces the why-clause; a rule never carries both. Rationale files are informative, not normative: no `## Future`, no `Reserved:`, no `Source of truth:` obligations, no bolded imperatives; date measurements (`measured in Safari 26.5, 2026-08`) so later pruning is safe. - **Rule first.** Within a spec paragraph the normative statement leads and any remaining inline why follows (applied opportunistically, not as a corpus rewrite). -**House form for rules.** A rule leads with a bolded imperative — **Never …**, **Must …**, **May …** — and at most one clause of why; the bold carries the emphasis, so scaffolding ("deliberately", "note that", "it is worth stating") is deleted. A rule list, precedence ladder, or flow renders as invariant bullets or a table, not prose; number rules only where another spec cites them. Mechanism constraining a single module lives as a comment at that code, the spec keeping the one-line rule and a `Source of truth:` pointer; mechanism constraining editors of *other* files stays in the spec. Consolidate `Source of truth:` pointers at the end of a section, as `` `symbol` in `path` `` with full repo paths — a bare file name dodges the path lint and rots. Name the test that pins a rule; never reproduce its case inventory. A diagram earns its place only for ordering or fan-out no table carries; prose beside a figure or table adds only what it cannot show, and a flow converts to a numbered list, never a sentence. A table cell states the fact, the section the rule, the rationale the why; a qualifier repeated in every row becomes a caption. A coined term is defined at its heading, not in an intro. Registries — `docs/specs/terminal-escapes.md`, `docs/specs/shortcuts.md`, the glossary tables — keep their inventories and own no behavior. +**House form for rules.** A rule leads with a bolded imperative — **Never …**, **Must …**, **May …** — and at most one clause of why; the bold carries the emphasis, so scaffolding ("deliberately", "note that", "it is worth stating") is deleted. An audited rule leads with **FAIL IF** instead — the condition, what the auditor reads, at most one clause of why — and lives only in a `docs/specs/security*.md` spec, each claimed by exactly one domain prompt in `.github/audit/`. A rule list, precedence ladder, or flow renders as invariant bullets or a table, not prose; number rules only where another spec cites them. Mechanism constraining a single module lives as a comment at that code, the spec keeping the one-line rule and a `Source of truth:` pointer; mechanism constraining editors of *other* files stays in the spec. Consolidate `Source of truth:` pointers at the end of a section, as `` `symbol` in `path` `` with full repo paths — a bare file name dodges the path lint and rots. Name the test that pins a rule; never reproduce its case inventory. A diagram earns its place only for ordering or fan-out no table carries; prose beside a figure or table adds only what it cannot show, and a flow converts to a numbered list, never a sentence. A table cell states the fact, the section the rule, the rationale the why; a qualifier repeated in every row becomes a caption. A coined term is defined at its heading, not in an intro. Registries — `docs/specs/terminal-escapes.md`, `docs/specs/shortcuts.md`, the glossary tables — keep their inventories and own no behavior. Generated help and canonical types own syntax and shape; specs own behavior and cross-boundary invariants, including the contract of a consumer that does not exist yet. Keep specs concise, but never replace an invariant or edge case with only a code pointer. `Source of truth:` is the form for implementation references; protocols, command orchestration, and cross-package boundaries state direction and scope. Docs-only compression spot-checks referenced symbols, message directions, and root-vs-package script ownership against code before committing. @@ -82,7 +88,7 @@ Specs are written ahead of the code: a new component's spec starts as a full des - **Reservations.** Unbuilt design that constrains present code — a reserved wire field, a reserved ref grammar, an additive-evolution guarantee — is stated in the body, marked `Reserved:`, pointing at the `## Future` item it serves. Test: if deleting the sentence would let someone break future compatibility today, it belongs in the body. - **Promotion is part of done.** A staged item is finished only when its text moves above the fold — "will" rewritten to "is", `Source of truth:` added — and the built portion is deleted from `## Future`. Never leave completed plan text (build orders, phase lists) below the fold; git keeps the record. -`scripts/spec-lint.mjs` (`pnpm lint:specs`, the first step of the root `pnpm test`) enforces the mechanically checkable conventions above — its header comment lists the checks — and ratchets size: every spec and this file carry a word budget in `scripts/spec-word-budgets.json`, its size rounded up to the nearest 50. Rationale files carry none; evidence may grow without limit. Over budget: cut to fit, or re-baseline with `node scripts/spec-lint.mjs --ratchet ` in the same PR. `SELF_HOST.md` rides the same checks. Advisory prose reviews follow `docs/prose-audit.md` (`pnpm audit:prose`). +`scripts/spec-lint.mjs` (`pnpm lint:specs`, the first step of the root `pnpm test`) enforces the mechanically checkable conventions above — its header comment lists the checks — and ratchets size: every spec, this file, `SECURITY.md`, and `SELF_HOST.md` carry a word budget in `scripts/spec-word-budgets.json`, its size rounded up to the nearest 50. Rationale files carry none; evidence may grow without limit. Over budget: cut to fit, or re-baseline with `node scripts/spec-lint.mjs --ratchet ` in the same PR. `SECURITY.md` and `SELF_HOST.md` ride the same checks. Advisory prose reviews follow `docs/prose-audit.md` (`pnpm audit:prose`). Five sibling lints run in `pnpm test`, each enforcing one invariant a spec states in prose; each names the line it enforces and fails if that line is gone: @@ -90,9 +96,9 @@ Five sibling lints run in `pnpm test`, each enforcing one invariant a spec state |---|---| | `scripts/public-docs-lint.mjs` (`pnpm lint:public-docs`) | The public-doc contracts in `docs/specs/website-docs.md`, every inventory derived from the file that owns it. | | `scripts/xterm-lint.mjs` (`pnpm lint:xterm`) | The `@xterm/*` version lockstep in `docs/specs/webgl-text.md`. | -| `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) | `SECURITY.md` -> "Loopback Listeners": a loopback bind is not an access control — a new listener references a guard module or is allowlisted with a reason. | -| `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) | `SECURITY.md` -> "Credentials at rest" and "Network posture (self-hosted)": the installer controls binding all three of `deploy/local/install-{macos,windows,linux}`. | -| `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) | The structural half of `SECURITY.md` -> "Remote Control": one Noise suite with no selector, no JavaScript curve, no legacy relay discriminant, no Server-side protocol-v1 type, no checked-in service worker, no optional field on a ciphertext or transcript. | +| `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) | `docs/specs/security-local.md` -> "Loopback Listeners": a loopback bind is not an access control — a new listener references a guard module or is allowlisted with a reason. | +| `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) | `docs/specs/security-remote.md` -> "Credentials at rest" and "Network posture (self-hosted)": the installer controls binding all three of `deploy/local/install-{macos,windows,linux}`. | +| `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) | The structural half of `docs/specs/security-remote.md` -> "Remote Control": one Noise suite with no selector, no JavaScript curve, no legacy relay discriminant, no Server-side protocol-v1 type, no checked-in service worker, no optional field on a ciphertext or transcript. | `scripts/spec-lint-selftest.mjs` plants one defect per finding check in the spec lint. The last two sibling lints carry self-tests that are inverses because the lints are: `scripts/deploy-lint-selftest.mjs` deletes each installer control (and, for exact-count rules, adds a copy); `scripts/e2e-lint-selftest.mjs` re-introduces each forbidden thing. Either way the lint must go red. **A rule added to one of these lints without its self-test case is not enforced** — it is a claim that something is checked. They share plumbing, and only that, through `scripts/lint-kit.mjs`. `scripts/installer-verify-test.mjs` (also `pnpm lint:deploy`) runs the installer shell helpers lint can only read, extracted from the shipped files. `pnpm test` also runs `scripts/clamp-issue-body-selftest.mjs`, the test for `scripts/clamp-issue-body.mjs` (the helper the audit workflows use to keep an issue body postable); it lives at the repo root because its callers do. diff --git a/README.md b/README.md index cc885e0f..690c0dc5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A multitasking terminal for VS Code and the desktop — a real tiling layout, tm - [CLI reference](https://dormouse.sh/docs/dor) — every `dor` command - [Agent skill](https://dormouse.sh/docs/agent-skill) — the operating guide Dormouse bundles for coding agents - [Self-host](https://dormouse.sh/docs/self-host) — run the coordinating server on your own tailnet +- [Security](https://dormouse.sh/docs/security) — what Dormouse guarantees, what it does not, and how that is checked ## Features diff --git a/SECURITY.md b/SECURITY.md index d49d3d98..e90a8ea0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,347 +1,25 @@ -# Security - -> **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. 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 - -Dormouse Pocket lets a phone attach to a terminal running on the user's laptop, so the pairing stack is the one part of the product that takes input from the network. An authorized Client is deliberately equivalent to a person sitting at that laptop's keyboard — `terminal.write` is raw keystroke injection into a live PTY, and protocol-v1 has no notion of a restricted session. The entire trust model therefore exists to make *authorized* hard to reach, and impossible to reach by accident. - -The design lives in [`docs/specs/remote-security-model.md`](docs/specs/remote-security-model.md), the server runtime in [`docs/specs/server.md`](docs/specs/server.md), and the self-host deployment — operator runbook and installer contract both — in [`SELF_HOST.md`](SELF_HOST.md). This section does not restate them: it names the properties that are load-bearing enough to audit, and the risks we have accepted rather than closed. Two deployment modes are defined (`docs/specs/remote-api.md` → "Transport"); everything below is **self-hosted**, the only one that ships today. Cloud-hosted is [staged](#cloud-hosted-mode-staged). - -### Trust boundary - -Five layers, none sufficient alone: a Noise IK channel gives confidentiality and proves both endpoints' long-term keys, a passkey proves fresh user presence *inside* that channel, a non-extractable per-Host X25519 static is long-lived Client identity, the Host's local ACL authorizes the *pair* of the passkey and that static on one record, and the Host makes the final access decision. A deployment can raise the presence layer to *user verification* — biometric or PIN rather than a touch — with `DORMOUSE_REQUIRE_USER_VERIFICATION=true`; the flag is mirrored to every Host in its enrollment response, because the Host is the final authority and a Server demanding UV while the Host did not would leave the weaker verifier deciding. - -**There is exactly one channel and no other path.** One suite (`Noise_IK_25519_ChaChaPoly_SHA256`) carries both ceremonies, protocol-v1, and the terminal stream; there is no negotiation, no cipher or pattern selector, no plaintext relay route, and no reader for any of the pre-cutover frames. That is not merely current practice — `scripts/e2e-lint.mjs` refuses each of those textually on every `pnpm test`, and `scripts/e2e-lint-selftest.mjs` proves the refusals load-bearing by re-introducing each forbidden thing and requiring the lint to go red. What each compromise actually buys: - -- **Server compromise** — account state, routing metadata, and **no new authorization and no plaintext**. A forged account, a forged presence stamp, and an injected ceremony frame all arrive in front of a Host that decrypts the request itself, recomputes the WebAuthn challenge from its *own* transcript, and checks its own ACL under the `ConnectionPolicy` recorded at enrollment. It cannot make the Host trust a Client the user never approved. - - What it buys on an *established* session is availability only: it can drop, delay, reorder, or refuse frames. It cannot read them, and it cannot inject one — every frame is authenticated under a `CipherState` from a handshake the Server does not hold a key for, and the first invalid ciphertext destroys the session rather than resynchronizing. Web Push is no exception **for confidentiality**: the Host seals every notification to the recipient's own static and the Server forwards ciphertext it holds no key for. It is one for freshness — a push carries no counter by design, so a Server that kept an envelope can re-deliver it later and the phone shows it as current. That is accepted residual, not a gap the seal closes. -- **Setup-password compromise** — Host credentials, and account takeover only through them. The password buys exactly one endpoint, `/api/host/enroll`; it registers no passkey, because `/api/setup/*` takes a Host-minted setup token and nothing else. What it therefore buys is a `hostToken`, which is the next bullet's edge: an enrolled Host mints setup tokens, so a password compromise reaches an owner passkey by way of one, not directly. `/api/host/enroll` accepts one other credential — the installer's one-time enrollment offer, below: owner-only *at rest*, which is the whole of what the file mode protects. The endpoint checks possession over HTTPS, not local identity, so a token that leaks off the machine redeems remotely; being single-use, expiring after 24 hours, and permanently disabled by the first Host enrollment is what bounds that. Still **no Host access**: reaching an already-enrolled Host requires a pairing a human confirms on that laptop. -- **Host-token compromise** — the Host's own relay traffic, and, transitively, **account takeover**: a `hostToken` mints setup tokens at `/api/host/setup-token`, and a setup token is the *only* thing that registers an owner passkey. This is a deliberate edge — the QR *is* the credential, so whatever can mint one can be set up by one — bounded three ways: each token is single-use and dies 5 minutes after minting; revoking the Host (deleting its row from `hosts.json`) stops the minting immediately *and* kills its already-minted tokens, which are re-checked against the store at both setup gates; and a signed-in phone retires an unused token at `/api/setup/retire` so a photographed code cannot register a passkey afterwards. Still **no Host access**, and now for a structural reason: pairing runs Noise IK against an **invitation keypair the Host generated locally and never sent anywhere**, so a setup token minted over a stolen `hostToken` buys sign-in and nothing else. The Host has no invitation to match it against. -- **A synced or stolen passkey** — sign-in, and the ability to *ask*. The paired Client static is missing, so `HostAcl` answers `client-not-paired` and the Client reaches nothing. -- **Client-static compromise** — requires a compromised browser or OS, or XSS in the Pocket origin. The key is usable in place but not extractable, connecting still needs a fresh passkey assertion, and the key authorizes exactly one Host. - -The property to hold on to: **the only path into a Host's ACL is a human typing, on that Host, two digits displayed on the phone that is asking**. Reading them requires holding the device — a relayed or injected request has no screen to read from — and the Host gets the comparison exactly once. The confirmation arrives as a bridge command from the Host's own webview, carrying the displayed ceremony's immutable `pairingId` and the typed digits; the service, not the webview, holds the expected code and decides whether that ceremony is still confirmable. The webview is therefore inside the trust boundary for *relaying* a confirmation, while remaining unable to choose what is authorized, to satisfy the confirmation without the phone, or to fabricate a request. The only path back out is [Revocation](#revocation-and-the-audit-trail), which is where this model is weakest. - -- FAIL IF the Host stops being the final authority: `RemoteHost.#onConnectionTransport` in `lib/src/remote/host/remote-host.ts` must consume its own challenge, verify the presence proof with `verifyPresenceProof` against a binding built from the Host's own `hostId`, connection id, challenge and handshake hash, and require one active `HostAclRecord` holding the account, the passkey credential, that key's hash, and the IK-authenticated Client static — before any session is established, and with no code path letting a Server-supplied claim stand in for any of them. -- FAIL IF local confirmation stops being the only thing that **mints** an ACL record: `HostAcl.approve` must have no caller other than `RemoteHost.#approvePairing`, that comparison must be constant-time and happen **exactly once** per ceremony, and it must be matched against the immutable `pairingId` of the request that was displayed, never against a mutable `clientId` alone. -- FAIL IF the expected two-digit code, or an invitation's private key, ever leaves the Host process. `PairingQueueItem` in `lib/src/host/remote/service-protocol.ts` carries `{ clientId, pairingId, label, requestedAt }` and nothing else; a mirrored code would make the confirmation something anything in the webview realm could satisfy, and a leaked invitation key would let a photographed QR be completed by whoever holds it. -- FAIL IF the pending-ceremony maps are unbounded. Every `e2e` frame allocates under a `clientId` the relay chooses, in **both** `RemoteHost`'s client map and the service's mirrored queue, and the only thing that removes one is a `client-gone` a hostile relay simply never sends. Pairings are capped at `MAX_PENDING_PAIRINGS` on both sides — oldest evicted first, because either can be fed independently and a cap only one honors is not a cap — connection handshakes at `MAX_PENDING_CONNECTION_HANDSHAKES`, and outstanding invitations at `MAX_TOKENS_PER_HOST`. `MAX_CLIENT_ID_LENGTH` bounds `clientId` at the frame boundary, before any map is touched, and a handshake that fails to decrypt must allocate no entry at all. Reachable by anything that can sign in — a synced or stolen passkey buys "the ability to ask", and this is what stops asking from being a denial of service. -- FAIL IF any Host bound stops being enforced by the Host itself, on its own clock, with no help from the relay. `MAX_ESTABLISHED_E2E_SESSIONS` is checked at promotion only — after the presence proof and the ACL conjunction — and a Client static replaces its own session while any other identity at the cap gets `host-busy` and evicts no other entry; a Host-global token bucket (`E2E_INIT_BURST` decaying at one per `E2E_INIT_REFILL_INTERVAL_MS`) gates the WebCrypto an accepted `init` buys, and a frame it refuses must perform no operation and allocate nothing; and one reaper over absolute timestamps — invitation expiry, pairing TTL, challenge TTL, and `ESTABLISHED_E2E_IDLE_TIMEOUT_MS`, the last refreshed only by a successfully decrypted Client→Host transport message — runs on every init, every local decision, every relay lifecycle event, and a next-expiry timer cleared on `stop()`. Those four constants live in `server-lib-common/src/security/e2e-bounds.ts`; the three in the bullet above do not, and `docs/specs/remote-security-model.md` → Host bounds tabulates which file declares each. `lib/src/remote/host/remote-host-bounds.test.ts` counts the crypto a rejected frame buys and drives every deadline off an injected clock, and `server/test/malicious-relay.test.mjs` shows a relay holding no guards of its own weakening none of the frame refusals those bounds sit behind. A bound that needs the relay to send `client-gone`, or a Server gate, is not a bound: the relay is the party this model assumes is hostile. -- FAIL IF `requireUserVerification` is reachable on one side without being mirrored to the other: the Server reads `DORMOUSE_REQUIRE_USER_VERIFICATION`, and `HostEnrollResponse` must carry it into the Host's `ConnectionPolicy`. -- FAIL IF the Host accepts an `e2e` frame it has not shape-validated itself with `isE2eServerToHostFrame`. The relay runs its own overlapping guard (`isE2eClientFrame` / `isE2eHostFrame` in `server/src/relay.ts`), and that is exactly why the Host cannot rely on it: the routing values become map keys and the ciphertext becomes WebCrypto work in the process that owns every PTY. The Client's device label must likewise be reduced with `boundedPairingLabel` before any consumer sees it — it is attacker-chosen text rendered in the one dialog the ACL rests on. -- FAIL IF a ceremony outcome stops being a fixed-size padded control message, or begins carrying which ACL half failed. Success and every denial must encrypt to the same length, and every ACL miss must answer `pairing-required`; the specific miss is logged owner-locally only. -- FAIL IF any **service→webview** message can carry `hostToken`, **or any other bearer credential the receiving realm has no route that takes** — `deliveryId` most of all, which addresses a Client's push rows and is why `PushDevicesResult` is labels only. Check the direction, not just the identifier: `RemoteHostResult`, `HostStatusEvent`, `PairingQueueEvent`, `InvitationEvent`, `SetupQrResult`, `RemoteHostConsoleStatus`, and `PushDevicesResult` in `lib/src/host/remote/service-protocol.ts` are the outbound shapes, and none may expose one. The test is whether the webview *calls* anything with the value, not whether exposing it is currently exploitable. The credentials that *do* cross outbound are the ones printed in the QR — the Server's **setup token** and the invitation's **public** half — both inside `SetupQrResult.url`, deliberately, since a QR that is never displayed sets up nothing; which is why they are minted only on request, single-use, and short-lived (the host-token-compromise bullet above). (Inbound is a different matter — `EnrollParams` carries the setup password by design: enrolling is initiated from the webview, whether from the Settings dialog or the `window.dormouseRemoteHost` console hook.) -- FAIL IF a private key agreement ever leaves WebCrypto. **X25519 stays WebCrypto-only** (`generateKey` / `deriveBits` / `importKey`) — that is what lets a Host static and a Client static exist as non-extractable `CryptoKey`s rather than as bytes in a process that owns every PTY, so a JavaScript curve (`@noble/curves`, `tweetnacl`, `libsodium`, or any other) is a downgrade even where it computes the same point. The one bundled primitive is ChaCha20-Poly1305, from an exactly-pinned `@noble/ciphers` release, because no shipping WebCrypto has an interoperable one; its two import sites and the pin's audit delta are recorded in `server-lib-common/src/security/noise.ts`'s header, rewritten by any version bump in the same commit. -- FAIL IF the Host's Noise static is ever sent to the Server, or a Host runs with halves that do not correspond. It is minted locally *before* the enrollment request and never sent in it, persisted only where `hostToken` is, and `RemoteHostService` derives the public point from the private half and compares before starting — a mismatch keeps the Host down, because starting anyway would present a changed Host identity to every paired Client rather than the corrupt state file it is. -- FAIL IF `server-lib-common/src/security/` stops being the shared implementation — the Server, the Host, and the Pocket client must verify assertions, presence challenges, handshakes, and transport framing with the same modules, so the three cannot disagree on what a valid credential is. The suite's own conformance is proven against an independent implementation's published vector (`server-lib-common/test/noise.test.mjs`), never against a value the production state machine computed, and the properties this section rests on are driven end to end by `server-lib-common/test/security-guarantees.test.mjs`. -- FAIL IF `scripts/e2e-lint.mjs` and `scripts/e2e-lint-selftest.mjs` stop running in the root `pnpm test`, or if a rule is added to the lint without the self-test proving it load-bearing. The lint is what makes "one suite, no negotiation, no plaintext path, no legacy discriminant" a build failure rather than a reading; the self-test is what keeps a rule from passing for the wrong reason, which is a textual lint's characteristic failure. Each rule in `RULES` names the line above that it enforces. - -### Where a Host may reach a relay server - -The baked relay-origin allowlist is what stops a Dormouse install from enrolling against, or connecting to, a relay the build was never pointed at. It is a build-time constant (`DORMOUSE_REMOTE_CONNECT_SRC`) compiled into the Node bundle that holds the socket — the Tauri sidecar and the VS Code extension host — and enforced by `originAllowedByConnectSrc` at two points: `enroll` refuses an outside origin *before the setup password leaves the machine*, and a Host refuses to start from a persisted enrollment naming one. Full semantics are in `docs/specs/server.md` → "Where a Host may reach a relay server (self-host builds)". - -Three properties carry the weight. The shipped default admits the SaaS origin only — no localhost, no plaintext scheme, and not the bare apex domain — so widening it is a per-build opt-in a self-hoster makes deliberately. The build asserts the define actually reached the bundle, because a lost esbuild define compiles green and shows up only as a Host silently using the shipped default instead of the selfhoster's origins. And the value is duplicated (a `.mjs` build script cannot import TypeScript), so the two copies must stay identical. - -- FAIL IF `DEFAULT_REMOTE_CONNECT_SRC` is not exactly `https://*.dormouse.sh wss://*.dormouse.sh` in **both** `scripts/csp-defaults.mjs` and `lib/src/host/remote/connect-src.ts`, or if `CONNECT_SRC_SOURCE_PATTERN` differs between them. Widening the default — a localhost entry, an `http`/`ws` scheme, a bare `*`, or the apex `dormouse.sh` — is a change to what every shipped binary will talk to. -- FAIL IF `assertConnectSrcBaked` is no longer called on the built bundle by both `standalone/scripts/build-sidecar-proxy.mjs` and `vscode-ext/scripts/esbuild.mjs` — including the **watch** branch of the VS Code script, which is the build people iterate in and therefore where a lost define most plausibly survives — or if `resolveRemoteConnectSrc` stops rejecting an override the runtime matcher cannot parse. `resolveRemoteConnectSrc` validates with the build script's *copy* of the grammar, so this bullet is only as strong as the previous one's requirement that the two copies stay identical; `lib/src/host/remote/connect-src.test.ts` is what pins them. -- FAIL IF `originAllowedByConnectSrc` stops gating both `enroll` and Host start-up in `lib/src/host/remote/service.ts`, or fails open on an unparseable origin or source. -- FAIL IF the enrollment exchange in `lib/src/remote/host/enrollment.ts` or the shared `hostFetch` in `lib/src/remote/host/host-fetch.ts` — the transport behind both push delivery and the setup-token mint — drops `redirect: 'error'`. A Node process does not re-check a redirect target the way a browser re-applies CSP, so a followed redirect could carry the setup password or the `hostToken` outside the allowlist. Any new Host→Server call goes through `hostFetch` for that reason. - -### Credentials at rest - -Five credentials outlive a process, and each one is a full bypass of some layer if it leaks to another local account. The Protection column states the *property* — reachable only by the owning user account — and then how each platform achieves it, because the two shipped self-host deployments achieve it differently: modes on macOS, an owner-only DACL on Windows, where Node's file modes are a silent no-op. - -| Credential | Where it lives | Protection | -| --- | --- | --- | -| Setup password | `config/server.env` in the install root | owner-only (mode `0600`; on Windows a DACL with one ACE, applied before the secret is written), generated locally, never printed by a routine install and never in the service definition — the LaunchAgent plist or the Scheduled Task XML | -| Enrollment offer | `run/enroll-offer.json` in the install root, under an owner-only `run/` | owner-only (mode `0600`; on Windows a DACL with one ACE — both applied before the token is written), one-time (`docs/specs/server.md` → Configuration), never printed — the service definition and the wrapper carry only its path | -| `hostToken` (the `/ws/host` bearer) and the Host's Noise static private key | server `hosts.json` (the token only); Host side both in the enrollment record — the Noise static is minted locally at enrollment and never sent to the server (`docs/specs/remote-security-model.md`) | server state dir + every file owner-only (`0700`/`0600`; on Windows the mode is a no-op and the files inherit the installer's DACL on `state`, which is why `manage verify` checks them individually); Host side a `0600` file in standalone (on Windows the mode is a no-op; the file is covered by the owner-only DACL the Rust side applies to the app-data dir, inherited when newly written and propagated onto one an earlier version left behind), `SecretStorage` (the OS keychain) in VS Code — never a webview realm | -| VAPID private key | server `vapid.json` | same owner-only treatment, by the same per-platform means | -| Session snapshots | `sessions/` under the standalone app-data dir | owner-only, best-effort: `0700` dir + `0600` file on unix, an owner-only DACL on Windows. These are `PersistedWindow` blobs carrying terminal **transcripts** — whatever the user's shells printed, which is a superset of every other secret here. They inherited the umask as `0644` until this was tightened | -| Host ACL | `HostStateStore`, keyed per `hostId` | a `0600` file in standalone; VS Code `globalState`. Mostly public keys, with one exception: each record's `deliveryId` is a bearer capability for that Client's push rows, so a reader could delete or hijack a subscription — not reach a terminal. Neither store provides *integrity* against a process running as the same user, and nothing here claims otherwise. What the mode buys is that another local **account** cannot add a record; a same-user compromise already reads the terminals. Deliberately never on the Server | - -Without explicit modes these files inherit the umask and end up world-readable, which hands live host tokens to any other local account on a shared machine. The Client's per-Host statics are the exception that needs no file protection: they are non-extractable `CryptoKey`s in IndexedDB and are never exported. - -- FAIL IF `server/src/state.ts` stops creating `$DORMOUSE_STATE_DIR` mode `0o700`, or stops writing every file through `writeAtomic` at mode `0o600`. The "every file" clause is a negative search over `server/src/`: no `writeFile`, `appendFile`, or `createWriteStream` may target the state directory outside `writeAtomic`. This is a cheap default rather than a cross-platform guarantee, and the difference is worth stating so nothing gets built on top of it. It earns its place on a **multi-user unix host**, where home-directory permissions vary by distro — `0700` on RHEL, `0755` historically on Debian, `0750` on Ubuntu since 21.04 — so without an explicit mode, whether a second account can read `hosts.json` depends on which distro the selfhoster happened to pick. It buys nothing on Windows, where modes are a no-op and the profile ACL already excludes other accounts; nothing in a container, where the namespace is the boundary; and nothing on a serverless deployment backed by a database, where this file never runs. What actually protects the installed self-host server's state is the installer's directory permissions, below. -- FAIL IF `write_session_to` in `standalone/src-tauri/src/lib.rs` stops restricting the `sessions/` directory and the snapshot to the owning user on **every** platform `restrict_to_owner` has an arm for — `0700`/`0600` on unix, and on Windows a DACL protected from inheritance carrying exactly one ACE for the current user, asserted by `restrict_to_owner_leaves_one_owner_only_ace`. Windows is not optional here and the mode is not the control there: a Unix mode is a silent no-op on that platform, so without the ACL the directory keeps whatever `%LOCALAPPDATA%` hands down, which is never owner-only — always SYSTEM and Administrators, plus whatever stale entries earlier installs left behind. These snapshots are terminal transcripts, the largest secret in this table. The mode is applied to the temp file *before* any bytes are written, because the atomic rename preserves it — tightening after the rename would leave a window where the transcript is world-readable. -- FAIL IF `FileHostStateStore` (`lib/src/host/remote/host-state-store.ts`) stops creating its directory `0o700` and writing `0o600` on non-Windows platforms, or if `VsCodeHostStateStore` stops keeping the **enrollment** in `SecretStorage`. On Windows those modes are no-ops and Node cannot set an ACL, so the guarantee is held one layer down: FAIL IF `remote_host_state_dir` in `standalone/src-tauri/src/lib.rs` stops calling `restrict_to_owner` on the state directory **before** spawning the sidecar. That call carries both legs: a newly written enrollment file *inherits* the owner-only entry, and one a prior version already left there under the `%LOCALAPPDATA%` ACL — with a live `hostToken` in it — has that entry *propagated* onto it, which is the half `restrict_to_owner_leaves_one_owner_only_ace` covers with its pre-existing `before.json`. The ACL's home in `globalState` is deliberate and is not a finding; the enrollment's is what carries `hostToken`. -- FAIL IF any installer stops generating the setup password locally from at least 32 bytes of a cryptographic RNG — `/dev/urandom` in `deploy/local/install-macos.sh`, `RandomNumberGenerator` in `deploy/local/install-windows.ps1`, and the staged release runtime's `crypto.randomBytes(32)` (OpenSSL `RAND_bytes`) in `deploy/local/install-linux.sh` — one named CSPRNG per installer, never `$RANDOM`, a timestamp, or any other non-CSPRNG source. All three length guards are in hex characters, so each must require 64, not 32 — a guard reading `-ge 32` passes a regression to half the entropy. -- FAIL IF any installer stops making `config/`, `state/`, and `config/server.env` reachable only by the installing user — stated as the effective property, because that is what `manage verify` tests and what the threat model needs: no principal other than that user may appear in the effective permissions. macOS and Linux achieve it with `0700`/`0600` under `umask 077`; Windows with a DACL protected from inheritance carrying exactly one ACE, which is how `Protect-Path` does it today but is not itself the invariant — a path that inherits that single ACE from an already-locked parent satisfies the property, and `Test-OwnerOnly` deliberately accepts it. The Windows and Linux installers must also create `server.env` and lock it — the DACL on Windows, `chmod 0600` on an empty file on Linux — *before* the password is written, because a secret written first would sit under the inherited `%LOCALAPPDATA%` ACL, or the directory's default mode, for a window. On unix the mode is not sufficient on its own — the effective property is mode **and** owner, since a `0700` directory owned by another principal satisfies the mode and inverts the property — so `manage verify` must assert both legs on `config/`, `state/`, `run/`, `config/server.env` and — while it is there — the enrollment offer. Linux does. macOS checks the modes only and not yet the owner, on every one of those paths, and Windows has the parallel gap from the other side: `Test-OwnerOnly` reads the DACL and never `$acl.Owner`, so a path another principal owns passes while that owner keeps implicit `WRITE_DAC` over it. Both are known gaps in those installers rather than accepted limits. `manage verify` additionally walks the files inside `state/` on Windows, since `server/src/state.ts`'s `0o600` is a no-op there and they are covered by what they inherit from the directory rather than by a mode of their own; an enumeration that fails fails verify, because that walk is the only thing holding the property there and a directory it could not read otherwise reported as one with no account in it yet. -- FAIL IF any installer stops preserving an existing `config/server.env` byte-for-byte across an update, or begins printing the setup password outside the explicit `manage show-password` path. A file that exists is not necessarily one an install finished writing, so each installer names the installer-owned keys a preserved file lacks and stops — nothing is rewritten or regenerated over it, because a half-written file and a hand-edited one are indistinguishable and their repairs are opposite: `rm` for the first, and never for the second, whose `DORMOUSE_ORIGIN` is durable WebAuthn identity. Before this check the bind-host guard told the operator to *fix* a zero-byte file, on every run, forever. -- FAIL IF any installer mints the enrollment offer's token from anything but the same named CSPRNG that installer uses for the setup password, or drops its length guard — 64 hex characters, counted the same way and for the same reason. The offer redeems for a Host enrollment, so its entropy is the password's entropy. -- FAIL IF the offer's publication file, **or `run/` itself**, is reachable by any principal other than the installing user, or becomes so only *after* the token is written. Each installer creates an owner-only temporary file inside `run/`, writes the complete offer, then atomically renames it over the well-known path; redemption must see one complete generation or the other, never a truncate/chmod/write window. The directory governs who may replace or delete the credential, so it is `0700` (a single-ACE DACL on Windows), alongside `config/` and `state/`, and `manage verify` asserts it. -- FAIL IF any installer prints the offer's token, or writes it anywhere but that owner-only same-directory publication file. Unlike the setup password there is no `manage show-password` counterpart — the reader is a Host process, not a human — so the token has no legitimate path to a terminal. -- FAIL IF any installer writes the offer anywhere but `/run/`, stops re-minting it on runs before the first Host enrollment, mints one after `state/hosts.json` exists, or mints it before the switched release, HTTPS Serve mapping, and pruning have succeeded. `hosts.json` is the durable "bootstrap completed" marker even when every row is later removed; the Server serializes that first-enrollment decision with the Host-store write and consumes the offer when either credential path wins. A credential that expires in 24 hours and is unlinked on redemption belongs in neither `config/` nor `state/`. -- FAIL IF `manage verify` stops failing when the service definition contains `DORMOUSE_SETUP_PASSWORD` — the LaunchAgent plist on macOS, the exported Scheduled Task XML on Windows, the systemd unit file on Linux. A service definition is world-readable on all three platforms; the credential belongs only in `config/server.env`. It must fail the same way when the definition could not be read at all — a missing plist or unit file, or an `Export-ScheduledTask` returning `$null` because CIM was blocked or the task vanished — since a search through nothing finds nothing. - -### The setup password — accepted risk - -One password bootstraps everything the Server can grant. It enrolls Hosts, and that is its only endpoint — but an enrolled Host mints setup tokens, and a setup token is what registers an owner passkey, so the account is one step behind it rather than beside it. Its hardening is deliberately minimal and should be read as accepted, not overlooked. The comparison is constant-time over SHA-256 digests and a failure costs a fixed 250 ms, and that is the whole of it — there is no rate limit, no lockout, no attempt counter, and no expiry or rotation after setup completes. `/api/*` also carries `cors({ origin: '*' })`, so any web page open in any browser on the tailnet can drive those routes and read the responses; that is safe from CSRF (there are no cookies, every credential is a header or a body field) but it does mean the guessing surface is not limited to something reachable only by a deliberate client. - -We accept this because the origin is tailnet-only, the password is 32 bytes of `/dev/urandom` written by the installer rather than chosen by a human, and the layer it protects still cannot reach a Host without local approval. Two consequences worth stating plainly: **the tailnet is doing real work here**, and a self-host origin that becomes internet-reachable is a materially different risk than the one analyzed. - -- FAIL IF the setup password comparison stops being constant-time or loses its fixed failure delay. Both halves are named because they live apart: `secretEquals` in `server/src/secrets.ts` compares SHA-256 digests with `timingSafeEqual`, and `CREDENTIAL_FAILURE_DELAY_MS` in `server/src/app.ts` is the fixed 250 ms every rejected credential costs. -- FAIL IF the permissive CORS policy is widened beyond `/api/*`, or if any endpoint begins accepting credentials via cookies — the "no cookies exist for a foreign origin to ride on" argument is the whole basis for `origin: '*'` being acceptable. - -### Network posture (self-hosted) - -`scripts/deploy-lint.mjs` (`pnpm test`) makes the cheap half of this section and of "Credentials at rest" deterministic: every installer must still contain the control each `FAIL IF` names, so a control deleted from one of the three fails a build instead of waiting for the next audit. It is textual and cannot tell whether a control is *correct*, only that it is still there — the audit still owns that, and on Windows, which nothing in CI can execute, this lint is the only automated signal at all. `scripts/deploy-lint-selftest.mjs` runs beside it and keeps it honest: it deletes each matched control in turn and requires the lint to fail, because a textual rule's characteristic failure is passing for the wrong reason — review of the first version found three rules satisfied by an unrelated occurrence, one of them the entropy guard's own explanatory comment. - -The shipped self-host deployment is a per-login user agent bound to loopback — a macOS LaunchAgent, a Windows Scheduled Task, or a Linux systemd user service — with `tailscale serve` terminating HTTPS on the node's own MagicDNS name. Two invariants follow from that shape, and they are the same on all three. The server always speaks plain HTTP, so the listen interface *is* a security boundary when the TLS proxy is local: leaving the socket unbound would publish the plaintext port to the LAN and to the tailnet itself, which is why the install pins `DORMOUSE_BIND_HOST=127.0.0.1` and refuses to proceed without it. And `DORMOUSE_ORIGIN` is durable WebAuthn identity — rewriting it silently invalidates the registered passkey and every enrolled Host, so the installer stops rather than rewriting a mismatch. - -Tailscale here is network-layer defense-in-depth *under* the passkey/ACL model, never a substitute for it — but the analysis above does lean on the origin being tailnet-only. `tailscale serve` and `tailscale funnel` share one configuration surface, and a Funnel on this node publishes the same origin to the public internet, where the setup password becomes an internet-facing guessing target with none of the mitigations above. - -- FAIL IF `deploy/local/install-macos.sh`, `deploy/local/install-windows.ps1`, or `deploy/local/install-linux.sh` stops requiring `DORMOUSE_BIND_HOST=127.0.0.1` in `config/server.env`, or if any `manage verify` stops asserting that the plaintext port is unreachable on the node's Tailscale IP. -- FAIL IF the unset default of `DORMOUSE_BIND_HOST` in `server/src/config.ts` stops being `undefined` (listen on every interface — what a container wants, where the namespace is the boundary), or `server/test/bind-host.test.mjs` stops spawning the real entrypoint to prove the plaintext port is unreachable off-loopback when it *is* set. -- FAIL IF any installer stops refusing to rewrite a `DORMOUSE_ORIGIN` that no longer matches the node's DNS name. -- FAIL IF any installer stops refusing to run with elevated privileges — `id -u` on macOS and Linux, the `Administrator` role check on Windows. The install belongs to one user account and its whole credential posture is that account owning the files; an elevated run would write them owned by another principal and register the service for it. -- FAIL IF `manage verify` does not fail on Funnel being on for this node. It matches `funnel on` across `tailscale serve status` and `tailscale funnel status`; that is node-scoped, not scoped to the served origin, and is deliberately the blunter test — any Funnel on the node that fronts this server is a thing to look at, and parsing a mapping out of CLI prose would fail open the day the wording changes. -- FAIL IF that check reports `off` when it could not run. A Tailscale CLI that is absent, unauthenticated, or too old for `funnel status` produces output that matches nothing, which is indistinguishable from a node with no Funnel until the exit status is consulted — so a nonzero status is its own verdict and fails verify, rather than being discarded with `2>/dev/null || true`. A check that could not run has not passed. -- FAIL IF any decision taken on Tailscale CLI or listener output is reached by piping that output into `grep -q` — or into a `head -1` that exits first. The installers and `manage` run under `set -o pipefail`, `grep -q` exits at the first match, and the writer's SIGPIPE makes the pipeline 141, which an `if` reads as "no match" and an assignment turns into an abort. Past the pipe buffer that reported a live Funnel as off, an off-loopback bind as loopback-only, and — the one that mutates rather than reports — a `serve status` carrying a foreign root mapping as no conflict at all, so the `confirm` guarding the operator's existing Serve config never ran. Every such search is over text captured first. Every decision about whether Serve maps `/` to us — the install-time conflict gate, `manage verify`, and the uninstall that turns Serve off — is additionally scoped to the root line with the port right-bounded: `/api` on this port is not `/` on it, and `127.0.0.1:31000` contains `127.0.0.1:3100`, either of which skipped the conflict `confirm`, green-ticked `manage verify` on an origin serving someone else at `/`, or made uninstall reset a root mapping this install never owned. The post-mutation `SERVE_AFTER` assertion is the one deliberate exception, since it asserts our own `serve --bg` landed rather than auditing a foreign config. Enforcement splits by control, not by helper, and only `scripts/installer-verify-test.mjs` runs installer code: it extracts `funnel_state`, `has_off_loopback` and `serve_state` and drives them over inputs larger than that buffer — reverting any of the three to a pipe goes red there — and pins `serve_proxies_root`'s root scoping and its port bound the same way, though not its `<<<`, which only `scripts/deploy-lint.mjs`'s pattern holds; that lint also counts the decisions that consult these helpers, since a helper whose answer is right survives a caller that stops asking. `serve_root_target` is held by neither on purpose: a `| head -1` in there raises 141 that nothing propagates, because `printf` runs last and its one caller is a `$( )`, which bash carries no `errexit` into without `inherit_errexit` — absent from bash 3.2 — so the parameter expansion is hygiene rather than a control. Neither fact is a property of being in a helper: the `head -1` half of this rule binds every site whose 141 can still reach an `if` or an assignment, which is an inline substitution always, and a helper the moment the failing assignment is its last command or a caller invokes it outside `$( )`. - -### What crosses the boundary - -The relay is a dumb ciphertext pipe: it routes `e2e` envelopes within one Client↔Host binding and decodes nothing. Both directions carry untrusted bytes once a Host has decrypted them. Inbound, `terminal.write` is keystrokes into a real shell — the ACL is the entire gate, which is what makes the approval modal load-bearing. Outbound, terminal bytes reach a phone, and notification text originates in a renderer and is Pane-derived, so it is bounded and sanitized on the Host **before it is sealed** and **re-sanitized in the Pocket service worker** at the render sink; both run the same `boundedPushText`, so the two layers cannot enforce different rules. The Server is no longer one of them — it cannot sanitize what it cannot read. - -Web Push is the one path where the Server makes an outbound request to an address a Client supplied, which on a server that sits *inside* a tailnet is a live SSRF concern: `100.64/10` is exactly the range a push endpoint must not be allowed to reach. Registration rejects credentials, localhost, and non-public IP literals, and delivery goes through a dedicated agent whose connection-time DNS lookup rejects loopback, private, CGNAT, link-local, documentation, benchmark, multicast, reserved, IPv4-mapped, unique-local, and site-local ranges — rejecting a hostname wholesale if *any* answer is blocked, and handing the socket the exact address it checked so rebinding cannot create a second unchecked resolution. - -- FAIL IF `server/src/push-endpoint.ts` stops rejecting non-public push endpoints at registration, stops applying `createPublicLookup`/`createPublicPushAgent` to delivery, or stops rejecting a hostname whose DNS answers are mixed public and blocked. -- FAIL IF `/api/push/send` stops taking the `hostId` from the Host's own token, begins selecting recipients when `recipients` is absent or empty, stops clamping them at `MAX_PUSH_QUERY_DELIVERY_IDS`, or if any read endpoint begins reporting on a delivery id the caller did not present. Possession of the 256-bit `deliveryId` is the whole authorization for the Client-facing push routes, so the Server must never *list* one to a session. -- FAIL IF the send route reads, rewrites, or logs notification text, or forwards anything but the sealed envelope plus the token's own `hostId`. The Server holds no key for it (`docs/specs/remote-security-model.md` -> Push sealing), so a route that could read a payload is one that was handed plaintext. The envelope's three fields must be copied individually rather than spread, since a spread would let a sending Host override its own token's `hostId`. -- FAIL IF a push stops being sealed per recipient, to that ACL record's own Client static, under a fresh salt. `sealPush` / `openPush` (`server-lib-common/src/security/push-seal.ts`, proven by `server-lib-common/test/push-seal.test.mjs`) derive a one-message key from the two pinned statics and spend the all-zero nonce exactly once per key; a Noise `CipherState`, a shared group key, or a reused salt each break that. `RemoteHost.sealPushForClient` hands `lib/src/remote/host/push-delivery.ts` a seal *capability* and never the Host's private key, and the worker in `lib/src/remote/pocket-app/sw.ts` is the only thing that opens one. -- FAIL IF push text stops being bounded with the shared `boundedPushText` on the Host before sealing, or re-bounded with it in `lib/src/remote/pocket-app/sw.ts` before `showNotification`. The worker is the sanitization sink now: the Server used to be a second pair of eyes and cannot be one on ciphertext, so a worker that renders what it decrypted without re-bounding it leaves the property with one enforcer instead of two. -- FAIL IF the relay routes a Host-originated frame from a socket that is not the Client's current Host binding, or begins decoding, remembering, or acting on an `e2e` ciphertext. `server/src/relay.ts` must route the `e2e` envelope and nothing else: it holds no gate, no challenge memory, and no notion of an authorized session, because only the Host knows whether a ceremony succeeded. A Server-side type import from the protocol-v1 half of `server-lib-common/src/remote/wire.ts` is the leading indicator and fails the same way — routing an opaque envelope needs no notion of what a `DirectoryEntry` is. - -### Revocation and the audit trail - -These are the two real gaps in the shipped model, and they are gaps rather than accepted risks — we intend to close them. - -**Revocation has no mechanism.** `HostAcl.revokeClient` / `revokePasskey` exist and have no callers; no relay frame carries a revocation; there is no management UI. Revoking a lost phone means hand-editing JSON on the Host **and restarting it**: `RemoteHostService.#startHost` reads the store once and hands the `RemoteHost` a snapshot for its whole lifetime, so an edit alone changes nothing that is running. The restart is the whole lever — it reloads the ACL and, by dropping the relay socket, ends every established session. Server-pushed revocation propagation is staged in `docs/specs/remote-security-model.md` → Future. - -**There is no audit trail.** The ACL records `approvedAt` / `approvedBy` for a pairing, and nothing records connects, attaches, denials, or writes. A self-hoster cannot answer "did anyone connect to my laptop last night", which also means an ACL entry added by any of the paths above would be invisible after the fact. - -Both are stated here rather than left in a spec's Future list because the audit's qualitative pass should not keep rediscovering them as findings, and because a reader deciding whether to run this needs to know that "revoke a device" is not currently a thing they can do quickly. - -### Accepted limitations - -Restated from `docs/specs/remote-security-model.md` so this document is self-contained about what is *not* defended: - -- **No defense against a compromised browser or OS**, on either end. Active XSS in the Pocket origin can *use* the non-extractable Client static without extracting it. Two endpoints are trusted by construction: the distributed Host binaries, and the exact Pocket artifact the origin serves — an operator who serves modified Pocket code is outside this model, not defended against by it. -- **No traffic-analysis resistance.** The Server still sees who talks to whom, when, how often, and how large each ciphertext is; without batching or cover traffic, Client→Host timing exposes inter-keystroke timing. Keystroke *values* stay encrypted. Sealing changed the contents and none of that. -- **A push proves confidentiality, not freshness.** Nothing binds a sealed envelope to a moment and the worker keeps no replay memory, so a Server that kept one can re-deliver it later and the phone shows it as current. Accepted, because the alternative is per-Client replay state in a worker that may run once a week. -- **One push endpoint per browser correlates a phone's Hosts.** A service-worker scope holds one `PushSubscription`, so the Server can see the whole set of Hosts one Pocket profile registered — which is the one thing the per-Host ACL design otherwise keeps apart. Per-Host endpoints would need a service worker per Host. -- **Two Pocket properties are verified on real hardware only**, and both are load-bearing: an X25519 `CryptoKey` surviving a structured clone into IndexedDB, and `getUserMedia` working inside an iOS Home Screen web app. No CI runner observes either; `docs/specs/remote-security-model.md` → Future, Device verification tracks them. -- **Client-static durability is best-effort.** Clearing site data destroys the key and forces re-pairing; on iOS a browser tab may be evicted after inactivity. This is recoverable, not catastrophic — a lost key authorized nothing on its own. -- **Availability is not a goal of the self-hosted deployment.** A LaunchAgent is a per-login agent, so the relay is down while the Mac sleeps, is shut off, or has no logged-in user. - -### Cloud-hosted mode (staged) - -Nothing in this subsection is implemented; it exists so the boundary is stated before the code arrives. When Dormouse operates the coordinating Server, the "Server compromise buys no Host access" property is unchanged — that is the point of putting the ACL on the Host — but two things above change character and must be re-analyzed here rather than inherited: - -- **We become the operator** of the relay. The end-to-end protocol already keeps ceremony, terminal, remote-api, and notification content out of that operator's reach; what stays visible is exactly the metadata in Accepted limitations, and an independent cryptographic review of the Noise integration, the WebAuthn channel binding, key storage, and the push construction is a precondition of claiming this model for a paid service. -- **The tailnet stops carrying load.** Every argument above that leans on "the origin is reachable only from the user's tailnet" — the setup password's minimal hardening most of all — has no cloud equivalent, and the multi-tenant account model replaces the single-owner setup password entirely (`docs/specs/server.md` → Future, Scope: saas-multitenant). - -- 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 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. - -A third question has **no request-header answer at all**, and getting that wrong is how the iframe proxy once handed a stranger two privileges: **who is allowed to frame me?** An iframe navigation carries no `Origin`, and `Sec-Fetch-Site` reads `cross-site` for our own webview and for an attacker page alike — so a response that confers something on its *embedder* has to name that embedder and let the browser enforce it. That is what `frame-ancestors` is for, and the webview supplies its own ancestor chain (not merely its origin — the check walks the whole chain) with each request for a proxy 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, names its embedder* 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 only in the bind forms it knows: `.listen` positional and options-object, `@hono/node-server`'s `serve({ hostname })`, and `ws`'s `new WebSocketServer({ port | host })`, each one proved load-bearing by `scripts/loopback-lint-selftest.mjs`. **Adding a server dependency means adding its bind spelling there**, because a library nobody has used yet spells its bind some way that file has never seen — and a host built at runtime is invisible to a regex in any spelling. It also only sees 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`, `.listen(`, `serve(` and `WebSocketServer` 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 iframe proxy **drops** an upstream's `X-Frame-Options` / CSP `frame-ancestors` without **replacing** them with a `frame-ancestors` naming the embedder chain the webview supplied, or injects its shim with any `postMessage` target but that chain's own origin. Dropping them for everyone gave a page that scanned the port two things the upstream had refused it — framing a document that answered `DENY`, and reading that document's live URL and anchor hrefs back cross-origin — and no request header can tell that page apart from Dormouse's webview. With no usable chain the proxy must strip nothing and inject nothing, which leaves the caller exactly what the upstream would have given it directly. Also FAIL IF a request bearing a *foreign* `Origin` refreshes 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 *absent* `Origin` must 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 `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). - -Every dependency Dormouse **puts on a user's machine** is listed at . That is the test, and it is narrower than "everything a user runs" for a reason given below. This includes: - -- every npm dependency (direct and transitive) -- every cargo dependency (direct is listed separately from transitive) -- the Node.js runtime bundled as a Tauri sidecar in the standalone app - -The roots of that graph are the `productDependencyFilters` in `website/scripts/generate-deps.js`. A workspace package is a root if Dormouse writes its files onto a user's disk, whatever the route: `dormouse-standalone` and `dormouse` (the VS Code extension) are installed, `dormouse-sidecar` rides along inside the Tauri bundle as a `bundle.resources` tree with its `node_modules` intact, `dor` is staged onto every terminal's `PATH`, and `server` is built and installed by a selfhoster ([`SELF_HOST.md`](SELF_HOST.md)) — `web-push` most of all, which signs with a private key and makes outbound requests. `dormouse-lib` is a root in its own right rather than a workspace edge, and the reason is worth stating: `vscode-ext` declares only `node-pty` and `ws`, reaching the lib through relative imports into `../lib/src/` from fifteen files, so the extension's dependency walk never arrives at it. Only `dormouse-standalone`'s edge would — which puts the disclosure of lib's entire subtree one refactor away from silently vanishing. Naming it a root is what makes that not matter. `server-lib-common` and `dor-lib-common` are reached as workspace edges from those roots. Note the roots are **package names**, not directory names, and the two differ once: `vscode-ext/` declares itself `dormouse`. - -Two workspace packages are deliberately not roots. `canopy` is a Storybook-only rendering lab that no shipped build imports, and `website` runs in a visitor's browser rather than being installed anywhere — the page says as much about its own React and react-router. Excluding `website` is what makes "puts on a user's machine" the operative test rather than "a user runs", and it is a judgement worth re-making if the site ever ships something a visitor installs. - -**External binaries are outside this graph by construction.** Dormouse is a terminal: it spawns the user's shell, and `dor ab` forwards to an `agent-browser` CLI the user installs themselves (`npm i -g agent-browser` — it is not a dependency of anything here and is resolved off `PATH`). Those are the user's software, not ours, and disclosing them is neither possible nor meaningful. What this document *can* promise is that we ship nothing that pulls them in silently. - -Those dependency snapshots are generated from the lockfiles and reviewed as part of release work. If a production dependency is added, removed, or upgraded, the dependency lists must be regenerated and committed — and CI fails the PR if they were not, because until that gate existed the nightly audit was the only thing that ever ran the generator, and two prod bumps (`ws`, `hono`) shipped undisclosed before it caught them. - -**Bundled themes are disclosed outside that walk.** The VS Code themes compiled into every build (`lib/src/lib/themes/bundled.json`) come from OpenVSX extensions rather than from npm, so no lockfile walk reaches them — `website/scripts/generate-deps.js` appends the checked-in `lib/src/lib/themes/bundled-extensions.json` to the npm table instead. Both files are written by a single run of `lib/scripts/bundle-themes.mjs`, but both are committed and the script needs network access, so an edit to one can drift off the other, and regenerating the disclosure then produces no diff — which is precisely the class the gate above cannot see. `lib/src/lib/themes/bundled-extensions.test.ts` is what pins them, joining on the `extensionId` each disclosure record carries: a bundled theme whose extension has no record, or a record with no bundled theme left, fails. The join is on the extension set only — `bundled.json` carries no version or license, so nothing pins a hand-edit to those fields of a record, which are what the published table shows. - -The standalone app ships a Node.js runtime binary (`standalone/src-tauri/build.rs` copies it into the bundle as a Tauri sidecar). Its version is pinned exactly in the root `package.json` under `devEngines.runtime.version`, and the build is the authority: `build.rs` runs `--version` on the binary it is about to bundle and fails the build unless it matches the pin. On Windows the build then flips one byte of the bundled `node.exe` — the PE Optional Header's `Subsystem` field from `IMAGE_SUBSYSTEM_WINDOWS_CUI` (3) to `IMAGE_SUBSYSTEM_WINDOWS_GUI` (2) — to suppress Windows Terminal's default-terminal handoff, which would otherwise spawn a stray terminal window behind the app. The version check runs before the byte flip and the patch leaves Node.js semantics unchanged (Node reads its stdio handles from `STARTUPINFO`, which is subsystem-agnostic); the bundled `node.exe` is therefore not byte-identical to the upstream archive — it differs at exactly the documented 2-byte field. The supply-chain page reads the same pin, so the version disclosed there provably equals the runtime users receive — it cannot drift to whatever Node happened to be on the build machine's PATH. Locally, pnpm honors `devEngines` (`onFail: "download"`) so scripts run under the pinned Node; CI extracts the same field to drive `actions/setup-node`. The version is a deliberate, manual pin (no automated ecosystem tracks it); the workflows that do not bundle the runtime are free to track the same pinned major. - -- FAIL IF `node website/scripts/generate-deps.js` changes `website/src/data/dependencies-npm.json`, `website/src/data/dependencies-cargo.json`, or `website/src/data/dependencies-runtime.json` when run against a clean working tree after `pnpm install --frozen-lockfile`. The install is a precondition, not a nicety: the generator resolves every dependency by walking real `node_modules` directories and throws rather than under-reporting if they are absent — so a stale `node_modules` makes this check pass on a tree that would fail in CI. -- FAIL IF `.github/workflows/ci.yml` stops running that generator and failing on a diff. The nightly audit finding a stale disclosure means it already merged; this is the gate that keeps it from merging. The install is a precondition, not a nicety: the generator resolves every dependency by walking real `node_modules` directories and throws rather than under-reporting if they are absent. -- FAIL IF `productDependencyFilters` in `website/scripts/generate-deps.js` omits a workspace package whose files Dormouse writes onto a user's disk — today the six named above, with `canopy` and `website` excluded for the stated reasons. Derive this from `pnpm-workspace.yaml` rather than from the enumeration: a package missing from both the roots and the exclusions is exactly the failure, since regenerating cannot catch a root that was never walked. Reaching a package as a workspace *edge* from a root counts as covered; being installed as a devDependency of the repo does not, or a selfhoster's `pnpm install` would drag the whole toolchain in. -- FAIL IF the root `package.json` is missing `devEngines.runtime.version`, or its value is not an exact Node.js version (a bare major such as `24` is not acceptable; it must be `MAJOR.MINOR.PATCH`). -- FAIL IF `standalone/src-tauri/build.rs` no longer verifies that the bundled Node.js binary matches `package.json`'s `devEngines.runtime.version` (this verification is what makes the disclosed runtime version provable), or if the check is skipped for any configuration the release matrix actually builds. One skip is deliberate and permitted: `verify_node_version` cannot execute a foreign-arch binary, so it warns and returns when `host != target`. That is acceptable only while every entry in `release.yml`'s standalone matrix is host-native — adding a cross-compiled target to the matrix ships an unverified runtime and fails this check. -- FAIL IF the `build-standalone` job in `.github/workflows/release.yml` does not install the pinned runtime via `node-version-file: package.json`, **or** the root `package.json` gains a `volta.node` or `engines.node` field. `setup-node` resolves that file by precedence (`volta.node` -> `devEngines.runtime` -> `engines.node`), so the pin this document relies on is the one it reads only while the higher-precedence fields are absent — adding one would silently change the bundled runtime with no diff to the workflow. Other jobs may pin `node-version` inline since their interpreter is never bundled. -- FAIL IF `pnpm-workspace.yaml` is missing `minimumReleaseAge: 1440`. -- FAIL IF `.github/renovate.json` is missing `npm` or `cargo` from `enabledManagers` (npm covers `/`; cargo covers `/standalone/src-tauri`), or is missing `minimumReleaseAge` package rules for those managers (the Renovate equivalent of dependency cooldown windows). -- FAIL IF `.github/renovate.json` has no `vulnerabilityAlerts` block, or that block does not set `minimumReleaseAge` **explicitly**. This reads backwards and is the whole point: Renovate's built-in default for `vulnerabilityAlerts` is `minimumReleaseAge: null`, force-applied before lookup, so *omitting* the key drops the cooldown rather than inheriting it from `packageRules`. Stating it is the only way to keep it. Keeping it is deliberate — the cooldown guards the opposite threat, a compromised release yanked within a day, which a reviewer reading a dependency diff cannot detect the way the ecosystem's own yank process can. Nothing auto-merges, so what it costs is a day before the remediation PR appears, not a day before anyone knows. -- FAIL IF secret scanning or its push protection is disabled on the repository (`gh api repos/diffplug/dormouse --jq .security_and_analysis`), or Dependabot alerts are off (`GET /repos/diffplug/dormouse/vulnerability-alerts` must answer 204, not 404). Push protection is the one control that acts *before* a credential lands: it blocks a push whose diff carries a recognized provider token, and it applies to `dormouse-bot` too — which is the point, since an injected agent pasting a token into a file is exactly the shape it stops. - -## GitHub Actions Policies - -GitHub Actions are pinned by commit hash, not version tag, in every workflow this repository authors. Renovate updates the hashes as necessary. The one exception is the `tend-*.yaml` files, which are generated by an upstream tool and carry its tag pins — see "Upstream compromise" below for what that costs and why it is accepted. - -**Agent-managed workflows** are `tend-*.yaml`, `workflow-audit.yaml`, and `security-audit.yaml`. They implement the repo's automation and self-audit infrastructure, and are exempt from the two rules below because they need to modify issues, PRs, or code, or fetch an OIDC token. Their bounded scope is defined in the "Automated Maintainer" section. - -**Release audit dispatch.** The `security-audit` job in `release.yml` holds `actions: write` — the one write permission a non-agent-managed workflow is granted beyond release provenance. It uses it solely to dispatch `security-audit.yaml` on the release tag and watch the resulting run, gating the VS Code publish on the result. Dispatch is required because `claude-code-action` rejects the `push` event that a tag-triggered `workflow_call` would inherit, and `GITHUB_EVENT_NAME` is a default variable that cannot be overridden — so a `workflow_dispatch` run is the only way to exercise the audit under a supported event. Blast radius is bounded: `actions: write` lets that job's `GITHUB_TOKEN` start or cancel workflow runs in this repo, but it cannot reach env-scoped secrets, merge to `main`, or push tags, and `release.yml` only runs on admin-gated `v*` tags — so exercising it already requires an admin-gated tag push. - -- FAIL IF `pull_request_target` appears in any `.github/workflows/**` file other than `tend-*.yaml`. -- FAIL IF a non-agent-managed workflow has **effective** write permissions other than the explicitly scoped release provenance permissions `id-token: write` and `attestations: write`, or the `actions: write` granted to the `security-audit` job in `release.yml` (see "Release audit dispatch" above). Effective has the same meaning as in the agent-managed bullet below: a job that declares no `permissions:` block inherits the repository default. - -## Automated Maintainer (tend) - -This repository runs the [tend](https://github.com/max-sixty/tend) agent harness as the GitHub user `dormouse-bot`. tend reviews PRs, triages issues, fixes CI failures, regenerates its own workflow files on a nightly schedule, responds to mentions, and polls its own notification feed every fifteen minutes. The agent expands the project's attack surface. - -An attacker who lands a prompt injection in tend's harness can reach three secrets. None of them escalates directly into malicious content on the `main` branch or into any deployment-related secret — those paths stay admin-gated. The boundaries we accept are codified below. - -- `TEND_BOT_TOKEN` (worst case): full `repo` + `workflow` write access *as a trusted collaborator*. Direct uses are issue/PR spam, force-pushing or deleting feature branches, and persistent compromise by authoring new workflows (persistent compromise mitigated by [`workflow-audit.yaml`](.github/workflows/workflow-audit.yaml)). Authoring a workflow is also the mechanism by which `CHROMATIC_PROJECT_TOKEN` is reached. **It cannot itself merge to `main`, push tags, or reach env-scoped secrets, but the bot's trusted identity can be used to social-engineer an admin toward a `main` merge.** -- `CLAUDE_CODE_OAUTH_TOKEN`: bounded Anthropic API-credit abuse, capped by the bot account's spend limit. -- `CHROMATIC_PROJECT_TOKEN`: lets the attacker corrupt snapshot testing; mitigated by rotation, and any abuse is visible in Chromatic's own dashboard. - -**Prompt-injection through user-supplied content.** tend's harness reads PR descriptions, code diffs, issue text, comments, and CI logs — all attacker-influenceable surfaces. A malicious prompt could direct the harness to push a workflow that references a repo-level secret to an external URL. The bot cannot merge to `main` or push tags, so admin-gated release paths stay sealed, but a workflow on a bot-pushed feature branch will still execute with repo-level secrets in scope. - -**Instruction files are part of that surface.** `tend-review.yaml` runs on `pull_request_target` and checks out the PR merge ref, so on a fork PR the working tree the agent reads is attacker-controlled — including the files Claude Code loads as *project instructions* (`CLAUDE.md`, `AGENTS.md`, `.claude/`, `.mcp.json`). Those are not read as data the way a diff is; they are read as authoritative guidance. tend closes this by reverting those paths from the reviewed base branch before the agent starts (`shared/steps/restore-sensitive-config.sh`), so instructions come from code a maintainer merged. - -This was **reported from this audit and is now fixed**. At the previously pinned `0.1.18` the revert list was a flat, root-relative `SENSITIVE` array naming `CLAUDE.md` but no `AGENTS.md` at all — and this repo keeps its instructions in `AGENTS.md` with `CLAUDE.md` as a one-line `@AGENTS.md` pointer, so the control reverted a pointer and left the content it pointed at attacker-controlled. The fix ([max-sixty/tend#1005](https://github.com/max-sixty/tend/pull/1005), merged 2026-08-22, released in `0.1.19` on 2026-08-26) replaces that list with pathspec globs — `':(glob)**/AGENTS.md'`, `':(glob)**/CLAUDE.md'`, `':(glob)**/.claude/**'` — which `restore-sensitive-config.sh` passes to `pin_to_base`, covering every depth rather than a hand-enumerated set of root paths. This repo regenerated onto `0.1.19`, so the gap is closed here rather than merely closable. - -Two things stay true regardless of the fix. The control's completeness is a property of the *pinned upstream version*, not of anything in this repo, so a pin that moves backwards silently reopens it — hence the `FAIL IF` below. And there remains a local remedy if it ever regresses: the regen overwrites the *workflow*, not this repository's instruction files, so moving the instruction body into `CLAUDE.md` and dropping the pointer would close it without any upstream dependency, at the cost of the filename convention other agent harnesses read. - -**Credential isolation bounds an injection.** The agent runs as a separate, non-sudo sandbox user behind a local credential-injecting proxy: `TEND_BOT_TOKEN` and the Anthropic credential live only in the proxy and never enter the agent's environment, its disk, or `.git/config` (the setup strips the credential `actions/checkout` persists there). An injected instruction can therefore make the bot *act* within its permissions — comment, push a feature branch — but cannot read the token value out and exfiltrate it. The worst-case analysis above is about what the bot's identity can do, not about the secret escaping. - -**Bot collaborator authority.** `dormouse-bot` is a direct repo collaborator with `push` permission and 2FA enforced by org policy. Its PAT (`TEND_BOT_TOKEN`) carries the scopes `repo`, `workflow`, `notifications`, `write:discussion`, `gist`, and `user`. The `workflow` scope is required for the nightly regeneration of `tend-*.yaml` files; the same scope lets the harness add arbitrary new workflow files. Ref-protection rulesets restrict where bot-controlled commits can land but do not gate workflow execution on feature branches. - -**The notifications poll widens what the bot sees.** Every other generated workflow picks its own subjects: an event payload names the PR, issue, or comment (`tend-review`, `tend-triage`, `tend-mention`, `tend-ci-fix`), or a scheduled sweep works a fixed list — recent commits, dependency PRs, last night's runs. `tend-notifications` is the one whose subject list is the bot account's own unread feed, and its inlined pre-check issues `PUT /repos/diffplug/dormouse/subscription -F subscribed=true` on every `*/15` cycle to keep that feed wide. That has taken effect: `dormouse-bot` watches this repository today (`gh api repos/diffplug/dormouse/subscription` as the bot → `subscribed: true`, corroborated without the bot credential by the public `GET /repos/diffplug/dormouse/subscribers` listing), so its feed is already *all repository activity* rather than *threads the bot has participated in*. This is not a permission change — `notifications` is already in the PAT scopes above, and watching reads rather than writes — but it moves the "should the bot say something here" decision from the trigger to the prompt. The notifications skill is what says to respond on an existing thread "only when the activity addresses the bot", while a new issue or PR from an external author may be triaged or reviewed outright; a prompt is not a control, in the same sense drawn about the audit subagents under "CI Validation Contract" below. What still bounds the bot on a thread no workflow was dispatched on is the `author_association` tiering and the admin gate on `main`. Unwatching by hand does not stick, and is not meant to: the PUT is idempotent and repeated every cycle precisely so a settings change is repaired without extra state, so the lever is `tend-notifications.yaml` itself rather than the repository's Unwatch button. - -**Reachable repo-level secrets.** `CHROMATIC_PROJECT_TOKEN` is reachable by any workflow the bot can author, because `chromatic.yml` is `pull_request`-triggered and GitHub environment policies cannot distinguish a bot from a human contributor at the ref level. Chromatic project tokens are scoped to a single project, easy to rotate, and any abuse is detectable in Chromatic's own dashboard — this risk is accepted with rotation as the mitigation. `OVSX_PAT` and `VSCE_PAT` are protected: they live only in the `vscode-extension-publish` environment, whose deployment-branch-policy admits only `v*` tags, and tag creation is admin-only. - -**Inert secret plumbing.** Every generated `tend-*.yaml` passes `anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}` to `max-sixty/tend/claude`. No such secret exists at repo or org level, so today it resolves to the empty string and the harness authenticates with `CLAUDE_CODE_OAUTH_TOKEN` instead. The input is upstream-generated and cannot be removed locally without being overwritten by the next nightly regen, so the risk is handled by enforcement rather than deletion: the moment anyone adds an `ANTHROPIC_API_KEY` secret for an unrelated reason, eight bot-triggered workflows would start reading it with no code change and no review. The FAIL IF below makes that addition a deliberate, documented expansion of the bot's reach. - -**Org-level secrets.** Secrets shared with this repo from the `diffplug` org would be reachable by any workflow the bot can author, exactly like repo-level ones, and they do not appear in this repo's own secret listing (`gh api repos/diffplug/dormouse/actions/organization-secrets` is the check). None are visible here today. `BUILDCACHE_USER` and `NEXUS_USER` were org-wide shares — visible to every `diffplug` repository, not grants made to this one — and were previously accepted on the grounds that they are usernames rather than the paired credentials. They have since been narrowed to `selected` visibility over the repositories that actually consume them, which excludes this one, so the acceptance no longer has to be made. Every `diffplug` org secret is now `selected` and none lists `diffplug/dormouse`. Any org secret becoming visible here is an exposure that must be re-evaluated and named before it is accepted — hence the FAIL IF below admits none. - -**Upstream compromise.** Tend's action is referenced as `max-sixty/tend/claude@` in every generated workflow — a **tag**, not a commit SHA. A tag is mutable by whoever owns that repository, so upstream can change what our workflows execute without any commit landing here, and `workflow-audit.yaml` would see nothing: the file is byte-identical. This is a real residual, not a solved problem. It is accepted because the file is generated (a hand-edited SHA is overwritten by the next nightly regen, so pinning locally is not durable) and because the trust it represents is the same trust the harness already has — tend runs the agent that holds `TEND_BOT_TOKEN` either way. What it means concretely is that the version pin bounds *deliberate* upgrades, not a hostile upstream. `uvx tend@latest` runs only at install and during nightly regen; a compromise of that path affects the next re-run, not the in-flight workflows. - -**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/`, `.config/tend.yaml`, `.github/audit/`, or `.vscode/` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. This paragraph is the prose spec of that job's `WINDOW`, so the two enumerations name the same paths: a path added to one without the other leaves a reader checking the `FAIL IF` below against a paragraph that contradicts it. What unites them is that each executes from a branch nobody reviewed — a workflow on a bot push, a `folderOpen` task on checkout, a prompt that decides what the nightly audit even looks at. The config is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. It reports the *unexplained*: two routine sources are classified and omitted on independently checked provenance and content. A Renovate pin bump must be a valid GitHub-signed commit with `author.login == "renovate[bot]"` and `committer.login == "web-flow"`, must be associated only with Renovate-authored PRs, and must change nothing but the ref of an already-referenced action. The signed author/committer pair is the provenance control: GitHub's automatically signed `createCommitOnBranch` mutation binds the author to the authenticating credential and does not permit the caller to supply the author or committer, while REST paths that permit those fields require the caller to supply the signature; requiring `web-flow` therefore rejects both a caller-supplied Renovate author and a commit signed by another identity. PR authorship is independent server-side corroboration. The content test adds a separate bound by requiring the diff to express nothing but a new ref for an action already referenced by name — the residual being a ref selected by Renovate inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header, and must not touch `.config/tend.yaml` in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. Identity is not evidence here at all: `TEND_BOT_TOKEN` is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. - -**Two known evasions of that diff window**, both from how the window is computed rather than from what it classifies. The lower bound is server-set (the previous successful run's `created_at`), but the filter that applies it is `git log --all --since`, which compares against the **committer date** — a field the pusher sets freely, so `GIT_COMMITTER_DATE=2020-01-01` on a commit adding a workflow makes it invisible to every future window. And a branch pushed, run with repo-level secrets in scope, and deleted before the nightly fetch is never in any window at all, because the audit only ever sees refs that still exist. Closing both means keying on server-observed ref changes (the repository activity API records pushes, force-pushes, and deletions with server timestamps and before/after SHAs) rather than on the commit graph as the client presents it. Neither is closed today; they are stated here so the control is not read as stronger than it is. - -- FAIL IF `workflow-audit.yaml` starts deriving its lower bound from anything the pusher controls. The `--since` filter is a known evasion (above); the *bound* coming from the previous successful run's server-set `created_at` is what keeps the window from being attacker-movable in both directions at once. - -- FAIL IF either admin-gating ruleset is missing or weakened. `Merge access` must target `~DEFAULT_BRANCH`, block nothing beyond `update`, and carry admin (`RepositoryRole` actor `5`) as its sole bypass actor; `Tag operations` must target `~ALL` tags, block both `creation` and `update`, and carry the same admin-only bypass. -- FAIL IF `dormouse-bot` holds `maintain` or `admin` on this repository. `GET /collaborators/dormouse-bot/permission` spells `push` as `write` in both `permission` and `role_name`, so the check is that neither of those two roles appears — not a string comparison against `push`. -- FAIL IF any GitHub environment's deployment-branch-policies admit a ref that is not admin-gated by the `Tag operations` or `Merge access` rulesets. Today this covers `vscode-extension-publish` (`v*` tag, admin-only via `Tag operations`), `security-audit` (`main` admin-only via `Merge access`, plus `v*` tag), `release-attest` (`v*` tag, admin-only via `Tag operations`), and `tend` (`main` only, admin-only via `Merge access`). -- FAIL IF the secret inventory departs from this placement. Env-scoping is what stops a workflow pushed to an excluded branch from reading a secret, so a repo-level copy reopens exactly what the environment gate closes. One pass over `actions/secrets`, `actions/organization-secrets`, and each environment's secret listing answers every line: - - `AUDIT_PAT` — in `security-audit`, absent at repo level. - - `TEND_BOT_TOKEN` — in `tend`, absent at repo level. - - `CLAUDE_CODE_OAUTH_TOKEN` — in **both** `tend` and `security-audit`, absent at repo level. Environments do not inherit each other's secrets, so a rotation must set both. - - `OVSX_PAT`, `VSCE_PAT` — in `vscode-extension-publish` only, absent at repo level. - - `ANTHROPIC_API_KEY` — absent at repo *and* org level, for as long as `tend-*.yaml` passes `anthropic_api_key` to `max-sixty/tend/claude` (see "Inert secret plumbing" above). - - `release-attest`'s own secret listing is empty **and** it declares no environment variables. The environment exists only to bound the ref a provenance OIDC token can be minted from (`release.yml`'s two build jobs); an empty environment is what keeps `id-token: write` the only credential those jobs can reach. - - No org-level secret visible to this repository at all (see "Org-level secrets" above). -- FAIL IF `CHROMATIC_PROJECT_TOKEN` is missing from `secrets.allowed` in `.config/tend.yaml`. The allowlist entry is an explicit acknowledgment that the bot can read this token. -- FAIL IF `.github/workflows/workflow-audit.yaml` is missing, disabled, or has not produced a successful run in the last 48 hours. The margin is thinner than it reads: `workflow-audit` runs at 07:13 UTC and this audit at 04:21, so the steady state is ~21.5h and a single skipped run lands at ~45.5h — inside tolerance by under three hours, which is a reason to treat one skipped run as a signal rather than noise. -- FAIL IF any `tend-*.yaml` pins `max-sixty/tend` below `0.1.19`, the release that pins instruction files by glob at any depth. The revert list is upstream code, so the protection this repo gets is whatever the pinned version implements — a downgrade reopens the fork-PR instruction-injection path with no visible change to any file here except a version number. -- FAIL IF any `tend-*.yaml` workflow uses an unpinned action reference (e.g. `@main`, no version). Tag pins are accepted inside `tend-*.yaml` because the file is owned by the upstream generator; every other workflow — agent-managed or not — must SHA-pin per the rule above. -- FAIL IF any job in an agent-managed workflow has **effective** `GITHUB_TOKEN` permissions beyond `contents: write`, `pull-requests: write`, `issues: write`, `id-token: write`, `actions: read`, or any `read` permission. Effective, not declared: a job with no `permissions:` block inherits the repository default, so this check is only meaningful together with the next one. A job that declares nothing textually "grants" nothing while its token carries nine write scopes. -- FAIL IF `default_workflow_permissions` for this repository is not `read`, or `can_approve_pull_request_reviews` is not `false` (`gh api repos/diffplug/dormouse/actions/permissions/workflow`). This is the backstop for every permission bullet in this document: with the default at `write`, one regenerated workflow that omits a `permissions:` block silently reopens what those bullets close, and the repository setting is the only place to fix it durably — a YAML edit does not survive the nightly regen. - -## VS Code Extension Releases - -The VS Code extension is published by GitHub Actions. The secrets which allow this publish are `VSCE_PAT` and `OVSX_PAT`. These secrets are contained only within a protected GitHub environment. The environment requires a human to manually approve, and it can't be the same account which triggered the publish. This prevents a single compromised tag or maintainer account from immediately publishing a new extension version without an explicit release approval. - -- FAIL IF `.github/workflows/release.yml` is missing the `vscode-extension-publish` environment on the VS Code publish job, or if `VSCE_PAT` / `OVSX_PAT` are referenced anywhere under `.github/workflows/**` from a job not bound to that environment. The second clause is repo-wide on purpose: scoping it to `release.yml` would let a reference from another workflow file pass unremarked. -- FAIL IF `.github/workflows/release.yml` uses production desktop signing secrets in CI, or stops generating an ephemeral Tauri updater key for unsigned CI artifacts. - -## Desktop Releases - -Desktop releases are not fully automated. GitHub Actions builds unsigned artifacts, publishes attestations and hash manifests, and uploads those unsigned artifacts for local release signing. Final desktop deployment is manual through `scripts/sign-and-deploy.sh`. Before signing, the script verifies the CI artifact attestations and the recorded SHA-256 hashes. The local machine then performs platform signing and uploads the final release assets. Windows Authenticode signing requires a physical YubiKey and the signing PIN. macOS signing and notarization also happen locally, outside GitHub Actions. CI must not have the production Tauri updater private key; CI uses only an ephemeral updater key so Tauri emits updater-shaped unsigned artifacts. Tauri updater signing is applied locally after OS signing so the updater signs the final release bundles that users will download. - -**Signing credentials and argv.** Three secrets reach `scripts/sign-and-deploy.sh` through the environment, and argv is readable via `ps` by any process on the machine for the lifetime of a call — which matters more than usual here, since `pnpm exec` means a dependency's lifecycle scripts share that session. One of the three is now env-only; two are on a command line because their tools offer nowhere else to put them: - -- `TAURI_SIGNING_PRIVATE_KEY` — **env-only**. `tauri signer sign` documents `--private-key` as falling back to that variable, so passing both was redundant exposure. -- `EV_SIGN_PIN` — on argv. `jsign` reads `--storepass` only as a literal option value, with no environment or file indirection. Bounded: local `ps` for the duration of one call, for a PIN inert without the physical YubiKey it unlocks. -- `APPLE_SIGN_PASS` — on argv, and the weakest of the three. `xcrun notarytool` offers no environment form either, but unlike the PIN this is a standalone credential, and `--wait --timeout 30m` holds it on the command line for up to half an hour per architecture. The documented remedy is `notarytool store-credentials` plus `--keychain-profile`, which moves the exposure to one short call instead of every submission. Not yet done — it changes the release runbook and cannot be exercised without live Apple credentials. - -- FAIL IF `scripts/sign-and-deploy.sh` stops doing any of three things: verifying GitHub artifact attestations, verifying artifact SHA-256 manifests, or using PIV-backed Windows signing. -- FAIL IF `TAURI_SIGNING_PRIVATE_KEY` is passed on a command line anywhere in `scripts/sign-and-deploy.sh` rather than through the environment. `jsign --storepass` is the one documented exception, for the reason above. - -## Reporting a Vulnerability - -Report privately through GitHub's **[Report a vulnerability](https://github.com/diffplug/dormouse/security/advisories/new)** form, which is enabled on this repository. That opens a private advisory visible only to you and the maintainers; it is the right channel for anything in this document, and specifically for anything in **Remote Control** — a public issue describing a live path into a Host's ACL is a disclosure, not a report. - -Do not open a public issue, and do not send a report to the maintainer's personal email — the advisory form is what gets triaged. Include what you would want if you were fixing it: the version or commit, the deployment mode (self-hosted server, standalone app, VS Code extension), and the shortest sequence that reproduces the problem. We will acknowledge the advisory and tell you what we intend to do about it; there is no bounty program. - -This is a small project with one maintainer. Nothing here promises a response time we cannot keep, and a fix that requires a coordinated release will say so in the advisory rather than in a schedule. - -- FAIL IF private vulnerability reporting is disabled on the repository (`gh api repos/diffplug/dormouse/private-vulnerability-reporting` must report `enabled: true`) — the advisory link above is the only reporting channel this document offers, and a disabled form sends a reporter to a public issue instead. - -## CI Validation Contract - -The `security-audit` workflow at `.github/workflows/security-audit.yaml` enforces this document. It runs nightly and is a required dependency of the VS Code publish job in `release.yml`, so no release ships without a passing audit. The audit reads SECURITY.md, executes each `FAIL IF` as a mechanical check, and also does a qualitative pass for security holes the specs don't cover. - -**The audit is fanned out to three subagents with disjoint scopes**, and the orchestrator audits nothing itself — it spawns them concurrently and merges what they return. The domains are `supply-chain` (**Dependency Supply Chain**), `ci-and-secrets` (**GitHub Actions Policies**, **Automated Maintainer (tend)**, both release sections, **Reporting a Vulnerability**, and this one), and `application-security` (**Remote Control**). The split is not about parallelism. These are different subject matters with different evidence — dependency provenance is lockfiles, CI is `gh api` output, and application security is reading the pairing code adversarially — and one context holding all three degrades the third, which is the newest, has the most code behind it, and is the easiest to crowd out with API responses. The separation is one of **context, not of credential**: `AUDIT_PAT` is a step-level `env:` on the one job, so every subagent inherits it in its process environment, and only the prompt tells the application-security agent not to use it. A prompt is not a control. Making that separation real would take a second job without the `security-audit` environment, passing fragments between jobs as artifacts — worth doing, not done. Until then the honest claim is that three contexts each *read* less, not that any of them *holds* less. - -**The domains do not all run on the same model.** `supply-chain` and `ci-and-secrets` are mechanical — run a generator, read an API response, compare a pin — and the session default handles them. `application-security` reads code adversarially, and it is where the findings that needed real reasoning have come from: tracing a relay-minted `clientId` to a keystroke-injection path, or working out that an eight-character device fingerprint carried ~40 bits rather than ~48 because a P-256 point's leading byte is constant. It runs on Opus, declared per-agent in `--agents`, so the cost lands on the one domain that has depth to find rather than on all three. `scripts/security-audit-local.sh` applies the same split, and pins **both** sides rather than only the strong one. Leaving the mechanical domains unpinned inherits whatever the operator's own default is, which is not necessarily weaker — on a machine defaulting to `opus[1m]` it is *stronger*, which inverts the relation and quietly turns the local loop into something other than a rehearsal of the nightly. CI gets this for free, since its session default is Sonnet and only one agent carries an override. - -Each subagent writes its own report fragment (`audit-supply-chain.md`, `audit-ci-secrets.md`, `audit-application.md`) before returning its verdict, and the orchestrator concatenates those files rather than retyping them. Fragments are uploaded with the transcript, so an orchestrator that dies mid-merge still ships whatever the domains found — the INCONCLUSIVE shape below, which the archive exists to explain. - -**The prompts live in `.github/audit/`, not inline in the workflow**, and that placement is load-bearing three times over. `scripts/security-audit-local.sh` runs the audit against the same files CI uses, so the loop that catches problems in this document is a local one and cannot drift from the nightly. Prompt changes get reviewed as ordinary markdown diffs rather than as YAML block-scalar churn. And the section-ownership rule below is only a grep because the `## ` headings sit in markdown — inline, block-scalar wrapping split `## Automated Maintainer (tend)` across two lines and the check silently matched nothing. - -**Subagents launch in the background, which is the trap that produced three INCONCLUSIVE runs.** The Task tool returns an id, not a report, so an orchestrator that ends its turn to await a completion notification ends the *session* — this is one headless run and nothing resumes it. Run 32618922852 passed all 21 mechanical checks that way and produced no verdict at all. The fix is not to stop delegating: it is to never end the turn. The orchestrator blocks in a Bash `until` loop on the fragment files, re-issuing it when a single call hits the ten-minute Bash cap, until every fragment exists or a 25-minute deadline passes. `--allowed-tools` is not what enforces this — it only auto-approves and removes nothing, which is why the tools were available in the first place. - -`FAIL IF` lines are grouped by the operation that answers them: one bullet may assert several properties when a single API call, file read, or script run establishes all of them. The grouping is presentation only — every clause remains an independent check, and the report records each with its own PASS/FAIL and its own evidence. A bullet is never satisfied in bulk. On any `FAIL IF` violation or BLOCKER-severity finding, the workflow opens (or updates) an issue labeled `security-audit-failure` carrying the audit report, and exits non-zero. When a subsequent audit passes, the open failure issue is auto-closed so the tracker matches the live state. - -The report is truncated to 32,000 characters before it is posted (`scripts/clamp-issue-body.mjs`, self-tested by `scripts/clamp-issue-body-selftest.mjs` under `pnpm test`). GitHub rejects an over-long body outright, that rejection lands on a `set -e` step *after* the verdict is decided, and the finding then reaches no issue and no comment — only a red run and an artifact that expires. Truncation keeps the head, which carries the verdict and the links, and the `audit-transcript` artifact always holds the report in full. The call is non-fatal (`|| echo …`) so a failure of the helper cannot reopen the window it closes. `.github/workflows/workflow-audit.yaml` truncates its own commit list the same way. - -The reporting step distinguishes three outcomes, not two. `PASS` and `FAIL` are verdicts the audit reached; anything else — a missing, empty, or non-verdict `audit-status.txt` — is **INCONCLUSIVE**, meaning the agent ended its turn without deciding. Only the literal strings `PASS` and `FAIL` are honored, so a status file containing prose cannot be mistaken for a verdict. An inconclusive run still exits non-zero and still files under `security-audit-failure` — an audit that reached no verdict must not let the release gate pass, and a later `PASS` should auto-close it like any other failure — but it is titled `INCONCLUSIVE` and its body states that it is not a security finding. Collapsing the two, as the step originally did, filed an identical issue for "the repo is insecure" and "the auditor stopped early". - -The audit runs as a single headless turn, and the recurring cause of INCONCLUSIVE is an agent that treats it as a resumable one. `claude_args` allows `Task`/`Agent` — the fan-out above depends on them — and denies only `Workflow`, which nothing here should be spawning. No allowlist can prevent the actual failure, since `--allowed-tools` only auto-approves and removes nothing: what discards the subagents' work is ending the turn to await them, so the control is the orchestrator prompt's non-turn-ending wait, and the `FAIL IF` below that requires it. The prompt splits the two output files along the fail-closed line: `audit-report.md` is always written before the turn ends — partial, if the agent runs short — while `audit-status.txt` is written only once the verdict covers every check. Partial has two shapes, and the INCONCLUSIVE issue names both: a check the agent reached but could not determine is marked `UNVERIFIABLE` within its domain's fragment, while a domain that never reported at all — an expired wait deadline, or a subagent that died — renders as a `_No report …_` placeholder in place of that domain's section. Its `## Summary` may likewise read `INCONCLUSIVE` rather than `PASS` or `FAIL`, since the whole point of withholding the status file is that no verdict covers every domain. A partial audit therefore still reaches a human (the reporting step reproduces the partial report in the INCONCLUSIVE issue) without a `PASS` on unrun checks closing the failure issue and opening the release gate. - -Every run uploads the agent's SDK transcript as the `audit-transcript` artifact (14-day retention), and failure issues deep-link it. Without it a run that produces no verdict is undiagnosable: `claude-code-action` keeps tool output out of the step log on purpose, and the runner is ephemeral. Because this repository is public the artifact is world-readable, which is consistent with the audit reports already posted to public issues — but note that artifact contents are **not** secret-masked the way logs are, so no step may ever print `$AUDIT_PAT` or `$CLAUDE_CODE_OAUTH_TOKEN`. The prompt passes the PAT only through an unexpanded `GH_TOKEN=` prefix, and `gh api` responses never carry secret values. - -The audit job declares `environment: security-audit`, whose deployment-branch-policy admits only `main` and `v*` tags. Both ref classes are admin-only by the rulesets in **Automated Maintainer (tend)**, so a write-scoped bot cannot reach the env's secrets (most importantly `AUDIT_PAT`, when provisioned) by pushing a workflow file to a feature branch. - -As a consequence of that env-gating, audit changes are iterated on `main` directly. A `workflow_dispatch` from any other ref is rejected by the environment's deployment-policy before any step runs. To experiment on a branch, widen the env's policy temporarily and revert after. - -`AUDIT_PAT` is **required**. A dedicated step verifies the secret is present before the audit step runs — after the checkout and install, not literally first — and refuses to continue otherwise — without it the audit cannot read the administration endpoints needed to verify ruleset bypass actors, repo-level secret listing, and environment policies, so the spec it claims to enforce would be unenforceable in its key sections. Mint a fine-grained PAT on an admin's account with read-only `Administration` + `Secrets` + `Environments` scoped to `diffplug/dormouse` only, then store it env-scoped: - -```bash -gh secret set AUDIT_PAT --env security-audit --repo diffplug/dormouse --body 'github_pat_…' -``` - -- FAIL IF `.github/workflows/security-audit.yaml` is missing or disabled, or if any of the three separate things that make it a release gate is gone: the `gh workflow run` dispatch, the `gh run watch --exit-status` that turns a failed audit into a failed job, and `publish-vscode`'s `needs:` edge on that job. They break independently — dropping `--exit-status` alone un-gates the release while leaving a green grep for "invoked". -- FAIL IF the audit stops fanning out to a dedicated application-security subagent scoped to **Remote Control**, or that subagent's scope is merged back into a context that also carries the supply-chain or CI domains. Folding it back in is how that section stops being audited without anyone deciding to stop auditing it. -- FAIL IF the orchestrator prompt stops requiring a non-turn-ending wait — a Bash `until` loop over the fragment files, re-issued past the ten-minute Bash cap, under a bounded deadline that is **persisted to a file** rather than recomputed from `now`. A deadline longer than the ten-minute cap cannot fire inside one call, so a re-issued loop that recomputes it never reaches it: the bound is then written down but never binds, and the only thing ending the wait is the runner's cancellation. Delegating is safe; ending the turn to wait is what kills the run, and no tool allowlist prevents it. -- FAIL IF `.github/audit/` is missing a prompt file the workflow names, or `scripts/security-audit-local.sh` stops running the audit from those same files. A local runner with its own copy of the prompts is worse than no local runner: it drifts, and the drift is invisible until a nightly disagrees with a local pass. -- FAIL IF `.github/audit/` or `.vscode/` is outside **every** consumer of `workflow-audit.yaml`'s diff window — the commit list, `own_changes`, and both classifiers' refusals. Widening one without the others is worse than not widening at all: `git log` matches the commit, `own_changes` returns nothing for it, the empty-list `continue` swallows it, and this bullet then claims a coverage that does not exist. One `WINDOW` array is the reason they cannot drift: the classifiers' half is *derived* from it (`"${WINDOW[@]:1}"`), not written out a second time, so adding a path cannot reach the commit list while missing the refusal. The prompts decide what gets audited and by whom; `.vscode/tasks.json` can execute on folder open. Both are changes to the security automation, which is the reason `.config/tend.yaml` is in that window. This document is deliberately *not* watched there: what that job catches is code executing from a branch nobody reviewed, and a `FAIL IF` is inert until it is merged to `main`, which is admin-gated — so the watch would add no coverage over PR review while reporting a commit on nearly every security PR. -- FAIL IF a `## ` section of this document is in no subagent's scope, or is in two. Every section is owned by exactly one domain: a section owned by none is unaudited, and one owned by two produces contradictory verdicts. Each domain file in `.github/audit/` names its sections as exact `## ` headings on their own lines, so this is a real grep over four markdown files rather than a reading of prose embedded in YAML. -- FAIL IF the union of the subagents' qualitative scopes does not cover every top-level path in the repository. The per-domain scopes replaced a single roving "flag any other security hole you find", so anything no domain names is now nobody's job — and the first version of this split silently orphaned `canopy/`, `.claude/` (named as prompt-injection surface two sections above), `docs/`, the root files, and all of `website/` outside `src/data/`, which includes the Tauri updater manifest that shipped apps fetch. The division is by **subtraction**, so that adding a directory cannot orphan it: - - `ci-and-secrets` — `.github/` (including `.github/audit/`), `.config/`, `.claude/`, `.vscode/`, `scripts/`, and `website/public/`. The updater manifest is a release artifact, not marketing; `.vscode/` is here because `tasks.json` can carry `"runOn": "folderOpen"`, which executes on checkout. - - `supply-chain` — the dependency graph, the lockfile, and all of `website/` except `website/public/`. Stated as a subtraction rather than as named subdirectories, because naming `src/` and `scripts/` left `website/`'s own build config owned by nobody — the same orphaning shape one level down. `generate-deps.js` is in there, so the generator behind the disclosed snapshot is audited, not just the `productDependencyFilters` array a bullet above names. - - `application-security` — **everything else**, worked out from `ls -A` rather than from a list, including `.impeccable/` (the design-token snapshot behind `DESIGN.md`). Dotfile directories are named explicitly wherever they land, in this list and in the prompt files, because a catch-all has twice now been read as covering them when no reader could tell which domain owned one. An enumeration here goes stale the moment a path is added, which is exactly how `.vscode/` and `.impeccable/` came to be owned by nobody after the first version of this split named paths explicitly. The subtraction is **recursive**: where another domain claims a subdirectory rather than a whole tree — as both do inside `website/` — the remainder of that tree belongs here, or the same orphaning recurs one level down. -- FAIL IF the `Redact secrets from agent output` step is removed, stops covering any sink that is later published (`audit-report.md`, the three per-domain fragments, and the transcript), or stops failing closed by deleting those files when the redactor itself throws. It is the only thing between an accidental `printenv` and a world-readable artifact, and until this bullet existed nothing would have tripped on its deletion. -- FAIL IF `application-security` does not run on a stronger model than the mechanical domains, in **both** `.github/workflows/security-audit.yaml`'s `--agents` and `scripts/security-audit-local.sh`. A local run that silently uses a weaker model than the nightly makes the local loop — the one that catches problems before they merge — worse than the thing it is standing in for. -- FAIL IF the reporting step writes issue prose per *combination* of conditions rather than one note per condition that holds. Four consecutive review rounds found the same defect in different clothes — an arm whose text was true only of the states that could reach it, made false by the next gate that widened. Prose proportional to combinations cannot be kept correct by fixing combinations; a note that claims nothing about the other conditions cannot be invalidated by a new one. -- FAIL IF either fragment guard is gated on the status at all. Recording what is true of a run and deciding its verdict are separate jobs, and gating the first on the second produced this defect three times in different clothes: gated on `PASS`, one empty fragment silenced the dissent check; widened to `!= FAIL`, an orchestrator that wrote `FAIL` itself silenced both, so a domain that left no report beside a real finding appeared nowhere at all. Both loops run unconditionally and only record; `STATUS` is assigned in exactly two places, where the status file is parsed and in the single escalation block. That block encodes the ordering — `FAIL` (a domain found something) outranks `MISSING` (the audit did not finish) outranks `PASS` — so a dissent can raise `MISSING` to `FAIL` and never the reverse, and a `FAIL` arriving alongside missing or unreadable fragments still reports them. -- FAIL IF a fragment's first line is not `VERDICT: PASS` or `VERDICT: FAIL`, or the reporting step stops downgrading a merged `PASS` that contradicts one — **or** stops treating a fragment with no readable verdict as inconclusive. Three cases, not two: a fragment the check cannot read must not fall through to an unchallenged `PASS`, because that puts the verdict back on a prompt having been followed, which is the thing this guard exists to stop being the control. Existence is not agreement: the missing-fragment guard catches a domain that produced nothing, and this catches one whose `FAIL` the merge lost — which is worse, because `PASS` closes the open failure issue and opens the release gate. -- FAIL IF the orchestrator can report `PASS` while a subagent left no report fragment. A domain that dies silently must not pass the audit — a missing fragment is indistinguishable from a domain that found nothing, and only one of those is safe to publish a release on. It must not be published as `FAIL` either, unless some domain actually returned one: the prompt writes no status file when a fragment is missing and no domain failed, which routes an audit that ran out of time to the INCONCLUSIVE issue rather than filing it as a security finding and relabelling an open issue up to `FAIL`. Both outcomes exit non-zero and hold the release gate shut, so the distinction costs nothing and is the whole reason there are three of them. -- FAIL IF the audit has been weakened in any other way — e.g. the prompt no longer requires the qualitative pass, a `FAIL IF` can be ignored, the failure-reporting step that opens a `security-audit-failure` issue and exits non-zero has been removed, or the `AUDIT_PAT` pre-check is removed or bypassed. This bullet is a judgement item, not a checklist: the examples are the ones that have come up, not the ones that exist. Two weakenings found by the audit's own first run were not covered by any example here, and both became their own bullets above. +# Security policy + +**Report a vulnerability privately** through GitHub's +[Report a vulnerability](https://github.com/diffplug/dormouse/security/advisories/new) +form. It opens an advisory visible only to you and the maintainers. Do not open +a public issue, and do not email the maintainer: a public issue describing a +live path into a laptop is a disclosure, not a report. Include the version or +commit, the deployment (self-hosted server, standalone app, VS Code extension), +and the shortest reproduction. Every advisory is acknowledged with what we +intend to do about it; there is no bounty. + +**What Dormouse guarantees, what it does not, and how that is checked** is the +security spec, [`docs/specs/security.md`](docs/specs/security.md), published at + — whole, but with the guarantees table and +the two lists narrowed there to that page's audience, so the spec itself is +where every row appears together. It names the five audited +checklists beside it — [local](docs/specs/security-local.md), +[remote control](docs/specs/security-remote.md), +[supply chain](docs/specs/security-supply-chain.md), +[CI and releases](docs/specs/security-ci.md), and +[the audit itself](docs/specs/security-audit.md) — whose `FAIL IF` lines a +nightly audit executes and every VS Code release is gated on. A failure files a +public 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 record. diff --git a/SELF_HOST.md b/SELF_HOST.md index 9e8d00d9..ed1efcc3 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -354,7 +354,7 @@ Host displays (`docs/specs/server.md` → Setup tokens and the pairing QR). setup password; the typed form behind "Enroll with a different server…" covers a server elsewhere or a spent offer (`docs/specs/server.md`, "Remote control, in the Settings dialog"). Enrollment persists in the Host service's - own store (`SECURITY.md` → "Credentials at rest"), so later launches connect + own store (`docs/specs/security-remote.md` → "Credentials at rest"), so later launches connect on their own; the section then shows the server, the relay connection and the paired-device count. @@ -560,7 +560,7 @@ branches. The security properties this deployment is audited against are the "Network posture (self-hosted)" and "Credentials at rest" `FAIL IF` lines in -`SECURITY.md`. **Those lines bind all three installers** — a control present in +`docs/specs/security-remote.md`. **Those lines bind all three installers** — a control present in one and absent from another is a finding — and `scripts/deploy-lint.mjs` (`pnpm lint:deploy`, part of `pnpm test`) checks each one textually against each installer, with `scripts/deploy-lint-selftest.mjs` deleting each matched control @@ -627,14 +627,14 @@ reports which mode is live rather than asserting either. pruning succeed — so a failure leaves the previous offer unspent. `run-server` exports `DORMOUSE_ENROLL_TOKEN_FILE`; unset, the server refuses every offer (`docs/specs/server.md` → Configuration). Generation and - protection: `SECURITY.md` → "Credentials at rest". + protection: `docs/specs/security-remote.md` → "Credentials at rest". - **Loopback only, and tailnet-only.** The install pins `DORMOUSE_BIND_HOST=127.0.0.1` and refuses to proceed without it (`docs/specs/server.md` → Configuration). Port 3100, not 3000, so the service coexists with `pnpm dev:server` / `pnpm dev:pocket-server`. **`verify` also fails on an active `tailscale funnel`** — one configuration surface with Serve, and a Funnel publishes this origin, and the password behind it, to the - public internet (`SECURITY.md` → "Network posture"). + public internet (`docs/specs/security-remote.md` → "Network posture"). - **`DORMOUSE_ORIGIN` is durable WebAuthn identity**, derived from the node's MagicDNS name. An installation recording a different origin stops the installer, because rewriting silently invalidates the registered passkey and @@ -646,7 +646,7 @@ reports which mode is live rather than asserting either. principal satisfies the mode and inverts it; Linux's `manage verify` asserts both legs on `config/`, `state/`, `run/` and `config/server.env`. *(macOS checks the modes only, Windows' `Test-OwnerOnly` the DACL but never the owner - — two known gaps, `SECURITY.md` → "Credentials at rest".)* + — two known gaps, `docs/specs/security-remote.md` → "Credentials at rest".)* - **A failed update is a failure.** The candidate release is health-checked on an ephemeral port against a throwaway state dir *before* `current` moves; if the live service then fails to answer, `current` is restored to `previous` and diff --git a/deploy/local/install-linux.sh b/deploy/local/install-linux.sh index 690c04a0..fc24437e 100755 --- a/deploy/local/install-linux.sh +++ b/deploy/local/install-linux.sh @@ -514,7 +514,7 @@ else fi NODE_PIN="$(json_query "$REPO_ROOT/package.json" "devEngines.runtime.version" || echo "")" -[ -n "$NODE_PIN" ] || die "root package.json has no devEngines.runtime.version. SECURITY.md keys a mechanical FAIL IF to that exact field." +[ -n "$NODE_PIN" ] || die "root package.json has no devEngines.runtime.version. docs/specs/security-supply-chain.md keys a mechanical FAIL IF to that exact field." case "$NODE_PIN" in *.*.*) : ;; *) die "devEngines.runtime.version must be an exact MAJOR.MINOR.PATCH version, got '$NODE_PIN'." ;; @@ -667,7 +667,7 @@ if [ ! -f "$ENV_FILE" ]; then SETUP_PASSWORD="$(random_hex32)" # 32 random bytes is 64 hex characters. The guard counts characters, so it # must be 64 — checking for 32 would pass a regression to 16 bytes, which is - # half the entropy SECURITY.md claims. + # half the entropy docs/specs/security-remote.md claims. [ ${#SETUP_PASSWORD} -ge 64 ] || die "generated setup password is implausibly short; refusing to install it." # Create the file and lock it down BEFORE the secret is written, so the @@ -1170,7 +1170,7 @@ cmd_verify() { # exact origin to the public internet. The whole security analysis of the # selfhost server assumes a tailnet-only origin — most of all the setup # password, whose hardening is a constant-time compare and a 250ms delay - # (SECURITY.md, "The setup password"). So this is checked, never assumed — + # (docs/specs/security-remote.md -> "The setup password"). So this is checked, never assumed — # and "assumed" is what `2>/dev/null || true` hid: it threw away the one # signal that separates a node with no Funnel from a CLI that never ran, so # an unavailable Tailscale printed the reassuring line. The status is kept, @@ -1789,7 +1789,7 @@ fi # ------------------------------------------------------------ enroll offer --- # run/enroll-offer.json, the one-time offer redeemed at POST /api/host/enroll in -# place of the setup password (SECURITY.md → "Credentials at rest"). +# place of the setup password (docs/specs/security-remote.md → "Credentials at rest"). # # Last state mutation: minting burns the previous unspent offer, so the release, # HTTPS Serve mapping, and pruning must all have succeeded first. The server diff --git a/deploy/local/install-macos.sh b/deploy/local/install-macos.sh index 59dcef53..63f31e24 100755 --- a/deploy/local/install-macos.sh +++ b/deploy/local/install-macos.sh @@ -314,7 +314,7 @@ else fi NODE_PIN="$(json_query "$REPO_ROOT/package.json" "devEngines.runtime.version" || echo "")" -[ -n "$NODE_PIN" ] || die "root package.json has no devEngines.runtime.version. SECURITY.md keys a mechanical FAIL IF to that exact field." +[ -n "$NODE_PIN" ] || die "root package.json has no devEngines.runtime.version. docs/specs/security-supply-chain.md keys a mechanical FAIL IF to that exact field." case "$NODE_PIN" in *.*.*) : ;; *) die "devEngines.runtime.version must be an exact MAJOR.MINOR.PATCH version, got '$NODE_PIN'." ;; @@ -493,7 +493,7 @@ if [ ! -f "$ENV_FILE" ]; then SETUP_PASSWORD="$(random_hex32)" # Both generators above produce 32 random bytes, i.e. 64 hex characters. The # guard counts characters, so it must be 64 — checking for 32 would pass a - # regression to `-l 16`, which is half the entropy SECURITY.md claims. + # regression to `-l 16`, which is half the entropy docs/specs/security-remote.md claims. [ ${#SETUP_PASSWORD} -ge 64 ] || die "generated setup password is implausibly short; refusing to install it." umask 077 @@ -920,7 +920,7 @@ cmd_verify() { # exact origin to the public internet. The whole security analysis of the # selfhost server assumes a tailnet-only origin — most of all the setup # password, whose hardening is a constant-time compare and a 250ms delay - # (SECURITY.md, "The setup password"). So this is checked, never assumed — + # (docs/specs/security-remote.md -> "The setup password"). So this is checked, never assumed — # and "assumed" is what `2>/dev/null || true` hid: it threw away the one # signal that separates a node with no Funnel from a CLI that never ran, so # an unavailable Tailscale printed the reassuring line. The status is kept, @@ -1525,7 +1525,7 @@ fi # ------------------------------------------------------------ enroll offer --- # run/enroll-offer.json, the one-time offer redeemed at POST /api/host/enroll in -# place of the setup password (SECURITY.md → "Credentials at rest"). +# place of the setup password (docs/specs/security-remote.md → "Credentials at rest"). # # Last state mutation: minting burns the previous unspent offer, so the release, # HTTPS Serve mapping, and pruning must all have succeeded first. The server diff --git a/deploy/local/install-windows.ps1 b/deploy/local/install-windows.ps1 index 78b66c49..386c2a2d 100644 --- a/deploy/local/install-windows.ps1 +++ b/deploy/local/install-windows.ps1 @@ -619,7 +619,7 @@ if ($GIT_DIRTY -eq 'true') { $rootPkg = Get-Content -Raw -LiteralPath (Join-Path $REPO_ROOT 'package.json') | ConvertFrom-Json $NODE_PIN = Get-JsonValue -Object $rootPkg -Path 'devEngines.runtime.version' if (-not $NODE_PIN) { - Die "root package.json has no devEngines.runtime.version. SECURITY.md keys a mechanical FAIL IF to that exact field." + Die "root package.json has no devEngines.runtime.version. docs/specs/security-supply-chain.md keys a mechanical FAIL IF to that exact field." } if ($NODE_PIN -notmatch '^\d+\.\d+\.\d+$') { Die "devEngines.runtime.version must be an exact MAJOR.MINOR.PATCH version, got '$NODE_PIN'." @@ -831,7 +831,7 @@ try { $SETUP_PASSWORD = New-RandomHex32 # 32 random bytes is 64 hex characters. The guard counts characters, so it # must be 64, not 32 -- a guard reading 32 would pass a regression to half - # the entropy SECURITY.md claims. + # the entropy docs/specs/security-remote.md claims. if ($SETUP_PASSWORD.Length -lt 64) { Die "generated setup password is implausibly short; refusing to install it." } @@ -1582,7 +1582,7 @@ function Invoke-Verify { # exact origin to the public internet. The whole security analysis of the # selfhost server assumes a tailnet-only origin -- most of all the setup # password, whose hardening is a constant-time compare and a 250ms delay - # (SECURITY.md, "The setup password"). So this is checked, never assumed -- + # (docs/specs/security-remote.md -> "The setup password"). So this is checked, never assumed -- # and a check that could not run has assumed. Every way the CLI can be # unavailable (off PATH, tailscaled down, `funnel status` unknown to an older # CLI) yields text that matches nothing, which is indistinguishable from a @@ -2301,7 +2301,7 @@ rem directly. # ------------------------------------------------------------ enroll offer --- # run\enroll-offer.json, the one-time offer redeemed at POST /api/host/enroll - # in place of the setup password (SECURITY.md -> "Credentials at rest"). + # in place of the setup password (docs/specs/security-remote.md -> "Credentials at rest"). # # Last state mutation: minting burns the previous unspent offer, so the # release, HTTPS Serve mapping, and pruning must all have succeeded first. The diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index 37dce15c..472a0e0f 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -156,7 +156,7 @@ The website changelog page imports `website/src/data/changelog.json`, but **`CHA **Non-secret signing identity is hardcoded at the top of `scripts/sign-and-deploy.sh`, never passed through the environment** — Developer ID string, team ID, Apple ID, `jsign` alias, TSA URL. The Developer ID cert lives in the local keychain, the EV cert on the YubiKey; neither is a value the script reads. -`SECURITY.md` -> "Desktop Releases" owns the argv-exposure rules for the three prompted secrets. +`docs/specs/security-ci.md` -> "Desktop Releases" owns the argv-exposure rules for the three prompted secrets. ## Future diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index ee535b57..d0618650 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -542,7 +542,7 @@ Security boundaries: **Must replace the upstream's framing controls with a `frame-ancestors` naming the embedder chain, never merely drop them**, and **the shim's `postMessage` must -target that chain's origin, never `'*'`** (rationale; `SECURITY.md` → "Loopback +target that chain's origin, never `'*'`** (rationale; `docs/specs/security-local.md` → "Loopback Listeners"). **With no chain the proxy strips nothing and injects nothing.** **Must refresh a grant's idle timer for every caller except one that named itself @@ -553,10 +553,10 @@ foreign.** `isOwnOrigin` and `isForeignOrigin` are not each other's negation — origin unchanged and keep an absent origin absent, on request and upgrade paths; `Referer` only substitutes the proxy's own origin. (rationale) The shared rule for all loopback listeners lives in `lib/src/host/loopback-guard.ts` and is -audited by `SECURITY.md` → "Loopback Listeners". +audited by `docs/specs/security-local.md` → "Loopback Listeners". **Never relax** the `Host` validation, the conditional `Origin` gate, or the -`frame-ancestors` replacement without updating that `SECURITY.md` audit. Pinned +`frame-ancestors` replacement without updating that `docs/specs/security-local.md` audit. Pinned by `lib/src/host/iframe-proxy.test.ts`, which covers the upgrade path as well as the request path. diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index ce0bd394..5ab23ed7 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -164,7 +164,7 @@ control server both hosts load: **The server picks the path, and picks it unguessably.** POSIX: `/dormouse-dor-/<8 random bytes>.sock`, the parent directory -created `0700` and re-checked on every use — a real directory, not a symlink, +created `0700` and checked before every bind — a real directory, not a symlink, owned by this uid, at exactly mode `0700`. One of ours that is merely loose gets tightened; anything else **stands the channel down** (the same predicate as `peerDirIsSafe()`). 8 random bytes rather than 16, so the POSIX path clears diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 7c5e34c3..d52a9087 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -475,7 +475,7 @@ prefix**: the manifest's `start_url`/`scope`, the worker's registration scope, and the shell's manifest/icon links are all root-absolute. **The origin is served with a Content-Security-Policy**, the defense in depth -around the active XSS `SECURITY.md` -> Accepted limitations names (rationale). +around the active XSS `docs/specs/security.md` -> "What is not defended" names (rationale). Every source is the app's own origin (`default-src 'self'`, with `frame-ancestors`, `base-uri` and `object-src` `'none'`), with three loosenings: diff --git a/docs/specs/pocket-app.rationale.md b/docs/specs/pocket-app.rationale.md index 6fa36cd3..c6e57937 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -110,7 +110,7 @@ **What the origin check buys.** A Pocket served anywhere else cannot sign in at all, since WebAuthn binds the passkey to the serving origin — so the rule is enforced by the Server, not merely observed by the client. -**Why the origin carries a CSP at all.** Pocket holds a per-Host Client static and the worker that opens sealed pushes, and `SECURITY.md` -> Accepted limitations already names active XSS here as a risk it cannot rule out. Both shipped webview hosts already have a policy, leaving Pocket the one origin without one. +**Why the origin carries a CSP at all.** Pocket holds a per-Host Client static and the worker that opens sealed pushes, and `docs/specs/security.md` -> "What is not defended" already names active XSS here as a risk it cannot rule out. Both shipped webview hosts already have a policy, leaving Pocket the one origin without one. **Why `connect-src` names the WebSocket origin instead of resting on `'self'`.** Browsers have disagreed about whether `'self'` covers `ws:`/`wss:` at the same origin, so a policy that relied on it would break the relay on some engines and not others. Naming `DORMOUSE_ORIGIN` with the scheme swapped is unambiguous everywhere. diff --git a/docs/specs/remote-api.rationale.md b/docs/specs/remote-api.rationale.md index 52348cd6..e961dcc9 100644 --- a/docs/specs/remote-api.rationale.md +++ b/docs/specs/remote-api.rationale.md @@ -12,7 +12,7 @@ ## Envelope -**Why the clamp's upper bound is the security-relevant half.** A local resize is derived from element geometry and cannot be large, but `terminal.resize` carries a peer-supplied number straight into `term.resize` in the webview that owns the pane, and xterm bounds only the minimum before allocating `rows × cols` cells. Unbounded, one frame asking for a million by a million wedges every terminal in that window, reachable by any authorized Client (`SECURITY.md` → "Remote Control", Trust boundary). `MAX_TERMINAL_DIMENSION` is 2000 — far past any real display, since a 4K screen at an unreadably small font is on the order of 800 columns — while capping the worst a peer can request at a few million cells. +**Why the clamp's upper bound is the security-relevant half.** A local resize is derived from element geometry and cannot be large, but `terminal.resize` carries a peer-supplied number straight into `term.resize` in the webview that owns the pane, and xterm bounds only the minimum before allocating `rows × cols` cells. Unbounded, one frame asking for a million by a million wedges every terminal in that window, reachable by any authorized Client (`docs/specs/security-remote.md` → "Trust boundary"). `MAX_TERMINAL_DIMENSION` is 2000 — far past any real display, since a 4K screen at an unreadably small font is on the order of 800 columns — while capping the worst a peer can request at a few million cells. ## Directory (the phone's picker) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index db7704ec..f2d60798 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -14,7 +14,7 @@ The trust model for remote control: three primitives between the Client static generated in the browser and stored non-extractably. Account compromise is therefore insufficient for host access -([Security Guarantees](#security-guarantees)). `SECURITY.md` -> "Remote Control" +([Security Guarantees](#security-guarantees)). `docs/specs/security-remote.md` -> "Remote Control" is this model's audited face — the properties checked nightly and the gaps left open (revocation, the audit trail). @@ -288,6 +288,10 @@ Source of truth: `RemoteHost.#onConnectionInit` / `#onConnectionTransport` / **A push gets its own construction** — no live session exists between the two endpoints when one is sent (rationale). +- **Push is opt-in.** A Host that never enrolls to a server sends none, and none + of the push limitations apply; an enrolled Host pushes only to a phone that + turned push on ([pocket-app.md](./pocket-app.md) -> Installable web app owns + the card). - **A fresh key per message, from the two pinned statics.** `ss = X25519(hostStatic, clientStatic)`, a random 32-byte salt, `key = HKDF-SHA-256(ikm = ss, salt, info = "dormouse/push/v1", 32)`, and @@ -444,7 +448,7 @@ Source of truth: `server-lib-common/src/security/noise.ts`, **Each Host mints one permanent Noise static at enrollment**, before the request and never in it: `noiseStaticPrivateKey` (PKCS#8, base64url) and `noiseStaticPublicKey` (raw 32 bytes, base64url) ride in the enrollment record, -landing exactly where `hostToken` does (`SECURITY.md` -> "Credentials at rest"). +landing exactly where `hostToken` does (`docs/specs/security-remote.md` -> "Credentials at rest"). The Host's local label rides there too, reaching a Client only inside an encrypted outcome. diff --git a/docs/specs/security-audit.md b/docs/specs/security-audit.md new file mode 100644 index 00000000..2a6ed845 --- /dev/null +++ b/docs/specs/security-audit.md @@ -0,0 +1,101 @@ +# Security Audit + +> Owns how the security specs are audited: the schedule and the release gate, the three domains and their prompts, the orchestration, the three outcomes, the reporting step, and the environment that holds `AUDIT_PAT`. Defers what is audited to `docs/specs/security.md` and the specs it names. +> Read `docs/specs/security.md` first. + +## Schedule and gate + +`.github/workflows/security-audit.yaml` audits `docs/specs/security.md` and the specs it names: nightly at `04:21 UTC` (`schedule`), on `workflow_dispatch`, and on the release tag, dispatched by `.github/workflows/release.yml` whose `publish-vscode` job `needs:` it — so no release ships without a passing audit. Dispatched, not `uses:`-called — see `docs/specs/security-ci.md` -> "GitHub Actions Policies". + +- **Must execute every `FAIL IF` as a mechanical check** (`gh api`, grep, file read, or a script run) **and add a qualitative pass** for security holes the specs do not cover. +- **`FAIL IF` lines are grouped by the operation that answers them**: one bullet may assert several properties when a single API call, file read, or script run establishes all of them. **Every clause stays an independent check**, with its own PASS/FAIL and its own evidence; never satisfied in bulk. +- **On any `FAIL IF` violation or BLOCKER-severity finding the workflow opens (or updates) a `security-audit-failure` issue** carrying the report, and exits non-zero; a subsequent passing audit auto-closes it. + +- **FAIL IF** `.github/workflows/security-audit.yaml` is missing or disabled, or any of the three separate things that make it a release gate is gone: the `gh workflow run` dispatch, the `gh run watch --exit-status` that turns a failed audit into a failed job, and `publish-vscode`'s `needs:` edge on that job (rationale). + +## Domains + +**The audit fans out to three subagents with disjoint scopes**; the orchestrator audits nothing itself, spawning them concurrently and merging what they return (rationale). + +**Ownership is by file: every `docs/specs/security*.md` spec is in exactly one domain's scope**, declared as backticked repo paths in the bullet list under the `**Scope` line of its domain file in `.github/audit/`, and enforced by `scripts/spec-lint.mjs`. + +| Domain | Specs | +|---|---| +| `supply-chain` | `docs/specs/security-supply-chain.md` | +| `ci-and-secrets` | `docs/specs/security-ci.md`, `docs/specs/security-audit.md`, `docs/specs/security.md` | +| `application-security` | `docs/specs/security-local.md`, `docs/specs/security-remote.md` | + +**The separation is one of context, not of credential.** `AUDIT_PAT` is a step-level `env:` on the one job, so every subagent inherits it, and only the prompt tells `application-security` not to use it. **Known gap:** a prompt is not a control; a real separation needs a second job outside the `security-audit` environment, passing fragments as artifacts — worth doing, not done. Three contexts each *read* less; none *holds* less. + +**The domains do not all run on the same model**: the mechanical two on the session default, `application-security` on Opus (rationale). + +**The prompts live in `.github/audit/`, not inline in the workflow**, three times over: `scripts/security-audit-local.sh` runs the audit from those same files, so the local loop cannot drift from the nightly; changes review as markdown diffs, not YAML block-scalar churn; and the scope blocks are read as markdown, not as prose embedded in YAML (rationale). + +**The qualitative scopes are stated by subtraction, so adding a directory cannot orphan it** (rationale). + +- `ci-and-secrets` — `.github/` (including `.github/audit/`), `.config/`, `.claude/`, `.vscode/`, `scripts/`, and `website/public/`, plus any code anywhere that touches a secret (rationale). +- `supply-chain` — the dependency graph, the lockfile, and all of `website/` except `website/public/`, stated as a subtraction rather than as named subdirectories. `website/scripts/generate-deps.js` is in that set, so the generator behind the disclosed snapshot is audited whole, not just its `productDependencyFilters` array (rationale). +- `application-security` — **everything else**, worked out from `ls -A` rather than from a list, including `.impeccable/`. **Dotfile directories are named explicitly wherever they land**, here and in the prompt files. **The subtraction is recursive**: where another domain claims a subdirectory rather than a whole tree — as both do inside `website/` — the remainder of that tree belongs here. + +- **FAIL IF** a `docs/specs/security*.md` spec is in no domain's scope, or in two, or a scope names a file that does not exist (rationale). +- **FAIL IF** the audit stops fanning out to a dedicated `application-security` subagent scoped to `docs/specs/security-local.md` and `docs/specs/security-remote.md`, or that scope is merged back into a context that also carries the supply-chain or CI domains (rationale). +- **FAIL IF** `application-security` does not run on a stronger model than the mechanical domains, in **both** `.github/workflows/security-audit.yaml`'s `--agents` and `scripts/security-audit-local.sh` (rationale). +- **FAIL IF** `.github/audit/` is missing a prompt file the workflow names, or `scripts/security-audit-local.sh` stops running the audit from those same files (rationale). +- **FAIL IF** the union of the subagents' qualitative scopes does not cover every top-level path in the repository (rationale). +- **FAIL IF** `.github/audit/` or `.vscode/` is outside **every** consumer of `.github/workflows/workflow-audit.yaml`'s diff window — the commit list, `own_changes`, and both classifiers' refusals, whose half is *derived* from the single `WINDOW` array (`"${WINDOW[@]:1}"`). The security specs are deliberately *not* watched there (rationale). + +Source of truth: `--agents` in `.github/workflows/security-audit.yaml`; `run_domain` in `scripts/security-audit-local.sh`. + +## Orchestration + +**Subagents launch in the background** — the Task tool returns an id, not a report — so an orchestrator that ends its turn to await a completion notification ends the whole run: one headless turn, nothing resumes it (rationale). + +- **The job's `timeout-minutes: 40` stays above the orchestrator's 25-minute wait deadline** (rationale). +- **`--allowed-tools` enforces none of this**: it only auto-approves and removes nothing. `Task`/`Agent` are allowed on purpose; only `Workflow` is denied. +- **Each subagent writes its own report fragment before returning its verdict** — `audit-supply-chain.md`, `audit-ci-secrets.md`, `audit-application.md` — and the orchestrator concatenates them rather than retyping. Fragments upload with the transcript, so an orchestrator that dies mid-merge still ships what the domains found. + +- **FAIL IF** the orchestrator prompt stops requiring a non-turn-ending wait — a Bash `until` loop over the fragment files, re-issued past the ten-minute Bash cap, under a bounded 25-minute deadline **persisted to a file** (`$RUNNER_TEMP/audit-deadline`) rather than recomputed from `now` (rationale). +- **FAIL IF** the orchestrator can report `PASS` while a subagent left no report fragment — nor `FAIL`, unless some domain actually returned one: the prompt writes no status file when a fragment is missing and no domain failed, routing an audit that ran out of time to INCONCLUSIVE. Both exit non-zero and hold the release gate shut (rationale). + +Source of truth: `2. Wait without ending your turn` and `4. The verdict` in `.github/audit/orchestrator.md`. + +## Outcomes and reporting + +**The reporting step distinguishes three outcomes, not two.** Only the literal strings `PASS` and `FAIL` are honored (rationale). + +| Outcome | `audit-status.txt` | Result | +|---|---|---| +| `PASS` | literally `PASS` | open failure issues auto-closed; exit zero | +| `FAIL` | literally `FAIL` | issue filed or updated; exit non-zero | +| INCONCLUSIVE | missing, empty, or anything else | filed under the same label, titled `INCONCLUSIVE`, body reproducing the partial report and saying it is not a security finding; exit non-zero | + +- **`audit-report.md` is always written before `audit-status.txt`** — partial, if the agent runs short — and `audit-status.txt` only once the verdict covers every check. +- **Partial has two shapes**, both named in the INCONCLUSIVE issue: `UNVERIFIABLE` for a check the agent reached but could not determine, inside its domain's fragment; a `_No report …_` placeholder for a domain that never reported. The merged `## Summary` may likewise read `INCONCLUSIVE`. +- **`STATUS` is assigned in exactly two places**: where the status file is parsed, and in the single escalation block, **which orders `FAIL` > `MISSING` > `PASS`** — a dissent can raise `MISSING` to `FAIL` and never the reverse, and a `FAIL` alongside missing or unreadable fragments still reports them. Both fragment guards run unconditionally and only record. +- **The report is truncated to 32,000 characters before posting**, head kept, by `scripts/clamp-issue-body.mjs` (self-tested by `scripts/clamp-issue-body-selftest.mjs`). The call is non-fatal; the `audit-transcript` artifact holds the report in full; `.github/workflows/workflow-audit.yaml` truncates its commit list the same way (rationale). +- **Every run uploads the `audit-transcript` artifact, which is world-readable and not secret-masked** — 14-day retention, deep-linked from failure issues (rationale). + +- **FAIL IF** the `Redact secrets from agent output` step is removed, stops covering any sink that is later published (`audit-report.md`, the three per-domain fragments, and the transcript), or stops failing closed by deleting those files when the redactor itself throws (rationale). +- **FAIL IF** the reporting step writes issue prose per *combination* of conditions rather than one note per condition that holds (rationale). +- **FAIL IF** either fragment guard is gated on the status at all (rationale). +- **FAIL IF** a fragment's first line is not literally `VERDICT: PASS` or `VERDICT: FAIL` and nothing else — the reporting step greps it — or the reporting step stops downgrading a merged `PASS` that contradicts one — **or** stops treating a fragment with no readable verdict as inconclusive. Three cases, not two (rationale). +- **FAIL IF** the audit has been weakened in any other way — e.g. the prompt no longer requires the qualitative pass, a `FAIL IF` can be ignored, the failure-reporting step that opens a `security-audit-failure` issue and exits non-zero has been removed, or the `AUDIT_PAT` pre-check is removed or bypassed. **This bullet is a judgement item, not a checklist**: the examples are the ones that have come up, not the ones that exist (rationale). + +Source of truth: `clampIssueBody` in `scripts/clamp-issue-body.mjs`; `Surface result, file or close issue` in `.github/workflows/security-audit.yaml`. + +## Environment and `AUDIT_PAT` + +The audit job declares `environment: security-audit`, **whose deployment-branch-policy admits only `main` and `v*` tags** — both admin-only by the rulesets in `docs/specs/security-ci.md` -> "Automated Maintainer (tend)", so a write-scoped bot cannot reach its secrets from a feature branch. + +- **Audit changes are iterated on `main` directly**: a `workflow_dispatch` from any other ref is rejected before any step runs. To experiment on a branch, widen the policy temporarily and revert after. +- **`AUDIT_PAT` is required.** A dedicated step verifies the secret is present before the audit step runs — after the checkout and install, not literally first — and refuses to continue otherwise (rationale). +- **The PAT is fine-grained and read-only**: `Administration` + `Secrets` + `Environments`, scoped to `diffplug/dormouse` only, minted on an admin's account, stored env-scoped. +- **No step may ever print `$AUDIT_PAT` or `$CLAUDE_CODE_OAUTH_TOKEN`.** The prompt passes the PAT only through an unexpanded `GH_TOKEN=` prefix, and `gh api` responses never carry secret values (rationale). + +```bash +gh secret set AUDIT_PAT --env security-audit --repo diffplug/dormouse --body 'github_pat_…' +``` + +- **FAIL IF** the step that verifies `AUDIT_PAT` is provisioned before the audit runs is removed or bypassed (rationale). + +Source of truth: `Verify AUDIT_PAT is provisioned` in `.github/workflows/security-audit.yaml`; `Never print a secret value` in `.github/audit/_preamble.md`. diff --git a/docs/specs/security-audit.rationale.md b/docs/specs/security-audit.rationale.md new file mode 100644 index 00000000..8eef5648 --- /dev/null +++ b/docs/specs/security-audit.rationale.md @@ -0,0 +1,65 @@ +# Security Audit — rationale + +## Schedule and gate + +The three release-gate pieces are named separately because they break independently: dropping `--exit-status` alone un-gates the release while leaving a green grep for "invoked". + +## Domains + +One context holding all three subject matters degrades application security — the newest domain, with the most code behind it, and the easiest to crowd out with API responses. + +Folding the application-security scope back into a shared context is how that spec stops being audited without anyone deciding to stop auditing it. + +The model split is where the findings that needed real reasoning came from: tracing a relay-minted `clientId` to a keystroke-injection path, and working out that an eight-character device fingerprint carried ~40 bits rather than ~48 because a P-256 point's leading byte is constant. The other two domains run a generator, read an API response, and compare a pin. + +Both sides of the local split are pinned, not only the strong one. Unpinned, the mechanical domains inherit whatever the operator's default is — not necessarily weaker: on a machine defaulting to `opus[1m]` it is *stronger*, inverting the relation and turning the local loop into something other than a rehearsal of the nightly. CI gets this free, its session default being Sonnet with one override. + +A local runner with its own copy of the prompts drifts, invisibly, until a nightly disagrees with a local pass. + +Ownership used to be by `## ` section of a single `SECURITY.md`, checked by a grep over the prompt files. That worked only because the headings sat in markdown: inline in the workflow, YAML block-scalar wrapping split `## Automated Maintainer (tend)` across two lines and the check matched nothing. Ownership is now by file, checked by `scripts/spec-lint.mjs`. A spec owned by none is unaudited; one owned by two produces contradictory verdicts. + +The per-domain scopes replaced a single roving "flag any other security hole you find", so anything no domain names is nobody's job. The first version silently orphaned `canopy/`, `.claude/` (itself named as a prompt-injection surface), `docs/`, the root files, and all of `website/` outside `src/data/` — which includes the Tauri updater manifest that shipped apps fetch. Naming `website/src/` and `website/scripts/` left `website/`'s build config owned by nobody, the same shape one level down; hence the subtraction, and hence `website/scripts/generate-deps.js` called out, so the generator behind the disclosed snapshot is audited and not just its `productDependencyFilters` array. + +`website/public/` sits with `ci-and-secrets` because the updater manifest is a release artifact rather than marketing, and `.vscode/` because `.vscode/tasks.json` can carry `"runOn": "folderOpen"`, which executes on checkout. No such task exists today; adding one should be a finding. + +Dotfile directories are named explicitly because a catch-all has twice been read as not covering them: `.vscode/` and `.impeccable/` (the design-token snapshot behind `DESIGN.md`) came to be owned by nobody after the first split named paths explicitly. An enumeration goes stale the moment a path is added, so the remainder clause is recursive. + +On the `workflow-audit.yaml` diff window: widening one consumer without the others is worse than not widening at all — `git log` matches the commit, `own_changes` returns nothing, the empty-list `continue` swallows it, and the bullet claims a coverage that does not exist. The prompts decide what gets audited and by whom, and `.vscode/tasks.json` can execute on folder open; both are changes to the security automation, which is also why `.config/tend.yaml` is in that window. The security specs are left out on purpose: that job catches code executing from a branch nobody reviewed, and a `FAIL IF` is inert until merged to `main`, which is admin-gated — the watch would add no coverage over PR review while reporting a commit on nearly every security PR. + +## Orchestration + +Run 32618922852 passed all 21 mechanical checks, handed the qualitative pass to two background subagents, then ended its turn to "wait for the completion notification" — which in a headless SDK run terminates everything, discarding the subagents and leaving both output files unwritten. A clean audit blocked the release gate for $5 and no verdict. Runs 31927560706 and 32100728239 are the same shape: SDK success reported, `Write` never called. + +The fix is not to stop delegating. `--allowed-tools` only auto-approves and removes nothing, which is why the tools were available in the first place; no allowlist stops an agent ending its turn. + +The deadline is persisted because one longer than the ten-minute Bash cap cannot fire inside a single call: a re-issued loop that recomputes it from `now` never reaches it, so the bound is written down but never binds, and only the runner's cancellation ends the wait. `RUNNER_TEMP` carries no fallback on purpose — a repo-root fallback would survive between hand-runs and hand an already-expired deadline to the next one. + +At `timeout-minutes: 20` the runner cancelled the job before the 25-minute deadline could fire, so the graceful "give up and report what the domains found" path was unreachable and every overrun landed as INCONCLUSIVE. The 40-minute slack also covers the merge, verdict, redact, upload, and reporting steps after the wait. + +A missing fragment is indistinguishable, in the merged report, from a domain that found nothing, and only one of those is safe to publish a release on. + +## Outcomes and reporting + +Collapsing the inconclusive case into `FAIL`, as the step originally did, filed an identical issue for "the repo is insecure" and "the auditor stopped early". + +GitHub rejects an over-long issue body outright; that rejection lands on a `set -e` step *after* the verdict is decided, and the finding then reaches no issue and no comment — only a red run and an artifact that expires. Truncation keeps the head because that is where the verdict and the links are, and the clamp call is non-fatal so a failure of the helper cannot reopen the window it closes. + +Issue prose per combination of conditions cannot be kept correct by fixing combinations. Four consecutive review rounds found the same defect in different clothes — an arm whose text was true only of the states that could reach it, made false by the next gate that widened. A note claiming nothing about the other conditions cannot be invalidated by a new one. + +Gating a fragment guard on the status produced the same defect three times: gated on `PASS`, one empty fragment silenced the dissent check; widened to `!= FAIL`, an orchestrator that wrote `FAIL` itself silenced both, so a domain that left no report beside a real finding appeared nowhere at all. Recording what is true of a run and deciding its verdict are separate jobs. + +Existence is not agreement. The missing-fragment guard catches a domain that produced nothing; the verdict-line guard catches one whose `FAIL` the merge lost, which is worse, because `PASS` closes the open failure issue and opens the release gate. A fragment the check cannot read must not fall through to an unchallenged `PASS` either — that puts the verdict back on a prompt having been followed, the thing the guard exists to stop being the control. + +The redaction step is the only thing between an accidental `printenv` and a world-readable artifact, and until its `FAIL IF` existed nothing would have tripped on its deletion. Its sinks are deleted rather than truncated on error because `: >` has to open the file and so fails on exactly the unreadable file that made the redactor throw, whereas `rm` needs only the directory. + +Without the transcript a run that produces no verdict is undiagnosable: `claude-code-action` keeps tool output out of the step log on purpose and the runner is ephemeral. World-readable is consistent with the audit reports already posted to public issues; `***` masking applies to step logs, not to artifact contents. + +Two weakenings found by the audit's own first run were covered by no example in the judgement bullet, and both became their own bullets. + +## Environment and `AUDIT_PAT` + +A bot-pushed feature branch cannot reach the audit job at all — GitHub rejects the run before any step starts — so `AUDIT_PAT` cannot be exfiltrated through a hand-authored workflow on a non-admin-gated ref. + +Without the PAT the audit cannot read the administration endpoints behind ruleset bypass actors, repo-level secret listing, and environment policies, so the specs it enforces would be unenforceable in their key sections. + +Passing the PAT only as an unexpanded `GH_TOKEN=` prefix is a convention, not a control: the agent holds unrestricted Bash and audits code that touches secrets, so one `printenv` or one `set -x` would publish an admin-read PAT for the artifact's whole retention. diff --git a/docs/specs/security-ci.md b/docs/specs/security-ci.md new file mode 100644 index 00000000..aac08ab2 --- /dev/null +++ b/docs/specs/security-ci.md @@ -0,0 +1,106 @@ +# CI and Release Security + +> Owns the posture of GitHub Actions, the bot maintainer, and the two release paths — what each identity can reach and what stays admin-gated. Defers the release procedure and the secrets table to `docs/specs/deploy.md` -> "Environment / secrets", and the audit machinery to `docs/specs/security-audit.md`. +> Read `docs/specs/security.md` first; `docs/specs/security-audit.md` says how the `FAIL IF` lines here are run. + +## GitHub Actions Policies + +**Must pin every action by commit hash, not version tag, in every workflow this repository authors**; Renovate updates the hashes. The one exception is `tend-*.yaml`, generated by an upstream tool and carrying its tag pins — cost and acceptance under "Upstream compromise". + +**Agent-managed workflows are `tend-*.yaml`, `.github/workflows/workflow-audit.yaml`, and `.github/workflows/security-audit.yaml`.** They implement the repo's automation and self-audit infrastructure, and are exempt from the two rules below because they must modify issues, PRs, or code, or fetch an OIDC token; "Automated Maintainer (tend)" bounds their scope. + +**Release audit dispatch.** The `security-audit` job in `.github/workflows/release.yml` holds `actions: write` and **may use it solely to** dispatch `security-audit.yaml` on the release tag and watch that run, gating the VS Code publish on the result (rationale). Blast radius: that job's `GITHUB_TOKEN` may start or cancel workflow runs in this repo, but cannot reach env-scoped secrets, merge to `main`, or push tags, and `release.yml` runs only on admin-gated `v*` tags. + +- **FAIL IF** `pull_request_target` appears in any `.github/workflows/**` file other than `tend-*.yaml`. +- **FAIL IF** a non-agent-managed workflow has **effective** write permissions other than the explicitly scoped release provenance permissions `id-token: write` and `attestations: write`, or the `actions: write` granted to the `security-audit` job in `release.yml` (see "Release audit dispatch"). Effective as in the agent-managed bullet below. + +## Automated Maintainer (tend) + +This repository runs the [tend](https://github.com/max-sixty/tend) agent harness as the GitHub user `dormouse-bot`: it reviews PRs, triages issues, fixes CI failures, regenerates its own workflow files nightly, responds to mentions, and polls its notification feed. A prompt injection in that harness reaches three secrets, and **none escalates directly into malicious content on the `main` branch or into any deployment-related secret** — those paths stay admin-gated. + +| Secret | What a compromise buys | What bounds it | +| --- | --- | --- | +| `TEND_BOT_TOKEN` (worst case) | full `repo` + `workflow` write *as a trusted collaborator*: issue/PR spam, force-pushing or deleting feature branches, persistent compromise by authoring new workflows — also how `CHROMATIC_PROJECT_TOKEN` is reached | cannot itself merge to `main`, push tags, or reach env-scoped secrets; new workflows are caught by `.github/workflows/workflow-audit.yaml`; the trusted identity can still social-engineer an admin toward a `main` merge | +| `CLAUDE_CODE_OAUTH_TOKEN` | Anthropic API-credit abuse | the bot account's spend limit | +| `CHROMATIC_PROJECT_TOKEN` | corrupted snapshot testing | rotation; abuse is visible in Chromatic's own dashboard | + +**Prompt-injection through user-supplied content.** The harness reads PR descriptions, code diffs, issue text, comments, and CI logs — all attacker-influenceable. **Assume a malicious prompt can push a workflow that sends a repo-level secret to an external URL**: admin-gated release paths stay sealed, but a workflow on a bot-pushed feature branch still executes with repo-level secrets in scope. + +**Instruction files are part of that surface.** On a fork PR the merge ref `tend-review.yaml` checks out under `pull_request_target` is attacker-controlled, the *project instructions* Claude Code loads (`CLAUDE.md`, `AGENTS.md`, `.claude/`, `.mcp.json`) included. **Must revert those paths from the reviewed base branch before the agent starts**, so instructions come from code a maintainer merged — tend's `shared/steps/restore-sensitive-config.sh` does it. **That control's completeness is a property of the pinned upstream version, not of anything in this repo** — hence the `0.1.19` floor below (rationale). + +**Credential isolation bounds an injection.** The agent runs as a separate, non-sudo sandbox user behind a local credential-injecting proxy. **`TEND_BOT_TOKEN` and the Anthropic credential must live only in that proxy — never in the agent's environment, its disk, or `.git/config`** — setup strips the credential `actions/checkout` persists there, so an injection can make the bot *act* within its permissions, never read a token value out (rationale). + +**Bot collaborator authority.** `dormouse-bot` is a direct repo collaborator with `push` permission and 2FA enforced by org policy; its PAT (`TEND_BOT_TOKEN`) carries the scopes `repo`, `workflow`, `notifications`, `write:discussion`, `gist`, and `user`. `workflow`, required for the nightly regeneration of `tend-*.yaml`, is the same scope that lets the harness add arbitrary new workflow files. **Ref-protection rulesets restrict where bot-controlled commits can land but do not gate workflow execution on feature branches.** + +**The notifications poll widens what the bot sees.** `tend-notifications.yaml` alone takes its subjects from the bot account's own unread feed rather than from an event payload or a fixed scheduled sweep, and its inlined pre-check `PUT /repos/diffplug/dormouse/subscription -F subscribed=true` runs every `*/15` cycle to keep that feed wide. `dormouse-bot` watches this repository today, so the feed is *all repository activity* rather than *threads the bot has participated in*, and whether to speak moves from the trigger to the prompt — **a prompt is not a control** (`docs/specs/security-audit.md` -> "Domains" draws the same point; rationale). What still bounds the bot on an undispatched thread is `author_association` tiering and the admin gate on `main`. **Never expect unwatching by hand to stick**: the PUT is idempotent and repeated every cycle, so the lever is `tend-notifications.yaml`, not the Unwatch button. + +**Reachable repo-level secrets.** `CHROMATIC_PROJECT_TOKEN` is reachable by any workflow the bot can author: `.github/workflows/chromatic.yml` is `pull_request`-triggered, and GitHub environment policies cannot distinguish a bot from a human contributor at the ref level. **Accepted, with rotation as the mitigation** — the token is scoped to a single project and easy to rotate. `OVSX_PAT` and `VSCE_PAT` are protected: they live only in the `vscode-extension-publish` environment, whose deployment-branch-policy admits only `v*` tags, and tag creation is admin-only. + +**Inert secret plumbing.** Every generated `tend-*.yaml` passes `anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}` to `max-sixty/tend/claude`; no such secret exists at repo or org level, so it resolves to the empty string and the harness authenticates with `CLAUDE_CODE_OAUTH_TOKEN` instead. **Never create an `ANTHROPIC_API_KEY` secret**: the input cannot be deleted locally, so the `FAIL IF` below is what makes adding one a deliberate, documented expansion of the bot's reach (rationale). + +**Org-level secrets.** An org secret shared with this repo is reachable by any workflow the bot can author, exactly like a repo-level one, and does not appear in this repo's own secret listing — `gh api repos/diffplug/dormouse/actions/organization-secrets` is the check. **None are visible here today** (rationale); **must re-evaluate and name any that becomes visible before accepting it**, and the `FAIL IF` below admits none. + +**Upstream compromise.** Every generated workflow references tend's action as `max-sixty/tend/claude@` — a **tag**, not a commit SHA, and mutable by whoever owns that repository, so upstream can change what our workflows execute with no commit landing here and `workflow-audit.yaml` seeing a byte-identical file. **A real residual, accepted** (rationale). **The version pin bounds *deliberate* upgrades, not a hostile upstream**; `uvx tend@latest` runs only at install and during nightly regen, so a compromise of that path affects the next re-run, not the in-flight workflows. + +**Audit visibility.** `.github/workflows/workflow-audit.yaml` walks nightly every commit touching `.github/workflows/`, `.config/tend.yaml`, `.github/audit/`, or `.vscode/` since its previous successful run — **across all branches, not just `main`**, so a workflow pushed to a feature branch is seen even though it never opens a PR. **This enumeration and the job's `WINDOW` must name the same paths** (rationale). It reports the *unexplained*, classifying out two routine sources on independently checked provenance and content: + +- A **Renovate pin bump** — a valid GitHub-signed commit with `author.login == "renovate[bot]"` and `committer.login == "web-flow"`, associated only with Renovate-authored PRs, changing nothing but the ref of an already-referenced action (rationale). Residual: the ref Renovate selected inside that action's own repo, the trust every Renovate bump already rests on. +- A **tend regeneration** — byte-for-byte reproducible from `uvx tend@ init` at the version in the files' own header, not touching `.config/tend.yaml` in the same commit (rationale). + +- **Identity is not evidence here at all**: `TEND_BOT_TOKEN` is precisely the credential in question. +- **Both classifiers fail open** — any error or ambiguity reports the commit. +- **Must still report commits already merged to `main`**; review is not proof (rationale). +- **Never deduplicate by branch or file set**, or a benign change is reported once and a later force-push of malicious content to the same files passes unremarked. +- **The liveness check below keys on a successful run, not on an issue existing** — a silent run is the healthy steady state; a bot push that disables or modifies the audit is caught in the next successful run's diff window. + +**Two known evasions of that diff window**, both in how the window is computed rather than in what it classifies, **neither closed today**: + +- `git log --all --since` applies the server-set lower bound against the **committer date**, which the pusher sets freely: `GIT_COMMITTER_DATE=2020-01-01` on a commit adding a workflow hides it from every future window. +- A branch pushed, run with repo-level secrets in scope, and deleted before the nightly fetch is in no window at all — the audit only ever sees refs that still exist. + +Closing both means keying on server-observed ref changes — the repository activity API's pushes, force-pushes, and deletions, with server timestamps and before/after SHAs — rather than on the commit graph as the client presents it. + +- **FAIL IF** `.github/workflows/workflow-audit.yaml` starts deriving its lower bound from anything the pusher controls; it must stay the previous successful run's server-set `created_at` (rationale). The `--since` filter is the known evasion above. +- **FAIL IF** either admin-gating ruleset is missing or weakened. `Merge access` must target `~DEFAULT_BRANCH`, block nothing beyond `update`, and carry admin (`RepositoryRole` actor `5`) as its sole bypass actor; `Tag operations` must target `~ALL` tags, block both `creation` and `update`, and carry the same admin-only bypass. +- **FAIL IF** `dormouse-bot` holds `maintain` or `admin` on this repository. `GET /collaborators/dormouse-bot/permission` spells `push` as `write` in both `permission` and `role_name`, so check that neither of those two roles appears rather than string-comparing against `push`. +- **FAIL IF** any GitHub environment's deployment-branch-policies admit a ref that is not admin-gated by the `Tag operations` or `Merge access` rulesets. Today: `vscode-extension-publish` and `release-attest` (`v*` tag, admin-only via `Tag operations`); `security-audit` (`main` admin-only via `Merge access`, plus `v*` tag); `tend` (`main` only, admin-only via `Merge access`). +- **FAIL IF** the secret inventory departs from this placement (rationale). One pass over `actions/secrets`, `actions/organization-secrets`, and each environment's secret listing answers every line: + - `AUDIT_PAT` — in `security-audit`, absent at repo level. + - `TEND_BOT_TOKEN` — in `tend`, absent at repo level. + - `CLAUDE_CODE_OAUTH_TOKEN` — in **both** `tend` and `security-audit`, absent at repo level. Environments do not inherit each other's secrets, so a rotation must set both. + - `OVSX_PAT`, `VSCE_PAT` — in `vscode-extension-publish` only, absent at repo level. + - `ANTHROPIC_API_KEY` — absent at repo *and* org level, for as long as `tend-*.yaml` passes `anthropic_api_key` to `max-sixty/tend/claude` (see "Inert secret plumbing"). + - `release-attest`'s own secret listing is empty **and** it declares no environment variables — an empty environment is what keeps `id-token: write` the only credential `release.yml`'s two build jobs can reach. + - No org-level secret visible to this repository at all (see "Org-level secrets"). +- **FAIL IF** `CHROMATIC_PROJECT_TOKEN` is missing from `secrets.allowed` in `.config/tend.yaml` (rationale). +- **FAIL IF** `.github/workflows/workflow-audit.yaml` is missing, disabled, or has not produced a successful run in the last 48 hours. Treat one skipped run as a signal rather than as slack in the window (rationale). +- **FAIL IF** any `tend-*.yaml` pins `max-sixty/tend` below `0.1.19`, the release that pins instruction files by glob at any depth (rationale). +- **FAIL IF** any `tend-*.yaml` workflow uses an unpinned action reference (e.g. `@main`, no version). Tag pins are accepted inside `tend-*.yaml` alone, the file being owned by the upstream generator; every other workflow — agent-managed or not — must SHA-pin per "GitHub Actions Policies". +- **FAIL IF** any job in an agent-managed workflow has **effective** `GITHUB_TOKEN` permissions beyond `contents: write`, `pull-requests: write`, `issues: write`, `id-token: write`, `actions: read`, or any `read` permission. Effective, not declared: a job with no `permissions:` block inherits the repository default, so this check is meaningful only together with the next one (rationale). +- **FAIL IF** `default_workflow_permissions` for this repository is not `read`, or `can_approve_pull_request_reviews` is not `false` (`gh api repos/diffplug/dormouse/actions/permissions/workflow`) — the backstop for every permission bullet in this spec (rationale). + +Source of truth: `WINDOW` in `.github/workflows/workflow-audit.yaml`. + +## VS Code Extension Releases + +The extension is published by GitHub Actions, and the publishing secrets `VSCE_PAT` and `OVSX_PAT` live only in a protected GitHub environment. **That environment must require a manual human approval, never from the account that triggered the publish**, so no single compromised tag or maintainer account publishes a new extension version without an explicit release approval. + +- **FAIL IF** `.github/workflows/release.yml` is missing the `vscode-extension-publish` environment on the VS Code publish job, or if `VSCE_PAT` / `OVSX_PAT` are referenced anywhere under `.github/workflows/**` from a job not bound to that environment. The second clause is repo-wide on purpose (rationale). +- **FAIL IF** `.github/workflows/release.yml` uses production desktop signing secrets in CI, or stops generating an ephemeral Tauri updater key for unsigned CI artifacts. + +## Desktop Releases + +**Desktop releases are not fully automated.** GitHub Actions builds unsigned artifacts, publishes attestations and hash manifests, and uploads them for local signing; final deployment is manual through `scripts/sign-and-deploy.sh`, which **must verify the CI artifact attestations and the recorded SHA-256 hashes before signing**. The local machine then signs each platform and uploads the final release assets — Windows Authenticode needs a physical YubiKey and the signing PIN, and macOS signing and notarization also run locally, outside GitHub Actions. **CI must never hold the production Tauri updater private key**: an ephemeral key makes Tauri emit updater-shaped unsigned artifacts, and **Tauri updater signing is applied locally after OS signing**, so the updater signs the final bundles users download. Procedure: `docs/specs/deploy.md` -> "Two-stage pipeline". + +**Signing credentials and argv.** Three secrets reach `scripts/sign-and-deploy.sh` through the environment, and argv is readable via `ps` by any process on the machine for the lifetime of a call (rationale). + +| Secret | Where it travels | What bounds it | +| --- | --- | --- | +| `TAURI_SIGNING_PRIVATE_KEY` | **env-only**; `tauri signer sign` documents `--private-key` as falling back to that variable | — | +| `EV_SIGN_PIN` | argv — `jsign` takes `--storepass` as a literal option value only, with no environment or file indirection | local `ps` for one call's duration, for a PIN inert without the physical YubiKey it unlocks | +| `APPLE_SIGN_PASS` | argv — `xcrun notarytool` offers no environment form either | the weakest of the three: unlike the PIN a standalone credential, and `--wait --timeout 30m` holds it on the command line up to half an hour per architecture | + +**Known gap.** The documented remedy for `APPLE_SIGN_PASS` is `notarytool store-credentials` plus `--keychain-profile`, moving the exposure to one short call instead of every submission. Not yet done — it changes the release runbook and cannot be exercised without live Apple credentials. + +- **FAIL IF** `scripts/sign-and-deploy.sh` stops doing any of three things: verifying GitHub artifact attestations, verifying artifact SHA-256 manifests, or using PIV-backed Windows signing. +- **FAIL IF** `TAURI_SIGNING_PRIVATE_KEY` is passed on a command line anywhere in `scripts/sign-and-deploy.sh` rather than through the environment. `jsign --storepass` is the one documented exception, for the reason in the table above. diff --git a/docs/specs/security-ci.rationale.md b/docs/specs/security-ci.rationale.md new file mode 100644 index 00000000..3fc7be32 --- /dev/null +++ b/docs/specs/security-ci.rationale.md @@ -0,0 +1,59 @@ +# CI and Release Security — rationale + +> Informative companion to [security-ci.md](security-ci.md): evidence keyed by that spec's headings. Nothing here is normative. + +## GitHub Actions Policies + +**Why the release audit is dispatched, not called.** `claude-code-action` rejects the `push` event that a tag-triggered `workflow_call` would inherit, and `GITHUB_EVENT_NAME` is a default variable that cannot be overridden, so a `workflow_dispatch` run is the only way to exercise the audit under a supported event. That is why the `security-audit` job in `release.yml` holds `actions: write` at all — the one write permission a non-agent-managed workflow is granted beyond release provenance. + +## Automated Maintainer (tend) + +**Why instruction files are a class of their own.** They are not read as data the way a diff is; Claude Code loads them as authoritative guidance, which is what makes a fork PR's copy of them a different class of input from the fork's code. + +**The `0.1.18` gap, reported from this audit and now fixed.** At the previously pinned `0.1.18` the revert list was a flat, root-relative `SENSITIVE` array naming `CLAUDE.md` but no `AGENTS.md` at all — and this repo keeps its instructions in `AGENTS.md` with `CLAUDE.md` as a one-line `@AGENTS.md` pointer, so the control reverted a pointer and left the content it pointed at attacker-controlled. The fix ([max-sixty/tend#1005](https://github.com/max-sixty/tend/pull/1005), merged 2026-08-22, released in `0.1.19` on 2026-08-26) replaces that list with pathspec globs — `':(glob)**/AGENTS.md'`, `':(glob)**/CLAUDE.md'`, `':(glob)**/.claude/**'` — which `restore-sensitive-config.sh` passes to `pin_to_base`, covering every depth rather than a hand-enumerated set of root paths. This repo regenerated onto `0.1.19`, so the gap is closed here rather than merely closable. + +**The local remedy if it ever regresses.** The nightly regen overwrites the *workflow*, not this repository's instruction files, so moving the instruction body into `CLAUDE.md` and dropping the pointer would close it with no upstream dependency, at the cost of the filename convention other agent harnesses read. + +**What credential isolation does and does not buy.** An injected instruction can make the bot *act* within its permissions — comment, push a feature branch — but cannot read the token value out and exfiltrate it. The worst-case table is therefore about what the bot's identity can do, not about the secret escaping. + +**How every other generated workflow picks its subjects.** An event payload names the PR, issue, or comment (`tend-review`, `tend-triage`, `tend-mention`, `tend-ci-fix`), or a scheduled sweep works a fixed list — recent commits, dependency PRs, last night's runs. + +**Evidence that the subscription PUT has taken effect.** Asking as the bot (`gh api repos/diffplug/dormouse/subscription` → `subscribed: true`), corroborated without the bot credential through the public `GET /repos/diffplug/dormouse/subscribers` listing. + +**Watching is not a permission change.** `notifications` is already in the PAT scopes, and watching reads rather than writes. What sits in the prompt instead of the trigger is the notifications skill, which says to respond on an existing thread only when the activity addresses the bot, while a new issue or PR from an external author may be triaged or reviewed outright. + +**Why the inert `anthropic_api_key` input is handled by enforcement, not deletion.** The input is upstream-generated and cannot be removed locally without being overwritten by the next nightly regen. The moment anyone adds an `ANTHROPIC_API_KEY` secret for an unrelated reason, eight bot-triggered workflows would start reading it with no code change and no review. + +**The org secrets that no longer need accepting.** `BUILDCACHE_USER` and `NEXUS_USER` were org-wide shares — visible to every `diffplug` repository, not grants made to this one — and were previously accepted on the grounds that they are usernames rather than the paired credentials. They have since been narrowed to `selected` visibility over the repositories that actually consume them, which excludes this one, so the acceptance no longer has to be made. Every `diffplug` org secret is now `selected`, and none lists `diffplug/dormouse`. + +**Why the mutable upstream tag is accepted.** The file is generated — a hand-edited SHA is overwritten by the next nightly regen, so pinning locally is not durable — and the trust it represents is the same trust the harness already has: tend runs the agent that holds `TEND_BOT_TOKEN` either way. + +**What unites the four `WINDOW` paths.** Each executes from a branch nobody reviewed — a workflow on a bot push, a `folderOpen` task on checkout, a prompt that decides what the nightly audit even looks at. `.config/tend.yaml` is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. The two enumerations have to agree because a path added to one without the other leaves a reader checking the `FAIL IF` against a paragraph that contradicts it. + +**Why signed author/committer is the Renovate classifier's provenance control.** GitHub's automatically signed `createCommitOnBranch` mutation binds the author to the authenticating credential and does not permit the caller to supply the author or committer, while REST paths that permit those fields require the caller to supply the signature; requiring `web-flow` therefore rejects both a caller-supplied Renovate author and a commit signed by another identity. PR authorship is independent server-side corroboration, and the content test adds a separate bound by requiring the diff to express nothing but a new ref for an action already referenced by name. + +**Why the tend-regeneration classifier refuses a commit that also edits `.config/tend.yaml`.** The config's values land verbatim in the generated YAML: such a commit would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. + +**Why merged commits are still reported.** Review is not proof — the social-engineering path ends in an admin merge. + +**Why the lower bound stays server-set.** It is what stops the window from being attacker-movable in both directions at once; the `--since` committer-date comparison already moves one end. + +**Why the secret inventory is placement-checked.** Env-scoping is what stops a workflow pushed to an excluded branch from reading a secret, so a repo-level copy of an environment secret reopens exactly what the environment gate closes. The `release-attest` environment exists only to bound the ref a provenance OIDC token can be minted from. + +**Why `CHROMATIC_PROJECT_TOKEN` is listed in `secrets.allowed`.** The entry is an explicit acknowledgment that the bot can read that token. + +**Why 48 hours is thinner than it reads.** `workflow-audit` runs at 07:13 UTC and the security audit at 04:21, so the steady state is ~21.5h and a single skipped run lands at ~45.5h — inside tolerance by under three hours, which is why one skipped run is a signal rather than noise. + +**Why the `0.1.19` floor matters.** The revert list is upstream code: the protection this repo gets is whatever the pinned version implements, and a downgrade reopens the fork-PR instruction-injection path with no visible change to any file here except a version number. + +**Why "effective, not declared" is the whole point.** A job that declares nothing textually "grants" nothing while its token carries nine write scopes. And with `default_workflow_permissions` at `write`, one regenerated workflow that omits a `permissions:` block silently reopens what every permission bullet closes — the repository setting is the only durable fix, since a YAML edit does not survive the nightly regen. + +## VS Code Extension Releases + +**Why the second clause is repo-wide.** Scoping it to `release.yml` would let a `VSCE_PAT` or `OVSX_PAT` reference from another workflow file pass unremarked. + +## Desktop Releases + +**Why argv exposure matters more here than usual.** `pnpm exec` means a dependency's lifecycle scripts share that session, so a `ps` reader is not hypothetical. + +**Why one of the three is env-only and two are not.** `TAURI_SIGNING_PRIVATE_KEY` used to be passed both on argv and through the environment; `tauri signer sign` documents `--private-key` as falling back to the variable, so the argv copy was redundant exposure and was removed. The other two stay on a command line only because their tools offer nowhere else to put them. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md new file mode 100644 index 00000000..d9098119 --- /dev/null +++ b/docs/specs/security-local.md @@ -0,0 +1,187 @@ +# Local Security + +> See `docs/specs/glossary.md` for 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 to `docs/specs/security-remote.md`. +> Read `docs/specs/security.md` first; `docs/specs/security-audit.md` says how the `FAIL IF` lines here are run. + +## Terminal output + +The attacker is any program writing to a PTY. + +**Every value the parser retains is bounded in code points and stripped of +control characters before storage**, and an unterminated OSC is buffered only to +`OSC_INCOMPLETE_LIMIT` (16,384 code units) before it is dropped +(`docs/specs/terminal-escapes.md` -> "Parsing location"). + +**Untrusted PTY output can never write the user's clipboard**: `OSC 52` is +consumed and never re-emitted, with `OSC 50` and every `OSC 1337` +(`docs/specs/terminal-escapes.md` -> "Supported OSCs"). + +**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). + +**Output's whole reach is the screen and the Session's own state** — an alert, a +title candidate, a prompt or command boundary, a CWD, an `OSC 8` region — plus +**exactly three answers Dormouse writes back into the PTY**: `OSC 10/11/12 ; ?` +color, `OSC 99` capability, `CSI > q` device. xterm.js answers standard cursor, +device and focus reports itself +(`docs/specs/terminal-escapes.md` -> "Report filtering on the input side"). + +- **FAIL IF** `isKnownUnsupportedIterm2Osc` in `lib/src/lib/terminal-protocol.ts` stops consuming `OSC 52`, or a parse site stops running `TerminalProtocolParser` before `pty:data` leaves it (rationale). Pinned by `lib/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_LIMIT` and `sanitizeText` in `lib/src/lib/terminal-protocol.ts`, `MAX_CWD_LENGTH` and `boundedCwdValue` in `lib/src/lib/terminal-state.ts`. `COMMAND_LINE_LIMIT` binds *after* the `\xNN` unescape, a 4x bound before it (rationale). +- **FAIL IF** an `OSC 8` activation reaches an adapter's `openExternal` without the confirmation dialog, or the dialog renders an open action for a **deceptive** verdict: `linkHandler` in `lib/src/lib/terminal-lifecycle.ts`, `classifyDisplayMatch` in `lib/src/lib/external-links.ts`, the render branches in `lib/src/components/ExternalLinkModal.tsx`. The classification is pinned by `lib/src/lib/external-links.test.ts`; the *absence* of the open action is not (rationale). + +## Browser panes + +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`. + +**The shim the proxy injects addresses the embedder chain's innermost origin, +never `'*'`**, and 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](#loopback-listeners)'s +business. + +- **FAIL IF** the injected shim targets anything but the embedder chain's innermost origin, or the proxy uses a chain it did not validate in full: `iframeShim` and `normalizeEmbedderOrigins` in `lib/src/host/iframe-proxy-rewrite.ts`, applied in `lib/src/host/iframe-proxy.ts`. Pinned by `lib/src/host/iframe-proxy-rewrite.test.ts` and `lib/src/host/iframe-proxy.test.ts`. +- **FAIL IF** a `VSCodeAdapter` host-channel listener acts on a message before `isHostMessage` (`lib/src/lib/vscode-message-token.ts`) accepts it, or the token stops being minted per serve and attached only by `WebviewChannel.post` in `vscode-ext/src/webview-messaging.ts`: `dor:controlRequest` is one of the shapes a framed page could otherwise claim. The proxy-origin listeners above are guarded by origin, not the token. Pinned by the `host message authentication` block in `lib/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 dor control socket + +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` +(`docs/specs/dor-cli.md` -> "Control-channel security"). + +**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: `/dormouse-dor-/<8 random bytes>.sock`, inside a +per-user directory `lstat`ed 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** `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. Pinned by `standalone/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-`timingSafeEqual` of `proofMatches`. The construction is hand-mirrored between `standalone/sidecar/dor-control-server.js` and `dor/src/control-client.ts`, and only the two proof domains are pinned (rationale). + +## 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, which reaches +loopback exactly as easily as our webview does; **an ephemeral port is not a secret +either** (rationale). + +**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** — and differs in what it does with +the second. Two refuse the request outright. The iframe proxy admits everyone and +instead declines to **vouch** (rationale). **The recognition mechanism differs per +listener because their URLs differ, and the differences are forced, not stylistic**: a +URL token is available to the browser-dev harness, which owns its page's URL, and never +to the iframe proxy (rationale). + +**A third question has no request-header answer at all: who is allowed to frame me?** +A response that confers something on its *embedder* must name that embedder and let the +browser enforce it, which no request header can do — so the webview supplies its own +ancestor chain, not merely its origin (the check walks the whole chain), with each +request for a proxy URL, and that chain is what the proxy's `frame-ancestors` names +(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`) makes the cheap half deterministic — a new loopback bind that does not reference a guard module fails the build — but only in the bind forms it knows: `.listen` positional and options-object, `@hono/node-server`'s `serve({ hostname })`, and `ws`'s `new WebSocketServer({ port | host })`, each proved load-bearing by `scripts/loopback-lint-selftest.mjs`. **Adding a server dependency means adding its bind spelling there**; a host built at runtime is invisible to a regex in any spelling. It also only sees 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`, `.listen(`, `serve(` and `WebSocketServer` rather than trusting this list. 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`. A foreign `Origin` must be forwarded untouched rather than blocked, so the upstream sees the truth and applies its own policy (rationale). +- **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 the `Host` check is what makes DNS rebinding fail. +- **FAIL IF** the iframe proxy **drops** an upstream's `X-Frame-Options` / CSP `frame-ancestors` without **replacing** them with a `frame-ancestors` naming the embedder chain the webview supplied, or injects its shim with any `postMessage` target but that chain's own origin. With no usable chain the proxy must strip nothing and inject nothing, which leaves the caller exactly what the upstream would have given it directly (rationale). +- **FAIL IF** a request bearing a *foreign* `Origin` refreshes 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 *absent* `Origin` must 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 `Origin` rather than dropping it. It needs no `Host` check while the token holds (rationale). +- **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, and 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` — 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). + +Source of truth: the shared rule and predicates — `isLoopbackHost`, `isOwnOrigin`, +`isForeignOrigin` — in `lib/src/host/loopback-guard.ts`. + +## Persisted state + +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.** +`restrict_to_owner` locks `/sessions/` 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 (`docs/specs/standalone.md` -> "Persistence"). 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, and the standalone store is +switched off today, clearing any legacy snapshot at boot. Snapshots older +versions left behind do carry transcripts (rationale). + +**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 writes one file +of its own there, `recovery.json`, at the umask: 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** — +`remote-host.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 +`/dormouse.log`, created and appended with no mode and no ACL, so it +lands at the umask — readable by another local account wherever `` is +shared (rationale). No log call carries PTY bytes; the `dor` control socket path +does. A gap, not an accepted risk. + +- **FAIL IF** `write_session_to` in `standalone/src-tauri/src/lib.rs` stops restricting the `sessions/` directory and the snapshot to the owning user on **every** platform `restrict_to_owner` has an arm for — `0700`/`0600` on unix, and on Windows a DACL protected from inheritance carrying exactly one ACE for the current user, asserted by `restrict_to_owner_leaves_one_owner_only_ace`. That test covers the Windows arm alone; the unix modes and both call sites are pinned by nothing. The mode reaches the temp file *before* any bytes are written (rationale). + +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`. diff --git a/docs/specs/security-local.rationale.md b/docs/specs/security-local.rationale.md new file mode 100644 index 00000000..2fd55b56 --- /dev/null +++ b/docs/specs/security-local.rationale.md @@ -0,0 +1,176 @@ +# Local Security — rationale + +## Terminal output + +What the `OSC 52` strip actually buys. The pinned `@xterm/xterm` registers OSC +handlers for 0, 1, 2, 4, 8, 10, 11, 12, 104, 110, 111 and 112 only — no 52 — and +no clipboard addon is a dependency, so an `OSC 52` reaching xterm today would be +discarded rather than acted on. Dormouse's strip is therefore the barrier it +controls, not the only one standing; xterm's handler table is not ours to keep, +and an addon or a version bump could add 52 without a diff here. + +Why the OSC 633 command line carries two bounds. `COMMAND_LINE_LIMIT` (2048) is +applied by `sanitizeText` *after* `decodeOsc633Value`, because the `\xNN` +unescape re-introduces the control characters the emit side removed. A 4x bound +holds the raw value before the unescape, so an emitter cannot make the decoder do +unbounded work on the way to that cap. + +Why fail-inertly is a rule and not an aspiration. Dormouse reports an +iTerm2-compatible identity to unlock the iTerm2-style sequences it does +implement, so emitters offer it many more than it models; every one of those +arrives from an untrusted program, and the only safe disposition for a sequence +with no behavior behind it is silence. + +Why the `OSC 633` terminator filter is emit-side. The parser scans raw bytes for +the three terminators `findOscTerminator` knows — `BEL`, `ESC \`, the C1 ST — so +a directory name or command line carrying one ends the `633` sequence early and +the remainder arrives as a fresh, fully trusted OSC. Nothing the parser can do +distinguishes that from an emitter that meant it, which is why the boundary is in +the scripts Dormouse ships. `lib/src/lib/terminal-protocol.test.ts` proves the +parser *cannot* defend it: for each of the three terminators it forges an +`OSC 9` notification with the body `PWNED` through an unfiltered `Cwd=`. + +Why the deceptive verdict needs the component read rather than a test reference. +`ExternalLinkModalHost` gates its open call on the URI's `openable` status alone, +and a deceptive link is still openable; the only thing withholding the open +action is which buttons `ExternalLinkModal` renders. No test asserts that +absence, so the property is one render change away from being lost silently. + +## Browser panes + +Why the origin check is not an authenticity check. The iframe proxy serves the +untrusted upstream on the same origin it grants the shim, so `e.origin` cannot +tell a message the shim sent from one the page sent; what the check buys is that +no *other* frame can send them at all. The four shim messages are bounded +downstream instead — exiting passthrough, selecting a pane, an `http:`/`https:` +only `browserSurfaceUrl` behind an open prompt, and a frame-URL reading that may +lie. `use-wall-keyboard`'s leader channel accepts any live grant rather than one +panel's, so a page in one browser pane can exit passthrough while another is +focused. + +Why "the standalone adapters" and not "the standalone webview". The Wall's two +proxy-origin `message` listeners (`use-wall-keyboard.ts`, `IframePanel.tsx`) are +bundled into the standalone webview as well as the VS Code one, so a framed +page's `parent.postMessage` does reach that window. What has no forgeable inbox +is the adapters' host channel, which arrives over Tauri IPC. +`docs/specs/vscode.md` -> "Webview message authentication" scopes it the same +way. + +## The dor control socket + +Why "a process running as the user is the user" is stated rather than assumed. A +`0700` directory and a `0600` file stop another local *account*; neither stops a +process already running under the user's own uid, which can read the socket path +out of its own environment. The same limit is already stated for the Host ACL +store in `docs/specs/security-remote.md`, and stating it here keeps an agent +holding `dor` from being read as a lesser principal than the person at the +keyboard. Note that `docs/specs/dor-cli.rationale.md` names "any process running +as the user" among the attackers the control channel defends against, which the +mechanism does not support; one of the two needs correcting. + +Why the proof construction being hand-mirrored matters. `proveToken` and +`proofMatches` exist twice, in `standalone/sidecar/dor-control-server.js` and +`dor/src/control-client.ts`, and only the two proof *domains* are pinned across +the copies by `lib/src/lib/mirrored-constants.test.ts`. A change to the HMAC +construction or the comparison in one copy breaks the channel loudly; a change +that weakens the comparison in both — a string compare for a `timingSafeEqual` — +breaks nothing visible. + +Where the directory check actually runs. `ensureControlDir` is called once, from +`resolveControlSocketPath` inside `createDorControlServer`, at host start-up — +not on every use. `docs/specs/dor-cli.md` -> "Control-channel security" says +"re-checked on every use", which is true of the peer link's `peerDirIsSafe()` +(re-run per contention round) and not of this one. The bind is what the +channel's life depends on, so a check before the bind is the load-bearing one, +but the two specs should agree. + +## Loopback Listeners + +**What the browser gives an attacker page.** An ephemeral port is not a secret — the +range scans in seconds. 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. + +**Why a URL token is not available to the iframe proxy.** It would land in +`location.pathname` and break client-side routers, and it would not survive onto +root-relative sub-resource requests at all. The browser-dev harness owns its page's URL, +so it can carry one. + +**Why no request header answers "who is allowed to frame me".** An iframe navigation +carries no `Origin`, and `Sec-Fetch-Site` reads `cross-site` for our own webview and for +an attacker page alike, so only an embedder named in `frame-ancestors` and enforced by +the browser distinguishes them. + +**Why the iframe proxy admits everyone.** Vouching for a stranger is what turns a +transparent proxy into an amplifier, so it declines to vouch rather than to admit; +refusing outright would be worse, because forwarding the caller's real `Origin` lets +the upstream apply its own policy. That is also why the upgrade path 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. + +**How the proxy once handed a stranger two privileges.** Dropping an upstream's +`X-Frame-Options` / CSP `frame-ancestors` for everyone gave a page that scanned the +port two things the upstream had refused it: framing a document that answered `DENY`, +and reading that document's live URL and anchor hrefs back cross-origin. No request +header can tell that page apart from Dormouse's webview, which is why the replacement +`frame-ancestors` has to name the embedder chain the webview supplied. + +**Why the listener set is derived, not trusted.** An enumeration goes stale the moment +someone adds a listener — the same failure mode that once left `.vscode/` owned by +nobody. + +**Why the stream relay needs no `Host` check.** Rebinding exists to make +same-origin-looking requests to loopback, which buys nothing against a listener +demanding an unguessable one-shot secret. + +**Why the browser-dev bridge's content-type gate is a security control.** Without it +the endpoint is CORS-simple and needs no preflight to survive, and what it dispatches +is `pty_spawn` with caller-supplied `shell`, `args`, `cwd` and `env`. + +## Persisted state + +Why session snapshots earn the strongest protection on disk. They are +`PersistedWindow` blobs, and historically they carried terminal transcripts — +whatever the user's shells printed, a superset of every other secret in the +install — and they inherited the umask as `0644` until this was tightened. On +Windows, without the DACL the directory keeps whatever `%LOCALAPPDATA%` hands +down, which is never owner-only: always SYSTEM and Administrators, plus whatever +stale entries earlier installs left behind. The mode goes on the temp file before +any bytes are written because the atomic rename preserves it; tightening after +the rename would leave a window where the transcript is world-readable. + +What the current writers actually store. `normalizeSessionV3` in +`lib/src/lib/session-types.ts` destructures `scrollback` out of every pane on +read, `saveSession` never emits it, and standalone's `PERSIST_SESSION` is +`false`, so the shipped app writes no snapshot at all and clears a legacy one at +boot. `docs/specs/standalone.md` -> "Persistence" and the `restrict_to_owner` +doc comment in `standalone/src-tauri/src/lib.rs` both still say the blob carries +transcripts; that is true of files older versions left behind and false of +anything written now. + +Why the peer-link token is listed here. It is a `randomUUID()` in the VS Code +extension's global storage, and its own comment says it is the only thing between +another local process and this installation's terminals — a local credential at +rest that the remote spec's credentials table does not cover, because nothing +about it is remote. Both of its controls are unix-only: `peerDirIsSafe()` returns +true immediately on `win32`, and Node's `mode: 0o600` there touches only the +read-only attribute, so unlike `remote_host_state_dir` no DACL work is done for +it. + +Where the standalone log is actually exposed. `env::temp_dir()` honors `TMPDIR`, +which on macOS is the per-user `/var/folders/.../T` directory at `0700`, so the +umask does not matter there (measured on a macOS host, 2026-09). The exposure is +Linux with `TMPDIR` unset — `/tmp` at `1777` — and wherever an operator points +`$DORMOUSE_LOG_FILE`. `init_log` truncates the file and `log_file` appends to it; +neither sets a mode, and `restrict_to_owner` is never called on it. The socket +path arrives via the sidecar's stderr, which Rust appends verbatim. + +What the Windows session-snapshot test does and does not cover. +`restrict_to_owner_leaves_one_owner_only_ace` is `#[cfg(windows)]`, runs in CI's +`standalone-platform-check` matrix, and asserts `SE_DACL_PROTECTED`, an ACE count +of one, and the SID — but it calls `restrict_to_owner` directly. Nothing asserts +the unix modes, and nothing exercises `write_session_to`'s two calls: deleting +either leaves the suite green on every platform. The single-ACE property also +depends on `FILE_ALL_ACCESS` rather than `GENERIC_ALL`, which would split into +two ACEs. diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md new file mode 100644 index 00000000..2a6a6fb2 --- /dev/null +++ b/docs/specs/security-remote.md @@ -0,0 +1,223 @@ +# Remote Control Security + +> See `docs/specs/glossary.md` for Pane; this spec uses it bare. +> Owns the boundary the product presents to the network: remote control. Defers the trust model to `docs/specs/remote-security-model.md`, the Server runtime to `docs/specs/server.md`, the self-host deployment to `SELF_HOST.md`, and the boundaries a local user has to `docs/specs/security-local.md`. +> Read `docs/specs/security.md` first; `docs/specs/security-audit.md` says how the `FAIL IF` lines here are run. + +## Remote Control + +Pocket lets a phone attach to a terminal on the user's laptop, so the pairing stack is +the one part of the product that takes input from the network. **An authorized Client +is equivalent to a person at that laptop's keyboard** — `terminal.write` is raw +keystroke injection into a live PTY and protocol-v1 has no restricted session — so the +model exists to make *authorized* hard to reach and impossible to reach by accident. +**Nothing here applies to a Host that never enrolls with a server**: enrollment is +where the relay, the phone, and push begin, and a Host that never enrolls has none +of them. Two deployment modes are defined (`docs/specs/remote-api.md` -> "Transport"); all of +the below is **self-hosted**, the only one that ships. Cloud-hosted is staged +([Cloud-hosted mode](#cloud-hosted-mode)). + +### Trust boundary + +**Five layers, none sufficient alone** (`docs/specs/remote-security-model.md` -> +"Trust Model"). A deployment may raise the presence layer to *user verification* with +`DORMOUSE_REQUIRE_USER_VERIFICATION=true`. + +**There is exactly one channel and no other path.** One suite +(`Noise_IK_25519_ChaChaPoly_SHA256`) carries both ceremonies, protocol-v1, and the +terminal stream; there is no negotiation, no cipher or pattern selector, +no plaintext relay route, and no reader for any of the pre-cutover frames. + +| Compromise | Buys | What still stands | +| --- | --- | --- | +| Server | account state, routing metadata | **no new authorization and no plaintext**. On an established session, availability only — drop, delay, reorder, or refuse, never read and never inject — and the first invalid ciphertext destroys the session. Web Push holds **confidentiality**, not **freshness**: a kept envelope re-delivers as current, accepted residual (rationale) | +| Setup password | one endpoint, `/api/host/enroll`, and thence a `hostToken` | it registers **no** passkey — `/api/setup/*` takes a Host-minted setup token and nothing else — so it reaches an owner passkey only via the next row. `/api/host/enroll` accepts one other credential, the installer's enrollment offer: owner-only *at rest*, the whole of what the file mode protects, checked by possession over HTTPS rather than local identity, so a leaked token redeems remotely — bounded single-use, 24-hour expiry, permanently disabled by the first Host enrollment. Still **no Host access** | +| `hostToken` | the Host's own relay traffic and, transitively, **account takeover**: it mints setup tokens at `/api/host/setup-token`, the only thing that registers an owner passkey | bounded three ways — single-use and dead 5 minutes after minting; revoking the Host (deleting its row from `hosts.json`) stops minting immediately *and* kills already-minted tokens, re-checked at both setup gates; a signed-in phone retires an unused token at `/api/setup/retire`. Still **no Host access**: pairing runs Noise IK against an invitation keypair the Host never sent anywhere (rationale) | +| Synced or stolen passkey | sign-in, and the ability to *ask* | the paired Client static is missing, so `HostAcl` answers `client-not-paired` | +| Client static | use of the key in place, and only through a compromised browser or OS, or XSS in the Pocket origin | the key is not extractable, connecting still needs a fresh passkey assertion, and it authorizes exactly one Host | + +**The only path into a Host's ACL is a human typing, on that Host, two digits displayed +on the phone that is asking**, and the Host gets the comparison exactly once. **The +webview is inside the trust boundary for *relaying* a confirmation and for nothing +else** — it cannot choose what is authorized, satisfy the confirmation without the +phone, or fabricate a request (rationale). The only path back out is +[Revocation and the audit trail](#revocation-and-the-audit-trail). + +- **FAIL IF** the Host stops being the final authority: `RemoteHost.#onConnectionTransport` in `lib/src/remote/host/remote-host.ts` must consume its own challenge, verify the presence proof with `verifyPresenceProof` against a binding built from the Host's own `hostId`, connection id, challenge and handshake hash, and require one active `HostAclRecord` holding the account, the passkey credential, that key's hash, and the IK-authenticated Client static — before any session is established, and with no code path letting a Server-supplied claim stand in for any of them. +- **FAIL IF** local confirmation stops being the only thing that **mints** an ACL record: `HostAcl.approve` must have no caller but `RemoteHost.#approvePairing`, the comparison must be constant-time and happen **exactly once** per ceremony, and it must match the immutable `pairingId` of the request that was displayed, never a mutable `clientId` alone. +- **FAIL IF** the expected two-digit code, or an invitation's private key, ever leaves the Host process: `PairingQueueItem` in `lib/src/host/remote/service-protocol.ts` carries `{ clientId, pairingId, label, requestedAt }` and nothing else (rationale). +- **FAIL IF** the pending-ceremony maps are unbounded, in **both** `RemoteHost`'s client map and the service's mirrored queue: pairings capped at `MAX_PENDING_PAIRINGS` on both sides, oldest evicted first; connection handshakes at `MAX_PENDING_CONNECTION_HANDSHAKES`; outstanding invitations at `MAX_TOKENS_PER_HOST`. `MAX_CLIENT_ID_LENGTH` bounds `clientId` at the frame boundary, before any map is touched, and a handshake that fails to decrypt allocates no entry at all (rationale). +- **FAIL IF** any Host bound stops being enforced by the Host itself, on its own clock, with no help from the relay. `MAX_ESTABLISHED_E2E_SESSIONS` is checked **at promotion only** — after the presence proof and the ACL conjunction — and a Client static replaces its own session while any other identity at the cap gets `host-busy` and evicts no other entry. A Host-global token bucket (`E2E_INIT_BURST` decaying at one per `E2E_INIT_REFILL_INTERVAL_MS`) gates the WebCrypto an accepted `init` buys, and a frame it refuses performs no operation and allocates nothing. One reaper over absolute timestamps — invitation expiry, pairing TTL, challenge TTL, `ESTABLISHED_E2E_IDLE_TIMEOUT_MS`, the last refreshed only by a successfully decrypted Client→Host transport message — runs on every init, every local decision, every relay lifecycle event, and a next-expiry timer cleared on `stop()`. Values, and which file declares each: `docs/specs/remote-security-model.md` -> "Host bounds". Pinned by `lib/src/remote/host/remote-host-bounds.test.ts` and `server/test/malicious-relay.test.mjs` (rationale). +- **FAIL IF** `requireUserVerification` is reachable on one side without being mirrored to the other: the Server reads `DORMOUSE_REQUIRE_USER_VERIFICATION`, and `HostEnrollResponse` must carry it into the Host's `ConnectionPolicy` (rationale). +- **FAIL IF** the Host accepts an `e2e` frame it has not shape-validated itself with `isE2eServerToHostFrame` — relying instead on the relay's own overlapping guard (`isE2eClientFrame` / `isE2eHostFrame` in `server/src/relay.ts`) — or lets the Client's device label reach any consumer un-reduced by `boundedPairingLabel` (rationale). +- **FAIL IF** a ceremony outcome stops being a fixed-size padded control message, or begins carrying which ACL half failed: success and every denial encrypt to the same length, every ACL miss answers `pairing-required`, and the specific miss is logged owner-locally only. +- **FAIL IF** any **service→webview** message can carry `hostToken`, **or any other bearer credential the receiving realm has no route that takes** — `deliveryId` most of all, which is why `PushDevicesResult` is labels only. Check the direction, not just the identifier: `RemoteHostResult`, `HostStatusEvent`, `PairingQueueEvent`, `InvitationEvent`, `SetupQrResult`, `RemoteHostConsoleStatus`, and `PushDevicesResult` in `lib/src/host/remote/service-protocol.ts` are the outbound shapes and none may expose one; the test is whether the webview *calls* anything with the value, not whether exposing it is currently exploitable. The credentials that *do* cross outbound are the Server's **setup token** and the invitation's **public** half, both inside `SetupQrResult.url`, minted only on request, single-use, and short-lived. Inbound differs — `EnrollParams` carries the setup password by design (rationale). +- **FAIL IF** a private key agreement ever leaves WebCrypto. **X25519 stays WebCrypto-only** (`generateKey` / `deriveBits` / `importKey`) and **never a JavaScript curve** (`@noble/curves`, `tweetnacl`, `libsodium`, or any other). The one bundled primitive is ChaCha20-Poly1305, from an exactly-pinned `@noble/ciphers` release; its two import sites and the pin's audit delta are recorded in `server-lib-common/src/security/noise.ts`'s header, rewritten by any version bump in the same commit (rationale). +- **FAIL IF** the Host's Noise static is ever sent to the Server, or a Host runs with halves that do not correspond: it is minted locally *before* the enrollment request and never sent in it, persisted only where `hostToken` is, and `RemoteHostService` derives the public point from the private half and compares before starting — a mismatch keeps the Host down (rationale). +- **FAIL IF** `server-lib-common/src/security/` stops being the shared implementation: the Server, the Host, and the Pocket client must verify assertions, presence challenges, handshakes, and transport framing with the same modules. Conformance is proven against an independent implementation's published vector (`server-lib-common/test/noise.test.mjs`), never against a value the production state machine computed, and this section's properties are driven end to end by `server-lib-common/test/security-guarantees.test.mjs`. +- **FAIL IF** `scripts/e2e-lint.mjs` and `scripts/e2e-lint-selftest.mjs` stop running in the root `pnpm test`, or a rule is added to the lint without the self-test proving it load-bearing. Each rule in `RULES` names the line above that it enforces (rationale). +- **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 cloud-hosted mode is staged. Reserved: the cloud boundary is analyzed in `## Future` -> Cloud-hosted mode before the code that needs it ships. + +### Where a Host may reach a relay server + +**The baked relay-origin allowlist is what stops an install from enrolling against, or +connecting to, a relay the build was never pointed at.** `DORMOUSE_REMOTE_CONNECT_SRC` +is a build-time constant compiled into the Node bundle that holds the socket — the +Tauri sidecar and the VS Code extension host — enforced at two points: `enroll`, +*before the setup password leaves the machine*, and Host start-up from a persisted +enrollment naming an outside origin. Full semantics: `docs/specs/server.md` -> "Where a +Host may reach a relay server (self-host builds)". + +- **FAIL IF** `DEFAULT_REMOTE_CONNECT_SRC` is not exactly `https://*.dormouse.sh wss://*.dormouse.sh` in **both** `scripts/csp-defaults.mjs` and `lib/src/host/remote/connect-src.ts`, or if `CONNECT_SRC_SOURCE_PATTERN` differs between them. The shipped default admits the SaaS origin only, so widening it — a localhost entry, an `http`/`ws` scheme, a bare `*`, or the apex `dormouse.sh` — is a per-build opt-in that changes what every shipped binary will talk to. +- **FAIL IF** `assertConnectSrcBaked` is no longer called on the built bundle by both `standalone/scripts/build-sidecar-proxy.mjs` and `vscode-ext/scripts/esbuild.mjs` — including the **watch** branch of the VS Code script — or if `resolveRemoteConnectSrc` stops rejecting an override the runtime matcher cannot parse. The value is duplicated (a `.mjs` build script cannot import TypeScript) and `resolveRemoteConnectSrc` validates with the build script's *copy* of the grammar, so this bullet is only as strong as the previous one's identical-copies requirement; `lib/src/host/remote/connect-src.test.ts` pins them (rationale). +- **FAIL IF** `originAllowedByConnectSrc` stops gating both `enroll` and Host start-up in `lib/src/host/remote/service.ts`, or fails open on an unparseable origin or source. +- **FAIL IF** the enrollment exchange in `lib/src/remote/host/enrollment.ts` or the shared `hostFetch` in `lib/src/remote/host/host-fetch.ts` — the transport behind both push delivery and the setup-token mint — drops `redirect: 'error'`. **Every new Host→Server call goes through `hostFetch`** (rationale). + +### Credentials at rest + +**Four credentials outlive a process, and each is a full bypass of some layer if it +leaks to another local account.** Protection states the *property* — reachable only by +the owning user account — and every row reaches it the same way, the caption of the +column: mode `0700`/`0600` on unix, a one-ACE DACL on Windows, where Node's file modes +are a silent no-op. Rows carry only what is additional. + +| Credential | Where it lives | Protection | +| --- | --- | --- | +| Setup password | `config/server.env` in the install root | the DACL applied before the secret is written; generated locally; never printed by a routine install and never in the service definition (LaunchAgent plist, Scheduled Task XML) | +| Enrollment offer | `run/enroll-offer.json` in the install root, under an owner-only `run/` | mode and DACL both applied before the token is written; one-time (`docs/specs/server.md` -> "Configuration"); never printed, the service definition and wrapper carrying only its path | +| `hostToken` (the `/ws/host` bearer) and the Host's Noise static private key | server `hosts.json` (the token only); Host side both in the enrollment record, the Noise static minted locally and never sent to the server (`docs/specs/remote-security-model.md` -> "Host identity") | the server state dir and every file in it; on Windows the files inherit the installer's DACL on `state`, so `manage verify` checks them individually. Host side a `0600` file in standalone (on Windows the app-data-dir DACL the Rust side applies), `SecretStorage` (the OS keychain) in VS Code — never a webview realm | +| VAPID private key | server `vapid.json` | nothing additional | +| Host ACL | `HostStateStore`, keyed per `hostId` | a `0600` file in standalone; VS Code `globalState`. Mostly public keys, with one exception: each record's `deliveryId` is a bearer capability for that Client's push rows, so a reader could delete or hijack a subscription — not reach a terminal. Neither store provides *integrity* against a same-user process and nothing here claims otherwise; the mode only stops another local **account** adding a record (rationale). Deliberately never on the Server | + +**Without explicit modes these files inherit the umask and end up world-readable**, +handing live host tokens to any other local account on a shared machine. The Client's +per-Host statics are the exception that needs no file protection: non-extractable +`CryptoKey`s in IndexedDB, never exported. + +- **FAIL IF** `server/src/state.ts` stops creating `$DORMOUSE_STATE_DIR` mode `0o700`, or stops writing every file through `writeAtomic` at mode `0o600`. The "every file" clause is a negative search over `server/src/`: no `writeFile`, `appendFile`, or `createWriteStream` may target the state directory outside `writeAtomic`. A cheap default, not a cross-platform guarantee; the installer's directory permissions below protect the installed server's state (rationale). +- **FAIL IF** `FileHostStateStore` (`lib/src/host/remote/host-state-store.ts`) stops creating its directory `0o700` and writing `0o600` on non-Windows platforms, or if `VsCodeHostStateStore` stops keeping the **enrollment** in `SecretStorage`. The ACL's home in `globalState` is deliberate and is not a finding; the enrollment's is what carries `hostToken`. +- **FAIL IF** `remote_host_state_dir` in `standalone/src-tauri/src/lib.rs` stops calling `restrict_to_owner` on the state directory **before** spawning the sidecar — on Windows those Node modes are no-ops and Node cannot set an ACL, so the guarantee is held one layer down. That call carries both legs: a newly written enrollment file *inherits* the owner-only entry, and one a prior version already left under the `%LOCALAPPDATA%` ACL — with a live `hostToken` in it — has that entry *propagated* onto it, the half `restrict_to_owner_leaves_one_owner_only_ace` covers with its pre-existing `before.json`. +- **FAIL IF** any installer stops generating the setup password locally from at least 32 bytes of a cryptographic RNG — `/dev/urandom` in `deploy/local/install-macos.sh`, `RandomNumberGenerator` in `deploy/local/install-windows.ps1`, and the staged release runtime's `crypto.randomBytes(32)` (OpenSSL `RAND_bytes`) in `deploy/local/install-linux.sh` — one named CSPRNG per installer, never `$RANDOM`, a timestamp, or any other non-CSPRNG source. All three length guards are in hex characters, so each must require 64, not 32 (rationale). +- **FAIL IF** any installer stops making `config/`, `state/`, and `config/server.env` reachable only by the installing user — the effective property `manage verify` tests: no principal other than that user may appear in the effective permissions. macOS and Linux achieve it with `0700`/`0600` under `umask 077`; Windows with a DACL protected from inheritance carrying exactly one ACE, which is how `Protect-Path` does it today but is not itself the invariant — a path that inherits that single ACE from an already-locked parent satisfies the property, and `Test-OwnerOnly` deliberately accepts it. The Windows and Linux installers must also create `server.env` and lock it — the DACL on Windows, `chmod 0600` on an empty file on Linux — *before* the password is written (rationale). +- **FAIL IF** `manage verify` stops asserting **both** legs of the unix property — mode **and** owner — on `config/`, `state/`, `run/`, `config/server.env`, and, while it is there, the enrollment offer; a `0700` directory owned by another principal satisfies the mode and inverts the property. Linux does. **Two known gaps, in those installers rather than accepted limits:** macOS checks the modes only and not yet the owner, on every one of those paths, and Windows `Test-OwnerOnly` reads the DACL and never `$acl.Owner`, so a path another principal owns passes while that owner keeps implicit `WRITE_DAC` over it. +- **FAIL IF** `manage verify` stops walking the files inside `state/` on Windows, where `server/src/state.ts`'s `0o600` is a no-op and they are covered by what they inherit from the directory. An enumeration that fails fails verify, because that walk is the only thing holding the property there (rationale). +- **FAIL IF** any installer stops preserving an existing `config/server.env` byte-for-byte across an update, or begins printing the setup password outside the explicit `manage show-password` path. Each installer names the installer-owned keys a preserved file lacks and stops; nothing is rewritten or regenerated over it (rationale). +- **FAIL IF** any installer mints the enrollment offer's token from anything but the same named CSPRNG that installer uses for the setup password, or drops its length guard — 64 hex characters, counted the same way. The offer redeems for a Host enrollment, so its entropy is the password's. +- **FAIL IF** the offer's publication file, **or `run/` itself**, is reachable by any principal other than the installing user, or becomes so only *after* the token is written. Each installer creates an owner-only temporary file inside `run/`, writes the complete offer, then atomically renames it over the well-known path: redemption sees one complete generation or the other, never a truncate/chmod/write window. `run/` is `0700` (a single-ACE DACL on Windows) alongside `config/` and `state/`, and `manage verify` asserts it (rationale). +- **FAIL IF** any installer prints the offer's token, or writes it anywhere but that owner-only same-directory publication file. There is no `manage show-password` counterpart: the reader is a Host process, not a human. +- **FAIL IF** any installer writes the offer anywhere but `/run/`, stops re-minting it on runs before the first Host enrollment, mints one after `state/hosts.json` exists, or mints it before the switched release, HTTPS Serve mapping, and pruning have succeeded. `hosts.json` is the durable "bootstrap completed" marker even when every row is later removed; the Server serializes that decision with the Host-store write and consumes the offer when either credential path wins (rationale). +- **FAIL IF** `manage verify` stops failing when the service definition contains `DORMOUSE_SETUP_PASSWORD` — the LaunchAgent plist on macOS, the exported Scheduled Task XML on Windows, the systemd unit file on Linux; a service definition is world-readable on all three, and the credential belongs only in `config/server.env`. It must fail the same way when the definition could not be read at all — a missing plist or unit file, or an `Export-ScheduledTask` returning `$null` (rationale). + +### The setup password + +**One password bootstraps everything the Server can grant.** Enrolling Hosts is its +only endpoint, but an enrolled Host mints setup tokens and a setup token registers an +owner passkey, so the account is one step behind it rather than beside it. + +**Its hardening is minimal and is accepted, not overlooked.** A constant-time +comparison and a fixed failure delay are the whole of it: no rate limit, no lockout, no +attempt counter, no expiry or rotation after setup completes. `/api/*` also carries +`cors({ origin: '*' })`, so any web page open in any browser on the tailnet can drive +those routes and read the responses — safe from CSRF, since there are no cookies, but +the guessing surface is not limited to a deliberate client (rationale). + +**Accepted because** the origin is tailnet-only, the password is 32 bytes of +`/dev/urandom` written by the installer rather than chosen by a human, and the layer it +protects still cannot reach a Host without local approval. Two consequences: **the +tailnet is doing real work here**, and a self-host origin that becomes +internet-reachable is a materially different risk than the one analyzed. + +- **FAIL IF** the setup password comparison stops being constant-time or loses its fixed failure delay. The halves live apart: `secretEquals` in `server/src/secrets.ts` compares SHA-256 digests with `timingSafeEqual`, and `CREDENTIAL_FAILURE_DELAY_MS` in `server/src/app.ts` is the fixed 250 ms every rejected credential costs. +- **FAIL IF** the permissive CORS policy is widened beyond `/api/*`, or if any endpoint begins accepting credentials via cookies — "no cookies exist for a foreign origin to ride on" is the whole basis for `origin: '*'` being acceptable. + +### Network posture (self-hosted) + +**`scripts/deploy-lint.mjs` (`pnpm test`) makes the cheap half of this section and of +"Credentials at rest" deterministic**: every installer must still contain the control +each `FAIL IF` names, so a control deleted from one of the three fails a build. It is +textual and cannot tell whether a control is *correct* — the audit owns that — and on +Windows, which nothing in CI can execute, it is the only automated signal at all. +`scripts/deploy-lint-selftest.mjs` deletes each matched control in turn and requires +the lint to fail (rationale). + +**The shipped self-host deployment is a per-login user agent bound to loopback** — a +macOS LaunchAgent, a Windows Scheduled Task, or a Linux systemd user service — with +`tailscale serve` terminating HTTPS on the node's own MagicDNS name. Two invariants +follow, the same on all three: + +- **The server always speaks plain HTTP, so the listen interface *is* a security boundary when the TLS proxy is local.** An unbound socket publishes the plaintext port to the LAN and to the tailnet itself, so the install pins `DORMOUSE_BIND_HOST=127.0.0.1` and refuses to proceed without it. +- **`DORMOUSE_ORIGIN` is durable WebAuthn identity.** Rewriting it silently invalidates the registered passkey and every enrolled Host, so the installer stops rather than rewriting a mismatch. + +**Tailscale is network-layer defense-in-depth *under* the passkey/ACL model, never a +substitute for it** — but the analysis above leans on the origin being tailnet-only. +`tailscale serve` and `tailscale funnel` share one configuration surface, and a Funnel +on this node publishes the same origin to the public internet, where the setup password +becomes an internet-facing guessing target with none of the mitigations above. + +- **FAIL IF** `deploy/local/install-macos.sh`, `deploy/local/install-windows.ps1`, or `deploy/local/install-linux.sh` stops requiring `DORMOUSE_BIND_HOST=127.0.0.1` in `config/server.env`, or if any `manage verify` stops asserting that the plaintext port is unreachable on the node's Tailscale IP. +- **FAIL IF** the unset default of `DORMOUSE_BIND_HOST` in `server/src/config.ts` stops being `undefined` — listen on every interface, what a container wants, where the namespace is the boundary — or if `server/test/bind-host.test.mjs` stops spawning the real entrypoint to prove the plaintext port is unreachable off-loopback when it *is* set. +- **FAIL IF** any installer stops refusing to rewrite a `DORMOUSE_ORIGIN` that no longer matches the node's DNS name. +- **FAIL IF** any installer stops refusing to run with elevated privileges — `id -u` on macOS and Linux, the `Administrator` role check on Windows (rationale). +- **FAIL IF** `manage verify` does not fail on Funnel being on for this node. It matches `funnel on` across `tailscale serve status` and `tailscale funnel status`; that is node-scoped, not scoped to the served origin, and is deliberately the blunter test (rationale). +- **FAIL IF** that check reports `off` when it could not run. A nonzero exit status is its own verdict and fails verify, never discarded with `2>/dev/null || true`: a check that could not run has not passed (rationale). +- **FAIL IF** any decision taken on Tailscale CLI or listener output is reached by piping that output into `grep -q`, or into a `head -1` that exits first; every such search is over text captured first. The `head -1` half binds every site whose 141 can still reach an `if` or an assignment — an inline substitution always, and a helper the moment the failing assignment is its last command or a caller invokes it outside `$( )` (rationale). +- **FAIL IF** any decision about whether Serve maps `/` to us — the install-time conflict gate, `manage verify`, and the uninstall that turns Serve off — is not additionally scoped to the root line with the port right-bounded: `/api` on this port is not `/` on it, and `127.0.0.1:31000` contains `127.0.0.1:3100`. The post-mutation `SERVE_AFTER` assertion is the one deliberate exception, since it asserts our own `serve --bg` landed rather than auditing a foreign config (rationale). +- **FAIL IF** `scripts/installer-verify-test.mjs` stops extracting `funnel_state`, `has_off_loopback` and `serve_state` and driving them over inputs larger than the pipe buffer, or stops pinning `serve_proxies_root`'s root scoping and its port bound. Enforcement splits by control, not by helper: `scripts/deploy-lint.mjs` holds that helper's `<<<` pattern and counts the decisions that consult these helpers, since a helper whose answer is right survives a caller that stops asking, and `serve_root_target` is held by neither on purpose (rationale). + +### What crosses the boundary + +**The relay is a dumb ciphertext pipe**: it routes `e2e` envelopes within one +Client↔Host binding and decodes nothing. Both directions carry untrusted bytes once a +Host has decrypted them — inbound, `terminal.write` is keystrokes into a real shell and +the ACL is the entire gate; outbound, terminal bytes reach a phone and notification text +originates in a renderer and is Pane-derived, so it is **bounded on the Host before +sealing and re-bounded at the render sink** (below; rationale). + +**Web Push is the one path where the Server makes an outbound request to an address a +Client supplied**, which on a server *inside* a tailnet is a live SSRF concern: +`100.64/10` is exactly the range a push endpoint must not be allowed to reach. +Registration rejects credentials, localhost, and non-public IP literals; delivery goes +through a dedicated agent whose connection-time DNS lookup rejects loopback, private, +CGNAT, link-local, documentation, benchmark, multicast, reserved, IPv4-mapped, +unique-local, and site-local ranges — rejecting a hostname wholesale if *any* answer is +blocked, and handing the socket the exact address it checked so rebinding cannot create +a second unchecked resolution. + +- **FAIL IF** `server/src/push-endpoint.ts` stops rejecting non-public push endpoints at registration, stops applying `createPublicLookup` / `createPublicPushAgent` to delivery, or stops rejecting a hostname whose DNS answers are mixed public and blocked. +- **FAIL IF** `/api/push/send` stops taking the `hostId` from the Host's own token, begins selecting recipients when `recipients` is absent or empty, stops clamping them at `MAX_PUSH_QUERY_DELIVERY_IDS`, or if any read endpoint begins reporting on a delivery id the caller did not present. Possession of the 256-bit `deliveryId` is the whole authorization for the Client-facing push routes, so the Server must never *list* one to a session. +- **FAIL IF** the send route reads, rewrites, or logs notification text, or forwards anything but the sealed envelope plus the token's own `hostId`. The Server holds no key for it (`docs/specs/remote-security-model.md` -> "Push sealing"), so a route that could read a payload is one that was handed plaintext. The envelope's three fields must be copied individually rather than spread, since a spread would let a sending Host override its own token's `hostId`. +- **FAIL IF** a push stops being sealed per recipient, to that ACL record's own Client static, under a fresh salt — the construction is `docs/specs/remote-security-model.md` -> "Push sealing", `sealPush` / `openPush` in `server-lib-common/src/security/push-seal.ts`, proven by `server-lib-common/test/push-seal.test.mjs`. A Noise `CipherState`, a shared group key, or a reused salt each break it. `RemoteHost.sealPushForClient` hands `lib/src/remote/host/push-delivery.ts` a seal *capability* and never the Host's private key, and the worker in `lib/src/remote/pocket-app/sw.ts` is the only thing that opens one. +- **FAIL IF** push text stops being bounded with the shared `boundedPushText` on the Host before sealing, or re-bounded with it in `lib/src/remote/pocket-app/sw.ts` before `showNotification`. The worker is the sanitization sink: a worker that renders what it decrypted without re-bounding it leaves the property with one enforcer instead of two (rationale). +- **FAIL IF** the relay routes a Host-originated frame from a socket that is not the Client's current Host binding, or begins decoding, remembering, or acting on an `e2e` ciphertext. `server/src/relay.ts` must route the `e2e` envelope and nothing else: it holds no gate, no challenge memory, and no notion of an authorized session (rationale). A Server-side type import from the protocol-v1 half of `server-lib-common/src/remote/wire.ts` is the leading indicator and fails the same way. + +### Revocation and the audit trail + +These are the two real gaps in the shipped model, and they are gaps rather than +accepted risks — we intend to close them (rationale). + +**Revocation has no mechanism.** `HostAcl.revokeClient` / `revokePasskey` exist and +have no callers; no relay frame carries a revocation; there is no management UI. +Revoking a lost phone means hand-editing JSON on the Host **and restarting it**: +`RemoteHostService.#startHost` reads the store once and hands the `RemoteHost` a +snapshot for its whole lifetime, so an edit alone changes nothing that is running. The +restart is the whole lever — it reloads the ACL and, by dropping the relay socket, ends +every established session. Server-pushed propagation is staged in +`docs/specs/remote-security-model.md` -> "Future" (Revocation propagation). + +**There is no audit trail.** The ACL records `approvedAt` / `approvedBy` for a pairing, +and nothing records connects, attaches, denials, or writes. A self-hoster cannot answer +"did anyone connect to my laptop last night", which also means an ACL entry added by +any of the paths above would be invisible after the fact. + +## Future + +### Cloud-hosted mode + +Nothing here is implemented; it exists so the boundary is stated before the code +arrives. When Dormouse operates the coordinating Server, "Server compromise buys no +Host access" is unchanged, but two things change character and must be re-analyzed here +rather than inherited: + +- **We become the operator** of the relay. The end-to-end protocol keeps ceremony, terminal, remote-api, and notification content out of that operator's reach; what stays visible is exactly the metadata in `docs/specs/remote-security-model.md` -> "Residual metadata". +- **An independent cryptographic review is a precondition** of claiming this model for a paid service (`docs/specs/remote-security-model.md` -> "Security Guarantees"). +- **The tailnet stops carrying load.** Every argument above that leans on "the origin is reachable only from the user's tailnet" — the setup password's minimal hardening most of all — has no cloud equivalent, and the multi-tenant account model replaces the single-owner setup password entirely (`docs/specs/server.md` -> "Future", the **saas-multitenant** scope). diff --git a/docs/specs/security-remote.rationale.md b/docs/specs/security-remote.rationale.md new file mode 100644 index 00000000..3959795d --- /dev/null +++ b/docs/specs/security-remote.rationale.md @@ -0,0 +1,211 @@ +# Remote Control Security — rationale + +## Trust boundary + +**Why a Server compromise buys no authorization.** A forged account, a forged presence +stamp, and an injected ceremony frame all arrive in front of a Host that decrypts the +request itself, recomputes the WebAuthn challenge from its *own* transcript, and checks +its own ACL under the `ConnectionPolicy` recorded at enrollment. It cannot make the +Host trust a Client the user never approved. On an established session every frame is +authenticated under a `CipherState` from a handshake the Server does not hold a key +for, and the first invalid ciphertext destroys the session rather than resynchronizing. +Web Push is no exception for confidentiality — the Host seals every notification to the +recipient's own static and the Server forwards ciphertext it holds no key for — and is +one for freshness, which is accepted residual rather than a gap the seal closes. + +**Why the host-token edge exists.** A `hostToken` mints setup tokens and a setup token +is the only thing that registers an owner passkey, so account takeover is transitive +rather than direct. That is deliberate: the QR *is* the credential, so whatever can +mint one can be set up by one. It still buys no Host access for a structural reason — +pairing runs Noise IK against an invitation keypair the Host generated locally and +never sent anywhere, so the Host has no invitation to match a stolen setup token +against. + +**Why `requireUserVerification` is mirrored.** The Host is the final authority, so a +Server demanding user verification while the Host did not would leave the weaker +verifier deciding. + +**Why the webview can relay a confirmation safely.** Reading the two digits requires +holding the device — a relayed or injected request has no screen to read from. The +confirmation arrives as a bridge command carrying the displayed ceremony's immutable +`pairingId` and the typed digits; the service, not the webview, holds the expected code +and decides whether that ceremony is still confirmable, which is what leaves the webview +unable to choose what is authorized, to satisfy a confirmation without the phone, or to +fabricate a request. A mirrored code would make the confirmation something anything in +the webview realm could satisfy, and a leaked invitation key would let a photographed QR +be completed by whoever holds it. + +**Why the pending maps need caps on both sides.** Every `e2e` frame allocates under a +`clientId` the relay chooses, in both `RemoteHost`'s client map and the service's +mirrored queue, and the only thing that removes one is a `client-gone` a hostile relay +simply never sends. Oldest-first eviction runs on both because either can be fed +independently, and a cap only one side honors is not a cap. The whole surface is +reachable by anything that can sign in — a synced or stolen passkey buys "the ability +to ask" — and these caps are what stop asking from being a denial of service. + +**Why the bounds are Host-local.** A bound that needs the relay to send `client-gone`, +or a Server gate, is not a bound: the relay is the party this model assumes is hostile. +`lib/src/remote/host/remote-host-bounds.test.ts` counts the crypto a rejected frame +buys and drives every deadline off an injected clock, and +`server/test/malicious-relay.test.mjs` shows a relay holding no guards of its own +weakening none of the frame refusals those bounds sit behind. + +**Why the Host revalidates a frame the relay already checked.** The relay runs its own +overlapping guard, and that is exactly why the Host cannot rely on it: the routing +values become map keys and the ciphertext becomes WebCrypto work in the process that +owns every PTY. The Client's device label gets the same treatment because it is +attacker-chosen text rendered in the one dialog the ACL rests on. + +**Why the QR credentials cross outbound.** A QR that is never displayed sets up +nothing, so the Server's setup token and the invitation's public half ride out inside +`SetupQrResult.url` on purpose — which is why they are minted only on request, +single-use, and short-lived. `deliveryId` is the counter-example: it addresses a +Client's push rows, so `PushDevicesResult` carries labels only. Inbound is a different +matter because enrolling is initiated from the webview — the Settings dialog or the +`window.dormouseRemoteHost` console hook — which is why `EnrollParams` carries the +setup password by design. + +**Why WebCrypto and not a JavaScript curve.** WebCrypto-only X25519 is what lets a Host +static and a Client static exist as non-extractable `CryptoKey`s rather than as bytes +in a process that owns every PTY, so a JavaScript curve is a downgrade even where it +computes the same point. ChaCha20-Poly1305 is the one bundled primitive because no +shipping WebCrypto has an interoperable one. + +**Why a Noise static mismatch keeps the Host down.** Starting anyway would present a +changed Host identity to every paired Client, rather than the corrupt state file it +actually is. + +**Why the lint and its self-test both run.** The lint is what makes "one suite, no +negotiation, no plaintext path, no legacy discriminant" a build failure rather than a +reading; the self-test is what keeps a rule from passing for the wrong reason, which is +a textual lint's characteristic failure. + +## Where a Host may reach a relay server + +**Why the build asserts the define landed.** A lost esbuild define compiles green and +shows up only as a Host silently using the shipped default instead of the selfhoster's +origins. The watch branch of the VS Code script is named explicitly because it is the +build people iterate in, and therefore where a lost define most plausibly survives. + +**Why `redirect: 'error'`.** A Node process does not re-check a redirect target the way +a browser re-applies CSP, so a followed redirect could carry the setup password or the +`hostToken` outside the allowlist. That is also why any new Host→Server call goes +through `hostFetch`. + +## Credentials at rest + +**Where the `0o700` state directory earns its place.** On a multi-user unix host, +home-directory permissions vary by distro — `0700` on RHEL, `0755` historically on +Debian, `0750` on Ubuntu since 21.04 — so without an explicit mode, whether a second +account can read `hosts.json` depends on which distro the selfhoster happened to pick. +It buys nothing on Windows, where modes are a no-op and the profile ACL already +excludes other accounts; nothing in a container, where the namespace is the boundary; +and nothing on a serverless deployment backed by a database, where this file never +runs. + +**What the Host ACL's file mode does not buy.** Neither store defends its records +against a process running as the same user, and nothing in the table claims it does — a +same-user compromise already reads the terminals. + +**Why `manage verify` walks `state/` on Windows.** `server/src/state.ts`'s `0o600` is a +no-op there, so the files are covered only by what they inherit from the directory. An +enumeration that fails has to fail verify, because a directory the walk could not read +would otherwise report as one with no account in it yet. + +**Why an existing `config/server.env` is preserved rather than repaired.** A file that +exists is not necessarily one an install finished writing, and a half-written file and +a hand-edited one are indistinguishable — while their repairs are opposite: `rm` for +the first, and never for the second, whose `DORMOUSE_ORIGIN` is durable WebAuthn +identity. Before this check the bind-host guard told the operator to *fix* a zero-byte +file, on every run, forever. + +**Why the length guards count 64.** They are stated in hex characters, so a guard +reading `-ge 32` passes a regression to half the entropy. + +**Why the enrollment offer lives in `run/`.** A credential that expires in 24 hours and +is unlinked on redemption belongs in neither `config/` nor `state/`. The directory is +owner-only because it governs who may replace or delete the credential, not only who +may read it. + +**Why an unreadable service definition fails the same way as a leaking one.** A search +through nothing finds nothing, so a missing plist or unit file, or an +`Export-ScheduledTask` returning `$null` because CIM was blocked or the task vanished, +has to be a failure rather than a pass. + +## The setup password + +**Why `cors({ origin: '*' })` is acceptable.** There are no cookies — every credential +is a header or a body field — so no cookies exist for a foreign origin to ride on, and +CSRF is not the exposure. What it does mean is that the guessing surface is not limited +to something reachable only by a deliberate client, which is why the tailnet-only +origin is load-bearing. + +## Network posture (self-hosted) + +**Why the deploy lint carries a self-test.** A textual rule's characteristic failure is +passing for the wrong reason: review of the first version found three rules satisfied +by an unrelated occurrence, one of them the entropy guard's own explanatory comment. + +**Why an elevated install is refused.** The install belongs to one user account and its +whole credential posture is that account owning the files; an elevated run would write +them owned by another principal and register the service for it. + +**Why the Funnel check is node-scoped.** Any Funnel on the node that fronts this server +is a thing to look at, and parsing a mapping out of CLI prose would fail open the day +the wording changes — so the blunter test is the deliberate choice. + +**Why a nonzero exit status is its own verdict.** A Tailscale CLI that is absent, +unauthenticated, or too old for `funnel status` produces output that matches nothing, +which is indistinguishable from a node with no Funnel until the exit status is +consulted. Discarding it with `2>/dev/null || true` would report `off`. + +**Why `grep -q` and `head -1` are banned on these decisions.** The installers and +`manage` run under `set -o pipefail`; `grep -q` exits at the first match, and the +writer's SIGPIPE makes the pipeline 141, which an `if` reads as "no match" and an +assignment turns into an abort. Past the pipe buffer that reported a live Funnel as +off, an off-loopback bind as loopback-only, and — the one that mutates rather than +reports — a `serve status` carrying a foreign root mapping as no conflict at all, so +the `confirm` guarding the operator's existing Serve config never ran. + +**Why the Serve checks are scoped to the root line with the port right-bounded.** `/api` +on this port is not `/` on it, and `127.0.0.1:31000` contains `127.0.0.1:3100` — either +of which skipped the conflict `confirm`, green-ticked `manage verify` on an origin +serving someone else at `/`, or made uninstall reset a root mapping this install never +owned. `SERVE_AFTER` is exempt because it asserts our own `serve --bg` landed rather +than auditing a foreign config. + +**Why `serve_root_target` is held by neither enforcement.** A `| head -1` in there +raises 141 that nothing propagates: `printf` runs last, and its one caller is a `$( )`, +which bash carries no `errexit` into without `inherit_errexit` — absent from bash 3.2 — +so the parameter expansion is hygiene rather than a control. Neither fact is a property +of being in a helper: the `head -1` half of the rule binds every site whose 141 can +still reach an `if` or an assignment, which is an inline substitution always, and a +helper the moment the failing assignment is its last command or a caller invokes it +outside `$( )`. + +**Why enforcement splits between two scripts.** Only +`scripts/installer-verify-test.mjs` runs installer code, so reverting `funnel_state`, +`has_off_loopback` or `serve_state` to a pipe goes red there; it cannot see +`serve_proxies_root`'s `<<<`, which only `scripts/deploy-lint.mjs`'s pattern holds. +That lint also counts the decisions consulting these helpers, since a helper whose +answer is right survives a caller that stops asking. + +## What crosses the boundary + +**Why the worker is the second sanitizer.** The Server used to be a second pair of eyes +on notification text and cannot be one on ciphertext — it cannot sanitize what it +cannot read — so a worker that renders what it decrypted without re-bounding it would +leave the property with one enforcer instead of two. + +**Why the relay holds no state.** Only the Host knows whether a ceremony succeeded, so +a gate, a challenge memory, or a notion of an authorized session on the Server would be +a second opinion nobody asked for. Routing an opaque envelope needs no notion of what a +`DirectoryEntry` is, which is what makes a Server-side protocol-v1 type import the +leading indicator. + +## Revocation and the audit trail + +Both gaps are stated in this spec rather than left in a Future list for two reasons: +the audit's qualitative pass should not keep rediscovering them as findings, and a +reader deciding whether to run this needs to know that "revoke a device" is not +currently something they can do quickly. diff --git a/docs/specs/security-supply-chain.md b/docs/specs/security-supply-chain.md new file mode 100644 index 00000000..f8504bfb --- /dev/null +++ b/docs/specs/security-supply-chain.md @@ -0,0 +1,66 @@ +# Supply Chain Security + +> Owns what Dormouse puts on a user's machine — the dependency graph, the bundled runtime, the themes — how that is disclosed, and the cooldown before a new release is adopted. Defers the disclosure page's rendering to `docs/specs/website-docs.md -> "Reference page chrome"` and the runtime's build to `docs/specs/standalone.md`. +> Read `docs/specs/security.md` first; `docs/specs/security-audit.md` says how the `FAIL IF` lines here are run. + +## Disclosure + +**Keep the runtime dependency surface small: add a dependency only when it is necessary**, and justify each change against its supply-chain risk. + +**Every dependency Dormouse *puts on a user's machine* is listed at [dormouse.sh/supply-chain](https://dormouse.sh/supply-chain).** The test is narrower than "everything a user runs" (rationale). Three inventories: + +- every npm dependency, direct and transitive +- every cargo dependency, direct listed separately from transitive +- the Node.js runtime bundled as a Tauri sidecar in the standalone app + +The roots are `productDependencyFilters` in `website/scripts/generate-deps.js`. **A workspace package is a root if Dormouse writes its files onto a user's disk, whatever the route.** + +| Root | Route onto the disk | +| --- | --- | +| `dormouse-standalone` | installed | +| `dormouse` | the VS Code extension, installed | +| `dormouse-sidecar` | rides inside the Tauri bundle as a `bundle.resources` tree, `node_modules` intact | +| `dor` | staged onto every terminal's `PATH` | +| `server` | built and installed by a selfhoster ([SELF_HOST.md](../../SELF_HOST.md)) — notably `web-push`, signing with a private key and making outbound requests | +| `dormouse-lib` | compiled into both hosts, yet the VS Code extension's dependency walk never arrives at it (rationale) | + +**`dormouse-lib` is a root in its own right, not a workspace edge**; `server-lib-common` and `dor-lib-common` are workspace edges from those roots. **Roots are package names, not directory names**, differing once: `vscode-ext/` declares itself `dormouse`. + +**Two workspace packages are deliberately not roots:** + +- `canopy` — a Storybook-only rendering lab no shipped build imports. +- `website` — runs in a visitor's browser rather than being installed anywhere, which is what makes "puts on a user's machine" the operative test (rationale). + +**External binaries are outside this graph by construction** — the user's shell, and the `agent-browser` CLI `dor ab` forwards to (`npm i -g agent-browser`, a dependency of nothing here, resolved off `PATH`). **Dormouse instead ships nothing that pulls them in silently** (rationale). + +**Regenerate and commit the dependency lists whenever a production dependency is added, removed, or upgraded** (rationale). + +**Bundled themes are disclosed outside that lockfile walk.** The themes compiled into every build (`lib/src/lib/themes/bundled.json`) come from OpenVSX extensions, not npm, so `website/scripts/generate-deps.js` appends the checked-in `lib/src/lib/themes/bundled-extensions.json` to the npm table instead. The two come from one run of `lib/scripts/bundle-themes.mjs` but both are committed and can drift, which the CI gate below cannot see (rationale). `lib/src/lib/themes/bundled-extensions.test.ts` pins them, joining on the `extensionId` each disclosure record carries: a bundled theme whose extension has no record, or a record with no bundled theme left, fails. **The join is on the extension set only** — `bundled.json` carries no version or license, so nothing pins a hand-edit to those published fields. + +- **FAIL IF** `node website/scripts/generate-deps.js` changes `website/src/data/dependencies-npm.json`, `website/src/data/dependencies-cargo.json`, or `website/src/data/dependencies-runtime.json` when run against a clean working tree after `pnpm install --frozen-lockfile`. The install is a precondition: the generator walks real `node_modules` directories and throws rather than under-reporting if they are absent. +- **FAIL IF** `.github/workflows/ci.yml` stops running that generator under that same install precondition, or stops failing on a diff (rationale). +- **FAIL IF** `productDependencyFilters` in `website/scripts/generate-deps.js` omits a workspace package whose files Dormouse writes onto a user's disk — today the six above, `canopy` and `website` excluded. Derive the set from `pnpm-workspace.yaml`, not the enumeration: a package in neither the roots nor the exclusions is the failure, since regenerating cannot catch a root that was never walked. A workspace *edge* from a root counts as covered; a devDependency of the repo does not (rationale). + +## Bundled runtime + +**The standalone app ships a Node.js runtime binary**, copied into the Tauri bundle as a sidecar by `standalone/src-tauri/build.rs`. + +- **Its version is pinned exactly in the root `package.json` under `devEngines.runtime.version`**, and the build is the authority. +- **The supply-chain page reads the same pin**, so the disclosed version provably equals the runtime users receive (rationale). +- **The pin is deliberate and manual** — no automated ecosystem tracks it; workflows that do not bundle the runtime may track the same pinned major. +- Locally, pnpm honours `devEngines` (`onFail: "download"`) so scripts run under the pinned Node; CI drives `actions/setup-node` from the same field, and `node-version-file: package.json` resolves by precedence: `volta.node`, then `devEngines.runtime`, then `engines.node`. + +**On Windows the build then flips one byte of the bundled `node.exe`** — the PE Optional Header's `Subsystem` field, `IMAGE_SUBSYSTEM_WINDOWS_CUI` (3) to `IMAGE_SUBSYSTEM_WINDOWS_GUI` (2) (`docs/specs/standalone.md -> "Windows node subsystem"`). **The version check runs before the byte flip**, the patch leaves Node.js semantics unchanged, and the bundled `node.exe` differs from the upstream archive at exactly that 2-byte field (rationale). + +- **FAIL IF** the root `package.json` is missing `devEngines.runtime.version`, or its value is not an exact `MAJOR.MINOR.PATCH` Node.js version — a bare major such as `24` is not acceptable. +- **FAIL IF** `standalone/src-tauri/build.rs` no longer runs `--version` on the binary it is about to bundle and fails the build unless it matches `package.json`'s `devEngines.runtime.version`, or if the check is skipped for any configuration the release matrix builds. One deliberate skip is permitted: `verify_node_version` cannot execute a foreign-arch binary, so it warns and returns when `host != target` — acceptable only while every entry in `release.yml`'s standalone matrix is host-native; a cross-compiled entry ships an unverified runtime and fails this check. +- **FAIL IF** the `build-standalone` job in `.github/workflows/release.yml` does not install the pinned runtime via `node-version-file: package.json`, **or** the root `package.json` gains a `volta.node` or `engines.node` field — `setup-node` reads this pin only while the higher-precedence fields above are absent (rationale). Other jobs may pin `node-version` inline since their interpreter is never bundled. + +## Cooldown and alerts + +**Maturity gating runs in both the pnpm configuration and the Renovate configuration.** + +- **FAIL IF** `pnpm-workspace.yaml` is missing `minimumReleaseAge: 1440`. +- **FAIL IF** `.github/renovate.json` is missing `npm` or `cargo` from `enabledManagers` (npm covers `/`; cargo covers `/standalone/src-tauri`), or is missing `minimumReleaseAge` package rules for those managers (rationale). +- **FAIL IF** `.github/renovate.json` has no `vulnerabilityAlerts` block, or that block does not set `minimumReleaseAge` **explicitly**. Renovate's built-in default for that block is `minimumReleaseAge: null`, force-applied before lookup, so *omitting* the key drops the cooldown rather than inheriting it from `packageRules`. Keeping it is deliberate (rationale). +- **FAIL IF** secret scanning or its push protection is disabled on the repository (`gh api repos/diffplug/dormouse --jq .security_and_analysis`), or Dependabot alerts are off (`GET /repos/diffplug/dormouse/vulnerability-alerts` must answer 204, not 404). Push protection is the one control that acts *before* a credential lands, blocking a push whose diff carries a recognized provider token; it applies to `dormouse-bot` too (rationale). diff --git a/docs/specs/security-supply-chain.rationale.md b/docs/specs/security-supply-chain.rationale.md new file mode 100644 index 00000000..48c44c7e --- /dev/null +++ b/docs/specs/security-supply-chain.rationale.md @@ -0,0 +1,35 @@ +# Supply Chain Security — rationale + +## Disclosure + +Why the test is "puts on a user's machine" rather than "everything a user runs": `website` runs in a visitor's browser rather than being installed anywhere, and the supply-chain page says as much about its own React and react-router. Excluding it is what makes the narrower test the operative one, and it is a judgement worth re-making if the site ever ships something a visitor installs. + +Why `dormouse-lib` has to be named a root rather than left as a workspace edge: `vscode-ext` declares only `node-pty` and `ws`, reaching the lib through relative imports into `../lib/src/` from fifteen files, so the extension's dependency walk never arrives at it. Only `dormouse-standalone`'s edge would — which puts the disclosure of lib's entire subtree one refactor away from silently vanishing. Naming it a root is what makes that not matter. + +Why external binaries cannot be disclosed: Dormouse is a terminal, so it spawns the user's shell, and `dor ab` forwards to an `agent-browser` CLI the user installs themselves and that is resolved off `PATH`. Those are the user's software, not ours, and disclosing them is neither possible nor meaningful. + +Where the snapshots come from: they are generated from the lockfiles and reviewed as part of release work. The `pnpm install --frozen-lockfile` precondition matters because a stale `node_modules` makes the regeneration check pass locally on a tree that would fail in CI. + +Why CI, and not the nightly audit, gates the regenerated snapshots: until the gate existed the nightly audit was the only thing that ever ran the generator, and two production bumps (`ws`, `hono`) shipped undisclosed before it caught them. A nightly finding of a stale disclosure means it already merged; the CI gate is what keeps it from merging. + +Why the two theme files can drift despite coming from one run of `lib/scripts/bundle-themes.mjs`: both are committed and the script needs network access, so an edit to one need not be accompanied by a re-run. Regenerating the disclosure then produces no diff, which is precisely the class the stale-snapshot gate cannot see — hence a separate test. + +Why being a devDependency of the repo does not count as covered: a selfhoster's `pnpm install` would otherwise drag the whole toolchain into the disclosure. + +## Bundled runtime + +What the pin buys the disclosure: because the supply-chain page reads the same `devEngines.runtime.version`, the published runtime version cannot drift to whatever Node happened to be on the build machine's `PATH`. + +Why the Windows subsystem byte is flipped: leaving the bundled `node.exe` as a console-subsystem binary lets Windows Terminal's default-terminal handoff spawn a stray terminal window behind the app. + +Why the patch is safe: Node reads its stdio handles from `STARTUPINFO`, which is subsystem-agnostic, so the flipped field changes nothing about Node.js semantics. + +Why the absence of `volta.node` and `engines.node` is load-bearing: adding either would silently change the bundled runtime, with no diff to the workflow to show that it had. + +## Cooldown and alerts + +What the `minimumReleaseAge` package rules are: the Renovate equivalent of the pnpm dependency cooldown window, applied per manager. + +Why the `vulnerabilityAlerts` cooldown is kept rather than dropped for speed: it guards the opposite threat from the alert itself — a compromised release that gets yanked within a day, which a reviewer reading a dependency diff cannot detect the way the ecosystem's own yank process can. Nothing here auto-merges, and the Dependabot alert already makes the vulnerability visible the moment it is published, so what the cooldown costs is a day before the remediation PR appears, not a day before anyone knows. + +Why push protection covering `dormouse-bot` is the point rather than an incidental: an injected agent pasting a token into a file is exactly the shape it stops. diff --git a/docs/specs/security.md b/docs/specs/security.md new file mode 100644 index 00000000..bb430cb6 --- /dev/null +++ b/docs/specs/security.md @@ -0,0 +1,187 @@ +# Security + +> Owns the guarantees Dormouse makes, what it does not defend, the gaps it +> knows about, and how all of it is checked. Defers every mechanism to the spec +> that owns it, and every audited check to the five specs under +> [How the guarantees are checked](#how-the-guarantees-are-checked). Published +> at `https://dormouse.sh/docs/security`, whole but for the three blocks split +> by audience; `docs/specs/website-docs.md` owns the page. + +Dormouse is a terminal, so users trust it with shells, source trees, +credentials, and local files. Three things sit on that boundary. The +**dependency graph and release pipeline** decide what code reaches a machine. +**Remote control**, pairing a phone with a laptop, is the one feature that +accepts input from the network, and an authorized phone is 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, a boundary precisely because +it does not look like one. + +**Only the self-hosted deployment ships.** The relay runs on hardware the user +owns, reachable from their own tailnet ([SELF_HOST.md](../../SELF_HOST.md)). +**Nothing about remote control applies to a Host that never enrolls with a +server**: enrollment is where the relay, the phone, and push begin. +Cloud-hosted operation is staged, and its boundary is re-analyzed before that +code ships ([security-remote.md](./security-remote.md#future)). + +## Guarantees + +Each guarantee names the spec that states the rule and what pins it on every +`pnpm test`. The nightly audit +([below](#how-the-guarantees-are-checked)) checks all of them; *audit* in the +last column means nothing cheaper does. + +| Guarantee | Rule | Pinned by | +| --- | --- | --- | +| **A program printing to your terminal cannot reach past the screen.** It can raise an alert, set a title, or mark a prompt; it cannot write your clipboard, open a link without your confirmation, read a file, or steal focus. | [Terminal output](./security-local.md#terminal-output) | `lib/src/lib/terminal-protocol.test.ts`, `lib/src/lib/external-links.test.ts` | +| **A page in a browser pane cannot impersonate Dormouse.** It can post to its parent, but in VS Code every host message carries a per-boot token it cannot read, and the standalone adapters have no inbox for it to post to. | [Browser panes](./security-local.md#browser-panes) | `lib/src/lib/platform/vscode-adapter.test.ts` | +| **Only your own account can drive your terminals through `dor`.** The socket sits in a directory only you can open, and its token never crosses the wire. | [The dor control socket](./security-local.md#the-dor-control-socket) | `standalone/sidecar/dor-control-server.test.js` | +| **A loopback listener grants a stranger nothing it could not get from the upstream directly.** | [Loopback Listeners](./security-local.md#loopback-listeners) | `scripts/loopback-lint.mjs` | +| **Terminal scrollback is never written to disk**, and the session state that is written belongs to your account alone. | [Persisted state](./security-local.md#persisted-state) | audit | +| **Nothing but a human at the laptop can authorize a phone.** The only path into a Host's ACL is typing, on that Host, the two digits the phone shows, and the Host makes every access decision. | [Pairing](./remote-security-model.md#pairing), [Host Authorization](./remote-security-model.md#host-authorization) | `server-lib-common/test/security-guarantees.test.mjs` | +| **The Server reads nothing.** One end-to-end channel per ceremony; the relay routes ciphertext it holds no key for, so a compromised Server gains no plaintext and no authorization. | [Trust Model](./remote-security-model.md#trust-model), [Noise suite](./remote-security-model.md#noise-suite) | `scripts/e2e-lint.mjs` | +| **Push notifications are opt-in, and a push is sealed to the one phone that receives it.** | [Push sealing](./remote-security-model.md#push-sealing) | `server-lib-common/test/push-seal.test.mjs` | +| **A stolen or synced passkey buys sign-in, not a terminal.** Every connection also needs the phone's own paired key and a fresh presence proof bound to that connection. | [Passkeys](./remote-security-model.md#passkeys), [Presence proofs](./remote-security-model.md#presence-proofs) | `server-lib-common/test/security-guarantees.test.mjs` | +| **A hostile relay cannot exhaust a Host.** Every bound runs on the Host's own clock, and a rejected frame costs it nothing. | [Host bounds](./remote-security-model.md#host-bounds) | `lib/src/remote/host/remote-host-bounds.test.ts`, `server/test/malicious-relay.test.mjs` | +| **A Host talks only to the relay its build was pointed at**, and refuses before any credential leaves the machine. | [Where a Host may reach a relay server](./security-remote.md#where-a-host-may-reach-a-relay-server) | `lib/src/host/remote/connect-src.test.ts` | +| **Credentials at rest are readable only by the account that installed them**, on macOS, Windows, and Linux alike. | [Credentials at rest](./security-remote.md#credentials-at-rest) | `scripts/deploy-lint.mjs` | +| **The self-host server listens on loopback only, behind the tailnet**, and a public Funnel fails its own verification. | [Network posture](./security-remote.md#network-posture-self-hosted) | `scripts/deploy-lint.mjs`, `scripts/installer-verify-test.mjs` | +| **Push, when enabled, cannot be aimed back into the tailnet.** | [What crosses the boundary](./security-remote.md#what-crosses-the-boundary) | `server/test/push-endpoint.test.mjs` | +| **Every dependency that reaches a machine is disclosed** at [dormouse.sh/supply-chain](https://dormouse.sh/supply-chain), and a change without the disclosure fails CI. | [Disclosure](./security-supply-chain.md#disclosure) | `.github/workflows/ci.yml` | +| **The bundled runtime is the version disclosed.** The build verifies the binary against the pin. | [Bundled runtime](./security-supply-chain.md#bundled-runtime) | `standalone/src-tauri/build.rs` | +| **No newly published dependency is adopted for 24 hours**, security fixes included. | [Cooldown and alerts](./security-supply-chain.md#cooldown-and-alerts) | audit | +| **Merging to `main` and creating a tag are admin-only**, and every workflow this repository authors pins its actions by commit. | [GitHub Actions Policies](./security-ci.md#github-actions-policies) | audit | +| **The bot maintainer cannot merge, tag, or read a release secret**, and its token never enters its own environment. | [Automated Maintainer (tend)](./security-ci.md#automated-maintainer-tend) | `.github/workflows/workflow-audit.yaml`, nightly | +| **Publishing the extension takes a second human's approval.** | [VS Code Extension Releases](./security-ci.md#vs-code-extension-releases) | audit | +| **Desktop binaries are signed offline.** CI never holds a signing or updater key, and the signing script verifies CI's attestations and hashes first. | [Desktop Releases](./security-ci.md#desktop-releases) | audit | + +## What is not defended + +Stated so the audit does not rediscover them and a reader deciding whether to +run this knows what they are taking on. + +- **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 + ([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)). +- **What VS Code does with the pane state it stores.** Structure persists in VS + Code's own storage under its modes, never a transcript + ([Persisted state](./security-local.md#persisted-state)). +- **A compromised browser or operating system, on either end.** Active XSS in + the Pocket origin can *use* the phone's key without extracting it. Exactly + two endpoints are trusted: the distributed Host binaries and the exact Pocket + artifact the origin serves ([Trust Model](./remote-security-model.md#trust-model)). +- **Traffic analysis.** The Server sees who talks to whom, when, how often, and + how large each ciphertext is, and keystroke timing, never keystroke values + ([Residual metadata](./remote-security-model.md#residual-metadata)). +- **Push replay, when push is enabled.** A push proves confidentiality, not freshness: a Server that + kept an envelope can re-deliver it ([Push sealing](./remote-security-model.md#push-sealing)). +- **Per-Host unlinkability, when push is enabled.** One push endpoint per browser lets the Server see + every Host one phone registered ([Residual metadata](./remote-security-model.md#residual-metadata)). +- **Phone-key durability.** Clearing site data means pairing again. Nothing is + compromised; a lost key authorized nothing on its own + ([Client static loss](./remote-security-model.md#client-static-loss)). +- **Availability.** The relay is down whenever the laptop is + ([Goals](./remote-security-model.md#goals); [keeping it up](../../SELF_HOST.md#keeping-the-relay-up-while-the-laptop-sleeps)). +- **The setup password's hardening is minimal**: a constant-time comparison and + a fixed delay, with no rate limit or lockout. Accepted because the origin is + tailnet-only and the password is 32 random bytes the installer wrote. **A + self-host origin reachable from the internet is a different risk than the one + analyzed** ([The setup password](./security-remote.md#the-setup-password)). +- **The bot's upstream is pinned by tag, not commit**, so a hostile upstream + could change what the bot runs without a diff here. Accepted: the trust equals + what the harness already holds ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). +- **The Chromatic token is reachable by any workflow the bot can author.** + Accepted with rotation; abuse is visible in Chromatic's dashboard + ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). +- **Two signing secrets travel on a command line** for the life of one local + call, because their tools offer nowhere else ([Desktop Releases](./security-ci.md#desktop-releases)). + +## Known gaps + +Gaps rather than accepted risks: we intend to close them. + +- **The standalone log file is written at the umask**, readable by another + local account wherever the temp directory is shared, and records the `dor` + socket path; no terminal output reaches it + ([Persisted state](./security-local.md#persisted-state)). +- **Revocation has no mechanism.** Revoking a lost phone is editing the Host's + ACL file and restarting the Host + ([Revocation and the audit trail](./security-remote.md#revocation-and-the-audit-trail)). +- **There is no audit trail.** Nothing records connects, attaches, denials, or + writes ([same](./security-remote.md#revocation-and-the-audit-trail)). +- **Owner checks are uneven across installers.** Linux verifies mode and owner + on every credential path; macOS verifies modes only, and Windows the DACL but + never the owner ([Credentials at rest](./security-remote.md#credentials-at-rest)). +- **The workflow audit's window has two evasions**: a backdated committer date, + and a branch pushed, run, and deleted before the nightly fetch + ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). +- **The audit's three subagents share one credential.** Their contexts are + separate; `AUDIT_PAT` is not ([Domains](./security-audit.md#domains)). +- **The notarization password sits on a command line for up to half an hour** + per architecture; the remedy is known and not yet done + ([Desktop Releases](./security-ci.md#desktop-releases)). +- **Two Pocket properties are verified on real hardware only** + ([Device verification](./remote-security-model.md#device-verification)). + +## How the guarantees are checked + +**On every `pnpm test`**, four lints turn the cheap half of these specs into +build failures: `scripts/spec-lint.mjs` (the specs' own conventions and word +budgets), `scripts/e2e-lint.mjs` (one Noise suite, no negotiation, no +plaintext path), `scripts/deploy-lint.mjs` (every installer control, on all +three platforms), and `scripts/loopback-lint.mjs` (a new loopback bind +references a guard). **Each carries a self-test that re-introduces the thing it +forbids and requires the lint to go red**; a rule without one is a claim, not a +check. `scripts/installer-verify-test.mjs` executes the installer helpers the +lints can only read. + +**Every night at 04:21 UTC, and before every VS Code release**, +`.github/workflows/security-audit.yaml` audits the repository against these +specs. Three subagents, each owning the specs below, run every `FAIL IF` as a +mechanical check with evidence, then read their domain adversarially for what +no check names. A failure, or a run that reaches no verdict, files a public +issue labeled +[`security-audit-failure`](https://github.com/diffplug/dormouse/issues?q=is%3Aissue+label%3Asecurity-audit-failure) +and holds the release; a later pass closes it. Open issues are live; closed +ones are the record of what tripped and what changed. +`scripts/security-audit-local.sh` runs the same prompts locally. +[security-audit.md](./security-audit.md) is the contract. + +| Domain | Specs | Covers | +| --- | --- | --- | +| `application-security` | [security-local.md](./security-local.md), [security-remote.md](./security-remote.md) | the local application's boundaries, remote control, and every path no other domain claims | +| `supply-chain` | [security-supply-chain.md](./security-supply-chain.md) | the dependency graph, the lockfile, the disclosure and its generator | +| `ci-and-secrets` | [security-ci.md](./security-ci.md), [security-audit.md](./security-audit.md), this spec | GitHub Actions, the bot, releases, secrets, and the audit itself | + +**Every pull request** that adds, removes, or upgrades a production dependency +fails CI until the regenerated disclosure is committed +([Disclosure](./security-supply-chain.md#disclosure)). + +**Every release** ships attestations and hash manifests from CI, verified +locally before anything is signed +([Desktop Releases](./security-ci.md#desktop-releases)). + +## Reporting a vulnerability + +Report privately through GitHub's +[Report a vulnerability](https://github.com/diffplug/dormouse/security/advisories/new) +form, which opens an advisory visible only to you and the maintainers. It is +the right channel for anything here, and for remote control most of all: a +public issue describing a live path into a Host's ACL is a disclosure, not a +report. + +**Never open a public issue, and never email the maintainer.** Include the +version or commit, the deployment (self-hosted server, standalone app, VS Code +extension), and the shortest reproduction. Every advisory is acknowledged with +what we intend to do about it. There is no bounty, and a fix that needs a +coordinated release says so in the advisory rather than promising a date; this +is a one-maintainer project and nothing here promises a response time it cannot +keep. + +- **FAIL IF** private vulnerability reporting is disabled on the repository + (`gh api repos/diffplug/dormouse/private-vulnerability-reporting` must report + `enabled: true`): the advisory form is the only channel this spec offers, and + a disabled one sends a reporter to a public issue. diff --git a/docs/specs/server.md b/docs/specs/server.md index 7f78cf3e..6733d44a 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -53,7 +53,7 @@ The whole of what `server/src/` reads from the environment: | `DORMOUSE_STATE_DIR` | Where the JSON state files live. Default `./data`. | | `DORMOUSE_POCKET_DIR` | The built Pocket app served at `/*`. Defaults to `lib/dist-pocket` resolved from the compiled server's own location, never the cwd (rationale). Absent or lacking `index.html`, `GET /` is a plaintext stub naming the build command. | | `PORT` | Default 3000. Blank reads as unset; an explicit `PORT=0` is a `ConfigError` (rationale). | -| `DORMOUSE_REQUIRE_USER_VERIFICATION` | Only the exact string `true` demands a *user-verified* passkey assertion (biometric/PIN) rather than mere user presence; off by default (rationale). Applies to sign-in and re-auth alike, and is mirrored to every Host as `ConnectionPolicy.requireUserVerification` in its `HostEnrollResponse` (`SECURITY.md` -> Remote Control). | +| `DORMOUSE_REQUIRE_USER_VERIFICATION` | Only the exact string `true` demands a *user-verified* passkey assertion (biometric/PIN) rather than mere user presence; off by default (rationale). Applies to sign-in and re-auth alike, and is mirrored to every Host as `ConnectionPolicy.requireUserVerification` in its `HostEnrollResponse` (`docs/specs/security-remote.md` -> "Trust boundary"). | | `DORMOUSE_BIND_HOST` | Interface to listen on; unset binds every interface (below). | | `DORMOUSE_VAPID_PUBLIC_KEY` / `DORMOUSE_VAPID_PRIVATE_KEY` | Web Push signing keypair; set both or neither. At startup the Server decodes both, derives the P-256 public point from the private key, and exits on a missing, malformed, or mismatched pair. Unset, it mints a pair on first boot into `vapid.json`. | | `DORMOUSE_VAPID_SUBJECT` | `mailto:`/`https:` contact for push-service operators (RFC 8292), defaulted from `DORMOUSE_ORIGIN` and validated at startup — Web Push below. | @@ -71,7 +71,7 @@ the plaintext port to the LAN and to the tailnet itself; the selfhost install sets `DORMOUSE_BIND_HOST`, and the default stays unbound for containers, where the namespace is the boundary. Binding loopback is *containment, not admission* — every route is still gated by the setup password or a bearer token, exactly as -`SECURITY.md` -> "Loopback Listeners" requires; `scripts/loopback-lint.mjs` does +`docs/specs/security-local.md` -> "Loopback Listeners" requires; `scripts/loopback-lint.mjs` does not cover this socket (rationale). **`DORMOUSE_ORIGIN` is normalized to a bare origin exactly once**, in @@ -434,7 +434,7 @@ server's one third-party runtime dependency. Host and webview halves: HTTPS agent whose connection-time DNS lookup rejects every non-public range, refuses a hostname *wholesale* if any answer is blocked, and hands the socket the exact address it checked, so rebinding and mixed answers cannot create a - second unchecked resolution. Range list: `SECURITY.md` -> "Remote Control". + second unchecked resolution. Range list: `docs/specs/security-remote.md` -> "What crosses the boundary". - **The payload is sealed, and the Server reads none of it.** A send carries `recipients: [{ deliveryId, sealed }]` — one envelope per Client, the seal being to that Client's own static — and the Server validates only shape and @@ -786,7 +786,7 @@ exists to honor: state**; enrolled answers `offer: null` without touching disk (rationale). - **The offer's token never enters a webview.** `status` carries only the origin; `enrollOffer` re-reads the file in the Host service, so an old card cannot - reuse a spent offer (`SECURITY.md`). + reuse a spent offer (`docs/specs/security-remote.md` -> "Credentials at rest"). - **The click echoes the origin the card displayed**, and the service refuses a file that no longer names it — an installer rerun rewrites the offer with an origin nobody reviewed. `enrollOffer` takes `{ origin, label }`: the origin @@ -969,7 +969,7 @@ HTTPS and proxying to the server on loopback. **[SELF_HOST.md](../../SELF_HOST.md) is both the operator runbook and the installer spec**: the per-platform mechanism map, the availability shape, the invariants the three installers hold, and the mechanical traps they encode live -there, audited by the `FAIL IF` lines in `SECURITY.md` and checked textually by +there, audited by the `FAIL IF` lines in `docs/specs/security-remote.md` and checked textually by `scripts/deploy-lint.mjs` (`pnpm lint:deploy`). Source of truth: `deploy/local/install-macos.sh`, `deploy/local/install-windows.ps1`, `deploy/local/install-linux.sh`. diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 60aed6b3..ddba393a 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -292,7 +292,7 @@ window, `/sessions/