agent-control-plane-core 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -192,9 +192,45 @@ A consumer that cannot import these modules (a bash hook wrapper, a Python
192
192
  monitor) reads `schema/control-plane/v1/agents.json` instead. It is generated
193
193
  from the adapters, with one row per agent: its block exit code and reason
194
194
  stream, whether a block needs a reason, whether it has an ask tier, the entry's
195
- default response when the hook breaks, its hook coverage, and the verdict
196
- fields an allow cannot carry. Read the file from the installed package, so it
197
- always matches the adapters that version ships.
195
+ default response when the hook breaks, its hook coverage, the verdict
196
+ fields an allow cannot carry, 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.
205
+ - `pre_tool_filing`: how the pre-tool hook files a model's tool call, so a
206
+ monitor can match the model's calls against the hook's. It is keyed by model
207
+ tool name: the call's `name`, prefixed with its `namespace` with no
208
+ separator, unless the namespace is empty or Codex's default `functions`
209
+ (`flatToolName` in `/contract`). Each value is one of:
210
+ - `null`: no pre-tool hook fires for this call.
211
+ - `{"tool_name": T, "input": I}`: the hook files `tool_name` T, and builds
212
+ `tool_input` from I. I is either `"$"`, meaning the call's whole arguments
213
+ value, or an object whose values are each `"$"` or `"$.<key>"`, meaning
214
+ that top-level argument. `<key>` is one literal key, non-empty and with no
215
+ `.`. If a `"$.<key>"` does not resolve (the arguments are not an object,
216
+ lack the key, or hold `null` there), the hook files nothing.
217
+
218
+ A function call the map does not list is filed with the model's own name and
219
+ arguments. A freeform (custom) call the map does not list files nothing; of
220
+ the shipped hosts only Codex makes them. The map describes calls the host
221
+ dispatches: Codex files nothing for a call it rejects first, e.g. a name it
222
+ has no tool for, a call kind the tool does not take, or arguments a handler
223
+ refuses before its hook (an `apply_patch` patch that does not parse, an
224
+ `exec_command` with a non-string `workdir`). On Codex the arguments value is a function call's
225
+ `arguments` parsed as JSON (an empty or all-whitespace string reads as `{}`,
226
+ unparseable text stays the raw string), or a freeform call's raw `input`
227
+ string. `fileModelCall` in `/contract` is the reference implementation; it
228
+ takes the call's kind, `"function"` or `"freeform"`. The map does not model
229
+ Codex's MCP names: Codex files an MCP call as `mcp__<namespace>__<tool>`,
230
+ which can differ from the flat name.
231
+
232
+ Read the file from the installed package, so it always matches the adapters
233
+ that version ships.
198
234
 
199
235
  Each entry fails the way its host expects when the hook itself breaks: claude,
200
236
  codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -37,6 +37,17 @@
37
37
  "mutated_input",
38
38
  "mutated_output"
39
39
  ]
40
+ },
41
+ "pre_tool_filing": {},
42
+ "pre_tool_response": {
43
+ "hook_specific_output_keys": [
44
+ "additionalContext",
45
+ "hookEventName",
46
+ "permissionDecision",
47
+ "permissionDecisionReason",
48
+ "updatedInput"
49
+ ],
50
+ "unread_key_fails_open": null
40
51
  }
41
52
  },
42
53
  "codex": {
@@ -81,6 +92,37 @@
81
92
  "mutated_input",
82
93
  "mutated_output"
83
94
  ]
95
+ },
96
+ "pre_tool_filing": {
97
+ "exec_command": {
98
+ "tool_name": "Bash",
99
+ "input": {
100
+ "command": "$.cmd"
101
+ }
102
+ },
103
+ "apply_patch": {
104
+ "tool_name": "apply_patch",
105
+ "input": {
106
+ "command": "$"
107
+ }
108
+ },
109
+ "multi_agent_v1spawn_agent": {
110
+ "tool_name": "spawn_agent",
111
+ "input": "$"
112
+ },
113
+ "write_stdin": null,
114
+ "wait": null,
115
+ "exec": null
116
+ },
117
+ "pre_tool_response": {
118
+ "hook_specific_output_keys": [
119
+ "additionalContext",
120
+ "hookEventName",
121
+ "permissionDecision",
122
+ "permissionDecisionReason",
123
+ "updatedInput"
124
+ ],
125
+ "unread_key_fails_open": true
84
126
  }
85
127
  },
86
128
  "amp": {
@@ -127,6 +169,11 @@
127
169
  "mutated_input",
128
170
  "mutated_output"
129
171
  ]
172
+ },
173
+ "pre_tool_filing": {},
174
+ "pre_tool_response": {
175
+ "hook_specific_output_keys": [],
176
+ "unread_key_fails_open": false
130
177
  }
131
178
  },
132
179
  "gemini": {
@@ -169,6 +216,13 @@
169
216
  "mutated_input",
170
217
  "mutated_output"
171
218
  ]
219
+ },
220
+ "pre_tool_filing": {},
221
+ "pre_tool_response": {
222
+ "hook_specific_output_keys": [
223
+ "tool_input"
224
+ ],
225
+ "unread_key_fails_open": false
172
226
  }
