Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/audit/_preamble.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,55 @@ if any `FAIL IF` in your scope is violated or any of your qualitative findings
is BLOCKER. Otherwise INCONCLUSIVE if any check is `UNVERIFIABLE` or
unfinished; PASS only when every check was determined.

**If you delegate, block for your delegates — never end your turn to wait.**
Subagents launch in the **background**: the Task tool returns an id, not a
report. Ending your turn ends *you*, and your caller reads that as your report
being finished — it merges the fragment as it stands and everything your
delegates write afterwards is lost. Block inside a Bash call instead, and never
with `run_in_background`, which returns an id immediately and blocks nothing. A
single Bash call is capped at ten minutes, so break the loop yourself under the
cap, issue it with `timeout: 600000`, and re-issue it under a bound of your
own:

```sh
# Persisted, because you re-issue this block in a fresh shell each time — and
# named after your own fragment, because every domain shares one $RUNNER_TEMP.
DEADLINE_FILE="$RUNNER_TEMP/delegate-deadline-<your fragment>"
if [ ! -f "$DEADLINE_FILE" ]; then
# Your caller's own deadline, less three minutes to close your fragment. It
# writes that file before it starts waiting; the 25 minutes here is only the
# fallback for the case where it has not.
CALLER=$(cat "$RUNNER_TEMP/audit-deadline" 2>/dev/null || true)
[ -n "$CALLER" ] || CALLER=$(( $(date +%s) + 1680 ))
echo $(( CALLER - 180 )) > "$DEADLINE_FILE"
fi
DEADLINE=$(cat "$DEADLINE_FILE")
CALL_END=$(( $(date +%s) + 540 ))
ANSWER="ALL FINISHED"
until <every delegate's output file is complete>; do
NOW=$(date +%s)
[ "$NOW" -ge "$DEADLINE" ] && { ANSWER="DEADLINE"; break; }
[ "$NOW" -ge "$CALL_END" ] && { ANSWER="STILL WAITING"; break; }
sleep 10
done
Comment thread
dormouse-bot marked this conversation as resolved.
echo "$ANSWER"
```

**The call's last line is its answer.** Re-issue the block verbatim on `STILL
WAITING`; on `DEADLINE` or `ALL FINISHED` stop waiting and write up what you
have. Your deadline is your caller's own, three minutes early, so you still
have room to rewrite your verdict line and close the fragment; the 25 minutes
is only the fallback for a caller that has not started waiting yet. An empty
answer means the call was moved to the background, so re-issue it rather than
waiting on that task.

Never substitute a bare `sleep` — the harness blocks it; the `until` loop above
is the sanctioned form. Run 35327271988's `application-security` domain
backgrounded its own wait loop, said it was holding for four outstanding work
streams, and ended its turn at 09:11:56. All four finished by 09:19:29,
fourteen minutes inside the deadline, and none of their results reached the
report.

Never print a secret value. `$AUDIT_PAT` is passed only as an unexpanded
`GH_TOKEN=` prefix; do not echo it, do not run `printenv` or `set -x`, and do
not paste the contents of any credential file into your report — report its
Expand Down
9 changes: 6 additions & 3 deletions .github/audit/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ emit() {
echo "_No report — this domain produced no fragment._"
else
[ "$(sed -e '/^[[:space:]]*$/d' "$2" | tail -n1)" = "<!-- END OF REPORT -->" ] ||
echo "_Incomplete — this domain was still writing when the deadline passed. What follows is what it had recorded, not a finished report._"
echo "_Incomplete — this domain never closed its report. What follows is what it had recorded, not a finished report._"
cat "$2"
fi
echo
Expand All @@ -163,8 +163,11 @@ domain that never reported contradicts the issue carrying it, and publishes
an overall `PASS` covering an unaudited domain.

For a domain cut off mid-report, the Summary says that and gives its verdict
line. **Never state how much such a domain covered** — you did not watch it
work, and its fragment's own prose is about the moment it was written. Run
line. **Never state how much such a domain covered, nor why it stopped** — you
did not watch it work, and its fragment's own prose is about the moment it was
written. An unclosed fragment is not evidence of a timeout: run 35327271988's
was published as "cut off ... at the 32-minute deadline" when that domain had
in fact ended its own turn twenty-one minutes before the deadline. Run
35205193090 read "two of seven work streams had not reported" and published
"completed only two of seven", turning five audited streams into five
unaudited ones in the one paragraph a reader starts from.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security-audit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ jobs:
# finished, and the no-verdict note above sends the reader
# after a marker this arm never wrote.
[ "$(sed -e '/^[[:space:]]*$/d' "$f" | tail -n1)" = "<!-- END OF REPORT -->" ] ||
echo "_Incomplete — this domain was still writing when the run ended. What follows is what it had recorded, not a finished report._"
echo "_Incomplete — this domain never closed its report. What follows is what it had recorded, not a finished report._"
cat "$f"
fi
echo
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/security-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Source of truth: `--agents` in `.github/workflows/security-audit.yaml`; `run_dom

