agent-control-plane-core 0.5.3 → 0.6.1

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.
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * The Codex CLI adapter — EXTERNAL_HOOK translator (≥ v0.135).
3
3
  *
4
- * Real Codex speaks nearly the same protocol as Claude Code: `PreToolUse` and
5
- * `PermissionRequest` hook events with `tool_name`/`tool_input`, and a deny
6
- * expressed as `hookSpecificOutput.permissionDecision = "deny"` plus exit 2.
4
+ * Real Codex speaks nearly the same protocol as Claude Code: `PreToolUse`,
5
+ * `PermissionRequest` and `PostToolUse` hook events with
6
+ * `tool_name`/`tool_input`/`tool_response`, a pre-tool deny expressed as
7
+ * `hookSpecificOutput.permissionDecision = "deny"` plus exit 2, and a post-tool
8
+ * block as top-level `decision: "block"` plus exit 2.
7
9
  * Managed pin: /etc/codex/requirements.toml + allow_managed_hooks_only.
8
10
  *
9
11
  * VERSION GATE: hook enforcement only exists in v0.135+. Below that, there is no
@@ -23,10 +25,13 @@ import {
23
25
  vetoableFor,
24
26
  assertGatedKinds,
25
27
  canonicalTool,
28
+ lookup,
26
29
  makeEvent,
27
30
  normalizeVerdict,
28
31
  nativeResponse,
29
- collectPassthrough,
32
+ UNRENDERED_ON_UNKNOWN,
33
+ readonlySet,
34
+ baseMeta,
30
35
  asObject,
31
36
  asString,
32
37
  asStringOrNull,
@@ -34,8 +39,12 @@ import {
34
39
 
35
40
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
36
41
  /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
37
- /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
38
42
  /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
43
+ // Re-exported on this subpath even though no signature below names it:
44
+ // tsc turns each @typedef into an `export type` in the generated .d.mts,
45
+ // so dropping this line breaks `import type { EventMeta } from "…/<agent>"`
46
+ // for every TypeScript consumer.
47
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
39
48
 
40
49
  export const AGENT = "codex";
41
50
  export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
@@ -43,50 +52,135 @@ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
43
52
  /**
44
53
  * Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `PreToolUse`
45
54
  * intercepts the shell (Bash) tool ONLY — other builtins and MCP tools never
46
- * reach it — so builtin is PARTIAL and MCP is UNCOVERED. Subagent and resumed
47
- * firing are undocumented ⇒ UNKNOWN (treated as uncovered until an item-⑤ probe
48
- * proves otherwise).
55
+ * reach it — so builtin is PARTIAL and MCP is UNCOVERED. A live probe
56
+ * ([X3]/[X4]) fired `PreToolUse` for a SUBAGENT's call and for a call in a
57
+ * RESUMED session, so both are PARTIAL too: covered, under the same Bash-only
58
+ * limit as [X1], rather than the ❓ the matrix carried before the probe ran.
59
+ *
60
+ * These rows describe PRE-TOOL routing, which is the only thing the matrix's
61
+ * [X1]/[X2] sources speak about. `parse` applies them to a pre-tool event only
62
+ * (see {@link vetoableCall}); Codex's `PostToolUse` fires for tools `PreToolUse`
63
+ * never sees, so a post-tool event judged by the MCP row would drop a block
64
+ * Codex documents it honours.
65
+ *
66
+ * The SUBAGENT row is the one the classifier now reaches on this host: the same
67
+ * probe found `agent_id`/`agent_type` present on a subagent's `PreToolUse`
68
+ * payload and ABSENT on the main thread's, so {@link classifyCallClass} answers
69
+ * SUBAGENT from that payload alone. It is PARTIAL, so such a call stays vetoable
70
+ * — the row's job here is to be honest, not to degrade. An MCP-named call takes
71
+ * the UNCOVERED MCP row either way, subagent or not.
72
+ *
73
+ * The RESUMED row is DECLARATIVE ONLY, for a reason no longer about coverage: a
74
+ * resumed session announces itself on `SessionStart` (`source: "resume"`), not on
75
+ * its tool payloads, and this package keeps no cross-event state — so the
76
+ * classifier cannot answer RESUMED from a lone pre-tool event and such a call is
77
+ * judged by the row its TOOL selects. Both rows are PARTIAL, so that reads the
78
+ * same either way.
49
79
  */
50
80
  /** @type {import("../control-plane.mjs").CoverageMap} */
51
81
  export const COVERAGE = Object.freeze({
52
82
  [CallClass.BUILTIN]: CoverageStatus.PARTIAL,
53
83
  [CallClass.MCP]: CoverageStatus.UNCOVERED,
54
- [CallClass.SUBAGENT]: CoverageStatus.UNKNOWN,
55
- [CallClass.RESUMED]: CoverageStatus.UNKNOWN,
84
+ [CallClass.SUBAGENT]: CoverageStatus.PARTIAL,
85
+ [CallClass.RESUMED]: CoverageStatus.PARTIAL,
56
86
  });
57
87
 
58
88
  /**
59
- * The event kinds this host can actually gate. `PreToolUse` is the one event Codex can veto. Every other native event parses as
60
- * UNKNOWN today, so without this set each of them reported an enforced block.
61
- * A kind absent here parses non-vetoable, so an unmodelled event never renders as
62
- * an enforced block the host will not perform. Module-private: `Object.freeze` does
63
- * not stop `Set.add`, so an exported set would let a consumer add UNKNOWN back
64
- * after `assertGatedKinds` has already run.
89
+ * The event kinds this host can actually gate. `PreToolUse`/`PermissionRequest`
90
+ * take a `permissionDecision: "deny"`; `PostToolUse` takes a top-level
91
+ * `decision: "block"` (exit 2 "writes the feedback reason to stderr and blocks
92
+ * further processing"), so a post-tool deny stops the turn even though the tool
93
+ * itself already ran — the same shape the Claude adapter gates. Every other
94
+ * native event parses as UNKNOWN, so without this set each of them reported an
95
+ * enforced block. A kind absent here parses non-vetoable, so an unmodelled event
96
+ * never renders as an enforced block the host will not perform. Module-private:
97
+ * `Object.freeze` does not stop `Set.add`, so an exported set would let a
98
+ * consumer add UNKNOWN back after `assertGatedKinds` has already run.
65
99
  */
66
- const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
100
+ const GATED_EVENTS = Object.freeze(
101
+ new Set([EventKind.PRE_TOOL, EventKind.POST_TOOL]),
102
+ );
67
103
  assertGatedKinds(GATED_EVENTS, AGENT);
68
104
 
105
+ /**
106
+ * Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each kind, so
107
+ * `render` drops them. Codex splits its two content channels by event:
108
+ * `updatedInput` exists only on PreToolUse (the call has not run yet), and
109
+ * `hookSpecificOutput.additionalContext` only on PostToolUse — Codex documents
110
+ * no context field on PreToolUse, where `updatedInput` is the whole surface.
111
+ *
112
+ * NEITHER kind can rewrite tool output: PostToolUse "can't undo side effects
113
+ * from a tool that already ran", and Codex rejects `updatedMCPToolOutput`, so
114
+ * `mutated_output` is dropped everywhere. A redaction verdict therefore does NOT
115
+ * reach the model here: the unredacted output stands, and a guardrail that must
116
+ * redact has to deny (post-tool, that blocks the turn rather than un-showing the
117
+ * output). Inventing a native key would be worse than the visible gap, because
118
+ * the host ignores it and the caller reads the render as a redaction applied.
119
+ * @type {Record<string, ReadonlySet<string>|undefined>}
120
+ */
121
+ export const UNRENDERED_FIELDS = Object.freeze({
122
+ ...Object.fromEntries(
123
+ Object.values(EventKind).map((kind) => [kind, UNRENDERED_ON_UNKNOWN]),
124
+ ),
125
+ [EventKind.PRE_TOOL]: readonlySet(["mutated_output", "additional_context"]),
126
+ [EventKind.POST_TOOL]: readonlySet(["mutated_input", "mutated_output"]),
127
+ });
128
+
69
129
  /** Minimum Codex version whose hook can actually veto a tool call. */
70
130
  export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
71
131
 
72
132
  /** {@link MIN_ENFORCING_VERSION} as the semver string the gate compares against. */
73
133
  const MIN_ENFORCING_SEMVER = `${MIN_ENFORCING_VERSION[0]}.${MIN_ENFORCING_VERSION[1]}.0`;
74
134
 
75
- // Both native events gate a tool call; PermissionRequest is the ask-tier veto.
76
- const GATING_EVENTS = new Set(["PreToolUse", "PermissionRequest"]);
135
+ /**
136
+ * Codex native hook event names (the `hook_event_name` field) this adapter
137
+ * models. Module-private: the barrel already exports a `HookEvent` (Claude
138
+ * Code's), and nothing outside this file needs a second one — `NATIVE_EVENT_FOR`
139
+ * is the exported answer for which native event a kind carries.
140
+ */
141
+ const HookEvent = Object.freeze({
142
+ PRE_TOOL_USE: "PreToolUse",
143
+ PERMISSION_REQUEST: "PermissionRequest",
144
+ POST_TOOL_USE: "PostToolUse",
145
+ });
146
+
147
+ /**
148
+ * Native event name → normalized {@link EventKind}. `PreToolUse` and
149
+ * `PermissionRequest` both gate a tool call before it runs (PermissionRequest is
150
+ * the ask-tier veto), so both normalize to `pre_tool`. Codex emits more events
151
+ * than these — SessionStart/SessionEnd/Stop/PreCompact/… — and every one of them
152
+ * still routes to `unknown`: this adapter models an event only once its host
153
+ * response is established, and an unmodelled event parses non-vetoable.
154
+ */
155
+ const NATIVE_TO_KIND = Object.freeze({
156
+ [HookEvent.PRE_TOOL_USE]: EventKind.PRE_TOOL,
157
+ [HookEvent.PERMISSION_REQUEST]: EventKind.PRE_TOOL,
158
+ [HookEvent.POST_TOOL_USE]: EventKind.POST_TOOL,
159
+ });
160
+
161
+ /**
162
+ * The native event a conformance probe should carry for each kind — this
163
+ * adapter's own answer, so an every-kind probe exercises the branch that kind
164
+ * really takes. `pre_tool` names `PreToolUse` rather than `PermissionRequest`
165
+ * because that is the branch a synthesized probe should take; `unknown` has no
166
+ * native name by definition and is absent.
167
+ * @type {Record<string, string|undefined>}
168
+ */
169
+ export const NATIVE_EVENT_FOR = Object.freeze({
170
+ [EventKind.PRE_TOOL]: HookEvent.PRE_TOOL_USE,
171
+ [EventKind.POST_TOOL]: HookEvent.POST_TOOL_USE,
172
+ });
77
173
 
78
174
  // Codex drops an enforced deny that carries no (or an empty) reason and runs the
79
175
  // tool, so a reasonless enforced deny still renders a non-empty one.
80
176
  export const DEFAULT_DENY_REASON = "blocked by monitor";
81
177
 
178
+ // The STANDARD_META_FIELDS are consumed by `baseMeta`, not listed here.
82
179
  const CONSUMED = new Set([
83
180
  "hook_event_name",
84
- "session_id",
85
- "cwd",
86
- "permission_mode",
87
- "transcript_path",
88
181
  "tool_name",
89
182
  "tool_input",
183
+ "tool_response",
90
184
  "version",
91
185
  ]);
92
186
 
@@ -115,6 +209,34 @@ export function canEnforce(version) {
115
209
  return semver.gte(coerced, MIN_ENFORCING_SEMVER);
116
210
  }
117
211
 
212
+ /**
213
+ * Whether an event of `kind` may be marked vetoable, given the tool it names.
214
+ *
215
+ * PRE_TOOL takes the coverage floor: `PreToolUse` fires for Bash only, so an
216
+ * MCP-sourced call is un-vetoable regardless of version (COVERAGE.mcp).
217
+ *
218
+ * POST_TOOL does NOT, and that is deliberate: {@link COVERAGE} records which
219
+ * calls reach `PreToolUse`, and a `PostToolUse` payload in hand is proof the
220
+ * post-tool hook already fired for this call — Codex documents PostToolUse
221
+ * firing for tools (apply_patch, MCP) that `PreToolUse` never sees. Judging it
222
+ * by the pre-tool MCP row would degrade a block Codex honours to a notify,
223
+ * which is a fail-CLOSED error but still a wrong one. It stays gated on
224
+ * GATED_EVENTS and on the version floor, so nothing here can mark an unmodelled
225
+ * or pre-0.135 event vetoable.
226
+ * @param {string} kind an {@link EventKind} value
227
+ * @param {string|null} nativeTool
228
+ * @param {Record<string, unknown>} raw the native payload, for `mcp_context`
229
+ * @returns {boolean}
230
+ */
231
+ function vetoableCall(kind, nativeTool, raw) {
232
+ if (kind === EventKind.POST_TOOL) return GATED_EVENTS.has(kind);
233
+ return vetoableFor(
234
+ kind,
235
+ GATED_EVENTS,
236
+ COVERAGE[classifyCallClass(nativeTool, raw)],
237
+ );
238
+ }
239
+
118
240
  /**
119
241
  * Parse a raw Codex hook payload. Never throws on unmodelled input. Marks the
120
242
  * event observe-only (this_call_vetoable=false) when the Codex version
@@ -125,60 +247,56 @@ export function canEnforce(version) {
125
247
  export function parse(native) {
126
248
  const raw = asObject(native);
127
249
  const nativeEvent = asString(raw.hook_event_name, "");
128
- const gating = GATING_EVENTS.has(nativeEvent);
129
- const kind = gating ? EventKind.PRE_TOOL : EventKind.UNKNOWN;
250
+ const kind =
251
+ lookup(
252
+ /** @type {Record<string, string>} */ (NATIVE_TO_KIND),
253
+ nativeEvent,
254
+ ) ?? EventKind.UNKNOWN;
130
255
  const enforce = canEnforce(raw.version);
131
256
 
132
- /** @type {EventMeta} */
133
- const meta = {
257
+ const meta = baseMeta({
134
258
  agent: AGENT,
135
259
  native_event: nativeEvent,
136
260
  integration_mode: enforce
137
261
  ? IntegrationMode.EXTERNAL_HOOK
138
262
  : IntegrationMode.OBSERVE_ONLY,
139
263
  primary_gate_present: true,
140
- passthrough: collectPassthrough(raw, CONSUMED),
141
- };
142
- if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
143
- if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
144
- if (typeof raw.permission_mode === "string")
145
- meta.permission_mode = raw.permission_mode;
146
- if (typeof raw.transcript_path === "string")
147
- meta.transcript_path = raw.transcript_path;
148
-
149
- // Coverage floor: even on an enforcing Codex, `PreToolUse` fires for Bash only,
150
- // so an MCP-sourced call is un-vetoable regardless of version (COVERAGE.mcp).
264
+ native: raw,
265
+ consumed: CONSUMED,
266
+ });
267
+
151
268
  // Classify on the NATIVE name (MCP detection keys on `mcp__…`).
152
269
  const nativeTool = asStringOrNull(raw.tool_name);
153
270
  if (nativeTool !== null) meta.native_tool = nativeTool;
154
- const vetoable =
155
- enforce &&
156
- vetoableFor(
157
- kind,
158
- GATED_EVENTS,
159
- COVERAGE[classifyCallClass(nativeTool, raw)],
160
- );
161
271
 
162
272
  return makeEvent({
163
273
  event: kind,
164
274
  tool: canonicalTool(nativeTool),
165
275
  input: asObject(raw.tool_input),
166
- response: undefined,
167
- this_call_vetoable: vetoable,
276
+ // `tool_response` is Codex's "tool-specific output" field, PostToolUse only:
277
+ // no other modelled event carries one, and reading it elsewhere would invent
278
+ // a response for a call that has not run.
279
+ response: kind === EventKind.POST_TOOL ? raw.tool_response : undefined,
280
+ this_call_vetoable: enforce && vetoableCall(kind, nativeTool, raw),
168
281
  meta,
169
282
  });
170
283
  }
