agent-control-plane-core 0.6.4 → 0.8.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 +69 -1
- package/bin/amp-hook.mjs +1 -1
- package/bin/claude-hook.mjs +1 -1
- package/bin/codex-hook.mjs +1 -1
- package/bin/gemini-hook.mjs +1 -1
- package/package.json +13 -2
- package/schema/control-plane/v1/tool-call-event.schema.json +152 -0
- package/schema/control-plane/v1/tool-input-keys.json +7 -0
- package/schema/control-plane/v1/verdict.schema.json +34 -0
- package/src/adapters/amp.mjs +54 -25
- package/src/adapters/claude.mjs +24 -2
- package/src/adapters/codex.mjs +25 -3
- package/src/adapters/gemini.mjs +11 -0
- package/src/conformance.mjs +156 -21
- package/src/control-plane.mjs +10 -6
- package/src/fixtures/claude.json +4 -2
- package/src/fixtures/codex.json +8 -6
- package/src/json-schema.mjs +331 -0
- package/{bin/hook-runtime.mjs → src/runtime.mjs} +62 -27
- package/types/adapters/amp.d.mts +32 -0
- package/types/adapters/claude.d.mts +11 -1
- package/types/adapters/codex.d.mts +11 -0
- package/types/adapters/gemini.d.mts +9 -0
- package/types/conformance.d.mts +22 -1
- package/types/control-plane.d.mts +14 -6
- package/types/json-schema.d.mts +77 -0
- package/types/runtime.d.mts +79 -0
package/README.md
CHANGED
|
@@ -91,7 +91,8 @@ pnpm add agent-control-plane-core
|
|
|
91
91
|
}
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
An **Adapter** is
|
|
94
|
+
An **Adapter** is
|
|
95
|
+
`{ AGENT, COVERAGE, UNRENDERED_FIELDS, NATIVE_ASK_TIER, parse, render }`:
|
|
95
96
|
|
|
96
97
|
```js
|
|
97
98
|
parse(nativeEvent) -> ToolCallEvent // never throws on unknown input
|
|
@@ -110,6 +111,25 @@ UNRENDERED_FIELDS = Object.freeze({
|
|
|
110
111
|
})
|
|
111
112
|
```
|
|
112
113
|
|
|
114
|
+
```js
|
|
115
|
+
// Does this host carry a distinct "ask the human" tier that it honours?
|
|
116
|
+
// true ⇒ a rendered `ask` suspends the call in front of a person
|
|
117
|
+
// (Claude Code's permissionDecision: "ask", Amp's exit 1).
|
|
118
|
+
// false ⇒ the host has no such tier, so `render` collapses `ask` onto another
|
|
119
|
+
// signal (Gemini CLI's advisory decision: "deny") and an un-escalated
|
|
120
|
+
// ask lets the tool run.
|
|
121
|
+
NATIVE_ASK_TIER = false;
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Read `NATIVE_ASK_TIER` instead of keeping a list of which agents to escalate an
|
|
125
|
+
`ask` to a `deny` on: a guardrail that must not let an ask through escalates it
|
|
126
|
+
where the flag is `false`, and leaves it alone where the host does the asking.
|
|
127
|
+
It describes the HOST, not the verdict — `ask` renders as `enforced: false`
|
|
128
|
+
everywhere — and it names the pre-tool gate, since a tool that already ran has
|
|
129
|
+
nothing left to suspend. Conformance probes your real `render` against it, so a
|
|
130
|
+
`true` whose ask renders as your abstaining allow fails, and so does a `false`
|
|
131
|
+
whose ask renders as anything but your own advisory deny.
|
|
132
|
+
|
|
113
133
|
`readonlySet([])` is the row for a kind that carries all three;
|
|
114
134
|
`UNRENDERED_ON_UNKNOWN` is the row for one that carries none. Both come from
|
|
115
135
|
`agent-control-plane-core/contract`. Build every row with `readonlySet` — a
|
|
@@ -138,6 +158,13 @@ const verdict =
|
|
|
138
158
|
process.stdout.write(JSON.stringify(claudeAdapter.render(verdict, event)));
|
|
139
159
|
```
|
|
140
160
|
|
|
161
|
+
Writing your own hook entry? `agent-control-plane-core/runtime` publishes the
|
|
162
|
+
plumbing the four `bin/*-hook.mjs` entries share — `readStdin`,
|
|
163
|
+
`renderHookResponse` (parse → judge → render, falling back to the failure
|
|
164
|
+
response THAT host expects) and `emit`, whose fully-drained write is what stops
|
|
165
|
+
a large deny body being truncated on a non-blocking pipe and silently becoming
|
|
166
|
+
an allow.
|
|
167
|
+
|
|
141
168
|
The core contract and every adapter (`claude`, `codex`, `amp`, `gemini`) are
|
|
142
169
|
also on the default entry:
|
|
143
170
|
|
|
@@ -202,9 +229,49 @@ The harness pins both directions and rejects a vacuous suite:
|
|
|
202
229
|
fixture event, so an adapter that collapses the objection onto "run it" fails
|
|
203
230
|
even with no non-vetoable fixture of its own. `observe_only` renders are
|
|
204
231
|
exempt: that transport has no pre-emption channel to differ in.
|
|
232
|
+
6. Your `NATIVE_ASK_TIER` declaration matches what `render` does with an `ask`,
|
|
233
|
+
probed on every pre-tool fixture event (both directions, described above).
|
|
205
234
|
|
|
206
235
|
See `src/fixtures/claude.json` and `src/fixtures/codex.json` for the format.
|
|
207
236
|
|
|
237
|
+
## Validating from another language
|
|
238
|
+
|
|
239
|
+
A guardrail that is not JavaScript cannot import `ToolCallEvent`, so it
|
|
240
|
+
hand-writes the field names it reads. It then drifts in the quietest direction:
|
|
241
|
+
the contract renames a field, the hand-written reader sees `undefined`, and it
|
|
242
|
+
takes its no-value branch instead of failing. The package ships the same
|
|
243
|
+
contract as JSON Schema (draft 2020-12), so that reader validates against the
|
|
244
|
+
seam instead of against its own copy.
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
import json, pathlib, jsonschema # a Python guardrail reading the event
|
|
248
|
+
base = pathlib.Path("node_modules/agent-control-plane-core/schema/control-plane/v1")
|
|
249
|
+
schema = json.loads((base / "tool-call-event.schema.json").read_text())
|
|
250
|
+
jsonschema.validate(event, schema) # raises on a field the contract does not model
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`tool-input-keys.json` sits beside them and publishes the input field a judge
|
|
254
|
+
should read per canonical tool (`Bash` → `command`, `Read` → `file_path`), so a
|
|
255
|
+
consumer looks that up rather than hard-coding it. It is plain data, not a
|
|
256
|
+
schema: a validator refuses an unknown keyword under its default options, so a
|
|
257
|
+
document carrying that map inside itself would not compile for the consumer who
|
|
258
|
+
installed it.
|
|
259
|
+
|
|
260
|
+
The event document also refuses two claims a producer must not make, each one a
|
|
261
|
+
veto the host will never perform: a `this_call_vetoable` `unknown` event, and a
|
|
262
|
+
`this_call_vetoable` event on an `observe_only` transport.
|
|
263
|
+
|
|
264
|
+
`pnpm gen:schema` generates the files from `src/control-plane.mjs`, and
|
|
265
|
+
`test/json-schema.test.mjs` byte-compares the result, so a stale document reds
|
|
266
|
+
the suite. The same suite validates every adapter's live output against the
|
|
267
|
+
published bytes, so a renamed contract field reds too. A JavaScript caller
|
|
268
|
+
builds the documents in memory from `agent-control-plane-core/json-schema`
|
|
269
|
+
instead of reading the files.
|
|
270
|
+
|
|
271
|
+
Read the schema from the installed package rather than pinning a copy by hand. A
|
|
272
|
+
document is one release's rendering of the wire version, and an added optional
|
|
273
|
+
field (backward-compatible, below) appears in the next release's document.
|
|
274
|
+
|
|
208
275
|
## Versioning
|
|
209
276
|
|
|
210
277
|
`src/control-plane.mjs` is the frozen contract and its own single source of
|
|
@@ -220,6 +287,7 @@ and bumps the version.
|
|
|
220
287
|
pnpm test # node --test
|
|
221
288
|
pnpm test:coverage # c8, 100% lines/branches/functions per file
|
|
222
289
|
pnpm check # tsc --noEmit (JSDoc types)
|
|
290
|
+
pnpm gen:schema # regenerate schema/ from the contract
|
|
223
291
|
pnpm lint # eslint
|
|
224
292
|
pnpm build # emit .d.mts declarations into types/
|
|
225
293
|
```
|
package/bin/amp-hook.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Usage: `node bin/amp-hook.mjs < payload.json`
|
|
14
14
|
*/
|
|
15
15
|
import { ampAdapter } from "../src/adapters/amp.mjs";
|
|
16
|
-
import { readStdin, renderHookResponse, emit } from "
|
|
16
|
+
import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
|
|
17
17
|
|
|
18
18
|
/** @type {import("../src/control-plane.mjs").NativeResponse} */
|
|
19
19
|
const FAIL_ASK = { transport: "external_hook", exit_code: 1, enforced: false };
|
package/bin/claude-hook.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Usage: `node bin/claude-hook.mjs < payload.json`
|
|
13
13
|
*/
|
|
14
14
|
import { claudeAdapter } from "../src/adapters/claude.mjs";
|
|
15
|
-
import { readStdin, renderHookResponse, emit } from "
|
|
15
|
+
import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
|
|
16
16
|
|
|
17
17
|
/** @type {import("../src/control-plane.mjs").NativeResponse} */
|
|
18
18
|
const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
|
package/bin/codex-hook.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Usage: `node bin/codex-hook.mjs < payload.json`
|
|
14
14
|
*/
|
|
15
15
|
import { codexAdapter } from "../src/adapters/codex.mjs";
|
|
16
|
-
import { readStdin, renderHookResponse, emit } from "
|
|
16
|
+
import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
|
|
17
17
|
|
|
18
18
|
/** @type {import("../src/control-plane.mjs").NativeResponse} */
|
|
19
19
|
const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
|
package/bin/gemini-hook.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Usage: `node bin/gemini-hook.mjs < payload.json`
|
|
12
12
|
*/
|
|
13
13
|
import { geminiAdapter } from "../src/adapters/gemini.mjs";
|
|
14
|
-
import { readStdin, renderHookResponse, emit } from "
|
|
14
|
+
import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
|
|
15
15
|
|
|
16
16
|
/** @type {import("../src/control-plane.mjs").NativeResponse} */
|
|
17
17
|
const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|
|
@@ -56,12 +56,21 @@
|
|
|
56
56
|
"./registry": {
|
|
57
57
|
"types": "./types/registry.d.mts",
|
|
58
58
|
"import": "./src/registry.mjs"
|
|
59
|
+
},
|
|
60
|
+
"./runtime": {
|
|
61
|
+
"types": "./types/runtime.d.mts",
|
|
62
|
+
"import": "./src/runtime.mjs"
|
|
63
|
+
},
|
|
64
|
+
"./json-schema": {
|
|
65
|
+
"types": "./types/json-schema.d.mts",
|
|
66
|
+
"import": "./src/json-schema.mjs"
|
|
59
67
|
}
|
|
60
68
|
},
|
|
61
69
|
"files": [
|
|
62
70
|
"src",
|
|
63
71
|
"types",
|
|
64
|
-
"bin"
|
|
72
|
+
"bin",
|
|
73
|
+
"schema"
|
|
65
74
|
],
|
|
66
75
|
"keywords": [
|
|
67
76
|
"claude",
|
|
@@ -113,6 +122,7 @@
|
|
|
113
122
|
"@eslint/js": "^9.13.0",
|
|
114
123
|
"@types/node": "^22.7.0",
|
|
115
124
|
"@types/semver": "^7.5.8",
|
|
125
|
+
"ajv": "^8.20.0",
|
|
116
126
|
"c8": "^10.1.2",
|
|
117
127
|
"eslint": "^9.13.0",
|
|
118
128
|
"fast-check": "^3.22.0",
|
|
@@ -123,6 +133,7 @@
|
|
|
123
133
|
},
|
|
124
134
|
"scripts": {
|
|
125
135
|
"build": "tsc -p tsconfig.build.json",
|
|
136
|
+
"gen:schema": "node scripts/gen-json-schema.mjs",
|
|
126
137
|
"test": "node --test test/*.test.mjs",
|
|
127
138
|
"test:coverage": "c8 node --test test/*.test.mjs",
|
|
128
139
|
"check": "tsc --noEmit",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "ToolCallEvent",
|
|
4
|
+
"description": "A normalized, agent-agnostic view of one agent event (control-plane/v1).",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"schema_version",
|
|
8
|
+
"event",
|
|
9
|
+
"tool",
|
|
10
|
+
"input",
|
|
11
|
+
"this_call_vetoable",
|
|
12
|
+
"meta"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"schema_version": {
|
|
16
|
+
"const": 1
|
|
17
|
+
},
|
|
18
|
+
"event": {
|
|
19
|
+
"enum": [
|
|
20
|
+
"pre_tool",
|
|
21
|
+
"post_tool",
|
|
22
|
+
"prompt_submit",
|
|
23
|
+
"session_start",
|
|
24
|
+
"unknown"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"tool": {
|
|
28
|
+
"type": [
|
|
29
|
+
"string",
|
|
30
|
+
"null"
|
|
31
|
+
],
|
|
32
|
+
"description": "Canonical tool name; null for prompt and session events. The raw native name is on meta.native_tool."
|
|
33
|
+
},
|
|
34
|
+
"input": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"description": "Passthrough tool input, verbatim; a submitted prompt is folded into input.prompt."
|
|
37
|
+
},
|
|
38
|
+
"response": {
|
|
39
|
+
"description": "Tool output, post_tool only: any JSON value, verbatim."
|
|
40
|
+
},
|
|
41
|
+
"this_call_vetoable": {
|
|
42
|
+
"type": "boolean",
|
|
43
|
+
"description": "false ⇒ the guardrail cannot veto THIS call, so a deny must degrade to a notification."
|
|
44
|
+
},
|
|
45
|
+
"meta": {
|
|
46
|
+
"$ref": "#/$defs/EventMeta"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"additionalProperties": false,
|
|
50
|
+
"allOf": [
|
|
51
|
+
{
|
|
52
|
+
"if": {
|
|
53
|
+
"required": [
|
|
54
|
+
"event"
|
|
55
|
+
],
|
|
56
|
+
"properties": {
|
|
57
|
+
"event": {
|
|
58
|
+
"const": "unknown"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"then": {
|
|
63
|
+
"properties": {
|
|
64
|
+
"this_call_vetoable": {
|
|
65
|
+
"const": false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"if": {
|
|
72
|
+
"required": [
|
|
73
|
+
"meta"
|
|
74
|
+
],
|
|
75
|
+
"properties": {
|
|
76
|
+
"meta": {
|
|
77
|
+
"required": [
|
|
78
|
+
"integration_mode"
|
|
79
|
+
],
|
|
80
|
+
"properties": {
|
|
81
|
+
"integration_mode": {
|
|
82
|
+
"const": "observe_only"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"then": {
|
|
89
|
+
"properties": {
|
|
90
|
+
"this_call_vetoable": {
|
|
91
|
+
"const": false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"$defs": {
|
|
98
|
+
"EventMeta": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"required": [
|
|
101
|
+
"agent",
|
|
102
|
+
"native_event",
|
|
103
|
+
"integration_mode",
|
|
104
|
+
"primary_gate_present",
|
|
105
|
+
"passthrough"
|
|
106
|
+
],
|
|
107
|
+
"properties": {
|
|
108
|
+
"agent": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"description": "Producing agent id (\"claude\", \"codex\", …)."
|
|
111
|
+
},
|
|
112
|
+
"native_event": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"description": "Original native event name, preserved verbatim."
|
|
115
|
+
},
|
|
116
|
+
"integration_mode": {
|
|
117
|
+
"enum": [
|
|
118
|
+
"external_hook",
|
|
119
|
+
"in_process",
|
|
120
|
+
"observe_only"
|
|
121
|
+
],
|
|
122
|
+
"description": "How the guardrail attaches to the host."
|
|
123
|
+
},
|
|
124
|
+
"primary_gate_present": {
|
|
125
|
+
"type": "boolean",
|
|
126
|
+
"description": "The agent's own native gate already ran, so this verdict is a second opinion."
|
|
127
|
+
},
|
|
128
|
+
"passthrough": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"description": "Unmodelled native top-level fields, verbatim."
|
|
131
|
+
},
|
|
132
|
+
"native_tool": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"description": "Original native tool name, present iff the event carries a tool."
|
|
135
|
+
},
|
|
136
|
+
"session_id": {
|
|
137
|
+
"type": "string"
|
|
138
|
+
},
|
|
139
|
+
"cwd": {
|
|
140
|
+
"type": "string"
|
|
141
|
+
},
|
|
142
|
+
"permission_mode": {
|
|
143
|
+
"type": "string"
|
|
144
|
+
},
|
|
145
|
+
"transcript_path": {
|
|
146
|
+
"type": "string"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
"additionalProperties": false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Verdict",
|
|
4
|
+
"description": "A normalized guardrail decision (control-plane/v1).",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"decision"
|
|
8
|
+
],
|
|
9
|
+
"properties": {
|
|
10
|
+
"decision": {
|
|
11
|
+
"enum": [
|
|
12
|
+
"allow",
|
|
13
|
+
"deny",
|
|
14
|
+
"ask"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"mutated_input": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"description": "Replacement tool input (pre_tool). A data channel, carried verbatim."
|
|
20
|
+
},
|
|
21
|
+
"mutated_output": {
|
|
22
|
+
"description": "Replacement tool output (post_tool): a string or the tool's structured output, verbatim. Any JSON value."
|
|
23
|
+
},
|
|
24
|
+
"additional_context": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Extra context to splice into the agent's stream."
|
|
27
|
+
},
|
|
28
|
+
"reason": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Human-readable rationale, shown on deny and ask."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"additionalProperties": false
|
|
34
|
+
}
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -156,6 +156,15 @@ export function parse(native) {
|
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
|
|
161
|
+
* treats as neither the allow (0) nor the reject (2). {@link render} emits it
|
|
162
|
+
* for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
|
|
163
|
+
* The table below leans on the same tier for the non-vetoable deny, which is
|
|
164
|
+
* only honest because the tier is real.
|
|
165
|
+
*/
|
|
166
|
+
export const NATIVE_ASK_TIER = true;
|
|
167
|
+
|
|
159
168
|
/**
|
|
160
169
|
* The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
|
|
161
170
|
* pure exit-code transport: 0 allow / 1 ask / 2 reject.
|
|
@@ -181,22 +190,52 @@ const EXIT_CODE_BY_DECISION = Object.freeze({
|
|
|
181
190
|
});
|
|
182
191
|
|
|
183
192
|
/**
|
|
184
|
-
* Totality check
|
|
185
|
-
* both vetoable columns. A decision added to the contract
|
|
186
|
-
*
|
|
193
|
+
* Totality check: every `decision` in `decisions` must have a row in `table`
|
|
194
|
+
* and every row both vetoable columns. A decision added to the contract with
|
|
195
|
+
* no matching row breaks loudly instead of silently defaulting to exit 0.
|
|
196
|
+
* Called at import against {@link Decision} and {@link EXIT_CODE_BY_DECISION};
|
|
197
|
+
* exported so a test can drive both branches.
|
|
198
|
+
* @param {readonly string[]} decisions
|
|
199
|
+
* @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
|
|
187
200
|
*/
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
201
|
+
export function assertExitCodeTableTotal(decisions, table) {
|
|
202
|
+
for (const decision of decisions) {
|
|
203
|
+
const row = lookup(table, decision);
|
|
204
|
+
if (row === undefined)
|
|
205
|
+
throw new Error(
|
|
206
|
+
`amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
|
|
207
|
+
);
|
|
208
|
+
for (const vetoable of ["true", "false"]) {
|
|
209
|
+
if (typeof lookup(row, vetoable) === "number") continue;
|
|
210
|
+
throw new Error(
|
|
211
|
+
`amp adapter: exit-code table row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
assertExitCodeTableTotal(Object.values(Decision), EXIT_CODE_BY_DECISION);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Look up the exit code for `decision`/`vetoable` in `table`. Throws instead of
|
|
221
|
+
* returning `undefined`: the alternative is `exit_code: undefined`, which
|
|
222
|
+
* `process.exit` renders as 0 — the exact silent allow this table exists to
|
|
223
|
+
* eliminate. Unreachable from {@link render} on the real
|
|
224
|
+
* {@link EXIT_CODE_BY_DECISION} while {@link assertExitCodeTableTotal} holds at
|
|
225
|
+
* load; exported so a test can drive both branches against a synthetic,
|
|
226
|
+
* deliberately incomplete table.
|
|
227
|
+
* @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
|
|
228
|
+
* @param {string} decision
|
|
229
|
+
* @param {boolean} vetoable
|
|
230
|
+
* @returns {number}
|
|
231
|
+
*/
|
|
232
|
+
export function exitCodeFor(table, decision, vetoable) {
|
|
233
|
+
const exit_code = lookup(lookup(table, decision) ?? {}, String(vetoable));
|
|
234
|
+
if (exit_code === undefined)
|
|
196
235
|
throw new Error(
|
|
197
|
-
`amp adapter: exit-code table
|
|
236
|
+
`amp adapter: exit-code table has no entry for decision ${JSON.stringify(decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
|
|
198
237
|
);
|
|
199
|
-
|
|
238
|
+
return exit_code;
|
|
200
239
|
}
|
|
201
240
|
|
|
202
241
|
/**
|
|
@@ -225,18 +264,7 @@ export function render(verdict, event) {
|
|
|
225
264
|
`amp adapter: this_call_vetoable must be a boolean, got ${JSON.stringify(vetoable)}`,
|
|
226
265
|
);
|
|
227
266
|
const enforced = vd.decision === Decision.DENY && vetoable;
|
|
228
|
-
const exit_code =
|
|
229
|
-
lookup(EXIT_CODE_BY_DECISION, vd.decision) ?? {},
|
|
230
|
-
String(vetoable),
|
|
231
|
-
);
|
|
232
|
-
// Unreachable while the import-time totality check and `normalizeVerdict` both
|
|
233
|
-
// hold; kept because the alternative to throwing is `exit_code: undefined`,
|
|
234
|
-
// which `process.exit` renders as 0 — the exact silent allow this table exists
|
|
235
|
-
// to eliminate.
|
|
236
|
-
if (exit_code === undefined)
|
|
237
|
-
throw new Error(
|
|
238
|
-
`amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
|
|
239
|
-
);
|
|
267
|
+
const exit_code = exitCodeFor(EXIT_CODE_BY_DECISION, vd.decision, vetoable);
|
|
240
268
|
return nativeResponse({
|
|
241
269
|
transport: INTEGRATION_MODE,
|
|
242
270
|
exit_code,
|
|
@@ -251,6 +279,7 @@ export const ampAdapter = {
|
|
|
251
279
|
INTEGRATION_MODE,
|
|
252
280
|
COVERAGE,
|
|
253
281
|
UNRENDERED_FIELDS,
|
|
282
|
+
NATIVE_ASK_TIER,
|
|
254
283
|
NATIVE_EVENT_FOR,
|
|
255
284
|
parse,
|
|
256
285
|
render,
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* agent shells out with stdin JSON and reads a deny body / exit code back.
|
|
8
8
|
*
|
|
9
9
|
* Deny signal (PreToolUse): `hookSpecificOutput.permissionDecision = "deny"`
|
|
10
|
-
* AND exit 2.
|
|
10
|
+
* AND exit 2. Exit 2 discards stdout, so an enforced deny repeats its reason on
|
|
11
|
+
* STDERR, which is where the host reads a block rationale. this_call_vetoable is true, but per the doctrine that is a
|
|
11
12
|
* useful FIRST filter, never the boundary — the Verdict stays advisory to the
|
|
12
13
|
* sandbox.
|
|
13
14
|
*/
|
|
@@ -114,6 +115,16 @@ export const UNRENDERED_FIELDS = Object.freeze({
|
|
|
114
115
|
[EventKind.UNKNOWN]: UNRENDERED_ON_UNKNOWN,
|
|
115
116
|
});
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Claude Code honours a distinct ask tier: `hookSpecificOutput.permissionDecision
|
|
120
|
+
* = "ask"` suspends the call and puts it in front of the user, and {@link render}
|
|
121
|
+
* emits exactly that (see {@link gatingBody}). So a consumer must NOT escalate an
|
|
122
|
+
* `ask` to a `deny` here — the human already sees it. `render` still reports the
|
|
123
|
+
* ask as `enforced: false`, because this guardrail did not block the call; the
|
|
124
|
+
* host did, pending an answer.
|
|
125
|
+
*/
|
|
126
|
+
export const NATIVE_ASK_TIER = true;
|
|
127
|
+
|
|
117
128
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
118
129
|
export const HookEvent = Object.freeze({
|
|
119
130
|
PRE_TOOL_USE: "PreToolUse",
|
|
@@ -221,7 +232,8 @@ export function parse(native) {
|
|
|
221
232
|
* Render into Claude Code's native external-hook transport: a
|
|
222
233
|
* `hookSpecificOutput` JSON body on stdout plus the exit code that carries the
|
|
223
234
|
* decision (deny ⇒ exit 2). A deny only counts as `enforced` when the event's
|
|
224
|
-
* `this_call_vetoable` holds
|
|
235
|
+
* `this_call_vetoable` holds, and an enforced deny also carries its `reason` on
|
|
236
|
+
* `NativeResponse.stderr` (see below).
|
|
225
237
|
*
|
|
226
238
|
* `soleGate` (default `false`) is a dangerous, explicit opt-in: when `true` AND
|
|
227
239
|
* the verdict is `allow`, the render emits Claude Code's REAL
|
|
@@ -253,6 +265,15 @@ export function render(verdict, event, { soleGate = false } = {}) {
|
|
|
253
265
|
exit_code: enforced ? 2 : 0,
|
|
254
266
|
enforced,
|
|
255
267
|
stdout,
|
|
268
|
+
// An ENFORCED deny exits 2, and Claude Code parses hook stdout as JSON only
|
|
269
|
+
// on exit 0: on an exit-2 PreToolUse block it discards the body and reads
|
|
270
|
+
// STDERR instead. So `permissionDecisionReason` never reached the model on
|
|
271
|
+
// the one path that actually blocks, and the call was refused with no
|
|
272
|
+
// rationale. Carry the reason on `NativeResponse.stderr`, which `emit`
|
|
273
|
+
// writes to fd 2 — the same block-reason channel the Amp and Gemini
|
|
274
|
+
// adapters use. ONLY the enforced path: an allow, an ask, and a deny this
|
|
275
|
+
// call cannot veto have blocked nothing, and their exit-0 body is read.
|
|
276
|
+
...(enforced && vd.reason !== undefined ? { stderr: vd.reason } : {}),
|
|
256
277
|
});
|
|
257
278
|
}
|
|
258
279
|
|
|
@@ -318,6 +339,7 @@ export const claudeAdapter = {
|
|
|
318
339
|
INTEGRATION_MODE,
|
|
319
340
|
COVERAGE,
|
|
320
341
|
UNRENDERED_FIELDS,
|
|
342
|
+
NATIVE_ASK_TIER,
|
|
321
343
|
NATIVE_EVENT_FOR,
|
|
322
344
|
parse,
|
|
323
345
|
render,
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -146,8 +146,9 @@ const HookEvent = Object.freeze({
|
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
148
|
* Native event name → normalized {@link EventKind}. `PreToolUse` and
|
|
149
|
-
* `PermissionRequest` both gate a tool call before it runs
|
|
150
|
-
*
|
|
149
|
+
* `PermissionRequest` both gate a tool call before it runs, so both normalize to
|
|
150
|
+
* `pre_tool`. Neither carries an ask tier: Codex's `PermissionRequestBehaviorWire`
|
|
151
|
+
* has two variants, allow and deny. Codex emits more events
|
|
151
152
|
* than these — SessionStart/SessionEnd/Stop/PreCompact/… — and every one of them
|
|
152
153
|
* still routes to `unknown`: this adapter models an event only once its host
|
|
153
154
|
* response is established, and an unmodelled event parses non-vetoable.
|
|
@@ -171,6 +172,18 @@ export const NATIVE_EVENT_FOR = Object.freeze({
|
|
|
171
172
|
[EventKind.POST_TOOL]: HookEvent.POST_TOOL_USE,
|
|
172
173
|
});
|
|
173
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Codex has NO ask tier. Its pre-tool output parser decodes
|
|
177
|
+
* `permissionDecision: "ask"`, classifies it unsupported, sets no block reason
|
|
178
|
+
* and runs the tool — upstream's own unit test for that path is named
|
|
179
|
+
* `unsupported_permission_decision_fails_open` (read at `rust-v0.155.1`, and
|
|
180
|
+
* byte-identical at `rust-v0.151.0`). `PermissionRequest` is no ask tier either:
|
|
181
|
+
* its behavior enum carries allow and deny only. So a verdict of `ask` has no
|
|
182
|
+
* channel here, and {@link render} sends it down the deny path rather than
|
|
183
|
+
* naming a key the host discards.
|
|
184
|
+
*/
|
|
185
|
+
export const NATIVE_ASK_TIER = false;
|
|
186
|
+
|
|
174
187
|
// Codex drops an enforced deny that carries no (or an empty) reason and runs the
|
|
175
188
|
// tool, so a reasonless enforced deny still renders a non-empty one.
|
|
176
189
|
export const DEFAULT_DENY_REASON = "blocked by monitor";
|
|
@@ -303,7 +316,15 @@ export function parse(native) {
|
|
|
303
316
|
* @returns {NativeResponse}
|
|
304
317
|
*/
|
|
305
318
|
export function render(verdict, event, { soleGate = false } = {}) {
|
|
306
|
-
const
|
|
319
|
+
const normalized = normalizeVerdict(verdict);
|
|
320
|
+
// An `ask` becomes a deny on the PRE-TOOL path, because this host acts on no
|
|
321
|
+
// other objection: emitting its own `"ask"` leaves the tool running with the
|
|
322
|
+
// run marked failed, which is the fail-open {@link NATIVE_ASK_TIER} records.
|
|
323
|
+
// The post-tool path maps it to `block` on its own, below.
|
|
324
|
+
const vd =
|
|
325
|
+
normalized.decision === Decision.ASK && event.event !== EventKind.POST_TOOL
|
|
326
|
+
? { ...normalized, decision: Decision.DENY }
|
|
327
|
+
: normalized;
|
|
307
328
|
const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
|
|
308
329
|
// The native name is preserved rather than derived from the kind, so a
|
|
309
330
|
// PermissionRequest answers as itself; NATIVE_EVENT_FOR names the fallback.
|
|
@@ -392,6 +413,7 @@ export const codexAdapter = {
|
|
|
392
413
|
INTEGRATION_MODE,
|
|
393
414
|
COVERAGE,
|
|
394
415
|
UNRENDERED_FIELDS,
|
|
416
|
+
NATIVE_ASK_TIER,
|
|
395
417
|
NATIVE_EVENT_FOR,
|
|
396
418
|
parse,
|
|
397
419
|
render,
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -347,6 +347,16 @@ export function parse(native) {
|
|
|
347
347
|
});
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Gemini CLI has NO ask tier: its hook vocabulary is `decision: "allow"` /
|
|
352
|
+
* `decision: "deny"` plus the exit-2 System Block, and nothing there suspends a
|
|
353
|
+
* call for a human. {@link applyAdvisoryDeny} therefore renders `ask` as the
|
|
354
|
+
* advisory `decision: "deny"`, byte-identical to a deny this call cannot veto.
|
|
355
|
+
* A consumer that must not let an ask through has to escalate it to a deny
|
|
356
|
+
* here, because an un-escalated ask lets Gemini run the tool.
|
|
357
|
+
*/
|
|
358
|
+
export const NATIVE_ASK_TIER = false;
|
|
359
|
+
|
|
350
360
|
/**
|
|
351
361
|
* Render into Gemini CLI's native external-hook transport. An enforceable deny
|
|
352
362
|
* renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
|
|
@@ -470,6 +480,7 @@ export const geminiAdapter = {
|
|
|
470
480
|
INTEGRATION_MODE,
|
|
471
481
|
COVERAGE,
|
|
472
482
|
UNRENDERED_FIELDS,
|
|
483
|
+
NATIVE_ASK_TIER,
|
|
473
484
|
NATIVE_EVENT_FOR,
|
|
474
485
|
parse,
|
|
475
486
|
render,
|