agent-control-plane-core 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/package.json +1 -1
- package/schema/control-plane/v1/agents.json +36 -5
- package/src/adapters/amp.mjs +2 -0
- package/src/adapters/claude.mjs +15 -5
- package/src/adapters/codex.mjs +106 -60
- package/src/adapters/gemini.mjs +13 -1
- package/src/agent-manifest.mjs +12 -5
- package/src/conformance.mjs +113 -74
- package/src/control-plane.mjs +41 -77
- package/src/fixtures/codex.json +11 -5
- package/types/adapters/claude.d.mts +5 -5
- package/types/adapters/codex.d.mts +27 -18
- package/types/adapters/gemini.d.mts +3 -1
- package/types/control-plane.d.mts +36 -19
package/README.md
CHANGED
|
@@ -193,15 +193,18 @@ monitor) reads `schema/control-plane/v1/agents.json` instead. It is generated
|
|
|
193
193
|
from the adapters, with one row per agent: its block exit code and reason
|
|
194
194
|
stream, whether a block needs a reason, whether it has an ask tier, the entry's
|
|
195
195
|
default response when the hook breaks, its hook coverage, the verdict
|
|
196
|
-
fields an allow cannot carry
|
|
197
|
-
|
|
198
|
-
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
196
|
+
fields an allow cannot carry on each kind's main native event (Codex's
|
|
197
|
+
`PermissionRequest`, which also parses as `pre_tool`, carries none), and two
|
|
198
|
+
pre-tool facts:
|
|
199
|
+
|
|
200
|
+
- `pre_tool_response`: the keys the host reads on an exit-0 pre-tool answer,
|
|
201
|
+
as two sorted lists: `top_level_keys` for the body's top level and
|
|
202
|
+
`hook_specific_output_keys` for `hookSpecificOutput`. Each has a bit saying
|
|
203
|
+
what any other key there does, `unread_top_level_key_fails_open` and
|
|
204
|
+
`unread_key_fails_open`: `true` means the host discards the whole body and
|
|
205
|
+
runs the call (Codex, at both levels), `false` means the key is ignored,
|
|
206
|
+
`null` means unknown. The conformance harness fails an adapter whose pre-tool
|
|
207
|
+
render emits a key outside either list.
|
|
205
208
|
- `pre_tool_filing`: how the pre-tool hook files a model's tool call, so a
|
|
206
209
|
monitor can match the model's calls against the hook's. It is keyed by model
|
|
207
210
|
tool name: the call's `name`, prefixed with its `namespace` with no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -47,7 +47,17 @@
|
|
|
47
47
|
"permissionDecisionReason",
|
|
48
48
|
"updatedInput"
|
|
49
49
|
],
|
|
50
|
-
"unread_key_fails_open": null
|
|
50
|
+
"unread_key_fails_open": null,
|
|
51
|
+
"top_level_keys": [
|
|
52
|
+
"continue",
|
|
53
|
+
"decision",
|
|
54
|
+
"hookSpecificOutput",
|
|
55
|
+
"reason",
|
|
56
|
+
"stopReason",
|
|
57
|
+
"suppressOutput",
|
|
58
|
+
"systemMessage"
|
|
59
|
+
],
|
|
60
|
+
"unread_top_level_key_fails_open": null
|
|
51
61
|
}
|
|
52
62
|
},
|
|
53
63
|
"codex": {
|
|
@@ -70,7 +80,6 @@
|
|
|
70
80
|
},
|
|
71
81
|
"unrendered_fields_on_allow": {
|
|
72
82
|
"pre_tool": [
|
|
73
|
-
"additional_context",
|
|
74
83
|
"mutated_output"
|
|
75
84
|
],
|
|
76
85
|
"post_tool": [
|
|
@@ -122,7 +131,17 @@
|
|
|
122
131
|
"permissionDecisionReason",
|
|
123
132
|
"updatedInput"
|
|
124
133
|
],
|
|
125
|
-
"unread_key_fails_open": true
|
|
134
|
+
"unread_key_fails_open": true,
|
|
135
|
+
"top_level_keys": [
|
|
136
|
+
"continue",
|
|
137
|
+
"decision",
|
|
138
|
+
"hookSpecificOutput",
|
|
139
|
+
"reason",
|
|
140
|
+
"stopReason",
|
|
141
|
+
"suppressOutput",
|
|
142
|
+
"systemMessage"
|
|
143
|
+
],
|
|
144
|
+
"unread_top_level_key_fails_open": true
|
|
126
145
|
}
|
|
127
146
|
},
|
|
128
147
|
"amp": {
|
|
@@ -173,7 +192,9 @@
|
|
|
173
192
|
"pre_tool_filing": {},
|
|
174
193
|
"pre_tool_response": {
|
|
175
194
|
"hook_specific_output_keys": [],
|
|
176
|
-
"unread_key_fails_open": false
|
|
195
|
+
"unread_key_fails_open": false,
|
|
196
|
+
"top_level_keys": [],
|
|
197
|
+
"unread_top_level_key_fails_open": false
|
|
177
198
|
}
|
|
178
199
|
},
|
|
179
200
|
"gemini": {
|
|
@@ -222,7 +243,17 @@
|
|
|
222
243
|
"hook_specific_output_keys": [
|
|
223
244
|
"tool_input"
|
|
224
245
|
],
|
|
225
|
-
"unread_key_fails_open": false
|
|
246
|
+
"unread_key_fails_open": false,
|
|
247
|
+
"top_level_keys": [
|
|
248
|
+
"continue",
|
|
249
|
+
"decision",
|
|
250
|
+
"hookSpecificOutput",
|
|
251
|
+
"reason",
|
|
252
|
+
"stopReason",
|
|
253
|
+
"suppressOutput",
|
|
254
|
+
"systemMessage"
|
|
255
|
+
],
|
|
256
|
+
"unread_top_level_key_fails_open": false
|
|
226
257
|
}
|
|
227
258
|
}
|
|
228
259
|
}
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -184,6 +184,8 @@ export const PRE_TOOL_FILING = Object.freeze({});
|
|
|
184
184
|
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
185
185
|
hook_specific_output_keys: Object.freeze([]),
|
|
186
186
|
unread_key_fails_open: false,
|
|
187
|
+
top_level_keys: Object.freeze([]),
|
|
188
|
+
unread_top_level_key_fails_open: false,
|
|
187
189
|
});
|
|
188
190
|
|
|
189
191
|
/**
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -133,11 +133,11 @@ export const NATIVE_ASK_TIER = true;
|
|
|
133
133
|
export const PRE_TOOL_FILING = Object.freeze({});
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
|
-
* The `hookSpecificOutput` keys Claude Code's hooks reference
|
|
137
|
-
* `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether
|
|
138
|
-
* fails open is NOT established: the reference
|
|
139
|
-
* schema validation lets the call go ahead, but
|
|
140
|
-
* it, and the host's source is not public.
|
|
136
|
+
* The top-level and `hookSpecificOutput` keys Claude Code's hooks reference
|
|
137
|
+
* documents for `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether
|
|
138
|
+
* an unread key fails open, at either level, is NOT established: the reference
|
|
139
|
+
* says an exit-0 body that fails schema validation lets the call go ahead, but
|
|
140
|
+
* not whether an extra key fails it, and the host's source is not public.
|
|
141
141
|
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
142
142
|
*/
|
|
143
143
|
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
@@ -149,6 +149,16 @@ export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
|
149
149
|
"updatedInput",
|
|
150
150
|
]),
|
|
151
151
|
unread_key_fails_open: null,
|
|
152
|
+
top_level_keys: Object.freeze([
|
|
153
|
+
"continue",
|
|
154
|
+
"decision",
|
|
155
|
+
"hookSpecificOutput",
|
|
156
|
+
"reason",
|
|
157
|
+
"stopReason",
|
|
158
|
+
"suppressOutput",
|
|
159
|
+
"systemMessage",
|
|
160
|
+
]),
|
|
161
|
+
unread_top_level_key_fails_open: null,
|
|
152
162
|
});
|
|
153
163
|
|
|
154
164
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
makeEvent,
|
|
32
32
|
normalizeVerdict,
|
|
33
33
|
gatedResponse,
|
|
34
|
-
blockTransportFor,
|
|
35
34
|
blockReason,
|
|
36
35
|
UNRENDERED_ON_UNKNOWN,
|
|
37
36
|
readonlySet,
|
|
@@ -91,8 +90,9 @@ export const COVERAGE = Object.freeze({
|
|
|
91
90
|
});
|
|
92
91
|
|
|
93
92
|
/**
|
|
94
|
-
* The event kinds this host can actually gate. `PreToolUse
|
|
95
|
-
*
|
|
93
|
+
* The event kinds this host can actually gate. `PreToolUse` takes
|
|
94
|
+
* `permissionDecision: "deny"`, `PermissionRequest` `decision.behavior: "deny"`;
|
|
95
|
+
* `PostToolUse` takes a top-level
|
|
96
96
|
* `decision: "block"` (exit 2 "writes the feedback reason to stderr and blocks
|
|
97
97
|
* further processing"), so a post-tool deny stops the turn even though the tool
|
|
98
98
|
* itself already ran — the same shape the Claude adapter gates. Every other
|
|
@@ -108,11 +108,15 @@ const GATED_EVENTS = Object.freeze(
|
|
|
108
108
|
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
* Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind
|
|
112
|
-
* `render` drops them.
|
|
113
|
-
* `updatedInput`
|
|
114
|
-
* `
|
|
115
|
-
*
|
|
111
|
+
* Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind's
|
|
112
|
+
* {@link NATIVE_EVENT_FOR} event, so `render` drops them. PreToolUse takes
|
|
113
|
+
* `updatedInput` (applied only beside `permissionDecision: "allow"`) and
|
|
114
|
+
* `additionalContext`; PostToolUse takes `additionalContext` alone ([X8], read
|
|
115
|
+
* at `rust-v0.166.0` and identical at `rust-v0.135.0`). `PermissionRequest`,
|
|
116
|
+
* which also parses as `pre_tool`, takes neither: its schema is
|
|
117
|
+
* `{hookEventName, decision: {behavior, message}}`, and a body carrying
|
|
118
|
+
* `updatedInput` is ignored (Codex's own approval flow decides), so
|
|
119
|
+
* {@link render} drops both there.
|
|
116
120
|
*
|
|
117
121
|
* NEITHER kind can rewrite tool output: PostToolUse "can't undo side effects
|
|
118
122
|
* from a tool that already ran", and Codex rejects `updatedMCPToolOutput`, so
|
|
@@ -127,7 +131,7 @@ export const UNRENDERED_FIELDS = Object.freeze({
|
|
|
127
131
|
...Object.fromEntries(
|
|
128
132
|
Object.values(EventKind).map((kind) => [kind, UNRENDERED_ON_UNKNOWN]),
|
|
129
133
|
),
|
|
130
|
-
[EventKind.PRE_TOOL]: readonlySet(["mutated_output"
|
|
134
|
+
[EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
|
|
131
135
|
[EventKind.POST_TOOL]: readonlySet(["mutated_input", "mutated_output"]),
|
|
132
136
|
});
|
|
133
137
|
|
|
@@ -173,12 +177,15 @@ export const PRE_TOOL_FILING = Object.freeze({
|
|
|
173
177
|
});
|
|
174
178
|
|
|
175
179
|
/**
|
|
176
|
-
* Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
180
|
+
* Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes the
|
|
181
|
+
* body into `deny_unknown_fields` structs at both levels: these seven top-level
|
|
182
|
+
* keys (`PreToolUseCommandOutputWire` with its flattened universal fields), and
|
|
183
|
+
* these five in `hookSpecificOutput`. Any other key fails the whole body: the
|
|
184
|
+
* run is marked failed, no block is set, and the call RUNS. A known key can
|
|
185
|
+
* still void the body by its value or combination (`continue: false`, any
|
|
186
|
+
* `stopReason`, `suppressOutput: true`, `decision: "approve"`, `updatedInput`
|
|
187
|
+
* without `permissionDecision: "allow"`), which this declaration does not
|
|
188
|
+
* describe.
|
|
182
189
|
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
183
190
|
*/
|
|
184
191
|
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
@@ -190,6 +197,16 @@ export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
|
190
197
|
"updatedInput",
|
|
191
198
|
]),
|
|
192
199
|
unread_key_fails_open: true,
|
|
200
|
+
top_level_keys: Object.freeze([
|
|
201
|
+
"continue",
|
|
202
|
+
"decision",
|
|
203
|
+
"hookSpecificOutput",
|
|
204
|
+
"reason",
|
|
205
|
+
"stopReason",
|
|
206
|
+
"suppressOutput",
|
|
207
|
+
"systemMessage",
|
|
208
|
+
]),
|
|
209
|
+
unread_top_level_key_fails_open: true,
|
|
193
210
|
});
|
|
194
211
|
|
|
195
212
|
/** Minimum Codex version whose hook can actually veto a tool call. */
|
|
@@ -361,15 +378,17 @@ export function parse(native, { version } = {}) {
|
|
|
361
378
|
* on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable` false) the
|
|
362
379
|
* body still renders but exit stays 0 and `enforced` is false — advisory only.
|
|
363
380
|
*
|
|
364
|
-
*
|
|
365
|
-
* decision rides `hookSpecificOutput.permissionDecision`, a
|
|
366
|
-
*
|
|
367
|
-
*
|
|
381
|
+
* Each native event gets its OWN shape, because Codex parses each strictly: a
|
|
382
|
+
* PreToolUse decision rides `hookSpecificOutput.permissionDecision`, a
|
|
383
|
+
* PermissionRequest one `hookSpecificOutput.decision.behavior`, and a
|
|
384
|
+
* post-tool one the top-level `decision`/`reason` pair.
|
|
368
385
|
*
|
|
369
386
|
* `soleGate` (default `false`) is the same dangerous, explicit opt-in as the
|
|
370
|
-
* Claude adapter
|
|
371
|
-
*
|
|
372
|
-
*
|
|
387
|
+
* Claude adapter, and acts only on PermissionRequest: there an `allow` renders
|
|
388
|
+
* `behavior: "allow"`, which grants the approval (except, from 0.166, when the
|
|
389
|
+
* hook is environment-sourced). PreToolUse has no allow that
|
|
390
|
+
* approves anything (Codex accepts `"allow"` only beside `updatedInput`, as a
|
|
391
|
+
* rewrite), and post-tool there is no approval left to grant.
|
|
373
392
|
* @param {Verdict} verdict
|
|
374
393
|
* @param {ToolCallEvent} event
|
|
375
394
|
* @param {{ soleGate?: boolean }} [options]
|
|
@@ -395,52 +414,85 @@ export function render(verdict, event, { soleGate = false } = {}) {
|
|
|
395
414
|
unblocked_exit_code: 0,
|
|
396
415
|
enforced,
|
|
397
416
|
reason: vd.reason,
|
|
398
|
-
stdout:
|
|
399
|
-
event.event === EventKind.POST_TOOL
|
|
400
|
-
? postToolBody(hookEventName, vd, enforced)
|
|
401
|
-
: preToolBody(hookEventName, vd, event, enforced, soleGate),
|
|
417
|
+
stdout: nativeBody(hookEventName, vd, event, soleGate),
|
|
402
418
|
});
|
|
403
419
|
}
|
|
404
420
|
|
|
405
421
|
/**
|
|
406
|
-
* The
|
|
422
|
+
* The stdout body for `event`, in the schema its native event answers in.
|
|
407
423
|
* @param {string} hookEventName
|
|
408
424
|
* @param {Verdict} vd a normalized verdict
|
|
409
425
|
* @param {ToolCallEvent} event
|
|
410
|
-
* @param {boolean} enforced
|
|
411
426
|
* @param {boolean} soleGate
|
|
412
427
|
* @returns {Record<string, unknown>}
|
|
413
428
|
*/
|
|
414
|
-
function
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
429
|
+
function nativeBody(hookEventName, vd, event, soleGate) {
|
|
430
|
+
if (event.event === EventKind.POST_TOOL)
|
|
431
|
+
return postToolBody(hookEventName, vd);
|
|
432
|
+
if (hookEventName === HookEvent.PERMISSION_REQUEST)
|
|
433
|
+
return permissionRequestBody(hookEventName, vd, soleGate);
|
|
434
|
+
return preToolBody(hookEventName, vd, event);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The body for a PreToolUse (or unmodelled) event. Codex's PreToolUse parser
|
|
439
|
+
* ([X8]) voids the body, and the call runs, on `"allow"` without `updatedInput`
|
|
440
|
+
* and on `updatedInput` without `"allow"` — so an allow emits a decision only
|
|
441
|
+
* to carry a mutation, and a deny never carries one.
|
|
442
|
+
* @param {string} hookEventName
|
|
443
|
+
* @param {Verdict} vd a normalized verdict
|
|
444
|
+
* @param {ToolCallEvent} event
|
|
445
|
+
* @returns {Record<string, unknown>}
|
|
446
|
+
*/
|
|
447
|
+
function preToolBody(hookEventName, vd, event) {
|
|
418
448
|
/** @type {Record<string, unknown>} */
|
|
419
449
|
const body = { hookEventName };
|
|
420
|
-
// An UNKNOWN event reaches this body too. It has no established
|
|
421
|
-
//
|
|
422
|
-
// the
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
450
|
+
// An UNKNOWN event reaches this body too. It has no established channel, so
|
|
451
|
+
// it carries nothing: a decision or `updatedInput` there would act on the
|
|
452
|
+
// host, or read to the caller as applied, while the host ignores it.
|
|
453
|
+
if (event.event !== EventKind.PRE_TOOL) return { hookSpecificOutput: body };
|
|
454
|
+
if (vd.decision !== Decision.ALLOW) {
|
|
455
|
+
// Codex voids a deny body whose reason is missing or empty: no block, no
|
|
456
|
+
// context, and the call runs (`parse_pre_tool_use`, [X8]). That holds on an
|
|
457
|
+
// advisory render too, so every deny carries a reason.
|
|
427
458
|
body.permissionDecision = vd.decision;
|
|
428
|
-
if (vd.reason !== undefined) body.permissionDecisionReason = vd.reason;
|
|
429
|
-
}
|
|
430
|
-
// Codex FAILS OPEN on an enforced deny whose permissionDecisionReason is missing
|
|
431
|
-
// or empty: its PreToolUse output parser drops the block (block_reason = None)
|
|
432
|
-
// and runs the tool (BLOCK_TRANSPORT.codex.reason_required).
|
|
433
|
-
if (enforced && blockTransportFor(AGENT).reason_required)
|
|
434
459
|
body.permissionDecisionReason = blockReason(vd.reason);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// there names a key the host ignores while reading to the caller as a
|
|
438
|
-
// mutation applied.
|
|
439
|
-
if (event.event === EventKind.PRE_TOOL && vd.mutated_input !== undefined)
|
|
460
|
+
} else if (vd.mutated_input !== undefined) {
|
|
461
|
+
body.permissionDecision = Decision.ALLOW;
|
|
440
462
|
body.updatedInput = vd.mutated_input;
|
|
463
|
+
}
|
|
464
|
+
if (vd.additional_context !== undefined)
|
|
465
|
+
body.additionalContext = vd.additional_context;
|
|
441
466
|
return { hookSpecificOutput: body };
|
|
442
467
|
}
|
|
443
468
|
|
|
469
|
+
/**
|
|
470
|
+
* The body for a PermissionRequest event: Codex's
|
|
471
|
+
* `PermissionRequestHookSpecificOutputWire` ([X8]), `{hookEventName, decision:
|
|
472
|
+
* {behavior, message}}`. `behavior: "allow"` GRANTS the approval, so an allow
|
|
473
|
+
* abstains unless `soleGate` opts in. Content fields have no channel here:
|
|
474
|
+
* `updatedInput` voids the body and there is no context key.
|
|
475
|
+
* @param {string} hookEventName
|
|
476
|
+
* @param {Verdict} vd a normalized verdict
|
|
477
|
+
* @param {boolean} soleGate
|
|
478
|
+
* @returns {Record<string, unknown>}
|
|
479
|
+
*/
|
|
480
|
+
function permissionRequestBody(hookEventName, vd, soleGate) {
|
|
481
|
+
/** @type {Record<string, unknown>} */
|
|
482
|
+
const hookSpecificOutput = { hookEventName };
|
|
483
|
+
// Codex substitutes its own text for an empty message
|
|
484
|
+
// (`permission_request_decision`, [X8]); ours says who denied it.
|
|
485
|
+
if (vd.decision !== Decision.ALLOW) {
|
|
486
|
+
hookSpecificOutput.decision = {
|
|
487
|
+
behavior: Decision.DENY,
|
|
488
|
+
message: blockReason(vd.reason),
|
|
489
|
+
};
|
|
490
|
+
} else if (soleGate) {
|
|
491
|
+
hookSpecificOutput.decision = { behavior: Decision.ALLOW };
|
|
492
|
+
}
|
|
493
|
+
return { hookSpecificOutput };
|
|
494
|
+
}
|
|
495
|
+
|
|
444
496
|
/**
|
|
445
497
|
* The body for a PostToolUse event: Codex's documented post-tool schema, a
|
|
446
498
|
* top-level `decision`/`reason` beside a `hookSpecificOutput` carrying
|
|
@@ -449,27 +501,21 @@ function preToolBody(hookEventName, vd, event, enforced, soleGate) {
|
|
|
449
501
|
* objection the host understands there, `block`.
|
|
450
502
|
* @param {string} hookEventName
|
|
451
503
|
* @param {Verdict} vd a normalized verdict
|
|
452
|
-
* @param {boolean} enforced
|
|
453
504
|
* @returns {Record<string, unknown>}
|
|
454
505
|
*/
|
|
455
|
-
function postToolBody(hookEventName, vd
|
|
506
|
+
function postToolBody(hookEventName, vd) {
|
|
456
507
|
/** @type {Record<string, unknown>} */
|
|
457
508
|
const hookSpecificOutput = { hookEventName };
|
|
458
509
|
if (vd.additional_context !== undefined)
|
|
459
510
|
hookSpecificOutput.additionalContext = vd.additional_context;
|
|
460
511
|
/** @type {Record<string, unknown>} */
|
|
461
512
|
const body = { hookSpecificOutput };
|
|
513
|
+
// `parse_post_tool_use` ([X8]) voids `decision: "block"` with an empty
|
|
514
|
+
// reason, and a sync handler's context with it, advisory render or not.
|
|
462
515
|
if (vd.decision !== Decision.ALLOW) {
|
|
463
516
|
body.decision = "block";
|
|
464
|
-
if (vd.reason !== undefined) body.reason = vd.reason;
|
|
465
|
-
}
|
|
466
|
-
// The same guarantee the pre-tool body makes, for the same reason: the
|
|
467
|
-
// documented post-tool schema pairs `decision` with a `reason`, and a block
|
|
468
|
-
// whose reason Codex may drop is a block that may not bite. Unverified for
|
|
469
|
-
// PostToolUse specifically — a non-empty reason is the safe side of that, and
|
|
470
|
-
// the model needs the text either way.
|
|
471
|
-
if (enforced && blockTransportFor(AGENT).reason_required)
|
|
472
517
|
body.reason = blockReason(vd.reason);
|
|
518
|
+
}
|
|
473
519
|
return body;
|
|
474
520
|
}
|
|
475
521
|
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -371,12 +371,24 @@ export const PRE_TOOL_FILING = Object.freeze({});
|
|
|
371
371
|
* other key is ignored (`packages/core/src/hooks/types.ts`
|
|
372
372
|
* `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
|
|
373
373
|
* and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
|
|
374
|
-
* `62bab5b2`).
|
|
374
|
+
* `62bab5b2`). The top level is the `HookOutput` interface's seven keys, read
|
|
375
|
+
* from an unchecked `JSON.parse` cast (`hooks/hookRunner.ts`), so an unknown
|
|
376
|
+
* top-level key is ignored too.
|
|
375
377
|
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
376
378
|
*/
|
|
377
379
|
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
378
380
|
hook_specific_output_keys: Object.freeze(["tool_input"]),
|
|
379
381
|
unread_key_fails_open: false,
|
|
382
|
+
top_level_keys: Object.freeze([
|
|
383
|
+
"continue",
|
|
384
|
+
"decision",
|
|
385
|
+
"hookSpecificOutput",
|
|
386
|
+
"reason",
|
|
387
|
+
"stopReason",
|
|
388
|
+
"suppressOutput",
|
|
389
|
+
"systemMessage",
|
|
390
|
+
]),
|
|
391
|
+
unread_top_level_key_fails_open: false,
|
|
380
392
|
});
|
|
381
393
|
|
|
382
394
|
/**
|
package/src/agent-manifest.mjs
CHANGED
|
@@ -30,12 +30,13 @@ import { HOST_FAILURE } from "./runtime.mjs";
|
|
|
30
30
|
* `ACP_FAIL_CLOSED=1` replaces it with `block`.
|
|
31
31
|
* - `coverage`: `COVERAGE`, per call class.
|
|
32
32
|
* - `unrendered_fields_on_allow`: `UNRENDERED_FIELDS`, each set as a sorted
|
|
33
|
-
* list. It describes the allow path
|
|
33
|
+
* list. It describes the allow path on each kind's `NATIVE_EVENT_FOR` event
|
|
34
|
+
* only: a deny, or another native event of that kind, can drop more.
|
|
34
35
|
* - `pre_tool_filing`: `PRE_TOOL_FILING`, how the pre-tool hook files a model
|
|
35
36
|
* tool call, in the mini-language the `PreToolFiling` typedef specifies.
|
|
36
|
-
* - `pre_tool_response`: `PRE_TOOL_RESPONSE`, the
|
|
37
|
-
* host reads on an exit-0 pre-tool answer,
|
|
38
|
-
*
|
|
37
|
+
* - `pre_tool_response`: `PRE_TOOL_RESPONSE`, the top-level and
|
|
38
|
+
* `hookSpecificOutput` keys the host reads on an exit-0 pre-tool answer, each
|
|
39
|
+
* list with its fail-open bit, as the `PreToolResponse` typedef specifies.
|
|
39
40
|
* @param {string} id
|
|
40
41
|
* @returns {Record<string, unknown>}
|
|
41
42
|
*/
|
|
@@ -61,7 +62,13 @@ function manifestRow(id) {
|
|
|
61
62
|
]),
|
|
62
63
|
),
|
|
63
64
|
pre_tool_filing: adapter.PRE_TOOL_FILING,
|
|
64
|
-
pre_tool_response:
|
|
65
|
+
pre_tool_response: {
|
|
66
|
+
...adapter.PRE_TOOL_RESPONSE,
|
|
67
|
+
hook_specific_output_keys: [
|
|
68
|
+
...adapter.PRE_TOOL_RESPONSE.hook_specific_output_keys,
|
|
69
|
+
].sort(),
|
|
70
|
+
top_level_keys: [...adapter.PRE_TOOL_RESPONSE.top_level_keys].sort(),
|
|
71
|
+
},
|
|
65
72
|
};
|
|
66
73
|
}
|
|
67
74
|
|
package/src/conformance.mjs
CHANGED
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
isCoverageStatus,
|
|
12
12
|
lookup,
|
|
13
13
|
VERDICT_CONTENT_FIELDS,
|
|
14
|
-
WHOLE_ARGUMENTS,
|
|
15
14
|
FILING_RULE_GRAMMAR,
|
|
16
15
|
isFilingRule,
|
|
16
|
+
isPlainObject,
|
|
17
17
|
} from "./control-plane.mjs";
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -407,14 +407,20 @@ function assertRowReads(agent, kind, row, assert) {
|
|
|
407
407
|
* @param {Set<string>} seen fields observed to REACH a wire
|
|
408
408
|
*/
|
|
409
409
|
function assertEveryKindRenders(adapter, byKind, assert, seen) {
|
|
410
|
-
for (const kind of Object.values(EventKind))
|
|
410
|
+
for (const kind of Object.values(EventKind)) {
|
|
411
|
+
// A parsed event under another native name is not the event the row
|
|
412
|
+
// describes, so the probe synthesizes the native one instead.
|
|
413
|
+
const parsed = lookup(byKind, kind);
|
|
411
414
|
assertContentChannels(
|
|
412
415
|
adapter,
|
|
413
|
-
|
|
416
|
+
parsed !== undefined && onKindsNativeEvent(adapter, parsed)
|
|
417
|
+
? parsed
|
|
418
|
+
: coherentEvent(adapter, byKind, kind),
|
|
414
419
|
`every-kind probe '${kind}'`,
|
|
415
420
|
assert,
|
|
416
421
|
seen,
|
|
417
422
|
);
|
|
423
|
+
}
|
|
418
424
|
}
|
|
419
425
|
|
|
420
426
|
/**
|
|
@@ -578,6 +584,10 @@ function assertAskTier(adapter, event, vetoable, caseName, assert) {
|
|
|
578
584
|
* (kind, decision) would state that, and is not worth its weight while the only
|
|
579
585
|
* field a blocked call can still want is `additional_context`.
|
|
580
586
|
*
|
|
587
|
+
* SCOPE — a row describes its kind's `NATIVE_EVENT_FOR` event, so an event of
|
|
588
|
+
* that kind under another native name (Codex's `PermissionRequest`, which
|
|
589
|
+
* parses as `pre_tool` and carries no content) is not held to it.
|
|
590
|
+
*
|
|
581
591
|
* Reaching the wire is checked as SERIALIZATION, so the rule proves a field is
|
|
582
592
|
* carried, not that the key it is carried in is one the host reads. Nothing
|
|
583
593
|
* machine-readable describes a host's schema, so that half stays with the
|
|
@@ -589,6 +599,7 @@ function assertAskTier(adapter, event, vetoable, caseName, assert) {
|
|
|
589
599
|
* @param {Set<string>} seen fields observed to REACH a wire, for the non-vacuity check
|
|
590
600
|
*/
|
|
591
601
|
function assertContentChannels(adapter, event, caseName, assert, seen) {
|
|
602
|
+
if (!onKindsNativeEvent(adapter, event)) return;
|
|
592
603
|
const declared = lookup(adapter.UNRENDERED_FIELDS, event.event);
|
|
593
604
|
|
|
594
605
|
// Every value of a field is rendered and the renders compared to EACH OTHER.
|
|
@@ -717,14 +728,49 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
|
|
|
717
728
|
}
|
|
718
729
|
}
|
|
719
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Whether `event` arrived as the native event `NATIVE_EVENT_FOR` names for its
|
|
733
|
+
* kind (always, when the adapter names none for that kind) — the event a
|
|
734
|
+
* kind-keyed declaration describes.
|
|
735
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
736
|
+
* @param {import("./control-plane.mjs").ToolCallEvent} event
|
|
737
|
+
* @returns {boolean}
|
|
738
|
+
*/
|
|
739
|
+
function onKindsNativeEvent(adapter, event) {
|
|
740
|
+
const native = lookup(adapter.NATIVE_EVENT_FOR ?? {}, event.event);
|
|
741
|
+
return native === undefined || event.meta.native_event === native;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The two levels of a pre-tool body `PRE_TOOL_RESPONSE` declares, each as the
|
|
746
|
+
* key-list member, its fail-open member, and where in the body it applies.
|
|
747
|
+
*/
|
|
748
|
+
const PRE_TOOL_RESPONSE_LEVELS = Object.freeze([
|
|
749
|
+
Object.freeze({
|
|
750
|
+
keys: "top_level_keys",
|
|
751
|
+
failsOpen: "unread_top_level_key_fails_open",
|
|
752
|
+
where: "the body",
|
|
753
|
+
/** @param {any} body */
|
|
754
|
+
pick: (body) => body,
|
|
755
|
+
}),
|
|
756
|
+
Object.freeze({
|
|
757
|
+
keys: "hook_specific_output_keys",
|
|
758
|
+
failsOpen: "unread_key_fails_open",
|
|
759
|
+
where: "hookSpecificOutput",
|
|
760
|
+
/** @param {any} body */
|
|
761
|
+
pick: (body) => body?.hookSpecificOutput,
|
|
762
|
+
}),
|
|
763
|
+
]);
|
|
764
|
+
|
|
720
765
|
/**
|
|
721
766
|
* Rule ⑫, for one render: on the host's native pre-tool event, every key the
|
|
722
|
-
* render puts in `hookSpecificOutput` is one the
|
|
723
|
-
* says the host reads. On Codex any other
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
767
|
+
* render puts at the body's top level or in `hookSpecificOutput` is one the
|
|
768
|
+
* adapter's `PRE_TOOL_RESPONSE` says the host reads there. On Codex any other
|
|
769
|
+
* key, at either level, voids the whole body and the call RUNS, so an unread
|
|
770
|
+
* key is a silent fail-open, not a harmless extra. Scoped to the native name
|
|
771
|
+
* `NATIVE_EVENT_FOR` gives the pre-tool kind (any pre-tool event when it gives
|
|
772
|
+
* none): Codex's `PermissionRequest` also parses as pre-tool but answers in a
|
|
773
|
+
* different schema this declaration does not describe.
|
|
728
774
|
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
729
775
|
* @param {import("./control-plane.mjs").ToolCallEvent} event
|
|
730
776
|
* @param {import("./control-plane.mjs").NativeResponse} rendered
|
|
@@ -733,47 +779,41 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
|
|
|
733
779
|
* @returns {boolean} whether the render was on the pre-tool event and checked
|
|
734
780
|
*/
|
|
735
781
|
function assertPreToolKeysRead(adapter, event, rendered, label, assert) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
EventKind.PRE_TOOL,
|
|
739
|
-
);
|
|
740
|
-
const onPreTool =
|
|
741
|
-
preToolName === undefined
|
|
742
|
-
? event.event === EventKind.PRE_TOOL
|
|
743
|
-
: event.meta.native_event === preToolName;
|
|
744
|
-
if (!onPreTool) return false;
|
|
782
|
+
if (event.event !== EventKind.PRE_TOOL || !onKindsNativeEvent(adapter, event))
|
|
783
|
+
return false;
|
|
745
784
|
// `?.`: a broken render under rule ⑩'s probes may not be an object at all,
|
|
746
|
-
// and that rule, not this one, is what names it.
|
|
747
|
-
const body =
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
(
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
785
|
+
// and that rule, not this one, is what names it. No body reads as an empty one.
|
|
786
|
+
const body = rendered?.stdout;
|
|
787
|
+
for (const level of PRE_TOOL_RESPONSE_LEVELS) {
|
|
788
|
+
const found = level.pick(body);
|
|
789
|
+
const part = found === undefined ? {} : found;
|
|
790
|
+
assert.ok(
|
|
791
|
+
isPlainObject(part),
|
|
792
|
+
`${adapter.AGENT}: in a pre-tool render, ${level.where} is ${JSON.stringify(part)}, not an object: ${label}`,
|
|
793
|
+
);
|
|
794
|
+
const read = new Set(
|
|
795
|
+
/** @type {readonly string[]} */ (
|
|
796
|
+
lookup(adapter.PRE_TOOL_RESPONSE, level.keys)
|
|
797
|
+
),
|
|
798
|
+
);
|
|
799
|
+
const unread = Object.keys(/** @type {object} */ (part)).filter(
|
|
800
|
+
(key) => !read.has(key),
|
|
801
|
+
);
|
|
802
|
+
assert.deepEqual(
|
|
803
|
+
unread,
|
|
804
|
+
[],
|
|
805
|
+
`${adapter.AGENT}: a pre-tool render puts ${JSON.stringify(unread)} in ${level.where}, which PRE_TOOL_RESPONSE.${level.keys} says this host does not read${lookup(adapter.PRE_TOOL_RESPONSE, level.failsOpen) ? " — the host then voids the body and runs the call" : ""}: ${label}`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
767
808
|
return true;
|
|
768
809
|
}
|
|
769
810
|
|
|
770
811
|
/**
|
|
771
812
|
* Assert an adapter declares a well-formed `PRE_TOOL_FILING` and
|
|
772
|
-
* `PRE_TOOL_RESPONSE`:
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
* Python consumer cannot apply is refused here, not discovered there.
|
|
813
|
+
* `PRE_TOOL_RESPONSE`: every filing rule `null` or in the mini-language
|
|
814
|
+
* {@link fileModelCall} reads, and each response key list an array of strings
|
|
815
|
+
* beside a boolean-or-null fail-open bit. `agents.json` publishes both, so a
|
|
816
|
+
* shape a Python consumer cannot apply is refused here, not discovered there.
|
|
777
817
|
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
778
818
|
* @param {any} assert
|
|
779
819
|
*/
|
|
@@ -781,41 +821,32 @@ function assertPreToolDeclarationsWellFormed(adapter, assert) {
|
|
|
781
821
|
const agent = adapter.AGENT;
|
|
782
822
|
const filing = adapter.PRE_TOOL_FILING;
|
|
783
823
|
assert.ok(
|
|
784
|
-
|
|
785
|
-
`${agent}: adapter declares no
|
|
824
|
+
isPlainObject(filing),
|
|
825
|
+
`${agent}: adapter declares no PRE_TOOL_FILING — every adapter needs one: {} when its host files every model call as asked`,
|
|
786
826
|
);
|
|
787
|
-
for (const [name, rule] of Object.entries(filing))
|
|
788
|
-
if (rule === null) continue;
|
|
789
|
-
const where = `${agent}: PRE_TOOL_FILING[${JSON.stringify(name)}]`;
|
|
790
|
-
assert.ok(Object.isFrozen(rule), `${where} is not frozen`);
|
|
827
|
+
for (const [name, rule] of Object.entries(filing))
|
|
791
828
|
assert.ok(
|
|
792
|
-
isFilingRule(rule),
|
|
793
|
-
`${
|
|
829
|
+
rule === null || isFilingRule(rule),
|
|
830
|
+
`${agent}: PRE_TOOL_FILING[${JSON.stringify(name)}] is ${JSON.stringify(rule)}, not null or ${FILING_RULE_GRAMMAR}`,
|
|
794
831
|
);
|
|
795
|
-
if (rule.input !== WHOLE_ARGUMENTS)
|
|
796
|
-
assert.ok(Object.isFrozen(rule.input), `${where}.input is not frozen`);
|
|
797
|
-
}
|
|
798
832
|
const response = adapter.PRE_TOOL_RESPONSE;
|
|
799
833
|
assert.ok(
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
Object.isFrozen(response),
|
|
803
|
-
`${agent}: adapter declares no frozen PRE_TOOL_RESPONSE — every adapter needs one naming the hookSpecificOutput keys its host reads on the pre-tool event (an empty list when it reads no body)`,
|
|
804
|
-
);
|
|
805
|
-
const keys = response.hook_specific_output_keys;
|
|
806
|
-
assert.ok(
|
|
807
|
-
Array.isArray(keys) && Object.isFrozen(keys),
|
|
808
|
-
`${agent}: PRE_TOOL_RESPONSE.hook_specific_output_keys is not a frozen array`,
|
|
809
|
-
);
|
|
810
|
-
assert.deepEqual(
|
|
811
|
-
[...keys],
|
|
812
|
-
[...new Set(keys)].sort(),
|
|
813
|
-
`${agent}: PRE_TOOL_RESPONSE.hook_specific_output_keys must be sorted and duplicate-free`,
|
|
814
|
-
);
|
|
815
|
-
assert.ok(
|
|
816
|
-
[true, false, null].includes(response.unread_key_fails_open),
|
|
817
|
-
`${agent}: PRE_TOOL_RESPONSE.unread_key_fails_open must be true, false or null`,
|
|
834
|
+
isPlainObject(response),
|
|
835
|
+
`${agent}: adapter declares no PRE_TOOL_RESPONSE — every adapter needs one naming the top-level and hookSpecificOutput keys its host reads on the pre-tool event (empty lists when it reads no body)`,
|
|
818
836
|
);
|
|
837
|
+
for (const level of PRE_TOOL_RESPONSE_LEVELS) {
|
|
838
|
+
const keys = lookup(response, level.keys);
|
|
839
|
+
assert.ok(
|
|
840
|
+
Array.isArray(keys) && keys.every((key) => typeof key === "string"),
|
|
841
|
+
`${agent}: PRE_TOOL_RESPONSE.${level.keys} is not an array of strings`,
|
|
842
|
+
);
|
|
843
|
+
assert.ok(
|
|
844
|
+
[true, false, null].includes(
|
|
845
|
+
/** @type {any} */ (lookup(response, level.failsOpen)),
|
|
846
|
+
),
|
|
847
|
+
`${agent}: PRE_TOOL_RESPONSE.${level.failsOpen} must be true, false or null`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
819
850
|
}
|
|
820
851
|
|
|
821
852
|
/**
|
|
@@ -1237,7 +1268,15 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1237
1268
|
renders += 1;
|
|
1238
1269
|
}
|
|
1239
1270
|
|
|
1240
|
-
|
|
1271
|
+
// The kind's native event stands for it in the every-kind sweep when one
|
|
1272
|
+
// arrives; another native name is only a seed to synthesize one from.
|
|
1273
|
+
const held = lookup(parsedByKind, parsed.event);
|
|
1274
|
+
if (
|
|
1275
|
+
held === undefined ||
|
|
1276
|
+
(!onKindsNativeEvent(adapter, held) &&
|
|
1277
|
+
onKindsNativeEvent(adapter, parsed))
|
|
1278
|
+
)
|
|
1279
|
+
parsedByKind[parsed.event] = parsed;
|
|
1241
1280
|
assertContentChannels(
|
|
1242
1281
|
adapter,
|
|
1243
1282
|
parsed,
|
package/src/control-plane.mjs
CHANGED
|
@@ -496,9 +496,6 @@ export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
|
|
|
496
496
|
/** The {@link PreToolFiling} input reference to a call's whole arguments value. */
|
|
497
497
|
export const WHOLE_ARGUMENTS = "$";
|
|
498
498
|
|
|
499
|
-
/** The prefix of a {@link PreToolFiling} input reference to one top-level argument. */
|
|
500
|
-
export const ARGUMENT_KEY_PREFIX = "$.";
|
|
501
|
-
|
|
502
499
|
/**
|
|
503
500
|
* How a host's pre-tool hook files a model's tool call, keyed by MODEL tool
|
|
504
501
|
* name, the {@link flatToolName} of the call's `namespace` and `name`. Each
|
|
@@ -544,90 +541,52 @@ export function flatToolName(namespace, name) {
|
|
|
544
541
|
}
|
|
545
542
|
|
|
546
543
|
/**
|
|
547
|
-
* What a host reads from
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
* `
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
* @typedef {Readonly<{ hook_specific_output_keys: readonly string[], unread_key_fails_open: boolean | null }>} PreToolResponse
|
|
544
|
+
* What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
|
|
545
|
+
* `BeforeTool`), as two key lists, each empty when the host reads no
|
|
546
|
+
* body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
|
|
547
|
+
* `top_level_keys` at the body's top level. Each list has a fail-open bit,
|
|
548
|
+
* `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
|
|
549
|
+
* other key there voids the whole body and the call RUNS; `false` ⇒ other keys
|
|
550
|
+
* are ignored; `null` ⇒ not established from the host's source.
|
|
551
|
+
* @typedef {Readonly<{ hook_specific_output_keys: readonly string[], unread_key_fails_open: boolean | null, top_level_keys: readonly string[], unread_top_level_key_fails_open: boolean | null }>} PreToolResponse
|
|
555
552
|
*/
|
|
556
553
|
|
|
557
554
|
/**
|
|
558
|
-
* Whether `
|
|
559
|
-
*
|
|
560
|
-
* @
|
|
561
|
-
* @returns {boolean}
|
|
555
|
+
* Whether `value` is a plain object: not null, not an array.
|
|
556
|
+
* @param {unknown} value
|
|
557
|
+
* @returns {value is Record<string, unknown>}
|
|
562
558
|
*/
|
|
563
|
-
function
|
|
564
|
-
|
|
565
|
-
if (typeof ref !== "string" || !ref.startsWith(ARGUMENT_KEY_PREFIX))
|
|
566
|
-
return false;
|
|
567
|
-
const key = ref.slice(ARGUMENT_KEY_PREFIX.length);
|
|
568
|
-
return key !== "" && !key.includes(".");
|
|
559
|
+
export function isPlainObject(value) {
|
|
560
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
569
561
|
}
|
|
570
562
|
|
|
571
|
-
/**
|
|
572
|
-
|
|
573
|
-
* object (not an array) whose every value is `"$"` or `"$.<key>"`.
|
|
574
|
-
* @param {unknown} input
|
|
575
|
-
* @returns {boolean}
|
|
576
|
-
*/
|
|
577
|
-
function isFilingInput(input) {
|
|
578
|
-
if (input === WHOLE_ARGUMENTS) return true;
|
|
579
|
-
return (
|
|
580
|
-
typeof input === "object" &&
|
|
581
|
-
input !== null &&
|
|
582
|
-
!Array.isArray(input) &&
|
|
583
|
-
Object.values(input).every(isFilingRef)
|
|
584
|
-
);
|
|
585
|
-
}
|
|
563
|
+
/** A {@link PreToolFiling} reference to one top-level argument; group 1 is the key. */
|
|
564
|
+
const ARGUMENT_REF = /^\$\.([^.]+)$/u;
|
|
586
565
|
|
|
587
566
|
/** What a {@link PreToolFiling} rule must be, for error messages. */
|
|
588
|
-
export const FILING_RULE_GRAMMAR = `{tool_name: <non-empty string>, input: "${WHOLE_ARGUMENTS}" or an object of "${WHOLE_ARGUMENTS}"/"
|
|
567
|
+
export const FILING_RULE_GRAMMAR = `{tool_name: <non-empty string>, input: "${WHOLE_ARGUMENTS}" or an object of "${WHOLE_ARGUMENTS}"/"$.<key>" references (<key>: non-empty, no ".")}`;
|
|
589
568
|
|
|
590
569
|
/**
|
|
591
|
-
* Whether `rule` is a non-null {@link PreToolFiling} rule: a
|
|
592
|
-
*
|
|
593
|
-
* grammar both {@link fileModelCall} and the
|
|
570
|
+
* Whether `rule` is a non-null {@link PreToolFiling} rule: exactly a non-empty
|
|
571
|
+
* string `tool_name` and an `input` that is `"$"` or an object of `"$"` /
|
|
572
|
+
* `"$.<key>"` references. The one grammar both {@link fileModelCall} and the
|
|
573
|
+
* conformance harness hold a rule to.
|
|
594
574
|
* @param {unknown} rule
|
|
595
575
|
* @returns {boolean}
|
|
596
576
|
*/
|
|
597
577
|
export function isFilingRule(rule) {
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
const
|
|
601
|
-
|
|
578
|
+
if (!isPlainObject(rule)) return false;
|
|
579
|
+
const { tool_name, input, ...extra } = rule;
|
|
580
|
+
const isRef = (/** @type {unknown} */ ref) =>
|
|
581
|
+
ref === WHOLE_ARGUMENTS ||
|
|
582
|
+
(typeof ref === "string" && ARGUMENT_REF.test(ref));
|
|
602
583
|
return (
|
|
603
|
-
keys.length ===
|
|
604
|
-
keys[0] === "input" &&
|
|
605
|
-
keys[1] === "tool_name" &&
|
|
584
|
+
Object.keys(extra).length === 0 &&
|
|
606
585
|
typeof tool_name === "string" &&
|
|
607
586
|
tool_name !== "" &&
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/** The kinds of model tool call a {@link PreToolFiling} distinguishes. */
|
|
613
|
-
export const MODEL_CALL_KINDS = readonlySet(["function", "freeform"]);
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Resolve one valid {@link PreToolFiling} input reference against `args`;
|
|
617
|
-
* `undefined` when it does not resolve (see {@link PreToolFiling}).
|
|
618
|
-
* @param {string} ref
|
|
619
|
-
* @param {unknown} args
|
|
620
|
-
* @returns {unknown}
|
|
621
|
-
*/
|
|
622
|
-
function resolveArgument(ref, args) {
|
|
623
|
-
if (ref === WHOLE_ARGUMENTS) return args;
|
|
624
|
-
if (typeof args !== "object" || args === null || Array.isArray(args))
|
|
625
|
-
return undefined;
|
|
626
|
-
const value = lookup(
|
|
627
|
-
/** @type {Record<string, unknown>} */ (args),
|
|
628
|
-
ref.slice(ARGUMENT_KEY_PREFIX.length),
|
|
587
|
+
(input === WHOLE_ARGUMENTS ||
|
|
588
|
+
(isPlainObject(input) && Object.values(input).every(isRef)))
|
|
629
589
|
);
|
|
630
|
-
return value === null ? undefined : value;
|
|
631
590
|
}
|
|
632
591
|
|
|
633
592
|
/**
|
|
@@ -635,7 +594,7 @@ function resolveArgument(ref, args) {
|
|
|
635
594
|
* with arguments value `args`, per that host's {@link PreToolFiling}; `null`
|
|
636
595
|
* when the hook files none. The reference reading of the mini-language
|
|
637
596
|
* `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
|
|
638
|
-
* grammar or a kind
|
|
597
|
+
* grammar or a kind other than `"function"` or `"freeform"`.
|
|
639
598
|
* @param {PreToolFiling} filing
|
|
640
599
|
* @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
|
|
641
600
|
* @param {string} name the model tool name, a {@link flatToolName}
|
|
@@ -643,7 +602,7 @@ function resolveArgument(ref, args) {
|
|
|
643
602
|
* @returns {{ tool_name: string, tool_input: unknown } | null}
|
|
644
603
|
*/
|
|
645
604
|
export function fileModelCall(filing, kind, name, args) {
|
|
646
|
-
if (
|
|
605
|
+
if (kind !== "function" && kind !== "freeform")
|
|
647
606
|
throw new Error(
|
|
648
607
|
`control-plane: model call kind ${JSON.stringify(kind)} is neither "function" nor "freeform"`,
|
|
649
608
|
);
|
|
@@ -657,10 +616,13 @@ export function fileModelCall(filing, kind, name, args) {
|
|
|
657
616
|
);
|
|
658
617
|
if (rule.input === WHOLE_ARGUMENTS)
|
|
659
618
|
return { tool_name: rule.tool_name, tool_input: args };
|
|
660
|
-
const entries = Object.entries(rule.input).map(([key, ref]) =>
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
619
|
+
const entries = Object.entries(rule.input).map(([key, ref]) => {
|
|
620
|
+
const argument = ARGUMENT_REF.exec(ref)?.[1];
|
|
621
|
+
if (argument === undefined) return [key, args];
|
|
622
|
+
// A missing key, a null value, or non-object arguments all fail to resolve.
|
|
623
|
+
const value = isPlainObject(args) ? lookup(args, argument) : undefined;
|
|
624
|
+
return [key, value === null ? undefined : value];
|
|
625
|
+
});
|
|
664
626
|
if (entries.some(([, value]) => value === undefined)) return null;
|
|
665
627
|
// fromEntries defines own properties, so an input key `__proto__` stays a
|
|
666
628
|
// key, as it is in a Python consumer's dict, instead of hitting the setter.
|
|
@@ -705,8 +667,8 @@ export const VETO_IMPOSSIBLE = Object.freeze([
|
|
|
705
667
|
* @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
|
|
706
668
|
* @property {boolean} NATIVE_ASK_TIER whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
|
|
707
669
|
* @property {PreToolFiling} PRE_TOOL_FILING how this host's pre-tool hook files a model tool call, for a consumer matching the model's calls against the hook's. `{}` when no rename is modelled.
|
|
708
|
-
* @property {PreToolResponse} PRE_TOOL_RESPONSE the `hookSpecificOutput` keys this host reads on its pre-tool event,
|
|
709
|
-
* @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
|
|
670
|
+
* @property {PreToolResponse} PRE_TOOL_RESPONSE the top-level and `hookSpecificOutput` keys this host reads on its pre-tool event, each list with whether an unread key there runs the call. The conformance harness holds every pre-tool render to both lists.
|
|
671
|
+
* @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path on the kind's `NATIVE_EVENT_FOR` event only; another native event of that kind (Codex's `PermissionRequest`) may drop more. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
|
|
710
672
|
*/
|
|
711
673
|
|
|
712
674
|
/**
|
|
@@ -1050,6 +1012,8 @@ export const DEFAULT_DENY_REASON = "blocked by monitor";
|
|
|
1050
1012
|
* exit. Every host here discards (or never gets) the stdout body on exit 2.
|
|
1051
1013
|
* - `reason_required`: the host drops a block whose reason is empty and runs
|
|
1052
1014
|
* the tool, so every body field that carries the reason must be non-empty.
|
|
1015
|
+
* Unlike the other two fields, this holds for ANY block body the host parses,
|
|
1016
|
+
* enforced or advisory: Codex voids an empty-reason deny either way.
|
|
1053
1017
|
*
|
|
1054
1018
|
* {@link gatedResponse} derives each adapter's enforced render from its row,
|
|
1055
1019
|
* and the conformance harness holds every enforced render to the same row.
|
package/src/fixtures/codex.json
CHANGED
|
@@ -79,7 +79,8 @@
|
|
|
79
79
|
"enforced": false,
|
|
80
80
|
"stdout": {
|
|
81
81
|
"hookSpecificOutput": {
|
|
82
|
-
"hookEventName": "PreToolUse"
|
|
82
|
+
"hookEventName": "PreToolUse",
|
|
83
|
+
"additionalContext": "redacted 1 secret"
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
}
|
|
@@ -127,7 +128,7 @@
|
|
|
127
128
|
"mutated_input": {
|
|
128
129
|
"command": "ls"
|
|
129
130
|
},
|
|
130
|
-
"additional_context": "
|
|
131
|
+
"additional_context": "narrowed to a plain ls"
|
|
131
132
|
},
|
|
132
133
|
"native": {
|
|
133
134
|
"transport": "external_hook",
|
|
@@ -136,9 +137,11 @@
|
|
|
136
137
|
"stdout": {
|
|
137
138
|
"hookSpecificOutput": {
|
|
138
139
|
"hookEventName": "PreToolUse",
|
|
140
|
+
"permissionDecision": "allow",
|
|
139
141
|
"updatedInput": {
|
|
140
142
|
"command": "ls"
|
|
141
|
-
}
|
|
143
|
+
},
|
|
144
|
+
"additionalContext": "narrowed to a plain ls"
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
}
|
|
@@ -285,6 +288,7 @@
|
|
|
285
288
|
"stdout": {
|
|
286
289
|
"hookSpecificOutput": {
|
|
287
290
|
"hookEventName": "PreToolUse",
|
|
291
|
+
"permissionDecision": "allow",
|
|
288
292
|
"updatedInput": {
|
|
289
293
|
"command": ["ls"]
|
|
290
294
|
}
|
|
@@ -370,8 +374,10 @@
|
|
|
370
374
|
"stdout": {
|
|
371
375
|
"hookSpecificOutput": {
|
|
372
376
|
"hookEventName": "PermissionRequest",
|
|
373
|
-
"
|
|
374
|
-
|
|
377
|
+
"decision": {
|
|
378
|
+
"behavior": "deny",
|
|
379
|
+
"message": "nope"
|
|
380
|
+
}
|
|
375
381
|
}
|
|
376
382
|
},
|
|
377
383
|
"stderr": "nope"
|
|
@@ -82,11 +82,11 @@ export const NATIVE_ASK_TIER: true;
|
|
|
82
82
|
*/
|
|
83
83
|
export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
|
|
84
84
|
/**
|
|
85
|
-
* The `hookSpecificOutput` keys Claude Code's hooks reference
|
|
86
|
-
* `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether
|
|
87
|
-
* fails open is NOT established: the reference
|
|
88
|
-
* schema validation lets the call go ahead, but
|
|
89
|
-
* it, and the host's source is not public.
|
|
85
|
+
* The top-level and `hookSpecificOutput` keys Claude Code's hooks reference
|
|
86
|
+
* documents for `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether
|
|
87
|
+
* an unread key fails open, at either level, is NOT established: the reference
|
|
88
|
+
* says an exit-0 body that fails schema validation lets the call go ahead, but
|
|
89
|
+
* not whether an extra key fails it, and the host's source is not public.
|
|
90
90
|
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
91
91
|
*/
|
|
92
92
|
export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
|
|
@@ -36,15 +36,17 @@ export function parse(native: any, { version }?: {
|
|
|
36
36
|
* on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable` false) the
|
|
37
37
|
* body still renders but exit stays 0 and `enforced` is false — advisory only.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
40
|
-
* decision rides `hookSpecificOutput.permissionDecision`, a
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
* Each native event gets its OWN shape, because Codex parses each strictly: a
|
|
40
|
+
* PreToolUse decision rides `hookSpecificOutput.permissionDecision`, a
|
|
41
|
+
* PermissionRequest one `hookSpecificOutput.decision.behavior`, and a
|
|
42
|
+
* post-tool one the top-level `decision`/`reason` pair.
|
|
43
43
|
*
|
|
44
44
|
* `soleGate` (default `false`) is the same dangerous, explicit opt-in as the
|
|
45
|
-
* Claude adapter
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* Claude adapter, and acts only on PermissionRequest: there an `allow` renders
|
|
46
|
+
* `behavior: "allow"`, which grants the approval (except, from 0.166, when the
|
|
47
|
+
* hook is environment-sourced). PreToolUse has no allow that
|
|
48
|
+
* approves anything (Codex accepts `"allow"` only beside `updatedInput`, as a
|
|
49
|
+
* rewrite), and post-tool there is no approval left to grant.
|
|
48
50
|
* @param {Verdict} verdict
|
|
49
51
|
* @param {ToolCallEvent} event
|
|
50
52
|
* @param {{ soleGate?: boolean }} [options]
|
|
@@ -103,11 +105,15 @@ export const INTEGRATION_MODE: "external_hook";
|
|
|
103
105
|
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
104
106
|
export const COVERAGE: import("../control-plane.mjs").CoverageMap;
|
|
105
107
|
/**
|
|
106
|
-
* Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind
|
|
107
|
-
* `render` drops them.
|
|
108
|
-
* `updatedInput`
|
|
109
|
-
* `
|
|
110
|
-
*
|
|
108
|
+
* Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind's
|
|
109
|
+
* {@link NATIVE_EVENT_FOR} event, so `render` drops them. PreToolUse takes
|
|
110
|
+
* `updatedInput` (applied only beside `permissionDecision: "allow"`) and
|
|
111
|
+
* `additionalContext`; PostToolUse takes `additionalContext` alone ([X8], read
|
|
112
|
+
* at `rust-v0.166.0` and identical at `rust-v0.135.0`). `PermissionRequest`,
|
|
113
|
+
* which also parses as `pre_tool`, takes neither: its schema is
|
|
114
|
+
* `{hookEventName, decision: {behavior, message}}`, and a body carrying
|
|
115
|
+
* `updatedInput` is ignored (Codex's own approval flow decides), so
|
|
116
|
+
* {@link render} drops both there.
|
|
111
117
|
*
|
|
112
118
|
* NEITHER kind can rewrite tool output: PostToolUse "can't undo side effects
|
|
113
119
|
* from a tool that already ran", and Codex rejects `updatedMCPToolOutput`, so
|
|
@@ -144,12 +150,15 @@ export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
|
|
|
144
150
|
*/
|
|
145
151
|
export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
|
|
146
152
|
/**
|
|
147
|
-
* Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
+
* Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes the
|
|
154
|
+
* body into `deny_unknown_fields` structs at both levels: these seven top-level
|
|
155
|
+
* keys (`PreToolUseCommandOutputWire` with its flattened universal fields), and
|
|
156
|
+
* these five in `hookSpecificOutput`. Any other key fails the whole body: the
|
|
157
|
+
* run is marked failed, no block is set, and the call RUNS. A known key can
|
|
158
|
+
* still void the body by its value or combination (`continue: false`, any
|
|
159
|
+
* `stopReason`, `suppressOutput: true`, `decision: "approve"`, `updatedInput`
|
|
160
|
+
* without `permissionDecision: "allow"`), which this declaration does not
|
|
161
|
+
* describe.
|
|
153
162
|
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
154
163
|
*/
|
|
155
164
|
export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
|
|
@@ -139,7 +139,9 @@ export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
|
|
|
139
139
|
* other key is ignored (`packages/core/src/hooks/types.ts`
|
|
140
140
|
* `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
|
|
141
141
|
* and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
|
|
142
|
-
* `62bab5b2`).
|
|
142
|
+
* `62bab5b2`). The top level is the `HookOutput` interface's seven keys, read
|
|
143
|
+
* from an unchecked `JSON.parse` cast (`hooks/hookRunner.ts`), so an unknown
|
|
144
|
+
* top-level key is ignored too.
|
|
143
145
|
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
144
146
|
*/
|
|
145
147
|
export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
|
|
@@ -135,9 +135,26 @@ export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
|
|
|
135
135
|
*/
|
|
136
136
|
export function flatToolName(namespace: string | null | undefined, name: string): string;
|
|
137
137
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
|
|
139
|
+
* `BeforeTool`), as two key lists, each empty when the host reads no
|
|
140
|
+
* body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
|
|
141
|
+
* `top_level_keys` at the body's top level. Each list has a fail-open bit,
|
|
142
|
+
* `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
|
|
143
|
+
* other key there voids the whole body and the call RUNS; `false` ⇒ other keys
|
|
144
|
+
* are ignored; `null` ⇒ not established from the host's source.
|
|
145
|
+
* @typedef {Readonly<{ hook_specific_output_keys: readonly string[], unread_key_fails_open: boolean | null, top_level_keys: readonly string[], unread_top_level_key_fails_open: boolean | null }>} PreToolResponse
|
|
146
|
+
*/
|
|
147
|
+
/**
|
|
148
|
+
* Whether `value` is a plain object: not null, not an array.
|
|
149
|
+
* @param {unknown} value
|
|
150
|
+
* @returns {value is Record<string, unknown>}
|
|
151
|
+
*/
|
|
152
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
153
|
+
/**
|
|
154
|
+
* Whether `rule` is a non-null {@link PreToolFiling} rule: exactly a non-empty
|
|
155
|
+
* string `tool_name` and an `input` that is `"$"` or an object of `"$"` /
|
|
156
|
+
* `"$.<key>"` references. The one grammar both {@link fileModelCall} and the
|
|
157
|
+
* conformance harness hold a rule to.
|
|
141
158
|
* @param {unknown} rule
|
|
142
159
|
* @returns {boolean}
|
|
143
160
|
*/
|
|
@@ -147,7 +164,7 @@ export function isFilingRule(rule: unknown): boolean;
|
|
|
147
164
|
* with arguments value `args`, per that host's {@link PreToolFiling}; `null`
|
|
148
165
|
* when the hook files none. The reference reading of the mini-language
|
|
149
166
|
* `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
|
|
150
|
-
* grammar or a kind
|
|
167
|
+
* grammar or a kind other than `"function"` or `"freeform"`.
|
|
151
168
|
* @param {PreToolFiling} filing
|
|
152
169
|
* @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
|
|
153
170
|
* @param {string} name the model tool name, a {@link flatToolName}
|
|
@@ -172,8 +189,8 @@ export function fileModelCall(filing: PreToolFiling, kind: "function" | "freefor
|
|
|
172
189
|
* @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
|
|
173
190
|
* @property {boolean} NATIVE_ASK_TIER whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
|
|
174
191
|
* @property {PreToolFiling} PRE_TOOL_FILING how this host's pre-tool hook files a model tool call, for a consumer matching the model's calls against the hook's. `{}` when no rename is modelled.
|
|
175
|
-
* @property {PreToolResponse} PRE_TOOL_RESPONSE the `hookSpecificOutput` keys this host reads on its pre-tool event,
|
|
176
|
-
* @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
|
|
192
|
+
* @property {PreToolResponse} PRE_TOOL_RESPONSE the top-level and `hookSpecificOutput` keys this host reads on its pre-tool event, each list with whether an unread key there runs the call. The conformance harness holds every pre-tool render to both lists.
|
|
193
|
+
* @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path on the kind's `NATIVE_EVENT_FOR` event only; another native event of that kind (Codex's `PermissionRequest`) may drop more. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
|
|
177
194
|
*/
|
|
178
195
|
/**
|
|
179
196
|
* Build a normalized {@link ToolCallEvent}, stamping the schema version. Pure —
|
|
@@ -582,12 +599,8 @@ export const VERDICT_CONTENT_FIELDS: readonly string[];
|
|
|
582
599
|
export const UNRENDERED_ON_UNKNOWN: ReadonlySet<string>;
|
|
583
600
|
/** The {@link PreToolFiling} input reference to a call's whole arguments value. */
|
|
584
601
|
export const WHOLE_ARGUMENTS: "$";
|
|
585
|
-
/** The prefix of a {@link PreToolFiling} input reference to one top-level argument. */
|
|
586
|
-
export const ARGUMENT_KEY_PREFIX: "$.";
|
|
587
602
|
/** What a {@link PreToolFiling} rule must be, for error messages. */
|
|
588
603
|
export const FILING_RULE_GRAMMAR: "{tool_name: <non-empty string>, input: \"$\" or an object of \"$\"/\"$.<key>\" references (<key>: non-empty, no \".\")}";
|
|
589
|
-
/** The kinds of model tool call a {@link PreToolFiling} distinguishes. */
|
|
590
|
-
export const MODEL_CALL_KINDS: ReadonlySet<string>;
|
|
591
604
|
/**
|
|
592
605
|
* PROBLEM CLASS — an event marked vetoable on a path where the host can never
|
|
593
606
|
* veto it, so a deny reads downstream as a block that never happened.
|
|
@@ -634,6 +647,8 @@ export const DEFAULT_DENY_REASON: "blocked by monitor";
|
|
|
634
647
|
* exit. Every host here discards (or never gets) the stdout body on exit 2.
|
|
635
648
|
* - `reason_required`: the host drops a block whose reason is empty and runs
|
|
636
649
|
* the tool, so every body field that carries the reason must be non-empty.
|
|
650
|
+
* Unlike the other two fields, this holds for ANY block body the host parses,
|
|
651
|
+
* enforced or advisory: Codex voids an empty-reason deny either way.
|
|
637
652
|
*
|
|
638
653
|
* {@link gatedResponse} derives each adapter's enforced render from its row,
|
|
639
654
|
* and the conformance harness holds every enforced render to the same row.
|
|
@@ -645,17 +660,19 @@ export const BLOCK_TRANSPORT: Readonly<Record<string, Readonly<{
|
|
|
645
660
|
reason_required: boolean;
|
|
646
661
|
}>>>;
|
|
647
662
|
/**
|
|
648
|
-
* What a host reads from
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
* `
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
663
|
+
* What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
|
|
664
|
+
* `BeforeTool`), as two key lists, each empty when the host reads no
|
|
665
|
+
* body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
|
|
666
|
+
* `top_level_keys` at the body's top level. Each list has a fail-open bit,
|
|
667
|
+
* `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
|
|
668
|
+
* other key there voids the whole body and the call RUNS; `false` ⇒ other keys
|
|
669
|
+
* are ignored; `null` ⇒ not established from the host's source.
|
|
655
670
|
*/
|
|
656
671
|
export type PreToolResponse = Readonly<{
|
|
657
672
|
hook_specific_output_keys: readonly string[];
|
|
658
673
|
unread_key_fails_open: boolean | null;
|
|
674
|
+
top_level_keys: readonly string[];
|
|
675
|
+
unread_top_level_key_fails_open: boolean | null;
|
|
659
676
|
}>;
|
|
660
677
|
/**
|
|
661
678
|
* The translator for one agent's protocol. `parse` maps a native event to a
|
|
@@ -687,11 +704,11 @@ export type Adapter = {
|
|
|
687
704
|
*/
|
|
688
705
|
PRE_TOOL_FILING: PreToolFiling;
|
|
689
706
|
/**
|
|
690
|
-
* the `hookSpecificOutput` keys this host reads on its pre-tool event,
|
|
707
|
+
* the top-level and `hookSpecificOutput` keys this host reads on its pre-tool event, each list with whether an unread key there runs the call. The conformance harness holds every pre-tool render to both lists.
|
|
691
708
|
*/
|
|
692
709
|
PRE_TOOL_RESPONSE: PreToolResponse;
|
|
693
710
|
/**
|
|
694
|
-
* per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
|
|
711
|
+
* per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path on the kind's `NATIVE_EVENT_FOR` event only; another native event of that kind (Codex's `PermissionRequest`) may drop more. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
|
|
695
712
|
*/
|
|
696
713
|
UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
|
|
697
714
|
};
|