171
284
 
172
285
  /**
173
- * Render into Codex's native external-hook transport: a `hookSpecificOutput`
174
- * body preserving the native event name (`PreToolUse`/`PermissionRequest`) plus
175
- * exit 2 on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable`
176
- * false) the body still renders but exit stays 0 and `enforced` is false —
177
- * advisory only.
286
+ * Render into Codex's native external-hook transport: a JSON body preserving the
287
+ * native event name (`PreToolUse`/`PermissionRequest`/`PostToolUse`) plus exit 2
288
+ * on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable` false) the
289
+ * body still renders but exit stays 0 and `enforced` is false — advisory only.
290
+ *
291
+ * The two kinds use DIFFERENT native shapes, because Codex does: a pre-tool
292
+ * decision rides `hookSpecificOutput.permissionDecision`, a post-tool one the
293
+ * top-level `decision`/`reason` pair (`permissionDecision` is accepted but inert
294
+ * on PostToolUse, so emitting it there would read as a block that never bites).
178
295
  *
179
296
  * `soleGate` (default `false`) is the same dangerous, explicit opt-in as the
180
297
  * Claude adapter: when `true` AND the verdict is `allow`, the render emits the
181
- * real `permissionDecision: "allow"` instead of abstaining.
298
+ * real `permissionDecision: "allow"` instead of abstaining. It has no meaning
299
+ * post-tool — there is no approval left to grant — so it is ignored there.
182
300
  * @param {Verdict} verdict
183
301
  * @param {ToolCallEvent} event
184
302
  * @param {{ soleGate?: boolean }} [options]
@@ -187,8 +305,31 @@ export function parse(native) {
187
305
  export function render(verdict, event, { soleGate = false } = {}) {
188
306
  const vd = normalizeVerdict(verdict);
189
307
  const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
190
- const hookEventName = event.meta.native_event || "PreToolUse";
308
+ // The native name is preserved rather than derived from the kind, so a
309
+ // PermissionRequest answers as itself; NATIVE_EVENT_FOR names the fallback.
310
+ const hookEventName = event.meta.native_event || HookEvent.PRE_TOOL_USE;
191
311
 
312
+ return nativeResponse({
313
+ transport: event.meta.integration_mode,
314
+ exit_code: enforced ? 2 : 0,
315
+ enforced,
316
+ stdout:
317
+ event.event === EventKind.POST_TOOL
318
+ ? postToolBody(hookEventName, vd, enforced)
319
+ : preToolBody(hookEventName, vd, event, enforced, soleGate),
320
+ });
321
+ }
322
+
323
+ /**
324
+ * The `hookSpecificOutput` body for a pre-tool (or unmodelled) event.
325
+ * @param {string} hookEventName
326
+ * @param {Verdict} vd a normalized verdict
327
+ * @param {ToolCallEvent} event
328
+ * @param {boolean} enforced
329
+ * @param {boolean} soleGate
330
+ * @returns {Record<string, unknown>}
331
+ */
332
+ function preToolBody(hookEventName, vd, event, enforced, soleGate) {
192
333
  // As in the Claude adapter, `allow` omits permissionDecision by default so the
193
334
  // guardrail never auto-approves a call it merely had no objection to; only
194
335
  // deny/ask emit an explicit decision, unless `soleGate` opts into it.
@@ -205,14 +346,44 @@ export function render(verdict, event, { soleGate = false } = {}) {
205
346
  // deny, whatever the judge supplied.
206
347
  if (enforced && !body.permissionDecisionReason)
207
348
  body.permissionDecisionReason = DEFAULT_DENY_REASON;
208
- if (vd.mutated_input !== undefined) body.updatedInput = vd.mutated_input;
349
+ // PRE_TOOL only. An UNKNOWN event reaches this body too, and an event the
350
+ // adapter cannot name has no channel it can claim: emitting `updatedInput`
351
+ // there names a key the host ignores while reading to the caller as a
352
+ // mutation applied.
353
+ if (event.event === EventKind.PRE_TOOL && vd.mutated_input !== undefined)
354
+ body.updatedInput = vd.mutated_input;
355
+ return { hookSpecificOutput: body };
356
+ }
209
357
 
210
- return nativeResponse({
211
- transport: event.meta.integration_mode,
212
- exit_code: enforced ? 2 : 0,
213
- enforced,
214
- stdout: { hookSpecificOutput: body },
215
- });
358
+ /**
359
+ * The body for a PostToolUse event: Codex's documented post-tool schema, a
360
+ * top-level `decision`/`reason` beside a `hookSpecificOutput` carrying
361
+ * `additionalContext`. The tool has already run, so every non-allow verdict —
362
+ * `ask` included, which has no post-tool tier of its own — renders as the one
363
+ * objection the host understands there, `block`.
364
+ * @param {string} hookEventName
365
+ * @param {Verdict} vd a normalized verdict
366
+ * @param {boolean} enforced
367
+ * @returns {Record<string, unknown>}
368
+ */
369
+ function postToolBody(hookEventName, vd, enforced) {
370
+ /** @type {Record<string, unknown>} */
371
+ const hookSpecificOutput = { hookEventName };
372
+ if (vd.additional_context !== undefined)
373
+ hookSpecificOutput.additionalContext = vd.additional_context;
374
+ /** @type {Record<string, unknown>} */
375
+ const body = { hookSpecificOutput };
376
+ if (vd.decision !== Decision.ALLOW) {
377
+ body.decision = "block";
378
+ if (vd.reason !== undefined) body.reason = vd.reason;
379
+ }
380
+ // The same guarantee the pre-tool body makes, for the same reason: the
381
+ // documented post-tool schema pairs `decision` with a `reason`, and a block
382
+ // whose reason Codex may drop is a block that may not bite. Unverified for
383
+ // PostToolUse specifically — a non-empty reason is the safe side of that, and
384
+ // the model needs the text either way.
385
+ if (enforced && !body.reason) body.reason = DEFAULT_DENY_REASON;
386
+ return body;
216
387
  }
217
388
 
218
389
  /** @type {import("../control-plane.mjs").Adapter} */
@@ -220,6 +391,8 @@ export const codexAdapter = {
220
391
  AGENT,
221
392
  INTEGRATION_MODE,
222
393
  COVERAGE,
394
+ UNRENDERED_FIELDS,
395
+ NATIVE_EVENT_FOR,
223
396
  parse,
224
397
  render,
225
398
  };