agent-control-plane-core 0.1.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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The Claude Code adapter — reference EXTERNAL_HOOK translator.
3
+ *
4
+ * The ONLY module that knows Claude Code's native hook field names
5
+ * (`hook_event_name`, `tool_name`, `tool_input`, `tool_response`, `prompt`,
6
+ * `permissionDecision`, `hookSpecificOutput`). Transport is external-hook: the
7
+ * agent shells out with stdin JSON and reads a deny body / exit code back.
8
+ *
9
+ * Deny signal (PreToolUse): `hookSpecificOutput.permissionDecision = "deny"`
10
+ * AND exit 2. this_call_vetoable is true, but per the doctrine that is a
11
+ * useful FIRST filter, never the boundary — the Verdict stays advisory to the
12
+ * sandbox.
13
+ */
14
+
15
+ import {
16
+ EventKind,
17
+ Decision,
18
+ IntegrationMode,
19
+ makeEvent,
20
+ normalizeVerdict,
21
+ nativeResponse,
22
+ collectPassthrough,
23
+ asObject,
24
+ asString,
25
+ asStringOrNull,
26
+ } from "../control-plane.mjs";
27
+
28
+ /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
29
+ /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
30
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
31
+ /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
32
+
33
+ /** Producing-agent id stamped onto every event this adapter parses. */
34
+ export const AGENT = "claude";
35
+
36
+ /** How this adapter attaches to the agent. */
37
+ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
38
+
39
+ /** Claude Code native hook event names (the `hook_event_name` field). */
40
+ export const HookEvent = Object.freeze({
41
+ PRE_TOOL_USE: "PreToolUse",
42
+ POST_TOOL_USE: "PostToolUse",
43
+ USER_PROMPT_SUBMIT: "UserPromptSubmit",
44
+ SESSION_START: "SessionStart",
45
+ });
46
+
47
+ const NATIVE_TO_KIND = Object.freeze({
48
+ [HookEvent.PRE_TOOL_USE]: EventKind.PRE_TOOL,
49
+ [HookEvent.POST_TOOL_USE]: EventKind.POST_TOOL,
50
+ [HookEvent.USER_PROMPT_SUBMIT]: EventKind.PROMPT_SUBMIT,
51
+ [HookEvent.SESSION_START]: EventKind.SESSION_START,
52
+ });
53
+
54
+ const KIND_TO_NATIVE = Object.freeze({
55
+ [EventKind.PRE_TOOL]: HookEvent.PRE_TOOL_USE,
56
+ [EventKind.POST_TOOL]: HookEvent.POST_TOOL_USE,
57
+ [EventKind.PROMPT_SUBMIT]: HookEvent.USER_PROMPT_SUBMIT,
58
+ [EventKind.SESSION_START]: HookEvent.SESSION_START,
59
+ });
60
+
61
+ const CONSUMED = new Set([
62
+ "hook_event_name",
63
+ "session_id",
64
+ "cwd",
65
+ "permission_mode",
66
+ "transcript_path",
67
+ "tool_name",
68
+ "tool_input",
69
+ "tool_response",
70
+ "prompt",
71
+ ]);
72
+
73
+ /**
74
+ * @param {string} kind
75
+ * @param {Record<string, unknown>} raw
76
+ * @returns {Record<string, unknown>}
77
+ */
78
+ function claudeInput(kind, raw) {
79
+ if (kind === EventKind.PROMPT_SUBMIT)
80
+ return { prompt: asString(raw.prompt, "") };
81
+ if (kind === EventKind.SESSION_START) return {};
82
+ return asObject(raw.tool_input);
83
+ }
84
+
85
+ /**
86
+ * @param {string} kind
87
+ * @param {Record<string, unknown>} raw
88
+ * @returns {string|null}
89
+ */
90
+ function claudeTool(kind, raw) {
91
+ if (kind === EventKind.PROMPT_SUBMIT || kind === EventKind.SESSION_START)
92
+ return null;
93
+ return asStringOrNull(raw.tool_name);
94
+ }
95
+
96
+ /**
97
+ * @param {string} nativeEvent
98
+ * @param {Record<string, unknown>} raw
99
+ * @returns {EventMeta}
100
+ */
101
+ function claudeMeta(nativeEvent, raw) {
102
+ /** @type {EventMeta} */
103
+ const meta = {
104
+ agent: AGENT,
105
+ native_event: nativeEvent,
106
+ integration_mode: INTEGRATION_MODE,
107
+ primary_gate_present: true,
108
+ passthrough: collectPassthrough(raw, CONSUMED),
109
+ };
110
+ if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
111
+ if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
112
+ if (typeof raw.permission_mode === "string")
113
+ meta.permission_mode = raw.permission_mode;
114
+ if (typeof raw.transcript_path === "string")
115
+ meta.transcript_path = raw.transcript_path;
116
+ return meta;
117
+ }
118
+
119
+ /**
120
+ * Parse a raw Claude Code hook payload into a normalized {@link ToolCallEvent}.
121
+ * Never throws on an unmodelled event type or tool-input field.
122
+ * @param {any} native
123
+ * @returns {ToolCallEvent}
124
+ */
125
+ export function parse(native) {
126
+ const raw = asObject(native);
127
+ const nativeEvent = asString(raw.hook_event_name, "");
128
+ const kind =
129
+ /** @type {Record<string, string>} */ (NATIVE_TO_KIND)[nativeEvent] ??
130
+ EventKind.UNKNOWN;
131
+ const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
132
+ return makeEvent({
133
+ event: kind,
134
+ tool: claudeTool(kind, raw),
135
+ input: claudeInput(kind, raw),
136
+ response,
137
+ this_call_vetoable: true,
138
+ meta: claudeMeta(nativeEvent, raw),
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Render into Claude Code's native external-hook transport: a
144
+ * `hookSpecificOutput` JSON body on stdout plus the exit code that carries the
145
+ * decision (deny ⇒ exit 2). A deny only counts as `enforced` when the event's
146
+ * `this_call_vetoable` holds.
147
+ *
148
+ * `soleGate` (default `false`) is a dangerous, explicit opt-in: when `true` AND
149
+ * the verdict is `allow`, the render emits Claude Code's REAL
150
+ * `permissionDecision: "allow"`, which bypasses the native permission prompt.
151
+ * Default behavior always abstains on allow (see {@link gatingBody}) so the
152
+ * guardrail can never silently become the sole gate by accident.
153
+ * @param {Verdict} verdict
154
+ * @param {ToolCallEvent} event
155
+ * @param {{ soleGate?: boolean }} [options]
156
+ * @returns {NativeResponse}
157
+ */
158
+ export function render(verdict, event, { soleGate = false } = {}) {
159
+ const vd = normalizeVerdict(verdict);
160
+ const kind = event.event;
161
+ const hookEventName =
162
+ /** @type {Record<string, string>} */ (KIND_TO_NATIVE)[kind] ??
163
+ event.meta.native_event;
164
+ const isDeny = vd.decision === Decision.DENY;
165
+ const enforced = isDeny && event.this_call_vetoable;
166
+
167
+ /** @type {Record<string, unknown>} */
168
+ const stdout =
169
+ kind === EventKind.PRE_TOOL
170
+ ? gatingBody(hookEventName, vd, soleGate)
171
+ : nonGatingBody(hookEventName, vd);
172
+
173
+ return nativeResponse({
174
+ transport: INTEGRATION_MODE,
175
+ exit_code: enforced ? 2 : 0,
176
+ enforced,
177
+ stdout,
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Build a PreToolUse `hookSpecificOutput`. `allow` OMITS `permissionDecision`
183
+ * by default — in Claude Code that field auto-approves the call, bypassing the
184
+ * normal permission prompt, so a guardrail that merely has "no objection" must
185
+ * stay silent on it and let the default flow run. Only `deny`/`ask` emit an
186
+ * explicit `permissionDecision`, UNLESS `soleGate` is true, in which case an
187
+ * `allow` also emits the real `permissionDecision: "allow"` (the monitor-as-
188
+ * sole-gate opt-in). `updatedInput`/`additionalContext` ride along regardless.
189
+ * @param {string} hookEventName
190
+ * @param {Verdict} vd
191
+ * @param {boolean} soleGate
192
+ * @returns {Record<string, unknown>}
193
+ */
194
+ function gatingBody(hookEventName, vd, soleGate) {
195
+ /** @type {Record<string, unknown>} */
196
+ const out = { hookEventName };
197
+ if (vd.decision !== Decision.ALLOW || soleGate) {
198
+ out.permissionDecision = vd.decision;
199
+ if (vd.reason !== undefined) out.permissionDecisionReason = vd.reason;
200
+ }
201
+ if (vd.mutated_input !== undefined) out.updatedInput = vd.mutated_input;
202
+ if (vd.additional_context !== undefined)
203
+ out.additionalContext = vd.additional_context;
204
+ return { hookSpecificOutput: out };
205
+ }
206
+
207
+ /**
208
+ * @param {string} hookEventName
209
+ * @param {Verdict} vd
210
+ * @returns {Record<string, unknown>}
211
+ */
212
+ function nonGatingBody(hookEventName, vd) {
213
+ /** @type {Record<string, unknown>} */
214
+ const hookSpecificOutput = { hookEventName };
215
+ if (vd.additional_context !== undefined)
216
+ hookSpecificOutput.additionalContext = vd.additional_context;
217
+ /** @type {Record<string, unknown>} */
218
+ const out = { hookSpecificOutput };
219
+ if (vd.decision !== Decision.ALLOW) {
220
+ out.decision = "block";
221
+ if (vd.reason !== undefined) out.reason = vd.reason;
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /** @type {import("../control-plane.mjs").Adapter} */
227
+ export const claudeAdapter = { AGENT, INTEGRATION_MODE, parse, render };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The Codex CLI adapter — EXTERNAL_HOOK translator (≥ v0.135).
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.
7
+ * Managed pin: /etc/codex/requirements.toml + allow_managed_hooks_only.
8
+ *
9
+ * VERSION GATE: hook enforcement only exists in v0.135+. Below that, there is no
10
+ * veto, so parse marks the event OBSERVE_ONLY with this_call_vetoable=false —
11
+ * the adapter must not pretend to enforce on an old Codex.
12
+ */
13
+
14
+ import {
15
+ EventKind,
16
+ Decision,
17
+ IntegrationMode,
18
+ makeEvent,
19
+ normalizeVerdict,
20
+ nativeResponse,
21
+ collectPassthrough,
22
+ asObject,
23
+ asString,
24
+ asStringOrNull,
25
+ } from "../control-plane.mjs";
26
+
27
+ /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
28
+ /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
29
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
30
+ /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
31
+
32
+ export const AGENT = "codex";
33
+ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
34
+
35
+ /** Minimum Codex version whose hook can actually veto a tool call. */
36
+ export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
37
+
38
+ // Both native events gate a tool call; PermissionRequest is the ask-tier veto.
39
+ const GATING_EVENTS = new Set(["PreToolUse", "PermissionRequest"]);
40
+
41
+ const CONSUMED = new Set([
42
+ "hook_event_name",
43
+ "session_id",
44
+ "cwd",
45
+ "permission_mode",
46
+ "transcript_path",
47
+ "tool_name",
48
+ "tool_input",
49
+ "version",
50
+ ]);
51
+
52
+ /**
53
+ * True when `version` ("0.135.0") is at or above {@link MIN_ENFORCING_VERSION}.
54
+ * A missing/garbage version is treated as too old (fail closed to advisory).
55
+ * @param {unknown} version
56
+ * @returns {boolean}
57
+ */
58
+ export function canEnforce(version) {
59
+ const parts = asString(version, "").split(".");
60
+ const major = Number(parts[0]);
61
+ const minor = Number(parts[1]);
62
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) return false;
63
+ if (major !== MIN_ENFORCING_VERSION[0])
64
+ return major > MIN_ENFORCING_VERSION[0];
65
+ return minor >= MIN_ENFORCING_VERSION[1];
66
+ }
67
+
68
+ /**
69
+ * Parse a raw Codex hook payload. Never throws on unmodelled input. Marks the
70
+ * event observe-only (this_call_vetoable=false) when the Codex version
71
+ * predates hook enforcement.
72
+ * @param {any} native
73
+ * @returns {ToolCallEvent}
74
+ */
75
+ export function parse(native) {
76
+ const raw = asObject(native);
77
+ const nativeEvent = asString(raw.hook_event_name, "");
78
+ const gating = GATING_EVENTS.has(nativeEvent);
79
+ const kind = gating ? EventKind.PRE_TOOL : EventKind.UNKNOWN;
80
+ const enforce = canEnforce(raw.version);
81
+
82
+ /** @type {EventMeta} */
83
+ const meta = {
84
+ agent: AGENT,
85
+ native_event: nativeEvent,
86
+ integration_mode: enforce
87
+ ? IntegrationMode.EXTERNAL_HOOK
88
+ : IntegrationMode.OBSERVE_ONLY,
89
+ primary_gate_present: true,
90
+ passthrough: collectPassthrough(raw, CONSUMED),
91
+ };
92
+ if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
93
+ if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
94
+ if (typeof raw.permission_mode === "string")
95
+ meta.permission_mode = raw.permission_mode;
96
+ if (typeof raw.transcript_path === "string")
97
+ meta.transcript_path = raw.transcript_path;
98
+
99
+ return makeEvent({
100
+ event: kind,
101
+ tool: asStringOrNull(raw.tool_name),
102
+ input: asObject(raw.tool_input),
103
+ response: undefined,
104
+ this_call_vetoable: enforce,
105
+ meta,
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Render into Codex's native external-hook transport: a `hookSpecificOutput`
111
+ * body preserving the native event name (`PreToolUse`/`PermissionRequest`) plus
112
+ * exit 2 on an enforceable deny. On a pre-v0.135 Codex (`this_call_vetoable`
113
+ * false) the body still renders but exit stays 0 and `enforced` is false —
114
+ * advisory only.
115
+ *
116
+ * `soleGate` (default `false`) is the same dangerous, explicit opt-in as the
117
+ * Claude adapter: when `true` AND the verdict is `allow`, the render emits the
118
+ * real `permissionDecision: "allow"` instead of abstaining.
119
+ * @param {Verdict} verdict
120
+ * @param {ToolCallEvent} event
121
+ * @param {{ soleGate?: boolean }} [options]
122
+ * @returns {NativeResponse}
123
+ */
124
+ export function render(verdict, event, { soleGate = false } = {}) {
125
+ const vd = normalizeVerdict(verdict);
126
+ const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
127
+ const hookEventName = event.meta.native_event || "PreToolUse";
128
+
129
+ // As in the Claude adapter, `allow` omits permissionDecision by default so the
130
+ // guardrail never auto-approves a call it merely had no objection to; only
131
+ // deny/ask emit an explicit decision, unless `soleGate` opts into it.
132
+ /** @type {Record<string, unknown>} */
133
+ const body = { hookEventName };
134
+ if (vd.decision !== Decision.ALLOW || soleGate) {
135
+ body.permissionDecision = vd.decision;
136
+ if (vd.reason !== undefined) body.permissionDecisionReason = vd.reason;
137
+ }
138
+ if (vd.mutated_input !== undefined) body.updatedInput = vd.mutated_input;
139
+
140
+ return nativeResponse({
141
+ transport: event.meta.integration_mode,
142
+ exit_code: enforced ? 2 : 0,
143
+ enforced,
144
+ stdout: { hookSpecificOutput: body },
145
+ });
146
+ }
147
+
148
+ /** @type {import("../control-plane.mjs").Adapter} */
149
+ export const codexAdapter = { AGENT, INTEGRATION_MODE, parse, render };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The Gemini CLI adapter — EXTERNAL_HOOK translator (hooks since v0.26.0).
3
+ *
4
+ * The only module that knows Gemini CLI's native hook field names. Transport is
5
+ * external-hook: the agent shells out with stdin JSON and reads a stdout body +
6
+ * exit code back, but the vocabulary differs from Claude/Codex — the pre-tool
7
+ * event is `BeforeTool` (not `PreToolUse`), tool names are snake_case
8
+ * (`run_shell_command`), and the decision body is `{ decision, reason,
9
+ * hookSpecificOutput: { tool_input } }` rather than `permissionDecision`.
10
+ *
11
+ * Decision channel (from geminicli.com/docs/hooks/reference): exit 0 → stdout is
12
+ * parsed as JSON (the path for allow / advisory / mutation); exit 2 → a "System
13
+ * Block" that pre-empts the call, with the reason taken from STDERR (stdout is
14
+ * ignored). So an ENFORCED deny renders as exit 2 (the only real block signal),
15
+ * and its `reason` has no home in this stdout-only NativeResponse — it is
16
+ * dropped here (a real hook wrapper also writes it to stderr), the same fidelity
17
+ * gap Amp's exit-code transport has. Gemini has no native "ask" tier, so `ask`
18
+ * maps to an exit-0 advisory `decision: "deny"` the model sees but that does not
19
+ * hard-block.
20
+ */
21
+
22
+ import {
23
+ EventKind,
24
+ Decision,
25
+ IntegrationMode,
26
+ makeEvent,
27
+ normalizeVerdict,
28
+ nativeResponse,
29
+ collectPassthrough,
30
+ asObject,
31
+ asStringOrNull,
32
+ } from "../control-plane.mjs";
33
+
34
+ /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
35
+ /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
36
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
37
+ /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
38
+
39
+ export const AGENT = "gemini";
40
+ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
41
+
42
+ /** Gemini CLI native hook event names (the `hook_event_name` field). */
43
+ export const HookEvent = Object.freeze({
44
+ BEFORE_TOOL: "BeforeTool",
45
+ AFTER_TOOL: "AfterTool",
46
+ });
47
+
48
+ const NATIVE_TO_KIND = Object.freeze({
49
+ [HookEvent.BEFORE_TOOL]: EventKind.PRE_TOOL,
50
+ [HookEvent.AFTER_TOOL]: EventKind.POST_TOOL,
51
+ });
52
+
53
+ // Only the fields the adapter maps are consumed; everything else (timestamp,
54
+ // mcp_context, original_request_name, …) survives verbatim in meta.passthrough.
55
+ const CONSUMED = new Set([
56
+ "hook_event_name",
57
+ "session_id",
58
+ "cwd",
59
+ "transcript_path",
60
+ "tool_name",
61
+ "tool_input",
62
+ "tool_response",
63
+ ]);
64
+
65
+ /**
66
+ * @param {string} nativeEvent
67
+ * @param {Record<string, unknown>} raw
68
+ * @returns {EventMeta}
69
+ */
70
+ function geminiMeta(nativeEvent, raw) {
71
+ /** @type {EventMeta} */
72
+ const meta = {
73
+ agent: AGENT,
74
+ native_event: nativeEvent,
75
+ integration_mode: INTEGRATION_MODE,
76
+ primary_gate_present: true,
77
+ passthrough: collectPassthrough(raw, CONSUMED),
78
+ };
79
+ if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
80
+ if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
81
+ if (typeof raw.transcript_path === "string")
82
+ meta.transcript_path = raw.transcript_path;
83
+ return meta;
84
+ }
85
+
86
+ /**
87
+ * Parse a raw Gemini CLI hook payload into a normalized {@link ToolCallEvent}.
88
+ * Never throws on an unmodelled event type or tool-input field. Any payload we
89
+ * receive comes from a hooks-capable Gemini (v0.26.0+) whose `BeforeTool` can
90
+ * pre-empt via exit 2, so `this_call_vetoable` is true.
91
+ * @param {any} native
92
+ * @returns {ToolCallEvent}
93
+ */
94
+ export function parse(native) {
95
+ const raw = asObject(native);
96
+ const nativeEvent =
97
+ typeof raw.hook_event_name === "string" ? raw.hook_event_name : "";
98
+ const kind =
99
+ /** @type {Record<string, string>} */ (NATIVE_TO_KIND)[nativeEvent] ??
100
+ EventKind.UNKNOWN;
101
+ const gating = kind === EventKind.PRE_TOOL || kind === EventKind.POST_TOOL;
102
+ const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
103
+ return makeEvent({
104
+ event: kind,
105
+ tool: gating ? asStringOrNull(raw.tool_name) : null,
106
+ input: gating ? asObject(raw.tool_input) : {},
107
+ response,
108
+ this_call_vetoable: true,
109
+ meta: geminiMeta(nativeEvent, raw),
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Render into Gemini CLI's native external-hook transport. An enforceable deny
115
+ * renders as exit 2 (the System Block); everything else exits 0 with a JSON
116
+ * decision body (or none, when `allow` abstains). `soleGate` (default false) is
117
+ * the same dangerous opt-in as the other adapters: it makes an `allow` emit the
118
+ * real `decision: "allow"` instead of abstaining.
119
+ * @param {Verdict} verdict
120
+ * @param {ToolCallEvent} event
121
+ * @param {{ soleGate?: boolean }} [options]
122
+ * @returns {NativeResponse}
123
+ */
124
+ export function render(verdict, event, { soleGate = false } = {}) {
125
+ const vd = normalizeVerdict(verdict);
126
+ const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
127
+
128
+ // Enforced deny is a System Block: exit 2, reason carried on stderr (no home
129
+ // in this stdout-only response), so no stdout body.
130
+ if (enforced)
131
+ return nativeResponse({
132
+ transport: INTEGRATION_MODE,
133
+ exit_code: 2,
134
+ enforced: true,
135
+ });
136
+
137
+ const body = decisionBody(vd, soleGate);
138
+ return nativeResponse({
139
+ transport: INTEGRATION_MODE,
140
+ exit_code: 0,
141
+ enforced: false,
142
+ ...(body === undefined ? {} : { stdout: body }),
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Build the exit-0 stdout decision body. `allow` abstains — no `decision` key,
148
+ * so Gemini runs its normal flow — unless `soleGate` opts into the real
149
+ * `decision: "allow"`. `deny`/`ask` both surface an advisory `decision: "deny"`
150
+ * (Gemini has no native ask tier). `mutated_input` rides along as
151
+ * `hookSpecificOutput.tool_input` and `additional_context` as Gemini's native
152
+ * `systemMessage`. Returns undefined when there is nothing to emit (a pure
153
+ * abstaining allow).
154
+ * @param {Verdict} vd
155
+ * @param {boolean} soleGate
156
+ * @returns {Record<string, unknown>|undefined}
157
+ */
158
+ function decisionBody(vd, soleGate) {
159
+ /** @type {Record<string, unknown>} */
160
+ const out = {};
161
+ if (vd.decision === Decision.DENY || vd.decision === Decision.ASK) {
162
+ out.decision = "deny";
163
+ if (vd.reason !== undefined) out.reason = vd.reason;
164
+ } else if (soleGate) {
165
+ out.decision = "allow";
166
+ }
167
+ if (vd.mutated_input !== undefined)
168
+ out.hookSpecificOutput = { tool_input: vd.mutated_input };
169
+ if (vd.additional_context !== undefined)
170
+ out.systemMessage = vd.additional_context;
171
+ return Object.keys(out).length > 0 ? out : undefined;
172
+ }
173
+
174
+ /** @type {import("../control-plane.mjs").Adapter} */
175
+ export const geminiAdapter = { AGENT, INTEGRATION_MODE, parse, render };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The control-plane conformance harness.
3
+ *
4
+ * Any adapter — the reference claude one, codex, or a future
5
+ * cursor/cline/gemini-cli/aider translator — must pass this against its own
6
+ * golden fixtures. It pins the two directions of the contract:
7
+ *
8
+ * 1. parse is golden: adapter.parse(native) deep-equals the fixture's
9
+ * normalized `event` (and never throws — an adapter that threw would fail
10
+ * here rather than in production).
11
+ * 2. render is golden: for each verdict scenario, adapter.render(verdict,
12
+ * parsedEvent) deep-equals the fixture's native response. An `allow` with
13
+ * the unmutated input renders to the agent's native allow; deny/ask/mutation
14
+ * each render to their native shape.
15
+ * 3. non-vacuity: the fixture set collectively renders an allow, a deny, an
16
+ * ask, AND a mutated_input, so a suite can't pass while silently skipping a
17
+ * decision the contract requires every adapter to express.
18
+ * 4. enforcement honesty: an enforceable deny (`rendered.enforced === true`)
19
+ * MUST carry a real block signal — a non-zero `exit_code` or a `throw_` —
20
+ * not just a JSON body the agent is free to ignore. At least one enforced
21
+ * deny must appear, so the honesty check is never vacuous.
22
+ * 5. non-vetoable honesty: when the parsed event's `this_call_vetoable` is
23
+ * false, EVERY render for that case must be `enforced === false` — a
24
+ * guardrail that cannot veto this call must never render as if it did.
25
+ * 6. allow = abstain: by default (no `soleGate` opt-in) every `allow` render
26
+ * is `enforced === false` AND `exit_code === 0` — an "I have no objection"
27
+ * verdict never renders as a block, on any adapter. At least one `allow`
28
+ * must be rendered, so this is never vacuous.
29
+ *
30
+ * `assert` is injected (node:assert/strict) so the harness stays test-framework
31
+ * neutral; it throws on the first mismatch. Returns a summary the caller can
32
+ * assert further on.
33
+ *
34
+ * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
35
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean }}
36
+ */
37
+ export function runAdapterConformance({ adapter, fixtures, assert }) {
38
+ assert.equal(
39
+ adapter.AGENT,
40
+ fixtures.agent,
41
+ `adapter AGENT '${adapter.AGENT}' does not match fixtures.agent '${fixtures.agent}'`,
42
+ );
43
+
44
+ /** @type {Set<string>} */
45
+ const decisionsSeen = new Set();
46
+ let mutationSeen = false;
47
+ let enforcedDenySeen = false;
48
+ let renders = 0;
49
+
50
+ for (const testCase of fixtures.cases) {
51
+ const parsed = adapter.parse(testCase.native);
52
+ assert.deepEqual(
53
+ parsed,
54
+ testCase.event,
55
+ `parse mismatch: ${testCase.name}`,
56
+ );
57
+
58
+ for (const [scenario, raw] of Object.entries(testCase.render)) {
59
+ const spec = /** @type {{ verdict: any, native: any }} */ (raw);
60
+ const rendered = adapter.render(spec.verdict, parsed);
61
+ assert.deepEqual(
62
+ rendered,
63
+ spec.native,
64
+ `render mismatch: ${testCase.name} / ${scenario}`,
65
+ );
66
+ if (rendered.enforced) {
67
+ assert.ok(
68
+ rendered.exit_code !== 0 || rendered.throw_ !== undefined,
69
+ `enforced deny carries no block signal: ${testCase.name} / ${scenario}`,
70
+ );
71
+ enforcedDenySeen = true;
72
+ }
73
+ if (parsed.this_call_vetoable === false) {
74
+ assert.equal(
75
+ rendered.enforced,
76
+ false,
77
+ `non-vetoable call rendered as enforced: ${testCase.name} / ${scenario}`,
78
+ );
79
+ }
80
+ if (spec.verdict.decision === "allow") {
81
+ assert.equal(
82
+ rendered.enforced,
83
+ false,
84
+ `allow rendered as enforced: ${testCase.name} / ${scenario}`,
85
+ );
86
+ assert.equal(
87
+ rendered.exit_code,
88
+ 0,
89
+ `allow rendered a non-zero exit_code: ${testCase.name} / ${scenario}`,
90
+ );
91
+ }
92
+ decisionsSeen.add(spec.verdict.decision);
93
+ if (spec.verdict.mutated_input !== undefined) mutationSeen = true;
94
+ renders += 1;
95
+ }
96
+ }
97
+
98
+ for (const decision of ["allow", "deny", "ask"]) {
99
+ assert.ok(
100
+ decisionsSeen.has(decision),
101
+ `conformance fixtures never render a '${decision}' verdict — the suite is vacuous`,
102
+ );
103
+ }
104
+ assert.ok(
105
+ mutationSeen,
106
+ "conformance fixtures never render a mutated_input verdict — mutation is untested",
107
+ );
108
+ assert.ok(
109
+ enforcedDenySeen,
110
+ "no enforced deny rendered — enforcement honesty is untested",
111
+ );
112
+ assert.ok(renders > 0, "conformance fixtures render nothing");
113
+
114
+ return {
115
+ cases: fixtures.cases.length,
116
+ renders,
117
+ decisionsSeen,
118
+ mutationSeen,
119
+ enforcedDenySeen,
120
+ };
121
+ }