agent-control-plane-core 0.6.4 → 0.8.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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * JSON Schema (draft 2020-12) for the two wire shapes, for a consumer that is
3
+ * not JavaScript.
4
+ *
5
+ * A Python guardrail cannot import {@link ToolCallEvent} from this package, so
6
+ * it hand-writes the field names it reads and drifts silently: the contract
7
+ * renames a field, the hand-written reader sees `undefined`, and it takes its
8
+ * no-value branch instead of failing. These documents are the machine-readable
9
+ * form of the same contract, so that reader validates against the seam rather
10
+ * than against its own copy.
11
+ *
12
+ * EVERY ENUMERATED VALUE IS DERIVED, never typed a second time — each one is
13
+ * read off the frozen export that owns it: {@link EventKind},
14
+ * {@link Decision}, {@link IntegrationMode}, {@link STANDARD_META_FIELDS},
15
+ * {@link SCHEMA_VERSION}, {@link MODELED_TOOL_INPUT_KEYS}. The files under
16
+ * `schema/` are generated output that `json-schema.test.mjs` regenerates and
17
+ * byte-compares, not a second contract.
18
+ *
19
+ * FIELD NAMES ARE WRITTEN HERE, because the contract exports no list of them —
20
+ * the root fields and the always-present `EventMeta` fields are typedefs, which
21
+ * do not survive to runtime. So a renamed contract field is caught rather than
22
+ * declared: `json-schema.test.mjs` re-parses every golden native payload through
23
+ * the LIVE adapter and validates the result, and `additionalProperties: false`
24
+ * turns the rename into a rejection. {@link VERDICT_CONTENT_FIELDS} is the one
25
+ * list that does exist, and {@link assertVerdictFieldsDeclared} holds the
26
+ * verdict document to it — in the ADDING direction only. `reason` is outside
27
+ * that list (it is not probeable from one abstaining verdict), so its schema
28
+ * here rests on the `Verdict` typedef alone.
29
+ *
30
+ * SCOPE — the documents describe the contract's DECLARED shapes, which `tsc`
31
+ * checks at build time. `normalizeVerdict` re-checks only `decision` at run
32
+ * time, so a JavaScript caller who ignores the types can hand it a numeric
33
+ * `reason` and get one back; that value fails this document, correctly.
34
+ *
35
+ * A tool `input` stays unconstrained on purpose: an adapter forwards a native
36
+ * input object verbatim, so a schema demanding a string `command` on every Bash
37
+ * event would reject events the library legitimately emits — one golden Codex
38
+ * fixture carries `command` as an array. The per-tool field a judge should read
39
+ * is published beside the documents as data instead
40
+ * ({@link MODELED_TOOL_INPUT_KEYS}, in `tool-input-keys.json`).
41
+ */
42
+
43
+ import {
44
+ CONTROL_PLANE_SCHEMA,
45
+ Decision,
46
+ EventKind,
47
+ IntegrationMode,
48
+ MODELED_TOOL_INPUT_KEYS,
49
+ SCHEMA_VERSION,
50
+ STANDARD_META_FIELDS,
51
+ VERDICT_CONTENT_FIELDS,
52
+ } from "./control-plane.mjs";
53
+
54
+ /** The JSON Schema dialect these documents are written in. */
55
+ const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
56
+
57
+ /**
58
+ * Package-relative directory the generated files live in, versioned by the WIRE
59
+ * version. A breaking shape change bumps {@link CONTROL_PLANE_SCHEMA} and so
60
+ * writes a new directory, leaving the previous one in place for a consumer
61
+ * still on it.
62
+ *
63
+ * A document is THIS RELEASE's rendering of that wire version, not a statement
64
+ * about every release that will carry it. Adding an optional field is
65
+ * backward-compatible and stays at v1 (see the README's Versioning section), so
66
+ * the next release's document may carry a property this one does not. That is
67
+ * why the files ship inside the package: a consumer reads the schema from the
68
+ * same installed version that produced the event, and the two move together.
69
+ * Validate against the installed file, never a copy pinned by hand.
70
+ *
71
+ * Neither document carries an `$id`. A URL naming a mutable branch would resolve
72
+ * to whatever the contract says LATER, and two installed versions declaring one
73
+ * `$id` collide inside a single validator instance. `$ref` here is
74
+ * document-relative, so nothing needs one.
75
+ */
76
+ export const SCHEMA_DIR = `schema/${CONTROL_PLANE_SCHEMA}`;
77
+
78
+ /** Basename of each generated document, keyed by the shape it describes. */
79
+ export const SCHEMA_FILES = Object.freeze({
80
+ ToolCallEvent: "tool-call-event.schema.json",
81
+ Verdict: "verdict.schema.json",
82
+ });
83
+
84
+ /**
85
+ * Basename of the per-tool input-key map, published as plain DATA rather than
86
+ * inside the event document. A vendor keyword there (`x-modeled-tool-input-keys`)
87
+ * is legal JSON Schema, but ajv refuses an unknown keyword under its default
88
+ * options, so the document a JavaScript consumer installed would not compile
89
+ * until that consumer registered a vocabulary it has no reason to know about.
90
+ */
91
+ export const TOOL_INPUT_KEYS_FILE = "tool-input-keys.json";
92
+
93
+ /**
94
+ * The schema for each optional {@link Verdict} field. Kept beside the
95
+ * assertion below rather than inlined, so a content field added to
96
+ * {@link VERDICT_CONTENT_FIELDS} fails loud here instead of quietly dropping
97
+ * out of the published document — a consumer would then read a field the
98
+ * schema calls forbidden and reject a verdict this library produces.
99
+ */
100
+ const VERDICT_FIELD_SCHEMAS = Object.freeze({
101
+ mutated_input: {
102
+ type: "object",
103
+ description:
104
+ "Replacement tool input (pre_tool). A data channel, carried verbatim.",
105
+ },
106
+ mutated_output: {
107
+ description:
108
+ "Replacement tool output (post_tool): a string or the tool's structured output, verbatim. Any JSON value.",
109
+ },
110
+ additional_context: {
111
+ type: "string",
112
+ description: "Extra context to splice into the agent's stream.",
113
+ },
114
+ reason: {
115
+ type: "string",
116
+ description: "Human-readable rationale, shown on deny and ask.",
117
+ },
118
+ });
119
+
120
+ /**
121
+ * Assert every content field the contract models has a schema above. Throws
122
+ * (fail loud) on the first that does not: `additionalProperties: false` makes an
123
+ * undeclared field a REJECTION rather than an omission, so a consumer would
124
+ * refuse a verdict this library produces. Called at import against the live
125
+ * {@link VERDICT_CONTENT_FIELDS}; exported so a test can drive both branches.
126
+ * @param {readonly string[]} fields
127
+ * @param {Record<string, unknown>} declared
128
+ */
129
+ export function assertVerdictFieldsDeclared(fields, declared) {
130
+ for (const field of fields)
131
+ if (!Object.hasOwn(declared, field))
132
+ throw new Error(
133
+ `json-schema: verdict content field ${JSON.stringify(field)} has no declared schema — add one to VERDICT_FIELD_SCHEMAS`,
134
+ );
135
+ }
136
+
137
+ assertVerdictFieldsDeclared(VERDICT_CONTENT_FIELDS, VERDICT_FIELD_SCHEMAS);
138
+
139
+ /**
140
+ * The `EventMeta` subschema. The optional string fields are
141
+ * {@link STANDARD_META_FIELDS} plus `native_tool`; `string` is not a guess for
142
+ * them, it is what `baseMeta` enforces (a non-string value leaves the field
143
+ * absent rather than stamping a number onto a field consumers read as text).
144
+ * @returns {Record<string, unknown>}
145
+ */
146
+ function eventMetaSchema() {
147
+ /** @type {Record<string, unknown>} */
148
+ const properties = {
149
+ agent: {
150
+ type: "string",
151
+ description: 'Producing agent id ("claude", "codex", …).',
152
+ },
153
+ native_event: {
154
+ type: "string",
155
+ description: "Original native event name, preserved verbatim.",
156
+ },
157
+ integration_mode: {
158
+ enum: Object.values(IntegrationMode),
159
+ description: "How the guardrail attaches to the host.",
160
+ },
161
+ primary_gate_present: {
162
+ type: "boolean",
163
+ description:
164
+ "The agent's own native gate already ran, so this verdict is a second opinion.",
165
+ },
166
+ passthrough: {
167
+ type: "object",
168
+ description: "Unmodelled native top-level fields, verbatim.",
169
+ },
170
+ native_tool: {
171
+ type: "string",
172
+ description:
173
+ "Original native tool name, present iff the event carries a tool.",
174
+ },
175
+ };
176
+ for (const field of STANDARD_META_FIELDS)
177
+ properties[field] = { type: "string" };
178
+ return {
179
+ type: "object",
180
+ required: [
181
+ "agent",
182
+ "native_event",
183
+ "integration_mode",
184
+ "primary_gate_present",
185
+ "passthrough",
186
+ ],
187
+ properties,
188
+ additionalProperties: false,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * The document describing a normalized {@link ToolCallEvent}.
194
+ *
195
+ * `additionalProperties: false` is honest here rather than strict: `makeEvent`
196
+ * builds every event, and the unmodelled remainder of a native payload lands in
197
+ * `meta.passthrough`, so an extra key at the root is a hand-rolled event, not a
198
+ * newer library's.
199
+ * @returns {Record<string, unknown>}
200
+ */
201
+ export function toolCallEventSchema() {
202
+ return {
203
+ $schema: JSON_SCHEMA_DIALECT,
204
+ title: "ToolCallEvent",
205
+ description: `A normalized, agent-agnostic view of one agent event (${CONTROL_PLANE_SCHEMA}).`,
206
+ type: "object",
207
+ required: [
208
+ "schema_version",
209
+ "event",
210
+ "tool",
211
+ "input",
212
+ "this_call_vetoable",
213
+ "meta",
214
+ ],
215
+ properties: {
216
+ schema_version: { const: SCHEMA_VERSION },
217
+ event: { enum: Object.values(EventKind) },
218
+ tool: {
219
+ type: ["string", "null"],
220
+ description:
221
+ "Canonical tool name; null for prompt and session events. The raw native name is on meta.native_tool.",
222
+ },
223
+ input: {
224
+ type: "object",
225
+ description:
226
+ "Passthrough tool input, verbatim; a submitted prompt is folded into input.prompt.",
227
+ },
228
+ response: {
229
+ description: "Tool output, post_tool only: any JSON value, verbatim.",
230
+ },
231
+ this_call_vetoable: {
232
+ type: "boolean",
233
+ description:
234
+ "false ⇒ the guardrail cannot veto THIS call, so a deny must degrade to a notification.",
235
+ },
236
+ meta: { $ref: "#/$defs/EventMeta" },
237
+ },
238
+ additionalProperties: false,
239
+ // Two claims a producer must not make, both the same fail-open: a veto the
240
+ // host will never perform, read downstream as a block that happened.
241
+ //
242
+ // An event the adapter could not name has no host response to veto, so a
243
+ // vetoable UNKNOWN is that claim. `makeEvent` throws on one. An OBSERVE_ONLY
244
+ // transport reads a transcript and cannot pre-empt anything, so a vetoable
245
+ // event on one is the same claim by a different route — every shipped
246
+ // adapter derives both flags from one "can this host enforce" answer, but
247
+ // `makeEvent` does NOT refuse it, so this document is the only place a
248
+ // producer is held to it.
249
+ allOf: [
250
+ vetoImpossibleWhen({ event: { const: EventKind.UNKNOWN } }),
251
+ vetoImpossibleWhen({
252
+ meta: {
253
+ required: ["integration_mode"],
254
+ properties: {
255
+ integration_mode: { const: IntegrationMode.OBSERVE_ONLY },
256
+ },
257
+ },
258
+ }),
259
+ ],
260
+ $defs: { EventMeta: eventMetaSchema() },
261
+ };
262
+ }
263
+
264
+ /**
265
+ * A conditional subschema forcing `this_call_vetoable: false` wherever `match`
266
+ * holds. `required` names every key `match` constrains, because an absent key
267
+ * satisfies a bare `properties` and would leave the `then` branch dead.
268
+ * @param {Record<string, unknown>} match
269
+ * @returns {Record<string, unknown>}
270
+ */
271
+ function vetoImpossibleWhen(match) {
272
+ return {
273
+ if: { required: Object.keys(match), properties: match },
274
+ then: { properties: { this_call_vetoable: { const: false } } },
275
+ };
276
+ }
277
+
278
+ /**
279
+ * The document describing a normalized {@link Verdict}. Only `decision` is
280
+ * required; `normalizeVerdict` emits the optional fields only when present.
281
+ * @returns {Record<string, unknown>}
282
+ */
283
+ export function verdictSchema() {
284
+ /** @type {Record<string, unknown>} */
285
+ const properties = { decision: { enum: Object.values(Decision) } };
286
+ // Cloned, not aliased. `Object.freeze` on VERDICT_FIELD_SCHEMAS freezes the
287
+ // outer object only, so assigning the nested `{type, description}` objects
288
+ // hands every caller the same two levels down: a consumer that tightens
289
+ // `properties.reason` on the document it was given would silently retighten
290
+ // every document produced afterwards. The other builders return fresh
291
+ // structures already.
292
+ for (const [field, schema] of Object.entries(VERDICT_FIELD_SCHEMAS))
293
+ properties[field] = structuredClone(schema);
294
+ return {
295
+ $schema: JSON_SCHEMA_DIALECT,
296
+ title: "Verdict",
297
+ description: `A normalized guardrail decision (${CONTROL_PLANE_SCHEMA}).`,
298
+ type: "object",
299
+ required: ["decision"],
300
+ properties,
301
+ additionalProperties: false,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Every generated file, keyed by its package-relative path — the SSOT the
307
+ * generator writes and the freshness test reads, so neither carries its own
308
+ * list of filenames. The two `.schema.json` entries are JSON Schema documents;
309
+ * `tool-input-keys.json` is the data map {@link TOOL_INPUT_KEYS_FILE} describes.
310
+ * @returns {Record<string, Record<string, unknown>>}
311
+ */
312
+ export function jsonSchemaDocuments() {
313
+ return {
314
+ [`${SCHEMA_DIR}/${SCHEMA_FILES.ToolCallEvent}`]: toolCallEventSchema(),
315
+ [`${SCHEMA_DIR}/${SCHEMA_FILES.Verdict}`]: verdictSchema(),
316
+ [`${SCHEMA_DIR}/${TOOL_INPUT_KEYS_FILE}`]: { ...MODELED_TOOL_INPUT_KEYS },
317
+ };
318
+ }
319
+
320
+ /**
321
+ * The bytes a generated file is written as: 2-space JSON with a trailing newline.
322
+ * This is NOT Prettier's rendering — Prettier collapses a short array onto one
323
+ * line — so `schema/` is in `.prettierignore` and this function owns the bytes
324
+ * the freshness test compares. A formatter allowed to rewrite them would red
325
+ * that test on a change to nothing.
326
+ * @param {Record<string, unknown>} document
327
+ * @returns {string}
328
+ */
329
+ export function serializeSchema(document) {
330
+ return `${JSON.stringify(document, null, 2)}\n`;
331
+ }
@@ -1,21 +1,39 @@
1
1
  /**
2
- * Shared plumbing for the per-host EXTERNAL_HOOK entries — NOT a runtime host
3
- * multiplexer. Each host has its OWN entry (`bin/<host>-hook.mjs`) that hardcodes
4
- * its adapter and declares its OWN failure posture; this module only holds the
5
- * transport mechanics they genuinely share (stdin read, the demo judge, the
6
- * parse→judge→render pipe with a host-supplied fail-safe, and stdout+exit
7
- * emission). The thing item ② deleted was the `--agent` switch that let one
8
- * binary impersonate every host and so had to pick a single shared failure
9
- * behavior; sharing pure plumbing across distinct entries is not that.
2
+ * The EXTERNAL_HOOK runtime, published at `agent-control-plane-core/runtime` —
3
+ * NOT a runtime host multiplexer. Each host has its OWN entry
4
+ * (`bin/<host>-hook.mjs`) that hardcodes its adapter and declares its OWN
5
+ * failure posture; this module only holds the transport mechanics they
6
+ * genuinely share (stdin read, the demo judge, the parse→judge→render pipe with
7
+ * a host-supplied fail-safe, and stdout+exit emission). The thing item ②
8
+ * deleted was the `--agent` switch that let one binary impersonate every host
9
+ * and so had to pick a single shared failure behavior; sharing pure plumbing
10
+ * across distinct entries is not that.
11
+ *
12
+ * It is exported because every consumer writing its own hook entry had to
13
+ * re-implement the same two things, and both fail silently when they are wrong:
14
+ * draining stdin before parsing, and flushing the response IN FULL before
15
+ * `process.exit` (see {@link writeAllSync}).
10
16
  */
11
17
  import { writeSync } from "node:fs";
12
- import { Decision, sanitizeVerdict } from "../src/control-plane.mjs";
18
+ import { Decision, sanitizeVerdict } from "./control-plane.mjs";
13
19
 
14
- /** Read all of stdin to a string. */
20
+ /**
21
+ * Read all of stdin to a string.
22
+ * @returns {Promise<string>}
23
+ */
15
24
  export function readStdin() {
16
25
  return new Promise((resolve, reject) => {
26
+ /** @type {Buffer[]} */
17
27
  const chunks = [];
18
- process.stdin.on("data", (chunk) => chunks.push(chunk));
28
+ // A consumer that set an encoding on stdin hands STRINGS to this listener, and
29
+ // Buffer.concat throws on one. That throw escapes from the stream's callback,
30
+ // not from this promise, so it never reaches the caller's fail-safe: the hook
31
+ // dies with no response and a fail-open host runs the unjudged call.
32
+ process.stdin.on("data", (chunk) =>
33
+ chunks.push(
34
+ typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk,
35
+ ),
36
+ );
19
37
  process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString()));
20
38
  process.stdin.on("error", reject);
21
39
  });
@@ -25,8 +43,8 @@ export function readStdin() {
25
43
  * The DEMO policy standing in for a real guardrail judge: deny any command
26
44
  * matching /rm -rf/, allow everything else. A real deployment swaps this for its
27
45
  * own judge over the normalized {@link ToolCallEvent}.
28
- * @param {import("../src/control-plane.mjs").ToolCallEvent} event
29
- * @returns {import("../src/control-plane.mjs").Verdict}
46
+ * @param {import("./control-plane.mjs").ToolCallEvent} event
47
+ * @returns {import("./control-plane.mjs").Verdict}
30
48
  */
31
49
  export function demoJudge(event) {
32
50
  const command =
@@ -54,11 +72,11 @@ export function demoJudge(event) {
54
72
  * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
55
73
  * so the guardrail is supplied here rather than by forking this file. Whatever
56
74
  * it returns is treated as untrusted (see the clamp below).
57
- * @param {import("../src/control-plane.mjs").Adapter} adapter
75
+ * @param {import("./control-plane.mjs").Adapter} adapter
58
76
  * @param {string} rawInput
59
- * @param {import("../src/control-plane.mjs").NativeResponse} onFailure
60
- * @param {(event: import("../src/control-plane.mjs").ToolCallEvent) => import("../src/control-plane.mjs").Verdict} [judge]
61
- * @returns {import("../src/control-plane.mjs").NativeResponse}
77
+ * @param {import("./control-plane.mjs").NativeResponse} onFailure
78
+ * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
79
+ * @returns {import("./control-plane.mjs").NativeResponse}
62
80
  */
63
81
  export function renderHookResponse(
64
82
  adapter,
@@ -102,6 +120,24 @@ export function renderHookResponse(
102
120
  */
103
121
  const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
104
122
 
123
+ /**
124
+ * Whether a failed `writeSync` is the one recoverable case: EAGAIN, meaning the
125
+ * non-blocking pipe is momentarily full because the host has not drained it
126
+ * yet. The caller sleeps 1ms and retries — the same wait a blocking write would
127
+ * have done in the kernel. Every other errno (EPIPE, EBADF, ...) propagates.
128
+ *
129
+ * A non-Error throw is NOT retryable, and is checked separately rather than
130
+ * left to the `code` comparison: a plain object carrying `code: "EAGAIN"` is
131
+ * not a write that the kernel asked us to repeat, and retrying one forever is a
132
+ * hang rather than a short write. Exported so a test can drive every branch —
133
+ * the real `writeSync` only ever throws an Error.
134
+ * @param {unknown} err
135
+ * @returns {boolean}
136
+ */
137
+ export function isRetryableWriteError(err) {
138
+ return err instanceof Error && /** @type {any} */ (err).code === "EAGAIN";
139
+ }
140
+
105
141
  /**
106
142
  * Write `text` to `fd` IN FULL, looping until every byte lands.
107
143
  *
@@ -114,24 +150,23 @@ const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
114
150
  * `mutated_input`) was silently truncated and the process still exited 0/2 with
115
151
  * a half-written JSON the host cannot parse: an enforced deny degrading to a
116
152
  * run. Looping restores the blocking-write semantics the caller assumes.
153
+ *
154
+ * Exported so a test can drive the propagation path against a real closed
155
+ * descriptor; {@link emit} is the only caller a consumer needs.
117
156
  * @param {number} fd
118
157
  * @param {string} text
119
158
  */
120
- function writeAllSync(fd, text) {
159
+ export function writeAllSync(fd, text) {
121
160
  const buf = Buffer.from(text, "utf8");
122
161
  let written = 0;
123
162
  while (written < buf.length) {
124
163
  try {
125
164
  written += writeSync(fd, buf, written);
126
165
  } 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;
166
+ // There is deliberately no retry cap: a cap would reintroduce exactly the
167
+ // silent truncation this loop exists to kill, and a host that never reads
168
+ // would have blocked us forever anyway.
169
+ if (!isRetryableWriteError(err)) throw err;
135
170
  Atomics.wait(RETRY_SLEEP, 0, 0, 1);
136
171
  }
137
172
  }
@@ -140,7 +175,7 @@ function writeAllSync(fd, text) {
140
175
  /**
141
176
  * Emit a {@link NativeResponse} to the host: write the native stdout body when
142
177
  * the transport has one, then exit with the transport's exit code.
143
- * @param {import("../src/control-plane.mjs").NativeResponse} response
178
+ * @param {import("./control-plane.mjs").NativeResponse} response
144
179
  * @returns {never}
145
180
  */
146
181
  export function emit(response) {
@@ -3,6 +3,30 @@
3
3
  * @returns {ToolCallEvent}
4
4
  */
5
5
  export function parse(native: any): ToolCallEvent;
6
+ /**
7
+ * Totality check: every `decision` in `decisions` must have a row in `table`
8
+ * and every row both vetoable columns. A decision added to the contract with
9
+ * no matching row breaks loudly instead of silently defaulting to exit 0.
10
+ * Called at import against {@link Decision} and {@link EXIT_CODE_BY_DECISION};
11
+ * exported so a test can drive both branches.
12
+ * @param {readonly string[]} decisions
13
+ * @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
14
+ */
15
+ export function assertExitCodeTableTotal(decisions: readonly string[], table: Readonly<Record<string, Readonly<Record<string, number>>>>): void;
16
+ /**
17
+ * Look up the exit code for `decision`/`vetoable` in `table`. Throws instead of
18
+ * returning `undefined`: the alternative is `exit_code: undefined`, which
19
+ * `process.exit` renders as 0 — the exact silent allow this table exists to
20
+ * eliminate. Unreachable from {@link render} on the real
21
+ * {@link EXIT_CODE_BY_DECISION} while {@link assertExitCodeTableTotal} holds at
22
+ * load; exported so a test can drive both branches against a synthetic,
23
+ * deliberately incomplete table.
24
+ * @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
25
+ * @param {string} decision
26
+ * @param {boolean} vetoable
27
+ * @returns {number}
28
+ */
29
+ export function exitCodeFor(table: Readonly<Record<string, Readonly<Record<string, number>>>>, decision: string, vetoable: boolean): number;
6
30
  /**
7
31
  * Render into Amp's pure exit-code transport: the decision is the exit code,
8
32
  * with no stdout body. An ENFORCED deny also carries its `reason` on
@@ -72,6 +96,14 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
72
96
  * @type {Record<string, ReadonlySet<string>|undefined>}
73
97
  */
74
98
  export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
99
+ /**
100
+ * Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
101
+ * treats as neither the allow (0) nor the reject (2). {@link render} emits it
102
+ * for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
103
+ * The table below leans on the same tier for the non-vetoable deny, which is
104
+ * only honest because the tier is real.
105
+ */
106
+ export const NATIVE_ASK_TIER: true;
75
107
  /** @type {import("../control-plane.mjs").Adapter} */
76
108
  export const ampAdapter: import("../control-plane.mjs").Adapter;
77
109
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -9,7 +9,8 @@ export function parse(native: any): ToolCallEvent;
9
9
  * Render into Claude Code's native external-hook transport: a
10
10
  * `hookSpecificOutput` JSON body on stdout plus the exit code that carries the
11
11
  * decision (deny ⇒ exit 2). A deny only counts as `enforced` when the event's
12
- * `this_call_vetoable` holds.
12
+ * `this_call_vetoable` holds, and an enforced deny also carries its `reason` on
13
+ * `NativeResponse.stderr` (see below).
13
14
  *
14
15
  * `soleGate` (default `false`) is a dangerous, explicit opt-in: when `true` AND
15
16
  * the verdict is `allow`, the render emits Claude Code's REAL
@@ -65,6 +66,15 @@ export const COVERAGE: import("../control-plane.mjs").CoverageMap;
65
66
  * @type {Record<string, ReadonlySet<string>|undefined>}
66
67
  */
67
68
  export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
69
+ /**
70
+ * Claude Code honours a distinct ask tier: `hookSpecificOutput.permissionDecision
71
+ * = "ask"` suspends the call and puts it in front of the user, and {@link render}
72
+ * emits exactly that (see {@link gatingBody}). So a consumer must NOT escalate an
73
+ * `ask` to a `deny` here — the human already sees it. `render` still reports the
74
+ * ask as `enforced: false`, because this guardrail did not block the call; the
75
+ * host did, pending an answer.
76
+ */
77
+ export const NATIVE_ASK_TIER: true;
68
78
  /** Claude Code native hook event names (the `hook_event_name` field). */
69
79
  export const HookEvent: Readonly<{
70
80
  PRE_TOOL_USE: "PreToolUse";
@@ -113,6 +113,17 @@ export const MIN_ENFORCING_VERSION: readonly number[];
113
113
  * @type {Record<string, string|undefined>}
114
114
  */
115
115
  export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
116
+ /**
117
+ * Codex has NO ask tier. Its pre-tool output parser decodes
118
+ * `permissionDecision: "ask"`, classifies it unsupported, sets no block reason
119
+ * and runs the tool — upstream's own unit test for that path is named
120
+ * `unsupported_permission_decision_fails_open` (read at `rust-v0.155.1`, and
121
+ * byte-identical at `rust-v0.151.0`). `PermissionRequest` is no ask tier either:
122
+ * its behavior enum carries allow and deny only. So a verdict of `ask` has no
123
+ * channel here, and {@link render} sends it down the deny path rather than
124
+ * naming a key the host discards.
125
+ */
126
+ export const NATIVE_ASK_TIER: false;
116
127
  export const DEFAULT_DENY_REASON: "blocked by monitor";
117
128
  /** @type {import("../control-plane.mjs").Adapter} */
118
129
  export const codexAdapter: import("../control-plane.mjs").Adapter;
@@ -117,6 +117,15 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
117
117
  * @type {Readonly<Record<string, string>>}
118
118
  */
119
119
  export const GEMINI_TOOL_ALIASES: Readonly<Record<string, string>>;
120
+ /**
121
+ * Gemini CLI has NO ask tier: its hook vocabulary is `decision: "allow"` /
122
+ * `decision: "deny"` plus the exit-2 System Block, and nothing there suspends a
123
+ * call for a human. {@link applyAdvisoryDeny} therefore renders `ask` as the
124
+ * advisory `decision: "deny"`, byte-identical to a deny this call cannot veto.
125
+ * A consumer that must not let an ask through has to escalate it to a deny
126
+ * here, because an un-escalated ask lets Gemini run the tool.
127
+ */
128
+ export const NATIVE_ASK_TIER: false;
120
129
  /** @type {import("../control-plane.mjs").Adapter} */
121
130
  export const geminiAdapter: import("../control-plane.mjs").Adapter;
122
131
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -1,3 +1,13 @@
1
+ /**
2
+ * A field added to VERDICT_CONTENT_FIELDS with no probe value would be dropped
3
+ * by normalizeVerdict and then reported as "reaches no native channel" against
4
+ * every adapter, naming the adapter for the harness's own omission. Called at
5
+ * import against the live {@link CONTENT_PROBE_VALUES}; exported so a test can
6
+ * drive both branches.
7
+ * @param {readonly string[]} fields
8
+ * @param {Record<string, readonly unknown[]>} values
9
+ */
10
+ export function assertContentProbeValuesComplete(fields: readonly string[], values: Record<string, readonly unknown[]>): void;
1
11
  /**
2
12
  * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
3
13
  * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
@@ -111,13 +121,23 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
111
121
  * that DID reach a wire come back as `contentFieldsSeen`; a suite covering
112
122
  * several adapters asserts their union to keep the positive half of the
113
123
  * rule non-vacuous, since no single host has a channel for all three.
124
+ * 11. ask-tier honesty: the adapter declares whether this host carries a
125
+ * distinct "ask the human" tier it honours (`NATIVE_ASK_TIER`), and the
126
+ * render is held to that declaration on every pre-tool event. A declared
127
+ * tier whose `ask` renders as the host's abstaining allow fails — the
128
+ * consumer is told a human will see the call while the tool just runs —
129
+ * and so does a declared-absent tier whose `ask` renders as anything but
130
+ * the adapter's own advisory deny. A consumer reads the declaration to
131
+ * decide where an `ask` must be escalated to a `deny`, so a wrong one
132
+ * either lets a call through or denies every asked call on the hosts that
133
+ * do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
114
134
  *
115
135
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
116
136
  * neutral; it throws on the first mismatch. Returns a summary the caller can
117
137
  * assert further on.
118
138
  *
119
139
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
120
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
140
+ * @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 }}
121
141
  */
122
142
  export function runAdapterConformance({ adapter, fixtures, assert }: {
123
143
  adapter: import("./control-plane.mjs").Adapter;
@@ -134,4 +154,5 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
134
154
  unknownKindSeen: boolean;
135
155
  coverageClassesChecked: Set<string>;
136
156
  unenforceableDenyChecks: number;
157
+ askTierChecks: number;
137
158
  };