agent-control-plane-core 0.13.0 → 0.15.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.15.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,52 +414,85 @@ 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, 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
410
- * @param {boolean} enforced
411
426
  * @param {boolean} soleGate
412
427
  * @returns {Record<string, unknown>}
413
428
  */
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.
429
+ function nativeBody(hookEventName, vd, event, soleGate) {
430
+ if (event.event === EventKind.POST_TOOL)
431
+ return postToolBody(hookEventName, vd);
432
+ if (hookEventName === HookEvent.PERMISSION_REQUEST)
433
+ return permissionRequestBody(hookEventName, vd, soleGate);
434
+ return preToolBody(hookEventName, vd, event);
435
+ }
436
+
437
+ /**
438
+ * The body for a PreToolUse (or unmodelled) event. Codex's PreToolUse parser
439
+ * ([X8]) voids the body, and the call runs, on `"allow"` without `updatedInput`
440
+ * and on `updatedInput` without `"allow"` — so an allow emits a decision only
441
+ * to carry a mutation, and a deny never carries one.
442
+ * @param {string} hookEventName
443
+ * @param {Verdict} vd a normalized verdict
444
+ * @param {ToolCallEvent} event
445
+ * @returns {Record<string, unknown>}
446
+ */
447
+ function preToolBody(hookEventName, vd, event) {
418
448
  /** @type {Record<string, unknown>} */
419
449
  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
- ) {
450
+ // An UNKNOWN event reaches this body too. It has no established channel, so
451
+ // it carries nothing: a decision or `updatedInput` there would act on the
452
+ // host, or read to the caller as applied, while the host ignores it.
453
+ if (event.event !== EventKind.PRE_TOOL) return { hookSpecificOutput: body };
454
+ if (vd.decision !== Decision.ALLOW) {
455
+ // Codex voids a deny body whose reason is missing or empty: no block, no
456
+ // context, and the call runs (`parse_pre_tool_use`, [X8]). That holds on an
457
+ // advisory render too, so every deny carries a reason.
427
458
  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
459
  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)
460
+ } else if (vd.mutated_input !== undefined) {
461
+ body.permissionDecision = Decision.ALLOW;
440
462
  body.updatedInput = vd.mutated_input;
463
+ }
464
+ if (vd.additional_context !== undefined)
465
+ body.additionalContext = vd.additional_context;
441
466
  return { hookSpecificOutput: body };
442
467
  }
443
468
 
469
+ /**
470
+ * The body for a PermissionRequest event: Codex's
471
+ * `PermissionRequestHookSpecificOutputWire` ([X8]), `{hookEventName, decision:
472
+ * {behavior, message}}`. `behavior: "allow"` GRANTS the approval, so an allow
473
+ * abstains unless `soleGate` opts in. Content fields have no channel here:
474
+ * `updatedInput` voids the body and there is no context key.
475
+ * @param {string} hookEventName
476
+ * @param {Verdict} vd a normalized verdict
477
+ * @param {boolean} soleGate
478
+ * @returns {Record<string, unknown>}
479
+ */
480
+ function permissionRequestBody(hookEventName, vd, soleGate) {
481
+ /** @type {Record<string, unknown>} */
482
+ const hookSpecificOutput = { hookEventName };
483
+ // Codex substitutes its own text for an empty message
484
+ // (`permission_request_decision`, [X8]); ours says who denied it.
485
+ if (vd.decision !== Decision.ALLOW) {
486
+ hookSpecificOutput.decision = {
487
+ behavior: Decision.DENY,
488
+ message: blockReason(vd.reason),
489
+ };
490
+ } else if (soleGate) {
491
+ hookSpecificOutput.decision = { behavior: Decision.ALLOW };
492
+ }
493
+ return { hookSpecificOutput };
494
+ }
495
+
444
496
  /**
445
497
  * The body for a PostToolUse event: Codex's documented post-tool schema, a
446
498
  * top-level `decision`/`reason` beside a `hookSpecificOutput` carrying
@@ -449,27 +501,21 @@ function preToolBody(hookEventName, vd, event, enforced, soleGate) {
449
501
  * objection the host understands there, `block`.
450
502
  * @param {string} hookEventName
451
503
  * @param {Verdict} vd a normalized verdict
452
- * @param {boolean} enforced
453
504
  * @returns {Record<string, unknown>}
454
505
  */
