- Owns the posture of GitHub Actions, the bot maintainer, and the two release paths — what each identity can reach and what stays admin-gated.
- Defers the release procedure and the secrets table to
docs/specs/deploy.md-> "Environment / secrets", and the audit machinery todocs/specs/security-audit.md.- Read
docs/specs/security.mdfirst;docs/specs/security-audit.mdsays how theFAIL IFlines here are run.
| Guarantee | Feature | Pinned by |
|---|---|---|
Merging to main and creating a tag are admin-only, except the hosted/ tag a dedicated App records after a reviewed Hosted deploy, and every workflow this repository authors pins its actions by commit. |
scripts/github-state-check.mjs, scripts/workflow-lint.mjs |
Renovate updates the commit pins; the generated tend-*.yaml pin max-sixty/tend/claude and max-sixty/tend/setup-runtime by version tag (see "Upstream compromise").
Agent-managed workflows are tend-*.yaml, .github/workflows/workflow-audit.yaml, and .github/workflows/security-audit.yaml. They are exempt from the pull_request_target and write-permission rules below because they must modify issues, PRs, or code; "Automated Maintainer (tend)" bounds their scope.
Release audit dispatch. The security-audit job in .github/workflows/release.yml holds actions: write to dispatch security-audit.yaml on the release tag and watch that run, gating the VS Code publish on the result (rationale). That token can start or cancel workflow runs in this repo, but cannot reach env-scoped secrets, merge to main, or push tags, and release.yml runs only on admin-gated v* tags.
- FAIL IF
S41a workflow under.github/workflows/references an action by anything but a commit hash, exceptmax-sixty/tend/claudeandmax-sixty/tend/setup-runtimeintend-*.yaml. Pinned byscripts/workflow-lint.mjs. - FAIL IF
S42.github/workflows/release.ymlruns on anything but a pushedv*tag. Pinned byscripts/workflow-lint.mjs. - FAIL IF
S43thesecurity-auditjob in.github/workflows/release.ymluses itsactions: writefor anything but dispatchingsecurity-audit.yamlon the release tag and watching that run. Pinned byscripts/workflow-lint.mjs. - FAIL IF
S44pull_request_targetappears as anon:trigger in any.github/workflows/**file other thantend-*.yaml. A mention in a comment is not a violation. Pinned byscripts/workflow-lint.mjs. - FAIL IF
S45a non-agent-managed workflow has effective write permissions other than the release provenance permissionsid-token: writeandattestations: write, theactions: writegranted to thesecurity-auditjob inrelease.yml(see "Release audit dispatch"), theissues: writegranted to thepublishjob incodex-audit-report.yaml(docs/specs/security-audit.md-> "Second-opinion audit"), or theissues: writegranted to theopenjob inred-team-issue.yaml(docs/specs/security-audit.md-> "Red team"). Effective as in the agent-managed bullet below. Pinned byscripts/workflow-lint.mjs.
.github/workflows/ci.yml also runs actionlint and zizmor over every workflow; .github/zizmor.yml names each exception it accepts.
| Guarantee | Feature | Pinned by |
|---|---|---|
| The bot maintainer cannot merge, tag, or read a release secret, and its token never enters its own environment. | .github/workflows/workflow-audit.yaml, nightly |
The tend agent harness runs as the GitHub user dormouse-bot: it reviews PRs, triages issues, fixes CI failures, regenerates its own workflow files nightly, responds to mentions, and polls its notification feed. A prompt injection in that harness reaches the secrets below, and none escalates directly into malicious content on main or into any deployment-related secret — those paths stay admin-gated.
| Secret | What a compromise buys | What bounds it |
|---|---|---|
TEND_BOT_TOKEN (worst case) |
full repo + workflow write as a trusted collaborator: issue/PR spam, force-pushing or deleting feature branches, persistent compromise by authoring new workflows — also how the repo-level secrets are reached |
cannot itself merge to main, push tags, or reach env-scoped secrets; new workflows are caught by .github/workflows/workflow-audit.yaml; the trusted identity can still social-engineer an admin toward a main merge |
CLAUDE_CODE_OAUTH_TOKEN |
Anthropic API-credit abuse | the bot account's spend limit |
ARGOS_TOKEN, the unused CHROMATIC_PROJECT_TOKEN |
corrupted snapshot testing, a replaced Storybook deploy | rotation; abuse is visible in each service's own dashboard |
A prompt injection can push a workflow that sends a repo-level secret to an external URL: the harness reads PR descriptions, diffs, issue text, comments, and CI logs, all attacker-influenceable; admin-gated release paths stay sealed, but a workflow on a bot-pushed feature branch executes with repo-level secrets in scope.
A fork PR's tree reaches the agent's copy-on-write view while the privileged pull_request_target workspace holds the base tree, so tend's shared/steps/restore-sensitive-config.sh reverts the project-instruction paths (CLAUDE.md, AGENTS.md, .claude/, .mcp.json) from the reviewed base branch before the agent starts; that control's completeness is a property of the pinned upstream version (rationale).
Trusted runner steps receive the real TEND_BOT_TOKEN and Anthropic credentials and provision the credential-injecting proxy; setup strips the checkout credential before the separate, non-sudo agent starts (rationale).
Must limit .config/tend.yaml's setup: to node scripts/setup-git.mjs on the base-tree checkout, which runs with those credentials present: it uses Node built-ins only, and the md-sentences merge driver it defines executes PR code only inside the agent's own merges, since no trusted runner step merges, rebases, or cherry-picks.
Bot collaborator authority. dormouse-bot is a direct repo collaborator with push permission and org-enforced 2FA; TEND_BOT_TOKEN carries the scopes repo, workflow, notifications, write:discussion, gist, and user. workflow, required to regenerate tend-*.yaml, also lets the harness add arbitrary workflow files. Ref-protection rulesets restrict where bot commits land but do not gate workflow execution on feature branches.
The notifications poll widens its input. tend-notifications.yaml alone takes its subjects from the bot's own unread feed, and its pre-check re-subscribes the bot to all repository activity (PUT /repos/diffplug/dormouse/subscription) on every poll; its prompt decides whether to respond (rationale). On an undispatched thread the bot is still bounded by author_association tiering and the admin gate on main. Unwatching by hand does not stick: the lever is tend-notifications.yaml, not the Unwatch button.
Reachable repo-level secrets. Every repo-level secret is reachable by any workflow the bot can author: .github/workflows/argos.yml is pull_request-triggered, and environment policies cannot tell a bot from a human contributor at the ref level. Accepted for ARGOS_TOKEN and the unused CHROMATIC_PROJECT_TOKEN alone, each scoped to one project (docs/specs/security.md -> "What is not defended"); any other repo-level secret needs its own acceptance there. OVSX_PAT and VSCE_PAT live only in the vscode-extension-publish environment, which admits only admin-created v* tags.
No ANTHROPIC_API_KEY secret exists. Every generated tend-*.yaml that runs max-sixty/tend/claude passes it anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}; with no such secret it resolves empty and the harness uses CLAUDE_CODE_OAUTH_TOKEN. The input cannot be deleted locally, so the inventory FAIL IF below makes adding one a deliberate expansion of the bot's reach (rationale).
Org-level secrets. An org secret shared with this repo is reachable exactly like a repo-level one but absent from this repo's own listing — gh api repos/diffplug/dormouse/actions/organization-secrets is the check. None are visible today (rationale); the inventory FAIL IF admits none, so one that becomes visible fails until it is evaluated and named.
Upstream compromise. Generated workflows reference max-sixty/tend/claude@<version> and max-sixty/tend/setup-runtime@<version> — mutable tags, so upstream can change what our workflows execute with no commit here and workflow-audit.yaml seeing a byte-identical file (docs/specs/security.md -> "What is not defended"; rationale). The version pin bounds deliberate upgrades, not a hostile upstream; uvx tend@latest runs only at install and nightly regen, so a compromise of that path affects the next re-run, not in-flight workflows. max-sixty/tend/setup-runtime installs the uv behind the tend runtime steps that hold TEND_BOT_TOKEN, checking its download against a checksum committed at that tag.
Audit visibility. .github/workflows/workflow-audit.yaml walks nightly every commit touching .github/workflows/, .config/tend.yaml, .github/audit/, .vscode/, or the markdown merge driver (.gitattributes, scripts/setup-git.mjs, scripts/md-merge.mjs, scripts/md-unwrap.mjs) since its previous successful scheduled run on main, across all branches, so a workflow pushed to a feature branch is seen without a PR. (rationale) It reports the unexplained, classifying out routine sources on independently checked provenance or content; each source's residual is docs/specs/security.md -> "What is not defended":
| Source | Explained when |
|---|---|
| Renovate pin bump | GitHub-signed, authored renovate[bot], committed web-flow, associated only with Renovate PRs, changing only the ref of an already-referenced action (rationale) |
| tend regeneration | byte-for-byte reproducible from uvx tend@<version> init at the files' own header version, without touching .config/tend.yaml (rationale) |
| clean merge | two-parent merge whose window paths equal git merge-tree of its parents |
| admin push | the earliest ref update containing it in the repository activity log (last quarter, server-set timestamps) is an admin's push or branch_creation, or an admin's force_push whose replaced window commits are each admin-introduced; and no author or committer field names a bot (rationale) |
- FAIL IF
S46WINDOWin.github/workflows/workflow-audit.yamlnames other paths than those above, or the walk stops covering all branches (rationale).WINDOWpinned byscripts/workflow-lint.mjs. - FAIL IF
S47an author or committer field the commit's GitHub signature does not bind explains a commit:TEND_BOT_TOKENis the credential in question, so the pusher GitHub records, or aweb-flowsignature binding the author, is evidence, and self-declared identity only ever refuses. - FAIL IF
S48the audit stops reporting a commit whose earliest retained introduction is a PR merge (the admin reviewing, not pushing), a commit already merged tomain(rationale), or one a classifier cannot decide — a failed API or Git call, a missing activity-log entry, or an activity row whose tip is lost unless that row is an admin'spushorbranch_creation, which is skipped so the commit's next row decides — each as unexplained. - FAIL IF
S49tend regeneration materializes anything except regular.config/tend.yamland workflow YAML blobs from the audited commit. Pinned byscripts/workflow-audit.test.mjs(rationale). - FAIL IF
S50the audit deduplicates by branch or file set, which lets a later force-push of malicious content to the same files pass unremarked, or keys liveness on anything but a successful run, such as an issue existing; a silent run is the healthy steady state.
The diff window's evasions are docs/specs/security.md -> "Known gaps".
- FAIL IF
S51.github/workflows/workflow-audit.yamlderives its lower bound from anything but the server-setcreated_atof its previous successfulschedulerun onmain, or, with none listed, a fixed date before the workflow's first run, so neither a pusher-set date, a run a branch or dispatch controls, nor a deleted run can move it later (rationale). Pinned byscripts/workflow-audit.test.mjs. - FAIL IF
S52either admin-gating ruleset is missing or weakened.Merge accessmust target~DEFAULT_BRANCH, block exactlycreation,update, anddeletion, and carry admin (RepositoryRoleactor5,bypass_mode: exempt) as its sole bypass actor;Tag operationsmust target~ALLtags exceptrefs/tags/hosted/**("Hosted Deployments"), block bothcreationandupdate, and carry the same admin-only bypass. Pinned byscripts/github-state-check.mjs. - FAIL IF
S53.config/tend.yamldoes not setmerge: restricted, or anytend-*.yamlpasses anothermerge:; tend'syolomode makes the bot aMerge accessbypass actor. Pinned byscripts/workflow-lint.mjs. - FAIL IF
S54dormouse-botholdsmaintainoradminon this repository.GET /collaborators/dormouse-bot/permissionspellspushaswritein bothpermissionandrole_name, so check that neither of those two roles appears rather than string-comparing againstpush. Pinned byscripts/github-state-check.mjs. - FAIL IF
S55any GitHub environment excepthosted-previewadmits, by its deployment-branch policy, a ref that is not admin-gated by theTag operationsorMerge accessrulesets; an environment'scan_admins_bypassis not judged here. Hosted environments follow "Hosted Deployments" below. Today:vscode-extension-publishandrelease-attest(v*tag, admin-only viaTag operations);security-auditandblyg-publish(mainadmin-only viaMerge access, plusv*tag);tend(mainonly, admin-only viaMerge access). An environment.github/audit/expected-github-state.jsondoes not name fails until it is evaluated and named there (rationale). Pinned byscripts/github-state-check.mjs. - FAIL IF
S56the secret inventory departs from this placement (rationale). It is exhaustive: a secret at repo, org, or environment level that no line names fails. One pass overactions/secrets,actions/organization-secrets, and each environment's secret listing answers every line:ARGOS_TOKENandCHROMATIC_PROJECT_TOKEN— repo level, the only secrets there; accepted with rotation (see "Reachable repo-level secrets"). No workflow readsCHROMATIC_PROJECT_TOKENsince Chromatic left CI; its absence is no violation.AUDIT_PAT— insecurity-audit, absent at repo level.EMBARGO_TOKEN— insecurity-audit, absent at repo level; read only by the stepsdocs/specs/security-audit.md-> "Embargo" names.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.BLYG_API_TOKEN— inblyg-publishonly, absent at repo level; Studio grants read, draft and publish access, expiring after 30 days. The environment admits onlymainandv*tags.CLOUDFLARE_API_TOKEN,NEON_API_KEY,PREVIEW_AUTH_SECRET— inhosted-preview;CLOUDFLARE_API_TOKEN,DATABASE_URL,BACKUP_AGE_IDENTITY— inhosted-production;HOSTED_TAG_APP_PRIVATE_KEY— inhosted-release-tag. Each absent at repo level; the twoCLOUDFLARE_API_TOKENs are separate values.ANTHROPIC_API_KEY— absent at repo and org level, for as long astend-*.yamlpassesanthropic_api_keytomax-sixty/tend/claude.release-attest's own secret listing is empty and it declares no environment variables, soid-token: writestays the only credentialrelease.yml's two build jobs can reach.- No org-level secret visible to this repository at all (see "Org-level secrets").
- Pinned by
scripts/github-state-check.mjs.
- FAIL IF
S57a repo-level secret is missing fromsecrets.allowedin.config/tend.yaml(rationale). Pinned byscripts/github-state-check.mjs. - FAIL IF
S58.github/workflows/workflow-audit.yamlis missing, disabled, or has not produced a successfulschedulerun onmainin the last 48 hours. One skipped run inside that window passes and is reported as INFO, a signal rather than slack (rationale). Pinned byscripts/github-state-check.mjs. - FAIL IF
S59Renovate'sgithub-actionsmanager can update.github/workflows/tend-*.yaml; the tend generator owns every dependency pin (rationale). Pinned byscripts/workflow-lint.mjs. - FAIL IF
S60anytend-*.yamlpinsmax-sixty/tendbelow0.1.19, the release that pins instruction files by glob at any depth (rationale). Pinned byscripts/workflow-lint.mjs. - FAIL IF
S61the pinnedmax-sixty/tend/claudestarts the agent on a workflow's fork-PR checkout before reverting the project-instruction paths, or hands the agent's environment, disk, or.git/configthe realTEND_BOT_TOKENor an Anthropic credential; or atend-*.yamlsets workflow-levelenv:or a job-levelenv:holding anything butmatrixvalues, which the harness forwards to the agent, or writes a secret to$GITHUB_ENV. Read the action at the tag the workflows pin:claude/action.yamland the shared steps it runs for checkout ordering, instruction pinning, and sandbox setup (rationale). A fork PR the agent checks out itself isdocs/specs/security.md-> "Known gaps". Theenv:and$GITHUB_ENVhalves pinned byscripts/workflow-lint.mjs. - FAIL IF
S62anytend-*.yamlreferencesmax-sixty/tend/claudeormax-sixty/tend/setup-runtimeunpinned (e.g.@main, no version). Every other action reference is "GitHub Actions Policies"'s. Pinned byscripts/workflow-lint.mjs. - FAIL IF
S63any job in an agent-managed workflow has effectiveGITHUB_TOKENpermissions beyondcontents: write,pull-requests: write,issues: write,actions: read, or anyreadpermission; or runsanthropics/claude-code-actionwithoutgithub_token, which hands its agent the Claude GitHub App's token. Effective, not declared: apply job permissions over workflow permissions over the repository default; omitted scopes in an explicit block becomenone(rationale). Pinned byscripts/workflow-lint.mjs. - FAIL IF
S64default_workflow_permissionsfor this repository is notread, orcan_approve_pull_request_reviewsis notfalse(gh api repos/diffplug/dormouse/actions/permissions/workflow) — the backstop for every permission bullet in this spec (rationale). Pinned byscripts/github-state-check.mjs.
Source of truth: packageRules in .github/renovate.json; .github/workflows/workflow-audit.yaml.
| Guarantee | Feature | Pinned by |
|---|---|---|
Hosted runs only code from main, and a production deploy needs a maintainer's approval. |
One-time link, Hosted phone | scripts/github-state-check.mjs |
Hosted credentials live only in the three Hosted environments the secret inventory above places them in. hosted-production and hosted-release-tag admit only main; hosted-preview admits only main and refs/pull/*/merge. hosted-preview and hosted-production require Ned or Edgar's review with administrator bypass disabled; self-review is allowed. Preview approval authorizes the PR code to receive test-resource credentials only. hosted-release-tag requires no review: approving the deploy approves its tag, which runs only after that deploy's live verification succeeds, so a hosted/ tag records what is live.
- FAIL IF
S65a Hosted environment lacks those branch restrictions, orhosted-previeworhosted-productionlacks required reviewers or disabled administrator bypass. Pinned byscripts/github-state-check.mjs. - FAIL IF
S66Hosted credentials appear at repository/org scope, or production credentials appear inhosted-preview. Pinned byscripts/github-state-check.mjs. - FAIL IF
S67hosted-release-tagis used by a job other thantagin.github/workflows/hosted-production.yml, a workflow readsHOSTED_TAG_TOKEN, ortaghandshosted/scripts/production-tag.mjsany credential but the token itsactions/create-github-app-tokenstep mints withowner: diffplug,repositories: dormouse, and onlypermission-contents: write(rationale). Pinned byscripts/workflow-lint.mjs.
The App itself holds only contents: write and metadata: read and subscribes to no event. No audit run can read that: the App is private, and listing its installation takes an organization administrator (rationale; ## Future -> Tagger App audit).
- FAIL IF
S68the App (Integrationactor5228264) bypasses any ruleset butHosted tag creation, which must target onlyrefs/tags/hosted/**, block onlycreation, and be bypassed otherwise only by admin; orHosted tag historystops blockingupdateanddeletiononrefs/tags/hosted/**with an admin-only bypass. A leaked key addshosted/tags, never moves or deletes one. Pinned byscripts/github-state-check.mjs. - FAIL IF
S69a Hosted preview deploy accepts a fork or a failing verification, or a Hosted production tag can run before live verification succeeds; inspect the workflow dependency/condition graph.
Source of truth: hosted/scripts/setup-github.mjs; .github/workflows/hosted-preview.yml; .github/workflows/hosted-production.yml.
| Guarantee | Feature | Pinned by |
|---|---|---|
| Publishing the extension takes a second account's approval. | scripts/github-state-check.mjs |
GitHub Actions publishes the extension only after human approval from an account other than the triggering account, with administrator bypass disabled; VSCE_PAT and OVSX_PAT live only in that protected environment.
- FAIL IF
S70vscode-extension-publishlacks nonempty required reviewers,prevent_self_review: true, orcan_admins_bypass: false. Pinned byscripts/github-state-check.mjs. - FAIL IF
S71.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 (rationale). Pinned byscripts/workflow-lint.mjs. - FAIL IF
S72.github/workflows/release.ymluses production desktop signing secrets in CI, or stops generating an ephemeral Tauri updater key for unsigned CI artifacts. Pinned byscripts/workflow-lint.mjs.
| Guarantee | Feature | Pinned by |
|---|---|---|
Desktop binaries are signed locally, and an installed app takes an update only under the one key it shipped trusting. CI never holds production signing or updater keys, the signing script verifies CI's attestations and hashes first, and the updater asks only dormouse.sh over HTTPS. |
scripts/sign-and-deploy.test.mjs, standalone/scripts/updater-trust-root.test.mjs |
Production signing is local, never in CI. GitHub Actions builds unsigned artifacts, publishes attestations and hash manifests, and uploads them; scripts/sign-and-deploy.sh verifies the CI artifact attestations and the recorded SHA-256 hashes before signing, then signs each platform locally (Windows Authenticode needs a physical YubiKey and the signing PIN; macOS signing and notarization run locally too) and uploads the release assets. CI never holds the production Tauri updater private key: it uses an ephemeral key, and Tauri updater signing is applied locally after OS signing, so the updater signs the bundles users download. Procedure: docs/specs/deploy.md -> "Two-stage pipeline".
Signing credentials and argv. Three secrets reach scripts/sign-and-deploy.sh through the environment; argv is readable via ps by any process on the machine for the lifetime of a call (rationale).
| Secret | Where it travels | What bounds it |
|---|---|---|
TAURI_SIGNING_PRIVATE_KEY |
env-only | — |
EV_SIGN_PIN |
env-only (jsign --storepass env:EV_SIGN_PIN) |
the physical YubiKey |
APPLE_SIGN_PASS |
argv — xcrun notarytool offers no environment form |
none (docs/specs/security.md -> "Known gaps") |
Its remedy is staged under ## Future → Notarization credentials.
- FAIL IF
S73scripts/sign-and-deploy.shstops doing any of three things: verifying GitHub artifact attestations, verifying artifact SHA-256 manifests, or using PIV-backed Windows signing. Pinned byscripts/sign-and-deploy.test.mjs. - FAIL IF
S74TAURI_SIGNING_PRIVATE_KEYis passed on a command line anywhere inscripts/sign-and-deploy.shrather than through the environment, orEV_SIGN_PINis passed literally tojsign --storepassinstead of by environment-variable reference. - FAIL IF
S75plugins.updaterinstandalone/src-tauri/tauri.conf.jsontrusts any key but minisignAC5A7E8D541A64DB, asks any endpoint buthttps://dormouse.sh/standalone-latest.json, or setsdangerousInsecureTransportProtocol; atauri.<platform>.conf.jsonoverlay configures the updater; or a platform inwebsite/public/standalone-latest.jsonis signed under another key. Every install takes its next update on that key's word. Pinned bystandalone/scripts/updater-trust-root.test.mjs. - FAIL IF
S76standalone/src-tauri/entitlements-macos-node.plistgrants anything butallow-jit,scripts/sign-and-deploy.shsigns anything butContents/MacOS/nodewith it, or leaves a bundled Mach-O, each.nodeaddon included, unsigned by the Developer ID identity before the outer.app(rationale). Pinned bystandalone/scripts/macos-entitlements.test.mjsandscripts/sign-and-deploy.test.mjs.
Scope: notarization-profile — Notarization credentials.
Scope: tagger-app-audit — Tagger App audit.
Use notarytool store-credentials plus --keychain-profile to move password exposure to one short provisioning call instead of every submission. Update the release runbook and verify with live Apple credentials before promotion.
Give the audit an organization-administration read — a scope on AUDIT_PAT or a token of its own in security-audit — so scripts/github-state-check.mjs reads GET /orgs/diffplug/installations and fails when the tagger App holds a permission beyond contents: write and metadata: read or subscribes to an event. Today it only warns, when an administrator's own login runs it.