agent-control-plane-core 0.5.3 → 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.
- package/README.md +35 -6
- package/bin/codex-hook.mjs +3 -2
- package/package.json +1 -1
- package/src/adapters/amp.mjs +89 -17
- package/src/adapters/claude.mjs +75 -36
- package/src/adapters/codex.mjs +234 -61
- package/src/adapters/gemini.mjs +196 -78
- package/src/conformance.mjs +614 -1
- package/src/control-plane.mjs +173 -10
- package/src/fixtures/amp.json +8 -3
- package/src/fixtures/codex.json +277 -0
- package/src/fixtures/gemini.json +99 -2
- package/src/index.mjs +5 -1
- package/types/adapters/amp.d.mts +48 -6
- package/types/adapters/claude.d.mts +34 -3
- package/types/adapters/codex.d.mts +63 -11
- package/types/adapters/gemini.d.mts +58 -8
- package/types/conformance.d.mts +16 -1
- package/types/control-plane.d.mts +178 -75
- package/types/index.d.mts +1 -1
package/src/control-plane.mjs
CHANGED
|
@@ -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.
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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 `
|
|
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
|
package/src/fixtures/amp.json
CHANGED
|
@@ -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": {
|
package/src/fixtures/codex.json
CHANGED
|
@@ -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
|
}
|
package/src/fixtures/gemini.json
CHANGED
|
@@ -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
|
}
|