Skip to main content

Module wire

Module wire 

Source
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§

HookResponse
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.