Owns how the security specs are audited: the schedule and the release gate, the four domains and their prompts, the orchestration, the three outcomes, the reporting step, and the environment that holds
AUDIT_PAT. Defers what is audited todocs/specs/security.mdand the specs it names. Readdocs/specs/security.mdfirst.
.github/workflows/security-audit.yaml audits docs/specs/security.md and the specs it names: nightly at 04:21 UTC (schedule), on workflow_dispatch, and on the release tag, dispatched by .github/workflows/release.yml whose publish-vscode job needs: it — so no release ships without a passing audit. Dispatched, not uses:-called — see docs/specs/security-ci.md -> "GitHub Actions Policies".
-
Must execute every
FAIL IFas a mechanical check (gh api, grep, file read, or a script run) and add a qualitative pass for security holes the specs do not cover. -
FAIL IFlines are grouped by the operation that answers them, one bullet asserting several properties when a single call establishes all of them. Every clause stays an independent check, with its own PASS/FAIL and evidence; never satisfied in bulk. -
On any
FAIL IFviolation or BLOCKER-severity finding the audit fails; Outcomes and reporting says what that files. -
FAIL IF
.github/workflows/security-audit.yamlis missing or disabled, or 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 (rationale).
Must fan the CI audit out to four subagents, each owning a disjoint share of the tree; a domain reads outside its share — application-security runs the repo's own lints — but owns nothing there. The orchestrator audits nothing itself, spawning them concurrently and merging what they return (rationale).
Ownership is by file: every docs/specs/security*.md spec is in exactly one domain's scope, declared as backticked repo paths in the bullet list under the **Scope line of its domain file in .github/audit/, and enforced by scripts/spec-lint.mjs.
| Domain | Specs |
|---|---|
supply-chain |
docs/specs/security-supply-chain.md |
ci-and-secrets |
docs/specs/security-ci.md, docs/specs/security-audit.md, docs/specs/security.md |
application-security |
docs/specs/security-local.md, docs/specs/security-remote.md |
hosted |
docs/specs/security-hosted.md |
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, and only the prompt tells application-security and hosted not to use it. A prompt is not a control: four contexts each read less, none holds less. A known gap, staged as ## Future -> Credential separation.
Must pin the mechanical domains to Sonnet and the two code-reading domains — application-security and hosted — to Opus in CI and locally (rationale).
Must keep shared CI/local prompts and their scopes in .github/audit/ (rationale).
Must make the sequential local runner exit nonzero for a failed process, a missing or unfinished fragment, or any verdict other than exact VERDICT: PASS. It uses the operator's gh authentication when no AUDIT_PAT is supplied; inaccessible local checks are inconclusive.
The qualitative scopes are stated by subtraction, so adding a directory cannot orphan it (rationale). application-security takes the remainder, worked out from ls -A rather than from a list.
-
Dotfile directories are named explicitly wherever they land, in the prompt files as here.
-
The subtraction is recursive: where a domain claims a subdirectory rather than a whole tree — as
supply-chaindoes insidewebsite/— the remainder of that tree belongs toapplication-security. -
FAIL IF a
docs/specs/security*.mdspec is in no domain's scope, or in two, or a scope names a file that does not exist (rationale). -
FAIL IF the audit stops fanning out to a dedicated
application-securitysubagent scoped to the application specs in the Domains table, or to a dedicatedhostedsubagent scoped todocs/specs/security-hosted.md, or either scope is merged back into a context that also carries another domain (rationale). -
FAIL IF
application-securityorhosteddoes not run on a stronger model than the mechanical domains, in both.github/workflows/security-audit.yaml'sclaude_args— its--modelsets the floor and its--agentsraises those two domains — andscripts/security-audit-local.sh(rationale). -
FAIL IF
.github/audit/is missing a prompt file the workflow names, orscripts/security-audit-local.shstops running the audit from those same files (rationale). -
FAIL IF the union of the subagents' qualitative scopes does not cover every top-level path in the repository (rationale).
-
FAIL IF
.github/audit/or.vscode/is outside any consumer of.github/workflows/workflow-audit.yaml's diff window — the commit list,own_changes,is_clean_merge, and both content classifiers' refusals, whose half is derived from the singleWINDOWarray ("${WINDOW[@]:1}"). Widening one consumer without the others is the failure. The security specs are deliberately not watched there (rationale).
Source of truth: the **Scope and ## Qualitative pass sections of each domain prompt in .github/audit/; claude_args in .github/workflows/security-audit.yaml; run_domain in scripts/security-audit-local.sh.
Subagents launch in the background, so an agent that ends its turn to await a completion notification is finished: an orchestrator ends the whole run, a delegating domain ships what it has (rationale).
-
The job's
timeout-minutes: 40stays above the orchestrator's 32-minute wait deadline (rationale). -
--allowed-toolsenforces none of this, only auto-approving;Task/Agentare allowed on purpose and onlyWorkflowis denied (rationale). -
Each subagent appends to its own fragment as it determines each result, never holding findings for a write-up at the end, and the orchestrator concatenates them rather than retyping;
AUDIT_FRAGMENTSin.github/workflows/security-audit.yamlnames the four. Fragments upload with the transcript, so an orchestrator that dies mid-merge still ships what the domains found. -
A fragment opens
VERDICT: INCONCLUSIVEand closes with the literal<!-- END OF REPORT -->, its verdict rewritten once at the end. The sentinel, not existence, is what a reader treats as finished (rationale). -
FAIL IF the orchestrator prompt stops requiring a non-turn-ending wait — a Bash
untilloop over the fragments' sentinels, breaking on its own sub-cap under the Bash cap the workflow sets so every call ends by printing its answer, re-issued under a bounded 32-minute deadline persisted to a file ($RUNNER_TEMP/audit-deadline) rather than recomputed fromnow. That cap isBASH_DEFAULT_TIMEOUT_MSin.github/workflows/security-audit.yaml, set above the loop's 540-second break; the harness default is two minutes, under it (rationale). -
FAIL IF the prompt permits ending the turn without
audit-report.md(rationale). -
FAIL IF a domain prompt lets findings be held for a write-up at the end, lets a domain that delegates end its turn or background its wait loop, or the wait, the merge, or the verdict treats existence rather than the sentinel as a domain having reported (rationale).
-
FAIL IF the orchestrator can report
PASSwhile a subagent left no report fragment — norFAIL, unless some domain actually returned one: the prompt writes no status file when a fragment is missing and no domain failed, routing an audit that ran out of time to INCONCLUSIVE. Both exit non-zero and hold the release gate shut (rationale).
Source of truth: 2. Wait without ending your turn, 3. Merge, and 4. The verdict in .github/audit/orchestrator.md; the fragment contract in .github/audit/_preamble.md; the wait and merge blocks run as shipped in scripts/security-audit.test.mjs.
The reporting step distinguishes three outcomes, not two. Only the literal strings PASS and FAIL are honored (rationale).
| Outcome | audit-status.txt |
Result |
|---|---|---|
PASS |
literally PASS |
open failure issues auto-closed; exit zero |
FAIL |
literally FAIL |
issue filed or updated; exit non-zero |
| INCONCLUSIVE | missing, empty, or anything else | filed under the same label, body reproducing the partial report and saying it is not a security finding; exit non-zero |
-
A title moves upward only. A new issue is titled for its outcome; an append retitles an open issue for
FAILalone, so an inconclusive run cannot relabel one already carrying findings, and a PASS closes it rather than walking it back. -
Must write
audit-report.mdbeforeaudit-status.txt. A partial report can support FAIL; PASS requires every domain's completed checks. -
Partial has three shapes, each named in the INCONCLUSIVE issue:
UNVERIFIABLEfor a check reached but not determined;_Incomplete …_above a fragment cut off mid-report;_No report …_for a domain that never wrote one. The merged## Summarymay likewise readINCONCLUSIVE, and gives no coverage count for a cut-off domain (rationale). -
With no
audit-report.mdthe reporting step publishes each fragment verbatim under its own heading, unmerged (rationale). -
Must return
VERDICT: INCONCLUSIVEfrom a domain with any undetermined check unless it found a failure. Only all-determined passing checks permitVERDICT: PASS; a domain's inconclusive verdict prevents a merged pass. -
Never write a
FAIL IFcondition no audit run can read: audit the readable half; stage the rest under## Futureonly while it is unbuilt, and otherwise state it beside the rule.AUDIT_PAT-readable GitHub state stays audited (rationale). -
STATUSis assigned in exactly two places: where the status file is parsed, and in the single escalation block, which ordersFAIL>MISSING>PASS— a dissent can raiseMISSINGtoFAILand never the reverse, and aFAILalongside missing or unreadable fragments still reports them. -
Every fragment's verdict line is lifted into the head ahead of the report, then every fragment's
UNVERIFIABLE,FAIL:,BLOCKERandWARNINGlines — two blocks, not one per domain. Both passes match at line start after an optional heading or bullet marker, so the fragments'FAIL IFvocabulary is not read as a finding, and both cut lines to 500 characters. Only the findings block carries the 40-line cap, so no domain's findings can push another's verdict out. Neither pass may fail the step when it matches nothing — underset -eo pipefailthat would post no report at all (rationale). -
The report is truncated to 32,000 characters before posting, head kept, by
scripts/clamp-issue-body.mjs(self-tested byscripts/clamp-issue-body-selftest.mjs). The call is non-fatal; theaudit-transcriptartifact holds the report in full;.github/workflows/workflow-audit.yamltruncates its commit list the same way (rationale). -
Every run uploads the
audit-transcriptartifact, which is world-readable and not secret-masked — 14-day retention, deep-linked from failure issues (rationale). -
FAIL IF the
Redact secrets from agent outputstep is removed, stops covering any sink that is later published (audit-report.md, the four per-domain fragments, and the transcript), or stops failing closed by deleting those files when the redactor itself throws (rationale). -
FAIL IF the reporting step writes issue prose per combination of conditions rather than one note per condition that holds (rationale).
-
FAIL IF either fragment guard is gated on the status at all (rationale).
-
FAIL IF the reporting step accepts any domain verdict other than exact
VERDICT: PASSas passing, fails to recognize aVERDICT: FAILprefix as dissent, ignores an inconclusive domain, accepts a fragment with no completion sentinel as finished, or accepts status text other than literalPASS/FAIL(rationale). -
FAIL IF the audit has been weakened in any other way — e.g. the prompt no longer requires the qualitative pass, a
FAIL 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 (rationale).
Source of truth: clampIssueBody in scripts/clamp-issue-body.mjs; Surface result, file or close issue in .github/workflows/security-audit.yaml; reporting, redaction, and local-runner regressions in scripts/security-audit.test.mjs.
The audit job declares environment: security-audit, whose deployment-branch-policy admits only main and v* tags — both admin-only by the rulesets in docs/specs/security-ci.md -> "Automated Maintainer (tend)" (rationale).
-
Audit changes are iterated on
maindirectly: aworkflow_dispatchfrom any other ref is rejected before any step runs; experimenting on a branch means widening the policy temporarily. -
AUDIT_PATis 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 (rationale). -
The PAT is fine-grained and read-only:
Administration+Secrets+Environments, scoped todiffplug/dormouseonly, minted on an admin's account, stored env-scoped. -
No step may ever print
$AUDIT_PATor$CLAUDE_CODE_OAUTH_TOKEN. The prompt passes the PAT only through an unexpandedGH_TOKEN=prefix, andgh apiresponses never carry secret values (rationale). -
FAIL IF the step that verifies
AUDIT_PATis provisioned before the audit runs is removed or bypassed (rationale).
Source of truth: Verify AUDIT_PAT is provisioned in .github/workflows/security-audit.yaml; Never print a secret value in .github/audit/_preamble.md.
A second job outside the security-audit environment, running the domains that
need no PAT and passing their fragments back as artifacts, would leave
application-security and hosted unable to hold AUDIT_PAT at all.