173
227
  }
174
228
  }
@@ -169,6 +169,23 @@ export function parse(native) {
169
169
  */
170
170
  export const NATIVE_ASK_TIER = true;
171
171
 
172
+ /**
173
+ * No rename is modelled: no source shows Amp filing a call under a
174
+ * name or input other than the model's.
175
+ * @type {import("../control-plane.mjs").PreToolFiling}
176
+ */
177
+ export const PRE_TOOL_FILING = Object.freeze({});
178
+
179
+ /**
180
+ * Amp reads only the hook's exit code and no body, so no key in one can change
181
+ * the outcome.
182
+ * @type {import("../control-plane.mjs").PreToolResponse}
183
+ */
184
+ export const PRE_TOOL_RESPONSE = Object.freeze({
185
+ hook_specific_output_keys: Object.freeze([]),
186
+ unread_key_fails_open: false,
187
+ });
188
+
172
189
  /**
173
190
  * The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
174
191
  * pure exit-code transport: 0 allow / 1 ask / 2 reject.
@@ -288,6 +305,8 @@ export const ampAdapter = {
288
305
  UNRENDERED_FIELDS,
289
306
  NATIVE_ASK_TIER,
290
307
  NATIVE_EVENT_FOR,
308
+ PRE_TOOL_FILING,
309
+ PRE_TOOL_RESPONSE,
291
310
  parse,
292
311
  render,
293
312
  };
@@ -125,6 +125,32 @@ export const UNRENDERED_FIELDS = Object.freeze({
125
125
  */
126
126
  export const NATIVE_ASK_TIER = true;
127
127
 
128
+ /**
129
+ * No rename is modelled: no source shows Claude Code filing a call under a
130
+ * name or input other than the model's.
131
+ * @type {import("../control-plane.mjs").PreToolFiling}
132
+ */
133
+ export const PRE_TOOL_FILING = Object.freeze({});
134
+
135
+ /**
136
+ * The `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.
141
+ * @type {import("../control-plane.mjs").PreToolResponse}
142
+ */
143
+ export const PRE_TOOL_RESPONSE = Object.freeze({
144
+ hook_specific_output_keys: Object.freeze([
145
+ "additionalContext",
146
+ "hookEventName",
147
+ "permissionDecision",
148
+ "permissionDecisionReason",
149
+ "updatedInput",
150
+ ]),
151
+ unread_key_fails_open: null,
152
+ });
153
+
128
154
  /** Claude Code native hook event names (the `hook_event_name` field). */
129
155
  export const HookEvent = Object.freeze({
130
156
  PRE_TOOL_USE: "PreToolUse",
@@ -339,6 +365,8 @@ export const claudeAdapter = {
339
365
  UNRENDERED_FIELDS,
340
366
  NATIVE_ASK_TIER,
341
367
  NATIVE_EVENT_FOR,
368
+ PRE_TOOL_FILING,
369
+ PRE_TOOL_RESPONSE,
342
370
  parse,
343
371
  render,
344
372
  };
@@ -39,6 +39,7 @@ import {
39
39
  asObject,
40
40
  asString,
41
41
  asStringOrNull,
42
+ WHOLE_ARGUMENTS,
42
43
  } from "../control-plane.mjs";
43
44
 
44
45
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
@@ -130,6 +131,67 @@ export const UNRENDERED_FIELDS = Object.freeze({
130
131
  [EventKind.POST_TOOL]: readonlySet(["mutated_input", "mutated_output"]),
131
132
  });
132
133
 
134
+ /**
135
+ * How Codex's `PreToolUse` hook files a model tool call ([X7], read from
136
+ * Codex's source at `rust-v0.166.0`). A function tool is filed under its flat
137
+ * name (namespace + name) with its parsed arguments by default; these
138
+ * handlers supply their own payload or none:
139
+ * - `exec_command` → `Bash` with `{command: <cmd>}`; arguments Codex cannot
140
+ * parse as `ExecCommandArgs` file nothing, which this table models only for
141
+ * a missing or null `cmd` or scalar arguments. Not modelled: other type
142
+ * errors, and a positional array, which serde reads (`["ls"]` is `cmd: "ls"`).
143
+ * - `apply_patch` (a freeform tool) → `apply_patch` with its raw patch text as
144
+ * `command`.
145
+ * - `spawn_agent` in the `multi_agent_v1` namespace → `spawn_agent`.
146
+ * - `write_stdin`, code mode's `wait`, and code mode's freeform `exec` → no hook.
147
+ * These rules assume the named tool owns the name; a client-defined tool
148
+ * called `wait` with code mode off takes the default payload.
149
+ * The default payload is function-only, so a freeform extension tool files
150
+ * nothing too — the mini-language's rule for an unlisted freeform call.
151
+ * NOT MODELLED: Codex files an MCP call as `mcp__<namespace>__<tool>`, with
152
+ * `_` trimmed from the namespace's end and the tool's start (`mcp.rs`
153
+ * `hook_tool_name`), which can differ from the flat name — a rule keyed by
154
+ * one name cannot express that.
155
+ * @type {import("../control-plane.mjs").PreToolFiling}
156
+ */
157
+ export const PRE_TOOL_FILING = Object.freeze({
158
+ exec_command: Object.freeze({
159
+ tool_name: "Bash",
160
+ input: Object.freeze({ command: "$.cmd" }),
161
+ }),
162
+ apply_patch: Object.freeze({
163
+ tool_name: "apply_patch",
164
+ input: Object.freeze({ command: WHOLE_ARGUMENTS }),
165
+ }),
166
+ multi_agent_v1spawn_agent: Object.freeze({
167
+ tool_name: "spawn_agent",
168
+ input: WHOLE_ARGUMENTS,
169
+ }),
170
+ write_stdin: null,
171
+ wait: null,
172
+ exec: null,
173
+ });
174
+
175
+ /**
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.
182
+ * @type {import("../control-plane.mjs").PreToolResponse}
183
+ */
184
+ export const PRE_TOOL_RESPONSE = Object.freeze({
185
+ hook_specific_output_keys: Object.freeze([
186
+ "additionalContext",
187
+ "hookEventName",
188
+ "permissionDecision",
189
+ "permissionDecisionReason",
190
+ "updatedInput",
191
+ ]),
192
+ unread_key_fails_open: true,
193
+ });
194
+
133
195
  /** Minimum Codex version whose hook can actually veto a tool call. */
134
196
  export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
135
197
 
@@ -440,6 +502,8 @@ export function createCodexAdapter(options = {}) {
440
502
  UNRENDERED_FIELDS,
441
503
  NATIVE_ASK_TIER,
442
504
  NATIVE_EVENT_FOR,
505
+ PRE_TOOL_FILING,
506
+ PRE_TOOL_RESPONSE,
443
507
  parse: (native) => parse(native, { version }),
444
508
  render,
445
509
  };
@@ -357,6 +357,28 @@ export function parse(native) {
357
357
  */
358
358
  export const NATIVE_ASK_TIER = false;
359
359
 
360
+ /**
361
+ * No rename is modelled. Gemini CLI hands `BeforeTool` the invocation's tool
362
+ * name beside an `originalRequestName`, so an aliased call may be filed under
363
+ * a name the model did not use; that is unverified, so not published.
364
+ * @type {import("../control-plane.mjs").PreToolFiling}
365
+ */
366
+ export const PRE_TOOL_FILING = Object.freeze({});
367
+
368
+ /**
369
+ * Gemini CLI keeps `hookSpecificOutput` as a `Record<string, unknown>` and its
370
+ * `BeforeTool` path reads only `tool_input` from it, by `in` lookup, so any
371
+ * other key is ignored (`packages/core/src/hooks/types.ts`
372
+ * `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
373
+ * and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
374
+ * `62bab5b2`).
375
+ * @type {import("../control-plane.mjs").PreToolResponse}
376
+ */
377
+ export const PRE_TOOL_RESPONSE = Object.freeze({
378
+ hook_specific_output_keys: Object.freeze(["tool_input"]),
379
+ unread_key_fails_open: false,
380
+ });
381
+
360
382
  /**
361
383
  * Render into Gemini CLI's native external-hook transport. An enforceable deny
362
384
  * renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
@@ -477,6 +499,8 @@ export const geminiAdapter = {
477
499
  UNRENDERED_FIELDS,
478
500
  NATIVE_ASK_TIER,
479
501
  NATIVE_EVENT_FOR,
502
+ PRE_TOOL_FILING,
503
+ PRE_TOOL_RESPONSE,
480
504
  parse,
481
505
  render,
482
506
  };
@@ -31,6 +31,11 @@ import { HOST_FAILURE } from "./runtime.mjs";
31
31
  * - `coverage`: `COVERAGE`, per call class.
32
32
  * - `unrendered_fields_on_allow`: `UNRENDERED_FIELDS`, each set as a sorted
33
33
  * list. It describes the allow path only: a deny can drop more.
34
+ * - `pre_tool_filing`: `PRE_TOOL_FILING`, how the pre-tool hook files a model
35
+ * 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).
34
39
  * @param {string} id
35
40
  * @returns {Record<string, unknown>}
36
41
  */
@@ -55,6 +60,8 @@ function manifestRow(id) {
55
60
  ].sort(),
56
61
  ]),
57
62
  ),
63
+ pre_tool_filing: adapter.PRE_TOOL_FILING,
64
+ pre_tool_response: adapter.PRE_TOOL_RESPONSE,
58
65
  };
