-
Notifications
You must be signed in to change notification settings - Fork 2.3k
[RSI, security] pre-push guard against mirror-like pushes and remote branch deletions #2446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" "$@" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ..."); | ||
|
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.`); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.