Skip to content

Git Agent Protocol #40

Description

@moshloop

SPEC: git-agent protocol v1

Overview

A wire protocol for a supervisor to hand a unit of work to a coding agent running on another machine, and to vet the result — using nothing but git.

The design constraint that shapes everything else: the coding agent runs git commit and git push and nothing else. No captain binary on the agent, no custom subcommand, no protocol awareness. Every mechanism below exists to make those two ordinary commands sufficient, and to make a rejected push read like an ordinary rejected push — with an actionable explanation attached.

Work flows through two admission tiers. The agent pushes to a sidecar, which runs a cheap local hook set; only a change the sidecar accepts is relayed to the supervisor, which runs a stricter set. A failure at either tier travels back down the same still-blocked push.

This document is implementation-neutral. The captain implementation and its adapter seam are specified in SPEC-sandbox-adapters.md.

Why git

The alternative in-tree channel — POST /api/captain/hooks/{provider} (pkg/monitor/hooks.go) — is unauthenticated, has a 1s timeout, and is fire-and-forget: lossy by design. Git moves trees with integrity, its receive hooks are a natural admission gate, and hook stderr is relayed to the pusher for free. The receive path is the only place where "reject this work and tell the author why" is a first-class operation.

Terminology

Term Meaning
Supervisor Originates work, owns the real repository, runs the final hook set.
Mailbox A bare repo sibling to the supervisor's real repo, sharing its object store via objects/info/alternates. All protocol refs live here.
Sidecar The process co-located with the agent. Owns a bare repo + the agent's worktree, runs the first hook set, and relays upward.
Agent The coding agent. Sees only an ordinary git worktree with an upstream.
Attempt One submit cycle. Monotonic per task, starting at 1.
Hook set An ordered list of checks run at one tier. Each yields a verdict.

Normative keywords MUST / MUST NOT / SHOULD / MAY are per RFC 2119.


1. Verified substrate

Four properties of git underpin the protocol. Each was verified against git 2.50.1 by hack/gitagent_empirical.sh; rerun it before porting to another git version. They are recorded here because two of them contradict the obvious reading of the documentation, and one is a silent-corruption trap.

1.1 Quarantine leaks into descendants — and --local-env-vars will not save you

During pre-receive, receive-pack sets GIT_QUARANTINE_PATH, GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES, and GIT_DIR=.. These are inherited by every descendant process.

Verified: a grandchild git update-ref run in an unrelated repository fails with fatal: not a git repository: '.' (rc=128). Scrubbing the variables makes the identical command succeed.

Verified: git rev-parse --local-env-vars returns

GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT
GIT_OBJECT_DIRECTORY GIT_DIR GIT_WORK_TREE GIT_IMPLICIT_WORK_TREE GIT_GRAFT_FILE
GIT_INDEX_FILE GIT_NO_REPLACE_OBJECTS GIT_REPLACE_REF_BASE GIT_PREFIX
GIT_SHALLOW_FILE GIT_COMMON_DIR

GIT_QUARANTINE_PATH is not in that list. The scrubbing idiom githooks(5) itself recommends therefore leaves it set. Implementations MUST scrub it by name.

R1.1 Before executing any hook, a receiver MUST unset GIT_QUARANTINE_PATH, GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, and GIT_ALTERNATE_OBJECT_DIRECTORIES from the hook's environment, and MUST NOT rely on --local-env-vars to enumerate them.

1.2 Push options survive

Verified: with receive.advertisePushOptions=true, --push-option=captain-envelope-v1 --push-option=attempt=2 arrives in pre-receive as GIT_PUSH_OPTION_COUNT=2, GIT_PUSH_OPTION_0, GIT_PUSH_OPTION_1, byte-identical.

The default for receive.advertisePushOptions is false, and a push with options to a receiver that has not advertised them fails outright.

R1.2 Every receiving repo MUST set receive.advertisePushOptions=true at creation. The control envelope MUST ride on push options, never on commit trailers (see §11, H6).

1.3 Materializing a quarantined tree is safe — but silently no-ops on a relative path

Verified: read-tree <oid> followed by checkout-index -a -f reads quarantined objects and writes the full tree including nested paths.

