Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
# Pre-push hook, run by git for every push (husky activates it after `npm ci`).
# The guard rules, the escape hatch, and the worktree install one-liner
# (git config core.hooksPath .husky) are documented in scripts/pre-push-guard.sh.
exec sh "$(dirname "$0")/../scripts/pre-push-guard.sh" "$@"
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"dev": "concurrently --names \"ai,agent,coding-agent,tui\" --prefix-colors \"cyan,yellow,red,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\"",
"dev:tsc": "cd packages/ai && npm run dev:tsc",
"check": "biome check --write --error-on-warnings . && npm run check:test-policy && tsgo --noEmit && npm run check:installer && npm run check:browser-smoke",
"check": "biome check --write --error-on-warnings . && npm run check:test-policy && tsgo --noEmit && npm run check:installer && npm run check:push-guard && npm run check:browser-smoke",
"check:test-policy": "node scripts/check-test-policy.mjs",
"check:installer": "node scripts/check-installer.mjs",
"check:push-guard": "node scripts/check-push-guard.mjs",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added a pre-push guard hook that refuses mirror-like pushes and remote branch deletions to real GitHub remotes, with an opt-out via PRIME_AGENT_ALLOW_MIRROR_PUSH=1.
14 changes: 14 additions & 0 deletions packages/coding-agent/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ Run from source:

The script can be called from any directory and preserves the caller's working directory. Use that behavior to run a source checkout against a separate test project.

## Pre-push Guard

`.husky/pre-push` runs for every `git push` and refuses mirror-like pushes and remote branch deletions to real GitHub remotes (github.com and ssh.github.com in scp, `http://`, `https://`, and `ssh://` forms, with or without credentials and ports, including `www.` and trailing-dot host spellings); local, `file://`, and other remotes are always allowed. The hook ships via husky and is active after `npm ci`.

Worktrees and clones without husky's `.husky/_` shims (for example a bare `git worktree add` tree) activate the tracked hooks directly, with no npm or husky needed:

```bash
git config core.hooksPath .husky
```

The setting is shared across worktrees and the relative path resolves per tree. Running `npm ci` in any tree regenerates husky's shims and resets the shared `core.hooksPath` to `.husky/_`, which silences the tracked hooks in shimless worktrees again; re-run the one-liner after `npm ci`, or run `npx husky` inside a worktree to give it its own shims.

Intentional pushes can opt out once with `PRIME_AGENT_ALLOW_MIRROR_PUSH=1 git push ...`. Pushes with more than 10 refs (a `git push --tags` release) are refused; use the escape hatch. Not caught: deletions of remote-only refs through `--mirror` pruning (git never lists them on the pre-push stdin), ssh alias remotes, `insteadOf` rewrites to a proxy, and uppercase hostnames. `git push --no-verify` bypasses pre-push hooks entirely, so this guard is advisory; server-side, GitHub branch protection rules are the real mitigation, and `main` is protected — that is what rejected the incident's force update to `main`.

## Product and Source Names

