agent-control-plane-core 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,10 +40,13 @@ import {
40
40
  canonicalTool,
41
41
  lookup,
42
42
  assertAliasTargetsModeled,
43
+ MODELED_TOOL_INPUT_KEYS,
43
44
  makeEvent,
44
45
  normalizeVerdict,
45
46
  nativeResponse,
46
- collectPassthrough,
47
+ UNRENDERED_ON_UNKNOWN,
48
+ readonlySet,
49
+ baseMeta,
47
50
  asObject,
48
51
  asString,
49
52
  asStringOrNull,
@@ -51,8 +54,12 @@ import {
51
54
 
52
55
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
53
56
  /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
54
- /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
55
57
  /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
58
+ // Re-exported on this subpath even though no signature below names it:
59
+ // tsc turns each @typedef into an `export type` in the generated .d.mts,
60
+ // so dropping this line breaks `import type { EventMeta } from "…/<agent>"`
61
+ // for every TypeScript consumer.
62
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
56
63
 
57
64
  export const AGENT = "gemini";
58
65
  export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
@@ -62,8 +69,18 @@ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
62
69
  * builtins on v0.26+ (COVERED). MCP routing through the same matcher is only
63
70
  * MEDIUM-confidence and unproven, subagent firing for a loaded agent is
64
71
  * undocumented, and resumed-session behavior has no source — all three are
65
- * UNKNOWN, held at fail-closed until an item-⑤ probe upgrades them. A guessed ✅
66
- * here would be a silent fail-open.
72
+ * UNKNOWN. A guessed ✅ here would be a silent fail-open.
73
+ *
74
+ * Only the MCP row is fail-closed in practice: `parse` reads it, because
75
+ * {@link classifyCallClass} detects MCP from the tool name or `mcp_context`.
76
+ * The SUBAGENT row answers only for a payload carrying a non-empty `agent_type`
77
+ * ({@link classifyCallClass}); Gemini CLI documents no such field, so a
78
+ * subagent's call is judged today by the row its TOOL selects — BUILTIN
79
+ * (COVERED, i.e. vetoable), or the MCP row above when the name or `mcp_context`
80
+ * says so. The RESUMED row has no signal at all and is DECLARATIVE ONLY. Both
81
+ * record the matrix verdict for a consumer reading COVERAGE directly; the
82
+ * subagent one starts biting the moment a payload names an agent, which is what
83
+ * an item-⑤ probe of [G3] would establish.
67
84
  */
68
85
  /** @type {import("../control-plane.mjs").CoverageMap} */
69
86
  export const COVERAGE = Object.freeze({
@@ -85,6 +102,54 @@ const GATED_EVENTS = Object.freeze(
85
102
  );
86
103
  assertGatedKinds(GATED_EVENTS, AGENT);
87
104
 
105
+ // The kinds whose only content channel is context: the tool has already run
106
+ // (or there is no tool), so neither an input nor an output replacement has
107
+ // anywhere to go.
108
+ const CONTEXT_ONLY = readonlySet(["mutated_input", "mutated_output"]);
109
+
110
+ /**
111
+ * Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each event
112
+ * kind, so `render` drops them. Gemini CLI documents
113
+ * `hookSpecificOutput.tool_input` on BeforeTool, `systemMessage` on the tool
114
+ * events and `hookSpecificOutput.additionalContext` on BeforeAgent. It documents
115
+ * NO AfterTool output-rewrite field, so `mutated_output` is dropped on every
116
+ * kind. `mutated_input` is dropped everywhere
117
+ * but BeforeTool: the tool has already run by AfterTool, and BeforeAgent has no
118
+ * tool input at all, so emitting `tool_input` there names a channel the host
119
+ * ignores while reading to the caller as a mutation applied.
120
+ *
121
+ * ALLOW path only. The enforced-deny branch of `render` returns no stdout at
122
+ * all, so a deny drops `additional_context` too, on every kind — these rows do
123
+ * not describe it.
124
+ *
125
+ * AfterTool is GATED here, so a redaction verdict does reach this adapter and
126
+ * cannot be honoured. It renders {@link POST_TOOL_REDACTION_UNSUPPORTED} on
127
+ * `systemMessage` instead, so the model is told the output above it is
128
+ * unredacted rather than left to read it as vetted. The raw output still reaches
129
+ * the model; a guardrail that must actually redact has to deny.
130
+ * @type {Record<string, ReadonlySet<string>|undefined>}
131
+ */
132
+ export const UNRENDERED_FIELDS = Object.freeze({
133
+ ...Object.fromEntries(
134
+ Object.values(EventKind).map((kind) => [kind, UNRENDERED_ON_UNKNOWN]),
135
+ ),
136
+ [EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
137
+ [EventKind.POST_TOOL]: CONTEXT_ONLY,
138
+ [EventKind.PROMPT_SUBMIT]: CONTEXT_ONLY,
139
+ });
140
+
141
+ /**
142
+ * What the model is told when a verdict redacts an AfterTool output Gemini has
143
+ * no channel to replace. Exported so a caller composing its own `systemMessage`
144
+ * can recognize it; recognize it with `endsWith`, never equality, because a
145
+ * verdict carrying `additional_context` too puts that context first (see
146
+ * {@link decisionBody}).
147
+ */
148
+ export const POST_TOOL_REDACTION_UNSUPPORTED =
149
+ "The monitor redacted this tool output, but Gemini CLI has no AfterTool " +
150
+ "channel to replace it. Treat the tool output above as UNREDACTED and " +
151
+ "unvetted.";
152
+
88
153
  /** Gemini CLI native hook event names (the `hook_event_name` field). */
89
154
  export const HookEvent = Object.freeze({
90
155
  BEFORE_TOOL: "BeforeTool",
@@ -98,40 +163,30 @@ const NATIVE_TO_KIND = Object.freeze({
98
163
  [HookEvent.BEFORE_AGENT]: EventKind.PROMPT_SUBMIT,
99
164
  });
100
165
 
166
+ /**
167
+ * The native event a conformance probe should carry for each kind — this
168
+ * adapter's own answer, so an every-kind probe exercises the branch that kind
169
+ * really takes. `session_start` and `unknown` have no Gemini event and are
170
+ * absent.
171
+ * @type {Record<string, string|undefined>}
172
+ */
173
+ export const NATIVE_EVENT_FOR = Object.freeze(
174
+ Object.fromEntries(
175
+ Object.entries(NATIVE_TO_KIND).map(([native, kind]) => [kind, native]),
176
+ ),
177
+ );
178
+
101
179
  // Only the fields the adapter maps are consumed; everything else (timestamp,
102
180
  // mcp_context, original_request_name, …) survives verbatim in meta.passthrough.
181
+ // The STANDARD_META_FIELDS are consumed by `baseMeta`, not listed here.
103
182
  const CONSUMED = new Set([
104
183
  "hook_event_name",
105
- "session_id",
106
- "cwd",
107
- "transcript_path",
108
184
  "tool_name",
109
185
  "tool_input",
110
186
  "tool_response",
111
187
  "prompt",
112
188
  ]);
113
189
 
114
- /**
115
- * @param {string} nativeEvent
116
- * @param {Record<string, unknown>} raw
117
- * @returns {EventMeta}
118
- */
119
- function geminiMeta(nativeEvent, raw) {
120
- /** @type {EventMeta} */
121
- const meta = {
122
- agent: AGENT,
123
- native_event: nativeEvent,
124
- integration_mode: INTEGRATION_MODE,
125
- primary_gate_present: true,
126
- passthrough: collectPassthrough(raw, CONSUMED),
127
- };
128
- if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
129
- if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
130
- if (typeof raw.transcript_path === "string")
131
- meta.transcript_path = raw.transcript_path;
132
- return meta;
133
- }
134
-
135
190
  /**
136
191
  * Adapter-scoped native-builtin → canonical tool aliases, applied ONLY when a
137
192
  * call classifies as BUILTIN. These names are too generic for the global
@@ -141,10 +196,11 @@ function geminiMeta(nativeEvent, raw) {
141
196
  * `mcp_{server}_{tool}` name (gemini-cli docs/tools/mcp-server.md), so a bare
142
197
  * builtin name in `tool_name` can only be the builtin. Renaming the tool also
143
198
  * renames its INPUT to the schema that name advertises, via
144
- * {@link GEMINI_INPUT_ALIASES} — a consumer told `event.tool` is `Read` reads
145
- * `input.file_path` and finds it, rather than reading `undefined` off a
146
- * forwarded `absolute_path` and allowing. Targets are pinned to
147
- * {@link MODELED_TOOLS} at import, every entry must be witnessed by a gemini
199
+ * {@link GEMINI_INPUT_ALIASES}, and the rename must actually produce that key or
200
+ * the call keeps its native name (see {@link geminiCall}) — a consumer told
201
+ * `event.tool` is `Read` reads `input.file_path` and finds it, rather than
202
+ * reading `undefined` off a forwarded `absolute_path` and allowing. Targets are
203
+ * pinned to {@link MODELED_TOOLS} at import, every entry must be witnessed by a gemini
148
204
  * conformance fixture (`assertToolAliasesCovered`), and every aliased case must
149
205
  * carry the canonical input key (`assertAliasedInputsCanonical`).
150
206
  * @type {Readonly<Record<string, string>>}
@@ -172,38 +228,27 @@ const GEMINI_INPUT_ALIASES = Object.freeze({
172
228
 
173
229
  assertAliasTargetsModeled(GEMINI_TOOL_ALIASES);
174
230
 
175
- /**
176
- * Canonicalize a Gemini native tool name: adapter-scoped builtin aliases first
177
- * (BUILTIN calls only — an MCP- or context-flagged call is never reclassified),
178
- * then the global {@link canonicalTool} map, else verbatim.
179
- * @param {string|null} nativeTool
180
- * @param {string} callClass a {@link CallClass} value
181
- * @returns {string|null}
182
- */
183
- function geminiCanonicalTool(nativeTool, callClass) {
184
- if (nativeTool !== null && callClass === CallClass.BUILTIN) {
185
- const scoped = lookup(GEMINI_TOOL_ALIASES, nativeTool);
186
- if (scoped !== undefined) return scoped;
187
- }
188
- return canonicalTool(nativeTool);
189
- }
190
-
191
231
  /**
192
232
  * @param {string} kind
193
233
  * @param {Record<string, unknown>} raw
194
- * @param {string|null} nativeTool
195
- * @param {string} callClass a {@link CallClass} value
196
- * @returns {Record<string, unknown>}
234
+ * @returns {Record<string, unknown>} the NATIVE tool input, in Gemini's dialect
197
235
  */
198
- function geminiInput(kind, raw, nativeTool, callClass) {
236
+ function geminiInput(kind, raw) {
199
237
  if (kind === EventKind.PROMPT_SUBMIT)
200
238
  return { prompt: asString(raw.prompt, "") };
201
239
  if (kind !== EventKind.PRE_TOOL && kind !== EventKind.POST_TOOL) return {};
202
- const input = asObject(raw.tool_input);
203
- // Renamed only where the NAME was renamed: the aliases are builtin-scoped, so
204
- // an MCP tool that happens to be called `read_file` keeps its own dialect.
205
- if (callClass !== CallClass.BUILTIN || nativeTool === null) return input;
206
- const renames = lookup(GEMINI_INPUT_ALIASES, nativeTool);
240
+ return asObject(raw.tool_input);
241
+ }
242
+
243
+ /**
244
+ * A copy of `input` with each `renames` key that the payload actually supplies
245
+ * moved to its canonical name. A native key the payload omits is NOT invented,
246
+ * so the result carries the canonical key only where a real value backs it.
247
+ * @param {Record<string, unknown>} input
248
+ * @param {Record<string, string>|undefined} renames native key → canonical key
249
+ * @returns {Record<string, unknown>}
250
+ */
251
+ function renameInputKeys(input, renames) {
207
252
  if (renames === undefined) return input;
208
253
  const out = { ...input };
209
254
  for (const [nativeKey, canonicalKey] of Object.entries(renames)) {
@@ -214,6 +259,46 @@ function geminiInput(kind, raw, nativeTool, callClass) {
214
259
  return out;
215
260
  }
216
261
 
262
+ /**
263
+ * Decide the canonical tool name and the input TOGETHER — the scoped alias
264
+ * applies to both or to neither.
265
+ *
266
+ * Renaming `read_file` to `Read` ADVERTISES the schema that name carries
267
+ * ({@link MODELED_TOOL_INPUT_KEYS}: `Read` promises `input.file_path`). So the
268
+ * alias is applied only when the renamed input really supplies that key. A
269
+ * builtin payload that does not — a `read_file` with no `absolute_path`, a
270
+ * `write_file` with no `file_path` — keeps its NATIVE name and its native input,
271
+ * because a native name promises nothing and reaches a judge as a name it must
272
+ * handle on purpose. Splitting the two decisions is the bypass this function
273
+ * exists to make unrepresentable: a judge told "this is a Read" read
274
+ * `input.file_path`, got `undefined`, and allowed.
275
+ *
276
+ * The aliases are builtin-scoped, so an MCP-sourced or context-flagged call is
277
+ * never reclassified and keeps its own dialect; a name the scoped map does not
278
+ * hold falls through to the global {@link canonicalTool}.
279
+ * @param {string|null} nativeTool
280
+ * @param {string} callClass a {@link CallClass} value
281
+ * @param {Record<string, unknown>} input the native tool input
282
+ * @returns {{ tool: string|null, input: Record<string, unknown> }}
283
+ */
284
+ function geminiCall(nativeTool, callClass, input) {
285
+ const native = { tool: canonicalTool(nativeTool), input };
286
+ if (nativeTool === null || callClass !== CallClass.BUILTIN) return native;
287
+ const scoped = lookup(GEMINI_TOOL_ALIASES, nativeTool);
288
+ if (scoped === undefined) return native;
289
+ const renamed = renameInputKeys(
290
+ input,
291
+ lookup(GEMINI_INPUT_ALIASES, nativeTool),
292
+ );
293
+ const advertised = lookup(
294
+ /** @type {Record<string, string>} */ (MODELED_TOOL_INPUT_KEYS),
295
+ scoped,
296
+ );
297
+ if (advertised === undefined || !Object.hasOwn(renamed, advertised))
298
+ return native;
299
+ return { tool: scoped, input: renamed };
300
+ }
301
+
217
302
  /**
218
303
  * Parse a raw Gemini CLI hook payload into a normalized {@link ToolCallEvent}.
219
304
  * Never throws on an unmodelled event type or tool-input field. A payload comes
@@ -238,16 +323,24 @@ export function parse(native) {
238
323
  const gating = kind === EventKind.PRE_TOOL || kind === EventKind.POST_TOOL;
239
324
  const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
240
325
  const nativeTool = gating ? asStringOrNull(raw.tool_name) : null;
241
- const meta = geminiMeta(nativeEvent, raw);
326
+ const meta = baseMeta({
327
+ agent: AGENT,
328
+ native_event: nativeEvent,
329
+ integration_mode: INTEGRATION_MODE,
330
+ primary_gate_present: true,
331
+ native: raw,
332
+ consumed: CONSUMED,
333
+ });
242
334
  if (nativeTool !== null) meta.native_tool = nativeTool;
243
335
  // Classify on the NATIVE name (MCP detection keys on the `mcp_…` FQN prefix
244
336
  // every Gemini MCP tool carries); the class gates both the veto flag and the
245
337
  // builtin-only adapter-scoped aliases.
246
338
  const callClass = classifyCallClass(nativeTool, raw);
339
+ const call = geminiCall(nativeTool, callClass, geminiInput(kind, raw));
247
340
  return makeEvent({
248
341
  event: kind,
249
- tool: geminiCanonicalTool(nativeTool, callClass),
250
- input: geminiInput(kind, raw, nativeTool, callClass),
342
+ tool: call.tool,
343
+ input: call.input,
251
344
  response,
252
345
  this_call_vetoable: vetoableFor(kind, GATED_EVENTS, COVERAGE[callClass]),
253
346
  meta,
@@ -275,6 +368,10 @@ export function render(verdict, event, { soleGate = false } = {}) {
275
368
  // reads its block rationale from stderr, not the ignored stdout body). Carry it
276
369
  // on NativeResponse.stderr so `emit` writes it to fd 2 — a genuine block is
277
370
  // never surfaced to the user/model with no explanation.
371
+ // No stdout body at all on this path, so EVERY content field is dropped here,
372
+ // whatever UNRENDERED_FIELDS says for the kind — that map describes the allow
373
+ // path. Nothing is lost that a blocked call could still use: the tool never
374
+ // runs, so only `additional_context` had anywhere to go.
278
375
  if (enforced)
279
376
  return nativeResponse({
280
377
  transport: INTEGRATION_MODE,
@@ -286,7 +383,7 @@ export function render(verdict, event, { soleGate = false } = {}) {
286
383
  const body =
287
384
  event.event === EventKind.PROMPT_SUBMIT
288
385
  ? promptSubmitBody(vd)
289
- : decisionBody(vd, soleGate);
386
+ : decisionBody(vd, event.event, soleGate);
290
387
  return nativeResponse({
291
388
  transport: INTEGRATION_MODE,
292
389
  exit_code: 0,
@@ -295,31 +392,53 @@ export function render(verdict, event, { soleGate = false } = {}) {
295
392
  });
296
393
  }
297
394
 
395
+ /**
396
+ * Write Gemini's advisory `decision: "deny"` (plus any reason) onto `out` for a
397
+ * deny or an ask — Gemini has no native ask tier, so both surface the same way.
398
+ * Shared by both body builders so the two cannot drift apart.
399
+ * @param {Record<string, unknown>} out
400
+ * @param {Verdict} vd
401
+ * @returns {boolean} whether a decision was written
402
+ */
403
+ function applyAdvisoryDeny(out, vd) {
404
+ if (vd.decision !== Decision.DENY && vd.decision !== Decision.ASK)
405
+ return false;
406
+ out.decision = "deny";
407
+ if (vd.reason !== undefined) out.reason = vd.reason;
408
+ return true;
409
+ }
410
+
298
411
  /**
299
412
  * Build the exit-0 stdout decision body. `allow` abstains — no `decision` key,
300
413
  * so Gemini runs its normal flow — unless `soleGate` opts into the real
301
414
  * `decision: "allow"`. `deny`/`ask` both surface an advisory `decision: "deny"`
302
415
  * (Gemini has no native ask tier). `mutated_input` rides along as
303
- * `hookSpecificOutput.tool_input` and `additional_context` as Gemini's native
304
- * `systemMessage`. Returns undefined when there is nothing to emit (a pure
305
- * abstaining allow).
416
+ * `hookSpecificOutput.tool_input` on BeforeTool only, and `additional_context`
417
+ * as Gemini's native `systemMessage`. A `mutated_output` has no channel at all:
418
+ * on AfterTool it becomes the {@link POST_TOOL_REDACTION_UNSUPPORTED} warning on
419
+ * that same `systemMessage`, joined after any context the verdict also carries.
420
+ * Returns undefined when there is nothing to emit (a pure abstaining allow).
306
421
  * @param {Verdict} vd
422
+ * @param {string} kind the normalized {@link EventKind} being rendered
307
423
  * @param {boolean} soleGate
308
424
  * @returns {Record<string, unknown>|undefined}
309
425
  */
310
- function decisionBody(vd, soleGate) {
426
+ function decisionBody(vd, kind, soleGate) {
311
427
  /** @type {Record<string, unknown>} */
312
428
  const out = {};
313
- if (vd.decision === Decision.DENY || vd.decision === Decision.ASK) {
314
- out.decision = "deny";
315
- if (vd.reason !== undefined) out.reason = vd.reason;
316
- } else if (soleGate) {
317
- out.decision = "allow";
318
- }
319
- if (vd.mutated_input !== undefined)
429
+ if (!applyAdvisoryDeny(out, vd) && soleGate) out.decision = "allow";
430
+ if (kind === EventKind.PRE_TOOL && vd.mutated_input !== undefined)
320
431
  out.hookSpecificOutput = { tool_input: vd.mutated_input };
321
- if (vd.additional_context !== undefined)
322
- out.systemMessage = vd.additional_context;
432
+ // `systemMessage` is documented on the TOOL events. An UNKNOWN kind is an
433
+ // event this adapter could not name, so it has no established channel and
434
+ // carries nothing.
435
+ const toolEvent = kind === EventKind.PRE_TOOL || kind === EventKind.POST_TOOL;
436
+ const messages = [];
437
+ if (toolEvent && vd.additional_context !== undefined)
438
+ messages.push(vd.additional_context);
439
+ if (kind === EventKind.POST_TOOL && vd.mutated_output !== undefined)
440
+ messages.push(POST_TOOL_REDACTION_UNSUPPORTED);
441
+ if (messages.length > 0) out.systemMessage = messages.join("\n\n");
323
442
  return Object.keys(out).length > 0 ? out : undefined;
324
443
  }
325
444
 
@@ -339,10 +458,7 @@ function decisionBody(vd, soleGate) {
339
458
  function promptSubmitBody(vd) {
340
459
  /** @type {Record<string, unknown>} */
341
460
  const out = {};
342
- if (vd.decision === Decision.DENY || vd.decision === Decision.ASK) {
343
- out.decision = "deny";
344
- if (vd.reason !== undefined) out.reason = vd.reason;
345
- }
461
+ applyAdvisoryDeny(out, vd);
346
462
  if (vd.additional_context !== undefined)
347
463
  out.hookSpecificOutput = { additionalContext: vd.additional_context };
348
464
  return Object.keys(out).length > 0 ? out : undefined;
@@ -353,6 +469,8 @@ export const geminiAdapter = {
353
469
  AGENT,
354
470
  INTEGRATION_MODE,
355
471
  COVERAGE,
472
+ UNRENDERED_FIELDS,
473
+ NATIVE_EVENT_FOR,
356
474
  parse,
357
475
  render,
358
476
  };