59
66
  }
60
67
 
@@ -11,6 +11,9 @@ import {
11
11
  isCoverageStatus,
12
12
  lookup,
13
13
  VERDICT_CONTENT_FIELDS,
14
+ WHOLE_ARGUMENTS,
15
+ FILING_RULE_GRAMMAR,
16
+ isFilingRule,
14
17
  } from "./control-plane.mjs";
15
18
 
16
19
  /**
@@ -623,6 +626,8 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
623
626
  );
624
627
  const paths = divergences(renders);
625
628
  const where = `'${caseName}' (${adapter.AGENT}), ${field} ${shape}`;
629
+ for (const rendered of renders)
630
+ assertPreToolKeysRead(adapter, event, rendered, where, assert);
626
631
  if (declared?.has(field)) {
627
632
  assert.deepEqual(
628
633
  paths,
@@ -712,6 +717,107 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
712
717
  }
713
718
  }
714
719
 
720
+ /**
721
+ * 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.
728
+ * @param {import("./control-plane.mjs").Adapter} adapter
729
+ * @param {import("./control-plane.mjs").ToolCallEvent} event
730
+ * @param {import("./control-plane.mjs").NativeResponse} rendered
731
+ * @param {string} label
732
+ * @param {any} assert
733
+ * @returns {boolean} whether the render was on the pre-tool event and checked
734
+ */
735
+ 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;
745
+ // `?.`: 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
+ );
767
+ return true;
768
+ }
769
+
770
+ /**
771
+ * 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.
777
+ * @param {import("./control-plane.mjs").Adapter} adapter
778
+ * @param {any} assert
779
+ */
780
+ function assertPreToolDeclarationsWellFormed(adapter, assert) {
781
+ const agent = adapter.AGENT;
782
+ const filing = adapter.PRE_TOOL_FILING;
783
+ 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`,
786
+ );
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`);
791
+ assert.ok(
792
+ isFilingRule(rule),
793
+ `${where} is ${JSON.stringify(rule)}, not ${FILING_RULE_GRAMMAR}`,
794
+ );
795
+ if (rule.input !== WHOLE_ARGUMENTS)
796
+ assert.ok(Object.isFrozen(rule.input), `${where}.input is not frozen`);
797
+ }
798
+ const response = adapter.PRE_TOOL_RESPONSE;
799
+ 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`,
818
+ );
819
+ }
820
+
715
821
  /**
716
822
  * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
717
823
  * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
@@ -962,13 +1068,18 @@ function assertBlockTransport(agent, rendered, label, assert) {
962
1068
  * decide where an `ask` must be escalated to a `deny`, so a wrong one
963
1069
  * either lets a call through or denies every asked call on the hosts that
964
1070
  * do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
1071
+ * 12. pre-tool key honesty: the adapter declares a well-formed
1072
+ * `PRE_TOOL_FILING` and `PRE_TOOL_RESPONSE`, and every render on the host's
1073
+ * native pre-tool event — fixture, rule ⑧ and rule ⑩ probes alike — puts
1074
+ * in `hookSpecificOutput` only keys `PRE_TOOL_RESPONSE` says the host
1075
+ * reads. At least one such render must be checked.
965
1076
  *
966
1077
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
967
1078
  * neutral; it throws on the first mismatch. Returns a summary the caller can
968
1079
  * assert further on.
969
1080
  *
970
1081
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
971
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number }}
1082
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number, preToolKeyChecks: number }}
972
1083
  */
973
1084
  export function runAdapterConformance({ adapter, fixtures, assert }) {
974
1085
  assert.equal(
@@ -987,6 +1098,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
987
1098
  "boolean",
988
1099
  `${adapter.AGENT}: adapter declares no NATIVE_ASK_TIER — every adapter needs a boolean saying whether this host honours a distinct "ask the human" tier (false when render collapses ask onto another signal)`,
989
1100
  );
1101
+ assertPreToolDeclarationsWellFormed(adapter, assert);
990
1102
 
991
1103
  /** @type {Set<string>} */
992
1104
  const decisionsSeen = new Set();
@@ -1003,6 +1115,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1003
1115
  let preToolCases = 0;
1004
1116
  let unenforceableDenyChecks = 0;
1005
1117
  let askTierChecks = 0;
1118
+ let preToolKeyChecks = 0;
1006
1119
  let renders = 0;
1007
1120
 
1008
1121
  for (const testCase of fixtures.cases) {
@@ -1057,6 +1170,16 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1057
1170
  spec.native,
1058
1171
  `render mismatch: ${testCase.name} / ${scenario}`,
1059
1172
  );
1173
+ if (
1174
+ assertPreToolKeysRead(
1175
+ adapter,
1176
+ parsed,
1177
+ rendered,
1178
+ `${testCase.name} / ${scenario}`,
1179
+ assert,
1180
+ )
1181
+ )
1182
+ preToolKeyChecks += 1;
1060
1183
  if (rendered.enforced) {
1061
1184
  assert.ok(
1062
1185
  rendered.exit_code !== 0,
@@ -1148,6 +1271,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1148
1271
  // An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
1149
1272
  // no pre-emption channel at all, so its deny and allow are identical by
1150
1273
  // construction. Demanding a distinct signal there would demand a fiction.
1274
+ assertPreToolKeysRead(
1275
+ adapter,
1276
+ unenforceable,
1277
+ deniedRender,
1278
+ `rule ⑧ probe: ${testCase.name}`,
1279
+ assert,
1280
+ );
1151
1281
  if (deniedRender.transport !== IntegrationMode.OBSERVE_ONLY)
1152
1282
  assert.notDeepEqual(
1153
1283
  deniedRender,
@@ -1225,6 +1355,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1225
1355
  `rule ⑪ (ask-tier declaration) ran on ${askTierChecks} of ${preToolCases} pre-tool cases`,
1226
1356
  );
1227
1357
 
1358
+ // A suite with pre-tool cases must reach the native pre-tool event at least
1359
+ // once, or a NATIVE_EVENT_FOR naming the wrong event would skip every render.
1360
+ assert.ok(
1361
+ preToolCases === 0 || preToolKeyChecks > 0,
1362
+ `rule ⑫ (pre-tool key honesty) checked no fixture render on ${adapter.AGENT}'s native pre-tool event — the suite is vacuous`,
1363
+ );
1364
+
1228
1365
  return {
1229
1366
  cases: fixtures.cases.length,
1230
1367
  renders,
@@ -1237,5 +1374,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1237
1374
  coverageClassesChecked,
1238
1375
  unenforceableDenyChecks,
1239
1376
  askTierChecks,
1377
+ preToolKeyChecks,
1240
1378
  };
