agent-control-plane-core 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Parse a raw Gemini CLI hook payload into a normalized {@link ToolCallEvent}.
3
+ * Never throws on an unmodelled event type or tool-input field. A payload comes
4
+ * from a hooks-capable Gemini (v0.26.0+) whose `BeforeTool` can pre-empt builtin
5
+ * tools via exit 2. But MCP firing is only medium-confidence (COVERAGE.mcp is
6
+ * UNKNOWN), so an MCP-sourced call — flagged by `mcp_context` or an `mcp_`-named
7
+ * tool — parses non-vetoable until an item-⑤ probe confirms the hook fires.
8
+ * `BeforeAgent` (fires after the user submits a prompt, before planning) maps to
9
+ * `prompt_submit`: no tool, the submitted text folded into `input.prompt`.
10
+ * @param {any} native
11
+ * @returns {ToolCallEvent}
12
+ */
13
+ export function parse(native: any): ToolCallEvent;
14
+ /**
15
+ * Render into Gemini CLI's native external-hook transport. An enforceable deny
16
+ * renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
17
+ * as "same as decision: deny" — it aborts the turn); everything else exits 0
18
+ * with a JSON decision body (or none, when `allow` abstains). `soleGate`
19
+ * (default false) is the same dangerous opt-in as the other adapters: it makes
20
+ * an `allow` emit the real `decision: "allow"` instead of abstaining (tool
21
+ * events only — BeforeAgent documents no allow behavior to opt into).
22
+ * @param {Verdict} verdict
23
+ * @param {ToolCallEvent} event
24
+ * @param {{ soleGate?: boolean }} [options]
25
+ * @returns {NativeResponse}
26
+ */
27
+ export function render(verdict: Verdict, event: ToolCallEvent, { soleGate }?: {
28
+ soleGate?: boolean;
29
+ }): NativeResponse;
30
+ /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
31
+ /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
32
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
33
+ /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
34
+ export const AGENT: "gemini";
35
+ export const INTEGRATION_MODE: "external_hook";
36
+ /**
37
+ * Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `BeforeTool` gates
38
+ * builtins on v0.26+ (COVERED). MCP routing through the same matcher is only
39
+ * MEDIUM-confidence and unproven, subagent firing for a loaded agent is
40
+ * undocumented, and resumed-session behavior has no source — all three are
41
+ * UNKNOWN, held at fail-closed until an item-⑤ probe upgrades them. A guessed ✅
42
+ * here would be a silent fail-open.
43
+ */
44
+ /** @type {import("../control-plane.mjs").CoverageMap} */
45
+ export const COVERAGE: import("../control-plane.mjs").CoverageMap;
46
+ /** Gemini CLI native hook event names (the `hook_event_name` field). */
47
+ export const HookEvent: Readonly<{
48
+ BEFORE_TOOL: "BeforeTool";
49
+ AFTER_TOOL: "AfterTool";
50
+ BEFORE_AGENT: "BeforeAgent";
51
+ }>;
52
+ /**
53
+ * Adapter-scoped native-builtin → canonical tool aliases, applied ONLY when a
54
+ * call classifies as BUILTIN. These names are too generic for the global
55
+ * {@link TOOL_ALIASES} (an MCP server could export a `read_file`), but Gemini
56
+ * CLI removes the ambiguity at parse time: every MCP tool is unconditionally
57
+ * registered — and surfaced in hook payloads — under its fully qualified
58
+ * `mcp_{server}_{tool}` name (gemini-cli docs/tools/mcp-server.md), so a bare
59
+ * builtin name in `tool_name` can only be the builtin. The builtin's native
60
+ * input fields still pass through verbatim (e.g. read_file's `absolute_path`,
61
+ * not Read's `file_path`) — `meta.native_tool` tells a consumer which field
62
+ * dialect to expect. Targets are pinned to {@link MODELED_TOOLS} at import, and
63
+ * every entry must be witnessed by a gemini conformance fixture
64
+ * (`assertToolAliasesCovered`).
65
+ * @type {Readonly<Record<string, string>>}
66
+ */
67
+ export const GEMINI_TOOL_ALIASES: Readonly<Record<string, string>>;
68
+ /** @type {import("../control-plane.mjs").Adapter} */
69
+ export const geminiAdapter: import("../control-plane.mjs").Adapter;
70
+ export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
71
+ export type Verdict = import("../control-plane.mjs").Verdict;
72
+ export type EventMeta = import("../control-plane.mjs").EventMeta;
73
+ export type NativeResponse = import("../control-plane.mjs").NativeResponse;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
3
+ * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
4
+ * {@link CALL_CLASSES} (no class missing, none unknown) and every value is a
5
+ * valid coverage status. This is the SSOT gate — adding a new call class to the
6
+ * contract fails every adapter until it declares that class, so a coverage hole
7
+ * can't be introduced by omission.
8
+ * @param {import("./control-plane.mjs").Adapter} adapter
9
+ * @param {any} assert node:assert/strict (injected)
10
+ */
11
+ export function assertCoverageWellFormed(adapter: import("./control-plane.mjs").Adapter, assert: any): void;
12
+ /**
13
+ * Assert every tool alias — the global {@link TOOL_ALIASES} entries AND each
14
+ * adapter-scoped alias map — is WITNESSED by a golden fixture: a case whose
15
+ * native tool name equals the alias key and whose normalized `event.tool`
16
+ * equals the canonical target. This is what ties the alias SSOTs to the
17
+ * fixtures: an alias added without a golden payload that exercises it fails
18
+ * here, so an alias "a new judge keys on" can't ship unproven. A global alias
19
+ * may be witnessed by any agent's fixtures; an adapter-scoped alias must be
20
+ * witnessed by THAT agent's fixtures (another agent's payload proves nothing
21
+ * about the scoping adapter's parse). Also cross-checks each witnessed pair:
22
+ * `event.tool` must be either the global {@link canonicalTool} result or the
23
+ * owning adapter's scoped target, so a fixture that preserved `native_tool` but
24
+ * mis-normalized `event.tool` is caught — including a scoped alias applied by
25
+ * the wrong agent's fixtures.
26
+ * @param {any[]} fixturesList the golden fixtures for every shipped adapter
27
+ * @param {any} assert node:assert/strict (injected)
28
+ * @param {Record<string, Record<string, string>>} [adapterAliases] agent id → that adapter's scoped alias map (e.g. `{ gemini: GEMINI_TOOL_ALIASES }`)
29
+ */
30
+ export function assertToolAliasesCovered(fixturesList: any[], assert: any, adapterAliases?: Record<string, Record<string, string>>): void;
31
+ /**
32
+ * The control-plane conformance harness.
33
+ *
34
+ * Any adapter — the reference claude one, codex, or a future
35
+ * cursor/cline/gemini-cli/aider translator — must pass this against its own
36
+ * golden fixtures. It pins the two directions of the contract:
37
+ *
38
+ * 1. parse is golden: adapter.parse(native) deep-equals the fixture's
39
+ * normalized `event` (and never throws — an adapter that threw would fail
40
+ * here rather than in production).
41
+ * 2. render is golden: for each verdict scenario, adapter.render(verdict,
42
+ * parsedEvent) deep-equals the fixture's native response. An `allow` with
43
+ * the unmutated input renders to the agent's native allow; deny/ask/mutation
44
+ * each render to their native shape.
45
+ * 3. non-vacuity: the fixture set collectively renders an allow, a deny, an
46
+ * ask, AND a mutated_input, so a suite can't pass while silently skipping a
47
+ * decision the contract requires every adapter to express.
48
+ * 4. enforcement honesty: an enforceable deny (`rendered.enforced === true`)
49
+ * MUST carry a real block signal — a non-zero `exit_code` — not just a JSON
50
+ * body the agent is free to ignore. At least one enforced deny must appear,
51
+ * so the honesty check is never vacuous.
52
+ * 5. non-vetoable honesty: when the parsed event's `this_call_vetoable` is
53
+ * false, EVERY render for that case must be `enforced === false` — a
54
+ * guardrail that cannot veto this call must never render as if it did.
55
+ * 6. allow = abstain: by default (no `soleGate` opt-in) every `allow` render
56
+ * is `enforced === false` AND `exit_code === 0` — an "I have no objection"
57
+ * verdict never renders as a block, on any adapter. At least one `allow`
58
+ * must be rendered, so this is never vacuous.
59
+ * 7. coverage honesty: the adapter's COVERAGE matrix is well-formed (item ③),
60
+ * and a fixture case tagged with a `call_class` whose coverage does NOT
61
+ * permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
62
+ * `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
63
+ * while parsing its calls as vetoable.
64
+ *
65
+ * `assert` is injected (node:assert/strict) so the harness stays test-framework
66
+ * neutral; it throws on the first mismatch. Returns a summary the caller can
67
+ * assert further on.
68
+ *
69
+ * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
70
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, coverageClassesChecked: Set<string> }}
71
+ */
72
+ export function runAdapterConformance({ adapter, fixtures, assert }: {
73
+ adapter: import("./control-plane.mjs").Adapter;
74
+ fixtures: any;
75
+ assert: any;
76
+ }): {
77
+ cases: number;
78
+ renders: number;
79
+ decisionsSeen: Set<string>;
80
+ mutationSeen: boolean;
81
+ enforcedDenySeen: boolean;
82
+ coverageClassesChecked: Set<string>;
83
+ };
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Load-bearing use of MODELED_TOOLS: assert every alias target is a modeled
3
+ * tool, so the alias SSOT can never normalize a native name onto a canon the
4
+ * contract doesn't model. Throws (fail loud) on the first bad target — a typo'd
5
+ * target is a bug, not a silent passthrough. Called at import against
6
+ * {@link TOOL_ALIASES}, and by adapters against their adapter-scoped alias
7
+ * maps; exported so a test can drive both branches.
8
+ * @param {Record<string, string>} aliases
9
+ */
10
+ export function assertAliasTargetsModeled(aliases: Record<string, string>): void;
11
+ /**
12
+ * Canonicalize a native tool name to its {@link MODELED_TOOLS} equivalent, or
13
+ * return it UNCHANGED when it is not a known alias — an unknown tool is never
14
+ * silently reclassified. `null` (a non-tool event) passes through as `null`.
15
+ * @param {string|null} tool native tool name
16
+ * @returns {string|null} the canonical name, or the native name verbatim
17
+ */
18
+ export function canonicalTool(tool: string | null): string | null;
19
+ /**
20
+ * Whether a coverage status PERMITS an adapter to mark a call in that class
21
+ * `this_call_vetoable: true`. Only a hook confirmed to fire does: COVERED, or
22
+ * PARTIAL (for the tools in its covered subset). UNCOVERED and UNKNOWN both
23
+ * forbid it — an unknown is fail-closed to uncovered, which is the whole point
24
+ * of the matrix. Throws on an unrecognized status (fail loud — a typo must not
25
+ * quietly read as "permitted").
26
+ * @param {string} status a {@link CoverageStatus} value
27
+ * @returns {boolean}
28
+ */
29
+ export function coverageAllowsVeto(status: string): boolean;
30
+ /**
31
+ * True when `status` is a recognized {@link CoverageStatus} value.
32
+ * @param {unknown} status
33
+ * @returns {boolean}
34
+ */
35
+ export function isCoverageStatus(status: unknown): boolean;
36
+ /**
37
+ * Classify a tool call into a {@link CallClass} from what a single pre-tool
38
+ * payload reveals. Only MCP is reliably detectable here — from the tool NAME
39
+ * (`mcp__server__tool` / `mcp_server_tool`) or an explicit `mcp_context` object
40
+ * on the native payload. Subagent- and resumed-session calls carry no universal
41
+ * signal in a lone pre-tool event (detecting them needs the live probe of
42
+ * item ⑤), so they are NOT distinguished here and fall through to BUILTIN — an
43
+ * adapter whose host leaves those classes un-gated relies on the sandbox, not
44
+ * this classifier. An adapter feeds the result into `coverageAllowsVeto(this.
45
+ * COVERAGE[class])` so a call in an uncovered/unknown class parses non-vetoable.
46
+ * @param {string|null} tool tool name (null for non-tool events)
47
+ * @param {Record<string, unknown>} [native] the raw native payload, for `mcp_context`
48
+ * @returns {string} a {@link CallClass} value
49
+ */
50
+ export function classifyCallClass(tool: string | null, native?: Record<string, unknown>): string;
51
+ /**
52
+ * @typedef {object} EventMeta
53
+ * @property {string} agent producing agent id ("claude", "codex", …)
54
+ * @property {string} native_event original native event name, preserved verbatim
55
+ * @property {string} [native_tool] original native tool name, preserved verbatim when `event.tool` was canonicalized (present iff the event carries a tool)
56
+ * @property {"external_hook"|"in_process"|"observe_only"} integration_mode how the guardrail attaches
57
+ * @property {boolean} primary_gate_present the agent's own native gate already ran (⇒ the monitor is a SECOND opinion; the LLM call can be skipped when the native gate already blocked)
58
+ * @property {string} [session_id]
59
+ * @property {string} [cwd]
60
+ * @property {string} [permission_mode]
61
+ * @property {string} [transcript_path]
62
+ * @property {Record<string, unknown>} passthrough unmodelled native top-level fields, verbatim
63
+ */
64
+ /**
65
+ * The result of rendering a {@link Verdict} for a specific agent's transport —
66
+ * the ATTACH mechanism, not the decision. A caller applies whichever channels
67
+ * are present: write `stdout`, exit with `exit_code`, or `throw`.
68
+ * @typedef {object} NativeResponse
69
+ * @property {"external_hook"|"in_process"|"observe_only"} transport
70
+ * @property {number} exit_code process exit code carrying the decision (0 = proceed)
71
+ * @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
72
+ * @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
73
+ */
74
+ /**
75
+ * A normalized, agent-agnostic view of one agent event.
76
+ * @typedef {object} ToolCallEvent
77
+ * @property {number} schema_version stamped {@link SCHEMA_VERSION}
78
+ * @property {"pre_tool"|"post_tool"|"prompt_submit"|"session_start"|"unknown"} event
79
+ * @property {string|null} tool CANONICAL tool name — a native alias (e.g. Gemini's `run_shell_command`) is normalized to its {@link MODELED_TOOLS} canon (`Bash`); the raw native name is preserved on `meta.native_tool`. An unknown tool passes through verbatim. null for prompt/session events.
80
+ * @property {Record<string, unknown>} input passthrough tool input; a submitted prompt is folded into `input.prompt`
81
+ * @property {unknown} [response] tool output, post_tool only (string or structured), verbatim
82
+ * @property {boolean} this_call_vetoable false ⇒ the guardrail cannot veto THIS call; a monitor must auto-degrade deny to notify, and any render of it stays advisory (never `enforced`)
83
+ * @property {EventMeta} meta
84
+ */
85
+ /**
86
+ * A normalized guardrail decision.
87
+ * @typedef {object} Verdict
88
+ * @property {"allow"|"deny"|"ask"} decision
89
+ * @property {Record<string, unknown>} [mutated_input] replacement tool input (pre_tool)
90
+ * @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).
91
+ * @property {string} [additional_context] extra context to splice into the agent's stream
92
+ * @property {string} [reason] human-readable rationale (shown on deny/ask)
93
+ */
94
+ /**
95
+ * The translator for one agent's protocol. `parse` maps a native event to a
96
+ * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
97
+ * integration mode / enforcement flags on `meta`); `render` maps a {@link Verdict}
98
+ * to that agent's native transport ({@link NativeResponse}), not just a JSON body.
99
+ * @typedef {object} Adapter
100
+ * @property {string} AGENT
101
+ * @property {"external_hook"|"in_process"|"observe_only"} INTEGRATION_MODE
102
+ * @property {Record<string, "covered"|"partial"|"uncovered"|"unknown">} COVERAGE per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
103
+ * @property {(native: any) => ToolCallEvent} parse
104
+ * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
105
+ */
106
+ /**
107
+ * Build a normalized {@link ToolCallEvent}, stamping the schema version. Pure —
108
+ * adapters pass already-normalized parts. `response` is omitted unless defined
109
+ * so a pre_tool event has no `response` key at all.
110
+ * @param {{ event: string, tool: string|null, input: Record<string, unknown>, response?: unknown, this_call_vetoable: boolean, meta: EventMeta }} parts
111
+ * @returns {ToolCallEvent}
112
+ */
113
+ export function makeEvent({ event, tool, input, response, this_call_vetoable, meta, }: {
114
+ event: string;
115
+ tool: string | null;
116
+ input: Record<string, unknown>;
117
+ response?: unknown;
118
+ this_call_vetoable: boolean;
119
+ meta: EventMeta;
120
+ }): ToolCallEvent;
121
+ /**
122
+ * Validate and normalize a {@link Verdict}. The decision must be one of
123
+ * allow/deny/ask — a verdict is produced internally, so an out-of-range
124
+ * decision is a bug and throws (fail loud), unlike the pass-through tolerance
125
+ * `parse` extends to untrusted upstream events. Returns a fresh object carrying
126
+ * only the modeled optional fields that are present; never mutates its input.
127
+ * @param {Verdict} verdict
128
+ * @returns {Verdict}
129
+ */
130
+ export function normalizeVerdict(verdict: Verdict): Verdict;
131
+ /**
132
+ * Harden an UNTRUSTED {@link Verdict} — one authored by a separate
133
+ * monitor/judge process (e.g. claude-guard's scrub-monitor-response) — before
134
+ * it is rendered. Two defenses:
135
+ *
136
+ * 1. Decision clamp (fail-to-ask): a `decision` outside allow/deny/ask
137
+ * becomes `"ask"`, and the clamp is made observable by appending a
138
+ * bracketed note to `reason` naming the rejected value — a malformed
139
+ * monitor answer escalates to a human instead of throwing (the internal
140
+ * strictness of {@link normalizeVerdict}) or silently allowing.
141
+ * 2. Text scrubbing: the caller-supplied `sanitizeText` runs over every
142
+ * monitor-authored PROSE field present — `reason` and
143
+ * `additional_context` (a non-string value in either is dropped; prose
144
+ * channels carry strings). `mutated_input`/`mutated_output` are NOT
145
+ * sanitized: they are data channels (replacement tool input/output the
146
+ * guardrail computed, often deliberately containing the very bytes a text
147
+ * scrubber would mangle), not monitor-authored prose. `mutated_input` is
148
+ * shape-checked only (a non-object is dropped — see the inline note);
149
+ * `mutated_output` is carried verbatim.
150
+ *
151
+ * `sanitizeText` is injected so this module stays dependency-free. It must be
152
+ * a function and must return a string — a sanitizer that eats the value is a
153
+ * bug, so a non-string return throws. Returns a fresh normalized verdict;
154
+ * never mutates its input.
155
+ * @param {unknown} verdict the untrusted verdict object
156
+ * @param {(text: string) => string} sanitizeText
157
+ * @returns {Verdict}
158
+ */
159
+ export function sanitizeVerdict(verdict: unknown, sanitizeText: (text: string) => string): Verdict;
160
+ /**
161
+ * Return a shallow copy of `native` with the `consumed` keys removed — the
162
+ * unmodelled remainder an adapter carries in `meta.passthrough` so an additive
163
+ * upstream field survives instead of being silently dropped.
164
+ * @param {Record<string, unknown>} native
165
+ * @param {Set<string>} consumed
166
+ * @returns {Record<string, unknown>}
167
+ */
168
+ export function collectPassthrough(native: Record<string, unknown>, consumed: Set<string>): Record<string, unknown>;
169
+ /**
170
+ * The value if it is a plain (non-array) object, else `{}`.
171
+ * @param {unknown} value
172
+ * @returns {Record<string, unknown>}
173
+ */
174
+ export function asObject(value: unknown): Record<string, unknown>;
175
+ /**
176
+ * The value if it is a string, else `null`.
177
+ * @param {unknown} value
178
+ * @returns {string|null}
179
+ */
180
+ export function asStringOrNull(value: unknown): string | null;
181
+ /**
182
+ * The value if it is a string, else `fallback`.
183
+ * @param {unknown} value
184
+ * @param {string} fallback
185
+ * @returns {string}
186
+ */
187
+ export function asString(value: unknown, fallback: string): string;
188
+ /**
189
+ * Assemble a {@link NativeResponse}, omitting an absent `stdout` so a pure
190
+ * exit-code transport carries no `stdout` key.
191
+ * @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown }} parts
192
+ * @returns {NativeResponse}
193
+ */
194
+ export function nativeResponse({ transport, exit_code, enforced, stdout }: {
195
+ transport: string;
196
+ exit_code: number;
197
+ enforced: boolean;
198
+ stdout?: unknown;
199
+ }): NativeResponse;
200
+ /**
201
+ * The vendor-neutral control-plane contract.
202
+ *
203
+ * This is the published seam between an agent's native hook/tool-call protocol
204
+ * and claude-guard's guardrails (monitor, deny-match, redaction, sanitizers).
205
+ * It defines TWO normalized shapes — {@link ToolCallEvent} (what an agent is
206
+ * about to / just did) and {@link Verdict} (what the guardrail decided) — plus
207
+ * the {@link Adapter} pair that translates a specific agent's protocol to and
208
+ * from them. Every downstream consumer imports THESE types, never an agent's
209
+ * raw hook JSON; the agent-specific field names live only in that agent's
210
+ * adapter (see adapter-claude.mjs / adapter-codex.mjs).
211
+ *
212
+ * DRIFT DISCIPLINE — the reason the seam earns its keep. An agent protocol
213
+ * drifts additively and independently (N agents, N release cadences). So an
214
+ * adapter's `parse` MUST NOT throw on an event type or tool-input field it does
215
+ * not model: the unmodelled remainder is carried through verbatim (an unknown
216
+ * event becomes {@link EventKind.UNKNOWN} with its native name preserved; extra
217
+ * top-level fields land in `meta.passthrough`). An additive upstream change is
218
+ * then a no-op here, not an outage. The core models ONLY the stable middle:
219
+ * four event kinds, three decisions, and the Bash/Edit/Write/Read/WebFetch tool
220
+ * inputs. Exotic per-agent tools pass through untouched.
221
+ *
222
+ * Dependency-free on purpose, so a fail-closed hook can import it without
223
+ * dragging in eager config-file reads.
224
+ *
225
+ * VERSIONING — this module IS the frozen contract (its own SSOT, no parallel
226
+ * schema file to drift). Adapters and guardrail consumers are built against it
227
+ * in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
228
+ * {@link MODELED_TOOLS}, and {@link TOOL_ALIASES} are frozen, and
229
+ * {@link SCHEMA_VERSION} / {@link CONTROL_PLANE_SCHEMA} are pinned
230
+ * (control-plane.test.mjs asserts the exact values, so any shape change is a
231
+ * deliberate, reviewed version bump). ADDING to the contract (a new optional
232
+ * field, a new modeled tool, a new tool alias) is backward-compatible and stays
233
+ * at v1; RENAMING or REMOVING a field, or changing a decision/event vocabulary,
234
+ * is breaking and bumps the version.
235
+ */
236
+ /** Wire identifier for this schema version; bump on a breaking shape change. */
237
+ export const CONTROL_PLANE_SCHEMA: "control-plane/v1";
238
+ /** Numeric schema version stamped onto every {@link ToolCallEvent}. */
239
+ export const SCHEMA_VERSION: 1;
240
+ /**
241
+ * The normalized event kinds the core models. A native event that maps to none
242
+ * of these is carried as {@link EventKind.UNKNOWN} with its native name kept in
243
+ * `meta.native_event`.
244
+ */
245
+ export const EventKind: Readonly<{
246
+ PRE_TOOL: "pre_tool";
247
+ POST_TOOL: "post_tool";
248
+ PROMPT_SUBMIT: "prompt_submit";
249
+ SESSION_START: "session_start";
250
+ UNKNOWN: "unknown";
251
+ }>;
252
+ /** The normalized verdict decisions a guardrail can return. */
253
+ export const Decision: Readonly<{
254
+ ALLOW: "allow";
255
+ DENY: "deny";
256
+ ASK: "ask";
257
+ }>;
258
+ /**
259
+ * Tools whose input shape the core models. Every other tool passes through
260
+ * unmodelled — its input object is preserved verbatim and no field is required.
261
+ */
262
+ export const MODELED_TOOLS: readonly string[];
263
+ /**
264
+ * Native-tool-name → canonical {@link MODELED_TOOLS} name. The SSOT that lets a
265
+ * judge key on `event.tool` without a per-agent lookup: an agent whose native
266
+ * name for the shell tool is `run_shell_command` (Gemini) is normalized to the
267
+ * same `Bash` a Claude/Codex/Amp payload already carries, with the raw native
268
+ * name preserved on `meta.native_tool`. Only globally-unambiguous native
269
+ * BUILTIN names belong here — a name that also occurs as an MCP tool (e.g. a
270
+ * `read_file` MCP server) must NOT be aliased, or the normalization would
271
+ * reclassify an unrelated tool. An adapter whose host makes such a name
272
+ * unambiguous at parse time (Gemini's `mcp_{server}_{tool}` FQN discipline)
273
+ * may carry its own ADAPTER-SCOPED alias map instead (see the gemini adapter's
274
+ * `GEMINI_TOOL_ALIASES`), applied only to calls classified BUILTIN. An unknown
275
+ * tool is never in the map, so it passes through verbatim (see
276
+ * {@link canonicalTool}). Every target is a
277
+ * {@link MODELED_TOOLS} member (enforced at load below), and every entry is
278
+ * witnessed by a conformance fixture (see `assertToolAliasesCovered`), so an
279
+ * alias cannot be added without a golden payload that exercises it.
280
+ */
281
+ export const TOOL_ALIASES: Readonly<{
282
+ run_shell_command: "Bash";
283
+ }>;
284
+ /**
285
+ * How a guardrail ATTACHES to an agent — the transport, kept separate from the
286
+ * normalized decision so the judge/Verdict core stays transport-agnostic:
287
+ * - EXTERNAL_HOOK: the agent shells out to a hook (stdin JSON → deny body /
288
+ * exit code). The decision can pre-empt the tool call.
289
+ * - IN_PROCESS: an embedded analyzer or a driven confirm/reject loop over the
290
+ * agent's own API. Can pre-empt.
291
+ * - OBSERVE_ONLY: transcript reader; cannot pre-empt, so its Verdict is
292
+ * advisory — the hard stop is the sandbox, not the hook.
293
+ */
294
+ export const IntegrationMode: Readonly<{
295
+ EXTERNAL_HOOK: "external_hook";
296
+ IN_PROCESS: "in_process";
297
+ OBSERVE_ONLY: "observe_only";
298
+ }>;
299
+ /**
300
+ * The CLASSES of tool call a host may or may not route through its pre-tool
301
+ * guardrail hook. Whether the hook fires is orthogonal to the tool's INPUT shape
302
+ * (which {@link MODELED_TOOLS} covers) — a host can gate its builtin tools while
303
+ * an MCP-sourced or subagent-spawned call of the SAME tool never reaches the
304
+ * hook. Each adapter declares a coverage status per class (see the adapter
305
+ * `COVERAGE` maps and `docs/hook-coverage-matrix.md`); the conformance harness
306
+ * enforces that an uncovered class is never marked `this_call_vetoable`.
307
+ */
308
+ export const CallClass: Readonly<{
309
+ BUILTIN: "builtin";
310
+ MCP: "mcp";
311
+ SUBAGENT: "subagent";
312
+ RESUMED: "resumed";
313
+ }>;
314
+ /** Canonical ordered list of {@link CallClass} values — the SSOT every adapter's `COVERAGE` must classify exactly. */
315
+ export const CALL_CLASSES: readonly ("builtin" | "mcp" | "subagent" | "resumed")[];
316
+ /**
317
+ * Whether a host's pre-tool hook fires for a {@link CallClass} — the machine
318
+ * form of the ✅/❌/⚠️/❓ matrix:
319
+ * - COVERED (✅): the hook fires for every tool in the class.
320
+ * - PARTIAL (⚠️): it fires for only SOME tools in the class (e.g. Codex gates
321
+ * Bash but not other builtins) — a call in the covered subset may be vetoed.
322
+ * - UNCOVERED (❌): it never fires; the call reaches the tool un-gated.
323
+ * - UNKNOWN (❓): undocumented — NOT yet proven to fire by a live probe.
324
+ * The doctrine (`docs/hook-coverage-matrix.md`): an UNKNOWN is treated as
325
+ * UNCOVERED until a probe proves ✅, because a guessed ✅ is a silent fail-open.
326
+ */
327
+ export const CoverageStatus: Readonly<{
328
+ COVERED: "covered";
329
+ PARTIAL: "partial";
330
+ UNCOVERED: "uncovered";
331
+ UNKNOWN: "unknown";
332
+ }>;
333
+ export type EventMeta = {
334
+ /**
335
+ * producing agent id ("claude", "codex", …)
336
+ */
337
+ agent: string;
338
+ /**
339
+ * original native event name, preserved verbatim
340
+ */
341
+ native_event: string;
342
+ /**
343
+ * original native tool name, preserved verbatim when `event.tool` was canonicalized (present iff the event carries a tool)
344
+ */
345
+ native_tool?: string | undefined;
346
+ /**
347
+ * how the guardrail attaches
348
+ */
349
+ integration_mode: "external_hook" | "in_process" | "observe_only";
350
+ /**
351
+ * the agent's own native gate already ran (⇒ the monitor is a SECOND opinion; the LLM call can be skipped when the native gate already blocked)
352
+ */
353
+ primary_gate_present: boolean;
354
+ session_id?: string | undefined;
355
+ cwd?: string | undefined;
356
+ permission_mode?: string | undefined;
357
+ transcript_path?: string | undefined;
358
+ /**
359
+ * unmodelled native top-level fields, verbatim
360
+ */
361
+ passthrough: Record<string, unknown>;
362
+ };
363
+ /**
364
+ * The result of rendering a {@link Verdict} for a specific agent's transport —
365
+ * the ATTACH mechanism, not the decision. A caller applies whichever channels
366
+ * are present: write `stdout`, exit with `exit_code`, or `throw`.
367
+ */
368
+ export type NativeResponse = {
369
+ transport: "external_hook" | "in_process" | "observe_only";
370
+ /**
371
+ * process exit code carrying the decision (0 = proceed)
372
+ */
373
+ exit_code: number;
374
+ /**
375
+ * whether THIS render actually blocks (false ⇒ advisory only)
376
+ */
377
+ enforced: boolean;
378
+ /**
379
+ * native JSON body to write to stdout, when the transport uses one
380
+ */
381
+ stdout?: unknown;
382
+ };
383
+ /**
384
+ * A normalized, agent-agnostic view of one agent event.
385
+ */
386
+ export type ToolCallEvent = {
387
+ /**
388
+ * stamped {@link SCHEMA_VERSION}
389
+ */
390
+ schema_version: number;
391
+ event: "pre_tool" | "post_tool" | "prompt_submit" | "session_start" | "unknown";
392
+ /**
393
+ * CANONICAL tool name — a native alias (e.g. Gemini's `run_shell_command`) is normalized to its {@link MODELED_TOOLS} canon (`Bash`); the raw native name is preserved on `meta.native_tool`. An unknown tool passes through verbatim. null for prompt/session events.
394
+ */
395
+ tool: string | null;
396
+ /**
397
+ * passthrough tool input; a submitted prompt is folded into `input.prompt`
398
+ */
399
+ input: Record<string, unknown>;
400
+ /**
401
+ * tool output, post_tool only (string or structured), verbatim
402
+ */
403
+ response?: unknown;
404
+ /**
405
+ * false ⇒ the guardrail cannot veto THIS call; a monitor must auto-degrade deny to notify, and any render of it stays advisory (never `enforced`)
406
+ */
407
+ this_call_vetoable: boolean;
408
+ meta: EventMeta;
409
+ };
410
+ /**
411
+ * A normalized guardrail decision.
412
+ */
413
+ export type Verdict = {
414
+ decision: "allow" | "deny" | "ask";
415
+ /**
416
+ * replacement tool input (pre_tool)
417
+ */
418
+ mutated_input?: Record<string, unknown> | undefined;
419
+ /**
420
+ * 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).
421
+ */
422
+ mutated_output?: unknown;
423
+ /**
424
+ * extra context to splice into the agent's stream
425
+ */
426
+ additional_context?: string | undefined;
427
+ /**
428
+ * human-readable rationale (shown on deny/ask)
429
+ */
430
+ reason?: string | undefined;
431
+ };
432
+ /**
433
+ * The translator for one agent's protocol. `parse` maps a native event to a
434
+ * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
435
+ * integration mode / enforcement flags on `meta`); `render` maps a {@link Verdict}
436
+ * to that agent's native transport ({@link NativeResponse}), not just a JSON body.
437
+ */
438
+ export type Adapter = {
439
+ AGENT: string;
440
+ INTEGRATION_MODE: "external_hook" | "in_process" | "observe_only";
441
+ /**
442
+ * per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
443
+ */
444
+ COVERAGE: Record<string, "covered" | "partial" | "uncovered" | "unknown">;
445
+ parse: (native: any) => ToolCallEvent;
446
+ render: (verdict: Verdict, event: ToolCallEvent, options?: {
447
+ soleGate?: boolean;
448
+ }) => NativeResponse;
449
+ };
450
+ export type CoverageStatusValue = "covered" | "partial" | "uncovered" | "unknown";
451
+ /**
452
+ * a per-{@link CallClass} coverage map (an adapter's `COVERAGE`)
453
+ */
454
+ export type CoverageMap = Record<string, CoverageStatusValue>;
@@ -0,0 +1,7 @@
1
+ export * from "./control-plane.mjs";
2
+ export { codexAdapter } from "./adapters/codex.mjs";
3
+ export { ampAdapter } from "./adapters/amp.mjs";
4
+ export { claudeAdapter, HookEvent } from "./adapters/claude.mjs";
5
+ export { geminiAdapter, GEMINI_TOOL_ALIASES } from "./adapters/gemini.mjs";
6
+ export { ADAPTERS, AGENT_IDS, adapterFor, assertRegistryConsistent } from "./registry.mjs";
7
+ export { runAdapterConformance, assertCoverageWellFormed, assertToolAliasesCovered } from "./conformance.mjs";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Assert every {@link ADAPTERS} key equals its adapter's own `AGENT`. A registry
3
+ * whose id disagrees with the adapter it resolves is a latent selector/dispatcher
4
+ * split, so this throws (fail loud) rather than resolving a surprising adapter.
5
+ * Called at import against {@link ADAPTERS}; exported so the conformance suite can
6
+ * drive it against a constructed-bad map too.
7
+ * @param {Record<string, Adapter>} adapters
8
+ */
9
+ export function assertRegistryConsistent(adapters: Record<string, Adapter>): void;
10
+ /**
11
+ * Resolve an agent id to its adapter. Throws (fail loud) on an unknown id — a
12
+ * selection signal that names no shipped adapter is a misconfiguration, not a
13
+ * reason to silently fall back to a default agent (which would run the wrong
14
+ * translator against the payloads and mis-render every verdict).
15
+ * @param {string} id agent id (a {@link AGENT_IDS} member)
16
+ * @returns {Adapter}
17
+ */
18
+ export function adapterFor(id: string): Adapter;
19
+ /** @typedef {import("./control-plane.mjs").Adapter} Adapter */
20
+ /**
21
+ * Every shipped adapter. Adding an adapter to the package means adding it here —
22
+ * the registry is the enrollment point, and {@link assertRegistryConsistent}
23
+ * (run at import and in the conformance suite) fails if a key does not equal its
24
+ * adapter's `AGENT`, so a typo'd or mismatched id cannot ship.
25
+ * @type {Readonly<Record<string, Adapter>>}
26
+ */
27
+ export const ADAPTERS: Readonly<Record<string, Adapter>>;
28
+ /**
29
+ * The canonical set of agent ids, frozen — the closed vocabulary a launch
30
+ * signal is validated against. Derived from {@link ADAPTERS} so it cannot list
31
+ * an id the registry does not resolve.
32
+ * @type {readonly string[]}
33
+ */
34
+ export const AGENT_IDS: readonly string[];
35
+ export type Adapter = import("./control-plane.mjs").Adapter;