Skip to content

fix(security): refuse credential-shaped material at the git publish boundary (PEN-3156) - #1753

Queued
allyblockcast[bot] wants to merge 17 commits into
masterfrom
cto/pen-3156-git-push-egress-refusal
Queued

allyblockcast[bot] wants to merge 17 commits into
masterfrom
cto/pen-3156-git-push-egress-refusal

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip runs agents that author text, and every wrapper the Helm seed publishes into the agent sandbox is a door that text can leave through.
  • PEN-2526 established the cost concretely: a reviewer's malformed LLM output interpolated its own process environment mid-sentence, and every layer downstream published it verbatim — a GitHub App private key and the fleet's agent JWT signing secret, public for ~7h35m.
  • PEN-2527 answered with scrubGitHubEgressText in front of gh; PEN-3152 put the same scrubber in front of the github MCP server and added an outbound coverage table so a third door could not be added unclassified.
  • That table already names this one. git sits in the same seed block, holds the same seat token, and on a publish carries commit messages and file contents — a strict superset of what create_or_update_file / push_files carry.
  • It needs a different fix in kind, which is why it was filed separately rather than ridden along on PEN-3152: a commit object is content-addressed, so the in-flight rewrite both other doors rely on is unavailable here.
  • This pull request makes the door refuse instead of redact, and — the part that turned out to matter most — puts that refusal somewhere agents actually reach.

Linked Issues or Issue Description

What Changed

  • packages/adapter-utils/src/github-git-egress-shim.ts — pure logic: argv classification, pre-push stdin parsing, range derivation, per-commit scanning, and the refusal text. Git is reached only through an injected runGit callback, so every rule is testable without a repository, a remote, or a network.
  • packages/adapter-utils/src/github-git-egress-runtime.ts — two modes that must agree about what a publish is. As the wrapper it injects core.hooksPath and rejects --no-verify; as the hook it reads the ref updates git computes, scans them, and exits non-zero to abort.
  • Helm seed — the git wrapper now execs the runtime (inside the token wrapper, matching github-mcp-server), a pre-push hook is seeded, and git is symlinked onto the PATH-visible bin.
  • deploy/helm/paperclip/tests/agent-egress-path.test.mjs — three assertions against the rendered chart, one of which executes the seed's own publish step and resolves git through the rendered PATH.

Why a hook rather than scanning in the wrapper

Git already resolves refspecs, push.default, and tracking config to decide what it is about to send, and hands the result to the hook as exact <local sha> <remote sha> pairs. Re-deriving that in the wrapper would be a second implementation of those rules that could disagree with the publish actually taking place — and disagreeing in the permissive direction is a silent hole. The wrapper is still needed because a hook alone is one --no-verify away from off, and because something has to inject core.hooksPath.

core.hooksPath is injected only for a publish. Setting it globally would silently disable every other hook a repository defines, since a hooks directory holding only pre-push makes pre-commit and commit-msg stop running.

The finding that changed the design

Measured in a live agent Job pod before writing any code:

PATH=/paperclip/opencode-api-key-bin:/paperclip/bin:/usr/local/sbin:...:/usr/bin:...
command -v gh   ->  /paperclip/bin/gh   (-> /paperclip/.local/bin/gh)
command -v git  ->  /usr/bin/git

/paperclip/.local/bin — where the seed writes all five wrappers — is absent from that PATH. /paperclip/.local/bin/git existed and was byte-identical to the chart; /paperclip/bin/git did not exist. So the wrapper this issue is about never ran.

This is PEN-2527's own documented bug, unfixed for the second binary — the seed comment at statefulset.yaml:487 says ${LOCAL_BIN} is sourced only by a login shell and that agent harnesses spawn non-login shells. PEN-2527 fixed it for gh with a symlink into ${PATH_BIN}; git never got one. The chart's paperclip.runtimePath helper does validate both dirs ahead of /usr/bin, but it is included only by statefulset.yaml and deployment-api.yaml, and agent Job pods are rendered from neither.

Without the symlink half of this change, the rest is decorative. A render test pins it.

A correctness detail I nearly shipped wrong

The content leg scans the commit's added lines with the + markers stripped, not raw git show output. scrubGitHubEgressText's environment-dump detector anchors each assignment to the start of a line (^[ \t]*[A-Z][A-Z0-9_]{2,}=), and every added line in a patch arrives prefixed with + — so scanning the raw patch is blind to exactly the class that caused PEN-2526. The non-line-anchored detectors (PEM, JWT, vendor key, credentialed URI) would still have fired, which is what would have made this pass review while being half-blind to its own motivating incident. Reverting to the raw patch fails a test written for it.

Verification

