agent-control-plane-core 0.5.2 → 0.6.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.
@@ -33,6 +33,14 @@
33
33
  * field, a new modeled tool, a new tool alias) is backward-compatible and stays
34
34
  * at v1; RENAMING or REMOVING a field, or changing a decision/event vocabulary,
35
35
  * is breaking and bumps the version.
36
+ *
37
+ * That version covers the WIRE shapes an event or verdict carries, not the
38
+ * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
39
+ * member breaks every third-party adapter while every event on the wire stays
40
+ * byte-identical, so it is a package-semver break and leaves
41
+ * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one: adding it is
42
+ * a one-line change per adapter, and the conformance harness names the member
43
+ * and the migration rather than throwing from inside itself.
36
44
  */
37
45
 
38
46
  /** Wire identifier for this schema version; bump on a breaking shape change. */
@@ -315,16 +323,40 @@ export function isCoverageStatus(status) {
315
323
 
316
324
  /**
317
325
  * Classify a tool call into a {@link CallClass} from what a single pre-tool
318
- * payload reveals. Only MCP is reliably detectable here — from the tool NAME
319
- * (`mcp__server__tool` / `mcp_server_tool`) or an explicit `mcp_context` object
320
- * on the native payload. Subagent- and resumed-session calls carry no universal
321
- * signal in a lone pre-tool event (detecting them needs the live probe of
322
- * item ⑤), so they are NOT distinguished here and fall through to BUILTIN — an
323
- * adapter whose host leaves those classes un-gated relies on the sandbox, not
324
- * this classifier. An adapter feeds the result into `coverageAllowsVeto(this.
325
- * COVERAGE[class])` so a call in an uncovered/unknown class parses non-vetoable.
326
+ * payload reveals. Two classes are detectable here:
327
+ *
328
+ * - MCP, from the tool NAME (`mcp__server__tool` / `mcp_server_tool`) or an
329
+ * explicit `mcp_context` object on the native payload. Checked FIRST: an MCP
330
+ * call made from inside a subagent is still routed through the host's MCP
331
+ * surface, and that is the axis a host most often leaves un-gated.
332
+ * - SUBAGENT, from a non-empty `agent_type` on the payload — the field a host
333
+ * stamps on a call made by a subagent rather than the main thread. Claude Code
334
+ * carries it (with `agent_id`) on its hook inputs, and a live Codex probe
335
+ * ([X3] in `docs/hook-coverage-matrix.md`) found both fields present on a
336
+ * subagent's `PreToolUse` and ABSENT on the main thread's, with the `agent_id`
337
+ * matching the one `SubagentStart` announced. Absence is what makes the field
338
+ * a DISCRIMINATOR rather than decoration, which is why it is read here.
339
+ *
340
+ * RESUMED is not detectable from a tool payload on any host measured so far.
341
+ * Codex does announce a resumed session — `SessionStart` carries
342
+ * `source: "resume"` ([X4]) — but that is a different event, and correlating it
343
+ * would mean keeping cross-event state this package deliberately does not have:
344
+ * `parse` is a pure function of one payload. So a resumed session's call falls
345
+ * through to BUILTIN (or MCP) and is judged by that row. An adapter feeds the
346
+ * result into `coverageAllowsVeto(this.COVERAGE[class])` so a call in an
347
+ * uncovered/unknown class parses non-vetoable.
348
+ *
349
+ * The fail-closed reading of a ❓ row therefore reaches BUILTIN, MCP, and — on a
350
+ * host that stamps `agent_type` — SUBAGENT. An adapter's RESUMED row is still
351
+ * never selected, so declaring it UNKNOWN would not make a resumed session's
352
+ * call non-vetoable: it is judged by the row its TOOL selects. Honouring that
353
+ * row would mean taking the minimum status across the classes this classifier
354
+ * cannot separate — which collapses BUILTIN to UNKNOWN and disables enforcement
355
+ * outright for every adapter that has a ❓ there, so it is deliberately not
356
+ * done. Where a host's resumed coverage is unmeasured, that gap is the
357
+ * sandbox's to cover, and the row stands as documentation.
326
358
  * @param {string|null} tool tool name (null for non-tool events)
327
- * @param {Record<string, unknown>} [native] the raw native payload, for `mcp_context`
359
+ * @param {Record<string, unknown>} [native] the raw native payload, for `mcp_context` and `agent_type`
328
360
  * @returns {string} a {@link CallClass} value
329
361
  */
330
362
  export function classifyCallClass(tool, native) {
@@ -336,6 +368,13 @@ export function classifyCallClass(tool, native) {
336
368
  // and silently downgrades a legitimate veto).
337
369
  if (ctx !== null && typeof ctx === "object" && !Array.isArray(ctx))
338
370
  return CallClass.MCP;
371
+ // A NON-EMPTY string only: a host that stamps the key with `""` or `null` on a
372
+ // main-thread call is saying "no subagent", and reading that as one would
373
+ // degrade every ordinary call on a host whose SUBAGENT row is ❓. Codex's
374
+ // probed value for its default subagent is the literal "default" — a real
375
+ // agent type, not an empty marker, so it classifies as one.
376
+ if (typeof native?.agent_type === "string" && native.agent_type !== "")
377
+ return CallClass.SUBAGENT;
339
378
  return CallClass.BUILTIN;
340
379
  }
341
380
 
@@ -382,11 +421,74 @@ export function classifyCallClass(tool, native) {
382
421
  * @typedef {object} Verdict
383
422
  * @property {"allow"|"deny"|"ask"} decision
384
423
  * @property {Record<string, unknown>} [mutated_input] replacement tool input (pre_tool)
385
- * @property {unknown} [mutated_output] replacement tool output (post_tool) — the normalized channel for a PostToolUse content transform (redaction/sanitize); a string or the tool's structured output, verbatim. An adapter renders it into whatever native output-mutation channel the host has, or drops it when the host has none (the same per-adapter fidelity gap `reason` has on Amp).
424
+ * @property {unknown} [mutated_output] replacement tool output (post_tool) — the normalized channel for a PostToolUse content transform (redaction/sanitize); a string or the tool's structured output, verbatim. An adapter renders it into whatever native output-mutation channel the host has, or drops it when the host has none (the same per-adapter fidelity gap `additional_context` has on Codex).
386
425
  * @property {string} [additional_context] extra context to splice into the agent's stream
387
426
  * @property {string} [reason] human-readable rationale (shown on deny/ask)
388
427
  */
389
428
 
429
+ /**
430
+ * The {@link Verdict} fields that carry CONTENT into the agent's stream, as
431
+ * opposed to the decision itself. Every adapter either renders each one into a
432
+ * native channel or has no channel for it and drops it; `UNRENDERED_FIELDS`
433
+ * declares which, and the conformance harness holds each adapter to its own
434
+ * declaration. `reason` is not listed: it is only live on deny/ask, so it is not
435
+ * probeable from a single abstaining verdict the way these three are.
436
+ */
437
+ export const VERDICT_CONTENT_FIELDS = Object.freeze([
438
+ "mutated_input",
439
+ "mutated_output",
440
+ "additional_context",
441
+ ]);
442
+
443
+ /**
444
+ * A genuinely immutable set of `values`, for a row a consumer can reach.
445
+ *
446
+ * `Object.freeze` does not freeze a Set's CONTENTS — `frozen.delete(x)` still
447
+ * succeeds — and these rows are shared between adapters, so one `delete` would
448
+ * make several declarations report a channel their renders still discard. The
449
+ * wrapper holds the Set privately and exposes only the read half, frozen.
450
+ * @param {Iterable<string>} values
451
+ * @returns {ReadonlySet<string>}
452
+ */
453
+ export function readonlySet(values) {
454
+ const inner = new Set(values);
455
+ /** @type {ReadonlySet<string>} */
456
+ const facade = Object.freeze(
457
+ /** @type {any} */ ({
458
+ /** @param {string} value */
459
+ has: (value) => inner.has(value),
460
+ keys: () => inner.keys(),
461
+ values: () => inner.values(),
462
+ entries: () => inner.entries(),
463
+ // The callback's third argument is the SET, and forwarding to
464
+ // `inner.forEach` hands the private mutable one straight to a consumer —
465
+ // `row.forEach((v, k, set) => set.clear())` would empty a row several
466
+ // adapters share. The shim passes the facade, so there is no reference to
467
+ // the inner Set anywhere a caller can reach.
468
+ /**
469
+ * @param {(value: string, key: string, set: ReadonlySet<string>) => void} fn
470
+ * @param {unknown} [thisArg]
471
+ */
472
+ forEach: (fn, thisArg) =>
473
+ inner.forEach((value) => fn.call(thisArg, value, value, facade)),
474
+ [Symbol.iterator]: () => inner[Symbol.iterator](),
475
+ get size() {
476
+ return inner.size;
477
+ },
478
+ }),
479
+ );
480
+ return facade;
481
+ }
482
+
483
+ /**
484
+ * The `UNRENDERED_FIELDS` row every adapter uses for {@link EventKind.UNKNOWN}.
485
+ * An event the adapter could not name is one whose host channels nobody
486
+ * established, so claiming any of them is the same fail-open `assertGatedKinds`
487
+ * refuses for the veto: the caller reads a mutation as applied while the host
488
+ * ignores the key it was written into.
489
+ */
490
+ export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
491
+
390
492
  /**
391
493
  * The translator for one agent's protocol. `parse` maps a native event to a
392
494
  * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
@@ -398,6 +500,8 @@ export function classifyCallClass(tool, native) {
398
500
  * @property {Record<string, "covered"|"partial"|"uncovered"|"unknown">} COVERAGE per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
399
501
  * @property {(native: any) => ToolCallEvent} parse
400
502
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
503
+ * @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
504
+ * @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
401
505
  */
402
506
 
403
507
  /**
@@ -603,6 +707,65 @@ export function collectPassthrough(native, consumed) {
603
707
  return rest;
604
708
  }
605
709
 
710
+ /**
711
+ * The optional {@link EventMeta} string fields a host may carry, and the SSOT
712
+ * {@link baseMeta} reads off every native payload.
713
+ *
714
+ * PROBLEM CLASS — an adapter drops a normalized metadata field. Each adapter
715
+ * hand-copied these, one copy omitted `permission_mode`, and a guardrail keyed
716
+ * on `event.meta.permission_mode` then read `undefined` for that agent alone and
717
+ * took its no-value branch. Nothing distinguished "this host never sends the
718
+ * field" from "this adapter forgot it". Every adapter now maps the WHOLE set
719
+ * through {@link baseMeta}, so a field a host does not send is simply absent and
720
+ * no adapter carries an omission of its own.
721
+ */
722
+ export const STANDARD_META_FIELDS = Object.freeze([
723
+ "session_id",
724
+ "cwd",
725
+ "permission_mode",
726
+ "transcript_path",
727
+ ]);
728
+
729
+ /**
730
+ * Build the {@link EventMeta} base every adapter shares: the four required
731
+ * fields, whichever {@link STANDARD_META_FIELDS} the payload carries, and the
732
+ * unmodelled remainder in `passthrough`. A mapped field is consumed here too, so
733
+ * a value that reached `meta` can never also appear in `meta.passthrough`.
734
+ *
735
+ * A non-string value leaves its field ABSENT rather than stamping a number or
736
+ * null onto a contract field consumers read as text. The result is deliberately
737
+ * mutable: the caller adds the agent-specific `native_tool` after the base.
738
+ * @param {{ agent: string, native_event: string, integration_mode: string, primary_gate_present: boolean, native: Record<string, unknown>, consumed: Set<string> }} parts
739
+ * @returns {EventMeta}
740
+ */
741
+ export function baseMeta({
742
+ agent,
743
+ native_event,
744
+ integration_mode,
745
+ primary_gate_present,
746
+ native,
747
+ consumed,
748
+ }) {
749
+ /** @type {EventMeta} */
750
+ const meta = {
751
+ agent,
752
+ native_event,
753
+ integration_mode: /** @type {EventMeta["integration_mode"]} */ (
754
+ integration_mode
755
+ ),
756
+ primary_gate_present,
757
+ passthrough: collectPassthrough(
758
+ native,
759
+ new Set([...consumed, ...STANDARD_META_FIELDS]),
760
+ ),
761
+ };
762
+ for (const field of STANDARD_META_FIELDS) {
763
+ const value = lookup(native, field);
764
+ if (typeof value === "string") Object.assign(meta, { [field]: value });
765
+ }
766
+ return meta;
767
+ }
768
+
606
769
  // ─── Coercion primitives ─────────────────────────────────────────────────────
607
770
  // Adapters read untrusted native JSON: a field may be absent, null, or the wrong
608
771
  // type. These coerce to a safe shape (never throw) so a malformed field degrades
@@ -10,7 +10,9 @@
10
10
  "command": "ls -la"
11
11
  },
12
12
  "session_id": "a1",
13
- "cwd": "/w"
13
+ "cwd": "/w",
14
+ "permission_mode": "default",
15
+ "transcript_path": "/t.jsonl"
14
16
  },
15
17
  "event": {
16
18
  "schema_version": 1,
@@ -28,7 +30,9 @@
28
30
  "primary_gate_present": true,
29
31
  "passthrough": {},
30
32
  "session_id": "a1",
31
- "cwd": "/w"
33
+ "cwd": "/w",
34
+ "permission_mode": "default",
35
+ "transcript_path": "/t.jsonl"
32
36
  }
33
37
  },
