diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000000..cd6e7f5da2 --- /dev/null +++ b/.husky/pre-push @@ -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" "$@" diff --git a/package.json b/package.json index 5bf9eb1f83..95195b27cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/coding-agent/.changes/rsi-pre-push-mirror-guard.md b/packages/coding-agent/.changes/rsi-pre-push-mirror-guard.md new file mode 100644 index 0000000000..58a55ecc39 --- /dev/null +++ b/packages/coding-agent/.changes/rsi-pre-push-mirror-guard.md @@ -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. diff --git a/packages/coding-agent/docs/development.md b/packages/coding-agent/docs/development.md index 443834c371..22631cb0a3 100644 --- a/packages/coding-agent/docs/development.md +++ b/packages/coding-agent/docs/development.md @@ -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. diff --git a/scripts/check-push-guard.mjs b/scripts/check-push-guard.mjs new file mode 100644 index 0000000000..ec14e71f34 --- /dev/null +++ b/scripts/check-push-guard.mjs @@ -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: " " +// per line, deletions as "(delete) ". +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 ..."); + 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.`); diff --git a/scripts/pre-push-guard.sh b/scripts/pre-push-guard.sh new file mode 100755 index 0000000000..663960a212 --- /dev/null +++ b/scripts/pre-push-guard.sh @@ -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://@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 ... +# +# 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: )" +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