Commands run, not inferred:

  • 68 unit tests pass across the two suites (30 at first review, plus the review round below). 12 chart render tests pass (9 pre-existing, 3 new).
  • Five mutations, all caught, each by the assertion intended for it: scanning the raw patch instead of added lines; dropping the --no-verify refusal; treating -c as a valueless flag so the subcommand shifts; removing the PATH symlink; reverting the wrapper to token-injection only. The tree was restored to green after each.
  • scripts/check-no-git-push.mjs passes. It first failed on a test fixture of mine, which is the check working; I reworded the fixture rather than reaching for the paperclip:allow-git-push marker, since that marker asserts an operator-approved publish path exists and none does for a test string.
  • Full packages/adapter-utils suite: 358 passed, 3 files failed to load with Cannot find package 'acpx/runtime'acpx-engine/* and mcp-isolation.integration, none touched here. Environmental: my sandbox borrows /app/node_modules, which lacks that package.
  • Typecheck is a judgement, not a clean result, and here is the falsifier. tsc --noEmit on packages/adapter-utils reports errors, but /app/node_modules/@types/node does not exist in this sandbox, so every remaining error in my files is Cannot find name 'process', Cannot find namespace 'NodeJS', Cannot find module 'node:*', or an implicit-any cascade from those. One real error was surfaced this way and fixed — a GitReader type imported from the runtime, which does not re-export it. The Build job is a full tsc against freshly compiled deps and is the check that settles this.

Review round — both Critical findings at 6228564 fixed

Ally raised two Criticals. Both were real, both are closed at b8ae369, and one of them corrected a design claim I had made in this description.

  1. Agent-controlled environment overrides (github-git-egress-runtime.ts). hooksDirectory() and gitBinary() read PAPERCLIP_GIT_EGRESS_HOOKS_DIR / PAPERCLIP_GIT_EGRESS_GIT and the deployed entrypoint called both with no argument — against process.env, which the agent controls. Pointing the first at an empty directory left core.hooksPath with no hook to run and the publish proceeded unscanned; the second aimed the hook's own reader at a binary of the caller's choosing, so a fake git reporting clean output cleared the scan. Neither is set by the chart. Removed rather than guarded, because the seam they existed for is already present and strictly better: runGitEgressRuntime takes hooksDir and runPrePushHook takes runGit as parameters. The tests now mutate process.env and assert the values do not move.

  2. Shell aliases — see the first Risks bullet; the measurement is the interesting part.

One thing I added that Ally did not ask for, because removing the override exposed it. git treats a hooks directory holding no executable pre-push as nothing to run, and the publish proceeds — indistinguishable downstream from a clean scan. While the path was overridable that was recoverable; as a constant it is not, and DEFAULT_HOOKS_DIR hardcodes /paperclip while the seed writes to {{ .Values.persistence.mountPath }}. Those are equal in every values file today, so this changes no behaviour now — it converts a future mount-path change from a silent hole into a refusal naming the missing file.

Mutation evidence, three mutations, each caught by the assertion written for it (tree restored green after each): reintroducing the process.env read → cannot be redirected by the agent-controlled environment; reverting shell aliases to pass-through → the three shell aliases refusal tests; dropping the hook-presence check → fails closed when the hook is not installed.

scripts/check-no-git-push.mjs flagged the new fixtures, as it did the first time. Reworded again rather than opted out: the expansions are assembled from parts, so the runtime string is byte-identical while the source carries no literal adjacency. A paperclip:allow-git-push marker asserts an operator-approved publish path, which a test string does not have.

The unrelated red on this PR

General tests (workspaces-a) failed at 6228564 on ui/src/components/IssueDocumentAnnotations.test.tsx. It is not caused by this change, and here is the evidence rather than the assertion: this PR touches no file under ui/, and git diff 4bff45c 6228564 -- ui/ is empty while the same range shows the adapter-utils changes as a positive control. That job passed on the earlier head 4bff45c of this same PR and on 60934ab (PR #1755) at 08:04Z. Identical ui/ content, opposite outcomes — nondeterministic. I have not re-run it; it should go green on the new head.

Risks

  • This widens what a paperclip-github-token-env failure takes down, and I want that on the record rather than buried. The git wrapper execs through it today, and publishing git onto the default PATH means agent pods now reach it for every git invocation, including local-only ones. That helper hard-refuses when GH_SEAT_TOKEN_VALUE is set-but-whitespace or carries embedded whitespace, so in that misconfiguration git status fails too, where previously only login shells were affected. I considered a second token-free wrapper for local commands and rejected it: the seed comment already explains that two copies of one wrapper drift into two different rules, and a guard that is bypassable by choosing the other spelling is not a guard. The misconfiguration is one that already breaks every gh call and every authenticated remote operation fleet-wide, so the marginal availability cost is bounded — but it is real, and it is a deliberate trade rather than an oversight. This corrects a promise I made on the issue that the failure mode would be kept off local-only git paths; it cannot be, without the drift.
  • Refusals are fail-closed and will occasionally be wrong. A legitimate fixture that is genuinely credential-shaped will be refused. That is the same trade the other two doors already make, and the refusal text points at deriving the value rather than embedding a literal, which is what the existing fixtures in this tree do.
  • All remotes are guarded, not only github.com. Scoping by remote URL would make git remote add the bypass. The cost is that a push to an unrelated remote can be refused.
  • Shell aliases are now refused, and the reasoning that let them through was wrong. This bullet previously recorded them as an accepted residual gap, on the grounds that refusing every one would break unrelated tooling. Ally challenged it and the measurement went against me: git prepends its exec-path to PATH for the shell it spawns, and /usr/lib/git-core ships a complete git binary. So inside a ! alias even a bare invocation resolves to the real git — no absolute path required — reaching neither the wrapper nor the hook. That is the worst shape available: an ordinary-looking git <name> that is silently unguarded. They are refused now. The cost I had asserted turned out to be zero when measured: no shell alias is defined in any config the agent image ships. Non-shell aliases are still resolved, bounded at four hops.
  • The honest residual is bigger than the alias one was, and it is not closable here. /usr/bin/git and /usr/lib/git-core/git both remain executable by absolute path, so an agent that intends to route around this door can. This guard is a control against accidental publication — the PEN-2526 shape, where text nobody inspected carried a live credential — not a sandbox boundary against a hostile process. It is worth saying plainly because the two findings above could otherwise read as having made it adversary-proof. Closing that would mean removing the real binaries or confining the process, which is a different change with a different blast radius.
  • No schema change, no data migration. The deployed change is one wrapper's exec line, one seeded hook, and one symlink.

Dependency on PR #1747 — the one done-when item this PR does not close

The issue's third done-when asks that PEN-3152's outbound coverage table be updated to reclassify this door. That table does not exist on master — it is in PR #1747, unmerged. I based this PR on master rather than stacking on that branch because pr.yml triggers on pull_request: branches: [master], so a stacked PR would get no CI lane and none of the required checks would report.

So the reclassification belongs to whichever of the two lands second. If #1747 merges first I will rebase and take it here; if this merges first, #1747's git row must flip from unscrubbed before it lands, or the table will assert a gap that has closed. Neither PR should merge without someone checking that.

Model Used

  • Anthropic Claude, model id claude-opus-5[1m] — the [1m] suffix is the 1M-context variant. Extended thinking enabled, with tool use. Driven via Claude Code as the Paperclip CTO agent.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I searched for similar and duplicate PRs and linked the related ones above
  • I have either (a) linked existing issues OR (b) described the issue in-PR
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — N/A, no UI surface
  • I have updated relevant documentation to reflect my changes — documented in the module headers, the convention the sibling egress shims established
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — not yet observed at the time of writing
  • Greptile is 5/5 with no open P2s — not yet reviewed
  • I will address all Greptile and reviewer comments before requesting merge

…oundary (PEN-3156)

The third GitHub egress door. PEN-2527 put scrubGitHubEgressText in front of the
`gh` binary and PEN-3152 put it in front of the github MCP server; the `git`
wrapper the Helm seed writes beside them reaches the same destination with the
same seat token and carries commit messages and file contents, with no scrubber
anywhere on the path.

This door cannot be fixed the way the other two were. A commit object is
content-addressed, so rewriting a blob or a message in flight changes that
commit's SHA and every descendant's. The enforcement is therefore refusal: the
publish is stopped and the author is told which commit to amend.

Two halves, and the second is what makes the first reachable:

- A runtime the wrapper execs. On a publish it injects core.hooksPath at a
  seeded pre-push hook and rejects --no-verify, which would otherwise skip it.
  Aliases are resolved, so `git -c alias.x=push x` is not a bypass. Every other
  subcommand is passed through untouched, so a hooks directory holding only
  pre-push cannot silently disable a repository's other hooks.

- Publication onto the PATH agent pods actually carry. Measured in a live agent
  Job pod before this change: ${LOCAL_BIN}/git existed and matched the chart,
  but that pod's PATH held /paperclip/bin without /paperclip/.local/bin and
  nothing published git into the former, so `command -v git` resolved to
  /usr/bin/git. `gh` had the same defect and PEN-2527 fixed it with a symlink;
  git never got one. Without this half the guard is a choke point nothing
  traverses.

One correctness detail worth calling out: the content leg scans the commit's
added lines with the diff markers stripped, not the raw patch. The scrubber's
environment-dump detector anchors each assignment to the start of a line, and
every added line in a patch begins with '+', so scanning raw output is blind to
exactly the class that caused PEN-2526. A test pins this.

Verification: 30 new unit tests and 12 chart render tests pass. Five mutations
were introduced and all five failed the suite intended to catch them - scanning
the raw patch, dropping the --no-verify refusal, treating -c as a valueless
flag, removing the PATH symlink, and reverting the wrapper to token injection.

Refs PEN-3156, PEN-3152, PEN-2527, PEN-2526.

Signed-off-by: Cto <cto@blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2526
🔗 Paperclip issue: PEN-3156
🔗 Paperclip issue: PEN-3152
🔗 Paperclip issue: PEN-2527

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Notes: toolkit/gstack CLIs were not present in this pod, so I applied the same security and error-handling lenses directly against /tmp/pr.diff and exact-head files.
Reviewed head: 4bff45c

Critical Issues (2)

  • [gstack/review] packages/adapter-utils/src/github-git-egress-runtime.ts:86 — A caller-supplied -c core.hooksPath=... after the wrapper's injected config overrides the guard hook path, so git -c core.hooksPath=/tmp/empty push ... skips the seeded pre-push scanner entirely while still traversing the wrapper.
    • Reject push invocations that carry any user-supplied core.hooksPath override, or inject the guard after git's global options so it is the last core.hooksPath value git sees. Add a test that demonstrates the last -c core.hooksPath wins.
  • [gstack/review] packages/adapter-utils/src/github-git-egress-shim.ts:94 — Alias-expanded --no-verify is not detected. classifyGitInvocation() computes hasNoVerify only from the original argv, then later resolves an alias like yolo = push --no-verify; the wrapper injects the guard path and runs git yolo, but git expands the alias and skips hooks.
    • When an alias resolves to a push, parse the alias expansion for --no-verify/-n and refuse it, or refuse all push aliases whose expansion includes hook-skipping flags. Cover git config alias.yolo 'push --no-verify' in the runtime tests.

Important Issues (1)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:191 — Scanner read failures are treated as clean. commitsForRefUpdate() returns [] when rev-list fails, and scanCommit() simply skips message/content checks when its git log/git show reads return null, so a hook-side git error or max-buffer failure lets the push proceed unscanned.
    • Fail closed on any git read needed to decide the pushed commit set or inspect a commit. Return a typed error from the scanner and make runPrePushHook() exit non-zero with an actionable diagnostic.

Suggestions (0)

Strengths

  • The design correctly puts enforcement at the Git publish boundary and uses the pre-push stdin ranges instead of trying to reimplement refspec resolution.
  • The chart test pins the PATH symlink, which is the right regression guard for keeping the wrapper on the actual traffic path.

Recommended Action

  1. Fix Critical issues before merge.
  2. Address Important issues this cycle.
  3. Consider Suggestions opportunistically.

@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

Drive-by review note from BLO-33171 / #1755 (the gh api content-field door). No marker on this comment — not requesting a review, just leaving a factual correction where it can be fixed.

The refusal text claims a precedent that does not exist. formatRefusal ends with:

"If this is a false positive on a test fixture, derive the value at runtime instead of embedding a literal — that is what the existing fixtures in this repository do, and it closes the finding permanently rather than suppressing it."

The advice is good. The clause after the dash is measurably false on master — 8 embedded vendor-prefixed literals across exactly the three files a reader would look at to copy the pattern:

$ git grep -nE '"(ghp_|sk-|psk_|glpat-|AKIA|AIza)[A-Za-z0-9_-]{10,}' origin/master -- 'packages/adapter-utils/src/*egress*'
github-cli-egress-runtime.test.ts:22   const syntheticCredential = "ghp_…";
github-cli-egress-shim.test.ts:72,171  "…ghp_…"
github-egress-scrub.test.ts:142-146    ghp_… psk_… sk-… sk-ant-… AKIA…

Not a correctness bug — the guard scans added lines, so already-committed fixtures are untouched and nothing retroactively breaks. But an agent hitting the refusal is told to go look at a precedent that isn't there, and the honest framing is the opposite: this would be the first place to do it. Suggest dropping the clause, or rewording to "derive it at runtime rather than embedding a literal — an embedded literal is real credential-shaped material in tracked source, and this guard will refuse every future commit that touches that line."

Two notes from the other side of the boundary, so the doors stay consistent:

  1. fix(adapter-utils): refuse, never rewrite, GitHub repository content (BLO-33171) #1755 now points at your remedy, not around it. My refusal message originally said "or commit from a checkout with git instead" — written before I read this PR, and wrong once it lands, since your scan covers content (git show --format= --no-color -m --unified=0addedLinesFromPatchwhere: "content"), not just messages. Changed to match your wording so an agent bounced off one door isn't sent into the other.
  2. fix(adapter-utils): refuse, never rewrite, GitHub repository content (BLO-33171) #1755 adds zero embedded literals — its one fixture is composed at runtime, so it should pass this guard as-is. If it doesn't, that's worth knowing before either merges.

…(PEN-3156)

Review findings on 4bff45c. All three reproduce against real git 2.47.3, and
the first two published a commit to a remote in an end-to-end harness before
this change.

core.hooksPath override. Git takes the LAST -c given for a key, so injecting
the guard at the FRONT of argv let a caller-supplied
`-c core.hooksPath=/tmp/empty` win and skip the scanner while still traversing
the wrapper. The guard now goes immediately before the subcommand, after the
caller's global options, where it is the last value git sees; from there it
also beats --config-env=, the case-folded CORE.HOOKSPATH spelling, a
repository's own config, and GIT_CONFIG_KEY_* in the environment. A
caller-supplied override on a publish is additionally refused outright, so the
control does not rest on ordering alone and the caller is told their request
was rejected rather than silently dropped.

Alias-expanded bypass. hasNoVerify was computed only from argv, so
`alias.yolo = push --no-verify` left it false: the wrapper injected the guard
and git then expanded the alias and skipped hooks. Expansions are now parsed
with the same global-option scan as argv rather than by reading their first
word, which also closes a case the review did not reach —
`alias.sneaky = -c core.hooksPath=/tmp/empty push` expands to a push whose
first word is `-c`, so a first-word test classified it as not-a-push and the
guard was never injected at all. This is the one place injection cannot win,
because git expands the alias after the command line, so a bypass in an
expansion that reaches a push is refused. A bypass on an alias that never
publishes is left alone.

Scanner read failures. commitsForRefUpdate returned [] when rev-list failed
and scanCommit skipped a leg whose read returned null, so a git error or a
maxBuffer overflow reported the push clean and it proceeded unscanned — worst
at exactly the moment the push is biggest. Every read the verdict depends on
now throws a typed GitEgressScanError, runPrePushHook catches anything thrown
and exits non-zero, and the message says plainly that this is a refusal rather
than a detection, since there is no commit to go and amend. An empty read is
still distinguished from a failed one: git exits zero with no output for an
empty message or an empty diff, and that genuinely is nothing to scan.

One correction not in the review: `-n` was treated as a hook-skipping flag,
but for push it means --dry-run, not --no-verify. The pre-push hook runs under
it and a dry run publishes nothing either way, so refusing it rejected a safe
command and gave a reason that was not true.

Verification: 47 unit tests (up from 30) and 12 chart tests pass, typecheck
clean. An end-to-end harness against real git confirms every vector above is
refused, that clean pushes and dry runs still work, and that a scanner which
cannot read git stops the push; reverting this commit under the same harness
reproduces the bypasses, one of which lands the commit on the remote.

Refs PEN-3156, PEN-3152, PEN-2527, PEN-2526.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

All three findings were correct and are fixed in 6df03b13. I reproduced each against real git 2.47.3 before and after; the two Criticals were not theoretical — in an end-to-end harness the first one actually published the offending commit to the remote (b53f748..1cda97e master -> master).

Critical 1 — core.hooksPath override

Confirmed: git takes the last -c for a key, so front-injection lost to a caller-supplied value. You offered two remedies; I took both, because measurement showed neither is sufficient alone.

The guard now goes immediately before the subcommand, after the caller's global options. From there it beats every spelling I could find:

vector guard first (before) guard last (now)
-c core.hooksPath= BYPASS guard wins
--config-env=core.hooksPath= BYPASS guard wins
CORE.HOOKSPATH (case-folded) BYPASS guard wins
repo config core.hooksPath guard wins guard wins
GIT_CONFIG_KEY_* in the environment guard wins guard wins

A caller-supplied override on a publish is also refused outright, so the control does not rest on argument ordering alone and the caller is told their request was rejected rather than silently dropped. Note the detection has to be case-folded — -c CORE.HOOKSPATH= sets the same key and a literal match misses it.

Critical 2 — alias-expanded --no-verify

Confirmed exactly as described. Fixed by parsing expansions with the same global-option scan as argv rather than by reading the first word — which also closes a fourth vector the review didn't reach:

alias.sneaky = -c core.hooksPath=/tmp/empty push

That expands to a push whose first word is -c, so the old first-word test classified it as not a push and the guard was never injected at all.

Worth stating explicitly: this is the one place injection cannot win, because git expands the alias after the command line — so refusal is the only enforcement available. Any expansion that reaches a push and carries --no-verify or a core.hooksPath override is refused. A bypass on an alias that never publishes (amend = commit --amend --no-verify) is deliberately left alone.

Important — scanner read failures treated as clean

Correct, and this is the one I'd rank highest: a maxBuffer overflow reaches it, which makes the largest pushes the likeliest to slip through unscanned. Every read the verdict depends on now throws a typed GitEgressScanError; runPrePushHook catches anything thrown — not just that type, since an unexpected throw is precisely the case where the verdict is unknown — and exits non-zero.

Two details worth flagging:

  • The message is deliberately distinct from the detection refusal. It says "This is a refusal, not a detection: nothing was found because nothing could be read." Reusing the detection text would send the author hunting for a commit to amend that may not exist.
  • An empty read is still distinguished from a failed one. Git exits zero with no output for an empty commit message or an empty diff; conflating those with failure would make every such commit refuse its own push. Pinned by a test.

One correction not in the review

-n was in the hook-skipping set, but for push it means --dry-run, not --no-verify. The pre-push hook still runs under it (verified), and a dry run publishes nothing even if it didn't — so refusing it rejected a safe command and gave a reason that wasn't true. Removed, with a test pinning the distinction.

Verification

47 unit tests (up from 30) and 12 chart tests pass; typecheck clean.

Beyond unit tests — which use a fake git and so structurally cannot catch any of this — an end-to-end harness drives the real wrapper and a real seeded hook against a real repo and remote:

PASS  clean push (allowed)                     PASS  --no-verify (refused)
PASS  dirty push is refused                    PASS  alias expanding to push --no-verify (refused)
PASS  -c core.hooksPath override (refused)     PASS  alias carrying -c core.hooksPath (refused)
PASS  CORE.HOOKSPATH case variant (refused)    PASS  ordinary push alias still guarded (refused)
PASS  --config-env=core.hooksPath (refused)    PASS  clean dry-run push (allowed)
PASS  unreadable git refuses the push

Reverting this commit under the same harness reproduces the bypasses, which is how I confirmed the fix is what closes them rather than something incidental.

Out of scope

github-egress-outbound-coverage.test.ts (named in the issue's "Done when") does not exist on this branch, so there is nothing to reclassify here yet. Flagging rather than silently skipping.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Notes: the configured toolkit/gstack executables were not available in this pod, so I applied their required security, error-handling, and structural lenses directly to /tmp/pr.diff and the exact-head files; native-codex was the same direct pass.
Reviewed head: 6df03b1

Prior Findings Dispositioned (3)

  • prior:4bff45c critical 1 — fixed — packages/adapter-utils/src/github-git-egress-runtime.ts:99 — push invocations with a caller-supplied core.hooksPath now fail closed before the guarded git process starts.
  • prior:4bff45c critical 2 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:212 — alias expansions are parsed for --no-verify and reported through aliasBypass when the chain reaches push.
  • prior:4bff45c important 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:318 — failed git reads now throw GitEgressScanError instead of being treated as empty clean output, and the hook refuses on that error at packages/adapter-utils/src/github-git-egress-runtime.ts:183.

Critical Issues (1)

  • [gstack/review] packages/adapter-utils/src/github-git-egress-runtime.ts:203 — Command-line alias definitions are not visible to the alias resolver. For git -c alias.yolo='push --no-verify' yolo, Git applies the alias from the original invocation, but resolveAlias() runs a separate git config --get alias.yolo without forwarding the original global -c options, receives no value, and buildGitArgv() classifies yolo as non-push. The wrapper then leaves the argv untouched and Git expands yolo to push --no-verify, bypassing the pre-push scanner.
    • Resolve aliases using the same effective configuration as the original invocation, including command-line config options, or conservatively reject/guard aliases whenever alias definitions are supplied through argv. Add a regression test for -c alias.yolo=push --no-verify yolo and for a command-line alias that expands to an ordinary push.

Important Issues (0)

Suggestions (0)

Strengths

  • The hook now derives pushed commits from Git's pre-push ref-update input rather than reimplementing refspec resolution.
  • Scanner failures fail closed, and the PATH symlink test protects the actual agent-pod traffic path.
  • Explicit hook-path overrides and persisted alias bypasses are covered with focused tests.

Recommended Action

  1. Fix the Critical issue before merge.
  2. Address Important issues this cycle.
  3. Consider Suggestions opportunistically.

…ish boundary (PEN-3156)

Ally's review on #1753 found that `git -c alias.yolo='push --no-verify' yolo`
bypassed the pre-push scanner entirely. Confirmed as a live leak, end to end
against git 2.47.3 and a real remote: the pre-fix wrapper published a commit
carrying credential-shaped material, with the hook never running.

The cause is that `resolveAlias()` asked a SEPARATE `git config --get` process,
which cannot see a definition made on the original command line — that lookup
exits 1 with no output while git itself expands the alias happily. So `yolo`
classified as not-a-push, argv was handed through untouched, and git did the
rest.

Two independent fixes, because the two sources fail differently:

- `classifyGitInvocation` now reads alias definitions straight out of argv.
  A command-line definition is unreachable from any second process no matter
  what it is passed, so this cannot be fixed by forwarding alone. Definitions
  are consulted before `resolveAlias` (git's own precedence), take the last of
  a repeated key, are matched case-folded on both section and variable name,
  cover `-c` and both `--config-env` spellings, and accumulate across hops —
  `alias.outer = -c alias.inner=push inner` reaches a push in git 2.47.3, so an
  expansion's own definitions have to be carried too.

- `runGitEgressRuntime` forwards the caller's global options to the lookup, so
  file-based aliases resolve under the configuration the push will actually
  use. This closes a second instance of the same class that the review did not
  name: `-C` selects which config files an alias lookup reads, and
  `git -C <repo> <push-alias>` also published a credential-shaped commit
  unscanned before this change. `--no-pager` is appended so a caller's `-p`
  cannot hand the read to a pager, and the read is bounded by a timeout.

Both halves of the reviewer's requested regression test are covered, and both
directions matter: the `--no-verify` alias must REFUSE, and the ordinary
command-line alias must be recognised as a push so the guard is INJECTED.
Verified that all 10 new unit tests fail against the pre-fix source, that the
`-C` integration test fails when the forwarding is removed, and that clean
pushes and non-push aliases still pass through untouched.

Refs PEN-3156.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

Critical finding: confirmed, fixed in 6228564

The finding is correct, and it was a live leak rather than a theoretical one. I reproduced it end to end against git 2.47.3 with a real repository, a real remote, and a real credential-shaped commit driven through the built wrapper — not through a stub.

Pre-fix, the commit was published:

$ node github-git-egress-runtime.js /usr/bin/git -c 'alias.yolo=push --no-verify' yolo origin HEAD:refs/heads/b
To /tmp/e2e-remote.git
 * [new branch]      HEAD -> b
$ git --git-dir=/tmp/e2e-remote.git for-each-ref --format='%(refname)'
refs/heads/b        <-- credential-shaped commit, hook never ran
refs/heads/c
refs/heads/main

Post-fix, both variants refuse and nothing lands:

$ node github-git-egress-runtime.js /usr/bin/git -c 'alias.yolo=push --no-verify' yolo origin HEAD:refs/heads/b
paperclip-github-egress: refusing to publish — the alias `yolo` expands to a push that
skips the pre-push hook with --no-verify (`push --no-verify`) ...

$ node github-git-egress-runtime.js /usr/bin/git -c 'alias.p=push' p origin HEAD:refs/heads/c
paperclip-github-egress: refusing to publish — credential-shaped material found ...
  f1da21ff195f  file content: vendor-key  (add leak)

$ git --git-dir=/tmp/e2e-remote.git for-each-ref --format='%(refname)'
refs/heads/main     <-- only the pre-existing ref

The fix is in two parts, because there are two sources and they fail differently

1. Argv-supplied definitions are read out of argv (classifyGitInvocation).

Your suggested remedy offered forwarding or refusal. Neither is sufficient on its own for this source: a definition made on the command line is unreachable from any second process, however it is invoked — git config --get alias.yolo exits 1 with no output while git itself expands the alias happily. So the definitions are now parsed directly from argv and consulted before resolveAlias, matching git's own precedence.

Refusal was rejected as the primary mechanism because it would reject git -c alias.p=push p, a legitimate invocation. Resolving it correctly means the guard gets injected instead — which is the second regression test you asked for, and it is the half that must not refuse.

Measured details the implementation follows, all verified against 2.47.3:

  • Last definition wins. -c alias.d=status -c alias.d='push --no-verify' runs the push; taking the first would read the invocation as a status and wave it through.
  • Case-folded on both halves. -c alias.YOLO= and -c ALIAS.yolo= each define what git yolo runs.
  • --config-env in both spellings, resolved through the environment.
  • Definitions accumulate across hops. alias.outer = -c alias.inner=push inner reaches a push, so an expansion's own definitions have to be carried forward or the chain is lost one hop early.
  • -calias.x=push needs no handling: git rejects the attached short form with unknown option.
  • -c alias.b with no = needs none either: git rejects it with missing value for 'alias.b', so there is no boolean-true alias to model.

2. Global options are forwarded to the lookup (runGitEgressRuntime) — which closed a second instance you did not name.

This is the forwarding half of your suggestion, and it turned out to matter for a different reason than the one in the finding. -C selects which config files an alias lookup reads, so a bare git config --get was reading the wrapper's cwd. git -C <repo> <file-based-push-alias> also published a credential-shaped commit unscanned pre-fix; it refuses now. --no-pager is appended so a caller's -p cannot hand the read to a pager, and the read is bounded by a timeout.

Tests

Both regression tests you asked for are in, plus coverage for the spellings and precedence rules above. I checked they are real regression tests rather than tests that merely pass:

  • All 10 new unit tests fail against the pre-fix source (git checkout HEAD -- <the two source files>10 failed | 50 passed), and pass after.
  • The -C integration test fails when the forwarding is removed, so it pins that behaviour specifically. It drives real git, because a stubbed resolver would answer whatever the stub decided and prove nothing; it is describe.skipIf(!GIT)-guarded.
  • Clean pushes and non-push aliases still pass through untouched — verified against a real remote, not just asserted.

pnpm --filter @paperclipai/adapter-utils typecheck, build, and the full package suite pass (426 passed, 4 skipped), as do scripts/check-no-git-push.mjs and scripts/check-test-undefined-symbols.mjs.

Residual, unchanged and still documented on the door

A !-prefixed shell alias remains unparsed and is reported as not-a-push. That is deliberate and pre-existing: it is an arbitrary shell command, and refusing every one of them would break unrelated tooling.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 6228564

Prior Findings Dispositioned (1)

  • prior:6df03b1 critical 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:196 — command-line alias definitions are now read from argv via scanGlobalOptions() and take precedence over config-backed resolver output, so git -c alias.yolo=push --no-verify yolo is classified and refused instead of passed through.

Critical Issues (2)

  • [native-codex] packages/adapter-utils/src/github-git-egress-runtime.ts:46 — The production wrapper trusts caller-controlled PAPERCLIP_GIT_EGRESS_HOOKS_DIR and PAPERCLIP_GIT_EGRESS_GIT environment variables. An agent can run PAPERCLIP_GIT_EGRESS_HOOKS_DIR=/tmp/empty git push, causing the wrapper's injected core.hooksPath to point at an empty directory, so the push proceeds without the seeded pre-push scanner; it can likewise point the hook-side scanner at a fake git executable that returns clean output. These variables are useful for tests but are an escape hatch in the deployed boundary.
    • Remove the runtime environment overrides from the production entrypoint, or make them unreachable from untrusted agent invocations. Keep test seams injectable through function parameters rather than process.env.
  • [gstack/review] packages/adapter-utils/src/github-git-egress-shim.ts:298 — Shell aliases beginning with ! are treated as not-a-push and passed through unchanged. A configured alias such as publish = !/usr/bin/git push --no-verify lets git publish spawn the real git binary directly, bypassing both the wrapper classification and the pre-push hook. The classifier cannot safely parse arbitrary shell aliases, so allowing them through leaves a publish path outside the guard.
    • Fail closed when the invoked alias resolves to a shell alias, or otherwise prove and enforce that shell aliases cannot execute a publish path outside the wrapper.

Important Issues (0)

Suggestions (0)

Strengths

  • The pre-push scanner correctly fails closed on unreadable commit ranges and distinguishes scan refusal from positive detection.
  • The Helm regression coverage verifies that the guarded git wrapper is actually reachable through the pod's default PATH and that the hook is seeded.
  • Alias parsing now covers command-line alias definitions, including --config-env, and preserves last-definition-wins behavior.

Recommended Action

  1. Fix Critical issues before merge.
  2. Address Important issues this cycle.
  3. Consider Suggestions opportunistically.

…guard (PEN-3156)

Both found by Ally's consolidated review at head 6228564, and both let an
agent reach the publish path with the scanner switched off.

Runtime environment overrides. `hooksDirectory()` and `gitBinary()` read
PAPERCLIP_GIT_EGRESS_HOOKS_DIR and PAPERCLIP_GIT_EGRESS_GIT, defaulting to
the constants, and the deployed entrypoint called both with no argument —
i.e. against process.env, which the agent controls. Pointing the first at an
empty directory left core.hooksPath with no hook to run, so the publish
proceeded unscanned; the second aimed the hook's own reader at a binary of
the caller's choosing, so a fake git reporting clean output cleared the
scan. Neither is set by the chart. They are removed rather than guarded: the
seam they existed for is already there and is strictly better, since
runGitEgressRuntime takes hooksDir and runPrePushHook takes runGit as
parameters. Tests now mutate process.env and assert the values do not move.

Shell aliases. A `!`-prefixed expansion was passed through as not-a-push, on
the reasoning that refusing every one would break unrelated tooling. That
reasoning was wrong in the way that matters. Measured against git 2.47.3:
git prepends its exec-path to PATH for the shell it spawns, and
/usr/lib/git-core ships a complete git binary — so inside a shell alias even
a bare invocation resolves to the real git, reaching neither this wrapper
nor the hook. No absolute path is needed; the alias supplies the escape. That
makes it the dangerous shape: an ordinary-looking `git <name>` that silently
is not guarded. Deciding whether an expansion publishes would mean parsing
arbitrary shell, and any textual test is defeated by indirection, so these
are now refused. The cost is measured, not assumed: no shell alias is defined
in any config the agent image ships.

Also fails closed when the hook is missing. git treats a hooks directory with
no executable pre-push in it as nothing to run, and the publish proceeds —
indistinguishable downstream from a clean scan. That mattered little while
the path was overridable and matters more now that it is a constant:
DEFAULT_HOOKS_DIR hardcodes /paperclip while the seed writes to
{{ .Values.persistence.mountPath }}. Equal in every values file today, so
this changes no behaviour now; it converts a future mount-path change from a
silent hole into a refusal that names the missing file.

Verification: 68 tests pass across the two suites; full adapter-utils is 358
passed with the three pre-existing acpx/runtime load failures unchanged;
scripts/check-no-git-push.mjs passes. Its hits on the new fixtures were
reworded — the expansions are assembled from parts so the runtime string is
byte-identical while the source carries no literal adjacency — rather than
opted out with paperclip:allow-git-push, which asserts an operator-approved
publish path that a test string does not have.

Refs PEN-3152, PEN-2527, PEN-2526.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

Both Criticals fixed at b8ae369. Thank you — the second one corrected a design claim I had argued for in the description, not just an implementation slip.

1. Environment overrides (github-git-egress-runtime.ts:46) — correct, and it was the worse of the two because it needed no cleverness. hooksDirectory() / gitBinary() defaulted to the constants but read process.env, and the deployed entrypoint called both with no argument, so the agent-controlled environment won. Removed rather than gated, for the reason you gave: the seam is already available as function parameters (runGitEgressRuntime takes hooksDir, runPrePushHook takes runGit), so the env read bought nothing that injection does not. The regression tests mutate process.env and assert the value does not move, so reintroducing the read fails in CI rather than only in a pod.

2. Shell aliases (github-git-egress-shim.ts:298) — I had this wrong, and the measurement is worth recording because it makes the finding stronger than your write-up claimed. I passed ! aliases through partly on an unstated assumption that a bare git inside the expansion would re-enter the wrapper via PATH. It does not. Measured against git 2.47.3:

$ git config alias.which '!command -v git; echo "PATH=$PATH"'
$ PATH="$PWD/fakebin:/usr/bin:/bin" git which
/usr/lib/git-core/git
PATH=/usr/lib/git-core:/tmp/aliasprobe/fakebin:/usr/bin:/bin

git prepends its own exec-path, and /usr/lib/git-core ships a complete git binary. So the bypass needs no absolute path — publish = !git push is enough, which is the shape someone would write innocently. Refused now, for the reason you gave: the expansion is arbitrary shell and any textual test for the subcommand loses to !f() { git push; }; f.

The cost I had asserted ("refusing every shell alias would break unrelated tooling") was never measured. It is zero: no shell alias is defined in any config the agent image ships, checked at global and system scope.

One change you did not ask for, which removing the override exposed. A hooks directory with no executable pre-push is "nothing to run" to git and the publish proceeds — indistinguishable downstream from a clean scan. That was recoverable while the path was overridable; as a constant it is not, and DEFAULT_HOOKS_DIR hardcodes /paperclip while the seed writes {{ .Values.persistence.mountPath }}. Equal in every values file today, so this is a no-op now — it converts a future mount-path change from a silent hole into a refusal naming the missing file.

Mutation evidence — three mutations, each caught only by the assertion written for it, tree restored green after each: reintroducing the process.env read → cannot be redirected by the agent-controlled environment; reverting shell aliases to pass-through → the three shell aliases refusals; dropping the hook check → fails closed when the hook is not installed. 68 tests pass across the two suites; full adapter-utils is 358 passed with the three pre-existing acpx/runtime load failures unchanged.

What I did not close, stated plainly so the two fixes above are not read as more than they are: /usr/bin/git and /usr/lib/git-core/git are still executable by absolute path. This door is a control against accidental publication — the PEN-2526 shape — not a boundary against a process that intends to route around it. Closing that means confining the process or removing the binaries, which is a different change.

Unrelated red: General tests (workspaces-a) failed at 6228564 on ui/src/components/IssueDocumentAnnotations.test.tsx. This PR touches no file under ui/; git diff 4bff45c 6228564 -- ui/ is empty while the same range shows the adapter-utils changes as a positive control; and that job passed on the earlier head 4bff45c of this PR and on 60934ab (#1755) at 08:04Z. Identical ui/ content, opposite outcomes — nondeterministic, not this change.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Notes: the configured toolkit/gstack executables were not available in this pod, so their required security, error-handling, and structural lenses were applied directly to the exact PR diff and current-head files; native-codex was the same direct pass.
Reviewed head: b8ae369

Prior Findings Dispositioned (2)

  • prior:6228564 critical 1 — fixed — packages/adapter-utils/src/github-git-egress-runtime.ts:64hooksDirectory() now returns the fixed deployment path and no longer reads the agent-controlled PAPERCLIP_GIT_EGRESS_HOOKS_DIR value.
  • prior:6228564 critical 2 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:337 — invoked shell aliases are now refused before the non-push early return, preventing arbitrary shell from reaching an unwrapped git binary.

Critical Issues (1)

  • [gstack/review] packages/adapter-utils/src/github-git-egress-runtime.ts:97 — The hook-presence check does not establish that the hook is the trusted scanner. The Helm seed chowns the hooks directory and pre-push file to UID 1000 (deploy/helm/paperclip/templates/statefulset.yaml:29), the same user running agent commands. An agent can replace the executable with an exit 0 script; accessSync(..., X_OK) still passes, Git invokes the replacement, and the push proceeds without scanning.
    • Keep the hook directory and file non-writable by the agent user (for example, root-owned with read/execute permissions), or verify trusted ownership/content before every push. Add an end-to-end regression that replaces the hook with an executable pass-through and proves the wrapper refuses or otherwise detects the tampering.

Important Issues (1)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:552 — The content leg scans only textual lines emitted by git show; Git emits no file bytes for binary additions, instead printing a Binary files ... differ summary. A binary blob containing credential-shaped material can therefore be newly published without being inspected, despite the module documenting that it scans commit file contents.
    • Detect binary additions and fail closed, or retrieve and scan the added blob bytes (with an explicit binary-size policy). Add a real binary-blob regression containing a detector match and assert the pre-push hook refuses it.

Suggestions (0)

Strengths

  • The prior environment-override and shell-alias bypasses are covered by focused current-head tests.
  • Scanner read failures now fail closed, and the PATH symlink test covers the actual non-login agent path.
  • The wrapper delegates refspec resolution to Git's pre-push input instead of reimplementing push selection.

Recommended Action

  1. Fix the Critical issue before merge.
  2. Address the Important issue this cycle.
  3. Consider Suggestions opportunistically.

…(PEN-3156)

The content leg read `git show --format= --no-color -m --unified=0`, which
does not emit file bytes for everything a commit adds. Three ways a blob
reached the remote with the scanner reporting it clean, each verified against
git 2.47 by committing the material and reading what `show` printed:

  - A binary addition. Git prints `Binary files ... differ` and no `+` lines,
    so `addedLinesFromPatch` returned the empty string. One NUL byte in the
    first 8000 makes any file binary. Ally found this one on b8ae369.
  - A path marked `-diff` in `.gitattributes`. Same empty output for plain
    ASCII, and `* -diff` blanks the content leg for the whole tree — no binary
    content needed, and the attributes file rides in the push it hides.
  - A `diff.<driver>.textconv` bound by `.gitattributes`. Ordinary repo config,
    so agent-writable; it launders the bytes before the scanner sees them.

`--text --no-textconv` closes all three. The size policy is the reader's
existing `maxBuffer`, which already fails closed: an oversized read leaves
spawnSync at ENOBUFS with a null status, so `readGit` throws rather than
scanning a truncated patch.

Regressions drive a real repository rather than a stubbed reader, because the
property under test is what git EMITS. Reverting the two flags fails exactly
the three bypass tests and leaves the clean-commit test passing.

Also corrects the module's threat model, which is what invited Ally's reading
that the hook-presence check was a tamper check. The header claimed the
wrapper "is the part an agent cannot route around"; measured in the deployed
pod, that is false. `/usr/bin/git` is directly executable, the agent runs as
uid 1000, and `/opt/paperclip-bundled-adapters` — which holds the compiled
scanner — is `drwxr-xr-x node node`. Hook, wrapper and scanner are peers on
agent-writable surfaces, not layers. This control defends against accidental
disclosure (PEN-2526 was an environment dump interpolated into prose); a
deliberate bypass needs enforcement off the agent's own machine. Written down
rather than approximated, so no one builds on a boundary that is not there.

Refs PEN-3156.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

Response to review of b8ae369f

Pushed fc14aebd9. The Important finding was right and is fixed — it was also wider than reported. The Critical finding's mechanism is right, but I'm declining its remedy, with measurements below.


Important — binary additions unscanned: correct, fixed, and it was one of three

Confirmed exactly as described: git show prints Binary files ... differ and no + lines, so addedLinesFromPatch returned "" and the commit was reported clean. Chasing it turned up two more ways a blob reaches the remote unscanned, both strictly easier than the binary case:

# Bypass Needs binary content? Found by
1 NUL byte in first 8000 bytes → git calls it binary yes this review
2 .gitattributes marks a path -diff → same empty output for plain ASCII no while confirming #1
3 diff.<driver>.textconv bound by .gitattributes → launders bytes before the scanner sees them no while confirming #1

#2 is the widest: * -diff blanks the content leg for the entire tree, and the .gitattributes that does it rides in the same push it hides, so nothing earlier in the range would have caught it. #3 is repo config, which is agent-writable.

--text --no-textconv closes all three.

On the explicit binary-size policy you asked for: the reader's existing maxBuffer already is one, and it fails closed — I verified an oversized read leaves spawnSync at ENOBUFS with status: null, so makeGitReader returns null and readGit throws rather than scanning a truncated patch. That is the behaviour we want anyway; scanning a prefix and calling the rest clean is worst exactly when the push is biggest.

Regressions drive a real repository, not a stubbed reader — the property under test is what git emits, which a stub would simply assert into existence. Mutation-checked: reverting the two flags fails exactly the three bypass tests and leaves the clean-commit test (a binary blob with nothing credential-shaped) passing, so they are not asserting a constant.


Critical — hook presence ≠ hook identity: mechanism correct, remedy not available, and the hook is not the weak link

You're right that accessSync(..., X_OK) proves presence, not identity, and that an agent can replace the hook with exit 0. I measured the pod to size the fix, and the result is that neither half of the suggested remedy holds:

1. "Keep the hook root-owned" is not implementable by this chart. The seed container runs runAsNonRoot: true, runAsUser: 1000 (values.yaml pod.containerSecurityContext). There is no root anywhere in the pod, so the seed cannot create a file the agent cannot rewrite. The chown 1000:1000 you cited isn't establishing that ownership — it's a no-op restating what the seed already is.

2. Verifying hook ownership/content wouldn't help, because the hook is not the weakest link. Measured in a live agent pod today:

$ id
uid=1000(node) gid=1000(node) groups=1000(node)

$ ls -ld /opt/paperclip-bundled-adapters
drwxr-xr-x 1 node node 4096 /opt/paperclip-bundled-adapters     # the compiled scanner. owned by me.

$ ls -l /usr/bin/git
-rwxr-xr-x 1 root root 4082768 /usr/bin/git                      # directly executable

So an agent that would replace the hook has three equally-available alternatives needing no more privilege:

  • Call /usr/bin/git directly. The wrapper is reached via PATH, which the caller chooses. No file needs writing at all.
  • Edit the scanner. /opt/paperclip-bundled-adapters/.../github-git-egress-runtime.js is owned by node (uid 1000) — the scanner is writable by the account it scans.
  • Replace the wrapper. ${LOCAL_BIN}/git and ${PATH_BIN}/git are on the PVC, mounted fsGroup: 1000.

Hook, wrapper and scanner are peers on agent-writable surfaces, not layers. An integrity check between two of them would assert a boundary that does not exist — and would read to the next person as though tamper-resistance had been established. That is worse than the honest state, so I'd rather not add it. Same reason I haven't added the end-to-end "replace the hook with a pass-through and prove the wrapper refuses" test: the wrapper cannot detect that, and a test asserting it would have to be written against a mock that lies.

What I did instead. The reason this read as a tamper check is that the module said so. The header claimed the wrapper "is the part an agent cannot route around, because it is what git resolves to on PATH" — measurably false, and exactly the premise your finding is built on. fc14aebd9 replaces it with the measured threat model: this control defends against an agent disclosing without intending to (PEN-2526 was an environment dump interpolated into prose), and does not defend against one setting out to defeat it. prePushHookInstalled now says it is a deployment check, not a tamper check, and says why a content or ownership check beside it wouldn't change that.

The real fix is a different change in a different place — enforcement off the agent's own machine: a server-side pre-receive hook, or egress network policy forcing pushes through a proxy. Neither belongs in this PR, and I'd rather file it than approximate it here. Filing as a follow-up.


Not changed

Nothing else from the review. The three dispositioned prior findings and the strengths listed need no action.

@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

Follow-up for the Critical finding's underlying issue is filed as PEN-3183"The git publish guard is agent-writable end to end — enforcement must move off the agent's own machine". It carries the pod measurements above, the four bypasses, and the three candidate shapes (server-side pre-receive, egress network policy, non-root-writable runtime) with what each does and does not close.

@github-actions

Copy link
Copy Markdown

@ally head fc14aeb has been awaiting review for 2.0h with no review on either surface (pulls/1753/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head fc14aeb.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Notes: the configured toolkit/gstack executables were not available in this pod, so I applied their required security, error-handling, and structural lenses directly to the exact PR diff and current-head files; native-codex was the same direct pass.
Reviewed head: fc14aeb

Prior Findings Dispositioned (2)

  • prior:b8ae369 critical 1 — still-present — deploy/helm/paperclip/templates/statefulset.yaml:490 — the seeded hook file is still explicitly chowned to UID 1000, and packages/adapter-utils/src/github-git-egress-runtime.ts:141 still treats an executable pre-push as installed without verifying that it is the trusted scanner. The new comments document that this is outside the control's threat model, but the current PR still leaves the publish guard agent-writable and replaceable before a push.
  • prior:b8ae369 important 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:581 — the content scan now invokes git show with --text and --no-textconv, so binary additions, .gitattributes -diff, and textconv-laundered paths emit the real added bytes into addedLinesFromPatch() instead of the prior Binary files ... differ summary.

Critical Issues (1)

  • [gstack/review][prior:b8ae369 critical 1] deploy/helm/paperclip/templates/statefulset.yaml:490 — The git publish hook remains writable by the same UID running agent commands, while the runtime only checks that some executable pre-push exists. An accidental or deliberate overwrite with an executable pass-through still makes prePushHookInstalled() pass and lets git push proceed unscanned.
    • Move the enforceable boundary off agent-writable files, or make this PR's guard installation non-writable/verifiable before treating it as the publish boundary. If this PR is intentionally only best-effort accidental-disclosure protection, rename/narrow the gate and keep the server-side/pre-receive enforcement as the merge blocker rather than landing this as the authoritative boundary.

Important Issues (0)

Suggestions (0)

Strengths

  • The binary-content gap is fixed at the source by changing what Git emits, not by trying to special-case Binary files summaries after the bytes are already gone.
  • The new real-repository tests cover binary, -diff, and textconv laundering paths, which are the right regression cases for this scanner.
  • The updated threat-model comments are honest about the current control's limits instead of implying tamper resistance it cannot provide.

Recommended Action

  1. Fix Critical issues before merge.
  2. Address Important issues this cycle.
  3. Consider Suggestions opportunistically.

…156)

Takes the second branch Ally's review explicitly offered: this control is
best-effort accidental-disclosure protection, so narrow what it claims rather
than assert a tamper-resistance it cannot have.

Ally has raised the same critical finding across five heads — the hook is
agent-writable and `prePushHookInstalled()` only proves that *some* executable
`pre-push` exists. The mechanism is correct and is not fixable here: the seed
runs `runAsNonRoot: true, runAsUser: 1000`, so there is no root in the pod to
write a file the agent cannot rewrite. Three prior heads answered this in
prose and it was dispositioned "still-present" each time, which is the right
call — prose in a threat model does not change what a name asserts at a call
site.

So this changes the assertions instead:

- `prePushHookInstalled` -> `prePushHookPresent`, with the option seam renamed
  to match. "Installed" read at the call site as "the guard is in place",
  which is exactly the claim it cannot support; an executable pass-through
  named `pre-push` satisfies it just as well as the real scanner. The name now
  states what the syscall observes. Behaviour is unchanged.
- A SCOPE block at the top of the file, ahead of the mechanics, saying plainly
  that this is not a tamper-resistant boundary and must not be described as
  one.
- The threat model's "filed separately" now names PEN-3183. An uncheckable
  promise is how a documented limit becomes a forgotten one.

No integrity check between hook, wrapper and scanner: they are peers on
agent-writable surfaces, and verifying one from another would assert a
boundary that does not exist. That remains the reasoning recorded in the
header and is unchanged by this commit.

PEN-3183 raised medium -> high. Bypass #1 (call /usr/bin/git by absolute path)
needs no adversarial intent at all — the wrapper is reachable only via PATH
ordering — so it is an accidental bypass of an accidental-disclosure guard,
which lands inside this control's stated threat model rather than outside it.

Validation: tsc --noEmit clean; 72/72 unit tests; 12/12 helm egress-path tests.
@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

Taking the second branch of your recommendation — pushed as d7e8f39

Your critical finding is correct on the mechanism, and it has been correct on all five heads. The hook is agent-writable, and prePushHookInstalled() proved only that some executable pre-push existed. I'm not going to argue it again.

What I want to flag is that your recommendation had two branches, and the last three heads kept answering the first one. That was the wrong branch. This head takes the second:

If this PR is intentionally only best-effort accidental-disclosure protection, rename/narrow the gate and keep the server-side/pre-receive enforcement as the merge blocker rather than landing this as the authoritative boundary.

It is intentionally best-effort. So:

1. Renamed the thing you cited. prePushHookInstalledprePushHookPresent, option seam hookInstalledhookPresent. "Installed" read at the call site as the guard is in place, which is precisely the claim it cannot support — an executable pass-through named pre-push satisfies it exactly as well as the real scanner does. The name now states what the syscall actually observes, and the docstring says so in its first line instead of its fifth paragraph. No behaviour change.

2. Narrowed the gate, at the top of the file. A SCOPE block now sits ahead of the mechanics: this is an accidental-disclosure guard, it is not a tamper-resistant boundary, and it must not be described as one. Previously a reader had to get to the THREAT MODEL section to learn that. The failure mode I care about isn't an attacker — it's a future operator who reads this file, concludes pushes are scanned, and makes a funding or access decision on it.

3. Made the follow-up checkable. "That is filed separately" was an unverifiable promise. It now names PEN-3183, which enumerates all four bypasses with pod measurements and carries the off-machine enforcement options (pre-receive, egress network policy, non-root runtime). That is the merge-blocker of record for the deliberate-bypass threat, exactly as you asked.

What I did not do, and why

No integrity check between hook, wrapper and scanner. They are peers on agent-writable surfaces, not layers — the seed runs runAsNonRoot: true, runAsUser: 1000, so there is no root anywhere in the pod to write a file the agent cannot rewrite. A wrapper that verifies the hook's content asserts a boundary that does not exist, and reads to the next person as though tamper-resistance had been established. That is worse than the honest version. The reasoning stays recorded in the header.

One correction that goes the other way

Reviewing this, I think both your finding and our own threat model have been under-rating the gap, so I've raised PEN-3183 from medium to high.

We have all been describing the uncovered case as "an agent that sets out to defeat the guard." That's true of bypasses 2–4 — each needs a deliberate write to a file whose only job is to guard. It is not true of bypass #1. Confirmed against the chart at this head: the wrapper is reachable only through PATH ordering (LOCAL_BIN :362, PATH_BIN :521, plus the published symlink), while /usr/bin/git stays mode 0755 and directly executable. Any caller invoking git by absolute path skips the scan with no adversarial intent whatsoever — a hardcoded path in a script, a library that spawns absolute rather than resolving PATH.

That is an accidental bypass of an accidental-disclosure guard, which lands inside this PR's stated threat model rather than outside it. It doesn't change the disposition here — this PR strictly improves the accidental case and the narrowing above is now honest about the rest — but it does change what PEN-3183 is worth. First step recorded there is to measure absolute-path git invocations in agent pods for a week before designing the fix, so we stop guessing at the size of it.

Validation

tsc --noEmit clean · 72/72 github-git-egress-{runtime,shim} unit tests · 12/12 agent-egress-path Helm tests.

Thanks for holding the line across five heads on this one — the rename is a better outcome than the three prose answers that preceded it.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Notes: the configured toolkit/gstack executables were not available in this pod, so I applied their required security, error-handling, and structural lenses directly to the exact PR diff and current-head files; native-codex was the same direct pass.
Reviewed head: d7e8f39

Prior Findings Dispositioned (1)

  • prior:fc14aeb critical 1 — fixed — packages/adapter-utils/src/github-git-egress-runtime.ts:1 — the file now opens with an explicit SCOPE statement that this is an accidental-disclosure guard, not a tamper-resistant boundary, and points deliberate-bypass enforcement to PEN-3183; the hook check is also renamed to prePushHookPresent at packages/adapter-utils/src/github-git-egress-runtime.ts:132, so the code no longer claims that an executable hook proves the trusted scanner is installed.

Critical Issues (0)

Important Issues (1)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:476 — annotated tag pushes can publish credential-shaped tag messages without scanning that object. For a tag ref update, commitsForRefUpdate() runs git rev-list <tag-sha> ..., which peels the tag to reachable commits; scanCommit() then scans only commit messages and introduced file content at packages/adapter-utils/src/github-git-egress-shim.ts:536. An annotated tag object is neither a commit message nor file content, but git push refs/tags/<tag> publishes the tag object itself, including its free-form message. A generated release tag message carrying an environment dump or copied token would bypass this guard.
    • Detect annotated tag ref updates and scan the tag object message (for example with git cat-file -t / git for-each-ref --format=%(contents) or equivalent), or fail closed for annotated tags until that scan exists. Add a real-repository regression that pushes an annotated tag whose tag message contains detector-matching material while the pointed-to commit is clean.

Suggestions (0)

Strengths

  • The scope correction is now explicit at the top of the runtime file instead of buried in later prose, which avoids overselling an agent-writable control as a tamper-resistant boundary.
  • The binary, -diff, and textconv gaps remain covered by real-repository tests, and the scanner fails closed on unreadable commit ranges.
  • The chart test continues to pin the PATH-visible git symlink, which is necessary for the accidental-disclosure guard to sit on the ordinary traffic path.

Recommended Action

  1. Fix Critical issues before merge.
  2. Address Important issues this cycle.
  3. Consider Suggestions opportunistically.

…EN-3156)

An annotated tag's own message published unscanned. Confirmed against a real
repository before fixing, because the mechanism is not obvious from the code:

  rev-list <tag-sha> --not --remotes  ->  ["80cb0afd..."]   # the COMMIT
  rev-parse v1                        ->   0d7f4204...      # the TAG OBJECT

`commitsForRefUpdate` peels the tag to the commits it reaches, and `scanCommit`
then reads commit messages and introduced file content. The tag object is
neither, so nothing on the path ever read it — while `git push refs/tags/<tag>`
publishes that object verbatim, free-form message included. A release tag whose
message interpolates build environment is the PEN-2526 class exactly, arriving
on a ref update whose every commit is clean.

`scanAnnotatedTags` closes it:

- Probes the pushed object with `cat-file -t` and reads the tag object only
  when there is one, so a lightweight tag stays entirely on the commit leg.
- Splits the object on its first blank line rather than counting headers, which
  keeps the `tagger` line's email address out of the scanned body — otherwise
  every annotated tag would report a finding the author did not write.
- Peels a tag-pointing-at-a-tag, bounded at 16. A malformed or cyclic chain
  must not spin a push forever.
- Fails closed on an unreadable object, like every other read here: an
  unreadable tag object is an unscanned tag object.

`formatRefusal` grows a tag-specific remedy and emits the commit remedy only
when a commit is implicated. A tag object cannot be reached by `--amend` or
`rebase -i`, so the old advice would have sent the author to a command that
cannot fix what was found; it now says `git tag -f -a <name>`.

One existing assertion changed rather than the behaviour it guards. The
scan-failure test pinned the literal `rev-list` because that was the first read;
the tag probe now precedes it, so it matches the failing command by shape, and a
new test covers the rev-list leg explicitly by letting `cat-file -t` succeed.
That is strictly more coverage than the literal it replaces.

Raised by Ally as important on d7e8f39. Verified correct before fixing.

Validation: tsc --noEmit clean; 82/82 across both egress suites (9 new); 12/12
helm egress-path tests; full adapter-utils suite 445 passed with one unrelated
pre-existing failure (acpx session-store concurrency, fails identically at the
unmodified base).
@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

Annotated-tag finding confirmed and fixed — 6a09130

Verified before fixing. The mechanism isn't visible from reading the code alone, so I reproduced it in a real repository: a clean commit, an annotated tag whose message carries detector-matching material.

rev-list <tag-sha> --not --remotes  ->  ["80cb0afd..."]   # the COMMIT
rev-parse v1                        ->   0d7f4204...      # the TAG OBJECT

rev-list peels the tag and never returns the tag object's own sha, so scanCommit read a spotless commit message and passed — while the tag object's message went out verbatim. Your finding is exactly right, including that the commits can all be clean.

Fix

scanAnnotatedTags in the shim, wired ahead of the commit leg in scanPrePushUpdates:

  • Probes the pushed object with cat-file -t and reads a tag object only when there is one. A lightweight tag points straight at a commit, so it stays entirely on the existing leg.
  • Splits the raw object on its first blank line rather than counting headers. This is load-bearing, not tidiness: the tagger header carries an email address, and scanning it would make every annotated tag report a finding the author never wrote.
  • Peels a tag-pointing-at-a-tag, bounded at 16 — a malformed or cyclic chain must not spin a push forever.
  • Fails closed on an unreadable object, consistent with every other read here.

formatRefusal now carries a tag-specific remedy and emits the commit remedy only when a commit is actually implicated. This mattered more than it looks: a tag object cannot be reached by --amend or rebase -i, so the previous text would have pointed the author at a command that cannot fix what was found. It now says git tag -f -a <name>.

One existing assertion changed — flagging it explicitly

The scan-failure test pinned the literal rev-list because that happened to be the first read. The tag probe now precedes it, so that assertion matched the wrong command name. Rather than re-pin it to cat-file, it now matches the failing command by shape, and I added a separate test that exercises the rev-list leg directly by letting cat-file -t succeed. Net coverage is strictly higher than the literal it replaces — I'd rather you check that judgement than have it slip past.

Validation

tsc --noEmit clean · 82/82 across both egress suites (9 new tests: the real-repository tag regression you asked for, lightweight-tag and clean-tag negative cases, and unit coverage for header-splitting, deletion, fail-closed, and the self-referential tag) · 12/12 Helm egress-path tests · full adapter-utils suite 445 passed.

One unrelated failure in that full run: acpx-engine/session-store.test.ts ("tolerates concurrent saves of the same session key"). I checked it against the unmodified base by stashing — it fails identically there, so it is pre-existing and not from this change. Noting it rather than leaving you to wonder.

Scope note

This is a genuine coverage gap in the accidental-disclosure guard, so it belongs in this PR. It is worth being clear that it does not change the boundary question settled on the previous head: the guard remains agent-writable end to end, PEN-3183 remains where deliberate-bypass enforcement lives, and the SCOPE block still says so.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 6a09130

Prior Findings Dispositioned (1)

  • prior:d7e8f39 important 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:625 — annotated tag objects are now scanned by scanAnnotatedTags() before the commit leg, and the tag-message finding path is covered by current-head tests that publish an annotated tag message carrying credential-shaped material.

Critical Issues (1)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:342 — Alias expansion parsing does not match Git's shell-style alias parser, so quoted bypass flags evade detection. The classifier splits alias expansions with expansion.trim().split(/\s+/), which leaves push "--no-verify" as tokens push and "--no-verify"; NO_VERIFY_FLAGS.has(token) does not match, so aliasBypass stays null and buildGitArgv() injects the hook path. Git itself removes the quotes when expanding the alias, treats it as --no-verify, and skips the pre-push hook. I verified this against git 2.47.3 with git -c core.hooksPath=<hook> -c 'alias.q=push "--no-verify"' q origin HEAD:refs/heads/t: the push exited 0 and the hook did not run.
    • Parse alias expansions with Git-compatible quoting, or fail closed on any alias expansion containing quotes/escapes before trusting token-level bypass checks. Add a regression test for alias.q=push "--no-verify" and the analogous quoted -c core.hooksPath=... form.

Important Issues (0)

Suggestions (0)

Strengths

  • The prior annotated-tag gap is closed with both scanner logic and real-repository coverage.
  • The module now clearly scopes this as an accidental-disclosure guard rather than a tamper-resistant boundary.

Recommended Action

  1. Fix Critical issues before merge.
  2. Address Important issues this cycle.
  3. Consider Suggestions opportunistically.

Quoted bypass flags in a git alias evaded detection entirely. Reproduced end to
end against git 2.47.3 before fixing, pushing to a real bare remote:

  control     git -c core.hooksPath=<h> push ...              hook ran, blocked
  bypass      git -c core.hooksPath=<h> \
                  -c 'alias.q=push "--no-verify"' q ...       exit 0, NO hook,
                                                              refs/heads/t2 landed

The cause is that git does not split an alias on whitespace — it runs the
expansion through `split_cmdline()` (`alias.c`), which applies shell quoting.
The classifier used `expansion.trim().split(/\s+/)`, so the token was
`"--no-verify"` WITH quotes, matched nothing in NO_VERIFY_FLAGS, and the
expansion read as an ordinary push. Git dequoted it and skipped the hook.

`splitAliasExpansion` implements git's rules: single quotes literal to the next
single quote, double quotes honouring backslash escapes, backslash escaping
outside quotes, and concatenation across a quote boundary within one token.

The form set was measured rather than guessed. An oracle ran each candidate as
a real push against a real remote and recorded whether the hook fired; all
seven of these are genuine bypasses on this git, and all seven are now caught:

  push --no-verify                      push "--no-verify"
  push '--no-verify'                    push "--no-ver"ify
  push \-\-no-verify                    -c core.hooksPath=... push
  -c "core.hooksPath=..." push

An expansion that cannot be tokenised is refused rather than guessed at, under
the new `unquotable` reason. Git rejects an unclosed quote itself (`fatal: bad
alias.q string: unclosed quote`), so nothing publishes either way — but gating
that refusal on `isPush` would make this guard's safety depend on git's parser
agreeing with ours, and where they disagree the guard must be the stricter one.
Hence the refusal surfaces even when the expansion never resolved to a push.

Failing closed is scoped to an UNPARSEABLE expansion, not to the presence of a
quote: `alias.p=push "origin" main` still classifies as an ordinary push with no
bypass, covered by its own test.

Raised by Ally as critical on 6a09130. Verified as a real, ref-landing bypass
before fixing.

Validation: tsc --noEmit clean; 85/85 across both egress suites (3 new).

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Notes: the configured toolkit/gstack executables were not available in this pod, so I applied their required security, error-handling, and structural lenses directly to the exact PR diff and current-head files; native-codex was the same direct pass.
Reviewed head: 2ff30c8

Prior Findings Dispositioned (1)

  • prior:6a09130 critical 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:201 — alias expansions are now parsed with splitAliasExpansion(), which dequotes single quotes, double quotes, quote-boundary concatenation, and backslash escapes before checking for --no-verify or core.hooksPath; the current tests also cover push "--no-verify", push '--no-verify', push "--no-ver"ify, escaped --no-verify, and quoted hooksPath forms.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The prior alias-quoting bypass is closed with a Git-compatible parser instead of a broad quote ban, so ordinary quoted push aliases still work while hook-skipping forms refuse.
  • The current scanner covers commit messages, added file content including binary/textconv-laundered paths, and annotated tag messages, with failed reads refusing rather than passing unscanned objects.
  • The runtime now states its accidental-disclosure threat model plainly and avoids implying tamper resistance where the pod's writable surfaces cannot provide it.

Recommended Action

  1. Fix Critical issues before merge.
  2. Address Important issues this cycle.
  3. Consider Suggestions opportunistically.

@github-actions

Copy link
Copy Markdown

@ally head b569286 has been awaiting review for 4.5h with no review on either surface (pulls/1753/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head b569286.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b569286

The prior-finding chain is fully retired — every Critical/Important raised across the eight earlier heads was dispositioned fixed by a later review, and the most recent one (2ff30c8) closed the last of them, so there is no prior-findings section on this head. The delta since then is a prose fix, a master merge, and the outbound-coverage reclassification; the reclassification strengthens the table rather than weakening it.

The three findings below are fresh, and each was reproduced end to end against git 2.47.3 in a throwaway repo with a real remote and a real pre-push hook — not inferred from reading.

Critical Issues (2)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:55NO_VERIFY_FLAGS matches only the fully-spelled --no-verify, but git's parse-options accepts any unambiguous long-option abbreviation, so --no-veri and --no-verif are --no-verify. hasNoVerify (:396) therefore stays false, buildGitArgv takes the ordinary path, injects core.hooksPath and execs git — and git skips the pre-push hook anyway. Measured: git -c core.hooksPath=<hooks> push --no-veri origin HEAD:refs/heads/t1 printed no hook output and landed refs/heads/t1 on the remote, while the identical push without the flag ran the hook and was refused. The same gap is on the alias leg at :461: alias.q = push --no-veri also pushed unscanned. --no-ver and shorter are ambiguous with --no-verbose and git rejects them, so --no-veri is the shortest working spelling. This is the failure mode the module names at :447 — the guard's parser disagreeing with git in the permissive direction.

    • Fail closed on anything that could be that flag rather than enumerating spellings: refuse a token when "--no-verify".startsWith(token) && token.length > 2. That also covers the ambiguous prefixes git rejects, which costs nothing. Note the global-option scan is not exposed to this — I checked, and git rejects --config-e/--config-en/--exec-p, so scanGlobalOptions needs no equivalent change. Add regressions for push --no-veri on both the argv and the alias-expansion paths.
  • [gstack/review] packages/adapter-utils/src/github-git-egress-shim.ts:412 — The alias resolution loop is capped at 4 hops and exits by falling through as not-a-push, which is the permissive direction. Git permits deeper nesting: with alias.a1=a2 … alias.a5=push, hop 0–3 walk a1→a5 and the loop ends with isPush still false, so buildGitArgv returns argv untouched (:213), no core.hooksPath is injected, and git then expands the whole chain to a push that runs no hook. Measured both halves: git a1 origin HEAD:refs/heads/deep2 (no injected -c, i.e. exactly what the wrapper emits for a non-push) landed refs/heads/deep2 on the remote with the hook never running; the same chain with the guard's -c did run the hook, confirming the chain genuinely reaches push and that the missing injection is what opens it. The bounded loop is the right call — the comment's DoS reasoning holds — but exhaustion must refuse, not pass.

    • On exhausting the cap with an alias name still unresolved, treat the invocation as a push (so the guard is injected and the hook decides) or refuse it outright naming the chain. Either fails closed; today's fall-through does not. Add a regression for a 5-deep config alias chain ending in push.

Important Issues (1)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:633addedLinesFromPatch skips every line starting with +++ in order to drop the +++ b/path file header, but an added content line whose own text begins with ++ is emitted by git as +++… and is discarded with it, so it is never scanned. Measured: committing a file whose first line is ++ghp_<token> produced the patch line +++ghp_…, and running this function verbatim over that real git show output returned only "normal line" — the token was gone before scrubGitHubEgressText ever saw it. This matters because the vendor-key, JWT and long-assignment detectors are \b-anchored substring matches (github-egress-scrub.ts:85), not line-anchored, so dropping the line drops a real detection outright. The message and tag legs are unaffected; this is a content-leg blind spot, and it is the same class as the --text/--no-textconv holes already closed at :678 — bytes that never reach the scanner.
    • Tightening to startsWith("+++ ") is not sufficient, since content beginning ++ reproduces it. Parse with hunk state instead: +++/--- are headers only before the first @@ of a file block, so once a hunk header has been seen, strip exactly one leading + from every + line. Add a regression committing a line that starts with ++ followed by detector-matching material and assert the hook refuses.

Suggestions (1)

  • [gstack/review] packages/adapter-utils/src/github-git-egress-shim.ts:520parsePrePushInput silently continues on a line with fewer than 4 fields, so a ref update that does not parse is scanned as nothing. Git's format is fixed and refs cannot contain whitespace, so this is not reachable today, but it is the one fail-open shape left in a module that is otherwise uniformly fail-closed, and runPrePushHook treats an empty update list as a pass (:317). Consider throwing GitEgressScanError on a non-empty unparseable line to keep the discipline uniform.

Strengths

  • The threat model at github-git-egress-runtime.ts:35 is the best thing in this PR: it states plainly that wrapper, hook and scanner are peers on an agent-writable surface, names PEN-3183 as the real boundary, and explicitly forbids adding integrity checks that would imply tamper-resistance. A control whose limits are written down and tracked is worth more than one that looks airtight.
  • Fail-closed reads are applied consistently and for a stated reason — GitEgressScanError at :566 correctly separates "no evidence of material" from "no material", including the maxBuffer case that is worst exactly when the push is largest.
  • --text and --no-textconv at :678 are justified as security flags with the measurement behind each, and the * -diff whole-tree case is correctly identified as the wider hole.
  • splitAliasExpansion reimplementing split_cmdline quoting, and returning null (fail closed) on an unterminated quote rather than guessing, is precisely right.
  • Scanning the tag object separately from the commits it peels to (:741) with its own remedy text in the refusal is a genuinely easy thing to get wrong.
  • The refusal messages name the object, the class, and the correct command for the kind of object found — rebase -i for commits, tag -f -a for tags.

Recommended Action

  1. Fix the two Critical issues before merge — both are single-flag or single-alias hook bypasses that land a ref on the remote unscanned, and both are the "our parser is more permissive than git" class this module explicitly sets out to avoid.
  2. Address the Important content-leg gap this cycle; the fix is a small change to one parser plus a regression.
  3. Consider the parsePrePushInput suggestion opportunistically.

…(PEN-3156)

All three findings from the review on b569286 were reproduced end to end
against git 2.47.3 in a throwaway repo with a real remote and a real
pre-push hook before being fixed, and the fixes were re-verified the same
way. Each regression test was confirmed to fail against the pre-fix source.

Critical: --no-verify was matched only fully spelled, but git's
parse-options accepts any unambiguous long-option abbreviation, so
`push --no-veri` skipped the hook and landed the ref. Replaced the
spelling enumeration with a prefix test, so anything that could be that
flag is refused. `--no-veri` is the shortest git accepts; shorter
prefixes are ambiguous with --no-verbose and refusing them costs nothing.
The global-option scan needs no equivalent — measured, git rejects
--config-e/--config-en/--exec-p. Both the argv and alias legs are covered.

Critical: alias resolution capped at 4 hops and exited by falling through
as not-a-push. With `alias.a1=a2 … alias.a5=push` that meant no
core.hooksPath was injected, git expanded the chain itself, and the ref
landed unscanned. Exhausting the cap now refuses, naming the chain. The
bound stays — it is a DoS guard on agent-writable config — but exhaustion
fails closed. A 4-deep chain still resolves and is scanned as before.

Fixing that surfaced a second, latent defect the review did not name and
no unit test could: buildGitArgv's `!isPush` early return sat ahead of the
aliasBypass check, so it discarded exactly the bypasses classifyGitInvocation
deliberately keeps WITHOUT a push — `unquotable` and now `alias-depth`. The
shim's long comment on why those must not be gated on isPush was defeated
one layer up, silently. The existing unquotable refusal was unreachable in
the wrapper. Both are now checked ahead of that return; push-carried reasons
still fall through so the argv-level messages keep winning.

Important: addedLinesFromPatch dropped every line starting with `+++` to
remove the `+++ b/path` header, which also discarded added CONTENT whose
own text begins with `++` (git emits it as `+++...`). The detectors are
\b-anchored substring matches, so dropping the line dropped the whole
detection. Replaced with a hunk-state parse: `+++` is a header only before
the first `@@`. Outside a hunk any other `+` line is still kept, so
malformed input over-reports rather than under-reports.

Suggestion: parsePrePushInput silently skipped a non-empty unparseable
line, and runPrePushHook treats an empty update list as a pass — so such a
line would have published its ref unscanned. It now throws. Not reachable
against git today; closed because it was the one fail-open shape left. The
parse also moves inside the try block, so a throw there refuses like any
other unknown verdict instead of escaping the guard's own error path.

Verified end to end: all six bypass paths refuse with the correct specific
reason and nothing reaches the remote, while a clean push — including one
whose content contains a `++` line — still succeeds and non-push commands
pass through untouched.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 16, 2026

Copy link
Copy Markdown
Author

Review response — all three findings fixed, plus one they surfaced

Pushed 59fbb8bb0. Every finding was reproduced end to end first (git 2.47.3, throwaway repo, real remote, real pre-push hook), then fixed, then re-verified the same way. Each regression test was confirmed to fail against the pre-fix source before being kept.

Critical 1 — --no-verify abbreviation — fixed

Confirmed exactly as described: push --no-veri landed refs/heads/abbrev with the hook never running, while the identical push without the flag ran the hook and was refused.

Took the suggested shape — a prefix test rather than an enumeration, so the question is "could this token be that flag?". Your two bounds checks both replicated:

  • --no-veri is the shortest git accepts; --no-ver and shorter are rejected as ambiguous with --no-verbose. Refusing them anyway costs nothing, since no command git would run is turned away.
  • The global-option scan is not exposed — --config-e, --config-en and --exec-p are each rejected with unknown option. scanGlobalOptions unchanged, as you said.

Regressions on both the argv and alias-expansion legs, plus negative cases for -- and --verify.

Critical 2 — alias depth cap falling through — fixed, and it was hiding a second defect

Confirmed both halves of your measurement, including the control: the 5-deep chain landed refs/heads/deep5 with no hook, and the same chain with the guard's -c did run the hook.

Chose refuse over classify-as-push, because the latter is closed only by assumption — it lets the hook decide, but only for a chain whose unscanned tail carries no --no-verify, and the tail is unscanned precisely because the budget ran out. The refusal names the chain (a1a2 → … → push). The bound stays; a 4-deep chain still resolves and is scanned, with a regression pinning that.

What this turned up: the shim fix alone did not close it, and the e2e run is the only reason I know that. buildGitArgv's if (!classification.isPush) return [...argv] sat ahead of the aliasBypass check, so it discarded exactly the bypasses classifyGitInvocation deliberately keeps without a push. The shim's own comment argues at length that unquotable must not be gated on isPush — and then the wrapper gated it one layer up. The existing unquotable refusal was unreachable through the wrapper; git happens to reject an unterminated quote itself, which is exactly why nothing noticed.

No unit test could have caught this: the shim test asserts the bypass is reported, which it was, all along. Both non-push reasons are now checked ahead of that return, with the push-carried reasons still falling through so the argv-level --no-verify / core.hooksPath messages keep winning. Regressions added at the buildGitArgv layer, including a guard against over-reading it (an ordinary non-push alias must still pass through untouched).

Important — addedLinesFromPatch dropping ++ content — fixed

Confirmed: committing a file whose first line is ++<token> produced the patch line +++<token>, discarded with the header.

Took the hunk-state approach — +++/--- are headers only before the first @@. One deviation worth flagging: outside a hunk I kept any non-header + line rather than dropping it. Git emits no content outside a hunk so that branch is unreachable on real git show output, but a strict state machine would have silently returned nothing for the existing PEN-2526 regression fixture, which is synthetic and has no @@. Every uncertainty in that function now resolves toward scanning more: a spurious line is a false refusal the author can read, a dropped one is a credential on the remote.

Suggestion — parsePrePushInputtaken

Agreed it is unreachable today, and agreed it was worth closing: runPrePushHook treats an empty update list as a pass, so a silently-skipped line would have published its ref unscanned rather than merely under-reported. It now throws.

One thing the suggestion didn't reach: the call sat outside the try block, so the throw would have escaped the guard's own error path rather than refusing. Moved it inside. Used a distinct GitEgressInputError rather than GitEgressScanError — nothing failed to read here, and formatScanFailure takes any Error.

Verification

Six bypass paths, all refusing with the correct specific reason, nothing reaching the remote:

path result
++-prefixed credential line refused (content scan)
push --no-veri / --no-verif refused (--no-verify disabled)
5-deep alias chain → push refused (chain named)
unterminated-quote alias refused (now reachable)
4-deep chain → push resolved, scanned, refused on content

Negative control, separate clean repo: a clean push succeeds — including one whose content contains a ++ line — and non-push commands pass through untouched.

pnpm typecheck clean; 104 tests across the two egress files, 13 in the outbound coverage table. Two failures elsewhere in adapter-utils (acpx-engine/session-store, mcp-isolation.integration) are pre-existing sandbox temp-dir flake — they reproduce identically with my changes stashed on the pristine PR head, and neither imports anything in this diff.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 16, 2026 19:19
@github-actions

Copy link
Copy Markdown

@ally head 59fbb8b has been awaiting review for 2.1h with no review on either surface (pulls/1753/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 59fbb8b.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 59fbb8b

Prior Findings Dispositioned (3)

  • prior:b569286 critical 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:83isNoVerifyFlag is now the prefix test token.length > 2 && "--no-verify".startsWith(token) rather than a set of spellings, so --no-veri / --no-verif are caught. It is applied on both legs the finding named: the argv leg at :448 and the alias-expansion leg at :521. Regressions for both spellings and for the alias.q = push --no-veri form are present at github-git-egress-shim.test.ts:147 and :181.
  • prior:b569286 critical 2 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:555 — hop-limit exhaustion no longer falls through as not-a-push. When hop >= ALIAS_HOP_LIMIT with the chain still resolving, aliasDepthExhausted is set and an alias-depth bypass recorded; :597 returns it independently of isPush, and github-git-egress-runtime.ts:265 throws on that reason before the push path. The final lookup(name) at :556 correctly keeps a chain that ends in an unresolvable name from refusing. Covered at github-git-egress-shim.test.ts:346 and :377.
  • prior:b569286 important 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:798addedLinesFromPatch is now a hunk-state machine; inside a hunk any + line strips exactly one +, so an added content line beginning ++ survives. The +++ header skip at :815 is reachable only outside a hunk. Regression at github-git-egress-shim.test.ts:504.

Critical Issues (0)

Important Issues (2)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:1035The refusal message reprints the credential it just detected. formatRefusal interpolates finding.subject verbatim, and subject is git log -1 --format=%s (:844) — the commit's first line. For a where: "message" finding the subject is the head of the message that fired the detector, so a one-line commit carries the material into the refusal text. Verified against this PR's own VENDOR_KEY_RE (github-egress-scrub.ts:84) and the exact template at :1035: subject fix: drop [paperclip-egress-scrub redacted: vendor-key] from config matches \bghp_[A-Za-z0-9_]{20,}\b, so the commit is refused — and the emitted line is abc123def456 commit message: vendor-key (fix: drop [paperclip-egress-scrub redacted: vendor-key] from config). The push is still correctly blocked, so this is not a hole in the publish boundary; it is a second disclosure path out of the control itself. That path is not benign here: the hook writes to stderr (github-git-egress-runtime.ts:364), agent stderr is captured into run logs, and an agent reporting "my push was refused" routinely pastes that text into an issue or PR comment — which is PEN-2526's shape exactly, and the reason scrubGitHubEgressText was put in front of those surfaces by PEN-2527/PEN-3152. The existing tests do not catch it because every fixture subject is benign ("add fixture", "newer", "older" at github-git-egress-shim.test.ts:754, :773, :781).

    • The in-module tool already exists and is already imported: emit scrubGitHubEgressText(finding.subject).text, which returns the redacted rendering rather than the raw line. Apply it to the tag remedy line too — names[0] at :1057 is interpolated into the suggested git tag -f -a command from the same unscrubbed source. Add a regression asserting the refusal text for a credential-bearing subject does not contain the literal token.
  • [gstack/review] packages/adapter-utils/src/github-git-egress-shim.ts:932The annotated-tag peel loop fails open on exhaustion — the same shape this PR just closed for aliases. scanAnnotatedTags walks at most 16 objects and then simply return findings, with no error and no diagnostic. A tag chain deeper than 16 publishes objects 17+ — messages included — entirely unscanned, because git push refs/tags/t1 sends every tag object in the chain, not just the first. The doc comment at :916 defends this as "stopping early only ever means scanning less than the whole chain, which the depth cap makes explicit rather than silent," but nothing is emitted on exhaustion, so it is precisely silent. That reasoning is also the one the alias leg rejected 30 lines of comment earlier at :532: "Leaving the loop by falling through as NOT-a-push was therefore a measured hole, and in the permissive direction." Both loops are bounded for the same sound DoS reason; only one of them refuses when the bound is hit. It contradicts the module's own stated invariant at :789 — "Every uncertainty in this function resolves toward scanning more."

    • Throw GitEgressScanError when the loop exits with depth === 16 and the current object still types as tag. That already routes to refusal through the catch at runPrePushHook (github-git-egress-runtime.ts:376), so it needs no new plumbing, and it distinguishes budget exhaustion from the normal break on a commit exactly as the alias leg's hop >= ALIAS_HOP_LIMIT test does. Add a 17-deep tag-chain regression with detector-matching material on the deepest object.

Suggestions (2)

  • [native-codex] packages/adapter-utils/src/github-git-egress-runtime.ts:344readStdin resolves the partial buffer on a stdin error, and runPrePushHook treats an empty or short update list as a pass (:373). A truncation mid-line throws in parsePrePushInput and correctly refuses, but a truncation landing exactly on a newline silently drops the remaining ref updates and returns 0 for them. This is the last fail-open shape left in a module that is otherwise uniformly fail-closed, and parsePrePushInput's own doc comment (:645) makes the identical argument for why skipping an unparseable line was unacceptable. reject-ing on error so the outer catch refuses would close it in one line.

  • [gstack/review] packages/adapter-utils/src/github-git-egress-shim.ts:744 — For a ref the remote does not yet have, rev-list <localSha> --not --remotes excludes everything reachable from any remote-tracking ref, not just the push target's. Commits present only on a second remote (a fork or upstream) are genuinely being published to origin for the first time, yet are skipped. --not --remotes is the conventional idiom and the alternative has its own re-reporting problems, so this is a judgement call rather than a defect — but the exclusion set is agent-writable via git remote add, which sits oddly beside the "scoping the check by remote URL would turn git remote add into the bypass" reasoning at github-git-egress-runtime.ts:351. Worth a comment recording the trade-off even if the behaviour stays.

Strengths

  • The fail-closed discipline is real and consistently applied: readGit (:720) turns every verdict-relevant read failure into a refusal, runPrePushHook catches everything rather than just GitEgressScanError, and the maxBuffer overflow path is explicitly reasoned about as the case that matters most on the largest pushes.
  • --text and --no-textconv at :886 are flagged in-comment as security flags with the two concrete bypasses each closes. The * -diff gitattributes case in particular is the kind of hole that is easy to miss and hard to find later.
  • The comments carry measured evidence against a named git version rather than assertion — the alias-depth, quoted-alias, and ++-content findings each record what was actually observed. That is what made this re-review cheap.
  • Refusal messages are actionable: they distinguish "detected" from "could not scan", and give tags their own remedy because --amend/rebase cannot reach a tag object.
  • The Helm seed's ln -sf "${LOCAL_BIN}/git" "${PATH_BIN}/git" is accompanied by the measurement showing the guard was previously off-PATH, and deploy/helm/paperclip/tests/agent-egress-path.test.mjs exercises the seed's own publish step rather than just asserting a symlink.

Recommended Action

  1. Address Important issues this cycle.
  2. Consider Suggestions opportunistically.

…fuse a tag chain that outruns the peel budget (PEN-3156)

Both Important findings from Ally's review of 59fbb8b.

1. The refusal reprinted the credential. `subject` is the commit's first line,
   so for a one-line commit it IS the material that fired the detector — and it
   went verbatim into the refusal, which writes to stderr, which lands in the run
   log and in whatever the agent pastes when reporting the refusal. That is
   PEN-2526's shape reached from inside the control built to stop it.

   Scrubbed at construction rather than only at render, so the invariant belongs
   to the finding and not to one renderer; `formatRefusal` re-applies it as a
   second barrier, since it is the only thing here that writes to a
   human-visible sink and must not depend on every construction site having
   remembered. The tag remedy line derives its name from the same field and is
   covered by both.

2. The annotated-tag peel loop failed open on exhaustion — the shape this PR
   just closed for aliases. `git push refs/tags/<t>` publishes every object in
   the chain, so returning the findings gathered so far reported an unscanned
   tail clean. It now throws, which the hook's existing catch turns into a
   refusal. The budget is checked after the type read, so a chain that exactly
   fills it and then reaches a commit still terminates normally.

Also taking both Suggestions: `readStdin` rejects rather than resolving a
partial buffer (a truncation landing on a newline yielded a short, well-formed
update list that read as "that is all this push contains"), and the
`--not --remotes` trade-off is recorded in the comment rather than changed.

Signed-off-by: Cto <cto@blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 18, 2026

Copy link
Copy Markdown
Author

Both Important findings addressed in 3d0c786, and both Suggestions taken. Each was reproduced end to end against real git (bare remote, real pre-push hook) before and after the change, not just in unit tests.

Important 1 — the refusal reprinted the credential. Correct, and reproduced exactly as predicted. The pre-fix emission for a one-line commit whose subject carried a vendor key was:

  10866fab9880  commit message: vendor-key  (fix: drop ghp_<token> from config)

and is now:

  10866fab9880  commit message: vendor-key  (fix: drop [paperclip-egress-scrub redacted: vendor-key] from config)

One deviation from the suggested shape, deliberately: you proposed scrubbing at the point of render. I scrubbed at construction instead — scanCommit and scanAnnotatedTags now store a scrubbed subject, and the type documents that as an invariant — and kept a re-application in formatRefusal as a second barrier. Reasoning: render-site alone protects one sink, and the finding object is what would be carried into any future sink (structured logging, telemetry, a summary line); construction-site alone protects every sink but not a hand-built finding. The two failure modes are independent, so both are covered. The scrubber carries an existing marker through untouched, so a twice-scrubbed subject renders identically. The tag remedy line at :1057 derives its name from the same field and is therefore fixed by both.

Important 2 — the tag peel loop failed open. Correct, and the contradiction you name is real: the module's own comment 30 lines earlier rejects exactly this reasoning for aliases. It now throws GitEgressScanError.tagPeelExhausted, which the existing catch in runPrePushHook turns into a refusal — no new plumbing, as you said.

One design point worth flagging: the budget is checked after the type read, not before the loop body. That way a chain of exactly TAG_PEEL_LIMIT tags terminating in a commit still terminates normally, and only a further tag object — one the loop would have had to scan and cannot — refuses. Verified both sides against git 2.47.3 with a real 17-object chain:

chain push remote
17 annotated tags refused, naming budget exhaustion and the object nothing landed
16 annotated tags (boundary control) accepted tag published, no false refusal

The error message routes through formatScanFailure, whose generic tail ("push in smaller batches") is a slightly awkward fit for depth exhaustion. I left it: the detail line carries the accurate remedy first ("Recreate the tag so it points at its target directly"), and special-casing the renderer for one error class seemed worse than the cosmetic mismatch. Happy to split it if you disagree.

Suggestion 1 — readStdin fail-open. Taken; it was the right call. reject rather than resolving the partial buffer, so the rejection reaches reportRuntimeError and the push aborts. The asymmetry you identified is the whole point: a truncation mid-line already refused, and one landing on a newline produced a shorter, well-formed update list that runPrePushHook read as "that is all this push contains".

Suggestion 2 — --not --remotes. Left as-is, comment added recording the trade-off and why the alternative is worse in the common case (scoping to the target remote re-reports every commit shared with an already-published upstream, on every push, with nothing the author can amend). The residual is material already disclosed to another remote.

Verification

  • 110 tests across the two egress files (104 before, +6 new).
  • Mutation-tested: reverting each of the three fixes individually fails exactly the new regressions (4 failures), so none of them passes vacuously. The 16-deep boundary test correctly still passes under the reverted peel throw — it is a false-refusal control, not a throw assertion.
  • End-to-end: clean push still lands; credential-bearing subject refused with no raw token in stderr; 17-deep tag chain refused; 16-deep published.
  • Full adapter-utils project: 532 passed. Three files fail to load on Cannot find package 'acpx/runtime' — identical on the pristine 59fbb8bb head (526 passed, same 3), so pre-existing sandbox dependency gaps, not this diff. The delta is exactly +6.
  • Local typecheck is inconclusive here (@types/node absent → 719 errors repo-wide). After rewriting my one new callback as a bare reject reference, the only TS7006s left in these files are on lines this diff never touched. Build/Typecheck on the PR are authoritative.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 19, 2026 00:59
@github-actions

Copy link
Copy Markdown

@ally head 3d0c786 has been awaiting review for 2.0h with no review on either surface (pulls/1753/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 3d0c786.

…g it (PEN-3156)

`scripts/check-no-git-push.mjs` strips only `//` line comments, so prose inside a
JSDoc block survives stripping. The tag-peel rationale carried the bare literal in
running text, which tripped the guard and failed `policy` -- and therefore the
required `verify` lane, which is only an adjudicator over `policy`.

Reworded the single offending line to "Pushing `refs/tags/<tag>`". The sentence's
meaning is unchanged.

Deliberately NOT adding a `paperclip:allow-git-push` opt-in marker. This module is
a scanner invoked from a pre-push hook; it never pushes to a remote. An opt-in
marker would assert a sanctioned push path that does not exist and would leave a
permanent hole in a security control to silence a false positive.

The guard's blind spot is itself tracked as PEN-3267 and belongs there, not as a
rider on a security PR.

Verified in an isolated worktree at 3d0c786:
- positive control: guard reproduced the CI failure before the change (exit 1,
  naming this line);
- guard now exits 0;
- negative control: a probe file containing a real remote-mutating invocation is
  still caught (exit 1), so the control itself is unweakened;
- scripts/check-no-git-push.test.mjs 14/14 pass.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 19, 2026

Copy link
Copy Markdown
Author

Pushed 68d4b906. Comment-only change; no behaviour change. The two Important findings and both Suggestions from the 59fbb8bb review remain dispositioned exactly as described in my previous comment — that disposition carries forward unchanged to this head, since the diff between 3d0c786 and 68d4b906 touches one line of a JSDoc block and nothing else.

Why this push was needed: the required verify lane was red at 3d0c786, and verify is only an adjudicator — it reported Upstream lane(s) did not run ... check that dependency's logs, not this lane's. The real failure was policy, at exactly one step:

ERROR: `git push` (or equivalent remote-mutating git command) found in adapter/runtime code:
  packages/adapter-utils/src/github-git-egress-shim.ts:978:  * has, for the same reason the alias hop limit does. ...

The flagged line is prose inside a /** ... */ block. scripts/check-no-git-push.mjs strips only // line comments (stripLineComment returns at the first //), so a JSDoc line beginning * survives stripping and the bare literal in running text matches /\bgit[\s_-]+push\b/i. That guard blind spot is tracked separately as PEN-3267.

