diff --git a/AGENTS.md b/AGENTS.md index 0f2f4ab96..334ebf49f 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. diff --git a/SECURITY.md b/SECURITY.md index 34bd813dc..aa8c7f56c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ 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), +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. 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..f55242dc8 100644 --- a/docs/specs/security-audit.md +++ b/docs/specs/security-audit.md @@ -83,7 +83,7 @@ Source of truth: `2. Wait without ending your turn`, `3. Merge`, and `4. The ver - **`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). -- **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..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. 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. 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 | | --- | --- | --- | @@ -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 06a96ea72..f2000dd98 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -13,33 +13,33 @@ 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` | +| **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.** 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` | @@ -55,9 +55,9 @@ last column means nothing cheaper does. | **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` | | **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 | @@ -65,9 +65,6 @@ last column means nothing cheaper does. ## 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 @@ -101,7 +98,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 @@ -131,16 +129,16 @@ 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)). +- **There is no structured audit trail** covering 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)). +- **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 @@ -175,30 +173,25 @@ packages Hosted consumes in its own repository. | `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)). +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 df489ef0f..f0a2fc288 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -22,13 +22,13 @@ "docs/specs/remote-api.md": 5200, "docs/specs/remote-network.md": 2850, "docs/specs/remote-security-model.md": 5400, - "docs/specs/security-audit.md": 2100, + "docs/specs/security-audit.md": 2150, "docs/specs/security-ci.md": 2950, "docs/specs/security-hosted.md": 2350, "docs/specs/security-local.md": 4000, "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": 2100, "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"));