agent-control-plane-core 0.11.1 → 0.12.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 CHANGED
@@ -127,7 +127,10 @@ Read `NATIVE_ASK_TIER` instead of keeping a list of which agents to escalate an
127
127
  where the flag is `false`, and leaves it alone where the host does the asking.
128
128
  `renderEscalated(adapter, verdict, event, { soleGate })` applies that rule: it
129
129
  re-renders as a `deny` any non-allow verdict that would render unenforced on a
130
- vetoable call, except an `ask` on a host whose flag is `true`.
130
+ vetoable call, except an `ask` on a host whose flag is `true`. A non-allow
131
+ verdict on a payload that names no event at all blocks through the host's exit
132
+ code and stderr, since `render` has no channel for it, unless the event parsed
133
+ observe-only (a Codex below its enforcing version), which cannot veto.
131
134
  It describes the HOST, not the verdict — `ask` renders as `enforced: false`
132
135
  everywhere — and it names the pre-tool gate, since a tool that already ran has
133
136
  nothing left to suspend. Conformance probes your real `render` against it, so a
@@ -179,6 +182,20 @@ on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
179
182
  set `ACP_CODEX_VERSION` to the Codex version your launcher pins (or call
180
183
  `createCodexAdapter({ version })`); without it, Codex calls parse advisory.
181
184
 
185
+ A judge may return its verdict or a promise of one, so a judge can ask a
186
+ remote monitor before it answers. `renderHookResponse` returns a promise.
187
+ `adapterFor(id, options)` takes the options its `LAUNCHER_OPTIONS[id]` row
188
+ names, so a launcher passes a pinned version to whichever agents read one
189
+ without naming them.
190
+
191
+ A consumer that cannot import these modules (a bash hook wrapper, a Python
192
+ monitor) reads `schema/control-plane/v1/agents.json` instead. It is generated
193
+ from the adapters, with one row per agent: its block exit code and reason
194
+ stream, whether a block needs a reason, whether it has an ask tier, the entry's
195
+ default response when the hook breaks, its hook coverage, and the verdict
196
+ fields an allow cannot carry. Read the file from the installed package, so it
197
+ always matches the adapters that version ships.
198
+
182
199
  Each entry fails the way its host expects when the hook itself breaks: claude,
183
200
  codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
184
201
  instead, on every host. The entry then exits 2 with the reason on stderr when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.11.1",
