agent-control-plane-core 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -192,9 +192,48 @@ A consumer that cannot import these modules (a bash hook wrapper, a Python
192
192
  monitor) reads `schema/control-plane/v1/agents.json` instead. It is generated
193
193
  from the adapters, with one row per agent: its block exit code and reason
194
194
  stream, whether a block needs a reason, whether it has an ask tier, the entry's
195
- default response when the hook breaks, its hook coverage, and the verdict
196
- fields an allow cannot carry. Read the file from the installed package, so it
197
- always matches the adapters that version ships.
195
+ default response when the hook breaks, its hook coverage, the verdict
196
+ fields an allow cannot carry on each kind's main native event (Codex's
197
+ `PermissionRequest`, which also parses as `pre_tool`, carries none), and two
198
+ pre-tool facts:
199
+
200
+ - `pre_tool_response`: the keys the host reads on an exit-0 pre-tool answer,
201
+ as two sorted lists: `top_level_keys` for the body's top level and
202
+ `hook_specific_output_keys` for `hookSpecificOutput`. Each has a bit saying
203
+ what any other key there does, `unread_top_level_key_fails_open` and
204
+ `unread_key_fails_open`: `true` means the host discards the whole body and
205
+ runs the call (Codex, at both levels), `false` means the key is ignored,
206
+ `null` means unknown. The conformance harness fails an adapter whose pre-tool
207
+ render emits a key outside either list.
208
+ - `pre_tool_filing`: how the pre-tool hook files a model's tool call, so a
209
+ monitor can match the model's calls against the hook's. It is keyed by model
210
+ tool name: the call's `name`, prefixed with its `namespace` with no
211
+ separator, unless the namespace is empty or Codex's default `functions`
212
+ (`flatToolName` in `/contract`). Each value is one of:
213
+ - `null`: no pre-tool hook fires for this call.
214
+ - `{"tool_name": T, "input": I}`: the hook files `tool_name` T, and builds
215
+ `tool_input` from I. I is either `"$"`, meaning the call's whole arguments
216
+ value, or an object whose values are each `"$"` or `"$.<key>"`, meaning
217
+ that top-level argument. `<key>` is one literal key, non-empty and with no
218
+ `.`. If a `"$.<key>"` does not resolve (the arguments are not an object,
219
+ lack the key, or hold `null` there), the hook files nothing.
220
+
221
+ A function call the map does not list is filed with the model's own name and
222
+ arguments. A freeform (custom) call the map does not list files nothing; of
223
+ the shipped hosts only Codex makes them. The map describes calls the host
224
+ dispatches: Codex files nothing for a call it rejects first, e.g. a name it
225
+ has no tool for, a call kind the tool does not take, or arguments a handler
226
+ refuses before its hook (an `apply_patch` patch that does not parse, an
227
+ `exec_command` with a non-string `workdir`). On Codex the arguments value is a function call's
228
+ `arguments` parsed as JSON (an empty or all-whitespace string reads as `{}`,
229
+ unparseable text stays the raw string), or a freeform call's raw `input`
230
+ string. `fileModelCall` in `/contract` is the reference implementation; it
231
+ takes the call's kind, `"function"` or `"freeform"`. The map does not model
232
+ Codex's MCP names: Codex files an MCP call as `mcp__<namespace>__<tool>`,
233
+ which can differ from the flat name.
234
+
235
+ Read the file from the installed package, so it always matches the adapters
236
+ that version ships.
198
237
 
199
238
  Each entry fails the way its host expects when the hook itself breaks: claude,
200
239
  codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -37,6 +37,27 @@
37
37
  "mutated_input",
38
38
  "mutated_output"
39
39
  ]
40
+ },
41
+ "pre_tool_filing": {},
42
+ "pre_tool_response": {
43
+ "hook_specific_output_keys": [
44
+ "additionalContext",
45
+ "hookEventName",
46
+ "permissionDecision",
47
+ "permissionDecisionReason",
48
+ "updatedInput"
49
+ ],
50
+ "unread_key_fails_open": null,
51
+ "top_level_keys": [
52
+ "continue",
53
+ "decision",
54
+ "hookSpecificOutput",
55
+ "reason",
56
+ "stopReason",
57
+ "suppressOutput",
58
+ "systemMessage"
59
+ ],
60
+ "unread_top_level_key_fails_open": null
40
61
  }