1241
1379
  }
@@ -40,10 +40,10 @@
40
40
  * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
41
41
  * member breaks every third-party adapter while every event on the wire stays
42
42
  * byte-identical, so it is a package-semver break and leaves
43
- * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one and
44
- * `NATIVE_ASK_TIER` is the second: adding either is a one-line change per
45
- * adapter, and the conformance harness names the member and the migration
46
- * rather than throwing from inside itself.
43
+ * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS`, `NATIVE_ASK_TIER`,
44
+ * `PRE_TOOL_FILING` and `PRE_TOOL_RESPONSE` are such members: adding one is a
45
+ * one-line change per adapter, and the conformance harness names the member
46
+ * and the migration rather than throwing from inside itself.
47
47
  */
48
48
 
49
49
  /** Wire identifier for this schema version; bump on a breaking shape change. */
@@ -493,6 +493,180 @@ export function readonlySet(values) {
493
493
  */
494
494
  export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
495
495
 
496
+ /** The {@link PreToolFiling} input reference to a call's whole arguments value. */
497
+ export const WHOLE_ARGUMENTS = "$";
498
+
499
+ /** The prefix of a {@link PreToolFiling} input reference to one top-level argument. */
500
+ export const ARGUMENT_KEY_PREFIX = "$.";
501
+
502
+ /**
503
+ * How a host's pre-tool hook files a model's tool call, keyed by MODEL tool
504
+ * name, the {@link flatToolName} of the call's `namespace` and `name`. Each
505
+ * value is one of:
506
+ * - `null`: no pre-tool hook fires for this call.
507
+ * - `{ tool_name, input }`: the hook fires with `tool_name`, and `tool_input`
508
+ * built from `input` — either `"$"` (the call's whole arguments value), or
509
+ * an object whose every value is `"$"` or `"$.<key>"`. `<key>` is one
510
+ * literal, non-empty top-level key with no `.` in it. The hook files NOTHING
511
+ * when a `"$.<key>"` does not resolve: the arguments are not an object, lack
512
+ * the key, or hold `null` there.
513
+ * A FUNCTION call the map does not list is filed as the model asked: same
514
+ * name, whole arguments. A FREEFORM (custom) call the map does not list files
515
+ * nothing. Of the shipped hosts only Codex makes freeform calls ([X7] in
516
+ * `docs/hook-coverage-matrix.md`).
517
+ * The map describes a call the host DISPATCHES. Codex files nothing for a call
518
+ * it rejects first: a name it has no tool for, a call kind the tool does not
519
+ * take (each listed rule assumes the tool's own kind), or arguments the
520
+ * handler refuses before its hook (`apply_patch` whose patch does not parse).
521
+ * On Codex the ARGUMENTS VALUE is a function call's `arguments` parsed as JSON
522
+ * (an empty or all-whitespace string reads as `{}`, unparseable text stays the
523
+ * raw string), or a freeform call's raw `input` string.
524
+ * Plain JSON by design: `agents.json` publishes it so a consumer with no JS
525
+ * runtime applies it the way {@link fileModelCall} does.
526
+ * @typedef {Readonly<Record<string, null | Readonly<{ tool_name: string, input: string | Readonly<Record<string, string>> }>>>} PreToolFiling
527
+ */
528
+
529
+ /** Codex's default namespace, which it stamps on every un-namespaced call. */
530
+ const DEFAULT_FUNCTION_NAMESPACE = "functions";
531
+
532
+ /**
533
+ * The model tool name a {@link PreToolFiling} is keyed by: `name`, prefixed
534
+ * (no separator) with `namespace` unless that is absent, empty, or Codex's
535
+ * default `functions` namespace (`flat_tool_name`, `is_default_namespace`;
536
+ * [X7] in `docs/hook-coverage-matrix.md`).
537
+ * @param {string | null | undefined} namespace
538
+ * @param {string} name
539
+ * @returns {string}
540
+ */
541
+ export function flatToolName(namespace, name) {
542
+ if (!namespace || namespace === DEFAULT_FUNCTION_NAMESPACE) return name;
543
+ return namespace + name;
544
+ }
545
+
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
555
+ */
556
+
557
+ /**
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}
562
+ */
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(".");
569
+ }
570
+
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
+ }
586
+
587
+ /** 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 ".")}`;
589
+
590
+ /**
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.
594
+ * @param {unknown} rule
595
+ * @returns {boolean}
596
+ */
597
+ 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);
602
+ return (
603
+ keys.length === 2 &&
604
+ keys[0] === "input" &&
605
+ keys[1] === "tool_name" &&
606
+ typeof tool_name === "string" &&
607
+ 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),
629
+ );
630
+ return value === null ? undefined : value;
631
+ }
632
+
633
+ /**
634
+ * The call a host's pre-tool hook files for a model call of `kind` to `name`
635
+ * with arguments value `args`, per that host's {@link PreToolFiling}; `null`
636
+ * when the hook files none. The reference reading of the mini-language
637
+ * `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
638
+ * grammar or a kind outside {@link MODEL_CALL_KINDS}.
639
+ * @param {PreToolFiling} filing
640
+ * @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
641
+ * @param {string} name the model tool name, a {@link flatToolName}
642
+ * @param {unknown} args the call's arguments value
643
+ * @returns {{ tool_name: string, tool_input: unknown } | null}
644
+ */
645
+ export function fileModelCall(filing, kind, name, args) {
646
+ if (!MODEL_CALL_KINDS.has(kind))
647
+ throw new Error(
648
+ `control-plane: model call kind ${JSON.stringify(kind)} is neither "function" nor "freeform"`,
649
+ );
650
+ if (!Object.hasOwn(filing, name))
651
+ return kind === "function" ? { tool_name: name, tool_input: args } : null;
652
+ const rule = filing[name];
653
+ if (rule === null) return null;
654
+ if (!isFilingRule(rule))
655
+ throw new Error(
656
+ `control-plane: filing rule for ${JSON.stringify(name)} is ${JSON.stringify(rule)}, not ${FILING_RULE_GRAMMAR}`,
657
+ );
658
+ if (rule.input === WHOLE_ARGUMENTS)
659
+ 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
+ ]);
664
+ if (entries.some(([, value]) => value === undefined)) return null;
665
+ // fromEntries defines own properties, so an input key `__proto__` stays a
666
+ // key, as it is in a Python consumer's dict, instead of hitting the setter.
667
+ return { tool_name: rule.tool_name, tool_input: Object.fromEntries(entries) };
668
+ }
669
+
496
670
  /**
497
671
  * PROBLEM CLASS — an event marked vetoable on a path where the host can never
498
672
  * veto it, so a deny reads downstream as a block that never happened.
@@ -530,6 +704,8 @@ export const VETO_IMPOSSIBLE = Object.freeze([
530
704
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
531
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`.
532
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
+ * @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.
533
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.
534
710
  */
