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 +39 -3
- package/package.json +1 -1
- package/schema/control-plane/v1/agents.json +54 -0
- package/src/adapters/amp.mjs +19 -0
- package/src/adapters/claude.mjs +28 -0
- package/src/adapters/codex.mjs +64 -0
- package/src/adapters/gemini.mjs +24 -0
- package/src/agent-manifest.mjs +7 -0
- package/src/conformance.mjs +139 -1
- package/src/control-plane.mjs +180 -4
- package/types/adapters/amp.d.mts +12 -0
- package/types/adapters/claude.d.mts +15 -0
- package/types/adapters/codex.d.mts +34 -0
- package/types/adapters/gemini.d.mts +17 -0
- package/types/conformance.d.mts +7 -1
- package/types/control-plane.d.mts +98 -4
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,
|
|
196
|
-
fields an allow cannot carry
|
|
197
|
-
|
|
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.
|
|
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
|
}
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -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
|
};
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -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
|
};
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -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
|
};
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -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
|
};
|
package/src/agent-manifest.mjs
CHANGED
|
@@ -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
|
|
package/src/conformance.mjs
CHANGED
|
@@ -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
|
}
|
package/src/control-plane.mjs
CHANGED
|
@@ -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`
|
|
44
|
-
* `
|
|
45
|
-
* adapter, and the conformance harness names the member
|
|
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
|
|
package/types/adapters/amp.d.mts
CHANGED
|
@@ -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;
|
package/types/conformance.d.mts
CHANGED
|
@@ -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`
|
|
358
|
-
* `
|
|
359
|
-
* adapter, and the conformance harness names the member
|
|
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
|
+
}>>>;
|