agent-control-plane-core 0.13.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
@@ -193,15 +193,18 @@ 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
195
  default response when the hook breaks, its hook coverage, the verdict
196
- fields an allow cannot carry, and two pre-tool facts:
197
-
198
- - `pre_tool_response`: `hook_specific_output_keys` lists the
199
- `hookSpecificOutput` keys the host reads on an exit-0 pre-tool answer.
200
- `unread_key_fails_open` says what any other key does there: `true` means the
201
- host discards the whole body and runs the call (Codex), `false` means the key
202
- is ignored, `null` means unknown. The conformance harness fails an adapter
203
- whose pre-tool render emits a key outside the list. Neither field covers the
204
- body's top-level keys, which Codex also parses strictly.
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.
205
208
  - `pre_tool_filing`: how the pre-tool hook files a model's tool call, so a
206
209
  monitor can match the model's calls against the hook's. It is keyed by model
207
210
  tool name: the call's `name`, prefixed with its `namespace` with no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.13.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": {
@@ -47,7 +47,17 @@
47
47
  "permissionDecisionReason",
48
48
  "updatedInput"
49
49
  ],
50
- "unread_key_fails_open": null
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
51
61
  }
52
62
  },
53
63
  "codex": {
@@ -70,7 +80,6 @@
70
80
  },
71
81
  "unrendered_fields_on_allow": {
72
82
  "pre_tool": [
73
- "additional_context",
74
83
  "mutated_output"
75
84
  ],
76
85
  "post_tool": [
@@ -122,7 +131,17 @@
122
131
  "permissionDecisionReason",
123
132
  "updatedInput"
124
133
  ],
125
- "unread_key_fails_open": true
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
126
145
  }
127
146
  },
128
147
  "amp": {
@@ -173,7 +192,9 @@
173
192
  "pre_tool_filing": {},
174
193
  "pre_tool_response": {
175
194
  "hook_specific_output_keys": [],
176
- "unread_key_fails_open": false
195
+ "unread_key_fails_open": false,
196
+ "top_level_keys": [],
197
+ "unread_top_level_key_fails_open": false
177
198
  }
178
199
  },
179
200
  "gemini": {
@@ -222,7 +243,17 @@
222
243
  "hook_specific_output_keys": [
223
244
  "tool_input"
224
245
  ],
225
- "unread_key_fails_open": false
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
226
257
  }
227
258
  }
228
259
  }
@@ -184,6 +184,8 @@ export const PRE_TOOL_FILING = Object.freeze({});
184
184
  export const PRE_TOOL_RESPONSE = Object.freeze({
185
185
  hook_specific_output_keys: Object.freeze([]),
186
186
  unread_key_fails_open: false,
187
+ top_level_keys: Object.freeze([]),
188
+ unread_top_level_key_fails_open: false,
187
189
  });
188
190
 
189
191
  /**
@@ -133,11 +133,11 @@ export const NATIVE_ASK_TIER = true;
133
133
  export const PRE_TOOL_FILING = Object.freeze({});
134
134
 
135
135
  /**
136
- * The `hookSpecificOutput` keys Claude Code's hooks reference documents for
137
- * `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether an unread key
138
- * fails open is NOT established: the reference says an exit-0 body that fails
139
- * schema validation lets the call go ahead, but not whether an extra key fails
140
- * it, and the host's source is not public.
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
141
  * @type {import("../control-plane.mjs").PreToolResponse}
142
142
  */
143
143
  export const PRE_TOOL_RESPONSE = Object.freeze({
@@ -149,6 +149,16 @@ export const PRE_TOOL_RESPONSE = Object.freeze({
149
149
  "updatedInput",
150
150
  ]),
151
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,
152
162
  });
153
163
 
154
164
  /** Claude Code native hook event names (the `hook_event_name` field). */
@@ -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,
@@ -91,8 +90,9 @@ export const COVERAGE = Object.freeze({
91
90
  });
92
91
 
93
92
  /**
94
- * The event kinds this host can actually gate. `PreToolUse`/`PermissionRequest`
95
- * 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
96
96
  * `decision: "block"` (exit 2 "writes the feedback reason to stderr and blocks
97
97
  * further processing"), so a post-tool deny stops the turn even though the tool
98
98
  * itself already ran — the same shape the Claude adapter gates. Every other
@@ -108,11 +108,15 @@ const GATED_EVENTS = Object.freeze(
108
108
  assertGatedKinds(GATED_EVENTS, AGENT);
109
109
 
110
110
  /**
111
- * Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind, so
112
- * `render` drops them. Codex splits its two content channels by event:
113
- * `updatedInput` exists only on PreToolUse (the call has not run yet), and
114
- * `hookSpecificOutput.additionalContext` only on PostToolUse — Codex documents
115
- * 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.
116
120
  *
117
121
  * NEITHER kind can rewrite tool output: PostToolUse "can't undo side effects
118
122
  * from a tool that already ran", and Codex rejects `updatedMCPToolOutput`, so
@@ -127,7 +131,7 @@ export const UNRENDERED_FIELDS = Object.freeze({
127
131
  ...Object.fromEntries(
128
132
  Object.values(EventKind).map((kind) => [kind, UNRENDERED_ON_UNKNOWN]),
129
133
  ),
130
- [EventKind.PRE_TOOL]: readonlySet(["mutated_output", "additional_context"]),
134
+ [EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
131
135
  [EventKind.POST_TOOL]: readonlySet(["mutated_input", "mutated_output"]),
132
136
  });
133
137
 
@@ -173,12 +177,15 @@ export const PRE_TOOL_FILING = Object.freeze({
173
177
  });
174
178
 
175
179
  /**
176
- * Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes
177
- * `hookSpecificOutput` into a `deny_unknown_fields` struct of exactly these
178
- * five keys. Any other key fails the whole body: the run is marked failed, no
179
- * block is set, and the call RUNS. A known key can still void the body by its
180
- * combination (`updatedInput` without `permissionDecision: "allow"`), which
181
- * this declaration does not describe.
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.
182
189
  * @type {import("../control-plane.mjs").PreToolResponse}
183
190
  */
