agent-control-plane-core 0.4.2 → 0.4.3
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 +66 -2
- package/src/conformance.mjs +78 -6
- package/src/control-plane.mjs +6 -2
- package/types/conformance.d.mts +10 -1
- package/types/control-plane.d.mts +6 -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.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
classifyCallClass,
|
|
21
21
|
coverageAllowsVeto,
|
|
22
22
|
canonicalTool,
|
|
23
|
+
lookup,
|
|
23
24
|
makeEvent,
|
|
24
25
|
normalizeVerdict,
|
|
25
26
|
nativeResponse,
|
|
@@ -87,6 +88,49 @@ export function parse(native) {
|
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
|
|
93
|
+
* pure exit-code transport: 0 allow / 1 ask / 2 reject.
|
|
94
|
+
*
|
|
95
|
+
* Written as a total table rather than a ternary chain because the chain's
|
|
96
|
+
* fall-through case was ALLOW: an unenforceable deny — a deny on a call this
|
|
97
|
+
* guardrail cannot veto — silently rendered as exit 0, Amp's "run it". A
|
|
98
|
+
* combination this table forgets is a construction error (below), not a silent
|
|
99
|
+
* approval.
|
|
100
|
+
*
|
|
101
|
+
* The non-vetoable DENY row is exit 1 (ask), not 0. Amp has a real ask tier, so
|
|
102
|
+
* the honest render of "I object but cannot block" is to put the call in front
|
|
103
|
+
* of the human rather than wave it through. It is still `enforced: false` — the
|
|
104
|
+
* guardrail is not claiming a veto it does not have; it is declining to spend
|
|
105
|
+
* its one remaining signal on an approval.
|
|
106
|
+
*
|
|
107
|
+
* @type {Readonly<Record<string, Readonly<Record<string, number>>>>}
|
|
108
|
+
*/
|
|
109
|
+
const EXIT_CODE_BY_DECISION = Object.freeze({
|
|
110
|
+
[Decision.ALLOW]: Object.freeze({ true: 0, false: 0 }),
|
|
111
|
+
[Decision.DENY]: Object.freeze({ true: 2, false: 1 }),
|
|
112
|
+
[Decision.ASK]: Object.freeze({ true: 1, false: 1 }),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Totality check at IMPORT: every {@link Decision} must have a row and every row
|
|
117
|
+
* both vetoable columns. A decision added to the contract breaks this module
|
|
118
|
+
* loudly at load instead of silently defaulting to exit 0.
|
|
119
|
+
*/
|
|
120
|
+
for (const decision of Object.values(Decision)) {
|
|
121
|
+
const row = lookup(EXIT_CODE_BY_DECISION, decision);
|
|
122
|
+
if (row === undefined)
|
|
123
|
+
throw new Error(
|
|
124
|
+
`amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
|
|
125
|
+
);
|
|
126
|
+
for (const vetoable of ["true", "false"]) {
|
|
127
|
+
if (typeof lookup(row, vetoable) === "number") continue;
|
|
128
|
+
throw new Error(
|
|
129
|
+
`amp adapter: exit-code table row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
90
134
|
/**
|
|
91
135
|
* Render into Amp's pure exit-code transport: the decision is the exit code,
|
|
92
136
|
* with no stdout body. `reason` has no native channel here, so it is dropped
|
|
@@ -99,8 +143,28 @@ export function parse(native) {
|
|
|
99
143
|
*/
|
|
100
144
|
export function render(verdict, event) {
|
|
101
145
|
const vd = normalizeVerdict(verdict);
|
|
102
|
-
const
|
|
103
|
-
|
|
146
|
+
const vetoable = event.this_call_vetoable;
|
|
147
|
+
// `makeEvent` already rejects a non-boolean, so reaching this means a
|
|
148
|
+
// hand-built event — and guessing whether it can be vetoed is how fail-open
|
|
149
|
+
// starts. The string "true" is the case that makes this a real check rather
|
|
150
|
+
// than a formality: it would otherwise index the vetoable column.
|
|
151
|
+
if (typeof vetoable !== "boolean")
|
|
152
|
+
throw new Error(
|
|
153
|
+
`amp adapter: this_call_vetoable must be a boolean, got ${JSON.stringify(vetoable)}`,
|
|
154
|
+
);
|
|
155
|
+
const enforced = vd.decision === Decision.DENY && vetoable;
|
|
156
|
+
const exit_code = lookup(
|
|
157
|
+
lookup(EXIT_CODE_BY_DECISION, vd.decision) ?? {},
|
|
158
|
+
String(vetoable),
|
|
159
|
+
);
|
|
160
|
+
// Unreachable while the import-time totality check and `normalizeVerdict` both
|
|
161
|
+
// hold; kept because the alternative to throwing is `exit_code: undefined`,
|
|
162
|
+
// which `process.exit` renders as 0 — the exact silent allow this table exists
|
|
163
|
+
// to eliminate.
|
|
164
|
+
if (exit_code === undefined)
|
|
165
|
+
throw new Error(
|
|
166
|
+
`amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
|
|
167
|
+
);
|
|
104
168
|
return nativeResponse({ transport: INTEGRATION_MODE, exit_code, enforced });
|
|
105
169
|
}
|
|
106
170
|
|
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,16 @@ 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
|
+
|
|
11
24
|
/**
|
|
12
25
|
* Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
|
|
13
26
|
* hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
|
|
@@ -66,14 +79,17 @@ export function assertToolAliasesCovered(
|
|
|
66
79
|
let witnessed = witnessedByAgent.get(fixtures.agent);
|
|
67
80
|
if (witnessed === undefined)
|
|
68
81
|
witnessedByAgent.set(fixtures.agent, (witnessed = new Map()));
|
|
69
|
-
|
|
82
|
+
// `fixtures.agent` and the native tool names below are ADAPTER-SUPPLIED
|
|
83
|
+
// strings; a bare index would resolve `constructor`/`__proto__` to an
|
|
84
|
+
// inherited Object.prototype member instead of missing. See `lookup`.
|
|
85
|
+
const scopedMap = lookup(adapterAliases, fixtures.agent) ?? {};
|
|
70
86
|
for (const testCase of fixtures.cases) {
|
|
71
87
|
const nativeTool = testCase.event?.meta?.native_tool;
|
|
72
88
|
if (typeof nativeTool !== "string") continue;
|
|
73
89
|
const canon = testCase.event.tool;
|
|
74
90
|
const allowed = new Set([canonicalTool(nativeTool)]);
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
const scopedTarget = lookup(scopedMap, nativeTool);
|
|
92
|
+
if (scopedTarget !== undefined) allowed.add(scopedTarget);
|
|
77
93
|
assert.ok(
|
|
78
94
|
allowed.has(canon),
|
|
79
95
|
`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 +211,21 @@ function assertAliasedInput(agent, caseName, event, assert) {
|
|
|
195
211
|
* permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
|
|
196
212
|
* `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
|
|
197
213
|
* while parsing its calls as vetoable.
|
|
214
|
+
* 8. unenforceable deny ≠ allow: for every pre-tool fixture event, rendering a
|
|
215
|
+
* DENY over a non-vetoable variant of that event must produce a response
|
|
216
|
+
* that differs from the same adapter's abstaining `allow` render. Rule ⑤
|
|
217
|
+
* only says such a deny is not `enforced`; without this, an adapter is free
|
|
218
|
+
* to collapse it onto the host's literal "run it" signal (Amp's exit 0),
|
|
219
|
+
* throwing away the objection entirely. The strongest honest signal a
|
|
220
|
+
* transport has left — an ask, an advisory body — must survive. An
|
|
221
|
+
* OBSERVE_ONLY render is exempt: it has no pre-emption channel to differ in.
|
|
198
222
|
*
|
|
199
223
|
* `assert` is injected (node:assert/strict) so the harness stays test-framework
|
|
200
224
|
* neutral; it throws on the first mismatch. Returns a summary the caller can
|
|
201
225
|
* assert further on.
|
|
202
226
|
*
|
|
203
227
|
* @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
|
|
228
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
205
229
|
*/
|
|
206
230
|
export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
207
231
|
assert.equal(
|
|
@@ -219,6 +243,8 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
219
243
|
let mutationSeen = false;
|
|
220
244
|
let enforcedDenySeen = false;
|
|
221
245
|
let vetoableDenySeen = false;
|
|
246
|
+
let preToolCases = 0;
|
|
247
|
+
let unenforceableDenyChecks = 0;
|
|
222
248
|
let renders = 0;
|
|
223
249
|
|
|
224
250
|
for (const testCase of fixtures.cases) {
|
|
@@ -239,11 +265,15 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
239
265
|
CALL_CLASSES.includes(testCase.call_class),
|
|
240
266
|
`unknown call_class '${testCase.call_class}': ${testCase.name}`,
|
|
241
267
|
);
|
|
242
|
-
|
|
268
|
+
// Fixture-supplied key ⇒ prototype-safe read (see `lookup`): a
|
|
269
|
+
// `call_class` naming an Object.prototype member must MISS, not resolve a
|
|
270
|
+
// function that `coverageAllowsVeto` would then reject or accept by luck.
|
|
271
|
+
const coverage = lookup(adapter.COVERAGE, testCase.call_class);
|
|
272
|
+
if (!coverageAllowsVeto(coverage)) {
|
|
243
273
|
assert.equal(
|
|
244
274
|
parsed.this_call_vetoable,
|
|
245
275
|
false,
|
|
246
|
-
`call_class '${testCase.call_class}' is ${
|
|
276
|
+
`call_class '${testCase.call_class}' is ${coverage} (no veto) but parsed this_call_vetoable !== false: ${testCase.name}`,
|
|
247
277
|
);
|
|
248
278
|
}
|
|
249
279
|
coverageClassesChecked.add(testCase.call_class);
|
|
@@ -307,6 +337,38 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
307
337
|
if (spec.verdict.mutated_input !== undefined) mutationSeen = true;
|
|
308
338
|
renders += 1;
|
|
309
339
|
}
|
|
340
|
+
|
|
341
|
+
// Rule ⑧, probed on a synthesized non-vetoable variant rather than on a
|
|
342
|
+
// fixture flag: an adapter whose COVERAGE marks every reachable call class
|
|
343
|
+
// as gated can never PARSE a non-vetoable event, so a fixture-driven check
|
|
344
|
+
// would sit vacuous on exactly the adapters this rule exists for. Only
|
|
345
|
+
// pre-tool events are probed — a post-tool/prompt event has no veto to lose,
|
|
346
|
+
// so its deny and allow renders may legitimately coincide.
|
|
347
|
+
if (parsed.event !== EventKind.PRE_TOOL) continue;
|
|
348
|
+
preToolCases += 1;
|
|
349
|
+
const unenforceable = { ...parsed, this_call_vetoable: false };
|
|
350
|
+
const deniedRender = adapter.render(
|
|
351
|
+
UNENFORCEABLE_DENY_PROBE,
|
|
352
|
+
unenforceable,
|
|
353
|
+
);
|
|
354
|
+
assert.equal(
|
|
355
|
+
deniedRender.enforced,
|
|
356
|
+
false,
|
|
357
|
+
`unenforceable deny rendered as enforced: ${testCase.name}`,
|
|
358
|
+
);
|
|
359
|
+
// Counted before the OBSERVE_ONLY exemption: the honesty half above ran, so
|
|
360
|
+
// an adapter whose every pre-tool case is observe-only has still been
|
|
361
|
+
// checked and must not fail the non-vacuity assertion below.
|
|
362
|
+
unenforceableDenyChecks += 1;
|
|
363
|
+
// An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
|
|
364
|
+
// no pre-emption channel at all, so its deny and allow are identical by
|
|
365
|
+
// construction. Demanding a distinct signal there would demand a fiction.
|
|
366
|
+
if (deniedRender.transport === IntegrationMode.OBSERVE_ONLY) continue;
|
|
367
|
+
assert.notDeepEqual(
|
|
368
|
+
deniedRender,
|
|
369
|
+
adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
|
|
370
|
+
`unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
|
|
371
|
+
);
|
|
310
372
|
}
|
|
311
373
|
|
|
312
374
|
for (const decision of ["allow", "deny", "ask"]) {
|
|
@@ -324,6 +386,15 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
324
386
|
"no enforced deny rendered — enforcement honesty is untested",
|
|
325
387
|
);
|
|
326
388
|
assert.ok(renders > 0, "conformance fixtures render nothing");
|
|
389
|
+
// EVERY pre-tool case, not merely one: the count is what catches rule ⑧ being
|
|
390
|
+
// skipped by a stray `continue` rather than merely present somewhere. A suite
|
|
391
|
+
// with no pre-tool case has no veto surface to lose, and rule ⑤'s own
|
|
392
|
+
// `vetoableDenySeen` guard already forces enforcement honesty there.
|
|
393
|
+
assert.equal(
|
|
394
|
+
unenforceableDenyChecks,
|
|
395
|
+
preToolCases,
|
|
396
|
+
`rule ⑧ (unenforceable deny ≠ allow) ran on ${unenforceableDenyChecks} of ${preToolCases} pre-tool cases`,
|
|
397
|
+
);
|
|
327
398
|
|
|
328
399
|
return {
|
|
329
400
|
cases: fixtures.cases.length,
|
|
@@ -333,5 +404,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
|
|
|
333
404
|
enforcedDenySeen,
|
|
334
405
|
vetoableDenySeen,
|
|
335
406
|
coverageClassesChecked,
|
|
407
|
+
unenforceableDenyChecks,
|
|
336
408
|
};
|
|
337
409
|
}
|
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
|
);
|
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, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
99
107
|
*/
|
|
100
108
|
export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
101
109
|
adapter: import("./control-plane.mjs").Adapter;
|
|
@@ -109,4 +117,5 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
|
109
117
|
enforcedDenySeen: boolean;
|
|
110
118
|
vetoableDenySeen: boolean;
|
|
111
119
|
coverageClassesChecked: Set<string>;
|
|
120
|
+
unenforceableDenyChecks: number;
|
|
112
121
|
};
|
|
@@ -38,10 +38,14 @@ 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;
|
|
45
49
|
/**
|
|
46
50
|
* True when `status` is a recognized {@link CoverageStatus} value.
|
|
47
51
|
* @param {unknown} status
|