Prime Agent is the product, public CLI, release artifact, and repository name. The monorepo still retains inherited `@earendil-works/pi-*` npm workspace names, a source-package `pi` bin entry, the `pi` package manifest key, and some `PI_*` compatibility environment variables. These names are source and compatibility details, not a signal that contributors should install or develop against pi-mono.
Expand Down
78 changes: 78 additions & 0 deletions scripts/check-push-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env node
// Table-driven check for the pre-push guard (scripts/pre-push-guard.sh via the
// .husky/pre-push wrapper), run by `npm run check`. Cases drive the hook with
// pre-push stdin fixtures: "<local ref> <local oid> <remote ref> <remote oid>"
// per line, deletions as "(delete) <zero oid> <remote ref> <remote oid>".
import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const hook = join(dirname(fileURLToPath(import.meta.url)), "..", ".husky", "pre-push");
const github = "https://github.com/PrimeIntellect-ai/prime-agent.git";
const zero = "0000000000000000000000000000000000000000";
const update = (ref) => `${ref} abc123 ${ref} ${zero}`;
const del = (ref) => `(delete) ${zero} ${ref} abc123`;
const refs = (n) => Array.from({ length: n }, (_, i) => update(`refs/heads/b${i}`)).join("\n");
const mirror = `${refs(12)}\n${del("refs/heads/victim")}`;
const cases = [
["refuses a mirror push to github", github, mirror, 1],
["allows a single-branch push to github", github, update("refs/heads/main"), 0],
["refuses a branch deletion to github", github, del("refs/heads/feature"), 1],
["refuses a leading-space delete line", github, ` ${del("refs/heads/victim")}`, 1],
["fails closed on (delete) spelling drift", github, `( delete) ${zero} refs/heads/b abc123`, 1],
["fails closed on a CRLF delete line", github, `${del("refs/heads/victim")}\r`, 1],
["allows mirror-like pushes to a file:// remote", "file:///tmp/scratch.git", mirror, 0],
["the escape hatch allows the mirror push", github, mirror, 0, { PRIME_AGENT_ALLOW_MIRROR_PUSH: "1" }],
["an empty escape value does not bypass", github, mirror, 1, { PRIME_AGENT_ALLOW_MIRROR_PUSH: "" }],
["an escape value of 0 does not bypass", github, mirror, 1, { PRIME_AGENT_ALLOW_MIRROR_PUSH: "0" }],
["an escape value of true does not bypass", github, mirror, 1, { PRIME_AGENT_ALLOW_MIRROR_PUSH: "true" }],
["refuses a refs/remotes destination", github, update("refs/remotes/origin/main"), 1],
["allows exactly 10 refs to github", github, refs(10), 0],
["skips blank lines in the count", github, `\n\n${refs(10)}\n\n`, 0],
["refuses 11 refs to github", github, refs(11), 1],
["handles a line without trailing newline", github, update("refs/heads/main"), 0],
["refuses a mirror push to git@github.com", "git@github.com:PrimeIntellect-ai/prime-agent.git", mirror, 1],
["refuses an scp URL without a user", "github.com:o/r.git", mirror, 1],
["refuses a token-userinfo https URL", "https://token@github.com/o/r.git", mirror, 1],
["refuses an https URL with a port", "https://github.com:443/o/r.git", mirror, 1],
["refuses ssh://github.com:22 without a user", "ssh://github.com:22/o/r.git", mirror, 1],
["refuses ssh://git@github.com:22", "ssh://git@github.com:22/o/r.git", mirror, 1],
["refuses git@ssh.github.com", "git@ssh.github.com:o/r.git", mirror, 1],
["allows a lookalike phishing host", "https://github.com.evil.com/o/r.git", mirror, 0],
["allows an ssh alias remote", "git@gh:o/r.git", mirror, 0],
["refuses an http://github.com URL", "http://github.com/o/r.git", mirror, 1],
["refuses a www.github.com URL", "https://www.github.com/o/r.git", mirror, 1],
["refuses a trailing-dot host", "https://github.com./o/r.git", mirror, 1],
["refuses a trailing-dot scp host", "git@github.com.:o/r.git", mirror, 1],
["fails closed on malformed stdin", github, "refs/heads/main refs/heads/main", 1],
["allows an empty up-to-date push", github, "", 0],
];