184
191
  export const PRE_TOOL_RESPONSE = Object.freeze({
@@ -190,6 +197,16 @@ export const PRE_TOOL_RESPONSE = Object.freeze({
190
197
  "updatedInput",
191
198
  ]),
192
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,
193
210
  });
194
211
 
195
212
  /** Minimum Codex version whose hook can actually veto a tool call. */
@@ -361,15 +378,17 @@ export function parse(native, { version } = {}) {
361
378
  * on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable` false) the
362
379
  * body still renders but exit stays 0 and `enforced` is false — advisory only.
363
380
  *
364
- * The two kinds use DIFFERENT native shapes, because Codex does: a pre-tool
365
- * decision rides `hookSpecificOutput.permissionDecision`, a post-tool one the
366
- * top-level `decision`/`reason` pair (`permissionDecision` is accepted but inert
367
- * 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.
368
385
  *
369
386
  * `soleGate` (default `false`) is the same dangerous, explicit opt-in as the
370
- * Claude adapter: when `true` AND the verdict is `allow`, the render emits the
371
- * real `permissionDecision: "allow"` instead of abstaining. It has no meaning
372
- * 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.
373
392
  * @param {Verdict} verdict
374
393
  * @param {ToolCallEvent} event
375
394
  * @param {{ soleGate?: boolean }} [options]
@@ -395,15 +414,12 @@ export function render(verdict, event, { soleGate = false } = {}) {
395
414
  unblocked_exit_code: 0,
396
415
  enforced,
397
416
  reason: vd.reason,
398
- stdout:
399
- event.event === EventKind.POST_TOOL
400
- ? postToolBody(hookEventName, vd, enforced)
401
- : preToolBody(hookEventName, vd, event, enforced, soleGate),
417
+ stdout: nativeBody(hookEventName, vd, event, enforced, soleGate),
402
418
  });
403
419
  }
404
420
 
405
421
  /**
406
- * The `hookSpecificOutput` body for a pre-tool (or unmodelled) event.
422
+ * The stdout body for `event`, in the schema its native event answers in.
407
423
  * @param {string} hookEventName
408
424
  * @param {Verdict} vd a normalized verdict
409
425
  * @param {ToolCallEvent} event
@@ -411,36 +427,73 @@ export function render(verdict, event, { soleGate = false } = {}) {
411
427
  * @param {boolean} soleGate
412
428
  * @returns {Record<string, unknown>}
413
429
  */
414
- function preToolBody(hookEventName, vd, event, enforced, soleGate) {
415
- // As in the Claude adapter, `allow` omits permissionDecision by default so the
416
- // guardrail never auto-approves a call it merely had no objection to; only
417
- // 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) {
418
449
  /** @type {Record<string, unknown>} */
419
450
  const body = { hookEventName };
420
- // An UNKNOWN event reaches this body too. It has no established decision
421
- // channel, so it carries none: a decision there would act on the host while
422
- // the render reports no block.
423
- if (
424
- event.event === EventKind.PRE_TOOL &&
425
- (vd.decision !== Decision.ALLOW || soleGate)
426
- ) {
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.
427
459
  body.permissionDecision = vd.decision;
428
- if (vd.reason !== undefined) body.permissionDecisionReason = vd.reason;
429
- }
430
- // Codex FAILS OPEN on an enforced deny whose permissionDecisionReason is missing
431
- // or empty: its PreToolUse output parser drops the block (block_reason = None)
432
- // and runs the tool (BLOCK_TRANSPORT.codex.reason_required).
433
- if (enforced && blockTransportFor(AGENT).reason_required)
434
460
  body.permissionDecisionReason = blockReason(vd.reason);
435
- // PRE_TOOL only. An UNKNOWN event reaches this body too, and an event the
436
- // adapter cannot name has no channel it can claim: emitting `updatedInput`
437
- // there names a key the host ignores while reading to the caller as a
438
- // mutation applied.
439
- if (event.event === EventKind.PRE_TOOL && vd.mutated_input !== undefined)
461
+ } else if (vd.mutated_input !== undefined) {
462
+ body.permissionDecision = Decision.ALLOW;
440
463
  body.updatedInput = vd.mutated_input;
464
+ }
465
+ if (vd.additional_context !== undefined)
466
+ body.additionalContext = vd.additional_context;
441
467
  return { hookSpecificOutput: body };
442
468
  }
443
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
+
444
497
  /**
445
498
  * The body for a PostToolUse event: Codex's documented post-tool schema, a
446
499
  * top-level `decision`/`reason` beside a `hookSpecificOutput` carrying
@@ -449,27 +502,21 @@ function preToolBody(hookEventName, vd, event, enforced, soleGate) {
449
502
  * objection the host understands there, `block`.
450
503
  * @param {string} hookEventName
451
504
  * @param {Verdict} vd a normalized verdict
452
- * @param {boolean} enforced
453
505
  * @returns {Record<string, unknown>}
454
506
  */
455
- function postToolBody(hookEventName, vd, enforced) {
507
+ function postToolBody(hookEventName, vd) {
456
508
  /** @type {Record<string, unknown>} */
457
509
  const hookSpecificOutput = { hookEventName };
458
510
  if (vd.additional_context !== undefined)
459
511
  hookSpecificOutput.additionalContext = vd.additional_context;
460
512
  /** @type {Record<string, unknown>} */
461
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.
462
516
  if (vd.decision !== Decision.ALLOW) {
463
517
  body.decision = "block";
464
- if (vd.reason !== undefined) body.reason = vd.reason;
465
- }
466
- // The same guarantee the pre-tool body makes, for the same reason: the
467
- // documented post-tool schema pairs `decision` with a `reason`, and a block
468
- // whose reason Codex may drop is a block that may not bite. Unverified for
469
- // PostToolUse specifically — a non-empty reason is the safe side of that, and
470
- // the model needs the text either way.
471
- if (enforced && blockTransportFor(AGENT).reason_required)
472
518
  body.reason = blockReason(vd.reason);
519
+ }
473
520
  return body;
474
521
  }
475
522
 
@@ -371,12 +371,24 @@ export const PRE_TOOL_FILING = Object.freeze({});
371
371
  * other key is ignored (`packages/core/src/hooks/types.ts`
372
372
  * `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
373
373
  * and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
374
- * `62bab5b2`).
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.
375
377
  * @type {import("../control-plane.mjs").PreToolResponse}
