Resolves #16535
Resolves #16660
The guard sits one layer up from where the ticket put it
The ticket asks for the check in validate_pr_review_body plus the PR-body lint plus each public surface. toolService.mjs already classifies every GitHub tool, and the public set is derived, not hand-listed:
const PUBLIC_GITHUB_WRITE_TOOLS = Object.freeze(new Set(
Object.entries(GITHUB_TOOL_ACCESS)
.filter(([, access]) => access === PUBLIC_GITHUB_WRITE_ACCESS)
.map(([toolName]) => toolName)));It resolves to exactly the surfaces the AC names, plus siblings a hand-list misses (signal_state_transition, update_issue_relationship), and assertNoUnclassifiedGitHubTools already fails closed on a tool that forgets to classify itself.
So the guard composes into guardGitHubWriteTools and inherits a completeness guarantee per-service wiring cannot have: a public-write tool added next year is scanned automatically. Wiring six services by hand would leave surface seven unguarded and silent — the ticket's own recurrence mechanism, one level up. This is its keystone rule (fix the generator, not the output) applied where it bites: the generator is dispatch, not the validator.
Confidentiality is the outer guard — a body carrying a client name is refused before an identity assertion makes a network call on its behalf.
Target visibility, not tool class
@neo-opus-vega's review catch, and it decides a condition: owner is an overridable leaf, so public-write describes what a tool mutates on github.com, not whether the repository is public. They coincide only while the leaf holds its default — and a private repo is the sanctioned home for client specifics. An unconditional block would refuse the one surface allowed to carry them.
isPrivate rides the existing boot query. repository was already the selection root, so this costs one field on a call that already happens once — not a lookup per write. That was the probe that decided it; the alternative "already cached for free" turned out to be false in one direction and "a network call per write" false in the other.
Four outcomes, because three of them are not a pass
| outcome |
meaning |
blocked |
matched — token and original offset, so the author scrubs |
clean |
scanned against a configured list, nothing matched |
unchecked |
no denylist configured — enforcement did not run |
skipped |
target is private |
The replaced defect was a validator returning a bare pass on a body it never examined, so every non-blocking outcome names its reason and a caller cannot read did not block as was checked without discarding a field.
reason further separates target-public from target-unknown. Behaviour identical, diagnosis opposite — unknown visibility is persistent, not a startup blip (a token without repo-metadata scope never resolves it), and an operator told only "token matched" would redact a legitimate name, be blocked again on the next one, and have nothing pointing at the token scope. The refusal message names the assumption.
OQ1 — resolved
Config leaf, empty in-repo, env-populated. Committing the list publishes the names it protects. Hashing does not rescue that: an unkeyed hash of a company name is brute-forceable from any wordlist of company names, and a keyed HMAC reintroduces the env dependency, collapsing into the chosen option with extra machinery.
The leaf's csv type is load-bearing and I got it wrong first: I wrote 'stringArray', which is not a registered parser. The registry documents that an unknown token validates as true and passes through — so the env var would never parse, the denylist would be permanently empty, and the guard would report unchecked forever while looking configured. That is this ticket's defect, reintroduced by a typo. Caught by reading the parser registry rather than trusting a grep that matched only my own line.
Deltas
| # |
delta |
| 1 |
confidentialTokenScanner.mjs — four outcomes, case/separator-insensitive matching, offsets mapped back through the fold |
| 2 |
guard composed into guardGitHubWriteTools, keyed on the derived set |
| 3 |
isPrivate on the existing boot query; repositoryVisibility cached beside permission |
| 4 |
confidentialTokenDenylist leaf — empty in-repo, csv, env-populated |
| 5 |
validate_pr_review_body carries the scan as a pre-publication advisory, outcome in its own field |
| 6 |
projectScanForPublicLog — REMOVES the matched token for world-readable sinks (#16660) |
| 7 |
lintPublicBodyConfidentiality.mjs + a job on both body-lint workflows — the one surface MCP dispatch cannot see |
Config surface change — clone-sync guidance
config.template.mjs derives from configBase, so the new leaf reaches the template surface automatically; ai/scripts/lint/config-leaf-parity.json records it in the same commit, which is what makes a config surface change reviewable rather than silent.
| item |
answer |
| Changed config key |
confidentialTokenDenylist — ai/mcp/server/github-workflow/configBase.mjs, leaf([], 'NEO_CONFIDENTIAL_TOKEN_DENYLIST', 'csv') |
Local config.mjs follow-up |
None required. Local configs subclass the same ConfigBase, so they inherit the leaf. No hand-edit, and no gitignored config.mjs is committed here. |
| Harness restart |
Recommended, not required. Nothing breaks without one — the leaf defaults empty, so the guard is inert and reports unchecked. A restart is what makes the surface exist in a running clone. |
| Live MCP behaviour in other clones |
Unchanged until an operator sets the env var. The one visible difference is validate_pr_review_body reporting confidentiality.outcome. |
Peer A2A sent, per the MCP config template change guide.
Test Evidence
Evidence: 26/26 green — 17 scanner (incl. 3 public-log projection), 9 guard.
The CI half covers the surface MCP dispatch structurally cannot: a body written with gh pr create --body-file reaches no MCP tool. Its disclosure contract is inverted — a public workflow log means the matched token must NOT be printed, the opposite of the local rule — so projectScanForPublicLog removes the token key rather than blanking it, and the spec asserts on the serialised shape. Mutation: making the projection carry the token reddens it.
Fork pull_request runs receive no secrets, so the scan reports NOT ENFORCED and exits 0 there, stated in the output on every such run rather than passing silently.
Mutation-proved on all three safety properties, since a green confidentiality test that cannot fail on the defect is worse than none:
| mutation |
reddens |
unchecked collapses into clean (the original defect) |
4 |
| unknown visibility resolves permissively (skip instead of scan) |
3 |
target-unknown reason collapses into target-public |
2 |
Inverse controls present throughout: a clean public body passes, a private target delegates, an unconfigured list does not block. Without them a guard that refused everything would satisfy every blocking assertion.
ConfigCompleteness.spec.mjs has one failing logger-priority assertion — pre-existing, verified by stashing: 1 failed / 5 passed identically with and without this branch.
Post-Merge Validation
With no NEO_CONFIDENTIAL_TOKEN_DENYLIST set, behaviour is unchanged except that validate_pr_review_body now reports confidentiality.outcome: "unchecked" — the intended visible signal that enforcement is not yet configured. Setting the env var activates it; a public write carrying a listed token is then refused with the token, its offset, and the reason.
Out of Scope
resources/content/ mirrors. Git-tracked and a public surface in their own right, reached by the sync pipeline rather than MCP dispatch. Plausibly covered transitively — content that never reaches GitHub cannot be mirrored back — but that is an inference about the pipeline's only source. Neither @neo-opus-vega nor I verified it, and it is stated that way rather than upgraded to a covered surface by whoever reads this next.
- Historical remediation and revision-history residue — operator-directed sweeps, per the ticket.
Related
#16520 (the review body where this surfaced).
Authored by Grace (Opus 5, Claude Code). Session 9ced67a1-8f21-4da2-a1bf-a2a968c47ed2.
Dropped — 928 lines for a rule that is one line in per-turn memory
Operator disposition: Drop + Supersede. Correct, and the measurement is the argument.
928 added, 3 deleted
§critical_gates #9 — no client names in public-facing artifacts — is already loaded into every agent turn. I built a 223-line scanner with four outcomes, a dispatch-layer guard, a public-log projection, a config leaf, two CI jobs and 26 specs on top of a rule that is already active on every turn, and the guard enforces nothing today because no denylist is configured.
The Substrate Accretion Defense requires a substrate-mutation PR to either net-reduce loaded bytes or cite a decay-mitigation rationale — a sunset condition, a retirement trigger. I cited neither. This PR is the accretion that rule exists to prevent, and I did not apply it to myself.
What I still think is true, stated rather than swallowed
The recurrence evidence is real: three instances, three different agents, one of them straight through a mandatory validator. Discipline demonstrably failed. So I do not think the correct residue is zero.
But the proportionate residue is small and one-surfaced: a denylist check inside validate_pr_review_body, which is already a mandatory call, on the surface where the recurrence actually happened. Roughly 40 lines. Not a dispatch-layer guard for surfaces that have never leaked, and not a CI subsystem for a secret nobody has set.
The generalisable error
I sized the mechanism to the severity of the harm (legal) rather than to the rate of the failure (3 events) and the cost of the guard (permanent, on every public write, forever). Severity justifies caring; it does not justify arbitrary machinery. That is the same defect as tonight's other two — a saving I never measured, and a field derivable from the one beside it — and it is the third time in one session that documentation made an unjustified design look considered.
Disposition
- #16535 stays open, un-narrowed. @neo-opus-vega restated AC3 at my request for a PR that is now dropped — that restatement should be reverted or re-decided by him, and I will not touch his ticket again without his call.
- #16660 stays open. The Actions-log disclosure inversion and the fork-secret gap are real findings and outlive this PR.
- Nothing here is merged, so no revert is needed.
Grace (Opus 5, Claude Code). Session 9ced67a1-8f21-4da2-a1bf-a2a968c47ed2.
Resolves #16535 Resolves #16660
The guard sits one layer up from where the ticket put it
The ticket asks for the check in
validate_pr_review_bodyplus the PR-body lint plus each public surface.toolService.mjsalready classifies every GitHub tool, and the public set is derived, not hand-listed:const PUBLIC_GITHUB_WRITE_TOOLS = Object.freeze(new Set( Object.entries(GITHUB_TOOL_ACCESS) .filter(([, access]) => access === PUBLIC_GITHUB_WRITE_ACCESS) .map(([toolName]) => toolName)));It resolves to exactly the surfaces the AC names, plus siblings a hand-list misses (
signal_state_transition,update_issue_relationship), andassertNoUnclassifiedGitHubToolsalready fails closed on a tool that forgets to classify itself.So the guard composes into
guardGitHubWriteToolsand inherits a completeness guarantee per-service wiring cannot have: a public-write tool added next year is scanned automatically. Wiring six services by hand would leave surface seven unguarded and silent — the ticket's own recurrence mechanism, one level up. This is its keystone rule (fix the generator, not the output) applied where it bites: the generator is dispatch, not the validator.Confidentiality is the outer guard — a body carrying a client name is refused before an identity assertion makes a network call on its behalf.
Target visibility, not tool class
@neo-opus-vega's review catch, and it decides a condition:
owneris an overridable leaf, sopublic-writedescribes what a tool mutates on github.com, not whether the repository is public. They coincide only while the leaf holds its default — and a private repo is the sanctioned home for client specifics. An unconditional block would refuse the one surface allowed to carry them.isPrivaterides the existing boot query.repositorywas already the selection root, so this costs one field on a call that already happens once — not a lookup per write. That was the probe that decided it; the alternative "already cached for free" turned out to be false in one direction and "a network call per write" false in the other.Four outcomes, because three of them are not a pass
blockedcleanuncheckedskippedThe replaced defect was a validator returning a bare pass on a body it never examined, so every non-blocking outcome names its reason and a caller cannot read did not block as was checked without discarding a field.
reasonfurther separatestarget-publicfromtarget-unknown. Behaviour identical, diagnosis opposite — unknown visibility is persistent, not a startup blip (a token without repo-metadata scope never resolves it), and an operator told only "token matched" would redact a legitimate name, be blocked again on the next one, and have nothing pointing at the token scope. The refusal message names the assumption.OQ1 — resolved
Config leaf, empty in-repo, env-populated. Committing the list publishes the names it protects. Hashing does not rescue that: an unkeyed hash of a company name is brute-forceable from any wordlist of company names, and a keyed HMAC reintroduces the env dependency, collapsing into the chosen option with extra machinery.
The leaf's
csvtype is load-bearing and I got it wrong first: I wrote'stringArray', which is not a registered parser. The registry documents that an unknown token validates as true and passes through — so the env var would never parse, the denylist would be permanently empty, and the guard would reportuncheckedforever while looking configured. That is this ticket's defect, reintroduced by a typo. Caught by reading the parser registry rather than trusting a grep that matched only my own line.Deltas
confidentialTokenScanner.mjs— four outcomes, case/separator-insensitive matching, offsets mapped back through the foldguardGitHubWriteTools, keyed on the derived setisPrivateon the existing boot query;repositoryVisibilitycached beside permissionconfidentialTokenDenylistleaf — empty in-repo,csv, env-populatedvalidate_pr_review_bodycarries the scan as a pre-publication advisory, outcome in its own fieldprojectScanForPublicLog— REMOVES the matched token for world-readable sinks (#16660)lintPublicBodyConfidentiality.mjs+ a job on both body-lint workflows — the one surface MCP dispatch cannot seeConfig surface change — clone-sync guidance
config.template.mjsderives fromconfigBase, so the new leaf reaches the template surface automatically;ai/scripts/lint/config-leaf-parity.jsonrecords it in the same commit, which is what makes a config surface change reviewable rather than silent.confidentialTokenDenylist—ai/mcp/server/github-workflow/configBase.mjs,leaf([], 'NEO_CONFIDENTIAL_TOKEN_DENYLIST', 'csv')config.mjsfollow-upConfigBase, so they inherit the leaf. No hand-edit, and no gitignoredconfig.mjsis committed here.unchecked. A restart is what makes the surface exist in a running clone.validate_pr_review_bodyreportingconfidentiality.outcome.Peer A2A sent, per the MCP config template change guide.
Test Evidence
Evidence: 26/26 green — 17 scanner (incl. 3 public-log projection), 9 guard.
The CI half covers the surface MCP dispatch structurally cannot: a body written with
gh pr create --body-filereaches no MCP tool. Its disclosure contract is inverted — a public workflow log means the matched token must NOT be printed, the opposite of the local rule — soprojectScanForPublicLogremoves the token key rather than blanking it, and the spec asserts on the serialised shape. Mutation: making the projection carry the token reddens it.Fork
pull_requestruns receive no secrets, so the scan reports NOT ENFORCED and exits 0 there, stated in the output on every such run rather than passing silently.Mutation-proved on all three safety properties, since a green confidentiality test that cannot fail on the defect is worse than none:
uncheckedcollapses intoclean(the original defect)target-unknownreason collapses intotarget-publicInverse controls present throughout: a clean public body passes, a private target delegates, an unconfigured list does not block. Without them a guard that refused everything would satisfy every blocking assertion.
ConfigCompleteness.spec.mjshas one failing logger-priority assertion — pre-existing, verified by stashing: 1 failed / 5 passed identically with and without this branch.Post-Merge Validation
With no
NEO_CONFIDENTIAL_TOKEN_DENYLISTset, behaviour is unchanged except thatvalidate_pr_review_bodynow reportsconfidentiality.outcome: "unchecked"— the intended visible signal that enforcement is not yet configured. Setting the env var activates it; a public write carrying a listed token is then refused with the token, its offset, and the reason.Out of Scope
resources/content/mirrors. Git-tracked and a public surface in their own right, reached by the sync pipeline rather than MCP dispatch. Plausibly covered transitively — content that never reaches GitHub cannot be mirrored back — but that is an inference about the pipeline's only source. Neither @neo-opus-vega nor I verified it, and it is stated that way rather than upgraded to a covered surface by whoever reads this next.Related
#16520 (the review body where this surfaced).
Authored by Grace (Opus 5, Claude Code). Session 9ced67a1-8f21-4da2-a1bf-a2a968c47ed2.
Dropped — 928 lines for a rule that is one line in per-turn memory
Operator disposition: Drop + Supersede. Correct, and the measurement is the argument.
§critical_gates #9 — no client names in public-facing artifacts — is already loaded into every agent turn. I built a 223-line scanner with four outcomes, a dispatch-layer guard, a public-log projection, a config leaf, two CI jobs and 26 specs on top of a rule that is already active on every turn, and the guard enforces nothing today because no denylist is configured.
The Substrate Accretion Defense requires a substrate-mutation PR to either net-reduce loaded bytes or cite a decay-mitigation rationale — a sunset condition, a retirement trigger. I cited neither. This PR is the accretion that rule exists to prevent, and I did not apply it to myself.
What I still think is true, stated rather than swallowed
The recurrence evidence is real: three instances, three different agents, one of them straight through a mandatory validator. Discipline demonstrably failed. So I do not think the correct residue is zero.
But the proportionate residue is small and one-surfaced: a denylist check inside
validate_pr_review_body, which is already a mandatory call, on the surface where the recurrence actually happened. Roughly 40 lines. Not a dispatch-layer guard for surfaces that have never leaked, and not a CI subsystem for a secret nobody has set.The generalisable error
I sized the mechanism to the severity of the harm (legal) rather than to the rate of the failure (3 events) and the cost of the guard (permanent, on every public write, forever). Severity justifies caring; it does not justify arbitrary machinery. That is the same defect as tonight's other two — a saving I never measured, and a field derivable from the one beside it — and it is the third time in one session that documentation made an unjustified design look considered.
Disposition
Grace (Opus 5, Claude Code). Session 9ced67a1-8f21-4da2-a1bf-a2a968c47ed2.