agent-control-plane-core 0.12.0 → 0.14.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 +42 -3
- package/package.json +1 -1
- package/schema/control-plane/v1/agents.json +86 -1
- package/src/adapters/amp.mjs +21 -0
- package/src/adapters/claude.mjs +38 -0
- package/src/adapters/codex.mjs +164 -53
- package/src/adapters/gemini.mjs +36 -0
- package/src/agent-manifest.mjs +9 -1
- package/src/conformance.mjs +204 -4
- package/src/control-plane.mjs +183 -5
- package/src/fixtures/codex.json +11 -5
- package/types/adapters/amp.d.mts +12 -0
- package/types/adapters/claude.d.mts +15 -0
- package/types/adapters/codex.d.mts +55 -12
- package/types/adapters/gemini.d.mts +19 -0
- package/types/conformance.d.mts +7 -1
- package/types/control-plane.d.mts +104 -6
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
|
/**
|
|
@@ -404,14 +407,20 @@ function assertRowReads(agent, kind, row, assert) {
|
|
|
404
407
|
* @param {Set<string>} seen fields observed to REACH a wire
|
|
405
408
|
*/
|
|
406
409
|
function assertEveryKindRenders(adapter, byKind, assert, seen) {
|
|
407
|
-
for (const kind of Object.values(EventKind))
|
|
410
|
+
for (const kind of Object.values(EventKind)) {
|
|
411
|
+
// A parsed event under another native name is not the event the row
|
|
412
|
+
// describes, so the probe synthesizes the native one instead.
|
|
413
|
+
const parsed = lookup(byKind, kind);
|
|
408
414
|
assertContentChannels(
|
|
409
415
|
adapter,
|
|
410
|
-
|
|
416
|
+
parsed !== undefined && onKindsNativeEvent(adapter, parsed)
|
|
417
|
+
? parsed
|
|
418
|
+
: coherentEvent(adapter, byKind, kind),
|
|
411
419
|
`every-kind probe '${kind}'`,
|
|
412
420
|
assert,
|
|
413
421
|
seen,
|
|
414
422
|
);
|
|
423
|
+
}
|
|
415
424
|
}
|
|
416
425
|
|
|
417
426
|
/**
|
|
@@ -575,6 +584,10 @@ function assertAskTier(adapter, event, vetoable, caseName, assert) {
|
|
|
575
584
|
* (kind, decision) would state that, and is not worth its weight while the only
|
|
576
585
|
* field a blocked call can still want is `additional_context`.
|
|
577
586
|
*
|
|
587
|
+
* SCOPE — a row describes its kind's `NATIVE_EVENT_FOR` event, so an event of
|
|
588
|
+
* that kind under another native name (Codex's `PermissionRequest`, which
|
|
589
|
+
* parses as `pre_tool` and carries no content) is not held to it.
|
|
590
|
+
*
|
|
578
591
|
* Reaching the wire is checked as SERIALIZATION, so the rule proves a field is
|
|
579
592
|
* carried, not that the key it is carried in is one the host reads. Nothing
|
|
580
593
|
* machine-readable describes a host's schema, so that half stays with the
|
|
@@ -586,6 +599,7 @@ function assertAskTier(adapter, event, vetoable, caseName, assert) {
|
|
|
586
599
|
* @param {Set<string>} seen fields observed to REACH a wire, for the non-vacuity check
|
|
587
600
|
*/
|
|
588
601
|
function assertContentChannels(adapter, event, caseName, assert, seen) {
|
|
602
|
+
if (!onKindsNativeEvent(adapter, event)) return;
|
|
589
603
|
const declared = lookup(adapter.UNRENDERED_FIELDS, event.event);
|
|
590
604
|
|
|
591
605
|
// Every value of a field is rendered and the renders compared to EACH OTHER.
|
|
@@ -623,6 +637,8 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
|
|
|
623
637
|
);
|
|
624
638
|
const paths = divergences(renders);
|
|
625
639
|
const where = `'${caseName}' (${adapter.AGENT}), ${field} ${shape}`;
|
|
640
|
+
for (const rendered of renders)
|
|
641
|
+
assertPreToolKeysRead(adapter, event, rendered, where, assert);
|
|
626
642
|
if (declared?.has(field)) {
|
|
627
643
|
assert.deepEqual(
|
|
628
644
|
paths,
|
|
@@ -712,6 +728,150 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
|
|
|
712
728
|
}
|
|
713
729
|
}
|
|
714
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Whether `event` arrived as the native event `NATIVE_EVENT_FOR` names for its
|
|
733
|
+
* kind (always, when the adapter names none for that kind) — the event a
|
|
734
|
+
* kind-keyed declaration describes.
|
|
735
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
736
|
+
* @param {import("./control-plane.mjs").ToolCallEvent} event
|
|
737
|
+
* @returns {boolean}
|
|
738
|
+
*/
|
|
739
|
+
function onKindsNativeEvent(adapter, event) {
|
|
740
|
+
const native = lookup(adapter.NATIVE_EVENT_FOR ?? {}, event.event);
|
|
741
|
+
return native === undefined || event.meta.native_event === native;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The two levels of a pre-tool body `PRE_TOOL_RESPONSE` declares, each as the
|
|
746
|
+
* key-list member, its fail-open member, and where in the body it applies.
|
|
747
|
+
*/
|
|
748
|
+
const PRE_TOOL_RESPONSE_LEVELS = Object.freeze([
|
|
749
|
+
Object.freeze({
|
|
750
|
+
keys: "top_level_keys",
|
|
751
|
+
failsOpen: "unread_top_level_key_fails_open",
|
|
752
|
+
where: "the body",
|
|
753
|
+
/** @param {any} body */
|
|
754
|
+
pick: (body) => body,
|
|
755
|
+
}),
|
|
756
|
+
Object.freeze({
|
|
757
|
+
keys: "hook_specific_output_keys",
|
|
758
|
+
failsOpen: "unread_key_fails_open",
|
|
759
|
+
where: "hookSpecificOutput",
|
|
760
|
+
/** @param {any} body */
|
|
761
|
+
pick: (body) => body?.hookSpecificOutput,
|
|
762
|
+
}),
|
|
763
|
+
]);
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Rule ⑫, for one render: on the host's native pre-tool event, every key the
|
|
767
|
+
* render puts at the body's top level or in `hookSpecificOutput` is one the
|
|
768
|
+
* adapter's `PRE_TOOL_RESPONSE` says the host reads there. On Codex any other
|
|
769
|
+
* key, at either level, voids the whole body and the call RUNS, so an unread
|
|
770
|
+
* key is a silent fail-open, not a harmless extra. Scoped to the native name
|
|
771
|
+
* `NATIVE_EVENT_FOR` gives the pre-tool kind (any pre-tool event when it gives
|
|
772
|
+
* none): Codex's `PermissionRequest` also parses as pre-tool but answers in a
|
|
773
|
+
* different schema this declaration does not describe.
|
|
774
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
775
|
+
* @param {import("./control-plane.mjs").ToolCallEvent} event
|
|
776
|
+
* @param {import("./control-plane.mjs").NativeResponse} rendered
|
|
777
|
+
* @param {string} label
|
|
778
|
+
* @param {any} assert
|
|
779
|
+
* @returns {boolean} whether the render was on the pre-tool event and checked
|
|
780
|
+
*/
|
|
781
|
+
function assertPreToolKeysRead(adapter, event, rendered, label, assert) {
|
|
782
|
+
if (event.event !== EventKind.PRE_TOOL || !onKindsNativeEvent(adapter, event))
|
|
783
|
+
return false;
|
|
784
|
+
// `?.`: a broken render under rule ⑩'s probes may not be an object at all,
|
|
785
|
+
// and that rule, not this one, is what names it. No body reads as an empty one.
|
|
786
|
+
const body = rendered?.stdout;
|
|
787
|
+
for (const level of PRE_TOOL_RESPONSE_LEVELS) {
|
|
788
|
+
const found = level.pick(body);
|
|
789
|
+
const part = found === undefined ? {} : found;
|
|
790
|
+
assert.ok(
|
|
791
|
+
isPlainObject(part),
|
|
792
|
+
`${adapter.AGENT}: in a pre-tool render, ${level.where} is ${JSON.stringify(part)}, not an object: ${label}`,
|
|
793
|
+
);
|
|
794
|
+
const read = new Set(
|
|
795
|
+
/** @type {readonly string[]} */ (
|
|
796
|
+
lookup(adapter.PRE_TOOL_RESPONSE, level.keys)
|
|
797
|
+
),
|
|
798
|
+
);
|
|
799
|
+
const unread = Object.keys(/** @type {object} */ (part)).filter(
|
|
800
|
+
(key) => !read.has(key),
|
|
801
|
+
);
|
|
802
|
+
assert.deepEqual(
|
|
803
|
+
unread,
|
|
804
|
+
[],
|
|
805
|
+
`${adapter.AGENT}: a pre-tool render puts ${JSON.stringify(unread)} in ${level.where}, which PRE_TOOL_RESPONSE.${level.keys} says this host does not read${lookup(adapter.PRE_TOOL_RESPONSE, level.failsOpen) ? " — the host then voids the body and runs the call" : ""}: ${label}`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
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
|
+
/**
|
|
821
|
+
* 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.
|
|
827
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
828
|
+
* @param {any} assert
|
|
829
|
+
*/
|
|
830
|
+
function assertPreToolDeclarationsWellFormed(adapter, assert) {
|
|
831
|
+
const agent = adapter.AGENT;
|
|
832
|
+
const filing = adapter.PRE_TOOL_FILING;
|
|
833
|
+
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`,
|
|
836
|
+
);
|
|
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`);
|
|
841
|
+
assert.ok(
|
|
842
|
+
isFilingRule(rule),
|
|
843
|
+
`${where} is ${JSON.stringify(rule)}, not ${FILING_RULE_GRAMMAR}`,
|
|
844
|
+
);
|
|
845
|
+
if (rule.input !== WHOLE_ARGUMENTS)
|
|
846
|
+
assert.ok(Object.isFrozen(rule.input), `${where}.input is not frozen`);
|
|
847
|
+
}
|
|
848
|
+
const response = adapter.PRE_TOOL_RESPONSE;
|
|
849
|
+
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)`,
|
|
854
|
+
);
|
|
855
|
+
for (const level of PRE_TOOL_RESPONSE_LEVELS) {
|
|
856
|
+
const keys = /** @type {any} */ (lookup(response, level.keys));
|
|
857
|
+
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`,
|
|
865
|
+
);
|
|
866
|
+
assert.ok(
|
|
867
|
+
[true, false, null].includes(
|
|
868
|
+
/** @type {any} */ (lookup(response, level.failsOpen)),
|
|
869
|
+
),
|
|
870
|
+
`${agent}: PRE_TOOL_RESPONSE.${level.failsOpen} must be true, false or null`,
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
715
875
|
/**
|
|
716
876
|
* Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
|
|
717
877
|
* hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
|
|
@@ -962,13 +1122,18 @@ function assertBlockTransport(agent, rendered, label, assert) {
|
|
|
962
1122
|
* decide where an `ask` must be escalated to a `deny`, so a wrong one
|
|
963
1123
|
* either lets a call through or denies every asked call on the hosts that
|
|
964
1124
|
* do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
|
|
1125
|
+
* 12. pre-tool key honesty: the adapter declares a well-formed
|
|
1126
|
+
* `PRE_TOOL_FILING` and `PRE_TOOL_RESPONSE`, and every render on the host's
|
|
1127
|
+
* native pre-tool event — fixture, rule ⑧ and rule ⑩ probes alike — puts
|
|
1128
|
+
* in `hookSpecificOutput` only keys `PRE_TOOL_RESPONSE` says the host
|
|
1129
|
+
* reads. At least one such render must be checked.
|
|
965
1130
|
*
|
|
966
1131
|
* `assert` is injected (node:assert/strict) so the harness stays test-framework
|
|
967
1132
|
* neutral; it throws on the first mismatch. Returns a summary the caller can
|
|
968
1133
|
* assert further on.
|
|
969
1134
|
*
|
|
970
1135
|
* @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 }}
|
|
1136
|
+
* @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
1137
|
*/
|
|
973
1138
|
export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
974
1139
|
assert.equal(
|
|
@@ -987,6 +1152,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
987
1152
|
"boolean",
|
|
988
1153
|
`${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
1154
|
);
|
|
1155
|
+
assertPreToolDeclarationsWellFormed(adapter, assert);
|
|
990
1156
|
|
|
991
1157
|
/** @type {Set<string>} */
|
|
992
1158
|
const decisionsSeen = new Set();
|
|
@@ -1003,6 +1169,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1003
1169
|
let preToolCases = 0;
|
|
1004
1170
|
let unenforceableDenyChecks = 0;
|
|
1005
1171
|
let askTierChecks = 0;
|
|
1172
|
+
let preToolKeyChecks = 0;
|
|
1006
1173
|
let renders = 0;
|
|
1007
1174
|
|
|
1008
1175
|
for (const testCase of fixtures.cases) {
|
|
@@ -1057,6 +1224,16 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1057
1224
|
spec.native,
|
|
1058
1225
|
`render mismatch: ${testCase.name} / ${scenario}`,
|
|
1059
1226
|
);
|
|
1227
|
+
if (
|
|
1228
|
+
assertPreToolKeysRead(
|
|
1229
|
+
adapter,
|
|
1230
|
+
parsed,
|
|
1231
|
+
rendered,
|
|
1232
|
+
`${testCase.name} / ${scenario}`,
|
|
1233
|
+
assert,
|
|
1234
|
+
)
|
|
1235
|
+
)
|
|
1236
|
+
preToolKeyChecks += 1;
|
|
1060
1237
|
if (rendered.enforced) {
|
|
1061
1238
|
assert.ok(
|
|
1062
1239
|
rendered.exit_code !== 0,
|
|
@@ -1114,7 +1291,15 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1114
1291
|
renders += 1;
|
|
1115
1292
|
}
|
|
1116
1293
|
|
|
1117
|
-
|
|
1294
|
+
// The kind's native event stands for it in the every-kind sweep when one
|
|
1295
|
+
// arrives; another native name is only a seed to synthesize one from.
|
|
1296
|
+
const held = lookup(parsedByKind, parsed.event);
|
|
1297
|
+
if (
|
|
1298
|
+
held === undefined ||
|
|
1299
|
+
(!onKindsNativeEvent(adapter, held) &&
|
|
1300
|
+
onKindsNativeEvent(adapter, parsed))
|
|
1301
|
+
)
|
|
1302
|
+
parsedByKind[parsed.event] = parsed;
|
|
1118
1303
|
assertContentChannels(
|
|
1119
1304
|
adapter,
|
|
1120
1305
|
parsed,
|
|
@@ -1148,6 +1333,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1148
1333
|
// An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
|
|
1149
1334
|
// no pre-emption channel at all, so its deny and allow are identical by
|
|
1150
1335
|
// construction. Demanding a distinct signal there would demand a fiction.
|
|
1336
|
+
assertPreToolKeysRead(
|
|
1337
|
+
adapter,
|
|
1338
|
+
unenforceable,
|
|
1339
|
+
deniedRender,
|
|
1340
|
+
`rule ⑧ probe: ${testCase.name}`,
|
|
1341
|
+
assert,
|
|
1342
|
+
);
|
|
1151
1343
|
if (deniedRender.transport !== IntegrationMode.OBSERVE_ONLY)
|
|
1152
1344
|
assert.notDeepEqual(
|
|
1153
1345
|
deniedRender,
|
|
@@ -1225,6 +1417,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1225
1417
|
`rule ⑪ (ask-tier declaration) ran on ${askTierChecks} of ${preToolCases} pre-tool cases`,
|
|
1226
1418
|
);
|
|
1227
1419
|
|
|
1420
|
+
// A suite with pre-tool cases must reach the native pre-tool event at least
|
|
1421
|
+
// once, or a NATIVE_EVENT_FOR naming the wrong event would skip every render.
|
|
1422
|
+
assert.ok(
|
|
1423
|
+
preToolCases === 0 || preToolKeyChecks > 0,
|
|
1424
|
+
`rule ⑫ (pre-tool key honesty) checked no fixture render on ${adapter.AGENT}'s native pre-tool event — the suite is vacuous`,
|
|
1425
|
+
);
|
|
1426
|
+
|
|
1228
1427
|
return {
|
|
1229
1428
|
cases: fixtures.cases.length,
|
|
1230
1429
|
renders,
|
|
@@ -1237,5 +1436,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
1237
1436
|
coverageClassesChecked,
|
|
1238
1437
|
unenforceableDenyChecks,
|
|
1239
1438
|
askTierChecks,
|
|
1439
|
+
preToolKeyChecks,
|
|
1240
1440
|
};
|
|
1241
1441
|
}
|
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 an exit-0 answer to its pre-tool event (`PreToolUse`,
|
|
548
|
+
* `BeforeTool`), as two sorted key lists, each empty when the host reads no
|
|
549
|
+
* body: `hook_specific_output_keys` inside `hookSpecificOutput`, and
|
|
550
|
+
* `top_level_keys` at the body's top level. Each list has a fail-open bit,
|
|
551
|
+
* `unread_key_fails_open` and `unread_top_level_key_fails_open`: `true` ⇒ any
|
|
552
|
+
* other key there voids the whole body and the call RUNS; `false` ⇒ other keys
|
|
553
|
+
* are ignored; `null` ⇒ not established from the host's source.
|
|
554
|
+
* @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
|
|
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,7 +704,9 @@ 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.
|
|
533
|
-
* @property {
|
|
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 top-level and `hookSpecificOutput` keys this host reads on its pre-tool event, each list with whether an unread key there runs the call. The conformance harness holds every pre-tool render to both lists.
|
|
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 on the kind's `NATIVE_EVENT_FOR` event only; another native event of that kind (Codex's `PermissionRequest`) may drop more. 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
|
|
|
536
712
|
/**
|
|
@@ -874,6 +1050,8 @@ export const DEFAULT_DENY_REASON = "blocked by monitor";
|
|
|
874
1050
|
* exit. Every host here discards (or never gets) the stdout body on exit 2.
|
|
875
1051
|
* - `reason_required`: the host drops a block whose reason is empty and runs
|
|
876
1052
|
* the tool, so every body field that carries the reason must be non-empty.
|
|
1053
|
+
* Unlike the other two fields, this holds for ANY block body the host parses,
|
|
1054
|
+
* enforced or advisory: Codex voids an empty-reason deny either way.
|
|
877
1055
|
*
|
|
878
1056
|
* {@link gatedResponse} derives each adapter's enforced render from its row,
|
|
879
1057
|
* and the conformance harness holds every enforced render to the same row.
|
package/src/fixtures/codex.json
CHANGED
|
@@ -79,7 +79,8 @@
|
|
|
79
79
|
"enforced": false,
|
|
80
80
|
"stdout": {
|
|
81
81
|
"hookSpecificOutput": {
|
|
82
|
-
"hookEventName": "PreToolUse"
|
|
82
|
+
"hookEventName": "PreToolUse",
|
|
83
|
+
"additionalContext": "redacted 1 secret"
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
}
|
|
@@ -127,7 +128,7 @@
|
|
|
127
128
|
"mutated_input": {
|
|
128
129
|
"command": "ls"
|
|
129
130
|
},
|
|
130
|
-
"additional_context": "
|
|
131
|
+
"additional_context": "narrowed to a plain ls"
|
|
131
132
|
},
|
|
132
133
|
"native": {
|
|
133
134
|
"transport": "external_hook",
|
|
@@ -136,9 +137,11 @@
|
|
|
136
137
|
"stdout": {
|
|
137
138
|
"hookSpecificOutput": {
|
|
138
139
|
"hookEventName": "PreToolUse",
|
|
140
|
+
"permissionDecision": "allow",
|
|
139
141
|
"updatedInput": {
|
|
140
142
|
"command": "ls"
|
|
141
|
-
}
|
|
143
|
+
},
|
|
144
|
+
"additionalContext": "narrowed to a plain ls"
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
}
|
|
@@ -285,6 +288,7 @@
|
|
|
285
288
|
"stdout": {
|
|
286
289
|
"hookSpecificOutput": {
|
|
287
290
|
"hookEventName": "PreToolUse",
|
|
291
|
+
"permissionDecision": "allow",
|
|
288
292
|
"updatedInput": {
|
|
289
293
|
"command": ["ls"]
|
|
290
294
|
}
|
|
@@ -370,8 +374,10 @@
|
|
|
370
374
|
"stdout": {
|
|
371
375
|
"hookSpecificOutput": {
|
|
372
376
|
"hookEventName": "PermissionRequest",
|
|
373
|
-
"
|
|
374
|
-
|
|
377
|
+
"decision": {
|
|
378
|
+
"behavior": "deny",
|
|
379
|
+
"message": "nope"
|
|
380
|
+
}
|
|
375
381
|
}
|
|
376
382
|
},
|
|
377
383
|
"stderr": "nope"
|
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 top-level and `hookSpecificOutput` keys Claude Code's hooks reference
|
|
86
|
+
* documents for `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether
|
|
87
|
+
* an unread key fails open, at either level, is NOT established: the reference
|
|
88
|
+
* says an exit-0 body that fails schema validation lets the call go ahead, but
|
|
89
|
+
* not whether an extra key fails 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";
|