agent-control-plane-core 0.1.0 → 0.2.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/bin/amp-hook.mjs +0 -0
- package/bin/claude-hook.mjs +0 -0
- package/bin/codex-hook.mjs +0 -0
- package/bin/gemini-hook.mjs +0 -0
- package/package.json +23 -14
- package/src/adapters/amp.mjs +29 -3
- package/src/adapters/claude.mjs +42 -4
- package/src/adapters/codex.mjs +37 -3
- package/src/adapters/gemini.mjs +138 -13
- package/src/conformance.mjs +134 -5
- package/src/control-plane.mjs +253 -38
- package/src/fixtures/amp.json +3 -0
- package/src/fixtures/claude.json +23 -0
- package/src/fixtures/codex.json +68 -0
- package/src/fixtures/gemini.json +257 -4
- package/src/index.mjs +14 -3
- package/src/registry.mjs +80 -0
- package/types/adapters/amp.d.mts +38 -0
- package/types/adapters/claude.d.mts +56 -0
- package/types/adapters/codex.d.mts +56 -0
- package/types/adapters/gemini.d.mts +73 -0
- package/types/conformance.d.mts +83 -0
- package/types/control-plane.d.mts +454 -0
- package/types/index.d.mts +7 -0
- package/types/registry.d.mts +35 -0
package/bin/amp-hook.mjs
CHANGED
|
File without changes
|
package/bin/claude-hook.mjs
CHANGED
|
File without changes
|
package/bin/codex-hook.mjs
CHANGED
|
File without changes
|
package/bin/gemini-hook.mjs
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/AlexanderMattTurner/agent-control-plane-core.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/AlexanderMattTurner/agent-control-plane-core/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/AlexanderMattTurner/agent-control-plane-core#readme",
|
|
6
14
|
"license": "MIT",
|
|
7
|
-
"packageManager": "pnpm@11.8.0",
|
|
8
15
|
"engines": {
|
|
9
16
|
"node": ">=20"
|
|
10
17
|
},
|
|
@@ -34,6 +41,10 @@
|
|
|
34
41
|
"./conformance": {
|
|
35
42
|
"types": "./types/conformance.d.mts",
|
|
36
43
|
"import": "./src/conformance.mjs"
|
|
44
|
+
},
|
|
45
|
+
"./registry": {
|
|
46
|
+
"types": "./types/registry.d.mts",
|
|
47
|
+
"import": "./src/registry.mjs"
|
|
37
48
|
}
|
|
38
49
|
},
|
|
39
50
|
"files": [
|
|
@@ -51,17 +62,6 @@
|
|
|
51
62
|
"adapter",
|
|
52
63
|
"guardrail"
|
|
53
64
|
],
|
|
54
|
-
"scripts": {
|
|
55
|
-
"prepare": "git config core.hooksPath .hooks",
|
|
56
|
-
"build": "tsc -p tsconfig.build.json",
|
|
57
|
-
"prepublishOnly": "pnpm build",
|
|
58
|
-
"test": "node --test test/*.test.mjs",
|
|
59
|
-
"test:coverage": "c8 node --test test/*.test.mjs",
|
|
60
|
-
"check": "tsc --noEmit",
|
|
61
|
-
"lint": "eslint .",
|
|
62
|
-
"format": "prettier --write .",
|
|
63
|
-
"format:check": "prettier --check ."
|
|
64
|
-
},
|
|
65
65
|
"lint-staged": {
|
|
66
66
|
"*.{css,scss}": [
|
|
67
67
|
"prettier --write"
|
|
@@ -105,5 +105,14 @@
|
|
|
105
105
|
"lint-staged": "^17.0.5",
|
|
106
106
|
"prettier": "^3.0.0",
|
|
107
107
|
"typescript": "^5.6.0"
|
|
108
|
+
},
|
|
109
|
+
"scripts": {
|
|
110
|
+
"build": "tsc -p tsconfig.build.json",
|
|
111
|
+
"test": "node --test test/*.test.mjs",
|
|
112
|
+
"test:coverage": "c8 node --test test/*.test.mjs",
|
|
113
|
+
"check": "tsc --noEmit",
|
|
114
|
+
"lint": "eslint .",
|
|
115
|
+
"format": "prettier --write .",
|
|
116
|
+
"format:check": "prettier --check ."
|
|
108
117
|
}
|
|
109
|
-
}
|
|
118
|
+
}
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
EventKind,
|
|
16
16
|
Decision,
|
|
17
17
|
IntegrationMode,
|
|
18
|
+
CallClass,
|
|
19
|
+
CoverageStatus,
|
|
20
|
+
classifyCallClass,
|
|
21
|
+
coverageAllowsVeto,
|
|
22
|
+
canonicalTool,
|
|
18
23
|
makeEvent,
|
|
19
24
|
normalizeVerdict,
|
|
20
25
|
nativeResponse,
|
|
@@ -31,6 +36,22 @@ import {
|
|
|
31
36
|
export const AGENT = "amp";
|
|
32
37
|
export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
|
|
33
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). Every tool call is
|
|
41
|
+
* checked against the permission engine; `amp.mcpPermissions` runs MCP tools
|
|
42
|
+
* through the same rule syntax, and a rule's `context: "subagent"` selector
|
|
43
|
+
* gates calls inside subagents — so builtin, MCP, and subagent are all COVERED.
|
|
44
|
+
* Resumed-thread firing is a strong-but-uncited structural argument, so it is
|
|
45
|
+
* held at UNKNOWN until an item-⑤ probe confirms it.
|
|
46
|
+
*/
|
|
47
|
+
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
48
|
+
export const COVERAGE = Object.freeze({
|
|
49
|
+
[CallClass.BUILTIN]: CoverageStatus.COVERED,
|
|
50
|
+
[CallClass.MCP]: CoverageStatus.COVERED,
|
|
51
|
+
[CallClass.SUBAGENT]: CoverageStatus.COVERED,
|
|
52
|
+
[CallClass.RESUMED]: CoverageStatus.UNKNOWN,
|
|
53
|
+
});
|
|
54
|
+
|
|
34
55
|
// Amp invokes the delegate for a tool call; the payload carries the tool name +
|
|
35
56
|
// input and the session context. Pinned by fixtures/amp.json.
|
|
36
57
|
const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
|
|
@@ -51,12 +72,17 @@ export function parse(native) {
|
|
|
51
72
|
};
|
|
52
73
|
if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
|
|
53
74
|
if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
|
|
75
|
+
const nativeTool = asStringOrNull(raw.tool);
|
|
76
|
+
if (nativeTool !== null) meta.native_tool = nativeTool;
|
|
54
77
|
return makeEvent({
|
|
55
78
|
event: EventKind.PRE_TOOL,
|
|
56
|
-
tool:
|
|
79
|
+
tool: canonicalTool(nativeTool),
|
|
57
80
|
input: asObject(raw.input),
|
|
58
81
|
response: undefined,
|
|
59
|
-
|
|
82
|
+
// Classify on the NATIVE name (MCP detection keys on `mcp__…`).
|
|
83
|
+
this_call_vetoable: coverageAllowsVeto(
|
|
84
|
+
COVERAGE[classifyCallClass(nativeTool, raw)],
|
|
85
|
+
),
|
|
60
86
|
meta,
|
|
61
87
|
});
|
|
62
88
|
}
|
|
@@ -79,4 +105,4 @@ export function render(verdict, event) {
|
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
82
|
-
export const ampAdapter = { AGENT, INTEGRATION_MODE, parse, render };
|
|
108
|
+
export const ampAdapter = { AGENT, INTEGRATION_MODE, COVERAGE, parse, render };
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
EventKind,
|
|
17
17
|
Decision,
|
|
18
18
|
IntegrationMode,
|
|
19
|
+
CallClass,
|
|
20
|
+
CoverageStatus,
|
|
21
|
+
classifyCallClass,
|
|
22
|
+
coverageAllowsVeto,
|
|
23
|
+
canonicalTool,
|
|
19
24
|
makeEvent,
|
|
20
25
|
normalizeVerdict,
|
|
21
26
|
nativeResponse,
|
|
@@ -36,6 +41,21 @@ export const AGENT = "claude";
|
|
|
36
41
|
/** How this adapter attaches to the agent. */
|
|
37
42
|
export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
|
|
38
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `PreToolUse` fires
|
|
46
|
+
* for builtins, for MCP tools (surfaced as `mcp__<server>__<tool>` through the
|
|
47
|
+
* same hook), and for every tool a subagent uses (subagents do not inherit the
|
|
48
|
+
* parent's permissions, so the hook is often their only gate). Resumed sessions
|
|
49
|
+
* re-read hooks from settings and fire per new call — structural, uncontested.
|
|
50
|
+
*/
|
|
51
|
+
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
52
|
+
export const COVERAGE = Object.freeze({
|
|
53
|
+
[CallClass.BUILTIN]: CoverageStatus.COVERED,
|
|
54
|
+
[CallClass.MCP]: CoverageStatus.COVERED,
|
|
55
|
+
[CallClass.SUBAGENT]: CoverageStatus.COVERED,
|
|
56
|
+
[CallClass.RESUMED]: CoverageStatus.COVERED,
|
|
57
|
+
});
|
|
58
|
+
|
|
39
59
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
40
60
|
export const HookEvent = Object.freeze({
|
|
41
61
|
PRE_TOOL_USE: "PreToolUse",
|
|
@@ -129,13 +149,20 @@ export function parse(native) {
|
|
|
129
149
|
/** @type {Record<string, string>} */ (NATIVE_TO_KIND)[nativeEvent] ??
|
|
130
150
|
EventKind.UNKNOWN;
|
|
131
151
|
const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
|
|
152
|
+
const nativeTool = claudeTool(kind, raw);
|
|
153
|
+
const meta = claudeMeta(nativeEvent, raw);
|
|
154
|
+
if (nativeTool !== null) meta.native_tool = nativeTool;
|
|
132
155
|
return makeEvent({
|
|
133
156
|
event: kind,
|
|
134
|
-
tool:
|
|
157
|
+
tool: canonicalTool(nativeTool),
|
|
135
158
|
input: claudeInput(kind, raw),
|
|
136
159
|
response,
|
|
137
|
-
|
|
138
|
-
|
|
160
|
+
// Classify on the NATIVE name — MCP detection keys on `mcp__…`, which a
|
|
161
|
+
// canonical builtin name would never carry.
|
|
162
|
+
this_call_vetoable: coverageAllowsVeto(
|
|
163
|
+
COVERAGE[classifyCallClass(nativeTool, raw)],
|
|
164
|
+
),
|
|
165
|
+
meta,
|
|
139
166
|
});
|
|
140
167
|
}
|
|
141
168
|
|
|
@@ -212,6 +239,11 @@ function gatingBody(hookEventName, vd, soleGate) {
|
|
|
212
239
|
function nonGatingBody(hookEventName, vd) {
|
|
213
240
|
/** @type {Record<string, unknown>} */
|
|
214
241
|
const hookSpecificOutput = { hookEventName };
|
|
242
|
+
// A PostToolUse content transform: `updatedToolOutput` replaces what the model
|
|
243
|
+
// sees (the tool already ran, so this governs only the model's view). The
|
|
244
|
+
// native channel for the whole redaction/sanitize pipeline.
|
|
245
|
+
if (vd.mutated_output !== undefined)
|
|
246
|
+
hookSpecificOutput.updatedToolOutput = vd.mutated_output;
|
|
215
247
|
if (vd.additional_context !== undefined)
|
|
216
248
|
hookSpecificOutput.additionalContext = vd.additional_context;
|
|
217
249
|
/** @type {Record<string, unknown>} */
|
|
@@ -224,4 +256,10 @@ function nonGatingBody(hookEventName, vd) {
|
|
|
224
256
|
}
|
|
225
257
|
|
|
226
258
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
227
|
-
export const claudeAdapter = {
|
|
259
|
+
export const claudeAdapter = {
|
|
260
|
+
AGENT,
|
|
261
|
+
INTEGRATION_MODE,
|
|
262
|
+
COVERAGE,
|
|
263
|
+
parse,
|
|
264
|
+
render,
|
|
265
|
+
};
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
EventKind,
|
|
16
16
|
Decision,
|
|
17
17
|
IntegrationMode,
|
|
18
|
+
CallClass,
|
|
19
|
+
CoverageStatus,
|
|
20
|
+
classifyCallClass,
|
|
21
|
+
coverageAllowsVeto,
|
|
22
|
+
canonicalTool,
|
|
18
23
|
makeEvent,
|
|
19
24
|
normalizeVerdict,
|
|
20
25
|
nativeResponse,
|
|
@@ -32,6 +37,21 @@ import {
|
|
|
32
37
|
export const AGENT = "codex";
|
|
33
38
|
export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
|
|
34
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `PreToolUse`
|
|
42
|
+
* intercepts the shell (Bash) tool ONLY — other builtins and MCP tools never
|
|
43
|
+
* reach it — so builtin is PARTIAL and MCP is UNCOVERED. Subagent and resumed
|
|
44
|
+
* firing are undocumented ⇒ UNKNOWN (treated as uncovered until an item-⑤ probe
|
|
45
|
+
* proves otherwise).
|
|
46
|
+
*/
|
|
47
|
+
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
48
|
+
export const COVERAGE = Object.freeze({
|
|
49
|
+
[CallClass.BUILTIN]: CoverageStatus.PARTIAL,
|
|
50
|
+
[CallClass.MCP]: CoverageStatus.UNCOVERED,
|
|
51
|
+
[CallClass.SUBAGENT]: CoverageStatus.UNKNOWN,
|
|
52
|
+
[CallClass.RESUMED]: CoverageStatus.UNKNOWN,
|
|
53
|
+
});
|
|
54
|
+
|
|
35
55
|
/** Minimum Codex version whose hook can actually veto a tool call. */
|
|
36
56
|
export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
|
|
37
57
|
|
|
@@ -96,12 +116,20 @@ export function parse(native) {
|
|
|
96
116
|
if (typeof raw.transcript_path === "string")
|
|
97
117
|
meta.transcript_path = raw.transcript_path;
|
|
98
118
|
|
|
119
|
+
// Coverage floor: even on an enforcing Codex, `PreToolUse` fires for Bash only,
|
|
120
|
+
// so an MCP-sourced call is un-vetoable regardless of version (COVERAGE.mcp).
|
|
121
|
+
// Classify on the NATIVE name (MCP detection keys on `mcp__…`).
|
|
122
|
+
const nativeTool = asStringOrNull(raw.tool_name);
|
|
123
|
+
if (nativeTool !== null) meta.native_tool = nativeTool;
|
|
124
|
+
const vetoable =
|
|
125
|
+
enforce && coverageAllowsVeto(COVERAGE[classifyCallClass(nativeTool, raw)]);
|
|
126
|
+
|
|
99
127
|
return makeEvent({
|
|
100
128
|
event: kind,
|
|
101
|
-
tool:
|
|
129
|
+
tool: canonicalTool(nativeTool),
|
|
102
130
|
input: asObject(raw.tool_input),
|
|
103
131
|
response: undefined,
|
|
104
|
-
this_call_vetoable:
|
|
132
|
+
this_call_vetoable: vetoable,
|
|
105
133
|
meta,
|
|
106
134
|
});
|
|
107
135
|
}
|
|
@@ -146,4 +174,10 @@ export function render(verdict, event, { soleGate = false } = {}) {
|
|
|
146
174
|
}
|
|
147
175
|
|
|
148
176
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
149
|
-
export const codexAdapter = {
|
|
177
|
+
export const codexAdapter = {
|
|
178
|
+
AGENT,
|
|
179
|
+
INTEGRATION_MODE,
|
|
180
|
+
COVERAGE,
|
|
181
|
+
parse,
|
|
182
|
+
render,
|
|
183
|
+
};
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -23,11 +23,18 @@ import {
|
|
|
23
23
|
EventKind,
|
|
24
24
|
Decision,
|
|
25
25
|
IntegrationMode,
|
|
26
|
+
CallClass,
|
|
27
|
+
CoverageStatus,
|
|
28
|
+
classifyCallClass,
|
|
29
|
+
coverageAllowsVeto,
|
|
30
|
+
canonicalTool,
|
|
31
|
+
assertAliasTargetsModeled,
|
|
26
32
|
makeEvent,
|
|
27
33
|
normalizeVerdict,
|
|
28
34
|
nativeResponse,
|
|
29
35
|
collectPassthrough,
|
|
30
36
|
asObject,
|
|
37
|
+
asString,
|
|
31
38
|
asStringOrNull,
|
|
32
39
|
} from "../control-plane.mjs";
|
|
33
40
|
|
|
@@ -39,15 +46,33 @@ import {
|
|
|
39
46
|
export const AGENT = "gemini";
|
|
40
47
|
export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
|
|
41
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `BeforeTool` gates
|
|
51
|
+
* builtins on v0.26+ (COVERED). MCP routing through the same matcher is only
|
|
52
|
+
* MEDIUM-confidence and unproven, subagent firing for a loaded agent is
|
|
53
|
+
* undocumented, and resumed-session behavior has no source — all three are
|
|
54
|
+
* UNKNOWN, held at fail-closed until an item-⑤ probe upgrades them. A guessed ✅
|
|
55
|
+
* here would be a silent fail-open.
|
|
56
|
+
*/
|
|
57
|
+
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
58
|
+
export const COVERAGE = Object.freeze({
|
|
59
|
+
[CallClass.BUILTIN]: CoverageStatus.COVERED,
|
|
60
|
+
[CallClass.MCP]: CoverageStatus.UNKNOWN,
|
|
61
|
+
[CallClass.SUBAGENT]: CoverageStatus.UNKNOWN,
|
|
62
|
+
[CallClass.RESUMED]: CoverageStatus.UNKNOWN,
|
|
63
|
+
});
|
|
64
|
+
|
|
42
65
|
/** Gemini CLI native hook event names (the `hook_event_name` field). */
|
|
43
66
|
export const HookEvent = Object.freeze({
|
|
44
67
|
BEFORE_TOOL: "BeforeTool",
|
|
45
68
|
AFTER_TOOL: "AfterTool",
|
|
69
|
+
BEFORE_AGENT: "BeforeAgent",
|
|
46
70
|
});
|
|
47
71
|
|
|
48
72
|
const NATIVE_TO_KIND = Object.freeze({
|
|
49
73
|
[HookEvent.BEFORE_TOOL]: EventKind.PRE_TOOL,
|
|
50
74
|
[HookEvent.AFTER_TOOL]: EventKind.POST_TOOL,
|
|
75
|
+
[HookEvent.BEFORE_AGENT]: EventKind.PROMPT_SUBMIT,
|
|
51
76
|
});
|
|
52
77
|
|
|
53
78
|
// Only the fields the adapter maps are consumed; everything else (timestamp,
|
|
@@ -60,6 +85,7 @@ const CONSUMED = new Set([
|
|
|
60
85
|
"tool_name",
|
|
61
86
|
"tool_input",
|
|
62
87
|
"tool_response",
|
|
88
|
+
"prompt",
|
|
63
89
|
]);
|
|
64
90
|
|
|
65
91
|
/**
|
|
@@ -83,11 +109,67 @@ function geminiMeta(nativeEvent, raw) {
|
|
|
83
109
|
return meta;
|
|
84
110
|
}
|
|
85
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Adapter-scoped native-builtin → canonical tool aliases, applied ONLY when a
|
|
114
|
+
* call classifies as BUILTIN. These names are too generic for the global
|
|
115
|
+
* {@link TOOL_ALIASES} (an MCP server could export a `read_file`), but Gemini
|
|
116
|
+
* CLI removes the ambiguity at parse time: every MCP tool is unconditionally
|
|
117
|
+
* registered — and surfaced in hook payloads — under its fully qualified
|
|
118
|
+
* `mcp_{server}_{tool}` name (gemini-cli docs/tools/mcp-server.md), so a bare
|
|
119
|
+
* builtin name in `tool_name` can only be the builtin. The builtin's native
|
|
120
|
+
* input fields still pass through verbatim (e.g. read_file's `absolute_path`,
|
|
121
|
+
* not Read's `file_path`) — `meta.native_tool` tells a consumer which field
|
|
122
|
+
* dialect to expect. Targets are pinned to {@link MODELED_TOOLS} at import, and
|
|
123
|
+
* every entry must be witnessed by a gemini conformance fixture
|
|
124
|
+
* (`assertToolAliasesCovered`).
|
|
125
|
+
* @type {Readonly<Record<string, string>>}
|
|
126
|
+
*/
|
|
127
|
+
export const GEMINI_TOOL_ALIASES = Object.freeze({
|
|
128
|
+
read_file: "Read",
|
|
129
|
+
write_file: "Write",
|
|
130
|
+
web_fetch: "WebFetch",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
assertAliasTargetsModeled(GEMINI_TOOL_ALIASES);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Canonicalize a Gemini native tool name: adapter-scoped builtin aliases first
|
|
137
|
+
* (BUILTIN calls only — an MCP- or context-flagged call is never reclassified),
|
|
138
|
+
* then the global {@link canonicalTool} map, else verbatim.
|
|
139
|
+
* @param {string|null} nativeTool
|
|
140
|
+
* @param {string} callClass a {@link CallClass} value
|
|
141
|
+
* @returns {string|null}
|
|
142
|
+
*/
|
|
143
|
+
function geminiCanonicalTool(nativeTool, callClass) {
|
|
144
|
+
if (nativeTool !== null && callClass === CallClass.BUILTIN) {
|
|
145
|
+
const scoped = GEMINI_TOOL_ALIASES[nativeTool];
|
|
146
|
+
if (scoped !== undefined) return scoped;
|
|
147
|
+
}
|
|
148
|
+
return canonicalTool(nativeTool);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} kind
|
|
153
|
+
* @param {Record<string, unknown>} raw
|
|
154
|
+
* @returns {Record<string, unknown>}
|
|
155
|
+
*/
|
|
156
|
+
function geminiInput(kind, raw) {
|
|
157
|
+
if (kind === EventKind.PROMPT_SUBMIT)
|
|
158
|
+
return { prompt: asString(raw.prompt, "") };
|
|
159
|
+
if (kind === EventKind.PRE_TOOL || kind === EventKind.POST_TOOL)
|
|
160
|
+
return asObject(raw.tool_input);
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
|
|
86
164
|
/**
|
|
87
165
|
* Parse a raw Gemini CLI hook payload into a normalized {@link ToolCallEvent}.
|
|
88
|
-
* Never throws on an unmodelled event type or tool-input field.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
166
|
+
* Never throws on an unmodelled event type or tool-input field. A payload comes
|
|
167
|
+
* from a hooks-capable Gemini (v0.26.0+) whose `BeforeTool` can pre-empt builtin
|
|
168
|
+
* tools via exit 2. But MCP firing is only medium-confidence (COVERAGE.mcp is
|
|
169
|
+
* UNKNOWN), so an MCP-sourced call — flagged by `mcp_context` or an `mcp_`-named
|
|
170
|
+
* tool — parses non-vetoable until an item-⑤ probe confirms the hook fires.
|
|
171
|
+
* `BeforeAgent` (fires after the user submits a prompt, before planning) maps to
|
|
172
|
+
* `prompt_submit`: no tool, the submitted text folded into `input.prompt`.
|
|
91
173
|
* @param {any} native
|
|
92
174
|
* @returns {ToolCallEvent}
|
|
93
175
|
*/
|
|
@@ -100,22 +182,31 @@ export function parse(native) {
|
|
|
100
182
|
EventKind.UNKNOWN;
|
|
101
183
|
const gating = kind === EventKind.PRE_TOOL || kind === EventKind.POST_TOOL;
|
|
102
184
|
const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
|
|
185
|
+
const nativeTool = gating ? asStringOrNull(raw.tool_name) : null;
|
|
186
|
+
const meta = geminiMeta(nativeEvent, raw);
|
|
187
|
+
if (nativeTool !== null) meta.native_tool = nativeTool;
|
|
188
|
+
// Classify on the NATIVE name (MCP detection keys on the `mcp_…` FQN prefix
|
|
189
|
+
// every Gemini MCP tool carries); the class gates both the veto flag and the
|
|
190
|
+
// builtin-only adapter-scoped aliases.
|
|
191
|
+
const callClass = classifyCallClass(nativeTool, raw);
|
|
103
192
|
return makeEvent({
|
|
104
193
|
event: kind,
|
|
105
|
-
tool:
|
|
106
|
-
input:
|
|
194
|
+
tool: geminiCanonicalTool(nativeTool, callClass),
|
|
195
|
+
input: geminiInput(kind, raw),
|
|
107
196
|
response,
|
|
108
|
-
this_call_vetoable:
|
|
109
|
-
meta
|
|
197
|
+
this_call_vetoable: coverageAllowsVeto(COVERAGE[callClass]),
|
|
198
|
+
meta,
|
|
110
199
|
});
|
|
111
200
|
}
|
|
112
201
|
|
|
113
202
|
/**
|
|
114
203
|
* Render into Gemini CLI's native external-hook transport. An enforceable deny
|
|
115
|
-
* renders as exit 2 (the System Block
|
|
116
|
-
* decision
|
|
117
|
-
*
|
|
118
|
-
*
|
|
204
|
+
* renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
|
|
205
|
+
* as "same as decision: deny" — it aborts the turn); everything else exits 0
|
|
206
|
+
* with a JSON decision body (or none, when `allow` abstains). `soleGate`
|
|
207
|
+
* (default false) is the same dangerous opt-in as the other adapters: it makes
|
|
208
|
+
* an `allow` emit the real `decision: "allow"` instead of abstaining (tool
|
|
209
|
+
* events only — BeforeAgent documents no allow behavior to opt into).
|
|
119
210
|
* @param {Verdict} verdict
|
|
120
211
|
* @param {ToolCallEvent} event
|
|
121
212
|
* @param {{ soleGate?: boolean }} [options]
|
|
@@ -134,7 +225,10 @@ export function render(verdict, event, { soleGate = false } = {}) {
|
|
|
134
225
|
enforced: true,
|
|
135
226
|
});
|
|
136
227
|
|
|
137
|
-
const body =
|
|
228
|
+
const body =
|
|
229
|
+
event.event === EventKind.PROMPT_SUBMIT
|
|
230
|
+
? promptSubmitBody(vd)
|
|
231
|
+
: decisionBody(vd, soleGate);
|
|
138
232
|
return nativeResponse({
|
|
139
233
|
transport: INTEGRATION_MODE,
|
|
140
234
|
exit_code: 0,
|
|
@@ -171,5 +265,36 @@ function decisionBody(vd, soleGate) {
|
|
|
171
265
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
172
266
|
}
|
|
173
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Build the exit-0 stdout body for a `prompt_submit` (BeforeAgent) render.
|
|
270
|
+
* BeforeAgent's documented channels differ from BeforeTool's: `decision:
|
|
271
|
+
* "deny"` blocks the turn and discards the prompt, and context injection is
|
|
272
|
+
* `hookSpecificOutput.additionalContext` (appended to the prompt for this turn
|
|
273
|
+
* only) — there is no `tool_input` to mutate and no documented allow decision,
|
|
274
|
+
* so `allow` always abstains and `mutated_input` has no home here. `deny`/`ask`
|
|
275
|
+
* both surface `decision: "deny"` with the reason, mirroring how BeforeTool
|
|
276
|
+
* renders ask (Gemini has no native ask tier). Returns undefined when there is
|
|
277
|
+
* nothing to emit.
|
|
278
|
+
* @param {Verdict} vd
|
|
279
|
+
* @returns {Record<string, unknown>|undefined}
|
|
280
|
+
*/
|
|
281
|
+
function promptSubmitBody(vd) {
|
|
282
|
+
/** @type {Record<string, unknown>} */
|
|
283
|
+
const out = {};
|
|
284
|
+
if (vd.decision === Decision.DENY || vd.decision === Decision.ASK) {
|
|
285
|
+
out.decision = "deny";
|
|
286
|
+
if (vd.reason !== undefined) out.reason = vd.reason;
|
|
287
|
+
}
|
|
288
|
+
if (vd.additional_context !== undefined)
|
|
289
|
+
out.hookSpecificOutput = { additionalContext: vd.additional_context };
|
|
290
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
174
293
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
175
|
-
export const geminiAdapter = {
|
|
294
|
+
export const geminiAdapter = {
|
|
295
|
+
AGENT,
|
|
296
|
+
INTEGRATION_MODE,
|
|
297
|
+
COVERAGE,
|
|
298
|
+
parse,
|
|
299
|
+
render,
|
|
300
|
+
};
|