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.
package/README.md CHANGED
@@ -32,7 +32,7 @@ removes.
32
32
  declaration of what that agent's transport cannot carry. `parse` turns that
33
33
  agent's raw hook payload into a `ToolCallEvent`; `render` turns your `Verdict`
34
34
  back into the **real** native signal that agent enforces (the right JSON body,
35
- exit code, or thrown error — not just a print).
35
+ exit code and stderr reason — not just a print).
36
36
 
37
37
  You write your logic **once** against the normalized types. Adding support for a
38
38
  new agent is a new adapter (~one file + fixtures), not a rewrite of your tool.
@@ -85,7 +85,8 @@ pnpm add agent-control-plane-core
85
85
  // Verdict — a normalized guardrail decision.
86
86
  {
87
87
  decision: "allow" | "deny" | "ask",
88
- mutated_input?: object, // replacement tool input
88
+ mutated_input?: object, // replacement tool input (pre_tool)
89
+ mutated_output?: unknown, // replacement tool output the model sees (post_tool)
89
90
  additional_context?: string, // extra context to splice into the agent's stream
90
91
  reason?: string, // shown on deny/ask
91
92
  }
@@ -144,9 +145,10 @@ the render does not carry.
144
145
 
145
146
  ```js
146
147
  import { claudeAdapter } from "agent-control-plane-core/claude";
148
+ import { readStdin, emit } from "agent-control-plane-core/runtime";
147
149
 
148
150
  // 1. Normalize the agent's raw hook payload.
149
- const event = claudeAdapter.parse(rawHookJson);
151
+ const event = claudeAdapter.parse(JSON.parse(await readStdin()));
150
152
 
151
153
  // 2. Your guardrail decides, using only the normalized types.
152
154
  const verdict =
@@ -154,16 +156,22 @@ const verdict =
154
156
  ? { decision: "deny", reason: "destructive command blocked" }
155
157
  : { decision: "allow" };
156
158
 
157
- // 3. Render back to the agent's native response.
158
- process.stdout.write(JSON.stringify(claudeAdapter.render(verdict, event)));
159
+ // 3. Send the native response: the stdout body, the block reason on stderr,
160
+ // and the exit code (2 on this deny), which is what makes Claude Code block.
161
+ emit(claudeAdapter.render(verdict, event));
159
162
  ```
160
163
 
161
- Writing your own hook entry? `agent-control-plane-core/runtime` publishes the
162
- plumbing the four `bin/*-hook.mjs` entries share — `readStdin`,
163
- `renderHookResponse` (parse → judge → render, falling back to the failure
164
- response THAT host expects) and `emit`, whose fully-drained write is what stops
165
- a large deny body being truncated on a non-blocking pipe and silently becoming
166
- an allow.
164
+ `render` returns the whole native response, not a body to print. Writing
165
+ `JSON.stringify(render(...))` to stdout exits 0, so the host never blocks.
166
+
167
+ `agent-control-plane-core/runtime` publishes the rest of the plumbing the four
168
+ hook entries share. `renderHookResponse` runs parse → judge → render, and returns
169
+ the failure response THAT host expects when the payload does not parse. `emit`
170
+ drains the whole body before it exits, so a large deny body is never truncated
171
+ on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
172
+ `acp-amp-hook` and `acp-gemini-hook`. Codex's hook stdin carries no version, so
173
+ set `ACP_CODEX_VERSION` to the Codex version your launcher pins (or call
174
+ `createCodexAdapter({ version })`); without it, Codex calls parse advisory.
167
175
 
168
176
  The core contract and every adapter (`claude`, `codex`, `amp`, `gemini`) are
169
177
  also on the default entry:
@@ -285,7 +293,7 @@ and bumps the version.
285
293
  ```bash
286
294
  ./setup.sh # install deps + git hooks
287
295
  pnpm test # node --test
288
- pnpm test:coverage # c8, 100% lines/branches/functions per file
296
+ pnpm test:coverage # c8, the per-file floor .c8rc.json sets
289
297
  pnpm check # tsc --noEmit (JSDoc types)
290
298
  pnpm gen:schema # regenerate schema/ from the contract
291
299
  pnpm lint # eslint
package/bin/amp-hook.mjs CHANGED
File without changes
File without changes
@@ -4,18 +4,22 @@
4
4
  * PreToolUse/PermissionRequest/PostToolUse payload on stdin, applies the demo
5
5
  * judge, and emits Codex's native response.
6
6
  *
7
+ * CODEX VERSION — set `ACP_CODEX_VERSION` to the version of the Codex binary
8
+ * the launcher pinned (for example `ACP_CODEX_VERSION=0.151.0`). Codex's hook
9
+ * stdin carries no version, so without it every call parses observe-only and a
10
+ * deny renders advisory (exit 0). Below v0.135 the render is advisory too,
11
+ * because Codex has no hook veto there.
12
+ *
7
13
  * FAILURE POSTURE — fail OPEN (exit 0), like Claude Code: Codex treats a
8
- * non-conforming hook as non-blocking. On a pre-v0.135 Codex the render is
9
- * advisory anyway (the adapter marks it observe-only), so a failure and a
10
- * successful advisory both leave the tool to proceed — the sandbox is the
11
- * backstop.
14
+ * non-conforming hook as non-blocking. The sandbox is the backstop.
12
15
  *
13
- * Usage: `node bin/codex-hook.mjs < payload.json`
16
+ * Usage: `ACP_CODEX_VERSION=0.151.0 node bin/codex-hook.mjs < payload.json`
14
17
  */
15
- import { codexAdapter } from "../src/adapters/codex.mjs";
18
+ import { createCodexAdapter } from "../src/adapters/codex.mjs";
16
19
  import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
17
20
 
18
21
  /** @type {import("../src/control-plane.mjs").NativeResponse} */
19
22
  const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
20
23
 
21
- emit(renderHookResponse(codexAdapter, await readStdin(), FAIL_OPEN));
24
+ const adapter = createCodexAdapter({ version: process.env.ACP_CODEX_VERSION });
25
+ emit(renderHookResponse(adapter, await readStdin(), FAIL_OPEN));
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.8.2",
3
+ "version": "0.9.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": {
@@ -24,6 +24,12 @@
24
24
  },
25
25
  "main": "./src/index.mjs",
26
26
  "types": "./types/index.d.mts",
27
+ "bin": {
28
+ "acp-claude-hook": "./bin/claude-hook.mjs",
29
+ "acp-codex-hook": "./bin/codex-hook.mjs",
30
+ "acp-amp-hook": "./bin/amp-hook.mjs",
31
+ "acp-gemini-hook": "./bin/gemini-hook.mjs"
32
+ },
27
33
  "exports": {
28
34
  ".": {
29
35
  "types": "./types/index.d.mts",
@@ -25,7 +25,8 @@ import {
25
25
  lookup,
26
26
  makeEvent,
27
27
  normalizeVerdict,
28
- nativeResponse,
28
+ gatedResponse,
29
+ blockTransportFor,
29
30
  readonlySet,
30
31
  VERDICT_CONTENT_FIELDS,
31
32
  baseMeta,
@@ -185,7 +186,10 @@ export const NATIVE_ASK_TIER = true;
185
186
  */
186
187
  const EXIT_CODE_BY_DECISION = Object.freeze({
187
188
  [Decision.ALLOW]: Object.freeze({ true: 0, false: 0 }),
188
- [Decision.DENY]: Object.freeze({ true: 2, false: 1 }),
189
+ [Decision.DENY]: Object.freeze({
190
+ true: blockTransportFor(AGENT).exit_code,
191
+ false: 1,
192
+ }),
189
193
  [Decision.ASK]: Object.freeze({ true: 1, false: 1 }),
190
194
  });
191
195
 
@@ -265,11 +269,11 @@ export function render(verdict, event) {
265
269
  );
266
270
  const enforced = vd.decision === Decision.DENY && vetoable;
267
271
  const exit_code = exitCodeFor(EXIT_CODE_BY_DECISION, vd.decision, vetoable);
268
- return nativeResponse({
272
+ return gatedResponse(AGENT, {
269
273
  transport: INTEGRATION_MODE,
270
- exit_code,
274
+ unblocked_exit_code: exit_code,
271
275
  enforced,
272
- ...(enforced && vd.reason !== undefined ? { stderr: vd.reason } : {}),
276
+ reason: vd.reason,
273
277
  });
274
278
  }
275
279
 
@@ -26,7 +26,7 @@ import {
26
26
  lookup,
27
27
  makeEvent,
28
28
  normalizeVerdict,
29
- nativeResponse,
29
+ gatedResponse,
30
30
  UNRENDERED_ON_UNKNOWN,
31
31
  readonlySet,
32
32
  baseMeta,
@@ -260,20 +260,15 @@ export function render(verdict, event, { soleGate = false } = {}) {
260
260
  ? gatingBody(hookEventName, vd, soleGate)
261
261
  : nonGatingBody(hookEventName, kind, vd);
262
262
 
263
- return nativeResponse({
263
+ // Claude Code parses hook stdout as JSON only on exit 0: on an exit-2 block it
264
+ // discards the body and reads STDERR, which `gatedResponse` fills from the
265
+ // BLOCK_TRANSPORT row.
266
+ return gatedResponse(AGENT, {
264
267
  transport: INTEGRATION_MODE,
265
- exit_code: enforced ? 2 : 0,
268
+ unblocked_exit_code: 0,
266
269
  enforced,
267
270
  stdout,
268
- // An ENFORCED deny exits 2, and Claude Code parses hook stdout as JSON only
269
- // on exit 0: on an exit-2 PreToolUse block it discards the body and reads
270
- // STDERR instead. So `permissionDecisionReason` never reached the model on
271
- // the one path that actually blocks, and the call was refused with no
272
- // rationale. Carry the reason on `NativeResponse.stderr`, which `emit`
273
- // writes to fd 2 — the same block-reason channel the Amp and Gemini
274
- // adapters use. ONLY the enforced path: an allow, an ask, and a deny this
275
- // call cannot veto have blocked nothing, and their exit-0 body is read.
276
- ...(enforced && vd.reason !== undefined ? { stderr: vd.reason } : {}),
271
+ reason: vd.reason,
277
272
  });
278
273
  }
279
274
 
@@ -326,7 +321,10 @@ function nonGatingBody(hookEventName, kind, vd) {
326
321
  hookSpecificOutput.additionalContext = vd.additional_context;
327
322
  /** @type {Record<string, unknown>} */
328
323
  const out = { hookSpecificOutput };
329
- if (vd.decision !== Decision.ALLOW) {
324
+ // Not on an UNKNOWN kind either: Claude Code honours `decision: "block"` on
325
+ // events this adapter does not model (on Stop it keeps the agent running),
326
+ // so writing it there acts on the host while the render reports no block.
327
+ if (kind !== EventKind.UNKNOWN && vd.decision !== Decision.ALLOW) {
330
328
  out.decision = "block";
331
329
  if (vd.reason !== undefined) out.reason = vd.reason;
332
330
  }
@@ -10,7 +10,9 @@
10
10
  *
11
11
  * VERSION GATE: hook enforcement only exists in v0.135+. Below that, there is no
12
12
  * veto, so parse marks the event OBSERVE_ONLY with this_call_vetoable=false —
13
- * the adapter must not pretend to enforce on an old Codex.
13
+ * the adapter must not pretend to enforce on an old Codex. Codex's hook stdin
14
+ * carries no version, so the launcher that pins the binary supplies it through
15
+ * {@link createCodexAdapter}.
14
16
  */
15
17
 
16
18
  import semver from "semver";
@@ -28,7 +30,9 @@ import {
28
30
  lookup,
29
31
  makeEvent,
30
32
  normalizeVerdict,
31
- nativeResponse,
33
+ gatedResponse,
34
+ blockTransportFor,
35
+ blockReason,
32
36
  UNRENDERED_ON_UNKNOWN,
33
37
  readonlySet,
34
38
  baseMeta,
@@ -184,10 +188,6 @@ export const NATIVE_EVENT_FOR = Object.freeze({
184
188
  */
185
189
  export const NATIVE_ASK_TIER = false;
186
190
 
187
- // Codex drops an enforced deny that carries no (or an empty) reason and runs the
188
- // tool, so a reasonless enforced deny still renders a non-empty one.
189
- export const DEFAULT_DENY_REASON = "blocked by monitor";
190
-
191
191
  // The STANDARD_META_FIELDS are consumed by `baseMeta`, not listed here.
192
192
  const CONSUMED = new Set([
193
193
  "hook_event_name",
@@ -203,16 +203,12 @@ const CONSUMED = new Set([
203
203
  * release core; anything it can't coerce to a valid version (missing, empty,
204
204
  * garbage) yields `null` and is treated as too old — fail closed to advisory.
205
205
  *
206
- * TRUST BOUNDARY (sandbox, not adapter): `version` is read from the hook stdin
207
- * payload, so a payload that under-reports it downgrades enforcement to advisory
208
- * (a deny renders exit 0). The adapter cannot distinguish a spoofed version from
209
- * a genuinely old Codex — the ground-truth version lives outside the payload —
210
- * so anti-spoofing belongs to the sandbox that controls what the hook is fed
211
- * (the same "pin it from outside" posture the managed-config mount enforces for
212
- * the hook binary; see docs/monitor-invariants.md §Invariant 1). Coercing an
213
- * absent/garbage version to enforcing HERE would break the legitimate old-Codex
214
- * case (rendering an exit-2 block a pre-0.135 Codex ignores, while dishonestly
215
- * marking it `enforced`), so the adapter stays faithful to what the payload says.
206
+ * WHERE THE VERSION COMES FROM: Codex's PreToolUse stdin carries no `version`
207
+ * field, so the launcher that pins the Codex binary supplies it, through
208
+ * {@link createCodexAdapter}. A payload `version` is a fallback only, and the
209
+ * payload is untrusted: one that under-reports downgrades a deny to advisory. Coercing an absent version to enforcing HERE would
210
+ * break the legitimate old-Codex case (an exit-2 block a pre-0.135 Codex
211
+ * ignores, dishonestly marked `enforced`), so an unknown version stays advisory.
216
212
  * @param {unknown} version
217
213
  * @returns {boolean}
218
214
  */
@@ -254,10 +250,16 @@ function vetoableCall(kind, nativeTool, raw) {
254
250
  * Parse a raw Codex hook payload. Never throws on unmodelled input. Marks the
255
251
  * event observe-only (this_call_vetoable=false) when the Codex version
256
252
  * predates hook enforcement.
253
+ *
254
+ * `version` is the Codex version the LAUNCHER pinned, and it wins. Codex's
255
+ * hook stdin documents no version field, so a payload `version` is read only
256
+ * when no launcher version was given: a wrapper that injects one into stdin
257
+ * keeps working. With neither, the event is advisory.
257
258
  * @param {any} native
259
+ * @param {{ version?: string }} [options]
258
260
  * @returns {ToolCallEvent}
259
261
  */
260
- export function parse(native) {
262
+ export function parse(native, { version } = {}) {
261
263
  const raw = asObject(native);
262
264
  const nativeEvent = asString(raw.hook_event_name, "");
263
265
  const kind =
@@ -265,7 +267,7 @@ export function parse(native) {
265
267
  /** @type {Record<string, string>} */ (NATIVE_TO_KIND),
266
268
  nativeEvent,
267
269
  ) ?? EventKind.UNKNOWN;
268
- const enforce = canEnforce(raw.version);
270
+ const enforce = canEnforce(version ?? raw.version);
269
271
 
270
272
  const meta = baseMeta({
271
273
  agent: AGENT,
@@ -330,10 +332,11 @@ export function render(verdict, event, { soleGate = false } = {}) {
330
332
  // PermissionRequest answers as itself; NATIVE_EVENT_FOR names the fallback.
331
333
  const hookEventName = event.meta.native_event || HookEvent.PRE_TOOL_USE;
332
334
 
333
- return nativeResponse({
335
+ return gatedResponse(AGENT, {
334
336
  transport: event.meta.integration_mode,
335
- exit_code: enforced ? 2 : 0,
337
+ unblocked_exit_code: 0,
336
338
  enforced,
339
+ reason: vd.reason,
337
340
  stdout:
338
341
  event.event === EventKind.POST_TOOL
339
342
  ? postToolBody(hookEventName, vd, enforced)
@@ -356,17 +359,21 @@ function preToolBody(hookEventName, vd, event, enforced, soleGate) {
356
359
  // deny/ask emit an explicit decision, unless `soleGate` opts into it.
357
360
  /** @type {Record<string, unknown>} */
358
361
  const body = { hookEventName };
359
- if (vd.decision !== Decision.ALLOW || soleGate) {
362
+ // An UNKNOWN event reaches this body too. It has no established decision
363
+ // channel, so it carries none: a decision there would act on the host while
364
+ // the render reports no block.
365
+ if (
366
+ event.event === EventKind.PRE_TOOL &&
367
+ (vd.decision !== Decision.ALLOW || soleGate)
368
+ ) {
360
369
  body.permissionDecision = vd.decision;
361
370
  if (vd.reason !== undefined) body.permissionDecisionReason = vd.reason;
362
371
  }
363
372
  // Codex FAILS OPEN on an enforced deny whose permissionDecisionReason is missing
364
373
  // or empty: its PreToolUse output parser drops the block (block_reason = None)
365
- // and runs the tool. Unlike Claude, which honours a bare deny, Codex needs a
366
- // non-empty reason for the block to bite — so guarantee one on every enforced
367
- // deny, whatever the judge supplied.
368
- if (enforced && !body.permissionDecisionReason)
369
- body.permissionDecisionReason = DEFAULT_DENY_REASON;
374
+ // and runs the tool (BLOCK_TRANSPORT.codex.reason_required).
375
+ if (enforced && blockTransportFor(AGENT).reason_required)
376
+ body.permissionDecisionReason = blockReason(vd.reason);
370
377
  // PRE_TOOL only. An UNKNOWN event reaches this body too, and an event the
371
378
  // adapter cannot name has no channel it can claim: emitting `updatedInput`
372
379
  // there names a key the host ignores while reading to the caller as a
@@ -403,18 +410,37 @@ function postToolBody(hookEventName, vd, enforced) {
403
410
  // whose reason Codex may drop is a block that may not bite. Unverified for
404
411
  // PostToolUse specifically — a non-empty reason is the safe side of that, and
405
412
  // the model needs the text either way.
406
- if (enforced && !body.reason) body.reason = DEFAULT_DENY_REASON;
413
+ if (enforced && blockTransportFor(AGENT).reason_required)
414
+ body.reason = blockReason(vd.reason);
407
415
  return body;
408
416
  }
409
417
 
410
- /** @type {import("../control-plane.mjs").Adapter} */
411
- export const codexAdapter = {
412
- AGENT,
413
- INTEGRATION_MODE,
414
- COVERAGE,
415
- UNRENDERED_FIELDS,
416
- NATIVE_ASK_TIER,
417
- NATIVE_EVENT_FOR,
418
- parse,
419
- render,
420
- };
418
+ /**
419
+ * Build a Codex adapter whose `parse` judges every payload against `version`,
420
+ * the Codex version the launcher pinned (Codex's hook stdin carries none).
421
+ * Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
422
+ * deny renders advisory. With no `version`, `parse` falls back to a payload
423
+ * `version` (see {@link parse}).
424
+ * @param {{ version?: string }} [options]
425
+ * @returns {import("../control-plane.mjs").Adapter}
426
+ */
427
+ export function createCodexAdapter({ version } = {}) {
428
+ return {
429
+ AGENT,
430
+ INTEGRATION_MODE,
431
+ COVERAGE,
432
+ UNRENDERED_FIELDS,
433
+ NATIVE_ASK_TIER,
434
+ NATIVE_EVENT_FOR,
435
+ parse: (native) => parse(native, { version }),
436
+ render,
437
+ };
438
+ }
439
+
440
+ /**
441
+ * The Codex adapter with no launcher-pinned version. Real Codex sends no
442
+ * version, so every event it sends parses observe-only here. A deployment that
443
+ * runs Codex ≥ v0.135 builds its own with {@link createCodexAdapter}.
444
+ * @type {import("../control-plane.mjs").Adapter}
445
+ */
446
+ export const codexAdapter = createCodexAdapter();
@@ -43,7 +43,7 @@ import {
43
43
  MODELED_TOOL_INPUT_KEYS,
44
44
  makeEvent,
45
45
  normalizeVerdict,
46
- nativeResponse,
46
+ gatedResponse,
47
47
  UNRENDERED_ON_UNKNOWN,
48
48
  readonlySet,
49
49
  baseMeta,
@@ -374,31 +374,23 @@ export function render(verdict, event, { soleGate = false } = {}) {
374
374
  const vd = normalizeVerdict(verdict);
375
375
  const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
376
376
 
377
- // Enforced deny is a System Block: exit 2, reason carried on STDERR (Gemini
378
- // reads its block rationale from stderr, not the ignored stdout body). Carry it
379
- // on NativeResponse.stderr so `emit` writes it to fd 2 — a genuine block is
380
- // never surfaced to the user/model with no explanation.
381
- // No stdout body at all on this path, so EVERY content field is dropped here,
382
- // whatever UNRENDERED_FIELDS says for the kind — that map describes the allow
383
- // path. Nothing is lost that a blocked call could still use: the tool never
384
- // runs, so only `additional_context` had anywhere to go.
385
- if (enforced)
386
- return nativeResponse({
387
- transport: INTEGRATION_MODE,
388
- exit_code: 2,
389
- enforced: true,
390
- ...(vd.reason !== undefined ? { stderr: vd.reason } : {}),
391
- });
392
-
393
- const body =
394
- event.event === EventKind.PROMPT_SUBMIT
395
- ? promptSubmitBody(vd)
396
- : decisionBody(vd, event.event, soleGate);
397
- return nativeResponse({
377
+ // An enforced deny is a System Block: `gatedResponse` writes the exit code
378
+ // and the stderr reason from the BLOCK_TRANSPORT row, and there is no stdout
379
+ // body, so EVERY content field is dropped whatever UNRENDERED_FIELDS says —
380
+ // that map describes the allow path. The tool never runs, so only
381
+ // `additional_context` had anywhere to go.
382
+ let body;
383
+ if (!enforced)
384
+ body =
385
+ event.event === EventKind.PROMPT_SUBMIT
386
+ ? promptSubmitBody(vd)
387
+ : decisionBody(vd, event.event, soleGate);
388
+ return gatedResponse(AGENT, {
398
389
  transport: INTEGRATION_MODE,
399
- exit_code: 0,
400
- enforced: false,
401
- ...(body === undefined ? {} : { stdout: body }),
390
+ unblocked_exit_code: 0,
391
+ enforced,
392
+ stdout: body,
393
+ reason: vd.reason,
402
394
  });
403
395
  }
404
396
 
@@ -436,7 +428,10 @@ function applyAdvisoryDeny(out, vd) {
436
428
  function decisionBody(vd, kind, soleGate) {
437
429
  /** @type {Record<string, unknown>} */
438
430
  const out = {};
439
- if (!applyAdvisoryDeny(out, vd) && soleGate) out.decision = "allow";
431
+ // An UNKNOWN kind has no established decision channel, and Gemini acts on a
432
+ // `decision` it does see (AfterAgent's deny forces a retry), so it gets none.
433
+ if (kind !== EventKind.UNKNOWN && !applyAdvisoryDeny(out, vd) && soleGate)
434
+ out.decision = "allow";
440
435
  if (kind === EventKind.PRE_TOOL && vd.mutated_input !== undefined)
441
436
  out.hookSpecificOutput = { tool_input: vd.mutated_input };
442
437
  // `systemMessage` is documented on the TOOL events. An UNKNOWN kind is an
@@ -5,6 +5,7 @@ import {
5
5
  IntegrationMode,
6
6
  MODELED_TOOL_INPUT_KEYS,
7
7
  TOOL_ALIASES,
8
+ BLOCK_TRANSPORT,
8
9
  canonicalTool,
9
10
  coverageAllowsVeto,
10
11
  isCoverageStatus,
@@ -860,6 +861,33 @@ function assertAliasedInput(agent, caseName, event, assert) {
860
861
  );
861
862
  }
862
863
 
864
+ /**
865
+ * Hold an enforced render to its host's {@link BLOCK_TRANSPORT} row: the row's
866
+ * exit code, and a non-empty reason on the row's reason stream. An adapter with
867
+ * no row (one this package does not ship) is held to the reason on `stderr`,
868
+ * the contract's block-reason channel. A block with no reason reaches the
869
+ * model with no rationale, and Codex drops it and runs the tool.
870
+ * @param {string} agent
871
+ * @param {import("./control-plane.mjs").NativeResponse} rendered
872
+ * @param {string} label
873
+ * @param {any} assert
874
+ */
875
+ function assertBlockTransport(agent, rendered, label, assert) {
876
+ const row = lookup(BLOCK_TRANSPORT, agent);
877
+ if (row !== undefined)
878
+ assert.equal(
879
+ rendered.exit_code,
880
+ row.exit_code,
881
+ `enforced deny exits ${rendered.exit_code}, but ${agent} blocks on exit ${row.exit_code}: ${label}`,
882
+ );
883
+ const stream = row?.reason_stream ?? "stderr";
884
+ const reason = rendered[stream];
885
+ assert.ok(
886
+ typeof reason === "string" && reason.trim() !== "",
887
+ `enforced deny carries no reason on ${stream}: ${label}`,
888
+ );
889
+ }
890
+
863
891
  /**
864
892
  * The control-plane conformance harness.
865
893
  *
@@ -879,7 +907,8 @@ function assertAliasedInput(agent, caseName, event, assert) {
879
907
  * decision the contract requires every adapter to express.
880
908
  * 4. enforcement honesty: an enforceable deny (`rendered.enforced === true`)
881
909
  * MUST carry a real block signal — a non-zero `exit_code` — not just a JSON
882
- * body the agent is free to ignore. At least one enforced deny must appear,
910
+ * body the agent is free to ignore — plus a non-empty reason on the
911
+ * reason stream its {@link BLOCK_TRANSPORT} row names. At least one enforced deny must appear,
883
912
  * so the honesty check is never vacuous.
884
913
  * 5. vetoability honesty, BOTH directions. When the parsed event's
885
914
  * `this_call_vetoable` is false, EVERY render for that case must be
@@ -1033,6 +1062,12 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1033
1062
  rendered.exit_code !== 0,
1034
1063
  `enforced deny carries no block signal: ${testCase.name} / ${scenario}`,
1035
1064
  );
1065
+ assertBlockTransport(
1066
+ adapter.AGENT,
1067
+ rendered,
1068
+ `${testCase.name} / ${scenario}`,
1069
+ assert,
1070
+ );
1036
1071
  enforcedDenySeen = true;
1037
1072
  }
1038
1073
  if (parsed.this_call_vetoable === false) {