535
711
 
@@ -107,6 +107,18 @@ export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
107
107
  * only honest because the tier is real.
108
108
  */
109
109
  export const NATIVE_ASK_TIER: true;
110
+ /**
111
+ * No rename is modelled: no source shows Amp filing a call under a
112
+ * name or input other than the model's.
113
+ * @type {import("../control-plane.mjs").PreToolFiling}
114
+ */
115
+ export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
116
+ /**
117
+ * Amp reads only the hook's exit code and no body, so no key in one can change
118
+ * the outcome.
119
+ * @type {import("../control-plane.mjs").PreToolResponse}
120
+ */
121
+ export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
110
122
  /** @type {import("../control-plane.mjs").Adapter} */
111
123
  export const ampAdapter: import("../control-plane.mjs").Adapter;
112
124
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -75,6 +75,21 @@ export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
75
75
  * host did, pending an answer.
76
76
  */
77
77
  export const NATIVE_ASK_TIER: true;
78
+ /**
79
+ * No rename is modelled: no source shows Claude Code filing a call under a
80
+ * name or input other than the model's.
81
+ * @type {import("../control-plane.mjs").PreToolFiling}
82
+ */
83
+ export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
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.
90
+ * @type {import("../control-plane.mjs").PreToolResponse}
91
+ */
92
+ export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
78
93
  /** Claude Code native hook event names (the `hook_event_name` field). */