376
378
  */
377
379
  export const PRE_TOOL_RESPONSE = Object.freeze({
378
380
  hook_specific_output_keys: Object.freeze(["tool_input"]),
379
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,
380
392
  });
381
393
 
382
394
  /**
@@ -30,12 +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.
34
35
  * - `pre_tool_filing`: `PRE_TOOL_FILING`, how the pre-tool hook files a model
35
36
  * tool call, in the mini-language the `PreToolFiling` typedef specifies.
36
- * - `pre_tool_response`: `PRE_TOOL_RESPONSE`, the `hookSpecificOutput` keys the
37
- * host reads on an exit-0 pre-tool answer, and `unread_key_fails_open`
38
- * (`true`: any other key voids the body and the call runs; `null`: unknown).
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.
39
40
  * @param {string} id
40
41
  * @returns {Record<string, unknown>}
41
42
  */
@@ -407,14 +407,20 @@ function assertRowReads(agent, kind, row, assert) {
407
407
  * @param {Set<string>} seen fields observed to REACH a wire
408
408
  */
409
409
  function assertEveryKindRenders(adapter, byKind, assert, seen) {
410
- for (const kind of Object.values(EventKind))
410
+ for (const kind of Object.values(EventKind)) {
411
+ // A parsed event under another native name is not the event the row
412
+ // describes, so the probe synthesizes the native one instead.
413
+ const parsed = lookup(byKind, kind);
411
414
  assertContentChannels(
412
415
  adapter,
413
- lookup(byKind, kind) ?? coherentEvent(adapter, byKind, kind),
416
+ parsed !== undefined && onKindsNativeEvent(adapter, parsed)
417
+ ? parsed
418
+ : coherentEvent(adapter, byKind, kind),
414
419
  `every-kind probe '${kind}'`,
415
420
  assert,
416
421
  seen,
417
422
  );
423
+ }
418
424
  }
419
425
 
420
426
  /**
@@ -578,6 +584,10 @@ function assertAskTier(adapter, event, vetoable, caseName, assert) {
578
584
  * (kind, decision) would state that, and is not worth its weight while the only
579
585
  * field a blocked call can still want is `additional_context`.
580
586
  *
587
+ * SCOPE — a row describes its kind's `NATIVE_EVENT_FOR` event, so an event of
588
+ * that kind under another native name (Codex's `PermissionRequest`, which
589
+ * parses as `pre_tool` and carries no content) is not held to it.
590
+ *
581
591
  * Reaching the wire is checked as SERIALIZATION, so the rule proves a field is
582
592
  * carried, not that the key it is carried in is one the host reads. Nothing
583
593
  * machine-readable describes a host's schema, so that half stays with the
@@ -589,6 +599,7 @@ function assertAskTier(adapter, event, vetoable, caseName, assert) {
589
599
  * @param {Set<string>} seen fields observed to REACH a wire, for the non-vacuity check
590
600
  */
591
601
  function assertContentChannels(adapter, event, caseName, assert, seen) {
602
+ if (!onKindsNativeEvent(adapter, event)) return;
592
603
  const declared = lookup(adapter.UNRENDERED_FIELDS, event.event);
593
604
 
594
605
  // Every value of a field is rendered and the renders compared to EACH OTHER.
@@ -717,14 +728,49 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
717
728
  }
718
729
  }