455
- function postToolBody(hookEventName, vd, enforced) {
506
+ function postToolBody(hookEventName, vd) {
456
507
  /** @type {Record<string, unknown>} */
457
508
  const hookSpecificOutput = { hookEventName };
458
509
  if (vd.additional_context !== undefined)
459
510
  hookSpecificOutput.additionalContext = vd.additional_context;
460
511
  /** @type {Record<string, unknown>} */
461
512
  const body = { hookSpecificOutput };
513
+ // `parse_post_tool_use` ([X8]) voids `decision: "block"` with an empty
514
+ // reason, and a sync handler's context with it, advisory render or not.
462
515
  if (vd.decision !== Decision.ALLOW) {
463
516
  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
517
  body.reason = blockReason(vd.reason);
518
+ }
473
519
  return body;
474
520
  }
475
521
 
@@ -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
  */
@@ -61,7 +62,13 @@ function manifestRow(id) {
61
62
  ]),
62
63
  ),
63
64
  pre_tool_filing: adapter.PRE_TOOL_FILING,
64
- pre_tool_response: adapter.PRE_TOOL_RESPONSE,
65
+ pre_tool_response: {
66
+ ...adapter.PRE_TOOL_RESPONSE,
67
+ hook_specific_output_keys: [
68
+ ...adapter.PRE_TOOL_RESPONSE.hook_specific_output_keys,
69
+ ].sort(),
70
+ top_level_keys: [...adapter.PRE_TOOL_RESPONSE.top_level_keys].sort(),
71
+ },
65
72
  };
66
73
  }
67
74
 
@@ -11,9 +11,9 @@ import {
11
11
  isCoverageStatus,
12
12
  lookup,
13
13
  VERDICT_CONTENT_FIELDS,
14
- WHOLE_ARGUMENTS,
15
14
  FILING_RULE_GRAMMAR,
16
15
  isFilingRule,
16
+ isPlainObject,
17
17
  } from "./control-plane.mjs";
18
18
 
19
19
  /**
@@ -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,47 +779,41 @@ 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
 
770
811
  /**
771
812
  * Assert an adapter declares a well-formed `PRE_TOOL_FILING` and
772
- * `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
776
- * Python consumer cannot apply is refused here, not discovered there.
813
+ * `PRE_TOOL_RESPONSE`: every filing rule `null` or in the mini-language
814
+ * {@link fileModelCall} reads, and each response key list an array of strings
815
+ * beside a boolean-or-null fail-open bit. `agents.json` publishes both, so a
816
+ * shape a Python consumer cannot apply is refused here, not discovered there.
777
817
  * @param {import("./control-plane.mjs").Adapter} adapter
778
818
  * @param {any} assert
779
819
  */
@@ -781,41 +821,32 @@ function assertPreToolDeclarationsWellFormed(adapter, assert) {
781
821
  const agent = adapter.AGENT;
782
822
  const filing = adapter.PRE_TOOL_FILING;
783
823
  assert.ok(
784
- typeof filing === "object" && filing !== null && Object.isFrozen(filing),
785
- `${agent}: adapter declares no frozen PRE_TOOL_FILING — every adapter needs one: Object.freeze({}) when its host files every model call as asked`,
824
+ isPlainObject(filing),
825
+ `${agent}: adapter declares no PRE_TOOL_FILING — every adapter needs one: {} when its host files every model call as asked`,
786
826
  );
787
- for (const [name, rule] of Object.entries(filing)) {
788
- if (rule === null) continue;
789
- const where = `${agent}: PRE_TOOL_FILING[${JSON.stringify(name)}]`;
790
- assert.ok(Object.isFrozen(rule), `${where} is not frozen`);
827
+ for (const [name, rule] of Object.entries(filing))
791
828
  assert.ok(
792
- isFilingRule(rule),
793
- `${where} is ${JSON.stringify(rule)}, not ${FILING_RULE_GRAMMAR}`,
829
+ rule === null || isFilingRule(rule),
830
+ `${agent}: PRE_TOOL_FILING[${JSON.stringify(name)}] is ${JSON.stringify(rule)}, not null or ${FILING_RULE_GRAMMAR}`,
794
831
  );
795
- if (rule.input !== WHOLE_ARGUMENTS)
796
- assert.ok(Object.isFrozen(rule.input), `${where}.input is not frozen`);
797
- }
798
832
  const response = adapter.PRE_TOOL_RESPONSE;
799
833
  assert.ok(
800
- typeof response === "object" &&
801
- response !== null &&
802
- 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`,
834
+ isPlainObject(response),
835
+ `${agent}: adapter declares no 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
836
  );
837
+ for (const level of PRE_TOOL_RESPONSE_LEVELS) {
838
+ const keys = lookup(response, level.keys);
839
+ assert.ok(
840
+ Array.isArray(keys) && keys.every((key) => typeof key === "string"),
841
+ `${agent}: PRE_TOOL_RESPONSE.${level.keys} is not an array of strings`,
842
+ );
843
+ assert.ok(
844
+ [true, false, null].includes(
845
+ /** @type {any} */ (lookup(response, level.failsOpen)),
846
+ ),
847
+ `${agent}: PRE_TOOL_RESPONSE.${level.failsOpen} must be true, false or null`,
848
+ );
849
+ }
819
850
  }
