agent-control-plane-core 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/package.json +1 -1
- package/schema/control-plane/v1/agents.json +228 -0
- package/src/adapters/amp.mjs +19 -0
- package/src/adapters/claude.mjs +28 -0
- package/src/adapters/codex.mjs +78 -2
- package/src/adapters/gemini.mjs +24 -0
- package/src/agent-manifest.mjs +82 -0
- package/src/conformance.mjs +139 -1
- package/src/control-plane.mjs +180 -4
- package/src/json-schema.mjs +4 -4
- package/src/registry.mjs +35 -11
- package/src/runtime.mjs +30 -10
- package/types/adapters/amp.d.mts +12 -0
- package/types/adapters/claude.d.mts +15 -0
- package/types/adapters/codex.d.mts +39 -2
- package/types/adapters/gemini.d.mts +17 -0
- package/types/agent-manifest.d.mts +8 -0
- package/types/conformance.d.mts +7 -1
- package/types/control-plane.d.mts +98 -4
- package/types/json-schema.d.mts +4 -4
- package/types/registry.d.mts +11 -3
- package/types/runtime.d.mts +23 -9
package/README.md
CHANGED
|
@@ -182,6 +182,56 @@ on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
|
|
|
182
182
|
set `ACP_CODEX_VERSION` to the Codex version your launcher pins (or call
|
|
183
183
|
`createCodexAdapter({ version })`); without it, Codex calls parse advisory.
|
|
184
184
|
|
|
185
|
+
A judge may return its verdict or a promise of one, so a judge can ask a
|
|
186
|
+
remote monitor before it answers. `renderHookResponse` returns a promise.
|
|
187
|
+
`adapterFor(id, options)` takes the options its `LAUNCHER_OPTIONS[id]` row
|
|
188
|
+
names, so a launcher passes a pinned version to whichever agents read one
|
|
189
|
+
without naming them.
|
|
190
|
+
|
|
191
|
+
A consumer that cannot import these modules (a bash hook wrapper, a Python
|
|
192
|
+
monitor) reads `schema/control-plane/v1/agents.json` instead. It is generated
|
|
193
|
+
from the adapters, with one row per agent: its block exit code and reason
|
|
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, the verdict
|
|
196
|
+
fields an allow cannot carry, and two pre-tool facts:
|
|
197
|
+
|
|
198
|
+
- `pre_tool_response`: `hook_specific_output_keys` lists the
|
|
199
|
+
`hookSpecificOutput` keys the host reads on an exit-0 pre-tool answer.
|
|
200
|
+
`unread_key_fails_open` says what any other key does there: `true` means the
|
|
201
|
+
host discards the whole body and runs the call (Codex), `false` means the key
|
|
202
|
+
is ignored, `null` means unknown. The conformance harness fails an adapter
|
|
203
|
+
whose pre-tool render emits a key outside the list. Neither field covers the
|
|
204
|
+
body's top-level keys, which Codex also parses strictly.
|
|
205
|
+
- `pre_tool_filing`: how the pre-tool hook files a model's tool call, so a
|
|
206
|
+
monitor can match the model's calls against the hook's. It is keyed by model
|
|
207
|
+
tool name: the call's `name`, prefixed with its `namespace` with no
|
|
208
|
+
separator, unless the namespace is empty or Codex's default `functions`
|
|
209
|
+
(`flatToolName` in `/contract`). Each value is one of:
|
|
210
|
+
- `null`: no pre-tool hook fires for this call.
|
|
211
|
+
- `{"tool_name": T, "input": I}`: the hook files `tool_name` T, and builds
|
|
212
|
+
`tool_input` from I. I is either `"$"`, meaning the call's whole arguments
|
|
213
|
+
value, or an object whose values are each `"$"` or `"$.<key>"`, meaning
|
|
214
|
+
that top-level argument. `<key>` is one literal key, non-empty and with no
|
|
215
|
+
`.`. If a `"$.<key>"` does not resolve (the arguments are not an object,
|
|
216
|
+
lack the key, or hold `null` there), the hook files nothing.
|
|
217
|
+
|
|
218
|
+
A function call the map does not list is filed with the model's own name and
|
|
219
|
+
arguments. A freeform (custom) call the map does not list files nothing; of
|
|
220
|
+
the shipped hosts only Codex makes them. The map describes calls the host
|
|
221
|
+
dispatches: Codex files nothing for a call it rejects first, e.g. a name it
|
|
222
|
+
has no tool for, a call kind the tool does not take, or arguments a handler
|
|
223
|
+
refuses before its hook (an `apply_patch` patch that does not parse, an
|
|
224
|
+
`exec_command` with a non-string `workdir`). On Codex the arguments value is a function call's
|
|
225
|
+
`arguments` parsed as JSON (an empty or all-whitespace string reads as `{}`,
|
|
226
|
+
unparseable text stays the raw string), or a freeform call's raw `input`
|
|
227
|
+
string. `fileModelCall` in `/contract` is the reference implementation; it
|
|
228
|
+
takes the call's kind, `"function"` or `"freeform"`. The map does not model
|
|
229
|
+
Codex's MCP names: Codex files an MCP call as `mcp__<namespace>__<tool>`,
|
|
230
|
+
which can differ from the flat name.
|
|
231
|
+
|
|
232
|
+
Read the file from the installed package, so it always matches the adapters
|
|
233
|
+
that version ships.
|
|
234
|
+
|
|
185
235
|
Each entry fails the way its host expects when the hook itself breaks: claude,
|
|
186
236
|
codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
|
|
187
237
|
instead, on every host. The entry then exits 2 with the reason on stderr when
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
{
|
|
2
|
+
"claude": {
|
|
3
|
+
"integration_mode": "external_hook",
|
|
4
|
+
"native_ask_tier": true,
|
|
5
|
+
"block": {
|
|
6
|
+
"exit_code": 2,
|
|
7
|
+
"reason_stream": "stderr",
|
|
8
|
+
"reason_required": false
|
|
9
|
+
},
|
|
10
|
+
"host_failure": {
|
|
11
|
+
"exit_code": 0,
|
|
12
|
+
"enforced": false
|
|
13
|
+
},
|
|
14
|
+
"coverage": {
|
|
15
|
+
"builtin": "covered",
|
|
16
|
+
"mcp": "covered",
|
|
17
|
+
"subagent": "covered",
|
|
18
|
+
"resumed": "covered"
|
|
19
|
+
},
|
|
20
|
+
"unrendered_fields_on_allow": {
|
|
21
|
+
"pre_tool": [
|
|
22
|
+
"mutated_output"
|
|
23
|
+
],
|
|
24
|
+
"post_tool": [
|
|
25
|
+
"mutated_input"
|
|
26
|
+
],
|
|
27
|
+
"prompt_submit": [
|
|
28
|
+
"mutated_input",
|
|
29
|
+
"mutated_output"
|
|
30
|
+
],
|
|
31
|
+
"session_start": [
|
|
32
|
+
"mutated_input",
|
|
33
|
+
"mutated_output"
|
|
34
|
+
],
|
|
35
|
+
"unknown": [
|
|
36
|
+
"additional_context",
|
|
37
|
+
"mutated_input",
|
|
38
|
+
"mutated_output"
|
|
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
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"codex": {
|
|
54
|
+
"integration_mode": "external_hook",
|
|
55
|
+
"native_ask_tier": false,
|
|
56
|
+
"block": {
|
|
57
|
+
"exit_code": 2,
|
|
58
|
+
"reason_stream": "stderr",
|
|
59
|
+
"reason_required": true
|
|
60
|
+
},
|
|
61
|
+
"host_failure": {
|
|
62
|
+
"exit_code": 0,
|
|
63
|
+
"enforced": false
|
|
64
|
+
},
|
|
65
|
+
"coverage": {
|
|
66
|
+
"builtin": "partial",
|
|
67
|
+
"mcp": "partial",
|
|
68
|
+
"subagent": "partial",
|
|
69
|
+
"resumed": "partial"
|
|
70
|
+
},
|
|
71
|
+
"unrendered_fields_on_allow": {
|
|
72
|
+
"pre_tool": [
|
|
73
|
+
"additional_context",
|
|
74
|
+
"mutated_output"
|
|
75
|
+
],
|
|
76
|
+
"post_tool": [
|
|
77
|
+
"mutated_input",
|
|
78
|
+
"mutated_output"
|
|
79
|
+
],
|
|
80
|
+
"prompt_submit": [
|
|
81
|
+
"additional_context",
|
|
82
|
+
"mutated_input",
|
|
83
|
+
"mutated_output"
|
|
84
|
+
],
|
|
85
|
+
"session_start": [
|
|
86
|
+
"additional_context",
|
|
87
|
+
"mutated_input",
|
|
88
|
+
"mutated_output"
|
|
89
|
+
],
|
|
90
|
+
"unknown": [
|
|
91
|
+
"additional_context",
|
|
92
|
+
"mutated_input",
|
|
93
|
+
"mutated_output"
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
"pre_tool_filing": {
|
|
97
|
+
"exec_command": {
|
|
98
|
+
"tool_name": "Bash",
|
|
99
|
+
"input": {
|
|
100
|
+
"command": "$.cmd"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"apply_patch": {
|
|
104
|
+
"tool_name": "apply_patch",
|
|
105
|
+
"input": {
|
|
106
|
+
"command": "$"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"multi_agent_v1spawn_agent": {
|
|
110
|
+
"tool_name": "spawn_agent",
|
|
111
|
+
"input": "$"
|
|
112
|
+
},
|
|
113
|
+
"write_stdin": null,
|
|
114
|
+
"wait": null,
|
|
115
|
+
"exec": null
|
|
116
|
+
},
|
|
117
|
+
"pre_tool_response": {
|
|
118
|
+
"hook_specific_output_keys": [
|
|
119
|
+
"additionalContext",
|
|
120
|
+
"hookEventName",
|
|
121
|
+
"permissionDecision",
|
|
122
|
+
"permissionDecisionReason",
|
|
123
|
+
"updatedInput"
|
|
124
|
+
],
|
|
125
|
+
"unread_key_fails_open": true
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"amp": {
|
|
129
|
+
"integration_mode": "external_hook",
|
|
130
|
+
"native_ask_tier": true,
|
|
131
|
+
"block": {
|
|
132
|
+
"exit_code": 2,
|
|
133
|
+
"reason_stream": "stderr",
|
|
134
|
+
"reason_required": false
|
|
135
|
+
},
|
|
136
|
+
"host_failure": {
|
|
137
|
+
"exit_code": 1,
|
|
138
|
+
"enforced": false
|
|
139
|
+
},
|
|
140
|
+
"coverage": {
|
|
141
|
+
"builtin": "covered",
|
|
142
|
+
"mcp": "covered",
|
|
143
|
+
"subagent": "covered",
|
|
144
|
+
"resumed": "unknown"
|
|
145
|
+
},
|
|
146
|
+
"unrendered_fields_on_allow": {
|
|
147
|
+
"pre_tool": [
|
|
148
|
+
"additional_context",
|
|
149
|
+
"mutated_input",
|
|
150
|
+
"mutated_output"
|
|
151
|
+
],
|
|
152
|
+
"post_tool": [
|
|
153
|
+
"additional_context",
|
|
154
|
+
"mutated_input",
|
|
155
|
+
"mutated_output"
|
|
156
|
+
],
|
|
157
|
+
"prompt_submit": [
|
|
158
|
+
"additional_context",
|
|
159
|
+
"mutated_input",
|
|
160
|
+
"mutated_output"
|
|
161
|
+
],
|
|
162
|
+
"session_start": [
|
|
163
|
+
"additional_context",
|
|
164
|
+
"mutated_input",
|
|
165
|
+
"mutated_output"
|
|
166
|
+
],
|
|
167
|
+
"unknown": [
|
|
168
|
+
"additional_context",
|
|
169
|
+
"mutated_input",
|
|
170
|
+
"mutated_output"
|
|
171
|
+
]
|
|
172
|
+
},
|
|
173
|
+
"pre_tool_filing": {},
|
|
174
|
+
"pre_tool_response": {
|
|
175
|
+
"hook_specific_output_keys": [],
|
|
176
|
+
"unread_key_fails_open": false
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"gemini": {
|
|
180
|
+
"integration_mode": "external_hook",
|
|
181
|
+
"native_ask_tier": false,
|
|
182
|
+
"block": {
|
|
183
|
+
"exit_code": 2,
|
|
184
|
+
"reason_stream": "stderr",
|
|
185
|
+
"reason_required": false
|
|
186
|
+
},
|
|
187
|
+
"host_failure": {
|
|
188
|
+
"exit_code": 0,
|
|
189
|
+
"enforced": false
|
|
190
|
+
},
|
|
191
|
+
"coverage": {
|
|
192
|
+
"builtin": "covered",
|
|
193
|
+
"mcp": "unknown",
|
|
194
|
+
"subagent": "unknown",
|
|
195
|
+
"resumed": "unknown"
|
|
196
|
+
},
|
|
197
|
+
"unrendered_fields_on_allow": {
|
|
198
|
+
"pre_tool": [
|
|
199
|
+
"mutated_output"
|
|
200
|
+
],
|
|
201
|
+
"post_tool": [
|
|
202
|
+
"mutated_input",
|
|
203
|
+
"mutated_output"
|
|
204
|
+
],
|
|
205
|
+
"prompt_submit": [
|
|
206
|
+
"mutated_input",
|
|
207
|
+
"mutated_output"
|
|
208
|
+
],
|
|
209
|
+
"session_start": [
|
|
210
|
+
"additional_context",
|
|
211
|
+
"mutated_input",
|
|
212
|
+
"mutated_output"
|
|
213
|
+
],
|
|
214
|
+
"unknown": [
|
|
215
|
+
"additional_context",
|
|
216
|
+
"mutated_input",
|
|
217
|
+
"mutated_output"
|
|
218
|
+
]
|
|
219
|
+
},
|
|
220
|
+
"pre_tool_filing": {},
|
|
221
|
+
"pre_tool_response": {
|
|
222
|
+
"hook_specific_output_keys": [
|
|
223
|
+
"tool_input"
|
|
224
|
+
],
|
|
225
|
+
"unread_key_fails_open": false
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -169,6 +169,23 @@ export function parse(native) {
|
|
|
169
169
|
*/
|
|
170
170
|
export const NATIVE_ASK_TIER = true;
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* No rename is modelled: no source shows Amp filing a call under a
|
|
174
|
+
* name or input other than the model's.
|
|
175
|
+
* @type {import("../control-plane.mjs").PreToolFiling}
|
|
176
|
+
*/
|
|
177
|
+
export const PRE_TOOL_FILING = Object.freeze({});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Amp reads only the hook's exit code and no body, so no key in one can change
|
|
181
|
+
* the outcome.
|
|
182
|
+
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
183
|
+
*/
|
|
184
|
+
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
185
|
+
hook_specific_output_keys: Object.freeze([]),
|
|
186
|
+
unread_key_fails_open: false,
|
|
187
|
+
});
|
|
188
|
+
|
|
172
189
|
/**
|
|
173
190
|
* The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
|
|
174
191
|
* pure exit-code transport: 0 allow / 1 ask / 2 reject.
|
|
@@ -288,6 +305,8 @@ export const ampAdapter = {
|
|
|
288
305
|
UNRENDERED_FIELDS,
|
|
289
306
|
NATIVE_ASK_TIER,
|
|
290
307
|
NATIVE_EVENT_FOR,
|
|
308
|
+
PRE_TOOL_FILING,
|
|
309
|
+
PRE_TOOL_RESPONSE,
|
|
291
310
|
parse,
|
|
292
311
|
render,
|
|
293
312
|
};
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -125,6 +125,32 @@ export const UNRENDERED_FIELDS = Object.freeze({
|
|
|
125
125
|
*/
|
|
126
126
|
export const NATIVE_ASK_TIER = true;
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* No rename is modelled: no source shows Claude Code filing a call under a
|
|
130
|
+
* name or input other than the model's.
|
|
131
|
+
* @type {import("../control-plane.mjs").PreToolFiling}
|
|
132
|
+
*/
|
|
133
|
+
export const PRE_TOOL_FILING = Object.freeze({});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The `hookSpecificOutput` keys Claude Code's hooks reference documents for
|
|
137
|
+
* `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether an unread key
|
|
138
|
+
* fails open is NOT established: the reference says an exit-0 body that fails
|
|
139
|
+
* schema validation lets the call go ahead, but not whether an extra key fails
|
|
140
|
+
* it, and the host's source is not public.
|
|
141
|
+
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
142
|
+
*/
|
|
143
|
+
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
144
|
+
hook_specific_output_keys: Object.freeze([
|
|
145
|
+
"additionalContext",
|
|
146
|
+
"hookEventName",
|
|
147
|
+
"permissionDecision",
|
|
148
|
+
"permissionDecisionReason",
|
|
149
|
+
"updatedInput",
|
|
150
|
+
]),
|
|
151
|
+
unread_key_fails_open: null,
|
|
152
|
+
});
|
|
153
|
+
|
|
128
154
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
129
155
|
export const HookEvent = Object.freeze({
|
|
130
156
|
PRE_TOOL_USE: "PreToolUse",
|
|
@@ -339,6 +365,8 @@ export const claudeAdapter = {
|
|
|
339
365
|
UNRENDERED_FIELDS,
|
|
340
366
|
NATIVE_ASK_TIER,
|
|
341
367
|
NATIVE_EVENT_FOR,
|
|
368
|
+
PRE_TOOL_FILING,
|
|
369
|
+
PRE_TOOL_RESPONSE,
|
|
342
370
|
parse,
|
|
343
371
|
render,
|
|
344
372
|
};
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
asObject,
|
|
40
40
|
asString,
|
|
41
41
|
asStringOrNull,
|
|
42
|
+
WHOLE_ARGUMENTS,
|
|
42
43
|
} from "../control-plane.mjs";
|
|
43
44
|
|
|
44
45
|
/** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
|
|
@@ -130,6 +131,67 @@ export const UNRENDERED_FIELDS = Object.freeze({
|
|
|
130
131
|
[EventKind.POST_TOOL]: readonlySet(["mutated_input", "mutated_output"]),
|
|
131
132
|
});
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* How Codex's `PreToolUse` hook files a model tool call ([X7], read from
|
|
136
|
+
* Codex's source at `rust-v0.166.0`). A function tool is filed under its flat
|
|
137
|
+
* name (namespace + name) with its parsed arguments by default; these
|
|
138
|
+
* handlers supply their own payload or none:
|
|
139
|
+
* - `exec_command` → `Bash` with `{command: <cmd>}`; arguments Codex cannot
|
|
140
|
+
* parse as `ExecCommandArgs` file nothing, which this table models only for
|
|
141
|
+
* a missing or null `cmd` or scalar arguments. Not modelled: other type
|
|
142
|
+
* errors, and a positional array, which serde reads (`["ls"]` is `cmd: "ls"`).
|
|
143
|
+
* - `apply_patch` (a freeform tool) → `apply_patch` with its raw patch text as
|
|
144
|
+
* `command`.
|
|
145
|
+
* - `spawn_agent` in the `multi_agent_v1` namespace → `spawn_agent`.
|
|
146
|
+
* - `write_stdin`, code mode's `wait`, and code mode's freeform `exec` → no hook.
|
|
147
|
+
* These rules assume the named tool owns the name; a client-defined tool
|
|
148
|
+
* called `wait` with code mode off takes the default payload.
|
|
149
|
+
* The default payload is function-only, so a freeform extension tool files
|
|
150
|
+
* nothing too — the mini-language's rule for an unlisted freeform call.
|
|
151
|
+
* NOT MODELLED: Codex files an MCP call as `mcp__<namespace>__<tool>`, with
|
|
152
|
+
* `_` trimmed from the namespace's end and the tool's start (`mcp.rs`
|
|
153
|
+
* `hook_tool_name`), which can differ from the flat name — a rule keyed by
|
|
154
|
+
* one name cannot express that.
|
|
155
|
+
* @type {import("../control-plane.mjs").PreToolFiling}
|
|
156
|
+
*/
|
|
157
|
+
export const PRE_TOOL_FILING = Object.freeze({
|
|
158
|
+
exec_command: Object.freeze({
|
|
159
|
+
tool_name: "Bash",
|
|
160
|
+
input: Object.freeze({ command: "$.cmd" }),
|
|
161
|
+
}),
|
|
162
|
+
apply_patch: Object.freeze({
|
|
163
|
+
tool_name: "apply_patch",
|
|
164
|
+
input: Object.freeze({ command: WHOLE_ARGUMENTS }),
|
|
165
|
+
}),
|
|
166
|
+
multi_agent_v1spawn_agent: Object.freeze({
|
|
167
|
+
tool_name: "spawn_agent",
|
|
168
|
+
input: WHOLE_ARGUMENTS,
|
|
169
|
+
}),
|
|
170
|
+
write_stdin: null,
|
|
171
|
+
wait: null,
|
|
172
|
+
exec: null,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes
|
|
177
|
+
* `hookSpecificOutput` into a `deny_unknown_fields` struct of exactly these
|
|
178
|
+
* five keys. Any other key fails the whole body: the run is marked failed, no
|
|
179
|
+
* block is set, and the call RUNS. A known key can still void the body by its
|
|
180
|
+
* combination (`updatedInput` without `permissionDecision: "allow"`), which
|
|
181
|
+
* this declaration does not describe.
|
|
182
|
+
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
183
|
+
*/
|
|
184
|
+
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
185
|
+
hook_specific_output_keys: Object.freeze([
|
|
186
|
+
"additionalContext",
|
|
187
|
+
"hookEventName",
|
|
188
|
+
"permissionDecision",
|
|
189
|
+
"permissionDecisionReason",
|
|
190
|
+
"updatedInput",
|
|
191
|
+
]),
|
|
192
|
+
unread_key_fails_open: true,
|
|
193
|
+
});
|
|
194
|
+
|
|
133
195
|
/** Minimum Codex version whose hook can actually veto a tool call. */
|
|
134
196
|
export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
|
|
135
197
|
|
|
@@ -411,16 +473,28 @@ function postToolBody(hookEventName, vd, enforced) {
|
|
|
411
473
|
return body;
|
|
412
474
|
}
|
|
413
475
|
|
|
476
|
+
/** The launcher option names {@link createCodexAdapter} reads. */
|
|
477
|
+
export const OPTION_NAMES = Object.freeze(["version"]);
|
|
478
|
+
|
|
414
479
|
/**
|
|
415
480
|
* Build a Codex adapter whose `parse` judges every payload against `version`,
|
|
416
481
|
* the Codex version the launcher pinned (Codex's hook stdin carries none).
|
|
417
482
|
* Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
|
|
418
483
|
* deny renders advisory. With no `version`, `parse` falls back to a payload
|
|
419
|
-
* `version` (see {@link parse}).
|
|
484
|
+
* `version` (see {@link parse}). An option outside {@link OPTION_NAMES} throws,
|
|
485
|
+
* so a misspelled setting is never dropped in silence.
|
|
420
486
|
* @param {{ version?: string }} [options]
|
|
421
487
|
* @returns {import("../control-plane.mjs").Adapter}
|
|
422
488
|
*/
|
|
423
|
-
export function createCodexAdapter(
|
|
489
|
+
export function createCodexAdapter(options = {}) {
|
|
490
|
+
const unread = Object.keys(options).filter(
|
|
491
|
+
(name) => !OPTION_NAMES.includes(name),
|
|
492
|
+
);
|
|
493
|
+
if (unread.length > 0)
|
|
494
|
+
throw new Error(
|
|
495
|
+
`codex: no option ${unread.map((name) => JSON.stringify(name)).join(", ")} (reads: ${OPTION_NAMES.join(", ")})`,
|
|
496
|
+
);
|
|
497
|
+
const { version } = options;
|
|
424
498
|
return {
|
|
425
499
|
AGENT,
|
|
426
500
|
INTEGRATION_MODE,
|
|
@@ -428,6 +502,8 @@ export function createCodexAdapter({ version } = {}) {
|
|
|
428
502
|
UNRENDERED_FIELDS,
|
|
429
503
|
NATIVE_ASK_TIER,
|
|
430
504
|
NATIVE_EVENT_FOR,
|
|
505
|
+
PRE_TOOL_FILING,
|
|
506
|
+
PRE_TOOL_RESPONSE,
|
|
431
507
|
parse: (native) => parse(native, { version }),
|
|
432
508
|
render,
|
|
433
509
|
};
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -357,6 +357,28 @@ export function parse(native) {
|
|
|
357
357
|
*/
|
|
358
358
|
export const NATIVE_ASK_TIER = false;
|
|
359
359
|
|
|
360
|
+
/**
|
|
361
|
+
* No rename is modelled. Gemini CLI hands `BeforeTool` the invocation's tool
|
|
362
|
+
* name beside an `originalRequestName`, so an aliased call may be filed under
|
|
363
|
+
* a name the model did not use; that is unverified, so not published.
|
|
364
|
+
* @type {import("../control-plane.mjs").PreToolFiling}
|
|
365
|
+
*/
|
|
366
|
+
export const PRE_TOOL_FILING = Object.freeze({});
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Gemini CLI keeps `hookSpecificOutput` as a `Record<string, unknown>` and its
|
|
370
|
+
* `BeforeTool` path reads only `tool_input` from it, by `in` lookup, so any
|
|
371
|
+
* other key is ignored (`packages/core/src/hooks/types.ts`
|
|
372
|
+
* `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
|
|
373
|
+
* and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
|
|
374
|
+
* `62bab5b2`).
|
|
375
|
+
* @type {import("../control-plane.mjs").PreToolResponse}
|
|
376
|
+
*/
|
|
377
|
+
export const PRE_TOOL_RESPONSE = Object.freeze({
|
|
378
|
+
hook_specific_output_keys: Object.freeze(["tool_input"]),
|
|
379
|
+
unread_key_fails_open: false,
|
|
380
|
+
});
|
|
381
|
+
|
|
360
382
|
/**
|
|
361
383
|
* Render into Gemini CLI's native external-hook transport. An enforceable deny
|
|
362
384
|
* renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
|
|
@@ -477,6 +499,8 @@ export const geminiAdapter = {
|
|
|
477
499
|
UNRENDERED_FIELDS,
|
|
478
500
|
NATIVE_ASK_TIER,
|
|
479
501
|
NATIVE_EVENT_FOR,
|
|
502
|
+
PRE_TOOL_FILING,
|
|
503
|
+
PRE_TOOL_RESPONSE,
|
|
480
504
|
parse,
|
|
481
505
|
render,
|
|
482
506
|
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-agent manifest: what each agent's hook transport does, published as
|
|
3
|
+
* plain JSON at `schema/control-plane/v1/agents.json`.
|
|
4
|
+
*
|
|
5
|
+
* PROBLEM CLASS — a consumer that cannot call these adapters (a bash hook
|
|
6
|
+
* wrapper, a Python validator) re-types each agent's rules by hand: which exit
|
|
7
|
+
* code blocks, whether a block needs a reason, whether an `ask` has a tier.
|
|
8
|
+
* Each copy is one more place an agent's next release has to be chased into.
|
|
9
|
+
* `agents.json` is the copy they read instead, generated from the adapters'
|
|
10
|
+
* exports, so an adapter change reaches them on the next `pnpm gen:schema`.
|
|
11
|
+
*
|
|
12
|
+
* `pnpm gen:schema` writes every {@link generatedDocuments} entry, and
|
|
13
|
+
* `json-schema.test.mjs` regenerates each in-process and byte-compares.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { EventKind, blockTransportFor, lookup } from "./control-plane.mjs";
|
|
17
|
+
import { jsonSchemaDocuments, SCHEMA_DIR } from "./json-schema.mjs";
|
|
18
|
+
import { ADAPTERS, AGENT_IDS } from "./registry.mjs";
|
|
19
|
+
import { HOST_FAILURE } from "./runtime.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One agent's manifest row. Each field is read from the export it names:
|
|
23
|
+
* - `integration_mode`: the adapter's `INTEGRATION_MODE`.
|
|
24
|
+
* - `native_ask_tier`: `NATIVE_ASK_TIER`. `false` means an `ask` the host
|
|
25
|
+
* receives does not stop the call, so a consumer must block instead.
|
|
26
|
+
* - `block`: the `BLOCK_TRANSPORT` row: the exit code the host blocks on, the
|
|
27
|
+
* stream it reads the reason from, and whether an empty reason runs the call.
|
|
28
|
+
* - `host_failure`: the `HOST_FAILURE` row, the hook entry's DEFAULT response
|
|
29
|
+
* when the hook breaks (exit 0 runs the call; amp's exit 1 asks the human).
|
|
30
|
+
* `ACP_FAIL_CLOSED=1` replaces it with `block`.
|
|
31
|
+
* - `coverage`: `COVERAGE`, per call class.
|
|
32
|
+
* - `unrendered_fields_on_allow`: `UNRENDERED_FIELDS`, each set as a sorted
|
|
33
|
+
* list. It describes the allow path only: a deny can drop more.
|
|
34
|
+
* - `pre_tool_filing`: `PRE_TOOL_FILING`, how the pre-tool hook files a model
|
|
35
|
+
* tool call, in the mini-language the `PreToolFiling` typedef specifies.
|
|
36
|
+
* - `pre_tool_response`: `PRE_TOOL_RESPONSE`, the `hookSpecificOutput` keys the
|
|
37
|
+
* host reads on an exit-0 pre-tool answer, and `unread_key_fails_open`
|
|
38
|
+
* (`true`: any other key voids the body and the call runs; `null`: unknown).
|
|
39
|
+
* @param {string} id
|
|
40
|
+
* @returns {Record<string, unknown>}
|
|
41
|
+
*/
|
|
42
|
+
function manifestRow(id) {
|
|
43
|
+
const adapter = ADAPTERS[id];
|
|
44
|
+
const failure = HOST_FAILURE[id];
|
|
45
|
+
return {
|
|
46
|
+
integration_mode: adapter.INTEGRATION_MODE,
|
|
47
|
+
native_ask_tier: adapter.NATIVE_ASK_TIER,
|
|
48
|
+
block: blockTransportFor(id),
|
|
49
|
+
host_failure: { exit_code: failure.exit_code, enforced: failure.enforced },
|
|
50
|
+
coverage: adapter.COVERAGE,
|
|
51
|
+
unrendered_fields_on_allow: Object.fromEntries(
|
|
52
|
+
// The conformance harness refuses an adapter missing a row, and spreading
|
|
53
|
+
// a missing one throws here, so a gap never publishes as "every field renders".
|
|
54
|
+
Object.values(EventKind).map((kind) => [
|
|
55
|
+
kind,
|
|
56
|
+
[
|
|
57
|
+
.../** @type {ReadonlySet<string>} */ (
|
|
58
|
+
lookup(adapter.UNRENDERED_FIELDS, kind)
|
|
59
|
+
),
|
|
60
|
+
].sort(),
|
|
61
|
+
]),
|
|
62
|
+
),
|
|
63
|
+
pre_tool_filing: adapter.PRE_TOOL_FILING,
|
|
64
|
+
pre_tool_response: adapter.PRE_TOOL_RESPONSE,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every generated file, keyed by its package-relative path: the JSON Schema
|
|
70
|
+
* documents and their data maps, plus `agents.json`, keyed by agent id in
|
|
71
|
+
* {@link AGENT_IDS} order. The SSOT the generator writes and the freshness test
|
|
72
|
+
* reads, so neither carries its own list of filenames.
|
|
73
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
74
|
+
*/
|
|
75
|
+
export function generatedDocuments() {
|
|
76
|
+
return {
|
|
77
|
+
...jsonSchemaDocuments(),
|
|
78
|
+
[`${SCHEMA_DIR}/agents.json`]: Object.fromEntries(
|
|
79
|
+
AGENT_IDS.map((id) => [id, manifestRow(id)]),
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
}
|