719
730
 
731
+ /**
732
+ * Whether `event` arrived as the native event `NATIVE_EVENT_FOR` names for its
733
+ * kind (always, when the adapter names none for that kind) — the event a
734
+ * kind-keyed declaration describes.
735
+ * @param {import("./control-plane.mjs").Adapter} adapter
736
+ * @param {import("./control-plane.mjs").ToolCallEvent} event
737
+ * @returns {boolean}
738
+ */
739
+ function onKindsNativeEvent(adapter, event) {
740
+ const native = lookup(adapter.NATIVE_EVENT_FOR ?? {}, event.event);
741
+ return native === undefined || event.meta.native_event === native;
742
+ }
743
+
744
+ /**
745
+ * The two levels of a pre-tool body `PRE_TOOL_RESPONSE` declares, each as the
746
+ * key-list member, its fail-open member, and where in the body it applies.
747
+ */
748
+ const PRE_TOOL_RESPONSE_LEVELS = Object.freeze([
749
+ Object.freeze({
750
+ keys: "top_level_keys",
751
+ failsOpen: "unread_top_level_key_fails_open",
752
+ where: "the body",
753
+ /** @param {any} body */
754
+ pick: (body) => body,
755
+ }),
756
+ Object.freeze({
757
+ keys: "hook_specific_output_keys",
758
+ failsOpen: "unread_key_fails_open",
759
+ where: "hookSpecificOutput",
760
+ /** @param {any} body */
761
+ pick: (body) => body?.hookSpecificOutput,
762
+ }),
763
+ ]);
764
+
720
765
  /**
721
766
  * Rule ⑫, for one render: on the host's native pre-tool event, every key the
722
- * render puts in `hookSpecificOutput` is one the adapter's `PRE_TOOL_RESPONSE`
723
- * says the host reads. On Codex any other key voids the whole body and the call
724
- * RUNS, so an unread key is a silent fail-open, not a harmless extra. Scoped to
725
- * the native name `NATIVE_EVENT_FOR` gives the pre-tool kind (the event kind
726
- * when it gives none): Codex's `PermissionRequest` also parses as pre-tool but
727
- * answers in a different schema this declaration does not describe.
767
+ * render puts at the body's top level or in `hookSpecificOutput` is one the
768
+ * adapter's `PRE_TOOL_RESPONSE` says the host reads there. On Codex any other
769
+ * key, at either level, voids the whole body and the call RUNS, so an unread
770
+ * key is a silent fail-open, not a harmless extra. Scoped to the native name
771
+ * `NATIVE_EVENT_FOR` gives the pre-tool kind (any pre-tool event when it gives
772
+ * none): Codex's `PermissionRequest` also parses as pre-tool but answers in a
773
+ * different schema this declaration does not describe.
728
774
  * @param {import("./control-plane.mjs").Adapter} adapter
729
775
  * @param {import("./control-plane.mjs").ToolCallEvent} event
730
776
  * @param {import("./control-plane.mjs").NativeResponse} rendered
@@ -733,46 +779,50 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
733
779
  * @returns {boolean} whether the render was on the pre-tool event and checked
734
780
  */
735
781
  function assertPreToolKeysRead(adapter, event, rendered, label, assert) {
736
- const preToolName = lookup(
737
- adapter.NATIVE_EVENT_FOR ?? {},
738
- EventKind.PRE_TOOL,
739
- );
740
- const onPreTool =
741
- preToolName === undefined
742
- ? event.event === EventKind.PRE_TOOL
743
- : event.meta.native_event === preToolName;
744
- if (!onPreTool) return false;
782
+ if (event.event !== EventKind.PRE_TOOL || !onKindsNativeEvent(adapter, event))
783
+ return false;
745
784
  // `?.`: a broken render under rule ⑩'s probes may not be an object at all,
746
- // and that rule, not this one, is what names it.
747
- const body = /** @type {{ hookSpecificOutput?: unknown } | undefined} */ (
748
- rendered?.stdout
749
- );
750
- const specific =
751
- body?.hookSpecificOutput === undefined ? {} : body.hookSpecificOutput;
752
- assert.ok(
753
- typeof specific === "object" &&
754
- specific !== null &&
755
- !Array.isArray(specific),
756
- `${adapter.AGENT}: a pre-tool render's hookSpecificOutput is ${JSON.stringify(specific)}, not an object: ${label}`,
757
- );
758
- const read = new Set(adapter.PRE_TOOL_RESPONSE.hook_specific_output_keys);
759
- const unread = Object.keys(/** @type {object} */ (specific)).filter(
760
- (key) => !read.has(key),
761
- );
762
- assert.deepEqual(
763
- unread,
764
- [],
765
- `${adapter.AGENT}: a pre-tool render puts ${JSON.stringify(unread)} in hookSpecificOutput, which PRE_TOOL_RESPONSE says this host does not read${adapter.PRE_TOOL_RESPONSE.unread_key_fails_open ? " — the host then voids the body and runs the call" : ""}: ${label}`,
766
- );
785
+ // and that rule, not this one, is what names it. No body reads as an empty one.
786
+ const body = rendered?.stdout;
787
+ for (const level of PRE_TOOL_RESPONSE_LEVELS) {
788
+ const found = level.pick(body);
789
+ const part = found === undefined ? {} : found;
790
+ assert.ok(
791
+ isPlainObject(part),
792
+ `${adapter.AGENT}: in a pre-tool render, ${level.where} is ${JSON.stringify(part)}, not an object: ${label}`,
793
+ );
794
+ const read = new Set(
795
+ /** @type {readonly string[]} */ (
796
+ lookup(adapter.PRE_TOOL_RESPONSE, level.keys)
797
+ ),
798
+ );
799
+ const unread = Object.keys(/** @type {object} */ (part)).filter(
800
+ (key) => !read.has(key),
801
+ );
802
+ assert.deepEqual(
803
+ unread,
804
+ [],
805
+ `${adapter.AGENT}: a pre-tool render puts ${JSON.stringify(unread)} in ${level.where}, which PRE_TOOL_RESPONSE.${level.keys} says this host does not read${lookup(adapter.PRE_TOOL_RESPONSE, level.failsOpen) ? " — the host then voids the body and runs the call" : ""}: ${label}`,
806
+ );
807
+ }
767
808
  return true;
768
809
  }
