agent-control-plane-core 0.1.0 → 0.2.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.
@@ -1,3 +1,107 @@
1
+ import {
2
+ CALL_CLASSES,
3
+ TOOL_ALIASES,
4
+ canonicalTool,
5
+ coverageAllowsVeto,
6
+ isCoverageStatus,
7
+ } from "./control-plane.mjs";
8
+
9
+ /**
10
+ * Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
11
+ * hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
12
+ * {@link CALL_CLASSES} (no class missing, none unknown) and every value is a
13
+ * valid coverage status. This is the SSOT gate — adding a new call class to the
14
+ * contract fails every adapter until it declares that class, so a coverage hole
15
+ * can't be introduced by omission.
16
+ * @param {import("./control-plane.mjs").Adapter} adapter
17
+ * @param {any} assert node:assert/strict (injected)
18
+ */
19
+ export function assertCoverageWellFormed(adapter, assert) {
20
+ assert.ok(
21
+ adapter.COVERAGE && typeof adapter.COVERAGE === "object",
22
+ `adapter '${adapter.AGENT}' declares no COVERAGE matrix`,
23
+ );
24
+ const declared = Object.keys(adapter.COVERAGE).sort();
25
+ assert.deepEqual(
26
+ declared,
27
+ [...CALL_CLASSES].sort(),
28
+ `adapter '${adapter.AGENT}' COVERAGE must classify exactly the call classes ${JSON.stringify([...CALL_CLASSES])}`,
29
+ );
30
+ for (const cls of CALL_CLASSES) {
31
+ assert.ok(
32
+ isCoverageStatus(adapter.COVERAGE[cls]),
33
+ `adapter '${adapter.AGENT}' COVERAGE.${cls} is not a valid coverage status: ${JSON.stringify(adapter.COVERAGE[cls])}`,
34
+ );
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Assert every tool alias — the global {@link TOOL_ALIASES} entries AND each
40
+ * adapter-scoped alias map — is WITNESSED by a golden fixture: a case whose
41
+ * native tool name equals the alias key and whose normalized `event.tool`
42
+ * equals the canonical target. This is what ties the alias SSOTs to the
43
+ * fixtures: an alias added without a golden payload that exercises it fails
44
+ * here, so an alias "a new judge keys on" can't ship unproven. A global alias
45
+ * may be witnessed by any agent's fixtures; an adapter-scoped alias must be
46
+ * witnessed by THAT agent's fixtures (another agent's payload proves nothing
47
+ * about the scoping adapter's parse). Also cross-checks each witnessed pair:
48
+ * `event.tool` must be either the global {@link canonicalTool} result or the
49
+ * owning adapter's scoped target, so a fixture that preserved `native_tool` but
50
+ * mis-normalized `event.tool` is caught — including a scoped alias applied by
51
+ * the wrong agent's fixtures.
52
+ * @param {any[]} fixturesList the golden fixtures for every shipped adapter
53
+ * @param {any} assert node:assert/strict (injected)
54
+ * @param {Record<string, Record<string, string>>} [adapterAliases] agent id → that adapter's scoped alias map (e.g. `{ gemini: GEMINI_TOOL_ALIASES }`)
55
+ */
56
+ export function assertToolAliasesCovered(
57
+ fixturesList,
58
+ assert,
59
+ adapterAliases = {},
60
+ ) {
61
+ /** @type {Map<string, Map<string, Set<string>>>} agent → native tool name → canonical names seen */
62
+ const witnessedByAgent = new Map();
63
+ for (const fixtures of fixturesList) {
64
+ let witnessed = witnessedByAgent.get(fixtures.agent);
65
+ if (witnessed === undefined)
66
+ witnessedByAgent.set(fixtures.agent, (witnessed = new Map()));
67
+ const scopedMap = adapterAliases[fixtures.agent] ?? {};
68
+ for (const testCase of fixtures.cases) {
69
+ const nativeTool = testCase.event?.meta?.native_tool;
70
+ if (typeof nativeTool !== "string") continue;
71
+ const canon = testCase.event.tool;
72
+ const allowed = new Set([canonicalTool(nativeTool)]);
73
+ if (scopedMap[nativeTool] !== undefined)
74
+ allowed.add(scopedMap[nativeTool]);
75
+ assert.ok(
76
+ allowed.has(canon),
77
+ `fixture '${testCase.name}' (${fixtures.agent}): native_tool ${JSON.stringify(nativeTool)} normalized to ${JSON.stringify(canon)}, but only ${JSON.stringify([...allowed])} are valid canonicalizations for this agent`,
78
+ );
79
+ let canons = witnessed.get(nativeTool);
80
+ if (canons === undefined) witnessed.set(nativeTool, (canons = new Set()));
81
+ canons.add(canon);
82
+ }
83
+ }
84
+ /** @type {(nativeName: string, canonical: string) => boolean} */
85
+ const witnessedAnywhere = (nativeName, canonical) =>
86
+ [...witnessedByAgent.values()].some((w) =>
87
+ w.get(nativeName)?.has(canonical),
88
+ );
89
+ for (const [nativeName, canonical] of Object.entries(TOOL_ALIASES)) {
90
+ assert.ok(
91
+ witnessedAnywhere(nativeName, canonical),
92
+ `tool alias ${JSON.stringify(nativeName)} -> ${JSON.stringify(canonical)} is not witnessed by any conformance fixture — add a golden case whose native tool is ${JSON.stringify(nativeName)}`,
93
+ );
94
+ }
95
+ for (const [agent, scopedMap] of Object.entries(adapterAliases)) {
96
+ for (const [nativeName, canonical] of Object.entries(scopedMap)) {
97
+ assert.ok(
98
+ witnessedByAgent.get(agent)?.get(nativeName)?.has(canonical),
99
+ `adapter-scoped tool alias ${JSON.stringify(nativeName)} -> ${JSON.stringify(canonical)} (${agent}) is not witnessed by a '${agent}' conformance fixture — add a golden ${agent} case whose native tool is ${JSON.stringify(nativeName)}`,
100
+ );
101
+ }
102
+ }
103
+ }
104
+
1
105
  /**
2
106
  * The control-plane conformance harness.
3
107
  *
@@ -16,9 +120,9 @@
16
120
  * ask, AND a mutated_input, so a suite can't pass while silently skipping a
17
121
  * decision the contract requires every adapter to express.
18
122
  * 4. enforcement honesty: an enforceable deny (`rendered.enforced === true`)
19
- * MUST carry a real block signal — a non-zero `exit_code` or a `throw_` —
20
- * not just a JSON body the agent is free to ignore. At least one enforced
21
- * deny must appear, so the honesty check is never vacuous.
123
+ * MUST carry a real block signal — a non-zero `exit_code` not just a JSON
124
+ * body the agent is free to ignore. At least one enforced deny must appear,
125
+ * so the honesty check is never vacuous.
22
126
  * 5. non-vetoable honesty: when the parsed event's `this_call_vetoable` is
23
127
  * false, EVERY render for that case must be `enforced === false` — a
24
128
  * guardrail that cannot veto this call must never render as if it did.
@@ -26,13 +130,18 @@
26
130
  * is `enforced === false` AND `exit_code === 0` — an "I have no objection"
27
131
  * verdict never renders as a block, on any adapter. At least one `allow`
28
132
  * must be rendered, so this is never vacuous.
133
+ * 7. coverage honesty: the adapter's COVERAGE matrix is well-formed (item ③),
134
+ * and a fixture case tagged with a `call_class` whose coverage does NOT
135
+ * permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
136
+ * `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
137
+ * while parsing its calls as vetoable.
29
138
  *
30
139
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
31
140
  * neutral; it throws on the first mismatch. Returns a summary the caller can
32
141
  * assert further on.
33
142
  *
34
143
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
35
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean }}
144
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, coverageClassesChecked: Set<string> }}
36
145
  */
37
146
  export function runAdapterConformance({ adapter, fixtures, assert }) {
38
147
  assert.equal(
@@ -41,8 +150,12 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
41
150
  `adapter AGENT '${adapter.AGENT}' does not match fixtures.agent '${fixtures.agent}'`,
42
151
  );
43
152
 
153
+ assertCoverageWellFormed(adapter, assert);
154
+
44
155
  /** @type {Set<string>} */
45
156
  const decisionsSeen = new Set();
157
+ /** @type {Set<string>} call classes exercised by a tagged fixture case */
158
+ const coverageClassesChecked = new Set();
46
159
  let mutationSeen = false;
47
160
  let enforcedDenySeen = false;
48
161
  let renders = 0;
@@ -55,6 +168,21 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
55
168
  `parse mismatch: ${testCase.name}`,
56
169
  );
57
170
 
171
+ if (testCase.call_class !== undefined) {
172
+ assert.ok(
173
+ CALL_CLASSES.includes(testCase.call_class),
174
+ `unknown call_class '${testCase.call_class}': ${testCase.name}`,
175
+ );
176
+ if (!coverageAllowsVeto(adapter.COVERAGE[testCase.call_class])) {
177
+ assert.equal(
178
+ parsed.this_call_vetoable,
179
+ false,
180
+ `call_class '${testCase.call_class}' is ${adapter.COVERAGE[testCase.call_class]} (no veto) but parsed this_call_vetoable !== false: ${testCase.name}`,
181
+ );
182
+ }
183
+ coverageClassesChecked.add(testCase.call_class);
184
+ }
185
+
58
186
  for (const [scenario, raw] of Object.entries(testCase.render)) {
59
187
  const spec = /** @type {{ verdict: any, native: any }} */ (raw);
60
188
  const rendered = adapter.render(spec.verdict, parsed);
@@ -65,7 +193,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
65
193
  );
66
194
  if (rendered.enforced) {
67
195
  assert.ok(
68
- rendered.exit_code !== 0 || rendered.throw_ !== undefined,
196
+ rendered.exit_code !== 0,
69
197
  `enforced deny carries no block signal: ${testCase.name} / ${scenario}`,
70
198
  );
71
199
  enforcedDenySeen = true;
@@ -117,5 +245,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
117
245
  decisionsSeen,
118
246
  mutationSeen,
119
247
  enforcedDenySeen,
248
+ coverageClassesChecked,
120
249
  };
121
250
  }
@@ -26,12 +26,13 @@
26
26
  * VERSIONING — this module IS the frozen contract (its own SSOT, no parallel
27
27
  * schema file to drift). Adapters and guardrail consumers are built against it
28
28
  * in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
29
- * and {@link MODELED_TOOLS} are frozen, and {@link SCHEMA_VERSION} /
30
- * {@link CONTROL_PLANE_SCHEMA} are pinned (control-plane.test.mjs asserts the
31
- * exact values, so any shape change is a deliberate, reviewed version bump).
32
- * ADDING to the contract (a new optional field, a new modeled tool) is
33
- * backward-compatible and stays at v1; RENAMING or REMOVING a field, or
34
- * changing a decision/event vocabulary, is breaking and bumps the version.
29
+ * {@link MODELED_TOOLS}, and {@link TOOL_ALIASES} are frozen, and
30
+ * {@link SCHEMA_VERSION} / {@link CONTROL_PLANE_SCHEMA} are pinned
31
+ * (control-plane.test.mjs asserts the exact values, so any shape change is a
32
+ * deliberate, reviewed version bump). ADDING to the contract (a new optional
33
+ * field, a new modeled tool, a new tool alias) is backward-compatible and stays
34
+ * at v1; RENAMING or REMOVING a field, or changing a decision/event vocabulary,
35
+ * is breaking and bumps the version.
35
36
  */
36
37
 
37
38
  /** Wire identifier for this schema version; bump on a breaking shape change. */
@@ -72,6 +73,63 @@ export const MODELED_TOOLS = Object.freeze([
72
73
  "WebFetch",
73
74
  ]);
74
75
 
76
+ /** @type {Set<string>} membership set for {@link MODELED_TOOLS}, used to constrain alias targets. */
77
+ const MODELED_TOOL_SET = new Set(MODELED_TOOLS);
78
+
79
+ /**
80
+ * Native-tool-name → canonical {@link MODELED_TOOLS} name. The SSOT that lets a
81
+ * judge key on `event.tool` without a per-agent lookup: an agent whose native
82
+ * name for the shell tool is `run_shell_command` (Gemini) is normalized to the
83
+ * same `Bash` a Claude/Codex/Amp payload already carries, with the raw native
84
+ * name preserved on `meta.native_tool`. Only globally-unambiguous native
85
+ * BUILTIN names belong here — a name that also occurs as an MCP tool (e.g. a
86
+ * `read_file` MCP server) must NOT be aliased, or the normalization would
87
+ * reclassify an unrelated tool. An adapter whose host makes such a name
88
+ * unambiguous at parse time (Gemini's `mcp_{server}_{tool}` FQN discipline)
89
+ * may carry its own ADAPTER-SCOPED alias map instead (see the gemini adapter's
90
+ * `GEMINI_TOOL_ALIASES`), applied only to calls classified BUILTIN. An unknown
91
+ * tool is never in the map, so it passes through verbatim (see
92
+ * {@link canonicalTool}). Every target is a
93
+ * {@link MODELED_TOOLS} member (enforced at load below), and every entry is
94
+ * witnessed by a conformance fixture (see `assertToolAliasesCovered`), so an
95
+ * alias cannot be added without a golden payload that exercises it.
96
+ */
97
+ export const TOOL_ALIASES = Object.freeze({
98
+ run_shell_command: "Bash",
99
+ });
100
+
101
+ /**
102
+ * Load-bearing use of MODELED_TOOLS: assert every alias target is a modeled
103
+ * tool, so the alias SSOT can never normalize a native name onto a canon the
104
+ * contract doesn't model. Throws (fail loud) on the first bad target — a typo'd
105
+ * target is a bug, not a silent passthrough. Called at import against
106
+ * {@link TOOL_ALIASES}, and by adapters against their adapter-scoped alias
107
+ * maps; exported so a test can drive both branches.
108
+ * @param {Record<string, string>} aliases
109
+ */
110
+ export function assertAliasTargetsModeled(aliases) {
111
+ for (const canonical of Object.values(aliases)) {
112
+ if (!MODELED_TOOL_SET.has(canonical))
113
+ throw new Error(
114
+ `control-plane: tool alias target ${JSON.stringify(canonical)} is not a modeled tool`,
115
+ );
116
+ }
117
+ }
118
+
119
+ assertAliasTargetsModeled(TOOL_ALIASES);
120
+
121
+ /**
122
+ * Canonicalize a native tool name to its {@link MODELED_TOOLS} equivalent, or
123
+ * return it UNCHANGED when it is not a known alias — an unknown tool is never
124
+ * silently reclassified. `null` (a non-tool event) passes through as `null`.
125
+ * @param {string|null} tool native tool name
126
+ * @returns {string|null} the canonical name, or the native name verbatim
127
+ */
128
+ export function canonicalTool(tool) {
129
+ if (typeof tool !== "string") return tool;
130
+ return /** @type {Record<string, string>} */ (TOOL_ALIASES)[tool] ?? tool;
131
+ }
132
+
75
133
  /**
76
134
  * How a guardrail ATTACHES to an agent — the transport, kept separate from the
77
135
  * normalized decision so the judge/Verdict core stays transport-agnostic:
@@ -88,10 +146,104 @@ export const IntegrationMode = Object.freeze({
88
146
  OBSERVE_ONLY: "observe_only",
89
147
  });
90
148
 
149
+ /**
150
+ * The CLASSES of tool call a host may or may not route through its pre-tool
151
+ * guardrail hook. Whether the hook fires is orthogonal to the tool's INPUT shape
152
+ * (which {@link MODELED_TOOLS} covers) — a host can gate its builtin tools while
153
+ * an MCP-sourced or subagent-spawned call of the SAME tool never reaches the
154
+ * hook. Each adapter declares a coverage status per class (see the adapter
155
+ * `COVERAGE` maps and `docs/hook-coverage-matrix.md`); the conformance harness
156
+ * enforces that an uncovered class is never marked `this_call_vetoable`.
157
+ */
158
+ export const CallClass = Object.freeze({
159
+ BUILTIN: "builtin",
160
+ MCP: "mcp",
161
+ SUBAGENT: "subagent",
162
+ RESUMED: "resumed",
163
+ });
164
+
165
+ /** Canonical ordered list of {@link CallClass} values — the SSOT every adapter's `COVERAGE` must classify exactly. */
166
+ export const CALL_CLASSES = Object.freeze(Object.values(CallClass));
167
+
168
+ /**
169
+ * Whether a host's pre-tool hook fires for a {@link CallClass} — the machine
170
+ * form of the ✅/❌/⚠️/❓ matrix:
171
+ * - COVERED (✅): the hook fires for every tool in the class.
172
+ * - PARTIAL (⚠️): it fires for only SOME tools in the class (e.g. Codex gates
173
+ * Bash but not other builtins) — a call in the covered subset may be vetoed.
174
+ * - UNCOVERED (❌): it never fires; the call reaches the tool un-gated.
175
+ * - UNKNOWN (❓): undocumented — NOT yet proven to fire by a live probe.
176
+ * The doctrine (`docs/hook-coverage-matrix.md`): an UNKNOWN is treated as
177
+ * UNCOVERED until a probe proves ✅, because a guessed ✅ is a silent fail-open.
178
+ */
179
+ export const CoverageStatus = Object.freeze({
180
+ COVERED: "covered",
181
+ PARTIAL: "partial",
182
+ UNCOVERED: "uncovered",
183
+ UNKNOWN: "unknown",
184
+ });
185
+
186
+ /** @typedef {"covered"|"partial"|"uncovered"|"unknown"} CoverageStatusValue */
187
+ /** @typedef {Record<string, CoverageStatusValue>} CoverageMap a per-{@link CallClass} coverage map (an adapter's `COVERAGE`) */
188
+
189
+ /** @type {Set<string>} the valid {@link CoverageStatus} values, for membership checks. */
190
+ const COVERAGE_STATUS_VALUES = new Set(Object.values(CoverageStatus));
191
+
192
+ /**
193
+ * Whether a coverage status PERMITS an adapter to mark a call in that class
194
+ * `this_call_vetoable: true`. Only a hook confirmed to fire does: COVERED, or
195
+ * PARTIAL (for the tools in its covered subset). UNCOVERED and UNKNOWN both
196
+ * forbid it — an unknown is fail-closed to uncovered, which is the whole point
197
+ * of the matrix. Throws on an unrecognized status (fail loud — a typo must not
198
+ * quietly read as "permitted").
199
+ * @param {string} status a {@link CoverageStatus} value
200
+ * @returns {boolean}
201
+ */
202
+ export function coverageAllowsVeto(status) {
203
+ if (!COVERAGE_STATUS_VALUES.has(status)) {
204
+ throw new Error(
205
+ `control-plane: invalid coverage status ${JSON.stringify(status)}`,
206
+ );
207
+ }
208
+ return status === CoverageStatus.COVERED || status === CoverageStatus.PARTIAL;
209
+ }
210
+
211
+ /**
212
+ * True when `status` is a recognized {@link CoverageStatus} value.
213
+ * @param {unknown} status
214
+ * @returns {boolean}
215
+ */
216
+ export function isCoverageStatus(status) {
217
+ return COVERAGE_STATUS_VALUES.has(/** @type {string} */ (status));
218
+ }
219
+
220
+ /**
221
+ * Classify a tool call into a {@link CallClass} from what a single pre-tool
222
+ * payload reveals. Only MCP is reliably detectable here — from the tool NAME
223
+ * (`mcp__server__tool` / `mcp_server_tool`) or an explicit `mcp_context` object
224
+ * on the native payload. Subagent- and resumed-session calls carry no universal
225
+ * signal in a lone pre-tool event (detecting them needs the live probe of
226
+ * item ⑤), so they are NOT distinguished here and fall through to BUILTIN — an
227
+ * adapter whose host leaves those classes un-gated relies on the sandbox, not
228
+ * this classifier. An adapter feeds the result into `coverageAllowsVeto(this.
229
+ * COVERAGE[class])` so a call in an uncovered/unknown class parses non-vetoable.
230
+ * @param {string|null} tool tool name (null for non-tool events)
231
+ * @param {Record<string, unknown>} [native] the raw native payload, for `mcp_context`
232
+ * @returns {string} a {@link CallClass} value
233
+ */
234
+ export function classifyCallClass(tool, native) {
235
+ if (typeof tool === "string" && /^mcp__?[^_]/.test(tool))
236
+ return CallClass.MCP;
237
+ const ctx = native ? native.mcp_context : undefined;
238
+ if (ctx !== null && typeof ctx === "object") return CallClass.MCP;
239
+ return CallClass.BUILTIN;
240
+ }
241
+
91
242
  /**
92
243
  * @typedef {object} EventMeta
93
244
  * @property {string} agent producing agent id ("claude", "codex", …)
94
245
  * @property {string} native_event original native event name, preserved verbatim
246
+ * @property {string} [native_tool] original native tool name, preserved verbatim when `event.tool` was canonicalized (present iff the event carries a tool)
95
247
  * @property {"external_hook"|"in_process"|"observe_only"} integration_mode how the guardrail attaches
96
248
  * @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)
97
249
  * @property {string} [session_id]
@@ -110,23 +262,6 @@ export const IntegrationMode = Object.freeze({
110
262
  * @property {number} exit_code process exit code carrying the decision (0 = proceed)
111
263
  * @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
112
264
  * @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
113
- * @property {{ message: string }} [throw_] a thrown-error block signal (plugin transports, e.g. opencode)
114
- * @property {ConfigFallback} [fallback] an out-of-band enforcement the adapter ALSO requires (external pin / config deny-wildcards)
115
- */
116
-
117
- /**
118
- * An enforcement the adapter cannot express in-band and so hands to the sandbox
119
- * to apply out-of-band — because the in-agent hook has a documented hole:
120
- * - `external_pin`: the agent can re-enable/override its own hook, so the
121
- * sandbox must pin it from OUTSIDE (read-only config mount / PATH-shadow).
122
- * - `config_deny`: an in-agent throw doesn't reach every path (e.g. opencode
123
- * MCP tools / subagents), so a config-level deny-by-name wildcard is ALSO
124
- * required. `uncovered` names paths still not covered (a logged, known gap).
125
- * @typedef {object} ConfigFallback
126
- * @property {"external_pin"|"config_deny"} kind
127
- * @property {string} reason
128
- * @property {string[]} [deny_globs] tool-NAME globs to deny at config level (config_deny)
129
- * @property {string[]} [uncovered] paths this fallback still cannot reach (known gap)
130
265
  */
131
266
 
132
267
  /**
@@ -134,7 +269,7 @@ export const IntegrationMode = Object.freeze({
134
269
  * @typedef {object} ToolCallEvent
135
270
  * @property {number} schema_version stamped {@link SCHEMA_VERSION}
136
271
  * @property {"pre_tool"|"post_tool"|"prompt_submit"|"session_start"|"unknown"} event
137
- * @property {string|null} tool tool name (null for prompt/session events)
272
+ * @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.
138
273
  * @property {Record<string, unknown>} input passthrough tool input; a submitted prompt is folded into `input.prompt`
139
274
  * @property {unknown} [response] tool output, post_tool only (string or structured), verbatim
140
275
  * @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`)
@@ -145,7 +280,8 @@ export const IntegrationMode = Object.freeze({
145
280
  * A normalized guardrail decision.
146
281
  * @typedef {object} Verdict
147
282
  * @property {"allow"|"deny"|"ask"} decision
148
- * @property {Record<string, unknown>} [mutated_input] replacement tool input
283
+ * @property {Record<string, unknown>} [mutated_input] replacement tool input (pre_tool)
284
+ * @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).
149
285
  * @property {string} [additional_context] extra context to splice into the agent's stream
150
286
  * @property {string} [reason] human-readable rationale (shown on deny/ask)
151
287
  */
@@ -158,6 +294,7 @@ export const IntegrationMode = Object.freeze({
158
294
  * @typedef {object} Adapter
159
295
  * @property {string} AGENT
160
296
  * @property {"external_hook"|"in_process"|"observe_only"} INTEGRATION_MODE
297
+ * @property {Record<string, "covered"|"partial"|"uncovered"|"unknown">} COVERAGE per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
161
298
  * @property {(native: any) => ToolCallEvent} parse
162
299
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
163
300
  */
@@ -214,12 +351,99 @@ export function normalizeVerdict(verdict) {
214
351
  const out = { decision };
215
352
  if (verdict.mutated_input !== undefined)
216
353
  out.mutated_input = verdict.mutated_input;
354
+ if (verdict.mutated_output !== undefined)
355
+ out.mutated_output = verdict.mutated_output;
217
356
  if (verdict.additional_context !== undefined)
218
357
  out.additional_context = verdict.additional_context;
219
358
  if (verdict.reason !== undefined) out.reason = verdict.reason;
220
359
  return out;
221
360
  }
222
361
 
362
+ /**
363
+ * Harden an UNTRUSTED {@link Verdict} — one authored by a separate
364
+ * monitor/judge process (e.g. claude-guard's scrub-monitor-response) — before
365
+ * it is rendered. Two defenses:
366
+ *
367
+ * 1. Decision clamp (fail-to-ask): a `decision` outside allow/deny/ask
368
+ * becomes `"ask"`, and the clamp is made observable by appending a
369
+ * bracketed note to `reason` naming the rejected value — a malformed
370
+ * monitor answer escalates to a human instead of throwing (the internal
371
+ * strictness of {@link normalizeVerdict}) or silently allowing.
372
+ * 2. Text scrubbing: the caller-supplied `sanitizeText` runs over every
373
+ * monitor-authored PROSE field present — `reason` and
374
+ * `additional_context` (a non-string value in either is dropped; prose
375
+ * channels carry strings). `mutated_input`/`mutated_output` are NOT
376
+ * sanitized: they are data channels (replacement tool input/output the
377
+ * guardrail computed, often deliberately containing the very bytes a text
378
+ * scrubber would mangle), not monitor-authored prose. `mutated_input` is
379
+ * shape-checked only (a non-object is dropped — see the inline note);
380
+ * `mutated_output` is carried verbatim.
381
+ *
382
+ * `sanitizeText` is injected so this module stays dependency-free. It must be
383
+ * a function and must return a string — a sanitizer that eats the value is a
384
+ * bug, so a non-string return throws. Returns a fresh normalized verdict;
385
+ * never mutates its input.
386
+ * @param {unknown} verdict the untrusted verdict object
387
+ * @param {(text: string) => string} sanitizeText
388
+ * @returns {Verdict}
389
+ */
390
+ export function sanitizeVerdict(verdict, sanitizeText) {
391
+ if (typeof sanitizeText !== "function") {
392
+ throw new TypeError(
393
+ "control-plane: sanitizeVerdict requires a sanitizeText(string) => string function",
394
+ );
395
+ }
396
+ const raw = asObject(verdict);
397
+ /** @type {(field: string, value: string) => string} */
398
+ const scrub = (field, value) => {
399
+ const out = sanitizeText(value);
400
+ if (typeof out !== "string") {
401
+ throw new TypeError(
402
+ `control-plane: sanitizeText returned a non-string for ${field} — a sanitizer must return the sanitized text`,
403
+ );
404
+ }
405
+ return out;
406
+ };
407
+ const { decision } = raw;
408
+ const valid =
409
+ decision === Decision.ALLOW ||
410
+ decision === Decision.DENY ||
411
+ decision === Decision.ASK;
412
+ /** @type {Verdict} */
413
+ const out = {
414
+ decision: /** @type {Verdict["decision"]} */ (
415
+ valid ? decision : Decision.ASK
416
+ ),
417
+ };
418
+ // mutated_input must be a plain object (it replaces a tool's input); a
419
+ // malformed value is dropped rather than coerced — an empty {} substitute
420
+ // would silently blank the tool call. mutated_output is unconstrained by the
421
+ // contract, so it is carried verbatim.
422
+ const mutatedInput = raw.mutated_input;
423
+ if (
424
+ mutatedInput !== undefined &&
425
+ mutatedInput !== null &&
426
+ typeof mutatedInput === "object" &&
427
+ !Array.isArray(mutatedInput)
428
+ )
429
+ out.mutated_input = /** @type {Record<string, unknown>} */ (mutatedInput);
430
+ if (raw.mutated_output !== undefined) out.mutated_output = raw.mutated_output;
431
+ if (typeof raw.additional_context === "string")
432
+ out.additional_context = scrub(
433
+ "additional_context",
434
+ raw.additional_context,
435
+ );
436
+ if (typeof raw.reason === "string") out.reason = scrub("reason", raw.reason);
437
+ if (!valid) {
438
+ // The rejected value is itself untrusted text, so it is scrubbed before
439
+ // being embedded in the clamp note.
440
+ const rejected = scrub("decision", JSON.stringify(decision) ?? "undefined");
441
+ const note = `[control-plane: invalid verdict decision ${rejected} clamped to "ask"]`;
442
+ out.reason = out.reason === undefined ? note : `${out.reason} ${note}`;
443
+ }
444
+ return normalizeVerdict(out);
445
+ }
446
+
223
447
  /**
224
448
  * Return a shallow copy of `native` with the `consumed` keys removed — the
225
449
  * unmodelled remainder an adapter carries in `meta.passthrough` so an additive
@@ -273,19 +497,12 @@ export function asString(value, fallback) {
273
497
  }
274
498
 
275
499
  /**
276
- * Assemble a {@link NativeResponse}, omitting absent optional channels so a
277
- * pure exit-code transport carries no `stdout` key and vice versa.
278
- * @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown, throw_?: {message:string}, fallback?: ConfigFallback }} parts
500
+ * Assemble a {@link NativeResponse}, omitting an absent `stdout` so a pure
501
+ * exit-code transport carries no `stdout` key.
502
+ * @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown }} parts
279
503
  * @returns {NativeResponse}
280
504
  */
281
- export function nativeResponse({
282
- transport,
283
- exit_code,
284
- enforced,
285
- stdout,
286
- throw_,
287
- fallback,
288
- }) {
505
+ export function nativeResponse({ transport, exit_code, enforced, stdout }) {
289
506
  /** @type {NativeResponse} */
290
507
  const out = {
291
508
  transport: /** @type {NativeResponse["transport"]} */ (transport),
@@ -293,7 +510,5 @@ export function nativeResponse({
293
510
  enforced,
294
511
  };
295
512
  if (stdout !== undefined) out.stdout = stdout;
296
- if (throw_ !== undefined) out.throw_ = throw_;
297
- if (fallback !== undefined) out.fallback = fallback;
298
513
  return out;
299
514
  }
@@ -3,6 +3,7 @@
3
3
  "cases": [
4
4
  {
5
5
  "name": "delegate Bash, exit-code transport, all verdicts",
6
+ "call_class": "builtin",
6
7
  "native": {
7
8
  "tool": "Bash",
8
9
  "input": {
@@ -22,6 +23,7 @@
22
23
  "meta": {
23
24
  "agent": "amp",
24
25
  "native_event": "delegate",
26
+ "native_tool": "Bash",
25
27
  "integration_mode": "external_hook",
26
28
  "primary_gate_present": true,
27
29
  "passthrough": {},
@@ -97,6 +99,7 @@
97
99
  "meta": {
98
100
  "agent": "amp",
99
101
  "native_event": "delegate",
102
+ "native_tool": "Read",
100
103
  "integration_mode": "external_hook",
101
104
  "primary_gate_present": true,
102
105
  "passthrough": {
@@ -3,6 +3,7 @@
3
3
  "cases": [
4
4
  {
5
5
  "name": "pre_tool Bash, full meta, all verdicts",
6
+ "call_class": "builtin",
6
7
  "native": {
7
8
  "hook_event_name": "PreToolUse",
8
9
  "session_id": "s1",
@@ -25,6 +26,7 @@
25
26
  "meta": {
26
27
  "agent": "claude",
27
28
  "native_event": "PreToolUse",
29
+ "native_tool": "Bash",
28
30
  "integration_mode": "external_hook",
29
31
  "primary_gate_present": true,
30
32
  "passthrough": {},
@@ -134,6 +136,7 @@
134
136
  "meta": {
135
137
  "agent": "claude",
136
138
  "native_event": "PreToolUse",
139
+ "native_tool": "Read",
137
140
  "integration_mode": "external_hook",
138
141
  "primary_gate_present": true,
139
142
  "passthrough": {
@@ -183,6 +186,7 @@
183
186
  "meta": {
184
187
  "agent": "claude",
185
188
  "native_event": "PostToolUse",
189
+ "native_tool": "Bash",
186
190
  "integration_mode": "external_hook",
187
191
  "primary_gate_present": true,
188
192
  "passthrough": {}
@@ -206,6 +210,25 @@
206
210
  }
207
211
  }
208
212
  },
213
+ "output_mutation": {
214
+ "verdict": {
215
+ "decision": "allow",
216
+ "mutated_output": "file1\n[REDACTED: secret]",
217
+ "additional_context": "redacted 1 secret"
218
+ },
219
+ "native": {
220
+ "transport": "external_hook",
221
+ "exit_code": 0,
222
+ "enforced": false,
223
+ "stdout": {
224
+ "hookSpecificOutput": {
225
+ "hookEventName": "PostToolUse",
226
+ "updatedToolOutput": "file1\n[REDACTED: secret]",
227
+ "additionalContext": "redacted 1 secret"
228
+ }
229
+ }
230
+ }
231
+ },
209
232
  "deny": {
210
233
  "verdict": {
211
234
  "decision": "deny",