let failures = 0;
for (const [name, url, input, code, env] of cases) {
const result = spawnSync("sh", [hook, "origin", url], {
input,
encoding: "utf8",
timeout: 9000,
env: { ...process.env, PRIME_AGENT_ALLOW_MIRROR_PUSH: "", ...env },
});
const stderr = result.stderr ?? "";
// Behavioral refusal signature, not message copy: the refusal line, the
// reason detail the hook always emits (rule refusal with its ref and
// deletion counts, or the malformed-stdin failure), and the escape hatch.
const refusedWell =
stderr.includes("refusing push to") &&
(/\d+ refs \(mirror-like\), including \d+ deletion\(s\)/.test(stderr) ||
stderr.includes("malformed ref line")) &&
stderr.includes("PRIME_AGENT_ALLOW_MIRROR_PUSH=1 git push origin ...");
Comment thread
sethkarten marked this conversation as resolved.
if (result.status !== code || (code === 1 && !refusedWell)) {
failures += 1;
console.error(`FAIL ${name}: exit=${result.status} expected=${code}`);
console.error(stderr);
}
}
if (failures > 0) {
console.error(`pre-push guard check: ${failures} failing case(s)`);
process.exit(1);
}
console.log(`pre-push guard check: ${cases.length} cases passed.`);
113 changes: 113 additions & 0 deletions scripts/pre-push-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/bin/sh
# Pre-push guard against mirror-like pushes and remote branch deletions.
# Provenance: a 2026-09-17 mirror push deleted 62 remote branches and auto-closed 63 PRs.
# Git invokes the pre-push hook for every push, whatever process calls it, and
# kernel command guards cannot see pushes made through raw subprocesses.
#
# Rules for real GitHub remotes, reading the ref list from stdin: allow if
# stdin is empty (up-to-date push) or if every line updates a refs/heads/* or
# refs/tags/* ref, no line deletes a ref (first field "(delete)"), and at most
# 10 refs change (mirror signature; mirror pushes also create refs/remotes/*
# on the remote, which normal pushes never do). Malformed lines fail closed.
# Any other remote URL (relative or absolute paths, file://, other hosts) is a
# local or scratch remote and is always allowed.
#
# A real remote is github.com or ssh.github.com, as an scp-style form (with or
# without the git@ user) or an http://, https://, or ssh:// URL, optionally
# carrying credentials and a port, so https://<token>@github.com/o/r.git,
# https://github.com:443/o/r.git, ssh://git@github.com:22/o/r.git,
# http://github.com/ (redirects to https), www.github.com, and trailing-dot
# hosts (github.com.) all match; git follows each of these to the same origin.
# Known fail-open residuals: ssh aliases (git@gh:owner/repo.git), insteadOf
# rewrites to a proxy, uppercase hostnames, and deletions of remote-only refs
# by --mirror pruning (git never lists those on stdin; clone-shaped mirror
# pushes still trip the refs/remotes rule).
#
# Escape hatch for an intentional push (allows mirrors, deletions, and large
# ref updates on real remotes):
# PRIME_AGENT_ALLOW_MIRROR_PUSH=1 git push <remote> ...
#
# Installed by husky via .husky/pre-push (active after `npm ci`). Clones and
# worktrees without husky's .husky/_ shims activate the tracked hooks directly:
# git config core.hooksPath .husky

set -f

remote=$1
url=$2

case $PRIME_AGENT_ALLOW_MIRROR_PUSH in
1)
exit 0
;;
esac

real=0
case $url in
git@github.com:* | git@ssh.github.com:* | github.com:* | ssh.github.com:* | \
git@github.com.:* | git@ssh.github.com.:* | github.com.:* | ssh.github.com.:*)
real=1
;;
http://* | https://* | ssh://*)
rest=${url#*://}
case $rest in
*@*) rest=${rest#*@} ;; esac
case $rest in
github.com/* | github.com:* | ssh.github.com/* | ssh.github.com:* | \
github.com./* | github.com.:* | ssh.github.com./* | ssh.github.com.:* | \
www.github.com/* | www.github.com:* | www.github.com./* | www.github.com.:*)
real=1
;;
esac
;;
esac
if [ "$real" -ne 1 ]; then
exit 0
fi

max_refs=10
refs=0
deletions=0
outside=0
malformed=0

while IFS= read -r line || [ -n "$line" ]; do
set -- $line
if [ $# -eq 0 ]; then
continue
fi
refs=$((refs + 1))
if [ $# -ne 4 ]; then
malformed=1
break
fi
case $3 in
refs/heads/* | refs/tags/*) ;;
refs/*)
outside=$((outside + 1))
;;
*)
malformed=1
break
;;
esac
if [ "$1" = "(delete)" ]; then
deletions=$((deletions + 1))
fi
done

if [ "$malformed" -eq 1 ]; then
detail="malformed ref line (expected: <local ref> <local oid> <remote ref> <remote oid>)"
elif [ "$refs" -gt "$max_refs" ] || [ "$deletions" -gt 0 ] || [ "$outside" -gt 0 ]; then
detail="$refs refs (mirror-like), including $deletions deletion(s) and $outside ref(s) outside refs/heads + refs/tags"
else
exit 0
fi

{
echo "pre-push guard: refusing push to $url: $detail"
echo "Allowed on real GitHub remotes: updates of refs/heads and refs/tags only, no deletions, at most $max_refs refs."
echo "Mirror pushes copy refs/remotes/* onto the remote; normal pushes never target them."
echo "To push anyway: PRIME_AGENT_ALLOW_MIRROR_PUSH=1 git push $remote ..."
} >&2
exit 1
Loading