Verified, and the trap: because GIT_DIR is ., any path derived from it is relative to the receiver's cwd. A relative GIT_WORK_TREE resolves against the wrong base, and checkout-index then writes nothing while exiting 0. A hook chained after it verifies an empty directory and passes. This is a silent false-accept, not a visible failure.

All three working forms require an absolute target:

Form Result
GIT_WORK_TREE=<abs> + checkout-index -a -f works
git -c core.bare=false --work-tree=<abs> checkout-index -a -f works
cd <abs> && git --git-dir=<abs-repo> ... --prefix=<abs>/ works

R1.3 A receiver MUST absolutise every path derived from GIT_DIR before use, and MUST assert the materialized tree is non-empty and its file count matches ls-tree -r before running any hook against it. Exit status 0 from checkout-index MUST NOT be treated as proof of materialization.

1.4 The relay needs no object copy

The sidecar relays by pushing quarantined objects onward from inside its own pre-receive. The naive attempt fails:

remote: error: ref updates forbidden inside quarantine environment

That error comes from the upstream's receive-pack, which inherited GIT_QUARANTINE_PATH from the outbound push and refused to update its own refs. It is not evidence that the objects are unreadable.

Verified: unsetting GIT_QUARANTINE_PATH alone is sufficient. The objects remain readable through the inherited GIT_OBJECT_DIRECTORY / GIT_ALTERNATE_OBJECT_DIRECTORIES, and the relay succeeds.

R1.4 The relay MUST unset GIT_QUARANTINE_PATH and MUST retain the inherited object-directory variables. Implementations MUST NOT copy objects out of quarantine before relaying — it is unnecessary, and it defeats quarantine's guarantee that a rejected push leaves nothing behind.