34
38
  "render": {
@@ -50,7 +54,8 @@
50
54
  "native": {
51
55
  "transport": "external_hook",
52
56
  "exit_code": 2,
53
- "enforced": true
57
+ "enforced": true,
58
+ "stderr": "blocked"
54
59
  }
55
60
  },
56
61
  "ask": {
@@ -53,6 +53,38 @@
53
53
  }
54
54
  }
55
55
  },
56
+ "output_mutation": {
57
+ "verdict": {
58
+ "decision": "allow",
59
+ "mutated_output": "file1\n[REDACTED: secret]"
60
+ },
61
+ "native": {
62
+ "transport": "external_hook",
63
+ "exit_code": 0,
64
+ "enforced": false,
65
+ "stdout": {
66
+ "hookSpecificOutput": {
67
+ "hookEventName": "PreToolUse"
68
+ }
69
+ }
70
+ }
71
+ },
72
+ "context": {
73
+ "verdict": {
74
+ "decision": "allow",
75
+ "additional_context": "redacted 1 secret"
76
+ },
77
+ "native": {
78
+ "transport": "external_hook",
79
+ "exit_code": 0,
80
+ "enforced": false,
81
+ "stdout": {
82
+ "hookSpecificOutput": {
83
+ "hookEventName": "PreToolUse"
84
+ }
85
+ }
86
+ }
87
+ },
56
88
  "deny": {
57
89
  "verdict": {
58
90
  "decision": "deny",
@@ -175,6 +207,126 @@
175
207
  }
176
208
  }
