Skip to content

feat(push): a bound agent may push only its own branch (close cross-agent clobber)#19

Merged
suzuke merged 1 commit into
mainfrom
fix/cross-branch-push-guard
Jul 6, 2026
Merged

feat(push): a bound agent may push only its own branch (close cross-agent clobber)#19
suzuke merged 1 commit into
mainfrom
fix/cross-branch-push-guard

Conversation

@suzuke

@suzuke suzuke commented Jul 6, 2026

Copy link
Copy Markdown
Owner

The gap (found while designing the multi-agent scenario)

The push guards blocked protected refs (main/master/policy), --mirror/--all, trust-root files, and snapshot refs — but NOT a push to another agent's non-protected branch. An agent bound to feat/a could:

git push origin +HEAD:feat/b   →  + …  HEAD -> feat/b (forced update)   # clobbered agent-b

Empirically reproduced. Local isolation held (checkout/worktree denied), but cross-agent branch integrity on a shared remote did not.

The fix

push_cross_branch_violation: a bound agent may push only its assigned branch — the symmetric partner to the cross-branch checkout deny. Coexists with the existing push guards (protected still gives better messages for main/master). Denies cross-branch push / delete / force-push / --all / --mirror / wildcard, and an implicit push when the worktree HEAD drifted off its binding or push.default=matching.

Parsing is option-aware (parse_push_argv): skips flags AND value-taking options (-o/--push-option/--receive-pack/--exec) and detects delete mode, so it doesn't mis-read git push --delete feat/b (no remote) or an option value as a remote/refspec — the exact holes the fugu design review flagged. Tag pushes (--tags, refs/tags/…, tag <name>) are exempt from the branch guard.

Verification

  • Unit table matrix (fugu's list): 15 deny forms + 16 allow forms, incl. --delete feat/b (no remote), HEAD:feat/b, :feat/b, +HEAD:feat/b, wildcard, drift, matching, tag forms, -o value.
  • End-to-end: agent-a's own-branch push succeeds; its force-push/delete of feat/b are denied; feat/b on the shared origin is not clobbered.
  • Full workspace suite green; clippy -D warnings clean.

🤖 Generated with Claude Code

…gent clobber)

Multi-agent scenario work surfaced a real gap: the push guards blocked
protected refs (main/master/policy), `--mirror`/`--all`, trust-root files, and
snapshot refs — but NOT a push to another agent's non-protected branch. An agent
bound to feat/a could `git push origin +HEAD:feat/b` and force-clobber agent-b's
branch on a shared remote (empirically reproduced: `forced update` succeeded).

Add `push_cross_branch_violation`: a bound agent may push ONLY its assigned
branch — the symmetric partner to the cross-branch checkout deny. Runs alongside
the existing push guards (fugu: coexist; protected still gives better messages
for main/master). Denies cross-branch pushes, deletes, force-pushes, `--all`/
`--mirror`, wildcards, and (per fugu review) an implicit push when the worktree
HEAD has drifted off its binding or push.default=matching.

Parsing is option-aware (`parse_push_argv`): it skips flags AND value-taking
options (`-o`/`--push-option`/`--receive-pack`/`--exec`) and detects delete
mode, so it doesn't mis-read `git push --delete feat/b` (no remote) or an option
value as a remote/refspec — the exact holes fugu's design review flagged. Tag
pushes (`--tags`, `refs/tags/…`, `tag <name>`) are exempt from the BRANCH guard.