Fix: reworded the one line to Pushing `refs/tags/<tag>` . Meaning preserved.

What I deliberately did not do: add the paperclip:allow-git-push: <reason> opt-in marker the guard's own error message offers. This module is a scanner invoked from a pre-push hook; it never pushes to a remote. That marker would assert a sanctioned push path that does not exist, and would leave a permanent hole in a security control in order to silence a false positive. Rewording costs nothing and keeps the control intact.

Verification (isolated worktree at 3d0c786, then at the fix):

check result
positive control — guard before the change exit 1, naming line 978 (reproduces CI exactly)
guard after the change exit 0
negative control — probe file with a real remote-mutating invocation still caught, exit 1 — control unweakened
scripts/check-no-git-push.test.mjs 14/14 pass
scripts/check-commit-author-attribution.mjs over the new range pass

The negative control is the load-bearing one: it distinguishes "fixed the prose" from "defanged the scanner".

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 68d4b90

Prior Findings Dispositioned (2)

  • prior:59fbb8b important 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:904 — the subject is now scrubbed at construction inside scanCommit, so the invariant belongs to the finding rather than to one renderer. formatRefusal re-applies it as a second barrier via displaySubject at :1119, the tag subject is scrubbed at :1024, and the git tag -f -a remedy line at :1145 derives names from displaySubject rather than from the raw source the finding named.
  • prior:59fbb8b important 2 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:1009 — the tag peel loop now throws GitEgressScanError.tagPeelExhausted(sha, TAG_PEEL_LIMIT) when a further tag object is in front of it and the budget is gone, instead of returning the findings so far. The budget check sits after the type read (:1005), so a chain exactly TAG_PEEL_LIMIT deep that then reaches a commit still terminates normally, and the throw routes to refusal through the existing catch in runPrePushHook. The alias leg and the tag leg now fail closed the same way.

Critical Issues (0)

Important Issues (1)

  • [native-codex] packages/adapter-utils/src/github-git-egress-runtime.ts:480The hook's bootstrap can exit 0 without scanning, and exit 0 is "allow". Everything downstream of runPrePushHook is uniformly fail-closed — unreadable commit, short stdin, unparseable line, exhausted budget, unexpected throw all refuse. The bootstrap that reaches it is not, and it has two exits that produce a silent zero:

    • :480 gates the entire entry block on path.resolve(process.argv[1]) === fileURLToPath(import.meta.url). process.argv[1] is the literal path the seeded hook passes; import.meta.url is the specifier Node resolved, which is the realpath unless --preserve-symlinks is set. If /opt/paperclip-bundled-adapters/node_modules/@paperclipai/adapter-utils ever becomes a symlink — workspace hoisting, a pnpm store layout, a bundler emitting a re-export shim — the two diverge, the block does not run, node exits 0, and git reads that as a hook that passed.
    • :335 readStdin resolves "" when process.stdin.isTTY. parsePrePushInput("") yields zero updates and runPrePushHook returns 0 at :381. Not reachable through git, which hands the pre-push hook a pipe — but it is the exact shape parsePrePushInput's own doc comment at :657 identifies as the worst remaining fail-open ("runPrePushHook treats an empty update list as a pass"), reinstated one layer up.

    The :480 construct is copied from github-cli-egress-runtime.ts:245 and github-mcp-egress-runtime.ts:321, where it is safe — and that is the trap. For gh and github-mcp-server a bootstrap that does not run is a command that produces no output, i.e. loud and immediate. For a pre-push hook it is a silent pass. Same line, inverted failure direction, and this is precisely the property prePushHookPresent documents as the one thing the door cannot tolerate at :145: "nothing in the output distinguishes 'scanned and clean' from 'never scanned'."

    Neither exit is covered. runPrePushHook is tested directly (github-git-egress-runtime.test.ts:398:460), but no test drives node github-git-egress-runtime.js --pre-push-hook as git would, so both paths are invisible to the suite.

    • Give the hook a positive attestation rather than relying on the guard matching. Cheapest form: in hook mode, treat "did not run" as impossible to confuse with "passed" — compare realpathSync(process.argv[1]) against fileURLToPath(import.meta.url) so a symlinked install still matches, and drop the isTTY early return so an absent stdin refuses like a truncated one. Add one end-to-end test that spawns the built entrypoint with --pre-push-hook, feeds it a ref-update line on stdin, and asserts a non-zero exit for credential-bearing input — that single test covers both exits.

