agent-control-plane-core 0.11.2 → 0.13.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.
@@ -11,6 +11,9 @@ import {
11
11
  isCoverageStatus,
12
12
  lookup,
13
13
  VERDICT_CONTENT_FIELDS,
14
+ WHOLE_ARGUMENTS,
15
+ FILING_RULE_GRAMMAR,
16
+ isFilingRule,
14
17
  } from "./control-plane.mjs";
15
18
 
16
19
  /**
@@ -623,6 +626,8 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
623
626
  );
624
627
  const paths = divergences(renders);
625
628
  const where = `'${caseName}' (${adapter.AGENT}), ${field} ${shape}`;
629
+ for (const rendered of renders)
630
+ assertPreToolKeysRead(adapter, event, rendered, where, assert);
626
631
  if (declared?.has(field)) {
627
632
  assert.deepEqual(
628
633
  paths,
@@ -712,6 +717,107 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
712
717
  }
713
718
  }
714
719
 
720
+ /**
721
+ * Rule ⑫, for one render: on the host's native pre-tool event, every key the
722
+ * render puts in `hookSpecificOutput` is one the adapter's `PRE_TOOL_RESPONSE`
723
+ * says the host reads. On Codex any other key voids the whole body and the call
724
+ * RUNS, so an unread key is a silent fail-open, not a harmless extra. Scoped to
725
+ * the native name `NATIVE_EVENT_FOR` gives the pre-tool kind (the event kind
726
+ * when it gives none): Codex's `PermissionRequest` also parses as pre-tool but
727
+ * answers in a different schema this declaration does not describe.
728
+ * @param {import("./control-plane.mjs").Adapter} adapter
729
+ * @param {import("./control-plane.mjs").ToolCallEvent} event
730
+ * @param {import("./control-plane.mjs").NativeResponse} rendered
731
+ * @param {string} label
732
+ * @param {any} assert
733
+ * @returns {boolean} whether the render was on the pre-tool event and checked
734
+ */
735
+ function assertPreToolKeysRead(adapter, event, rendered, label, assert) {
736
+ const preToolName = lookup(
737
+ adapter.NATIVE_EVENT_FOR ?? {},
738
+ EventKind.PRE_TOOL,
739
+ );
740
+ const onPreTool =
741
+ preToolName === undefined
742
+ ? event.event === EventKind.PRE_TOOL
743
+ : event.meta.native_event === preToolName;
744
+ if (!onPreTool) return false;
745
+ // `?.`: a broken render under rule ⑩'s probes may not be an object at all,
746
+ // and that rule, not this one, is what names it.
747
+ const body = /** @type {{ hookSpecificOutput?: unknown } | undefined} */ (
748
+ rendered?.stdout
749
+ );
750
+ const specific =
751
+ body?.hookSpecificOutput === undefined ? {} : body.hookSpecificOutput;
752
+ assert.ok(
753
+ typeof specific === "object" &&
754
+ specific !== null &&
755
+ !Array.isArray(specific),
756
+ `${adapter.AGENT}: a pre-tool render's hookSpecificOutput is ${JSON.stringify(specific)}, not an object: ${label}`,
757
+ );
758
+ const read = new Set(adapter.PRE_TOOL_RESPONSE.hook_specific_output_keys);
759
+ const unread = Object.keys(/** @type {object} */ (specific)).filter(
760
+ (key) => !read.has(key),
761
+ );
762
+ assert.deepEqual(
763
+ unread,
764
+ [],
765
+ `${adapter.AGENT}: a pre-tool render puts ${JSON.stringify(unread)} in hookSpecificOutput, which PRE_TOOL_RESPONSE says this host does not read${adapter.PRE_TOOL_RESPONSE.unread_key_fails_open ? " — the host then voids the body and runs the call" : ""}: ${label}`,
766
+ );
767
+ return true;
768
+ }
769
+
770
+ /**
771
+ * Assert an adapter declares a well-formed `PRE_TOOL_FILING` and
772
+ * `PRE_TOOL_RESPONSE`: both frozen, every filing rule `null` or a frozen
773
+ * `{tool_name, input}` in the mini-language {@link fileModelCall} reads, and the
774
+ * response's key list sorted and duplicate-free beside a boolean-or-null
775
+ * `unread_key_fails_open`. `agents.json` publishes both verbatim, so a shape a
776
+ * Python consumer cannot apply is refused here, not discovered there.
777
+ * @param {import("./control-plane.mjs").Adapter} adapter
778
+ * @param {any} assert
779
+ */
780
+ function assertPreToolDeclarationsWellFormed(adapter, assert) {
781
+ const agent = adapter.AGENT;
782
+ const filing = adapter.PRE_TOOL_FILING;
783
+ assert.ok(
784
+ typeof filing === "object" && filing !== null && Object.isFrozen(filing),
785
+ `${agent}: adapter declares no frozen PRE_TOOL_FILING — every adapter needs one: Object.freeze({}) when its host files every model call as asked`,
786
+ );
787
+ for (const [name, rule] of Object.entries(filing)) {
788
+ if (rule === null) continue;
789
+ const where = `${agent}: PRE_TOOL_FILING[${JSON.stringify(name)}]`;
790
+ assert.ok(Object.isFrozen(rule), `${where} is not frozen`);
791
+ assert.ok(
792
+ isFilingRule(rule),
793
+ `${where} is ${JSON.stringify(rule)}, not ${FILING_RULE_GRAMMAR}`,
794
+ );
795
+ if (rule.input !== WHOLE_ARGUMENTS)
796
+ assert.ok(Object.isFrozen(rule.input), `${where}.input is not frozen`);
797
+ }
798
+ const response = adapter.PRE_TOOL_RESPONSE;
799
+ assert.ok(
800
+ typeof response === "object" &&
801
+ response !== null &&
802
+ Object.isFrozen(response),
803
+ `${agent}: adapter declares no frozen PRE_TOOL_RESPONSE — every adapter needs one naming the hookSpecificOutput keys its host reads on the pre-tool event (an empty list when it reads no body)`,
804
+ );
805
+ const keys = response.hook_specific_output_keys;
806
+ assert.ok(
807
+ Array.isArray(keys) && Object.isFrozen(keys),
808
+ `${agent}: PRE_TOOL_RESPONSE.hook_specific_output_keys is not a frozen array`,
809
+ );
810
+ assert.deepEqual(
811
+ [...keys],
812
+ [...new Set(keys)].sort(),
813
+ `${agent}: PRE_TOOL_RESPONSE.hook_specific_output_keys must be sorted and duplicate-free`,
814
+ );
815
+ assert.ok(
816
+ [true, false, null].includes(response.unread_key_fails_open),
817
+ `${agent}: PRE_TOOL_RESPONSE.unread_key_fails_open must be true, false or null`,
818
+ );
819
+ }
820
+
715
821
  /**
716
822
  * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
717
823
  * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
@@ -962,13 +1068,18 @@ function assertBlockTransport(agent, rendered, label, assert) {
962
1068
  * decide where an `ask` must be escalated to a `deny`, so a wrong one
963
1069
  * either lets a call through or denies every asked call on the hosts that
964
1070
  * do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
1071
+ * 12. pre-tool key honesty: the adapter declares a well-formed
1072
+ * `PRE_TOOL_FILING` and `PRE_TOOL_RESPONSE`, and every render on the host's
1073
+ * native pre-tool event — fixture, rule ⑧ and rule ⑩ probes alike — puts
1074
+ * in `hookSpecificOutput` only keys `PRE_TOOL_RESPONSE` says the host
1075
+ * reads. At least one such render must be checked.
965
1076
  *
966
1077
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
967
1078
  * neutral; it throws on the first mismatch. Returns a summary the caller can
968
1079
  * assert further on.
969
1080
  *
970
1081
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
971
- * @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 }}
1082
+ * @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, preToolKeyChecks: number }}
972
1083
  */
