agent-control-plane-core 0.4.3 → 0.5.1

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.1",
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,16 @@ 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. Module-private: `Object.freeze` does
61
+ * not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back
62
+ * after `assertGatedKinds` has already run.
63
+ */
64
+ const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
65
+ assertGatedKinds(GATED_EVENTS, AGENT);
66
+
56
67
  // Amp invokes the delegate for a tool call; the payload carries the tool name +
57
68
  // input and the session context. Pinned by fixtures/amp.json.
58
69
  const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
@@ -81,7 +92,9 @@ export function parse(native) {
81
92
  input: asObject(raw.input),
82
93
  response: undefined,
83
94
  // Classify on the NATIVE name (MCP detection keys on `mcp__…`).
84
- this_call_vetoable: coverageAllowsVeto(
95
+ this_call_vetoable: vetoableFor(
96
+ EventKind.PRE_TOOL,
97
+ GATED_EVENTS,
85
98
  COVERAGE[classifyCallClass(nativeTool, raw)],
86
99
  ),
87
100
  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,20 @@ 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. Module-private: `Object.freeze` does
67
+ * not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back after
68
+ * `assertGatedKinds` has already run.
69
+ */
70
+ const GATED_EVENTS = Object.freeze(
71
+ new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
72
+ );
73
+ assertGatedKinds(GATED_EVENTS, AGENT);
74
+
60
75
  /** Claude Code native hook event names (the `hook_event_name` field). */
61
76
  export const HookEvent = Object.freeze({
62
77
  PRE_TOOL_USE: "PreToolUse",
@@ -162,7 +177,9 @@ export function parse(native) {
162
177
  response,
163
178
  // Classify on the NATIVE name — MCP detection keys on `mcp__…`, which a
164
179
  // canonical builtin name would never carry.
165
- this_call_vetoable: coverageAllowsVeto(
180
+ this_call_vetoable: vetoableFor(
181
+ kind,
182
+ GATED_EVENTS,
166
183
  COVERAGE[classifyCallClass(nativeTool, raw)],
167
184
  ),
168
185
  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,17 @@ 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. Module-private: `Object.freeze` does
63
+ * not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back
64
+ * after `assertGatedKinds` has already run.
65
+ */
66
+ const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
67
+ assertGatedKinds(GATED_EVENTS, AGENT);
68
+
57
69
  /** Minimum Codex version whose hook can actually veto a tool call. */
58
70
  export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
59
71
 
@@ -140,7 +152,12 @@ export function parse(native) {
140
152
  const nativeTool = asStringOrNull(raw.tool_name);
141
153
  if (nativeTool !== null) meta.native_tool = nativeTool;
142
154
  const vetoable =
143
- enforce && coverageAllowsVeto(COVERAGE[classifyCallClass(nativeTool, raw)]);
155
+ enforce &&
156
+ vetoableFor(
157
+ kind,
158
+ GATED_EVENTS,
159
+ COVERAGE[classifyCallClass(nativeTool, raw)],
160
+ );
144
161
 
145
162
  return makeEvent({
146
163
  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,18 @@ 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. Module-private: `Object.freeze` does
80
+ * not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back
81
+ * after `assertGatedKinds` has already run.
82
+ */
83
+ const GATED_EVENTS = Object.freeze(
84
+ new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
85
+ );
86
+ assertGatedKinds(GATED_EVENTS, AGENT);
87
+
75
88
  /** Gemini CLI native hook event names (the `hook_event_name` field). */
76
89
  export const HookEvent = Object.freeze({
77
90
  BEFORE_TOOL: "BeforeTool",
@@ -236,7 +249,7 @@ export function parse(native) {
236
249
  tool: geminiCanonicalTool(nativeTool, callClass),
237
250
  input: geminiInput(kind, raw, nativeTool, callClass),
238
251
  response,
239
- this_call_vetoable: coverageAllowsVeto(COVERAGE[callClass]),
252
+ this_call_vetoable: vetoableFor(kind, GATED_EVENTS, COVERAGE[callClass]),
240
253
  meta,
241
254
  });
242
255
  }
@@ -21,6 +21,21 @@ const UNENFORCEABLE_DENY_PROBE = Object.freeze({
21
21
  });
22
22
  const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
23
23
 
24
+ /**
25
+ * The payload rule ⑨ drifts an adapter with. No host emits that event name, so
26
+ * an adapter that maps event names must answer {@link EventKind.UNKNOWN}.
27
+ *
28
+ * `version` carries a release far past any adapter's enforcement floor. Without
29
+ * it Codex's own version gate answers non-vetoable for an unrelated reason, and
30
+ * the rule then passes on the adapter that needs it most — Codex routes EVERY
31
+ * non-PreToolUse event into UNKNOWN, so a probe it cannot bite on certifies
32
+ * nothing.
33
+ */
34
+ const DRIFT_PROBE_NATIVE = Object.freeze({
35
+ hook_event_name: "ConformanceProbeUnmodelledEvent",
36
+ version: "9999.0.0",
37
+ });
38
+
24
39
  /**
25
40
  * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
26
41
  * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
@@ -225,7 +240,7 @@ function assertAliasedInput(agent, caseName, event, assert) {
225
240
  * assert further on.
226
241
  *
227
242
  * @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 }}
243
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
229
244
  */
230
245
  export function runAdapterConformance({ adapter, fixtures, assert }) {
231
246
  assert.equal(
@@ -243,6 +258,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
243
258
  let mutationSeen = false;
244
259
  let enforcedDenySeen = false;
245
260
  let vetoableDenySeen = false;
261
+ let unknownKindSeen = false;
246
262
  let preToolCases = 0;
247
263
  let unenforceableDenyChecks = 0;
248
264
  let renders = 0;
@@ -260,6 +276,18 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
260
276
  // a rename-without-its-input land by editing the expectation.
261
277
  assertAliasedInput(adapter.AGENT, testCase.name, parsed, assert);
262
278
 
279
+ // An event the adapter could not name is one whose host response nobody has
280
+ // established, so claiming a veto for it is the one error that lies to a
281
+ // guardrail: the transcript shows a block and the host runs the tool anyway.
282
+ if (parsed.event === EventKind.UNKNOWN) {
283
+ assert.equal(
284
+ parsed.this_call_vetoable,
285
+ false,
286
+ `unmodelled event parsed as vetoable: ${testCase.name}`,
287
+ );
288
+ unknownKindSeen = true;
289
+ }
290
+
263
291
  if (testCase.call_class !== undefined) {
264
292
  assert.ok(
265
293
  CALL_CLASSES.includes(testCase.call_class),
@@ -385,6 +413,26 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
385
413
  enforcedDenySeen,
386
414
  "no enforced deny rendered — enforcement honesty is untested",
387
415
  );
416
+ // Rule ⑨: parse must ANSWER for an event it cannot name rather than throw, and
417
+ // the render of a deny on that answer must not claim a block. Probed on a
418
+ // synthesized payload rather than a fixture flag, because an adapter whose
419
+ // parse can never emit UNKNOWN — amp routes every payload to pre_tool — has no
420
+ // such fixture to write, and a fixture-driven check would then sit vacuous on
421
+ // exactly the adapters this rule covers.
422
+ const drifted = adapter.parse({ ...DRIFT_PROBE_NATIVE });
423
+ if (drifted.event === EventKind.UNKNOWN) {
424
+ // Not that the event is non-vetoable — `makeEvent` refuses to construct a
425
+ // vetoable UNKNOWN at all, so asserting it here would police a state the
426
+ // constructor already makes unreachable. What only a probe can reach is the
427
+ // RENDER: an adapter is free to derive `enforced` from something other than
428
+ // the flag, and then the false block returns by another door.
429
+ assert.equal(
430
+ adapter.render(UNENFORCEABLE_DENY_PROBE, drifted).enforced,
431
+ false,
432
+ "a deny on an unmodelled event rendered as an enforced block",
433
+ );
434
+ unknownKindSeen = true;
435
+ }
388
436
  assert.ok(renders > 0, "conformance fixtures render nothing");
389
437
  // EVERY pre-tool case, not merely one: the count is what catches rule ⑧ being
390
438
  // skipped by a stray `continue` rather than merely present somewhere. A suite
@@ -403,6 +451,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
403
451
  mutationSeen,
404
452
  enforcedDenySeen,
405
453
  vetoableDenySeen,
454
+ unknownKindSeen,
406
455
  coverageClassesChecked,
407
456
  unenforceableDenyChecks,
408
457
  };
@@ -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
@@ -383,6 +429,16 @@ export function makeEvent({
383
429
  throw new TypeError(
384
430
  `control-plane: makeEvent this_call_vetoable must be a boolean, got ${typeof this_call_vetoable}`,
385
431
  );
432
+ // The same fail-open seam, one step further: an event the adapter could not
433
+ // name is one whose host response nobody established, so a veto reported for
434
+ // it is a block the render claims and the host never performs. Refused HERE
435
+ // because every adapter builds its event through this call, so an adapter that
436
+ // computes the flag without `vetoableFor` cannot reach the wire either.
437
+ if (event === EventKind.UNKNOWN && this_call_vetoable)
438
+ throw new Error(
439
+ "control-plane: makeEvent got a vetoable UNKNOWN event — an unmodelled " +
440
+ "event has no host response to veto, so reporting one is a false block",
441
+ );
386
442
  /** @type {ToolCallEvent} */
387
443
  const evt = {
388
444
  schema_version: SCHEMA_VERSION,
@@ -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
  }
@@ -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