177
209
  },
210
+ {
211
+ "name": "PreToolUse v0.151 from a subagent (agent_type present)",
212
+ "call_class": "subagent",
213
+ "native": {
214
+ "hook_event_name": "PreToolUse",
215
+ "version": "0.151.0",
216
+ "session_id": "c-sub",
217
+ "cwd": "/w",
218
+ "permission_mode": "default",
219
+ "transcript_path": "/r.jsonl",
220
+ "tool_name": "Bash",
221
+ "tool_input": {
222
+ "command": ["ls", "-la"]
223
+ },
224
+ "agent_id": "agt_7f3",
225
+ "agent_type": "default",
226
+ "model": "gpt-5-codex",
227
+ "tool_use_id": "call_1",
228
+ "turn_id": "turn_3"
229
+ },
230
+ "event": {
231
+ "schema_version": 1,
232
+ "event": "pre_tool",
233
+ "tool": "Bash",
234
+ "input": {
235
+ "command": ["ls", "-la"]
236
+ },
237
+ "this_call_vetoable": true,
238
+ "meta": {
239
+ "agent": "codex",
240
+ "native_event": "PreToolUse",
241
+ "integration_mode": "external_hook",
242
+ "primary_gate_present": true,
243
+ "passthrough": {
244
+ "agent_id": "agt_7f3",
245
+ "agent_type": "default",
246
+ "model": "gpt-5-codex",
247
+ "tool_use_id": "call_1",
248
+ "turn_id": "turn_3"
249
+ },
250
+ "session_id": "c-sub",
251
+ "cwd": "/w",
252
+ "permission_mode": "default",
253
+ "transcript_path": "/r.jsonl",
254
+ "native_tool": "Bash"
255
+ }
256
+ },
257
+ "render": {
258
+ "allow": {
259
+ "verdict": {
260
+ "decision": "allow"
261
+ },
262
+ "native": {
263
+ "transport": "external_hook",
264
+ "exit_code": 0,
265
+ "enforced": false,
266
+ "stdout": {
267
+ "hookSpecificOutput": {
268
+ "hookEventName": "PreToolUse"
269
+ }
270
+ }
271
+ }
272
+ },
273
+ "input_mutation": {
274
+ "verdict": {
275
+ "decision": "allow",
276
+ "mutated_input": {
277
+ "command": ["ls"]
278
+ }
279
+ },
280
+ "native": {
281
+ "transport": "external_hook",
282
+ "exit_code": 0,
283
+ "enforced": false,
284
+ "stdout": {
285
+ "hookSpecificOutput": {
286
+ "hookEventName": "PreToolUse",
287
+ "updatedInput": {
288
+ "command": ["ls"]
289
+ }
290
+ }
291
+ }
292
+ }
293
+ },
294
+ "deny": {
295
+ "verdict": {
296
+ "decision": "deny",
297
+ "reason": "subagent shell blocked"
298
+ },
299
+ "native": {
300
+ "transport": "external_hook",
301
+ "exit_code": 2,
302
+ "enforced": true,
303
+ "stdout": {
304
+ "hookSpecificOutput": {
305
+ "hookEventName": "PreToolUse",
306
+ "permissionDecision": "deny",
307
+ "permissionDecisionReason": "subagent shell blocked"
308
+ }
309
+ }
310
+ }
311
+ },
312
+ "ask": {
313
+ "verdict": {
314
+ "decision": "ask"
315
+ },
316
+ "native": {
317
+ "transport": "external_hook",
318
+ "exit_code": 0,
319
+ "enforced": false,
320
+ "stdout": {
321
+ "hookSpecificOutput": {
322
+ "hookEventName": "PreToolUse",
323
+ "permissionDecision": "ask"
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ },
178
330
  {
179
331
  "name": "PermissionRequest round-trips the native event name",
180
332
  "native": {
@@ -285,6 +437,131 @@
285
437
  }
286
438
  }
287
439
  }
440
+ },
441
+ {
442
+ "name": "PostToolUse v0.151 blocks on the post-tool schema",
443
+ "native": {
444
+ "hook_event_name": "PostToolUse",
445
+ "version": "0.151.0",
446
+ "session_id": "c2",
447
+ "cwd": "/w",
448
+ "tool_name": "Bash",
449
+ "tool_input": {
450
+ "command": "cat .env"
451
+ },
452
+ "tool_response": "OPENAI_API_KEY=sk-live-1",
453
+ "tool_use_id": "call_9"
454
+ },
455
+ "event": {
456
+ "schema_version": 1,
457
+ "event": "post_tool",
458
+ "tool": "Bash",
459
+ "input": {
460
+ "command": "cat .env"
461
+ },
462
+ "this_call_vetoable": true,
463
+ "meta": {
464
+ "agent": "codex",
465
+ "native_event": "PostToolUse",
466
+ "integration_mode": "external_hook",
467
+ "primary_gate_present": true,
468
+ "passthrough": {
469
+ "tool_use_id": "call_9"
470
+ },
471
+ "session_id": "c2",
472
+ "cwd": "/w",
473
+ "native_tool": "Bash"
474
+ },
475
+ "response": "OPENAI_API_KEY=sk-live-1"
476
+ },
477
+ "render": {
478
+ "context": {
479
+ "verdict": {
480
+ "decision": "allow",
481
+ "additional_context": "1 secret in this output"
482
+ },
483
+ "native": {
484
+ "transport": "external_hook",
485
+ "exit_code": 0,
486
+ "enforced": false,
487
+ "stdout": {
488
+ "hookSpecificOutput": {
489
+ "hookEventName": "PostToolUse",
490
+ "additionalContext": "1 secret in this output"
491
+ }
492
+ }
493
+ }
494
+ },
495
+ "output_mutation": {
496
+ "verdict": {
497
+ "decision": "allow",
498
+ "mutated_output": "[REDACTED]"
499
+ },
500
+ "native": {
501
+ "transport": "external_hook",
502
+ "exit_code": 0,
503
+ "enforced": false,
504
+ "stdout": {
505
+ "hookSpecificOutput": {
506
+ "hookEventName": "PostToolUse"
507
+ }
508
+ }
509
+ }
510
+ },
511
+ "ask": {
512
+ "verdict": {
513
+ "decision": "ask",
514
+ "reason": "secret in output"
515
+ },
516
+ "native": {
517
+ "transport": "external_hook",
518
+ "exit_code": 0,
519
+ "enforced": false,
520
+ "stdout": {
521
+ "hookSpecificOutput": {
522
+ "hookEventName": "PostToolUse"
523
+ },
524
+ "decision": "block",
525
+ "reason": "secret in output"
526
+ }
527
+ }
528
+ },
529
+ "deny": {
530
+ "verdict": {
531
+ "decision": "deny",
532
+ "reason": "leaked credential"
533
+ },
534
+ "native": {
535
+ "transport": "external_hook",
536
+ "exit_code": 2,
537
+ "enforced": true,
538
+ "stdout": {
539
+ "hookSpecificOutput": {
540
+ "hookEventName": "PostToolUse"
541
+ },
542
+ "decision": "block",
543
+ "reason": "leaked credential"
544
+ }
545
+ }
546
+ },
547
+ "deny_no_reason": {
548
+ "verdict": {
549
+ "decision": "deny"
550
+ },
551
+ "native": {
552
+ "transport": "external_hook",
553
+ "exit_code": 2,
554
+ "enforced": true,
555
+ "stdout": {
556
+ "hookSpecificOutput": {
557
+ "hookEventName": "PostToolUse"
558
+ },
559
+ "decision": "block",
560
+ "reason": "blocked by monitor"
561
+ }
562
+ }
563
+ }
564
+ }
288
565
  }
289
566
  ]
290
567
  }
@@ -13,7 +13,8 @@
13
13
  "tool_name": "run_shell_command",
14
14
  "tool_input": {
15
15
  "command": "echo hi"
16
- }
16
+ },
17
+ "permission_mode": "default"
17
18
  },
18
19
  "event": {
19
20
  "schema_version": 1,
@@ -34,7 +35,8 @@
34
35
  },
35
36
  "session_id": "g1",
36
37
  "cwd": "/w",
37
- "transcript_path": "/t.json"
38
+ "transcript_path": "/t.json",
39
+ "permission_mode": "default"
38
40
  }
39
41
  },
40
42
  "render": {
@@ -186,6 +188,21 @@
186
188
  "exit_code": 0,
187
189
  "enforced": false
188
190
  }
191
+ },
192
+ "output_mutation": {
193
+ "verdict": {
194
+ "decision": "allow",
195
+ "mutated_output": "file1\n[REDACTED: secret]",
196
+ "additional_context": "redacted 1 secret"
197
+ },
198
+ "native": {
199
+ "transport": "external_hook",
200
+ "exit_code": 0,
201
+ "enforced": false,
202
+ "stdout": {
203
+ "systemMessage": "redacted 1 secret\n\nThe monitor redacted this tool output, but Gemini CLI has no AfterTool channel to replace it. Treat the tool output above as UNREDACTED and unvetted."
204
+ }
205
+ }
189
206
  }
190
207
  }
191
208
  },
@@ -437,6 +454,86 @@
437
454
  }
438
455
  }
439
456
  }