2. Topology

         supervisor host                          agent host
  ┌───────────────────────────┐           ┌──────────────────────────┐
  │  real repo (user's)       │           │  sidecar                 │
  │      ▲                    │           │   ├─ repo.git (bare)     │
  │      │ alternates         │  <─SSH─>  │   └─ worktree/  ◀── agent│
  │  mailbox.git (bare)       │           │        (branch+upstream) │
  │   └─ hook set #2          │           │        hook set #1       │
  └───────────────────────────┘           └──────────────────────────┘

R2.1 Protocol refs MUST land in a mailbox repo, never in the user's working repository. The mailbox shares objects via objects/info/alternates, so this costs no duplication.

The mailbox exists because refs/captain/* in a live repo is not inert: it is visible to git log --all, fsck and gitk; core.logAllRefUpdates does not cover it so there is no reflog; receive.autogc defaults on and would fire gc inside a repo with a live worktree; and fsmonitor/IDE indexers storm on every ref write.

R2.2 A mailbox MUST set receive.advertisePushOptions=true, receive.fsckObjects=true, receive.denyDeletes=true, receive.autogc=false, core.logAllRefUpdates=always, and a finite receive.maxInputSize.

R2.3 Both receivers MUST serve git-receive-pack only. git-upload-pack MUST NOT be exposed: a shared upload-pack leaks every task namespace and every user branch to every enrolled agent.


3. Ref layout

The two hops are deliberately asymmetric. The agent's hop uses ordinary branch semantics it already understands; the machine-to-machine hop is an append-only audit trail.

3.1 Agent ↔ sidecar

refs/heads/captain/<task-id>

A plain branch. The sidecar creates it at dispatch and configures the worktree's branch.<name>.remote / .merge, so a bare git push resolves without arguments.

R3.1 Dispatch MUST set the worktree's branch and upstream. Without it the agent's first push fails with fatal: The current branch has no upstream branch, and the no-special-tooling guarantee is void.

3.2 Sidecar ↔ supervisor mailbox

refs/captain/tasks/<task-id>/dispatch/<n>   code     tree = supervisor's dirty worktree, parent = base
refs/captain/tasks/<task-id>/control/<n>    control  tree = {task.json, hooks.json, policy.json}
refs/captain/tasks/<task-id>/result/<n>     code     tree = the agent's work, parent = dispatch/<n>
refs/captain/tasks/<task-id>/verdict/<n>    control  tree = {verdict.json, log.txt}

<task-id> MUST match ^[a-z0-9-]{1,64}$. <n> is the attempt, a positive decimal integer with no leading zeros.

R3.2 Every protocol ref push MUST be a create. A push to an existing ref MUST be rejected; deletes and non-fast-forward updates MUST be rejected. Attempt-scoping makes a two-writer race a create-collision rather than a lost update.

R3.3 Control refs MUST point at commits, not bare trees. A tree-tipped ref is unusual on the wire and trips gc, bitmap and fsck paths; one extra object per attempt removes the whole class.

R3.4 dispatch/<n> and control/<n> MUST be pushed in one --atomic push, as MUST result/<n> and its control ref. A tree without its envelope is unprocessable.


4. Control envelope

Carried in push options (§1.2). Option 0 is the version tag; the remainder are key=value.

Key Required Meaning
v yes Protocol version. 1 for this document.
task yes Task id.
attempt yes Attempt number.
base yes Supervisor HEAD OID at dispatch, for three-way integration (H14).
depth yes Hook-recursion depth; 0 at top level (H15).
agent dispatch Target agent name.
relay dispatch sync | async (§6.4).

R4.1 A receiver MUST reject a push whose envelope is absent, whose v it does not implement, or whose task/attempt disagree with the ref name being written.

R4.2 Envelope values MUST NOT be read from commit trailers. Trailers are extracted only when the trailing block is ≥25% trailer-shaped — an LLM's bullet list silently deletes them — values continue across whitespace-led lines, giving an injection vector, and commit-tree -F validates nothing, giving a forgery vector. Trailers MAY carry human provenance and MUST NOT carry authority.

Payloads referenced by control/<n>:

  • task.json — prompt, model, budget, working-directory hints. Materialized outside the agent's worktree so it is not itself submittable.
  • hooks.json — the two hook sets (§5), serialized.
  • policy.json — path allowlist/denylist, size caps, maxAttempts.

5. Hook sets

A hook is a single-verdict check: given a working tree and the list of changed paths, it returns pass/fail plus feedback. Four kinds are defined; all four reduce to that one contract.

Kind Declared as Semantics
exec a shell command Exit 0 passes. Output tail becomes feedback. Covers gavel test, make lint, anything else.
commit a commit policy Content gates — secret detection, size caps, path policy — plus how the work is committed.
fixture a fixture document Acceptance criteria evaluated by an external runner. Declared by every implementation, executed only by one that owns fixtures.
prompt a prompt reference An LLM judge rendering a prompt whose output schema is {ok, reason, feedback}.

R5.1 A hook set MUST run in declaration order and MUST stop at the first failing hook. Feedback from that hook is the verdict's feedback.

R5.2 Hooks MUST execute inside a sandbox, never on the receiving host. Every input is agent-authored — the Makefile, the _test.go files, the lint config — so hook execution is remote code execution by construction (H1). This requirement is not waivable by configuration.

R5.3 A sandbox used for hook execution MUST NOT mount a container runtime socket and MUST NOT weaken network isolation. Either makes every other control in this document decorative.

R5.4 prompt hooks MUST run with a non-relocating sandbox; declaring a relocating sandbox inside a hook prompt MUST be a validation error, not a silent downgrade. The depth envelope field MUST be incremented per nesting level and MUST be bounded. Hook prompts are subject to the run's budget (H15).

R5.5 Every hook MUST have a wall-clock timeout, MUST be killed by process group, and MUST have bounded captured output. An unbounded hook is a denial-of-service against the blocked push.


6. Sequences

6.1 Dispatch — supervisor → agent

  1. Snapshot. Stage the supervisor's dirty worktree — tracked modifications, staged content, and untracked files subject to policy — then write-tree and commit-tree -p <base>.

    R6.1 The snapshot MUST be built from an explicit path set. It MUST NOT be built by read-tree HEAD followed by add --all: under sparse-checkout, skip-worktree or assume-unchanged, that stages mass deletions of paths that merely are not present in the working tree (H4).

  2. Refuse loudly. Abort dispatch with a diagnostic — never degrade — if git lfs ls-files is non-empty, a required clean/smudge filter is declared, a submodule is dirty, or the snapshot exceeds policy caps. Each round-trips incorrectly and silently (H5).

    R6.2 The snapshot MUST be taken with core.autocrlf=false, core.eol=lf and core.attributesFile=/dev/null, so the bytes committed are the bytes on disk.

  3. Push dispatch/<n> + control/<n> atomically, envelope in push options.

  4. Sidecar pre-receive — admission only: agent identity → namespace, ref shape, caps.

  5. Sidecar post-receivegit worktree add on captain/<task-id>, set its upstream (R3.1), materialize task.json outside the worktree, launch the agent.

    R6.3 The agent MUST be launched fully detached — new session, stdio redirected to files. A child that inherits the hook's stdout/stderr keeps the pipe open, receive-pack waits for EOF, and the dispatch push hangs for the lifetime of the agent (H12).

    R6.4 worktree add is legal here and only here: quarantine has ended by post-receive. It MUST NOT be attempted in pre-receive, where ref updates are rejected by construction (H2).

6.2 Submit — agent → sidecar → supervisor, one blocking push

  1. The agent runs git commit and git push. The push blocks for everything below.

  2. Sidecar pre-receive — admission. Sub-second, pure data: ref shape and namespace, no deletes, no force, content gates over changed paths, path policy, blob caps. receive.fsckObjects catches .git-component trees and malicious .gitmodules.

  3. Sidecar pre-receive — hook set build(deps): bump golang.org/x/crypto from 0.43.0 to 0.45.0 #1. Materialize per §1.3 and R1.3; scrub per R1.1; run the sidecar hook set sandboxed. On failure, reject here — the supervisor is never contacted.

    R6.5 Materialization MUST use read-tree + checkout-index. It MUST NOT use git archive, which honours agent-controlled export-ignore — making the verified tree differ from the integrated tree — and whose output piped to tar -x bypasses git's verify_path, letting a .git/ tree entry become a real .git/config and execute on the next git invocation (H9).

  4. Relay. Still inside the same pre-receive, push result/<n> + control/<n> to the mailbox and stream the supervisor's sideband back out through the sidecar's own stderr.

    R6.6 The relay MUST live in pre-receive. A relay in post-receive has already reported success to the agent and can no longer reject; a non-zero upstream exit would be logged and dropped (H16).

    R6.7 A non-zero upstream exit MUST fail the sidecar's hook and reject the agent's push.

  5. Supervisor pre-receive — hook set build(deps): bump github.com/cert-manager/cert-manager from 1.16.1 to 1.16.2 #2. Same machinery, stricter set. Same sandbox requirement — the tree is still agent-authored (R5.2).

  6. Verdict. On failure: feedback on the sideband, non-zero exit propagated down the chain. Quarantine discards every object at both tiers, and the agent's branch does not advance. On success: the supervisor writes verdict/<n> and integrates; the sidecar accepts; the agent's push reports success.

6.3 Rejection is not termination

A rejected push leaves the agent's local branch intact and ahead. The agent fixes and pushes again as attempt n+1, bounded by policy.maxAttempts. This is ordinary git: the agent needs no new concept to recover.

6.4 Sync vs async

Chained-synchronous is the default, because it is what makes one git push a complete answer. A prompt hook can take minutes.

R6.8 An implementation MUST support relay=sync. It MAY support relay=async, in which the sidecar accepts after hook set #1 and reports the supervisor's verdict out-of-band. async MUST NOT be the default.

6.5 Resumption

A rejected push leaves no verdict ref, because quarantine discarded the objects.

R6.9 Before exiting non-zero, pre-receive MUST persist the verdict outside git, keyed by (task, attempt), and expose it out-of-band. This is also how async reports, and how a verdict survives a dropped connection.


7. Feedback wire format

Everything reaches the agent through the sideband, prefixed remote: by the client.

remote: captain: REJECTED task 01jb-refactor-store attempt 2 (verify)
remote:
remote: ✗ gate:path-denied   .env
remote: ✗ verify:make lint
remote:     pkg/foo/bar.go:12:2: undefined: Baz
remote:
remote: captain-json: {"v":1,"status":"rejected","attempt":2,"tier":"sidecar","findings":[…]}

R7.1 Feedback MUST NOT contain \r. The sideband demuxer consumes CR as a progress-line terminator and the text is lost.

R7.2 The JSON summary MUST be a single line prefixed captain-json: , so one rg '^captain-json: ' recovers it.

R7.3 The block MUST be capped at 64 KiB, with an explicit truncation marker and a pointer to the retained full log.

R7.4 During a long hook the receiver SHOULD emit periodic progress to the sideband. Without traffic, intermediaries drop the connection and the agent sees a transport error rather than a verdict.

7.1 verdict.json

{
  "v": 1,
  "task": "01jb-refactor-store",
  "attempt": 2,
  "status": "rejected",
  "tier": "sidecar",
  "findings": [
    {"hook": "gate:path-denied", "kind": "commit", "path": ".env",
     "message": "path denied by policy"},
    {"hook": "verify:make lint", "kind": "exec", "exitCode": 1,
     "feedback": "pkg/foo/bar.go:12:2: undefined: Baz"}
  ]
}

statusaccepted | rejected | error. error means the tier could not reach a verdict — a hook timed out, the sandbox failed to start, the relay could not connect.

R7.5 error MUST NOT be treated as accepted. An indeterminate verdict rejects.


8. Identity and authorization

R8.1 Each agent MUST authenticate with its own keypair. The receiver maps the presented public-key fingerprint to an agent identity, and that identity to a ref namespace.

R8.2 Enrollment MUST issue a single-use, short-TTL join token, never a private key. The agent generates its keypair on first start and registers the public half; the token authorizes that one registration and is then burned. Private key material MUST NOT transit a terminal, clipboard or CI log.

R8.3 Namespace comparison MUST append a separator before matching — compare against <prefix>/, not <prefix>. Bare prefix matching lets agent a write agent ab's namespace (H11).

R8.4 A repository path supplied by the client MUST be resolved and confirmed to be contained within the configured root. Stripping quotes and leading slashes is insufficient: .. traversal escapes it (H13).

R8.5 Revocation MUST take effect for connections established after it. A revoked fingerprint MUST be refused.


9. Egress credential proxy

The sandbox never holds a real credential. It holds captain-placeholder-<name>-<nonce>; the sidecar proxy substitutes the real value on the way out.

A grant is a destination — host, methods, path prefixes — plus the headers that may carry a credential to it. Each header's value is resolved at request time from a reference, so the proxy never knows anything about secret storage.

R9.1 The proxy MUST resolve DNS itself and validate the upstream certificate against the name it resolved. The Host header, the CONNECT target and the SNI are all sandbox-controlled and MUST NOT be trusted as identity.

R9.2 Substitution MUST occur only when the placeholder appears in exactly the granted header for the granted destination. A placeholder anywhere else — a different header, a body, a query parameter — MUST cause the request to be rejected and logged. It MUST NOT be stripped and forwarded: the appearance is an exfiltration attempt, and silently continuing hides it.

R9.3 Destinations MUST be deny-by-default, enforced at the network layer. The proxy sees only HTTP; SSH, DNS and raw TCP bypass it entirely, so proxy-level allowlisting alone is not containment.

R9.4 A credential that fails to resolve MUST fail the request loudly. The placeholder MUST NOT be forwarded upstream.

R9.5 Every decision MUST be logged with destination, header name and verdict, and MUST NOT log the value.

Non-goal, stated so nothing is built on it: response-body redaction is telemetry, not a control. An upstream can be asked to return the secret base64'd, reversed, or split across fields.

Scope, not just secrecy. The proxy protects the credential's value, not its capability. An allowlisted api.github.com still permits POST /gists, which is a working exfiltration channel with a legitimate-looking destination (H7).

R9.6 Grants MUST be scoped by method and path prefix, and the upstream credential SHOULD be minted with the narrowest scope the task needs. Host-level allowlisting alone is insufficient.


10. Integration

R10.1 The supervisor MUST integrate result/<n> as a three-way merge against the base recorded in the envelope, not against current HEAD. HEAD may have moved during the task.

R10.2 A merge conflict MUST be reported as structured feedback or re-dispatched. It MUST NOT be auto-resolved.

R10.3 A sidecar MUST run git worktree prune at startup, to reclaim worktrees orphaned by a crashed hook.


11. Hazard register

IDs are stable and referenced throughout this document; a retired entry leaves a gap rather than renumbering. Entries marked [V] were empirically verified (§1).

# Hazard Rule
H1 Hook inputs are agent-authored → RCE on the receiving host R5.2 — sandboxed, non-waivable
H2 [V] worktree add fails in pre-receive; GIT_QUARANTINE_PATH is absent from --local-env-vars, so the recommended scrub idiom leaves it set and breaks any descendant git R1.1, R6.4, R6.5
H4 read-tree HEAD + add --all stages mass deletions under sparse-checkout / skip-worktree, and sweeps stray untracked files R6.1
H5 LFS pointers, core.autocrlf, clean/smudge filters, submodules — all corrupt the round-trip silently R6.2 + refuse loudly
H6 Trailers are heuristically extracted (≥25% rule), continue across whitespace-led lines, and are forgeable via commit-tree -F R4.2
H7 The proxy protects the value, not the capability R9.6
H8 refs/captain/* in a live repo: visible to log --all/fsck, no reflog, receive.autogc on, fsmonitor storms R2.1, R2.2
H9 git archive honours agent-controlled export-ignore; | tar -x bypasses verify_path R6.5
H10 DoS: unlimited receive.maxInputSize; hooks with no timeout; Kill signals the pid, not the group R2.2, R5.5
H11 Bare-prefix namespace matching lets a write ab's refs; unguarded deletes/force; shared upload-pack leaks everything R2.3, R3.2, R8.3
H12 A post-receive child inheriting the hook's stdio hangs the push for the agent's lifetime. Also: post-receive does not fire when no ref changed R6.3
H13 Path traversal — stripping quotes and leading slashes does not reject .. R8.4
H14 HEAD moves mid-task; two agents race a task; a crashed hook orphans a worktree R3.2, R10.1, R10.3
H15 A prompt hook resolves a sandbox that may relocate → infinite dispatch recursion; and it is an unbounded-cost LLM call inside a blocking push R5.4
H16 A relay in post-receive has already reported success and cannot reject — the upstream verdict is silently dropped R6.6, R6.7
H17 The agent's git push needs a branch and upstream; without them the no-tooling guarantee is void R3.1
H18 [V] A relative work-tree makes checkout-index write nothing while exiting 0; the hook then verifies an empty tree and passes — a silent false-accept R1.3

12. Conformance

An implementation conforms if it satisfies every MUST above and passes these observable checks.

Baseline

Integrity

  • Materialization of an empty result is detected rather than passing (H18)
  • A snapshot taken under sparse-checkout does not stage deletions (H4)
  • Dispatch aborts, with a diagnostic, on LFS pointers / a required filter / a dirty submodule (H5)
  • A tree containing a .git path component is rejected (H9)
  • Round-trip fidelity holds for renames, staged deletions, symlinks, exec bits and CRLF content

Authorization

  • Agent a cannot write agent ab's namespace (H11)
  • Force-push and delete of a protocol ref are rejected (R3.2)
  • A result parented on anything other than its dispatch is rejected
  • A join token succeeds once and fails on replay (R8.2)
  • A revoked fingerprint is refused (R8.5)
  • .. in the repo path is rejected (H13)

Proxy

  • A placeholder in a non-granted header is rejected, not stripped (R9.2)
  • A placeholder granted for host A, sent to host B, is rejected
  • A non-allowlisted destination is denied
  • An unresolvable credential fails the request rather than forwarding the placeholder (R9.4)
  • The real secret is absent from the sandbox's environment and filesystem, verified by scanning

Robustness

  • A hook exceeding its timeout is killed by process group and yields status: error
  • status: error rejects rather than accepts (R7.5)
  • Feedback exceeding 64 KiB is truncated with a marker and a log pointer (R7.3)
  • Feedback containing CR is normalized before transmission (R7.1)
  • A prompt hook declaring a relocating sandbox is a validation error (R5.4)

13. References

  • githooks(5), git-receive-pack(1), gitnamespaces(7)
  • hack/gitagent_empirical.sh — the §1 verification harness
  • SPEC-sandbox-adapters.md — the captain implementation seam

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions