Expand description
The subprocess binding’s encoding: one JSON object in, one JSON object out.
What crosses the pipe, and nothing else. The types being encoded live in
contract and are shared with the in-process binding —
this module is how they reach a program that is not compiled against basis
(ADR-0012: one contract, transports are adapters).
Versioned the way crate::event versions its stream, and for the same
reason: a hook is written once against a shape and must be able to tell when
that shape has moved. Every request carries HOOK_SCHEMA_VERSION as
hook_schema, so the first thing a hook can check is whether it still
understands basis. An Interceptor needs no such check —
it is compiled against this crate and cannot skew — which is the one place
the two bindings genuinely differ.
§Request (basis → hook, on stdin)
{
"hook_schema": 1,
"event": "pre_tool_use",
"workspace": "/repo",
"agent_id": "agent-1",
"tool_call_id": "call-1",
"tool_name": "shell",
"input": {"command": "git push --force"}
}That is HookRequest serialized. Field names match
Event::ToolQueued so a hook and a
stream consumer describe a tool call the same way. input is the parsed
tool input when it is valid JSON, and the raw string when it is not — the
same rule the event stream follows.
A hook declared "event": "post_tool_use" is sent the same object with the
result on it:
{
"hook_schema": 1,
"event": "post_tool_use",
"workspace": "/repo",
"agent_id": "agent-1",
"tool_call_id": "call-1",
"tool_name": "spawn",
"input": {"command": "cat .env"},
"output": "AWS_SECRET_ACCESS_KEY=…",
"is_error": false
}One envelope rather than two, so a hook that already reads input reads
output beside it. input there is the input the tool ran with, after
any modify — what happened rather than what was asked for. output is
the result as the runtime typed it: a structured result as itself, a text
result as a JSON string. Both it and is_error are absent before the call,
not null, because a call that has not run has no output to be null about.
§Response (hook → basis, on stdout)
{"decision": "allow"}
{"decision": "deny", "reason": "force-push is not allowed in this workspace"}
{"decision": "modify", "input": {"command": "git push"}, "reason": "dropped --force"}
{"decision": "replace", "output": "[redacted]", "is_error": false, "reason": "a token"}allow and deny mean the same thing at either event, and the other two
belong to one each: modify rewrites an input that has not been used yet,
replace rewrites a result that has. A hook that answers with the wrong
one for the event it was asked at has not answered, and takes the failure
path below — quietly reinterpreting it would give a guard powers it did not
ask for.
What allow and deny do is where the two events part. Before the call,
deny stops it. After it, nothing can be stopped — the tool has run and
the event stream already carries what it returned — so deny shows the
model the reason in place of the output, marked as an error, and allow
lets the output through unchanged.
stdout is the decision; the exit code is only a liveness signal. Two channels answering one question invites them to disagree, so there is one authority: a hook that exits non-zero has failed regardless of what it printed, and a hook that exits zero has decided whatever it printed.
Silence is not an answer. Empty stdout is treated as a failure rather than
as consent, because a hook that crashed before printing looks exactly like
one that meant to say nothing. Saying yes costs a hook author one echo.
§Rewriting a call, and rewriting a result
modify replaces the tool’s input, for the cases a veto answers badly:
redacting a secret out of an argument, pinning a ref, narrowing an
over-broad command. Denying those costs a round trip and often does not
converge, because the model is told “no” without being told what would have
been acceptable.
The rules a modification obeys are the chain’s, not this transport’s, and
they are written down once, on HookRunner — they hold
identically for an interceptor, which is the point of there being one
contract.
replace is the same move on the other side of the call, for the question
that only the output can answer: a command that succeeded and printed a
credential, a result worth annotating rather than hiding. It carries
is_error because a rewritten result is still a claim about whether the
call worked, and omitting it leaves that claim as the tool made it.
reason is for the audit trail; it does not reach the model, because the
model is not being told “no” — it is simply running with different input,
or reading a different result.
Neither is a way to get past a participant that speaks later: a hook that runs after a rewrite sees the rewrite, and can still refuse it.
Re-exports§
pub use super::contract::HookCall;pub use super::contract::HookRequest;
Enums§
- Hook
Response - What a hook answered.
Constants§
- HOOK_
SCHEMA_ VERSION - Version of the hook wire format. Bumped when a change would break a hook that reads the current shape.