457
+ },
458
+ {
459
+ "name": "BeforeTool builtin read_file without absolute_path stays native",
460
+ "call_class": "builtin",
461
+ "native": {
462
+ "hook_event_name": "BeforeTool",
463
+ "tool_name": "read_file",
464
+ "tool_input": {
465
+ "path": "/w/notes.txt"
466
+ }
467
+ },
468
+ "event": {
469
+ "schema_version": 1,
470
+ "event": "pre_tool",
471
+ "tool": "read_file",
472
+ "input": {
473
+ "path": "/w/notes.txt"
474
+ },
475
+ "this_call_vetoable": true,
476
+ "meta": {
477
+ "agent": "gemini",
478
+ "native_event": "BeforeTool",
479
+ "native_tool": "read_file",
480
+ "integration_mode": "external_hook",
481
+ "primary_gate_present": true,
482
+ "passthrough": {}
483
+ }
484
+ },
485
+ "render": {
486
+ "allow": {
487
+ "verdict": {
488
+ "decision": "allow"
489
+ },
490
+ "native": {
491
+ "transport": "external_hook",
492
+ "exit_code": 0,
493
+ "enforced": false
494
+ }
495
+ }
496
+ }
497
+ },
498
+ {
499
+ "name": "BeforeTool builtin write_file without file_path stays native",
500
+ "call_class": "builtin",
501
+ "native": {
502
+ "hook_event_name": "BeforeTool",
503
+ "tool_name": "write_file",
504
+ "tool_input": {
505
+ "content": "hi"
506
+ }
507
+ },
508
+ "event": {
509
+ "schema_version": 1,
510
+ "event": "pre_tool",
511
+ "tool": "write_file",
512
+ "input": {
513
+ "content": "hi"
514
+ },
515
+ "this_call_vetoable": true,
516
+ "meta": {
517
+ "agent": "gemini",
518
+ "native_event": "BeforeTool",
519
+ "native_tool": "write_file",
520
+ "integration_mode": "external_hook",
521
+ "primary_gate_present": true,
522
+ "passthrough": {}
523
+ }
524
+ },
525
+ "render": {
526
+ "allow": {
527
+ "verdict": {
528
+ "decision": "allow"
529
+ },
530
+ "native": {
531
+ "transport": "external_hook",
532
+ "exit_code": 0,
533
+ "enforced": false
534
+ }
535
+ }
536
+ }
440
537
  }
441
538
  ]
442
539
  }