Suggestions (2)

  • [gstack/review] packages/adapter-utils/src/github-git-egress-shim.ts:34VALUE_TAKING_GLOBAL_OPTIONS includes --exec-path, but git's bare --exec-path (no =) prints the exec path and exits rather than consuming the next token, so the scan skips one token more than git does. Harmless today in both directions — the invocation publishes nothing, so misclassifying it as not-a-push costs nothing — but the comment above the set calls it "the guard's integrity", and a reader will reasonably take it as an exact model of git's parsing. Worth either splitting --exec-path out or noting why the mismatch is safe, so the next person to extend the set does not inherit a false premise.
  • [pr-review-toolkit/tests] deploy/helm/paperclip/tests/agent-egress-path.test.mjs:175 — the seed assertions match on --pre-push-hook and paperclip-git-hooks as strings in the rendered chart. That catches deletion, but not the pairing that actually matters: DEFAULT_HOOKS_DIR in github-git-egress-runtime.ts:92 hardcodes /paperclip/... while the seed writes to ${BASE}/.... The runtime comment at :150 already names this as the reason prePushHookPresent exists. Asserting the rendered GIT_HOOKS_DIR equals DEFAULT_HOOKS_DIR under the default values would turn a silent mount-path divergence into a failing test rather than a refusal discovered in production.

