agent-control-plane-core 0.8.2 → 0.9.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.
@@ -398,7 +398,8 @@ export function classifyCallClass(tool, native) {
398
398
  /**
399
399
  * The result of rendering a {@link Verdict} for a specific agent's transport —
400
400
  * the ATTACH mechanism, not the decision. A caller applies whichever channels
401
- * are present: write `stdout`, exit with `exit_code`, or `throw`.
401
+ * are present: write `stdout`, write `stderr`, then exit with `exit_code`
402
+ * (`emit` in `agent-control-plane-core/runtime` does all three).
402
403
  * @typedef {object} NativeResponse
403
404
  * @property {"external_hook"|"in_process"|"observe_only"} transport
404
405
  * @property {number} exit_code process exit code carrying the decision (0 = proceed)
@@ -492,6 +493,30 @@ export function readonlySet(values) {
492
493
  */
493
494
  export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
494
495
 
496
+ /**
497
+ * PROBLEM CLASS — an event marked vetoable on a path where the host can never
498
+ * veto it, so a deny reads downstream as a block that never happened.
499
+ *
500
+ * Each entry names a field (`path` into the {@link ToolCallEvent}) whose `value`
501
+ * makes `this_call_vetoable: true` a false claim. This is the ONE list:
502
+ * {@link makeEvent} refuses an event that breaks an entry, and the JSON-schema
503
+ * generator writes one `if`/`then` rule per entry, so the runtime and the
504
+ * published schema cannot hold different invariants.
505
+ * @type {ReadonlyArray<{ path: readonly string[], value: string, why: string }>}
506
+ */
507
+ export const VETO_IMPOSSIBLE = Object.freeze([
508
+ Object.freeze({
509
+ path: Object.freeze(["event"]),
510
+ value: EventKind.UNKNOWN,
511
+ why: "an unmodelled event has no host response to veto, so reporting one is a false block",
512
+ }),
513
+ Object.freeze({
514
+ path: Object.freeze(["meta", "integration_mode"]),
515
+ value: IntegrationMode.OBSERVE_ONLY,
516
+ why: "an observe-only transport reads a transcript and cannot pre-empt a call, so reporting a veto is a false block",
517
+ }),
518
+ ]);
519
+
495
520
  /**
496
521
  * The translator for one agent's protocol. `parse` maps a native event to a
497
522
  * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
@@ -537,16 +562,6 @@ export function makeEvent({
537
562
  throw new TypeError(
538
563
  `control-plane: makeEvent this_call_vetoable must be a boolean, got ${typeof this_call_vetoable}`,
539
564
  );
540
- // The same fail-open seam, one step further: an event the adapter could not
541
- // name is one whose host response nobody established, so a veto reported for
542
- // it is a block the render claims and the host never performs. Refused HERE
543
- // because every adapter builds its event through this call, so an adapter that
544
- // computes the flag without `vetoableFor` cannot reach the wire either.
545
- if (event === EventKind.UNKNOWN && this_call_vetoable)
546
- throw new Error(
547
- "control-plane: makeEvent got a vetoable UNKNOWN event — an unmodelled " +
548
- "event has no host response to veto, so reporting one is a false block",
549
- );
550
565
  /** @type {ToolCallEvent} */
551
566
  const evt = {
552
567
  schema_version: SCHEMA_VERSION,
@@ -557,6 +572,18 @@ export function makeEvent({
557
572
  meta,
558
573
  };
559
574
  if (response !== undefined) evt.response = response;
575
+ // Refused HERE because every adapter builds its event through this call, so an
576
+ // adapter that computes the flag without `vetoableFor` cannot reach the wire.
577
+ if (this_call_vetoable) {
578
+ const broken = VETO_IMPOSSIBLE.find(
579
+ ({ path, value }) =>
580
+ path.reduce((/** @type {any} */ at, key) => at[key], evt) === value,
581
+ );
582
+ if (broken !== undefined)
583
+ throw new Error(
584
+ `control-plane: makeEvent got a vetoable event whose ${broken.path.join(".")} is ${JSON.stringify(broken.value)} — ${broken.why}`,
585
+ );
586
+ }
560
587
  return evt;
561
588
  }
562
589
 
@@ -828,3 +855,106 @@ export function nativeResponse({
828
855
  if (stderr !== undefined) out.stderr = stderr;
829
856
  return out;
830
857
  }
858
+
859
+ /**
860
+ * The block reason a render writes when the judge gave none (or an empty one).
861
+ * Codex drops an enforced deny whose reason is empty and runs the tool, and on
862
+ * every host the model is refused with no rationale without one.
863
+ */
864
+ export const DEFAULT_DENY_REASON = "blocked by monitor";
865
+
866
+ /**
867
+ * PROBLEM CLASS — an enforced block whose reason never reaches the host, or
868
+ * whose exit code is not the one the host treats as a block, because each
869
+ * adapter hand-wired its own stderr and exit code.
870
+ *
871
+ * One row per agent, describing that host's ENFORCED block:
872
+ * - `exit_code`: the exit status the host treats as a block.
873
+ * - `reason_stream`: the stream the host reads the block reason from on that
874
+ * exit. Every host here discards (or never gets) the stdout body on exit 2.
875
+ * - `reason_required`: the host drops a block whose reason is empty and runs
876
+ * the tool, so every body field that carries the reason must be non-empty.
877
+ *
878
+ * {@link gatedResponse} derives each adapter's enforced render from its row,
879
+ * and the conformance harness holds every enforced render to the same row.
880
+ * @type {Readonly<Record<string, Readonly<{ exit_code: number, reason_stream: "stderr", reason_required: boolean }>>>}
881
+ */
882
+ export const BLOCK_TRANSPORT = Object.freeze({
883
+ claude: Object.freeze({
884
+ exit_code: 2,
885
+ reason_stream: "stderr",
886
+ reason_required: false,
887
+ }),
888
+ codex: Object.freeze({
889
+ exit_code: 2,
890
+ reason_stream: "stderr",
891
+ reason_required: true,
892
+ }),
893
+ amp: Object.freeze({
894
+ exit_code: 2,
895
+ reason_stream: "stderr",
896
+ reason_required: false,
897
+ }),
898
+ gemini: Object.freeze({
899
+ exit_code: 2,
900
+ reason_stream: "stderr",
901
+ reason_required: false,
902
+ }),
903
+ });
904
+
905
+ /**
906
+ * The {@link BLOCK_TRANSPORT} row for `agent`. Throws for an agent with no row:
907
+ * a missing row would otherwise render an enforced block with no exit code.
908
+ * @param {string} agent
909
+ * @returns {Readonly<{ exit_code: number, reason_stream: "stderr", reason_required: boolean }>}
910
+ */
911
+ export function blockTransportFor(agent) {
912
+ const row = lookup(BLOCK_TRANSPORT, agent);
913
+ if (row === undefined)
914
+ throw new Error(
915
+ `control-plane: no BLOCK_TRANSPORT row for agent ${JSON.stringify(agent)}`,
916
+ );
917
+ return row;
918
+ }
919
+
920
+ /**
921
+ * `reason` when it holds visible text, else {@link DEFAULT_DENY_REASON}.
922
+ * @param {unknown} reason
923
+ * @returns {string}
924
+ */
925
+ export function blockReason(reason) {
926
+ return typeof reason === "string" && reason.trim() !== ""
927
+ ? reason
928
+ : DEFAULT_DENY_REASON;
929
+ }
930
+
931
+ /**
932
+ * Build an adapter's {@link NativeResponse} from its {@link BLOCK_TRANSPORT}
933
+ * row. When `enforced`, the exit code is the row's and the reason (or the
934
+ * default) goes on the row's reason stream. Otherwise `unblocked_exit_code`
935
+ * stands and no reason stream is written: nothing was blocked, so the host
936
+ * reads the exit-0 (or ask) body instead.
937
+ * @param {string} agent
938
+ * @param {{ transport: string, enforced: boolean, unblocked_exit_code: number, stdout?: unknown, reason?: string }} parts
939
+ * @returns {NativeResponse}
940
+ */
941
+ export function gatedResponse(
942
+ agent,
943
+ { transport, enforced, unblocked_exit_code, stdout, reason },
944
+ ) {
945
+ if (!enforced)
946
+ return nativeResponse({
947
+ transport,
948
+ exit_code: unblocked_exit_code,
949
+ enforced,
950
+ stdout,
951
+ });
952
+ const row = blockTransportFor(agent);
953
+ return nativeResponse({
954
+ transport,
955
+ exit_code: row.exit_code,
956
+ enforced,
957
+ stdout,
958
+ [row.reason_stream]: blockReason(reason),
959
+ });
960
+ }
@@ -2,11 +2,10 @@
2
2
  "agent": "codex",
3
3
  "cases": [
4
4
  {
5
- "name": "PreToolUse v0.135 enforcing, all verdicts",
5
+ "name": "PreToolUse on an enforcing Codex, all verdicts",
6
6
  "call_class": "builtin",
7
7
  "native": {
8
8
  "hook_event_name": "PreToolUse",
9
- "version": "0.135.0",
10
9
  "session_id": "c1",
11
10
  "cwd": "/w",
12
11
  "permission_mode": "default",
@@ -100,7 +99,8 @@
100
99
  "permissionDecision": "deny",
101
100
  "permissionDecisionReason": "blocked"
102
101
  }
103
- }
102
+ },
103
+ "stderr": "blocked"
104
104
  }
105
105
  },
106
106
  "ask": {
@@ -117,7 +117,8 @@
117
117
  "permissionDecision": "deny",
118
118
  "permissionDecisionReason": "blocked by monitor"
119
119
  }
120
- }
120
+ },
121
+ "stderr": "blocked by monitor"
121
122
  }
