feat(claude-lane-incident-aggregator): write the incident issue with the ambient token - #331
Conversation
…the ambient token The incident issue lives in this repository, so maintaining it is a same-repo write: `issues: write` on the ambient GITHUB_TOKEN is the whole authority the create, update, and close calls need. `create-issue-from-file` targets `github.repository` by default and the close step names `context.repo`, so no call leaves this repository. The App credential seam survives untouched for the half that genuinely needs it — reading PRIVATE consumer repositories, which the ambient token 404s on — and is now minted read-only, since it authors nothing. Granting a write scope retires the older guarantee that rested on the job holding none. Its replacement is a single job-level boolean, `WRITES_ENABLED`, derived from the `dry-run` dispatch input and from nothing else. Every writing step opens its `if:` with that one expression; the dry-run report step is gated on its negation, so no run can both write the issue and report that it wrote nothing. The gate deliberately does not consult the App token, which is what makes the write path work with or without one. Minting now happens on dry runs too. The old workflow skipped it because the write gate WAS the token's presence, so minting would have armed the writers; with the gate independent, a dry run can poll the full installation and render the body a live run would have written — what the input's description already promised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rtition over every step The old suite named the two writing steps in a literal array, so a third write step added ungated would have passed. That is exactly the failure the new gate has to rule out, so the tests now derive both halves of the partition from the workflow text: enumerate every step in the `aggregate` job, require each one to be either a registered writer whose `if:` LEADS with the shared gate, or a step with no mutating API surface and no action outside a read-only allowlist. Adding a write step fails whichever way it is added — unregistered it trips the no-mutation scan, registered it trips the gate assertion. The mutating-call scan is written against shapes rather than the three issue verbs this workflow happens to call: any mutating REST verb in any namespace, the raw request and GraphQL escape hatches, the gh CLI, and a hand-rolled curl. The `uses:` allowlist closes the remaining hole, where write capability hides inside an opaque third-party action. Step enumeration asserts every step is named, since an anonymous one would be invisible to the by-name lookup and would skip both halves. Permissions are now pinned as an exact scope-to-level map rather than a "contains no write" scan, which is what mechanically catches a second scope being widened alongside the intended one. The gate's own expression is extracted from the workflow and evaluated against the three event shapes, so inverting the operator or re-coupling it to the App credential fails. Sixteen mutations were applied to a scratch copy — ungated writers via three different surfaces, an anonymous step, gate removal, trailing-conjunct and `||` placement, operator inversion, credential re-coupling, scope widening, scope removal, a re-gated dry-run report, an over-privileged mint, a writer authoring with the App token, a reading step losing its ambient fallback, and an emptied registry. All sixteen turn the suite red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
|
Warning Automated review did not complete — this is an infrastructure failure, not a review verdict. Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."
Re-run the job to retry the review. A new push does not re-trigger this lane. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9e1117edd4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…lint `noTemplateCurlyInString` fires on an assertion message that quoted the GitHub expression delimiters inside a plain string; the message reads the same without them. The rest is the formatter's own wrapping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
Independent verification: DO NOT MERGE AS-IS — the replacement guarantee does not holdA fresh-context verifier audited this PR and returned two failures. The shipped workflow is correctly gated today (both current writers carry the gate; no other current step mutates), so this is not "a dry run can write now". What fails is the durability claim — and it fails while the header instructs reviewers to stop looking. FAIL — the write-gate partition is defeatableThe partition is enforced by regex over raw YAML text, not over parsed YAML. Six independently-constructed ungated write steps left the suite GREEN and were actionlint-clean:
Non-vacuity confirmed: unnamed steps, third-party writers, plain FAIL — the header now carries a false universal claimLines 41–44, verbatim: "A new write step therefore cannot reach main ungated — unregistered it fails the no-mutation check, registered it fails the gate check. A reviewer confirms 'a dry run cannot write' by reading one env line and one test, not by tracing every branch." Six counterexamples above. This is the load-bearing failure: it is the sentence telling a future reviewer that branch-tracing is unnecessary. The same false universal is repeated in the test file's write-gate section header. PASSES worth recording
Remediation direction (from the verifier)Parse the workflow with a real YAML parser and enumerate steps structurally — that alone kills survivors 1–3 and makes the Also flagged: Author's note: this session has exhausted its model budget (resets Aug 4), so I cannot land the remediation myself. Recording it here so the finding is durable rather than lost with the session. |
A contract test that enumerates a workflow's steps by matching patterns against the raw file can be walked past by any step spelled in a shape the pattern did not anticipate. Three such shapes exist in ordinary YAML: a bare `-` on its own line is a sequence item that a bullet pattern requiring `- ` never sees; step names need not be unique, so a by-name lookup answers with the first match; and `- name:` inside a `script: |` block scalar is text a slice terminator happily stops on. Each hides a whole step from whatever the test claims to check. This adds the structural alternative: a parser for the block-style subset the workflow and action-metadata files here are written in — block mappings and sequences, block scalars with chomping and folding, quoted and plain scalars, flow collections. FAIL CLOSED is the design. Anchors, aliases, merge keys, tags, directives, explicit keys, multiple documents, tab indentation, a duplicate key, an unterminated quote, and any line the grammar cannot place all raise WorkflowYamlError, and parsing ends by proving every line was consumed by exactly one production. A caller reports the throw as a finding, so a parser gap costs a red build rather than a node that quietly went missing — the opposite of how the pattern-matching approach fails. The subset was chosen against this repository's own corpus, and the result was differentially validated against yq v4.53.3 over every YAML file in the tree: 75 of 76 parse to a byte-identical JSON projection. The one file that does not is `standards-sync.yml`, which uses a real YAML anchor; it is rejected loudly rather than approximated, which is the contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y over parsed YAML
The previous enforcement matched patterns against the raw workflow text and
cleared a step that failed to look dangerous. An independent verifier landed six
ungated writes through it, each actionlint-clean and each leaving the suite
green: a bare-hyphen step appended past the last writer, a second step reusing a
writer's name, a `- name:` decoy inside a block scalar, an implicit-POST `curl`
carrying no method flag, `const api = github.rest` plus bracket access to the
verb, and a local `./` action, which has no pin for a pin pattern to anchor on.
Enforcement moves into claude-lane-incident-write-gate.cjs and inverts. It takes
the workflow's source, parses it, and for every step of every job whose resolved
permissions can mutate anything, requires the step to be either a REGISTERED
WRITER carrying the shared gate or an affirmative match for a read-only shape:
- every `uses:` a pinned `owner/repo@<40-hex sha>` on an allowlisted action;
- a `github-script` body whose every `github`/`octokit`/`core`/`context`
member path is an exact read endpoint, whose `require(…)` targets are
allowlisted, and which names no reflective escape;
- a `run:` body holding no credential outside the read-only forms and
invoking only commands on a read-only list;
- `actions/checkout` with `persist-credentials: false`.
A step matching neither half is a violation, so no spelling of a write has to be
anticipated. Structural enumeration retires the first three defeats outright,
the pin requirement the sixth, and the two allowlists the fourth and fifth. The
job shape is checked too: a write-scoped job declaring `container`, `services`,
`defaults`, a reusable-workflow `uses:`, no steps at all, or a step whose `name`
is an expression rather than a literal is rejected, and permissions that cannot
be resolved from the file are treated as write.
Each of the six is now a regression test whose expectation names the reason it
is caught, so a fix that happened to reject the mutation for an unrelated reason
would not satisfy it. Verified both ways: on the previous gate all six leave the
suite green, and on this one each fails with the stated violation.
The header's claim that a reviewer confirms "a dry run cannot write" by reading
one env line and one test "not by tracing every branch" is removed. It was false
— the six defeats are its counterexamples — and it was the sentence telling the
next reviewer to stop looking. What replaces it states the bound: the audit
proves no step other than the registered writers can mutate anything, and does
not decide whether the gate is the right condition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 6m 14s —— View job Security review completeScope: the 5 files changed in this PR (workflow permissions/credential changes, the new YAML parser, and the new write-gate audit script). Diff taken against I independently re-verified the six write-gate bypasses @kyle-sexton's earlier audit found (bare-hyphen tail item, duplicate step name, Two findings, both posted inline with full detail and remediation direction: IMPORTANT — object-spread aliasing evades the IMPORTANT — losing the App credential mid-incident can silently auto-close a fleet-wide incident No other issues found. Scope/permissions changes are otherwise sound: exactly one scope widened ( I did not implement fixes — this was a review-only request. |
…scan too
The step audit inverted, but the scan it applies to a `github-script` body did
not: it pinned exact member paths for four roots and left every other identifier
to a small denylist. That is the same failure one level down, and it is
load-bearing — `actions/github-script` defaults `github-token` to the ambient
token, so all three non-writing script steps already hold write capability and
their code is the only thing deciding what it spends.
Three ungated writes went through the previous revision untouched:
- `fetch(…, { method: "POST" })`, a runtime global that was not a scanned
root, authenticated from `process.env["INPUT_GITHUB-TOKEN"]` — the token's
environment spelling keeps the hyphen `github-token` carries, so it is not
the `GITHUB_TOKEN` a word-boundary match looks for;
- `exec.exec("gh", ["issue", "comment", …])`, using a helper github-script
injects alongside `github`, as `io` and `glob` also are;
- `__original_require__("node:https")`, which contains no `require(` for the
require-argument allowlist to match.
The scan now allowlists FREE IDENTIFIERS. Every identifier in reference position
must be either bound by the script itself or one of sixteen permitted globals;
`fetch`, `exec`, `io`, `glob`, `__original_require__` and whatever a future
runtime injects next are denied by absence, because the set of ways to reach the
network is not a list anyone can finish writing. `process` joins the roots whose
paths are pinned, admitting `process.env.NAME` and rejecting the computed
`process.env[…]` lookup. `INPUT_*…TOKEN` joins the credential tripwire as an
independent second signal.
Identifiers are read from a copy with comments and string literals blanked and
template `${…}` substitutions kept, so a declaration cannot be faked in a
comment or a string. The stripper reports an unterminated string, template, or
comment as a violation rather than parsing on.
Two regression tests join the six, in the same shape: the injected-global
network call and the require alias. The other six are unchanged except for one
expectation whose message text moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 6m 23s —— View job Security review completeScope: full re-review of the 5 files changed in this PR, against
Result: this push does not close the durability gap. It substantially hardens the free-identifier scan (undeclared globals like CRITICAL — alias-then-use defeats both the Octokit-surface allowlist and the
Confidence: the IMPORTANT — App-credential-loss auto-close gap is still open (repeat finding, posted inline for visibility) — confirmed this push's diff to the workflow file is header-comment-only, so the gap @chatgpt-codex-connector and I both flagged previously (losing the App credential mid-incident narrows polling to self-only with no signal to No new issues found elsewhere in the diff. The rest of the rewrite holds up: the six previously-defeated bypasses (bare-hyphen tail step, duplicate writer name, block-scalar |
|
Important Superseded. This comment documents the step-classifying gate, which has Write-gate remediation: what each new test covers, and the before/after outputFollowing the independent verification above. The regex-over-raw-YAML gate is
The eleven defeats, and the test that covers each
7–11 are not from the original list. They were found adversarially reviewing the Before / afterBefore — each mutation applied to this branch's pre-remediation head After — the same eleven mutations, now as tests, on this head: Whole suite: (422 before; +9 parser tests, +11 defeat regressions, net of the replaced Other checks
What this does not claim — and why that is written downDefeats 7–11 were all found in one place: the scan applied to an INLINE SCRIPT. The step partition is structural: it reads a parsed document, so the That ceiling cannot be engineered away for two steps. So: the audit proves no step other than the two registered writers can mutate The |
…ema does Differential fuzzing against yq v4.53.3 turned up two places where the parser read a document differently from a real implementation. Neither reaches the write-gate audit's conclusions — both concern scalar VALUES rather than step structure — but a structural audit is only worth what its parse is worth, so a known divergence is not something to carry. `007` and `1e3` were left strings. The YAML 1.2 core schema resolves the first as an integer (its decimal form has no leading-zero restriction) and the second as a float (the fraction is optional when an exponent is present), and yq agrees on both. The integer form also admits the `0o` and `0x` bases the schema lists. `24.18.0` stays a string, having two dots. `|+` dropped one trailing break. Keep chomping retains the break after every line it retains, including the blank ones it alone preserves, so the final newline belongs there exactly as it does under clip. The parser suite now pins both, and the fuzz corpus — 36 hand-built shapes covering quoting, chomping, folding, flow collections, indentation, and scalar resolution — agrees with yq on all 36. The repository corpus is unchanged at 75 of 76, the exception being the file that uses a real YAML anchor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 28s —— View job Security review complete
This push ( Both previously-reported findings remain open, unchanged, since this push doesn't touch the affected files: CRITICAL — alias-then-use still defeats both the Octokit-surface allowlist and the
IMPORTANT — App-credential-loss auto-close gap still open (posted inline) No new issues found in the diff beyond these two repeat findings. Everything else re-verified sound: the eight originally-defeated bypasses (bare-hyphen tail step, duplicate writer name, block-scalar Given the |
… just the handles
Allowlisting free identifiers closed the injected-global routes, but left the
members of the globals it admits unexamined. Every value in JavaScript reaches a
Function constructor through its own prototype, so that was still a way to run
anything: `String.constructor("return fetch")()` names nothing the scan objected
to, and neither does the same reflection off a local through a subscript, where
no member name appears in the source at all.
Three changes, each narrowing rather than enumerating:
- every permitted global's member paths are pinned exactly, as the Octokit
handle's already were, so `String.constructor` is as much a violation as
`github.rest` is;
- `constructor`, `__proto__` and `prototype` are refused wherever they appear,
because reflection is reachable from a local this audit does not track. The
set is closed by the language, not guessed at;
- a subscript that is not a plain number is refused, since a computed property
name is one the scan cannot read — which also stops the three names above
being spelled dynamically.
The permitted-globals set narrows to the ten the shipped scripts actually name.
A global that is admitted but has no allowed path would have been a confusing
half-state, and leaving one out costs a red build rather than an admission.
Two regression tests join the eight, in the same shape: reflection through a
permitted global, and the computed property name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wo fixtures The corpus generator wrote `services:` and `strategy:` as `key: value` even when the value was a nested block starting on the next line, leaving a space after the colon. The editorconfig lane caught it on CI, which is the lane doing its job; the local pre-commit sweep had checked the hand-written files and not the generated ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 44s —— View job Security review complete
All three new fixtures are wired into the test corpus with the correct expectation ( Both items I was tracking as open are now resolved or accounted for:
Still open — IMPORTANT, unaddressed across nine review rounds: losing the App credential mid-incident can silently auto-close a fleet-wide incident ( No new issues found. Permissions delta across the whole PR remains Given the |
…y rule with a fixture
Closes the last two gaps an independent adversarial pass left open after
returning NO BYPASS FOUND on the credential rule.
`github.token` is the ambient credential under a name that reaches no `secrets`
context, so the whole-file scan never saw it. The audit's own header claimed
every credential reference was pinned; strictly, this one was not scanned at
all. It is now, in the dotted, bracket and case-varying spellings, for the same
reason the `secrets` match is on the bare word.
The pass also flagged that its rule-coverage matrix was derived against an
earlier revision, so rules added since were absent from it by construction. Re-
running that method here — neuter one `violations.push` site at a time, check a
fixture stops matching its asserted message — showed 20 of 22 sites pinned. The
two survivors were the defensive not-a-mapping branches for a job and a step;
both now have fixtures, and all 22 sites are pinned. Corpus 40 → 43.
Two couplings the pass identified as load-bearing are now recorded at the code
rather than left to be rediscovered:
- the exempt region and the byte comparison share ONE offset, which is what
stops a forged marker planted earlier from widening the exempt span —
widening it also moves the slice that must equal the pin;
- the two poll-side credential expressions are allowlisted position-
independently, which is safe ONLY because both halves are provably
read-only. Weakening `poll`'s permissions or the mint pin turns that
allowlist into a bypass.
Also settled, from the pinned revision's own `src/main.ts`: an empty
`issue-number` reaches `create-issue-from-file` as `Number('')` → `0`, which is
falsy, so it takes the CREATE branch. The first-ever incident opens an issue
rather than erroring — the pass's one outstanding correctness question, and
unchanged from the pre-split behavior.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 20s —— View job Security review complete
This push ( What it does, and it holds up:
Still open — IMPORTANT, unaddressed across nine-plus review rounds (repeat finding; not touched by this push): losing the App credential mid-incident can silently auto-close a fleet-wide incident. No new issues found. The previously-reported CRITICAL findings (secret-surface job/workflow-level I wasn't able to execute Given the |
Redesigned: the write is its own job, and the scanners are deletedSupersedes the comment above, which documents the step-classifying gate. That Why it changedThree independent adversarial passes each found a fresh bypass of the The shape now
Nothing in What the last adversarial pass found, and what it could notIt found a real bypass of the first cut, in a rule my own header claimed to
It landed On re-verification it returned NO BYPASS FOUND, and could not break: the The corpus43 fixtures, each a complete workflow fed to the audit's entry point and Every rule is pinned. Verified by neutering each of the audit's 22 Correctness of the split, not just its gate
What this does not claim
Two things remain unproven and are flagged rather than asserted: whether a
|
…ssignment
A fresh-context adversarial pass returned BYPASS FOUND against the job-split
design, and the break was total: a third job keyed `__proto__`, holding
`issues: write` and writing through `actions/github-script` (whose token input
defaults to the ambient credential, so the file names no secret at all), left
`auditWriteGate` returning `[]` with actionlint exit 0 and the suite green.
The parser bound keys with `mapping[key] = value`, which is not key-agnostic:
for `__proto__` with an object value it reassigns the prototype instead of
creating a property. The job's lines were consumed, so the every-line-consumed
invariant held, but the entry vanished from `Object.keys` and `JSON.stringify`.
The job-set check read exactly `poll,write` and the write-scope count read 1 —
"looked, found nothing to check, passed", the same shape as every previous
bypass, one layer below where the previous ones lived. `Object.hasOwn` never
reported it either, so the duplicate-key guard was blind to it too.
`__proto__` is a legal job id, so this was a projection silently omitting a
node the runner enumerates — the one thing this parser exists not to do.
`defineProperty` is key-agnostic and is what `JSON.parse` does with the same
input: a mapping is data. Applied at all six binding sites, block and flow
alike; `constructor` was already safe, because bracket-assigning it creates a
shadowing own property.
The parser fix is primary, but a projection defect is exactly what a second
mechanism should have caught, and none was reading raw text. So the audit now
also requires every grant of a write — `<key>: write` in any quoting, and the
`write-all` string — to lie inside a byte-pinned region. That rule never learns
what a job is, so no parse can hide a grant from it, and it subsumes the
App-token rule: a minted `permission-<scope>: write` is the same shape. It is
loose enough to match prose, which cost two header comments a rewording; that
is the safe direction to be wrong in, and it is not airtight, which the code
says rather than implies.
Corpus 43 -> 45, and three entries repaired against the same pass's audit:
- `write-job-local-action` asserted the byte-pin, leaving the `uses:` pin
rule that actually rejects `./` untested. Moved into `poll`, where the
byte-pin does not reach it, and renamed for where it now lives;
- `extra-job` and `step-not-a-mapping` each tripped a second, unrelated rule,
so neither isolated the property it names;
- the not-a-mapping branch for the document itself had no fixture.
`proto-job` asserts BOTH mechanisms name it, so the redundancy is pinned rather
than incidental. Also pins the artifact coupling: create-issue-from-file exits
silently when `content-filepath` is absent, so a rename on either side of the
hand-off was a green run that wrote nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 32s —— View job Security review complete
This push ( The bug it fixes: The backstop: Verification, not just trust: the new The Still open — IMPORTANT, unaddressed across ten-plus review rounds (repeat finding; this push doesn't touch it): losing the App credential mid-incident can silently auto-close a fleet-wide incident. No new issues found. Everything previously closed remains closed: the credential-surface scan (job/workflow-level Given the |
…d scan `vars`
A second adversarial pass returned NO BYPASS FOUND, but defeated the audit's own
logic twice on the way.
`isWriteScoped` asked whether a scope value `includes("write")`, which decides
safety by FAILING TO RECOGNIZE a value: every spelling the search does not
anticipate reads as safe. `issues: >` with `write` on the next line resolves to
`"write\n"`, so the job read as read-only and `auditWriteGate` returned `[]`.
Both mechanisms missed it at once — the raw-text scan too, since `: write` is
never adjacent in the source. What stopped it was actionlint rejecting the
resolved scalar and a `deepEqual` on `poll.permissions` in the suite; the gate
contributed nothing, and neither of those is the gate.
Inverted to an allowlist: a value this file cannot prove is `read` or `none` is
a write. An unanticipated spelling now costs a red build instead of a silent
pass, and the question of whether the runner trims a scalar before matching its
enum — which the docs do not answer — stops mattering.
The credential scan covered `secrets` and `github.token`. `vars` is the third
channel a token arrives on: variables are not secret, so a write-capable token
in one is a misconfiguration rather than a seam, but the rule here is over the
CHANNEL, and a PAT bound into a job `env:` from `vars` reaches every `run:` in
scope exactly as one from `secrets` does. The header claimed every credential
reference in the whole file was pinned; that claim is now true.
Two bounds the pass identified are recorded rather than closed, because both are
harmless only while `poll` holds no write scope — which is the design's own
premise, and is now written where someone weakening it would read it:
- `uses:` is checked for SHAPE, not identity: `evil/pwn@<40 hex>` is a valid
pin. A third-party action in `poll` receives `poll`'s read-only credentials;
- the credential scan matches the three context WORDS, so an expression
naming none of them reaches the ambient token unseen — likewise read-only.
Corpus 45 -> 47. The context-availability list the header restated is upstream's
to change, so it is now a link rather than a copy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 23s —— View job Security review complete
This push ( 1. 2. The credential-surface scan covered Both fixes are honestly scoped, not oversold — the new header addition (L35-43) explicitly names two remaining bounds (uses: pins checked by shape not identity; the credential scan matches three specific context words, so a fully-obfuscated expression like No new issues found. The pinned files ( Still open — IMPORTANT, unaddressed across eleven review rounds (repeat finding; this push doesn't touch it): losing the App credential mid-incident can silently auto-close a fleet-wide incident. I wasn't able to execute Given the |
…at the incident implicates Polling scope is not fixed: it IS the App installation's repository list, and it narrows to this repository alone the moment the App credential goes missing, because a deleted or rotated secret skips the mint step and the uncredentialed fallback takes over. That narrowing raises no read error — nothing failed, the poll simply asked a smaller question — so `classifyCycle` sees lane runs, no escalating class, and zero read errors, and calls the cycle clean. An incident caused by PRIVATE consumer repositories therefore met three clean cycles that never read those repositories at all, and closed itself. Scope is not in `classifyCycle`'s inputs and neither is the incident's own evidence, so no amount of care there could have caught it. The poll now publishes the scope it observed rather than only how many repositories were in it, and a clean cycle advances the clean-cycle counter only when that scope covers every repository the open incident's persisted index names. A gap holds the incident exactly as `indeterminate` does — advancing nothing, resetting nothing — and annotates the run with what went unobserved, so a held cycle does not log `cycle=clean action=none` with no explanation. Coverage fails, and the incident holds, in three ways: a named implicated repository was not polled; the bounded index dropped names it cannot account for (`repositoriesSeen` exceeds what it lists); or the open issue carries no parsable state block at all, which names nothing and so can contradict nothing. The last supersedes the clean path's body recovery, which rebuilt a hand-edited or older-schema body from an empty state and counted toward close from zero — the same silent close by another route. The escalating path still rebuilds the block, so a live incident's body self-repairs. An omitted or unparsable scope proves nothing rather than everything, so a plumbing mistake holds incidents open instead of closing them. That plumbing is now covered by executing the shipped state-advance step's own text against the env it is wired to, the way the poll step already was: a unit test calling `nextState` directly cannot see a mis-wired env key, and either one would make the check vacuous while every unit test stayed green. The cost is a stall when an implicated repository is legitimately gone — archived, or dropped from the installation — which is indistinguishable here from a credential that vanished. A held incident is visible and a human closes it; the silent close this prevents looks exactly like recovery. `poll` keeps zero write scopes and the `write` job and mint step are unchanged, byte-for-byte, against their pins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 1s —— View job Security review complete
This push ( What it does: I traced the wiring end to end and it holds:
Honest documented tradeoff, not a defect: a legitimately-gone repository (archived, uninstalled) is indistinguishable from a lost credential, so a gap now holds the incident open indefinitely rather than auto-closing — the commit and header are explicit that this is the deliberate fail-direction (a stalled, visible incident over a silent false-recovery). Not a security issue. No new issues found. This diff doesn't touch permissions, Given the extensive prior review history on this PR (write-gate isolation, byte-pinning, credential-surface closure via |
…, not only one that failed to parse
Coverage turned on WHY the incident's repository index was empty rather than on
what it contained: a state block that did not parse held, but one that parsed
to an empty index reported coverage complete and walked three narrowed cycles
to a close. Both claim exactly nothing, so neither can contradict a cycle, and
the doc block already said so — the code only implemented half of it.
Not reachable from the poll: an observation is pushed only after the fork check
proves the repository name matches the head's, so a real GitHub `full_name`
always validates and an escalating tally always names its repository. A body
hand-edited to `"repositories": {}` is the only route, and anyone who can edit
the body can already close the issue outright, so this was never a privilege
gap — but a rule that turns on provenance instead of content is one a later
change would reopen from a new direction.
`unparsableState` accordingly becomes `namesNothing`, which is the property the
check actually tests.
The doc block now also records the two other standing operator costs of failing
toward holding, alongside the archived-repository one it already named: an
incident wider than MAX_TRACKED_REPOSITORIES never resolves its `unlisted`
remainder, and a STATE_SCHEMA_VERSION bump strands every incident open at the
time.
Correcting the framing of the previous commit's message: it described the
silent auto-close in the past tense as something that had happened. It is a
latent defect the code would have produced — no incident issue has ever been
opened in this repository.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 1m 11s —— View job Security review complete
This push ( What it does: Traced for correctness, not just trusted:
No new issues found. This diff doesn't touch permissions, Standing summary across this whole review thread: every previously-reported CRITICAL and IMPORTANT finding (write-gate step-classification bypasses, JS alias/spread evasions, credential-surface |
…le must be a covered one The body still promised an unconditional auto-close — "auto-closes at 3" and "observe 3 consecutive clean cycles; it closes itself" — after coverage made both conditional. In the three cases the check holds indefinitely (a gone repository, an incident wider than the tracked index, a schema bump) the issue would read `Consecutive clean cycles: 0 of 3` forever while the only explanation lived in a per-run warning annotation that never reaches the issue. The durable artifact is what an operator reads, so it has to carry the condition and the manual-close instruction. Remediation step 3 already documents the kill-switch's never-closes condition in the body; the new step 4 is its parallel for coverage, and points at the `coverage=` field and the run's warning for the diagnosis. `describeCoverageGap` loses an unreachable `!gap` branch: its only caller tests for a gap first, and there is nothing to say about complete coverage. The poll's comment claimed the published list was the scope "observed" when it is the scope attempted. The two coincide wherever the list is consulted — anything the poll failed to read raises a read error, which caps the cycle at `indeterminate` before coverage is reached — but that is an inference the sentence was leaving to the reader. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 56s —— View job Security review complete
This push (
No security-relevant surface is touched: no permissions change, no No findings. Every previously-reported issue across this thread (write-gate step-classification bypasses, JS alias/spread evasions, credential-surface |
…in the stale label comment (#350) ## Summary Two findings against the claude-lane incident watchdog, both left over from #331, fixed in one PR because neither is worth a branch of its own. ### #348 — the write job's label comment was stale in both halves The pinned comment claimed `claude-lane-incident` "does not exist in this repository's label taxonomy yet, and that taxonomy is managed by github-iac (Labels.cs)". It exists: github-iac declares it in `GovernedRepositories.cs` under this repository's `ExtraLabels`, and it is live on this repository with that description. `Labels.cs` owns the shared core taxonomy and is not where a per-repository extra label is declared, so the new comment cites `GovernedRepositories.cs` instead. The consequence half is rewritten too: the next apply keeps the label rather than pruning it. ### #344 — the coverage copy named the rendered table, and was untested `renderIssueBody` told the operator that a clean cycle counts only if it polled "every repository listed above" (and "below" in the header bullet). The gate is `coverageGap`, which reads the TRACKED index — `MAX_TRACKED_REPOSITORIES` = 60 — and `repositoriesSeen`, while the table is capped separately at `MAX_RENDERED_REPOSITORIES` = 40 and a character budget. So the copy was right for a small incident and wrong in exactly the fleet-wide case it was written for. Measured on this branch: a 120-repository incident tracks 60 and renders 40 rows at short names, 23 at the longest repository name that can exist. Naming the tracked index alone would have replaced one overpromise with another: `coverageGap` also holds on `unlisted > 0`, so an incident wider than the tracked cap never gets a counting clean cycle however much was polled. Both sentences now state the whole condition — every tracked repository polled, AND the index accounting for everything seen — and the header bullet defers to step 4 rather than compressing the rule into a parenthetical that would misstate it. Step 4 also attributed a permanent hold to a gone repository alone. `coverageGap` gates on three, so the other two now appear in the durable artifact rather than only in the run warning: an incident wider than the tracked cap, and an index this watchdog can no longer read. The second is stated as the gate condition (`namesNothing`) rather than as one of its triggers — a hand-edited state block and one written by an older schema degrade identically, and a `STATE_SCHEMA_VERSION` bump is how that reaches every open incident at once, not the only way an operator meets it. The copy was also untested: the issue's second finding is that reverting it left the suite green. A rendered-body substring assertion over the fleet-wide state now covers it, following the convention the suite already uses. ## How the re-pin was done The repo's pin discipline is byte-exact text comparison, not a digest. `claude-lane-incident-write-gate.cjs` compares the workflow from the marker `# THE ONLY WRITE-SCOPED JOB.` to end of file against `claude-lane-incident-write-job.pinned.yml`, and the aggregator test additionally asserts `workflow.endsWith(pinned)`. Every fixture that carries the write job carries the same bytes, so the comment could not be fixed in one file. Rather than re-syncing the pinned tail over each fixture — which would have made the deliberately-broken write-job fixtures conformant and turned their CORPUS assertions green-on-nothing — a script substituted the same five comment lines in place, once per file, failing loudly on any file where the block did not appear exactly once. 49 files changed in one commit; `workflow-not-a-mapping.yml` is the one fixture carrying no write job and was correctly skipped. `claude-lane-incident-mint-step.pinned.yml` is a separate pinned region and is untouched. ## Verification - `node --test .github/scripts/*.test.cjs` — 505 pass, 0 fail (504 on origin/main plus the one assertion this PR adds). - Regression proof for #344: reverting `claude-lane-incident.cjs` alone, with the new test retained, turns exactly that test red (43 pass, 1 fail). A green suite would not have been evidence — that is the finding. - `npx @biomejs/biome@2.5.4 ci --config-path=fixtures/typescript/good/biome.json --error-on-warnings .github/scripts` — clean, matching what CI's biome job runs. - The pin is the workflow's byte-exact tail, checked directly (`workflow.slice(marker) === pinned`) as well as by the test. - The github-iac declaration and the live label were both read at authoring time rather than taken from the issue text, and the "keeps rather than prunes" claim was traced through `Labels.Apply`, which unions `_core` with the repository's `ExtraLabels` for every spec with `ManagedLabels` (default `true`, which `ci-workflows` takes). All 38 CI checks on the head commit are green. Closes #344 Closes #348 ## Related - #331 — the merged PR both findings came out of, raised by its final pre-merge verifier as non-blocking (verdict PASS-WITH-CONCERNS). - melodic-software/github-iac#252 — declared `claude-lane-incident` in the taxonomy, which is what made the pinned comment stale. - #334 — also open against `claude-lane-incident-aggregator.yml`, but only in the workflow's header comment block and the aggregator test's poll assertions. No overlap with the write-job region or the fixtures this PR re-pins; the two merge in either order. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… its own wording (#358) ## Summary #350 closed #344 by fixing the copy and adding a test. The test greps the rendered body, so it pins the copy against its own wording — which is the #344 defect one level up. This makes the assertion check the copy against the **gate** it describes. Two failure modes survived the merge, both found by the pre-merge reviewers and confirmed here at #350's merged head `9681fee`: - **The fleet-wide fixture was inert.** All five assertions passed unchanged against a one-repository state, so `fleetWideTally(120, 40)` exercised nothing. The asserted strings were static literals, insensitive to all three of the numbers the fixture exists to make disagree. - **The negative guard was pinned to two literal sentences.** `/every repository (?:listed above|below)/u` caught only the phrasings #331 happened to ship. An equivalent rewording would have reintroduced #344 with a green suite. ## What changed Only `.github/scripts/claude-lane-incident.test.cjs`. No production copy or logic is touched. **The gate is now the assertion.** From the same fleet-wide state the test renders, it derives the tracked index and calls `coverageGap` over every tracked repository — which is still non-null, with `unobserved: []` and `unlisted === 60`. That is the condition the copy claims: complete coverage needs the polled scope **and** the index's own accounting, so an incident wider than the tracked cap never gets a counting clean cycle however much was polled. Copy and gate are held to one predicate instead of two independent restatements of it. **The fixture is no longer inert.** `repositoriesSeen === 120`, `tracked.length === 60`, and `tracked.length > 40` are asserted against the state rather than assumed, and `unlisted` is derived (`repositoriesSeen - tracked.length`) rather than hardcoded. **The guard is widened to the defect's shape** — a counting rule tied to a position in the document rather than to the tracked index — and a second test holds it to the paraphrases a future editor would plausibly reach for. It is bounded to a single sentence so the copy's own contrastive "not the table above", a separate sentence, stays legal. **The false comment is corrected.** The old annotation claimed "The three sets differ exactly when the copy matters", which was true of the fixture and false of the assertions it annotated. ## Verification - `node --test .github/scripts/*.test.cjs` — 513 pass, 0 fail on the branch; 512 pass, 0 fail on `origin/main`, both measured directly rather than carried over. - `npx @biomejs/biome@2.5.4 ci --config-path=fixtures/typescript/good/biome.json --error-on-warnings .github/scripts` — clean, matching CI's biome job at the version its action pins. - `comment-hygiene` scan — 12 violations with and without this change: an unchanged pre-existing baseline, zero delta from this PR. **Mutation-tested, because a green suite is exactly what the old test proved and it was not evidence:** - Collapsing the fixture to `fleetWideTally(1, 1)` now **fails** (`1 !== 120`). Against the merged head's test, the identical collapse passed all five assertions. - Rewording the copy to "it polled every repository in the table above" now **fails**. Checked directly against that mutated body, the old guard returns `false` and the widened guard returns `true` — the exact phrasing the reviewer named as the hole. ## Body budget No copy changed, so the 65536 budget is untouched. Recording the re-measure #350 owed but never wrote down, since the question will come up again: `fixedLength` (`claude-lane-incident.cjs:737-742`) deducts the actual prologue and epilogue length from the table allowance, so copy growth costs rendered rows rather than headroom — the bound is structural, not a margin to be spent. Re-measured across 8,568 incident shapes at #350's two heads: zero over-limit bodies, and the worst case moved 65486 → 65487 of 65536 across the step-4 copy growth that prompted the concern. Two notes for whoever edits this copy next. The reviewer's "65433 at 120×10, 103 characters of headroom" did not reproduce in the shapes swept here (120×10 measures 64364, or 64786 at nine-digit pull numbers); hostile `statusCounts` crossed with that shape was not swept, so the figure is not contradicted, just unconfirmed. Separately, the body-budget test drives three shapes — 40×10, 120×40, 200×300 — and none is near the true worst case, which sits at 65487 of 65536 (49 characters of headroom) at 54×14 with nine-digit pull numbers. Tightening that test is out of scope here and is tracked in #360, together with the two copy gaps whose fixes would grow this region. Closes #357 ## Related - #350 — merged the copy fix and the assertion this PR replaces. - [Independent review: outstanding items before merge](#350 (comment)), item 1. - [Addendum from a second independent reviewer](#350 (comment)), item 2. - #344 — the original defect, whose class this test now actually detects. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sentence's other clause was equally stale: the mint step requests permission-issues: read since #331, so "whether it narrows is implementation" is a settled question, not an open one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCJfVqDNYt92YRyvKUMgYW
… (#391) One-sentence fix: the Approval-record narrowing paragraph described ci-workflows#331 as "the open PR moving the incident write to the ambient token"; that PR merged (`a90ff43`). Present tense corrected with the merge SHA recorded. Surfaced by the fresh-context verifier on #390 as an out-of-diff observation in the exact region the Phase 4 close-out amendment re-activates. ## Related No linked issue. For reference: #390, #331. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HCJfVqDNYt92YRyvKUMgYW --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Summary
The aggregator maintains its incident issue in this repository, so every
create, update, and close is a same-repo write:
create-issue-from-fileleavesrepositoryat its default of${{ github.repository }}, and the close stepnames
context.repo. A same-repo issue write needs nothing butissues: writeon the ambient
GITHUB_TOKEN(workflow-syntax,
the official example),
so the aggregator can maintain its issue today, with no App and no new secret.
The App credential seam survives for the half that genuinely needs it. The
ambient token 404s on a private consumer repository (verified live against
melodic-software/medley), while public cross-repo reads succeed:GITHUB_TOKEN(always)CLAUDE_LANE_INCIDENT_APP_PRIVATE_KEY)The App token is minted read-only, because it authors nothing — and the
audit now refuses a minted write permission anywhere in the file, because a
token minted with one is authority
permissions:does not govern.The design: the write is its own job
Granting
issues: writeretires the guarantee that rested on the job holding nowrite scope at all. That guarantee is restored rather than replaced, by
splitting the workflow:
pollgithub-scriptsteps, the shell, the dry-run report. Renders the incident body and hands it on as an artifact.writeissues: write(+contents: read)create-issue-from-file, close a recovered incident. No checkout, norun:, no repository code.Nothing in
pollcan mutate anything with its own token, however its stepsare wired — so its steps do not have to be classified as safe. That is the whole
point, and it is why this shape replaces the previous one.
Two honest qualifications, both now stated in the workflow header rather than
implied away.
pollstill decides whatwriteacts on: the incident body,through the artifact it uploads, and the target issue, through the
issue-numberoutput — guarded only by a positive-safe-integer parse. And ajob's
permissions:govern only the ambient token, so a credential MINTEDinside
pollwould write regardless; that is why the credential surface ispinned rather than reasoned about.
The dry-run gate is
write's job-levelif:, written inline rather than sharedthrough a job env var because
envis not an available context injobs.<job_id>.if— that key sees onlygithub,needs,varsandinputs(context availability).
The dry-run report step in
pollcarries the exact negation, and a testextracts both operators and asserts they are opposed, because the shared
expression that used to guarantee it no longer exists.
!= 'true'fails open, deliberately: anything that is not exactly thestring
trueenables writes.github.event.inputsis absent onschedule, sothe hourly cycle writes; only a dispatch that explicitly asked for a dry run
does not.
An adversarial pass against the FIRST cut of this split found the mint rule
keyed on a single action name, so a different App-token action spent the same
allowlisted private key and minted
issues: writeinside the read-only job withthe audit reporting nothing. That is fixed on the right axis — the rule is now
over the SECRET, everywhere in the file, not over an action name — and the same
change closed a PAT bound in a workflow-level
env:, a job-levelenv:, or ajob
outputs:, none of which the step-walking scan had ever looked at.Why not keep hardening the previous gate
The previous revision kept
issues: writeon one 9-step job and tried to makethat safe by classifying every step — a hand-written YAML parser plus
bespoke shell-command and JavaScript-identifier scanners. Three independent
adversarial passes each found a fresh bypass of it:
if bash /tmp/x.sh; then …— the shell scan took the first word of afragment, so a compound-command keyword swallowed the real command
permission-issues: write— neveraudited at all, because that job's own
permissions:were read-onlygithub.paginate("DELETE /repos/…")— an allowlisted call whose route is astring literal, and string literals were blanked by design
/[/*]/and/`/— regex literals the code-stripper mislexed, silentlyblanking real code
const send = true ? fetch : null— a ternary's colon disguising an evaluatedidentifier as an object key
- name:decoyin a block scalar, implicit-POST
curl,github.restaliasing, local./action)
That is not a run of unlucky misses. A hand-rolled YAML parser plus custom shell
and JS scanners is a larger trusted computing base than the thing it
protects, and it kept yielding. So the scanners are deleted, not fixed.
What the audit does now
claude-lane-incident-write-gate.cjsno longer classifies steps. It proves theshape:
every job's effective permissions are resolved — a job with no
permissions:inherits the workflow block;permissions: write-allis astring, not a mapping, so a mapping-scan would read it as granting nothing;
and permissions that cannot be resolved from the file are treated as write;
exactly one job is write-scoped, and it is
write. This is a POSITIVEcount: zero write-scoped jobs is a hard failure, because "looked, found
nothing to check, passed" is the shape every bypass above produced;
that job matches
claude-lane-incident-write-job.pinned.ymlbyte forbyte, so any edit to it — of any kind — fails CI and is read by a person;
the
on:trigger block is pinned, so apull_request_targetcannot quietlystart running an
issues: writejob in an attacker-influenceable context;the
concurrencyblock is pinned, so parallel runs cannot be made to race oneissue body;
no job declares
container,services,defaults,strategy,environment,continue-on-error, a job-leveluses:, or a non-literalruns-on; every job declares a positivetimeout-minutes; no step declarescontinue-on-error;every
uses:is a pinnedowner/repo@<40-hex sha>;every credential reference in the whole file — the
secretscontext inany spelling, and
github.token— lies inside one of two pinned regions — the write job, or the mint step, which is pinned byte-for-byte forthe same reason. The match is on the bare WORD
secrets, case-insensitively:expressions reach a context by property de-reference or by the
[ ]indexoperator, so
secrets['NAME']names a secret without a dot, andtoJSON(secrets)names none of them while dumping all of them. Matching theword covers every spelling at once rather than enumerating them.
Every rule is pinned by a fixture. Verified by neutering each of the audit's
22
violations.pushsites one at a time and confirming a fixture stopsmatching its asserted message — a rule no fixture pins is a rule that could be
deleted with CI staying green. Two poll-side expressions are permitted outside them and are
themselves pinned as whole expressions: the
!= ''presence probe, whichdiscloses only whether a secret is set, and the read fallback, which binds the
ambient token into a job already proved to hold no write scope.
The YAML parser is kept — a verifier explicitly failed to break it, and
resolving permissions and enumerating jobs needs a real parse. Everything else
is gone: the identifier allowlist, the shell-command allowlist, the member-path
pins, the reflection rules, and the expression evaluator.
Test plan
node --test .github/scripts/*.test.cjs— 475 pass, 0 fail (was 422).actionlint— clean.biome ci --error-on-warnings .github/scripts(v2.5.4, asci.ymlruns it) — clean.zizmor— advisory findings unchanged before/after; all pre-existing.comment-hygiene,
editorconfig-checker,typos— no finding in a changed file.Fixture corpus: 43 complete workflows under
.github/scripts/fixtures/claude-lane-incident-write-gate/, each fed to theaudit's entry point and asserting the message its own rule produces — so a
fixture rejected for failing to parse cannot be mistaken for one that tripped
its rule.
base.ymlconforms (zero violations), so every other fixturediffers from a passing workflow by exactly one property. Corpus entries are
architecture-independent: they outlive a redesign of the audit in a way a test
named after a defeated exploit does not.
Coverage: the six original exploits, every bypass found since, and each
carry-forward — inherited permissions, the
write-allstring form, areusable-workflow job, a minted write permission, a mint by a DIFFERENT
App-token action, a PAT in a job
env:/ a workflowenv:/ a joboutputs:,continue-on-erroron a job and on a step, a rewrittenconcurrency, aremoved timeout, an added trigger, a non-literal runner, a shell override, an
unpinned
uses:, a YAML anchor, and zero write-scoped jobs.Parser, differentially validated against
yqv4.53.3: 75 of 76 repoYAML files produce a byte-identical JSON projection, plus 36 of 36
hand-built shapes. The exception is
standards-sync.yml, which uses a realYAML anchor and is rejected loudly rather than approximated. Two divergences
the fuzzing found (
007/1e3scalar resolution,|+chomping) are fixed.Verified rather than assumed
envis not available injobs.<job_id>.if— checked against thecontexts-availability table, which is what forced the gate inline.
actions/github-scriptreadsgithub-tokenwithrequired: true(checkedagainst the pinned revision's own
src/main.ts), so a script step cannot behanded an unauthenticated client. That is part of why
writeis byte-pinnedrather than analyzed.
issue-numberreachescreate-issue-from-fileasNumber('') === 0,which is falsy, so it takes the CREATE branch — the first-ever incident opens
an issue rather than erroring. Read from the pinned revision's own
src/main.ts, and unchanged from the pre-split behavior.download-artifactneeds nopermissions:scope: the toolkit'sartifact package requires
actions: readonly for the cross-run/cross-repofindBypath. This is inferred from that documented split rather than from anexplicit "same-run needs nothing" statement, so it is flagged as an inference.
contents: readalongsideissues: write. Neithercreate-issue-from-filenordownload-artifactdocuments its scoperequirement, the single-job form ran them with it, and a
permissions:blocksets every unlisted scope to
none. Dropping it on an inference would trade asecurity property worth nothing — a read cannot mutate — for a runtime failure
on a schedule nobody is watching.
issuesremains the only write, which isthe property the audit enforces.
Related
No linked issue — this closes nothing on its own. It relates to #228 and #238:
it removes the credential blocker that kept the Phase 4 watchdog dry-run-only,
while leaving the App seam in place for the private cross-repository reads those
issues still need.
Follow-ups this PR does not do:
.github/CODEOWNERSnames an owner for.github/scripts/**and thisworkflow, because the contract lane globs
*.test.cjs— a deleted test fileyields fewer tests and still reports green, and no assertion survives its own
deletion. Requiring that review is a ruleset setting owned by github-iac.
claude-lane-incidentlabel is applied on creation only and must bedeclared in github-iac's
Labels.cs, or the next apply prunes it. Issueselection never keys on the label, so a pruned label degrades a sanity query
and nothing else.
🤖 Generated with Claude Code