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. 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. The dependency graph and release pipeline is part of the product's security boundary.

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 shipped in the end-user application is listed at https://dormouse.sh/supply-chain. 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

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.

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 from a clean checkout.
  • 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).
  • FAIL IF the build-standalone job in .github/workflows/release.yml does not install the pinned runtime by reading devEngines.runtime.version from package.json and passing it to actions/setup-node (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 always pinned by commit hash, not version tag. Renovate will update the hashes as necessary.

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 grants 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).

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 no local remedy: the workflow is regenerated nightly, so a hand-edit does not survive. 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 pinned by commit SHA (max-sixty/tend@<sha>) in every generated workflow, so silent updates to the running setup are not possible. uvx tend@latest runs only at install and during nightly regen; a compromise of that path would affect 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.

  • 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 a permission higher than push on this repository.
  • 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), 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).
    • 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.
  • 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 agent-managed workflow grants a permission beyond contents: write, pull-requests: write, issues: write, id-token: write, actions: read, or any read permission.

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.

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.

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

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 §3's rulesets, 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. The audit's first step verifies the secret is present and refuses to run 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, disabled, or no longer invoked from release.yml's publish path.
  • FAIL IF the audit has been weakened — 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.

There aren't any published security advisories