agent-control-plane-core 0.4.2 → 0.5.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 CHANGED
@@ -167,6 +167,12 @@ The harness pins both directions and rejects a vacuous suite:
167
167
  3. The fixture set collectively renders an `allow`, a `deny`, an `ask`, **and** a
168
168
  `mutated_input` — so a suite can't pass while skipping a decision the contract
169
169
  requires every adapter to express.
170
+ 4. A deny the adapter cannot enforce (`this_call_vetoable: false`) must render as
171
+ something OTHER than that host's allow signal — an ask, or an advisory body.
172
+ The harness probes this itself on a non-vetoable variant of every pre-tool
173
+ fixture event, so an adapter that collapses the objection onto "run it" fails
174
+ even with no non-vetoable fixture of its own. `observe_only` renders are
175
+ exempt: that transport has no pre-emption channel to differ in.
170
176
 
171
177
  See `src/fixtures/claude.json` and `src/fixtures/codex.json` for the format.
172
178
 
@@ -95,6 +95,48 @@ export function renderHookResponse(
95
95
  }
96
96
  }
97
97
 
98
+ /**
99
+ * A one-millisecond synchronous sleep buffer for the EAGAIN retry in
100
+ * {@link writeAllSync}. Allocated once at module scope: the retry path must not
101
+ * allocate per iteration while it is draining a full pipe.
102
+ */
103
+ const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
104
+
105
+ /**
106
+ * Write `text` to `fd` IN FULL, looping until every byte lands.
107
+ *
108
+ * A single `writeSync` is not enough. When the host captures the hook, fd 1/2 is
109
+ * a pipe, and libuv puts that pipe in NON-BLOCKING mode the moment anything
110
+ * initializes `process.stdout`/`process.stderr` (a `console.log` in a judge, the
111
+ * fail-safe stderr diagnostic in {@link renderHookResponse}). A non-blocking
112
+ * `write(2)` returns a SHORT COUNT once the kernel pipe buffer fills — measured
113
+ * at ~143 KiB here — so a deny body larger than that (a long `reason`, a big
114
+ * `mutated_input`) was silently truncated and the process still exited 0/2 with
115
+ * a half-written JSON the host cannot parse: an enforced deny degrading to a
116
+ * run. Looping restores the blocking-write semantics the caller assumes.
117
+ * @param {number} fd
118
+ * @param {string} text
119
+ */
120
+ function writeAllSync(fd, text) {
121
+ const buf = Buffer.from(text, "utf8");
122
+ let written = 0;
123
+ while (written < buf.length) {
124
+ try {
125
+ written += writeSync(fd, buf, written);
126
+ } catch (err) {
127
+ // EAGAIN is the one recoverable case: the non-blocking pipe is momentarily
128
+ // full because the host has not drained it yet. Sleep 1ms and retry — the
129
+ // same wait a blocking write would have done in the kernel. Every other
130
+ // errno (EPIPE, EBADF, ...) propagates. There is deliberately no retry cap:
131
+ // a cap would reintroduce exactly the silent truncation this loop exists to
132
+ // kill, and a host that never reads would have blocked us forever anyway.
133
+ if (!(err instanceof Error) || /** @type {any} */ (err).code !== "EAGAIN")
134
+ throw err;
135
+ Atomics.wait(RETRY_SLEEP, 0, 0, 1);
136
+ }
137
+ }
138
+ }
139
+
98
140
  /**
99
141
  * Emit a {@link NativeResponse} to the host: write the native stdout body when
100
142
  * the transport has one, then exit with the transport's exit code.
@@ -102,18 +144,18 @@ export function renderHookResponse(
102
144
  * @returns {never}
103
145
  */
