1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
//! 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`](super::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`](super::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)
//!
//! ```json
//! {
//! "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`](crate::event::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:
//!
//! ```json
//! {
//! "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)
//!
//! ```json
//! {"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`](super::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.
use ;
use Value;
/// The types a subprocess hook is handed, re-exported at the path a hook
/// author's code already names them by. They belong to the contract both
/// bindings speak; this module only encodes them.
pub use ;
/// Version of the hook wire format. Bumped when a change would break a hook
/// that reads the current shape.
///
/// `post_tool_use` did not bump it, and the test is not "did anything change"
/// but "can a hook written against the old shape still be right". A hook is
/// told which event it is being asked at, and one that never declared
/// `post_tool_use` is never asked at it: its requests are byte-identical, and
/// its answers mean exactly what they did. The new fields are absent from
/// them, and the new decision is one nobody has to send.
///
/// The version is also the `schema` in `.basis/hooks.json`, which is checked
/// for equality ([`HookConfigError::UnsupportedSchema`](super::HookConfigError::UnsupportedSchema)) —
/// so a bump refuses every hooks file in existence. That is the right answer
/// when the meaning of a field has moved and the wrong one when nothing a hook
/// relies on has.
pub const HOOK_SCHEMA_VERSION: u32 = 1;
/// What a hook answered.
///
/// The wire spelling of [`HookOutcome`](super::HookOutcome), which is what an
/// in-process [`Interceptor`](super::Interceptor) returns directly. Two shapes
/// for one vocabulary, because JSON has no enums and a shell script has no
/// `serde_json::Value` — everything past the parse is shared.