Skip to content

Security: diffplug/dormouse

SECURITY.md

Security

Audited automatically. This spec is checked against the repository by security-audit.yaml on a 24-hour schedule (04:21 UTC) and as a required gate before every VS Code release. The audit runs as three scoped subagents — supply chain, CI and secrets, and application security — merged into one verdict; see CI Validation Contract. Each failure is filed as an issue labeled security-audit-failure — open ones are live, closed ones are the historical record of what tripped past audits and what changed to clear them.

Dormouse is a terminal, so users trust it with shells, source trees, credentials, and local files. Two things sit on that security boundary, and this document covers both. The dependency graph and release pipeline decide what code reaches a user's machine. Remote control — pairing a phone with a laptop — is the one feature that accepts input from the network, and an authorized phone is equivalent to a person at the keyboard.

Remote Control

Dormouse Pocket lets a phone attach to a terminal running on the user's laptop, so the pairing stack is the one part of the product that takes input from the network. An authorized Client is deliberately equivalent to a person sitting at that laptop's keyboard — terminal.write is raw keystroke injection into a live PTY, and protocol-v1 has no notion of a restricted session. The entire trust model therefore exists to make authorized hard to reach, and impossible to reach by accident.

The design lives in docs/specs/remote-security-model.md, the deployment in docs/specs/server.md, and the operator runbook in SELF_HOST.md. This section does not restate them: it names the properties that are load-bearing enough to audit, and the risks we have accepted rather than closed. Two deployment modes are defined (docs/specs/remote-api.md → "Server deployment modes"); everything below is self-hosted, the only one that ships today. Cloud-hosted is staged.

Trust boundary

Four layers, none sufficient alone: a passkey proves fresh user presence, a non-extractable per-browser device key is long-lived Client identity, the Host's local ACL authorizes the pair of those two, and the Host makes the final access decision. What each compromise actually buys:

  • Server compromise — relay traffic and account state, but no Host access. A forged account, a forged presence stamp, and an injected ConnectionRequest all still arrive in front of authorizeConnection on the Host, which re-verifies the passkey assertion and the device-key signature against its own ACL and its own ConnectionPolicy (the origin/rpId recorded at enrollment) regardless of what the Server claims to have checked.
  • Setup-password compromise — full account takeover: /api/setup/* is gated by the password alone, so re-presenting it registers another passkey, and /api/host/enroll mints Host credentials. Still no Host access: reaching an already-enrolled Host requires a pairing ceremony that a human approves in a modal on that laptop.
  • A synced or stolen passkey — sign-in, and the ability to ask. The paired device key is missing, so HostAcl answers device-not-paired and the Client reaches nothing.
  • Device-key compromise — requires a compromised browser or OS, or XSS in the Pocket origin. The key is usable in place but not extractable, and connecting still needs a fresh passkey assertion.

The property to hold on to: the only path into a Host's ACL is a human clicking Approve on that Host — with one honest qualification, the same one adopt gets below. That click arrives as a bridge command from the Host's own webview, carrying the displayed ticket's immutable pairingId; the service, not the webview, then decides whether that ticket is still approvable. The webview is therefore inside the trust boundary for triggering an approval, while remaining unable to choose what is approved, to approve a request the Host never received, or to fabricate one. The only path back out is Revocation, which is where this model is weakest.

  • FAIL IF the Host stops being the final authority: authorizeConnection in server-lib-common/src/security/connection.ts must verify the passkey assertion, the device-key signature, and the ACL against the Host's own ConnectionPolicy before any session is established, and no code path may let a Server-supplied claim stand in for any of the three.
  • FAIL IF local approval stops being the only thing that mints an ACL record: HostAcl.approve must have no caller other than PairingCeremony.approve, and an approval must be matched against the immutable pairingId of the request that was displayed, never against a mutable clientId alone. (Records can also be carried rather than minted — see the adopt bullet below — which is a different act and is bounded separately.)
  • FAIL IF the Host accepts a pair frame it has not shape-validated itself. isPairingRequest runs on the Server too, and that is exactly why the Host cannot rely on it: a relay-supplied object reaching PairingCeremony.begin puts unvalidated fields into the approval UI and, on approval, into a persisted record. The requestedLabel must likewise be reduced with boundedPairingLabel before any consumer sees it — it is attacker-chosen text rendered in the one dialog the ACL rests on.
  • FAIL IF any service→webview message can carry hostToken. Check the direction, not just the identifier: RemoteHostResult, HostStatusEvent, PairingQueueEvent, and RemoteHostConsoleStatus in lib/src/host/remote/service-protocol.ts are the outbound shapes, and none may expose it. (Inbound is a different matter — EnrollParams carries the setup password and AdoptParams.enrollment structurally carries a hostToken, both by design: enrolling is initiated from the webview, whether from the Settings dialog or the window.dormouseRemoteHost console hook.)
  • FAIL IF adopt — the migration hand-off from builds that persisted the Host in webview localStorage, and the one command that carries ACL records inbound — stops being bounded on all three of: the service's own store holding no enrollment, a serverUrl inside the baked allowlist below, and every record passing the full isHostAclRecord shape guard as well as the hostId match. It is not a second authorization path: it cannot touch a machine that already has a Host, and it authorizes nothing that whoever could already write the webview's storage could not authorize by other means. It is bounded because it converts local compromise into persistent remote access, which outlives the local compromise being fixed.
  • FAIL IF server-lib-common/src/security/ stops being the shared implementation — the Server, the Host, and the Pocket client must verify assertions, device signatures, and challenges with the same modules, so the three cannot disagree on what a valid credential is.

Where a Host may reach a relay server

The baked relay-origin allowlist is what stops a Dormouse install from enrolling against, or connecting to, a relay the build was never pointed at. It is a build-time constant (DORMOUSE_REMOTE_CONNECT_SRC) compiled into the Node bundle that holds the socket — the Tauri sidecar and the VS Code extension host — and enforced by originAllowedByConnectSrc at two points: enroll refuses an outside origin before the setup password leaves the machine, and a Host refuses to start from a persisted enrollment naming one. Full semantics are in docs/specs/server.md → "Where a Host may reach a relay server (self-host builds)".

Three properties carry the weight. The shipped default admits the SaaS origin only — no localhost, no plaintext scheme, and not the bare apex domain — so widening it is a per-build opt-in a self-hoster makes deliberately. The build asserts the define actually reached the bundle, because a lost esbuild define compiles green and shows up only as a Host silently using the shipped default instead of the selfhoster's origins. And the value is duplicated (a .mjs build script cannot import TypeScript), so the two copies must stay identical.

  • FAIL IF DEFAULT_REMOTE_CONNECT_SRC is not exactly https://*.dormouse.sh wss://*.dormouse.sh in both scripts/csp-defaults.mjs and lib/src/host/remote/connect-src.ts, or if CONNECT_SRC_SOURCE_PATTERN differs between them. Widening the default — a localhost entry, an http/ws scheme, a bare *, or the apex dormouse.sh — is a change to what every shipped binary will talk to.
  • FAIL IF assertConnectSrcBaked is no longer called on the built bundle by both standalone/scripts/build-sidecar-proxy.mjs and vscode-ext/scripts/esbuild.mjs — including the watch branch of the VS Code script, which is the build people iterate in and therefore where a lost define most plausibly survives — or if resolveRemoteConnectSrc stops rejecting an override the runtime matcher cannot parse. resolveRemoteConnectSrc validates with the build script's copy of the grammar, so this bullet is only as strong as the previous one's requirement that the two copies stay identical; lib/src/host/remote/connect-src.test.ts is what pins them.
  • FAIL IF originAllowedByConnectSrc stops gating both enroll and Host start-up in lib/src/host/remote/service.ts (including the adopt path), or fails open on an unparseable origin or source.
  • FAIL IF the enrollment exchange in lib/src/remote/host/enrollment.ts or the Host-authenticated push fetches in lib/src/remote/host/push-delivery.ts drop redirect: 'error'. A Node process does not re-check a redirect target the way a browser re-applies CSP, so a followed redirect could carry the setup password or the hostToken outside the allowlist.

Credentials at rest

Four credentials outlive a process, and each one is a full bypass of some layer if it leaks to another local account:

Credential Where it lives Protection
Setup password config/server.env in the install root mode 0600, generated locally, never printed by a routine install and never in the LaunchAgent plist
hostToken (the /ws/host bearer) server hosts.json; Host side in the enrollment record server state dir 0700 + every file 0600; Host side a 0600 file in standalone, SecretStorage (the OS keychain) in VS Code — never a webview realm
VAPID private key server vapid.json same 0700/0600 treatment
Host ACL HostStateStore, keyed per hostId a 0600 file in standalone; VS Code globalState. The records are public keys, so confidentiality is not the concern — but neither store provides integrity against a process running as the same user, and nothing here claims otherwise. What the mode buys is that another local account cannot add a record; a same-user compromise already reads the terminals. Deliberately never on the Server

Without explicit modes these files inherit the umask and end up world-readable, which hands live host tokens to any other local account on a shared machine. The Client's device key is the exception that needs no file protection: it is a non-extractable CryptoKey in IndexedDB and is never exported.

  • FAIL IF server/src/state.ts stops creating $DORMOUSE_STATE_DIR mode 0o700, or stops writing every file through writeAtomic at mode 0o600. The "every file" clause is a negative search over server/src/: no writeFile, appendFile, or createWriteStream may target the state directory outside writeAtomic.
  • FAIL IF FileHostStateStore (lib/src/host/remote/host-state-store.ts) stops creating its directory 0o700 and writing 0o600 on non-Windows platforms, or if VsCodeHostStateStore stops keeping the enrollment in SecretStorage. The ACL's home in globalState is deliberate and is not a finding; the enrollment's is what carries hostToken.
  • FAIL IF deploy/local/install-macos.sh stops generating the setup password locally from at least 32 bytes of /dev/urandom. Its own length guard is in hex characters, so it must require 64, not 32 — a guard reading -ge 32 passes a regression to half the entropy.
  • FAIL IF the installer stops writing config/server.env at mode 0600 under umask 077, or stops keeping config/ and state/ at 0700.
  • FAIL IF the installer stops preserving an existing config/server.env byte-for-byte across an update, or begins printing the setup password outside the explicit manage show-password path.
  • FAIL IF manage verify stops failing when the LaunchAgent plist contains DORMOUSE_SETUP_PASSWORD.

The setup password — accepted risk

One password bootstraps everything the Server can grant: it creates the account, adds passkeys to it, and enrolls Hosts. Its hardening is deliberately minimal and should be read as accepted, not overlooked. The comparison is constant-time over SHA-256 digests and a failure costs a fixed 250 ms, and that is the whole of it — there is no rate limit, no lockout, no attempt counter, and no expiry or rotation after setup completes. /api/* also carries cors({ origin: '*' }), so any web page open in any browser on the tailnet can drive those routes and read the responses; that is safe from CSRF (there are no cookies, every credential is a header or a body field) but it does mean the guessing surface is not limited to something reachable only by a deliberate client.

We accept this because the origin is tailnet-only, the password is 32 bytes of /dev/urandom written by the installer rather than chosen by a human, and the layer it protects still cannot reach a Host without local approval. Two consequences worth stating plainly: the tailnet is doing real work here, and a self-host origin that becomes internet-reachable is a materially different risk than the one analyzed.

  • FAIL IF the setup password comparison in server/src/app.ts stops being constant-time or loses its fixed failure delay.
  • FAIL IF the permissive CORS policy is widened beyond /api/*, or if any endpoint begins accepting credentials via cookies — the "no cookies exist for a foreign origin to ride on" argument is the whole basis for origin: '*' being acceptable.

Network posture (self-hosted)

The shipped self-host deployment is a per-login macOS LaunchAgent bound to loopback, with tailscale serve terminating HTTPS on the node's own MagicDNS name. Two invariants follow from that shape. The server always speaks plain HTTP, so the listen interface is a security boundary when the TLS proxy is local: leaving the socket unbound would publish the plaintext port to the LAN and to the tailnet itself, which is why the install pins DORMOUSE_BIND_HOST=127.0.0.1 and refuses to proceed without it. And DORMOUSE_ORIGIN is durable WebAuthn identity — rewriting it silently invalidates the registered passkey and every enrolled Host, so the installer stops rather than rewriting a mismatch.

Tailscale here is network-layer defense-in-depth under the passkey/ACL model, never a substitute for it — but the analysis above does lean on the origin being tailnet-only. tailscale serve and tailscale funnel share one configuration surface, and a Funnel on this node publishes the same origin to the public internet, where the setup password becomes an internet-facing guessing target with none of the mitigations above.

  • FAIL IF deploy/local/install-macos.sh stops requiring DORMOUSE_BIND_HOST=127.0.0.1 in config/server.env, or if manage verify stops asserting that the plaintext port is unreachable on the node's Tailscale IP.
  • FAIL IF the unset default of DORMOUSE_BIND_HOST in server/src/config.ts stops being undefined (listen on every interface — what a container wants, where the namespace is the boundary), or server/test/bind-host.test.mjs stops spawning the real entrypoint to prove the plaintext port is unreachable off-loopback when it is set.
  • FAIL IF the installer stops refusing to rewrite a DORMOUSE_ORIGIN that no longer matches the node's DNS name.
  • FAIL IF manage verify does not fail on Funnel being on for this node. It matches funnel on across tailscale serve status and tailscale funnel status; that is node-scoped, not scoped to the served origin, and is deliberately the blunter test — any Funnel on the node that fronts this server is a thing to look at, and parsing a mapping out of CLI prose would fail open the day the wording changes.

What crosses the boundary

After authorizeConnection the relay is a dumb pipe; before it, only an allowlist of handshake frame types is forwarded. Both directions carry untrusted bytes. Inbound, terminal.write is keystrokes into a real shell — the ACL is the entire gate, which is what makes the approval modal load-bearing. Outbound, terminal bytes reach a phone, and notification text originates in a renderer and is Pane-derived, so it is bounded and sanitized on the Host and re-sanitized on the Server at the push boundary; both sides call the same boundedPushText so the two layers cannot enforce different rules.

Web Push is the one path where the Server makes an outbound request to an address a Client supplied, which on a server that sits inside a tailnet is a live SSRF concern: 100.64/10 is exactly the range a push endpoint must not be allowed to reach. Registration rejects credentials, localhost, and non-public IP literals, and delivery goes through a dedicated agent whose connection-time DNS lookup rejects loopback, private, CGNAT, link-local, documentation, benchmark, multicast, reserved, IPv4-mapped, unique-local, and site-local ranges — rejecting a hostname wholesale if any answer is blocked, and handing the socket the exact address it checked so rebinding cannot create a second unchecked resolution.

  • FAIL IF server/src/push-endpoint.ts stops rejecting non-public push endpoints at registration, stops applying createPublicLookup/createPublicPushAgent to delivery, or stops rejecting a hostname whose DNS answers are mixed public and blocked.
  • FAIL IF /api/push/send stops taking the hostId from the Host's own token, begins selecting recipients when devicePublicKeys is absent or empty, or if any read endpoint begins reporting on a devicePublicKey supplied by the caller rather than one proven by the presented credential.
  • FAIL IF push text stops being sanitized with the shared boundedPushText on both the Host and the Server.
  • FAIL IF the relay forwards non-handshake frames before a session is authorized, or routes a Host-originated frame from a socket that is not the Client's current Host binding.

Revocation and the audit trail

These are the two real gaps in the shipped model, and they are gaps rather than accepted risks — we intend to close them.

Revocation has no mechanism. HostAcl.revokeDevice / revokePasskey exist and have no callers; no relay frame carries a revocation; there is no management UI. Revoking a lost phone means hand-editing JSON on the Host, and it takes effect at that Client's next authorizeConnection — an already-established session survives it, and the operator's only lever is stopping the Host. Server-pushed revocation propagation is staged in docs/specs/remote-security-model.md → Future.

There is no audit trail. The ACL records approvedAt / approvedBy for a pairing, and nothing records connects, attaches, denials, or writes. A self-hoster cannot answer "did anyone connect to my laptop last night", which also means an ACL entry added by any of the paths above would be invisible after the fact.

Both are stated here rather than left in a spec's Future list because the audit's qualitative pass should not keep rediscovering them as findings, and because a reader deciding whether to run this needs to know that "revoke a device" is not currently a thing they can do quickly.

Accepted limitations

Restated from docs/specs/remote-security-model.md so this document is self-contained about what is not defended:

  • No defense against a compromised browser or OS, on either end. Active XSS in the Pocket origin can use the non-extractable device key without extracting it.
  • No end-to-end encryption. The relay terminates TLS and forwards cleartext terminal bytes, so whoever operates the Server can read every keystroke and every byte of output. In self-hosted mode that operator is the user, which is the entire reason self-hosted ships first. The PRF-derived session key that would change this is staged in the security model's Future.
  • Device-key durability is best-effort. Clearing site data destroys the key and forces re-pairing; on iOS a browser tab may be evicted after inactivity. This is recoverable, not catastrophic — a lost key authorized nothing on its own.
  • Availability is not a goal of the self-hosted deployment. A LaunchAgent is a per-login agent, so the relay is down while the Mac sleeps, is shut off, or has no logged-in user.

Cloud-hosted mode (staged)

Nothing in this subsection is implemented; it exists so the boundary is stated before the code arrives. When Dormouse operates the coordinating Server, the "Server compromise buys no Host access" property is unchanged — that is the point of putting the ACL on the Host — but two things above change character and must be re-analyzed here rather than inherited:

  • We become the operator who can read cleartext relay traffic (see Accepted limitations). That is the claim that most needs either an honest disclosure or the PRF-derived end-to-end key.

  • The tailnet stops carrying load. Every argument above that leans on "the origin is reachable only from the user's tailnet" — the setup password's minimal hardening most of all — has no cloud equivalent, and the multi-tenant account model replaces the single-owner setup password entirely (docs/specs/server.md → Future, Scope: saas-multitenant).

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

Dependency Supply Chain

Dormouse keeps its runtime dependency surface intentionally small. We add dependencies only when they are necessary, and we expect dependency changes to justify their value against their supply-chain risk. We use maturity gating inside our pnpm configuration and also inside our Renovate configuration.

Every dependency Dormouse puts on a user's machine is listed at https://dormouse.sh/supply-chain. That is the test, and it is narrower than "everything a user runs" for a reason given below. This includes:

  • every npm dependency (direct and transitive)
  • every cargo dependency (direct is listed separately from transitive)
  • the Node.js runtime bundled as a Tauri sidecar in the standalone app

The roots of that graph are the productDependencyFilters in website/scripts/generate-deps.js. A workspace package is a root if Dormouse writes its files onto a user's disk, whatever the route: dormouse-standalone and dormouse (the VS Code extension) are installed, dormouse-sidecar rides along inside the Tauri bundle as a bundle.resources tree with its node_modules intact, dor is staged onto every terminal's PATH, and server is built and installed by a selfhoster (SELF_HOST.md) — web-push most of all, which signs with a private key and makes outbound requests. dormouse-lib, server-lib-common, and dor-lib-common are reached as workspace edges from those. Note the roots are package names, not directory names, and the two differ once: vscode-ext/ declares itself dormouse.

Two workspace packages are deliberately not roots. canopy is a Storybook-only rendering lab that no shipped build imports, and website runs in a visitor's browser rather than being installed anywhere — the page says as much about its own React and react-router. Excluding website is what makes "puts on a user's machine" the operative test rather than "a user runs", and it is a judgement worth re-making if the site ever ships something a visitor installs.

External binaries are outside this graph by construction. Dormouse is a terminal: it spawns the user's shell, and dor ab forwards to an agent-browser CLI the user installs themselves (npm i -g agent-browser — it is not a dependency of anything here and is resolved off PATH). Those are the user's software, not ours, and disclosing them is neither possible nor meaningful. What this document can promise is that we ship nothing that pulls them in silently.

Those dependency snapshots are generated from the lockfiles and reviewed as part of release work. If a production dependency is added, removed, or upgraded, the dependency lists must be regenerated and committed — and CI fails the PR if they were not, because until that gate existed the nightly audit was the only thing that ever ran the generator, and two prod bumps (ws, hono) shipped undisclosed before it caught them.

The standalone app ships a Node.js runtime binary (standalone/src-tauri/build.rs copies it into the bundle as a Tauri sidecar). Its version is pinned exactly in the root package.json under devEngines.runtime.version, and the build is the authority: build.rs runs --version on the binary it is about to bundle and fails the build unless it matches the pin. On Windows the build then flips one byte of the bundled node.exe — the PE Optional Header's Subsystem field from IMAGE_SUBSYSTEM_WINDOWS_CUI (3) to IMAGE_SUBSYSTEM_WINDOWS_GUI (2) — to suppress Windows Terminal's default-terminal handoff, which would otherwise spawn a stray terminal window behind the app. The version check runs before the byte flip and the patch leaves Node.js semantics unchanged (Node reads its stdio handles from STARTUPINFO, which is subsystem-agnostic); the bundled node.exe is therefore not byte-identical to the upstream archive — it differs at exactly the documented 2-byte field. The supply-chain page reads the same pin, so the version disclosed there provably equals the runtime users receive — it cannot drift to whatever Node happened to be on the build machine's PATH. Locally, pnpm honors devEngines (onFail: "download") so scripts run under the pinned Node; CI extracts the same field to drive actions/setup-node. The version is a deliberate, manual pin (no automated ecosystem tracks it); the workflows that do not bundle the runtime are free to track the same pinned major.

  • FAIL IF node website/scripts/generate-deps.js changes website/src/data/dependencies-npm.json, website/src/data/dependencies-cargo.json, or website/src/data/dependencies-runtime.json when run against a clean working tree after pnpm install --frozen-lockfile. The install is a precondition, not a nicety: the generator resolves every dependency by walking real node_modules directories and throws rather than under-reporting if they are absent — so a stale node_modules makes this check pass on a tree that would fail in CI.
  • FAIL IF .github/workflows/ci.yml stops running that generator and failing on a diff. The nightly audit finding a stale disclosure means it already merged; this is the gate that keeps it from merging. The install is a precondition, not a nicety: the generator resolves every dependency by walking real node_modules directories and throws rather than under-reporting if they are absent.
  • FAIL IF productDependencyFilters in website/scripts/generate-deps.js omits a workspace package whose files Dormouse writes onto a user's disk — today the six named above, with canopy and website excluded for the stated reasons. Derive this from pnpm-workspace.yaml rather than from the enumeration: a package missing from both the roots and the exclusions is exactly the failure, since regenerating cannot catch a root that was never walked. Reaching a package as a workspace edge from a root counts as covered; being installed as a devDependency of the repo does not, or a selfhoster's pnpm install would drag the whole toolchain in.
  • FAIL IF the root package.json is missing devEngines.runtime.version, or its value is not an exact Node.js version (a bare major such as 24 is not acceptable; it must be MAJOR.MINOR.PATCH).
  • FAIL IF standalone/src-tauri/build.rs no longer verifies that the bundled Node.js binary matches package.json's devEngines.runtime.version (this verification is what makes the disclosed runtime version provable), or if the check is skipped for any configuration the release matrix actually builds. One skip is deliberate and permitted: verify_node_version cannot execute a foreign-arch binary, so it warns and returns when host != target. That is acceptable only while every entry in release.yml's standalone matrix is host-native — adding a cross-compiled target to the matrix ships an unverified runtime and fails this check.
  • FAIL IF the build-standalone job in .github/workflows/release.yml does not install the pinned runtime via node-version-file: package.json, or the root package.json gains a volta.node or engines.node field. setup-node resolves that file by precedence (volta.node -> devEngines.runtime -> engines.node), so the pin this document relies on is the one it reads only while the higher-precedence fields are absent — adding one would silently change the bundled runtime with no diff to the workflow. Other jobs may pin node-version inline since their interpreter is never bundled.
  • FAIL IF pnpm-workspace.yaml is missing minimumReleaseAge: 1440.
  • FAIL IF .github/renovate.json is missing npm or cargo from enabledManagers (npm covers /; cargo covers /standalone/src-tauri), or is missing minimumReleaseAge package rules for those managers (the Renovate equivalent of dependency cooldown windows).

GitHub Actions Policies

GitHub Actions are pinned by commit hash, not version tag, in every workflow this repository authors. Renovate updates the hashes as necessary. The one exception is the tend-*.yaml files, which are generated by an upstream tool and carry its tag pins — see "Upstream compromise" below for what that costs and why it is accepted.

Agent-managed workflows are tend-*.yaml, workflow-audit.yaml, and security-audit.yaml. They implement the repo's automation and self-audit infrastructure, and are exempt from the two rules below because they need to modify issues, PRs, or code, or fetch an OIDC token. Their bounded scope is defined in the "Automated Maintainer" section.

Release audit dispatch. The security-audit job in release.yml holds actions: write — the one write permission a non-agent-managed workflow is granted beyond release provenance. It uses it solely to dispatch security-audit.yaml on the release tag and watch the resulting run, gating the VS Code publish on the result. Dispatch is required because claude-code-action rejects the push event that a tag-triggered workflow_call would inherit, and GITHUB_EVENT_NAME is a default variable that cannot be overridden — so a workflow_dispatch run is the only way to exercise the audit under a supported event. Blast radius is bounded: actions: write lets that job's GITHUB_TOKEN start or cancel workflow runs in this repo, but it cannot reach env-scoped secrets, merge to main, or push tags, and release.yml only runs on admin-gated v* tags — so exercising it already requires an admin-gated tag push.

  • FAIL IF pull_request_target appears in any .github/workflows/** file other than tend-*.yaml.
  • FAIL IF a non-agent-managed workflow has effective write permissions other than the explicitly scoped release provenance permissions id-token: write and attestations: write, or the actions: write granted to the security-audit job in release.yml (see "Release audit dispatch" above). Effective has the same meaning as in the agent-managed bullet below: a job that declares no permissions: block inherits the repository default.

Automated Maintainer (tend)

This repository runs the tend agent harness as the GitHub user dormouse-bot. tend reviews PRs, triages issues, fixes CI failures, regenerates its own workflow files on a nightly schedule, and responds to mentions. The agent expands the project's attack surface.

An attacker who lands a prompt injection in tend's harness can reach three secrets. None of them escalates directly into malicious content on the main branch or into any deployment-related secret — those paths stay admin-gated. The boundaries we accept are codified below.

  • TEND_BOT_TOKEN (worst case): full repo + workflow write access as a trusted collaborator. Direct uses are issue/PR spam, force-pushing or deleting feature branches, and persistent compromise by authoring new workflows (persistent compromise mitigated by workflow-audit.yaml). Authoring a workflow is also the mechanism by which CHROMATIC_PROJECT_TOKEN is reached. It cannot itself merge to main, push tags, or reach env-scoped secrets, but the bot's trusted identity can be used to social-engineer an admin toward a main merge.
  • CLAUDE_CODE_OAUTH_TOKEN: bounded Anthropic API-credit abuse, capped by the bot account's spend limit.
  • CHROMATIC_PROJECT_TOKEN: lets the attacker corrupt snapshot testing; mitigated by rotation, and any abuse is visible in Chromatic's own dashboard.

Prompt-injection through user-supplied content. tend's harness reads PR descriptions, code diffs, issue text, comments, and CI logs — all attacker-influenceable surfaces. A malicious prompt could direct the harness to push a workflow that references a repo-level secret to an external URL. The bot cannot merge to main or push tags, so admin-gated release paths stay sealed, but a workflow on a bot-pushed feature branch will still execute with repo-level secrets in scope.

Instruction files are part of that surface. tend-review.yaml runs on pull_request_target and checks out the PR merge ref, so on a fork PR the working tree the agent reads is attacker-controlled — including the files Claude Code loads as project instructions (CLAUDE.md, AGENTS.md, .claude/, .mcp.json). Those are not read as data the way a diff is; they are read as authoritative guidance. tend closes this by reverting those paths from the reviewed base branch before the agent starts (shared/steps/restore-sensitive-config.sh), so instructions come from code a maintainer merged. The control is only as complete as its path list: this repo keeps its instructions in AGENTS.md with CLAUDE.md as a one-line @AGENTS.md pointer, so a list naming only CLAUDE.md reverts a pointer and leaves the content it points at attacker-controlled. AGENTS.md is absent from that list at the pinned 0.1.18, and every entry there is root-relative. Reported from this audit; a fix with a regression test is committed on max-sixty/tend#1005, which is still open. It reaches us only once upstream merges it, cuts a release, and the nightly regen bumps the pin — three steps outside this repo's control, so treat the gap as live rather than as closing on a schedule. There is a local remedy, and it is the reason to state this precisely: the regen overwrites the workflow, not this repository's instruction files. CLAUDE.md is on the restored path list, so moving the instruction body into CLAUDE.md and dropping the @AGENTS.md pointer closes the gap today, permanently, with no upstream dependency — at the cost of the filename convention that other agent harnesses read. Until that trade is made or the upstream fix lands, the gap is live. Note that the control's completeness is a property of the pinned upstream version, not of anything in this repo: if the instructions ever move to a filename that list does not name, the revert silently stops covering them.

Credential isolation bounds an injection. The agent runs as a separate, non-sudo sandbox user behind a local credential-injecting proxy: TEND_BOT_TOKEN and the Anthropic credential live only in the proxy and never enter the agent's environment, its disk, or .git/config (the setup strips the credential actions/checkout persists there). An injected instruction can therefore make the bot act within its permissions — comment, push a feature branch — but cannot read the token value out and exfiltrate it. The worst-case analysis above is about what the bot's identity can do, not about the secret escaping.

Bot collaborator authority. dormouse-bot is a direct repo collaborator with push permission and 2FA enforced by org policy. Its PAT (TEND_BOT_TOKEN) carries the scopes repo, workflow, notifications, write:discussion, gist, and user. The workflow scope is required for the nightly regeneration of tend-*.yaml files; the same scope lets the harness add arbitrary new workflow files. Ref-protection rulesets restrict where bot-controlled commits can land but do not gate workflow execution on feature branches.

Reachable repo-level secrets. CHROMATIC_PROJECT_TOKEN is reachable by any workflow the bot can author, because chromatic.yml is pull_request-triggered and GitHub environment policies cannot distinguish a bot from a human contributor at the ref level. Chromatic project tokens are scoped to a single project, easy to rotate, and any abuse is detectable in Chromatic's own dashboard — this risk is accepted with rotation as the mitigation. OVSX_PAT and VSCE_PAT are protected: they live only in the vscode-extension-publish environment, whose deployment-branch-policy admits only v* tags, and tag creation is admin-only.

Inert secret plumbing. Every generated tend-*.yaml passes anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} to max-sixty/tend/claude. No such secret exists at repo or org level, so today it resolves to the empty string and the harness authenticates with CLAUDE_CODE_OAUTH_TOKEN instead. The input is upstream-generated and cannot be removed locally without being overwritten by the next nightly regen, so the risk is handled by enforcement rather than deletion: the moment anyone adds an ANTHROPIC_API_KEY secret for an unrelated reason, eight bot-triggered workflows would start reading it with no code change and no review. The FAIL IF below makes that addition a deliberate, documented expansion of the bot's reach.

Org-level secrets. Secrets shared with this repo from the diffplug org would be reachable by any workflow the bot can author, exactly like repo-level ones, and they do not appear in this repo's own secret listing (gh api repos/diffplug/dormouse/actions/organization-secrets is the check). None are visible here today. BUILDCACHE_USER and NEXUS_USER were org-wide shares — visible to every diffplug repository, not grants made to this one — and were previously accepted on the grounds that they are usernames rather than the paired credentials. They have since been narrowed to selected visibility over the repositories that actually consume them, which excludes this one, so the acceptance no longer has to be made. Every diffplug org secret is now selected and none lists diffplug/dormouse. Any org secret becoming visible here is an exposure that must be re-evaluated and named before it is accepted — hence the FAIL IF below admits none.

Upstream compromise. Tend's action is referenced as max-sixty/tend/claude@0.1.18 in every generated workflow — a tag, not a commit SHA. A tag is mutable by whoever owns that repository, so upstream can change what our workflows execute without any commit landing here, and workflow-audit.yaml would see nothing: the file is byte-identical. This is a real residual, not a solved problem. It is accepted because the file is generated (a hand-edited SHA is overwritten by the next nightly regen, so pinning locally is not durable) and because the trust it represents is the same trust the harness already has — tend runs the agent that holds TEND_BOT_TOKEN either way. What it means concretely is that the version pin bounds deliberate upgrades, not a hostile upstream. uvx tend@latest runs only at install and during nightly regen; a compromise of that path affects the next re-run, not the in-flight workflows.

Audit visibility. workflow-audit.yaml is a nightly job that walks every commit touching .github/workflows/ or .config/tend.yaml since its previous successful run — across all branches, not just main, so a workflow pushed to a feature branch is seen even though it never opens a PR. The config is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. It reports the unexplained: two routine sources are classified and omitted on independently checked provenance and content. A Renovate pin bump must be a valid GitHub-signed commit with author.login == "renovate[bot]" and committer.login == "web-flow", must be associated only with Renovate-authored PRs, and must change nothing but the ref of an already-referenced action. The signed author/committer pair is the provenance control: GitHub's automatically signed createCommitOnBranch mutation binds the author to the authenticating credential and does not permit the caller to supply the author or committer, while REST paths that permit those fields require the caller to supply the signature; requiring web-flow therefore rejects both a caller-supplied Renovate author and a commit signed by another identity. PR authorship is independent server-side corroboration. The content test adds a separate bound by requiring the diff to express nothing but a new ref for an action already referenced by name — the residual being a ref selected by Renovate inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from uvx tend@<version> init at the version in the files' own header, and must not touch .config/tend.yaml in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. Identity is not evidence here at all: TEND_BOT_TOKEN is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to main are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window.

Two known evasions of that diff window, both from how the window is computed rather than from what it classifies. The lower bound is server-set (the previous successful run's created_at), but the filter that applies it is git log --all --since, which compares against the committer date — a field the pusher sets freely, so GIT_COMMITTER_DATE=2020-01-01 on a commit adding a workflow makes it invisible to every future window. And a branch pushed, run with repo-level secrets in scope, and deleted before the nightly fetch is never in any window at all, because the audit only ever sees refs that still exist. Closing both means keying on server-observed ref changes (the repository activity API records pushes, force-pushes, and deletions with server timestamps and before/after SHAs) rather than on the commit graph as the client presents it. Neither is closed today; they are stated here so the control is not read as stronger than it is.

  • FAIL IF workflow-audit.yaml starts deriving its lower bound from anything the pusher controls. The --since filter is a known evasion (above); the bound coming from the previous successful run's server-set created_at is what keeps the window from being attacker-movable in both directions at once.

  • FAIL IF either admin-gating ruleset is missing or weakened. Merge access must target ~DEFAULT_BRANCH, block nothing beyond update, and carry admin (RepositoryRole actor 5) as its sole bypass actor; Tag operations must target ~ALL tags, block both creation and update, and carry the same admin-only bypass.

  • FAIL IF dormouse-bot holds maintain or admin on this repository. GET /collaborators/dormouse-bot/permission spells push as write in both permission and role_name, so the check is that neither of those two roles appears — not a string comparison against push.

  • FAIL IF any GitHub environment's deployment-branch-policies admit a ref that is not admin-gated by the Tag operations or Merge access rulesets. Today this covers vscode-extension-publish (v* tag, admin-only via Tag operations), security-audit (main admin-only via Merge access, plus v* tag), release-attest (v* tag, admin-only via Tag operations), and tend (main only, admin-only via Merge access).

  • FAIL IF the secret inventory departs from this placement. Env-scoping is what stops a workflow pushed to an excluded branch from reading a secret, so a repo-level copy reopens exactly what the environment gate closes. One pass over actions/secrets, actions/organization-secrets, and each environment's secret listing answers every line:

    • AUDIT_PAT — in security-audit, absent at repo level.
    • TEND_BOT_TOKEN — in tend, absent at repo level.
    • CLAUDE_CODE_OAUTH_TOKEN — in both tend and security-audit, absent at repo level. Environments do not inherit each other's secrets, so a rotation must set both.
    • OVSX_PAT, VSCE_PAT — in vscode-extension-publish only, absent at repo level.
    • ANTHROPIC_API_KEY — absent at repo and org level, for as long as tend-*.yaml passes anthropic_api_key to max-sixty/tend/claude (see "Inert secret plumbing" above).
    • release-attest's own secret listing is empty and it declares no environment variables. The environment exists only to bound the ref a provenance OIDC token can be minted from (release.yml's two build jobs); an empty environment is what keeps id-token: write the only credential those jobs can reach.
    • No org-level secret visible to this repository at all (see "Org-level secrets" above).
  • FAIL IF CHROMATIC_PROJECT_TOKEN is missing from secrets.allowed in .config/tend.yaml. The allowlist entry is an explicit acknowledgment that the bot can read this token.

  • FAIL IF .github/workflows/workflow-audit.yaml is missing, disabled, or has not produced a successful run in the last 48 hours. The margin is thinner than it reads: workflow-audit runs at 07:13 UTC and this audit at 04:21, so the steady state is ~21.5h and a single skipped run lands at ~45.5h — inside tolerance by under three hours, which is a reason to treat one skipped run as a signal rather than noise.

  • FAIL IF any tend-*.yaml workflow uses an unpinned action reference (e.g. @main, no version). Tag pins are accepted inside tend-*.yaml because the file is owned by the upstream generator; every other workflow — agent-managed or not — must SHA-pin per the rule above.

  • FAIL IF any job in an agent-managed workflow has effective GITHUB_TOKEN permissions beyond contents: write, pull-requests: write, issues: write, id-token: write, actions: read, or any read permission. Effective, not declared: a job with no permissions: block inherits the repository default, so this check is only meaningful together with the next one. A job that declares nothing textually "grants" nothing while its token carries nine write scopes.

  • FAIL IF default_workflow_permissions for this repository is not read, or can_approve_pull_request_reviews is not false (gh api repos/diffplug/dormouse/actions/permissions/workflow). This is the backstop for every permission bullet in this document: with the default at write, one regenerated workflow that omits a permissions: block silently reopens what those bullets close, and the repository setting is the only place to fix it durably — a YAML edit does not survive the nightly regen.

VS Code Extension Releases

The VS Code extension is published by GitHub Actions. The secrets which allow this publish are VSCE_PAT and OVSX_PAT. These secrets are contained only within a protected GitHub environment. The environment requires a human to manually approve, and it can't be the same account which triggered the publish. This prevents a single compromised tag or maintainer account from immediately publishing a new extension version without an explicit release approval.

  • FAIL IF .github/workflows/release.yml is missing the vscode-extension-publish environment on the VS Code publish job, or if VSCE_PAT / OVSX_PAT are referenced anywhere under .github/workflows/** from a job not bound to that environment. The second clause is repo-wide on purpose: scoping it to release.yml would let a reference from another workflow file pass unremarked.
  • FAIL IF .github/workflows/release.yml uses production desktop signing secrets in CI, or stops generating an ephemeral Tauri updater key for unsigned CI artifacts.

Desktop Releases

Desktop releases are not fully automated. GitHub Actions builds unsigned artifacts, publishes attestations and hash manifests, and uploads those unsigned artifacts for local release signing. Final desktop deployment is manual through scripts/sign-and-deploy.sh. Before signing, the script verifies the CI artifact attestations and the recorded SHA-256 hashes. The local machine then performs platform signing and uploads the final release assets. Windows Authenticode signing requires a physical YubiKey and the signing PIN. macOS signing and notarization also happen locally, outside GitHub Actions. CI must not have the production Tauri updater private key; CI uses only an ephemeral updater key so Tauri emits updater-shaped unsigned artifacts. Tauri updater signing is applied locally after OS signing so the updater signs the final release bundles that users will download.

  • 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.

Reporting a Vulnerability

Report privately through GitHub's Report a vulnerability form, which is enabled on this repository. That opens a private advisory visible only to you and the maintainers; it is the right channel for anything in this document, and specifically for anything in Remote Control — a public issue describing a live path into a Host's ACL is a disclosure, not a report.

Do not open a public issue, and do not send a report to the maintainer's personal email — the advisory form is what gets triaged. Include what you would want if you were fixing it: the version or commit, the deployment mode (self-hosted server, standalone app, VS Code extension), and the shortest sequence that reproduces the problem. We will acknowledge the advisory and tell you what we intend to do about it; there is no bounty program.

This is a small project with one maintainer. Nothing here promises a response time we cannot keep, and a fix that requires a coordinated release will say so in the advisory rather than in a schedule.

  • FAIL IF private vulnerability reporting is disabled on the repository (gh api repos/diffplug/dormouse/private-vulnerability-reporting must report enabled: true) — the advisory link above is the only reporting channel this document offers, and a disabled form sends a reporter to a public issue instead.

CI Validation Contract

The security-audit workflow at .github/workflows/security-audit.yaml enforces this document. It runs nightly and is a required dependency of the VS Code publish job in release.yml, so no release ships without a passing audit. The audit reads SECURITY.md, executes each FAIL IF as a mechanical check, and also does a qualitative pass for security holes the specs don't cover.

The audit is fanned out to three subagents with disjoint scopes, and the orchestrator audits nothing itself — it spawns them concurrently and merges what they return. The domains are supply-chain (Dependency Supply Chain), ci-and-secrets (GitHub Actions Policies, Automated Maintainer (tend), both release sections, Reporting a Vulnerability, and this one), and application-security (Remote Control). The split is not about parallelism. These are different subject matters with different evidence — dependency provenance is lockfiles, CI is gh api output, and application security is reading the pairing code adversarially — and one context holding all three degrades the third, which is the newest, has the most code behind it, and is the easiest to crowd out with API responses. The separation is one of context, not of credential: AUDIT_PAT is a step-level env: on the one job, so every subagent inherits it in its process environment, and only the prompt tells the application-security agent not to use it. A prompt is not a control. Making that separation real would take a second job without the security-audit environment, passing fragments between jobs as artifacts — worth doing, not done. Until then the honest claim is that three contexts each read less, not that any of them holds less.

Each subagent writes its own report fragment (audit-supply-chain.md, audit-ci-secrets.md, audit-application.md) before returning its verdict, and the orchestrator concatenates those files rather than retyping them. Fragments are uploaded with the transcript, so an orchestrator that dies mid-merge still ships whatever the domains found — the INCONCLUSIVE shape below, which the archive exists to explain.

The prompts live in .github/audit/, not inline in the workflow, and that placement is load-bearing three times over. scripts/security-audit-local.sh runs the audit against the same files CI uses, so the loop that catches problems in this document is a local one and cannot drift from the nightly. Prompt changes get reviewed as ordinary markdown diffs rather than as YAML block-scalar churn. And the section-ownership rule below is only a grep because the ## headings sit in markdown — inline, block-scalar wrapping split ## Automated Maintainer (tend) across two lines and the check silently matched nothing.

Subagents launch in the background, which is the trap that produced three INCONCLUSIVE runs. The Task tool returns an id, not a report, so an orchestrator that ends its turn to await a completion notification ends the session — this is one headless run and nothing resumes it. Run 32618922852 passed all 21 mechanical checks that way and produced no verdict at all. The fix is not to stop delegating: it is to never end the turn. The orchestrator blocks in a Bash until loop on the fragment files, re-issuing it when a single call hits the ten-minute Bash cap, until every fragment exists or a 25-minute deadline passes. --allowed-tools is not what enforces this — it only auto-approves and removes nothing, which is why the tools were available in the first place.

FAIL IF lines are grouped by the operation that answers them: one bullet may assert several properties when a single API call, file read, or script run establishes all of them. The grouping is presentation only — every clause remains an independent check, and the report records each with its own PASS/FAIL and its own evidence. A bullet is never satisfied in bulk. On any FAIL IF violation or BLOCKER-severity finding, the workflow opens (or updates) an issue labeled security-audit-failure with the full audit report, and exits non-zero. When a subsequent audit passes, the open failure issue is auto-closed so the tracker matches the live state.

The reporting step distinguishes three outcomes, not two. PASS and FAIL are verdicts the audit reached; anything else — a missing, empty, or non-verdict audit-status.txt — is INCONCLUSIVE, meaning the agent ended its turn without deciding. Only the literal strings PASS and FAIL are honored, so a status file containing prose cannot be mistaken for a verdict. An inconclusive run still exits non-zero and still files under security-audit-failure — an audit that reached no verdict must not let the release gate pass, and a later PASS should auto-close it like any other failure — but it is titled INCONCLUSIVE and its body states that it is not a security finding. Collapsing the two, as the step originally did, filed an identical issue for "the repo is insecure" and "the auditor stopped early".

The audit runs as a single headless turn, and the recurring cause of INCONCLUSIVE is an agent that treats it as a resumable one. claude_args therefore denies the delegation tools (Task/Agent, Workflow), because handing work to a background subagent and ending the turn to await it discards that work and the run reaches no verdict even when every mechanical check has passed. The prompt splits the two output files along the fail-closed line: audit-report.md is always written before the turn ends — partial, with unreached areas marked UNVERIFIABLE, if the agent runs short — while audit-status.txt is written only once the verdict covers every check. A partial audit therefore still reaches a human (the reporting step reproduces the partial report in the INCONCLUSIVE issue) without a PASS on unrun checks closing the failure issue and opening the release gate.

Every run uploads the agent's SDK transcript as the audit-transcript artifact (14-day retention), and failure issues deep-link it. Without it a run that produces no verdict is undiagnosable: claude-code-action keeps tool output out of the step log on purpose, and the runner is ephemeral. Because this repository is public the artifact is world-readable, which is consistent with the audit reports already posted to public issues — but note that artifact contents are not secret-masked the way logs are, so no step may ever print $AUDIT_PAT or $CLAUDE_CODE_OAUTH_TOKEN. The prompt passes the PAT only through an unexpanded GH_TOKEN= prefix, and gh api responses never carry secret values.

The audit job declares environment: security-audit, whose deployment-branch-policy admits only main and v* tags. Both ref classes are admin-only by the rulesets in Automated Maintainer (tend), so a write-scoped bot cannot reach the env's secrets (most importantly AUDIT_PAT, when provisioned) by pushing a workflow file to a feature branch.

As a consequence of that env-gating, audit changes are iterated on main directly. A workflow_dispatch from any other ref is rejected by the environment's deployment-policy before any step runs. To experiment on a branch, widen the env's policy temporarily and revert after.

AUDIT_PAT is required. A dedicated step verifies the secret is present before the audit step runs — after the checkout and install, not literally first — and refuses to continue otherwise — without it the audit cannot read the administration endpoints needed to verify ruleset bypass actors, repo-level secret listing, and environment policies, so the spec it claims to enforce would be unenforceable in its key sections. Mint a fine-grained PAT on an admin's account with read-only Administration + Secrets + Environments scoped to diffplug/dormouse only, then store it env-scoped:

gh secret set AUDIT_PAT --env security-audit --repo diffplug/dormouse --body 'github_pat_…'
  • FAIL IF .github/workflows/security-audit.yaml is missing or disabled, or if any of the three separate things that make it a release gate is gone: the gh workflow run dispatch, the gh run watch --exit-status that turns a failed audit into a failed job, and publish-vscode's needs: edge on that job. They break independently — dropping --exit-status alone un-gates the release while leaving a green grep for "invoked".
  • FAIL IF the audit stops fanning out to a dedicated application-security subagent scoped to Remote Control, or that subagent's scope is merged back into a context that also carries the supply-chain or CI domains. Folding it back in is how that section stops being audited without anyone deciding to stop auditing it.
  • FAIL IF the orchestrator prompt stops requiring a non-turn-ending wait — a Bash until loop over the fragment files, re-issued past the ten-minute Bash cap, under a bounded deadline. Delegating is safe; ending the turn to wait is what kills the run, and no tool allowlist prevents it.
  • FAIL IF .github/audit/ is missing a prompt file the workflow names, or scripts/security-audit-local.sh stops running the audit from those same files. A local runner with its own copy of the prompts is worse than no local runner: it drifts, and the drift is invisible until a nightly disagrees with a local pass.
  • FAIL IF .github/audit/ is outside workflow-audit.yaml's diff window. The prompts decide what gets audited and by whom, so a change to them is a change to the security automation — the same reason .config/tend.yaml is in that window.
  • FAIL IF a ## section of this document is in no subagent's scope, or is in two. Every section is owned by exactly one domain: a section owned by none is unaudited, and one owned by two produces contradictory verdicts. Each domain file in .github/audit/ names its sections as exact ## headings on their own lines, so this is a real grep over four markdown files rather than a reading of prose embedded in YAML.
  • FAIL IF the union of the subagents' qualitative scopes does not cover every top-level path in the repository. The per-domain scopes replaced a single roving "flag any other security hole you find", so anything no domain names is now nobody's job — and the first version of this split silently orphaned canopy/, .claude/ (named as prompt-injection surface two sections above), docs/, the root files, and all of website/ outside src/data/, which includes the Tauri updater manifest that shipped apps fetch. The division:
    • application-securitylib/, server/, server-lib-common/, standalone/, vscode-ext/, dor/, dor-lib-common/, canopy/, deploy/, docs/, and the repository root files.
    • ci-and-secrets.github/, .config/, .claude/, scripts/, and website/public/ (the updater manifest is a release artifact, not marketing).
    • supply-chain — the dependency graph, the lockfile, and website/src/.
  • FAIL IF the Redact secrets from agent output step is removed, stops covering any sink that is later published (audit-report.md, the three per-domain fragments, and the transcript), or stops failing closed by deleting those files when the redactor itself throws. It is the only thing between an accidental printenv and a world-readable artifact, and until this bullet existed nothing would have tripped on its deletion.
  • FAIL IF the orchestrator can report PASS while a subagent left no report fragment. A domain that dies silently must fail the audit, not be absent from it — a missing fragment is indistinguishable from a domain that found nothing, and only one of those is safe to publish a release on.
  • FAIL IF the audit has been weakened in any other way — e.g. the prompt no longer requires the qualitative pass, a FAIL IF can be ignored, the failure-reporting step that opens a security-audit-failure issue and exits non-zero has been removed, or the AUDIT_PAT pre-check is removed or bypassed. This bullet is a judgement item, not a checklist: the examples are the ones that have come up, not the ones that exist. Two weakenings found by the audit's own first run were not covered by any example here, and both became their own bullets above.

There aren't any published security advisories