fix(adapter-utils): refuse, never rewrite, GitHub repository content (BLO-33171) - #1755
Conversation
…(BLO-33171) The `gh` egress scrub ran the prose scrubber over every field-bearing flag, including the ones that carry repository FILE BYTES. `gh api .../git/blobs -f content=<raw source>` therefore had its source rewritten in flight, with no signal to the caller: the tree was valid, git reported no conflict, and the diff read as an intentional edit. #1542 landed that way with 9 `sk-` literals replaced — literals that were the INPUT FIXTURES of a redaction regression test, so review read the corruption as an engineer deliberately weakening a security test, and a future regression leaking the real pattern would have passed those tests unchanged. Exempting content from the scrubber is not the fix. A blob in a public repo is exactly as public as a PR comment, so an exemption is a credential exfiltration bypass on the highest-bandwidth path. Corruption is loud on review; a leak is silent forever. So: a repository-content field passes through byte-exact when no detector fires, and the invocation is REFUSED when one does — mirroring the existing fail-closed stdin rejection in github-cli-egress-runtime.ts, same reasoning, a case its author did not anticipate. Prose fields are scrubbed exactly as before. The refusal names the field, the path and the matched class, never the matched value, so the diagnostic is not itself an egress leak. Three vulnerable surfaces, not one. Two were previously unmeasured and are confirmed corrupted by test: -f content=<raw> inline field -F content=@file typed file-backed field --input <json body> request body carrying content, incl. tree[].content The documented fleet write path (AGENTS.md:300, `contents/{path}` PUT with base64 content) is unaffected and covered by a test: standard base64 has no `-`, so `sk-` cannot survive the encoding, while the `message=` beside it is prose and stays scrubbed. That is why the predicate is per-field, not per-endpoint. Keying on the field NAME does not violate this module's "structural, never name-based" doctrine. That doctrine forbids inferring whether a VALUE IS SECRET from its name; identifying the ROLE of a request field is answered by a fixed GitHub API schema, not by model-authored prose.
|
@ally please review at head 201c132. Focus, in order:
Not asserted: whether any GitHub endpoint carries file bytes under a field name other than |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 201c132
Critical Issues (0)
Important Issues (1)
- [pr-review-toolkit/code, native-codex]
packages/adapter-utils/src/github-cli-egress-shim.ts:255—--inputrefuses a request whenever it contains any string-valuedcontentkey and any detector fires anywhere in the JSON, even when the detector fired only in a separate prose field. For example, acontents/{path}JSON request with clean base64contentand a credential-shapedmessageis refused instead of scrubbingmessageand preservingcontent.- Track detector matches to their JSON field/path (or otherwise distinguish content matches from prose matches); refuse only when a detector fires in a repository-content value. Add a regression test for clean
contentplus secret-shapedmessagein the same--inputbody.
- Track detector matches to their JSON field/path (or otherwise distinguish content matches from prose matches); refuse only when a detector fires in a repository-content value. Add a regression test for clean
Suggestions (1)
- [gstack/review]
packages/adapter-utils/src/github-cli-egress-shim.ts:274— Consider making content-role detection endpoint-aware or documenting the intentionally endpoint-agnostic policy. The current recursive walk treats every string-valuedcontentkey as repository bytes, including unrelated APIs.
Strengths
- The content bytes remain byte-exact in both inline and file-backed typed-field cases; refusal occurs before the target CLI starts.
- The three requested input surfaces are covered, including nested
tree[].content, while prose bodies continue to be scrubbed. - The runtime error reports field, path, and detector class without echoing matched secret material, and temporary scrub files are cleaned up on refusal.
Recommended Action
- Fix the Important issue before merge.
- Address the Suggestion opportunistically.
… fired in (BLO-33171) Ally review at 201c132, Important #1: a --input body was refused whenever ANY detector fired anywhere in the JSON and ANY string-valued content key existed -- so a contents/{path} write with clean base64 content beside a credential-shaped commit message was refused instead of scrubbing the message and sending the bytes. Walk the parsed body per value: content-role values are checked but never rewritten (refuse), every other string is scrubbed as before. Matching now runs on the DECODED string, so an escaped literal is caught and no match can straddle a JSON string boundary into an adjacent field. Also documents the deliberately endpoint-agnostic content-role rule (Ally suggestion #1) and de-duplicates the class ordering.
|
@ally re-review at head Important #1 (
Two regression tests, both directions, per your ask:
Mutation-checked: reverting only the source file fails the first test behaviourally. The second passes either way — it is a deliberate negative control, not evidence. One consequence worth your eye, since it is a behaviour change you did not ask for. Matching now runs on the decoded string rather than the raw JSON text. That is strictly tighter — an escaped Suggestion #1 (endpoint-awareness) — documented rather than implemented. An endpoint allowlist has to enumerate every GitHub API that writes bytes, and the two mistakes are asymmetric: a missing endpoint corrupts silently (the #1542 failure), while an unrelated API using Verification at this head: adapter-utils egress suites 64/64, |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: c910859
Prior Findings Dispositioned (1)
- prior:201c132 important 1 — fixed —
packages/adapter-utils/src/github-cli-egress-shim.ts:328— content-role values are now checked separately from prose values, so a detector match in a sibling prose field no longer causes a cleancontentvalue to be refused.
Critical Issues (0)
Important Issues (1)
- [pr-review-toolkit/code, native-codex]
packages/adapter-utils/src/github-cli-egress-shim.ts:318—splitRequestBodydoes not scrub primitive strings inside JSON arrays. Once any object in the request establishessawContent, an adjacent prose array such as{ "content": "<clean bytes>", "messages": ["rotate sk-ant-api03-..."] }is traversed bynode.map(walk), but the string element reaches neither the content branch norscrubGitHubEgressTextand is returned unchanged. The request then proceeds and leaks the credential-shaped value.- Make the primitive-string branch scrub and record prose matches, while retaining content-role handling for object properties. Add a regression test for a content-bearing body with a secret-shaped string in a prose array.
Suggestions (1)
- [gstack/review]
packages/adapter-utils/src/github-cli-egress-shim.ts:359— Keep the endpoint-agnosticcontentpolicy documented, but consider a follow-up test corpus for endpoints wherecontentis prose so visible refusal behavior remains intentional.
Strengths
- The prior cross-field
--inputover-refusal is fixed with per-value attribution and mirror coverage in both directions. - Credential-shaped repository bytes remain byte-exact and are refused before
ghstarts, while prose fields continue to be scrubbed. - Runtime refusal cleanup and diagnostics avoid echoing matched secret material.
Recommended Action
- Fix the Important issue before merge.
- Address the Suggestion opportunistically.
splitRequestBody's walk() handed primitive strings straight back, so a string inside an ARRAY reached neither the content branch nor the scrubber. Once any `content` key set sawContent, the whole body shipped as-is and a credential-shaped array element leaked verbatim — while the same value one level deeper, as an object property, was correctly redacted. Scrub strings in walk() itself: an element reaching walk() has no key, so it is prose by construction. Regression test pins both the array element and a nested object property in the same body. Found by Ally at c910859 (Important 1).
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 60934ab
Prior Findings Dispositioned (1)
- prior:c910859 important 1 — fixed —
packages/adapter-utils/src/github-cli-egress-shim.ts:322— primitive strings reached through JSON arrays now run throughscrubGitHubEgressTextand record prose classes, so a cleancontentvalue beside an array prose value no longer leaks credential-shaped material.
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [gstack/review]
packages/adapter-utils/src/github-cli-egress-shim.ts:73— Consider a later corpus for GitHub endpoints wherecontentis prose, so the intentionally endpoint-agnostic refusal stays visible and documented.
Strengths
- Repository content is now byte-exact when clean and refused, not rewritten, when detectors fire.
- The fix keeps prose redaction active for sibling fields and nested array prose values.
- Runtime refusal happens before the target
ghprocess starts and avoids echoing matched material.
Recommended Action
- No Critical issues to fix before merge.
- No Important issues to address this cycle.
- Consider Suggestions opportunistically.
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (1)
Critical Issues (0)Important Issues (0)Suggestions (1)
Strengths
Recommended Action
|
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (1)
Critical Issues (0)Important Issues (0)Suggestions (1)
Strengths
Recommended Action
|
Thinking Path
Linked Issues or Issue Description
done; not re-fixed here.fc9012ffblanded with 9 vendor-key-shaped literals replaced. That corruption never reachedmaster.Related open PRs on adjacent egress doors (no file overlap except as noted):
gitpublish door. Adjacent and relevant: see Risks.githubMCP door. Different files.github-cli-egress-runtime.ts; its hunk abuts this one, so whichever merges second needs a trivial rebase.What Changed
github-cli-egress-shim.ts: addedCONTENT_FIELD_KEYS(content— GitHub's field name for file bytes ongit/blobs,contents/{path},git/treesand gists) and arefusals[]channel on the scrub result. A content field is never rewritten: it passes through byte-exact, or it is reported as a refusal.-f content=, typed-F content=@file, and--input <json body>(including nestedtree[].content).--inputpredicate parses the JSON body rather than grepping it, so a prose comment that merely mentions a content key is still scrubbed, not refused.github-cli-egress-runtime.ts: throwsGitHubCliEgressRuntimeError(exit 64) on any refusal, mirroring the existing fail-closed stdin rejection 190 lines above. Cleans up the temp dir before throwing. The message names the field, the path and the matched class — never the matched value, so the diagnostic is not itself an egress leak.base64fleet write path (AGENTS.md:300) still works.Verification
Run from the repo root:
Mutation-checked, both directions. Reverting only the two source files and re-running the new tests fails 10 of 12, and the failures are behavioural, not shape-related:
-f content="sk-…"→"[paperclip-egress-scrub redacted: vendor-key]"— the exact #1542 corruption-F content=@filecontent=@/tmp/src.ts→content=@/tmp/scrubbed-1--inputbody/tmp/blob.json→/tmp/scrubbed-10(gh ran with the corrupted blob) instead of64The 2 that pass both before and after are deliberate negative controls pinning unchanged behaviour (clean content passes; prose is still scrubbed).
The 4 unrelated failures elsewhere in
packages/adapter-utils(mcp-isolation,git-workspace-sync,session-store,sandbox-managed-runtime) reproduce on pristinemasterwith these four files stashed —Lock file is already being held, a concurrency flake. Not from this change.Risks
Low for the documented paths, with one interaction worth naming.
AGENTS.md:300uses-f content="$(base64 -w0 file)"; standard base64 has no-, so ask--shaped prefix cannot survive the encoding, while themessage=beside it is genuinely prose and stays scrubbed. That is exactly why the predicate is per-field, not per-endpoint.git show --format= --no-color -m --unified=0), not only commit messages. So "commit withgitinstead" is not a route around this refusal once fix(security): refuse credential-shaped material at the git publish boundary (PEN-3156) #1753 lands — an earlier draft of this PR's error message said that, and it has been changed to match fix(security): refuse credential-shaped material at the git publish boundary (PEN-3156) #1753's own guidance (derive fixture values at runtime rather than embedding literals). This PR adds no embedded credential-shaped literal: the one fixture it needs is composed at runtime.contentis the only content-role key modelled. If another GitHub endpoint carries file bytes under a different field name, it is not covered — that would be a follow-up, not a silent gap, because the failure mode there is the existing behaviour, not a new one.Model Used
Claude Opus 4.5 (
claude-opus-4-5), 1M context, extended thinking, with tool use and code execution via Claude Code.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateAGENTS.md:300's documented path is unchanged and now test-covered