122
123
  },
123
124
  "mutation": {
@@ -145,11 +146,10 @@
145
146
  }
146
147
  },
147
148
  {
148
- "name": "PreToolUse v0.135 MCP tool is non-vetoable (hook fires for Bash only)",
149
+ "name": "PreToolUse MCP tool is non-vetoable (hook fires for Bash only)",
149
150
  "call_class": "mcp",
150
151
  "native": {
151
152
  "hook_event_name": "PreToolUse",
152
- "version": "0.135.0",
153
153
  "tool_name": "mcp__github__create_issue",
154
154
  "tool_input": {
155
155
  "title": "hi"
@@ -209,11 +209,10 @@
209
209
  }
210
210
  },
211
211
  {
212
- "name": "PreToolUse v0.151 from a subagent (agent_type present)",
212
+ "name": "PreToolUse from a subagent (agent_type present)",
213
213
  "call_class": "subagent",
214
214
  "native": {
215
215
  "hook_event_name": "PreToolUse",
216
- "version": "0.151.0",
217
216
  "session_id": "c-sub",
218
217
  "cwd": "/w",
219
218
  "permission_mode": "default",
@@ -307,7 +306,8 @@
307
306
  "permissionDecision": "deny",
308
307
  "permissionDecisionReason": "subagent shell blocked"
309
308
  }
310
- }
309
+ },
310
+ "stderr": "subagent shell blocked"
311
311
  }