820
851
 
821
852
  /**
@@ -1237,7 +1268,15 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1237
1268
  renders += 1;
1238
1269
  }
1239
1270
 
1240
- parsedByKind[parsed.event] ??= parsed;
1271
+ // The kind's native event stands for it in the every-kind sweep when one
1272
+ // arrives; another native name is only a seed to synthesize one from.
1273
+ const held = lookup(parsedByKind, parsed.event);
1274
+ if (
1275
+ held === undefined ||
1276
+ (!onKindsNativeEvent(adapter, held) &&
1277
+ onKindsNativeEvent(adapter, parsed))
1278
+ )
1279
+ parsedByKind[parsed.event] = parsed;
1241
1280
  assertContentChannels(
1242
1281
  adapter,
1243
1282
  parsed,
@@ -496,9 +496,6 @@ export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
496
496
  /** The {@link PreToolFiling} input reference to a call's whole arguments value. */
497
497
  export const WHOLE_ARGUMENTS = "$";
498
498
 
499
- /** The prefix of a {@link PreToolFiling} input reference to one top-level argument. */
500
- export const ARGUMENT_KEY_PREFIX = "$.";
501
-
502
499
  /**
503
500
  * How a host's pre-tool hook files a model's tool call, keyed by MODEL tool
504
501
  * name, the {@link flatToolName} of the call's `namespace` and `name`. Each
@@ -544,90 +541,52 @@ export function flatToolName(namespace, name) {
544
541
  }
545
542
 
546
543
  /**
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
544
+ * What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
545
+ * `BeforeTool`), as two key lists, each empty when the host reads no
546
+ * body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
547
+ * `top_level_keys` at the body's top level. Each list has a fail-open bit,
548
+ * `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
549
+ * other key there voids the whole body and the call RUNS; `false` ⇒ other keys
550
+ * are ignored; `null` ⇒ not established from the host's source.
551
+ * @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
552
  */
556
553
 
557
554
  /**
558
- * Whether `ref` is a {@link PreToolFiling} input reference: `"$"`, or `"$."`
559
- * followed by one non-empty key with no `.`.
560
- * @param {unknown} ref
561
- * @returns {boolean}
555
+ * Whether `value` is a plain object: not null, not an array.
556
+ * @param {unknown} value
557
+ * @returns {value is Record<string, unknown>}
562
558
  */