769
810
 
811
+ /**
812
+ * Whether `value` is an object that is neither `null` nor an array.
813
+ * @param {unknown} value
814
+ * @returns {value is Record<string, unknown>}
815
+ */
816
+ function isPlainObject(value) {
817
+ return typeof value === "object" && value !== null && !Array.isArray(value);
818
+ }
819
+
770
820
  /**
771
821
  * Assert an adapter declares a well-formed `PRE_TOOL_FILING` and
772
822
  * `PRE_TOOL_RESPONSE`: both frozen, every filing rule `null` or a frozen
773
- * `{tool_name, input}` in the mini-language {@link fileModelCall} reads, and the
774
- * response's key list sorted and duplicate-free beside a boolean-or-null
775
- * `unread_key_fails_open`. `agents.json` publishes both verbatim, so a shape a
823
+ * `{tool_name, input}` in the mini-language {@link fileModelCall} reads, and
824
+ * each of the response's key lists sorted and duplicate-free beside its
825
+ * boolean-or-null fail-open bit. `agents.json` publishes both verbatim, so a shape a
776
826
  * Python consumer cannot apply is refused here, not discovered there.
777
827
  * @param {import("./control-plane.mjs").Adapter} adapter
778
828
  * @param {any} assert
@@ -800,22 +850,26 @@ function assertPreToolDeclarationsWellFormed(adapter, assert) {
800
850
  typeof response === "object" &&
801
851
  response !== null &&
802
852
  Object.isFrozen(response),
803
- `${agent}: adapter declares no frozen PRE_TOOL_RESPONSE — every adapter needs one naming the hookSpecificOutput keys its host reads on the pre-tool event (an empty list when it reads no body)`,
804
- );
805
- const keys = response.hook_specific_output_keys;
806
- assert.ok(
807
- Array.isArray(keys) && Object.isFrozen(keys),
808
- `${agent}: PRE_TOOL_RESPONSE.hook_specific_output_keys is not a frozen array`,
809
- );
810
- assert.deepEqual(
811
- [...keys],
812
- [...new Set(keys)].sort(),
813
- `${agent}: PRE_TOOL_RESPONSE.hook_specific_output_keys must be sorted and duplicate-free`,
814
- );
815
- assert.ok(
816
- [true, false, null].includes(response.unread_key_fails_open),
817
- `${agent}: PRE_TOOL_RESPONSE.unread_key_fails_open must be true, false or null`,
853
+ `${agent}: adapter declares no frozen PRE_TOOL_RESPONSE — every adapter needs one naming the top-level and hookSpecificOutput keys its host reads on the pre-tool event (empty lists when it reads no body)`,
818
854
  );
855
+ for (const level of PRE_TOOL_RESPONSE_LEVELS) {
856
+ const keys = /** @type {any} */ (lookup(response, level.keys));
857
+ assert.ok(
858
+ Array.isArray(keys) && Object.isFrozen(keys),
859
+ `${agent}: PRE_TOOL_RESPONSE.${level.keys} is not a frozen array`,
860
+ );
861
+ assert.deepEqual(
862
+ [...keys],
863
+ [...new Set(keys)].sort(),
864
+ `${agent}: PRE_TOOL_RESPONSE.${level.keys} must be sorted and duplicate-free`,
865
+ );
866
+ assert.ok(
867
+ [true, false, null].includes(
868
+ /** @type {any} */ (lookup(response, level.failsOpen)),
869
+ ),
870
+ `${agent}: PRE_TOOL_RESPONSE.${level.failsOpen} must be true, false or null`,
871
+ );
872
+ }
819
873
  }
820
874
 
