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.
- package/README.md +194 -0
- package/bin/amp-hook.mjs +21 -0
- package/bin/claude-hook.mjs +20 -0
- package/bin/codex-hook.mjs +20 -0
- package/bin/gemini-hook.mjs +19 -0
- package/bin/hook-runtime.mjs +69 -0
- package/package.json +109 -0
- package/src/adapters/amp.mjs +82 -0
- package/src/adapters/claude.mjs +227 -0
- package/src/adapters/codex.mjs +149 -0
- package/src/adapters/gemini.mjs +175 -0
- package/src/conformance.mjs +121 -0
- package/src/control-plane.mjs +299 -0
- package/src/fixtures/amp.json +121 -0
- package/src/fixtures/claude.json +328 -0
- package/src/fixtures/codex.json +222 -0
- package/src/fixtures/gemini.json +187 -0
- package/src/index.mjs +17 -0
|
@@ -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
|
+
}
|