3
+ "version": "0.12.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": {
@@ -0,0 +1,174 @@
1
+ {
2
+ "claude": {
3
+ "integration_mode": "external_hook",
4
+ "native_ask_tier": true,
5
+ "block": {
6
+ "exit_code": 2,
7
+ "reason_stream": "stderr",
8
+ "reason_required": false
9
+ },
10
+ "host_failure": {
11
+ "exit_code": 0,
12
+ "enforced": false
13
+ },
14
+ "coverage": {
15
+ "builtin": "covered",
16
+ "mcp": "covered",
17
+ "subagent": "covered",
18
+ "resumed": "covered"
19
+ },
20
+ "unrendered_fields_on_allow": {
21
+ "pre_tool": [
22
+ "mutated_output"
23
+ ],
24
+ "post_tool": [
25
+ "mutated_input"
26
+ ],
27
+ "prompt_submit": [
28
+ "mutated_input",
29
+ "mutated_output"
30
+ ],
31
+ "session_start": [
32
+ "mutated_input",
33
+ "mutated_output"
34
+ ],
35
+ "unknown": [
36
+ "additional_context",
37
+ "mutated_input",
38
+ "mutated_output"
39
+ ]
40
+ }
41
+ },
42
+ "codex": {
43
+ "integration_mode": "external_hook",
44
+ "native_ask_tier": false,
45
+ "block": {
46
+ "exit_code": 2,
47
+ "reason_stream": "stderr",
48
+ "reason_required": true
49
+ },
50
+ "host_failure": {
51
+ "exit_code": 0,
52
+ "enforced": false
53
+ },
54
+ "coverage": {
55
+ "builtin": "partial",
56
+ "mcp": "partial",
57
+ "subagent": "partial",
58
+ "resumed": "partial"
59
+ },
60
+ "unrendered_fields_on_allow": {
61
+ "pre_tool": [
62
+ "additional_context",
63
+ "mutated_output"
64
+ ],
65
+ "post_tool": [
66
+ "mutated_input",
67
+ "mutated_output"
68
+ ],
69
+ "prompt_submit": [
70
+ "additional_context",
71
+ "mutated_input",
72
+ "mutated_output"
73
+ ],
74
+ "session_start": [
75
+ "additional_context",
76
+ "mutated_input",
77
+ "mutated_output"
78
+ ],
79
+ "unknown": [
80
+ "additional_context",
81
+ "mutated_input",
82
+ "mutated_output"
83
+ ]
84
+ }
85
+ },
86
+ "amp": {
87
+ "integration_mode": "external_hook",
88
+ "native_ask_tier": true,
89
+ "block": {
90
+ "exit_code": 2,
91
+ "reason_stream": "stderr",
92
+ "reason_required": false
93
+ },
94
+ "host_failure": {
95
+ "exit_code": 1,
96
+ "enforced": false
97
+ },
98
+ "coverage": {
99
+ "builtin": "covered",
100
+ "mcp": "covered",
101
+ "subagent": "covered",
102
+ "resumed": "unknown"
103
+ },
104
+ "unrendered_fields_on_allow": {
105
+ "pre_tool": [
106
+ "additional_context",
107
+ "mutated_input",
108
+ "mutated_output"
109
+ ],
110
+ "post_tool": [
111
+ "additional_context",
112
+ "mutated_input",
113
+ "mutated_output"
114
+ ],
115
+ "prompt_submit": [
116
+ "additional_context",
117
+ "mutated_input",
118
+ "mutated_output"
119
+ ],
120
+ "session_start": [
121
+ "additional_context",
122
+ "mutated_input",
123
+ "mutated_output"
124
+ ],
125
+ "unknown": [
126
+ "additional_context",
127
+ "mutated_input",
128
+ "mutated_output"
129
+ ]
130
+ }
131
+ },
132
+ "gemini": {
133
+ "integration_mode": "external_hook",
134
+ "native_ask_tier": false,
135
+ "block": {
136
+ "exit_code": 2,
137
+ "reason_stream": "stderr",
138
+ "reason_required": false
139
+ },
140
+ "host_failure": {
141
+ "exit_code": 0,
142
+ "enforced": false
143
+ },
144
+ "coverage": {
145
+ "builtin": "covered",
146
+ "mcp": "unknown",
147
+ "subagent": "unknown",
148
+ "resumed": "unknown"
149
+ },
150
+ "unrendered_fields_on_allow": {
151
+ "pre_tool": [
152
+ "mutated_output"
153
+ ],
154
+ "post_tool": [
155
+ "mutated_input",
156
+ "mutated_output"
157
+ ],
158
+ "prompt_submit": [
159
+ "mutated_input",
160
+ "mutated_output"
161
+ ],
162
+ "session_start": [
163
+ "additional_context",
164
+ "mutated_input",
165
+ "mutated_output"
166
+ ],
167
+ "unknown": [
168
+ "additional_context",
169
+ "mutated_input",
170
+ "mutated_output"
171
+ ]
172
+ }
173
+ }
174
+ }
@@ -411,16 +411,28 @@ function postToolBody(hookEventName, vd, enforced) {
411
411
  return body;
412
412
  }
413
413
 
414
+ /** The launcher option names {@link createCodexAdapter} reads. */
415
+ export const OPTION_NAMES = Object.freeze(["version"]);
416
+
414
417
  /**
415
418
  * Build a Codex adapter whose `parse` judges every payload against `version`,
416
419
  * the Codex version the launcher pinned (Codex's hook stdin carries none).
417
420
  * Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
418
421
  * deny renders advisory. With no `version`, `parse` falls back to a payload
419
- * `version` (see {@link parse}).
422
+ * `version` (see {@link parse}). An option outside {@link OPTION_NAMES} throws,
423
+ * so a misspelled setting is never dropped in silence.
420
424
  * @param {{ version?: string }} [options]
421
425
  * @returns {import("../control-plane.mjs").Adapter}
422
426
  */
423
- export function createCodexAdapter({ version } = {}) {
427
+ export function createCodexAdapter(options = {}) {
428
+ const unread = Object.keys(options).filter(
429
+ (name) => !OPTION_NAMES.includes(name),
430
+ );
431
+ if (unread.length > 0)
432
+ throw new Error(
433
+ `codex: no option ${unread.map((name) => JSON.stringify(name)).join(", ")} (reads: ${OPTION_NAMES.join(", ")})`,
434
+ );
435
+ const { version } = options;
424
436
  return {
425
437
  AGENT,
426
438
  INTEGRATION_MODE,
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The per-agent manifest: what each agent's hook transport does, published as
3
+ * plain JSON at `schema/control-plane/v1/agents.json`.
4
+ *
5
+ * PROBLEM CLASS — a consumer that cannot call these adapters (a bash hook
6
+ * wrapper, a Python validator) re-types each agent's rules by hand: which exit
7
+ * code blocks, whether a block needs a reason, whether an `ask` has a tier.
8
+ * Each copy is one more place an agent's next release has to be chased into.
9
+ * `agents.json` is the copy they read instead, generated from the adapters'
10
+ * exports, so an adapter change reaches them on the next `pnpm gen:schema`.
11
+ *
12
+ * `pnpm gen:schema` writes every {@link generatedDocuments} entry, and
13
+ * `json-schema.test.mjs` regenerates each in-process and byte-compares.
14
+ */
15
+
16
+ import { EventKind, blockTransportFor, lookup } from "./control-plane.mjs";
17
+ import { jsonSchemaDocuments, SCHEMA_DIR } from "./json-schema.mjs";
18
+ import { ADAPTERS, AGENT_IDS } from "./registry.mjs";
19
+ import { HOST_FAILURE } from "./runtime.mjs";
20
+
21
+ /**
22
+ * One agent's manifest row. Each field is read from the export it names:
23
+ * - `integration_mode`: the adapter's `INTEGRATION_MODE`.
24
+ * - `native_ask_tier`: `NATIVE_ASK_TIER`. `false` means an `ask` the host
25
+ * receives does not stop the call, so a consumer must block instead.
26
+ * - `block`: the `BLOCK_TRANSPORT` row: the exit code the host blocks on, the
27
+ * stream it reads the reason from, and whether an empty reason runs the call.
28
+ * - `host_failure`: the `HOST_FAILURE` row, the hook entry's DEFAULT response
29
+ * when the hook breaks (exit 0 runs the call; amp's exit 1 asks the human).
30
+ * `ACP_FAIL_CLOSED=1` replaces it with `block`.
31
+ * - `coverage`: `COVERAGE`, per call class.
32
+ * - `unrendered_fields_on_allow`: `UNRENDERED_FIELDS`, each set as a sorted
33
+ * list. It describes the allow path only: a deny can drop more.
34
+ * @param {string} id
35
+ * @returns {Record<string, unknown>}
36
+ */
37
+ function manifestRow(id) {
38
+ const adapter = ADAPTERS[id];
39
+ const failure = HOST_FAILURE[id];
40
+ return {
41
+ integration_mode: adapter.INTEGRATION_MODE,
42
+ native_ask_tier: adapter.NATIVE_ASK_TIER,
43
+ block: blockTransportFor(id),
44
+ host_failure: { exit_code: failure.exit_code, enforced: failure.enforced },
45
+ coverage: adapter.COVERAGE,
46
+ unrendered_fields_on_allow: Object.fromEntries(
47
+ // The conformance harness refuses an adapter missing a row, and spreading
48
+ // a missing one throws here, so a gap never publishes as "every field renders".
49
+ Object.values(EventKind).map((kind) => [
50
+ kind,
51
+ [
52
+ .../** @type {ReadonlySet<string>} */ (
53
+ lookup(adapter.UNRENDERED_FIELDS, kind)
54
+ ),
55
+ ].sort(),
56
+ ]),
57
+ ),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Every generated file, keyed by its package-relative path: the JSON Schema
63
+ * documents and their data maps, plus `agents.json`, keyed by agent id in
64
+ * {@link AGENT_IDS} order. The SSOT the generator writes and the freshness test
65
+ * reads, so neither carries its own list of filenames.
66
+ * @returns {Record<string, Record<string, unknown>>}
67
+ */
68
+ export function generatedDocuments() {
69
+ return {
70
+ ...jsonSchemaDocuments(),
71
+ [`${SCHEMA_DIR}/agents.json`]: Object.fromEntries(
72
+ AGENT_IDS.map((id) => [id, manifestRow(id)]),
73
+ ),
74
+ };
75
+ }
@@ -967,6 +967,13 @@ export function gatedResponse(
967
967
  * ({@link Adapter} `NATIVE_ASK_TIER`). Nothing changes for an allow, for a call
968
968
  * this guardrail cannot veto (a deny there blocks nothing either), or for a
969
969
  * render the host already enforces. The deny keeps the verdict's other fields.
970
+ *
971
+ * A payload that names no event at all (`meta.native_event` empty) is the one
972
+ * exception: `render` has no channel for it, so a non-allow verdict there takes
973
+ * the host's {@link BLOCK_TRANSPORT} block, which stops whatever call the hook
974
+ * was run for. An observe-only parse renders that block unenforced (exit 0),
975
+ * since its host cannot veto. An event the payload names but no adapter models keeps the rule
976
+ * above: on Claude Code's Stop, a block would keep the agent running.
970
977
  * @param {Adapter} adapter
971
978
  * @param {Verdict} verdict
972
979
  * @param {ToolCallEvent} event
@@ -974,7 +981,18 @@ export function gatedResponse(
974
981
  * @returns {NativeResponse}
975
982
  */
976
983
  export function renderEscalated(adapter, verdict, event, options = {}) {
977
- const { decision } = normalizeVerdict(verdict);
984
+ const { decision, reason } = normalizeVerdict(verdict);
985
+ // This block is what stops a guardrail's deny on an unreadable payload from
986
+ // rendering as an empty exit 0, which every host runs.
987
+ if (event.meta.native_event === "" && decision !== Decision.ALLOW)
988
+ return gatedResponse(adapter.AGENT, {
989
+ transport: event.meta.integration_mode,
990
+ // An observe-only parse (a Codex below its enforcing version) has no
991
+ // veto, so claiming the block was enforced would report a false block.
992
+ enforced: event.meta.integration_mode !== IntegrationMode.OBSERVE_ONLY,
993
+ unblocked_exit_code: 0,
994
+ reason,
995
+ });
978
996
  const response = adapter.render(verdict, event, options);
979
997
  if (
980
998
  response.enforced ||
@@ -291,10 +291,10 @@ export function verdictSchema() {
291
291
  }
292
292
 
293
293
  /**
294
- * Every generated file, keyed by its package-relative path — the SSOT the
295
- * generator writes and the freshness test reads, so neither carries its own
296
- * list of filenames. The two `.schema.json` entries are JSON Schema documents;
297
- * `tool-input-keys.json` is the data map {@link TOOL_INPUT_KEYS_FILE} describes.
294
+ * The contract's generated files, keyed by package-relative path. The two
295
+ * `.schema.json` entries are JSON Schema documents; `tool-input-keys.json` is
296
+ * the data map {@link TOOL_INPUT_KEYS_FILE} describes. `agent-manifest.mjs`'s
297
+ * `generatedDocuments` adds the per-agent manifest to these.
298
298
  * @returns {Record<string, Record<string, unknown>>}
299
299
  */
300
300
  export function jsonSchemaDocuments() {
package/src/registry.mjs CHANGED
@@ -16,7 +16,11 @@
16
16
 
17
17
  import { lookup } from "./control-plane.mjs";
18
18
  import { claudeAdapter } from "./adapters/claude.mjs";
19
- import { codexAdapter, createCodexAdapter } from "./adapters/codex.mjs";
19
+ import {
20
+ codexAdapter,
21
+ createCodexAdapter,
22
+ OPTION_NAMES as CODEX_OPTION_NAMES,
23
+ } from "./adapters/codex.mjs";
20
24
  import { ampAdapter } from "./adapters/amp.mjs";
21
25
  import { geminiAdapter } from "./adapters/gemini.mjs";
22
26
 
@@ -64,23 +68,43 @@ export function assertRegistryConsistent(adapters) {
64
68
  assertRegistryConsistent(ADAPTERS);
65
69
 
66
70
  /**
67
- * The adapters that take launcher options, keyed by agent id. Only Codex does:
68
- * its hook stdin carries no version, so the launcher pins one.
69
- * @type {Readonly<Record<string, (options: any) => Adapter>>}
71
+ * The adapters that take launcher options, keyed by agent id: the option names
72
+ * each reads, and its factory. Only Codex takes one: its hook stdin carries no
73
+ * version, so the launcher pins one.
74
+ * @type {Readonly<Record<string, Readonly<{ options: readonly string[], create: (options: any) => Adapter }>>>}
70
75
  */
71
76
  const CONFIGURABLE = Object.freeze({
72
- [codexAdapter.AGENT]: createCodexAdapter,
77
+ [codexAdapter.AGENT]: Object.freeze({
78
+ options: CODEX_OPTION_NAMES,
79
+ create: createCodexAdapter,
80
+ }),
73
81
  });
74
82
 
83
+ /**
84
+ * The launcher option names each agent's adapter reads, for EVERY agent id (an
85
+ * empty list when it reads none). A launcher builds the options for any agent
86
+ * from this instead of naming the agents that take a version.
87
+ * @type {Readonly<Record<string, readonly string[]>>}
88
+ */
89
+ export const LAUNCHER_OPTIONS = Object.freeze(
90
+ Object.fromEntries(
91
+ AGENT_IDS.map((id) => [
92
+ id,
93
+ lookup(CONFIGURABLE, id)?.options ?? Object.freeze([]),
94
+ ]),
95
+ ),
96
+ );
97
+
75
98
  /**
76
99
  * Resolve an agent id to its adapter. Throws (fail loud) on an unknown id — a
77
100
  * selection signal that names no shipped adapter is a misconfiguration, not a
78
101
  * reason to silently fall back to a default agent (which would run the wrong
79
102
  * translator against the payloads and mis-render every verdict).
80
103
  *
81
- * `options` are the launcher's settings for that adapter (Codex:
82
- * `{ version }`). Passing options to an adapter that takes none throws, so a
83
- * setting the launcher believes it applied cannot be dropped in silence.
104
+ * `options` are the launcher's settings for that adapter, named by its
105
+ * {@link LAUNCHER_OPTIONS} row. Options for an adapter that takes none throw
106
+ * here, and an option a configurable adapter does not read throws in its
107
+ * factory, so a setting the launcher believes it applied is never dropped.
84
108
  * @param {string} id agent id (a {@link AGENT_IDS} member)
85
109
  * @param {Record<string, unknown>} [options]
86
110
  * @returns {Adapter}
@@ -92,10 +116,10 @@ export function adapterFor(id, options) {
92
116
  `registry: no adapter for agent id ${JSON.stringify(id)} (known: ${AGENT_IDS.join(", ")})`,
93
117
  );
94
118
  if (options === undefined) return adapter;
95
- const configure = lookup(CONFIGURABLE, id);
96
- if (configure === undefined)
119
+ const configurable = lookup(CONFIGURABLE, id);
120
+ if (configurable === undefined)
97
121
  throw new Error(
98
122
  `registry: adapter ${JSON.stringify(id)} takes no options, got ${JSON.stringify(options)}`,
99
123
  );
100
- return configure(options);
124
+ return configurable.create(options);
101
125
  }
package/src/runtime.mjs CHANGED
@@ -20,6 +20,12 @@ import {
20
20
  sanitizeVerdict,
21
21
  } from "./control-plane.mjs";
22
22
 
23
+ /**
24
+ * A guardrail judge: one normalized event in, its verdict out, synchronously or
25
+ * as a promise. Its answer is untrusted either way (see {@link renderHookResponse}).
26
+ * @typedef {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict | Promise<import("./control-plane.mjs").Verdict>} Judge
27
+ */
28
+
23
29
  /**
24
30
  * Read all of stdin to a string.
25
31
  * @returns {Promise<string>}
@@ -77,16 +83,18 @@ export function demoJudge(event) {
77
83
  * greppable trace. Every host tolerates hook stderr: claude/codex surface it
78
84
  * only in verbose/debug on a 0 exit, amp forwards the helper's stderr, and
79
85
  * gemini reads stderr as a reason only on exit 2.
80
- * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
81
- * so the guardrail is supplied here rather than by forking this file. Whatever
82
- * it returns is treated as untrusted (see the clamp below).
86
+ * `judge` is the seam a real deployment fills, so the guardrail is supplied here
87
+ * rather than by forking this file ({@link demoJudge} is a stand-in). It may
88
+ * return a promise (a judge that asks a remote monitor); a rejection takes the
89
+ * same path as a throw. Whatever it returns is treated as untrusted (see the
90
+ * clamp below).
83
91
  * @param {import("./control-plane.mjs").Adapter} adapter
84
92
  * @param {string} rawInput
85
93
  * @param {import("./control-plane.mjs").NativeResponse} onFailure
86
- * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
87
- * @returns {import("./control-plane.mjs").NativeResponse}
94
+ * @param {Judge} [judge]
95
+ * @returns {Promise<import("./control-plane.mjs").NativeResponse>}
88
96
  */
89
- export function renderHookResponse(
97
+ export async function renderHookResponse(
90
98
  adapter,
91
99
  rawInput,
92
100
  onFailure,
@@ -107,7 +115,7 @@ export function renderHookResponse(
107
115
  /** @type {unknown} */
108
116
  let answer;
109
117
  try {
110
- answer = judge(event);
118
+ answer = await judge(event);
111
119
  } catch (err) {
112
120
  // No `decision`, so the clamp below turns this into an `ask`.
113
121
  answer = { reason: `hook judge threw: ${describe(err).split("\n")[0]}` };
@@ -227,9 +235,11 @@ export function failurePosture(agent, env = process.env) {
227
235
  * {@link renderHookResponse} with that host's {@link failurePosture}, and
228
236
  * {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
229
237
  * failure response also answers a stdin read error and, when
230
- * {@link hookDeadlineMs} is set, a hook still running at that deadline.
238
+ * {@link hookDeadlineMs} is set, a hook still running at that deadline. It also
239
+ * answers a judge promise that never settles once nothing else keeps node alive,
240
+ * which would otherwise end the process with no response.
231
241
  * @param {import("./control-plane.mjs").Adapter} adapter
232
- * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
242
+ * @param {Judge} [judge]
233
243
  * @returns {Promise<never>}
234
244
  */
235
245
  export async function runHookEntry(adapter, judge = demoJudge) {
@@ -258,7 +268,17 @@ export async function runHookEntry(adapter, judge = demoJudge) {
258
268
  } catch (err) {
259
269
  return fail("hook stdin error", err);
260
270
  }
261
- return emit(renderHookResponse(adapter, raw, onFailure, judge));
271
+ // An unsettled judge promise with no pending I/O drains the event loop, and
272
+ // node then exits with no response (13 under top-level await, 0 otherwise),
273
+ // which a fail-open host runs. Registered after stdin is read, so only the
274
+ // judge can be what never answered.
275
+ process.once("beforeExit", () =>
276
+ fail(
277
+ "hook judge never answered",
278
+ new Error("the judge's promise never settled"),
279
+ ),
280
+ );
281
+ return emit(await renderHookResponse(adapter, raw, onFailure, judge));
262
282
  }
263
283
 
264
284
  /**
@@ -58,11 +58,12 @@ export function render(verdict: Verdict, event: ToolCallEvent, { soleGate }?: {
58
58
  * the Codex version the launcher pinned (Codex's hook stdin carries none).
59
59
  * Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
60
60
  * deny renders advisory. With no `version`, `parse` falls back to a payload
61
- * `version` (see {@link parse}).
61
+ * `version` (see {@link parse}). An option outside {@link OPTION_NAMES} throws,
62
+ * so a misspelled setting is never dropped in silence.
62
63
  * @param {{ version?: string }} [options]
63
64
  * @returns {import("../control-plane.mjs").Adapter}
64
65
  */
65
- export function createCodexAdapter({ version }?: {
66
+ export function createCodexAdapter(options?: {
66
67
  version?: string;
67
68
  }): import("../control-plane.mjs").Adapter;
68
69
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
@@ -140,6 +141,8 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
140
141
  * naming a key the host discards.
141
142
  */
142
143
  export const NATIVE_ASK_TIER: false;
144
+ /** The launcher option names {@link createCodexAdapter} reads. */
145
+ export const OPTION_NAMES: readonly string[];
143
146
  /**
144
147
  * The Codex adapter with no launcher-pinned version. Real Codex sends no
145
148
  * version, so every event it sends parses observe-only here. A deployment that
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Every generated file, keyed by its package-relative path: the JSON Schema
3
+ * documents and their data maps, plus `agents.json`, keyed by agent id in
4
+ * {@link AGENT_IDS} order. The SSOT the generator writes and the freshness test
5
+ * reads, so neither carries its own list of filenames.
6
+ * @returns {Record<string, Record<string, unknown>>}
7
+ */
8
+ export function generatedDocuments(): Record<string, Record<string, unknown>>;
@@ -296,6 +296,13 @@ export function gatedResponse(agent: string, { transport, enforced, unblocked_ex
296
296
  * ({@link Adapter} `NATIVE_ASK_TIER`). Nothing changes for an allow, for a call
297
297
  * this guardrail cannot veto (a deny there blocks nothing either), or for a
298
298
  * render the host already enforces. The deny keeps the verdict's other fields.
299
+ *
300
+ * A payload that names no event at all (`meta.native_event` empty) is the one
301
+ * exception: `render` has no channel for it, so a non-allow verdict there takes
302
+ * the host's {@link BLOCK_TRANSPORT} block, which stops whatever call the hook
303
+ * was run for. An observe-only parse renders that block unenforced (exit 0),
304
+ * since its host cannot veto. An event the payload names but no adapter models keeps the rule
305
+ * above: on Claude Code's Stop, a block would keep the agent running.
299
306
  * @param {Adapter} adapter
300
307
  * @param {Verdict} verdict
301
308
  * @param {ToolCallEvent} event
@@ -25,10 +25,10 @@ export function toolCallEventSchema(): Record<string, unknown>;
25
25
  */
26
26
  export function verdictSchema(): Record<string, unknown>;
27
27
  /**
28
- * Every generated file, keyed by its package-relative path — the SSOT the
29
- * generator writes and the freshness test reads, so neither carries its own
30
- * list of filenames. The two `.schema.json` entries are JSON Schema documents;
31
- * `tool-input-keys.json` is the data map {@link TOOL_INPUT_KEYS_FILE} describes.
28
+ * The contract's generated files, keyed by package-relative path. The two
29
+ * `.schema.json` entries are JSON Schema documents; `tool-input-keys.json` is
30
+ * the data map {@link TOOL_INPUT_KEYS_FILE} describes. `agent-manifest.mjs`'s
31
+ * `generatedDocuments` adds the per-agent manifest to these.
32
32
  * @returns {Record<string, Record<string, unknown>>}
33
33
  */
34
34
  export function jsonSchemaDocuments(): Record<string, Record<string, unknown>>;
@@ -13,9 +13,10 @@ export function assertRegistryConsistent(adapters: Record<string, Adapter>): voi
13
13
  * reason to silently fall back to a default agent (which would run the wrong
14
14
  * translator against the payloads and mis-render every verdict).
15
15
  *
16
- * `options` are the launcher's settings for that adapter (Codex:
17
- * `{ version }`). Passing options to an adapter that takes none throws, so a
18
- * setting the launcher believes it applied cannot be dropped in silence.
16
+ * `options` are the launcher's settings for that adapter, named by its
17
+ * {@link LAUNCHER_OPTIONS} row. Options for an adapter that takes none throw
18
+ * here, and an option a configurable adapter does not read throws in its
19
+ * factory, so a setting the launcher believes it applied is never dropped.
19
20
  * @param {string} id agent id (a {@link AGENT_IDS} member)
20
21
  * @param {Record<string, unknown>} [options]
21
22
  * @returns {Adapter}
@@ -37,4 +38,11 @@ export const ADAPTERS: Readonly<Record<string, Adapter>>;
37
38
  * @type {readonly string[]}
38
39
  */
39
40
  export const AGENT_IDS: readonly string[];
41
+ /**
42
+ * The launcher option names each agent's adapter reads, for EVERY agent id (an
43
+ * empty list when it reads none). A launcher builds the options for any agent
44
+ * from this instead of naming the agents that take a version.
45
+ * @type {Readonly<Record<string, readonly string[]>>}
46
+ */
47
+ export const LAUNCHER_OPTIONS: Readonly<Record<string, readonly string[]>>;
40
48
  export type Adapter = import("./control-plane.mjs").Adapter;
@@ -1,3 +1,8 @@
1
+ /**
2
+ * A guardrail judge: one normalized event in, its verdict out, synchronously or
3
+ * as a promise. Its answer is untrusted either way (see {@link renderHookResponse}).
4
+ * @typedef {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict | Promise<import("./control-plane.mjs").Verdict>} Judge
5
+ */
1
6
  /**
2
7
  * Read all of stdin to a string.
3
8
  * @returns {Promise<string>}
@@ -31,16 +36,18 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
31
36
  * greppable trace. Every host tolerates hook stderr: claude/codex surface it
32
37
  * only in verbose/debug on a 0 exit, amp forwards the helper's stderr, and
33
38
  * gemini reads stderr as a reason only on exit 2.
34
- * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
35
- * so the guardrail is supplied here rather than by forking this file. Whatever
36
- * it returns is treated as untrusted (see the clamp below).
39
+ * `judge` is the seam a real deployment fills, so the guardrail is supplied here
40
+ * rather than by forking this file ({@link demoJudge} is a stand-in). It may
41
+ * return a promise (a judge that asks a remote monitor); a rejection takes the
42
+ * same path as a throw. Whatever it returns is treated as untrusted (see the
43
+ * clamp below).
37
44
  * @param {import("./control-plane.mjs").Adapter} adapter
38
45
  * @param {string} rawInput
39
46
  * @param {import("./control-plane.mjs").NativeResponse} onFailure
40
- * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
41
- * @returns {import("./control-plane.mjs").NativeResponse}
47
+ * @param {Judge} [judge]
48
+ * @returns {Promise<import("./control-plane.mjs").NativeResponse>}
42
49
  */
43
- export function renderHookResponse(adapter: import("./control-plane.mjs").Adapter, rawInput: string, onFailure: import("./control-plane.mjs").NativeResponse, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): import("./control-plane.mjs").NativeResponse;
50
+ export function renderHookResponse(adapter: import("./control-plane.mjs").Adapter, rawInput: string, onFailure: import("./control-plane.mjs").NativeResponse, judge?: Judge): Promise<import("./control-plane.mjs").NativeResponse>;
44
51
  /**
45
52
  * The response that BLOCKS the call on `agent`'s host: its
46
53
  * {@link BLOCK_TRANSPORT} row's exit code, with `reason` on the row's reason
@@ -69,12 +76,14 @@ export function failurePosture(agent: string, env?: NodeJS.ProcessEnv): import("
69
76
  * {@link renderHookResponse} with that host's {@link failurePosture}, and
70
77
  * {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
71
78
  * failure response also answers a stdin read error and, when
72
- * {@link hookDeadlineMs} is set, a hook still running at that deadline.
79
+ * {@link hookDeadlineMs} is set, a hook still running at that deadline. It also
80
+ * answers a judge promise that never settles once nothing else keeps node alive,
81
+ * which would otherwise end the process with no response.
73
82
  * @param {import("./control-plane.mjs").Adapter} adapter
74
- * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
83
+ * @param {Judge} [judge]
75
84
  * @returns {Promise<never>}
76
85
  */
77
- export function runHookEntry(adapter: import("./control-plane.mjs").Adapter, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): Promise<never>;
86
+ export function runHookEntry(adapter: import("./control-plane.mjs").Adapter, judge?: Judge): Promise<never>;
78
87
  /**
79
88
  * `ACP_HOOK_TIMEOUT_MS` as a positive integer, or `undefined` when unset. Set it
80
89
  * below the host's own hook timeout, so the hook's failure response lands
@@ -135,3 +144,8 @@ export function emit(response: import("./control-plane.mjs").NativeResponse): ne
135
144
  * @type {Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>}
136
145
  */
137
146
  export const HOST_FAILURE: Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>;
147
+ /**
148
+ * A guardrail judge: one normalized event in, its verdict out, synchronously or
149
+ * as a promise. Its answer is untrusted either way (see {@link renderHookResponse}).
150
+ */
151
+ export type Judge = (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict | Promise<import("./control-plane.mjs").Verdict>;