You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
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.
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.4dispatch/<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.
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.
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.4prompt 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
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).
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.
Push dispatch/<n> + control/<n> atomically, envelope in push options.
Sidecar post-receive — git 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.4worktree 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).
The agent runs git commit and git push. The push blocks for everything below.
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.
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).
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.
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.
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.
status ∈ accepted | 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.5error 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
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 commitandgit pushand 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
objects/info/alternates. All protocol refs live here.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-varswill not save youDuring
pre-receive, receive-pack setsGIT_QUARANTINE_PATH,GIT_OBJECT_DIRECTORY,GIT_ALTERNATE_OBJECT_DIRECTORIES, andGIT_DIR=.. These are inherited by every descendant process.Verified: a grandchild
git update-refrun in an unrelated repository fails withfatal: not a git repository: '.'(rc=128). Scrubbing the variables makes the identical command succeed.Verified:
git rev-parse --local-env-varsreturnsGIT_QUARANTINE_PATHis not in that list. The scrubbing idiomgithooks(5)itself recommends therefore leaves it set. Implementations MUST scrub it by name.1.2 Push options survive
Verified: with
receive.advertisePushOptions=true,--push-option=captain-envelope-v1 --push-option=attempt=2arrives inpre-receiveasGIT_PUSH_OPTION_COUNT=2,GIT_PUSH_OPTION_0,GIT_PUSH_OPTION_1, byte-identical.The default for
receive.advertisePushOptionsis false, and a push with options to a receiver that has not advertised them fails outright.1.3 Materializing a quarantined tree is safe — but silently no-ops on a relative path
Verified:
read-tree <oid>followed bycheckout-index -a -freads quarantined objects and writes the full tree including nested paths.Verified, and the trap: because
GIT_DIRis., any path derived from it is relative to the receiver's cwd. A relativeGIT_WORK_TREEresolves against the wrong base, andcheckout-indexthen 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:
GIT_WORK_TREE=<abs>+checkout-index -a -fgit -c core.bare=false --work-tree=<abs> checkout-index -a -fcd <abs> && git --git-dir=<abs-repo> ... --prefix=<abs>/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:That error comes from the upstream's receive-pack, which inherited
GIT_QUARANTINE_PATHfrom the outbound push and refused to update its own refs. It is not evidence that the objects are unreadable.Verified: unsetting
GIT_QUARANTINE_PATHalone is sufficient. The objects remain readable through the inheritedGIT_OBJECT_DIRECTORY/GIT_ALTERNATE_OBJECT_DIRECTORIES, and the relay succeeds.2. Topology
The mailbox exists because
refs/captain/*in a live repo is not inert: it is visible togit log --all,fsckand gitk;core.logAllRefUpdatesdoes not cover it so there is no reflog;receive.autogcdefaults on and would fire gc inside a repo with a live worktree; and fsmonitor/IDE indexers storm on every ref write.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
A plain branch. The sidecar creates it at dispatch and configures the worktree's
branch.<name>.remote/.merge, so a baregit pushresolves without arguments.3.2 Sidecar ↔ supervisor mailbox
<task-id>MUST match^[a-z0-9-]{1,64}$.<n>is the attempt, a positive decimal integer with no leading zeros.4. Control envelope
Carried in push options (§1.2). Option 0 is the version tag; the remainder are
key=value.v1for this document.taskattemptbaseHEADOID at dispatch, for three-way integration (H14).depth0at top level (H15).agentrelaysync|async(§6.4).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.
execgavel test,make lint, anything else.commitfixtureprompt{ok, reason, feedback}.6. Sequences
6.1 Dispatch — supervisor → agent
Snapshot. Stage the supervisor's dirty worktree — tracked modifications, staged content, and untracked files subject to policy — then
write-treeandcommit-tree -p <base>.Refuse loudly. Abort dispatch with a diagnostic — never degrade — if
git lfs ls-filesis non-empty, arequiredclean/smudge filter is declared, a submodule is dirty, or the snapshot exceeds policy caps. Each round-trips incorrectly and silently (H5).Push
dispatch/<n>+control/<n>atomically, envelope in push options.Sidecar
pre-receive— admission only: agent identity → namespace, ref shape, caps.Sidecar
post-receive—git worktree addoncaptain/<task-id>, set its upstream (R3.1), materializetask.jsonoutside the worktree, launch the agent.6.2 Submit — agent → sidecar → supervisor, one blocking push
The agent runs
git commitandgit push. The push blocks for everything below.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.fsckObjectscatches.git-component trees and malicious.gitmodules.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.Relay. Still inside the same
pre-receive, pushresult/<n>+control/<n>to the mailbox and stream the supervisor's sideband back out through the sidecar's own stderr.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).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 pusha complete answer. Aprompthook can take minutes.6.5 Resumption
A rejected push leaves no verdict ref, because quarantine discarded the objects.
7. Feedback wire format
Everything reaches the agent through the sideband, prefixed
remote:by the client.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"} ] }status∈accepted|rejected|error.errormeans the tier could not reach a verdict — a hook timed out, the sandbox failed to start, the relay could not connect.8. Identity and authorization
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.
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.comstill permitsPOST /gists, which is a working exfiltration channel with a legitimate-looking destination (H7).10. Integration
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).
worktree addfails inpre-receive;GIT_QUARANTINE_PATHis absent from--local-env-vars, so the recommended scrub idiom leaves it set and breaks any descendant gitread-tree HEAD+add --allstages mass deletions under sparse-checkout /skip-worktree, and sweeps stray untracked filescore.autocrlf, clean/smudge filters, submodules — all corrupt the round-trip silentlycommit-tree -Frefs/captain/*in a live repo: visible tolog --all/fsck, no reflog,receive.autogcon, fsmonitor stormsgit archivehonours agent-controlledexport-ignore;| tar -xbypassesverify_pathreceive.maxInputSize; hooks with no timeout;Killsignals the pid, not the groupawriteab's refs; unguarded deletes/force; shared upload-pack leaks everythingpost-receivechild inheriting the hook's stdio hangs the push for the agent's lifetime. Also:post-receivedoes not fire when no ref changed..HEADmoves mid-task; two agents race a task; a crashed hook orphans a worktreeprompthook resolves a sandbox that may relocate → infinite dispatch recursion; and it is an unbounded-cost LLM call inside a blocking pushpost-receivehas already reported success and cannot reject — the upstream verdict is silently droppedgit pushneeds a branch and upstream; without them the no-tooling guarantee is voidcheckout-indexwrite nothing while exiting 0; the hook then verifies an empty tree and passes — a silent false-accept12. Conformance
An implementation conforms if it satisfies every MUST above and passes these observable checks.
Baseline
git commit+ baregit pushfrom a vanilla worktree, with no protocol tooling installed on the agent, completes a full cycle (H17)verdict/<n>and integrates against the envelope'sbaseIntegrity
requiredfilter / a dirty submodule (H5).gitpath component is rejected (H9)Authorization
acannot write agentab's namespace (H11)..in the repo path is rejected (H13)Proxy
Robustness
status: errorstatus: errorrejects rather than accepts (R7.5)prompthook 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