Audited automatically. This spec is checked against the repository by
security-audit.yamlon 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 labeledsecurity-audit-failure— open ones are live, closed ones are the historical record of what tripped past audits and what changed to clear them.
Dormouse is a terminal, so users trust it with shells, source trees, credentials, and local files. Three things sit on that security boundary. The dependency graph and release pipeline decide what code reaches a user's machine. Remote control — pairing a phone with a laptop — is the one feature that accepts input from the network, and an authorized phone is equivalent to a person at the keyboard. And the loopback listeners Dormouse binds for its own surfaces accept input from any page in the user's browser, which is a boundary precisely because it does not look like one.
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.
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. A deployment can raise the first layer from presence to user verification — biometric or PIN rather than a touch — with DORMOUSE_REQUIRE_USER_VERIFICATION=true; the flag is mirrored to every Host in its enrollment response, because the Host is the final authority and a Server demanding UV while the Host did not would leave the weaker verifier deciding. What each compromise actually buys:
-
Server compromise — relay traffic and account state, and no new authorization: a forged account, a forged presence stamp, and an injected
ConnectionRequestall still arrive in front ofauthorizeConnectionon the Host, which re-verifies the passkey assertion and the device-key signature against its own ACL and its ownConnectionPolicy(the origin/rpId recorded at enrollment) regardless of what the Server claims to have checked. It cannot make the Host trust a device the user never approved.What it does buy, on any session an authorized Client already has open, is read and write — not read alone. After a decision the Host gates
msgframes onstate.establishedfor aclientIdthe relay itself minted (RemoteHost.#onMsg), and there is no per-frame authentication, so a compromised Server can fabricate frames for an established session and reachterminal.write— keystroke injection into a live PTY — as well as suppress or rewrite frames in either direction. This follows directly from the relay being a dumb pipe with no end-to-end authentication (see Accepted limitations; the staged PRF-derived session key would close it). It is architectural, not a defect, but "no Host access" read stronger than what holds: the bound is that compromise cannot create an authorized Client, not that it cannot act through one. -
Setup-password compromise — full account takeover:
/api/setup/*is gated by the password alone, so re-presenting it registers another passkey, and/api/host/enrollmints 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
HostAclanswersdevice-not-pairedand 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:
authorizeConnectioninserver-lib-common/src/security/connection.tsmust verify the passkey assertion, the device-key signature, and the ACL against the Host's ownConnectionPolicybefore 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.approvemust have no caller other thanPairingCeremony.approve, and an approval must be matched against the immutablepairingIdof the request that was displayed, never against a mutableclientIdalone. (Records can also be carried rather than minted — see theadoptbullet below — which is a different act and is bounded separately.) - FAIL IF the pending-pairing queues are unbounded. Every
pairframe allocates under aclientIdthe relay chooses, in bothRemoteHost's client map and the service's mirrored queue, and the only thing that removes one is aclient-gonea hostile relay simply never sends. Both are capped atMAX_PENDING_PAIRINGS, oldest evicted first, because either can be fed independently and a cap only one honors is not a cap. The controller's eviction drops the record, not merely the request it holds: clearing the payload while keeping the map slot bounds the part thatPAIRING_FIELD_LIMITalready bounded and leaves the relay-chosen key — which is whyMAX_CLIENT_ID_LENGTHboundsclientIdat the frame boundary too, before any map is touched. This bounds the pairing path;connectallocates a client entry by another route that the pairing counter deliberately does not evict, since dropping an entry that may beestablishedis a different act. Those carry no request and are cleared when the socket drops. Capping the ceremony's ticket map alone was not enough: it left 5000 frames retaining megabytes of relay-chosen strings in the process that owns every PTY, with the whole queue re-serialized to the webview per frame, so the cost was quadratic. Reachable by anything that can sign in — a synced or stolen passkey buys "the ability to ask", and this is what stops asking from being a denial of service. - FAIL IF
requireUserVerificationis reachable on one side without being mirrored to the other: the Server readsDORMOUSE_REQUIRE_USER_VERIFICATION, andHostEnrollResponsemust carry it into the Host'sConnectionPolicy. - FAIL IF the Host accepts a
pairframe it has not shape-validated itself.isPairingRequestruns on the Server too, and that is exactly why the Host cannot rely on it: a relay-supplied object reachingPairingCeremony.beginputs unvalidated fields into the approval UI and, on approval, into a persisted record. TherequestedLabelmust likewise be reduced withboundedPairingLabelbefore 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, andRemoteHostConsoleStatusinlib/src/host/remote/service-protocol.tsare the outbound shapes, and none may expose it. (Inbound is a different matter —EnrollParamscarries the setup password andAdoptParams.enrollmentstructurally carries ahostToken, both by design: enrolling is initiated from the webview, whether from the Settings dialog or thewindow.dormouseRemoteHostconsole hook.) - FAIL IF
adopt— the migration hand-off from builds that persisted the Host in webviewlocalStorage, and the one command that carries ACL records inbound — stops being bounded on all three of: the service's own store holding no enrollment, aserverUrlinside the baked allowlist below, and every record passing the fullisHostAclRecordshape guard as well as thehostIdmatch. 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.
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_SRCis not exactlyhttps://*.dormouse.sh wss://*.dormouse.shin bothscripts/csp-defaults.mjsandlib/src/host/remote/connect-src.ts, or ifCONNECT_SRC_SOURCE_PATTERNdiffers between them. Widening the default — a localhost entry, anhttp/wsscheme, a bare*, or the apexdormouse.sh— is a change to what every shipped binary will talk to. - FAIL IF
assertConnectSrcBakedis no longer called on the built bundle by bothstandalone/scripts/build-sidecar-proxy.mjsandvscode-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 ifresolveRemoteConnectSrcstops rejecting an override the runtime matcher cannot parse.resolveRemoteConnectSrcvalidates 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.tsis what pins them. - FAIL IF
originAllowedByConnectSrcstops gating bothenrolland Host start-up inlib/src/host/remote/service.ts(including theadoptpath), or fails open on an unparseable origin or source. - FAIL IF the enrollment exchange in
lib/src/remote/host/enrollment.tsor the Host-authenticated push fetches inlib/src/remote/host/push-delivery.tsdropredirect: '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 thehostTokenoutside the allowlist.
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 |
| Session snapshots | sessions/ under the standalone app-data dir |
0700 dir, 0600 file, best-effort on unix. These are PersistedWindow blobs carrying terminal transcripts — whatever the user's shells printed, which is a superset of every other secret here. They inherited the umask as 0644 until this was tightened |
| Host ACL | HostStateStore, keyed per hostId |
a 0600 file in standalone; VS Code globalState. 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.tsstops creating$DORMOUSE_STATE_DIRmode0o700, or stops writing every file throughwriteAtomicat mode0o600. The "every file" clause is a negative search overserver/src/: nowriteFile,appendFile, orcreateWriteStreammay target the state directory outsidewriteAtomic. - FAIL IF
write_session_toinstandalone/src-tauri/src/lib.rsstops restricting thesessions/directory to0700and the snapshot to0600on unix. The mode is applied to the temp file before any bytes are written, because the atomic rename preserves it — tightening after the rename would leave a window where the transcript is world-readable. - FAIL IF
FileHostStateStore(lib/src/host/remote/host-state-store.ts) stops creating its directory0o700and writing0o600on non-Windows platforms, or ifVsCodeHostStateStorestops keeping the enrollment inSecretStorage. The ACL's home inglobalStateis deliberate and is not a finding; the enrollment's is what carrieshostToken. - FAIL IF
deploy/local/install-macos.shstops 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 32passes a regression to half the entropy. - FAIL IF the installer stops writing
config/server.envat mode0600underumask 077, or stops keepingconfig/andstate/at0700. - FAIL IF the installer stops preserving an existing
config/server.envbyte-for-byte across an update, or begins printing the setup password outside the explicitmanage show-passwordpath. - FAIL IF
manage verifystops failing when the LaunchAgent plist containsDORMOUSE_SETUP_PASSWORD.
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.tsstops 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 fororigin: '*'being acceptable.
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.shstops requiringDORMOUSE_BIND_HOST=127.0.0.1inconfig/server.env, or ifmanage verifystops asserting that the plaintext port is unreachable on the node's Tailscale IP. - FAIL IF the unset default of
DORMOUSE_BIND_HOSTinserver/src/config.tsstops beingundefined(listen on every interface — what a container wants, where the namespace is the boundary), orserver/test/bind-host.test.mjsstops 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_ORIGINthat no longer matches the node's DNS name. - FAIL IF
manage verifydoes not fail on Funnel being on for this node. It matchesfunnel onacrosstailscale serve statusandtailscale 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.
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.tsstops rejecting non-public push endpoints at registration, stops applyingcreatePublicLookup/createPublicPushAgentto delivery, or stops rejecting a hostname whose DNS answers are mixed public and blocked. - FAIL IF
/api/push/sendstops taking thehostIdfrom the Host's own token, begins selecting recipients whendevicePublicKeysis absent or empty, or if any read endpoint begins reporting on adevicePublicKeysupplied by the caller rather than one proven by the presented credential. - FAIL IF push text stops being sanitized with the shared
boundedPushTexton 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.
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.
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.
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
accountIdother thanSELFHOST_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.
Dormouse binds loopback HTTP and WebSocket servers to render its own surfaces. A loopback bind is not an access control. 127.0.0.1 keeps out the network, but the attacker that matters is a page open in the user's own browser, and it reaches loopback exactly as easily as our webview does. An ephemeral port is not a secret either — the range scans in seconds. Two properties of the browser make this sharper than it looks: a POST with a simple content-type needs no preflight, so it executes even when the attacker cannot read the reply; and WebSockets are not subject to CORS at all, so a socket that connects is a socket that can be read.
The rule is about privilege, not admission: no listener may grant an unrecognized caller anything it could not already obtain by reaching the upstream directly. Every such listener answers two questions on every request — was I addressed by my own loopback name, and do I recognize this caller — but what it does with the second answer differs by listener. Two refuse the request outright. The iframe proxy deliberately admits everyone and instead declines to vouch: vouching for a stranger is what turns a transparent proxy into an amplifier, and refusing outright would be worse, because forwarding the caller's real Origin lets the upstream apply its own policy. The shared rule and the two shared predicates live in lib/src/host/loopback-guard.ts.
The mechanism for "do I recognize this caller" differs per listener because their URLs differ, and the differences are forced, not stylistic: the iframe proxy cannot use a URL token because it would land in location.pathname and break client-side routers — and would not survive onto root-relative sub-resource requests at all — while the browser-dev harness can, because it owns the page's URL.
- FAIL IF any loopback HTTP or WebSocket listener grants an unrecognized caller a privilege it could not obtain by reaching the upstream directly. Refusing the request is one way; the iframe proxy's admits all, vouches for none is another, and is not a violation.
scripts/loopback-lint.mjs(pnpm test) makes the cheap half of this deterministic — a new loopback bind that does not reference a guard module fails the build — but it can only see that a file knows a guard exists, never that the guard is called on every request, so this bullet still has to be read. Derive the set by searching the shipped trees forcreateServerand.listen(rather than trusting this list — an enumeration goes stale the moment someone adds a listener, which is the same failure mode that once left.vscode/owned by nobody. Today the set is three: the iframe proxy (lib/src/host/iframe-proxy.ts), the VS Code agent-browser stream relay (vscode-ext/src/agent-browser-host.ts), and the browser-dev bridge (standalone/scripts/dev-agent-browser.mjs). A Unix-domain socket or named pipe is not in scope — no browser can reach one — which is why thedorcontrol channel is bounded by socket permissions instead. - FAIL IF the iframe proxy rewrites
Originto the upstream's own origin for a caller whose inboundOriginis not the proxy's own — inhandleRequestorhandleUpgrade. The upgrade path is the one that matters most: a launderedOriginthere does not merely let a stranger write, it hands them a readable socket to a dev server oropenvscode-serverthat would have refused their real origin. A foreignOriginmust be forwarded untouched rather than blocked, so the upstream sees the truth and applies its own policy. - FAIL IF the iframe proxy stops checking that
Hostnames its own grant port, on either path. Its per-grant ephemeral port and one-fixed-upstream binding are real mitigations but neither is a secret, so this is what makes DNS rebinding fail. - FAIL IF the stream relay's grant stops being single-use, TTL-bounded, and pinned to one target port, or if it begins rewriting
Originrather than dropping it. It needs noHostcheck while the token holds: rebinding exists to make same-origin-looking requests to loopback, which buys nothing against a listener demanding an unguessable one-shot secret. - FAIL IF the browser-dev bridge drops any of its four gates — the per-run token, the loopback
Hostcheck, theapplication/jsoncontent-type required of every non-GET, or the exact-originaccess-control-allow-origin. The first three live together in the gate that runs before routing, so a route that never reads a body is covered by all of them. It is dev-only and ships in nothing, but it dispatchespty_spawnwith caller-suppliedshell,args,cwdandenv, so reaching it is arbitrary command execution on a maintainer or CI-agent machine — the machines the Automated Maintainer threat model is about. The content-type rule is a security control, not tidiness: without it the endpoint is CORS-simple and needs no preflight to survive.
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 is a root in its own right rather than a workspace edge, and the reason is worth stating: vscode-ext declares only node-pty and ws, reaching the lib through relative imports into ../lib/src/ from fifteen files, so the extension's dependency walk never arrives at it. Only dormouse-standalone's edge would — which puts the disclosure of lib's entire subtree one refactor away from silently vanishing. Naming it a root is what makes that not matter. server-lib-common and dor-lib-common are reached as workspace edges from those roots. Note the roots are package names, not directory names, and the two differ once: vscode-ext/ declares itself dormouse.
Two workspace packages are deliberately not roots. canopy is a Storybook-only rendering lab that no shipped build imports, and website runs in a visitor's browser rather than being installed anywhere — the page says as much about its own React and react-router. Excluding website is what makes "puts on a user's machine" the operative test rather than "a user runs", and it is a judgement worth re-making if the site ever ships something a visitor installs.
External binaries are outside this graph by construction. Dormouse is a terminal: it spawns the user's shell, and dor ab forwards to an agent-browser CLI the user installs themselves (npm i -g agent-browser — it is not a dependency of anything here and is resolved off PATH). Those are the user's software, not ours, and disclosing them is neither possible nor meaningful. What this document can promise is that we ship nothing that pulls them in silently.
Those dependency snapshots are generated from the lockfiles and reviewed as part of release work. If a production dependency is added, removed, or upgraded, the dependency lists must be regenerated and committed — and CI fails the PR if they were not, because until that gate existed the nightly audit was the only thing that ever ran the generator, and two prod bumps (ws, hono) shipped undisclosed before it caught them.
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.jschangeswebsite/src/data/dependencies-npm.json,website/src/data/dependencies-cargo.json, orwebsite/src/data/dependencies-runtime.jsonwhen run against a clean working tree afterpnpm install --frozen-lockfile. The install is a precondition, not a nicety: the generator resolves every dependency by walking realnode_modulesdirectories and throws rather than under-reporting if they are absent — so a stalenode_modulesmakes this check pass on a tree that would fail in CI. - FAIL IF
.github/workflows/ci.ymlstops 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 realnode_modulesdirectories and throws rather than under-reporting if they are absent. - FAIL IF
productDependencyFiltersinwebsite/scripts/generate-deps.jsomits a workspace package whose files Dormouse writes onto a user's disk — today the six named above, withcanopyandwebsiteexcluded for the stated reasons. Derive this frompnpm-workspace.yamlrather 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'spnpm installwould drag the whole toolchain in. - FAIL IF the root
package.jsonis missingdevEngines.runtime.version, or its value is not an exact Node.js version (a bare major such as24is not acceptable; it must beMAJOR.MINOR.PATCH). - FAIL IF
standalone/src-tauri/build.rsno longer verifies that the bundled Node.js binary matchespackage.json'sdevEngines.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_versioncannot execute a foreign-arch binary, so it warns and returns whenhost != target. That is acceptable only while every entry inrelease.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-standalonejob in.github/workflows/release.ymldoes not install the pinned runtime vianode-version-file: package.json, or the rootpackage.jsongains avolta.nodeorengines.nodefield.setup-noderesolves 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 pinnode-versioninline since their interpreter is never bundled. - FAIL IF
pnpm-workspace.yamlis missingminimumReleaseAge: 1440. - FAIL IF
.github/renovate.jsonis missingnpmorcargofromenabledManagers(npm covers/; cargo covers/standalone/src-tauri), or is missingminimumReleaseAgepackage rules for those managers (the Renovate equivalent of dependency cooldown windows). - FAIL IF
.github/renovate.jsonhas novulnerabilityAlertsblock, or that block does not setminimumReleaseAgeexplicitly. This reads backwards and is the whole point: Renovate's built-in default forvulnerabilityAlertsisminimumReleaseAge: null, force-applied before lookup, so omitting the key drops the cooldown rather than inheriting it frompackageRules. Stating it is the only way to keep it. Keeping it is deliberate — the cooldown guards the opposite threat, a compromised release yanked within a day, which a reviewer reading a dependency diff cannot detect the way the ecosystem's own yank process can. Nothing auto-merges, so what it costs is a day before the remediation PR appears, not a day before anyone knows. - FAIL IF secret scanning or its push protection is disabled on the repository (
gh api repos/diffplug/dormouse --jq .security_and_analysis), or Dependabot alerts are off (GET /repos/diffplug/dormouse/vulnerability-alertsmust answer 204, not 404). Push protection is the one control that acts before a credential lands: it blocks a push whose diff carries a recognized provider token, and it applies todormouse-bottoo — which is the point, since an injected agent pasting a token into a file is exactly the shape it stops.
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_targetappears in any.github/workflows/**file other thantend-*.yaml. - FAIL IF a non-agent-managed workflow has effective write permissions other than the explicitly scoped release provenance permissions
id-token: writeandattestations: write, or theactions: writegranted to thesecurity-auditjob inrelease.yml(see "Release audit dispatch" above). Effective has the same meaning as in the agent-managed bullet below: a job that declares nopermissions:block inherits the repository default.
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): fullrepo+workflowwrite 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 byworkflow-audit.yaml). Authoring a workflow is also the mechanism by whichCHROMATIC_PROJECT_TOKENis reached. It cannot itself merge tomain, push tags, or reach env-scoped secrets, but the bot's trusted identity can be used to social-engineer an admin toward amainmerge.CLAUDE_CODE_OAUTH_TOKEN: bounded Anthropic API-credit abuse, capped by the bot account's spend limit.CHROMATIC_PROJECT_TOKEN: lets the attacker corrupt snapshot testing; mitigated by rotation, and any abuse is visible in Chromatic's own dashboard.
Prompt-injection through user-supplied content. tend's harness reads PR descriptions, code diffs, issue text, comments, and CI logs — all attacker-influenceable surfaces. A malicious prompt could direct the harness to push a workflow that references a repo-level secret to an external URL. The bot cannot merge to main or push tags, so admin-gated release paths stay sealed, but a workflow on a bot-pushed feature branch will still execute with repo-level secrets in scope.
Instruction files are part of that surface. tend-review.yaml runs on pull_request_target and checks out the PR merge ref, so on a fork PR the working tree the agent reads is attacker-controlled — including the files Claude Code loads as project instructions (CLAUDE.md, AGENTS.md, .claude/, .mcp.json). Those are not read as data the way a diff is; they are read as authoritative guidance. tend closes this by reverting those paths from the reviewed base branch before the agent starts (shared/steps/restore-sensitive-config.sh), so instructions come from code a maintainer merged.
This was reported from this audit and is now fixed. At the previously pinned 0.1.18 the revert list was a flat, root-relative SENSITIVE array naming CLAUDE.md but no AGENTS.md at all — and this repo keeps its instructions in AGENTS.md with CLAUDE.md as a one-line @AGENTS.md pointer, so the control reverted a pointer and left the content it pointed at attacker-controlled. The fix (max-sixty/tend#1005, merged 2026-08-22, released in 0.1.19 on 2026-08-26) replaces that list with pathspec globs — ':(glob)**/AGENTS.md', ':(glob)**/CLAUDE.md', ':(glob)**/.claude/**' — which restore-sensitive-config.sh passes to pin_to_base, covering every depth rather than a hand-enumerated set of root paths. This repo regenerated onto 0.1.19, so the gap is closed here rather than merely closable.
Two things stay true regardless of the fix. The control's completeness is a property of the pinned upstream version, not of anything in this repo, so a pin that moves backwards silently reopens it — hence the FAIL IF below. And there remains a local remedy if it ever regresses: the regen overwrites the workflow, not this repository's instruction files, so moving the instruction body into CLAUDE.md and dropping the pointer would close it without any upstream dependency, at the cost of the filename convention other agent harnesses read.
Credential isolation bounds an injection. The agent runs as a separate, non-sudo sandbox user behind a local credential-injecting proxy: TEND_BOT_TOKEN and the Anthropic credential live only in the proxy and never enter the agent's environment, its disk, or .git/config (the setup strips the credential actions/checkout persists there). An injected instruction can therefore make the bot act within its permissions — comment, push a feature branch — but cannot read the token value out and exfiltrate it. The worst-case analysis above is about what the bot's identity can do, not about the secret escaping.
Bot collaborator authority. dormouse-bot is a direct repo collaborator with push permission and 2FA enforced by org policy. Its PAT (TEND_BOT_TOKEN) carries the scopes repo, workflow, notifications, write:discussion, gist, and user. The workflow scope is required for the nightly regeneration of tend-*.yaml files; the same scope lets the harness add arbitrary new workflow files. Ref-protection rulesets restrict where bot-controlled commits can land but do not gate workflow execution on feature branches.
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.19 in every generated workflow — a tag, not a commit SHA. A tag is mutable by whoever owns that repository, so upstream can change what our workflows execute without any commit landing here, and workflow-audit.yaml would see nothing: the file is byte-identical. This is a real residual, not a solved problem. It is accepted because the file is generated (a hand-edited SHA is overwritten by the next nightly regen, so pinning locally is not durable) and because the trust it represents is the same trust the harness already has — tend runs the agent that holds TEND_BOT_TOKEN either way. What it means concretely is that the version pin bounds deliberate upgrades, not a hostile upstream. uvx tend@latest runs only at install and during nightly regen; a compromise of that path affects the next re-run, not the in-flight workflows.
Audit visibility. workflow-audit.yaml is a nightly job that walks every commit touching .github/workflows/, .config/tend.yaml, .github/audit/, or .vscode/ since its previous successful run — across all branches, not just main, so a workflow pushed to a feature branch is seen even though it never opens a PR. This paragraph is the prose spec of that job's WINDOW, so the two enumerations name the same paths: a path added to one without the other leaves a reader checking the FAIL IF below against a paragraph that contradicts it. What unites them is that each executes from a branch nobody reviewed — a workflow on a bot push, a folderOpen task on checkout, a prompt that decides what the nightly audit even looks at. The config is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. It reports the unexplained: two routine sources are classified and omitted on independently checked provenance and content. A Renovate pin bump must be a valid GitHub-signed commit with author.login == "renovate[bot]" and committer.login == "web-flow", must be associated only with Renovate-authored PRs, and must change nothing but the ref of an already-referenced action. The signed author/committer pair is the provenance control: GitHub's automatically signed createCommitOnBranch mutation binds the author to the authenticating credential and does not permit the caller to supply the author or committer, while REST paths that permit those fields require the caller to supply the signature; requiring web-flow therefore rejects both a caller-supplied Renovate author and a commit signed by another identity. PR authorship is independent server-side corroboration. The content test adds a separate bound by requiring the diff to express nothing but a new ref for an action already referenced by name — the residual being a ref selected by Renovate inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from uvx tend@<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.yamlstarts deriving its lower bound from anything the pusher controls. The--sincefilter is a known evasion (above); the bound coming from the previous successful run's server-setcreated_atis what keeps the window from being attacker-movable in both directions at once. -
FAIL IF either admin-gating ruleset is missing or weakened.
Merge accessmust target~DEFAULT_BRANCH, block nothing beyondupdate, and carry admin (RepositoryRoleactor5) as its sole bypass actor;Tag operationsmust target~ALLtags, block bothcreationandupdate, and carry the same admin-only bypass. -
FAIL IF
dormouse-botholdsmaintainoradminon this repository.GET /collaborators/dormouse-bot/permissionspellspushaswritein bothpermissionandrole_name, so the check is that neither of those two roles appears — not a string comparison againstpush. -
FAIL IF any GitHub environment's deployment-branch-policies admit a ref that is not admin-gated by the
Tag operationsorMerge accessrulesets. Today this coversvscode-extension-publish(v*tag, admin-only viaTag operations),security-audit(mainadmin-only viaMerge access, plusv*tag),release-attest(v*tag, admin-only viaTag operations), andtend(mainonly, admin-only viaMerge 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— insecurity-audit, absent at repo level.TEND_BOT_TOKEN— intend, absent at repo level.CLAUDE_CODE_OAUTH_TOKEN— in bothtendandsecurity-audit, absent at repo level. Environments do not inherit each other's secrets, so a rotation must set both.OVSX_PAT,VSCE_PAT— invscode-extension-publishonly, absent at repo level.ANTHROPIC_API_KEY— absent at repo and org level, for as long astend-*.yamlpassesanthropic_api_keytomax-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 keepsid-token: writethe 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_TOKENis missing fromsecrets.allowedin.config/tend.yaml. The allowlist entry is an explicit acknowledgment that the bot can read this token. -
FAIL IF
.github/workflows/workflow-audit.yamlis missing, disabled, or has not produced a successful run in the last 48 hours. The margin is thinner than it reads:workflow-auditruns 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-*.yamlpinsmax-sixty/tendbelow0.1.19, the release that pins instruction files by glob at any depth. The revert list is upstream code, so the protection this repo gets is whatever the pinned version implements — a downgrade reopens the fork-PR instruction-injection path with no visible change to any file here except a version number. -
FAIL IF any
tend-*.yamlworkflow uses an unpinned action reference (e.g.@main, no version). Tag pins are accepted insidetend-*.yamlbecause 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_TOKENpermissions beyondcontents: write,pull-requests: write,issues: write,id-token: write,actions: read, or anyreadpermission. Effective, not declared: a job with nopermissions: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_permissionsfor this repository is notread, orcan_approve_pull_request_reviewsis notfalse(gh api repos/diffplug/dormouse/actions/permissions/workflow). This is the backstop for every permission bullet in this document: with the default atwrite, one regenerated workflow that omits apermissions: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.
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.ymlis missing thevscode-extension-publishenvironment on the VS Code publish job, or ifVSCE_PAT/OVSX_PATare referenced anywhere under.github/workflows/**from a job not bound to that environment. The second clause is repo-wide on purpose: scoping it torelease.ymlwould let a reference from another workflow file pass unremarked. - FAIL IF
.github/workflows/release.ymluses production desktop signing secrets in CI, or stops generating an ephemeral Tauri updater key for unsigned CI artifacts.
Desktop releases are not fully automated. GitHub Actions builds unsigned artifacts, publishes attestations and hash manifests, and uploads those unsigned artifacts for local release signing. Final desktop deployment is manual through scripts/sign-and-deploy.sh. Before signing, the script verifies the CI artifact attestations and the recorded SHA-256 hashes. The local machine then performs platform signing and uploads the final release assets. Windows Authenticode signing requires a physical YubiKey and the signing PIN. macOS signing and notarization also happen locally, outside GitHub Actions. CI must not have the production Tauri updater private key; CI uses only an ephemeral updater key so Tauri emits updater-shaped unsigned artifacts. Tauri updater signing is applied locally after OS signing so the updater signs the final release bundles that users will download.
Signing credentials and argv. Three secrets reach scripts/sign-and-deploy.sh through the environment, and argv is readable via ps by any process on the machine for the lifetime of a call — which matters more than usual here, since pnpm exec means a dependency's lifecycle scripts share that session. One of the three is now env-only; two are on a command line because their tools offer nowhere else to put them:
-
TAURI_SIGNING_PRIVATE_KEY— env-only.tauri signer signdocuments--private-keyas falling back to that variable, so passing both was redundant exposure. -
EV_SIGN_PIN— on argv.jsignreads--storepassonly as a literal option value, with no environment or file indirection. Bounded: localpsfor the duration of one call, for a PIN inert without the physical YubiKey it unlocks. -
APPLE_SIGN_PASS— on argv, and the weakest of the three.xcrun notarytooloffers no environment form either, but unlike the PIN this is a standalone credential, and--wait --timeout 30mholds it on the command line for up to half an hour per architecture. The documented remedy isnotarytool store-credentialsplus--keychain-profile, which moves the exposure to one short call instead of every submission. Not yet done — it changes the release runbook and cannot be exercised without live Apple credentials. -
FAIL IF
scripts/sign-and-deploy.shstops doing any of three things: verifying GitHub artifact attestations, verifying artifact SHA-256 manifests, or using PIV-backed Windows signing. -
FAIL IF
TAURI_SIGNING_PRIVATE_KEYis passed on a command line anywhere inscripts/sign-and-deploy.shrather than through the environment.jsign --storepassis the one documented exception, for the reason above.
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-reportingmust reportenabled: 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.
The security-audit workflow at .github/workflows/security-audit.yaml enforces this document. It runs nightly and is a required dependency of the VS Code publish job in release.yml, so no release ships without a passing audit. The audit reads SECURITY.md, executes each FAIL IF as a mechanical check, and also does a qualitative pass for security holes the specs don't cover.
The audit is fanned out to three subagents with disjoint scopes, and the orchestrator audits nothing itself — it spawns them concurrently and merges what they return. The domains are supply-chain (Dependency Supply Chain), ci-and-secrets (GitHub Actions Policies, Automated Maintainer (tend), both release sections, Reporting a Vulnerability, and this one), and application-security (Remote Control). The split is not about parallelism. These are different subject matters with different evidence — dependency provenance is lockfiles, CI is gh api output, and application security is reading the pairing code adversarially — and one context holding all three degrades the third, which is the newest, has the most code behind it, and is the easiest to crowd out with API responses. The separation is one of context, not of credential: AUDIT_PAT is a step-level env: on the one job, so every subagent inherits it in its process environment, and only the prompt tells the application-security agent not to use it. A prompt is not a control. Making that separation real would take a second job without the security-audit environment, passing fragments between jobs as artifacts — worth doing, not done. Until then the honest claim is that three contexts each read less, not that any of them holds less.
The domains do not all run on the same model. supply-chain and ci-and-secrets are mechanical — run a generator, read an API response, compare a pin — and the session default handles them. application-security reads code adversarially, and it is where the findings that needed real reasoning have come from: tracing a relay-minted clientId to a keystroke-injection path, or working out that an eight-character device fingerprint carried ~40 bits rather than ~48 because a P-256 point's leading byte is constant. It runs on Opus, declared per-agent in --agents, so the cost lands on the one domain that has depth to find rather than on all three. scripts/security-audit-local.sh applies the same split, and pins both sides rather than only the strong one. Leaving the mechanical domains unpinned inherits whatever the operator's own default is, which is not necessarily weaker — on a machine defaulting to opus[1m] it is stronger, which inverts the relation and quietly turns the local loop into something other than a rehearsal of the nightly. CI gets this for free, since its session default is Sonnet and only one agent carries an override.
Each subagent writes its own report fragment (audit-supply-chain.md, audit-ci-secrets.md, audit-application.md) before returning its verdict, and the orchestrator concatenates those files rather than retyping them. Fragments are uploaded with the transcript, so an orchestrator that dies mid-merge still ships whatever the domains found — the INCONCLUSIVE shape below, which the archive exists to explain.
The prompts live in .github/audit/, not inline in the workflow, and that placement is load-bearing three times over. scripts/security-audit-local.sh runs the audit against the same files CI uses, so the loop that catches problems in this document is a local one and cannot drift from the nightly. Prompt changes get reviewed as ordinary markdown diffs rather than as YAML block-scalar churn. And the section-ownership rule below is only a grep because the ## headings sit in markdown — inline, block-scalar wrapping split ## Automated Maintainer (tend) across two lines and the check silently matched nothing.
Subagents launch in the background, which is the trap that produced three INCONCLUSIVE runs. The Task tool returns an id, not a report, so an orchestrator that ends its turn to await a completion notification ends the session — this is one headless run and nothing resumes it. Run 32618922852 passed all 21 mechanical checks that way and produced no verdict at all. The fix is not to stop delegating: it is to never end the turn. The orchestrator blocks in a Bash until loop on the fragment files, re-issuing it when a single call hits the ten-minute Bash cap, until every fragment exists or a 25-minute deadline passes. --allowed-tools is not what enforces this — it only auto-approves and removes nothing, which is why the tools were available in the first place.
FAIL IF lines are grouped by the operation that answers them: one bullet may assert several properties when a single API call, file read, or script run establishes all of them. The grouping is presentation only — every clause remains an independent check, and the report records each with its own PASS/FAIL and its own evidence. A bullet is never satisfied in bulk. On any FAIL IF violation or BLOCKER-severity finding, the workflow opens (or updates) an issue labeled security-audit-failure 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 allows Task/Agent — the fan-out above depends on them — and denies only Workflow, which nothing here should be spawning. No allowlist can prevent the actual failure, since --allowed-tools only auto-approves and removes nothing: what discards the subagents' work is ending the turn to await them, so the control is the orchestrator prompt's non-turn-ending wait, and the FAIL IF below that requires it. The prompt splits the two output files along the fail-closed line: audit-report.md is always written before the turn ends — partial, if the agent runs short — while audit-status.txt is written only once the verdict covers every check. Partial has two shapes, and the INCONCLUSIVE issue names both: a check the agent reached but could not determine is marked UNVERIFIABLE within its domain's fragment, while a domain that never reported at all — an expired wait deadline, or a subagent that died — renders as a _No report …_ placeholder in place of that domain's section. Its ## Summary may likewise read INCONCLUSIVE rather than PASS or FAIL, since the whole point of withholding the status file is that no verdict covers every domain. A partial audit therefore still reaches a human (the reporting step reproduces the partial report in the INCONCLUSIVE issue) without a PASS on unrun checks closing the failure issue and opening the release gate.
Every run uploads the agent's SDK transcript as the audit-transcript artifact (14-day retention), and failure issues deep-link it. Without it a run that produces no verdict is undiagnosable: claude-code-action keeps tool output out of the step log on purpose, and the runner is ephemeral. Because this repository is public the artifact is world-readable, which is consistent with the audit reports already posted to public issues — but note that artifact contents are not secret-masked the way logs are, so no step may ever print $AUDIT_PAT or $CLAUDE_CODE_OAUTH_TOKEN. The prompt passes the PAT only through an unexpanded GH_TOKEN= prefix, and gh api responses never carry secret values.
The audit job declares environment: security-audit, whose deployment-branch-policy admits only main and v* tags. Both ref classes are admin-only by the rulesets in Automated Maintainer (tend), so a write-scoped bot cannot reach the env's secrets (most importantly AUDIT_PAT, when provisioned) by pushing a workflow file to a feature branch.
As a consequence of that env-gating, audit changes are iterated on main directly. A workflow_dispatch from any other ref is rejected by the environment's deployment-policy before any step runs. To experiment on a branch, widen the env's policy temporarily and revert after.
AUDIT_PAT is required. A dedicated step verifies the secret is present before the audit step runs — after the checkout and install, not literally first — and refuses to continue otherwise — without it the audit cannot read the administration endpoints needed to verify ruleset bypass actors, repo-level secret listing, and environment policies, so the spec it claims to enforce would be unenforceable in its key sections. Mint a fine-grained PAT on an admin's account with read-only Administration + Secrets + Environments scoped to diffplug/dormouse only, then store it env-scoped:
gh secret set AUDIT_PAT --env security-audit --repo diffplug/dormouse --body 'github_pat_…'- FAIL IF
.github/workflows/security-audit.yamlis missing or disabled, or if any of the three separate things that make it a release gate is gone: thegh workflow rundispatch, thegh run watch --exit-statusthat turns a failed audit into a failed job, andpublish-vscode'sneeds:edge on that job. They break independently — dropping--exit-statusalone 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
untilloop over the fragment files, re-issued past the ten-minute Bash cap, under a bounded deadline that is persisted to a file rather than recomputed fromnow. A deadline longer than the ten-minute cap cannot fire inside one call, so a re-issued loop that recomputes it never reaches it: the bound is then written down but never binds, and the only thing ending the wait is the runner's cancellation. Delegating is safe; ending the turn to wait is what kills the run, and no tool allowlist prevents it. - FAIL IF
.github/audit/is missing a prompt file the workflow names, orscripts/security-audit-local.shstops running the audit from those same files. A local runner with its own copy of the prompts is worse than no local runner: it drifts, and the drift is invisible until a nightly disagrees with a local pass. - FAIL IF
.github/audit/or.vscode/is outside every consumer ofworkflow-audit.yaml's diff window — the commit list,own_changes, and both classifiers' refusals. Widening one without the others is worse than not widening at all:git logmatches the commit,own_changesreturns nothing for it, the empty-listcontinueswallows it, and this bullet then claims a coverage that does not exist. OneWINDOWarray is the reason they cannot drift: the classifiers' half is derived from it ("${WINDOW[@]:1}"), not written out a second time, so adding a path cannot reach the commit list while missing the refusal. The prompts decide what gets audited and by whom;.vscode/tasks.jsoncan execute on folder open. Both are changes to the security automation, which is the reason.config/tend.yamlis in that window. This document is deliberately not watched there: what that job catches is code executing from a branch nobody reviewed, and aFAIL IFis inert until it is merged tomain, which is admin-gated — so the watch would add no coverage over PR review while reporting a commit on nearly every security PR. - FAIL IF a
##section of this document is in no subagent's scope, or is in two. Every section is owned by exactly one domain: a section owned by none is unaudited, and one owned by two produces contradictory verdicts. Each domain file in.github/audit/names its sections as exact##headings on their own lines, so this is a real grep over four markdown files rather than a reading of prose embedded in YAML. - FAIL IF the union of the subagents' qualitative scopes does not cover every top-level path in the repository. The per-domain scopes replaced a single roving "flag any other security hole you find", so anything no domain names is now nobody's job — and the first version of this split silently orphaned
canopy/,.claude/(named as prompt-injection surface two sections above),docs/, the root files, and all ofwebsite/outsidesrc/data/, which includes the Tauri updater manifest that shipped apps fetch. The division is by subtraction, so that adding a directory cannot orphan it:ci-and-secrets—.github/(including.github/audit/),.config/,.claude/,.vscode/,scripts/, andwebsite/public/. The updater manifest is a release artifact, not marketing;.vscode/is here becausetasks.jsoncan carry"runOn": "folderOpen", which executes on checkout.supply-chain— the dependency graph, the lockfile, and all ofwebsite/exceptwebsite/public/. Stated as a subtraction rather than as named subdirectories, because namingsrc/andscripts/leftwebsite/'s own build config owned by nobody — the same orphaning shape one level down.generate-deps.jsis in there, so the generator behind the disclosed snapshot is audited, not just theproductDependencyFiltersarray a bullet above names.application-security— everything else, worked out fromls -Arather than from a list, including.impeccable/(the design-token snapshot behindDESIGN.md). Dotfile directories are named explicitly wherever they land, in this list and in the prompt files, because a catch-all has twice now been read as covering them when no reader could tell which domain owned one. An enumeration here goes stale the moment a path is added, which is exactly how.vscode/and.impeccable/came to be owned by nobody after the first version of this split named paths explicitly. The subtraction is recursive: where another domain claims a subdirectory rather than a whole tree — as both do insidewebsite/— the remainder of that tree belongs here, or the same orphaning recurs one level down.
- FAIL IF the
Redact secrets from agent outputstep 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 accidentalprintenvand a world-readable artifact, and until this bullet existed nothing would have tripped on its deletion. - FAIL IF
application-securitydoes not run on a stronger model than the mechanical domains, in both.github/workflows/security-audit.yaml's--agentsandscripts/security-audit-local.sh. A local run that silently uses a weaker model than the nightly makes the local loop — the one that catches problems before they merge — worse than the thing it is standing in for. - FAIL IF the reporting step writes issue prose per combination of conditions rather than one note per condition that holds. Four consecutive review rounds found the same defect in different clothes — an arm whose text was true only of the states that could reach it, made false by the next gate that widened. Prose proportional to combinations cannot be kept correct by fixing combinations; a note that claims nothing about the other conditions cannot be invalidated by a new one.
- FAIL IF either fragment guard is gated on the status at all. Recording what is true of a run and deciding its verdict are separate jobs, and gating the first on the second produced this defect three times in different clothes: gated on
PASS, one empty fragment silenced the dissent check; widened to!= FAIL, an orchestrator that wroteFAILitself silenced both, so a domain that left no report beside a real finding appeared nowhere at all. Both loops run unconditionally and only record;STATUSis assigned in exactly two places, where the status file is parsed and in the single escalation block. That block encodes the ordering —FAIL(a domain found something) outranksMISSING(the audit did not finish) outranksPASS— so a dissent can raiseMISSINGtoFAILand never the reverse, and aFAILarriving alongside missing or unreadable fragments still reports them. - FAIL IF a fragment's first line is not
VERDICT: PASSorVERDICT: FAIL, or the reporting step stops downgrading a mergedPASSthat contradicts one — or stops treating a fragment with no readable verdict as inconclusive. Three cases, not two: a fragment the check cannot read must not fall through to an unchallengedPASS, because that puts the verdict back on a prompt having been followed, which is the thing this guard exists to stop being the control. Existence is not agreement: the missing-fragment guard catches a domain that produced nothing, and this catches one whoseFAILthe merge lost — which is worse, becausePASScloses the open failure issue and opens the release gate. - FAIL IF the orchestrator can report
PASSwhile a subagent left no report fragment. A domain that dies silently must not pass the audit — a missing fragment is indistinguishable from a domain that found nothing, and only one of those is safe to publish a release on. It must not be published asFAILeither, unless some domain actually returned one: the prompt writes no status file when a fragment is missing and no domain failed, which routes an audit that ran out of time to the INCONCLUSIVE issue rather than filing it as a security finding and relabelling an open issue up toFAIL. Both outcomes exit non-zero and hold the release gate shut, so the distinction costs nothing and is the whole reason there are three of them. - FAIL IF the audit has been weakened in any other way — e.g. the prompt no longer requires the qualitative pass, a
FAIL IFcan be ignored, the failure-reporting step that opens asecurity-audit-failureissue and exits non-zero has been removed, or theAUDIT_PATpre-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.