Skip to content

[RSI, security] pre-push guard against mirror-like pushes and remote branch deletions - #2446

Merged
sethkarten merged 2 commits into
mainfrom
rsi/pre-push-mirror-guard
Sep 17, 2026
Merged

sethkarten merged 2 commits into
mainfrom
rsi/pre-push-mirror-guard

Conversation

@sethkarten

@sethkarten sethkarten commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Context

On 2026-09-17 an agent ran git push --mi (git resolves it to --mirror) from a worktree against the real origin; the mirror push force-updated refs and deleted 62 remote branches, auto-closing 63 PRs. The kernel bash() guards are text scans inside the tool layer and were bypassed entirely because the push ran through a raw subprocess. Git's pre-push hook is invoked by git itself for every push, whatever process calls it, so it is the only enforcement layer that intercepts this exact incident path.

Changes

  • Adds a tracked .husky/pre-push wrapper and scripts/pre-push-guard.sh. For real GitHub remotes (github.com and ssh.github.com in scp, http://, https://, and ssh:// forms, with optional credentials and ports, including www. and trailing-dot host spellings git redirects to the same origin) the guard refuses pushes that delete a ref (stdin first field (delete)), update more than 10 refs, or target refs outside refs/heads and refs/tags (mirror pushes copy refs/remotes/* onto the remote; normal pushes never do). Local, file://, and other remotes are always allowed, malformed stdin fails closed, and an empty stdin (up-to-date push) is allowed. PRIME_AGENT_ALLOW_MIRROR_PUSH=1 is the documented escape hatch, printed in the refusal message.
  • Installs through the existing husky flow (npm ci -> prepare -> husky). docs/development.md documents the git config core.hooksPath .husky one-liner for shimless clones/worktrees, the npm ci reset trap for shimless worktrees, and the known limits (deletions by --mirror pruning are invisible to the pre-push stdin; ssh aliases, insteadOf proxies, and uppercase hostnames are not classified; git push --no-verify bypasses pre-push hooks entirely — client-side hooks are advisory, and GitHub branch protection rules are the server-side mitigation, with main protected, which is what rejected the incident's force update to main).
  • Adds scripts/check-push-guard.mjs, wired into npm run check as check:push-guard: a 31-case table-driven check that drives the hook with pre-push stdin fixtures (mirror, deletions, URL matrix, threshold boundary, malformed and empty stdin, escape-hatch semantics); refusals are asserted on the hook's behavioral signature (refusal line, reason detail, escape hatch), not on message copy. A vitest file cannot carry this PR: check:test-policy counts zero source lines for a hooks-only change, so the check follows the existing scripts/check-* convention instead.

Validation

  • npm run check passes (biome, check:test-policy against origin/main, tsgo, check:installer, check:push-guard, check:browser-smoke).
  • Incident-class reproduction in a scratch repo, with a local bare remote exposed as a git@github.com: URL through a GIT_SSH_COMMAND shim (no real remote is contacted): a python-subprocess git push --mirror without the hook deletes every remote-only branch and copies refs/remotes/* onto the remote; with the hook installed the same push, the --mi variant, and an explicit branch deletion are refused with exit 1 and the remote is untouched; a plain push and --force-with-lease still work; PRIME_AGENT_ALLOW_MIRROR_PUSH=1 permits the mirror push as documented.
  • The guard fires through the husky shim path, the core.hooksPath .husky one-liner, and direct sh invocation, including pushes from subdirectories and linked worktrees.
  • Nonstandard but live GitHub spellings (http://github.com/, https://www.github.com/, https://github.com./) were each verified against the real repository with git ls-remote before adding them to the match set.

Note

Medium Risk
Introduces client-side enforcement on pushes to production GitHub remotes with documented bypass paths (--no-verify, env opt-out, SSH aliases); misconfiguration could block legitimate large tag pushes until the escape hatch is used.

Overview
Adds a Git pre-push hook (via .husky/pre-pushscripts/pre-push-guard.sh) that blocks mirror-like pushes and remote branch deletions when the push target is a real GitHub remote (github.com / ssh.github.com, common URL spellings). Allowed pushes are limited to updates on refs/heads/* and refs/tags/*, with no deletions and at most 10 refs; local/file:///other hosts are unchanged. Intentional overrides use PRIME_AGENT_ALLOW_MIRROR_PUSH=1.

CI and docs: scripts/check-push-guard.mjs (31 stdin-driven cases) runs as npm run check:push-guard; development.md documents worktree activation (git config core.hooksPath .husky), limits, and that the guard is advisory (--no-verify still bypasses).

Reviewed by Cursor Bugbot for commit 2654bee. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add pre-push guard to block mirror-like pushes and branch deletions to GitHub remotes

  • Adds a POSIX shell guard at scripts/pre-push-guard.sh that runs via a tracked .husky/pre-push wrapper and refuses pushes to matching GitHub remotes that contain more than 10 refs, any deletion, or destinations outside refs/heads and refs/tags.
  • Non-GitHub remotes and the exact PRIME_AGENT_ALLOW_MIRROR_PUSH=1 opt-out skip all checks. Malformed ref input exits 1 with a diagnostic to stderr.
  • Integrates the guard into npm run check via a new check:push-guard script, backed by a 31-case table-driven test suite in scripts/check-push-guard.mjs.
  • Documents the guard's matching rules, fail-open cases, and the 10-ref limit in development.md.
  • Risk: the guard fails open for unparseable or malformed stdin and for non-GitHub remote URLs; reviewers should confirm the URL-matching logic in pre-push-guard.sh covers all expected GitHub host forms (scp, credentials, ports, www, trailing-dot).

Macroscope summarized 2654bee.

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Prime Agent performance — completed

PR 2654bee8 compared with main e2fb7bfa.

Overall: 25 regressed · 0 improved · 15 no clear change.

Metric Main This PR Change
Cold startup 1,079.4 ms 1,525.8 ms $\textcolor{#c25c5b}{\textsf{↑ +446.3 ms (+41.35\%)}}$
Warm startup 664.0 ms 906.5 ms $\textcolor{#c05e5c}{\textsf{↑ +242.6 ms (+36.53\%)}}$
Installation 11.90 s 15.22 s $\textcolor{#ba615e}{\textsf{↑ +3.32 s (+27.88\%)}}$
Compressed release artifacts 72.52 MB 72.49 MB ≈ -0.03 MB (-0.04%)
Installed footprint 577.65 MB 577.65 MB ≈ -0.0007 MB (-0.00%)
Idle memory, summed RSS 1,274.86 MB 1,278.43 MB ≈ +3.57 MB (+0.28%)

Python runtime

Metric Main This PR Change
Python kernel startup 99.1 ms 138.5 ms $\textcolor{#c15c5b}{\textsf{↑ +39.4 ms (+39.75\%)}}$
Python cell round trip 0.342 ms 0.562 ms $\textcolor{#d05456}{\textsf{↑ +0.219 ms (+64.03\%)}}$
Empty bash command 7.8 ms 12.5 ms $\textcolor{#ce5556}{\textsf{↑ +4.7 ms (+60.96\%)}}$
Bash git status 11.4 ms 18.9 ms $\textcolor{#d15355}{\textsf{↑ +7.5 ms (+66.32\%)}}$
Bash 32 KiB output 8.0 ms 13.1 ms $\textcolor{#d05456}{\textsf{↑ +5.1 ms (+64.38\%)}}$
35 cells / 9 shell calls 112.4 ms 192.0 ms $\textcolor{#d45254}{\textsf{↑ +79.5 ms (+70.72\%)}}$
Python interrupt to done 1.110 ms 1.641 ms $\textcolor{#c65a5a}{\textsf{↑ +0.531 ms (+47.80\%)}}$
Python state snapshot 19.5 ms 26.6 ms $\textcolor{#bf5e5c}{\textsf{↑ +7.1 ms (+36.20\%)}}$
Python state restore 251.5 ms 384.8 ms $\textcolor{#c95858}{\textsf{↑ +133.3 ms (+53.01\%)}}$
Python idle RSS 34.87 MB 36.09 MB ≈ +1.21 MB (+3.48%)
Python RSS after pandas workload 96.95 MB 98.62 MB ≈ +1.67 MB (+1.72%)

UI interactions

Metric Main This PR Change
Resume large session (cold) 2,822.2 ms 3,953.0 ms $\textcolor{#c25c5b}{\textsf{↑ +1,130.8 ms (+40.07\%)}}$
CPU, resume large session 4,090.0 ms 6,410.0 ms $\textcolor{#cb5757}{\textsf{↑ +2,320.0 ms (+56.72\%)}}$
Switch into large session 3,266.9 ms 5,439.3 ms $\textcolor{#d15355}{\textsf{↑ +2,172.3 ms (+66.49\%)}}$
CPU, switch into large session 5,420.0 ms 9,160.0 ms $\textcolor{#d35354}{\textsf{↑ +3,740.0 ms (+69.00\%)}}$
Open agents view from a session 151.8 ms 164.9 ms ≈ +13.2 ms (+8.67%)
CPU, open agents view 370.0 ms 460.0 ms ≈ +90.0 ms (+24.32%)
Full agents roster, many sessions 4.04 s 4.46 s ≈ +0.42 s (+10.30%)
CPU, full agents roster 1.91 s 2.77 s $\textcolor{#c55b5a}{\textsf{↑ +0.86 s (+45.03\%)}}$
Open another session from agents view 2,411.7 ms 2,763.9 ms ≈ +352.2 ms (+14.60%)
CPU, open from agents view 1,980.0 ms 2,630.0 ms ≈ +650.0 ms (+32.83%)
Reopen resident large session 377.3 ms 543.7 ms $\textcolor{#c45b5a}{\textsf{↑ +166.5 ms (+44.12\%)}}$
CPU, reopen resident session 710.0 ms 1,060.0 ms $\textcolor{#c75959}{\textsf{↑ +350.0 ms (+49.30\%)}}$
Open subagent session at depth 6 19,755.8 ms 21,338.9 ms ≈ +1,583.1 ms (+8.01%)
CPU, open subagent at depth 6 7,170.0 ms 10,330.0 ms $\textcolor{#c45b5a}{\textsf{↑ +3,160.0 ms (+44.07\%)}}$
Open chain parent from agents view 3,501.6 ms 4,058.7 ms ≈ +557.2 ms (+15.91%)
CPU, open chain parent 2,700.0 ms 3,660.0 ms $\textcolor{#bf5e5c}{\textsf{↑ +960.0 ms (+35.56\%)}}$
Scheduled catalog, first request 1,156.1 ms 1,897.2 ms ≈ +741.1 ms (+64.10%)
CPU, scheduled catalog 1,910.0 ms 3,310.0 ms ≈ +1,400.0 ms (+73.30%)
Scheduled catalog, repeated request 740.6 ms 1,278.5 ms $\textcolor{#d55154}{\textsf{↑ +537.9 ms (+72.63\%)}}$
CPU, repeated catalog 1,030.0 ms 1,760.0 ms $\textcolor{#d45254}{\textsf{↑ +730.0 ms (+70.87\%)}}$
Cold worker with three catalog scans 1,016.2 ms 1,627.2 ms $\textcolor{#cd5657}{\textsf{↑ +611.0 ms (+60.13\%)}}$
CPU, cold worker and scans 2,960.0 ms 4,790.0 ms $\textcolor{#ce5556}{\textsf{↑ +1,830.0 ms (+61.82\%)}}$
UI memory after interactions 2,755.33 MB 2,713.71 MB ≈ -41.62 MB (-1.51%)

Sandbox cost: ~$0.1364 — no inference calls.
Run, logs, and downloadable raw results

Methodology and samples

Main resolved at 2026-09-17T20:14:03.786207+00:00. Harness e2fb7bfa.
Linux x64, 4 vCPU, 8 GB RAM, 20 GB disk; region us.
Image: node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2.
Stock tools, skills, daemon, and Python bootstrap enabled; fresh homes and a fixed Git fixture.
Onboarding is dismissed; the editor starts without a selected model or submitted prompt.
Medians shown. Arrows require a 20% timing/memory change plus absolute floors and IQR.
These practical noise floors are not a statistical significance test.
Cold means stopped Prime processes; OS filesystem caches are not flushed.
No model requests or credentials. Installation excludes build/setup time.
Installer tarballs use loopback; npm/Python downloads use the network with fresh caches.
Artifact size counts release tarballs; footprint after first use includes registry packages.
MB is decimal. Summed RSS can double-count shared pages; PSS is recorded when available.
Provisioning, setup, and build durations are recorded separately in the raw results.
Kernel probes use the installed JSONL runtime, outside the TUI/TypeScript host.
Per trial: 50 Python cells, 5 calls per shell case, and one 35-cell mix (9 git status calls).
Cell/shell values are batch means; other runtime timings are single operations.
State fixture: a 10,000-row × 8-column integer DataFrame and a 10,000-integer list.
Restore runs in a fresh kernel, including pandas imports; kernel startup is excluded.
Kernel RSS covers the isolated Python process; loaded RSS follows the pandas workload.
UI trials use a fresh fixture set: 194 top-level sessions including one ~40 MB transcript,
40 ledger fan-out children, and a 6-deep subagent chain (~46 spawn edges).
Large fixtures hold 1,999 complete triples (~5 MB JSONL); medium 119; subagents 399 each.
Interactions: cold --resume of a large session, warm /resume switch, left-arrow to agents view,
roster settle with many saved sessions, search-and-open of another large session,
reattaching to that resident session, opening the chain parent, and drilling to depth 6.
Readiness is the rendered transcript tail plus a confirmed editor echo.
CPU metrics sum utime+stime across the whole benchmark-user process tree per interaction.
UI memory sums RSS after the interactions; PTY byte counts are in the raw results.
A separate catalog fixture has 2,300 sessions, 2,298 edges, and 13 paused scheduled-job owners.
Catalog timings cover first/repeated reads and cold worker creation under three pending scans.
All expected jobs and owner metadata are checked; worker readiness excludes TUI rendering.
Costs estimate full sandbox lifetimes at configured rates, including setup and build.
Budget target: $1; not a billing cap. Performance changes are informational.
Failed or incomplete execution fails the workflow; saved artifacts remain available.
Each side stops a phase after 2 identical consecutive failures.
Skipped trials are not attempted samples. Warm startup requires a successful cold launch.

Metric Main successful/attempted PR successful/attempted Main spread PR spread
Cold startup 10/10 10/10 IQR 10.8 ms IQR 90.4 ms
Warm startup 10/10 10/10 IQR 57.3 ms IQR 63.6 ms
Installation 3/3 3/3 range 0.70 s range 0.98 s
Compressed release artifacts 1/1 1/1
Installed footprint 1/1 1/1
Idle memory, summed RSS 10/10 10/10 IQR 16.41 MB IQR 22.86 MB
Python kernel startup 10/10 10/10 IQR 2.9 ms IQR 9.8 ms
Python cell round trip 10/10 10/10 IQR 0.013 ms IQR 0.120 ms
Empty bash command 10/10 10/10 IQR 0.3 ms IQR 2.2 ms
Bash git status 10/10 10/10 IQR 0.7 ms IQR 2.7 ms
Bash 32 KiB output 10/10 10/10 IQR 0.4 ms IQR 3.0 ms
35 cells / 9 shell calls 10/10 10/10 IQR 2.0 ms IQR 55.8 ms
Python interrupt to done 10/10 10/10 IQR 0.054 ms IQR 0.482 ms
Python state snapshot 10/10 10/10 IQR 0.4 ms IQR 3.2 ms
Python state restore 10/10 10/10 IQR 6.1 ms IQR 26.5 ms
Python idle RSS 10/10 10/10 IQR 4.61 MB IQR 5.15 MB
Python RSS after pandas workload 10/10 10/10 IQR 5.66 MB IQR 3.40 MB
Resume large session (cold) 3/3 3/3 range 459.7 ms range 991.6 ms
CPU, resume large session 3/3 3/3 range 400.0 ms range 1,230.0 ms
Switch into large session 3/3 3/3 range 132.6 ms range 135.0 ms
CPU, switch into large session 3/3 3/3 range 280.0 ms range 240.0 ms
Open agents view from a session 3/3 3/3 range 18.0 ms range 13.9 ms
CPU, open agents view 3/3 3/3 range 260.0 ms range 120.0 ms
Full agents roster, many sessions 3/3 3/3 range 0.0039 s range 0.01 s
CPU, full agents roster 3/3 3/3 range 0.07 s range 0.33 s
Open another session from agents view 3/3 3/3 range 26.4 ms range 181.0 ms
CPU, open from agents view 3/3 3/3 range 350.0 ms range 740.0 ms
Reopen resident large session 3/3 3/3 range 18.9 ms range 82.4 ms
CPU, reopen resident session 3/3 3/3 range 260.0 ms range 130.0 ms
Open subagent session at depth 6 3/3 3/3 range 494.2 ms range 1,185.2 ms
CPU, open subagent at depth 6 3/3 3/3 range 460.0 ms range 1,800.0 ms
Open chain parent from agents view 3/3 3/3 range 90.0 ms range 161.3 ms
CPU, open chain parent 3/3 3/3 range 190.0 ms range 570.0 ms
Scheduled catalog, first request 3/3 3/3 range 27.1 ms range 861.7 ms
CPU, scheduled catalog 3/3 3/3 range 30.0 ms range 1,680.0 ms
Scheduled catalog, repeated request 3/3 3/3 range 55.2 ms range 241.5 ms
CPU, repeated catalog 3/3 3/3 range 250.0 ms range 160.0 ms
Cold worker with three catalog scans 3/3 3/3 range 64.5 ms range 221.7 ms
CPU, cold worker and scans 3/3 3/3 range 370.0 ms range 660.0 ms
UI memory after interactions 3/3 3/3 range 54.54 MB range 82.86 MB

Comment thread .husky/pre-push

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 53bfa85. Configure here.

Comment thread scripts/check-push-guard.mjs
Comment thread scripts/pre-push-guard.sh Outdated
…ertions

The pre-push guard's refusal output and header now describe the current
rules (allowed ref shapes, at-most-10 threshold, escape hatch) instead of
retelling the 2026-09-17 incident; the provenance stays as a one-line
header note. check-push-guard now asserts the behavioral refusal
signature (refusal line, reason detail with ref/deletion counts or the
malformed-stdin failure, escape hatch) instead of incident prose, so
message copy edits no longer break the check while behavior mutations
still fail it. development.md documents the git push --no-verify bypass
as a known limit and names GitHub branch protection rules as the
server-side mitigation.
@sethkarten
sethkarten enabled auto-merge (squash) September 17, 2026 21:18
@sethkarten
sethkarten merged commit 1ae2514 into main Sep 17, 2026
62 of 64 checks passed
@sethkarten
sethkarten deleted the rsi/pre-push-mirror-guard branch September 17, 2026 23:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants