agent-control-plane-core 0.4.2 → 0.5.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 +6 -0
- package/bin/hook-runtime.mjs +46 -4
- package/package.json +1 -1
- package/src/adapters/amp.mjs +79 -4
- package/src/adapters/claude.mjs +17 -2
- package/src/adapters/codex.mjs +17 -2
- package/src/adapters/gemini.mjs +13 -2
- package/src/conformance.mjs +116 -6
- package/src/control-plane.mjs +52 -2
- package/src/fixtures/claude.json +19 -1
- package/types/adapters/amp.d.mts +6 -0
- package/types/adapters/claude.d.mts +8 -0
- package/types/adapters/codex.d.mts +7 -0
- package/types/adapters/gemini.d.mts +6 -0
- package/types/conformance.d.mts +11 -1
- package/types/control-plane.d.mts +28 -2
package/README.md
CHANGED
|
@@ -167,6 +167,12 @@ The harness pins both directions and rejects a vacuous suite:
|
|
|
167
167
|
3. The fixture set collectively renders an `allow`, a `deny`, an `ask`, **and** a
|
|
168
168
|
`mutated_input` — so a suite can't pass while skipping a decision the contract
|
|
169
169
|
requires every adapter to express.
|
|
170
|
+
4. A deny the adapter cannot enforce (`this_call_vetoable: false`) must render as
|
|
171
|
+
something OTHER than that host's allow signal — an ask, or an advisory body.
|
|
172
|
+
The harness probes this itself on a non-vetoable variant of every pre-tool
|
|
173
|
+
fixture event, so an adapter that collapses the objection onto "run it" fails
|
|
174
|
+
even with no non-vetoable fixture of its own. `observe_only` renders are
|
|
175
|
+
exempt: that transport has no pre-emption channel to differ in.
|
|
170
176
|
|
|
171
177
|
See `src/fixtures/claude.json` and `src/fixtures/codex.json` for the format.
|
|
172
178
|
|
package/bin/hook-runtime.mjs
CHANGED
|
@@ -95,6 +95,48 @@ export function renderHookResponse(
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* A one-millisecond synchronous sleep buffer for the EAGAIN retry in
|
|
100
|
+
* {@link writeAllSync}. Allocated once at module scope: the retry path must not
|
|
101
|
+
* allocate per iteration while it is draining a full pipe.
|
|
102
|
+
*/
|
|
103
|
+
const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Write `text` to `fd` IN FULL, looping until every byte lands.
|
|
107
|
+
*
|
|
108
|
+
* A single `writeSync` is not enough. When the host captures the hook, fd 1/2 is
|
|
109
|
+
* a pipe, and libuv puts that pipe in NON-BLOCKING mode the moment anything
|
|
110
|
+
* initializes `process.stdout`/`process.stderr` (a `console.log` in a judge, the
|
|
111
|
+
* fail-safe stderr diagnostic in {@link renderHookResponse}). A non-blocking
|
|
112
|
+
* `write(2)` returns a SHORT COUNT once the kernel pipe buffer fills — measured
|
|
113
|
+
* at ~143 KiB here — so a deny body larger than that (a long `reason`, a big
|
|
114
|
+
* `mutated_input`) was silently truncated and the process still exited 0/2 with
|
|
115
|
+
* a half-written JSON the host cannot parse: an enforced deny degrading to a
|
|
116
|
+
* run. Looping restores the blocking-write semantics the caller assumes.
|
|
117
|
+
* @param {number} fd
|
|
118
|
+
* @param {string} text
|
|
119
|
+
*/
|
|
120
|
+
function writeAllSync(fd, text) {
|
|
121
|
+
const buf = Buffer.from(text, "utf8");
|
|
122
|
+
let written = 0;
|
|
123
|
+
while (written < buf.length) {
|
|
124
|
+
try {
|
|
125
|
+
written += writeSync(fd, buf, written);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// EAGAIN is the one recoverable case: the non-blocking pipe is momentarily
|
|
128
|
+
// full because the host has not drained it yet. Sleep 1ms and retry — the
|
|
129
|
+
// same wait a blocking write would have done in the kernel. Every other
|
|
130
|
+
// errno (EPIPE, EBADF, ...) propagates. There is deliberately no retry cap:
|
|
131
|
+
// a cap would reintroduce exactly the silent truncation this loop exists to
|
|
132
|
+
// kill, and a host that never reads would have blocked us forever anyway.
|
|
133
|
+
if (!(err instanceof Error) || /** @type {any} */ (err).code !== "EAGAIN")
|
|
134
|
+
throw err;
|
|
135
|
+
Atomics.wait(RETRY_SLEEP, 0, 0, 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
98
140
|
/**
|
|
99
141
|
* Emit a {@link NativeResponse} to the host: write the native stdout body when
|
|
100
142
|
* the transport has one, then exit with the transport's exit code.
|
|
@@ -102,18 +144,18 @@ export function renderHookResponse(
|
|
|
102
144
|
* @returns {never}
|
|
103
145
|
*/
|
|
104
146
|
export function emit(response) {
|
|
105
|
-
// `
|
|
147
|
+
// `writeAllSync` (not `process.stdout.write`) so the body is flushed to fd 1
|
|
106
148
|
// BEFORE `process.exit`. On a pipe — which stdout is when the host captures the
|
|
107
149
|
// hook — `process.stdout.write` is asynchronous and `process.exit` does not
|
|
108
150
|
// drain it, so an enforced-deny body (Claude/Codex `hookSpecificOutput`) could
|
|
109
151
|
// be truncated; the host then reads a block-less exit and runs the tool. A
|
|
110
|
-
// synchronous write closes that silent deny→allow window.
|
|
152
|
+
// synchronous, fully-drained write closes that silent deny→allow window.
|
|
111
153
|
if (response.stdout !== undefined)
|
|
112
|
-
|
|
154
|
+
writeAllSync(1, JSON.stringify(response.stdout));
|
|
113
155
|
// A host that reads the block reason from STDERR (Gemini's exit-2 System Block)
|
|
114
156
|
// gets it here — written synchronously to fd 2 before exit, for the same
|
|
115
157
|
// flush-before-exit reason as stdout above, so an enforced deny is never shown
|
|
116
158
|
// with no rationale.
|
|
117
|
-
if (response.stderr !== undefined)
|
|
159
|
+
if (response.stderr !== undefined) writeAllSync(2, response.stderr);
|
|
118
160
|
process.exit(response.exit_code);
|
|
119
161
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
CallClass,
|
|
19
19
|
CoverageStatus,
|
|
20
20
|
classifyCallClass,
|
|
21
|
-
|
|
21
|
+
vetoableFor,
|
|
22
|
+
assertGatedKinds,
|
|
22
23
|
canonicalTool,
|
|
24
|
+
lookup,
|
|
23
25
|
makeEvent,
|
|
24
26
|
normalizeVerdict,
|
|
25
27
|
nativeResponse,
|
|
@@ -52,6 +54,14 @@ export const COVERAGE = Object.freeze({
|
|
|
52
54
|
[CallClass.RESUMED]: CoverageStatus.UNKNOWN,
|
|
53
55
|
});
|
|
54
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.
|
|
61
|
+
*/
|
|
62
|
+
export const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
|
|
63
|
+
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
64
|
+
|
|
55
65
|
// Amp invokes the delegate for a tool call; the payload carries the tool name +
|
|
56
66
|
// input and the session context. Pinned by fixtures/amp.json.
|
|
57
67
|
const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
|
|
@@ -80,13 +90,58 @@ export function parse(native) {
|
|
|
80
90
|
input: asObject(raw.input),
|
|
81
91
|
response: undefined,
|
|
82
92
|
// Classify on the NATIVE name (MCP detection keys on `mcp__…`).
|
|
83
|
-
this_call_vetoable:
|
|
93
|
+
this_call_vetoable: vetoableFor(
|
|
94
|
+
EventKind.PRE_TOOL,
|
|
95
|
+
GATED_EVENTS,
|
|
84
96
|
COVERAGE[classifyCallClass(nativeTool, raw)],
|
|
85
97
|
),
|
|
86
98
|
meta,
|
|
87
99
|
});
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
|
|
104
|
+
* pure exit-code transport: 0 allow / 1 ask / 2 reject.
|
|
105
|
+
*
|
|
106
|
+
* Written as a total table rather than a ternary chain because the chain's
|
|
107
|
+
* fall-through case was ALLOW: an unenforceable deny — a deny on a call this
|
|
108
|
+
* guardrail cannot veto — silently rendered as exit 0, Amp's "run it". A
|
|
109
|
+
* combination this table forgets is a construction error (below), not a silent
|
|
110
|
+
* approval.
|
|
111
|
+
*
|
|
112
|
+
* The non-vetoable DENY row is exit 1 (ask), not 0. Amp has a real ask tier, so
|
|
113
|
+
* the honest render of "I object but cannot block" is to put the call in front
|
|
114
|
+
* of the human rather than wave it through. It is still `enforced: false` — the
|
|
115
|
+
* guardrail is not claiming a veto it does not have; it is declining to spend
|
|
116
|
+
* its one remaining signal on an approval.
|
|
117
|
+
*
|
|
118
|
+
* @type {Readonly<Record<string, Readonly<Record<string, number>>>>}
|
|
119
|
+
*/
|
|
120
|
+
const EXIT_CODE_BY_DECISION = Object.freeze({
|
|
121
|
+
[Decision.ALLOW]: Object.freeze({ true: 0, false: 0 }),
|
|
122
|
+
[Decision.DENY]: Object.freeze({ true: 2, false: 1 }),
|
|
123
|
+
[Decision.ASK]: Object.freeze({ true: 1, false: 1 }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Totality check at IMPORT: every {@link Decision} must have a row and every row
|
|
128
|
+
* both vetoable columns. A decision added to the contract breaks this module
|
|
129
|
+
* loudly at load instead of silently defaulting to exit 0.
|
|
130
|
+
*/
|
|
131
|
+
for (const decision of Object.values(Decision)) {
|
|
132
|
+
const row = lookup(EXIT_CODE_BY_DECISION, decision);
|
|
133
|
+
if (row === undefined)
|
|
134
|
+
throw new Error(
|
|
135
|
+
`amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
|
|
136
|
+
);
|
|
137
|
+
for (const vetoable of ["true", "false"]) {
|
|
138
|
+
if (typeof lookup(row, vetoable) === "number") continue;
|
|
139
|
+
throw new Error(
|
|
140
|
+
`amp adapter: exit-code table row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
90
145
|
/**
|
|
91
146
|
* Render into Amp's pure exit-code transport: the decision is the exit code,
|
|
92
147
|
* with no stdout body. `reason` has no native channel here, so it is dropped
|
|
@@ -99,8 +154,28 @@ export function parse(native) {
|
|
|
99
154
|
*/
|
|
100
155
|
export function render(verdict, event) {
|
|
101
156
|
const vd = normalizeVerdict(verdict);
|
|
102
|
-
const
|
|
103
|
-
|
|
157
|
+
const vetoable = event.this_call_vetoable;
|
|
158
|
+
// `makeEvent` already rejects a non-boolean, so reaching this means a
|
|
159
|
+
// hand-built event — and guessing whether it can be vetoed is how fail-open
|
|
160
|
+
// starts. The string "true" is the case that makes this a real check rather
|
|
161
|
+
// than a formality: it would otherwise index the vetoable column.
|
|
162
|
+
if (typeof vetoable !== "boolean")
|
|
163
|
+
throw new Error(
|
|
164
|
+
`amp adapter: this_call_vetoable must be a boolean, got ${JSON.stringify(vetoable)}`,
|
|
165
|
+
);
|
|
166
|
+
const enforced = vd.decision === Decision.DENY && vetoable;
|
|
167
|
+
const exit_code = lookup(
|
|
168
|
+
lookup(EXIT_CODE_BY_DECISION, vd.decision) ?? {},
|
|
169
|
+
String(vetoable),
|
|
170
|
+
);
|
|
171
|
+
// Unreachable while the import-time totality check and `normalizeVerdict` both
|
|
172
|
+
// hold; kept because the alternative to throwing is `exit_code: undefined`,
|
|
173
|
+
// which `process.exit` renders as 0 — the exact silent allow this table exists
|
|
174
|
+
// to eliminate.
|
|
175
|
+
if (exit_code === undefined)
|
|
176
|
+
throw new Error(
|
|
177
|
+
`amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
|
|
178
|
+
);
|
|
104
179
|
return nativeResponse({ transport: INTEGRATION_MODE, exit_code, enforced });
|
|
105
180
|
}
|
|
106
181
|
|
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,18 @@ 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.
|
|
67
|
+
*/
|
|
68
|
+
export const GATED_EVENTS = Object.freeze(
|
|
69
|
+
new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
|
|
70
|
+
);
|
|
71
|
+
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
72
|
+
|
|
60
73
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
61
74
|
export const HookEvent = Object.freeze({
|
|
62
75
|
PRE_TOOL_USE: "PreToolUse",
|
|
@@ -162,7 +175,9 @@ export function parse(native) {
|
|
|
162
175
|
response,
|
|
163
176
|
// Classify on the NATIVE name — MCP detection keys on `mcp__…`, which a
|
|
164
177
|
// canonical builtin name would never carry.
|
|
165
|
-
this_call_vetoable:
|
|
178
|
+
this_call_vetoable: vetoableFor(
|
|
179
|
+
kind,
|
|
180
|
+
GATED_EVENTS,
|
|
166
181
|
COVERAGE[classifyCallClass(nativeTool, raw)],
|
|
167
182
|
),
|
|
168
183
|
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,15 @@ 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.
|
|
63
|
+
*/
|
|
64
|
+
export const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
|
|
65
|
+
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
66
|
+
|
|
57
67
|
/** Minimum Codex version whose hook can actually veto a tool call. */
|
|
58
68
|
export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
|
|
59
69
|
|
|
@@ -140,7 +150,12 @@ export function parse(native) {
|
|
|
140
150
|
const nativeTool = asStringOrNull(raw.tool_name);
|
|
141
151
|
if (nativeTool !== null) meta.native_tool = nativeTool;
|
|
142
152
|
const vetoable =
|
|
143
|
-
enforce &&
|
|
153
|
+
enforce &&
|
|
154
|
+
vetoableFor(
|
|
155
|
+
kind,
|
|
156
|
+
GATED_EVENTS,
|
|
157
|
+
COVERAGE[classifyCallClass(nativeTool, raw)],
|
|
158
|
+
);
|
|
144
159
|
|
|
145
160
|
return makeEvent({
|
|
146
161
|
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,16 @@ 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.
|
|
80
|
+
*/
|
|
81
|
+
export const GATED_EVENTS = Object.freeze(
|
|
82
|
+
new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL, EventKind.PROMPT_SUBMIT]),
|
|
83
|
+
);
|
|
84
|
+
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
85
|
+
|
|
75
86
|
/** Gemini CLI native hook event names (the `hook_event_name` field). */
|
|
76
87
|
export const HookEvent = Object.freeze({
|
|
77
88
|
BEFORE_TOOL: "BeforeTool",
|
|
@@ -236,7 +247,7 @@ export function parse(native) {
|
|
|
236
247
|
tool: geminiCanonicalTool(nativeTool, callClass),
|
|
237
248
|
input: geminiInput(kind, raw, nativeTool, callClass),
|
|
238
249
|
response,
|
|
239
|
-
this_call_vetoable:
|
|
250
|
+
this_call_vetoable: vetoableFor(kind, GATED_EVENTS, COVERAGE[callClass]),
|
|
240
251
|
meta,
|
|
241
252
|
});
|
|
242
253
|
}
|
package/src/conformance.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CALL_CLASSES,
|
|
3
|
+
Decision,
|
|
4
|
+
EventKind,
|
|
5
|
+
IntegrationMode,
|
|
3
6
|
MODELED_TOOL_INPUT_KEYS,
|
|
4
7
|
TOOL_ALIASES,
|
|
5
8
|
canonicalTool,
|
|
@@ -8,6 +11,22 @@ import {
|
|
|
8
11
|
lookup,
|
|
9
12
|
} from "./control-plane.mjs";
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The two probe verdicts rule ⑧ renders against a non-vetoable variant of each
|
|
16
|
+
* pre-tool fixture event. Frozen so a mutating adapter cannot poison later cases.
|
|
17
|
+
*/
|
|
18
|
+
const UNENFORCEABLE_DENY_PROBE = Object.freeze({
|
|
19
|
+
decision: Decision.DENY,
|
|
20
|
+
reason: "conformance probe: unenforceable deny",
|
|
21
|
+
});
|
|
22
|
+
const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The native event name rule ⑨ drifts an adapter with. No host emits it, so an
|
|
26
|
+
* adapter that maps event names must answer {@link EventKind.UNKNOWN} for it.
|
|
27
|
+
*/
|
|
28
|
+
const UNMODELLED_EVENT_NAME = "ConformanceProbeUnmodelledEvent";
|
|
29
|
+
|
|
11
30
|
/**
|
|
12
31
|
* Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
|
|
13
32
|
* hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
|
|
@@ -66,14 +85,17 @@ export function assertToolAliasesCovered(
|
|
|
66
85
|
let witnessed = witnessedByAgent.get(fixtures.agent);
|
|
67
86
|
if (witnessed === undefined)
|
|
68
87
|
witnessedByAgent.set(fixtures.agent, (witnessed = new Map()));
|
|
69
|
-
|
|
88
|
+
// `fixtures.agent` and the native tool names below are ADAPTER-SUPPLIED
|
|
89
|
+
// strings; a bare index would resolve `constructor`/`__proto__` to an
|
|
90
|
+
// inherited Object.prototype member instead of missing. See `lookup`.
|
|
91
|
+
const scopedMap = lookup(adapterAliases, fixtures.agent) ?? {};
|
|
70
92
|
for (const testCase of fixtures.cases) {
|
|
71
93
|
const nativeTool = testCase.event?.meta?.native_tool;
|
|
72
94
|
if (typeof nativeTool !== "string") continue;
|
|
73
95
|
const canon = testCase.event.tool;
|
|
74
96
|
const allowed = new Set([canonicalTool(nativeTool)]);
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
const scopedTarget = lookup(scopedMap, nativeTool);
|
|
98
|
+
if (scopedTarget !== undefined) allowed.add(scopedTarget);
|
|
77
99
|
assert.ok(
|
|
78
100
|
allowed.has(canon),
|
|
79
101
|
`fixture '${testCase.name}' (${fixtures.agent}): native_tool ${JSON.stringify(nativeTool)} normalized to ${JSON.stringify(canon)}, but only ${JSON.stringify([...allowed])} are valid canonicalizations for this agent`,
|
|
@@ -195,13 +217,21 @@ function assertAliasedInput(agent, caseName, event, assert) {
|
|
|
195
217
|
* permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
|
|
196
218
|
* `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
|
|
197
219
|
* while parsing its calls as vetoable.
|
|
220
|
+
* 8. unenforceable deny ≠ allow: for every pre-tool fixture event, rendering a
|
|
221
|
+
* DENY over a non-vetoable variant of that event must produce a response
|
|
222
|
+
* that differs from the same adapter's abstaining `allow` render. Rule ⑤
|
|
223
|
+
* only says such a deny is not `enforced`; without this, an adapter is free
|
|
224
|
+
* to collapse it onto the host's literal "run it" signal (Amp's exit 0),
|
|
225
|
+
* throwing away the objection entirely. The strongest honest signal a
|
|
226
|
+
* transport has left — an ask, an advisory body — must survive. An
|
|
227
|
+
* OBSERVE_ONLY render is exempt: it has no pre-emption channel to differ in.
|
|
198
228
|
*
|
|
199
229
|
* `assert` is injected (node:assert/strict) so the harness stays test-framework
|
|
200
230
|
* neutral; it throws on the first mismatch. Returns a summary the caller can
|
|
201
231
|
* assert further on.
|
|
202
232
|
*
|
|
203
233
|
* @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
|
|
204
|
-
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string
|
|
234
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
205
235
|
*/
|
|
206
236
|
export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
207
237
|
assert.equal(
|
|
@@ -219,6 +249,9 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
219
249
|
let mutationSeen = false;
|
|
220
250
|
let enforcedDenySeen = false;
|
|
221
251
|
let vetoableDenySeen = false;
|
|
252
|
+
let unknownKindSeen = false;
|
|
253
|
+
let preToolCases = 0;
|
|
254
|
+
let unenforceableDenyChecks = 0;
|
|
222
255
|
let renders = 0;
|
|
223
256
|
|
|
224
257
|
for (const testCase of fixtures.cases) {
|
|
@@ -234,16 +267,32 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
234
267
|
// a rename-without-its-input land by editing the expectation.
|
|
235
268
|
assertAliasedInput(adapter.AGENT, testCase.name, parsed, assert);
|
|
236
269
|
|
|
270
|
+
// An event the adapter could not name is one whose host response nobody has
|
|
271
|
+
// established, so claiming a veto for it is the one error that lies to a
|
|
272
|
+
// guardrail: the transcript shows a block and the host runs the tool anyway.
|
|
273
|
+
if (parsed.event === EventKind.UNKNOWN) {
|
|
274
|
+
assert.equal(
|
|
275
|
+
parsed.this_call_vetoable,
|
|
276
|
+
false,
|
|
277
|
+
`unmodelled event parsed as vetoable: ${testCase.name}`,
|
|
278
|
+
);
|
|
279
|
+
unknownKindSeen = true;
|
|
280
|
+
}
|
|
281
|
+
|
|
237
282
|
if (testCase.call_class !== undefined) {
|
|
238
283
|
assert.ok(
|
|
239
284
|
CALL_CLASSES.includes(testCase.call_class),
|
|
240
285
|
`unknown call_class '${testCase.call_class}': ${testCase.name}`,
|
|
241
286
|
);
|
|
242
|
-
|
|
287
|
+
// Fixture-supplied key ⇒ prototype-safe read (see `lookup`): a
|
|
288
|
+
// `call_class` naming an Object.prototype member must MISS, not resolve a
|
|
289
|
+
// function that `coverageAllowsVeto` would then reject or accept by luck.
|
|
290
|
+
const coverage = lookup(adapter.COVERAGE, testCase.call_class);
|
|
291
|
+
if (!coverageAllowsVeto(coverage)) {
|
|
243
292
|
assert.equal(
|
|
244
293
|
parsed.this_call_vetoable,
|
|
245
294
|
false,
|
|
246
|
-
`call_class '${testCase.call_class}' is ${
|
|
295
|
+
`call_class '${testCase.call_class}' is ${coverage} (no veto) but parsed this_call_vetoable !== false: ${testCase.name}`,
|
|
247
296
|
);
|
|
248
297
|
}
|
|
249
298
|
coverageClassesChecked.add(testCase.call_class);
|
|
@@ -307,6 +356,38 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
307
356
|
if (spec.verdict.mutated_input !== undefined) mutationSeen = true;
|
|
308
357
|
renders += 1;
|
|
309
358
|
}
|
|
359
|
+
|
|
360
|
+
// Rule ⑧, probed on a synthesized non-vetoable variant rather than on a
|
|
361
|
+
// fixture flag: an adapter whose COVERAGE marks every reachable call class
|
|
362
|
+
// as gated can never PARSE a non-vetoable event, so a fixture-driven check
|
|
363
|
+
// would sit vacuous on exactly the adapters this rule exists for. Only
|
|
364
|
+
// pre-tool events are probed — a post-tool/prompt event has no veto to lose,
|
|
365
|
+
// so its deny and allow renders may legitimately coincide.
|
|
366
|
+
if (parsed.event !== EventKind.PRE_TOOL) continue;
|
|
367
|
+
preToolCases += 1;
|
|
368
|
+
const unenforceable = { ...parsed, this_call_vetoable: false };
|
|
369
|
+
const deniedRender = adapter.render(
|
|
370
|
+
UNENFORCEABLE_DENY_PROBE,
|
|
371
|
+
unenforceable,
|
|
372
|
+
);
|
|
373
|
+
assert.equal(
|
|
374
|
+
deniedRender.enforced,
|
|
375
|
+
false,
|
|
376
|
+
`unenforceable deny rendered as enforced: ${testCase.name}`,
|
|
377
|
+
);
|
|
378
|
+
// Counted before the OBSERVE_ONLY exemption: the honesty half above ran, so
|
|
379
|
+
// an adapter whose every pre-tool case is observe-only has still been
|
|
380
|
+
// checked and must not fail the non-vacuity assertion below.
|
|
381
|
+
unenforceableDenyChecks += 1;
|
|
382
|
+
// An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
|
|
383
|
+
// no pre-emption channel at all, so its deny and allow are identical by
|
|
384
|
+
// construction. Demanding a distinct signal there would demand a fiction.
|
|
385
|
+
if (deniedRender.transport === IntegrationMode.OBSERVE_ONLY) continue;
|
|
386
|
+
assert.notDeepEqual(
|
|
387
|
+
deniedRender,
|
|
388
|
+
adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
|
|
389
|
+
`unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
|
|
390
|
+
);
|
|
310
391
|
}
|
|
311
392
|
|
|
312
393
|
for (const decision of ["allow", "deny", "ask"]) {
|
|
@@ -323,7 +404,34 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
323
404
|
enforcedDenySeen,
|
|
324
405
|
"no enforced deny rendered — enforcement honesty is untested",
|
|
325
406
|
);
|
|
407
|
+
// Rule ⑨, probed on a synthesized payload rather than a fixture flag: an
|
|
408
|
+
// adapter whose parse can never emit UNKNOWN — amp routes every payload to
|
|
409
|
+
// pre_tool — has no such fixture to write, so a fixture-driven check would sit
|
|
410
|
+
// vacuous on exactly the adapters this rule covers.
|
|
411
|
+
const drifted = adapter.parse({ hook_event_name: UNMODELLED_EVENT_NAME });
|
|
412
|
+
if (drifted.event === EventKind.UNKNOWN) {
|
|
413
|
+
assert.equal(
|
|
414
|
+
drifted.this_call_vetoable,
|
|
415
|
+
false,
|
|
416
|
+
"an unmodelled event parsed as vetoable",
|
|
417
|
+
);
|
|
418
|
+
assert.equal(
|
|
419
|
+
adapter.render(UNENFORCEABLE_DENY_PROBE, drifted).enforced,
|
|
420
|
+
false,
|
|
421
|
+
"a deny on an unmodelled event rendered as an enforced block",
|
|
422
|
+
);
|
|
423
|
+
unknownKindSeen = true;
|
|
424
|
+
}
|
|
326
425
|
assert.ok(renders > 0, "conformance fixtures render nothing");
|
|
426
|
+
// EVERY pre-tool case, not merely one: the count is what catches rule ⑧ being
|
|
427
|
+
// skipped by a stray `continue` rather than merely present somewhere. A suite
|
|
428
|
+
// with no pre-tool case has no veto surface to lose, and rule ⑤'s own
|
|
429
|
+
// `vetoableDenySeen` guard already forces enforcement honesty there.
|
|
430
|
+
assert.equal(
|
|
431
|
+
unenforceableDenyChecks,
|
|
432
|
+
preToolCases,
|
|
433
|
+
`rule ⑧ (unenforceable deny ≠ allow) ran on ${unenforceableDenyChecks} of ${preToolCases} pre-tool cases`,
|
|
434
|
+
);
|
|
327
435
|
|
|
328
436
|
return {
|
|
329
437
|
cases: fixtures.cases.length,
|
|
@@ -332,6 +440,8 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
332
440
|
mutationSeen,
|
|
333
441
|
enforcedDenySeen,
|
|
334
442
|
vetoableDenySeen,
|
|
443
|
+
unknownKindSeen,
|
|
335
444
|
coverageClassesChecked,
|
|
445
|
+
unenforceableDenyChecks,
|
|
336
446
|
};
|
|
337
447
|
}
|
package/src/control-plane.mjs
CHANGED
|
@@ -242,11 +242,15 @@ const COVERAGE_STATUS_VALUES = new Set(Object.values(CoverageStatus));
|
|
|
242
242
|
* forbid it — an unknown is fail-closed to uncovered, which is the whole point
|
|
243
243
|
* of the matrix. Throws on an unrecognized status (fail loud — a typo must not
|
|
244
244
|
* quietly read as "permitted").
|
|
245
|
-
*
|
|
245
|
+
*
|
|
246
|
+
* `undefined` is accepted as an INPUT type — that is what a prototype-safe
|
|
247
|
+
* `lookup` of a missing key yields — and takes the same throw: a coverage a
|
|
248
|
+
* caller could not resolve must never read as "permitted" either.
|
|
249
|
+
* @param {string|undefined} status a {@link CoverageStatus} value
|
|
246
250
|
* @returns {boolean}
|
|
247
251
|
*/
|
|
248
252
|
export function coverageAllowsVeto(status) {
|
|
249
|
-
if (!COVERAGE_STATUS_VALUES.has(status)) {
|
|
253
|
+
if (!COVERAGE_STATUS_VALUES.has(/** @type {string} */ (status))) {
|
|
250
254
|
throw new Error(
|
|
251
255
|
`control-plane: invalid coverage status ${JSON.stringify(status)}`,
|
|
252
256
|
);
|
|
@@ -254,6 +258,52 @@ export function coverageAllowsVeto(status) {
|
|
|
254
258
|
return status === CoverageStatus.COVERED || status === CoverageStatus.PARTIAL;
|
|
255
259
|
}
|
|
256
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
|
+
|
|
257
307
|
/**
|
|
258
308
|
* True when `status` is a recognized {@link CoverageStatus} value.
|
|
259
309
|
* @param {unknown} status
|
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/adapters/amp.d.mts
CHANGED
|
@@ -30,6 +30,12 @@ export const INTEGRATION_MODE: "external_hook";
|
|
|
30
30
|
*/
|
|
31
31
|
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
32
32
|
export const COVERAGE: import("../control-plane.mjs").CoverageMap;
|
|
33
|
+
/**
|
|
34
|
+
* 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.
|
|
35
|
+
* A kind absent here parses non-vetoable, so an unmodelled event never renders as
|
|
36
|
+
* an enforced block the host will not perform.
|
|
37
|
+
*/
|
|
38
|
+
export const GATED_EVENTS: Readonly<Set<"pre_tool">>;
|
|
33
39
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
34
40
|
export const ampAdapter: import("../control-plane.mjs").Adapter;
|
|
35
41
|
export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
|
|
@@ -41,6 +41,14 @@ export const INTEGRATION_MODE: "external_hook";
|
|
|
41
41
|
*/
|
|
42
42
|
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
43
43
|
export const COVERAGE: import("../control-plane.mjs").CoverageMap;
|
|
44
|
+
/**
|
|
45
|
+
* The event kinds this host can actually gate. Claude Code honours a deny on these three. SESSION_START is left out: exit 2 does
|
|
46
|
+
* not abort a session start, and the matrix documents no host response for it — an
|
|
47
|
+
* undocumented kind is fail-closed to non-vetoable, the same rule coverage uses.
|
|
48
|
+
* A kind absent here parses non-vetoable, so an unmodelled event never renders as
|
|
49
|
+
* an enforced block the host will not perform.
|
|
50
|
+
*/
|
|
51
|
+
export const GATED_EVENTS: Readonly<Set<"pre_tool" | "post_tool" | "prompt_submit">>;
|
|
44
52
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
45
53
|
export const HookEvent: Readonly<{
|
|
46
54
|
PRE_TOOL_USE: "PreToolUse";
|
|
@@ -59,6 +59,13 @@ export const INTEGRATION_MODE: "external_hook";
|
|
|
59
59
|
*/
|
|
60
60
|
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
61
61
|
export const COVERAGE: import("../control-plane.mjs").CoverageMap;
|
|
62
|
+
/**
|
|
63
|
+
* The event kinds this host can actually gate. `PreToolUse` is the one event Codex can veto. Every other native event parses as
|
|
64
|
+
* UNKNOWN today, so without this set each of them reported an enforced block.
|
|
65
|
+
* A kind absent here parses non-vetoable, so an unmodelled event never renders as
|
|
66
|
+
* an enforced block the host will not perform.
|
|
67
|
+
*/
|
|
68
|
+
export const GATED_EVENTS: Readonly<Set<"pre_tool">>;
|
|
62
69
|
/** Minimum Codex version whose hook can actually veto a tool call. */
|
|
63
70
|
export const MIN_ENFORCING_VERSION: readonly number[];
|
|
64
71
|
export const DEFAULT_DENY_REASON: "blocked by monitor";
|
|
@@ -43,6 +43,12 @@ export const INTEGRATION_MODE: "external_hook";
|
|
|
43
43
|
*/
|
|
44
44
|
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
45
45
|
export const COVERAGE: import("../control-plane.mjs").CoverageMap;
|
|
46
|
+
/**
|
|
47
|
+
* The event kinds this host can actually gate. Gemini CLI honours a deny on each of the three events NATIVE_TO_KIND maps.
|
|
48
|
+
* A kind absent here parses non-vetoable, so an unmodelled event never renders as
|
|
49
|
+
* an enforced block the host will not perform.
|
|
50
|
+
*/
|
|
51
|
+
export const GATED_EVENTS: Readonly<Set<"pre_tool" | "post_tool" | "prompt_submit">>;
|
|
46
52
|
/** Gemini CLI native hook event names (the `hook_event_name` field). */
|
|
47
53
|
export const HookEvent: Readonly<{
|
|
48
54
|
BEFORE_TOOL: "BeforeTool";
|
package/types/conformance.d.mts
CHANGED
|
@@ -89,13 +89,21 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
|
|
|
89
89
|
* permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
|
|
90
90
|
* `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
|
|
91
91
|
* while parsing its calls as vetoable.
|
|
92
|
+
* 8. unenforceable deny ≠ allow: for every pre-tool fixture event, rendering a
|
|
93
|
+
* DENY over a non-vetoable variant of that event must produce a response
|
|
94
|
+
* that differs from the same adapter's abstaining `allow` render. Rule ⑤
|
|
95
|
+
* only says such a deny is not `enforced`; without this, an adapter is free
|
|
96
|
+
* to collapse it onto the host's literal "run it" signal (Amp's exit 0),
|
|
97
|
+
* throwing away the objection entirely. The strongest honest signal a
|
|
98
|
+
* transport has left — an ask, an advisory body — must survive. An
|
|
99
|
+
* OBSERVE_ONLY render is exempt: it has no pre-emption channel to differ in.
|
|
92
100
|
*
|
|
93
101
|
* `assert` is injected (node:assert/strict) so the harness stays test-framework
|
|
94
102
|
* neutral; it throws on the first mismatch. Returns a summary the caller can
|
|
95
103
|
* assert further on.
|
|
96
104
|
*
|
|
97
105
|
* @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
|
|
98
|
-
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string
|
|
106
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
99
107
|
*/
|
|
100
108
|
export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
101
109
|
adapter: import("./control-plane.mjs").Adapter;
|
|
@@ -108,5 +116,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
|
108
116
|
mutationSeen: boolean;
|
|
109
117
|
enforcedDenySeen: boolean;
|
|
110
118
|
vetoableDenySeen: boolean;
|
|
119
|
+
unknownKindSeen: boolean;
|
|
111
120
|
coverageClassesChecked: Set<string>;
|
|
121
|
+
unenforceableDenyChecks: number;
|
|
112
122
|
};
|
|
@@ -38,10 +38,36 @@ export function lookup<T>(map: Record<string, T>, key: string): T | undefined;
|
|
|
38
38
|
* forbid it — an unknown is fail-closed to uncovered, which is the whole point
|
|
39
39
|
* of the matrix. Throws on an unrecognized status (fail loud — a typo must not
|
|
40
40
|
* quietly read as "permitted").
|
|
41
|
-
*
|
|
41
|
+
*
|
|
42
|
+
* `undefined` is accepted as an INPUT type — that is what a prototype-safe
|
|
43
|
+
* `lookup` of a missing key yields — and takes the same throw: a coverage a
|
|
44
|
+
* caller could not resolve must never read as "permitted" either.
|
|
45
|
+
* @param {string|undefined} status a {@link CoverageStatus} value
|
|
42
46
|
* @returns {boolean}
|
|
43
47
|
*/
|
|
44
|
-
export function coverageAllowsVeto(status: string): boolean;
|
|
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;
|
|
45
71
|
/**
|
|
46
72
|
* True when `status` is a recognized {@link CoverageStatus} value.
|
|
47
73
|
* @param {unknown} status
|