821
875
  /**
@@ -1237,7 +1291,15 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1237
1291
  renders += 1;
1238
1292
  }
1239
1293
 
1240
- parsedByKind[parsed.event] ??= parsed;
1294
+ // The kind's native event stands for it in the every-kind sweep when one
1295
+ // arrives; another native name is only a seed to synthesize one from.
1296
+ const held = lookup(parsedByKind, parsed.event);
1297
+ if (
1298
+ held === undefined ||
1299
+ (!onKindsNativeEvent(adapter, held) &&
1300
+ onKindsNativeEvent(adapter, parsed))
1301
+ )
1302
+ parsedByKind[parsed.event] = parsed;
1241
1303
  assertContentChannels(
1242
1304
  adapter,
1243
1305
  parsed,
@@ -544,14 +544,14 @@ export function flatToolName(namespace, name) {
544
544
  }
545
545
 
546
546
  /**
547
- * What a host reads from the `hookSpecificOutput` of an exit-0 answer to its
548
- * pre-tool event (`PreToolUse`, `BeforeTool`). `hook_specific_output_keys` is
549
- * every key it reads there, sorted (empty when it reads no body).
550
- * `unread_key_fails_open`: `true` ⇒ any other key there voids the whole body
551
- * and the call RUNS; `false` ⇒ other keys are ignored; `null` ⇒ not
552
- * established from the host's source. Top-level keys are outside this record;
553
- * Codex parses those `deny_unknown_fields` too ([X8]).
554
- * @typedef {Readonly<{ hook_specific_output_keys: readonly string[], unread_key_fails_open: boolean | null }>} PreToolResponse
547
+ * What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
548
+ * `BeforeTool`), as two sorted key lists, each empty when the host reads no
549
+ * body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
550
+ * `top_level_keys` at the body's top level. Each list has a fail-open bit,
551
+ * `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
552
+ * other key there voids the whole body and the call RUNS; `false` ⇒ other keys
553
+ * are ignored; `null` ⇒ not established from the host's source.
554
+ * @typedef {Readonly<{ hook_specific_output_keys: readonly string[], unread_key_fails_open: boolean | null, top_level_keys: readonly string[], unread_top_level_key_fails_open: boolean | null }>} PreToolResponse
555
555
  */
556
556
 
557
557
  /**
@@ -705,8 +705,8 @@ export const VETO_IMPOSSIBLE = Object.freeze([
705
705
  * @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
706
706
  * @property {boolean} NATIVE_ASK_TIER whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
707
707
  * @property {PreToolFiling} PRE_TOOL_FILING how this host's pre-tool hook files a model tool call, for a consumer matching the model's calls against the hook's. `{}` when no rename is modelled.
708
- * @property {PreToolResponse} PRE_TOOL_RESPONSE the `hookSpecificOutput` keys this host reads on its pre-tool event, and whether an unread key there runs the call. The conformance harness holds every pre-tool render to the key list.
709
- * @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
708
+ * @property {PreToolResponse} PRE_TOOL_RESPONSE the top-level and `hookSpecificOutput` keys this host reads on its pre-tool event, each list with whether an unread key there runs the call. The conformance harness holds every pre-tool render to both lists.
709
+ * @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path on the kind's `NATIVE_EVENT_FOR` event only; another native event of that kind (Codex's `PermissionRequest`) may drop more. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
710
710
  */
711
711
 
712
712
  /**
@@ -1050,6 +1050,8 @@ export const DEFAULT_DENY_REASON = "blocked by monitor";
1050
1050
  * exit. Every host here discards (or never gets) the stdout body on exit 2.
1051
1051
  * - `reason_required`: the host drops a block whose reason is empty and runs
1052
1052
  * the tool, so every body field that carries the reason must be non-empty.
1053
+ * Unlike the other two fields, this holds for ANY block body the host parses,
1054
+ * enforced or advisory: Codex voids an empty-reason deny either way.
1053
1055
  *
1054
1056
  * {@link gatedResponse} derives each adapter's enforced render from its row,
1055
1057
  * and the conformance harness holds every enforced render to the same row.
@@ -79,7 +79,8 @@
79
79
  "enforced": false,
80
80
  "stdout": {
81
81
  "hookSpecificOutput": {
82
- "hookEventName": "PreToolUse"
82
+ "hookEventName": "PreToolUse",
83
+ "additionalContext": "redacted 1 secret"
83
84
  }
84
85
  }
85
86
  }
@@ -127,7 +128,7 @@
127
128
  "mutated_input": {
128
129
  "command": "ls"
129
130
  },
130
- "additional_context": "dropped by codex"
131
+ "additional_context": "narrowed to a plain ls"
131
132
  },
132
133
  "native": {
133
134
  "transport": "external_hook",
@@ -136,9 +137,11 @@
136
137
  "stdout": {
137
138
  "hookSpecificOutput": {
138
139
  "hookEventName": "PreToolUse",
140
+ "permissionDecision": "allow",
139
141
  "updatedInput": {
140
142
  "command": "ls"
141
- }
143
+ },
144
+ "additionalContext": "narrowed to a plain ls"
142
145
  }
143
146
  }
144
147
  }
@@ -285,6 +288,7 @@
285
288
  "stdout": {
286
289
  "hookSpecificOutput": {
287
290
  "hookEventName": "PreToolUse",
291
+ "permissionDecision": "allow",
288
292
  "updatedInput": {
289
293
  "command": ["ls"]
290
294
  }
@@ -370,8 +374,10 @@
370
374
  "stdout": {
371
375
  "hookSpecificOutput": {
372
376
  "hookEventName": "PermissionRequest",
373
- "permissionDecision": "deny",
374
- "permissionDecisionReason": "nope"
377
+ "decision": {
378
+ "behavior": "deny",
379
+ "message": "nope"
380
+ }
375
381
  }
376
382
  },
377
383
  "stderr": "nope"
@@ -82,11 +82,11 @@ export const NATIVE_ASK_TIER: true;
82
82
  */