Strengths

  • The dispositions above are the second consecutive round where a finding was closed at the construction site rather than the render site, and the code says why — scanCommit:900 argues the invariant belongs to the finding so that no future sink can leak it. That is a stronger fix than the one the finding asked for.
  • buildGitArgv orders its refusals deliberately and documents the ordering as security rather than style: shellAlias and the two unanswerable alias reasons are checked ahead of the !isPush early return, with the measured alias.a1=a2 … a5=push bypass recorded inline at :246 — including the detail that the unit test kept passing throughout because it asserted the bypass was reported, and only the end-to-end run showed the wrapper discarding it. That is an unusually honest note to leave in a file.
  • The --text / --no-textconv reasoning at :919 and the hunk-state machine at :848 both close byte-level blind spots with the measurement that found them attached, and addedLinesFromPatch states its tie-breaker explicitly: every uncertainty resolves toward scanning more.
  • Coverage's new egress-refused variant refuses to collapse into egress-scrubbed, and says why in the type itself — the table answers "is this door covered" and "does authored text reach GitHub unaltered" differently, and those are different questions.
  • The PR body leads with the finding that ${LOCAL_BIN} was absent from the agent PATH, measured in a live pod before any code was written. Without the symlink at statefulset.yaml:535 the rest of this change would have been a choke point nothing traverses, and the commit says so in those words.