312
312
  },
313
313
  "ask": {
@@ -324,7 +324,8 @@
324
324
  "permissionDecision": "deny",
325
325
  "permissionDecisionReason": "blocked by monitor"
326
326
  }
327
- }
327
+ },
328
+ "stderr": "blocked by monitor"
328
329
  }
329
330
  }
330
331
  }
@@ -333,7 +334,6 @@
333
334
  "name": "PermissionRequest round-trips the native event name",
334
335
  "native": {
335
336
  "hook_event_name": "PermissionRequest",
336
- "version": "1.0.0",
337
337
  "tool_name": "Bash",
338
338
  "tool_input": {
339
339
  "command": "rm -rf /"
@@ -372,79 +372,16 @@
372
372
  "permissionDecision": "deny",
373
373
  "permissionDecisionReason": "nope"
374
374
  }
375
- }
376
- }
377
- }
378
- }
379
- },
380
- {
381
- "name": "pre-v0.135 downgrades to observe_only (advisory deny)",
382
- "native": {
383
- "hook_event_name": "PreToolUse",
384
- "version": "0.134.9",
385
- "tool_name": "Bash",
386
- "tool_input": {
387
- "command": "curl evil"
388
- }
389
- },
390
- "event": {
391
- "schema_version": 1,
392
- "event": "pre_tool",
393
- "tool": "Bash",
394
- "input": {
395
- "command": "curl evil"
396
- },
397
- "this_call_vetoable": false,
398
- "meta": {
399
- "agent": "codex",
400
- "native_event": "PreToolUse",
401
- "native_tool": "Bash",
402
- "integration_mode": "observe_only",
403
- "primary_gate_present": true,
404
- "passthrough": {}
405
- }
406
- },
407
- "render": {
408
- "allow": {
409
- "verdict": {
410
- "decision": "allow"
411
- },
412
- "native": {
413
- "transport": "observe_only",
414
- "exit_code": 0,
415
- "enforced": false,
416
- "stdout": {
417
- "hookSpecificOutput": {
418
- "hookEventName": "PreToolUse"
419
- }
420
- }
421
- }
422
- },
423
- "deny": {
424
- "verdict": {
425
- "decision": "deny",
426
- "reason": "advisory only"
427
- },
428
- "native": {
429
- "transport": "observe_only",
430
- "exit_code": 0,
431
- "enforced": false,
432
- "stdout": {
433
- "hookSpecificOutput": {
434
- "hookEventName": "PreToolUse",
435
- "permissionDecision": "deny",
436
- "permissionDecisionReason": "advisory only"
437
- }
438
- }
375
+ },
376
+ "stderr": "nope"
439
377
  }
440
378
  }
441
379
  }
