agent-control-plane-core 0.6.4 → 0.7.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 +30 -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 +5 -1
- package/src/adapters/amp.mjs +10 -0
- 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 +127 -7
- package/src/control-plane.mjs +5 -3
- package/src/fixtures/claude.json +4 -2
- package/src/fixtures/codex.json +8 -6
- package/{bin/hook-runtime.mjs → src/runtime.mjs} +36 -18
- package/types/adapters/amp.d.mts +8 -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 +12 -1
- package/types/control-plane.d.mts +9 -3
- package/types/runtime.d.mts +45 -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,6 +229,8 @@ 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
|
|
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.7.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,6 +56,10 @@
|
|
|
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"
|
|
59
63
|
}
|
|
60
64
|
},
|
|
61
65
|
"files": [
|
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.
|
|
@@ -251,6 +260,7 @@ export const ampAdapter = {
|
|
|
251
260
|
INTEGRATION_MODE,
|
|
252
261
|
COVERAGE,
|
|
253
262
|
UNRENDERED_FIELDS,
|
|
263
|
+
NATIVE_ASK_TIER,
|
|
254
264
|
NATIVE_EVENT_FOR,
|
|
255
265
|
parse,
|
|
256
266
|
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,
|
package/src/conformance.mjs
CHANGED
|
@@ -22,6 +22,22 @@ const UNENFORCEABLE_DENY_PROBE = Object.freeze({
|
|
|
22
22
|
});
|
|
23
23
|
const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The two probe verdicts rule ⑪ renders against the same event, to read whether
|
|
27
|
+
* this host's transport carries an ask tier of its own. They share one `reason`
|
|
28
|
+
* on purpose: the rule compares the two RENDERS, and a reason that differed
|
|
29
|
+
* would make them differ for a reason that is not the decision.
|
|
30
|
+
*/
|
|
31
|
+
const ASK_TIER_REASON = "conformance probe: ask tier";
|
|
32
|
+
const ASK_TIER_ASK_PROBE = Object.freeze({
|
|
33
|
+
decision: Decision.ASK,
|
|
34
|
+
reason: ASK_TIER_REASON,
|
|
35
|
+
});
|
|
36
|
+
const ASK_TIER_DENY_PROBE = Object.freeze({
|
|
37
|
+
decision: Decision.DENY,
|
|
38
|
+
reason: ASK_TIER_REASON,
|
|
39
|
+
});
|
|
40
|
+
|
|
25
41
|
/**
|
|
26
42
|
* The payload rule ⑨ drifts an adapter with. No host emits that event name, so
|
|
27
43
|
* an adapter that maps event names must answer {@link EventKind.UNKNOWN}.
|
|
@@ -454,6 +470,78 @@ function coherentEvent(adapter, byKind, kind) {
|
|
|
454
470
|
};
|
|
455
471
|
}
|
|
456
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Rule ⑪, for one pre-tool event: the adapter's `NATIVE_ASK_TIER` declaration
|
|
475
|
+
* agrees with what `render` does with an `ask`.
|
|
476
|
+
*
|
|
477
|
+
* The declaration is what lets a consumer stop keeping a hand-typed list of
|
|
478
|
+
* which hosts honour an ask. A consumer that must not let an ask through
|
|
479
|
+
* escalates it to a deny on a host with no ask tier — and applying that
|
|
480
|
+
* uniformly turns every ask into a deny on the hosts that DO have one. So the
|
|
481
|
+
* declaration has to be checked against the render rather than believed.
|
|
482
|
+
*
|
|
483
|
+
* Probed on a NON-vetoable variant of the event, for rule ⑧'s reason and one
|
|
484
|
+
* more: it makes the deny comparison below an ADVISORY deny, which is the
|
|
485
|
+
* signal a host with no ask tier has left to spend on an ask.
|
|
486
|
+
*
|
|
487
|
+
* What a render can show, and what it cannot. Whether a host suspends the call
|
|
488
|
+
* for a human lives in the HOST, so no probe reads it off a render. What a probe
|
|
489
|
+
* does read is each direction's tell:
|
|
490
|
+
*
|
|
491
|
+
* - Declared `true`, rendered as the abstaining ALLOW: the consumer is told the
|
|
492
|
+
* human will see this call, and the transport carries nothing that says so —
|
|
493
|
+
* the tool just runs. That is the lie this rule exists to catch.
|
|
494
|
+
* - Declared `false`, rendered as something the adapter's own advisory deny is
|
|
495
|
+
* not: the render invents a third signal on a transport the declaration says
|
|
496
|
+
* has only two, so one of the two is wrong.
|
|
497
|
+
*
|
|
498
|
+
* An OBSERVE_ONLY render is exempt, as in rule ⑧: that transport has no
|
|
499
|
+
* pre-emption channel at all, so its ask, deny and allow coincide by
|
|
500
|
+
* construction and demanding a distinct ask would demand a fiction.
|
|
501
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
502
|
+
* @param {import("./control-plane.mjs").ToolCallEvent} event a non-vetoable pre-tool event
|
|
503
|
+
* @param {import("./control-plane.mjs").ToolCallEvent} vetoable the fixture's own pre-tool event, whose veto the block half needs
|
|
504
|
+
* @param {string} caseName
|
|
505
|
+
* @param {any} assert
|
|
506
|
+
*/
|
|
507
|
+
function assertAskTier(adapter, event, vetoable, caseName, assert) {
|
|
508
|
+
const asked = adapter.render(ASK_TIER_ASK_PROBE, event);
|
|
509
|
+
assert.equal(
|
|
510
|
+
asked.enforced,
|
|
511
|
+
false,
|
|
512
|
+
`an ask rendered as an enforced block: ${caseName}`,
|
|
513
|
+
);
|
|
514
|
+
if (asked.transport === IntegrationMode.OBSERVE_ONLY) return;
|
|
515
|
+
if (adapter.NATIVE_ASK_TIER) {
|
|
516
|
+
assert.notDeepEqual(
|
|
517
|
+
asked,
|
|
518
|
+
adapter.render(ABSTAINING_ALLOW_PROBE, event),
|
|
519
|
+
`${adapter.AGENT}: NATIVE_ASK_TIER is true, but an ask renders exactly as this host's abstaining allow — nothing in the transport asks anyone: ${caseName}`,
|
|
520
|
+
);
|
|
521
|
+
// The other way to declare an ask tier this host has not got: render the ask
|
|
522
|
+
// as the host's BLOCK. It differs from the allow, so the half above passes,
|
|
523
|
+
// and the consumer it tells to stop escalating then hands a call to a host
|
|
524
|
+
// that denies it outright rather than suspending it for a human. Probed on
|
|
525
|
+
// the vetoable event, because a block is the one signal a non-vetoable
|
|
526
|
+
// render cannot carry.
|
|
527
|
+
if (vetoable.this_call_vetoable) {
|
|
528
|
+
const blocked = adapter.render(ASK_TIER_DENY_PROBE, vetoable);
|
|
529
|
+
if (blocked.enforced)
|
|
530
|
+
assert.notDeepEqual(
|
|
531
|
+
adapter.render(ASK_TIER_ASK_PROBE, vetoable),
|
|
532
|
+
blocked,
|
|
533
|
+
`${adapter.AGENT}: NATIVE_ASK_TIER is true, but an ask renders exactly as this host's enforced deny — the call is blocked, not suspended: ${caseName}`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
assert.deepEqual(
|
|
539
|
+
asked,
|
|
540
|
+
adapter.render(ASK_TIER_DENY_PROBE, event),
|
|
541
|
+
`${adapter.AGENT}: NATIVE_ASK_TIER is false, but an ask renders differently from this host's advisory deny — the render carries an ask signal the declaration says the host has not got: ${caseName}`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
457
545
|
/**
|
|
458
546
|
* Rule ⑩, for one parsed event: every {@link VERDICT_CONTENT_FIELDS} entry
|
|
459
547
|
* either reaches this host's wire or is DECLARED unreachable in the adapter's
|
|
@@ -820,13 +908,23 @@ function assertAliasedInput(agent, caseName, event, assert) {
|
|
|
820
908
|
* that DID reach a wire come back as `contentFieldsSeen`; a suite covering
|
|
821
909
|
* several adapters asserts their union to keep the positive half of the
|
|
822
910
|
* rule non-vacuous, since no single host has a channel for all three.
|
|
911
|
+
* 11. ask-tier honesty: the adapter declares whether this host carries a
|
|
912
|
+
* distinct "ask the human" tier it honours (`NATIVE_ASK_TIER`), and the
|
|
913
|
+
* render is held to that declaration on every pre-tool event. A declared
|
|
914
|
+
* tier whose `ask` renders as the host's abstaining allow fails — the
|
|
915
|
+
* consumer is told a human will see the call while the tool just runs —
|
|
916
|
+
* and so does a declared-absent tier whose `ask` renders as anything but
|
|
917
|
+
* the adapter's own advisory deny. A consumer reads the declaration to
|
|
918
|
+
* decide where an `ask` must be escalated to a `deny`, so a wrong one
|
|
919
|
+
* either lets a call through or denies every asked call on the hosts that
|
|
920
|
+
* do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
|
|
823
921
|
*
|
|
824
922
|
* `assert` is injected (node:assert/strict) so the harness stays test-framework
|
|
825
923
|
* neutral; it throws on the first mismatch. Returns a summary the caller can
|
|
826
924
|
* assert further on.
|
|
827
925
|
*
|
|
828
926
|
* @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
|
|
829
|
-
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
927
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number }}
|
|
830
928
|
*/
|
|
831
929
|
export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
832
930
|
assert.equal(
|
|
@@ -837,6 +935,14 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
837
935
|
|
|
838
936
|
assertCoverageWellFormed(adapter, assert);
|
|
839
937
|
assertEveryKindHasARow(adapter, assert);
|
|
938
|
+
// Named here rather than left to a `TypeError` inside rule ⑪: a third-party
|
|
939
|
+
// adapter written against the earlier contract has no such member, and its
|
|
940
|
+
// author is who reads this.
|
|
941
|
+
assert.equal(
|
|
942
|
+
typeof adapter.NATIVE_ASK_TIER,
|
|
943
|
+
"boolean",
|
|
944
|
+
`${adapter.AGENT}: adapter declares no NATIVE_ASK_TIER — every adapter needs a boolean saying whether this host honours a distinct "ask the human" tier (false when render collapses ask onto another signal)`,
|
|
945
|
+
);
|
|
840
946
|
|
|
841
947
|
/** @type {Set<string>} */
|
|
842
948
|
const decisionsSeen = new Set();
|
|
@@ -852,6 +958,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
852
958
|
const parsedByKind = {};
|
|
853
959
|
let preToolCases = 0;
|
|
854
960
|
let unenforceableDenyChecks = 0;
|
|
961
|
+
let askTierChecks = 0;
|
|
855
962
|
let renders = 0;
|
|
856
963
|
|
|
857
964
|
for (const testCase of fixtures.cases) {
|
|
@@ -991,12 +1098,17 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
991
1098
|
// An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
|
|
992
1099
|
// no pre-emption channel at all, so its deny and allow are identical by
|
|
993
1100
|
// construction. Demanding a distinct signal there would demand a fiction.
|
|
994
|
-
if (deniedRender.transport
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1101
|
+
if (deniedRender.transport !== IntegrationMode.OBSERVE_ONLY)
|
|
1102
|
+
assert.notDeepEqual(
|
|
1103
|
+
deniedRender,
|
|
1104
|
+
adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
|
|
1105
|
+
`unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
|
|
1106
|
+
);
|
|
1107
|
+
// Rule ⑪, on the same non-vetoable variant and AFTER rule ⑧: an adapter that
|
|
1108
|
+
// collapses both its deny and its ask onto the host's allow is reported as
|
|
1109
|
+
// the lost objection it is, not as a mis-declared ask tier.
|
|
1110
|
+
assertAskTier(adapter, unenforceable, parsed, testCase.name, assert);
|
|
1111
|
+
askTierChecks += 1;
|
|
1000
1112
|
}
|
|
1001
1113
|
|
|
1002
1114
|
for (const decision of ["allow", "deny", "ask"]) {
|
|
@@ -1055,6 +1167,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1055
1167
|
preToolCases,
|
|
1056
1168
|
`rule ⑧ (unenforceable deny ≠ allow) ran on ${unenforceableDenyChecks} of ${preToolCases} pre-tool cases`,
|
|
1057
1169
|
);
|
|
1170
|
+
// Same reason as the count above: the rule is in the same loop, so a stray
|
|
1171
|
+
// `continue` would skip it silently on the later cases.
|
|
1172
|
+
assert.equal(
|
|
1173
|
+
askTierChecks,
|
|
1174
|
+
preToolCases,
|
|
1175
|
+
`rule ⑪ (ask-tier declaration) ran on ${askTierChecks} of ${preToolCases} pre-tool cases`,
|
|
1176
|
+
);
|
|
1058
1177
|
|
|
1059
1178
|
return {
|
|
1060
1179
|
cases: fixtures.cases.length,
|
|
@@ -1067,5 +1186,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1067
1186
|
unknownKindSeen,
|
|
1068
1187
|
coverageClassesChecked,
|
|
1069
1188
|
unenforceableDenyChecks,
|
|
1189
|
+
askTierChecks,
|
|
1070
1190
|
};
|
|
1071
1191
|
}
|
package/src/control-plane.mjs
CHANGED
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
* {@link Adapter} interface an integrator implements. A new REQUIRED adapter
|
|
39
39
|
* member breaks every third-party adapter while every event on the wire stays
|
|
40
40
|
* byte-identical, so it is a package-semver break and leaves
|
|
41
|
-
* {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one
|
|
42
|
-
*
|
|
43
|
-
* and the
|
|
41
|
+
* {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one and
|
|
42
|
+
* `NATIVE_ASK_TIER` is the second: adding either is a one-line change per
|
|
43
|
+
* adapter, and the conformance harness names the member and the migration
|
|
44
|
+
* rather than throwing from inside itself.
|
|
44
45
|
*/
|
|
45
46
|
|
|
46
47
|
/** Wire identifier for this schema version; bump on a breaking shape change. */
|
|
@@ -501,6 +502,7 @@ export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
|
|
|
501
502
|
* @property {(native: any) => ToolCallEvent} parse
|
|
502
503
|
* @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
|
|
503
504
|
* @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`.
|
|
505
|
+
* @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.
|
|
504
506
|
* @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.
|
|
505
507
|
*/
|
|
506
508
|
|
package/src/fixtures/claude.json
CHANGED
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"permissionDecision": "deny",
|
|
68
68
|
"permissionDecisionReason": "blocked rm"
|
|
69
69
|
}
|
|
70
|
-
}
|
|
70
|
+
},
|
|
71
|
+
"stderr": "blocked rm"
|
|
71
72
|
}
|
|
72
73
|
},
|
|
73
74
|
"ask": {
|
|
@@ -244,7 +245,8 @@
|
|
|
244
245
|
},
|
|
245
246
|
"decision": "block",
|
|
246
247
|
"reason": "leak"
|
|
247
|
-
}
|
|
248
|
+
},
|
|
249
|
+
"stderr": "leak"
|
|
248
250
|
}
|
|
249
251
|
},
|
|
250
252
|
"ask": {
|
package/src/fixtures/codex.json
CHANGED
|
@@ -109,12 +109,13 @@
|
|
|
109
109
|
},
|
|
110
110
|
"native": {
|
|
111
111
|
"transport": "external_hook",
|
|
112
|
-
"exit_code":
|
|
113
|
-
"enforced":
|
|
112
|
+
"exit_code": 2,
|
|
113
|
+
"enforced": true,
|
|
114
114
|
"stdout": {
|
|
115
115
|
"hookSpecificOutput": {
|
|
116
116
|
"hookEventName": "PreToolUse",
|
|
117
|
-
"permissionDecision": "
|
|
117
|
+
"permissionDecision": "deny",
|
|
118
|
+
"permissionDecisionReason": "blocked by monitor"
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
}
|
|
@@ -315,12 +316,13 @@
|
|
|
315
316
|
},
|
|
316
317
|
"native": {
|
|
317
318
|
"transport": "external_hook",
|
|
318
|
-
"exit_code":
|
|
319
|
-
"enforced":
|
|
319
|
+
"exit_code": 2,
|
|
320
|
+
"enforced": true,
|
|
320
321
|
"stdout": {
|
|
321
322
|
"hookSpecificOutput": {
|
|
322
323
|
"hookEventName": "PreToolUse",
|
|
323
|
-
"permissionDecision": "
|
|
324
|
+
"permissionDecision": "deny",
|
|
325
|
+
"permissionDecisionReason": "blocked by monitor"
|
|
324
326
|
}
|
|
325
327
|
}
|
|
326
328
|
}
|
|
@@ -1,21 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* multiplexer. Each host has its OWN entry
|
|
4
|
-
* its adapter and declares its OWN
|
|
5
|
-
*
|
|
6
|
-
* parse→judge→render pipe with
|
|
7
|
-
* emission). The thing item ②
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* The EXTERNAL_HOOK runtime, published at `agent-control-plane-core/runtime` —
|
|
3
|
+
* NOT a runtime host multiplexer. Each host has its OWN entry
|
|
4
|
+
* (`bin/<host>-hook.mjs`) that hardcodes its adapter and declares its OWN
|
|
5
|
+
* failure posture; this module only holds the transport mechanics they
|
|
6
|
+
* genuinely share (stdin read, the demo judge, the parse→judge→render pipe with
|
|
7
|
+
* a host-supplied fail-safe, and stdout+exit emission). The thing item ②
|
|
8
|
+
* deleted was the `--agent` switch that let one binary impersonate every host
|
|
9
|
+
* and so had to pick a single shared failure behavior; sharing pure plumbing
|
|
10
|
+
* across distinct entries is not that.
|
|
11
|
+
*
|
|
12
|
+
* It is exported because every consumer writing its own hook entry had to
|
|
13
|
+
* re-implement the same two things, and both fail silently when they are wrong:
|
|
14
|
+
* draining stdin before parsing, and flushing the response IN FULL before
|
|
15
|
+
* `process.exit` (see {@link writeAllSync}).
|
|
10
16
|
*/
|
|
11
17
|
import { writeSync } from "node:fs";
|
|
12
|
-
import { Decision, sanitizeVerdict } from "
|
|
18
|
+
import { Decision, sanitizeVerdict } from "./control-plane.mjs";
|
|
13
19
|
|
|
14
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Read all of stdin to a string.
|
|
22
|
+
* @returns {Promise<string>}
|
|
23
|
+
*/
|
|
15
24
|
export function readStdin() {
|
|
16
25
|
return new Promise((resolve, reject) => {
|
|
26
|
+
/** @type {Buffer[]} */
|
|
17
27
|
const chunks = [];
|
|
18
|
-
|
|
28
|
+
// A consumer that set an encoding on stdin hands STRINGS to this listener, and
|
|
29
|
+
// Buffer.concat throws on one. That throw escapes from the stream's callback,
|
|
30
|
+
// not from this promise, so it never reaches the caller's fail-safe: the hook
|
|
31
|
+
// dies with no response and a fail-open host runs the unjudged call.
|
|
32
|
+
process.stdin.on("data", (chunk) =>
|
|
33
|
+
chunks.push(
|
|
34
|
+
typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
19
37
|
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
20
38
|
process.stdin.on("error", reject);
|
|
21
39
|
});
|
|
@@ -25,8 +43,8 @@ export function readStdin() {
|
|
|
25
43
|
* The DEMO policy standing in for a real guardrail judge: deny any command
|
|
26
44
|
* matching /rm -rf/, allow everything else. A real deployment swaps this for its
|
|
27
45
|
* own judge over the normalized {@link ToolCallEvent}.
|
|
28
|
-
* @param {import("
|
|
29
|
-
* @returns {import("
|
|
46
|
+
* @param {import("./control-plane.mjs").ToolCallEvent} event
|
|
47
|
+
* @returns {import("./control-plane.mjs").Verdict}
|
|
30
48
|
*/
|
|
31
49
|
export function demoJudge(event) {
|
|
32
50
|
const command =
|
|
@@ -54,11 +72,11 @@ export function demoJudge(event) {
|
|
|
54
72
|
* `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
|
|
55
73
|
* so the guardrail is supplied here rather than by forking this file. Whatever
|
|
56
74
|
* it returns is treated as untrusted (see the clamp below).
|
|
57
|
-
* @param {import("
|
|
75
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
58
76
|
* @param {string} rawInput
|
|
59
|
-
* @param {import("
|
|
60
|
-
* @param {(event: import("
|
|
61
|
-
* @returns {import("
|
|
77
|
+
* @param {import("./control-plane.mjs").NativeResponse} onFailure
|
|
78
|
+
* @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
|
|
79
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
62
80
|
*/
|
|
63
81
|
export function renderHookResponse(
|
|
64
82
|
adapter,
|
|
@@ -140,7 +158,7 @@ function writeAllSync(fd, text) {
|
|
|
140
158
|
/**
|
|
141
159
|
* Emit a {@link NativeResponse} to the host: write the native stdout body when
|
|
142
160
|
* the transport has one, then exit with the transport's exit code.
|
|
143
|
-
* @param {import("
|
|
161
|
+
* @param {import("./control-plane.mjs").NativeResponse} response
|
|
144
162
|
* @returns {never}
|
|
145
163
|
*/
|
|
146
164
|
export function emit(response) {
|
package/types/adapters/amp.d.mts
CHANGED
|
@@ -72,6 +72,14 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
|
72
72
|
* @type {Record<string, ReadonlySet<string>|undefined>}
|
|
73
73
|
*/
|
|
74
74
|
export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
|
|
75
|
+
/**
|
|
76
|
+
* Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
|
|
77
|
+
* treats as neither the allow (0) nor the reject (2). {@link render} emits it
|
|
78
|
+
* for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
|
|
79
|
+
* The table below leans on the same tier for the non-vetoable deny, which is
|
|
80
|
+
* only honest because the tier is real.
|
|
81
|
+
*/
|
|
82
|
+
export const NATIVE_ASK_TIER: true;
|
|
75
83
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
76
84
|
export const ampAdapter: import("../control-plane.mjs").Adapter;
|
|
77
85
|
export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
|
|
@@ -9,7 +9,8 @@ export function parse(native: any): ToolCallEvent;
|
|
|
9
9
|
* Render into Claude Code's native external-hook transport: a
|
|
10
10
|
* `hookSpecificOutput` JSON body on stdout plus the exit code that carries the
|
|
11
11
|
* decision (deny ⇒ exit 2). A deny only counts as `enforced` when the event's
|
|
12
|
-
* `this_call_vetoable` holds
|
|
12
|
+
* `this_call_vetoable` holds, and an enforced deny also carries its `reason` on
|
|
13
|
+
* `NativeResponse.stderr` (see below).
|
|
13
14
|
*
|
|
14
15
|
* `soleGate` (default `false`) is a dangerous, explicit opt-in: when `true` AND
|
|
15
16
|
* the verdict is `allow`, the render emits Claude Code's REAL
|
|
@@ -65,6 +66,15 @@ export const COVERAGE: import("../control-plane.mjs").CoverageMap;
|
|
|
65
66
|
* @type {Record<string, ReadonlySet<string>|undefined>}
|
|
66
67
|
*/
|
|
67
68
|
export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* Claude Code honours a distinct ask tier: `hookSpecificOutput.permissionDecision
|
|
71
|
+
* = "ask"` suspends the call and puts it in front of the user, and {@link render}
|
|
72
|
+
* emits exactly that (see {@link gatingBody}). So a consumer must NOT escalate an
|
|
73
|
+
* `ask` to a `deny` here — the human already sees it. `render` still reports the
|
|
74
|
+
* ask as `enforced: false`, because this guardrail did not block the call; the
|
|
75
|
+
* host did, pending an answer.
|
|
76
|
+
*/
|
|
77
|
+
export const NATIVE_ASK_TIER: true;
|
|
68
78
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
69
79
|
export const HookEvent: Readonly<{
|
|
70
80
|
PRE_TOOL_USE: "PreToolUse";
|
|
@@ -113,6 +113,17 @@ export const MIN_ENFORCING_VERSION: readonly number[];
|
|
|
113
113
|
* @type {Record<string, string|undefined>}
|
|
114
114
|
*/
|
|
115
115
|
export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
116
|
+
/**
|
|
117
|
+
* Codex has NO ask tier. Its pre-tool output parser decodes
|
|
118
|
+
* `permissionDecision: "ask"`, classifies it unsupported, sets no block reason
|
|
119
|
+
* and runs the tool — upstream's own unit test for that path is named
|
|
120
|
+
* `unsupported_permission_decision_fails_open` (read at `rust-v0.155.1`, and
|
|
121
|
+
* byte-identical at `rust-v0.151.0`). `PermissionRequest` is no ask tier either:
|
|
122
|
+
* its behavior enum carries allow and deny only. So a verdict of `ask` has no
|
|
123
|
+
* channel here, and {@link render} sends it down the deny path rather than
|
|
124
|
+
* naming a key the host discards.
|
|
125
|
+
*/
|
|
126
|
+
export const NATIVE_ASK_TIER: false;
|
|
116
127
|
export const DEFAULT_DENY_REASON: "blocked by monitor";
|
|
117
128
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
118
129
|
export const codexAdapter: import("../control-plane.mjs").Adapter;
|
|
@@ -117,6 +117,15 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
|
117
117
|
* @type {Readonly<Record<string, string>>}
|
|
118
118
|
*/
|
|
119
119
|
export const GEMINI_TOOL_ALIASES: Readonly<Record<string, string>>;
|
|
120
|
+
/**
|
|
121
|
+
* Gemini CLI has NO ask tier: its hook vocabulary is `decision: "allow"` /
|
|
122
|
+
* `decision: "deny"` plus the exit-2 System Block, and nothing there suspends a
|
|
123
|
+
* call for a human. {@link applyAdvisoryDeny} therefore renders `ask` as the
|
|
124
|
+
* advisory `decision: "deny"`, byte-identical to a deny this call cannot veto.
|
|
125
|
+
* A consumer that must not let an ask through has to escalate it to a deny
|
|
126
|
+
* here, because an un-escalated ask lets Gemini run the tool.
|
|
127
|
+
*/
|
|
128
|
+
export const NATIVE_ASK_TIER: false;
|
|
120
129
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
121
130
|
export const geminiAdapter: import("../control-plane.mjs").Adapter;
|
|
122
131
|
export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
|
package/types/conformance.d.mts
CHANGED
|
@@ -111,13 +111,23 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
|
|
|
111
111
|
* that DID reach a wire come back as `contentFieldsSeen`; a suite covering
|
|
112
112
|
* several adapters asserts their union to keep the positive half of the
|
|
113
113
|
* rule non-vacuous, since no single host has a channel for all three.
|
|
114
|
+
* 11. ask-tier honesty: the adapter declares whether this host carries a
|
|
115
|
+
* distinct "ask the human" tier it honours (`NATIVE_ASK_TIER`), and the
|
|
116
|
+
* render is held to that declaration on every pre-tool event. A declared
|
|
117
|
+
* tier whose `ask` renders as the host's abstaining allow fails — the
|
|
118
|
+
* consumer is told a human will see the call while the tool just runs —
|
|
119
|
+
* and so does a declared-absent tier whose `ask` renders as anything but
|
|
120
|
+
* the adapter's own advisory deny. A consumer reads the declaration to
|
|
121
|
+
* decide where an `ask` must be escalated to a `deny`, so a wrong one
|
|
122
|
+
* either lets a call through or denies every asked call on the hosts that
|
|
123
|
+
* do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
|
|
114
124
|
*
|
|
115
125
|
* `assert` is injected (node:assert/strict) so the harness stays test-framework
|
|
116
126
|
* neutral; it throws on the first mismatch. Returns a summary the caller can
|
|
117
127
|
* assert further on.
|
|
118
128
|
*
|
|
119
129
|
* @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
|
|
120
|
-
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
130
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number }}
|
|
121
131
|
*/
|
|
122
132
|
export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
123
133
|
adapter: import("./control-plane.mjs").Adapter;
|
|
@@ -134,4 +144,5 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
|
134
144
|
unknownKindSeen: boolean;
|
|
135
145
|
coverageClassesChecked: Set<string>;
|
|
136
146
|
unenforceableDenyChecks: number;
|
|
147
|
+
askTierChecks: number;
|
|
137
148
|
};
|
|
@@ -136,6 +136,7 @@ export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
|
|
|
136
136
|
* @property {(native: any) => ToolCallEvent} parse
|
|
137
137
|
* @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
|
|
138
138
|
* @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
|
|
139
|
+
* @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.
|
|
139
140
|
* @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.
|
|
140
141
|
*/
|
|
141
142
|
/**
|
|
@@ -293,9 +294,10 @@ export function nativeResponse({ transport, exit_code, enforced, stdout, stderr,
|
|
|
293
294
|
* {@link Adapter} interface an integrator implements. A new REQUIRED adapter
|
|
294
295
|
* member breaks every third-party adapter while every event on the wire stays
|
|
295
296
|
* byte-identical, so it is a package-semver break and leaves
|
|
296
|
-
* {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one
|
|
297
|
-
*
|
|
298
|
-
* and the
|
|
297
|
+
* {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one and
|
|
298
|
+
* `NATIVE_ASK_TIER` is the second: adding either is a one-line change per
|
|
299
|
+
* adapter, and the conformance harness names the member and the migration
|
|
300
|
+
* rather than throwing from inside itself.
|
|
299
301
|
*/
|
|
300
302
|
/** Wire identifier for this schema version; bump on a breaking shape change. */
|
|
301
303
|
export const CONTROL_PLANE_SCHEMA: "control-plane/v1";
|
|
@@ -515,6 +517,10 @@ export type Adapter = {
|
|
|
515
517
|
* 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`.
|
|
516
518
|
*/
|
|
517
519
|
NATIVE_EVENT_FOR?: Record<string, string | undefined> | undefined;
|
|
520
|
+
/**
|
|
521
|
+
* 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.
|
|
522
|
+
*/
|
|
523
|
+
NATIVE_ASK_TIER: boolean;
|
|
518
524
|
/**
|
|
519
525
|
* 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.
|
|
520
526
|
*/
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read all of stdin to a string.
|
|
3
|
+
* @returns {Promise<string>}
|
|
4
|
+
*/
|
|
5
|
+
export function readStdin(): Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* The DEMO policy standing in for a real guardrail judge: deny any command
|
|
8
|
+
* matching /rm -rf/, allow everything else. A real deployment swaps this for its
|
|
9
|
+
* own judge over the normalized {@link ToolCallEvent}.
|
|
10
|
+
* @param {import("./control-plane.mjs").ToolCallEvent} event
|
|
11
|
+
* @returns {import("./control-plane.mjs").Verdict}
|
|
12
|
+
*/
|
|
13
|
+
export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): import("./control-plane.mjs").Verdict;
|
|
14
|
+
/**
|
|
15
|
+
* Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
|
|
16
|
+
* can't be parsed or the pipe throws, return `onFailure` — the host's OWN
|
|
17
|
+
* fail-safe response — so a crashed hook degrades the way THAT host expects
|
|
18
|
+
* (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
|
|
19
|
+
* single shared default. This is the deliberate, necessary recovery the whole
|
|
20
|
+
* per-host split exists for, not a blanket swallow.
|
|
21
|
+
*
|
|
22
|
+
* Before returning the fail-safe, the error is written to STDERR (never stdout,
|
|
23
|
+
* which belongs to the host transport) so a regression that silently disables
|
|
24
|
+
* enforcement leaves a greppable trace. Every host tolerates hook stderr:
|
|
25
|
+
* claude/codex surface it only in verbose/debug on a 0 exit, amp forwards the
|
|
26
|
+
* helper's stderr, and gemini reads stderr as a reason only on exit 2 — none of
|
|
27
|
+
* these fail-safe exits is a 2 for a stdout-carrying transport, and amp's ask
|
|
28
|
+
* (exit 1) shows the diagnostic alongside the prompt, which is the point.
|
|
29
|
+
* `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
|
|
30
|
+
* so the guardrail is supplied here rather than by forking this file. Whatever
|
|
31
|
+
* it returns is treated as untrusted (see the clamp below).
|
|
32
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
33
|
+
* @param {string} rawInput
|
|
34
|
+
* @param {import("./control-plane.mjs").NativeResponse} onFailure
|
|
35
|
+
* @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
|
|
36
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
37
|
+
*/
|
|
38
|
+
export function renderHookResponse(adapter: import("./control-plane.mjs").Adapter, rawInput: string, onFailure: import("./control-plane.mjs").NativeResponse, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): import("./control-plane.mjs").NativeResponse;
|
|
39
|
+
/**
|
|
40
|
+
* Emit a {@link NativeResponse} to the host: write the native stdout body when
|
|
41
|
+
* the transport has one, then exit with the transport's exit code.
|
|
42
|
+
* @param {import("./control-plane.mjs").NativeResponse} response
|
|
43
|
+
* @returns {never}
|
|
44
|
+
*/
|
|
45
|
+
export function emit(response: import("./control-plane.mjs").NativeResponse): never;
|