104
146
  export function emit(response) {
105
- // `writeSync` (not `process.stdout.write`) so the body is flushed to fd 1
147
+ // `writeAllSync` (not `process.stdout.write`) so the body is flushed to fd 1
106
148
  // BEFORE `process.exit`. On a pipe — which stdout is when the host captures the
107
149
  // hook — `process.stdout.write` is asynchronous and `process.exit` does not
108
150
  // drain it, so an enforced-deny body (Claude/Codex `hookSpecificOutput`) could
109
151
  // be truncated; the host then reads a block-less exit and runs the tool. A
110
- // synchronous write closes that silent deny→allow window.
152
+ // synchronous, fully-drained write closes that silent deny→allow window.
111
153
  if (response.stdout !== undefined)
112
- writeSync(1, JSON.stringify(response.stdout));
154
+ writeAllSync(1, JSON.stringify(response.stdout));
113
155
  // A host that reads the block reason from STDERR (Gemini's exit-2 System Block)
114
156
  // gets it here — written synchronously to fd 2 before exit, for the same
115
157
  // flush-before-exit reason as stdout above, so an enforced deny is never shown
116
158
  // with no rationale.
117
- if (response.stderr !== undefined) writeSync(2, response.stderr);
159
+ if (response.stderr !== undefined) writeAllSync(2, response.stderr);
118
160
  process.exit(response.exit_code);
119
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.4.2",
3
+ "version": "0.5.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": {
@@ -18,8 +18,10 @@ import {
18
18
  CallClass,
19
19
  CoverageStatus,
20
20
  classifyCallClass,
21
- coverageAllowsVeto,
21
+ vetoableFor,
22
+ assertGatedKinds,
22
23
  canonicalTool,
24
+ lookup,
23
25
  makeEvent,
24
26
  normalizeVerdict,
25
27
  nativeResponse,
@@ -52,6 +54,14 @@ export const COVERAGE = Object.freeze({
52
54
  [CallClass.RESUMED]: CoverageStatus.UNKNOWN,
53
55
  });
54
56
 
57
+ /**
58
+ * The event kinds this host can actually gate. Amp invokes the delegate for a tool call and nothing else, so parse only ever emits PRE_TOOL.
59
+ * A kind absent here parses non-vetoable, so an unmodelled event never renders as
60
+ * an enforced block the host will not perform.
61
+ */
62
+ export const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
63
+ assertGatedKinds(GATED_EVENTS, AGENT);
64
+
55
65
  // Amp invokes the delegate for a tool call; the payload carries the tool name +
56
66
  // input and the session context. Pinned by fixtures/amp.json.
57
67
  const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
@@ -80,13 +90,58 @@ export function parse(native) {
80
90
  input: asObject(raw.input),
81
91
  response: undefined,
82
92
  // Classify on the NATIVE name (MCP detection keys on `mcp__…`).
83
- this_call_vetoable: coverageAllowsVeto(
93
+ this_call_vetoable: vetoableFor(
94
+ EventKind.PRE_TOOL,
95
+ GATED_EVENTS,
84
96
  COVERAGE[classifyCallClass(nativeTool, raw)],
85
97
  ),
86
98
  meta,
87
99
  });
88
100
  }
89
101
 
102
+ /**
103
+ * The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
104
+ * pure exit-code transport: 0 allow / 1 ask / 2 reject.
105
+ *
106
+ * Written as a total table rather than a ternary chain because the chain's
107
+ * fall-through case was ALLOW: an unenforceable deny — a deny on a call this
108
+ * guardrail cannot veto — silently rendered as exit 0, Amp's "run it". A
109
+ * combination this table forgets is a construction error (below), not a silent
110
+ * approval.
111
+ *
112
+ * The non-vetoable DENY row is exit 1 (ask), not 0. Amp has a real ask tier, so
113
+ * the honest render of "I object but cannot block" is to put the call in front
114
+ * of the human rather than wave it through. It is still `enforced: false` — the
115
+ * guardrail is not claiming a veto it does not have; it is declining to spend
116
+ * its one remaining signal on an approval.
117
+ *
118
+ * @type {Readonly<Record<string, Readonly<Record<string, number>>>>}
119
+ */
120
+ const EXIT_CODE_BY_DECISION = Object.freeze({
121
+ [Decision.ALLOW]: Object.freeze({ true: 0, false: 0 }),
122
+ [Decision.DENY]: Object.freeze({ true: 2, false: 1 }),
123
+ [Decision.ASK]: Object.freeze({ true: 1, false: 1 }),
124
+ });
125
+
126
+ /**
127
+ * Totality check at IMPORT: every {@link Decision} must have a row and every row
128
+ * both vetoable columns. A decision added to the contract breaks this module
129
+ * loudly at load instead of silently defaulting to exit 0.
130
+ */
131
+ for (const decision of Object.values(Decision)) {
132
+ const row = lookup(EXIT_CODE_BY_DECISION, decision);
133
+ if (row === undefined)
134
+ throw new Error(
135
+ `amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
136
+ );
137
+ for (const vetoable of ["true", "false"]) {
138
+ if (typeof lookup(row, vetoable) === "number") continue;
139
+ throw new Error(
140
+ `amp adapter: exit-code table row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
141
+ );
142
+ }
143
+ }
144
+
90
145
  /**
91
146
  * Render into Amp's pure exit-code transport: the decision is the exit code,
92
147
  * with no stdout body. `reason` has no native channel here, so it is dropped
@@ -99,8 +154,28 @@ export function parse(native) {
99
154
  */
100
155
  export function render(verdict, event) {
101
156
  const vd = normalizeVerdict(verdict);
102
- const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
103
- const exit_code = enforced ? 2 : vd.decision === Decision.ASK ? 1 : 0;
157
+ const vetoable = event.this_call_vetoable;
158
+ // `makeEvent` already rejects a non-boolean, so reaching this means a
159
+ // hand-built event — and guessing whether it can be vetoed is how fail-open
160
+ // starts. The string "true" is the case that makes this a real check rather
161
+ // than a formality: it would otherwise index the vetoable column.
162
+ if (typeof vetoable !== "boolean")
163
+ throw new Error(
164
+ `amp adapter: this_call_vetoable must be a boolean, got ${JSON.stringify(vetoable)}`,
165
+ );
166
+ const enforced = vd.decision === Decision.DENY && vetoable;
167
+ const exit_code = lookup(
168
+ lookup(EXIT_CODE_BY_DECISION, vd.decision) ?? {},
169
+ String(vetoable),
170
+ );
171
+ // Unreachable while the import-time totality check and `normalizeVerdict` both
172
+ // hold; kept because the alternative to throwing is `exit_code: undefined`,
173
+ // which `process.exit` renders as 0 — the exact silent allow this table exists
174
+ // to eliminate.
175
+ if (exit_code === undefined)
176
+ throw new Error(
177
+ `amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
178
+ );
104
179
  return nativeResponse({ transport: INTEGRATION_MODE, exit_code, enforced });
105
180
  }
106
181
 
@@ -19,7 +19,8 @@ import {
19
19
  CallClass,
20
20
  CoverageStatus,
21
21
  classifyCallClass,
22
- coverageAllowsVeto,
22
+ vetoableFor,
23
+ assertGatedKinds,
23
24
  canonicalTool,
24
25
  lookup,
25
26
  makeEvent,
@@ -57,6 +58,18 @@ export const COVERAGE = Object.freeze({
57
58
  [CallClass.RESUMED]: CoverageStatus.COVERED,
58
59
  });
59
60
 
61
+ /**
62
+ * The event kinds this host can actually gate. Claude Code honours a deny on these three. SESSION_START is left out: exit 2 does
63
+ * not abort a session start, and the matrix documents no host response for it — an
64
+ * undocumented kind is fail-closed to non-vetoable, the same rule coverage uses.
65
+ * A kind absent here parses non-vetoable, so an unmodelled event never renders as
66
+ * an enforced block the host will not perform.
67
+ */
68
+ export const GATED_EVENTS = Object.freeze(
69
+ new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
70
+ );
71
+ assertGatedKinds(GATED_EVENTS, AGENT);
72
+
60
73
  /** Claude Code native hook event names (the `hook_event_name` field). */
61
74
  export const HookEvent = Object.freeze({
62
75
  PRE_TOOL_USE: "PreToolUse",
@@ -162,7 +175,9 @@ export function parse(native) {
162
175
  response,
163
176
  // Classify on the NATIVE name — MCP detection keys on `mcp__…`, which a
164
177
  // canonical builtin name would never carry.
165
- this_call_vetoable: coverageAllowsVeto(
178
+ this_call_vetoable: vetoableFor(
179
+ kind,
180
+ GATED_EVENTS,
166
181
  COVERAGE[classifyCallClass(nativeTool, raw)],
167
182
  ),
168
183
  meta,
@@ -20,7 +20,8 @@ import {
20
20
  CallClass,
21
21
  CoverageStatus,
22
22
  classifyCallClass,
23
- coverageAllowsVeto,
23
+ vetoableFor,
24
+ assertGatedKinds,
24
25
  canonicalTool,
25
26
  makeEvent,
26
27
  normalizeVerdict,
@@ -54,6 +55,15 @@ export const COVERAGE = Object.freeze({
54
55
  [CallClass.RESUMED]: CoverageStatus.UNKNOWN,
55
56
  });
56
57
 
58
+ /**
59
+ * The event kinds this host can actually gate. `PreToolUse` is the one event Codex can veto. Every other native event parses as
60
+ * UNKNOWN today, so without this set each of them reported an enforced block.
61
+ * A kind absent here parses non-vetoable, so an unmodelled event never renders as
62
+ * an enforced block the host will not perform.
63
+ */
64
+ export const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
65
+ assertGatedKinds(GATED_EVENTS, AGENT);
66
+
57
67
  /** Minimum Codex version whose hook can actually veto a tool call. */
58
68
  export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
59
69
 
@@ -140,7 +150,12 @@ export function parse(native) {
140
150
  const nativeTool = asStringOrNull(raw.tool_name);
141
151
  if (nativeTool !== null) meta.native_tool = nativeTool;
142
152
  const vetoable =
143
- enforce && coverageAllowsVeto(COVERAGE[classifyCallClass(nativeTool, raw)]);
153
+ enforce &&
154
+ vetoableFor(
155
+ kind,
156
+ GATED_EVENTS,
157
+ COVERAGE[classifyCallClass(nativeTool, raw)],
158
+ );
144
159
 
145
160
  return makeEvent({
146
161
  event: kind,
@@ -35,7 +35,8 @@ import {
35
35
  CallClass,
36
36
  CoverageStatus,
37
37
  classifyCallClass,
38
- coverageAllowsVeto,
38
+ vetoableFor,
39
+ assertGatedKinds,
39
40
  canonicalTool,
40
41
  lookup,
41
42
  assertAliasTargetsModeled,
@@ -72,6 +73,16 @@ export const COVERAGE = Object.freeze({
72
73
  [CallClass.RESUMED]: CoverageStatus.UNKNOWN,
73
74
  });
74
75
 
76
+ /**
77
+ * The event kinds this host can actually gate. Gemini CLI honours a deny on each of the three events NATIVE_TO_KIND maps.
78
+ * A kind absent here parses non-vetoable, so an unmodelled event never renders as
79
+ * an enforced block the host will not perform.
80
+ */
81
+ export const GATED_EVENTS = Object.freeze(
82
+ new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
83
+ );
84
+ assertGatedKinds(GATED_EVENTS, AGENT);
85
+
75
86
  /** Gemini CLI native hook event names (the `hook_event_name` field). */
76
87
  export const HookEvent = Object.freeze({
77
88
  BEFORE_TOOL: "BeforeTool",
@@ -236,7 +247,7 @@ export function parse(native) {
236
247
  tool: geminiCanonicalTool(nativeTool, callClass),
237
248
  input: geminiInput(kind, raw, nativeTool, callClass),
238
249
  response,
239
- this_call_vetoable: coverageAllowsVeto(COVERAGE[callClass]),
250
+ this_call_vetoable: vetoableFor(kind, GATED_EVENTS, COVERAGE[callClass]),
240
251
  meta,
241
252
  });
242
253
  }
@@ -1,5 +1,8 @@
1
1
  import {
2
2
  CALL_CLASSES,
3
+ Decision,
4
+ EventKind,
5
+ IntegrationMode,
3
6
  MODELED_TOOL_INPUT_KEYS,
4
7
  TOOL_ALIASES,
5
8
  canonicalTool,
@@ -8,6 +11,22 @@ import {
8
11
  lookup,
9
12
  } from "./control-plane.mjs";
10
13
 
14
+ /**
15
+ * The two probe verdicts rule ⑧ renders against a non-vetoable variant of each
16
+ * pre-tool fixture event. Frozen so a mutating adapter cannot poison later cases.
17
+ */
18
+ const UNENFORCEABLE_DENY_PROBE = Object.freeze({
19
+ decision: Decision.DENY,
20
+ reason: "conformance probe: unenforceable deny",
21
+ });
22
+ const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
23
+
24
+ /**
25
+ * The native event name rule ⑨ drifts an adapter with. No host emits it, so an
26
+ * adapter that maps event names must answer {@link EventKind.UNKNOWN} for it.
27
+ */
28
+ const UNMODELLED_EVENT_NAME = "ConformanceProbeUnmodelledEvent";
29
+
11
30
  /**
12
31
  * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
13
32
  * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
@@ -66,14 +85,17 @@ export function assertToolAliasesCovered(
66
85
  let witnessed = witnessedByAgent.get(fixtures.agent);
67
86
  if (witnessed === undefined)
68
87
  witnessedByAgent.set(fixtures.agent, (witnessed = new Map()));
69
- const scopedMap = adapterAliases[fixtures.agent] ?? {};
88
+ // `fixtures.agent` and the native tool names below are ADAPTER-SUPPLIED
89
+ // strings; a bare index would resolve `constructor`/`__proto__` to an
90
+ // inherited Object.prototype member instead of missing. See `lookup`.
91
+ const scopedMap = lookup(adapterAliases, fixtures.agent) ?? {};
70
92
  for (const testCase of fixtures.cases) {
71
93
  const nativeTool = testCase.event?.meta?.native_tool;
72
94
  if (typeof nativeTool !== "string") continue;
73
95
  const canon = testCase.event.tool;
74
96
  const allowed = new Set([canonicalTool(nativeTool)]);
75
- if (scopedMap[nativeTool] !== undefined)
76
- allowed.add(scopedMap[nativeTool]);
97
+ const scopedTarget = lookup(scopedMap, nativeTool);
98
+ if (scopedTarget !== undefined) allowed.add(scopedTarget);
77
99
  assert.ok(
78
100
  allowed.has(canon),
79
101
  `fixture '${testCase.name}' (${fixtures.agent}): native_tool ${JSON.stringify(nativeTool)} normalized to ${JSON.stringify(canon)}, but only ${JSON.stringify([...allowed])} are valid canonicalizations for this agent`,
@@ -195,13 +217,21 @@ function assertAliasedInput(agent, caseName, event, assert) {
195
217
  * permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
196
218
  * `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
197
219
  * while parsing its calls as vetoable.
220
+ * 8. unenforceable deny ≠ allow: for every pre-tool fixture event, rendering a
221
+ * DENY over a non-vetoable variant of that event must produce a response
222
+ * that differs from the same adapter's abstaining `allow` render. Rule ⑤
223
+ * only says such a deny is not `enforced`; without this, an adapter is free
224
+ * to collapse it onto the host's literal "run it" signal (Amp's exit 0),
225
+ * throwing away the objection entirely. The strongest honest signal a
226
+ * transport has left — an ask, an advisory body — must survive. An
227
+ * OBSERVE_ONLY render is exempt: it has no pre-emption channel to differ in.
198
228
  *
199
229
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
200
230
  * neutral; it throws on the first mismatch. Returns a summary the caller can
201
231
  * assert further on.
202
232
  *
203
233
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
204
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string> }}
234
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
205
235
  */