79
94
  export const HookEvent: Readonly<{
80
95
  PRE_TOOL_USE: "PreToolUse";
@@ -119,6 +119,40 @@ export const COVERAGE: import("../control-plane.mjs").CoverageMap;
119
119
  * @type {Record<string, ReadonlySet<string>|undefined>}
120
120
  */
121
121
  export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
122
+ /**
123
+ * How Codex's `PreToolUse` hook files a model tool call ([X7], read from
124
+ * Codex's source at `rust-v0.166.0`). A function tool is filed under its flat
125
+ * name (namespace + name) with its parsed arguments by default; these
126
+ * handlers supply their own payload or none:
127
+ * - `exec_command` → `Bash` with `{command: <cmd>}`; arguments Codex cannot
128
+ * parse as `ExecCommandArgs` file nothing, which this table models only for
129
+ * a missing or null `cmd` or scalar arguments. Not modelled: other type
130
+ * errors, and a positional array, which serde reads (`["ls"]` is `cmd: "ls"`).
131
+ * - `apply_patch` (a freeform tool) → `apply_patch` with its raw patch text as
132
+ * `command`.
133
+ * - `spawn_agent` in the `multi_agent_v1` namespace → `spawn_agent`.
134
+ * - `write_stdin`, code mode's `wait`, and code mode's freeform `exec` → no hook.
135
+ * These rules assume the named tool owns the name; a client-defined tool
136
+ * called `wait` with code mode off takes the default payload.
137
+ * The default payload is function-only, so a freeform extension tool files
138
+ * nothing too — the mini-language's rule for an unlisted freeform call.
139
+ * NOT MODELLED: Codex files an MCP call as `mcp__<namespace>__<tool>`, with
140
+ * `_` trimmed from the namespace's end and the tool's start (`mcp.rs`
141
+ * `hook_tool_name`), which can differ from the flat name — a rule keyed by
142
+ * one name cannot express that.
143
+ * @type {import("../control-plane.mjs").PreToolFiling}
144
+ */
145
+ export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
146
+ /**
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
+ * @type {import("../control-plane.mjs").PreToolResponse}
154
+ */
155
+ export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
122
156
  /** Minimum Codex version whose hook can actually veto a tool call. */
123
157
  export const MIN_ENFORCING_VERSION: readonly number[];
124
158
  /**
@@ -126,6 +126,23 @@ export const GEMINI_TOOL_ALIASES: Readonly<Record<string, string>>;
126
126
  * here, because an un-escalated ask lets Gemini run the tool.
127
127
  */
128
128
  export const NATIVE_ASK_TIER: false;
129
+ /**
130
+ * No rename is modelled. Gemini CLI hands `BeforeTool` the invocation's tool
131
+ * name beside an `originalRequestName`, so an aliased call may be filed under
132
+ * a name the model did not use; that is unverified, so not published.
133
+ * @type {import("../control-plane.mjs").PreToolFiling}
134
+ */
135
+ export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
136
+ /**
137
+ * Gemini CLI keeps `hookSpecificOutput` as a `Record<string, unknown>` and its
138
+ * `BeforeTool` path reads only `tool_input` from it, by `in` lookup, so any
139
+ * other key is ignored (`packages/core/src/hooks/types.ts`
140
+ * `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
141
+ * and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
142
+ * `62bab5b2`).
143
+ * @type {import("../control-plane.mjs").PreToolResponse}
144
+ */
145
+ export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
129
146
  /** @type {import("../control-plane.mjs").Adapter} */
130
147
  export const geminiAdapter: import("../control-plane.mjs").Adapter;
131
148
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -132,13 +132,18 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
132
132
  * decide where an `ask` must be escalated to a `deny`, so a wrong one
133
133
  * either lets a call through or denies every asked call on the hosts that
134
134
  * do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
135
+ * 12. pre-tool key honesty: the adapter declares a well-formed
136
+ * `PRE_TOOL_FILING` and `PRE_TOOL_RESPONSE`, and every render on the host's
137
+ * native pre-tool event — fixture, rule ⑧ and rule ⑩ probes alike — puts
138
+ * in `hookSpecificOutput` only keys `PRE_TOOL_RESPONSE` says the host
139
+ * reads. At least one such render must be checked.
135
140
  *
136
141
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
137
142
  * neutral; it throws on the first mismatch. Returns a summary the caller can
138
143
  * assert further on.
139
144
  *
140
145
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
141
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number }}
146
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number, preToolKeyChecks: number }}
142
147
  */