563
- function isFilingRef(ref) {
564
- if (ref === WHOLE_ARGUMENTS) return true;
565
- if (typeof ref !== "string" || !ref.startsWith(ARGUMENT_KEY_PREFIX))
566
- return false;
567
- const key = ref.slice(ARGUMENT_KEY_PREFIX.length);
568
- return key !== "" && !key.includes(".");
559
+ export function isPlainObject(value) {
560
+ return typeof value === "object" && value !== null && !Array.isArray(value);
569
561
  }
570
562
 
571
- /**
572
- * Whether `input` is a {@link PreToolFiling} rule's input: `"$"`, or a plain
573
- * object (not an array) whose every value is `"$"` or `"$.<key>"`.
574
- * @param {unknown} input
575
- * @returns {boolean}
576
- */
577
- function isFilingInput(input) {
578
- if (input === WHOLE_ARGUMENTS) return true;
579
- return (
580
- typeof input === "object" &&
581
- input !== null &&
582
- !Array.isArray(input) &&
583
- Object.values(input).every(isFilingRef)
584
- );
585
- }
563
+ /** A {@link PreToolFiling} reference to one top-level argument; group 1 is the key. */
564
+ const ARGUMENT_REF = /^\$\.([^.]+)$/u;
586
565
 
587
566
  /** What a {@link PreToolFiling} rule must be, for error messages. */
588
- export const FILING_RULE_GRAMMAR = `{tool_name: <non-empty string>, input: "${WHOLE_ARGUMENTS}" or an object of "${WHOLE_ARGUMENTS}"/"${ARGUMENT_KEY_PREFIX}<key>" references (<key>: non-empty, no ".")}`;
567
+ export const FILING_RULE_GRAMMAR = `{tool_name: <non-empty string>, input: "${WHOLE_ARGUMENTS}" or an object of "${WHOLE_ARGUMENTS}"/"$.<key>" references (<key>: non-empty, no ".")}`;
589
568
 
590
569
  /**
591
- * Whether `rule` is a non-null {@link PreToolFiling} rule: a plain object with
592
- * exactly a non-empty string `tool_name` and a well-formed `input`. The one
593
- * grammar both {@link fileModelCall} and the conformance harness hold a rule to.
570
+ * Whether `rule` is a non-null {@link PreToolFiling} rule: exactly a non-empty
571
+ * string `tool_name` and an `input` that is `"$"` or an object of `"$"` /
572
+ * `"$.<key>"` references. The one grammar both {@link fileModelCall} and the
573
+ * conformance harness hold a rule to.
594
574
  * @param {unknown} rule
595
575
  * @returns {boolean}
596
576
  */
597
577
  export function isFilingRule(rule) {
598
- if (typeof rule !== "object" || rule === null || Array.isArray(rule))
599
- return false;
600
- const keys = Object.keys(rule).sort();
601
- const { tool_name, input } = /** @type {Record<string, unknown>} */ (rule);
578
+ if (!isPlainObject(rule)) return false;
579
+ const { tool_name, input, ...extra } = rule;
580
+ const isRef = (/** @type {unknown} */ ref) =>
581
+ ref === WHOLE_ARGUMENTS ||
582
+ (typeof ref === "string" && ARGUMENT_REF.test(ref));
602
583
  return (
603
- keys.length === 2 &&
604
- keys[0] === "input" &&
605
- keys[1] === "tool_name" &&
584
+ Object.keys(extra).length === 0 &&
606
585
  typeof tool_name === "string" &&
607
586
  tool_name !== "" &&
608
- isFilingInput(input)
609
- );
610
- }
611
-
612
- /** The kinds of model tool call a {@link PreToolFiling} distinguishes. */
613
- export const MODEL_CALL_KINDS = readonlySet(["function", "freeform"]);
614
-
615
- /**
616
- * Resolve one valid {@link PreToolFiling} input reference against `args`;
617
- * `undefined` when it does not resolve (see {@link PreToolFiling}).
618
- * @param {string} ref
619
- * @param {unknown} args
620
- * @returns {unknown}
621
- */
622
- function resolveArgument(ref, args) {
623
- if (ref === WHOLE_ARGUMENTS) return args;
624
- if (typeof args !== "object" || args === null || Array.isArray(args))
625
- return undefined;
626
- const value = lookup(
627
- /** @type {Record<string, unknown>} */ (args),
628
- ref.slice(ARGUMENT_KEY_PREFIX.length),
587
+ (input === WHOLE_ARGUMENTS ||
588
+ (isPlainObject(input) && Object.values(input).every(isRef)))
629
589
  );
630
- return value === null ? undefined : value;
631
590
  }
632
591
 
633
592
  /**
@@ -635,7 +594,7 @@ function resolveArgument(ref, args) {
635
594
  * with arguments value `args`, per that host's {@link PreToolFiling}; `null`
636
595
  * when the hook files none. The reference reading of the mini-language
637
596
  * `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
638
- * grammar or a kind outside {@link MODEL_CALL_KINDS}.
597
+ * grammar or a kind other than `"function"` or `"freeform"`.
639
598
  * @param {PreToolFiling} filing
640
599
  * @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
641
600
  * @param {string} name the model tool name, a {@link flatToolName}
@@ -643,7 +602,7 @@ function resolveArgument(ref, args) {
643
602
  * @returns {{ tool_name: string, tool_input: unknown } | null}
644
603
  */
645
604
  export function fileModelCall(filing, kind, name, args) {
646
- if (!MODEL_CALL_KINDS.has(kind))
605
+ if (kind !== "function" && kind !== "freeform")
647
606
  throw new Error(
648
607
  `control-plane: model call kind ${JSON.stringify(kind)} is neither "function" nor "freeform"`,
649
608
  );
@@ -657,10 +616,13 @@ export function fileModelCall(filing, kind, name, args) {
657
616
  );
658
617
  if (rule.input === WHOLE_ARGUMENTS)
659
618
  return { tool_name: rule.tool_name, tool_input: args };
660
- const entries = Object.entries(rule.input).map(([key, ref]) => [
661
- key,
662
- resolveArgument(ref, args),
663
- ]);
619
+ const entries = Object.entries(rule.input).map(([key, ref]) => {
620
+ const argument = ARGUMENT_REF.exec(ref)?.[1];
621
+ if (argument === undefined) return [key, args];
622
+ // A missing key, a null value, or non-object arguments all fail to resolve.
623
+ const value = isPlainObject(args) ? lookup(args, argument) : undefined;
624
+ return [key, value === null ? undefined : value];
625
+ });
664
626
  if (entries.some(([, value]) => value === undefined)) return null;
665
627
  // fromEntries defines own properties, so an input key `__proto__` stays a
666
628
  // key, as it is in a Python consumer's dict, instead of hitting the setter.
@@ -705,8 +667,8 @@ export const VETO_IMPOSSIBLE = Object.freeze([
705
667
  * @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
668
  * @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
669
  * @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.
670
+ * @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.
671
+ * @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
672
  */
711
673
 
712
674
  /**
@@ -1050,6 +1012,8 @@ export const DEFAULT_DENY_REASON = "blocked by monitor";
1050
1012
  * exit. Every host here discards (or never gets) the stdout body on exit 2.
1051
1013
  * - `reason_required`: the host drops a block whose reason is empty and runs
1052
1014
  * the tool, so every body field that carries the reason must be non-empty.
1015
+ * Unlike the other two fields, this holds for ANY block body the host parses,
1016
+ * enforced or advisory: Codex voids an empty-reason deny either way.
1053
1017
  *
1054
1018
  * {@link gatedResponse} derives each adapter's enforced render from its row,
1055
1019
  * 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;
@@ -135,9 +135,26 @@ export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
135
135
  */
136
136
  export function flatToolName(namespace: string | null | undefined, name: string): string;
137
137
  /**
138
- * Whether `rule` is a non-null {@link PreToolFiling} rule: a plain object with
139
- * exactly a non-empty string `tool_name` and a well-formed `input`. The one
140
- * grammar both {@link fileModelCall} and the conformance harness hold a rule to.
138
+ * What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
139
+ * `BeforeTool`), as two key lists, each empty when the host reads no
140
+ * body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
141
+ * `top_level_keys` at the body's top level. Each list has a fail-open bit,
142
+ * `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
143
+ * other key there voids the whole body and the call RUNS; `false` ⇒ other keys
144
+ * are ignored; `null` ⇒ not established from the host's source.
145
+ * @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
146
+ */
147
+ /**
148
+ * Whether `value` is a plain object: not null, not an array.
149
+ * @param {unknown} value
150
+ * @returns {value is Record<string, unknown>}
151
+ */
152
+ export function isPlainObject(value: unknown): value is Record<string, unknown>;
153
+ /**
154
+ * Whether `rule` is a non-null {@link PreToolFiling} rule: exactly a non-empty
155
+ * string `tool_name` and an `input` that is `"$"` or an object of `"$"` /
156
+ * `"$.<key>"` references. The one grammar both {@link fileModelCall} and the
157
+ * conformance harness hold a rule to.
141
158
  * @param {unknown} rule
142
159
  * @returns {boolean}
143
160
  */
@@ -147,7 +164,7 @@ export function isFilingRule(rule: unknown): boolean;
147
164
  * with arguments value `args`, per that host's {@link PreToolFiling}; `null`
148
165
  * when the hook files none. The reference reading of the mini-language
149
166
  * `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
150
- * grammar or a kind outside {@link MODEL_CALL_KINDS}.
167
+ * grammar or a kind other than `"function"` or `"freeform"`.
151
168
  * @param {PreToolFiling} filing
152
169
  * @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
153
170
  * @param {string} name the model tool name, a {@link flatToolName}
@@ -172,8 +189,8 @@ export function fileModelCall(filing: PreToolFiling, kind: "function" | "freefor
172
189
  * @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
190
  * @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
191
  * @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.
192
+ * @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.
193
+ * @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
194
  */
178
195
  /**
179
196
  * Build a normalized {@link ToolCallEvent}, stamping the schema version. Pure —
@@ -582,12 +599,8 @@ export const VERDICT_CONTENT_FIELDS: readonly string[];
582
599
  export const UNRENDERED_ON_UNKNOWN: ReadonlySet<string>;
583
600
  /** The {@link PreToolFiling} input reference to a call's whole arguments value. */
584
601
  export const WHOLE_ARGUMENTS: "$";
585
- /** The prefix of a {@link PreToolFiling} input reference to one top-level argument. */
586
- export const ARGUMENT_KEY_PREFIX: "$.";
587
602
  /** What a {@link PreToolFiling} rule must be, for error messages. */
588
603
  export const FILING_RULE_GRAMMAR: "{tool_name: <non-empty string>, input: \"$\" or an object of \"$\"/\"$.<key>\" references (<key>: non-empty, no \".\")}";
589
- /** The kinds of model tool call a {@link PreToolFiling} distinguishes. */
590
- export const MODEL_CALL_KINDS: ReadonlySet<string>;
591
604
  /**
592
605
  * PROBLEM CLASS — an event marked vetoable on a path where the host can never
593
606
  * veto it, so a deny reads downstream as a block that never happened.
@@ -634,6 +647,8 @@ export const DEFAULT_DENY_REASON: "blocked by monitor";
634
647
  * exit. Every host here discards (or never gets) the stdout body on exit 2.
635
648
  * - `reason_required`: the host drops a block whose reason is empty and runs
636
649
  * the tool, so every body field that carries the reason must be non-empty.
650
+ * Unlike the other two fields, this holds for ANY block body the host parses,
651
+ * enforced or advisory: Codex voids an empty-reason deny either way.
637
652
  *
638
653
  * {@link gatedResponse} derives each adapter's enforced render from its row,
639
654
  * and the conformance harness holds every enforced render to the same row.
@@ -645,17 +660,19 @@ export const BLOCK_TRANSPORT: Readonly<Record<string, Readonly<{
645
660
  reason_required: boolean;
646
661
  }>>>;
647
662
  /**
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]).
663
+ * What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
664
+ * `BeforeTool`), as two key lists, each empty when the host reads no
665
+ * body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
666
+ * `top_level_keys` at the body's top level. Each list has a fail-open bit,
667
+ * `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
668
+ * other key there voids the whole body and the call RUNS; `false` ⇒ other keys
669
+ * are ignored; `null` ⇒ not established from the host's source.
655
670
  */
656
671
  export type PreToolResponse = Readonly<{
657
672
  hook_specific_output_keys: readonly string[];
658
673
  unread_key_fails_open: boolean | null;
674
+ top_level_keys: readonly string[];
675
+ unread_top_level_key_fails_open: boolean | null;
659
676
  }>;
660
677
  /**
661
678
  * The translator for one agent's protocol. `parse` maps a native event to a
@@ -687,11 +704,11 @@ export type Adapter = {
687
704
  */
688
705
  PRE_TOOL_FILING: PreToolFiling;
689
706
  /**
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.
707
+ * 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
708
  */
692
709
  PRE_TOOL_RESPONSE: PreToolResponse;
693
710
  /**
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.
711
+ * 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
712
  */
696
713
  UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
697
714
  };