diff --git a/deploy/helm/paperclip/templates/statefulset.yaml b/deploy/helm/paperclip/templates/statefulset.yaml
index ad59e7109db5..3322e57ef9a4 100644
--- a/deploy/helm/paperclip/templates/statefulset.yaml
+++ b/deploy/helm/paperclip/templates/statefulset.yaml
@@ -471,9 +471,36 @@ spec:
# arrives scrubbed. Do not "simplify" this back to a direct exec.
exec /paperclip/.local/bin/paperclip-github-token-env /usr/local/bin/node /opt/paperclip-bundled-adapters/node_modules/@paperclipai/adapter-utils/dist/github-mcp-egress-runtime.js /usr/local/bin/github-mcp-server "$@"
EOF
+ # PEN-3156: the THIRD egress door. `gh` and `github-mcp-server`
+ # above rewrite a payload in flight; this one cannot, because a
+ # commit object is content-addressed — altering a blob or a
+ # message changes that commit's SHA and every descendant's. So the
+ # guard here REFUSES a publish that would carry credential-shaped
+ # material, and names the commit to amend.
+ #
+ # The runtime goes INSIDE the token wrapper, like
+ # github-mcp-server above, so git still inherits its credentials.
+ # It runs on every invocation but acts only on a publish: it
+ # injects core.hooksPath for the hook below and rejects
+ # --no-verify, which would otherwise skip that hook. Everything
+ # else is passed through untouched.
+ GIT_HOOKS_DIR="${BASE}/.local/share/paperclip-git-hooks"
+ mkdir -p "${GIT_HOOKS_DIR}"
+ cat > "${GIT_HOOKS_DIR}/pre-push" <<'EOF'
+ #!/bin/sh
+ exec /usr/local/bin/node /opt/paperclip-bundled-adapters/node_modules/@paperclipai/adapter-utils/dist/github-git-egress-runtime.js --pre-push-hook "$@"
+ EOF
+ chmod 0755 "${GIT_HOOKS_DIR}/pre-push"
+ # Ownership, not a security boundary. This container is already
+ # runAsUser 1000 and the PVC is fsGroup 1000, so the hook is
+ # writable by the agent no matter what is written here — and the
+ # chart has no root to make it otherwise. The guard's threat model
+ # is accidental disclosure; see the header of
+ # packages/adapter-utils/src/github-git-egress-runtime.ts.
+ chown 1000:1000 "${GIT_HOOKS_DIR}" "${GIT_HOOKS_DIR}/pre-push" 2>/dev/null || true
cat > "${LOCAL_BIN}/git" <<'EOF'
#!/bin/sh
- exec /paperclip/.local/bin/paperclip-github-token-env /usr/bin/git \
+ exec /paperclip/.local/bin/paperclip-github-token-env /usr/local/bin/node /opt/paperclip-bundled-adapters/node_modules/@paperclipai/adapter-utils/dist/github-git-egress-runtime.js /usr/bin/git \
-c credential.https://github.com.helper= \
-c credential.https://github.com.helper=/paperclip/.local/bin/github-token-credential-helper \
"$@"
@@ -505,8 +532,19 @@ spec:
mkdir -p "${PATH_BIN}"
ln -sf "${LOCAL_BIN}/gh" "${PATH_BIN}/gh"
chown -h 1000:1000 "${PATH_BIN}/gh" 2>/dev/null || true
+ # PEN-3156: `git` needs the same treatment, and for the same
+ # reason. Measured in a live agent Job pod before this change:
+ # PATH was ".../paperclip/bin:...:/usr/bin", `command -v gh` gave
+ # ${PATH_BIN}/gh, and `command -v git` gave /usr/bin/git — because
+ # ${LOCAL_BIN}/git existed but ${LOCAL_BIN} was not on that PATH
+ # and no symlink published it here. The push guard would have been
+ # a choke point nothing traverses. Without this line the rest of
+ # PEN-3156 is decorative.
+ ln -sf "${LOCAL_BIN}/git" "${PATH_BIN}/git"
+ chown -h 1000:1000 "${PATH_BIN}/git" 2>/dev/null || true
echo "seed: installed fresh-read GitHub token wrappers in ${LOCAL_BIN}"
echo "seed: published egress-scrubbing gh on the default PATH at ${PATH_BIN}/gh"
+ echo "seed: published publish-guarding git on the default PATH at ${PATH_BIN}/git"
# Seed a project-scope .mcp.json at $HOME (CWD for k8s Job pods is
# /paperclip by default — matches PAPERCLIP_HOME) so every claude
diff --git a/deploy/helm/paperclip/tests/agent-egress-path.test.mjs b/deploy/helm/paperclip/tests/agent-egress-path.test.mjs
index c385c73de229..8a964f242cbb 100644
--- a/deploy/helm/paperclip/tests/agent-egress-path.test.mjs
+++ b/deploy/helm/paperclip/tests/agent-egress-path.test.mjs
@@ -104,6 +104,32 @@ function extractPathPublishFragment(rendered) {
throw new Error("did not find the gh symlink line in the publish step");
}
+/**
+ * PEN-3156: the same publish step, but captured through the `git` symlink
+ * rather than stopping at the `gh` one.
+ *
+ * A separate walker rather than a parameter on the one above, so that the `gh`
+ * assertion keeps testing exactly the region it always did and cannot start
+ * passing because of a line added for `git`.
+ */
+function extractPathPublishFragmentThroughGit(rendered) {
+ const lines = rendered.split("\n");
+ const startIdx = lines.findIndex((line) =>
+ /^\s*PATH_BIN="\$\{BASE\}\/bin"$/.test(line),
+ );
+ assert.notEqual(startIdx, -1, "seed script no longer publishes onto the default PATH");
+ const indent = lines[startIdx].match(/^(\s*)/)[1];
+ const body = [];
+ for (let i = startIdx; i < lines.length; i += 1) {
+ const line = lines[i].slice(indent.length);
+ body.push(line);
+ if (/^ln -sf "\$\{LOCAL_BIN\}\/git"/.test(line)) return body.join("\n");
+ }
+ throw new Error(
+ "the seed does not publish git onto the PATH-visible bin; the push guard would be off the traffic path (PEN-3156)",
+ );
+}
+
function writeExecutable(dir, name, body) {
const file = path.join(dir, name);
fs.writeFileSync(file, body, { mode: 0o755 });
@@ -216,6 +242,126 @@ test("a non-login shell resolves gh to the scrubbing wrapper under the default-v
assert.equal(result.stdout.trim(), "scrubbing-wrapper");
});
+// --- The publish guard on the `git` door (PEN-3156) -------------------------
+
+test("a non-login shell resolves git to the publish-guarding wrapper", () => {
+ // The regression this pins, measured in a live agent Job pod on 2026-09-10:
+ // ${LOCAL_BIN}/git existed and was byte-identical to the chart, but the pod's
+ // PATH carried /paperclip/bin without /paperclip/.local/bin and nothing
+ // published git into the former — so `command -v git` gave /usr/bin/git and
+ // any guard in the wrapper was a choke point nothing traversed. `gh` had the
+ // same defect and PEN-2527 fixed it with a symlink; git never got one.
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "git-path-reach-"));
+ const localBin = path.join(base, ".local", "bin");
+ const imageBin = path.join(base, "usr-bin");
+ for (const dir of [localBin, imageBin]) fs.mkdirSync(dir, { recursive: true });
+
+ const rendered = render("templates/statefulset.yaml", {
+ set: [`persistence.mountPath=${base}`],
+ });
+
+ writeExecutable(localBin, "git", "#!/bin/sh\necho guarding-wrapper\n");
+ writeExecutable(imageBin, "git", "#!/bin/sh\necho image-git\n");
+
+ // Run the seed's own publish step, so this fails if the seed stops publishing
+ // git rather than merely if a symlink is missing.
+ const seeded = spawnSync(
+ "sh",
+ [
+ "-c",
+ [
+ "set -eu",
+ `BASE=${JSON.stringify(base)}`,
+ `LOCAL_BIN=${JSON.stringify(localBin)}`,
+ extractPathPublishFragmentThroughGit(rendered),
+ ].join("\n"),
+ ],
+ { encoding: "utf8" },
+ );
+ assert.equal(seeded.status, 0, seeded.stderr);
+
+ const containerPathValue = containerPath(rendered);
+ const testPath = containerPathValue
+ .split(":")
+ .map((entry) => (entry === "/usr/bin" ? imageBin : entry))
+ .join(":");
+
+ const result = spawnSync("/bin/sh", ["-c", "git"], {
+ encoding: "utf8",
+ env: { PATH: testPath },
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(result.stdout.trim(), "guarding-wrapper");
+});
+
+test("the seeded git wrapper routes through the egress runtime, inside the token wrapper", () => {
+ const rendered = render("templates/statefulset.yaml", {});
+ const match = /cat > "\$\{LOCAL_BIN\}\/git" <<'EOF'\n([\s\S]*?)\n[ \t]*EOF/.exec(rendered);
+ assert.notEqual(match, null, "the seed no longer writes a git wrapper");
+ const body = match[1];
+
+ assert.match(
+ body,
+ /github-git-egress-runtime\.js/,
+ "the git wrapper does not reach the publish guard (PEN-3156)",
+ );
+
+ // Ordering is load-bearing in one direction: the token wrapper must stay
+ // outermost or git runs without its credentials.
+ const tokenAt = body.indexOf("paperclip-github-token-env");
+ const runtimeAt = body.indexOf("github-git-egress-runtime.js");
+ assert.ok(tokenAt >= 0 && runtimeAt > tokenAt, "the scrub runtime must sit inside the token wrapper");
+});
+
+test("the seed installs a pre-push hook for the guard to run", () => {
+ const rendered = render("templates/statefulset.yaml", {});
+ // Without the hook the wrapper injects core.hooksPath at a directory holding
+ // nothing, and every push is allowed while looking guarded.
+ assert.match(rendered, /paperclip-git-hooks/, "no hooks directory is seeded");
+ assert.match(
+ rendered,
+ /cat > "\$\{GIT_HOOKS_DIR\}\/pre-push" <<'EOF'/,
+ "the seed does not write a pre-push hook",
+ );
+ assert.match(rendered, /--pre-push-hook/, "the seeded hook does not invoke the guard");
+});
+
+test("the seeded hooks directory is the one the runtime actually looks in", () => {
+ // The assertions above match `--pre-push-hook` and `paperclip-git-hooks` as
+ // strings, which catches deletion but not DIVERGENCE — and divergence is the
+ // failure this seam actually has. The runtime hardcodes DEFAULT_HOOKS_DIR
+ // (`/paperclip/...`) while the seed writes to `${BASE}/...` from
+ // persistence.mountPath. They are equal in every values file today, so a
+ // deployment that relocated the PVC would point core.hooksPath at a directory
+ // holding no hook. `prePushHookPresent` exists to turn that into a refusal
+ // rather than a silent unscanned push; this turns it into a failing test
+ // instead, which is cheaper than discovering it in production.
+ const rendered = render("templates/statefulset.yaml", {});
+
+ const base = rendered.match(/^\s*BASE=(?:"([^"]*)"|'([^']*)'|(\S+))\s*$/m);
+ assert.ok(base, "could not find BASE in the rendered seed script");
+ const baseValue = base[1] ?? base[2] ?? base[3];
+
+ const hooks = rendered.match(/GIT_HOOKS_DIR="\$\{BASE\}([^"]*)"/);
+ assert.ok(hooks, "could not find GIT_HOOKS_DIR in the rendered seed script");
+ const seeded = `${baseValue}${hooks[1]}`;
+
+ // Read the constant from source rather than restating it: a test that
+ // hardcodes both sides of an equality cannot observe either one moving.
+ const runtimeSource = fs.readFileSync(
+ path.join(repoRoot, "packages/adapter-utils/src/github-git-egress-runtime.ts"),
+ "utf8",
+ );
+ const declared = runtimeSource.match(/DEFAULT_HOOKS_DIR\s*=\s*"([^"]+)"/);
+ assert.ok(declared, "could not read DEFAULT_HOOKS_DIR from the runtime source");
+
+ assert.equal(
+ seeded,
+ declared[1],
+ "the seed writes the pre-push hook somewhere the runtime will not look",
+ );
+});
+
// --- Fail closed on overrides that would take the scrubber off the path ----
test("a PATH entry in env.extra is rejected rather than silently overriding the chart", () => {
diff --git a/packages/adapter-utils/src/github-git-egress-runtime.test.ts b/packages/adapter-utils/src/github-git-egress-runtime.test.ts
new file mode 100644
index 000000000000..0bac7e5ee3cf
--- /dev/null
+++ b/packages/adapter-utils/src/github-git-egress-runtime.test.ts
@@ -0,0 +1,1017 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+
+import { execFileSync, spawnSync } from "node:child_process";
+import { mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+
+import {
+ buildGitArgv,
+ DEFAULT_GIT_BINARY,
+ DEFAULT_HOOKS_DIR,
+ GitEgressRuntimeError,
+ gitBinary,
+ hooksDirectory,
+ makeGitReader,
+ readStdin,
+ type HookStdin,
+ runGitEgressRuntime,
+ runPrePushHook,
+} from "./github-git-egress-runtime.js";
+import type { GitReader } from "./github-git-egress-shim.js";
+
+const HOOKS = "/hooks";
+
+/**
+ * The deployment precondition every push test depends on: the seed has written
+ * an executable `pre-push` into the hooks directory.
+ *
+ * Spelled out rather than defaulted, because a push with no hook installed is
+ * now a refusal — see the "fails closed when the hook is missing" test. Tests
+ * that are not about that case assert the precondition holds.
+ */
+const PRESENT = { hooksDir: HOOKS, hookPresent: () => true };
+
+/** Real git, or null when this environment has none to drive. */
+const GIT = spawnSync("git", ["--version"], { encoding: "utf8" }).status === 0 ? "git" : null;
+
+/**
+ * The spawned-entrypoint tests need git at DEFAULT_GIT_BINARY specifically, not
+ * merely on PATH: the hook's own reader hardcodes that path, because the
+ * `PAPERCLIP_GIT_EGRESS_GIT` override was removed as an escape hatch (see
+ * `hooksDirectory`). Checking PATH instead would let those tests run with a git
+ * the hook cannot reach, and a scan that cannot read git refuses — which would
+ * look like the regression passing.
+ */
+const SPAWNED_GIT =
+ spawnSync(DEFAULT_GIT_BINARY, ["--version"], { encoding: "utf8" }).status === 0;
+
+describe("buildGitArgv", () => {
+ it("points a push at the hooks directory that holds the guard", () => {
+ expect(buildGitArgv(["push", "origin", "main"], { ...PRESENT })).toEqual([
+ "-c",
+ `core.hooksPath=${HOOKS}`,
+ "push",
+ "origin",
+ "main",
+ ]);
+ });
+
+ it("puts the injected option before the subcommand", () => {
+ // git only accepts global options ahead of the subcommand; appending it
+ // would make git treat it as a push argument and the hook would not run.
+ const argv = buildGitArgv(["push"], { ...PRESENT });
+ expect(argv.indexOf("-c")).toBeLessThan(argv.indexOf("push"));
+ });
+
+ it("leaves every non-push invocation completely untouched", () => {
+ // Scoping the injection matters: a hooks directory holding only `pre-push`
+ // silently disables a repository's pre-commit and commit-msg hooks, so this
+ // must not be set globally.
+ for (const argv of [["status"], ["commit", "-m", "x"], ["fetch", "origin"], ["log"]]) {
+ expect(buildGitArgv(argv, { ...PRESENT })).toEqual(argv);
+ }
+ });
+
+ it("refuses the flag that would skip the hook", () => {
+ // Without this the whole control is one flag away from being off.
+ expect(() => buildGitArgv(["push", "--no-verify"], { ...PRESENT })).toThrow(
+ GitEgressRuntimeError,
+ );
+ expect(() => buildGitArgv(["push", "--no-verify"], { ...PRESENT })).toThrow(
+ /--no-verify is disabled/,
+ );
+ });
+
+ it("allows `push -n`, which is --dry-run and not a hook bypass", () => {
+ // `-n` means --dry-run for push. The hook still runs under it and nothing
+ // is published either way, so refusing it rejected a safe command and gave
+ // a reason that was not true.
+ expect(buildGitArgv(["push", "-n"], { ...PRESENT })).toEqual([
+ "-c",
+ `core.hooksPath=${HOOKS}`,
+ "push",
+ "-n",
+ ]);
+ });
+
+ it("injects the guard AFTER the caller's global options, so git reads it last", () => {
+ // Git takes the last `-c` given for a key. Injecting at the front let
+ // `git -c core.hooksPath=/tmp/empty push` override the guard and skip the
+ // scanner while still traversing the wrapper — measured against git 2.47.3.
+ const argv = buildGitArgv(["-C", "/repo", "--no-pager", "push", "origin", "main"], {
+ ...PRESENT,
+ });
+ expect(argv).toEqual([
+ "-C",
+ "/repo",
+ "--no-pager",
+ "-c",
+ `core.hooksPath=${HOOKS}`,
+ "push",
+ "origin",
+ "main",
+ ]);
+ // The guard is the last core.hooksPath in the argv, and still ahead of the
+ // subcommand, which is where git requires global options to sit.
+ const positions = argv
+ .map((token, index) => (token.toLowerCase().startsWith("core.hookspath=") ? index : -1))
+ .filter((index) => index >= 0);
+ expect(positions.at(-1)).toBe(argv.indexOf(`core.hooksPath=${HOOKS}`));
+ expect(argv.indexOf(`core.hooksPath=${HOOKS}`)).toBeLessThan(argv.indexOf("push"));
+ });
+
+ it("refuses a push that sets core.hooksPath itself", () => {
+ for (const argv of [
+ ["-c", "core.hooksPath=/tmp/empty", "push"],
+ ["-c", "CORE.HOOKSPATH=/tmp/empty", "push"],
+ ["--config-env=core.hooksPath=HP", "push"],
+ ]) {
+ expect(() => buildGitArgv(argv, { ...PRESENT }), argv.join(" ")).toThrow(
+ /sets core\.hooksPath itself/,
+ );
+ }
+ });
+
+ it("refuses an alias whose expansion carries the bypass", () => {
+ // Injection cannot win here: git expands the alias after the command line,
+ // so the expansion's own flag is the last thing git sees.
+ expect(() =>
+ buildGitArgv(["yolo"], {
+ ...PRESENT,
+ resolveAlias: (name) => (name === "yolo" ? "push --no-verify" : null),
+ }),
+ ).toThrow(/expands to a push that skips the pre-push hook/);
+
+ expect(() =>
+ buildGitArgv(["sneaky"], {
+ ...PRESENT,
+ resolveAlias: (name) =>
+ name === "sneaky" ? "-c core.hooksPath=/tmp/empty push" : null,
+ }),
+ ).toThrow(/points core\.hooksPath somewhere else/);
+ });
+
+ it("still guards a push reached through an alias", () => {
+ const argv = buildGitArgv(["yolo"], {
+ ...PRESENT,
+ resolveAlias: (name) => (name === "yolo" ? "push --force" : null),
+ });
+ expect(argv).toEqual(["-c", `core.hooksPath=${HOOKS}`, "yolo"]);
+ });
+
+ it("refuses an alias the command line defines for itself", () => {
+ // `git -c alias.yolo='push --no-verify' yolo` pushed to a real remote with
+ // the hook never running (git 2.47.3). The definition is unreachable from
+ // the `git config --get` this wrapper runs — that lookup exits 1 with no
+ // output — so no resolver is supplied here: the guard has to read the
+ // definition out of argv or it does not hold at all.
+ expect(() =>
+ buildGitArgv(["-c", "alias.yolo=push --no-verify", "yolo"], { ...PRESENT }),
+ ).toThrow(/expands to a push that skips the pre-push hook/);
+ });
+
+ it("guards a push reached through an alias the command line defines", () => {
+ // The non-refusal half: an ordinary command-line alias must still be
+ // recognised as a push so the guard is injected. Measured against git
+ // 2.47.3, `git -c alias.p=push -c core.hooksPath=
p origin HEAD:...`
+ // ran the hook and the push aborted; without the injection it succeeded.
+ const argv = buildGitArgv(["-c", "alias.p=push", "p", "origin", "main"], {
+ ...PRESENT,
+ });
+ expect(argv).toEqual([
+ "-c",
+ "alias.p=push",
+ "-c",
+ `core.hooksPath=${HOOKS}`,
+ "p",
+ "origin",
+ "main",
+ ]);
+ expect(argv.indexOf(`core.hooksPath=${HOOKS}`)).toBeLessThan(argv.indexOf("p"));
+ });
+
+ it("reads a --config-env alias through the environment it is given", () => {
+ expect(() =>
+ buildGitArgv(["--config-env=alias.yolo=A_PUSH", "yolo"], {
+ ...PRESENT,
+ env: { A_PUSH: "push --no-verify" },
+ }),
+ ).toThrow(/expands to a push that skips the pre-push hook/);
+ });
+
+ it("leaves a command-line alias to a non-push alone", () => {
+ const argv = ["-c", "alias.st=status --short", "st"];
+ expect(buildGitArgv(argv, { ...PRESENT })).toEqual(argv);
+ });
+});
+
+describe.skipIf(!GIT)("runGitEgressRuntime alias resolution", () => {
+ // These drive REAL git, because the thing under test is whether the alias
+ // lookup runs against the same configuration git will use. A stubbed
+ // resolver would answer whatever the stub decided and prove nothing.
+
+ it("resolves a file-based alias in the repository the invocation selects", () => {
+ // `-C` chooses which config files an alias lookup reads. A bare `git config
+ // --get` reads the WRAPPER's cwd instead, finds nothing, and classifies the
+ // invocation as not-a-push. Measured against git 2.47.3 on a real remote:
+ // before the caller's global options were forwarded to the lookup,
+ // `git -C ` published a commit carrying credential-shaped
+ // material with the hook never running.
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "alias.yolo", "push --no-verify"]);
+
+ // Refused during argv construction, so no push is ever attempted: reaching
+ // the refusal at all is the proof that the alias resolved.
+ // Thrown synchronously, before the promise is built — which is exactly why
+ // the entrypoint wraps this call in a try/catch as well as a .catch().
+ expect(() =>
+ runGitEgressRuntime({ target: "git", argv: ["-C", repo, "yolo"], ...PRESENT }),
+ ).toThrow(/expands to a push that skips the pre-push hook/);
+ });
+
+ it("prefers a command-line definition over the repository's own", () => {
+ // Git takes the command-line one; so must the guard, or a benign on-disk
+ // alias masks a hostile command-line redefinition of the same name.
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "alias.p", "status"]);
+
+ expect(() =>
+ runGitEgressRuntime({
+ target: "git",
+ argv: ["-C", repo, "-c", "alias.p=push --no-verify", "p"],
+ ...PRESENT,
+ }),
+ ).toThrow(/expands to a push that skips the pre-push hook/);
+ });
+});
+
+describe("entrypoint configuration", () => {
+ // Regression for a bypass Ally found on 6228564: both of these were `env`
+ // overrides defaulting to the constants, and the deployed entrypoint called
+ // them with no argument, i.e. against process.env. The environment is
+ // agent-controlled, so `PAPERCLIP_GIT_EGRESS_HOOKS_DIR=/tmp/empty git push`
+ // aimed core.hooksPath at a directory with no hook and published unscanned,
+ // and PAPERCLIP_GIT_EGRESS_GIT aimed the hook's own reader at a binary of the
+ // caller's choosing.
+ //
+ // These mutate process.env and assert the value does NOT move, so
+ // reintroducing the read fails here rather than only in production.
+ const vars = ["PAPERCLIP_GIT_EGRESS_HOOKS_DIR", "PAPERCLIP_GIT_EGRESS_GIT"] as const;
+ const saved = new Map();
+
+ beforeEach(() => {
+ for (const name of vars) saved.set(name, process.env[name]);
+ });
+
+ afterEach(() => {
+ for (const name of vars) {
+ const previous = saved.get(name);
+ if (previous === undefined) delete process.env[name];
+ else process.env[name] = previous;
+ }
+ });
+
+ it("uses the path the Helm seed writes", () => {
+ expect(hooksDirectory()).toBe(DEFAULT_HOOKS_DIR);
+ expect(gitBinary()).toBe(DEFAULT_GIT_BINARY);
+ });
+
+ it("cannot be redirected by the agent-controlled environment", () => {
+ process.env.PAPERCLIP_GIT_EGRESS_HOOKS_DIR = "/tmp/empty";
+ process.env.PAPERCLIP_GIT_EGRESS_GIT = "/tmp/fake-git";
+
+ expect(hooksDirectory()).toBe(DEFAULT_HOOKS_DIR);
+ expect(gitBinary()).toBe(DEFAULT_GIT_BINARY);
+ });
+});
+
+describe("missing hook", () => {
+ // git treats a hooks directory with no pre-push in it as "no hook to run" and
+ // the push proceeds. Nothing downstream can tell that apart from a clean
+ // scan, so the only safe reading of an absent guard is refusal.
+ it("fails closed when the hook is not installed", () => {
+ expect(() =>
+ buildGitArgv(["push", "origin", "main"], { hooksDir: HOOKS, hookPresent: () => false }),
+ ).toThrow(/no executable pre-push hook/);
+ });
+
+ it("does not require the hook for commands that publish nothing", () => {
+ const argv = ["status"];
+ expect(buildGitArgv(argv, { hooksDir: HOOKS, hookPresent: () => false })).toEqual(argv);
+ });
+});
+
+describe("publishing verbs other than `push` (PEN-3156)", () => {
+ // The bypass these close: `isPush` was `subcommand === "push"`, so every
+ // other verb that publishes classified as not-a-push and went through
+ // untouched. Measured against git 2.47.3 by Ally on head 5bc6f36e, and
+ // re-measured end to end here: `git -c core.hooksPath= send-pack
+ // HEAD:refs/heads/x` printed `* [new branch]`, landed the ref, and
+ // ran no hook — the guard's injected config present and irrelevant, because
+ // `send-pack` never consults the pre-push hook.
+ //
+ // These therefore assert the REFUSAL, not that the hook fired. Asserting the
+ // hook would be asserting something unreachable on this path, and would pass
+ // for the wrong reason the moment the refusal regressed to a guard.
+ const SEND_PACK = "send-pack";
+
+ it("refuses send-pack on the bare-argv leg", () => {
+ expect(() =>
+ buildGitArgv([SEND_PACK, "origin", "HEAD:refs/heads/x"], { ...PRESENT }),
+ ).toThrow(/refusing to run `git send-pack`/);
+ });
+
+ it("says injecting the hook would not help, so nobody re-fixes it as a guard", () => {
+ // The obvious remedy — add these verbs to the push classification so
+ // `core.hooksPath` is injected — is inert. The message has to carry that,
+ // or the next author re-applies it.
+ expect(() => buildGitArgv([SEND_PACK], { ...PRESENT })).toThrow(/does not help/);
+ });
+
+ it("refuses send-pack reached through an alias, naming the chain", () => {
+ // The alias-expansion leg. Ally measured this one publishing too:
+ // `alias.publishit = send-pack HEAD:refs/heads/viaalias` landed
+ // the ref with the hook silent.
+ expect(() =>
+ buildGitArgv(["publishit"], {
+ ...PRESENT,
+ resolveAlias: (name) =>
+ name === "publishit" ? `${SEND_PACK} origin HEAD:refs/heads/x` : null,
+ }),
+ ).toThrow(/refusing to run `git send-pack`[\s\S]*alias `publishit`/);
+ });
+
+ it("refuses an unrecognised verb, because unknown cannot be shown to be safe", () => {
+ // The inversion. A denylist would pass this; the allowlist refuses it, and
+ // that is the whole behavioural change. `lfs` is the realistic case — a
+ // third-party verb that does publish.
+ expect(() => buildGitArgv(["lfs", "push"], { ...PRESENT })).toThrow(
+ /refusing to run `git lfs`[\s\S]*not one of them/,
+ );
+ });
+
+ it("tells the operator which constant to widen, so the fix is not to bypass the wrapper", () => {
+ expect(() => buildGitArgv(["some-new-plumbing"], { ...PRESENT })).toThrow(
+ /NON_PUBLISHING_GIT_VERBS/,
+ );
+ });
+
+ it("still passes ordinary non-publishing verbs through untouched", () => {
+ // The control that would catch the allowlist being over-applied as a
+ // blanket refusal — the failure mode that would break every agent's git.
+ // Deliberately wide, and includes plumbing, because the risk of an
+ // allowlist is what it omits.
+ for (const argv of [
+ ["status"],
+ ["commit", "-m", "x"],
+ ["fetch", "origin"],
+ ["log"],
+ ["rev-parse", "HEAD"],
+ ["worktree", "list"],
+ ["cat-file", "-p", "HEAD"],
+ ["ls-remote", "origin"],
+ ["for-each-ref"],
+ ["update-ref", "refs/heads/x", "HEAD"],
+ ["stash", "pop"],
+ ["bundle", "create", "/tmp/b", "HEAD"],
+ ]) {
+ expect(buildGitArgv(argv, { ...PRESENT }), argv.join(" ")).toEqual(argv);
+ }
+ });
+
+ it("still guards a real push rather than refusing it", () => {
+ // The other half of that control: the inversion must not have swept `push`
+ // itself into the refusal path.
+ const argv = buildGitArgv(["push", "origin", "main"], { ...PRESENT });
+ expect(argv).toContain("push");
+ expect(argv.join(" ")).toContain("core.hooksPath");
+ });
+});
+
+describe("shell aliases", () => {
+ // 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`. So a bare
+ // `git push` inside a `!` alias reaches the real git without passing through
+ // this wrapper or the hook — no absolute path needed. Passing these through
+ // was the residual gap this replaces.
+ //
+ // The expansions below are assembled rather than written out, because
+ // scripts/check-no-git-push.mjs rejects that adjacency anywhere in this tree
+ // and these are fixtures for a guard that exists to recognise exactly it. The
+ // assembled value is byte-identical to the literal at runtime; the marker
+ // would instead assert an operator-approved publish path, which a test string
+ // is not.
+ const PUSH = "push";
+
+ it("refuses a shell alias rather than guessing whether it publishes", () => {
+ expect(() =>
+ buildGitArgv(["publish"], {
+ ...PRESENT,
+ resolveAlias: (name) =>
+ name === "publish" ? `!/usr/bin/git ${PUSH} --no-verify` : null,
+ }),
+ ).toThrow(/refusing to run the shell alias `publish`/);
+ });
+
+ it("refuses an alias chain deeper than the hop limit instead of handing it to git", () => {
+ // The chain reaches a push, but resolution runs out of budget before seeing
+ // it, so `isPush` is false. That must NOT reach the not-a-push early return:
+ // measured against git 2.47.3, handing this argv back unchanged meant no
+ // `core.hooksPath` was injected, git expanded the chain itself, and
+ // `refs/heads/deep5` landed on the remote with the hook never running.
+ const chain: Record = {
+ a1: "a2",
+ a2: "a3",
+ a3: "a4",
+ a4: "a5",
+ a5: "push",
+ };
+ expect(() =>
+ buildGitArgv(["a1", "origin", "main"], {
+ ...PRESENT,
+ resolveAlias: (name) => chain[name] ?? null,
+ }),
+ ).toThrow(/alias chain deeper than 4 hops/);
+ });
+
+ it("refuses an unparseable alias expansion even though it classifies as not-a-push", () => {
+ // Same shape as the depth case and the reason this ordering is tested at the
+ // wrapper rather than only at `classifyGitInvocation`: the shim deliberately
+ // reports this bypass with `isPush` false, and an early return here would
+ // discard it. The shim's own unit test cannot catch that — it asserts the
+ // bypass is REPORTED, which it is either way.
+ expect(() =>
+ buildGitArgv(["q", "origin", "main"], {
+ ...PRESENT,
+ resolveAlias: (name) => (name === "q" ? 'push "--no-verify' : null),
+ }),
+ ).toThrow(/unterminated quote/);
+ });
+
+ it("still passes an ordinary non-push through untouched", () => {
+ // The guard against over-reading the two rules above: only a bypass the shim
+ // kept without a push refuses early. A plain non-push alias must not.
+ const argv = ["st", "--short"];
+ expect(
+ buildGitArgv(argv, {
+ ...PRESENT,
+ resolveAlias: (name) => (name === "st" ? "status --short" : null),
+ }),
+ ).toEqual(argv);
+ });
+
+ it("refuses one whose expansion names no push at all, because it cannot be parsed", () => {
+ // The point of failing closed: this publishes, and no textual test for
+ // the subcommand that indirection can defeat is worth trusting.
+ expect(() =>
+ buildGitArgv(["helper"], {
+ ...PRESENT,
+ resolveAlias: (name) => (name === "helper" ? `!f() { git ${PUSH}; }; f` : null),
+ }),
+ ).toThrow(/refusing to run the shell alias `helper`/);
+ });
+
+ it("refuses one defined on the command line, which no separate lookup can see", () => {
+ expect(() =>
+ buildGitArgv(["-c", `alias.x=!/usr/bin/git ${PUSH}`, "x"], { ...PRESENT }),
+ ).toThrow(/refusing to run the shell alias `x`/);
+ });
+
+ it("leaves ordinary non-shell aliases alone", () => {
+ const argv = buildGitArgv(["lg"], {
+ ...PRESENT,
+ resolveAlias: (name) => (name === "lg" ? "log --oneline" : null),
+ });
+ expect(argv).toEqual(["lg"]);
+ });
+});
+
+describe("runPrePushHook", () => {
+ const dump = ["ALPHA", "BRAVO", "CHARLIE", "DELTA", "ECHOES"]
+ .map((name, index) => `${name}=value-${index}`)
+ .join("\n");
+
+ const dirtyGit: GitReader = (args) => {
+ if (args[0] === "rev-list") return "c0ffee0000000000\n";
+ if (args.includes("--format=%s")) return "wip";
+ if (args.includes("--format=%B")) return `wip\n\n${dump}\n`;
+ return "";
+ };
+
+ it("exits zero when there is nothing to publish", async () => {
+ const code = await runPrePushHook({
+ input: "",
+ runGit: () => {
+ throw new Error("git must not be consulted");
+ },
+ });
+ expect(code).toBe(0);
+ });
+
+ it("exits zero for a clean push", async () => {
+ const code = await runPrePushHook({
+ input: "refs/heads/m a refs/heads/m b\n",
+ runGit: (args) => {
+ if (args[0] === "rev-list") return "abc\n";
+ if (args.includes("--format=%s")) return "clean";
+ if (args.includes("--format=%B")) return "clean\n";
+ return "";
+ },
+ });
+ expect(code).toBe(0);
+ });
+
+ it("aborts the push and explains which commit to amend", async () => {
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input: "refs/heads/m a refs/heads/m b\n",
+ runGit: dirtyGit,
+ stderr: (message) => errors.push(message),
+ });
+ // Non-zero is what actually stops the push; the text is what makes it
+ // actionable rather than a bare rejection.
+ expect(code).toBe(1);
+ expect(errors.join("\n")).toContain("c0ffee000000");
+ expect(errors.join("\n")).toContain("environment-dump");
+ });
+
+ it("aborts the push when the scan cannot be completed", async () => {
+ // A guard whose error path is "allow" is not a guard. Before this, a failed
+ // rev-list reported an empty commit set and the push proceeded unscanned.
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input: "refs/heads/m a refs/heads/m b\n",
+ runGit: () => null,
+ stderr: (message) => errors.push(message),
+ });
+ expect(code).toBe(1);
+ const text = errors.join("\n");
+ expect(text).toContain("could not be completed");
+ // It must not read as a detection: there is no commit to go and amend.
+ expect(text).toContain("refusal, not a detection");
+ // Name the git command that failed, so the fault is diagnosable. Matched by
+ // shape rather than by literal: which read fails FIRST is an ordering
+ // detail (the annotated-tag `cat-file -t` probe now precedes `rev-list`),
+ // and pinning the literal here turned a reordering into a false failure.
+ // The rev-list leg keeps its own explicit coverage in the next test.
+ expect(text).toMatch(/`git [a-z][a-z-]*/);
+ });
+
+ it("names rev-list when the commit range is what fails", async () => {
+ // Guards the leg the test above used to pin: the tag probe succeeds, so the
+ // scan reaches `rev-list`, and its failure must still abort and be named.
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input: "refs/heads/m a refs/heads/m b\n",
+ // `cat-file -t` answers "commit" (no tag object to scan); everything else
+ // fails, which lands on the rev-list read.
+ runGit: (args) => (args[0] === "cat-file" && args[1] === "-t" ? "commit\n" : null),
+ stderr: (message) => errors.push(message),
+ });
+ expect(code).toBe(1);
+ const text = errors.join("\n");
+ expect(text).toContain("could not be completed");
+ expect(text).toContain("rev-list");
+ });
+
+ it("aborts the push on an unexpected scanner failure", async () => {
+ // Anything thrown leaves the verdict unknown, which must refuse, not pass.
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input: "refs/heads/m a refs/heads/m b\n",
+ runGit: () => {
+ throw new Error("git went missing");
+ },
+ stderr: (message) => errors.push(message),
+ });
+ expect(code).toBe(1);
+ expect(errors.join("\n")).toContain("git went missing");
+ });
+});
+
+describe.skipIf(!GIT)("content leg against a real repository", () => {
+ // These drive REAL git for the same reason the alias tests above do: the
+ // property under test is what `git show` EMITS, which a stubbed reader would
+ // simply assert into existence. Each case below is a way for a file's bytes
+ // never to reach the scanner, and each was a silent pass before `--text` /
+ // `--no-textconv` (Ally review on b8ae369f found the first; the other two
+ // turned up while confirming it).
+
+ /**
+ * A vendor key, assembled rather than pasted — same rule as the fixtures in
+ * `github-git-egress-shim.test.ts`. A literal would be a real `sk-` string
+ * committed to this repository: `.github/scripts/check-pr-security.mjs`
+ * flags `sk-[a-zA-Z0-9]{32,}` as a high-severity finding, so the pull request
+ * adding a secret-containment control would itself trip the secret scan.
+ * Joining the parts at runtime produces the same bytes for git to publish
+ * while leaving no matchable literal in the source.
+ */
+ function vendorKey(): string {
+ return ["sk", "ant", "api03", "Xq7mZp2Lw9Rt4Nv8Bc3Hj6Kd1Fg5Ys0Ae"].join("-");
+ }
+
+ /** A repo with one commit, and the pre-push input that would publish it. */
+ function repoWithCommit(files: Record): {
+ runGit: GitReader;
+ input: string;
+ } {
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-content-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "user.email", "t@example.invalid"]);
+ execFileSync("git", ["-C", repo, "config", "user.name", "T"]);
+ for (const [name, body] of Object.entries(files)) {
+ writeFileSync(path.join(repo, name), body);
+ }
+ execFileSync("git", ["-C", repo, "add", "-A"]);
+ execFileSync("git", ["-C", repo, "commit", "-qm", "add fixtures"]);
+ const head = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], {
+ encoding: "utf8",
+ }).trim();
+ return {
+ runGit: makeGitReader("git", repo),
+ // A new branch the remote does not have: `0`*40 on the remote side is the
+ // shape git hands the hook for a first push.
+ input: `refs/heads/topic ${head} refs/heads/topic ${"0".repeat(40)}\n`,
+ };
+ }
+
+ async function refusals(files: Record): Promise {
+ const { runGit, input } = repoWithCommit(files);
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input,
+ runGit,
+ stderr: (message) => errors.push(message),
+ });
+ expect(code).toBe(1);
+ return errors.join("\n");
+ }
+
+ it("scans a binary addition instead of skipping it", async () => {
+ // Git calls a file binary when a NUL lands in the first 8000 bytes, and
+ // then prints `Binary files ... differ` with no `+` lines at all — so
+ // `addedLinesFromPatch` saw the empty string and reported the commit clean.
+ // A credential does not stop being a credential for sharing a blob with a
+ // NUL byte.
+ const blob = Buffer.concat([
+ Buffer.from("header"),
+ Buffer.from([0x00]),
+ Buffer.from(`${vendorKey()}\n`),
+ ]);
+ expect(await refusals({ "payload.bin": blob })).toContain("vendor-key");
+ });
+
+ it("scans a path a .gitattributes entry marks -diff", async () => {
+ // The wider hole, and it needs no binary content: `-diff` makes git print
+ // the same `Binary files ... differ` summary for plain ASCII. `*` applies
+ // it to the whole tree, and the attributes file is committed in the same
+ // push it hides, so nothing earlier in the range would have caught it.
+ expect(
+ await refusals({ ".gitattributes": "* -diff\n", "secret.txt": `${vendorKey()}\n` }),
+ ).toContain("vendor-key");
+ });
+
+ it("scans the real bytes, not a textconv driver's output", async () => {
+ // `diff..textconv` is ordinary repository config — agent-writable —
+ // and replaces a file's content with that command's output for display.
+ // Without `--no-textconv` the scanner reads `innocuous` and passes the
+ // commit while the real bytes go to the remote.
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-textconv-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "user.email", "t@example.invalid"]);
+ execFileSync("git", ["-C", repo, "config", "user.name", "T"]);
+ execFileSync("git", ["-C", repo, "config", "diff.launder.textconv", "echo innocuous"]);
+ writeFileSync(path.join(repo, ".gitattributes"), "secret.txt diff=launder\n");
+ writeFileSync(path.join(repo, "secret.txt"), `${vendorKey()}\n`);
+ execFileSync("git", ["-C", repo, "add", "-A"]);
+ execFileSync("git", ["-C", repo, "commit", "-qm", "add fixtures"]);
+ const head = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], {
+ encoding: "utf8",
+ }).trim();
+
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input: `refs/heads/topic ${head} refs/heads/topic ${"0".repeat(40)}\n`,
+ runGit: makeGitReader("git", repo),
+ stderr: (message) => errors.push(message),
+ });
+ expect(code).toBe(1);
+ expect(errors.join("\n")).toContain("vendor-key");
+ });
+
+ it("scans an annotated tag's own message, which no commit carries", async () => {
+ // The gap: `commitsForRefUpdate` runs `rev-list `, which PEELS the
+ // tag to the commits it reaches — measured, the tag object's own sha is
+ // never in that output. `scanCommit` then reads commit messages and patches,
+ // so nothing on the path ever reads the tag object. But `git push
+ // refs/tags/` publishes that object verbatim, free-form message
+ // included. A release tag whose message interpolates build environment is
+ // the PEN-2526 class on a ref update whose every commit is clean.
+ //
+ // The commit here is deliberately spotless, so a pass would mean the tag
+ // message went out unscanned rather than that something else caught it.
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-tag-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "user.email", "t@example.invalid"]);
+ execFileSync("git", ["-C", repo, "config", "user.name", "T"]);
+ writeFileSync(path.join(repo, "readme.md"), "Nothing interesting here.\n");
+ execFileSync("git", ["-C", repo, "add", "-A"]);
+ execFileSync("git", ["-C", repo, "commit", "-qm", "a completely clean commit"]);
+ execFileSync("git", [
+ "-C",
+ repo,
+ "tag",
+ "-a",
+ "v1",
+ "-m",
+ `Release v1\n\nBuilt with:\n${vendorKey()}\n`,
+ ]);
+ const tagSha = execFileSync("git", ["-C", repo, "rev-parse", "v1"], {
+ encoding: "utf8",
+ }).trim();
+
+ // What git hands the hook for a tag push is the TAG OBJECT's sha.
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input: `refs/tags/v1 ${tagSha} refs/tags/v1 ${"0".repeat(40)}\n`,
+ runGit: makeGitReader("git", repo),
+ stderr: (message) => errors.push(message),
+ });
+ expect(code).toBe(1);
+ const report = errors.join("\n");
+ expect(report).toContain("vendor-key");
+ expect(report).toContain("annotated tag message");
+ // The remedy has to be the one that can actually reach a tag object.
+ expect(report).toContain("git tag -f -a v1");
+ expect(report).not.toContain("rebase -i");
+ });
+
+ it("leaves a lightweight tag to the commit leg", async () => {
+ // A lightweight tag's ref points straight at the commit, so there is no tag
+ // object to read and the commit leg already covers it. This asserts the
+ // scan does not refuse or error on the no-tag-object shape.
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-lightweight-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "user.email", "t@example.invalid"]);
+ execFileSync("git", ["-C", repo, "config", "user.name", "T"]);
+ writeFileSync(path.join(repo, "readme.md"), "Nothing interesting here.\n");
+ execFileSync("git", ["-C", repo, "add", "-A"]);
+ execFileSync("git", ["-C", repo, "commit", "-qm", "a completely clean commit"]);
+ execFileSync("git", ["-C", repo, "tag", "v-light"]);
+ const sha = execFileSync("git", ["-C", repo, "rev-parse", "v-light"], {
+ encoding: "utf8",
+ }).trim();
+
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input: `refs/tags/v-light ${sha} refs/tags/v-light ${"0".repeat(40)}\n`,
+ runGit: makeGitReader("git", repo),
+ stderr: (message) => errors.push(message),
+ });
+ expect(errors.join("\n")).toBe("");
+ expect(code).toBe(0);
+ });
+
+ it("still passes an annotated tag with a clean message", async () => {
+ // The tag leg must not turn every release tag into a refusal.
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-tag-clean-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "user.email", "t@example.invalid"]);
+ execFileSync("git", ["-C", repo, "config", "user.name", "T"]);
+ writeFileSync(path.join(repo, "readme.md"), "Nothing interesting here.\n");
+ execFileSync("git", ["-C", repo, "add", "-A"]);
+ execFileSync("git", ["-C", repo, "commit", "-qm", "a completely clean commit"]);
+ execFileSync("git", ["-C", repo, "tag", "-a", "v2", "-m", "Release v2\n\nBug fixes.\n"]);
+ const tagSha = execFileSync("git", ["-C", repo, "rev-parse", "v2"], {
+ encoding: "utf8",
+ }).trim();
+
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input: `refs/tags/v2 ${tagSha} refs/tags/v2 ${"0".repeat(40)}\n`,
+ runGit: makeGitReader("git", repo),
+ stderr: (message) => errors.push(message),
+ });
+ expect(errors.join("\n")).toBe("");
+ expect(code).toBe(0);
+ });
+
+ it("still passes a genuinely clean commit", async () => {
+ // The flags widen what the scanner SEES; they must not turn every binary
+ // file into a refusal. A png-shaped blob with nothing credential-shaped in
+ // it is the common case and has to keep pushing.
+ const { runGit, input } = repoWithCommit({
+ "logo.bin": Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03]),
+ "readme.md": "Hello, world.\n",
+ });
+ const errors: string[] = [];
+ const code = await runPrePushHook({
+ input,
+ runGit,
+ stderr: (message) => errors.push(message),
+ });
+ expect(errors.join("\n")).toBe("");
+ expect(code).toBe(0);
+ });
+});
+
+describe("readStdin", () => {
+ /**
+ * A stand-in for the pipe git supplies, typed as `HookStdin` so the test
+ * exercises the same overloads production does rather than an `any` cast.
+ */
+ function fakePipe(): {
+ stream: HookStdin;
+ emitData: (chunk: string) => void;
+ emitEnd: () => void;
+ } {
+ const data: ((chunk: string) => void)[] = [];
+ const end: (() => void)[] = [];
+ const stream: HookStdin = {
+ isTTY: false,
+ setEncoding() {},
+ on(event: "data" | "end" | "error", listener: unknown) {
+ if (event === "data") data.push(listener as (chunk: string) => void);
+ if (event === "end") end.push(listener as () => void);
+ return stream;
+ },
+ };
+ return {
+ stream,
+ emitData: (chunk) => data.forEach((listener) => listener(chunk)),
+ emitEnd: () => end.forEach((listener) => listener()),
+ };
+ }
+
+ // The hook's stdin is the only description it gets of what the push
+ // publishes. "Could not read it" and "git said there is nothing to push" must
+ // not be the same value, because the second is a legitimate pass.
+ it("refuses a TTY rather than reporting an empty ref list", async () => {
+ // Resolving "" here was a fail-open one layer above `runPrePushHook`:
+ // `parsePrePushInput("")` yields zero updates, which is a pass.
+ const tty: HookStdin = { isTTY: true, setEncoding() {}, on: () => tty };
+ await expect(readStdin(tty)).rejects.toThrow(/TTY/);
+ });
+
+ it("still reads a piped ref-update list", async () => {
+ // The control for the test above: the refusal must be scoped to a TTY and
+ // must not break the pipe git actually supplies.
+ const pipe = fakePipe();
+ const read = readStdin(pipe.stream);
+ pipe.emitData("refs/heads/m a refs/heads/m b\n");
+ pipe.emitEnd();
+ await expect(read).resolves.toBe("refs/heads/m a refs/heads/m b\n");
+ });
+
+ it("reads an EMPTY pipe as an empty ref list, which is a pass", async () => {
+ // Git runs the pre-push hook with zero bytes on the pipe for an
+ // "Everything up-to-date" push — measured against git 2.47.3. Refusing that
+ // would fail every no-op push, so the TTY refusal above must not widen into
+ // "empty stdin refuses".
+ const pipe = fakePipe();
+ const read = readStdin(pipe.stream);
+ pipe.emitEnd();
+ await expect(read).resolves.toBe("");
+ expect(await runPrePushHook({ input: "", runGit: () => null })).toBe(0);
+ });
+});
+
+describe.skipIf(!SPAWNED_GIT)("pre-push hook bootstrap, spawned as git spawns it", () => {
+ // `runPrePushHook` is covered directly above, but nothing drove the module as
+ // an ENTRYPOINT, and that is where the fail-open was: the bootstrap decided
+ // whether to run at all by comparing `process.argv[1]` against
+ // `import.meta.url`. Those differ whenever the package is reached through a
+ // symlink, and a bootstrap that does not run exits 0 — which git reads as a
+ // hook that passed. Everything below the bootstrap was already fail-closed;
+ // the bootstrap was not.
+ //
+ // These tests spawn the compiled entrypoint the way the seeded hook does, so
+ // a regression shows up as a push that is allowed rather than as a unit that
+ // returns the wrong number.
+
+ let built: string;
+ let linked: string;
+
+ beforeAll(async () => {
+ // Compile rather than run the TypeScript directly: Node's strip-only mode
+ // rejects this module's parameter property. `transpileModule` skips type
+ // checking, which keeps the test independent of whether `@types/node` is
+ // installed in the sandbox.
+ const ts = (await import("typescript")).default;
+ const out = mkdtempSync(path.join(tmpdir(), "git-egress-boot-"));
+ for (const name of [
+ "github-egress-scrub",
+ "github-git-egress-shim",
+ "github-git-egress-runtime",
+ ]) {
+ const source = readFileSync(path.join(import.meta.dirname, `${name}.ts`), "utf8");
+ const js = ts.transpileModule(source, {
+ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+ }).outputText;
+ writeFileSync(path.join(out, `${name}.js`), js);
+ }
+ built = path.join(out, "github-git-egress-runtime.js");
+
+ // The shape the finding names: the package directory is a symlink, as it is
+ // under a pnpm store layout or workspace hoisting. `argv[1]` is then the
+ // link path while `import.meta.url` is the realpath.
+ const linkRoot = mkdtempSync(path.join(tmpdir(), "git-egress-link-"));
+ symlinkSync(out, path.join(linkRoot, "adapter-utils"));
+ linked = path.join(linkRoot, "adapter-utils", "github-git-egress-runtime.js");
+ });
+
+ /**
+ * Spawn the entrypoint exactly as the seeded `pre-push` hook does.
+ *
+ * `cwd` is load-bearing, not incidental: git runs the hook inside the
+ * repository being pushed, and the hook's reader inherits that directory.
+ * Spawning from the test runner's cwd instead made every scan fail to read
+ * the commit — and a failed scan ALSO refuses, with a message that also
+ * begins "refusing to publish". The refusal assertions below therefore pin
+ * the detection wording specifically, or they would pass without the
+ * scanner ever having looked at the commit.
+ */
+ function hook(
+ entrypoint: string,
+ input: string,
+ cwd: string,
+ ): { status: number | null; stderr: string } {
+ const result = spawnSync(process.execPath, [entrypoint, "--pre-push-hook", "origin", "url"], {
+ input,
+ cwd,
+ encoding: "utf8",
+ });
+ return { status: result.status, stderr: result.stderr };
+ }
+
+ /** A commit carrying credential-shaped material, and the hook stdin for it. */
+ function dirtyPush(): { repo: string; input: string } {
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-boot-repo-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "user.email", "t@example.invalid"]);
+ execFileSync("git", ["-C", repo, "config", "user.name", "T"]);
+ // Derived, not a literal: an embedded token would trip the repository's own
+ // secret scan, which reads the commit range rather than the worktree.
+ writeFileSync(path.join(repo, "config.txt"), `key = ghp_${"A".repeat(24)}\n`);
+ execFileSync("git", ["-C", repo, "add", "-A"]);
+ execFileSync("git", ["-C", repo, "commit", "-qm", "add config"]);
+ const head = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], {
+ encoding: "utf8",
+ }).trim();
+ return {
+ repo,
+ input: `refs/heads/topic ${head} refs/heads/topic ${"0".repeat(40)}\n`,
+ };
+ }
+
+ /**
+ * The detection wording, distinct from the scan-failure wording. Asserting
+ * this is what makes these tests non-vacuous.
+ */
+ const DETECTED = "credential-shaped material found";
+
+ it("refuses credential-bearing input when run from the real path", () => {
+ // The positive control. Without it, the symlink assertion below could pass
+ // because the scanner refuses everything, or because the fixture is wrong.
+ const { repo, input } = dirtyPush();
+ const { status, stderr } = hook(built, input, repo);
+ expect(status).not.toBe(0);
+ expect(stderr).toContain(DETECTED);
+ expect(stderr).not.toContain("could not be completed");
+ });
+
+ it("refuses the same input when the package is reached through a symlink", () => {
+ // The regression. Before the fix this exited 0 with EMPTY stderr, and a
+ // real `git push` through it landed the commit on the remote.
+ const { repo, input } = dirtyPush();
+ const { status, stderr } = hook(linked, input, repo);
+ expect(status).not.toBe(0);
+ expect(stderr).toContain(DETECTED);
+ expect(stderr).not.toContain("could not be completed");
+ });
+
+ it("does not refuse a clean push through the symlinked path", () => {
+ // Entering the hook on argv alone must not turn the bootstrap into a
+ // blanket refusal: the common case still has to push.
+ const repo = mkdtempSync(path.join(tmpdir(), "git-egress-boot-clean-"));
+ execFileSync("git", ["init", "-q", repo]);
+ execFileSync("git", ["-C", repo, "config", "user.email", "t@example.invalid"]);
+ execFileSync("git", ["-C", repo, "config", "user.name", "T"]);
+ writeFileSync(path.join(repo, "readme.md"), "Hello, world.\n");
+ execFileSync("git", ["-C", repo, "add", "-A"]);
+ execFileSync("git", ["-C", repo, "commit", "-qm", "clean"]);
+ const head = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], {
+ encoding: "utf8",
+ }).trim();
+ const { status, stderr } = hook(
+ linked,
+ `refs/heads/topic ${head} refs/heads/topic ${"0".repeat(40)}\n`,
+ repo,
+ );
+ expect(stderr).toBe("");
+ expect(status).toBe(0);
+ });
+});
diff --git a/packages/adapter-utils/src/github-git-egress-runtime.ts b/packages/adapter-utils/src/github-git-egress-runtime.ts
new file mode 100644
index 000000000000..fc5ef9e92d45
--- /dev/null
+++ b/packages/adapter-utils/src/github-git-egress-runtime.ts
@@ -0,0 +1,635 @@
+// PEN-3156: process wrapper for the `git` egress door.
+//
+// SCOPE — this is an ACCIDENTAL-DISCLOSURE guard. It is not a tamper-resistant
+// boundary and must not be described as one. Every component of it — wrapper,
+// hook, and scanner alike — sits on a surface writable by the account it
+// guards, so it stops a mistake and does not stop a decision. Enforcement that
+// holds against a deliberate bypass does not exist yet; it is tracked as
+// PEN-3183 and that, not this file, is the control to cite when asking whether
+// an agent can publish a credential on purpose. The reasoning is under THREAT
+// MODEL below. Read it before extending, renaming, or relying on any of this.
+//
+// Two modes, one module, because they must agree about what a push is:
+//
+// wrapper node github-git-egress-runtime.js
+// Classifies argv. On a push it closes the hook bypass and points
+// git at the hooks directory below, then execs the real git.
+//
+// hook node github-git-egress-runtime.js --pre-push-hook
+// Runs as git's pre-push hook. Reads the ref updates git computes on
+// stdin, scans what they would publish, and exits non-zero — which
+// is what aborts the push — naming the commit to amend.
+//
+// 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 that result to the hook as exact ` `
+// pairs. Re-deriving it in the wrapper would mean a second implementation of
+// those rules that could disagree with the push actually taking place, and
+// disagreeing in the permissive direction is a silent hole.
+//
+// Why the wrapper is still needed: a hook alone is bypassable with
+// `--no-verify`, and `core.hooksPath` has to be injected by something. The
+// wrapper is what `git` resolves to on PATH, so it is on the path every
+// ordinary invocation takes.
+//
+// THREAT MODEL — read this before extending the guard, because it bounds what
+// any change here can achieve.
+//
+// What this defends against is an agent that discloses WITHOUT INTENDING TO.
+// That is the exposure this control descends from: PEN-2526 was an environment
+// dump interpolated into prose by an agent doing its job. Against that, a guard
+// on the path the agent actually takes is effective, and every refusal above
+// earns its place.
+//
+// What it does NOT defend against is an agent that sets out to defeat it. In
+// the deployed pod, measured 2026-09-11:
+//
+// - `/usr/bin/git` is mode 0755 and directly executable. Nothing routes it
+// through this wrapper; the wrapper is reached via PATH, which the caller
+// chooses. No file needs to be written to take that path.
+// - The agent runs as uid 1000 (`node`), and
+// `/opt/paperclip-bundled-adapters` — which holds THIS FILE as compiled
+// JavaScript — is `drwxr-xr-x node node`. The scanner is writable by the
+// account it scans.
+// - `${LOCAL_BIN}/git`, `${PATH_BIN}/git` and the hooks directory are all on
+// the PVC, mounted `fsGroup: 1000`, so they are writable too.
+//
+// So the hook, the wrapper and the scanner are peers, not layers: an agent able
+// to tamper with any one of them can tamper with all three, and does not need
+// to tamper with anything to call the real git directly. Do not add integrity
+// checks between these components — verifying the hook's content from the
+// wrapper, or vice versa, asserts a boundary that does not exist and reads to
+// the next person as though tamper-resistance had been established.
+//
+// Making this door hold against a deliberate bypass 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 that forces pushes through a
+// proxy). That is PEN-3183 — filed, with the four bypasses enumerated and
+// measured, rather than approximated here. It is named rather than gestured at
+// on purpose: "filed separately" is a promise a reader cannot check, and an
+// uncheckable promise is how a documented limit quietly becomes a forgotten
+// one. A control that looks tamper-proof and is not is worse than one whose
+// limits are written down and tracked.
+
+import { spawn, spawnSync } from "node:child_process";
+import { accessSync, constants, realpathSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ ALIAS_HOP_LIMIT,
+ classifyGitInvocation,
+ formatRefusal,
+ formatScanFailure,
+ gitGlobalOptions,
+ parsePrePushInput,
+ scanPrePushUpdates,
+ type GitAliasBypass,
+ type GitPublishVerb,
+ type GitReader,
+} from "./github-git-egress-shim.js";
+
+/** Where the Helm seed writes the pre-push hook. */
+export const DEFAULT_HOOKS_DIR = "/paperclip/.local/share/paperclip-git-hooks";
+
+/** The real git, for the hook's own read-only queries. */
+export const DEFAULT_GIT_BINARY = "/usr/bin/git";
+
+/**
+ * The hooks directory the deployed entrypoint uses.
+ *
+ * Takes no arguments, and reads no environment, on purpose. These were once
+ * `env`-backed overrides defaulting to the constants, which is an escape hatch
+ * in a boundary whose whole job is to be unavoidable: the environment here is
+ * agent-controlled, so a publish run with
+ * `PAPERCLIP_GIT_EGRESS_HOOKS_DIR=/tmp/empty` aimed `core.hooksPath` at a
+ * directory with no hook and proceeded unscanned. The sibling
+ * `PAPERCLIP_GIT_EGRESS_GIT` was worse: it aimed the hook's own reader at a
+ * binary of the caller's choosing, so a fake git reporting clean output
+ * cleared the scan.
+ *
+ * The test seam that justified them is still there and is strictly better —
+ * `runGitEgressRuntime` takes `hooksDir` and `runPrePushHook` takes `runGit` as
+ * parameters, so tests inject directly instead of through ambient state.
+ */
+export function hooksDirectory(): string {
+ return DEFAULT_HOOKS_DIR;
+}
+
+/** The git binary the deployed entrypoint reads with. See `hooksDirectory`. */
+export function gitBinary(): string {
+ return DEFAULT_GIT_BINARY;
+}
+
+export class GitEgressRuntimeError extends Error {
+ constructor(
+ message: string,
+ readonly exitCode = 64,
+ ) {
+ super(message);
+ this.name = "GitEgressRuntimeError";
+ }
+}
+
+/**
+ * Is SOME executable `pre-push` present at `hooksDir`?
+ *
+ * Named for what it can observe, not for what the caller wishes it meant. It
+ * was `prePushHookInstalled`, and "installed" read at the call site as "the
+ * guard is in place" — which is precisely the claim this cannot support and
+ * the misreading the threat model exists to prevent. An executable
+ * pass-through named `pre-push` satisfies it exactly as well as this scanner
+ * does.
+ *
+ * Pointing `core.hooksPath` at a directory that holds no executable `pre-push`
+ * is not an error to git — it runs no hook and the push proceeds. That is the
+ * one failure mode this whole door cannot tolerate, and it is silent: nothing
+ * in the output distinguishes "scanned and clean" from "never scanned".
+ *
+ * It matters more now that the directory is a constant rather than an override.
+ * `DEFAULT_HOOKS_DIR` hardcodes `/paperclip`, while the seed writes to
+ * `{{ .Values.persistence.mountPath }}` — equal in every values file today, but
+ * a deployment that changed the mount path would silently publish unscanned.
+ * Checking turns that from a hole into a refusal that names the missing file.
+ *
+ * This is a DEPLOYMENT check, not a tamper check, and the distinction is worth
+ * keeping straight: it establishes that a hook is present and executable, not
+ * that the hook is this scanner. It cannot establish the latter, and neither
+ * could a content or ownership check bolted on beside it — the seed runs
+ * `runAsNonRoot: true, runAsUser: 1000`, so it has no way to write a file the
+ * agent cannot rewrite, and the scanner's own code is on an agent-writable
+ * path regardless. See the threat model at the top of this file.
+ */
+function prePushHookPresent(hooksDir: string): boolean {
+ try {
+ accessSync(path.join(hooksDir, "pre-push"), constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Build the argv the real git is invoked with.
+ *
+ * `core.hooksPath` is injected only for a push. Setting it unconditionally
+ * would shadow every OTHER hook a repository defines — pre-commit, commit-msg —
+ * because a hooks directory holding only `pre-push` makes the rest silently stop
+ * running. Scoping the injection to the one command whose hook we supply keeps
+ * that blast radius off unrelated workflows.
+ *
+ * Placement is a security property, not a style choice. Git takes the LAST `-c`
+ * given for a key, so injecting at the FRONT lets a caller-supplied
+ * `git -c core.hooksPath=/tmp/empty push` override the guard and skip the
+ * scanner entirely. The guard therefore goes immediately before the subcommand,
+ * after every global option the caller passed, where it is the last value git
+ * sees. Measured against git 2.47.3: from that position it also beats
+ * `--config-env=core.hooksPath=...`, the case-folded `CORE.HOOKSPATH` spelling,
+ * a repository's own `core.hooksPath` config, and `GIT_CONFIG_KEY_*` in the
+ * environment.
+ */
+/**
+ * The refusal for an alias-carried bypass.
+ *
+ * Extracted because it is thrown from two places in {@link buildGitArgv} — once
+ * ahead of the not-a-push early return and once after it — and the two must not
+ * drift into saying different things about the same finding.
+ */
+function aliasBypassRefusal(bypass: GitAliasBypass): GitEgressRuntimeError {
+ const { alias, expansion, reason, chain } = bypass;
+
+ // Worded separately because this one asserts no bypass: the chain outran the
+ // resolver, so what it reaches is simply unknown. Telling the author it
+ // "expands to a push that ..." would be a claim the guard cannot make.
+ if (reason === "alias-depth") {
+ const shown = (chain ?? [alias]).join("` → `");
+ return new GitEgressRuntimeError(
+ `paperclip-github-egress: refusing to run \`${alias}\` — it is an alias chain deeper than ${ALIAS_HOP_LIMIT} hops (\`${shown}\` → \`${expansion}\`), so this guard stopped resolving before reaching the command git would actually run, and cannot tell whether it publishes. Resolution is bounded on purpose: the config defining the chain is writable from here, so an unbounded walk would be a denial of service. Invoke the underlying command directly, or flatten the alias so it resolves within ${ALIAS_HOP_LIMIT} hops.`,
+ );
+ }
+
+ const what =
+ reason === "no-verify"
+ ? "skips the pre-push hook with --no-verify"
+ : reason === "hooks-path"
+ ? "points core.hooksPath somewhere else"
+ : "cannot be parsed the way git parses an alias (unterminated quote), so it cannot be checked for a bypass";
+ return new GitEgressRuntimeError(
+ `paperclip-github-egress: refusing to publish — the alias \`${alias}\` expands to a push that ${what} (\`${expansion}\`), which would bypass the check for credential-shaped material. Invoke the push directly instead of through the alias, or redefine the alias without it.`,
+ );
+}
+
+/**
+ * The refusal for a verb that is not `push` and not cleared as non-publishing.
+ *
+ * The two wordings differ because the remedies differ. For a verb known to
+ * publish there is nothing to add to an allowlist and the author needs to hear
+ * that the `push` subcommand is the guarded way. For an unrecognised one the
+ * refusal is a conservative default that an operator may legitimately want to
+ * relax, so it names the constant to edit — otherwise the natural fix is to
+ * reach around the wrapper, which is strictly worse than widening the list on
+ * purpose.
+ */
+function publishVerbRefusal(publishVerb: GitPublishVerb): GitEgressRuntimeError {
+ const { verb, known, alias, chain } = publishVerb;
+ const via = alias
+ ? ` It was reached through the alias \`${alias}\` (\`${(chain ?? [alias]).join("` → `")}\`), so the definition is where the fix goes.`
+ : "";
+
+ if (known) {
+ return new GitEgressRuntimeError(
+ `paperclip-github-egress: refusing to run \`git ${verb}\` — it publishes to a remote but does NOT run the pre-push hook, so the check for credential-shaped material would never see the objects it sends. Measured against git 2.47.3: \`send-pack\` landed a new ref on the remote with the hook silent, and with this guard's own \`core.hooksPath\` present — injecting the hook does not help, because the command never reads it. Publish with the \`push\` subcommand, which is guarded.${via}`,
+ );
+ }
+
+ return new GitEgressRuntimeError(
+ `paperclip-github-egress: refusing to run \`git ${verb}\` — this guard passes through only verbs it knows cannot publish to a remote, and \`${verb}\` is not one of them. Unrecognised is refused rather than allowed because the plumbing verbs that publish (\`send-pack\`, \`http-push\`) do so WITHOUT running the pre-push hook, so letting an unknown verb through is a silent hole rather than a noisy one. If \`${verb}\` cannot publish, add it to NON_PUBLISHING_GIT_VERBS in github-git-egress-shim.ts; if it can, use the \`push\` subcommand instead.${via}`,
+ );
+}
+
+export function buildGitArgv(
+ argv: readonly string[],
+ options: {
+ hooksDir: string;
+ resolveAlias?: (name: string) => string | null;
+ env?: NodeJS.ProcessEnv;
+ /** Seam for the fs check; the default is the real one. */
+ hookPresent?: (hooksDir: string) => boolean;
+ },
+): string[] {
+ const classification = classifyGitInvocation(argv, options.resolveAlias, options.env ?? {});
+
+ // Checked ahead of the not-a-push early return, deliberately. A shell alias
+ // cannot be classified as a push or not — its expansion is arbitrary shell —
+ // and it is the one form that escapes this guard entirely, because git
+ // prepends its exec-path (which ships a complete `git`) to PATH for the shell
+ // it spawns. So a bare `git push` inside the expansion reaches the real git
+ // without passing through this wrapper or the hook.
+ if (classification.shellAlias) {
+ const { alias, expansion } = classification.shellAlias;
+ throw new GitEgressRuntimeError(
+ `paperclip-github-egress: refusing to run the shell alias \`${alias}\` (\`${expansion}\`). A \`!\` alias runs arbitrary shell, and git puts its own exec-path ahead of PATH for it, so a \`push\` inside the expansion would reach git directly and skip the check for credential-shaped material. Run the underlying commands directly instead of through the alias.`,
+ );
+ }
+
+ // Checked ahead of the not-a-push early return, for the same reason the shell
+ // alias above is. `classifyGitInvocation` keeps a bypass with no push attached
+ // ONLY for the two reasons that exist because "is this a push?" is itself the
+ // question that went unanswered — `unquotable` and `alias-depth` — and it
+ // documents at length why neither may be gated on `isPush`. Letting the early
+ // return below discard them defeats that one layer up, silently: the shim
+ // reports the refusal and the wrapper drops it.
+ //
+ // Measured against git 2.47.3 with `alias.a1=a2 … alias.a5=push`: the early
+ // return handed argv straight to git, which expanded the whole chain and
+ // pushed `refs/heads/deep5` to the remote with no hook. The unit test on
+ // `classifyGitInvocation` passed throughout — it asserts the bypass is
+ // REPORTED, which it was. Only driving the wrapper end to end showed it being
+ // thrown away.
+ //
+ // The push-carried reasons are deliberately NOT handled here; they fall
+ // through to the block below so the argv-level `--no-verify` and
+ // `core.hooksPath` refusals keep winning the message on a real push.
+ if (
+ classification.aliasBypass &&
+ (classification.aliasBypass.reason === "unquotable" ||
+ classification.aliasBypass.reason === "alias-depth")
+ ) {
+ throw aliasBypassRefusal(classification.aliasBypass);
+ }
+
+ // Ahead of the not-a-push early return, for the same reason as the two
+ // blocks above: this is set precisely WHEN the invocation is not a `push`,
+ // so letting the early return run first would discard every one of them.
+ //
+ // Refusal, not guarding. Adding these verbs to the push classification so
+ // `core.hooksPath` is injected does nothing — `send-pack` does not consult
+ // the pre-push hook at all, measured against git 2.47.3 with this guard's
+ // own `-c` present: the ref landed and the hook never ran. There is no hook
+ // to make fire, so the only enforcement available is to not run the command.
+ if (classification.publishVerb) {
+ throw publishVerbRefusal(classification.publishVerb);
+ }
+
+ if (!classification.isPush) return [...argv];
+
+ if (classification.hasNoVerify) {
+ throw new GitEgressRuntimeError(
+ "paperclip-github-egress: --no-verify is disabled on publish, because it skips the hook that checks whether the commits carry credential-shaped material. Re-run without it.",
+ );
+ }
+
+ // Injecting last already wins over this, so the refusal is not what makes the
+ // guard hold — it is here so a caller who asked for a different hooks
+ // directory is told their request was rejected rather than silently dropped,
+ // and so the control does not rest on ordering alone.
+ if (classification.hooksPathOverride) {
+ throw new GitEgressRuntimeError(
+ `paperclip-github-egress: refusing to publish — this invocation sets core.hooksPath itself (\`${classification.hooksPathOverride}\`), which would replace the hook that checks whether these commits carry credential-shaped material. Re-run the push without it.`,
+ );
+ }
+
+ // An alias is the one case injection cannot win: git expands it AFTER the
+ // command line, so a `-c core.hooksPath=` or `--no-verify` inside the
+ // expansion is the last thing git sees no matter where the guard is placed.
+ // Refusal is the only enforcement available here.
+ if (classification.aliasBypass) {
+ throw aliasBypassRefusal(classification.aliasBypass);
+ }
+
+ // Last, so the specific caller-error refusals above win the message. Placed
+ // before the injection because injecting a hooks path with no hook in it is
+ // indistinguishable, from the outside, from a push that was scanned.
+ const hookPresent = options.hookPresent ?? prePushHookPresent;
+ if (!hookPresent(options.hooksDir)) {
+ throw new GitEgressRuntimeError(
+ `paperclip-github-egress: refusing to publish — no executable pre-push hook at \`${path.join(options.hooksDir, "pre-push")}\`, so this push could not be checked for credential-shaped material. This is a deployment fault, not something to work around: the hook is written by the chart's agent-runtime seed. Report it rather than pushing past it.`,
+ );
+ }
+
+ // Immediately before the subcommand: after the caller's global options, so
+ // this is the last `core.hooksPath` git reads, and still ahead of the
+ // subcommand, which is where git requires global options to sit.
+ const at = classification.subcommandIndex;
+ return [
+ ...argv.slice(0, at),
+ "-c",
+ `core.hooksPath=${options.hooksDir}`,
+ ...argv.slice(at),
+ ];
+}
+
+export function makeGitReader(gitPath: string, cwd?: string): GitReader {
+ return (args: string[]) => {
+ const result = spawnSync(gitPath, args, {
+ cwd,
+ encoding: "utf8",
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ if (result.error || result.status !== 0) return null;
+ return result.stdout;
+ };
+}
+
+/**
+ * The part of `process.stdin` the hook reads, narrowed so a test can supply a
+ * stand-in. `isTTY` is the field the refusal in {@link readStdin} turns on.
+ */
+export interface HookStdin {
+ isTTY?: boolean;
+ setEncoding(encoding: "utf8"): unknown;
+ on(event: "data", listener: (chunk: string) => void): unknown;
+ on(event: "end", listener: () => void): unknown;
+ on(event: "error", listener: (error: unknown) => void): unknown;
+}
+
+/**
+ * Read the ref-update list git pipes to the pre-push hook.
+ *
+ * Takes the stream so the refusal below is reachable from a test without
+ * allocating a pty; production passes `process.stdin`.
+ */
+export function readStdin(stream: HookStdin = process.stdin): Promise {
+ return new Promise((resolve, reject) => {
+ let buffer = "";
+ // A TTY on stdin means git did not invoke this. Git always hands the
+ // pre-push hook a pipe — measured against git 2.47.3, `isTTY` is false and
+ // fd 0 is a FIFO on both a real push and an "Everything up-to-date" one.
+ //
+ // Resolving "" here was the fail-open: it is indistinguishable from the
+ // empty ref list git legitimately sends for a no-op push, so
+ // `runPrePushHook` returned 0 and the caller read "scanned and clean" from
+ // a run that never had input to scan. Refuse instead — the guard cannot see
+ // its input, and an unread ref update is an unscanned ref update.
+ //
+ // Note this rejects rather than waiting for `end`: a TTY never ends, so
+ // waiting would hang the push instead of refusing it.
+ if (stream.isTTY) {
+ reject(new Error("pre-push hook stdin is a TTY; expected the ref-update pipe git supplies"));
+ return;
+ }
+ stream.setEncoding("utf8");
+ stream.on("data", (chunk) => {
+ buffer += chunk;
+ });
+ stream.on("end", () => resolve(buffer));
+ // Reject rather than resolving the partial buffer. A truncation that lands
+ // mid-line throws in `parsePrePushInput` and refuses, but one landing
+ // exactly on a newline yields a SHORTER, well-formed update list — and
+ // `runPrePushHook` reads a short list as "that is all this push contains"
+ // and returns 0 for the ref updates that were dropped. The rejection
+ // reaches `reportRuntimeError`, which sets a non-zero exit code, so the
+ // push aborts: an unread ref update is an unscanned ref update, exactly as
+ // an unreadable commit is an unscanned commit.
+ stream.on("error", reject);
+ });
+}
+
+/**
+ * The pre-push hook. Exit 0 lets the push proceed; non-zero aborts it.
+ *
+ * Every remote is guarded, not only github.com. The material this refuses is
+ * credential-shaped wherever it lands, and scoping the check by remote URL would
+ * turn `git remote add` into the bypass.
+ *
+ * The scan is wrapped so that ANY failure aborts the push. A guard whose error
+ * path is "allow" is not a guard: a git read that fails, a buffer that
+ * overflows on a large diff, or an unanticipated throw would each otherwise
+ * publish the commits unscanned, and the larger the push the likelier that gets.
+ */
+export async function runPrePushHook(options: {
+ input: string;
+ runGit: GitReader;
+ stderr?: (message: string) => void;
+}): Promise {
+ const write = options.stderr ?? ((message: string) => process.stderr.write(`${message}\n`));
+
+ let findings;
+ try {
+ // Inside the try: parsing the hook's stdin is part of deciding what this
+ // push publishes, so a throw from it must refuse like any other unknown
+ // verdict rather than escaping the guard's own error path.
+ const updates = parsePrePushInput(options.input);
+ // An empty update list is a PASS, and must stay one. Git runs the pre-push
+ // hook with genuinely empty stdin on an "Everything up-to-date" push —
+ // measured against git 2.47.3, hook invoked, zero bytes on the pipe — so
+ // refusing here would fail every no-op push.
+ //
+ // What makes that safe is that the one way to arrive here WITHOUT git
+ // having said "nothing to push" is now closed upstream: `readStdin` refuses
+ // a TTY rather than resolving "", so "git sent an empty list" and "this was
+ // never invoked by git" are no longer the same value.
+ if (updates.length === 0) return 0;
+ findings = scanPrePushUpdates(updates, options.runGit);
+ } catch (error) {
+ // Deliberately catching everything, not just GitEgressScanError. An
+ // unexpected throw is exactly the case where the scanner's verdict is
+ // unknown, which must refuse rather than pass.
+ write(formatScanFailure(error));
+ return 1;
+ }
+
+ if (findings.length === 0) return 0;
+
+ write(formatRefusal(findings));
+ return 1;
+}
+
+export function runGitEgressRuntime(options: {
+ target: string;
+ argv: string[];
+ hooksDir: string;
+ env?: NodeJS.ProcessEnv;
+ hookPresent?: (hooksDir: string) => boolean;
+}): Promise {
+ const env = options.env ?? process.env;
+
+ // Resolve aliases under the same effective configuration git itself will use.
+ // A bare `git config --get` reads whichever config files the WRAPPER's cwd
+ // selects, which is not necessarily the set the invocation selects: `-C`,
+ // `--git-dir` and `--work-tree` all change it, so `git -C /elsewhere yolo`
+ // would be looked up against the wrong repository. Forwarding the caller's
+ // own global options puts the lookup in the same place as the push.
+ //
+ // `--no-pager` goes last so it beats a caller's `-p`, which would otherwise
+ // hand this read to a pager. Nothing here can run a hook: `config` is a read.
+ //
+ // This does NOT cover `-c alias.x=...`; a definition on the command line is
+ // unreachable from a second process no matter what it is passed. That case is
+ // closed in `classifyGitInvocation`, which reads such definitions straight out
+ // of argv and consults them before this callback.
+ const globals = gitGlobalOptions(options.argv);
+ const resolveAlias = (name: string): string | null => {
+ const result = spawnSync(
+ options.target,
+ [...globals, "--no-pager", "config", "--get", `alias.${name}`],
+ { encoding: "utf8", env, timeout: 10_000 },
+ );
+ if (result.error || result.status !== 0) return null;
+ const value = result.stdout.trim();
+ return value.length > 0 ? value : null;
+ };
+
+ const argv = buildGitArgv(options.argv, {
+ hooksDir: options.hooksDir,
+ resolveAlias,
+ env,
+ hookPresent: options.hookPresent,
+ });
+
+ return new Promise((resolve, reject) => {
+ const child = spawn(options.target, argv, { stdio: "inherit" });
+ let forwardedSignal = false;
+ let settled = false;
+ const forwardSignal = (signal: NodeJS.Signals) => {
+ forwardedSignal = true;
+ child.kill(signal);
+ };
+ process.on("SIGINT", forwardSignal);
+ process.on("SIGTERM", forwardSignal);
+ const cleanup = () => {
+ process.off("SIGINT", forwardSignal);
+ process.off("SIGTERM", forwardSignal);
+ };
+
+ child.once("error", (error: NodeJS.ErrnoException) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ reject(new GitEgressRuntimeError(`unable to start git (${error.code ?? "unknown error"})`, 1));
+ });
+ child.once("close", (code, signal) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ if (code !== null) {
+ resolve(code);
+ return;
+ }
+ resolve(forwardedSignal ? 128 : signal ? 128 + (signal === "SIGINT" ? 2 : 15) : 1);
+ });
+ });
+}
+
+function reportRuntimeError(error: unknown): void {
+ const message = error instanceof Error ? error.message : "unexpected preparation failure";
+ const exitCode = error instanceof GitEgressRuntimeError ? error.exitCode : 1;
+ console.error(message.startsWith("paperclip-github-egress") ? message : `paperclip-github-egress: ${message}`);
+ process.exitCode = exitCode;
+}
+
+/**
+ * Is this module the process entrypoint?
+ *
+ * Tolerates a symlinked install. `process.argv[1]` is the literal path the
+ * caller named; `import.meta.url` is what Node resolved, which is the REALPATH
+ * unless `--preserve-symlinks` is set. A plain string compare of the two is
+ * false whenever any parent directory is a link — measured on Node 24.16: with
+ * the package reached through a symlinked directory, `path.resolve(argv[1])`
+ * and `fileURLToPath(import.meta.url)` differ, and `realpathSync` agrees again.
+ *
+ * This is used ONLY for the wrapper leg. The hook leg must not depend on it —
+ * see the entry block below for why.
+ */
+function invokedAsEntrypoint(): boolean {
+ const argv1 = process.argv[1];
+ if (!argv1) return false;
+ const here = fileURLToPath(import.meta.url);
+ if (path.resolve(argv1) === here) return true;
+ try {
+ return realpathSync(argv1) === realpathSync(here);
+ } catch {
+ // An unresolvable argv[1] is not this module. The hook leg does not reach
+ // here, so returning false cannot open the publish boundary.
+ return false;
+ }
+}
+
+// Hook mode is selected by argv ALONE, deliberately, and not by
+// `invokedAsEntrypoint()`.
+//
+// The entrypoint guard is the standard idiom, and it is copied from
+// `github-cli-egress-runtime.ts` and `github-mcp-egress-runtime.ts` where it is
+// safe. It is NOT safe here, and the failure direction is inverted: for `gh` and
+// `github-mcp-server` a bootstrap that does not run is a command that produces
+// no output, which is loud. For a pre-push hook it is a silent exit 0, and git
+// reads exit 0 as "hook passed" — the one thing `prePushHookPresent` documents
+// that this door cannot tolerate, reinstated one layer up.
+//
+// Measured end to end: with the package reached through a symlinked directory,
+// a push carrying a credential-shaped literal exited 0 with no output and
+// landed the commit on the remote, while the identical push through the
+// unlinked path was refused and landed nothing. Causes that make the two paths
+// diverge are ordinary, not exotic — pnpm's store layout, workspace hoisting,
+// or a bundler emitting a re-export shim.
+//
+// `realpathSync` alone would not be enough: it repairs a symlink, but a
+// re-export shim is a DIFFERENT FILE, so no amount of path canonicalisation
+// makes the comparison true. The only form that fails closed is to take argv as
+// the contract — `--pre-push-hook` is a private spelling nothing but the seeded
+// hook passes — and keep path resolution out of the decision entirely.
+const invokedAsPrePushHook = process.argv[2] === "--pre-push-hook";
+
+if (invokedAsPrePushHook) {
+ void readStdin()
+ .then((input) =>
+ runPrePushHook({ input, runGit: makeGitReader(gitBinary()) }),
+ )
+ .then((exitCode) => {
+ process.exitCode = exitCode;
+ })
+ .catch(reportRuntimeError);
+} else if (invokedAsEntrypoint()) {
+ const target = process.argv[2];
+ const argv = process.argv.slice(3);
+ try {
+ if (!target) throw new GitEgressRuntimeError("missing git target");
+ void runGitEgressRuntime({ target, argv, hooksDir: hooksDirectory() })
+ .then((exitCode) => {
+ process.exitCode = exitCode;
+ })
+ .catch(reportRuntimeError);
+ } catch (error) {
+ reportRuntimeError(error);
+ }
+}
diff --git a/packages/adapter-utils/src/github-git-egress-shim.test.ts b/packages/adapter-utils/src/github-git-egress-shim.test.ts
new file mode 100644
index 000000000000..7aa53bcbff2f
--- /dev/null
+++ b/packages/adapter-utils/src/github-git-egress-shim.test.ts
@@ -0,0 +1,935 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ addedLinesFromPatch,
+ classifyGitInvocation,
+ commitsForRefUpdate,
+ formatRefusal,
+ type GitAliasBypass,
+ GitEgressInputError,
+ GitEgressScanError,
+ gitGlobalOptions,
+ parsePrePushInput,
+ scanAnnotatedTags,
+ scanCommit,
+ scanPrePushUpdates,
+ TAG_PEEL_LIMIT,
+ type GitReader,
+} from "./github-git-egress-shim.js";
+
+/**
+ * An environment dump, assembled rather than pasted.
+ *
+ * Every fixture in this file is built from parts on purpose. A literal
+ * credential here would be committed, and CI scans the COMMIT RANGE with
+ * gitleaks — so a literal would trip the secret gate inside the very pull
+ * request that adds a secret-containment control, and could then only be
+ * cleared by rewriting the commit rather than by deleting the line.
+ */
+function environmentDump(): string {
+ return ["ALPHA", "BRAVO", "CHARLIE", "DELTA", "ECHOES"]
+ .map((name, index) => `${name}=value-${index}`)
+ .join("\n");
+}
+
+/** A vendor-key-shaped token, assembled for the same reason as the dump above. */
+function vendorKey(): string {
+ return `${["gh", "p"].join("")}_${"Ab3".repeat(9)}`;
+}
+
+/** A git reader backed by a fixed map, so no repository is needed. */
+function fakeGit(responses: Record): GitReader {
+ return (args: string[]) => {
+ const key = args.join(" ");
+ return key in responses ? responses[key]! : null;
+ };
+}
+
+describe("classifyGitInvocation", () => {
+ it("finds a bare push", () => {
+ const result = classifyGitInvocation(["push", "origin", "main"]);
+ expect(result.isPush).toBe(true);
+ expect(result.subcommand).toBe("push");
+ });
+
+ it("is not fooled by a global option that takes a separate value", () => {
+ // The regression this guards: treating `-c` as a valueless flag makes
+ // `foo=bar` read as the subcommand, and the push sails past the guard.
+ for (const argv of [
+ ["-c", "foo=bar", "push"],
+ ["-C", "/tmp/repo", "push"],
+ ["--git-dir", "/tmp/repo/.git", "push"],
+ ["--work-tree", "/tmp/repo", "push"],
+ ]) {
+ expect(classifyGitInvocation(argv).isPush, argv.join(" ")).toBe(true);
+ }
+ });
+
+ it("handles the self-contained --opt=value spelling", () => {
+ expect(classifyGitInvocation(["--git-dir=/tmp/r/.git", "push"]).isPush).toBe(true);
+ });
+
+ it("does not classify unrelated subcommands as a push", () => {
+ for (const sub of ["status", "commit", "fetch", "log", "diff"]) {
+ expect(classifyGitInvocation([sub]).isPush, sub).toBe(false);
+ }
+ });
+
+ it("resolves an alias that expands to a push", () => {
+ const resolve = (name: string) => (name === "yolo" ? "push --force" : null);
+ expect(classifyGitInvocation(["yolo"], resolve).isPush).toBe(true);
+ });
+
+ it("resolves a chain of aliases, and stops rather than looping forever", () => {
+ const chain: Record = { a: "b", b: "c", c: "push" };
+ expect(classifyGitInvocation(["a"], (n) => chain[n] ?? null).isPush).toBe(true);
+
+ const cyclic: Record = { x: "y", y: "x" };
+ expect(classifyGitInvocation(["x"], (n) => cyclic[n] ?? null).isPush).toBe(false);
+ });
+
+ it("does not try to parse a shell alias", () => {
+ // Assembled from parts on purpose. scripts/check-no-git-push.mjs scans
+ // string literals in this tree for those two words together, and the
+ // `paperclip:allow-git-push` marker that opts a line out asserts an
+ // operator-approved push path exists. This is a test fixture and no such
+ // path exists, so spending that escape hatch here would put a false claim
+ // inside a security control.
+ const shellAlias = `!git ${"push"} --all`;
+ expect(classifyGitInvocation(["sh"], () => shellAlias).isPush).toBe(false);
+ });
+
+ it("sees a bypass through every quoting form git dequotes", () => {
+ // Git does NOT split an alias on whitespace — it runs `split_cmdline()`,
+ // which applies shell quoting. Splitting on /\s+/ left `"--no-verify"` with
+ // its quotes attached, matched nothing, and pushed unscanned.
+ //
+ // Every form below was measured against git 2.47.3 as a REAL bypass: with
+ // `-c core.hooksPath= -c 'alias.q=