Tests: a full deny/allow table matrix (fugu's list) as unit tests, plus an
end-to-end repro — agent-a's own-branch push succeeds, its force-push/delete of
feat/b are denied, and feat/b on the shared origin is NOT clobbered. Full
workspace suite green, clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@suzuke suzuke merged commit 602e46a into main Jul 6, 2026
4 checks passed
suzuke added a commit that referenced this pull request Jul 6, 2026
…is) (#20)

* demo: multi-agent verification scenario (agents produce evidence, supervisor verifies)

Two agents share one repo at once, each on its own branch. verify.sh (the
deterministic supervisor) launches agent-a and agent-b CONCURRENTLY, each in its
own guarded session running agent-run.sh; each agent does real work, pushes its
own branch, tries to interfere with the other, and leaves a machine-recomputable
evidence bundle (every command's raw stdout/stderr/exit-code + a state snapshot).

Per the adversarial design review: an agent's own PASS is not proof — the
supervisor RE-DERIVES the truth from git/home/audit state (never the agent's
narrative) and checks nine invariants: distinct worktrees on bound branches,
git-is-the-shim in both sessions, both branches on the shared origin, per-agent
provenance (not mixed), isolated working trees, the shared source repo
untouched, one integrity key with both signed bindings, every guarded step
behaving (own push ok; cross-agent checkout/force-push/delete all DENIED), and
each agent's self-verdict consistent with the re-derived state.

Invariant 8 reads the real exit codes, so it FAILS if a guard doesn't fire —
it is designed to catch a regression of exactly the cross-agent force-push
clobber (PR #19) whose gap this scenario's design first surfaced.

Verified end-to-end (9/9 invariants held with the installed 0.2.2 + PR #19).
Throwaway world in a temp dir; only remote is a local bare repo. shellcheck
clean (SC2015 excluded: pass/bad are non-failing printf helpers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(multi-agent): synthesis re-derives ALL invariants from state (close forged-artifact hole)

fugu review REJECTED: the synthesis still trusted agent-written artifacts for the
canonical-checkout invariant. It only checked the shared project repo, never the
stand-in real checkout, and took the agent's own rc/verdict files as the source
of truth — so an agent could move the canonical checkout's HEAD, write 10.rc=1 +
verdict=PASS, and the synthesis reported success (fugu reproduced it).

Rewrite the synthesis to RE-DERIVE every invariant from independent state:
- worktrees come from the shim-written, HMAC-signed bindings (not the agent's
  evidence.env); each worktree's current branch is read with real git (catches a
  cross-branch checkout that drifted HEAD).
- cross-agent push/delete is caught by the origin state itself: both branches
  present, distinct, each trailered to its OWN agent.
- NEW: the stand-in real checkout's HEAD must equal its pre-run value — this
  independently catches an agent touching your checkout regardless of what it
  reported (the exact hole).
- the agent's rc/verdict becomes a CONSISTENCY cross-check only: it can flag a
  self-report that disagrees, but can never turn a real violation into a pass.

Verified: normal run VERIFIED (11 invariants, re-derived); and fugu's forge
attack — tamper the canonical HEAD + fake agent-a's 10.rc=1/verdict=PASS — is now
caught by the independent canonical invariant. shellcheck: verify.sh fully clean;
agent-run.sh clean except an intentional single-quoted sh -c (SC2016).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(multi-agent): derive worktrees from the owned project, not agent-writable HOME

fugu review REJECTED again (deeper): the synthesis called binding.json
authoritative but never verified its HMAC signature — I5 only checked that .sig
FILES exist. A same-uid agent with $AGENTIC_GIT_HOME can rewrite its binding.json
to point at a fake clean repo (sig stale but present), and the synthesis accepted
the fake worktree. fugu reproduced it.

Root cause: HOME is agent-writable at same-uid (the agent can even read the HMAC
key and re-sign), so NO home-derived state is trustworthy against a malicious
agent — the seatbelt-not-cage limit.

Fix: the supervisor now derives the agents' worktrees from the SHARED PROJECT's
own `git worktree list` (the supervisor owns the project repo; an agent can't
redirect it by editing HOME). Dropped the cosmetic file-leak check (an agent can
create any file in its own worktree — no signal). All the load-bearing
invariants already key off supervisor-owned state (bare origin branches +
provenance, the stand-in checkout's HEAD, the project's HEAD).

README now states the honest boundary explicitly: this verifies the guards for
agents going THROUGH the shim (the threat model); a malicious same-uid agent
that forges its HOME state or bypasses the shim via /usr/bin/git is out of scope
— which is exactly why the supervisor keys off repos it owns, not the agent's home.

Verified: normal run VERIFIED; the binding-forgery no longer redirects the
supervisor (it reads the project's worktree list → the real worktree). verify.sh
shellcheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(multi-agent): README matches the supervisor-owned invariants (no stale claims)

fugu review REJECTED (documentation): after moving the load-bearing checks to
supervisor-owned state, the README still advertised invariants the code no longer
has — a nine-item list including "working trees isolated (file-leak)" (dropped as
cosmetic) and "invariant 8 reads exit codes to prove guarded steps behaved" (exit
codes are now only the non-authoritative consistency cross-check). A reader was
told the scenario proves properties it no longer checks — the opposite of the
honest boundary this scenario is about.

Rewrite the invariant list and the "why it's a real test" section to match the
actual supervisor-owned checks: the shared project's own worktree list, origin
branches + per-agent provenance (this is what catches a clobber), the shared
repo's and the stand-in checkout's HEADs unchanged, one non-split-brain key,
per-agent audit attribution, plus the agents' self-report as a consistency
cross-check only. Also fix the intro's "re-derives from git/home/audit" to
"state it owns … never the agent's home".

Docs only; verify.sh unchanged (fugu confirmed the code-side forge fixes at
94589cc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant