agent-control-plane-core 0.6.4 → 0.8.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.
@@ -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}.
@@ -202,17 +218,23 @@ const CONTENT_PROBE_VALUES = Object.freeze({
202
218
  ]),
203
219
  });
204
220
 
205
- // A field added to VERDICT_CONTENT_FIELDS with no probe value would be dropped
206
- // by normalizeVerdict and then reported as "reaches no native channel" against
207
- // every adapter, naming the adapter for the harness's own omission.
208
- if (
209
- VERDICT_CONTENT_FIELDS.some(
210
- (field) => (lookup(CONTENT_PROBE_VALUES, field) ?? []).length < 2,
211
- )
212
- )
213
- throw new Error(
214
- `conformance: CONTENT_PROBE_VALUES needs two or more values for each of ${VERDICT_CONTENT_FIELDS.join(", ")}`,
215
- );
221
+ /**
222
+ * A field added to VERDICT_CONTENT_FIELDS with no probe value would be dropped
223
+ * by normalizeVerdict and then reported as "reaches no native channel" against
224
+ * every adapter, naming the adapter for the harness's own omission. Called at
225
+ * import against the live {@link CONTENT_PROBE_VALUES}; exported so a test can
226
+ * drive both branches.
227
+ * @param {readonly string[]} fields
228
+ * @param {Record<string, readonly unknown[]>} values
229
+ */
230
+ export function assertContentProbeValuesComplete(fields, values) {
231
+ if (fields.some((field) => (lookup(values, field) ?? []).length < 2))
232
+ throw new Error(
233
+ `conformance: CONTENT_PROBE_VALUES needs two or more values for each of ${fields.join(", ")}`,
234
+ );
235
+ }
236
+
237
+ assertContentProbeValuesComplete(VERDICT_CONTENT_FIELDS, CONTENT_PROBE_VALUES);
216
238
 
217
239
  /**
218
240
  * Rule ⑩'s first half: the adapter declares a row for EVERY {@link EventKind}.
@@ -454,6 +476,78 @@ function coherentEvent(adapter, byKind, kind) {
454
476
  };
455
477
  }
456
478
 
479
+ /**
480
+ * Rule ⑪, for one pre-tool event: the adapter's `NATIVE_ASK_TIER` declaration
481
+ * agrees with what `render` does with an `ask`.
482
+ *
483
+ * The declaration is what lets a consumer stop keeping a hand-typed list of
484
+ * which hosts honour an ask. A consumer that must not let an ask through
485
+ * escalates it to a deny on a host with no ask tier — and applying that
486
+ * uniformly turns every ask into a deny on the hosts that DO have one. So the
487
+ * declaration has to be checked against the render rather than believed.
488
+ *
489
+ * Probed on a NON-vetoable variant of the event, for rule ⑧'s reason and one
490
+ * more: it makes the deny comparison below an ADVISORY deny, which is the
491
+ * signal a host with no ask tier has left to spend on an ask.
492
+ *
493
+ * What a render can show, and what it cannot. Whether a host suspends the call
494
+ * for a human lives in the HOST, so no probe reads it off a render. What a probe
495
+ * does read is each direction's tell:
496
+ *
497
+ * - Declared `true`, rendered as the abstaining ALLOW: the consumer is told the
498
+ * human will see this call, and the transport carries nothing that says so —
499
+ * the tool just runs. That is the lie this rule exists to catch.
500
+ * - Declared `false`, rendered as something the adapter's own advisory deny is
501
+ * not: the render invents a third signal on a transport the declaration says
502
+ * has only two, so one of the two is wrong.
503
+ *
504
+ * An OBSERVE_ONLY render is exempt, as in rule ⑧: that transport has no
505
+ * pre-emption channel at all, so its ask, deny and allow coincide by
506
+ * construction and demanding a distinct ask would demand a fiction.
507
+ * @param {import("./control-plane.mjs").Adapter} adapter
508
+ * @param {import("./control-plane.mjs").ToolCallEvent} event a non-vetoable pre-tool event
509
+ * @param {import("./control-plane.mjs").ToolCallEvent} vetoable the fixture's own pre-tool event, whose veto the block half needs
510
+ * @param {string} caseName
511
+ * @param {any} assert
512
+ */
513
+ function assertAskTier(adapter, event, vetoable, caseName, assert) {
514
+ const asked = adapter.render(ASK_TIER_ASK_PROBE, event);
515
+ assert.equal(
516
+ asked.enforced,
517
+ false,
518
+ `an ask rendered as an enforced block: ${caseName}`,
519
+ );
520
+ if (asked.transport === IntegrationMode.OBSERVE_ONLY) return;
521
+ if (adapter.NATIVE_ASK_TIER) {
522
+ assert.notDeepEqual(
523
+ asked,
524
+ adapter.render(ABSTAINING_ALLOW_PROBE, event),
525
+ `${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}`,
526
+ );
527
+ // The other way to declare an ask tier this host has not got: render the ask
528
+ // as the host's BLOCK. It differs from the allow, so the half above passes,
529
+ // and the consumer it tells to stop escalating then hands a call to a host
530
+ // that denies it outright rather than suspending it for a human. Probed on
531
+ // the vetoable event, because a block is the one signal a non-vetoable
532
+ // render cannot carry.
533
+ if (vetoable.this_call_vetoable) {
534
+ const blocked = adapter.render(ASK_TIER_DENY_PROBE, vetoable);
535
+ if (blocked.enforced)
536
+ assert.notDeepEqual(
537
+ adapter.render(ASK_TIER_ASK_PROBE, vetoable),
538
+ blocked,
539
+ `${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}`,
540
+ );
541
+ }
542
+ return;
543
+ }
544
+ assert.deepEqual(
545
+ asked,
546
+ adapter.render(ASK_TIER_DENY_PROBE, event),
547
+ `${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}`,
548
+ );
549
+ }
550
+
457
551
  /**
458
552
  * Rule ⑩, for one parsed event: every {@link VERDICT_CONTENT_FIELDS} entry
459
553
  * either reaches this host's wire or is DECLARED unreachable in the adapter's
@@ -513,8 +607,13 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
513
607
  // nowhere to hide.
514
608
  /** @param {string} field @param {Record<string, unknown>} held @param {string} shape */