41
62
  },
42
63
  "codex": {
@@ -59,7 +80,6 @@
59
80
  },
60
81
  "unrendered_fields_on_allow": {
61
82
  "pre_tool": [
62
- "additional_context",
63
83
  "mutated_output"
64
84
  ],
65
85
  "post_tool": [
@@ -81,6 +101,47 @@
81
101
  "mutated_input",
82
102
  "mutated_output"
83
103
  ]
104
+ },
105
+ "pre_tool_filing": {
106
+ "exec_command": {
107
+ "tool_name": "Bash",
108
+ "input": {
109
+ "command": "$.cmd"
110
+ }
111
+ },
112
+ "apply_patch": {
113
+ "tool_name": "apply_patch",
114
+ "input": {
115
+ "command": "$"
116
+ }
117
+ },
118
+ "multi_agent_v1spawn_agent": {
119
+ "tool_name": "spawn_agent",
120
+ "input": "$"
121
+ },
122
+ "write_stdin": null,
123
+ "wait": null,
124
+ "exec": null
125
+ },
126
+ "pre_tool_response": {
127
+ "hook_specific_output_keys": [
128
+ "additionalContext",
129
+ "hookEventName",
130
+ "permissionDecision",
131
+ "permissionDecisionReason",
132
+ "updatedInput"
133
+ ],
134
+ "unread_key_fails_open": true,
135
+ "top_level_keys": [
136
+ "continue",
137
+ "decision",
138
+ "hookSpecificOutput",
139
+ "reason",
140
+ "stopReason",
141
+ "suppressOutput",
142
+ "systemMessage"
143
+ ],
144
+ "unread_top_level_key_fails_open": true
84
145
  }
85
146
  },
86
147
  "amp": {
@@ -127,6 +188,13 @@
127
188
  "mutated_input",
128
189
  "mutated_output"
129
190
  ]
191
+ },
192
+ "pre_tool_filing": {},
193
+ "pre_tool_response": {
194
+ "hook_specific_output_keys": [],
195
+ "unread_key_fails_open": false,
196
+ "top_level_keys": [],
197
+ "unread_top_level_key_fails_open": false
130
198
  }
131
199
  },
132
200
  "gemini": {
@@ -169,6 +237,23 @@
169
237
  "mutated_input",
170
238
  "mutated_output"
171
239
  ]
240
+ },
241
+ "pre_tool_filing": {},
242
+ "pre_tool_response": {
243
+ "hook_specific_output_keys": [
244
+ "tool_input"
245
+ ],
246
+ "unread_key_fails_open": false,
247
+ "top_level_keys": [
248
+ "continue",
249
+ "decision",
250
+ "hookSpecificOutput",
251
+ "reason",
252
+ "stopReason",
253
+ "suppressOutput",
254
+ "systemMessage"
255
+ ],
256
+ "unread_top_level_key_fails_open": false
172
257
  }
173
258
  }
174
259
  }
@@ -169,6 +169,25 @@ export function parse(native) {
169
169
  */
170
170
  export const NATIVE_ASK_TIER = true;
171
171
 
172
+ /**
173
+ * No rename is modelled: no source shows Amp filing a call under a
174
+ * name or input other than the model's.
175
+ * @type {import("../control-plane.mjs").PreToolFiling}
176
+ */
177
+ export const PRE_TOOL_FILING = Object.freeze({});
178
+
179
+ /**
180
+ * Amp reads only the hook's exit code and no body, so no key in one can change
181
+ * the outcome.
182
+ * @type {import("../control-plane.mjs").PreToolResponse}
183
+ */
184
+ export const PRE_TOOL_RESPONSE = Object.freeze({
185
+ hook_specific_output_keys: Object.freeze([]),
186
+ unread_key_fails_open: false,
187
+ top_level_keys: Object.freeze([]),
188
+ unread_top_level_key_fails_open: false,
189
+ });
190
+
172
191
  /**
173
192
  * The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
174
193
  * pure exit-code transport: 0 allow / 1 ask / 2 reject.
@@ -288,6 +307,8 @@ export const ampAdapter = {
288
307
  UNRENDERED_FIELDS,
289
308
  NATIVE_ASK_TIER,
290
309
  NATIVE_EVENT_FOR,
310
+ PRE_TOOL_FILING,
311
+ PRE_TOOL_RESPONSE,
291
312
  parse,
292
313
  render,
293
314
  };
@@ -125,6 +125,42 @@ export const UNRENDERED_FIELDS = Object.freeze({
125
125
  */
126
126
  export const NATIVE_ASK_TIER = true;
127
127
 
128
+ /**
129
+ * No rename is modelled: no source shows Claude Code filing a call under a
130
+ * name or input other than the model's.
131
+ * @type {import("../control-plane.mjs").PreToolFiling}
132
+ */
133
+ export const PRE_TOOL_FILING = Object.freeze({});
134
+
135
+ /**
136
+ * The top-level and `hookSpecificOutput` keys Claude Code's hooks reference
137
+ * documents for `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether
138
+ * an unread key fails open, at either level, is NOT established: the reference
139
+ * says an exit-0 body that fails schema validation lets the call go ahead, but
140
+ * not whether an extra key fails it, and the host's source is not public.
141
+ * @type {import("../control-plane.mjs").PreToolResponse}
142
+ */
143
+ export const PRE_TOOL_RESPONSE = Object.freeze({
144
+ hook_specific_output_keys: Object.freeze([
145
+ "additionalContext",
146
+ "hookEventName",
147
+ "permissionDecision",
148
+ "permissionDecisionReason",
149
+ "updatedInput",
150
+ ]),
151
+ unread_key_fails_open: null,
152
+ top_level_keys: Object.freeze([
153
+ "continue",
154
+ "decision",
155
+ "hookSpecificOutput",
156
+ "reason",
157
+ "stopReason",
158
+ "suppressOutput",
159
+ "systemMessage",
160
+ ]),
161
+ unread_top_level_key_fails_open: null,
162
+ });
163
+
128
164
  /** Claude Code native hook event names (the `hook_event_name` field). */
129
165
  export const HookEvent = Object.freeze({
130
166
  PRE_TOOL_USE: "PreToolUse",
@@ -339,6 +375,8 @@ export const claudeAdapter = {
339
375
  UNRENDERED_FIELDS,
340
376
  NATIVE_ASK_TIER,
341
377
  NATIVE_EVENT_FOR,
378
+ PRE_TOOL_FILING,
379
+ PRE_TOOL_RESPONSE,
342
380
  parse,
343
381
  render,
344
382
  };
@@ -31,7 +31,6 @@ import {
31
31
  makeEvent,
32
32
  normalizeVerdict,
33
33
  gatedResponse,
34
- blockTransportFor,
35
34
  blockReason,
36
35
  UNRENDERED_ON_UNKNOWN,
37
36
  readonlySet,
@@ -39,6 +38,7 @@ import {
39
38
  asObject,
40
39
  asString,
41
40
  asStringOrNull,
41
+ WHOLE_ARGUMENTS,
42
42
  } from "../control-plane.mjs";
43
43
 
44
44
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
@@ -90,8 +90,9 @@ export const COVERAGE = Object.freeze({
90
90
  });
91
91
 
92
92
  /**
93
- * The event kinds this host can actually gate. `PreToolUse`/`PermissionRequest`
94
- * take a `permissionDecision: "deny"`; `PostToolUse` takes a top-level
93
+ * The event kinds this host can actually gate. `PreToolUse` takes
94
+ * `permissionDecision: "deny"`, `PermissionRequest` `decision.behavior: "deny"`;
95
+ * `PostToolUse` takes a top-level
95
96
  * `decision: "block"` (exit 2 "writes the feedback reason to stderr and blocks
96
97
  * further processing"), so a post-tool deny stops the turn even though the tool
97
98
  * itself already ran — the same shape the Claude adapter gates. Every other
@@ -107,11 +108,15 @@ const GATED_EVENTS = Object.freeze(
107
108
  assertGatedKinds(GATED_EVENTS, AGENT);
108
109
 
109
110
  /**
110
- * Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind, so
111
- * `render` drops them. Codex splits its two content channels by event:
112
- * `updatedInput` exists only on PreToolUse (the call has not run yet), and
113
- * `hookSpecificOutput.additionalContext` only on PostToolUse — Codex documents
114
- * no context field on PreToolUse, where `updatedInput` is the whole surface.
111
+ * Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind's
112
+ * {@link NATIVE_EVENT_FOR} event, so `render` drops them. PreToolUse takes
113
+ * `updatedInput` (applied only beside `permissionDecision: "allow"`) and
114
+ * `additionalContext`; PostToolUse takes `additionalContext` alone ([X8], read
115
+ * at `rust-v0.166.0` and identical at `rust-v0.135.0`). `PermissionRequest`,
116
+ * which also parses as `pre_tool`, takes neither: its schema is
117
+ * `{hookEventName, decision: {behavior, message}}`, and a body carrying
118
+ * `updatedInput` is ignored (Codex's own approval flow decides), so
119
+ * {@link render} drops both there.
115
120
  *
116
121
  * NEITHER kind can rewrite tool output: PostToolUse "can't undo side effects
117
122
  * from a tool that already ran", and Codex rejects `updatedMCPToolOutput`, so
@@ -126,10 +131,84 @@ export const UNRENDERED_FIELDS = Object.freeze({
126
131
  ...Object.fromEntries(
127
132
  Object.values(EventKind).map((kind) => [kind, UNRENDERED_ON_UNKNOWN]),
128
133
  ),
129
- [EventKind.PRE_TOOL]: readonlySet(["mutated_output", "additional_context"]),
134
+ [EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
130
135
  [EventKind.POST_TOOL]: readonlySet(["mutated_input", "mutated_output"]),
131
136
  });
132
137
 
138
+ /**
139
+ * How Codex's `PreToolUse` hook files a model tool call ([X7], read from
140
+ * Codex's source at `rust-v0.166.0`). A function tool is filed under its flat
141
+ * name (namespace + name) with its parsed arguments by default; these
142
+ * handlers supply their own payload or none:
143
+ * - `exec_command` → `Bash` with `{command: <cmd>}`; arguments Codex cannot
144
+ * parse as `ExecCommandArgs` file nothing, which this table models only for
145
+ * a missing or null `cmd` or scalar arguments. Not modelled: other type
146
+ * errors, and a positional array, which serde reads (`["ls"]` is `cmd: "ls"`).
147
+ * - `apply_patch` (a freeform tool) → `apply_patch` with its raw patch text as
148
+ * `command`.
149
+ * - `spawn_agent` in the `multi_agent_v1` namespace → `spawn_agent`.
150
+ * - `write_stdin`, code mode's `wait`, and code mode's freeform `exec` → no hook.
151
+ * These rules assume the named tool owns the name; a client-defined tool
152
+ * called `wait` with code mode off takes the default payload.
153
+ * The default payload is function-only, so a freeform extension tool files
154
+ * nothing too — the mini-language's rule for an unlisted freeform call.
155
+ * NOT MODELLED: Codex files an MCP call as `mcp__<namespace>__<tool>`, with
156
+ * `_` trimmed from the namespace's end and the tool's start (`mcp.rs`
157
+ * `hook_tool_name`), which can differ from the flat name — a rule keyed by
158
+ * one name cannot express that.
159
+ * @type {import("../control-plane.mjs").PreToolFiling}
160
+ */
161
+ export const PRE_TOOL_FILING = Object.freeze({
162
+ exec_command: Object.freeze({
163
+ tool_name: "Bash",
164
+ input: Object.freeze({ command: "$.cmd" }),
165
+ }),
166
+ apply_patch: Object.freeze({
167
+ tool_name: "apply_patch",
168
+ input: Object.freeze({ command: WHOLE_ARGUMENTS }),
169
+ }),
170
+ multi_agent_v1spawn_agent: Object.freeze({
171
+ tool_name: "spawn_agent",
172
+ input: WHOLE_ARGUMENTS,
173
+ }),
174
+ write_stdin: null,
175
+ wait: null,
176
+ exec: null,
177
+ });
178
+
179
+ /**
180
+ * Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes the
181
+ * body into `deny_unknown_fields` structs at both levels: these seven top-level
182
+ * keys (`PreToolUseCommandOutputWire` with its flattened universal fields), and
183
+ * these five in `hookSpecificOutput`. Any other key fails the whole body: the
184
+ * run is marked failed, no block is set, and the call RUNS. A known key can
185
+ * still void the body by its value or combination (`continue: false`, any
186
+ * `stopReason`, `suppressOutput: true`, `decision: "approve"`, `updatedInput`
187
+ * without `permissionDecision: "allow"`), which this declaration does not
188
+ * describe.
189
+ * @type {import("../control-plane.mjs").PreToolResponse}
190
+ */
191
+ export const PRE_TOOL_RESPONSE = Object.freeze({
192
+ hook_specific_output_keys: Object.freeze([
193
+ "additionalContext",
194
+ "hookEventName",
195
+ "permissionDecision",
196
+ "permissionDecisionReason",
197
+ "updatedInput",
198
+ ]),
199
+ unread_key_fails_open: true,
200
+ top_level_keys: Object.freeze([
201
+ "continue",
202
+ "decision",
203
+ "hookSpecificOutput",
204
+ "reason",
205
+ "stopReason",
206
+ "suppressOutput",
207
+ "systemMessage",
208
+ ]),
209
+ unread_top_level_key_fails_open: true,
210
+ });
211
+
133
212
  /** Minimum Codex version whose hook can actually veto a tool call. */
134
213
  export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
135
214
 
@@ -299,15 +378,17 @@ export function parse(native, { version } = {}) {
299
378
  * on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable` false) the
300
379
  * body still renders but exit stays 0 and `enforced` is false — advisory only.
301
380
  *
302
- * The two kinds use DIFFERENT native shapes, because Codex does: a pre-tool
303
- * decision rides `hookSpecificOutput.permissionDecision`, a post-tool one the
304
- * top-level `decision`/`reason` pair (`permissionDecision` is accepted but inert
305
- * on PostToolUse, so emitting it there would read as a block that never bites).
381
+ * Each native event gets its OWN shape, because Codex parses each strictly: a
382
+ * PreToolUse decision rides `hookSpecificOutput.permissionDecision`, a
383
+ * PermissionRequest one `hookSpecificOutput.decision.behavior`, and a
384
+ * post-tool one the top-level `decision`/`reason` pair.
306
385
  *
307
386
  * `soleGate` (default `false`) is the same dangerous, explicit opt-in as the
308
- * Claude adapter: when `true` AND the verdict is `allow`, the render emits the
309
- * real `permissionDecision: "allow"` instead of abstaining. It has no meaning
310
- * post-tool — there is no approval left to grant — so it is ignored there.
387
+ * Claude adapter, and acts only on PermissionRequest: there an `allow` renders
388
+ * `behavior: "allow"`, which grants the approval (except, from 0.166, when the
389
+ * hook is environment-sourced). PreToolUse has no allow that
390
+ * approves anything (Codex accepts `"allow"` only beside `updatedInput`, as a
391
+ * rewrite), and post-tool there is no approval left to grant.
311
392
  * @param {Verdict} verdict
312
393
  * @param {ToolCallEvent} event
313
394
  * @param {{ soleGate?: boolean }} [options]
@@ -333,15 +414,12 @@ export function render(verdict, event, { soleGate = false } = {}) {
333
414
  unblocked_exit_code: 0,
334
415
  enforced,
335
416
  reason: vd.reason,
336
- stdout:
337
- event.event === EventKind.POST_TOOL
338
- ? postToolBody(hookEventName, vd, enforced)
339
- : preToolBody(hookEventName, vd, event, enforced, soleGate),
417
+ stdout: nativeBody(hookEventName, vd, event, enforced, soleGate),
340
418
  });
341
419
  }
342
420
 
343
421
  /**
344
- * The `hookSpecificOutput` body for a pre-tool (or unmodelled) event.
422
+ * The stdout body for `event`, in the schema its native event answers in.
345
423
  * @param {string} hookEventName
346
424
  * @param {Verdict} vd a normalized verdict
347
425
  * @param {ToolCallEvent} event
@@ -349,36 +427,73 @@ export function render(verdict, event, { soleGate = false } = {}) {
349
427
  * @param {boolean} soleGate
350
428
  * @returns {Record<string, unknown>}
351
429
  */
352
- function preToolBody(hookEventName, vd, event, enforced, soleGate) {
353
- // As in the Claude adapter, `allow` omits permissionDecision by default so the
354
- // guardrail never auto-approves a call it merely had no objection to; only
355
- // deny/ask emit an explicit decision, unless `soleGate` opts into it.
430
+ function nativeBody(hookEventName, vd, event, enforced, soleGate) {
431
+ if (event.event === EventKind.POST_TOOL)
432
+ return postToolBody(hookEventName, vd);
433
+ if (hookEventName === HookEvent.PERMISSION_REQUEST)
434
+ return permissionRequestBody(hookEventName, vd, enforced, soleGate);
435
+ return preToolBody(hookEventName, vd, event);
436
+ }
437
+
438
+ /**
439
+ * The body for a PreToolUse (or unmodelled) event. Codex's PreToolUse parser
440
+ * ([X8]) voids the body, and the call runs, on `"allow"` without `updatedInput`
441
+ * and on `updatedInput` without `"allow"` — so an allow emits a decision only
442
+ * to carry a mutation, and a deny never carries one.
443
+ * @param {string} hookEventName
444
+ * @param {Verdict} vd a normalized verdict
445
+ * @param {ToolCallEvent} event
446
+ * @returns {Record<string, unknown>}
447
+ */
448
+ function preToolBody(hookEventName, vd, event) {
356
449
  /** @type {Record<string, unknown>} */
357
450
  const body = { hookEventName };
358
- // An UNKNOWN event reaches this body too. It has no established decision
359
- // channel, so it carries none: a decision there would act on the host while
360
- // the render reports no block.
361
- if (
362
- event.event === EventKind.PRE_TOOL &&
363
- (vd.decision !== Decision.ALLOW || soleGate)
364
- ) {
451
+ // An UNKNOWN event reaches this body too. It has no established channel, so
452
+ // it carries nothing: a decision or `updatedInput` there would act on the
453
+ // host, or read to the caller as applied, while the host ignores it.
454
+ if (event.event !== EventKind.PRE_TOOL) return { hookSpecificOutput: body };
455
+ if (vd.decision !== Decision.ALLOW) {
456
+ // Codex voids a deny body whose reason is missing or empty: no block, no
457
+ // context, and the call runs (`parse_pre_tool_use`, [X8]). That holds on an
458
+ // advisory render too, so every deny carries a reason.
365
459
  body.permissionDecision = vd.decision;
366
- if (vd.reason !== undefined) body.permissionDecisionReason = vd.reason;
367
- }
368
- // Codex FAILS OPEN on an enforced deny whose permissionDecisionReason is missing
369
- // or empty: its PreToolUse output parser drops the block (block_reason = None)
370
- // and runs the tool (BLOCK_TRANSPORT.codex.reason_required).
371
- if (enforced && blockTransportFor(AGENT).reason_required)
372
460
  body.permissionDecisionReason = blockReason(vd.reason);
373
- // PRE_TOOL only. An UNKNOWN event reaches this body too, and an event the
374
- // adapter cannot name has no channel it can claim: emitting `updatedInput`
375
- // there names a key the host ignores while reading to the caller as a
376
- // mutation applied.
377
- if (event.event === EventKind.PRE_TOOL && vd.mutated_input !== undefined)
461
+ } else if (vd.mutated_input !== undefined) {
462
+ body.permissionDecision = Decision.ALLOW;
378
463
  body.updatedInput = vd.mutated_input;
464
+ }
465
+ if (vd.additional_context !== undefined)
466
+ body.additionalContext = vd.additional_context;
379
467
  return { hookSpecificOutput: body };
380
468
  }
381
469
 
470
+ /**
471
+ * The body for a PermissionRequest event: Codex's
472
+ * `PermissionRequestHookSpecificOutputWire` ([X8]), `{hookEventName, decision:
473
+ * {behavior, message}}`. `behavior: "allow"` GRANTS the approval, so an allow
474
+ * abstains unless `soleGate` opts in. Content fields have no channel here:
475
+ * `updatedInput` voids the body and there is no context key.
476
+ * @param {string} hookEventName
477
+ * @param {Verdict} vd a normalized verdict
478
+ * @param {boolean} enforced
479
+ * @param {boolean} soleGate
480
+ * @returns {Record<string, unknown>}
481
+ */
482
+ function permissionRequestBody(hookEventName, vd, enforced, soleGate) {
483
+ /** @type {Record<string, unknown>} */
484
+ const hookSpecificOutput = { hookEventName };
485
+ if (vd.decision !== Decision.ALLOW) {
486
+ const message = enforced ? blockReason(vd.reason) : vd.reason;
487
+ hookSpecificOutput.decision = {
488
+ behavior: Decision.DENY,
489
+ ...(message === undefined ? {} : { message }),
490
+ };
491
+ } else if (soleGate) {
492
+ hookSpecificOutput.decision = { behavior: Decision.ALLOW };
493
+ }
494
+ return { hookSpecificOutput };
495
+ }
496
+
382
497
  /**
383
498
  * The body for a PostToolUse event: Codex's documented post-tool schema, a
384
499
  * top-level `decision`/`reason` beside a `hookSpecificOutput` carrying
@@ -387,27 +502,21 @@ function preToolBody(hookEventName, vd, event, enforced, soleGate) {
387
502
  * objection the host understands there, `block`.
388
503
  * @param {string} hookEventName
389
504
  * @param {Verdict} vd a normalized verdict
390
- * @param {boolean} enforced
391
505
  * @returns {Record<string, unknown>}
392
506
  */
393
- function postToolBody(hookEventName, vd, enforced) {
507
+ function postToolBody(hookEventName, vd) {
394
508
  /** @type {Record<string, unknown>} */
395
509
  const hookSpecificOutput = { hookEventName };
396
510
  if (vd.additional_context !== undefined)
397
511
  hookSpecificOutput.additionalContext = vd.additional_context;
398
512
  /** @type {Record<string, unknown>} */
399
513
  const body = { hookSpecificOutput };
514
+ // `parse_post_tool_use` ([X8]) voids `decision: "block"` with an empty
515
+ // reason, and a sync handler's context with it, advisory render or not.
400
516
  if (vd.decision !== Decision.ALLOW) {
401
517
  body.decision = "block";
402
- if (vd.reason !== undefined) body.reason = vd.reason;
403
- }
404
- // The same guarantee the pre-tool body makes, for the same reason: the
405
- // documented post-tool schema pairs `decision` with a `reason`, and a block
406
- // whose reason Codex may drop is a block that may not bite. Unverified for
407
- // PostToolUse specifically — a non-empty reason is the safe side of that, and
408
- // the model needs the text either way.
409
- if (enforced && blockTransportFor(AGENT).reason_required)
410
518
  body.reason = blockReason(vd.reason);
519
+ }
411
520
  return body;
412
521
  }
413
522
 
@@ -440,6 +549,8 @@ export function createCodexAdapter(options = {}) {
440
549
  UNRENDERED_FIELDS,
441
550
  NATIVE_ASK_TIER,
442
551
  NATIVE_EVENT_FOR,
552
+ PRE_TOOL_FILING,
553
+ PRE_TOOL_RESPONSE,
443
554
  parse: (native) => parse(native, { version }),
444
555
  render,
445
556
  };
@@ -357,6 +357,40 @@ export function parse(native) {
357
357
  */
358
358
  export const NATIVE_ASK_TIER = false;
359
359
 
360
+ /**
361
+ * No rename is modelled. Gemini CLI hands `BeforeTool` the invocation's tool
362
+ * name beside an `originalRequestName`, so an aliased call may be filed under
363
+ * a name the model did not use; that is unverified, so not published.
364
+ * @type {import("../control-plane.mjs").PreToolFiling}
365
+ */
366
+ export const PRE_TOOL_FILING = Object.freeze({});
367
+
368
+ /**
369
+ * Gemini CLI keeps `hookSpecificOutput` as a `Record<string, unknown>` and its
370
+ * `BeforeTool` path reads only `tool_input` from it, by `in` lookup, so any
371
+ * other key is ignored (`packages/core/src/hooks/types.ts`
372
+ * `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
373
+ * and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
374
+ * `62bab5b2`). The top level is the `HookOutput` interface's seven keys, read
375
+ * from an unchecked `JSON.parse` cast (`hooks/hookRunner.ts`), so an unknown
376
+ * top-level key is ignored too.
377
+ * @type {import("../control-plane.mjs").PreToolResponse}
378
+ */
379
+ export const PRE_TOOL_RESPONSE = Object.freeze({
380
+ hook_specific_output_keys: Object.freeze(["tool_input"]),
381
+ unread_key_fails_open: false,
382
+ top_level_keys: Object.freeze([
383
+ "continue",
384
+ "decision",
385
+ "hookSpecificOutput",
386
+ "reason",
387
+ "stopReason",
388
+ "suppressOutput",
389
+ "systemMessage",
390
+ ]),
391
+ unread_top_level_key_fails_open: false,
392
+ });
393
+
360
394
  /**
361
395
  * Render into Gemini CLI's native external-hook transport. An enforceable deny
362
396
  * renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
@@ -477,6 +511,8 @@ export const geminiAdapter = {
477
511
  UNRENDERED_FIELDS,
478
512
  NATIVE_ASK_TIER,
479
513
  NATIVE_EVENT_FOR,
514
+ PRE_TOOL_FILING,
515
+ PRE_TOOL_RESPONSE,
480
516
  parse,
481
517
  render,
482
518
  };
@@ -30,7 +30,13 @@ import { HOST_FAILURE } from "./runtime.mjs";
30
30
  * `ACP_FAIL_CLOSED=1` replaces it with `block`.
31
31
  * - `coverage`: `COVERAGE`, per call class.
32
32
  * - `unrendered_fields_on_allow`: `UNRENDERED_FIELDS`, each set as a sorted
33
- * list. It describes the allow path only: a deny can drop more.
33
+ * list. It describes the allow path on each kind's `NATIVE_EVENT_FOR` event
34
+ * only: a deny, or another native event of that kind, can drop more.
35
+ * - `pre_tool_filing`: `PRE_TOOL_FILING`, how the pre-tool hook files a model
36
+ * tool call, in the mini-language the `PreToolFiling` typedef specifies.
37
+ * - `pre_tool_response`: `PRE_TOOL_RESPONSE`, the top-level and
38
+ * `hookSpecificOutput` keys the host reads on an exit-0 pre-tool answer, each
39
+ * list with its fail-open bit, as the `PreToolResponse` typedef specifies.
34
40
  * @param {string} id
35
41
  * @returns {Record<string, unknown>}
36
42
  */
@@ -55,6 +61,8 @@ function manifestRow(id) {
55
61
  ].sort(),
56
62
  ]),
57
63
  ),
64
+ pre_tool_filing: adapter.PRE_TOOL_FILING,
65
+ pre_tool_response: adapter.PRE_TOOL_RESPONSE,
58
66
  };
59
67
  }
60
68