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.
- package/README.md +50 -0
- package/package.json +1 -1
- package/schema/control-plane/v1/agents.json +228 -0
- package/src/adapters/amp.mjs +19 -0
- package/src/adapters/claude.mjs +28 -0
- package/src/adapters/codex.mjs +78 -2
- package/src/adapters/gemini.mjs +24 -0
- package/src/agent-manifest.mjs +82 -0
- package/src/conformance.mjs +139 -1
- package/src/control-plane.mjs +180 -4
- package/src/json-schema.mjs +4 -4
- package/src/registry.mjs +35 -11
- package/src/runtime.mjs +30 -10
- package/types/adapters/amp.d.mts +12 -0
- package/types/adapters/claude.d.mts +15 -0
- package/types/adapters/codex.d.mts +39 -2
- package/types/adapters/gemini.d.mts +17 -0
- package/types/agent-manifest.d.mts +8 -0
- package/types/conformance.d.mts +7 -1
- package/types/control-plane.d.mts +98 -4
- package/types/json-schema.d.mts +4 -4
- package/types/registry.d.mts +11 -3
- package/types/runtime.d.mts +23 -9
package/src/conformance.mjs
CHANGED
|
@@ -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
|
}
|
package/src/control-plane.mjs
CHANGED
|
@@ -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`
|
|
44
|
-
* `
|
|
45
|
-
* adapter, and the conformance harness names the member
|
|
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
|
|
package/src/json-schema.mjs
CHANGED
|
@@ -291,10 +291,10 @@ export function verdictSchema() {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
/**
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* `
|
|
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 {
|
|
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
|
|
68
|
-
* its
|
|
69
|
-
*
|
|
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]:
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
96
|
-
if (
|
|
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
|
|
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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
/**
|
package/types/adapters/amp.d.mts
CHANGED
|
@@ -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";
|