agent-control-plane-core 0.8.2 → 0.9.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 +20 -12
- package/bin/amp-hook.mjs +0 -0
- package/bin/claude-hook.mjs +0 -0
- package/bin/codex-hook.mjs +11 -7
- package/bin/gemini-hook.mjs +0 -0
- package/package.json +7 -1
- package/src/adapters/amp.mjs +9 -5
- package/src/adapters/claude.mjs +11 -13
- package/src/adapters/codex.mjs +64 -38
- package/src/adapters/gemini.mjs +21 -26
- package/src/conformance.mjs +36 -1
- package/src/control-plane.mjs +141 -11
- package/src/fixtures/codex.json +18 -79
- package/src/index.mjs +1 -1
- package/src/json-schema.mjs +16 -28
- package/src/registry.mjs +23 -3
- package/src/runtime.mjs +46 -17
- package/types/adapters/codex.d.mts +33 -13
- package/types/conformance.d.mts +2 -1
- package/types/control-plane.d.mts +81 -2
- package/types/index.d.mts +1 -1
- package/types/registry.d.mts +6 -1
- package/types/runtime.d.mts +11 -8
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
|
|
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(
|
|
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.
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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,
|
|
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
|
package/bin/claude-hook.mjs
CHANGED
|
File without changes
|
package/bin/codex-hook.mjs
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
-
|
|
24
|
+
const adapter = createCodexAdapter({ version: process.env.ACP_CODEX_VERSION });
|
|
25
|
+
emit(renderHookResponse(adapter, await readStdin(), FAIL_OPEN));
|
package/bin/gemini-hook.mjs
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
lookup,
|
|
26
26
|
makeEvent,
|
|
27
27
|
normalizeVerdict,
|
|
28
|
-
|
|
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({
|
|
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
|
|
272
|
+
return gatedResponse(AGENT, {
|
|
269
273
|
transport: INTEGRATION_MODE,
|
|
270
|
-
exit_code,
|
|
274
|
+
unblocked_exit_code: exit_code,
|
|
271
275
|
enforced,
|
|
272
|
-
|
|
276
|
+
reason: vd.reason,
|
|
273
277
|
});
|
|
274
278
|
}
|
|
275
279
|
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
lookup,
|
|
27
27
|
makeEvent,
|
|
28
28
|
normalizeVerdict,
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
+
unblocked_exit_code: 0,
|
|
266
269
|
enforced,
|
|
267
270
|
stdout,
|
|
268
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* a
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
335
|
+
return gatedResponse(AGENT, {
|
|
334
336
|
transport: event.meta.integration_mode,
|
|
335
|
-
|
|
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
|
-
|
|
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.
|
|
366
|
-
|
|
367
|
-
|
|
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 &&
|
|
413
|
+
if (enforced && blockTransportFor(AGENT).reason_required)
|
|
414
|
+
body.reason = blockReason(vd.reason);
|
|
407
415
|
return body;
|
|
408
416
|
}
|
|
409
417
|
|
|
410
|
-
/**
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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();
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
MODELED_TOOL_INPUT_KEYS,
|
|
44
44
|
makeEvent,
|
|
45
45
|
normalizeVerdict,
|
|
46
|
-
|
|
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
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
400
|
-
enforced
|
|
401
|
-
|
|
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
|
-
|
|
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
|
package/src/conformance.mjs
CHANGED
|
@@ -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
|
|
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) {
|