## Orchestration

**Subagents launch in the background** — the Task tool returns an id, not a report — so an orchestrator that ends its turn to await a completion notification ends the whole run: one headless turn, nothing resumes it (rationale).
**Subagents launch in the background** — the Task tool returns an id, not a report — so an agent that ends its turn to await a completion notification is finished: the orchestrator ends the run, a delegating domain ships what it has (rationale).

- **The job's `timeout-minutes: 40` stays above the orchestrator's 32-minute wait deadline** (rationale).
- **`--allowed-tools` enforces none of this**: it only auto-approves and removes nothing. `Task`/`Agent` are allowed on purpose; only `Workflow` is denied.
Expand All @@ -59,7 +59,7 @@ Source of truth: `--agents` in `.github/workflows/security-audit.yaml`; `run_dom

- **FAIL IF** the orchestrator prompt stops requiring a non-turn-ending wait — a Bash `until` loop over the fragments' sentinels, **breaking on its own sub-cap under the ten-minute Bash cap** 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 from `now` (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, or the wait, the merge, or the verdict treats existence rather than the sentinel as a domain having reported (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 `PASS` while a subagent left no report fragment — nor `FAIL`, 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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/security-audit.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ A missing fragment is indistinguishable, in the merged report, from a domain tha

A domain that writes its fragment once, at the end, publishes nothing at all if it does not reach the end. Run 35205193090 is the case: `application-security` fanned out to fourteen nested subagents, every one of them returned (the last at 09:45:12), and the domain then produced no further output before the wait deadline at 09:53:15 — no completion notification for it ever arrived, unlike the sixteen other agents in the run. Seven work streams of finished audit were in its context and none of it was in its file, so the night's report carried `Qualitative findings: Pending.` and the ninth consecutive run held the release gate shut. Appending as findings are determined makes the same death cost only the synthesis.

A domain that delegates hits the orchestrator's own §2 failure one level down, and never reads the file that warns about it. Run 35327271988's `application-security` fanned out to eight nested agents, issued its wait loop with `run_in_background: true` — which returns an id and blocks nothing — reported "I'm holding for them before assembling the final fragment", and ended its turn at 09:11:56. Ending the turn is how a subagent completes, so the orchestrator saw the task finish with no sentinel written; the four outstanding work streams returned at 09:13:00, 09:13:04, 09:13:45 and 09:19:29, all inside a deadline that did not expire until 09:33:24, and every one of their results was discarded. The orchestrator then published the failure as "cut off ... at the 32-minute deadline" — a cause it could not have observed, twenty-one minutes wrong.

The sentinel exists because the same run showed that existence is the wrong predicate. The domain wrote a placeholder into its real fragment path at 09:40 to satisfy "write that file before you return"; `[ -s audit-application.md ]` went true, and the orchestrator — correctly unwilling to merge a placeholder — improvised `grep -q "Audit in progress"`, a predicate that worked only because it guessed wording no contract defined. With findings appended continuously the file is nonempty for most of the run, so the predicate has to be something the domain writes deliberately and last.

The sentinel is checked in the reporting step too, not only in the orchestrator's wait. A domain rewrites its verdict line and then writes the sentinel, so a death between those two writes leaves `VERDICT: PASS` on line 1 of a report that stopped early — the one state where every other guard is satisfied and `PASS` closes the failure issue and opens the release gate.
Expand Down
5 changes: 5 additions & 0 deletions scripts/security-audit-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ set -euo pipefail
cd "$(dirname "$0")/.."
AUDIT_DIR=.github/audit
export GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-diffplug/dormouse}"
# `_preamble.md`'s delegate wait persists its deadline under `$RUNNER_TEMP`,
# which only Actions sets. A fresh directory per run gives a hand-run the same
# property CI has — nothing from the last run survives into this one — where a
# repo-root fallback would hand the next run an expired deadline.
Comment on lines +24 to +27

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_domain passes --disallowed-tools "Task,Agent,Workflow" on both paths, and §2 of orchestrator.md records that this script "runs the domains directly and never the orchestrator" — so no local domain can delegate, nothing here ever reads $RUNNER_TEMP/audit-deadline, and the delegate block this export exists for is unreachable locally. As written the comment claims a hand-run needs it, which sends the next reader looking for a local failure that cannot happen; it also leaves an empty mktemp -d behind on every run, including the early exits above. Keeping the export as insurance against that tool list changing is reasonable — the comment just has to say that is what it is. Dropping both is the other defensible option.

Suggested change
# `_preamble.md`'s delegate wait persists its deadline under `$RUNNER_TEMP`,
# which only Actions sets. A fresh directory per run gives a hand-run the same
# property CI has — nothing from the last run survives into this one — where a
# repo-root fallback would hand the next run an expired deadline.
# `_preamble.md`'s delegate wait persists its deadline under `$RUNNER_TEMP`,
# which only Actions sets. No domain reaches that block here — `run_domain`
# denies `Task`/`Agent`, so a local domain cannot delegate, and the
# orchestrator never runs locally — but a fresh directory per run keeps the
# wait bounded if that ever changes, where a repo-root fallback would hand the
# next run an expired deadline.

export RUNNER_TEMP="${RUNNER_TEMP:-$(mktemp -d)}"

if ! command -v claude >/dev/null 2>&1; then
echo "error: the \`claude\` CLI is not on PATH." >&2
Expand Down
24 changes: 22 additions & 2 deletions scripts/security-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ const cases = [
// read as a pass. Those are the inverse of the bug this arm fixes.
{ name: 'no merged report publishes the fragments, marking cut-off and absent domains', report: null, verdicts: ['PASS', 'PASS', null], unfinished: [1], expected: 'INCONCLUSIVE',
notes: ['the merge never ran', '## audit-supply-chain.md', 'VERDICT: PASS', '## audit-application.md', '_No report — this domain produced no fragment._',
'## audit-ci-secrets.md\n\n_Incomplete — this domain was still writing'],
counts: { '_Incomplete — this domain was still writing': 1 } },
'## audit-ci-secrets.md\n\n_Incomplete — this domain never closed its report'],
counts: { '_Incomplete — this domain never closed its report': 1 } },
];
for (const scenario of cases) {
test(`reporting: ${scenario.name}`, (t) => {
Expand Down Expand Up @@ -189,6 +189,26 @@ test('the preamble tells domains to write the sentinel every reader waits for',
assert.equal(written.length, 1, 'expected exactly one closing `printf` in the preamble');
assert.match(written[0][0], new RegExp(SENTINEL.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&')));
});
// The delegation wait. The orchestrator carries this rule for itself in §2,
// but a domain is given `_preamble.md` plus its own file and never reads that
// one — so run 35327271988's `application-security` backgrounded its wait loop,
// ended its turn, and lost four work streams that finished before the deadline.
// The block is a template, not runnable, so the parts that make it work are
// pinned as text: the rule, the backgrounding ban, the Bash timeout without
// which every call is backgrounded anyway, and the two properties of its
// deadline. Every domain shares one `$RUNNER_TEMP`, so a fixed deadline file
// would make whichever domain delegates first set the bound for all of them;
// and the bound that has to hold is the caller's, which is already on disk at
// the path §2 persists it to.
test('the preamble forbids a delegating domain from waiting by ending its turn', () => {
const preamble = readFileSync(join(repo, '.github/audit/_preamble.md'), 'utf8');
assert.match(preamble, /never end your turn to wait/i);
assert.match(preamble, /never\s+with `run_in_background`/);
assert.match(preamble, /`timeout: 600000`/);
assert.match(preamble, /^DEADLINE_FILE="\$RUNNER_TEMP\/delegate-deadline-<your fragment>"$/m);
assert.match(preamble, /^\s*CALLER=\$\(cat "\$RUNNER_TEMP\/audit-deadline"/m);
});

const finishedFn = orchestrator.match(/^finished\(\) \{.*$/m)[0];

for (const [name, body, expected] of [
Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"docs/specs/relay.md": 10200,
"docs/specs/remote-api.md": 4700,
"docs/specs/remote-security-model.md": 4800,
"docs/specs/security-audit.md": 1900,
"docs/specs/security-audit.md": 1950,
"docs/specs/security-ci.md": 2700,
"docs/specs/security-hosted.md": 450,
"docs/specs/security-local.md": 3150,
Expand Down
Loading