From 66b931bf1580034ce08b74c4d49109e1ecfa63da Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 23:39:21 -0700 Subject: [PATCH 1/3] Condense release and security references around their owning controls --- AGENTS.md | 26 +++-- SECURITY.md | 26 ++--- docs/specs/deploy.md | 13 ++- docs/specs/deploy.rationale.md | 6 + docs/specs/security-audit.md | 6 +- docs/specs/security-audit.rationale.md | 4 + docs/specs/security-ci.md | 12 +- docs/specs/security-ci.rationale.md | 2 + docs/specs/security-supply-chain.md | 21 +--- docs/specs/security.md | 148 +++++++++++-------------- scripts/spec-word-budgets.json | 10 +- website/scripts/generate-deps.js | 18 +-- 12 files changed, 140 insertions(+), 152 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0f2f4ab96..00ffa82cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ A mouse-friendly multitasking terminal built with pnpm, react, typescript, vite, ``` pnpm install # install deps -pnpm build # build lib, vscode extension, Pocket, and website +pnpm build # build lib, vscode extension, Pocket, website, and Hosted ``` **Inside Dormouse, run `innerdogfood`** — `dor tool innerdogfood`. @@ -31,7 +31,7 @@ The Tool shows the harness in its own pane and prints the command to drive it - **`vscode-ext/`** — VS Code extension wrapping the lib in a webview (esbuild; node-pty via forked child process; direct-path WebRTC via node-datachannel, every platform's addon in one VSIX) - **`website/`** — Marketing site (Vite) bundling part of the lib as an interactive demo on `FakePtyAdapter` - **`relay/`** — Selfhost coordinating Relay for remote control (Hono): accounts + passkey auth in local JSON files (no database), WebSocket routing between Clients and Burrows, serves the built Pocket app -- **`hosted/`** — Hosted's three Hono Workers: account and Better Auth (`hosted.dormouse.sh`), one-time rendezvous (`relay.`), voice (`voice.`); Postgres. +- **`hosted/`** — Hosted's three Hono Workers: account and Better Auth (`hosted.dormouse.sh`), account-scoped Relay/Pocket and one-time rendezvous (`relay.`), voice (`voice.`); Postgres. - **`dor/`** — The `dor` CLI (stricli) staged onto the `PATH` of every Dormouse-launched terminal; talks to its host over a private control socket - **`remote-lib-common/`** — Security primitives + remote wire contract shared by `relay`, the Burrow module in `lib`, and the Pocket app (bare ES2022 — no DOM or Node types) - **`dor-lib-common/`** — Cross-platform external-process spawning (`spawnAndCapture`) shared by `dor` and the `lib` host. Despite the parallel names, the two `*-lib-common` packages are unrelated: `remote-lib-common` is remote security/wire, `dor-lib-common` is spawn plumbing. @@ -75,7 +75,7 @@ A spec is the accurate reference for the current code: it states the invariants - **`docs/specs/remote-network.md`** — The network policy (Nothing / Local networks / Anywhere / My Relay only): its choke points, the update reminder, the Local networks path check, Cloudflare STUN, and each level's paired-phone path. - **`docs/specs/remote-api.md`** — What an authorized Client speaks: the shipped terminal-only **protocol-v1** and the staged remainder. - **`docs/specs/relay.md`** — The selfhost coordinating Relay and shared Burrow-service runtime: env config, JSON-file state, WebAuthn without a library, HTTP API, relay flow, enrollment, running it end to end. -- **`docs/specs/hosted.md`** — Hosted accounts: application boundary, login/linking policy, local development, and staged paid services. +- **`docs/specs/hosted.md`** — Hosted accounts: login/linking policy, account-scoped Relay and enrollment, Worker deployment, local development, and staged paid services. - **`docs/specs/one-time.md`** — One-time connection: the link a laptop shows, its Settings panel and Baseboard indicator, the Hosted rendezvous wire that carries only its handshake, the phone page Hosted serves, and the direct-only session; no account, nothing saved. - **`docs/specs/security-hosted.md`** — Hosted account origin, identity, and deployment security checks. - **`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. @@ -113,18 +113,20 @@ Specs are written ahead of the code: a new component's spec starts as a full des `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`, `SELF_HOST.md`, and `docs/compatible-agents.md` ride the same checks. Advisory prose reviews follow `docs/prose-audit.md` (`pnpm audit:prose`). -Six sibling lints run in `pnpm test`. Five enforce one invariant a spec states in prose and name the line they enforce; only `public-docs-lint` and `e2e-lint` read that prose and fail when the line is gone. `ps1-cmdlet-lint` guards the one shipped file nothing else can parse: +Root `package.json` owns the lint suite. This map locates each lint's owning contract; `public-docs-lint` and `e2e-lint` also require the prose they enforce to remain present: | Lint | Enforces | |---|---| -| `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`) | `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/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. No job has a PowerShell, so this is the Windows installer's only syntax gate. | -| `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 Relay-side protocol-v1 type, no one-time reader in the Relay or `BurrowRuntime`, no store in the one-time phone, no checked-in service worker, no optional field on a ciphertext or transcript; and `docs/specs/security-hosted.md`: no frame read in Hosted's one-time room, or decoded or kept in its `RelayRoom`. | - -`scripts/spec-lint-selftest.mjs` plants one defect per finding check in the spec lint. The `deploy`, `e2e`, and `loopback` lints carry self-tests that mutate each rule in whichever direction it points: a present-control rule has its control deleted (and, for exact-count rules, a copy added), a `forbidden` rule has the banned text appended. `scripts/e2e-lint-selftest.mjs` is mostly the second kind; `scripts/deploy-lint-selftest.mjs` mostly the first. 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; `scripts/ps1-cmdlet-lint-selftest.mjs` carries the `ps1-cmdlet` lint's mutations. `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. +| `scripts/public-docs-lint.mjs` (`pnpm lint:public-docs`) | `docs/specs/website-docs.md` → "Public-doc validation". | +| `scripts/xterm-lint.mjs` (`pnpm lint:xterm`) | `docs/specs/webgl-text.md` → "Fork pipeline", "Following upstream", "Canopy lab". | +| `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) | `docs/specs/security-local.md` → "Loopback Listeners". | +| `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) | `docs/specs/security-remote.md` → "Credentials at rest", "Network posture (self-hosted)"; `SELF_HOST.md` → "Installer contract (maintainers)". | +| `scripts/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. This is the Windows installer's syntax gate. | +| `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) | `docs/specs/security-remote.md` → "Remote Control"; `docs/specs/security-hosted.md` → "Rendezvous boundary", "Relay boundary". | + +**Must add a mutation self-test for every new finding rule in a lint that carries self-tests, and require that mutation to make the lint fail.** An untested rule is a claim, not an enforced check. Root `package.json` names the suites; their shared plumbing, and only that, is `scripts/lint-kit.mjs`. + +`scripts/installer-verify-test.mjs` executes extracted shipped installer helpers. `scripts/clamp-issue-body-selftest.mjs` pins the audit issue-body helper. Both run in root `pnpm test`. ## Design diff --git a/SECURITY.md b/SECURITY.md index 34bd813dc..503b1fa5b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,22 +1,12 @@ # Security policy -**Report a vulnerability privately** through GitHub's +**Report vulnerabilities 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 Relay, 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. +form. [Reporting a vulnerability](docs/specs/security.md#reporting-a-vulnerability) +owns the reporting rules and handling policy. -**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. Its -[Domains table](docs/specs/security.md#how-the-guarantees-are-checked) names -every audited checklist beside it, 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. +The canonical [security spec](docs/specs/security.md) states the guarantees, +accepted risks, known gaps, and [how they are checked](docs/specs/security.md#how-the-guarantees-are-checked). +It is published at [dormouse.sh/security](https://dormouse.sh/security), with +the guarantee and risk blocks narrowed to that page's audience; the repository +spec contains every row. diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index a0db254e5..7568a35b0 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -43,7 +43,7 @@ Human-driven, in order: ## Versioning -**Must synchronize the four version files — `lib/package.json`, `vscode-ext/package.json`, `standalone/src-tauri/Cargo.toml`, `standalone/src-tauri/tauri.conf.json` — and Cargo.lock's `dormouse` entry with `scripts/bump-version.sh`** (`cargo check --offline`). **A version is `X.Y.Z`**: both the bump script and `sign-and-deploy.sh` reject a prerelease suffix, rather than one of them discovering it after the tag is pushed. +**Must synchronize the four version files — `lib/package.json`, `vscode-ext/package.json`, `standalone/src-tauri/Cargo.toml`, `standalone/src-tauri/tauri.conf.json` — and Cargo.lock's `dormouse` entry with `scripts/bump-version.sh`.** The bump runs Cargo directly; an ambient Node mismatch can fail after version files have changed. **Must accept only `X.Y.Z` release versions** in both bump and signing scripts. **A release is triggered by pushing one tag (`v0.1.0`)** — never separate `vscode-ext/v*` and `standalone/v*` tags, because one changelog entry covers both. @@ -51,7 +51,7 @@ Source of truth: `scripts/bump-version.sh`; `on.push.tags` in `.github/workflows ## Two-stage pipeline -**Both signing steps must run locally** — Windows code signing requires a physical USB hardware key (EV cert via PIV), macOS a local Developer ID cert. +**Must apply production OS and updater signatures locally** — Windows OS signing uses a physical PIV key, macOS a local Developer ID certificate. - **Stage 1 (CI)** — build, attest, and upload the unsigned artifacts: the three Tauri bundles and the `.vsix`, which Stage 2 verifies but never signs. - **Stage 2 (local, `sign-and-deploy.sh`)** — verify, sign, and release. @@ -120,16 +120,19 @@ pnpm --dir standalone exec tauri signer generate # creates the Tauri update sig ### Two signing layers -**Both layers are required** (rationale). +**Must apply OS signing on macOS and Windows, and updater signing to every desktop bundle.** Linux receives the updater signature alone (rationale). | Layer | What it signs | Who verifies | Without it | |-------|--------------|--------------|------------| | OS (codesign / jsign) | Executable (`.app` / `.exe`) | OS, on launch | Gatekeeper / SmartScreen warnings | | Tauri updater (ed25519) | Update bundle (`.tar.gz` / `.exe` / `.AppImage`) | Running app, on update | Updater rejects the download | -**Must OS-sign the inner executable, package it, then Tauri-sign the final bundle.** Embed the generated `.sig` in the website manifest and remove the sidecar signature file before upload. +**Must OS-sign macOS and Windows code before packaging, then Tauri-sign every final bundle.** Embed the generated `.sig` in the website manifest and remove the sidecar signature file before upload. -Two macOS packaging edge cases the script enforces, each of which would ship a release that fails only on the user's machine: **never `--deep`-sign the outer `.app`** — nested binaries (the Node sidecar, the node-pty and node-datachannel prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and requires both native addons from it — and **build the `.tar.gz` with `COPYFILE_DISABLE=1`**, re-scanning the result for `._*`. Both carry their reasoning at `sign_macos_app` and `notarize_macos` in the script. +- **Never `--deep`-sign the outer macOS `.app`; sign nested code first and verify the signed sidecar loads `node-pty` and `node-datachannel/polyfill`.** +- **Must archive macOS updates with `COPYFILE_DISABLE=1` and reject `._*` entries.** + +Source of truth: `sign_macos_app` / `notarize_macos` in `scripts/sign-and-deploy.sh`. ### Packaged app logging diff --git a/docs/specs/deploy.rationale.md b/docs/specs/deploy.rationale.md index 56d434664..9e9aa3818 100644 --- a/docs/specs/deploy.rationale.md +++ b/docs/specs/deploy.rationale.md @@ -2,6 +2,10 @@ > Informative companion to [deploy.md](deploy.md): evidence and design history keyed by that spec's headings. Nothing here is normative. +## Versioning + +Source audit, 2026-10: the bump invokes Cargo directly after editing version files. The native build refuses an ambient Node version that differs from the workspace pin; the script does not arrange that pin on PATH. + ## Stage 1: CI workflow **Why `release-attest` is its own environment, with no secrets and no reviewer.** A required reviewer would stall every release on manual approval at its first jobs, and build jobs have no business seeing credentials. Neither existing `v*` environment fits: `vscode-extension-publish` requires reviewers; `security-audit` holds `AUDIT_PAT` and `CLAUDE_CODE_OAUTH_TOKEN`. @@ -27,3 +31,5 @@ The 2026-09-05 audit found that standalone resume commands reset every working a ## Two signing layers **What each layer actually proves.** OS signing proves the executable is from DiffPlug; Tauri signing proves the update bundle was not tampered with in transit. + +The October 2026 audit traced `sign_macos`, `sign_windows`, and `sign_updates`: the Linux AppImage is copied from verified CI artifacts and receives Tauri signing, with no Linux OS-signing step. The body now states that platform scope rather than requiring both layers for Linux too. diff --git a/docs/specs/security-audit.md b/docs/specs/security-audit.md index 319b5ff2b..39eb70edd 100644 --- a/docs/specs/security-audit.md +++ b/docs/specs/security-audit.md @@ -80,10 +80,10 @@ Source of truth: `2. Wait without ending your turn`, `3. Merge`, and `4. The ver - **With no `audit-report.md` the reporting step publishes each fragment verbatim under its own heading**, unmerged (rationale). - **Must return `VERDICT: INCONCLUSIVE` from a domain with any undetermined check unless it found a failure.** Only all-determined passing checks permit `VERDICT: PASS`; a domain's inconclusive verdict prevents a merged pass. - **Never write a `FAIL IF` condition no audit run can read**: audit the readable half; stage the rest under `## Future` only while it is unbuilt, and otherwise state it beside the rule. `AUDIT_PAT`-readable GitHub state stays audited (rationale). -- **`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. +- **Must escalate once in `FAIL` > `MISSING` > `PASS` order.** Dissent can raise `MISSING` to `FAIL`, never demote `FAIL`; incomplete evidence remains reported beside it. - **Every fragment's verdict line is lifted into the head ahead of the report, then every fragment's `UNVERIFIABLE`, `FAIL:`, `BLOCKER` and `WARNING` lines** — two blocks, not one per domain. Both passes match at line start after an optional heading or bullet marker, so the fragments' `FAIL IF` vocabulary is not read as a finding, and both cut lines to 500 characters. **Only the findings block carries the 40-line cap**, so no domain's findings can push another's verdict out. **Neither pass may fail the step when it matches nothing** — under `set -eo pipefail` that would post no report at all (rationale). - **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). +- **Must attempt the world-readable `audit-transcript` upload during postprocessing** — 14-day retention; runner timeout or cancellation can prevent upload, and a missing artifact receives no download link (rationale). - **FAIL IF** the `Redact secrets from agent output` step is removed, stops covering any sink that is later published (`audit-report.md`, the four 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). @@ -91,6 +91,8 @@ Source of truth: `2. Wait without ending your turn`, `3. Merge`, and `4. The ver - **FAIL IF** the reporting step accepts any domain verdict other than exact `VERDICT: PASS` as passing, fails to recognize a `VERDICT: FAIL` prefix as dissent, ignores an inconclusive domain, accepts a fragment with no completion sentinel as finished, or accepts status text other than literal `PASS`/`FAIL` (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). +Known gaps: PASS can be accepted without a merged report; default issue-list pagination can leave older failures open; quoted `VERDICT:` lines can flood the preserved head (rationale). + Source of truth: `clampIssueBody` in `scripts/clamp-issue-body.mjs`; `Surface result, file or close issue` in `.github/workflows/security-audit.yaml`; reporting, redaction, and local-runner regressions in `scripts/security-audit.test.mjs`. ## Environment and `AUDIT_PAT` diff --git a/docs/specs/security-audit.rationale.md b/docs/specs/security-audit.rationale.md index 489860f2e..7d082dc3d 100644 --- a/docs/specs/security-audit.rationale.md +++ b/docs/specs/security-audit.rationale.md @@ -60,6 +60,8 @@ Run 35205193090's `## Summary` also inverted the placeholder it was reading: "tw ## Outcomes and reporting +Source audit, 2026-10: quoted `VERDICT:` lines in the first fragment can displace later verdicts beyond the 32,000-character clamp. PASS status with finished domains but no merged report can still close failure issues. The default `gh issue list` returns only 30, leaving older open failures unreconciled. These reporter defects remain unfixed in the current code. + 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". A `FAIL IF` condition no audit run can read makes the verdict a coin flip, because no run can ever determine it. `AUDIT_PAT`-readable GitHub state is not in that class: a failed call there is a real `UNVERIFIABLE`. `## Future` holds such an obligation only while its subject is unbuilt, since a staged item must eventually be promoted; a standing obligation on existing infrastructure is present-tense fact and stays beside its rule. `security-hosted.md` carried two: the Cloudflare script-injection exclusion, which is a zone setting, and a closing activation sentence that stated its own answer. Run 35586089654 (2026-09-21) reached both as `UNVERIFIABLE` in its sub-auditors and its domain lead resolved both to PASS, on the ground that the audited condition was the in-repo half; run 35709640946 (2026-09-22) left both `UNVERIFIABLE`, so a pass with 375 PASS and 0 FAIL returned INCONCLUSIVE and held the release gate shut (issue #747). Nothing in the tree had changed between them. #757 staged both under `security-hosted.md`'s `## Future` and kept the in-repo half — the deploy's `preflight` gate — as a `FAIL IF`. @@ -80,6 +82,8 @@ The redaction step is the only thing between an accidental `printenv` and a worl 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. +The October 2026 audit checked the upload's `if: always()` and the reporter's absent-artifact branch. They attempt postprocessing after ordinary failures, but cannot establish an upload after the runner itself times out or is cancelled; the issue links a download only when the artifact lookup returns an id. + 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. Publishing the fragments when no merged report exists is the same "a prompt is not a control" split as the guards above. Run 34581574869 (2026-09-11) ended its turn before the merge, so this step's report section was one line saying no report was produced — while `supply-chain` and `ci-and-secrets` had finished `VERDICT: PASS` fragments in the working directory, already redacted and already read twice by the guard loops. `.github/audit/orchestrator.md` §4 now forbids ending the turn there, but the run's findings should not depend on that sentence being followed. Verbatim and unmerged, because §3's merge is the only thing entitled to characterise a fragment; the cut-off and absent markers are the exception, being the same mechanical tests the step's own guard loops already ran, and a fragment published without them reads as a finished report. diff --git a/docs/specs/security-ci.md b/docs/specs/security-ci.md index 2a7d55b7c..779e5a686 100644 --- a/docs/specs/security-ci.md +++ b/docs/specs/security-ci.md @@ -16,7 +16,7 @@ ## 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. +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. | Secret | What a compromise buys | What bounds it | | --- | --- | --- | @@ -29,7 +29,7 @@ This repository runs the [tend](https://github.com/max-sixty/tend) agent harness **Instruction files are part of that surface.** On a fork PR the privileged `pull_request_target` runner workspace holds the base tree; the attacker-controlled PR tree exists only in the agent's copy-on-write view, and that is where the *project instructions* Claude Code loads (`CLAUDE.md`, `AGENTS.md`, `.claude/`, `.mcp.json`) come from. **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). +**Must keep real `TEND_BOT_TOKEN` and Anthropic credentials out of the agent's environment, disk, and `.git/config`.** Trusted runner steps receive them and provision the credential-injecting proxy; setup strips the checkout credential before the separate, non-sudo agent starts (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.** @@ -118,7 +118,13 @@ The extension is published by GitHub Actions, and the publishing secrets `VSCE_P | `EV_SIGN_PIN` | **env-only**; `jsign --storepass env:EV_SIGN_PIN` resolves it from the process environment | the physical YubiKey | | `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. +The `APPLE_SIGN_PASS` exposure is a known gap; its remedy is staged under `## Future` → Notarization 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. Pinned by `scripts/sign-and-deploy.test.mjs`. - **FAIL IF** `TAURI_SIGNING_PRIVATE_KEY` is passed on a command line anywhere in `scripts/sign-and-deploy.sh` rather than through the environment, or `EV_SIGN_PIN` is passed literally to `jsign --storepass` instead of by environment-variable reference. + +## Future + +### Notarization credentials + +Use `notarytool store-credentials` plus `--keychain-profile` to move password exposure to one short provisioning call instead of every submission. Update the release runbook and verify with live Apple credentials before promotion. diff --git a/docs/specs/security-ci.rationale.md b/docs/specs/security-ci.rationale.md index 6e6f6623f..f56c04b31 100644 --- a/docs/specs/security-ci.rationale.md +++ b/docs/specs/security-ci.rationale.md @@ -16,6 +16,8 @@ **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. +The October 2026 audit read tend 0.3.5's `claude/action.yaml`, `restore-sensitive-config.sh`, and instruction-pinning helper. Trusted outer steps receive credentials for preflight and proxy setup; the boundary is their absence from the sandbox agent, rather than an assertion that no trusted runner process outside the proxy ever holds them. The generated reaction/pre-check steps also receive the bot credential without launching an agent. + **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. diff --git a/docs/specs/security-supply-chain.md b/docs/specs/security-supply-chain.md index 10d983b5c..fd129c421 100644 --- a/docs/specs/security-supply-chain.md +++ b/docs/specs/security-supply-chain.md @@ -13,24 +13,9 @@ - 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.** +**Must classify every workspace from its shipping route: a product root or runtime edge if Dormouse writes its files onto a user's disk, an exclusion only if it installs no artifact.** The root and exclusion arrays document those routes beside their entries; the audit derives shipping independently from the builds. -| 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` | -| `relay` | 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) | - -**Must list `dormouse-lib` as a root independently of workspace edges**; `remote-lib-common`, `dor-lib-common`, `dor-tools-builtin`, and `dor-tools-lib` are workspace edges from those roots. **Must use package names for roots and exclusions**; for example, `vscode-ext/` declares itself `dormouse` and `website/` declares itself `dormouse-website`. - -**Must exclude workspaces that install no artifact:** - -- `canopy` — a Storybook-only rendering lab no shipped build imports. -- `dormouse-website` — runs in a visitor's browser rather than being installed anywhere (rationale). -- `dormouse-hosted` — runs on Workers and in the browser; no installed desktop or selfhost artifact imports it. +**Must list `dormouse-lib` as a root independently of workspace edges** (rationale). **Must use package names for roots and exclusions.** **External binaries are outside this graph by construction** — the user's shell, and the `agent-browser` CLI `dor agent-browser` 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). @@ -49,7 +34,7 @@ The roots are `productDependencyFilters` in `website/scripts/generate-deps.js`. - **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` (rationale). - **FAIL IF** `.github/workflows/ci.yml` stops running that generator under that same install precondition, or stops failing on a diff (rationale). -- **FAIL IF** the disclosure omits a shipped workspace's graph or excludes a shipped package. Derive shipping routes from `pnpm-workspace.yaml` and the builds, not the enumeration above; the generator enforces classification, but cannot establish whether an exclusion is justified (rationale). +- **FAIL IF** the disclosure omits a shipped workspace's graph or excludes a shipped package. Derive shipping routes from `pnpm-workspace.yaml` and the builds, not the generator's arrays; the generator enforces classification, but cannot establish whether an exclusion is justified (rationale). Source of truth: `productDependencyFilters` / `excludedWorkspacePackages` / `optionalSiblingsAtSameVersion` in `website/scripts/generate-deps.js`; `assertWorkspaceCoverage` in `website/scripts/dependency-workspaces.js`; `getShippedCargoGraph` / `getCargoGitRepository` in `website/scripts/cargo-dependencies.js`. diff --git a/docs/specs/security.md b/docs/specs/security.md index e7f2ad74f..7337a5227 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -13,51 +13,51 @@ Dormouse holds shells, source trees, credentials, and local files. Its **remote control** admits an authorized phone as a person at the keyboard; **loopback listeners** receive requests from pages in the user's browser. -**Remote control ships two ways: through a self-hosted Relay, and as a one-time -connection that needs no Relay.** Hosted account code is implemented with -production provisioning pending ([Hosted accounts](./hosted.md)); it grants no -terminal access. Hosted also serves the one-time phone page and forwards a -one-time connection's handshake ciphertext, authorizing nothing -([Hosted rendezvous](./one-time.md#hosted-rendezvous)); its deploy pipeline and -Cloudflare account are part of a one-time session's trust base. The relay -runs on hardware the user owns and is private to their tailnet by default, but -its application boundary assumes the HTTPS origin is public -([SELF_HOST.md](../../SELF_HOST.md)). -**Nothing about remote control applies to a Burrow (a Standalone or VS Code -Dormouse) that never enrolls with a Relay and never opens a one-time link**: -enrollment is where the relay, the phone, and push begin, and a link admits -one phone for one session. -Cloud-hosted operation is staged, and its boundary is re-analyzed before that -code ships ([security-remote.md](./security-remote.md#future)). +**Remote control supports relay-backed sessions and direct one-time connections.** +The Relay can be self-hosted; Hosted implements admin-entitled enrollment, +account-scoped relay routing, and Pocket, with live production controls and paid-service +acceptance requiring verification ([Hosted](./hosted.md)). **Hosted account sign-in alone grants +no terminal access.** Pairing and presence remain the Burrow's decision. +Hosted also serves the one-time phone page and forwards only that connection's +handshake ciphertext, authorizing nothing +([Hosted rendezvous](./one-time.md#hosted-rendezvous)); its deployment pipeline and +Cloudflare account are part of a one-time session's trust base. + +A self-hosted Relay runs on hardware the user owns; the default installer keeps +it private to the tailnet, while the application boundary permits a public HTTPS +origin ([SELF_HOST.md](../../SELF_HOST.md)). **Remote access starts only when a +Burrow enrolls with a Relay or opens a one-time link**; a link admits one phone +for one session. Paid cloud operation still needs the review under +[Cloud-hosted mode](./security-remote.md#cloud-hosted-mode). ## 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. +Each guarantee names its owning rule and automated checks. The +[nightly audit](#how-the-guarantees-are-checked) checks all of them; *audit* in the +last column identifies a property without a cheaper automated check. Native +Cargo tests run in separate CI jobs, outside root `pnpm test`. | Guarantee | Rule | Pinned by | | --- | --- | --- | -| **A program printing to your terminal cannot write your clipboard, read a file, or steal focus.** Its `OSC 52` copy is only an offer the copy editor shows. It can raise an alert, set a title, or mark a prompt; an OSC 8 link requires confirmation unless it is a local file link naming its target, which previews, and a deceptive link has no open action. | [Terminal output](./security-local.md#terminal-output) | `lib/src/lib/terminal-protocol.test.ts`, `lib/src/lib/external-links.test.ts`, `lib/src/lib/terminal-link-activation.test.ts`, `lib/src/components/ExternalLinkModalHost.test.tsx` | -| **A page in a browser pane cannot forge a host message.** 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` | +| **Terminal output cannot write your clipboard or steal focus.** File access requires a user action or a running designated Tool's gated OSC 367 `open`. OSC 52 only offers a copy format; links require confirmation or an allowed local preview, and deceptive links have no open action. | [Terminal output](./security-local.md#terminal-output) | `lib/src/lib/terminal-protocol.test.ts`, `lib/src/lib/external-links.test.ts`, `lib/src/lib/terminal-link-activation.test.ts`, `lib/src/components/ExternalLinkModalHost.test.tsx` | +| **A page in a browser pane cannot forge a host message.** | [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`.** 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` | | **Current persistence writers never save terminal scrollback.** Standalone snapshots are owner-only; VS Code controls access to its own storage. Older snapshots may contain transcripts. | [Persisted state](./security-local.md#persisted-state) | `cargo test` in `standalone/src-tauri` (the owner-only half); audit | -| **Nothing but a human at the laptop can authorize a phone.** The only path into a Burrow's ACL is typing, on that Burrow, the two digits the phone shows, and the Burrow makes every access decision. | [Pairing](./remote-security-model.md#pairing), [Burrow Authorization](./remote-security-model.md#burrow-authorization) | `remote-lib-common/test/security-guarantees.test.mjs` | -| **A one-time connection is one session, confirmed at the laptop.** Only typing, on the laptop, the two digits that phone shows authorizes it; nothing is saved at either end, and its terminal traffic runs only directly between the two devices, over a network Settings → Network allows; Hosted carries the handshake alone. | [One-time connection](./remote-security-model.md#one-time-connection), [its checks](./security-remote.md#one-time-connection) | `lib/src/remote/client/one-time-e2e.test.ts`, `scripts/e2e-lint.mjs` | -| **The Relay cannot read ceremony or terminal content or grant terminal access.** One end-to-end channel per ceremony carries content under keys the Relay never holds; account data and routing metadata remain visible. | [Trust Model](./remote-security-model.md#trust-model), [Residual metadata](./remote-security-model.md#residual-metadata) | `scripts/e2e-lint.mjs` | +| **Phone authorization requires local confirmation at the Burrow, which makes every access decision.** | [Pairing](./remote-security-model.md#pairing), [Burrow Authorization](./remote-security-model.md#burrow-authorization) | `remote-lib-common/test/security-guarantees.test.mjs` | +| **A one-time connection is one session, confirmed at the laptop, with nothing saved at either end.** Terminal traffic runs directly between the devices under Settings → Network; Hosted carries only the handshake. | [One-time connection](./remote-security-model.md#one-time-connection), [its checks](./security-remote.md#one-time-connection) | `lib/src/remote/client/one-time-e2e.test.ts`, `scripts/e2e-lint.mjs` | +| **The Relay cannot read ceremony or terminal content or grant terminal access.** Account data and routing metadata remain visible. | [Trust Model](./remote-security-model.md#trust-model), [Residual metadata](./remote-security-model.md#residual-metadata) | `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) | `remote-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) | `remote-lib-common/test/security-guarantees.test.mjs` | | **The Burrow bounds remote session state and handshake admission independently of the Relay.** Deadlines use its own clock. | [Burrow bounds](./remote-security-model.md#burrow-bounds) | `lib/src/remote/burrow/burrow-bounds.test.ts`, `relay/test/malicious-relay.test.mjs` | | **Under Settings → Network → Nothing, a new install's level, Dormouse opens no connection on its own**: no relay socket, one-time link, push, managed voice, or update check. What you click, and what your terminals and browser panes reach, are yours. | [Network policy](./security-local.md#network-policy) | `lib/src/host/remote/service.test.ts`, `lib/src/host/managed-voice-host.test.ts`, `standalone/src/updater.test.ts` | | **A Burrow talks only to the one relay origin its build was pointed at, and a self-host build contacts Dormouse's servers only when you click a link.** A stock build reaches only Hosted. | [Relay origin](./security-remote.md#relay-origin) | `lib/src/host/relay-origin.test.ts` | | **The self-host installer restricts Relay credentials to the installing account**, on macOS, Windows, and Linux; Burrow enrollment uses protected app storage or VS Code's secret storage. Installer owner-check gaps are listed below. | [Credentials at rest](./security-remote.md#credentials-at-rest) | `scripts/deploy-lint.mjs` | -| **The self-host HTTPS origin may be public; its plaintext backend may not.** The Relay generates its 256-bit setup credential with no operator-supplied value, Burrow enrollment is globally admission-limited, cross-origin browsers receive no grant, and terminal access still needs local Burrow approval. | [The setup password](./security-remote.md#the-setup-password), [Cross-origin access](./security-remote.md#cross-origin-access), [Network posture](./security-remote.md#network-posture-self-hosted) | `relay/test/setup-password-store.test.mjs`, `relay/test/config.test.mjs`, `relay/test/token-bucket.test.mjs`, `relay/test/cors.test.mjs`, `scripts/deploy-lint.mjs` | +| **The self-host HTTPS origin may be public; its plaintext backend may not.** Enrollment is admission-limited, cross-origin browsers receive no grant, and terminal access still requires local Burrow approval. | [The setup password](./security-remote.md#the-setup-password), [Cross-origin access](./security-remote.md#cross-origin-access), [Network posture](./security-remote.md#network-posture-self-hosted) | `relay/test/setup-password-store.test.mjs`, `relay/test/config.test.mjs`, `relay/test/token-bucket.test.mjs`, `relay/test/cors.test.mjs`, `scripts/deploy-lint.mjs` | | **Push, when enabled, cannot be aimed back into the tailnet.** | [What crosses the boundary](./security-remote.md#what-crosses-the-boundary) | `relay/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` | +| **Every dependency Dormouse puts on a user's 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` | -| **Dependencies wait 24 hours**, except audited pgstencil releases approved with 2FA. | [Cooldown and alerts](./security-supply-chain.md#cooldown-and-alerts) | audit | +| **Dependency adoption has at least a 24-hour cooldown**, except audited pgstencil releases approved with 2FA. | [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 | @@ -101,7 +101,8 @@ run this knows what they are taking on. - **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 +- **Availability.** Remote terminal access needs an online Burrow and, for + new relay-backed sessions, an available Relay ([Goals](./remote-security-model.md#goals); [keeping it up](../../SELF_HOST.md#keeping-the-relay-up-while-the-laptop-sleeps)). - **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 @@ -128,74 +129,57 @@ Gaps rather than accepted risks: we intend to close them. - **Revocation has no mechanism.** Revoking a lost phone is editing the Burrow's ACL file and restarting the Burrow ([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)). -- **The workflow audit's window has two evasions**, both in how the window is - computed ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). -- **The audit's four subagents share one credential.** Their contexts are - separate; `AUDIT_PAT` is not ([Domains](./security-audit.md#domains)). +- **There is no structured audit trail** covering connects, attaches, denials, + or writes ([same](./security-remote.md#revocation-and-the-audit-trail)). +- **The workflow audit can miss malicious changes** + ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). +- **Audit domains 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** +- **Pocket Home Screen camera verification requires real iOS hardware** ([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. Four 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 reaching no verdict, files a public +Root `pnpm test` runs the JavaScript checks and lint mutation suites; native +Cargo CI jobs test platform-specific persistence. `package.json` owns the root +suite, and [Specs in AGENTS.md](../../AGENTS.md#specs) maps its lints to their +owning contracts. A mutation test must make its protected rule fail; +`scripts/installer-verify-test.mjs` executes installer helpers the lints only read. + +**Every night at 04:21 UTC, and before every VS Code release**, the +[security audit](./security-audit.md#schedule-and-gate) executes every audited +clause and reads each domain adversarially for holes no check names. Its +[Domains](./security-audit.md#domains) own the disjoint scopes and prompts. +A failure or inconclusive run 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 record what tripped and changed. -`scripts/security-audit-local.sh` runs the same prompts locally. -[security-audit.md](./security-audit.md) is the contract. pgstencil audits the -packages Hosted consumes in its own repository. - -| Domain | Specs | Covers | -| --- | --- | --- | -| `application-security` | [security-local.md](./security-local.md), [security-remote.md](./security-remote.md) | local boundaries, remote control, and everything no other domain claims | -| `hosted` | [security-hosted.md](./security-hosted.md) | Hosted accounts, the one-time rendezvous, and the pgstencil provenance link | -| `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 | +ones record what tripped and changed. `scripts/security-audit-local.sh` runs +the same prompts locally. pgstencil audits the packages Hosted consumes in +its own repository. -**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)). +Production dependency changes require committed regenerated +[disclosure](./security-supply-chain.md#disclosure). Desktop release artifacts +carry CI attestations and hash manifests, verified locally before +[signing](./security-ci.md#desktop-releases). -**Every desktop release** ships attestations and hash manifests from CI, verified -locally before anything is signed -([Desktop Releases](./security-ci.md#desktop-releases)). +Source of truth: root scripts in `package.json`; native jobs in +`.github/workflows/ci.yml`; `.github/workflows/security-audit.yaml` and its +release gate in `.github/workflows/release.yml`. ## Reporting a vulnerability -Report privately through GitHub's +**Must report vulnerabilities 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 Burrow'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 Relay, 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. +form, visible only to the reporter and maintainers. **Never open a public issue +or email the maintainer.** Include the version or commit, deployment +(self-hosted Relay, Hosted, standalone app, or VS Code extension), and shortest +reproduction. Every advisory is acknowledged with intended next steps; there +is no bounty or promised response time. A coordinated-release requirement is +communicated in the advisory. - **FAIL IF** private vulnerability reporting is disabled on the repository (`gh api repos/diffplug/dormouse/private-vulnerability-reporting` must report diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 8a6106690..757422c10 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,6 +1,6 @@ { - "AGENTS.md": 3600, - "SECURITY.md": 200, + "AGENTS.md": 3400, + "SECURITY.md": 100, "SELF_HOST.md": 6100, "docs/compatible-agents.md": 1800, "docs/specs/alert.md": 8650, @@ -23,12 +23,12 @@ "docs/specs/remote-network.md": 2700, "docs/specs/remote-security-model.md": 5400, "docs/specs/security-audit.md": 2100, - "docs/specs/security-ci.md": 2950, + "docs/specs/security-ci.md": 2900, "docs/specs/security-hosted.md": 2350, "docs/specs/security-local.md": 3950, "docs/specs/security-remote.md": 7300, - "docs/specs/security-supply-chain.md": 1250, - "docs/specs/security.md": 2150, + "docs/specs/security-supply-chain.md": 1150, + "docs/specs/security.md": 1850, "docs/specs/shortcuts.md": 1100, "docs/specs/standalone.md": 12050, "docs/specs/terminal-context.md": 1100, diff --git a/website/scripts/generate-deps.js b/website/scripts/generate-deps.js index 494083396..c657ead0e 100644 --- a/website/scripts/generate-deps.js +++ b/website/scripts/generate-deps.js @@ -21,16 +21,20 @@ const themeExtensionsPath = resolve(repoRoot, "lib/src/lib/themes/bundled-extens // `web-push` in particular signs with a private key and makes outbound // requests. See docs/specs/security-supply-chain.md -> "Disclosure". const productDependencyFilters = [ - "dor", - "dormouse", - "dormouse-standalone", - "dormouse-lib", - "dormouse-sidecar", - "relay", + "dor", // Staged on every Dormouse terminal's PATH. + "dormouse", // Installed VS Code extension (vscode-ext/package.json). + "dormouse-standalone", // Installed standalone frontend. + "dormouse-lib", // Compiled into both hosts; relative imports bypass the VSIX's dependency walk. + "dormouse-sidecar", // Tauri bundle.resources includes this node_modules tree. + "relay", // Built and installed by the selfhost runbook. ]; // These packages do not install an artifact on a user's disk. Any new workspace // requires classification here or a runtime edge from a product root. -const excludedWorkspacePackages = ["canopy", "dormouse-website", "dormouse-hosted"]; +const excludedWorkspacePackages = [ + "canopy", // Storybook-only rendering lab; no production build imports it. + "dormouse-website", // Visitor browser code; no installed artifact. + "dormouse-hosted", // Workers and browser code; no desktop or selfhost import. +]; function readJson(path) { return JSON.parse(readFileSync(path, "utf-8")); From a06f92fb253acd743d15fc07687cb03eb6d8b0ba Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 23:48:33 -0700 Subject: [PATCH 2/3] docs: remove redundant accepted-risk introduction --- docs/specs/security.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/specs/security.md b/docs/specs/security.md index 44a0f1364..ecfb9ec10 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -65,9 +65,6 @@ Cargo tests run in separate CI jobs, outside root `pnpm test`. ## 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 From 26dad3bf8e748dba8446ae7aa67c632a9b04ab71 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 2 Oct 2026 06:40:38 -0700 Subject: [PATCH 3/3] Restore the security contracts the condensation pass dropped The condense pass cut text whose readers cannot follow a pointer: - SECURITY.md, which GitHub shows to vulnerability reporters, became a link to security.md and lost the no-public-issue / no-email rule, what to include, and the description of the nightly audit and its failure issues. - security.md (published at /security) lost the Domains table, so `.github/audit/_preamble.md`'s claim that it "names the spec each domain audits" was false; lost the `pnpm test` lint paragraph; and blurred concrete guarantees into vague ones: the two-digit pairing and one-time confirmation, the per-boot browser-pane token, the socket directory, the 256-bit setup credential, the end-to-end channel the Relay holds no keys for, and "two evasions in the window" (which became "can miss malicious changes"). - security-audit.md dropped the rule that STATUS is assigned in exactly two places, which security-audit.yaml still honors (the parse and the single escalation block). - AGENTS.md lost which lints carry self-tests, the spec-lint-selftest and ps1-cmdlet-lint-selftest pointers, and the e2e-lint inventory. - security-ci.md lost its summary that no tend secret escalates to main or a deployment secret. Restore each from the base, keeping the PR's genuine corrections: the Hosted intro and Hosted as a reporting deployment (now in SECURITY.md too), the OSC 367 file-access caveat, the single Pocket device- verification gap, the cargo-outside-pnpm-test note, the build and hosted lines in AGENTS.md, and the deploy, supply-chain, and generate-deps.js changes. The security-ci summary says "the four secrets below" rather than base's "three", matching its table. Budgets ratcheted for the restored text. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 20 ++++++------ SECURITY.md | 26 +++++++++++----- docs/specs/security-audit.md | 2 +- docs/specs/security-ci.md | 2 +- docs/specs/security.md | 56 +++++++++++++++++++++------------- scripts/spec-word-budgets.json | 10 +++--- 6 files changed, 68 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 00ffa82cd..334ebf49f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,20 +113,18 @@ Specs are written ahead of the code: a new component's spec starts as a full des `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`, `SELF_HOST.md`, and `docs/compatible-agents.md` ride the same checks. Advisory prose reviews follow `docs/prose-audit.md` (`pnpm audit:prose`). -Root `package.json` owns the lint suite. This map locates each lint's owning contract; `public-docs-lint` and `e2e-lint` also require the prose they enforce to remain present: +Six sibling lints run in `pnpm test`. Five enforce one invariant a spec states in prose and name the line they enforce; only `public-docs-lint` and `e2e-lint` read that prose and fail when the line is gone. `ps1-cmdlet-lint` guards the one shipped file nothing else can parse: | Lint | Enforces | |---|---| -| `scripts/public-docs-lint.mjs` (`pnpm lint:public-docs`) | `docs/specs/website-docs.md` → "Public-doc validation". | -| `scripts/xterm-lint.mjs` (`pnpm lint:xterm`) | `docs/specs/webgl-text.md` → "Fork pipeline", "Following upstream", "Canopy lab". | -| `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) | `docs/specs/security-local.md` → "Loopback Listeners". | -| `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) | `docs/specs/security-remote.md` → "Credentials at rest", "Network posture (self-hosted)"; `SELF_HOST.md` → "Installer contract (maintainers)". | -| `scripts/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. This is the Windows installer's syntax gate. | -| `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) | `docs/specs/security-remote.md` → "Remote Control"; `docs/specs/security-hosted.md` → "Rendezvous boundary", "Relay boundary". | - -**Must add a mutation self-test for every new finding rule in a lint that carries self-tests, and require that mutation to make the lint fail.** An untested rule is a claim, not an enforced check. Root `package.json` names the suites; their shared plumbing, and only that, is `scripts/lint-kit.mjs`. - -`scripts/installer-verify-test.mjs` executes extracted shipped installer helpers. `scripts/clamp-issue-body-selftest.mjs` pins the audit issue-body helper. Both run in root `pnpm test`. +| `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`) | `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/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. No job has a PowerShell, so this is the Windows installer's only syntax gate. | +| `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 Relay-side protocol-v1 type, no one-time reader in the Relay or `BurrowRuntime`, no store in the one-time phone, no checked-in service worker, no optional field on a ciphertext or transcript; and `docs/specs/security-hosted.md`: no frame read in Hosted's one-time room, or decoded or kept in its `RelayRoom`. | + +`scripts/spec-lint-selftest.mjs` plants one defect per finding check in the spec lint. The `deploy`, `e2e`, and `loopback` lints carry self-tests that mutate each rule in whichever direction it points: a present-control rule has its control deleted (and, for exact-count rules, a copy added), a `forbidden` rule has the banned text appended. `scripts/e2e-lint-selftest.mjs` is mostly the second kind; `scripts/deploy-lint-selftest.mjs` mostly the first. 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; `scripts/ps1-cmdlet-lint-selftest.mjs` carries the `ps1-cmdlet` lint's mutations. `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. ## Design diff --git a/SECURITY.md b/SECURITY.md index 503b1fa5b..aa8c7f56c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,12 +1,22 @@ # Security policy -**Report vulnerabilities privately** through GitHub's +**Report a vulnerability privately** through GitHub's [Report a vulnerability](https://github.com/diffplug/dormouse/security/advisories/new) -form. [Reporting a vulnerability](docs/specs/security.md#reporting-a-vulnerability) -owns the reporting rules and handling policy. +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 Relay, Hosted, 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. -The canonical [security spec](docs/specs/security.md) states the guarantees, -accepted risks, known gaps, and [how they are checked](docs/specs/security.md#how-the-guarantees-are-checked). -It is published at [dormouse.sh/security](https://dormouse.sh/security), with -the guarantee and risk blocks narrowed to that page's audience; the repository -spec contains every row. +**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. Its +[Domains table](docs/specs/security.md#how-the-guarantees-are-checked) names +every audited checklist beside it, 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/docs/specs/security-audit.md b/docs/specs/security-audit.md index 39eb70edd..f55242dc8 100644 --- a/docs/specs/security-audit.md +++ b/docs/specs/security-audit.md @@ -80,7 +80,7 @@ Source of truth: `2. Wait without ending your turn`, `3. Merge`, and `4. The ver - **With no `audit-report.md` the reporting step publishes each fragment verbatim under its own heading**, unmerged (rationale). - **Must return `VERDICT: INCONCLUSIVE` from a domain with any undetermined check unless it found a failure.** Only all-determined passing checks permit `VERDICT: PASS`; a domain's inconclusive verdict prevents a merged pass. - **Never write a `FAIL IF` condition no audit run can read**: audit the readable half; stage the rest under `## Future` only while it is unbuilt, and otherwise state it beside the rule. `AUDIT_PAT`-readable GitHub state stays audited (rationale). -- **Must escalate once in `FAIL` > `MISSING` > `PASS` order.** Dissent can raise `MISSING` to `FAIL`, never demote `FAIL`; incomplete evidence remains reported beside it. +- **`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. - **Every fragment's verdict line is lifted into the head ahead of the report, then every fragment's `UNVERIFIABLE`, `FAIL:`, `BLOCKER` and `WARNING` lines** — two blocks, not one per domain. Both passes match at line start after an optional heading or bullet marker, so the fragments' `FAIL IF` vocabulary is not read as a finding, and both cut lines to 500 characters. **Only the findings block carries the 40-line cap**, so no domain's findings can push another's verdict out. **Neither pass may fail the step when it matches nothing** — under `set -eo pipefail` that would post no report at all (rationale). - **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). - **Must attempt the world-readable `audit-transcript` upload during postprocessing** — 14-day retention; runner timeout or cancellation can prevent upload, and a missing artifact receives no download link (rationale). diff --git a/docs/specs/security-ci.md b/docs/specs/security-ci.md index 779e5a686..d4e27ed38 100644 --- a/docs/specs/security-ci.md +++ b/docs/specs/security-ci.md @@ -16,7 +16,7 @@ ## 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. +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 the four secrets below, 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 | | --- | --- | --- | diff --git a/docs/specs/security.md b/docs/specs/security.md index ecfb9ec10..85ca5f8d1 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -40,20 +40,20 @@ Cargo tests run in separate CI jobs, outside root `pnpm test`. | Guarantee | Rule | Pinned by | | --- | --- | --- | | **Terminal output cannot write your clipboard or steal focus.** File access requires a user action or a running designated Tool's gated OSC 367 `open`. OSC 52 only offers a copy format; links require confirmation or an allowed local preview, and deceptive links have no open action. | [Terminal output](./security-local.md#terminal-output) | `lib/src/lib/terminal-protocol.test.ts`, `lib/src/lib/external-links.test.ts`, `lib/src/lib/terminal-link-activation.test.ts`, `lib/src/components/ExternalLinkModalHost.test.tsx` | -| **A page in a browser pane cannot forge a host message.** | [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`.** 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 page in a browser pane cannot forge a host message.** 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` | | **Current persistence writers never save terminal scrollback.** Standalone snapshots are owner-only; VS Code controls access to its own storage. Older snapshots may contain transcripts. | [Persisted state](./security-local.md#persisted-state) | `cargo test` in `standalone/src-tauri` (the owner-only half); audit | -| **Phone authorization requires local confirmation at the Burrow, which makes every access decision.** | [Pairing](./remote-security-model.md#pairing), [Burrow Authorization](./remote-security-model.md#burrow-authorization) | `remote-lib-common/test/security-guarantees.test.mjs` | -| **A one-time connection is one session, confirmed at the laptop, with nothing saved at either end.** Terminal traffic runs directly between the devices under Settings → Network; Hosted carries only the handshake. | [One-time connection](./remote-security-model.md#one-time-connection), [its checks](./security-remote.md#one-time-connection) | `lib/src/remote/client/one-time-e2e.test.ts`, `scripts/e2e-lint.mjs` | -| **The Relay cannot read ceremony or terminal content or grant terminal access.** Account data and routing metadata remain visible. | [Trust Model](./remote-security-model.md#trust-model), [Residual metadata](./remote-security-model.md#residual-metadata) | `scripts/e2e-lint.mjs` | +| **Nothing but a human at the laptop can authorize a phone.** The only path into a Burrow's ACL is typing, on that Burrow, the two digits the phone shows, and the Burrow makes every access decision. | [Pairing](./remote-security-model.md#pairing), [Burrow Authorization](./remote-security-model.md#burrow-authorization) | `remote-lib-common/test/security-guarantees.test.mjs` | +| **A one-time connection is one session, confirmed at the laptop.** Only typing, on the laptop, the two digits that phone shows authorizes it; nothing is saved at either end, and its terminal traffic runs only directly between the two devices, over a network Settings → Network allows; Hosted carries the handshake alone. | [One-time connection](./remote-security-model.md#one-time-connection), [its checks](./security-remote.md#one-time-connection) | `lib/src/remote/client/one-time-e2e.test.ts`, `scripts/e2e-lint.mjs` | +| **The Relay cannot read ceremony or terminal content or grant terminal access.** One end-to-end channel per ceremony carries content under keys the Relay never holds; account data and routing metadata remain visible. | [Trust Model](./remote-security-model.md#trust-model), [Residual metadata](./remote-security-model.md#residual-metadata) | `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) | `remote-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) | `remote-lib-common/test/security-guarantees.test.mjs` | | **The Burrow bounds remote session state and handshake admission independently of the Relay.** Deadlines use its own clock. | [Burrow bounds](./remote-security-model.md#burrow-bounds) | `lib/src/remote/burrow/burrow-bounds.test.ts`, `relay/test/malicious-relay.test.mjs` | | **Under Settings → Network → Nothing, a new install's level, Dormouse opens no connection on its own**: no relay socket, one-time link, push, managed voice, or update check. What you click, and what your terminals and browser panes reach, are yours. | [Network policy](./security-local.md#network-policy) | `lib/src/host/remote/service.test.ts`, `lib/src/host/managed-voice-host.test.ts`, `standalone/src/updater.test.ts` | | **A Burrow talks only to the one relay origin its build was pointed at, and a self-host build contacts Dormouse's servers only when you click a link.** A stock build reaches only Hosted. | [Relay origin](./security-remote.md#relay-origin) | `lib/src/host/relay-origin.test.ts` | | **The self-host installer restricts Relay credentials to the installing account**, on macOS, Windows, and Linux; Burrow enrollment uses protected app storage or VS Code's secret storage. Installer owner-check gaps are listed below. | [Credentials at rest](./security-remote.md#credentials-at-rest) | `scripts/deploy-lint.mjs` | -| **The self-host HTTPS origin may be public; its plaintext backend may not.** Enrollment is admission-limited, cross-origin browsers receive no grant, and terminal access still requires local Burrow approval. | [The setup password](./security-remote.md#the-setup-password), [Cross-origin access](./security-remote.md#cross-origin-access), [Network posture](./security-remote.md#network-posture-self-hosted) | `relay/test/setup-password-store.test.mjs`, `relay/test/config.test.mjs`, `relay/test/token-bucket.test.mjs`, `relay/test/cors.test.mjs`, `scripts/deploy-lint.mjs` | +| **The self-host HTTPS origin may be public; its plaintext backend may not.** The Relay generates its 256-bit setup credential with no operator-supplied value, Burrow enrollment is globally admission-limited, cross-origin browsers receive no grant, and terminal access still needs local Burrow approval. | [The setup password](./security-remote.md#the-setup-password), [Cross-origin access](./security-remote.md#cross-origin-access), [Network posture](./security-remote.md#network-posture-self-hosted) | `relay/test/setup-password-store.test.mjs`, `relay/test/config.test.mjs`, `relay/test/token-bucket.test.mjs`, `relay/test/cors.test.mjs`, `scripts/deploy-lint.mjs` | | **Push, when enabled, cannot be aimed back into the tailnet.** | [What crosses the boundary](./security-remote.md#what-crosses-the-boundary) | `relay/test/push-endpoint.test.mjs` | | **Every dependency Dormouse puts on a user's 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` | @@ -130,8 +130,8 @@ Gaps rather than accepted risks: we intend to close them. ([Revocation and the audit trail](./security-remote.md#revocation-and-the-audit-trail)). - **There is no structured audit trail** covering connects, attaches, denials, or writes ([same](./security-remote.md#revocation-and-the-audit-trail)). -- **The workflow audit can miss malicious changes** - ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). +- **The workflow audit's window has two evasions**, both in how the window is + computed ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). - **Audit domains 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** @@ -142,23 +142,35 @@ Gaps rather than accepted risks: we intend to close them. ## How the guarantees are checked -Root `pnpm test` runs the JavaScript checks and lint mutation suites; native -Cargo CI jobs test platform-specific persistence. `package.json` owns the root -suite, and [Specs in AGENTS.md](../../AGENTS.md#specs) maps its lints to their -owning contracts. A mutation test must make its protected rule fail; -`scripts/installer-verify-test.mjs` executes installer helpers the lints only read. - -**Every night at 04:21 UTC, and before every VS Code release**, the -[security audit](./security-audit.md#schedule-and-gate) executes every audited -clause and reads each domain adversarially for holes no check names. Its -[Domains](./security-audit.md#domains) own the disjoint scopes and prompts. -A failure or inconclusive run files a public +**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. Four 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 reaching 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 record what tripped and changed. `scripts/security-audit-local.sh` runs -the same prompts locally. pgstencil audits the packages Hosted consumes in -its own repository. +ones record what tripped and changed. +`scripts/security-audit-local.sh` runs the same prompts locally. +[security-audit.md](./security-audit.md) is the contract. pgstencil audits the +packages Hosted consumes in its own repository. + +| Domain | Specs | Covers | +| --- | --- | --- | +| `application-security` | [security-local.md](./security-local.md), [security-remote.md](./security-remote.md) | local boundaries, remote control, and everything no other domain claims | +| `hosted` | [security-hosted.md](./security-hosted.md) | Hosted accounts, the one-time rendezvous, and the pgstencil provenance link | +| `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 | Production dependency changes require committed regenerated [disclosure](./security-supply-chain.md#disclosure). Desktop release artifacts diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 757422c10..98396aa38 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,6 +1,6 @@ { - "AGENTS.md": 3400, - "SECURITY.md": 100, + "AGENTS.md": 3600, + "SECURITY.md": 200, "SELF_HOST.md": 6100, "docs/compatible-agents.md": 1800, "docs/specs/alert.md": 8650, @@ -22,13 +22,13 @@ "docs/specs/remote-api.md": 5150, "docs/specs/remote-network.md": 2700, "docs/specs/remote-security-model.md": 5400, - "docs/specs/security-audit.md": 2100, - "docs/specs/security-ci.md": 2900, + "docs/specs/security-audit.md": 2150, + "docs/specs/security-ci.md": 2950, "docs/specs/security-hosted.md": 2350, "docs/specs/security-local.md": 3950, "docs/specs/security-remote.md": 7300, "docs/specs/security-supply-chain.md": 1150, - "docs/specs/security.md": 1850, + "docs/specs/security.md": 2050, "docs/specs/shortcuts.md": 1100, "docs/specs/standalone.md": 12050, "docs/specs/terminal-context.md": 1100,