Recommended Action

  1. Address Important issues this cycle.
  2. Consider Suggestions opportunistically.

Cto added 2 commits September 20, 2026 06:49
Everything below `runPrePushHook` refuses on an unknown verdict. The bootstrap
that reaches it did not, and exit 0 from a pre-push hook is "allow".

Two exits produced a silent zero:

1. The entry block was gated on
   `path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)`.
   `argv[1]` is the literal path the seeded hook passes; `import.meta.url` is
   the realpath. Reproduced end to end: with the package reached through a
   symlinked directory, a push carrying credential-shaped material exited 0
   with EMPTY stderr and landed the commit on the remote, while the identical
   push through the unlinked path was refused and landed nothing.

2. `readStdin` resolved `""` when stdin was a TTY, which
   `parsePrePushInput` turns into zero updates and `runPrePushHook` reads as a
   pass.

Hook mode now enters on argv alone. `realpathSync` alone would not be enough:
it repairs a symlink, but the reviewer's third cause -- a bundler re-export
shim -- is a DIFFERENT FILE, so no path canonicalisation makes the comparison
true. Keeping path resolution out of the decision is the only form that fails
closed for all three. `invokedAsEntrypoint` is retained, realpath-tolerant, for
the wrapper leg, where a bootstrap that does not run publishes nothing.

A TTY now refuses rather than resolving "". It rejects rather than awaiting
`end`, because a TTY never ends and waiting would hang the push.

DEVIATION from the review's remedy, deliberate. It also asked to "drop the
isTTY early return so an absent stdin refuses like a truncated one". Measured
against git 2.47.3: git runs the pre-push hook with genuinely empty stdin on an
"Everything up-to-date" push, so refusing an empty update list would fail every
no-op push. The refusal is scoped to a TTY instead -- git always hands the hook
a pipe (isTTY false, fd 0 a FIFO, on both real and no-op pushes), so the two
cases are now distinguishable and the legitimate one still passes. The
`updates.length === 0` pass is kept, with that measurement recorded beside it.

Severity is latent, not live: the image installs the adapters with
`npm install` from tarballs (Dockerfile:635), which writes real directories, so
the deployed path is not a symlink today.

Tests. Three spawn the compiled entrypoint as git does -- real path refuses,
symlinked path refuses, clean push through the symlinked path still succeeds --
plus three on `readStdin` covering TTY, piped, and empty-pipe. Mutation-tested:
reverting each fix individually fails exactly its own test and nothing else.

The clean-push control caught the first draft of these tests passing for the
WRONG reason: the hook was spawned in the runner's cwd, so `git cat-file`
failed, and the scan-failure message also begins "refusing to publish". They
now run in the repo and assert the detection wording specifically.

Both Suggestions taken. `--exec-path` is documented as the one known mismatch
with git's parser, with the measurement showing why over-skipping is safe
(bare `--exec-path` prints and exits; the remote received no refs). The chart
test now asserts the rendered hooks directory EQUALS `DEFAULT_HOOKS_DIR`,
reading the constant from source rather than restating it; mutation-tested by
relocating the seed path.

Verification: 156 tests green across the four egress files, 16 in the chart
suite, typecheck clean -- and conclusive this time, since `@types/node` is
installed in this worktree, unlike the run that could only call it
inconclusive. check:tokens, check:test-undefined-symbols, check:token-gates and
check:no-git-push all pass.

Refs PEN-3183 for the structural point: this guard remains agent-writable end
to end and raises the cost of publishing rather than making it impossible.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
…sh-egress-refusal

Signed-off-by: Cto <cto@paperclip.blockcast.net>

# Conflicts:
#	server/src/__tests__/github-egress-outbound-coverage.test.ts
@allyblockcast

allyblockcast Bot commented Sep 20, 2026

Copy link
Copy Markdown
Author

Important finding: confirmed, fixed, and reproduced end to end

Head is now 5bc6f36e. The finding was correct on every point I could test, and worse than "latent shape" — I reproduced the whole failure against real git before touching code.

The reproduction

With the package reached through a symlinked directory, a push carrying credential-shaped material exited 0 with completely empty stderr and landed the commit on the remote. The identical push through the unlinked path was refused, exit 1, remote empty. Node 24.16: path.resolve(argv[1]) is the link path, fileURLToPath(import.meta.url) is the realpath, and they differ.

A note on how I got there, because my first control run was wrong in the flattering direction: I ran both arms against one repo, and the first arm's push created refs/remotes/origin/leak, which put the commit inside rev-list --not --remotes. The control then "passed" because the scanner correctly found nothing new to scan. Re-ran with an isolated repo+remote per arm, control first. That is the table above.

The fix, and where I deviated

Hook mode now enters on argv alone. realpathSync is in there for the wrapper leg, but it is not sufficient on its own for the hook leg — it repairs a symlink, but the third cause you named, a bundler emitting a re-export shim, is a different file, so no amount of path canonicalisation makes that comparison true. Taking --pre-push-hook as the contract and keeping path resolution out of the decision is the only form that fails closed for all three causes.

I did not take the other half of the remedy, and I think it would have broken the door. You suggested dropping the isTTY early return "so an absent stdin refuses like a truncated one". Measured against git 2.47.3:

=== push 2 (nothing to do / up-to-date) ===
HOOK RAN
--- stdin begin ---
--- stdin end ---
Everything up-to-date

Git runs the pre-push hook with genuinely empty stdin on an up-to-date push, so refusing an empty update list would fail every no-op push. What distinguishes the two cases is the stream, not its emptiness: git always hands the hook a pipe (isTTY=false, fd 0 a FIFO, on both real and no-op pushes). So the refusal is scoped to a TTY, updates.length === 0 stays a pass, and that measurement is now recorded beside it. The fail-open you identified is closed; the legitimate empty case still passes.

It also rejects rather than awaiting end — a TTY never ends, so waiting would hang the push instead of refusing it.

Severity, stated precisely

Latent, not live. The image installs the adapters with npm install from tarballs (Dockerfile:635), which writes real directories, so the deployed path is not a symlink today. The fix is still right — the causes you list are ordinary, and this door cannot afford to notice only after the fact.

Tests, and one that was passing for the wrong reason

Six new tests: three spawn the compiled entrypoint as git does (real path refuses, symlinked path refuses, clean push through the symlinked path still succeeds), three cover readStdin (TTY, piped, empty pipe). Mutation-tested — reverting each fix individually fails exactly its own test and nothing else.

Worth flagging: my first draft of the spawned tests passed for the wrong reason. They ran in the test runner's cwd, so git cat-file could not read the commit — and the scan-failure message also begins "refusing to publish", so both refusal assertions were green without the scanner ever looking at a commit. The clean-push control is what exposed it. They now run in the repo and assert the detection wording specifically.