973
1084
  export function runAdapterConformance({ adapter, fixtures, assert }) {
974
1085
  assert.equal(
@@ -987,6 +1098,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
987
1098
  "boolean",
988
1099
  `${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)`,
989
1100
  );
1101
+ assertPreToolDeclarationsWellFormed(adapter, assert);
990
1102
 
991
1103
  /** @type {Set<string>} */
992
1104
  const decisionsSeen = new Set();
@@ -1003,6 +1115,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1003
1115
  let preToolCases = 0;
1004
1116
  let unenforceableDenyChecks = 0;
1005
1117
  let askTierChecks = 0;
1118
+ let preToolKeyChecks = 0;
1006
1119
  let renders = 0;
1007
1120
 
1008
1121
  for (const testCase of fixtures.cases) {
@@ -1057,6 +1170,16 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1057
1170
  spec.native,
1058
1171
  `render mismatch: ${testCase.name} / ${scenario}`,
1059
1172
  );
1173
+ if (
1174
+ assertPreToolKeysRead(
1175
+ adapter,
1176
+ parsed,
1177
+ rendered,
1178
+ `${testCase.name} / ${scenario}`,
1179
+ assert,
1180
+ )
1181
+ )
1182
+ preToolKeyChecks += 1;
1060
1183
  if (rendered.enforced) {
1061
1184
  assert.ok(
1062
1185
  rendered.exit_code !== 0,
@@ -1148,6 +1271,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1148
1271
  // An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
1149
1272
  // no pre-emption channel at all, so its deny and allow are identical by
1150
1273
  // construction. Demanding a distinct signal there would demand a fiction.
1274
+ assertPreToolKeysRead(
1275
+ adapter,
1276
+ unenforceable,
1277
+ deniedRender,
1278
+ `rule ⑧ probe: ${testCase.name}`,
1279
+ assert,
1280
+ );
1151
1281
  if (deniedRender.transport !== IntegrationMode.OBSERVE_ONLY)
1152
1282
  assert.notDeepEqual(
1153
1283
  deniedRender,
@@ -1225,6 +1355,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1225
1355
  `rule ⑪ (ask-tier declaration) ran on ${askTierChecks} of ${preToolCases} pre-tool cases`,
1226
1356
  );
1227
1357
 
1358
+ // A suite with pre-tool cases must reach the native pre-tool event at least
1359
+ // once, or a NATIVE_EVENT_FOR naming the wrong event would skip every render.
1360
+ assert.ok(
1361
+ preToolCases === 0 || preToolKeyChecks > 0,
1362
+ `rule ⑫ (pre-tool key honesty) checked no fixture render on ${adapter.AGENT}'s native pre-tool event — the suite is vacuous`,
1363
+ );
1364
+
1228
1365
  return {
1229
1366
  cases: fixtures.cases.length,
1230
1367
  renders,
@@ -1237,5 +1374,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1237
1374
  coverageClassesChecked,
1238
1375
  unenforceableDenyChecks,
1239
1376
  askTierChecks,
1377
+ preToolKeyChecks,
1240
1378
  };
1241
1379
  }
@@ -40,10 +40,10 @@
40
40
  * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
41
41
  * member breaks every third-party adapter while every event on the wire stays
42
42
  * byte-identical, so it is a package-semver break and leaves
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.
43
+ * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS`, `NATIVE_ASK_TIER`,
44
+ * `PRE_TOOL_FILING` and `PRE_TOOL_RESPONSE` are such members: adding one is a
45
+ * one-line change per adapter, and the conformance harness names the member
46
+ * and the migration rather than throwing from inside itself.
47
47
  */
48
48
 
49
49
  /** Wire identifier for this schema version; bump on a breaking shape change. */
@@ -493,6 +493,180 @@ export function readonlySet(values) {
493
493
  */
494
494
  export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
495
495
 
496
+ /** The {@link PreToolFiling} input reference to a call's whole arguments value. */
497
+ export const WHOLE_ARGUMENTS = "$";
498
+
499
+ /** The prefix of a {@link PreToolFiling} input reference to one top-level argument. */
500
+ export const ARGUMENT_KEY_PREFIX = "$.";
501
+
502
+ /**
503
+ * How a host's pre-tool hook files a model's tool call, keyed by MODEL tool
504
+ * name, the {@link flatToolName} of the call's `namespace` and `name`. Each
505
+ * value is one of:
506
+ * - `null`: no pre-tool hook fires for this call.
507
+ * - `{ tool_name, input }`: the hook fires with `tool_name`, and `tool_input`
508
+ * built from `input` — either `"$"` (the call's whole arguments value), or
509
+ * an object whose every value is `"$"` or `"$.<key>"`. `<key>` is one
510
+ * literal, non-empty top-level key with no `.` in it. The hook files NOTHING
511
+ * when a `"$.<key>"` does not resolve: the arguments are not an object, lack
512
+ * the key, or hold `null` there.
513
+ * A FUNCTION call the map does not list is filed as the model asked: same
514
+ * name, whole arguments. A FREEFORM (custom) call the map does not list files
515
+ * nothing. Of the shipped hosts only Codex makes freeform calls ([X7] in
516
+ * `docs/hook-coverage-matrix.md`).
517
+ * The map describes a call the host DISPATCHES. Codex files nothing for a call
518
+ * it rejects first: a name it has no tool for, a call kind the tool does not
519
+ * take (each listed rule assumes the tool's own kind), or arguments the
520
+ * handler refuses before its hook (`apply_patch` whose patch does not parse).
521
+ * On Codex the ARGUMENTS VALUE is a function call's `arguments` parsed as JSON
522
+ * (an empty or all-whitespace string reads as `{}`, unparseable text stays the
523
+ * raw string), or a freeform call's raw `input` string.
524
+ * Plain JSON by design: `agents.json` publishes it so a consumer with no JS
525
+ * runtime applies it the way {@link fileModelCall} does.
526
+ * @typedef {Readonly<Record<string, null | Readonly<{ tool_name: string, input: string | Readonly<Record<string, string>> }>>>} PreToolFiling
527
+ */
528
+
529
+ /** Codex's default namespace, which it stamps on every un-namespaced call. */
530
+ const DEFAULT_FUNCTION_NAMESPACE = "functions";
531
+
532
+ /**
533
+ * The model tool name a {@link PreToolFiling} is keyed by: `name`, prefixed
534
+ * (no separator) with `namespace` unless that is absent, empty, or Codex's
535
+ * default `functions` namespace (`flat_tool_name`, `is_default_namespace`;
536
+ * [X7] in `docs/hook-coverage-matrix.md`).
537
+ * @param {string | null | undefined} namespace
538
+ * @param {string} name
539
+ * @returns {string}
540
+ */
541
+ export function flatToolName(namespace, name) {
542
+ if (!namespace || namespace === DEFAULT_FUNCTION_NAMESPACE) return name;
543
+ return namespace + name;
544
+ }
545
+
546
+ /**
547
+ * What a host reads from the `hookSpecificOutput` of an exit-0 answer to its
548
+ * pre-tool event (`PreToolUse`, `BeforeTool`). `hook_specific_output_keys` is
549
+ * every key it reads there, sorted (empty when it reads no body).
550
+ * `unread_key_fails_open`: `true` ⇒ any other key there voids the whole body
551
+ * and the call RUNS; `false` ⇒ other keys are ignored; `null` ⇒ not
552
+ * established from the host's source. Top-level keys are outside this record;
553
+ * Codex parses those `deny_unknown_fields` too ([X8]).
554
+ * @typedef {Readonly<{ hook_specific_output_keys: readonly string[], unread_key_fails_open: boolean | null }>} PreToolResponse
555
+ */
556
+
557
+ /**
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}
562
+ */
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(".");
569
+ }
570
+
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
+ }
586
+
587
+ /** 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 ".")}`;
589
+
590
+ /**
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.
594
+ * @param {unknown} rule
595
+ * @returns {boolean}
596
+ */
597
+ 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);
602
+ return (
603
+ keys.length === 2 &&
604
+ keys[0] === "input" &&
605
+ keys[1] === "tool_name" &&
606
+ typeof tool_name === "string" &&
607
+ 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),
629
+ );
630
+ return value === null ? undefined : value;
631
+ }
632
+
633
+ /**
634
+ * The call a host's pre-tool hook files for a model call of `kind` to `name`
635
+ * with arguments value `args`, per that host's {@link PreToolFiling}; `null`
636
+ * when the hook files none. The reference reading of the mini-language
637
+ * `agents.json` publishes as `pre_tool_filing`. Throws on a rule outside that
638
+ * grammar or a kind outside {@link MODEL_CALL_KINDS}.
639
+ * @param {PreToolFiling} filing
640
+ * @param {"function" | "freeform"} kind whether the model made a function call or a freeform (custom) call
641
+ * @param {string} name the model tool name, a {@link flatToolName}
642
+ * @param {unknown} args the call's arguments value
643
+ * @returns {{ tool_name: string, tool_input: unknown } | null}
644
+ */
645
+ export function fileModelCall(filing, kind, name, args) {
646
+ if (!MODEL_CALL_KINDS.has(kind))
647
+ throw new Error(
648
+ `control-plane: model call kind ${JSON.stringify(kind)} is neither "function" nor "freeform"`,
649
+ );
650
+ if (!Object.hasOwn(filing, name))
651
+ return kind === "function" ? { tool_name: name, tool_input: args } : null;
652
+ const rule = filing[name];
653
+ if (rule === null) return null;
654
+ if (!isFilingRule(rule))
655
+ throw new Error(
656
+ `control-plane: filing rule for ${JSON.stringify(name)} is ${JSON.stringify(rule)}, not ${FILING_RULE_GRAMMAR}`,
657
+ );
658
+ if (rule.input === WHOLE_ARGUMENTS)
659
+ 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
+ ]);
664
+ if (entries.some(([, value]) => value === undefined)) return null;
665
+ // fromEntries defines own properties, so an input key `__proto__` stays a
666
+ // key, as it is in a Python consumer's dict, instead of hitting the setter.
667
+ return { tool_name: rule.tool_name, tool_input: Object.fromEntries(entries) };
668
+ }
669
+
496
670
  /**
497
671
  * PROBLEM CLASS — an event marked vetoable on a path where the host can never
498
672
  * veto it, so a deny reads downstream as a block that never happened.
@@ -530,6 +704,8 @@ export const VETO_IMPOSSIBLE = Object.freeze([
530
704
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
531
705
  * @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`.
532
706
  * @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.
707
+ * @property {PreToolFiling} PRE_TOOL_FILING how this host's pre-tool hook files a model tool call, for a consumer matching the model's calls against the hook's. `{}` when no rename is modelled.
708
+ * @property {PreToolResponse} PRE_TOOL_RESPONSE the `hookSpecificOutput` keys this host reads on its pre-tool event, and whether an unread key there runs the call. The conformance harness holds every pre-tool render to the key list.
533
709
  * @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.
534
710
  */