442
380
  },
443
381
  {
444
- "name": "PostToolUse v0.151 blocks on the post-tool schema",
382
+ "name": "PostToolUse blocks on the post-tool schema",
445
383
  "native": {
446
384
  "hook_event_name": "PostToolUse",
447
- "version": "0.151.0",
448
385
  "session_id": "c2",
449
386
  "cwd": "/w",
450
387
  "tool_name": "Bash",
@@ -543,7 +480,8 @@
543
480
  },
544
481
  "decision": "block",
545
482
  "reason": "leaked credential"
546
- }
483
+ },
484
+ "stderr": "leaked credential"
547
485
  }
548
486
  },
549
487
  "deny_no_reason": {
@@ -560,7 +498,8 @@
560
498
  },
561
499
  "decision": "block",
562
500
  "reason": "blocked by monitor"
563
- }
501
+ },
502
+ "stderr": "blocked by monitor"
564
503
  }
565
504
  }
566
505
  }
package/src/index.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  export * from "./control-plane.mjs";
21
21
  export { claudeAdapter, HookEvent } from "./adapters/claude.mjs";
22
- export { codexAdapter } from "./adapters/codex.mjs";
22
+ export { codexAdapter, createCodexAdapter } from "./adapters/codex.mjs";
23
23
  export { ampAdapter } from "./adapters/amp.mjs";
24
24
  export {
25
25
  geminiAdapter,
@@ -49,6 +49,7 @@ import {
49
49
  SCHEMA_VERSION,
50
50
  STANDARD_META_FIELDS,
51
51
  VERDICT_CONTENT_FIELDS,
52
+ VETO_IMPOSSIBLE,
52
53
  } from "./control-plane.mjs";
53
54
 
54
55
  /** The JSON Schema dialect these documents are written in. */
@@ -236,42 +237,29 @@ export function toolCallEventSchema() {
236
237
  meta: { $ref: "#/$defs/EventMeta" },
237
238
  },
238
239
  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
- ],
240
+ // One rule per VETO_IMPOSSIBLE entry — the same list `makeEvent` enforces.
241
+ allOf: VETO_IMPOSSIBLE.map(({ path, value }) => ({
242
+ if: pathMatch(path, value),
243
+ then: { properties: { this_call_vetoable: { const: false } } },
244
+ })),
260
245
  $defs: { EventMeta: eventMetaSchema() },
261
246
  };
262
247
  }
263
248
 