515
609
  const carries = (field, held, shape) => {
516
- const values = lookup(CONTENT_PROBE_VALUES, field) ?? [];
517
- if (values.length < 2) return;
610
+ // `field` is always a VERDICT_CONTENT_FIELDS member (the only caller below
611
+ // iterates it), and assertContentProbeValuesComplete guarantees at import
612
+ // that every such member has two or more entries here — so neither a
613
+ // missing row nor a short one can reach this point.
614
+ const values = /** @type {readonly unknown[]} */ (
615
+ lookup(CONTENT_PROBE_VALUES, field)
616
+ );
518
617
  const renders = values.map((value) =>
519
618
  adapter.render(
520
619
  { decision: Decision.ALLOW, ...held, [field]: value },
@@ -592,7 +691,11 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
592
691
  const held = others.reduce(
593
692
  (combinations, name) =>
594
693
  combinations.flatMap((carried) =>
595
- (lookup(CONTENT_PROBE_VALUES, name) ?? []).map((value) => ({
694
+ // Same guarantee as `carries` above: `name` is a VERDICT_CONTENT_FIELDS
695
+ // member, so the row is always present.
696
+ /** @type {readonly unknown[]} */ (
697
+ lookup(CONTENT_PROBE_VALUES, name)
698
+ ).map((value) => ({
596
699
  ...carried,
597
700
  [name]: value,
598
701
  })),
@@ -820,13 +923,23 @@ function assertAliasedInput(agent, caseName, event, assert) {
820
923
  * that DID reach a wire come back as `contentFieldsSeen`; a suite covering
821
924
  * several adapters asserts their union to keep the positive half of the
822
925
  * rule non-vacuous, since no single host has a channel for all three.
926
+ * 11. ask-tier honesty: the adapter declares whether this host carries a
927
+ * distinct "ask the human" tier it honours (`NATIVE_ASK_TIER`), and the
928
+ * render is held to that declaration on every pre-tool event. A declared
929
+ * tier whose `ask` renders as the host's abstaining allow fails — the
930
+ * consumer is told a human will see the call while the tool just runs —
931
+ * and so does a declared-absent tier whose `ask` renders as anything but
932
+ * the adapter's own advisory deny. A consumer reads the declaration to
933
+ * decide where an `ask` must be escalated to a `deny`, so a wrong one
934
+ * either lets a call through or denies every asked call on the hosts that
935
+ * do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
823
936
  *
824
937
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
825
938
  * neutral; it throws on the first mismatch. Returns a summary the caller can
826
939
  * assert further on.
827
940
  *
828
941
  * @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 }}
942
+ * @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
943
  */
831
944
  export function runAdapterConformance({ adapter, fixtures, assert }) {
832
945
  assert.equal(
@@ -837,6 +950,14 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
837
950
 
838
951
  assertCoverageWellFormed(adapter, assert);
839
952
  assertEveryKindHasARow(adapter, assert);
953
+ // Named here rather than left to a `TypeError` inside rule ⑪: a third-party
954
+ // adapter written against the earlier contract has no such member, and its
955
+ // author is who reads this.
956
+ assert.equal(
957
+ typeof adapter.NATIVE_ASK_TIER,
958
+ "boolean",
959
+ `${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)`,
960
+ );
840
961
 
841
962
  /** @type {Set<string>} */
842
963
  const decisionsSeen = new Set();
@@ -852,6 +973,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
852
973
  const parsedByKind = {};
853
974
  let preToolCases = 0;
854
975
  let unenforceableDenyChecks = 0;
976
+ let askTierChecks = 0;
855
977
  let renders = 0;
856
978
 
857
979
  for (const testCase of fixtures.cases) {
@@ -991,12 +1113,17 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
991
1113
  // An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
992
1114
  // no pre-emption channel at all, so its deny and allow are identical by
993
1115
  // construction. Demanding a distinct signal there would demand a fiction.
994
- if (deniedRender.transport === IntegrationMode.OBSERVE_ONLY) continue;
995
- assert.notDeepEqual(
996
- deniedRender,
997
- adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
998
- `unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
999
- );
1116
+ if (deniedRender.transport !== IntegrationMode.OBSERVE_ONLY)
1117
+ assert.notDeepEqual(
1118
+ deniedRender,
1119
+ adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
1120
+ `unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
1121
+ );
1122
+ // Rule ⑪, on the same non-vetoable variant and AFTER rule ⑧: an adapter that
1123
+ // collapses both its deny and its ask onto the host's allow is reported as
1124
+ // the lost objection it is, not as a mis-declared ask tier.
1125
+ assertAskTier(adapter, unenforceable, parsed, testCase.name, assert);
1126
+ askTierChecks += 1;
1000
1127
  }
1001
1128
 
1002
1129
  for (const decision of ["allow", "deny", "ask"]) {
@@ -1055,6 +1182,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1055
1182
  preToolCases,
1056
1183
  `rule ⑧ (unenforceable deny ≠ allow) ran on ${unenforceableDenyChecks} of ${preToolCases} pre-tool cases`,
1057
1184
  );
1185
+ // Same reason as the count above: the rule is in the same loop, so a stray
1186
+ // `continue` would skip it silently on the later cases.
1187
+ assert.equal(
1188
+ askTierChecks,
1189
+ preToolCases,
1190
+ `rule ⑪ (ask-tier declaration) ran on ${askTierChecks} of ${preToolCases} pre-tool cases`,
1191
+ );
1058
1192
 
1059
1193
  return {
1060
1194
  cases: fixtures.cases.length,
@@ -1067,5 +1201,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1067
1201
  unknownKindSeen,
1068
1202
  coverageClassesChecked,
1069
1203
  unenforceableDenyChecks,
1204
+ askTierChecks,
1070
1205
  };
1071
1206
  }
@@ -23,9 +23,11 @@
23
23
  * Dependency-free on purpose, so a fail-closed hook can import it without
24
24
  * dragging in eager config-file reads.
25
25
  *
26
- * VERSIONING — this module IS the frozen contract (its own SSOT, no parallel
27
- * schema file to drift). Adapters and guardrail consumers are built against it
28
- * in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
26
+ * VERSIONING — this module IS the frozen contract and its own SSOT. The JSON
27
+ * Schema documents under `schema/` are GENERATED from it by `pnpm gen:schema`
28
+ * and byte-compared in `json-schema.test.mjs`, so they are output rather than a
29
+ * second contract; never hand-edit one. Adapters and guardrail consumers are
30
+ * built against this module in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
29
31
  * {@link MODELED_TOOLS}, and {@link TOOL_ALIASES} are frozen, and
30
32
  * {@link SCHEMA_VERSION} / {@link CONTROL_PLANE_SCHEMA} are pinned
31
33
  * (control-plane.test.mjs asserts the exact values, so any shape change is a
@@ -38,9 +40,10 @@
38
40
  * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
39
41
  * member breaks every third-party adapter while every event on the wire stays
40
42
  * byte-identical, so it is a package-semver break and leaves
41
- * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one: adding it is
42
- * a one-line change per adapter, and the conformance harness names the member
43
- * and the migration rather than throwing from inside itself.
43
+ * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one and
44
+ * `NATIVE_ASK_TIER` is the second: adding either is a one-line change per
45
+ * adapter, and the conformance harness names the member and the migration
46
+ * rather than throwing from inside itself.
44
47
  */
45
48
 
46
49
  /** Wire identifier for this schema version; bump on a breaking shape change. */
@@ -501,6 +504,7 @@ export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
501
504
  * @property {(native: any) => ToolCallEvent} parse
502
505
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
503
506
  * @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`.
507
+ * @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
508
  * @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
509
  */
506
510
 
@@ -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": {
@@ -109,12 +109,13 @@
109
109
  },
110
110
  "native": {
111
111
  "transport": "external_hook",
112
- "exit_code": 0,
113
- "enforced": false,
112
+ "exit_code": 2,
113
+ "enforced": true,
114
114
  "stdout": {
115
115
  "hookSpecificOutput": {
116
116
  "hookEventName": "PreToolUse",
117
- "permissionDecision": "ask"
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": 0,
319
- "enforced": false,
319
+ "exit_code": 2,
320
+ "enforced": true,
320
321
  "stdout": {
321
322
  "hookSpecificOutput": {
322
323
  "hookEventName": "PreToolUse",
323
- "permissionDecision": "ask"
324
+ "permissionDecision": "deny",
325
+ "permissionDecisionReason": "blocked by monitor"
324
326
  }
325
327
  }
326
328
  }