143
148
  export function runAdapterConformance({ adapter, fixtures, assert }: {
144
149
  adapter: import("./control-plane.mjs").Adapter;
@@ -156,4 +161,5 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
156
161
  coverageClassesChecked: Set<string>;
157
162
  unenforceableDenyChecks: number;
158
163
  askTierChecks: number;
164
+ preToolKeyChecks: number;
159
165
  };
@@ -124,6 +124,40 @@ export function classifyCallClass(tool: string | null, native?: Record<string, u
124
124
  * @returns {ReadonlySet<string>}
125
125
  */
126
126
  export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
127
+ /**
128
+ * The model tool name a {@link PreToolFiling} is keyed by: `name`, prefixed
129
+ * (no separator) with `namespace` unless that is absent, empty, or Codex's
130
+ * default `functions` namespace (`flat_tool_name`, `is_default_namespace`;
131
+ * [X7] in `docs/hook-coverage-matrix.md`).
132
+ * @param {string | null | undefined} namespace
133
+ * @param {string} name
134
+ * @returns {string}
135
+ */
136
+ export function flatToolName(namespace: string | null | undefined, name: string): string;
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.
141
+ * @param {unknown} rule
142
+ * @returns {boolean}
143
+ */
144
+ export function isFilingRule(rule: unknown): boolean;
145
+ /**
146
+ * The call a host's pre-tool hook files for a model call of `kind` to `name`
147
+ * with arguments value `args`, per that host's {@link PreToolFiling}; `null`
148
+ * when the hook files none. The reference reading of the mini-language
149
+ * `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
150
+ * grammar or a kind outside {@link MODEL_CALL_KINDS}.
151
+ * @param {PreToolFiling} filing
152
+ * @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
153
+ * @param {string} name the model tool name, a {@link flatToolName}
154
+ * @param {unknown} args the call's arguments value
155
+ * @returns {{ tool_name: string, tool_input: unknown } | null}
156
+ */
157
+ export function fileModelCall(filing: PreToolFiling, kind: "function" | "freeform", name: string, args: unknown): {
158
+ tool_name: string;
159
+ tool_input: unknown;
160
+ } | null;
127
161
  /**
128
162
  * The translator for one agent's protocol. `parse` maps a native event to a
129
163
  * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
@@ -137,6 +171,8 @@ export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
137
171
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
138
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`.
139
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
+ * @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.
140
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.
141
177
  */
142
178
  /**
@@ -354,10 +390,10 @@ export function renderEscalated(adapter: Adapter, verdict: Verdict, event: ToolC
354
390
  * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
355
391
  * member breaks every third-party adapter while every event on the wire stays
356
392
  * byte-identical, so it is a package-semver break and leaves
357
- * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one and
358
- * `NATIVE_ASK_TIER` is the second: adding either is a one-line change per
359
- * adapter, and the conformance harness names the member and the migration
360
- * rather than throwing from inside itself.
393
+ * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS`, `NATIVE_ASK_TIER`,
394
+ * `PRE_TOOL_FILING` and `PRE_TOOL_RESPONSE` are such members: adding one is a
395
+ * one-line change per adapter, and the conformance harness names the member
396
+ * and the migration rather than throwing from inside itself.
361
397
  */
362
398
  /** Wire identifier for this schema version; bump on a breaking shape change. */
363
399
  export const CONTROL_PLANE_SCHEMA: "control-plane/v1";
@@ -544,6 +580,14 @@ export const VERDICT_CONTENT_FIELDS: readonly string[];
544
580
  * ignores the key it was written into.
545
581
  */
546
582
  export const UNRENDERED_ON_UNKNOWN: ReadonlySet<string>;
583
+ /** The {@link PreToolFiling} input reference to a call's whole arguments value. */
584
+ 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
+ /** What a {@link PreToolFiling} rule must be, for error messages. */
588
+ 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>;
547
591
  /**
548
592
  * PROBLEM CLASS — an event marked vetoable on a path where the host can never
549
593
  * veto it, so a deny reads downstream as a block that never happened.
@@ -600,6 +644,19 @@ export const BLOCK_TRANSPORT: Readonly<Record<string, Readonly<{
600
644
  reason_stream: "stderr";
601
645
  reason_required: boolean;
602
646
  }>>>;
647
+ /**
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]).
655
+ */
656
+ export type PreToolResponse = Readonly<{
657
+ hook_specific_output_keys: readonly string[];
658
+ unread_key_fails_open: boolean | null;
659
+ }>;
603
660
  /**
604
661
  * The translator for one agent's protocol. `parse` maps a native event to a
605
662
  * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
@@ -625,6 +682,14 @@ export type Adapter = {
625
682
  * 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.
626
683
  */
627
684
  NATIVE_ASK_TIER: boolean;
685
+ /**
686
+ * 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.
687
+ */
688
+ PRE_TOOL_FILING: PreToolFiling;
689
+ /**
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.
691
+ */
692
+ PRE_TOOL_RESPONSE: PreToolResponse;
628
693
  /**
629
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.
630
695
  */
@@ -739,3 +804,32 @@ export type Verdict = {
739
804
  */
740
805
  reason?: string | undefined;
741
806
  };
807
+ /**
808
+ * How a host's pre-tool hook files a model's tool call, keyed by MODEL tool
809
+ * name, the {@link flatToolName} of the call's `namespace` and `name`. Each
810
+ * value is one of:
811
+ * - `null`: no pre-tool hook fires for this call.
812
+ * - `{ tool_name, input }`: the hook fires with `tool_name`, and `tool_input`
813
+ * built from `input` — either `"$"` (the call's whole arguments value), or
814
+ * an object whose every value is `"$"` or `"$.<key>"`. `<key>` is one
815
+ * literal, non-empty top-level key with no `.` in it. The hook files NOTHING
816
+ * when a `"$.<key>"` does not resolve: the arguments are not an object, lack
817
+ * the key, or hold `null` there.
818
+ * A FUNCTION call the map does not list is filed as the model asked: same
819
+ * name, whole arguments. A FREEFORM (custom) call the map does not list files
820
+ * nothing. Of the shipped hosts only Codex makes freeform calls ([X7] in
821
+ * `docs/hook-coverage-matrix.md`).
822
+ * The map describes a call the host DISPATCHES. Codex files nothing for a call
823
+ * it rejects first: a name it has no tool for, a call kind the tool does not
824
+ * take (each listed rule assumes the tool's own kind), or arguments the
825
+ * handler refuses before its hook (`apply_patch` whose patch does not parse).
826
+ * On Codex the ARGUMENTS VALUE is a function call's `arguments` parsed as JSON
827
+ * (an empty or all-whitespace string reads as `{}`, unparseable text stays the
828
+ * raw string), or a freeform call's raw `input` string.
829
+ * Plain JSON by design: `agents.json` publishes it so a consumer with no JS
830
+ * runtime applies it the way {@link fileModelCall} does.
831
+ */
832
+ export type PreToolFiling = Readonly<Record<string, null | Readonly<{
833
+ tool_name: string;
834
+ input: string | Readonly<Record<string, string>>;
835
+ }>>>;