264
249
  /**
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
250
+ * A subschema that holds where the field at `path` equals `value`. Each level
251
+ * names its key in `required`, because an absent key satisfies a bare
252
+ * `properties` and would leave the `then` branch dead.
253
+ * @param {readonly string[]} path
254
+ * @param {string} value
269
255
  * @returns {Record<string, unknown>}
270
256
  */
271
- function vetoImpossibleWhen(match) {
257
+ function pathMatch([key, ...rest], value) {
272
258
  return {
273
- if: { required: Object.keys(match), properties: match },
274
- then: { properties: { this_call_vetoable: { const: false } } },
259
+ required: [key],
260
+ properties: {
261
+ [key]: rest.length === 0 ? { const: value } : pathMatch(rest, value),
262
+ },
275
263
  };
276
264
  }
277
265
 
package/src/registry.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { lookup } from "./control-plane.mjs";
18
18
  import { claudeAdapter } from "./adapters/claude.mjs";
19
- import { codexAdapter } from "./adapters/codex.mjs";
19
+ import { codexAdapter, createCodexAdapter } from "./adapters/codex.mjs";
20
20
  import { ampAdapter } from "./adapters/amp.mjs";
21
21
  import { geminiAdapter } from "./adapters/gemini.mjs";
22
22
 
@@ -63,19 +63,39 @@ export function assertRegistryConsistent(adapters) {
63
63
 
64
64
  assertRegistryConsistent(ADAPTERS);
65
65
 
66
+ /**
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>>}
70
+ */
71
+ const CONFIGURABLE = Object.freeze({
72
+ [codexAdapter.AGENT]: createCodexAdapter,
73
+ });
74
+
66
75
  /**
67
76
  * Resolve an agent id to its adapter. Throws (fail loud) on an unknown id — a
68
77
  * selection signal that names no shipped adapter is a misconfiguration, not a
69
78
  * reason to silently fall back to a default agent (which would run the wrong
70
79
  * translator against the payloads and mis-render every verdict).
80
+ *
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.
71
84
  * @param {string} id agent id (a {@link AGENT_IDS} member)
85
+ * @param {Record<string, unknown>} [options]
72
86
  * @returns {Adapter}
73
87
  */
74
- export function adapterFor(id) {
88
+ export function adapterFor(id, options) {
75
89
  const adapter = lookup(ADAPTERS, id);
76
90
  if (adapter === undefined)
77
91
  throw new Error(
78
92
  `registry: no adapter for agent id ${JSON.stringify(id)} (known: ${AGENT_IDS.join(", ")})`,
79
93
  );
80
- return adapter;
94
+ if (options === undefined) return adapter;
95
+ const configure = lookup(CONFIGURABLE, id);
96
+ if (configure === undefined)
97
+ throw new Error(
98
+ `registry: adapter ${JSON.stringify(id)} takes no options, got ${JSON.stringify(options)}`,
99
+ );
100
+ return configure(options);
81
101
  }
package/src/runtime.mjs CHANGED
@@ -56,19 +56,22 @@ export function demoJudge(event) {
56
56
 
57
57
  /**
58
58
  * Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
59
- * can't be parsed or the pipe throws, return `onFailure` — the host's OWN
59
+ * can't be parsed or the render throws, return `onFailure` — the host's OWN
60
60
  * fail-safe response — so a crashed hook degrades the way THAT host expects
61
61
  * (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
62
62
  * single shared default. This is the deliberate, necessary recovery the whole
63
63
  * per-host split exists for, not a blanket swallow.
64
64
  *
65
- * Before returning the fail-safe, the error is written to STDERR (never stdout,
66
- * which belongs to the host transport) so a regression that silently disables
67
- * enforcement leaves a greppable trace. Every host tolerates hook stderr:
68
- * claude/codex surface it only in verbose/debug on a 0 exit, amp forwards the
69
- * helper's stderr, and gemini reads stderr as a reason only on exit 2 — none of
70
- * these fail-safe exits is a 2 for a stdout-carrying transport, and amp's ask
71
- * (exit 1) shows the diagnostic alongside the prompt, which is the point.
65
+ * A judge that THROWS is not a malformed payload: the call parsed, and the judge
66
+ * failed to answer. That takes the same path as a judge that answers garbage —
67
+ * the clamp's `ask` (see below) — so a crashing guardrail puts the call in front
68
+ * of a human instead of taking an exit-0 fail-safe that runs it.
69
+ *
70
+ * Every error is written to STDERR (never stdout, which belongs to the host
71
+ * transport) so a regression that silently disables enforcement leaves a
72
+ * greppable trace. Every host tolerates hook stderr: claude/codex surface it
73
+ * only in verbose/debug on a 0 exit, amp forwards the helper's stderr, and
74
+ * gemini reads stderr as a reason only on exit 2.
72
75
  * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
73
76
  * so the guardrail is supplied here rather than by forking this file. Whatever
74
77
  * it returns is treated as untrusted (see the clamp below).
@@ -96,23 +99,49 @@ export function renderHookResponse(
96
99
  `hook payload must be a JSON object, got ${native === null ? "null" : Array.isArray(native) ? "array" : typeof native}`,
97
100
  );
98
101
  const event = adapter.parse(native);
102
+ /** @type {unknown} */
103
+ let answer;
104
+ try {
105
+ answer = judge(event);
106
+ } catch (err) {
107
+ // No `decision`, so the clamp below turns this into an `ask`.
108
+ answer = { reason: `hook judge threw: ${describe(err).split("\n")[0]}` };
109
+ report("hook judge error", err);
110
+ }
99
111
  // A judge's answer is clamped, not trusted. `normalizeVerdict` (inside
100
112
  // every adapter's render) THROWS on a decision outside allow/deny/ask, and
101
- // that throw lands in the catch below — which returns the host's fail-safe,
102
- // exit 0 on claude/codex/gemini. So a judge that means DENY but spells it
103
- // "denied" would let the tool run. Clamping to "ask" first keeps a
104
- // malformed answer in front of a human. Prose is passed through: scrubbing
105
- // monitor-authored text needs a scrubber this transport module has no
106
- // business choosing, and the decision is the half that gates the call.
107
- const verdict = sanitizeVerdict(judge(event), (text) => text);
113
+ // that throw would land in the catch below — the host's fail-safe, exit 0
114
+ // on claude/codex/gemini. So a judge that means DENY but spells it "denied"
115
+ // would let the tool run. Clamping to "ask" first keeps a malformed answer
116
+ // in front of a human. Prose is passed through: scrubbing monitor-authored
117
+ // text needs a scrubber this transport module has no business choosing.
118
+ const verdict = sanitizeVerdict(answer, (text) => text);
108
119
  return adapter.render(verdict, event);
109
120
  } catch (err) {
110
- const detail = err instanceof Error ? (err.stack ?? err.message) : `${err}`;
111
- process.stderr.write(`[acp] hook pipeline error: ${detail}\n`);
121
+ report("hook pipeline error", err);
112
122
  return onFailure;
113
123
  }
114
124
  }
115
125
 
126
+ /**
127
+ * A thrown value as readable text: an Error's stack (or message when it has no
128
+ * stack), or the value itself for a bare throw.
129
+ * @param {unknown} err
130
+ * @returns {string}
131
+ */
132
+ function describe(err) {
133
+ return err instanceof Error ? (err.stack ?? err.message) : `${err}`;
134
+ }
135
+
136
+ /**
137
+ * Write one diagnostic line for `err` to stderr.
138
+ * @param {string} label
139
+ * @param {unknown} err
140
+ */
141
+ function report(label, err) {
142
+ process.stderr.write(`[acp] ${label}: ${describe(err)}\n`);
143
+ }
144
+
116
145
  /**
117
146
  * A one-millisecond synchronous sleep buffer for the EAGAIN retry in
118
147
  * {@link writeAllSync}. Allocated once at module scope: the retry path must not
@@ -4,16 +4,12 @@
4
4
  * release core; anything it can't coerce to a valid version (missing, empty,
5
5
  * garbage) yields `null` and is treated as too old — fail closed to advisory.
6
6
  *
7
- * TRUST BOUNDARY (sandbox, not adapter): `version` is read from the hook stdin
8
- * payload, so a payload that under-reports it downgrades enforcement to advisory
9
- * (a deny renders exit 0). The adapter cannot distinguish a spoofed version from
10
- * a genuinely old Codex — the ground-truth version lives outside the payload —
11
- * so anti-spoofing belongs to the sandbox that controls what the hook is fed
12
- * (the same "pin it from outside" posture the managed-config mount enforces for
13
- * the hook binary; see docs/monitor-invariants.md §Invariant 1). Coercing an
14
- * absent/garbage version to enforcing HERE would break the legitimate old-Codex
15
- * case (rendering an exit-2 block a pre-0.135 Codex ignores, while dishonestly
16
- * marking it `enforced`), so the adapter stays faithful to what the payload says.
7
+ * WHERE THE VERSION COMES FROM: Codex's PreToolUse stdin carries no `version`
8
+ * field, so the launcher that pins the Codex binary supplies it, through
9
+ * {@link createCodexAdapter}. A payload `version` is a fallback only, and the
10
+ * payload is untrusted: one that under-reports downgrades a deny to advisory. Coercing an absent version to enforcing HERE would
11
+ * break the legitimate old-Codex case (an exit-2 block a pre-0.135 Codex
12
+ * ignores, dishonestly marked `enforced`), so an unknown version stays advisory.
17
13
  * @param {unknown} version
18
14
  * @returns {boolean}
19
15
  */
@@ -22,10 +18,18 @@ export function canEnforce(version: unknown): boolean;
22
18
  * Parse a raw Codex hook payload. Never throws on unmodelled input. Marks the
23
19
  * event observe-only (this_call_vetoable=false) when the Codex version
24
20
  * predates hook enforcement.
21
+ *
22
+ * `version` is the Codex version the LAUNCHER pinned, and it wins. Codex's
23
+ * hook stdin documents no version field, so a payload `version` is read only
24
+ * when no launcher version was given: a wrapper that injects one into stdin
25
+ * keeps working. With neither, the event is advisory.
25
26
  * @param {any} native
27
+ * @param {{ version?: string }} [options]
26
28
  * @returns {ToolCallEvent}
27
29
  */
28
- export function parse(native: any): ToolCallEvent;
30
+ export function parse(native: any, { version }?: {
31
+ version?: string;
32
+ }): ToolCallEvent;
29
33
  /**
30
34
  * Render into Codex's native external-hook transport: a JSON body preserving the
31
35
  * native event name (`PreToolUse`/`PermissionRequest`/`PostToolUse`) plus exit 2
@@ -49,6 +53,18 @@ export function parse(native: any): ToolCallEvent;
49
53
  export function render(verdict: Verdict, event: ToolCallEvent, { soleGate }?: {
50
54
  soleGate?: boolean;
51
55
  }): NativeResponse;
56
+ /**
57
+ * Build a Codex adapter whose `parse` judges every payload against `version`,
58
+ * the Codex version the launcher pinned (Codex's hook stdin carries none).
59
+ * Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
60
+ * deny renders advisory. With no `version`, `parse` falls back to a payload
61
+ * `version` (see {@link parse}).
62
+ * @param {{ version?: string }} [options]
63
+ * @returns {import("../control-plane.mjs").Adapter}
64
+ */
65
+ export function createCodexAdapter({ version }?: {
66
+ version?: string;
67
+ }): import("../control-plane.mjs").Adapter;
52
68
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
53
69
  /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
54
70
  /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
@@ -124,8 +140,12 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
124
140
  * naming a key the host discards.
125
141
  */
126
142
  export const NATIVE_ASK_TIER: false;
127
- export const DEFAULT_DENY_REASON: "blocked by monitor";
128
- /** @type {import("../control-plane.mjs").Adapter} */
143
+ /**
144
+ * The Codex adapter with no launcher-pinned version. Real Codex sends no
145
+ * version, so every event it sends parses observe-only here. A deployment that
146
+ * runs Codex ≥ v0.135 builds its own with {@link createCodexAdapter}.
147
+ * @type {import("../control-plane.mjs").Adapter}
148
+ */
129
149
  export const codexAdapter: import("../control-plane.mjs").Adapter;
130
150
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
131
151
  export type Verdict = import("../control-plane.mjs").Verdict;