agent-control-plane-core 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adapters/amp.mjs +15 -2
- package/src/adapters/claude.mjs +19 -2
- package/src/adapters/codex.mjs +19 -2
- package/src/adapters/gemini.mjs +15 -2
- package/src/conformance.mjs +50 -1
- package/src/control-plane.mjs +56 -0
- package/src/fixtures/claude.json +19 -1
- package/types/conformance.d.mts +2 -1
- package/types/control-plane.d.mts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
CallClass,
|
|
19
19
|
CoverageStatus,
|
|
20
20
|
classifyCallClass,
|
|
21
|
-
|
|
21
|
+
vetoableFor,
|
|
22
|
+
assertGatedKinds,
|
|
22
23
|
canonicalTool,
|
|
23
24
|
lookup,
|
|
24
25
|
makeEvent,
|
|
@@ -53,6 +54,16 @@ export const COVERAGE = Object.freeze({
|
|
|
53
54
|
[CallClass.RESUMED]: CoverageStatus.UNKNOWN,
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The event kinds this host can actually gate. Amp invokes the delegate for a tool call and nothing else, so parse only ever emits PRE_TOOL.
|
|
59
|
+
* A kind absent here parses non-vetoable, so an unmodelled event never renders as
|
|
60
|
+
* an enforced block the host will not perform. Module-private: `Object.freeze` does
|
|
61
|
+
* not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back
|
|
62
|
+
* after `assertGatedKinds` has already run.
|
|
63
|
+
*/
|
|
64
|
+
const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
|
|
65
|
+
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
66
|
+
|
|
56
67
|
// Amp invokes the delegate for a tool call; the payload carries the tool name +
|
|
57
68
|
// input and the session context. Pinned by fixtures/amp.json.
|
|
58
69
|
const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
|
|
@@ -81,7 +92,9 @@ export function parse(native) {
|
|
|
81
92
|
input: asObject(raw.input),
|
|
82
93
|
response: undefined,
|
|
83
94
|
// Classify on the NATIVE name (MCP detection keys on `mcp__…`).
|
|
84
|
-
this_call_vetoable:
|
|
95
|
+
this_call_vetoable: vetoableFor(
|
|
96
|
+
EventKind.PRE_TOOL,
|
|
97
|
+
GATED_EVENTS,
|
|
85
98
|
COVERAGE[classifyCallClass(nativeTool, raw)],
|
|
86
99
|
),
|
|
87
100
|
meta,
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
CallClass,
|
|
20
20
|
CoverageStatus,
|
|
21
21
|
classifyCallClass,
|
|
22
|
-
|
|
22
|
+
vetoableFor,
|
|
23
|
+
assertGatedKinds,
|
|
23
24
|
canonicalTool,
|
|
24
25
|
lookup,
|
|
25
26
|
makeEvent,
|
|
@@ -57,6 +58,20 @@ export const COVERAGE = Object.freeze({
|
|
|
57
58
|
[CallClass.RESUMED]: CoverageStatus.COVERED,
|
|
58
59
|
});
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The event kinds this host can actually gate. Claude Code honours a deny on these three. SESSION_START is left out: exit 2 does
|
|
63
|
+
* not abort a session start, and the matrix documents no host response for it — an
|
|
64
|
+
* undocumented kind is fail-closed to non-vetoable, the same rule coverage uses.
|
|
65
|
+
* A kind absent here parses non-vetoable, so an unmodelled event never renders as
|
|
66
|
+
* an enforced block the host will not perform. Module-private: `Object.freeze` does
|
|
67
|
+
* not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back after
|
|
68
|
+
* `assertGatedKinds` has already run.
|
|
69
|
+
*/
|
|
70
|
+
const GATED_EVENTS = Object.freeze(
|
|
71
|
+
new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
|
|
72
|
+
);
|
|
73
|
+
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
74
|
+
|
|
60
75
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
61
76
|
export const HookEvent = Object.freeze({
|
|
62
77
|
PRE_TOOL_USE: "PreToolUse",
|
|
@@ -162,7 +177,9 @@ export function parse(native) {
|
|
|
162
177
|
response,
|
|
163
178
|
// Classify on the NATIVE name — MCP detection keys on `mcp__…`, which a
|
|
164
179
|
// canonical builtin name would never carry.
|
|
165
|
-
this_call_vetoable:
|
|
180
|
+
this_call_vetoable: vetoableFor(
|
|
181
|
+
kind,
|
|
182
|
+
GATED_EVENTS,
|
|
166
183
|
COVERAGE[classifyCallClass(nativeTool, raw)],
|
|
167
184
|
),
|
|
168
185
|
meta,
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
CallClass,
|
|
21
21
|
CoverageStatus,
|
|
22
22
|
classifyCallClass,
|
|
23
|
-
|
|
23
|
+
vetoableFor,
|
|
24
|
+
assertGatedKinds,
|
|
24
25
|
canonicalTool,
|
|
25
26
|
makeEvent,
|
|
26
27
|
normalizeVerdict,
|
|
@@ -54,6 +55,17 @@ export const COVERAGE = Object.freeze({
|
|
|
54
55
|
[CallClass.RESUMED]: CoverageStatus.UNKNOWN,
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The event kinds this host can actually gate. `PreToolUse` is the one event Codex can veto. Every other native event parses as
|
|
60
|
+
* UNKNOWN today, so without this set each of them reported an enforced block.
|
|
61
|
+
* A kind absent here parses non-vetoable, so an unmodelled event never renders as
|
|
62
|
+
* an enforced block the host will not perform. Module-private: `Object.freeze` does
|
|
63
|
+
* not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back
|
|
64
|
+
* after `assertGatedKinds` has already run.
|
|
65
|
+
*/
|
|
66
|
+
const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
|
|
67
|
+
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
68
|
+
|
|
57
69
|
/** Minimum Codex version whose hook can actually veto a tool call. */
|
|
58
70
|
export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
|
|
59
71
|
|
|
@@ -140,7 +152,12 @@ export function parse(native) {
|
|
|
140
152
|
const nativeTool = asStringOrNull(raw.tool_name);
|
|
141
153
|
if (nativeTool !== null) meta.native_tool = nativeTool;
|
|
142
154
|
const vetoable =
|
|
143
|
-
enforce &&
|
|
155
|
+
enforce &&
|
|
156
|
+
vetoableFor(
|
|
157
|
+
kind,
|
|
158
|
+
GATED_EVENTS,
|
|
159
|
+
COVERAGE[classifyCallClass(nativeTool, raw)],
|
|
160
|
+
);
|
|
144
161
|
|
|
145
162
|
return makeEvent({
|
|
146
163
|
event: kind,
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -35,7 +35,8 @@ import {
|
|
|
35
35
|
CallClass,
|
|
36
36
|
CoverageStatus,
|
|
37
37
|
classifyCallClass,
|
|
38
|
-
|
|
38
|
+
vetoableFor,
|
|
39
|
+
assertGatedKinds,
|
|
39
40
|
canonicalTool,
|
|
40
41
|
lookup,
|
|
41
42
|
assertAliasTargetsModeled,
|
|
@@ -72,6 +73,18 @@ export const COVERAGE = Object.freeze({
|
|
|
72
73
|
[CallClass.RESUMED]: CoverageStatus.UNKNOWN,
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The event kinds this host can actually gate. Gemini CLI honours a deny on each of the three events NATIVE_TO_KIND maps.
|
|
78
|
+
* A kind absent here parses non-vetoable, so an unmodelled event never renders as
|
|
79
|
+
* an enforced block the host will not perform. Module-private: `Object.freeze` does
|
|
80
|
+
* not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back
|
|
81
|
+
* after `assertGatedKinds` has already run.
|
|
82
|
+
*/
|
|
83
|
+
const GATED_EVENTS = Object.freeze(
|
|
84
|
+
new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
|
|
85
|
+
);
|
|
86
|
+
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
87
|
+
|
|
75
88
|
/** Gemini CLI native hook event names (the `hook_event_name` field). */
|
|
76
89
|
export const HookEvent = Object.freeze({
|
|
77
90
|
BEFORE_TOOL: "BeforeTool",
|
|
@@ -236,7 +249,7 @@ export function parse(native) {
|
|
|
236
249
|
tool: geminiCanonicalTool(nativeTool, callClass),
|
|
237
250
|
input: geminiInput(kind, raw, nativeTool, callClass),
|
|
238
251
|
response,
|
|
239
|
-
this_call_vetoable:
|
|
252
|
+
this_call_vetoable: vetoableFor(kind, GATED_EVENTS, COVERAGE[callClass]),
|
|
240
253
|
meta,
|
|
241
254
|
});
|
|
242
255
|
}
|
package/src/conformance.mjs
CHANGED
|
@@ -21,6 +21,21 @@ const UNENFORCEABLE_DENY_PROBE = Object.freeze({
|
|
|
21
21
|
});
|
|
22
22
|
const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The payload rule ⑨ drifts an adapter with. No host emits that event name, so
|
|
26
|
+
* an adapter that maps event names must answer {@link EventKind.UNKNOWN}.
|
|
27
|
+
*
|
|
28
|
+
* `version` carries a release far past any adapter's enforcement floor. Without
|
|
29
|
+
* it Codex's own version gate answers non-vetoable for an unrelated reason, and
|
|
30
|
+
* the rule then passes on the adapter that needs it most — Codex routes EVERY
|
|
31
|
+
* non-PreToolUse event into UNKNOWN, so a probe it cannot bite on certifies
|
|
32
|
+
* nothing.
|
|
33
|
+
*/
|
|
34
|
+
const DRIFT_PROBE_NATIVE = Object.freeze({
|
|
35
|
+
hook_event_name: "ConformanceProbeUnmodelledEvent",
|
|
36
|
+
version: "9999.0.0",
|
|
37
|
+
});
|
|
38
|
+
|
|
24
39
|
/**
|
|
25
40
|
* Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
|
|
26
41
|
* hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
|
|
@@ -225,7 +240,7 @@ function assertAliasedInput(agent, caseName, event, assert) {
|
|
|
225
240
|
* assert further on.
|
|
226
241
|
*
|
|
227
242
|
* @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
|
|
228
|
-
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
243
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
229
244
|
*/
|
|
230
245
|
export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
231
246
|
assert.equal(
|
|
@@ -243,6 +258,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
243
258
|
let mutationSeen = false;
|
|
244
259
|
let enforcedDenySeen = false;
|
|
245
260
|
let vetoableDenySeen = false;
|
|
261
|
+
let unknownKindSeen = false;
|
|
246
262
|
let preToolCases = 0;
|
|
247
263
|
let unenforceableDenyChecks = 0;
|
|
248
264
|
let renders = 0;
|
|
@@ -260,6 +276,18 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
260
276
|
// a rename-without-its-input land by editing the expectation.
|
|
261
277
|
assertAliasedInput(adapter.AGENT, testCase.name, parsed, assert);
|
|
262
278
|
|
|
279
|
+
// An event the adapter could not name is one whose host response nobody has
|
|
280
|
+
// established, so claiming a veto for it is the one error that lies to a
|
|
281
|
+
// guardrail: the transcript shows a block and the host runs the tool anyway.
|
|
282
|
+
if (parsed.event === EventKind.UNKNOWN) {
|
|
283
|
+
assert.equal(
|
|
284
|
+
parsed.this_call_vetoable,
|
|
285
|
+
false,
|
|
286
|
+
`unmodelled event parsed as vetoable: ${testCase.name}`,
|
|
287
|
+
);
|
|
288
|
+
unknownKindSeen = true;
|
|
289
|
+
}
|
|
290
|
+
|
|
263
291
|
if (testCase.call_class !== undefined) {
|
|
264
292
|
assert.ok(
|
|
265
293
|
CALL_CLASSES.includes(testCase.call_class),
|
|
@@ -385,6 +413,26 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
385
413
|
enforcedDenySeen,
|
|
386
414
|
"no enforced deny rendered — enforcement honesty is untested",
|
|
387
415
|
);
|
|
416
|
+
// Rule ⑨: parse must ANSWER for an event it cannot name rather than throw, and
|
|
417
|
+
// the render of a deny on that answer must not claim a block. Probed on a
|
|
418
|
+
// synthesized payload rather than a fixture flag, because an adapter whose
|
|
419
|
+
// parse can never emit UNKNOWN — amp routes every payload to pre_tool — has no
|
|
420
|
+
// such fixture to write, and a fixture-driven check would then sit vacuous on
|
|
421
|
+
// exactly the adapters this rule covers.
|
|
422
|
+
const drifted = adapter.parse({ ...DRIFT_PROBE_NATIVE });
|
|
423
|
+
if (drifted.event === EventKind.UNKNOWN) {
|
|
424
|
+
// Not that the event is non-vetoable — `makeEvent` refuses to construct a
|
|
425
|
+
// vetoable UNKNOWN at all, so asserting it here would police a state the
|
|
426
|
+
// constructor already makes unreachable. What only a probe can reach is the
|
|
427
|
+
// RENDER: an adapter is free to derive `enforced` from something other than
|
|
428
|
+
// the flag, and then the false block returns by another door.
|
|
429
|
+
assert.equal(
|
|
430
|
+
adapter.render(UNENFORCEABLE_DENY_PROBE, drifted).enforced,
|
|
431
|
+
false,
|
|
432
|
+
"a deny on an unmodelled event rendered as an enforced block",
|
|
433
|
+
);
|
|
434
|
+
unknownKindSeen = true;
|
|
435
|
+
}
|
|
388
436
|
assert.ok(renders > 0, "conformance fixtures render nothing");
|
|
389
437
|
// EVERY pre-tool case, not merely one: the count is what catches rule ⑧ being
|
|
390
438
|
// skipped by a stray `continue` rather than merely present somewhere. A suite
|
|
@@ -403,6 +451,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
403
451
|
mutationSeen,
|
|
404
452
|
enforcedDenySeen,
|
|
405
453
|
vetoableDenySeen,
|
|
454
|
+
unknownKindSeen,
|
|
406
455
|
coverageClassesChecked,
|
|
407
456
|
unenforceableDenyChecks,
|
|
408
457
|
};
|
package/src/control-plane.mjs
CHANGED
|
@@ -258,6 +258,52 @@ export function coverageAllowsVeto(status) {
|
|
|
258
258
|
return status === CoverageStatus.COVERED || status === CoverageStatus.PARTIAL;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* The event kinds a host can gate at all. {@link EventKind.UNKNOWN} is absent by
|
|
263
|
+
* construction, and that absence is the point: an event this package could not
|
|
264
|
+
* name is one whose host response nobody has established.
|
|
265
|
+
*/
|
|
266
|
+
/** @type {Set<string>} */
|
|
267
|
+
const GATEABLE_KINDS = new Set([
|
|
268
|
+
EventKind.PRE_TOOL,
|
|
269
|
+
EventKind.POST_TOOL,
|
|
270
|
+
EventKind.PROMPT_SUBMIT,
|
|
271
|
+
EventKind.SESSION_START,
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Whether an event may be marked `this_call_vetoable`.
|
|
276
|
+
*
|
|
277
|
+
* INVARIANT: an event whose kind the adapter's `GATED_EVENTS` does not name is
|
|
278
|
+
* never vetoable, whatever its coverage says. Coverage answers whether the
|
|
279
|
+
* host's hook fires for a CLASS of call and says nothing about a kind the
|
|
280
|
+
* adapter could not model — so keying on coverage alone reported an enforced
|
|
281
|
+
* block for every unmodelled event, which is the one direction that lies to a
|
|
282
|
+
* guardrail: the transcript shows a block and the host runs the tool.
|
|
283
|
+
* @param {string} kind an {@link EventKind} value
|
|
284
|
+
* @param {Set<string>} gatedKinds the adapter's own gated set
|
|
285
|
+
* @param {string|undefined} coverage a {@link CoverageStatus} value
|
|
286
|
+
* @returns {boolean}
|
|
287
|
+
*/
|
|
288
|
+
export function vetoableFor(kind, gatedKinds, coverage) {
|
|
289
|
+
if (!gatedKinds.has(kind)) return false;
|
|
290
|
+
return coverageAllowsVeto(coverage);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Refuse a gated-kind set naming a kind no host can gate. Called at module load
|
|
295
|
+
* by each adapter, so `EventKind.UNKNOWN` cannot be added to one by hand.
|
|
296
|
+
* @param {Set<string>} kinds an adapter's `GATED_EVENTS`
|
|
297
|
+
* @param {string} agent the adapter's agent id, for the message
|
|
298
|
+
*/
|
|
299
|
+
export function assertGatedKinds(kinds, agent) {
|
|
300
|
+
for (const kind of kinds)
|
|
301
|
+
if (!GATEABLE_KINDS.has(kind))
|
|
302
|
+
throw new Error(
|
|
303
|
+
`${agent} adapter: ${JSON.stringify(kind)} is not a gateable event kind`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
261
307
|
/**
|
|
262
308
|
* True when `status` is a recognized {@link CoverageStatus} value.
|
|
263
309
|
* @param {unknown} status
|
|
@@ -383,6 +429,16 @@ export function makeEvent({
|
|
|
383
429
|
throw new TypeError(
|
|
384
430
|
`control-plane: makeEvent this_call_vetoable must be a boolean, got ${typeof this_call_vetoable}`,
|
|
385
431
|
);
|
|
432
|
+
// The same fail-open seam, one step further: an event the adapter could not
|
|
433
|
+
// name is one whose host response nobody established, so a veto reported for
|
|
434
|
+
// it is a block the render claims and the host never performs. Refused HERE
|
|
435
|
+
// because every adapter builds its event through this call, so an adapter that
|
|
436
|
+
// computes the flag without `vetoableFor` cannot reach the wire either.
|
|
437
|
+
if (event === EventKind.UNKNOWN && this_call_vetoable)
|
|
438
|
+
throw new Error(
|
|
439
|
+
"control-plane: makeEvent got a vetoable UNKNOWN event — an unmodelled " +
|
|
440
|
+
"event has no host response to veto, so reporting one is a false block",
|
|
441
|
+
);
|
|
386
442
|
/** @type {ToolCallEvent} */
|
|
387
443
|
const evt = {
|
|
388
444
|
schema_version: SCHEMA_VERSION,
|
package/src/fixtures/claude.json
CHANGED
|
@@ -318,7 +318,7 @@
|
|
|
318
318
|
"event": "session_start",
|
|
319
319
|
"tool": null,
|
|
320
320
|
"input": {},
|
|
321
|
-
"this_call_vetoable":
|
|
321
|
+
"this_call_vetoable": false,
|
|
322
322
|
"meta": {
|
|
323
323
|
"agent": "claude",
|
|
324
324
|
"native_event": "SessionStart",
|
|
@@ -344,6 +344,24 @@
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
|
+
},
|
|
348
|
+
"deny": {
|
|
349
|
+
"verdict": {
|
|
350
|
+
"decision": "deny",
|
|
351
|
+
"reason": "not a gateable event"
|
|
352
|
+
},
|
|
353
|
+
"native": {
|
|
354
|
+
"transport": "external_hook",
|
|
355
|
+
"exit_code": 0,
|
|
356
|
+
"enforced": false,
|
|
357
|
+
"stdout": {
|
|
358
|
+
"hookSpecificOutput": {
|
|
359
|
+
"hookEventName": "SessionStart"
|
|
360
|
+
},
|
|
361
|
+
"decision": "block",
|
|
362
|
+
"reason": "not a gateable event"
|
|
363
|
+
}
|
|
364
|
+
}
|
|
347
365
|
}
|
|
348
366
|
}
|
|
349
367
|
}
|
package/types/conformance.d.mts
CHANGED
|
@@ -103,7 +103,7 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
|
|
|
103
103
|
* assert further on.
|
|
104
104
|
*
|
|
105
105
|
* @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
|
|
106
|
-
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
106
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
107
107
|
*/
|
|
108
108
|
export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
109
109
|
adapter: import("./control-plane.mjs").Adapter;
|
|
@@ -116,6 +116,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
|
116
116
|
mutationSeen: boolean;
|
|
117
117
|
enforcedDenySeen: boolean;
|
|
118
118
|
vetoableDenySeen: boolean;
|
|
119
|
+
unknownKindSeen: boolean;
|
|
119
120
|
coverageClassesChecked: Set<string>;
|
|
120
121
|
unenforceableDenyChecks: number;
|
|
121
122
|
};
|
|
@@ -46,6 +46,28 @@ export function lookup<T>(map: Record<string, T>, key: string): T | undefined;
|
|
|
46
46
|
* @returns {boolean}
|
|
47
47
|
*/
|
|
48
48
|
export function coverageAllowsVeto(status: string | undefined): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Whether an event may be marked `this_call_vetoable`.
|
|
51
|
+
*
|
|
52
|
+
* INVARIANT: an event whose kind the adapter's `GATED_EVENTS` does not name is
|
|
53
|
+
* never vetoable, whatever its coverage says. Coverage answers whether the
|
|
54
|
+
* host's hook fires for a CLASS of call and says nothing about a kind the
|
|
55
|
+
* adapter could not model — so keying on coverage alone reported an enforced
|
|
56
|
+
* block for every unmodelled event, which is the one direction that lies to a
|
|
57
|
+
* guardrail: the transcript shows a block and the host runs the tool.
|
|
58
|
+
* @param {string} kind an {@link EventKind} value
|
|
59
|
+
* @param {Set<string>} gatedKinds the adapter's own gated set
|
|
60
|
+
* @param {string|undefined} coverage a {@link CoverageStatus} value
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function vetoableFor(kind: string, gatedKinds: Set<string>, coverage: string | undefined): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Refuse a gated-kind set naming a kind no host can gate. Called at module load
|
|
66
|
+
* by each adapter, so `EventKind.UNKNOWN` cannot be added to one by hand.
|
|
67
|
+
* @param {Set<string>} kinds an adapter's `GATED_EVENTS`
|
|
68
|
+
* @param {string} agent the adapter's agent id, for the message
|
|
69
|
+
*/
|
|
70
|
+
export function assertGatedKinds(kinds: Set<string>, agent: string): void;
|
|
49
71
|
/**
|
|
50
72
|
* True when `status` is a recognized {@link CoverageStatus} value.
|
|
51
73
|
* @param {unknown} status
|