206
236
  export function runAdapterConformance({ adapter, fixtures, assert }) {
207
237
  assert.equal(
@@ -219,6 +249,9 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
219
249
  let mutationSeen = false;
220
250
  let enforcedDenySeen = false;
221
251
  let vetoableDenySeen = false;
252
+ let unknownKindSeen = false;
253
+ let preToolCases = 0;
254
+ let unenforceableDenyChecks = 0;
222
255
  let renders = 0;
223
256
 
224
257
  for (const testCase of fixtures.cases) {
@@ -234,16 +267,32 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
234
267
  // a rename-without-its-input land by editing the expectation.
235
268
  assertAliasedInput(adapter.AGENT, testCase.name, parsed, assert);
236
269
 
270
+ // An event the adapter could not name is one whose host response nobody has
271
+ // established, so claiming a veto for it is the one error that lies to a
272
+ // guardrail: the transcript shows a block and the host runs the tool anyway.
273
+ if (parsed.event === EventKind.UNKNOWN) {
274
+ assert.equal(
275
+ parsed.this_call_vetoable,
276
+ false,
277
+ `unmodelled event parsed as vetoable: ${testCase.name}`,
278
+ );
279
+ unknownKindSeen = true;
280
+ }
281
+
237
282
  if (testCase.call_class !== undefined) {
238
283
  assert.ok(
239
284
  CALL_CLASSES.includes(testCase.call_class),
240
285
  `unknown call_class '${testCase.call_class}': ${testCase.name}`,
241
286
  );
242
- if (!coverageAllowsVeto(adapter.COVERAGE[testCase.call_class])) {
287
+ // Fixture-supplied key ⇒ prototype-safe read (see `lookup`): a
288
+ // `call_class` naming an Object.prototype member must MISS, not resolve a
289
+ // function that `coverageAllowsVeto` would then reject or accept by luck.
290
+ const coverage = lookup(adapter.COVERAGE, testCase.call_class);
291
+ if (!coverageAllowsVeto(coverage)) {
243
292
  assert.equal(
244
293
  parsed.this_call_vetoable,
245
294
  false,
246
- `call_class '${testCase.call_class}' is ${adapter.COVERAGE[testCase.call_class]} (no veto) but parsed this_call_vetoable !== false: ${testCase.name}`,
295
+ `call_class '${testCase.call_class}' is ${coverage} (no veto) but parsed this_call_vetoable !== false: ${testCase.name}`,
247
296
  );
248
297
  }
249
298
  coverageClassesChecked.add(testCase.call_class);
@@ -307,6 +356,38 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
307
356
  if (spec.verdict.mutated_input !== undefined) mutationSeen = true;
308
357
  renders += 1;
309
358
  }
359
+
360
+ // Rule ⑧, probed on a synthesized non-vetoable variant rather than on a
361
+ // fixture flag: an adapter whose COVERAGE marks every reachable call class
362
+ // as gated can never PARSE a non-vetoable event, so a fixture-driven check
363
+ // would sit vacuous on exactly the adapters this rule exists for. Only
364
+ // pre-tool events are probed — a post-tool/prompt event has no veto to lose,
365
+ // so its deny and allow renders may legitimately coincide.
366
+ if (parsed.event !== EventKind.PRE_TOOL) continue;
367
+ preToolCases += 1;
368
+ const unenforceable = { ...parsed, this_call_vetoable: false };
369
+ const deniedRender = adapter.render(
370
+ UNENFORCEABLE_DENY_PROBE,
371
+ unenforceable,
372
+ );
373
+ assert.equal(
374
+ deniedRender.enforced,
375
+ false,
376
+ `unenforceable deny rendered as enforced: ${testCase.name}`,
377
+ );
378
+ // Counted before the OBSERVE_ONLY exemption: the honesty half above ran, so
379
+ // an adapter whose every pre-tool case is observe-only has still been
380
+ // checked and must not fail the non-vacuity assertion below.
381
+ unenforceableDenyChecks += 1;
382
+ // An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
383
+ // no pre-emption channel at all, so its deny and allow are identical by
384
+ // construction. Demanding a distinct signal there would demand a fiction.
385
+ if (deniedRender.transport === IntegrationMode.OBSERVE_ONLY) continue;
386
+ assert.notDeepEqual(
387
+ deniedRender,
388
+ adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
389
+ `unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
390
+ );
310
391
  }
311
392
 
312
393
  for (const decision of ["allow", "deny", "ask"]) {
@@ -323,7 +404,34 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
323
404
  enforcedDenySeen,
324
405
  "no enforced deny rendered — enforcement honesty is untested",
325
406
  );
407
+ // Rule ⑨, probed on a synthesized payload rather than a fixture flag: an
408
+ // adapter whose parse can never emit UNKNOWN — amp routes every payload to
409
+ // pre_tool — has no such fixture to write, so a fixture-driven check would sit
410
+ // vacuous on exactly the adapters this rule covers.
411
+ const drifted = adapter.parse({ hook_event_name: UNMODELLED_EVENT_NAME });
412
+ if (drifted.event === EventKind.UNKNOWN) {
413
+ assert.equal(
414
+ drifted.this_call_vetoable,
415
+ false,
416
+ "an unmodelled event parsed as vetoable",
417
+ );
418
+ assert.equal(
419
+ adapter.render(UNENFORCEABLE_DENY_PROBE, drifted).enforced,
420
+ false,
421
+ "a deny on an unmodelled event rendered as an enforced block",
422
+ );
423
+ unknownKindSeen = true;
424
+ }
326
425
  assert.ok(renders > 0, "conformance fixtures render nothing");
426
+ // EVERY pre-tool case, not merely one: the count is what catches rule ⑧ being
427
+ // skipped by a stray `continue` rather than merely present somewhere. A suite
428
+ // with no pre-tool case has no veto surface to lose, and rule ⑤'s own
429
+ // `vetoableDenySeen` guard already forces enforcement honesty there.
430
+ assert.equal(
431
+ unenforceableDenyChecks,
432
+ preToolCases,
433
+ `rule ⑧ (unenforceable deny ≠ allow) ran on ${unenforceableDenyChecks} of ${preToolCases} pre-tool cases`,
434
+ );
327
435
 
328
436
  return {
329
437
  cases: fixtures.cases.length,
@@ -332,6 +440,8 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
332
440
  mutationSeen,
333
441
  enforcedDenySeen,
334
442
  vetoableDenySeen,
443
+ unknownKindSeen,
335
444
  coverageClassesChecked,
445
+ unenforceableDenyChecks,
336
446
  };
337
447
  }
@@ -242,11 +242,15 @@ const COVERAGE_STATUS_VALUES = new Set(Object.values(CoverageStatus));
242
242
  * forbid it — an unknown is fail-closed to uncovered, which is the whole point
243
243
  * of the matrix. Throws on an unrecognized status (fail loud — a typo must not
244
244
  * quietly read as "permitted").
245
- * @param {string} status a {@link CoverageStatus} value
245
+ *
246
+ * `undefined` is accepted as an INPUT type — that is what a prototype-safe
247
+ * `lookup` of a missing key yields — and takes the same throw: a coverage a
248
+ * caller could not resolve must never read as "permitted" either.
249
+ * @param {string|undefined} status a {@link CoverageStatus} value
246
250
  * @returns {boolean}
247
251
  */
248
252
  export function coverageAllowsVeto(status) {
249
- if (!COVERAGE_STATUS_VALUES.has(status)) {
253
+ if (!COVERAGE_STATUS_VALUES.has(/** @type {string} */ (status))) {
250
254
  throw new Error(
251
255
  `control-plane: invalid coverage status ${JSON.stringify(status)}`,
252
256
  );
@@ -254,6 +258,52 @@ export function coverageAllowsVeto(status) {
254
258
  return status === CoverageStatus.COVERED || status === CoverageStatus.PARTIAL;
255
259
  }
256
260
 
261
+ /**
262
+ * The event kinds a host can gate at all. {@link EventKind.UNKNOWN} is absent by
263
+ * construction, and that absence is the point: an event this package could not
264
+ * name is one whose host response nobody has established.
265
+ */
266
+ /** @type {Set<string>} */
267
+ const GATEABLE_KINDS = new Set([
268
+ EventKind.PRE_TOOL,
269
+ EventKind.POST_TOOL,
270
+ EventKind.PROMPT_SUBMIT,
271
+ EventKind.SESSION_START,
272
+ ]);
273
+
274
+ /**
275
+ * Whether an event may be marked `this_call_vetoable`.
276
+ *
277
+ * INVARIANT: an event whose kind the adapter's `GATED_EVENTS` does not name is
278
+ * never vetoable, whatever its coverage says. Coverage answers whether the
279
+ * host's hook fires for a CLASS of call and says nothing about a kind the
280
+ * adapter could not model — so keying on coverage alone reported an enforced
281
+ * block for every unmodelled event, which is the one direction that lies to a
282
+ * guardrail: the transcript shows a block and the host runs the tool.
283
+ * @param {string} kind an {@link EventKind} value
284
+ * @param {Set<string>} gatedKinds the adapter's own gated set
285
+ * @param {string|undefined} coverage a {@link CoverageStatus} value
286
+ * @returns {boolean}
287
+ */
288
+ export function vetoableFor(kind, gatedKinds, coverage) {
289
+ if (!gatedKinds.has(kind)) return false;
290
+ return coverageAllowsVeto(coverage);
291
+ }
292
+
293
+ /**
294
+ * Refuse a gated-kind set naming a kind no host can gate. Called at module load
295
+ * by each adapter, so `EventKind.UNKNOWN` cannot be added to one by hand.
296
+ * @param {Set<string>} kinds an adapter's `GATED_EVENTS`
297
+ * @param {string} agent the adapter's agent id, for the message
298
+ */
299
+ export function assertGatedKinds(kinds, agent) {
300
+ for (const kind of kinds)
301
+ if (!GATEABLE_KINDS.has(kind))
302
+ throw new Error(
303
+ `${agent} adapter: ${JSON.stringify(kind)} is not a gateable event kind`,
304
+ );
305
+ }
306
+
257
307
  /**
258
308
  * True when `status` is a recognized {@link CoverageStatus} value.
259
309
  * @param {unknown} status
@@ -318,7 +318,7 @@
318
318
  "event": "session_start",
319
319
  "tool": null,
320
320
  "input": {},
321
- "this_call_vetoable": true,
321
+ "this_call_vetoable": false,
322
322
  "meta": {
323
323
  "agent": "claude",
324
324
  "native_event": "SessionStart",
@@ -344,6 +344,24 @@
344
344
  }
345
345
  }
346
346
  }
347
+ },
348
+ "deny": {
349
+ "verdict": {
350
+ "decision": "deny",
351
+ "reason": "not a gateable event"
352
+ },
353
+ "native": {
354
+ "transport": "external_hook",
355
+ "exit_code": 0,
356
+ "enforced": false,
357
+ "stdout": {
358
+ "hookSpecificOutput": {
359
+ "hookEventName": "SessionStart"
360
+ },
361
+ "decision": "block",
362
+ "reason": "not a gateable event"
363
+ }
364
+ }
347
365
  }
348
366
  }
349
367
  }
@@ -30,6 +30,12 @@ export const INTEGRATION_MODE: "external_hook";
30
30
  */
31
31
  /** @type {import("../control-plane.mjs").CoverageMap} */
32
32
  export const COVERAGE: import("../control-plane.mjs").CoverageMap;
33
+ /**
34
+ * The event kinds this host can actually gate. Amp invokes the delegate for a tool call and nothing else, so parse only ever emits PRE_TOOL.
35
+ * A kind absent here parses non-vetoable, so an unmodelled event never renders as
36
+ * an enforced block the host will not perform.
37
+ */
38
+ export const GATED_EVENTS: Readonly<Set<"pre_tool">>;
33
39
  /** @type {import("../control-plane.mjs").Adapter} */
34
40
  export const ampAdapter: import("../control-plane.mjs").Adapter;
35
41
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -41,6 +41,14 @@ export const INTEGRATION_MODE: "external_hook";
41
41
  */
42
42
  /** @type {import("../control-plane.mjs").CoverageMap} */
43
43
  export const COVERAGE: import("../control-plane.mjs").CoverageMap;
44
+ /**
45
+ * The event kinds this host can actually gate. Claude Code honours a deny on these three. SESSION_START is left out: exit 2 does
46
+ * not abort a session start, and the matrix documents no host response for it — an
47
+ * undocumented kind is fail-closed to non-vetoable, the same rule coverage uses.
48
+ * A kind absent here parses non-vetoable, so an unmodelled event never renders as
49
+ * an enforced block the host will not perform.
50
+ */
51
+ export const GATED_EVENTS: Readonly<Set<"pre_tool" | "post_tool" | "prompt_submit">>;
44
52
  /** Claude Code native hook event names (the `hook_event_name` field). */
45
53
  export const HookEvent: Readonly<{
46
54
  PRE_TOOL_USE: "PreToolUse";
@@ -59,6 +59,13 @@ export const INTEGRATION_MODE: "external_hook";
59
59
  */
60
60
  /** @type {import("../control-plane.mjs").CoverageMap} */
61
61
  export const COVERAGE: import("../control-plane.mjs").CoverageMap;
62
+ /**
63
+ * The event kinds this host can actually gate. `PreToolUse` is the one event Codex can veto. Every other native event parses as
64
+ * UNKNOWN today, so without this set each of them reported an enforced block.
65
+ * A kind absent here parses non-vetoable, so an unmodelled event never renders as
66
+ * an enforced block the host will not perform.
67
+ */
68
+ export const GATED_EVENTS: Readonly<Set<"pre_tool">>;
62
69
  /** Minimum Codex version whose hook can actually veto a tool call. */
63
70
  export const MIN_ENFORCING_VERSION: readonly number[];
64
71
  export const DEFAULT_DENY_REASON: "blocked by monitor";
@@ -43,6 +43,12 @@ export const INTEGRATION_MODE: "external_hook";
43
43
  */
44
44
  /** @type {import("../control-plane.mjs").CoverageMap} */
45
45
  export const COVERAGE: import("../control-plane.mjs").CoverageMap;
46
+ /**
47
+ * The event kinds this host can actually gate. Gemini CLI honours a deny on each of the three events NATIVE_TO_KIND maps.
48
+ * A kind absent here parses non-vetoable, so an unmodelled event never renders as
49
+ * an enforced block the host will not perform.
50
+ */
51
+ export const GATED_EVENTS: Readonly<Set<"pre_tool" | "post_tool" | "prompt_submit">>;
46
52
  /** Gemini CLI native hook event names (the `hook_event_name` field). */
47
53
  export const HookEvent: Readonly<{
48
54
  BEFORE_TOOL: "BeforeTool";
@@ -89,13 +89,21 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
89
89
  * permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
90
90
  * `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
91
91
  * while parsing its calls as vetoable.
92
+ * 8. unenforceable deny ≠ allow: for every pre-tool fixture event, rendering a
93
+ * DENY over a non-vetoable variant of that event must produce a response
94
+ * that differs from the same adapter's abstaining `allow` render. Rule ⑤
95
+ * only says such a deny is not `enforced`; without this, an adapter is free
96
+ * to collapse it onto the host's literal "run it" signal (Amp's exit 0),
97
+ * throwing away the objection entirely. The strongest honest signal a
98
+ * transport has left — an ask, an advisory body — must survive. An
99
+ * OBSERVE_ONLY render is exempt: it has no pre-emption channel to differ in.
92
100
  *
93
101
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
94
102
  * neutral; it throws on the first mismatch. Returns a summary the caller can
95
103
  * assert further on.
96
104
  *
97
105
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
98
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string> }}
106
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
99
107
  */
100
108
  export function runAdapterConformance({ adapter, fixtures, assert }: {
101
109
  adapter: import("./control-plane.mjs").Adapter;
@@ -108,5 +116,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
108
116
  mutationSeen: boolean;
109
117
  enforcedDenySeen: boolean;
110
118
  vetoableDenySeen: boolean;
119
+ unknownKindSeen: boolean;
111
120
  coverageClassesChecked: Set<string>;
121
+ unenforceableDenyChecks: number;
112
122
  };
@@ -38,10 +38,36 @@ export function lookup<T>(map: Record<string, T>, key: string): T | undefined;
38
38
  * forbid it — an unknown is fail-closed to uncovered, which is the whole point
39
39
  * of the matrix. Throws on an unrecognized status (fail loud — a typo must not
40
40
  * quietly read as "permitted").
41
- * @param {string} status a {@link CoverageStatus} value
41
+ *
42
+ * `undefined` is accepted as an INPUT type — that is what a prototype-safe
43
+ * `lookup` of a missing key yields — and takes the same throw: a coverage a
44
+ * caller could not resolve must never read as "permitted" either.
45
+ * @param {string|undefined} status a {@link CoverageStatus} value
42
46
  * @returns {boolean}
43
47
  */
44
- export function coverageAllowsVeto(status: string): boolean;
48
+ export function coverageAllowsVeto(status: string | undefined): boolean;
49
+ /**
50
+ * Whether an event may be marked `this_call_vetoable`.
51
+ *
52
+ * INVARIANT: an event whose kind the adapter's `GATED_EVENTS` does not name is
53
+ * never vetoable, whatever its coverage says. Coverage answers whether the
54
+ * host's hook fires for a CLASS of call and says nothing about a kind the
55
+ * adapter could not model — so keying on coverage alone reported an enforced
56
+ * block for every unmodelled event, which is the one direction that lies to a
57
+ * guardrail: the transcript shows a block and the host runs the tool.
58
+ * @param {string} kind an {@link EventKind} value
59
+ * @param {Set<string>} gatedKinds the adapter's own gated set
60
+ * @param {string|undefined} coverage a {@link CoverageStatus} value
61
+ * @returns {boolean}
62
+ */
63
+ export function vetoableFor(kind: string, gatedKinds: Set<string>, coverage: string | undefined): boolean;
64
+ /**
65
+ * Refuse a gated-kind set naming a kind no host can gate. Called at module load
66
+ * by each adapter, so `EventKind.UNKNOWN` cannot be added to one by hand.
67
+ * @param {Set<string>} kinds an adapter's `GATED_EVENTS`
68
+ * @param {string} agent the adapter's agent id, for the message
69
+ */
70
+ export function assertGatedKinds(kinds: Set<string>, agent: string): void;
45
71
  /**
46
72
  * True when `status` is a recognized {@link CoverageStatus} value.
47
73
  * @param {unknown} status