agent-control-plane-core 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -3
- package/package.json +1 -1
- package/schema/control-plane/v1/agents.json +86 -1
- package/src/adapters/amp.mjs +21 -0
- package/src/adapters/claude.mjs +38 -0
- package/src/adapters/codex.mjs +164 -53
- package/src/adapters/gemini.mjs +36 -0
- package/src/agent-manifest.mjs +9 -1
- package/src/conformance.mjs +204 -4
- package/src/control-plane.mjs +183 -5
- package/src/fixtures/codex.json +11 -5
- package/types/adapters/amp.d.mts +12 -0
- package/types/adapters/claude.d.mts +15 -0
- package/types/adapters/codex.d.mts +55 -12
- package/types/adapters/gemini.d.mts +19 -0
- package/types/conformance.d.mts +7 -1
- package/types/control-plane.d.mts +104 -6
package/README.md
CHANGED
|
@@ -192,9 +192,48 @@ 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 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.
|
|
208
|
+
- `pre_tool_filing`: how the pre-tool hook files a model's tool call, so a
|
|
209
|
+
monitor can match the model's calls against the hook's. It is keyed by model
|
|
210
|
+
tool name: the call's `name`, prefixed with its `namespace` with no
|
|
211
|
+
separator, unless the namespace is empty or Codex's default `functions`
|
|
212
|
+
(`flatToolName` in `/contract`). Each value is one of:
|
|
213
|
+
- `null`: no pre-tool hook fires for this call.
|
|
214
|
+
- `{"tool_name": T, "input": I}`: the hook files `tool_name` T, and builds
|
|
215
|
+
`tool_input` from I. I is either `"$"`, meaning the call's whole arguments
|
|
216
|
+
value, or an object whose values are each `"$"` or `"$.<key>"`, meaning
|
|
217
|
+
that top-level argument. `<key>` is one literal key, non-empty and with no
|
|
218
|
+
`.`. If a `"$.<key>"` does not resolve (the arguments are not an object,
|
|
219
|
+
lack the key, or hold `null` there), the hook files nothing.
|
|
220
|
+
|
|
221
|
+
A function call the map does not list is filed with the model's own name and
|
|
222
|
+
arguments. A freeform (custom) call the map does not list files nothing; of
|
|
223
|
+
the shipped hosts only Codex makes them. The map describes calls the host
|
|
224
|
+
dispatches: Codex files nothing for a call it rejects first, e.g. a name it
|
|
225
|
+
has no tool for, a call kind the tool does not take, or arguments a handler
|
|
226
|
+
refuses before its hook (an `apply_patch` patch that does not parse, an
|
|
227
|
+
`exec_command` with a non-string `workdir`). On Codex the arguments value is a function call's
|
|
228
|
+
`arguments` parsed as JSON (an empty or all-whitespace string reads as `{}`,
|
|
229
|
+
unparseable text stays the raw string), or a freeform call's raw `input`
|
|
230
|
+
string. `fileModelCall` in `/contract` is the reference implementation; it
|
|
231
|
+
takes the call's kind, `"function"` or `"freeform"`. The map does not model
|
|
232
|
+
Codex's MCP names: Codex files an MCP call as `mcp__<namespace>__<tool>`,
|
|
233
|
+
which can differ from the flat name.
|
|
234
|
+
|
|
235
|
+
Read the file from the installed package, so it always matches the adapters
|
|
236
|
+
that version ships.
|
|
198
237
|
|
|
199
238
|
Each entry fails the way its host expects when the hook itself breaks: claude,
|
|
200
239
|
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.14.0",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -37,6 +37,27 @@
|
|
|
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,
|
|
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
|
|
40
61
|
}
|
|
41
62
|
},
|
|
42
63
|
"codex": {
|
|
@@ -59,7 +80,6 @@
|
|
|
59
80
|
},
|
|
60
81
|
"unrendered_fields_on_allow": {
|
|
61
82
|
"pre_tool": [
|
|
62
|
-
"additional_context",
|
|
63
83
|
"mutated_output"
|
|
64
84
|
],
|
|
65
85
|
"post_tool": [
|
|
@@ -81,6 +101,47 @@
|
|
|
81
101
|
"mutated_input",
|
|
82
102
|
"mutated_output"
|
|
83
103
|
]
|
|
104
|
+
},
|
|
105
|
+
"pre_tool_filing": {
|
|
106
|
+
"exec_command": {
|
|
107
|
+
"tool_name": "Bash",
|
|
108
|
+
"input": {
|
|
109
|
+
"command": "$.cmd"
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"apply_patch": {
|
|
113
|
+
"tool_name": "apply_patch",
|
|
114
|
+
"input": {
|
|
115
|
+
"command": "$"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"multi_agent_v1spawn_agent": {
|
|
119
|
+
"tool_name": "spawn_agent",
|
|
120
|
+
"input": "$"
|
|
121
|
+
},
|
|
122
|
+
"write_stdin": null,
|
|
123
|
+
"wait": null,
|
|
124
|
+
"exec": null
|
|
125
|
+
},
|
|
126
|
+
"pre_tool_response": {
|
|
127
|
+
"hook_specific_output_keys": [
|
|
128
|
+
"additionalContext",
|
|
129
|
+
"hookEventName",
|
|
130
|
+
"permissionDecision",
|
|
131
|
+
"permissionDecisionReason",
|
|
132
|
+
"updatedInput"
|
|
133
|
+
],
|
|
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
|
|
84
145
|
}
|
|
85
146
|
},
|
|
86
147
|
"amp": {
|
|
@@ -127,6 +188,13 @@
|
|
|
127
188
|
"mutated_input",
|
|
128
189
|
"mutated_output"
|
|
129
190
|
]
|
|
191
|
+
},
|
|
192
|
+
"pre_tool_filing": {},
|
|
193
|
+
"pre_tool_response": {
|
|
194
|
+
"hook_specific_output_keys": [],
|
|
195
|
+
"unread_key_fails_open": false,
|
|
196
|
+
"top_level_keys": [],
|
|
197
|
+
"unread_top_level_key_fails_open": false
|
|
130
198
|
}
|
|
131
199
|
},
|
|
132
200
|
"gemini": {
|
|
@@ -169,6 +237,23 @@
|
|
|
169
237
|
"mutated_input",
|
|
170
238
|
"mutated_output"
|
|
171
239
|
]
|
|
240
|
+
},
|
|
241
|
+
"pre_tool_filing": {},
|
|
242
|
+
"pre_tool_response": {
|
|
243
|
+
"hook_specific_output_keys": [
|
|
244
|
+
"tool_input"
|
|
245
|
+
],
|
|
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
|
|
172
257
|
}
|
|
173
258
|
}
|
|
174
259
|
}
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -169,6 +169,25 @@ 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
|
+
top_level_keys: Object.freeze([]),
|
|
188
|
+
unread_top_level_key_fails_open: false,
|
|
189
|
+
});
|
|
190
|
+
|
|
172
191
|
/**
|
|
173
192
|
* The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
|
|
174
193
|
* pure exit-code transport: 0 allow / 1 ask / 2 reject.
|
|
@@ -288,6 +307,8 @@ export const ampAdapter = {
|
|
|
288
307
|
UNRENDERED_FIELDS,
|
|
289
308
|
NATIVE_ASK_TIER,
|
|
290
309
|
NATIVE_EVENT_FOR,
|
|
310
|
+
PRE_TOOL_FILING,
|
|
311
|
+
PRE_TOOL_RESPONSE,
|
|
291
312
|
parse,
|
|
292
313
|
render,
|
|
293
314
|
};
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -125,6 +125,42 @@ 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 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
|
+
* @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
|
+
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,
|
|
162
|
+
});
|
|
163
|
+
|
|
128
164
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
129
165
|
export const HookEvent = Object.freeze({
|
|
130
166
|
PRE_TOOL_USE: "PreToolUse",
|
|
@@ -339,6 +375,8 @@ export const claudeAdapter = {
|
|
|
339
375
|
UNRENDERED_FIELDS,
|
|
340
376
|
NATIVE_ASK_TIER,
|
|
341
377
|
NATIVE_EVENT_FOR,
|
|
378
|
+
PRE_TOOL_FILING,
|
|
379
|
+
PRE_TOOL_RESPONSE,
|
|
342
380
|
parse,
|
|
343
381
|
render,
|
|
344
382
|
};
|
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,
|
|
@@ -39,6 +38,7 @@ import {
|
|
|
39
38
|
asObject,
|
|
40
39
|
asString,
|
|
41
40
|
asStringOrNull,
|
|
41
|
+
WHOLE_ARGUMENTS,
|
|
42
42
|
} from "../control-plane.mjs";
|
|
43
43
|
|
|
44
44
|
/** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
|
|
@@ -90,8 +90,9 @@ export const COVERAGE = Object.freeze({
|
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* The event kinds this host can actually gate. `PreToolUse
|
|
94
|
-
*
|
|
93
|
+
* The event kinds this host can actually gate. `PreToolUse` takes
|
|
94
|
+
* `permissionDecision: "deny"`, `PermissionRequest` `decision.behavior: "deny"`;
|
|
95
|
+
* `PostToolUse` takes a top-level
|
|
95
96
|
* `decision: "block"` (exit 2 "writes the feedback reason to stderr and blocks
|
|
96
97
|
* further processing"), so a post-tool deny stops the turn even though the tool
|
|
97
98
|
* itself already ran — the same shape the Claude adapter gates. Every other
|
|
@@ -107,11 +108,15 @@ const GATED_EVENTS = Object.freeze(
|
|
|
107
108
|
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
108
109
|
|
|
109
110
|
/**
|
|
110
|
-
* Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind
|
|
111
|
-
* `render` drops them.
|
|
112
|
-
* `updatedInput`
|
|
113
|
-
* `
|
|
114
|
-
*
|
|
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.
|
|
115
120
|
*
|
|
116
121
|
* NEITHER kind can rewrite tool output: PostToolUse "can't undo side effects
|
|
117
122
|
* from a tool that already ran", and Codex rejects `updatedMCPToolOutput`, so
|
|
@@ -126,10 +131,84 @@ export const UNRENDERED_FIELDS = Object.freeze({
|
|
|
126
131
|
...Object.fromEntries(
|
|
127
132
|
Object.values(EventKind).map((kind) => [kind, UNRENDERED_ON_UNKNOWN]),
|
|
128
133
|
),
|
|
129
|
-
[EventKind.PRE_TOOL]: readonlySet(["mutated_output"
|
|
134
|
+
[EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
|
|
130
135
|
[EventKind.POST_TOOL]: readonlySet(["mutated_input", "mutated_output"]),
|
|
131
136
|
});
|
|
132
137
|
|
|
138
|
+
/**
|
|
139
|
+
* How Codex's `PreToolUse` hook files a model tool call ([X7], read from
|
|
140
|
+
* Codex's source at `rust-v0.166.0`). A function tool is filed under its flat
|
|
141
|
+
* name (namespace + name) with its parsed arguments by default; these
|
|
142
|
+
* handlers supply their own payload or none:
|
|
143
|
+
* - `exec_command` → `Bash` with `{command: <cmd>}`; arguments Codex cannot
|
|
144
|
+
* parse as `ExecCommandArgs` file nothing, which this table models only for
|
|
145
|
+
* a missing or null `cmd` or scalar arguments. Not modelled: other type
|
|
146
|
+
* errors, and a positional array, which serde reads (`["ls"]` is `cmd: "ls"`).
|
|
147
|
+
* - `apply_patch` (a freeform tool) → `apply_patch` with its raw patch text as
|
|
148
|
+
* `command`.
|
|
149
|
+
* - `spawn_agent` in the `multi_agent_v1` namespace → `spawn_agent`.
|
|
150
|
+
* - `write_stdin`, code mode's `wait`, and code mode's freeform `exec` → no hook.
|
|
151
|
+
* These rules assume the named tool owns the name; a client-defined tool
|
|
152
|
+
* called `wait` with code mode off takes the default payload.
|
|
153
|
+
* The default payload is function-only, so a freeform extension tool files
|
|
154
|
+
* nothing too — the mini-language's rule for an unlisted freeform call.
|
|
155
|
+
* NOT MODELLED: Codex files an MCP call as `mcp__<namespace>__<tool>`, with
|
|
156
|
+
* `_` trimmed from the namespace's end and the tool's start (`mcp.rs`
|
|
157
|
+
* `hook_tool_name`), which can differ from the flat name — a rule keyed by
|
|
158
|
+
* one name cannot express that.
|
|
159
|
+
* @type {import("../control-plane.mjs").PreToolFiling}
|
|
160
|
+
*/
|
|
161
|
+
export const PRE_TOOL_FILING = Object.freeze({
|
|
162
|
+
exec_command: Object.freeze({
|
|
163
|
+
tool_name: "Bash",
|
|
164
|
+
input: Object.freeze({ command: "$.cmd" }),
|
|
165
|
+
}),
|
|
166
|
+
apply_patch: Object.freeze({
|
|
167
|
+
tool_name: "apply_patch",
|
|
168
|
+
input: Object.freeze({ command: WHOLE_ARGUMENTS }),
|
|
169
|
+
}),
|
|
170
|
+
multi_agent_v1spawn_agent: Object.freeze({
|
|
171
|
+
tool_name: "spawn_agent",
|
|
172
|
+
input: WHOLE_ARGUMENTS,
|
|
173
|
+
}),
|
|
174
|
+
write_stdin: null,
|
|
175
|
+
wait: null,
|
|
176
|
+
exec: null,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
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.
|
|
189
|
+
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
190
|
+
*/
|
|
191
|
+
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
192
|
+
hook_specific_output_keys: Object.freeze([
|
|
193
|
+
"additionalContext",
|
|
194
|
+
"hookEventName",
|
|
195
|
+
"permissionDecision",
|
|
196
|
+
"permissionDecisionReason",
|
|
197
|
+
"updatedInput",
|
|
198
|
+
]),
|
|
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,
|
|
210
|
+
});
|
|
211
|
+
|
|
133
212
|
/** Minimum Codex version whose hook can actually veto a tool call. */
|
|
134
213
|
export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
|
|
135
214
|
|
|
@@ -299,15 +378,17 @@ export function parse(native, { version } = {}) {
|
|
|
299
378
|
* on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable` false) the
|
|
300
379
|
* body still renders but exit stays 0 and `enforced` is false — advisory only.
|
|
301
380
|
*
|
|
302
|
-
*
|
|
303
|
-
* decision rides `hookSpecificOutput.permissionDecision`, a
|
|
304
|
-
*
|
|
305
|
-
*
|
|
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.
|
|
306
385
|
*
|
|
307
386
|
* `soleGate` (default `false`) is the same dangerous, explicit opt-in as the
|
|
308
|
-
* Claude adapter
|
|
309
|
-
*
|
|
310
|
-
*
|
|
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.
|
|
311
392
|
* @param {Verdict} verdict
|
|
312
393
|
* @param {ToolCallEvent} event
|
|
313
394
|
* @param {{ soleGate?: boolean }} [options]
|
|
@@ -333,15 +414,12 @@ export function render(verdict, event, { soleGate = false } = {}) {
|
|
|
333
414
|
unblocked_exit_code: 0,
|
|
334
415
|
enforced,
|
|
335
416
|
reason: vd.reason,
|
|
336
|
-
stdout:
|
|
337
|
-
event.event === EventKind.POST_TOOL
|
|
338
|
-
? postToolBody(hookEventName, vd, enforced)
|
|
339
|
-
: preToolBody(hookEventName, vd, event, enforced, soleGate),
|
|
417
|
+
stdout: nativeBody(hookEventName, vd, event, enforced, soleGate),
|
|
340
418
|
});
|
|
341
419
|
}
|
|
342
420
|
|
|
343
421
|
/**
|
|
344
|
-
* The
|
|
422
|
+
* The stdout body for `event`, in the schema its native event answers in.
|
|
345
423
|
* @param {string} hookEventName
|
|
346
424
|
* @param {Verdict} vd a normalized verdict
|
|
347
425
|
* @param {ToolCallEvent} event
|
|
@@ -349,36 +427,73 @@ export function render(verdict, event, { soleGate = false } = {}) {
|
|
|
349
427
|
* @param {boolean} soleGate
|
|
350
428
|
* @returns {Record<string, unknown>}
|
|
351
429
|
*/
|
|
352
|
-
function
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
430
|
+
function nativeBody(hookEventName, vd, event, enforced, soleGate) {
|
|
431
|
+
if (event.event === EventKind.POST_TOOL)
|
|
432
|
+
return postToolBody(hookEventName, vd);
|
|
433
|
+
if (hookEventName === HookEvent.PERMISSION_REQUEST)
|
|
434
|
+
return permissionRequestBody(hookEventName, vd, enforced, soleGate);
|
|
435
|
+
return preToolBody(hookEventName, vd, event);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The body for a PreToolUse (or unmodelled) event. Codex's PreToolUse parser
|
|
440
|
+
* ([X8]) voids the body, and the call runs, on `"allow"` without `updatedInput`
|
|
441
|
+
* and on `updatedInput` without `"allow"` — so an allow emits a decision only
|
|
442
|
+
* to carry a mutation, and a deny never carries one.
|
|
443
|
+
* @param {string} hookEventName
|
|
444
|
+
* @param {Verdict} vd a normalized verdict
|
|
445
|
+
* @param {ToolCallEvent} event
|
|
446
|
+
* @returns {Record<string, unknown>}
|
|
447
|
+
*/
|
|
448
|
+
function preToolBody(hookEventName, vd, event) {
|
|
356
449
|
/** @type {Record<string, unknown>} */
|
|
357
450
|
const body = { hookEventName };
|
|
358
|
-
// An UNKNOWN event reaches this body too. It has no established
|
|
359
|
-
//
|
|
360
|
-
// the
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
451
|
+
// An UNKNOWN event reaches this body too. It has no established channel, so
|
|
452
|
+
// it carries nothing: a decision or `updatedInput` there would act on the
|
|
453
|
+
// host, or read to the caller as applied, while the host ignores it.
|
|
454
|
+
if (event.event !== EventKind.PRE_TOOL) return { hookSpecificOutput: body };
|
|
455
|
+
if (vd.decision !== Decision.ALLOW) {
|
|
456
|
+
// Codex voids a deny body whose reason is missing or empty: no block, no
|
|
457
|
+
// context, and the call runs (`parse_pre_tool_use`, [X8]). That holds on an
|
|
458
|
+
// advisory render too, so every deny carries a reason.
|
|
365
459
|
body.permissionDecision = vd.decision;
|
|
366
|
-
if (vd.reason !== undefined) body.permissionDecisionReason = vd.reason;
|
|
367
|
-
}
|
|
368
|
-
// Codex FAILS OPEN on an enforced deny whose permissionDecisionReason is missing
|
|
369
|
-
// or empty: its PreToolUse output parser drops the block (block_reason = None)
|
|
370
|
-
// and runs the tool (BLOCK_TRANSPORT.codex.reason_required).
|
|
371
|
-
if (enforced && blockTransportFor(AGENT).reason_required)
|
|
372
460
|
body.permissionDecisionReason = blockReason(vd.reason);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// there names a key the host ignores while reading to the caller as a
|
|
376
|
-
// mutation applied.
|
|
377
|
-
if (event.event === EventKind.PRE_TOOL && vd.mutated_input !== undefined)
|
|
461
|
+
} else if (vd.mutated_input !== undefined) {
|
|
462
|
+
body.permissionDecision = Decision.ALLOW;
|
|
378
463
|
body.updatedInput = vd.mutated_input;
|
|
464
|
+
}
|
|
465
|
+
if (vd.additional_context !== undefined)
|
|
466
|
+
body.additionalContext = vd.additional_context;
|
|
379
467
|
return { hookSpecificOutput: body };
|
|
380
468
|
}
|
|
381
469
|
|
|
470
|
+
/**
|
|
471
|
+
* The body for a PermissionRequest event: Codex's
|
|
472
|
+
* `PermissionRequestHookSpecificOutputWire` ([X8]), `{hookEventName, decision:
|
|
473
|
+
* {behavior, message}}`. `behavior: "allow"` GRANTS the approval, so an allow
|
|
474
|
+
* abstains unless `soleGate` opts in. Content fields have no channel here:
|
|
475
|
+
* `updatedInput` voids the body and there is no context key.
|
|
476
|
+
* @param {string} hookEventName
|
|
477
|
+
* @param {Verdict} vd a normalized verdict
|
|
478
|
+
* @param {boolean} enforced
|
|
479
|
+
* @param {boolean} soleGate
|
|
480
|
+
* @returns {Record<string, unknown>}
|
|
481
|
+
*/
|
|
482
|
+
function permissionRequestBody(hookEventName, vd, enforced, soleGate) {
|
|
483
|
+
/** @type {Record<string, unknown>} */
|
|
484
|
+
const hookSpecificOutput = { hookEventName };
|
|
485
|
+
if (vd.decision !== Decision.ALLOW) {
|
|
486
|
+
const message = enforced ? blockReason(vd.reason) : vd.reason;
|
|
487
|
+
hookSpecificOutput.decision = {
|
|
488
|
+
behavior: Decision.DENY,
|
|
489
|
+
...(message === undefined ? {} : { message }),
|
|
490
|
+
};
|
|
491
|
+
} else if (soleGate) {
|
|
492
|
+
hookSpecificOutput.decision = { behavior: Decision.ALLOW };
|
|
493
|
+
}
|
|
494
|
+
return { hookSpecificOutput };
|
|
495
|
+
}
|
|
496
|
+
|
|
382
497
|
/**
|
|
383
498
|
* The body for a PostToolUse event: Codex's documented post-tool schema, a
|
|
384
499
|
* top-level `decision`/`reason` beside a `hookSpecificOutput` carrying
|
|
@@ -387,27 +502,21 @@ function preToolBody(hookEventName, vd, event, enforced, soleGate) {
|
|
|
387
502
|
* objection the host understands there, `block`.
|
|
388
503
|
* @param {string} hookEventName
|
|
389
504
|
* @param {Verdict} vd a normalized verdict
|
|
390
|
-
* @param {boolean} enforced
|
|
391
505
|
* @returns {Record<string, unknown>}
|
|
392
506
|
*/
|
|
393
|
-
function postToolBody(hookEventName, vd
|
|
507
|
+
function postToolBody(hookEventName, vd) {
|
|
394
508
|
/** @type {Record<string, unknown>} */
|
|
395
509
|
const hookSpecificOutput = { hookEventName };
|
|
396
510
|
if (vd.additional_context !== undefined)
|
|
397
511
|
hookSpecificOutput.additionalContext = vd.additional_context;
|
|
398
512
|
/** @type {Record<string, unknown>} */
|
|
399
513
|
const body = { hookSpecificOutput };
|
|
514
|
+
// `parse_post_tool_use` ([X8]) voids `decision: "block"` with an empty
|
|
515
|
+
// reason, and a sync handler's context with it, advisory render or not.
|
|
400
516
|
if (vd.decision !== Decision.ALLOW) {
|
|
401
517
|
body.decision = "block";
|
|
402
|
-
if (vd.reason !== undefined) body.reason = vd.reason;
|
|
403
|
-
}
|
|
404
|
-
// The same guarantee the pre-tool body makes, for the same reason: the
|
|
405
|
-
// documented post-tool schema pairs `decision` with a `reason`, and a block
|
|
406
|
-
// whose reason Codex may drop is a block that may not bite. Unverified for
|
|
407
|
-
// PostToolUse specifically — a non-empty reason is the safe side of that, and
|
|
408
|
-
// the model needs the text either way.
|
|
409
|
-
if (enforced && blockTransportFor(AGENT).reason_required)
|
|
410
518
|
body.reason = blockReason(vd.reason);
|
|
519
|
+
}
|
|
411
520
|
return body;
|
|
412
521
|
}
|
|
413
522
|
|
|
@@ -440,6 +549,8 @@ export function createCodexAdapter(options = {}) {
|
|
|
440
549
|
UNRENDERED_FIELDS,
|
|
441
550
|
NATIVE_ASK_TIER,
|
|
442
551
|
NATIVE_EVENT_FOR,
|
|
552
|
+
PRE_TOOL_FILING,
|
|
553
|
+
PRE_TOOL_RESPONSE,
|
|
443
554
|
parse: (native) => parse(native, { version }),
|
|
444
555
|
render,
|
|
445
556
|
};
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -357,6 +357,40 @@ 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`). 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.
|
|
377
|
+
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
378
|
+
*/
|
|
379
|
+
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
380
|
+
hook_specific_output_keys: Object.freeze(["tool_input"]),
|
|
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,
|
|
392
|
+
});
|
|
393
|
+
|
|
360
394
|
/**
|
|
361
395
|
* Render into Gemini CLI's native external-hook transport. An enforceable deny
|
|
362
396
|
* renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
|
|
@@ -477,6 +511,8 @@ export const geminiAdapter = {
|
|
|
477
511
|
UNRENDERED_FIELDS,
|
|
478
512
|
NATIVE_ASK_TIER,
|
|
479
513
|
NATIVE_EVENT_FOR,
|
|
514
|
+
PRE_TOOL_FILING,
|
|
515
|
+
PRE_TOOL_RESPONSE,
|
|
480
516
|
parse,
|
|
481
517
|
render,
|
|
482
518
|
};
|
package/src/agent-manifest.mjs
CHANGED
|
@@ -30,7 +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.
|
|
35
|
+
* - `pre_tool_filing`: `PRE_TOOL_FILING`, how the pre-tool hook files a model
|
|
36
|
+
* tool call, in the mini-language the `PreToolFiling` typedef specifies.
|
|
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.
|
|
34
40
|
* @param {string} id
|
|
35
41
|
* @returns {Record<string, unknown>}
|
|
36
42
|
*/
|
|
@@ -55,6 +61,8 @@ function manifestRow(id) {
|
|
|
55
61
|
].sort(),
|
|
56
62
|
]),
|
|
57
63
|
),
|
|
64
|
+
pre_tool_filing: adapter.PRE_TOOL_FILING,
|
|
65
|
+
pre_tool_response: adapter.PRE_TOOL_RESPONSE,
|
|
58
66
|
};
|
|
59
67
|
}
|
|
60
68
|
|