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.
- 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/README.md
CHANGED
|
@@ -28,10 +28,11 @@ removes.
|
|
|
28
28
|
(a tool about to run, a tool that just ran, a prompt, a session start).
|
|
29
29
|
- **`Verdict`** — one normalized way to say what your guardrail decided:
|
|
30
30
|
`allow` / `deny` / `ask`, optionally with a rewritten input or extra context.
|
|
31
|
-
- **Adapters** — a thin `{ parse, render }` translator per agent
|
|
32
|
-
that agent's
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
- **Adapters** — a thin `{ parse, render }` translator per agent, plus the
|
|
32
|
+
declaration of what that agent's transport cannot carry. `parse` turns that
|
|
33
|
+
agent's raw hook payload into a `ToolCallEvent`; `render` turns your `Verdict`
|
|
34
|
+
back into the **real** native signal that agent enforces (the right JSON body,
|
|
35
|
+
exit code, or thrown error — not just a print).
|
|
35
36
|
|
|
36
37
|
You write your logic **once** against the normalized types. Adding support for a
|
|
37
38
|
new agent is a new adapter (~one file + fixtures), not a rewrite of your tool.
|
|
@@ -90,13 +91,35 @@ pnpm add agent-control-plane-core
|
|
|
90
91
|
}
|
|
91
92
|
```
|
|
92
93
|
|
|
93
|
-
An **Adapter** is
|
|
94
|
+
An **Adapter** is `{ AGENT, COVERAGE, UNRENDERED_FIELDS, parse, render }`:
|
|
94
95
|
|
|
95
96
|
```js
|
|
96
97
|
parse(nativeEvent) -> ToolCallEvent // never throws on unknown input
|
|
97
98
|
render(verdict, event) -> nativeResponse // the agent's native shape
|
|
99
|
+
|
|
100
|
+
// Which Verdict content fields this host has NO native channel for, per event
|
|
101
|
+
// kind. Every EventKind needs a row: an omission would read as "every field
|
|
102
|
+
// reaches a channel here", which is the reverse of the truth on a transport
|
|
103
|
+
// that carries none.
|
|
104
|
+
UNRENDERED_FIELDS = Object.freeze({
|
|
105
|
+
[EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
|
|
106
|
+
[EventKind.POST_TOOL]: readonlySet(["mutated_output"]),
|
|
107
|
+
[EventKind.PROMPT_SUBMIT]: UNRENDERED_ON_UNKNOWN,
|
|
108
|
+
[EventKind.SESSION_START]: UNRENDERED_ON_UNKNOWN,
|
|
109
|
+
[EventKind.UNKNOWN]: UNRENDERED_ON_UNKNOWN,
|
|
110
|
+
})
|
|
98
111
|
```
|
|
99
112
|
|
|
113
|
+
`readonlySet([])` is the row for a kind that carries all three;
|
|
114
|
+
`UNRENDERED_ON_UNKNOWN` is the row for one that carries none. Both come from
|
|
115
|
+
`agent-control-plane-core/contract`. Build every row with `readonlySet` — a
|
|
116
|
+
plain `Set` exposes `clear()`, so a consumer could empty a declaration after
|
|
117
|
+
conformance certified it, and `Object.freeze` does not stop that. Freeze the map
|
|
118
|
+
itself too: an immutable row still leaves a consumer free to swap a whole row
|
|
119
|
+
for one claiming another channel. A render that ships a field the row
|
|
120
|
+
declares dropped fails conformance, and so does a row that declares a channel
|
|
121
|
+
the render does not carry.
|
|
122
|
+
|
|
100
123
|
## Usage
|
|
101
124
|
|
|
102
125
|
```js
|
|
@@ -167,7 +190,13 @@ The harness pins both directions and rejects a vacuous suite:
|
|
|
167
190
|
3. The fixture set collectively renders an `allow`, a `deny`, an `ask`, **and** a
|
|
168
191
|
`mutated_input` — so a suite can't pass while skipping a decision the contract
|
|
169
192
|
requires every adapter to express.
|
|
170
|
-
4.
|
|
193
|
+
4. Every `Verdict` content field either reaches this host's wire or is declared
|
|
194
|
+
in `UNRENDERED_FIELDS` for that event kind, checked both ways on a
|
|
195
|
+
synthesized verdict — so an adapter with no channel for a field must say so,
|
|
196
|
+
and a declared drop that still ships the value fails. Set the optional
|
|
197
|
+
`NATIVE_EVENT_FOR` to have a kind your fixtures skip probed on its real
|
|
198
|
+
native event rather than on your unrecognized-event branch.
|
|
199
|
+
5. A deny the adapter cannot enforce (`this_call_vetoable: false`) must render as
|
|
171
200
|
something OTHER than that host's allow signal — an ask, or an advisory body.
|
|
172
201
|
The harness probes this itself on a non-vetoable variant of every pre-tool
|
|
173
202
|
fixture event, so an adapter that collapses the objection onto "run it" fails
|
package/bin/codex-hook.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Codex CLI EXTERNAL_HOOK entry. Reads a native
|
|
4
|
-
* payload on stdin, applies the demo
|
|
3
|
+
* Codex CLI EXTERNAL_HOOK entry. Reads a native
|
|
4
|
+
* PreToolUse/PermissionRequest/PostToolUse payload on stdin, applies the demo
|
|
5
|
+
* judge, and emits Codex's native response.
|
|
5
6
|
*
|
|
6
7
|
* FAILURE POSTURE — fail OPEN (exit 0), like Claude Code: Codex treats a
|
|
7
8
|
* non-conforming hook as non-blocking. On a pre-v0.135 Codex the render is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* This adapter is what proves the transport split earns its keep: the normalized
|
|
10
10
|
* ToolCallEvent/Verdict are identical to Claude's, but `render` carries the
|
|
11
|
-
* decision in `exit_code` with no `stdout` at all.
|
|
11
|
+
* decision in `exit_code` with no `stdout` at all. An enforced deny's `reason`
|
|
12
|
+
* goes to the helper's STDERR, which Amp surfaces.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import {
|
|
@@ -25,15 +26,21 @@ import {
|
|
|
25
26
|
makeEvent,
|
|
26
27
|
normalizeVerdict,
|
|
27
28
|
nativeResponse,
|
|
28
|
-
|
|
29
|
+
readonlySet,
|
|
30
|
+
VERDICT_CONTENT_FIELDS,
|
|
31
|
+
baseMeta,
|
|
29
32
|
asObject,
|
|
30
33
|
asStringOrNull,
|
|
31
34
|
} from "../control-plane.mjs";
|
|
32
35
|
|
|
33
36
|
/** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
|
|
34
37
|
/** @typedef {import("../control-plane.mjs").Verdict} Verdict */
|
|
35
|
-
/** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
|
|
36
38
|
/** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
|
|
39
|
+
// Re-exported on this subpath even though no signature below names it:
|
|
40
|
+
// tsc turns each @typedef into an `export type` in the generated .d.mts,
|
|
41
|
+
// so dropping this line breaks `import type { EventMeta } from "…/<agent>"`
|
|
42
|
+
// for every TypeScript consumer.
|
|
43
|
+
/** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
|
|
37
44
|
|
|
38
45
|
export const AGENT = "amp";
|
|
39
46
|
export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
|
|
@@ -45,6 +52,23 @@ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
|
|
|
45
52
|
* gates calls inside subagents — so builtin, MCP, and subagent are all COVERED.
|
|
46
53
|
* Resumed-thread firing is a strong-but-uncited structural argument, so it is
|
|
47
54
|
* held at UNKNOWN until an item-⑤ probe confirms it.
|
|
55
|
+
*
|
|
56
|
+
* That UNKNOWN is this adapter's own fail-closed reading, NOT a transcription of
|
|
57
|
+
* the matrix cell, which reads `✅ struct. [A4]` — COVERED, on evidence the
|
|
58
|
+
* `struct.` suffix marks as uncited. A cell is a CEILING: an adapter may never
|
|
59
|
+
* claim more than it, but declaring LESS is always safe, since every step down
|
|
60
|
+
* degrades a deny toward notify. This row takes that conservative option until a
|
|
61
|
+
* probe cites it; Claude's takes the cell at face value on the same class of
|
|
62
|
+
* argument. The difference is free today, for the reason below.
|
|
63
|
+
*
|
|
64
|
+
* The row is DECLARATIVE ONLY either way: no host marks a lone tool event as
|
|
65
|
+
* belonging to a resumed session, so {@link classifyCallClass} never returns
|
|
66
|
+
* RESUMED and `parse` never reads the entry — a call in a resumed thread is
|
|
67
|
+
* classified by its TOOL, BUILTIN or MCP, and both are COVERED, so it parses
|
|
68
|
+
* vetoable. (The SUBAGENT row is reachable — the classifier answers it for a
|
|
69
|
+
* payload carrying `agent_type` — but Amp's subagent coverage is COVERED too,
|
|
70
|
+
* so it changes nothing here either.) The entry is there for a consumer reading
|
|
71
|
+
* COVERAGE directly, and becomes load-bearing only once a signal exists.
|
|
48
72
|
*/
|
|
49
73
|
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
50
74
|
export const COVERAGE = Object.freeze({
|
|
@@ -62,11 +86,44 @@ export const COVERAGE = Object.freeze({
|
|
|
62
86
|
* after `assertGatedKinds` has already run.
|
|
63
87
|
*/
|
|
64
88
|
const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The native event a conformance probe should carry for each kind — this
|
|
92
|
+
* adapter's own answer, so an every-kind probe exercises the branch that kind
|
|
93
|
+
* really takes. Amp's observer names every event `delegate` and reaches no
|
|
94
|
+
* other kind.
|
|
95
|
+
* @type {Record<string, string|undefined>}
|
|
96
|
+
*/
|
|
97
|
+
export const NATIVE_EVENT_FOR = Object.freeze({
|
|
98
|
+
[EventKind.PRE_TOOL]: "delegate",
|
|
99
|
+
});
|
|
65
100
|
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
66
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Which {@link VERDICT_CONTENT_FIELDS} have no native channel, so `render`
|
|
104
|
+
* drops them. Amp's transport is the exit code and nothing else — there is no
|
|
105
|
+
* stdout body to carry a replacement input, a replacement output or extra
|
|
106
|
+
* context, so ALL THREE are dropped on every kind. A guardrail that needs any of
|
|
107
|
+
* them cannot use Amp as its only integration. `reason` is not one of them and
|
|
108
|
+
* is NOT dropped: an enforced deny writes it to the helper's stderr, which Amp
|
|
109
|
+
* surfaces (see {@link render}). Built over every
|
|
110
|
+
* {@link EventKind} rather than the one `parse` emits: a row this adapter cannot
|
|
111
|
+
* reach is still the honest answer for a caller that asks.
|
|
112
|
+
* @type {Record<string, ReadonlySet<string>|undefined>}
|
|
113
|
+
*/
|
|
114
|
+
export const UNRENDERED_FIELDS = Object.freeze(
|
|
115
|
+
Object.fromEntries(
|
|
116
|
+
Object.values(EventKind).map((kind) => [
|
|
117
|
+
kind,
|
|
118
|
+
readonlySet(VERDICT_CONTENT_FIELDS),
|
|
119
|
+
]),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
|
|
67
123
|
// Amp invokes the delegate for a tool call; the payload carries the tool name +
|
|
68
|
-
// input
|
|
69
|
-
|
|
124
|
+
// input. The session context is the STANDARD_META_FIELDS set `baseMeta`
|
|
125
|
+
// consumes, so it is not listed here. Pinned by fixtures/amp.json.
|
|
126
|
+
const CONSUMED = new Set(["tool", "input"]);
|
|
70
127
|
|
|
71
128
|
/**
|
|
72
129
|
* @param {any} native
|
|
@@ -74,16 +131,14 @@ const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
|
|
|
74
131
|
*/
|
|
75
132
|
export function parse(native) {
|
|
76
133
|
const raw = asObject(native);
|
|
77
|
-
|
|
78
|
-
const meta = {
|
|
134
|
+
const meta = baseMeta({
|
|
79
135
|
agent: AGENT,
|
|
80
136
|
native_event: "delegate",
|
|
81
137
|
integration_mode: INTEGRATION_MODE,
|
|
82
138
|
primary_gate_present: true,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
|
|
139
|
+
native: raw,
|
|
140
|
+
consumed: CONSUMED,
|
|
141
|
+
});
|
|
87
142
|
const nativeTool = asStringOrNull(raw.tool);
|
|
88
143
|
if (nativeTool !== null) meta.native_tool = nativeTool;
|
|
89
144
|
return makeEvent({
|
|
@@ -146,10 +201,14 @@ for (const decision of Object.values(Decision)) {
|
|
|
146
201
|
|
|
147
202
|
/**
|
|
148
203
|
* Render into Amp's pure exit-code transport: the decision is the exit code,
|
|
149
|
-
* with no stdout body.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
204
|
+
* with no stdout body. An ENFORCED deny also carries its `reason` on
|
|
205
|
+
* `NativeResponse.stderr`, which `emit` writes to fd 2: the delegate is a PATH
|
|
206
|
+
* helper whose stderr Amp surfaces, and this render is what that helper writes,
|
|
207
|
+
* so a block that reached the user with no rationale was the adapter throwing
|
|
208
|
+
* the reason away. Only the enforced path — a deny this call cannot veto and an
|
|
209
|
+
* ask have blocked nothing, and `stderr` is the contract's block-reason channel.
|
|
210
|
+
* No `soleGate` option — an allow already renders as exit 0 either way, so
|
|
211
|
+
* there's no distinct "real approve" signal for this transport to opt into.
|
|
153
212
|
* @param {Verdict} verdict
|
|
154
213
|
* @param {ToolCallEvent} event
|
|
155
214
|
* @returns {NativeResponse}
|
|
@@ -178,8 +237,21 @@ export function render(verdict, event) {
|
|
|
178
237
|
throw new Error(
|
|
179
238
|
`amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
|
|
180
239
|
);
|
|
181
|
-
return nativeResponse({
|
|
240
|
+
return nativeResponse({
|
|
241
|
+
transport: INTEGRATION_MODE,
|
|
242
|
+
exit_code,
|
|
243
|
+
enforced,
|
|
244
|
+
...(enforced && vd.reason !== undefined ? { stderr: vd.reason } : {}),
|
|
245
|
+
});
|
|
182
246
|
}
|
|
183
247
|
|
|
184
248
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
185
|
-
export const ampAdapter = {
|
|
249
|
+
export const ampAdapter = {
|
|
250
|
+
AGENT,
|
|
251
|
+
INTEGRATION_MODE,
|
|
252
|
+
COVERAGE,
|
|
253
|
+
UNRENDERED_FIELDS,
|
|
254
|
+
NATIVE_EVENT_FOR,
|
|
255
|
+
parse,
|
|
256
|
+
render,
|
|
257
|
+
};
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
makeEvent,
|
|
27
27
|
normalizeVerdict,
|
|
28
28
|
nativeResponse,
|
|
29
|
-
|
|
29
|
+
UNRENDERED_ON_UNKNOWN,
|
|
30
|
+
readonlySet,
|
|
31
|
+
baseMeta,
|
|
30
32
|
asObject,
|
|
31
33
|
asString,
|
|
32
34
|
asStringOrNull,
|
|
@@ -34,8 +36,12 @@ import {
|
|
|
34
36
|
|
|
35
37
|
/** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
|
|
36
38
|
/** @typedef {import("../control-plane.mjs").Verdict} Verdict */
|
|
37
|
-
/** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
|
|
38
39
|
/** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
|
|
40
|
+
// Re-exported on this subpath even though no signature below names it:
|
|
41
|
+
// tsc turns each @typedef into an `export type` in the generated .d.mts,
|
|
42
|
+
// so dropping this line breaks `import type { EventMeta } from "…/<agent>"`
|
|
43
|
+
// for every TypeScript consumer.
|
|
44
|
+
/** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
|
|
39
45
|
|
|
40
46
|
/** Producing-agent id stamped onto every event this adapter parses. */
|
|
41
47
|
export const AGENT = "claude";
|
|
@@ -48,7 +54,13 @@ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
|
|
|
48
54
|
* for builtins, for MCP tools (surfaced as `mcp__<server>__<tool>` through the
|
|
49
55
|
* same hook), and for every tool a subagent uses (subagents do not inherit the
|
|
50
56
|
* parent's permissions, so the hook is often their only gate). Resumed sessions
|
|
51
|
-
* re-read hooks from settings and fire per new call —
|
|
57
|
+
* re-read hooks from settings and fire per new call — `✅ struct. [C4]`, where
|
|
58
|
+
* the `struct.` suffix marks the status as uncited: no source addresses resumed
|
|
59
|
+
* sessions directly, and COVERED is an inference from the reload mechanism. This
|
|
60
|
+
* row takes the cell at face value. Amp holds the equivalent row at UNKNOWN,
|
|
61
|
+
* which the matrix permits — a cell is a ceiling, and declaring less coverage
|
|
62
|
+
* than it is always the safe direction. Both are free today, since
|
|
63
|
+
* `classifyCallClass` never returns RESUMED and no call is judged by this entry.
|
|
52
64
|
*/
|
|
53
65
|
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
54
66
|
export const COVERAGE = Object.freeze({
|
|
@@ -72,6 +84,36 @@ const GATED_EVENTS = Object.freeze(
|
|
|
72
84
|
);
|
|
73
85
|
assertGatedKinds(GATED_EVENTS, AGENT);
|
|
74
86
|
|
|
87
|
+
// The kinds whose only content channel is context: the tool has already run
|
|
88
|
+
// (or there is no tool), so neither an input nor an output replacement has
|
|
89
|
+
// anywhere to go.
|
|
90
|
+
const CONTEXT_ONLY = readonlySet(["mutated_input", "mutated_output"]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each event
|
|
94
|
+
* kind, so `render` drops them. Claude Code splits its content channels by
|
|
95
|
+
* event: `updatedInput` exists only on PreToolUse (the call has not run yet),
|
|
96
|
+
* and `updatedToolOutput` only on PostToolUse (there is no tool output to
|
|
97
|
+
* replace anywhere else). `additionalContext` is the one channel every kind
|
|
98
|
+
* carries. The conformance harness holds this to what `render` actually emits,
|
|
99
|
+
* in both directions.
|
|
100
|
+
*
|
|
101
|
+
* `additionalContext` on PreToolUse is inherited behaviour, not a checked claim:
|
|
102
|
+
* Claude Code documents the field for UserPromptSubmit, SessionStart and
|
|
103
|
+
* PostToolUse, and this adapter has emitted it on PreToolUse since before the
|
|
104
|
+
* declaration existed. Rule ⑩ can see that the value reaches the wire, never
|
|
105
|
+
* that the host reads the key — confirm it against the hook reference before
|
|
106
|
+
* relying on it.
|
|
107
|
+
* @type {Record<string, ReadonlySet<string>|undefined>}
|
|
108
|
+
*/
|
|
109
|
+
export const UNRENDERED_FIELDS = Object.freeze({
|
|
110
|
+
[EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
|
|
111
|
+
[EventKind.POST_TOOL]: readonlySet(["mutated_input"]),
|
|
112
|
+
[EventKind.PROMPT_SUBMIT]: CONTEXT_ONLY,
|
|
113
|
+
[EventKind.SESSION_START]: CONTEXT_ONLY,
|
|
114
|
+
[EventKind.UNKNOWN]: UNRENDERED_ON_UNKNOWN,
|
|
115
|
+
});
|
|
116
|
+
|
|
75
117
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
76
118
|
export const HookEvent = Object.freeze({
|
|
77
119
|
PRE_TOOL_USE: "PreToolUse",
|
|
@@ -94,12 +136,17 @@ const KIND_TO_NATIVE = Object.freeze({
|
|
|
94
136
|
[EventKind.SESSION_START]: HookEvent.SESSION_START,
|
|
95
137
|
});
|
|
96
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The native event a conformance probe should carry for each kind — this
|
|
141
|
+
* adapter's own answer, so an every-kind probe exercises the branch that kind
|
|
142
|
+
* really takes. `unknown` has no native name by definition and is absent.
|
|
143
|
+
* @type {Record<string, string|undefined>}
|
|
144
|
+
*/
|
|
145
|
+
export const NATIVE_EVENT_FOR = KIND_TO_NATIVE;
|
|
146
|
+
|
|
147
|
+
// The STANDARD_META_FIELDS are consumed by `baseMeta`, not listed here.
|
|
97
148
|
const CONSUMED = new Set([
|
|
98
149
|
"hook_event_name",
|
|
99
|
-
"session_id",
|
|
100
|
-
"cwd",
|
|
101
|
-
"permission_mode",
|
|
102
|
-
"transcript_path",
|
|
103
150
|
"tool_name",
|
|
104
151
|
"tool_input",
|
|
105
152
|
"tool_response",
|
|
@@ -129,29 +176,6 @@ function claudeTool(kind, raw) {
|
|
|
129
176
|
return asStringOrNull(raw.tool_name);
|
|
130
177
|
}
|
|
131
178
|
|
|
132
|
-
/**
|
|
133
|
-
* @param {string} nativeEvent
|
|
134
|
-
* @param {Record<string, unknown>} raw
|
|
135
|
-
* @returns {EventMeta}
|
|
136
|
-
*/
|
|
137
|
-
function claudeMeta(nativeEvent, raw) {
|
|
138
|
-
/** @type {EventMeta} */
|
|
139
|
-
const meta = {
|
|
140
|
-
agent: AGENT,
|
|
141
|
-
native_event: nativeEvent,
|
|
142
|
-
integration_mode: INTEGRATION_MODE,
|
|
143
|
-
primary_gate_present: true,
|
|
144
|
-
passthrough: collectPassthrough(raw, CONSUMED),
|
|
145
|
-
};
|
|
146
|
-
if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
|
|
147
|
-
if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
|
|
148
|
-
if (typeof raw.permission_mode === "string")
|
|
149
|
-
meta.permission_mode = raw.permission_mode;
|
|
150
|
-
if (typeof raw.transcript_path === "string")
|
|
151
|
-
meta.transcript_path = raw.transcript_path;
|
|
152
|
-
return meta;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
179
|
/**
|
|
156
180
|
* Parse a raw Claude Code hook payload into a normalized {@link ToolCallEvent}.
|
|
157
181
|
* Never throws on an unmodelled event type or tool-input field.
|
|
@@ -168,7 +192,14 @@ export function parse(native) {
|
|
|
168
192
|
) ?? EventKind.UNKNOWN;
|
|
169
193
|
const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
|
|
170
194
|
const nativeTool = claudeTool(kind, raw);
|
|
171
|
-
const meta =
|
|
195
|
+
const meta = baseMeta({
|
|
196
|
+
agent: AGENT,
|
|
197
|
+
native_event: nativeEvent,
|
|
198
|
+
integration_mode: INTEGRATION_MODE,
|
|
199
|
+
primary_gate_present: true,
|
|
200
|
+
native: raw,
|
|
201
|
+
consumed: CONSUMED,
|
|
202
|
+
});
|
|
172
203
|
if (nativeTool !== null) meta.native_tool = nativeTool;
|
|
173
204
|
return makeEvent({
|
|
174
205
|
event: kind,
|
|
@@ -215,7 +246,7 @@ export function render(verdict, event, { soleGate = false } = {}) {
|
|
|
215
246
|
const stdout =
|
|
216
247
|
kind === EventKind.PRE_TOOL
|
|
217
248
|
? gatingBody(hookEventName, vd, soleGate)
|
|
218
|
-
: nonGatingBody(hookEventName, vd);
|
|
249
|
+
: nonGatingBody(hookEventName, kind, vd);
|
|
219
250
|
|
|
220
251
|
return nativeResponse({
|
|
221
252
|
transport: INTEGRATION_MODE,
|
|
@@ -253,18 +284,24 @@ function gatingBody(hookEventName, vd, soleGate) {
|
|
|
253
284
|
|
|
254
285
|
/**
|
|
255
286
|
* @param {string} hookEventName
|
|
287
|
+
* @param {string} kind the normalized {@link EventKind} being rendered
|
|
256
288
|
* @param {Verdict} vd
|
|
257
289
|
* @returns {Record<string, unknown>}
|
|
258
290
|
*/
|
|
259
|
-
function nonGatingBody(hookEventName, vd) {
|
|
291
|
+
function nonGatingBody(hookEventName, kind, vd) {
|
|
260
292
|
/** @type {Record<string, unknown>} */
|
|
261
293
|
const hookSpecificOutput = { hookEventName };
|
|
262
294
|
// A PostToolUse content transform: `updatedToolOutput` replaces what the model
|
|
263
295
|
// sees (the tool already ran, so this governs only the model's view). The
|
|
264
|
-
// native channel for the whole redaction/sanitize pipeline.
|
|
265
|
-
|
|
296
|
+
// native channel for the whole redaction/sanitize pipeline. PostToolUse ONLY:
|
|
297
|
+
// a UserPromptSubmit or SessionStart carries no tool output, so the field is
|
|
298
|
+
// dropped there (declared in {@link UNRENDERED_FIELDS}) rather than emitted
|
|
299
|
+
// into a key the host ignores, which reads to a caller as a redaction applied.
|
|
300
|
+
if (kind === EventKind.POST_TOOL && vd.mutated_output !== undefined)
|
|
266
301
|
hookSpecificOutput.updatedToolOutput = vd.mutated_output;
|
|
267
|
-
|
|
302
|
+
// Not on an UNKNOWN kind: `hookEventName` there is the unrecognized native
|
|
303
|
+
// event name, so the adapter has established no channel to write into.
|
|
304
|
+
if (kind !== EventKind.UNKNOWN && vd.additional_context !== undefined)
|
|
268
305
|
hookSpecificOutput.additionalContext = vd.additional_context;
|
|
269
306
|
/** @type {Record<string, unknown>} */
|
|
270
307
|
const out = { hookSpecificOutput };
|
|
@@ -280,6 +317,8 @@ export const claudeAdapter = {
|
|
|
280
317
|
AGENT,
|
|
281
318
|
INTEGRATION_MODE,
|
|
282
319
|
COVERAGE,
|
|
320
|
+
UNRENDERED_FIELDS,
|
|
321
|
+
NATIVE_EVENT_FOR,
|
|
283
322
|
parse,
|
|
284
323
|
render,
|
|
285
324
|
};
|