535
711
 
@@ -291,10 +291,10 @@ export function verdictSchema() {
291
291
  }
292
292
 
293
293
  /**
294
- * Every generated file, keyed by its package-relative path — the SSOT the
295
- * generator writes and the freshness test reads, so neither carries its own
296
- * list of filenames. The two `.schema.json` entries are JSON Schema documents;
297
- * `tool-input-keys.json` is the data map {@link TOOL_INPUT_KEYS_FILE} describes.
294
+ * The contract's generated files, keyed by package-relative path. The two
295
+ * `.schema.json` entries are JSON Schema documents; `tool-input-keys.json` is
296
+ * the data map {@link TOOL_INPUT_KEYS_FILE} describes. `agent-manifest.mjs`'s
297
+ * `generatedDocuments` adds the per-agent manifest to these.
298
298
  * @returns {Record<string, Record<string, unknown>>}
299
299
  */
300
300
  export function jsonSchemaDocuments() {
package/src/registry.mjs CHANGED
@@ -16,7 +16,11 @@
16
16
 
17
17
  import { lookup } from "./control-plane.mjs";
18
18
  import { claudeAdapter } from "./adapters/claude.mjs";
19
- import { codexAdapter, createCodexAdapter } from "./adapters/codex.mjs";
19
+ import {
20
+ codexAdapter,
21
+ createCodexAdapter,
22
+ OPTION_NAMES as CODEX_OPTION_NAMES,
23
+ } from "./adapters/codex.mjs";
20
24
  import { ampAdapter } from "./adapters/amp.mjs";
21
25
  import { geminiAdapter } from "./adapters/gemini.mjs";
22
26
 
@@ -64,23 +68,43 @@ export function assertRegistryConsistent(adapters) {
64
68
  assertRegistryConsistent(ADAPTERS);
65
69
 
66
70
  /**
67
- * The adapters that take launcher options, keyed by agent id. Only Codex does:
68
- * its hook stdin carries no version, so the launcher pins one.
69
- * @type {Readonly<Record<string, (options: any) => Adapter>>}
71
+ * The adapters that take launcher options, keyed by agent id: the option names
72
+ * each reads, and its factory. Only Codex takes one: its hook stdin carries no
73
+ * version, so the launcher pins one.
74
+ * @type {Readonly<Record<string, Readonly<{ options: readonly string[], create: (options: any) => Adapter }>>>}
70
75
  */
71
76
  const CONFIGURABLE = Object.freeze({
72
- [codexAdapter.AGENT]: createCodexAdapter,
77
+ [codexAdapter.AGENT]: Object.freeze({
78
+ options: CODEX_OPTION_NAMES,
79
+ create: createCodexAdapter,
80
+ }),
73
81
  });
74
82
 
83
+ /**
84
+ * The launcher option names each agent's adapter reads, for EVERY agent id (an
85
+ * empty list when it reads none). A launcher builds the options for any agent
86
+ * from this instead of naming the agents that take a version.
87
+ * @type {Readonly<Record<string, readonly string[]>>}
88
+ */
89
+ export const LAUNCHER_OPTIONS = Object.freeze(
90
+ Object.fromEntries(
91
+ AGENT_IDS.map((id) => [
92
+ id,
93
+ lookup(CONFIGURABLE, id)?.options ?? Object.freeze([]),
94
+ ]),
95
+ ),
96
+ );
97
+
75
98
  /**
76
99
  * Resolve an agent id to its adapter. Throws (fail loud) on an unknown id — a
77
100
  * selection signal that names no shipped adapter is a misconfiguration, not a
78
101
  * reason to silently fall back to a default agent (which would run the wrong
79
102
  * translator against the payloads and mis-render every verdict).
80
103
  *
81
- * `options` are the launcher's settings for that adapter (Codex:
82
- * `{ version }`). Passing options to an adapter that takes none throws, so a
83
- * setting the launcher believes it applied cannot be dropped in silence.
104
+ * `options` are the launcher's settings for that adapter, named by its
105
+ * {@link LAUNCHER_OPTIONS} row. Options for an adapter that takes none throw
106
+ * here, and an option a configurable adapter does not read throws in its
107
+ * factory, so a setting the launcher believes it applied is never dropped.
84
108
  * @param {string} id agent id (a {@link AGENT_IDS} member)
85
109
  * @param {Record<string, unknown>} [options]
86
110
  * @returns {Adapter}
@@ -92,10 +116,10 @@ export function adapterFor(id, options) {
92
116
  `registry: no adapter for agent id ${JSON.stringify(id)} (known: ${AGENT_IDS.join(", ")})`,
93
117
  );
94
118
  if (options === undefined) return adapter;
95
- const configure = lookup(CONFIGURABLE, id);
96
- if (configure === undefined)
119
+ const configurable = lookup(CONFIGURABLE, id);
120
+ if (configurable === undefined)
97
121
  throw new Error(
98
122
  `registry: adapter ${JSON.stringify(id)} takes no options, got ${JSON.stringify(options)}`,
99
123
  );
100
- return configure(options);
124
+ return configurable.create(options);
101
125
  }
package/src/runtime.mjs CHANGED
@@ -20,6 +20,12 @@ import {
20
20
  sanitizeVerdict,
21
21
  } from "./control-plane.mjs";
22
22
 
23
+ /**
24
+ * A guardrail judge: one normalized event in, its verdict out, synchronously or
25
+ * as a promise. Its answer is untrusted either way (see {@link renderHookResponse}).
26
+ * @typedef {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict | Promise<import("./control-plane.mjs").Verdict>} Judge
27
+ */
28
+
23
29
  /**
24
30
  * Read all of stdin to a string.
25
31
  * @returns {Promise<string>}
@@ -77,16 +83,18 @@ export function demoJudge(event) {
77
83
  * greppable trace. Every host tolerates hook stderr: claude/codex surface it
78
84
  * only in verbose/debug on a 0 exit, amp forwards the helper's stderr, and
79
85
  * gemini reads stderr as a reason only on exit 2.
80
- * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
81
- * so the guardrail is supplied here rather than by forking this file. Whatever
82
- * it returns is treated as untrusted (see the clamp below).
86
+ * `judge` is the seam a real deployment fills, so the guardrail is supplied here
87
+ * rather than by forking this file ({@link demoJudge} is a stand-in). It may
88
+ * return a promise (a judge that asks a remote monitor); a rejection takes the
89
+ * same path as a throw. Whatever it returns is treated as untrusted (see the
90
+ * clamp below).
83
91
  * @param {import("./control-plane.mjs").Adapter} adapter
84
92
  * @param {string} rawInput
85
93
  * @param {import("./control-plane.mjs").NativeResponse} onFailure
86
- * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
87
- * @returns {import("./control-plane.mjs").NativeResponse}
94
+ * @param {Judge} [judge]
95
+ * @returns {Promise<import("./control-plane.mjs").NativeResponse>}
88
96
  */
89
- export function renderHookResponse(
97
+ export async function renderHookResponse(
90
98
  adapter,
91
99
  rawInput,
92
100
  onFailure,
@@ -107,7 +115,7 @@ export function renderHookResponse(
107
115
  /** @type {unknown} */
108
116
  let answer;
109
117
  try {
110
- answer = judge(event);
118
+ answer = await judge(event);
111
119
  } catch (err) {
112
120
  // No `decision`, so the clamp below turns this into an `ask`.
113
121
  answer = { reason: `hook judge threw: ${describe(err).split("\n")[0]}` };
@@ -227,9 +235,11 @@ export function failurePosture(agent, env = process.env) {
227
235
  * {@link renderHookResponse} with that host's {@link failurePosture}, and
228
236
  * {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
229
237
  * failure response also answers a stdin read error and, when
230
- * {@link hookDeadlineMs} is set, a hook still running at that deadline.
238
+ * {@link hookDeadlineMs} is set, a hook still running at that deadline. It also
239
+ * answers a judge promise that never settles once nothing else keeps node alive,
240
+ * which would otherwise end the process with no response.
231
241
  * @param {import("./control-plane.mjs").Adapter} adapter
232
- * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
242
+ * @param {Judge} [judge]
233
243
  * @returns {Promise<never>}
234
244
  */
235
245
  export async function runHookEntry(adapter, judge = demoJudge) {
@@ -258,7 +268,17 @@ export async function runHookEntry(adapter, judge = demoJudge) {
258
268
  } catch (err) {
259
269
  return fail("hook stdin error", err);
260
270
  }
261
- return emit(renderHookResponse(adapter, raw, onFailure, judge));
271
+ // An unsettled judge promise with no pending I/O drains the event loop, and
272
+ // node then exits with no response (13 under top-level await, 0 otherwise),
273
+ // which a fail-open host runs. Registered after stdin is read, so only the
274
+ // judge can be what never answered.
275
+ process.once("beforeExit", () =>
276
+ fail(
277
+ "hook judge never answered",
278
+ new Error("the judge's promise never settled"),
279
+ ),
280
+ );
281
+ return emit(await renderHookResponse(adapter, raw, onFailure, judge));
262
282
  }
263
283
 
264
284
  /**
@@ -107,6 +107,18 @@ export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
107
107
  * only honest because the tier is real.
108
108
  */
109
109
  export const NATIVE_ASK_TIER: true;
110
+ /**
111
+ * No rename is modelled: no source shows Amp filing a call under a
112
+ * name or input other than the model's.
113
+ * @type {import("../control-plane.mjs").PreToolFiling}
114
+ */
115
+ export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
116
+ /**
117
+ * Amp reads only the hook's exit code and no body, so no key in one can change
118
+ * the outcome.
119
+ * @type {import("../control-plane.mjs").PreToolResponse}
120
+ */
121
+ export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
110
122
  /** @type {import("../control-plane.mjs").Adapter} */
111
123
  export const ampAdapter: import("../control-plane.mjs").Adapter;
112
124
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -75,6 +75,21 @@ export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
75
75
  * host did, pending an answer.
76
76
  */
77
77
  export const NATIVE_ASK_TIER: true;
78
+ /**
79
+ * No rename is modelled: no source shows Claude Code filing a call under a
80
+ * name or input other than the model's.
81
+ * @type {import("../control-plane.mjs").PreToolFiling}
82
+ */
83
+ export const PRE_TOOL_FILING: import("../control-plane.mjs").PreToolFiling;
84
+ /**
85
+ * The `hookSpecificOutput` keys Claude Code's hooks reference documents for
86
+ * `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether an unread key
87
+ * fails open is NOT established: the reference says an exit-0 body that fails
88
+ * schema validation lets the call go ahead, but not whether an extra key fails
89
+ * it, and the host's source is not public.
90
+ * @type {import("../control-plane.mjs").PreToolResponse}
91
+ */
92
+ export const PRE_TOOL_RESPONSE: import("../control-plane.mjs").PreToolResponse;
78
93
  /** Claude Code native hook event names (the `hook_event_name` field). */
79
94
  export const HookEvent: Readonly<{
80
95
  PRE_TOOL_USE: "PreToolUse";