83
83
  export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
84
84
  /**
85
- * The `hookSpecificOutput` keys Claude Code's hooks reference documents for
86
- * `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether an unread key
87
- * fails open is NOT established: the reference says an exit-0 body that fails
88
- * schema validation lets the call go ahead, but not whether an extra key fails
89
- * it, and the host's source is not public.
85
+ * The top-level and `hookSpecificOutput` keys Claude Code's hooks reference
86
+ * documents for `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether
87
+ * an unread key fails open, at either level, is NOT established: the reference
88
+ * says an exit-0 body that fails schema validation lets the call go ahead, but
89
+ * not whether an extra key fails it, and the host's source is not public.
90
90
  * @type {import("../control-plane.mjs").PreToolResponse}
91
91
  */
92
92
  export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
@@ -36,15 +36,17 @@ export function parse(native: any, { version }?: {
36
36
  * on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable` false) the
37
37
  * body still renders but exit stays 0 and `enforced` is false — advisory only.
38
38
  *
39
- * The two kinds use DIFFERENT native shapes, because Codex does: a pre-tool
40
- * decision rides `hookSpecificOutput.permissionDecision`, a post-tool one the
41
- * top-level `decision`/`reason` pair (`permissionDecision` is accepted but inert
42
- * on PostToolUse, so emitting it there would read as a block that never bites).
39
+ * Each native event gets its OWN shape, because Codex parses each strictly: a
40
+ * PreToolUse decision rides `hookSpecificOutput.permissionDecision`, a
41
+ * PermissionRequest one `hookSpecificOutput.decision.behavior`, and a
42
+ * post-tool one the top-level `decision`/`reason` pair.
43
43
  *
44
44
  * `soleGate` (default `false`) is the same dangerous, explicit opt-in as the
45
- * Claude adapter: when `true` AND the verdict is `allow`, the render emits the
46
- * real `permissionDecision: "allow"` instead of abstaining. It has no meaning
47
- * post-tool — there is no approval left to grant — so it is ignored there.
45
+ * Claude adapter, and acts only on PermissionRequest: there an `allow` renders
46
+ * `behavior: "allow"`, which grants the approval (except, from 0.166, when the
47
+ * hook is environment-sourced). PreToolUse has no allow that
48
+ * approves anything (Codex accepts `"allow"` only beside `updatedInput`, as a
49
+ * rewrite), and post-tool there is no approval left to grant.
48
50
  * @param {Verdict} verdict
49
51
  * @param {ToolCallEvent} event
50
52
  * @param {{ soleGate?: boolean }} [options]
@@ -103,11 +105,15 @@ export const INTEGRATION_MODE: "external_hook";
103
105
  /** @type {import("../control-plane.mjs").CoverageMap} */
104
106
  export const COVERAGE: import("../control-plane.mjs").CoverageMap;
105
107
  /**
106
- * Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind, so
107
- * `render` drops them. Codex splits its two content channels by event:
108
- * `updatedInput` exists only on PreToolUse (the call has not run yet), and
109
- * `hookSpecificOutput.additionalContext` only on PostToolUse — Codex documents
110
- * no context field on PreToolUse, where `updatedInput` is the whole surface.
108
+ * Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind's
109
+ * {@link NATIVE_EVENT_FOR} event, so `render` drops them. PreToolUse takes
110
+ * `updatedInput` (applied only beside `permissionDecision: "allow"`) and
111
+ * `additionalContext`; PostToolUse takes `additionalContext` alone ([X8], read
112
+ * at `rust-v0.166.0` and identical at `rust-v0.135.0`). `PermissionRequest`,
113
+ * which also parses as `pre_tool`, takes neither: its schema is
114
+ * `{hookEventName, decision: {behavior, message}}`, and a body carrying
115
+ * `updatedInput` is ignored (Codex's own approval flow decides), so
116
+ * {@link render} drops both there.
111
117
  *
112
118
  * NEITHER kind can rewrite tool output: PostToolUse "can't undo side effects
113
119
  * from a tool that already ran", and Codex rejects `updatedMCPToolOutput`, so
@@ -144,12 +150,15 @@ export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
144
150
  */
145
151
  export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
146
152
  /**
147
- * Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes
148
- * `hookSpecificOutput` into a `deny_unknown_fields` struct of exactly these
149
- * five keys. Any other key fails the whole body: the run is marked failed, no
150
- * block is set, and the call RUNS. A known key can still void the body by its
151
- * combination (`updatedInput` without `permissionDecision: "allow"`), which
152
- * this declaration does not describe.
153
+ * Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes the
154
+ * body into `deny_unknown_fields` structs at both levels: these seven top-level
155
+ * keys (`PreToolUseCommandOutputWire` with its flattened universal fields), and
156
+ * these five in `hookSpecificOutput`. Any other key fails the whole body: the
157
+ * run is marked failed, no block is set, and the call RUNS. A known key can
158
+ * still void the body by its value or combination (`continue: false`, any
159
+ * `stopReason`, `suppressOutput: true`, `decision: "approve"`, `updatedInput`
160
+ * without `permissionDecision: "allow"`), which this declaration does not
161
+ * describe.
153
162
  * @type {import("../control-plane.mjs").PreToolResponse}
154
163
  */
155
164
  export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
@@ -139,7 +139,9 @@ export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
139
139
  * other key is ignored (`packages/core/src/hooks/types.ts`
140
140
  * `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
141
141
  * and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
142
- * `62bab5b2`).
142
+ * `62bab5b2`). The top level is the `HookOutput` interface's seven keys, read
143
+ * from an unchecked `JSON.parse` cast (`hooks/hookRunner.ts`), so an unknown
144
+ * top-level key is ignored too.
143
145
  * @type {import("../control-plane.mjs").PreToolResponse}