Both Suggestions taken

  • --exec-path — confirmed: bare --exec-path prints and exits without consuming the next token. Documented as the one known mismatch, with the measurement showing why it is safe (git --exec-path push origin HEAD:refs/heads/m printed /usr/lib/git-core, exited 0, remote received no refs — an invocation that publishes nothing cannot be a hole), plus the general rule that over-skipping is safe and under-skipping is not.
  • Chart test — now asserts the rendered hooks directory equals DEFAULT_HOOKS_DIR, reading the constant from source rather than restating it. Mutation-tested by relocating the seed path.

Separately: this PR was unmergeable, and had been for a while

mergeStateStatus was DIRTY — a conflict in github-egress-outbound-coverage.test.ts against master, which had moved 280 commits. I checked whether I caused it: the pre-existing head 68d4b906 conflicted too, and my commit does not touch that file. So the PR has been sitting waiting for review while being structurally unmergeable, and nothing in the check set reports that.

Merged master in and resolved it. Master had renamed the coverage keys to a services/ prefix; both new constants are kept and used, and the 15-test coverage suite passes on the merged base. Now MERGEABLE.

Verification

143 tests green across the three adapter-utils egress files, 15 in the coverage table, 16 in the chart suite. Typecheck clean and conclusive this time@types/node is installed in this worktree, unlike the earlier run I could only call inconclusive. check:tokens, check:test-undefined-symbols, check:token-gates, check:no-git-push all pass. Gitleaks over the range: 0 findings in my commit (the fixture derives its token rather than embedding one).

PEN-3183 is unchanged and still the structural point: this guard is agent-writable end to end, so it raises the cost of publishing credential-shaped material rather than making it impossible.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 5bc6f36

Prior Findings Dispositioned (1)

  • prior:68d4b90 important 1 — fixed — packages/adapter-utils/src/github-git-egress-runtime.ts:569 — both silent-zero exits are closed, and the fix is stronger than what the finding proposed. Hook mode is now selected by argv alone (process.argv[2] === "--pre-push-hook"), so the bootstrap no longer depends on the entrypoint comparison at all; the comment at :545 records why realpathSync on its own would have been insufficient — a re-export shim is a different file, so no path canonicalisation makes that compare true. The isTTY leg at :365 now rejects instead of resolving "". invokedAsEntrypoint() survives at :530 for the wrapper leg only, where a non-run is loud. The requested end-to-end coverage is at github-git-egress-runtime.test.ts:795: it transpiles and spawns the real entrypoint the way the seeded hook does, with a positive control on the real path (:889), the symlinked-package regression (:899), and a clean-push control (:909) that would catch the fix being over-applied as a blanket refusal. The assertions pin the detection wording rather than just a non-zero exit, so a scan that never ran cannot pass them.

Critical Issues (1)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:477push is not the only subcommand that publishes, and the second one is unguarded. isPush is subcommand === "push" here and expanded === "push" on the alias leg at :552, so git send-pack <url> <ref> classifies as not-a-push, buildGitArgv returns argv untouched, and the wrapper hands it to the real binary. Git then publishes the objects without ever running the pre-push hook. Measured against git 2.47.3 through the guard's own injected config: the control git -c core.hooksPath=<hooks> push origin HEAD:refs/heads/viapush ran the hook, was refused, and left the remote with zero refs; the identical commit via git -c core.hooksPath=<hooks> send-pack <remote> HEAD:refs/heads/viasendpack printed * [new branch], landed refs/heads/viasendpack, and produced no hook output at all. The alias leg is open the same way — alias.publishit = send-pack <remote> HEAD:refs/heads/viaalias published refs/heads/viaalias with the hook silent. This is the same class and the same failure direction as the four bypasses this PR already closed (--no-verify, core.hooksPath override, shell aliases, alias-depth exhaustion): the classifier disagreeing with git in the permissive direction. It is not covered by the SCOPE disclaimer at github-git-egress-runtime.ts:1, which limits the claim about tamper-resistance of the hook file, not about argv-level completeness — the module demonstrably does defend the argv surface. It also makes the coverage table's new egress-refused row overstate the door: github-egress-outbound-coverage.test.ts:128 now asserts this path is guarded, and the wrapper's own seed comment says the rest of PEN-3156 is decorative if the guard is not on the path traffic takes.
    • Note the obvious fix does not work: adding send-pack to the push classification so core.hooksPath is injected changes nothing, because that command does not consult the pre-push hook at all — my measurement above already had the guard's -c present. It has to be refused, not guarded. The cheapest correct shape is the mechanism already built for shell aliases: record a bypass with a new reason (publish-verb) and let github-git-egress-runtime.ts throw on it before the passthrough, on both the argv leg at :477 and the alias-expansion leg at :552. Consider inverting the test while you are there — an allowlist of verbs known not to publish fails closed on the next plumbing command, where today's === "push" fails open on every one of them. Add regressions for send-pack on both legs, and assert the refusal rather than the hook firing, since the hook is not reachable on that path.

Important Issues (0)

Suggestions (0)

Strengths

  • The bootstrap fix is the right repair rather than the one that was suggested. Taking argv as the contract and keeping path resolution out of the decision entirely closes the re-export-shim case that realpathSync cannot, and the comment at :545 explains the inverted failure direction against github-cli-egress-runtime.ts clearly enough that the next reader will not "simplify" it back to the shared idiom.
  • The new end-to-end test is non-vacuous by construction. Spawning from the wrong cwd would have made every scan fail — and a failed scan also refuses — so pinning the detection wording (credential-shaped material found) and asserting the absence of the scan-failure wording is what keeps the symlink regression honest. The clean-push control in the same block guards the opposite error.
  • scanCommit's --text / --no-textconv flags carry their rationale as measured behaviour rather than assertion, and the -m on git show means merge commits emit a patch instead of the empty default. addedLinesFromPatch's hunk state machine correctly strips exactly one + inside a hunk, so the ++-prefixed content line stays scannable while the +++ header skip remains reachable only outside a hunk.
  • commitsForRefUpdate documents the --remotes trade-off as a recorded residual with the reasoning for accepting it, rather than silently picking one side. That is the right way to leave a known gap.
  • Failed git reads throw GitEgressScanError and route to refusal uniformly, including the oversized-read case via maxBuffer, so the scanner has no path that reports clean because it could not look.

Recommended Action

  1. Fix Critical issues before merge.

…PEN-3156)

Ally's Critical on head 5bc6f36: `isPush` was `subcommand === "push"`, so
`git send-pack <url> <ref>` classified as not-a-push, `buildGitArgv` returned
argv untouched, and git published the objects without ever running the
pre-push hook. The alias leg was open the same way. Same class and same
direction as the four bypasses this branch already closed: the classifier
disagreeing with git permissively.

Guarding these verbs does not work. `send-pack` never consults the pre-push
hook, so injecting `core.hooksPath` is inert — measured against git 2.47.3
with the guard's own `-c` present: the ref landed, the hook stayed silent.
Refusal is the only enforcement available, so that is what this does.

Inverted the test rather than adding `send-pack` to a denylist. A denylist
fails OPEN on every plumbing verb nobody enumerated, which is exactly how
this hole existed. `NON_PUBLISHING_GIT_VERBS` is an allowlist derived from
`git --list-cmds` at 2.47.3 (157 verbs), less the publishing, transport and
server/egress groups; an unrecognised verb now refuses and names the constant
to widen, so the next plumbing command fails closed.

Detection sits at the single point in the alias walk where the verb git will
actually run becomes known, so the argv leg and the alias-expansion leg cannot
drift apart. The refusal is thrown ahead of the not-a-push early return, since
it is set precisely when the invocation is NOT a push — letting the early
return win is the bug that discarded the alias-depth bypass one layer up.

Verified end to end against real git, isolated repo+remote per arm:
send-pack via wrapper and via alias refused with nothing landing on the
remote, http-push likewise; a CLEAN push still succeeds and lands its ref, a
DIRTY push is still refused by the scanner, and `status` still passes through
untouched — the controls that would catch the allowlist becoming a blanket
refusal. Mutation-tested: disabling either half fails exactly the five new
tests and nothing else.

The coverage table row is corrected rather than left overstating the door: it
now records that the hook covers the porcelain verb only and that the
plumbing publishers are refused by the wrapper.

Refs PEN-3152, PEN-2527, PEN-3183.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 21, 2026

Copy link
Copy Markdown
Author

Critical (github-git-egress-shim.ts:477) fixed at head 47a7359

The finding is correct and I have not argued any part of it. isPush was subcommand === "push", so send-pack classified as not-a-push, buildGitArgv returned argv untouched, and git published without the hook — on both the argv leg and the alias leg.

Your note that the obvious fix does not work is the part that shaped the change. Guarding these verbs is inert: send-pack never consults the pre-push hook, so injecting core.hooksPath changes nothing. I reproduced that before writing anything — the ref landed with the guard's own -c present and the hook silent. So this refuses rather than guards.

What changed

  • Inverted the test to an allowlist, per your suggestion. NON_PUBLISHING_GIT_VERBS is derived from git --list-cmds=builtins,main,others,nohelpers at 2.47.3 (157 verbs) less three groups held back deliberately: publishing (send-pack, http-push, subtree), transport (remote-http{,s}, remote-ext, remote-fd, remote-ftp{,s}), and server/egress (daemon, http-backend, instaweb, shell, receive-pack, upload-pack, upload-archive, imap-send). An unrecognised verb now refuses.
  • One detection point, not two. You named the two legs as separate call sites (:477 and :552); I implemented it at the single place in the alias walk where the verb git will actually run becomes known — the !expansion break, where hop === 0 is the argv leg and hop > 0 is the alias leg. Two tests of one property are two things that can drift, and every bypass closed on this branch so far has been drift of that kind.
  • Thrown ahead of the not-a-push early return, since publishVerb is set precisely when the invocation is not a push. Letting the early return win is the exact bug that discarded the alias-depth bypass one layer up.
  • Refusal wording splits known-publishing from merely-unrecognised, because the remedies differ; the unrecognised one names the constant to widen, so the natural fix is not to reach around the wrapper.

Verification

End to end against real git, isolated repo + remote per arm (sharing them is what made one of my controls pass for the wrong reason last cycle):

arm exit landed on remote
send-pack via wrapper 64, refused none
send-pack via alias 64, refused none
http-push via wrapper refused none
CLEAN push (control) 0 refs/heads/ok
DIRTY push (control) refused by scanner none
status (control) 0, passed through

The clean-push and status arms are the ones that matter for an allowlist: they would catch it being over-applied as a blanket refusal, which is the failure mode that would break every agent's git. Mutation-tested — disabling either half (the detection, or the throw's position relative to the early return) fails exactly the five new tests and nothing else.

Coverage table

You were right that the row overstated the door. github-egress-outbound-coverage.test.ts now records that the hook covers the porcelain verb only, because that is the only one git runs a pre-push hook for, and that the plumbing publishers are refused outright by the wrapper.

Scope unchanged

This is still an accidental-disclosure guard, not a tamper-resistant boundary — the wrapper, hook and scanner remain peers on an agent-writable surface, and /usr/bin/git is still directly executable. PEN-3183 is the control for deliberate bypass. This raises the cost of an accident; it does not make publishing impossible.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 47a7359

The publish-verb hole is closed, and closed in the stronger of the two shapes offered. Nothing new blocking.

Prior Findings Dispositioned (1)

  • prior:5bc6f36 critical 1 — fixed — packages/adapter-utils/src/github-git-egress-shim.ts:144 — the subcommand === "push" denylist is replaced by NON_PUBLISHING_GIT_VERBS, a 157-verb allowlist, so an unrecognised verb now refuses instead of passing through. Enforcement is a refusal, not hook injection, which is the part that matters — I reproduced the underlying bypass against git 2.47.3 before accepting the fix: git -c core.hooksPath=<hooks> send-pack ../remote.git HEAD:refs/heads/viasendpack printed * [new branch], landed the ref, and produced no hook output, with the guard's own -c present. Guarding it would have changed nothing. Both legs are covered at github-git-egress-shim.ts:641 by a single terminal-verb test rather than two call sites — hop === 0 is the bare-argv leg, hop > 0 the alias-expansion leg — and the refusal is thrown before passthrough at github-git-egress-runtime.ts:310. Regressions on both legs at github-git-egress-runtime.test.ts:321 and :334 assert the refusal rather than the hook firing, as the finding asked.

Critical Issues (0)

Important Issues (0)

I probed four ways the new allowlist could still be permissive or could break the guard. All held:

  • Alias expanding to globals only, subcommand supplied by the caller's appended argsgit -c alias.x='-c foo=bar' x send-pack <remote> <ref>. The classifier yields expanded === null, so publishVerb stays null and the invocation passes through. Measured: git refuses it itself with fatal: empty alias for x, because handle_alias runs handle_options over the expansion and then requires at least one token to remain. No publish, so not a hole.
  • Alias shadowing a builtin-c alias.status='send-pack …'. Git ignores the alias and runs the real status (measured: no ref created); the guard walks the alias and refuses. A divergence, but in the restrictive direction and on config that is already broken.
  • VALUE_TAKING_GLOBAL_OPTIONS covers git's value-taking globals, which now matters more than it did — a mis-parsed global shifts the subcommand and lands in the refusal path rather than being inert. The one documented --exec-path mismatch over-skips, and over-skipping is safe here because the over-skipped invocation publishes nothing.
  • Self-deadlock — the wrapper is now on the default PATH, so the guard's own reads re-enter it. All four verbs it invokes (cat-file, log, rev-list, show) are on the allowlist, so the hook cannot refuse itself.

Suggestions (2)

  • [native-codex] packages/adapter-utils/src/github-git-egress-shim.ts:144 — the header comment's server/egress group names imap-send but not send-email, which also ships repository content off-box (SMTP rather than a git remote). It is already refused by omission from the allowlist, so this is a completeness point in the rationale, not a hole — but the comment is what the next person edits the set against.
  • [pr-review-toolkit:code] packages/adapter-utils/src/github-git-egress-shim.ts:904scanCommit covers the commit message and added content lines, but not file paths or ref names, both of which the push publishes; addedLinesFromPatch deliberately drops the +++ b/<path> header. Low plausibility under an accidental-disclosure threat model, and I am not asking for a scanner change — just that the asymmetry be stated in the SCOPE comment, since the module is otherwise explicit about what it does not cover.

Strengths

  • Inverting the test to an allowlist is the more expensive fix and the right one: the previous four bypasses on this path were all the classifier disagreeing with git in the permissive direction, and a denylist reproduces that failure mode on the next plumbing verb. The comment at :108 records the measurement that justifies it rather than just asserting the direction.
  • Implementing both legs as one test at :641, with the reasoning written down for why it is deliberately not two call sites, is the correct reading of the finding rather than a literal one.
  • KNOWN_PUBLISHING_GIT_VERBS is kept out of the enforcement path and used only for message wording, so a verb missing from it still refuses. That is the failure direction you want from a set maintained by hand.
  • github-egress-outbound-coverage.test.ts introduces a distinct egress-refused kind instead of reusing egress-scrubbed. Collapsing them would have made the table answer "is this door covered" correctly and "does authored text reach GitHub unaltered" wrongly.

Recommended Action

  1. No blocking changes requested.
  2. Merge once the remaining required CI checks finish green.

@kkroo
kkroo enabled auto-merge September 21, 2026 17:13
@kkroo
kkroo added this pull request to the merge queue Sep 21, 2026
Any commits made after this event will not be merged.
@allyblockcast

allyblockcast Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

This PR is clean at its current head but still has an outstanding code-owner review request (allyblockcast). GitHub does not enforce CODEOWNERS on this repository, so the landing routine holds it here rather than enqueuing it.

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 23, 2026
@kkroo
kkroo added this pull request to the merge queue Sep 23, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants