agent-control-plane-core 0.5.0 → 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.5.0",
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": {
@@ -57,9 +57,11 @@ export const COVERAGE = Object.freeze({
57
57
  /**
58
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
59
  * A kind absent here parses non-vetoable, so an unmodelled event never renders as
60
- * an enforced block the host will not perform.
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.
61
63
  */
62
- export const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
64
+ const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
63
65
  assertGatedKinds(GATED_EVENTS, AGENT);
64
66
 
65
67
  // Amp invokes the delegate for a tool call; the payload carries the tool name +
@@ -63,9 +63,11 @@ export const COVERAGE = Object.freeze({
63
63
  * not abort a session start, and the matrix documents no host response for it — an
64
64
  * undocumented kind is fail-closed to non-vetoable, the same rule coverage uses.
65
65
  * A kind absent here parses non-vetoable, so an unmodelled event never renders as
66
- * an enforced block the host will not perform.
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.
67
69
  */
68
- export const GATED_EVENTS = Object.freeze(
70
+ const GATED_EVENTS = Object.freeze(
69
71
  new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
70
72
  );
71
73
  assertGatedKinds(GATED_EVENTS, AGENT);
@@ -59,9 +59,11 @@ export const COVERAGE = Object.freeze({
59
59
  * The event kinds this host can actually gate. `PreToolUse` is the one event Codex can veto. Every other native event parses as
60
60
  * UNKNOWN today, so without this set each of them reported an enforced block.
61
61
  * A kind absent here parses non-vetoable, so an unmodelled event never renders as
62
- * an enforced block the host will not perform.
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.
63
65
  */
64
- export const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
66
+ const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
65
67
  assertGatedKinds(GATED_EVENTS, AGENT);
66
68
 
67
69
  /** Minimum Codex version whose hook can actually veto a tool call. */
@@ -76,9 +76,11 @@ export const COVERAGE = Object.freeze({
76
76
  /**
77
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
78
  * A kind absent here parses non-vetoable, so an unmodelled event never renders as
79
- * an enforced block the host will not perform.
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.
80
82
  */
81
- export const GATED_EVENTS = Object.freeze(
83
+ const GATED_EVENTS = Object.freeze(
82
84
  new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
83
85
  );
84
86
  assertGatedKinds(GATED_EVENTS, AGENT);
@@ -22,10 +22,19 @@ const UNENFORCEABLE_DENY_PROBE = Object.freeze({
22
22
  const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
23
23
 
24
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.
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.
27
33
  */
28
- const UNMODELLED_EVENT_NAME = "ConformanceProbeUnmodelledEvent";
34
+ const DRIFT_PROBE_NATIVE = Object.freeze({
35
+ hook_event_name: "ConformanceProbeUnmodelledEvent",
36
+ version: "9999.0.0",
37
+ });
29
38
 
30
39
  /**
31
40
  * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
@@ -404,17 +413,19 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
404
413
  enforcedDenySeen,
405
414
  "no enforced deny rendered — enforcement honesty is untested",
406
415
  );
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 });
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 });
412
423
  if (drifted.event === EventKind.UNKNOWN) {
413
- assert.equal(
414
- drifted.this_call_vetoable,
415
- false,
416
- "an unmodelled event parsed as vetoable",
417
- );
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.
418
429
  assert.equal(
419
430
  adapter.render(UNENFORCEABLE_DENY_PROBE, drifted).enforced,
420
431
  false,
@@ -429,6 +429,16 @@ export function makeEvent({
429
429
  throw new TypeError(
430
430
  `control-plane: makeEvent this_call_vetoable must be a boolean, got ${typeof this_call_vetoable}`,
431
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
+ );
432
442
  /** @type {ToolCallEvent} */
433
443
  const evt = {
434
444
  schema_version: SCHEMA_VERSION,
@@ -30,12 +30,6 @@ 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">>;
39
33
  /** @type {import("../control-plane.mjs").Adapter} */
40
34
  export const ampAdapter: import("../control-plane.mjs").Adapter;
41
35
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -41,14 +41,6 @@ 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">>;
52
44
  /** Claude Code native hook event names (the `hook_event_name` field). */
53
45
  export const HookEvent: Readonly<{
54
46
  PRE_TOOL_USE: "PreToolUse";
@@ -59,13 +59,6 @@ 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">>;
69
62
  /** Minimum Codex version whose hook can actually veto a tool call. */
70
63
  export const MIN_ENFORCING_VERSION: readonly number[];
71
64
  export const DEFAULT_DENY_REASON: "blocked by monitor";
@@ -43,12 +43,6 @@ 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">>;
52
46
  /** Gemini CLI native hook event names (the `hook_event_name` field). */
53
47
  export const HookEvent: Readonly<{
54
48
  BEFORE_TOOL: "BeforeTool";