144
146
  */
145
147
  export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
@@ -172,8 +172,8 @@ export function fileModelCall(filing: PreToolFiling, kind: "function" | "freefor
172
172
  * @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
173
173
  * @property {boolean} NATIVE_ASK_TIER whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
174
174
  * @property {PreToolFiling} PRE_TOOL_FILING how this host's pre-tool hook files a model tool call, for a consumer matching the model's calls against the hook's. `{}` when no rename is modelled.
175
- * @property {PreToolResponse} PRE_TOOL_RESPONSE the `hookSpecificOutput` keys this host reads on its pre-tool event, and whether an unread key there runs the call. The conformance harness holds every pre-tool render to the key list.
176
- * @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
175
+ * @property {PreToolResponse} PRE_TOOL_RESPONSE the top-level and `hookSpecificOutput` keys this host reads on its pre-tool event, each list with whether an unread key there runs the call. The conformance harness holds every pre-tool render to both lists.
176
+ * @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path on the kind's `NATIVE_EVENT_FOR` event only; another native event of that kind (Codex's `PermissionRequest`) may drop more. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
177
177
  */
178
178
  /**
179
179
  * Build a normalized {@link ToolCallEvent}, stamping the schema version. Pure —
@@ -634,6 +634,8 @@ export const DEFAULT_DENY_REASON: "blocked by monitor";
634
634
  * exit. Every host here discards (or never gets) the stdout body on exit 2.
635
635
  * - `reason_required`: the host drops a block whose reason is empty and runs
636
636
  * the tool, so every body field that carries the reason must be non-empty.
637
+ * Unlike the other two fields, this holds for ANY block body the host parses,
638
+ * enforced or advisory: Codex voids an empty-reason deny either way.
637
639
  *
638
640
  * {@link gatedResponse} derives each adapter's enforced render from its row,
639
641
  * and the conformance harness holds every enforced render to the same row.
@@ -645,17 +647,19 @@ export const BLOCK_TRANSPORT: Readonly<Record<string, Readonly<{
645
647
  reason_required: boolean;
646
648
  }>>>;
647
649
  /**
648
- * What a host reads from the `hookSpecificOutput` of an exit-0 answer to its
649
- * pre-tool event (`PreToolUse`, `BeforeTool`). `hook_specific_output_keys` is
650
- * every key it reads there, sorted (empty when it reads no body).
651
- * `unread_key_fails_open`: `true` ⇒ any other key there voids the whole body
652
- * and the call RUNS; `false` ⇒ other keys are ignored; `null` ⇒ not
653
- * established from the host's source. Top-level keys are outside this record;
654
- * Codex parses those `deny_unknown_fields` too ([X8]).
650
+ * What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
651
+ * `BeforeTool`), as two sorted key lists, each empty when the host reads no
652
+ * body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
653
+ * `top_level_keys` at the body's top level. Each list has a fail-open bit,
654
+ * `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
655
+ * other key there voids the whole body and the call RUNS; `false` ⇒ other keys
656
+ * are ignored; `null` ⇒ not established from the host's source.
655
657
  */
656
658
  export type PreToolResponse = Readonly<{
657
659
  hook_specific_output_keys: readonly string[];
658
660
  unread_key_fails_open: boolean | null;
661
+ top_level_keys: readonly string[];
662
+ unread_top_level_key_fails_open: boolean | null;
659
663
  }>;
660
664
  /**
661
665
  * The translator for one agent's protocol. `parse` maps a native event to a
@@ -687,11 +691,11 @@ export type Adapter = {
687
691
  */
688
692
  PRE_TOOL_FILING: PreToolFiling;
689
693
  /**
690
- * the `hookSpecificOutput` keys this host reads on its pre-tool event, and whether an unread key there runs the call. The conformance harness holds every pre-tool render to the key list.
694
+ * the top-level and `hookSpecificOutput` keys this host reads on its pre-tool event, each list with whether an unread key there runs the call. The conformance harness holds every pre-tool render to both lists.
691
695
  */
692
696
  PRE_TOOL_RESPONSE: PreToolResponse;
693
697
  /**
694
- * per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
698
+ * per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path on the kind's `NATIVE_EVENT_FOR` event only; another native event of that kind (Codex's `PermissionRequest`) may drop more. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
695
699
  */
696
700
  UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
697
701
  };