agent-control-plane-core 0.14.0 → 0.15.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.14.0",
3
+ "version": "0.15.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": {
@@ -414,7 +414,7 @@ export function render(verdict, event, { soleGate = false } = {}) {
414
414
  unblocked_exit_code: 0,
415
415
  enforced,
416
416
  reason: vd.reason,
417
- stdout: nativeBody(hookEventName, vd, event, enforced, soleGate),
417
+ stdout: nativeBody(hookEventName, vd, event, soleGate),
418
418
  });
419
419
  }
420
420
 
@@ -423,15 +423,14 @@ export function render(verdict, event, { soleGate = false } = {}) {
423
423
  * @param {string} hookEventName
424
424
  * @param {Verdict} vd a normalized verdict
425
425
  * @param {ToolCallEvent} event
426
- * @param {boolean} enforced
427
426
  * @param {boolean} soleGate
428
427
  * @returns {Record<string, unknown>}
429
428
  */
430
- function nativeBody(hookEventName, vd, event, enforced, soleGate) {
429
+ function nativeBody(hookEventName, vd, event, soleGate) {
431
430
  if (event.event === EventKind.POST_TOOL)
432
431
  return postToolBody(hookEventName, vd);
433
432
  if (hookEventName === HookEvent.PERMISSION_REQUEST)
434
- return permissionRequestBody(hookEventName, vd, enforced, soleGate);
433
+ return permissionRequestBody(hookEventName, vd, soleGate);
435
434
  return preToolBody(hookEventName, vd, event);
436
435
  }
437
436
 
@@ -475,18 +474,18 @@ function preToolBody(hookEventName, vd, event) {
475
474
  * `updatedInput` voids the body and there is no context key.
476
475
  * @param {string} hookEventName
477
476
  * @param {Verdict} vd a normalized verdict
478
- * @param {boolean} enforced
479
477
  * @param {boolean} soleGate
480
478
  * @returns {Record<string, unknown>}
481
479
  */
482
- function permissionRequestBody(hookEventName, vd, enforced, soleGate) {
480
+ function permissionRequestBody(hookEventName, vd, soleGate) {
483
481
  /** @type {Record<string, unknown>} */
484
482
  const hookSpecificOutput = { hookEventName };
483
+ // Codex substitutes its own text for an empty message
484
+ // (`permission_request_decision`, [X8]); ours says who denied it.
485
485
  if (vd.decision !== Decision.ALLOW) {
486
- const message = enforced ? blockReason(vd.reason) : vd.reason;
487
486
  hookSpecificOutput.decision = {
488
487
  behavior: Decision.DENY,
489
- ...(message === undefined ? {} : { message }),
488
+ message: blockReason(vd.reason),
490
489
  };
491
490
  } else if (soleGate) {
492
491
  hookSpecificOutput.decision = { behavior: Decision.ALLOW };
@@ -62,7 +62,13 @@ function manifestRow(id) {
62
62
  ]),
63
63
  ),
64
64
  pre_tool_filing: adapter.PRE_TOOL_FILING,
65
- pre_tool_response: adapter.PRE_TOOL_RESPONSE,
65
+ pre_tool_response: {
66
+ ...adapter.PRE_TOOL_RESPONSE,
67
+ hook_specific_output_keys: [
68
+ ...adapter.PRE_TOOL_RESPONSE.hook_specific_output_keys,
69
+ ].sort(),
70
+ top_level_keys: [...adapter.PRE_TOOL_RESPONSE.top_level_keys].sort(),
71
+ },
66
72
  };
67
73
  }
68
74
 
@@ -11,9 +11,9 @@ import {
11
11
  isCoverageStatus,
12
12
  lookup,
13
13
  VERDICT_CONTENT_FIELDS,
14
- WHOLE_ARGUMENTS,
15
14
  FILING_RULE_GRAMMAR,
16
15
  isFilingRule,
16
+ isPlainObject,
17
17
  } from "./control-plane.mjs";
18
18
 
19
19
  /**
@@ -808,22 +808,12 @@ function assertPreToolKeysRead(adapter, event, rendered, label, assert) {
808
808
  return true;
809
809
  }
810
810
 
811
- /**
812
- * Whether `value` is an object that is neither `null` nor an array.
813
- * @param {unknown} value
814
- * @returns {value is Record<string, unknown>}
815
- */
816
- function isPlainObject(value) {
817
- return typeof value === "object" && value !== null && !Array.isArray(value);
818
- }
819
-
820
811
  /**
821
812
  * Assert an adapter declares a well-formed `PRE_TOOL_FILING` and
822
- * `PRE_TOOL_RESPONSE`: both frozen, every filing rule `null` or a frozen
823
- * `{tool_name, input}` in the mini-language {@link fileModelCall} reads, and
824
- * each of the response's key lists sorted and duplicate-free beside its
825
- * boolean-or-null fail-open bit. `agents.json` publishes both verbatim, so a shape a
826
- * Python consumer cannot apply is refused here, not discovered there.
813
+ * `PRE_TOOL_RESPONSE`: every filing rule `null` or in the mini-language
814
+ * {@link fileModelCall} reads, and each response key list an array of strings
815
+ * beside a boolean-or-null fail-open bit. `agents.json` publishes both, so a
816
+ * shape a Python consumer cannot apply is refused here, not discovered there.
827
817
  * @param {import("./control-plane.mjs").Adapter} adapter
828
818
  * @param {any} assert
829
819
  */
@@ -831,37 +821,24 @@ function assertPreToolDeclarationsWellFormed(adapter, assert) {
831
821
  const agent = adapter.AGENT;
832
822
  const filing = adapter.PRE_TOOL_FILING;
833
823
  assert.ok(
834
- typeof filing === "object" && filing !== null && Object.isFrozen(filing),
835
- `${agent}: adapter declares no frozen PRE_TOOL_FILING — every adapter needs one: Object.freeze({}) when its host files every model call as asked`,
824
+ isPlainObject(filing),
825
+ `${agent}: adapter declares no PRE_TOOL_FILING — every adapter needs one: {} when its host files every model call as asked`,
836
826
  );
837
- for (const [name, rule] of Object.entries(filing)) {
838
- if (rule === null) continue;
839
- const where = `${agent}: PRE_TOOL_FILING[${JSON.stringify(name)}]`;
840
- assert.ok(Object.isFrozen(rule), `${where} is not frozen`);
827
+ for (const [name, rule] of Object.entries(filing))
841
828
  assert.ok(
842
- isFilingRule(rule),
843
- `${where} is ${JSON.stringify(rule)}, not ${FILING_RULE_GRAMMAR}`,
829
+ rule === null || isFilingRule(rule),
830
+ `${agent}: PRE_TOOL_FILING[${JSON.stringify(name)}] is ${JSON.stringify(rule)}, not null or ${FILING_RULE_GRAMMAR}`,
844
831
  );
845
- if (rule.input !== WHOLE_ARGUMENTS)
846
- assert.ok(Object.isFrozen(rule.input), `${where}.input is not frozen`);
847
- }
848
832
  const response = adapter.PRE_TOOL_RESPONSE;
849
833
  assert.ok(
850
- typeof response === "object" &&
851
- response !== null &&
852
- Object.isFrozen(response),
853
- `${agent}: adapter declares no frozen PRE_TOOL_RESPONSE — every adapter needs one naming the top-level and hookSpecificOutput keys its host reads on the pre-tool event (empty lists when it reads no body)`,
834
+ isPlainObject(response),
835
+ `${agent}: adapter declares no PRE_TOOL_RESPONSE — every adapter needs one naming the top-level and hookSpecificOutput keys its host reads on the pre-tool event (empty lists when it reads no body)`,
854
836
  );
855
837
  for (const level of PRE_TOOL_RESPONSE_LEVELS) {
856
- const keys = /** @type {any} */ (lookup(response, level.keys));
838
+ const keys = lookup(response, level.keys);
857
839
  assert.ok(
858
- Array.isArray(keys) && Object.isFrozen(keys),
859
- `${agent}: PRE_TOOL_RESPONSE.${level.keys} is not a frozen array`,
860
- );
861
- assert.deepEqual(
862
- [...keys],
863
- [...new Set(keys)].sort(),
864
- `${agent}: PRE_TOOL_RESPONSE.${level.keys} must be sorted and duplicate-free`,
840
+ Array.isArray(keys) && keys.every((key) => typeof key === "string"),
841
+ `${agent}: PRE_TOOL_RESPONSE.${level.keys} is not an array of strings`,
865
842
  );
866
843
  assert.ok(
867
844
  [true, false, null].includes(
@@ -496,9 +496,6 @@ export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
496
496
  /** The {@link PreToolFiling} input reference to a call's whole arguments value. */
497
497
  export const WHOLE_ARGUMENTS = "$";
498
498
 
499
- /** The prefix of a {@link PreToolFiling} input reference to one top-level argument. */
500
- export const ARGUMENT_KEY_PREFIX = "$.";
501
-
502
499
  /**
503
500
  * How a host's pre-tool hook files a model's tool call, keyed by MODEL tool
504
501
  * name, the {@link flatToolName} of the call's `namespace` and `name`. Each
@@ -545,7 +542,7 @@ export function flatToolName(namespace, name) {
545
542
 
546
543
  /**
547
544
  * What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
548
- * `BeforeTool`), as two sorted key lists, each empty when the host reads no
545
+ * `BeforeTool`), as two key lists, each empty when the host reads no
549
546
  * body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
550
547
  * `top_level_keys` at the body's top level. Each list has a fail-open bit,
551
548
  * `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
@@ -555,79 +552,41 @@ export function flatToolName(namespace, name) {
555
552
  */
556
553
 
557
554
  /**
558
- * Whether `ref` is a {@link PreToolFiling} input reference: `"$"`, or `"$."`
559
- * followed by one non-empty key with no `.`.
560
- * @param {unknown} ref
561
- * @returns {boolean}
555
+ * Whether `value` is a plain object: not null, not an array.
556
+ * @param {unknown} value
557
+ * @returns {value is Record<string, unknown>}
562
558
  */
563
- function isFilingRef(ref) {
564
- if (ref === WHOLE_ARGUMENTS) return true;
565
- if (typeof ref !== "string" || !ref.startsWith(ARGUMENT_KEY_PREFIX))
566
- return false;
567
- const key = ref.slice(ARGUMENT_KEY_PREFIX.length);
568
- return key !== "" && !key.includes(".");
559
+ export function isPlainObject(value) {
560
+ return typeof value === "object" && value !== null && !Array.isArray(value);
569
561
  }
570
562
 
571
- /**
572
- * Whether `input` is a {@link PreToolFiling} rule's input: `"$"`, or a plain
573
- * object (not an array) whose every value is `"$"` or `"$.<key>"`.
574
- * @param {unknown} input
575
- * @returns {boolean}
576
- */
577
- function isFilingInput(input) {
578
- if (input === WHOLE_ARGUMENTS) return true;
579
- return (
580
- typeof input === "object" &&
581
- input !== null &&
582
- !Array.isArray(input) &&
583
- Object.values(input).every(isFilingRef)
584
- );
585
- }
563
+ /** A {@link PreToolFiling} reference to one top-level argument; group 1 is the key. */
564
+ const ARGUMENT_REF = /^\$\.([^.]+)$/u;
586
565
 
587
566
  /** What a {@link PreToolFiling} rule must be, for error messages. */
588
- export const FILING_RULE_GRAMMAR = `{tool_name: <non-empty string>, input: "${WHOLE_ARGUMENTS}" or an object of "${WHOLE_ARGUMENTS}"/"${ARGUMENT_KEY_PREFIX}<key>" references (<key>: non-empty, no ".")}`;
567
+ export const FILING_RULE_GRAMMAR = `{tool_name: <non-empty string>, input: "${WHOLE_ARGUMENTS}" or an object of "${WHOLE_ARGUMENTS}"/"$.<key>" references (<key>: non-empty, no ".")}`;
589
568
 
590
569
  /**
591
- * Whether `rule` is a non-null {@link PreToolFiling} rule: a plain object with
592
- * exactly a non-empty string `tool_name` and a well-formed `input`. The one
593
- * grammar both {@link fileModelCall} and the conformance harness hold a rule to.
570
+ * Whether `rule` is a non-null {@link PreToolFiling} rule: exactly a non-empty
571
+ * string `tool_name` and an `input` that is `"$"` or an object of `"$"` /
572
+ * `"$.<key>"` references. The one grammar both {@link fileModelCall} and the
573
+ * conformance harness hold a rule to.
594
574
  * @param {unknown} rule
595
575
  * @returns {boolean}
596
576
  */
597
577
  export function isFilingRule(rule) {
598
- if (typeof rule !== "object" || rule === null || Array.isArray(rule))
599
- return false;
600
- const keys = Object.keys(rule).sort();
601
- const { tool_name, input } = /** @type {Record<string, unknown>} */ (rule);
578
+ if (!isPlainObject(rule)) return false;
579
+ const { tool_name, input, ...extra } = rule;
580
+ const isRef = (/** @type {unknown} */ ref) =>
581
+ ref === WHOLE_ARGUMENTS ||
582
+ (typeof ref === "string" && ARGUMENT_REF.test(ref));
602
583
  return (
603
- keys.length === 2 &&
604
- keys[0] === "input" &&
605
- keys[1] === "tool_name" &&
584
+ Object.keys(extra).length === 0 &&
606
585
  typeof tool_name === "string" &&
607
586
  tool_name !== "" &&
608
- isFilingInput(input)
609
- );
610
- }
611
-
612
- /** The kinds of model tool call a {@link PreToolFiling} distinguishes. */
613
- export const MODEL_CALL_KINDS = readonlySet(["function", "freeform"]);
614
-
615
- /**
616
- * Resolve one valid {@link PreToolFiling} input reference against `args`;
617
- * `undefined` when it does not resolve (see {@link PreToolFiling}).
618
- * @param {string} ref
619
- * @param {unknown} args
620
- * @returns {unknown}
621
- */
622
- function resolveArgument(ref, args) {
623
- if (ref === WHOLE_ARGUMENTS) return args;
624
- if (typeof args !== "object" || args === null || Array.isArray(args))
625
- return undefined;
626
- const value = lookup(
627
- /** @type {Record<string, unknown>} */ (args),
628
- ref.slice(ARGUMENT_KEY_PREFIX.length),
587
+ (input === WHOLE_ARGUMENTS ||
588
+ (isPlainObject(input) && Object.values(input).every(isRef)))
629
589
  );
630
- return value === null ? undefined : value;
631
590
  }
632
591
 
633
592
  /**
@@ -635,7 +594,7 @@ function resolveArgument(ref, args) {
635
594
  * with arguments value `args`, per that host's {@link PreToolFiling}; `null`
636
595
  * when the hook files none. The reference reading of the mini-language
637
596
  * `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
638
- * grammar or a kind outside {@link MODEL_CALL_KINDS}.
597
+ * grammar or a kind other than `"function"` or `"freeform"`.
639
598
  * @param {PreToolFiling} filing
640
599
  * @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
641
600
  * @param {string} name the model tool name, a {@link flatToolName}
@@ -643,7 +602,7 @@ function resolveArgument(ref, args) {
643
602
  * @returns {{ tool_name: string, tool_input: unknown } | null}
644
603
  */
645
604
  export function fileModelCall(filing, kind, name, args) {
646
- if (!MODEL_CALL_KINDS.has(kind))
605
+ if (kind !== "function" && kind !== "freeform")
647
606
  throw new Error(
648
607
  `control-plane: model call kind ${JSON.stringify(kind)} is neither "function" nor "freeform"`,
649
608
  );
@@ -657,10 +616,13 @@ export function fileModelCall(filing, kind, name, args) {
657
616
  );
658
617
  if (rule.input === WHOLE_ARGUMENTS)
659
618
  return { tool_name: rule.tool_name, tool_input: args };
660
- const entries = Object.entries(rule.input).map(([key, ref]) => [
661
- key,
662
- resolveArgument(ref, args),
663
- ]);
619
+ const entries = Object.entries(rule.input).map(([key, ref]) => {
620
+ const argument = ARGUMENT_REF.exec(ref)?.[1];
621
+ if (argument === undefined) return [key, args];
622
+ // A missing key, a null value, or non-object arguments all fail to resolve.
623
+ const value = isPlainObject(args) ? lookup(args, argument) : undefined;
624
+ return [key, value === null ? undefined : value];
625
+ });
664
626
  if (entries.some(([, value]) => value === undefined)) return null;
665
627
  // fromEntries defines own properties, so an input key `__proto__` stays a
666
628
  // key, as it is in a Python consumer's dict, instead of hitting the setter.
@@ -135,9 +135,26 @@ export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
135
135
  */
136
136
  export function flatToolName(namespace: string | null | undefined, name: string): string;
137
137
  /**
138
- * Whether `rule` is a non-null {@link PreToolFiling} rule: a plain object with
139
- * exactly a non-empty string `tool_name` and a well-formed `input`. The one
140
- * grammar both {@link fileModelCall} and the conformance harness hold a rule to.
138
+ * What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
139
+ * `BeforeTool`), as two key lists, each empty when the host reads no
140
+ * body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
141
+ * `top_level_keys` at the body's top level. Each list has a fail-open bit,
142
+ * `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
143
+ * other key there voids the whole body and the call RUNS; `false` ⇒ other keys
144
+ * are ignored; `null` ⇒ not established from the host's source.
145
+ * @typedef {Readonly<{ hook_specific_output_keys: readonly string[], unread_key_fails_open: boolean | null, top_level_keys: readonly string[], unread_top_level_key_fails_open: boolean | null }>} PreToolResponse
146
+ */
147
+ /**
148
+ * Whether `value` is a plain object: not null, not an array.
149
+ * @param {unknown} value
150
+ * @returns {value is Record<string, unknown>}
151
+ */
152
+ export function isPlainObject(value: unknown): value is Record<string, unknown>;
153
+ /**
154
+ * Whether `rule` is a non-null {@link PreToolFiling} rule: exactly a non-empty
155
+ * string `tool_name` and an `input` that is `"$"` or an object of `"$"` /
156
+ * `"$.<key>"` references. The one grammar both {@link fileModelCall} and the
157
+ * conformance harness hold a rule to.
141
158
  * @param {unknown} rule
142
159
  * @returns {boolean}
143
160
  */
@@ -147,7 +164,7 @@ export function isFilingRule(rule: unknown): boolean;
147
164
  * with arguments value `args`, per that host's {@link PreToolFiling}; `null`
148
165
  * when the hook files none. The reference reading of the mini-language
149
166
  * `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
150
- * grammar or a kind outside {@link MODEL_CALL_KINDS}.
167
+ * grammar or a kind other than `"function"` or `"freeform"`.
151
168
  * @param {PreToolFiling} filing
152
169
  * @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
153
170
  * @param {string} name the model tool name, a {@link flatToolName}
@@ -582,12 +599,8 @@ export const VERDICT_CONTENT_FIELDS: readonly string[];
582
599
  export const UNRENDERED_ON_UNKNOWN: ReadonlySet<string>;
583
600
  /** The {@link PreToolFiling} input reference to a call's whole arguments value. */
584
601
  export const WHOLE_ARGUMENTS: "$";
585
- /** The prefix of a {@link PreToolFiling} input reference to one top-level argument. */
586
- export const ARGUMENT_KEY_PREFIX: "$.";
587
602
  /** What a {@link PreToolFiling} rule must be, for error messages. */
588
603
  export const FILING_RULE_GRAMMAR: "{tool_name: <non-empty string>, input: \"$\" or an object of \"$\"/\"$.<key>\" references (<key>: non-empty, no \".\")}";
589
- /** The kinds of model tool call a {@link PreToolFiling} distinguishes. */
590
- export const MODEL_CALL_KINDS: ReadonlySet<string>;
591
604
  /**
592
605
  * PROBLEM CLASS — an event marked vetoable on a path where the host can never
593
606
  * veto it, so a deny reads downstream as a block that never happened.
@@ -648,7 +661,7 @@ export const BLOCK_TRANSPORT: Readonly<Record<string, Readonly<{
648
661
  }>>>;
649
662
  /**
650
663
  * What a host reads from an exit-0 answer to its pre-tool event (`PreToolUse`,
651
- * `BeforeTool`), as two sorted key lists, each empty when the host reads no
664
+ * `BeforeTool`), as two key lists, each empty when the host reads no
652
665
  * body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
653
666
  * `top_level_keys` at the body's top level. Each list has a fail-open bit,
654
667
  * `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any