agent-control-plane-core 0.4.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.4.3",
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,7 +18,8 @@ import {
18
18
  CallClass,
19
19
  CoverageStatus,
20
20
  classifyCallClass,
21
- coverageAllowsVeto,
21
+ vetoableFor,
22
+ assertGatedKinds,
22
23
  canonicalTool,
23
24
  lookup,
24
25
  makeEvent,
@@ -53,6 +54,14 @@ export const COVERAGE = Object.freeze({
53
54
  [CallClass.RESUMED]: CoverageStatus.UNKNOWN,
54
55
  });
55
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
+
56
65
  // Amp invokes the delegate for a tool call; the payload carries the tool name +
57
66
  // input and the session context. Pinned by fixtures/amp.json.
58
67
  const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
@@ -81,7 +90,9 @@ export function parse(native) {
81
90
  input: asObject(raw.input),
82
91
  response: undefined,
83
92
  // Classify on the NATIVE name (MCP detection keys on `mcp__…`).
84
- this_call_vetoable: coverageAllowsVeto(
93
+ this_call_vetoable: vetoableFor(
94
+ EventKind.PRE_TOOL,
95
+ GATED_EVENTS,
85
96
  COVERAGE[classifyCallClass(nativeTool, raw)],
86
97
  ),
87
98
  meta,
@@ -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
  }
@@ -21,6 +21,12 @@ const UNENFORCEABLE_DENY_PROBE = Object.freeze({
21
21
  });
22
22
  const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
23
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
+
24
30
  /**
25
31
  * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
26
32
  * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
@@ -225,7 +231,7 @@ function assertAliasedInput(agent, caseName, event, assert) {
225
231
  * assert further on.
226
232
  *
227
233
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
228
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
234
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
229
235
  */
230
236
  export function runAdapterConformance({ adapter, fixtures, assert }) {
231
237
  assert.equal(
@@ -243,6 +249,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
243
249
  let mutationSeen = false;
244
250
  let enforcedDenySeen = false;
245
251
  let vetoableDenySeen = false;
252
+ let unknownKindSeen = false;
246
253
  let preToolCases = 0;
247
254
  let unenforceableDenyChecks = 0;
248
255
  let renders = 0;
@@ -260,6 +267,18 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
260
267
  // a rename-without-its-input land by editing the expectation.
261
268
  assertAliasedInput(adapter.AGENT, testCase.name, parsed, assert);
262
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
+
263
282
  if (testCase.call_class !== undefined) {
264
283
  assert.ok(
265
284
  CALL_CLASSES.includes(testCase.call_class),
@@ -385,6 +404,24 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
385
404
  enforcedDenySeen,
386
405
  "no enforced deny rendered — enforcement honesty is untested",
387
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
+ }
388
425
  assert.ok(renders > 0, "conformance fixtures render nothing");
389
426
  // EVERY pre-tool case, not merely one: the count is what catches rule ⑧ being
390
427
  // skipped by a stray `continue` rather than merely present somewhere. A suite
@@ -403,6 +440,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
403
440
  mutationSeen,
404
441
  enforcedDenySeen,
405
442
  vetoableDenySeen,
443
+ unknownKindSeen,
406
444
  coverageClassesChecked,
407
445
  unenforceableDenyChecks,
408
446
  };
@@ -258,6 +258,52 @@ export function coverageAllowsVeto(status) {
258
258
  return status === CoverageStatus.COVERED || status === CoverageStatus.PARTIAL;
259
259
  }
260
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
+
261
307
  /**
262
308
  * True when `status` is a recognized {@link CoverageStatus} value.
263
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";
@@ -103,7 +103,7 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
103
103
  * assert further on.
104
104
  *
105
105
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
106
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
106
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
107
107
  */
108
108
  export function runAdapterConformance({ adapter, fixtures, assert }: {
109
109
  adapter: import("./control-plane.mjs").Adapter;
@@ -116,6 +116,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
116
116
  mutationSeen: boolean;
117
117
  enforcedDenySeen: boolean;
118
118
  vetoableDenySeen: boolean;
119
+ unknownKindSeen: boolean;
119
120
  coverageClassesChecked: Set<string>;
120
121
  unenforceableDenyChecks: number;
121
122
  };
@@ -46,6 +46,28 @@ export function lookup<T>(map: Record<string, T>, key: string): T | undefined;
46
46
  * @returns {boolean}
47
47
  */
48
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;
49
71
  /**
50
72
  * True when `status` is a recognized {@link CoverageStatus} value.
51
73
  * @param {unknown} status