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.
- package/README.md +35 -6
- package/bin/codex-hook.mjs +3 -2
- package/package.json +1 -1
- package/src/adapters/amp.mjs +89 -17
- package/src/adapters/claude.mjs +75 -36
- package/src/adapters/codex.mjs +234 -61
- package/src/adapters/gemini.mjs +196 -78
- package/src/conformance.mjs +614 -1
- package/src/control-plane.mjs +173 -10
- package/src/fixtures/amp.json +8 -3
- package/src/fixtures/codex.json +277 -0
- package/src/fixtures/gemini.json +99 -2
- package/src/index.mjs +5 -1
- package/types/adapters/amp.d.mts +48 -6
- package/types/adapters/claude.d.mts +34 -3
- package/types/adapters/codex.d.mts +63 -11
- package/types/adapters/gemini.d.mts +58 -8
- package/types/conformance.d.mts +16 -1
- package/types/control-plane.d.mts +178 -75
- package/types/index.d.mts +1 -1
package/src/adapters/codex.mjs
CHANGED
|
@@ -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
|
|
5
|
-
* `PermissionRequest` hook events with
|
|
6
|
-
*
|
|
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
|
-
|
|
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.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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.
|
|
55
|
-
[CallClass.RESUMED]: CoverageStatus.
|
|
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`
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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(
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
};
|