agent-control-plane-core 0.3.1 → 0.4.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.
@@ -9,7 +9,7 @@
9
9
  * behavior; sharing pure plumbing across distinct entries is not that.
10
10
  */
11
11
  import { writeSync } from "node:fs";
12
- import { Decision } from "../src/control-plane.mjs";
12
+ import { Decision, sanitizeVerdict } from "../src/control-plane.mjs";
13
13
 
14
14
  /** Read all of stdin to a string. */
15
15
  export function readStdin() {
@@ -51,12 +51,21 @@ export function demoJudge(event) {
51
51
  * helper's stderr, and gemini reads stderr as a reason only on exit 2 — none of
52
52
  * these fail-safe exits is a 2 for a stdout-carrying transport, and amp's ask
53
53
  * (exit 1) shows the diagnostic alongside the prompt, which is the point.
54
+ * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
55
+ * so the guardrail is supplied here rather than by forking this file. Whatever
56
+ * it returns is treated as untrusted (see the clamp below).
54
57
  * @param {import("../src/control-plane.mjs").Adapter} adapter
55
58
  * @param {string} rawInput
56
59
  * @param {import("../src/control-plane.mjs").NativeResponse} onFailure
60
+ * @param {(event: import("../src/control-plane.mjs").ToolCallEvent) => import("../src/control-plane.mjs").Verdict} [judge]
57
61
  * @returns {import("../src/control-plane.mjs").NativeResponse}
58
62
  */
59
- export function renderHookResponse(adapter, rawInput, onFailure) {
63
+ export function renderHookResponse(
64
+ adapter,
65
+ rawInput,
66
+ onFailure,
67
+ judge = demoJudge,
68
+ ) {
60
69
  try {
61
70
  const native = JSON.parse(rawInput);
62
71
  // A non-object top-level payload (null, array, number, string) is malformed:
@@ -69,7 +78,16 @@ export function renderHookResponse(adapter, rawInput, onFailure) {
69
78
  `hook payload must be a JSON object, got ${native === null ? "null" : Array.isArray(native) ? "array" : typeof native}`,
70
79
  );
71
80
  const event = adapter.parse(native);
72
- return adapter.render(demoJudge(event), event);
81
+ // A judge's answer is clamped, not trusted. `normalizeVerdict` (inside
82
+ // every adapter's render) THROWS on a decision outside allow/deny/ask, and
83
+ // that throw lands in the catch below — which returns the host's fail-safe,
84
+ // exit 0 on claude/codex/gemini. So a judge that means DENY but spells it
85
+ // "denied" would let the tool run. Clamping to "ask" first keeps a
86
+ // malformed answer in front of a human. Prose is passed through: scrubbing
87
+ // monitor-authored text needs a scrubber this transport module has no
88
+ // business choosing, and the decision is the half that gates the call.
89
+ const verdict = sanitizeVerdict(judge(event), (text) => text);
90
+ return adapter.render(verdict, event);
73
91
  } catch (err) {
74
92
  const detail = err instanceof Error ? (err.stack ?? err.message) : `${err}`;
75
93
  process.stderr.write(`[acp] hook pipeline error: ${detail}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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": {
@@ -126,18 +126,35 @@ function geminiMeta(nativeEvent, raw) {
126
126
  * CLI removes the ambiguity at parse time: every MCP tool is unconditionally
127
127
  * registered — and surfaced in hook payloads — under its fully qualified
128
128
  * `mcp_{server}_{tool}` name (gemini-cli docs/tools/mcp-server.md), so a bare
129
- * builtin name in `tool_name` can only be the builtin. The builtin's native
130
- * input fields still pass through verbatim (e.g. read_file's `absolute_path`,
131
- * not Read's `file_path`) — `meta.native_tool` tells a consumer which field
132
- * dialect to expect. Targets are pinned to {@link MODELED_TOOLS} at import, and
133
- * every entry must be witnessed by a gemini conformance fixture
134
- * (`assertToolAliasesCovered`).
129
+ * builtin name in `tool_name` can only be the builtin. Renaming the tool also
130
+ * renames its INPUT to the schema that name advertises, via
131
+ * {@link GEMINI_INPUT_ALIASES} — a consumer told `event.tool` is `Read` reads
132
+ * `input.file_path` and finds it, rather than reading `undefined` off a
133
+ * forwarded `absolute_path` and allowing. Targets are pinned to
134
+ * {@link MODELED_TOOLS} at import, every entry must be witnessed by a gemini
135
+ * conformance fixture (`assertToolAliasesCovered`), and every aliased case must
136
+ * carry the canonical input key (`assertAliasedInputsCanonical`).
135
137
  * @type {Readonly<Record<string, string>>}
136
138
  */
139
+ // `web_fetch` is deliberately ABSENT. Canonicalizing to `WebFetch` advertises
140
+ // `input.url`, and Gemini's payload has no URL field at all — the target sits
141
+ // inside a prose `prompt` ("summarize https://example.com"). Recovering it means
142
+ // guessing which URL in free text is the target, and a guardrail that gates on a
143
+ // guessed destination is worse than one that plainly does not recognize the
144
+ // tool: under the alias a domain deny-lister read `input.url`, got `undefined`,
145
+ // and allowed the fetch. Left native, `web_fetch` reaches a judge as a name it
146
+ // must handle on purpose.
137
147
  export const GEMINI_TOOL_ALIASES = Object.freeze({
138
148
  read_file: "Read",
139
149
  write_file: "Write",
140
- web_fetch: "WebFetch",
150
+ });
151
+
152
+ // Native input field → the canonical field the alias target advertises, per
153
+ // tool. Only a pure RENAME belongs here: the value is carried across untouched,
154
+ // so no consumer receives a field this adapter invented. A dialect that cannot
155
+ // be expressed as a rename does not get an alias at all (see `web_fetch`).
156
+ const GEMINI_INPUT_ALIASES = Object.freeze({
157
+ read_file: { absolute_path: "file_path" },
141
158
  });
142
159
 
143
160
  assertAliasTargetsModeled(GEMINI_TOOL_ALIASES);
@@ -161,14 +178,27 @@ function geminiCanonicalTool(nativeTool, callClass) {
161
178
  /**
162
179
  * @param {string} kind
163
180
  * @param {Record<string, unknown>} raw
181
+ * @param {string|null} nativeTool
182
+ * @param {string} callClass a {@link CallClass} value
164
183
  * @returns {Record<string, unknown>}
165
184
  */
166
- function geminiInput(kind, raw) {
185
+ function geminiInput(kind, raw, nativeTool, callClass) {
167
186
  if (kind === EventKind.PROMPT_SUBMIT)
168
187
  return { prompt: asString(raw.prompt, "") };
169
- if (kind === EventKind.PRE_TOOL || kind === EventKind.POST_TOOL)
170
- return asObject(raw.tool_input);
171
- return {};
188
+ if (kind !== EventKind.PRE_TOOL && kind !== EventKind.POST_TOOL) return {};
189
+ const input = asObject(raw.tool_input);
190
+ // Renamed only where the NAME was renamed: the aliases are builtin-scoped, so
191
+ // an MCP tool that happens to be called `read_file` keeps its own dialect.
192
+ if (callClass !== CallClass.BUILTIN || nativeTool === null) return input;
193
+ const renames = lookup(GEMINI_INPUT_ALIASES, nativeTool);
194
+ if (renames === undefined) return input;
195
+ const out = { ...input };
196
+ for (const [nativeKey, canonicalKey] of Object.entries(renames)) {
197
+ if (!Object.hasOwn(out, nativeKey)) continue;
198
+ out[canonicalKey] = out[nativeKey];
199
+ delete out[nativeKey];
200
+ }
201
+ return out;
172
202
  }
173
203
 
174
204
  /**
@@ -204,7 +234,7 @@ export function parse(native) {
204
234
  return makeEvent({
205
235
  event: kind,
206
236
  tool: geminiCanonicalTool(nativeTool, callClass),
207
- input: geminiInput(kind, raw),
237
+ input: geminiInput(kind, raw, nativeTool, callClass),
208
238
  response,
209
239
  this_call_vetoable: coverageAllowsVeto(COVERAGE[callClass]),
210
240
  meta,
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  CALL_CLASSES,
3
+ MODELED_TOOL_INPUT_KEYS,
3
4
  TOOL_ALIASES,
4
5
  canonicalTool,
5
6
  coverageAllowsVeto,
6
7
  isCoverageStatus,
8
+ lookup,
7
9
  } from "./control-plane.mjs";
8
10
 
9
11
  /**
@@ -102,6 +104,45 @@ export function assertToolAliasesCovered(
102
104
  }
103
105
  }
104
106
 
107
+ /**
108
+ * Assert that every case whose tool name was ALIASED carries the canonical
109
+ * tool's input key.
110
+ *
111
+ * Renaming a native tool to a {@link MODELED_TOOLS} name tells every consumer
112
+ * "this is a Read, read it like one" — the README's whole "write your logic
113
+ * once against the normalized types" promise. An adapter that renames the name
114
+ * but forwards the native input dialect makes that promise false in the one
115
+ * direction nobody checks: the judge reads the canonical field, gets
116
+ * `undefined`, and allows. A native-NAMED tool promises nothing and is left
117
+ * alone; this fires only where the adapter itself claimed the schema.
118
+ *
119
+ * The remedy for a failure is a real choice, not a fixture edit: rename the
120
+ * input keys too when the mapping is a pure rename, or drop the alias when it
121
+ * is not (a URL buried in a prose `prompt` cannot be renamed into a `url`
122
+ * without inventing it, and an invented target is worse than a visible gap).
123
+ * @param {any[]} fixturesList the golden fixtures for every shipped adapter
124
+ * @param {any} assert node:assert/strict (injected)
125
+ */
126
+ export function assertAliasedInputsCanonical(fixturesList, assert) {
127
+ for (const fixtures of fixturesList) {
128
+ for (const testCase of fixtures.cases) {
129
+ const nativeTool = testCase.event?.meta?.native_tool;
130
+ const canon = testCase.event?.tool;
131
+ if (typeof nativeTool !== "string" || nativeTool === canon) continue;
132
+ const key = lookup(
133
+ /** @type {Record<string, string>} */ (MODELED_TOOL_INPUT_KEYS),
134
+ canon,
135
+ );
136
+ if (key === undefined) continue;
137
+ assert.ok(
138
+ testCase.event.input !== undefined &&
139
+ Object.hasOwn(testCase.event.input, key),
140
+ `fixture '${testCase.name}' (${fixtures.agent}): ${JSON.stringify(nativeTool)} was canonicalized to ${JSON.stringify(canon)}, which advertises input.${key}, but the input carries ${JSON.stringify(Object.keys(testCase.event.input ?? {}))} — rename the input keys or drop the alias`,
141
+ );
142
+ }
143
+ }
144
+ }
145
+
105
146
  /**
106
147
  * The control-plane conformance harness.
107
148
  *
@@ -123,9 +164,17 @@ export function assertToolAliasesCovered(
123
164
  * MUST carry a real block signal — a non-zero `exit_code` — not just a JSON
124
165
  * body the agent is free to ignore. At least one enforced deny must appear,
125
166
  * so the honesty check is never vacuous.
126
- * 5. non-vetoable honesty: when the parsed event's `this_call_vetoable` is
127
- * false, EVERY render for that case must be `enforced === false` — a
128
- * guardrail that cannot veto this call must never render as if it did.
167
+ * 5. vetoability honesty, BOTH directions. When the parsed event's
168
+ * `this_call_vetoable` is false, EVERY render for that case must be
169
+ * `enforced === false` — a guardrail that cannot veto this call must never
170
+ * render as if it did. And when it is true, a `deny` verdict MUST render
171
+ * `enforced === true` with a non-zero `exit_code`: where the host can be
172
+ * stopped, a deny has to stop it. Without the forward half the whole
173
+ * deny-must-block property rests on each fixture's golden `deepEqual`, so
174
+ * an adapter that renders a vetoable deny as exit 0 passes as long as its
175
+ * own fixture was written to match — which is the one bug this harness
176
+ * exists to make impossible. Item ④'s enforced-deny requirement keeps this
177
+ * non-vacuous: a suite that never enforces anything fails there.
129
178
  * 6. allow = abstain: by default (no `soleGate` opt-in) every `allow` render
130
179
  * is `enforced === false` AND `exit_code === 0` — an "I have no objection"
131
180
  * verdict never renders as a block, on any adapter. At least one `allow`
@@ -141,7 +190,7 @@ export function assertToolAliasesCovered(
141
190
  * assert further on.
142
191
  *
143
192
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
144
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, coverageClassesChecked: Set<string> }}
193
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string> }}
145
194
  */
146
195
  export function runAdapterConformance({ adapter, fixtures, assert }) {
147
196
  assert.equal(
@@ -158,6 +207,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
158
207
  const coverageClassesChecked = new Set();
159
208
  let mutationSeen = false;
160
209
  let enforcedDenySeen = false;
210
+ let vetoableDenySeen = false;
161
211
  let renders = 0;
162
212
 
163
213
  for (const testCase of fixtures.cases) {
@@ -205,6 +255,26 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
205
255
  `non-vetoable call rendered as enforced: ${testCase.name} / ${scenario}`,
206
256
  );
207
257
  }
258
+ // The forward direction, and the one a guardrail's safety rests on: when
259
+ // the host CAN be stopped and the verdict says deny, the render must
260
+ // actually stop it. Without this the direction is left to each fixture's
261
+ // golden `deepEqual`, so an adapter that renders a vetoable deny as exit 0
262
+ // passes as long as its own fixture was written to match.
263
+ if (
264
+ spec.verdict.decision === "deny" &&
265
+ parsed.this_call_vetoable === true
266
+ ) {
267
+ assert.equal(
268
+ rendered.enforced,
269
+ true,
270
+ `vetoable deny did not enforce: ${testCase.name} / ${scenario}`,
271
+ );
272
+ assert.ok(
273
+ rendered.exit_code !== 0,
274
+ `vetoable deny carries no block signal: ${testCase.name} / ${scenario}`,
275
+ );
276
+ vetoableDenySeen = true;
277
+ }
208
278
  if (spec.verdict.decision === "allow") {
209
279
  assert.equal(
210
280
  rendered.enforced,
@@ -245,6 +315,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
245
315
  decisionsSeen,
246
316
  mutationSeen,
247
317
  enforcedDenySeen,
318
+ vetoableDenySeen,
248
319
  coverageClassesChecked,
249
320
  };
250
321
  }
@@ -64,17 +64,40 @@ export const Decision = Object.freeze({
64
64
  ASK: "ask",
65
65
  });
66
66
 
67
+ /**
68
+ * Every modeled tool, mapped to the input field a guardrail reads for it — the
69
+ * schema that renaming a native tool to this canonical name ADVERTISES.
70
+ *
71
+ * Canonicalizing the name while passing the native input dialect through is a
72
+ * silent bypass, and the worst kind: a judge written against `Read` reads
73
+ * `input.file_path`, an un-renamed Gemini payload supplies `absolute_path`, so
74
+ * the judge sees `undefined` and allows — having been told by `event.tool` that
75
+ * it was looking at a Read. Leaving the native name would at least fail
76
+ * visibly. `assertAliasedInputsCanonical` holds aliases to this map, so a new
77
+ * alias whose input dialect cannot be renamed faithfully fails conformance
78
+ * instead of shipping as a hole.
79
+ *
80
+ * This is also where {@link MODELED_TOOLS} comes from, rather than a second
81
+ * list beside it. A modeled tool missing its input key would make that guard
82
+ * skip it — re-opening the bypass one tool-add later — and deriving the names
83
+ * from the keys makes the two impossible to drift instead of asking a test to
84
+ * notice.
85
+ */
86
+ export const MODELED_TOOL_INPUT_KEYS = Object.freeze({
87
+ Bash: "command",
88
+ Edit: "file_path",
89
+ Write: "file_path",
90
+ Read: "file_path",
91
+ WebFetch: "url",
92
+ });
93
+
67
94
  /**
68
95
  * Tools whose input shape the core models. Every other tool passes through
69
96
  * unmodelled — its input object is preserved verbatim and no field is required.
70
97
  */
71
- export const MODELED_TOOLS = Object.freeze([
72
- "Bash",
73
- "Edit",
74
- "Write",
75
- "Read",
76
- "WebFetch",
77
- ]);
98
+ export const MODELED_TOOLS = Object.freeze(
99
+ Object.keys(MODELED_TOOL_INPUT_KEYS),
100
+ );
78
101
 
79
102
  /** @type {Set<string>} membership set for {@link MODELED_TOOLS}, used to constrain alias targets. */
80
103
  const MODELED_TOOL_SET = new Set(MODELED_TOOLS);
@@ -291,7 +291,7 @@
291
291
  "event": "pre_tool",
292
292
  "tool": "Read",
293
293
  "input": {
294
- "absolute_path": "/w/notes.txt"
294
+ "file_path": "/w/notes.txt"
295
295
  },
296
296
  "this_call_vetoable": true,
297
297
  "meta": {
@@ -359,7 +359,7 @@
359
359
  }
360
360
  },
361
361
  {
362
- "name": "BeforeTool builtin web_fetch canonicalizes to WebFetch",
362
+ "name": "BeforeTool builtin web_fetch stays native (no url to canonicalize)",
363
363
  "call_class": "builtin",
364
364
  "native": {
365
365
  "hook_event_name": "BeforeTool",
@@ -371,7 +371,7 @@
371
371
  "event": {
372
372
  "schema_version": 1,
373
373
  "event": "pre_tool",
374
- "tool": "WebFetch",
374
+ "tool": "web_fetch",
375
375
  "input": {
376
376
  "prompt": "summarize https://example.com"
377
377
  },
package/src/index.mjs CHANGED
@@ -32,4 +32,5 @@ export {
32
32
  runAdapterConformance,
33
33
  assertCoverageWellFormed,
34
34
  assertToolAliasesCovered,
35
+ assertAliasedInputsCanonical,
35
36
  } from "./conformance.mjs";
@@ -56,12 +56,14 @@ export const HookEvent: Readonly<{
56
56
  * CLI removes the ambiguity at parse time: every MCP tool is unconditionally
57
57
  * registered — and surfaced in hook payloads — under its fully qualified
58
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`).
59
+ * builtin name in `tool_name` can only be the builtin. Renaming the tool also
60
+ * renames its INPUT to the schema that name advertises, via
61
+ * {@link GEMINI_INPUT_ALIASES} — a consumer told `event.tool` is `Read` reads
62
+ * `input.file_path` and finds it, rather than reading `undefined` off a
63
+ * forwarded `absolute_path` and allowing. Targets are pinned to
64
+ * {@link MODELED_TOOLS} at import, every entry must be witnessed by a gemini
65
+ * conformance fixture (`assertToolAliasesCovered`), and every aliased case must
66
+ * carry the canonical input key (`assertAliasedInputsCanonical`).
65
67
  * @type {Readonly<Record<string, string>>}
66
68
  */
67
69
  export const GEMINI_TOOL_ALIASES: Readonly<Record<string, string>>;
@@ -28,6 +28,26 @@ export function assertCoverageWellFormed(adapter: import("./control-plane.mjs").
28
28
  * @param {Record<string, Record<string, string>>} [adapterAliases] agent id → that adapter's scoped alias map (e.g. `{ gemini: GEMINI_TOOL_ALIASES }`)
29
29
  */
30
30
  export function assertToolAliasesCovered(fixturesList: any[], assert: any, adapterAliases?: Record<string, Record<string, string>>): void;
31
+ /**
32
+ * Assert that every case whose tool name was ALIASED carries the canonical
33
+ * tool's input key.
34
+ *
35
+ * Renaming a native tool to a {@link MODELED_TOOLS} name tells every consumer
36
+ * "this is a Read, read it like one" — the README's whole "write your logic
37
+ * once against the normalized types" promise. An adapter that renames the name
38
+ * but forwards the native input dialect makes that promise false in the one
39
+ * direction nobody checks: the judge reads the canonical field, gets
40
+ * `undefined`, and allows. A native-NAMED tool promises nothing and is left
41
+ * alone; this fires only where the adapter itself claimed the schema.
42
+ *
43
+ * The remedy for a failure is a real choice, not a fixture edit: rename the
44
+ * input keys too when the mapping is a pure rename, or drop the alias when it
45
+ * is not (a URL buried in a prose `prompt` cannot be renamed into a `url`
46
+ * without inventing it, and an invented target is worse than a visible gap).
47
+ * @param {any[]} fixturesList the golden fixtures for every shipped adapter
48
+ * @param {any} assert node:assert/strict (injected)
49
+ */
50
+ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any): void;
31
51
  /**
32
52
  * The control-plane conformance harness.
33
53
  *
@@ -49,9 +69,17 @@ export function assertToolAliasesCovered(fixturesList: any[], assert: any, adapt
49
69
  * MUST carry a real block signal — a non-zero `exit_code` — not just a JSON
50
70
  * body the agent is free to ignore. At least one enforced deny must appear,
51
71
  * 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.
72
+ * 5. vetoability honesty, BOTH directions. When the parsed event's
73
+ * `this_call_vetoable` is false, EVERY render for that case must be
74
+ * `enforced === false` — a guardrail that cannot veto this call must never
75
+ * render as if it did. And when it is true, a `deny` verdict MUST render
76
+ * `enforced === true` with a non-zero `exit_code`: where the host can be
77
+ * stopped, a deny has to stop it. Without the forward half the whole
78
+ * deny-must-block property rests on each fixture's golden `deepEqual`, so
79
+ * an adapter that renders a vetoable deny as exit 0 passes as long as its
80
+ * own fixture was written to match — which is the one bug this harness
81
+ * exists to make impossible. Item ④'s enforced-deny requirement keeps this
82
+ * non-vacuous: a suite that never enforces anything fails there.
55
83
  * 6. allow = abstain: by default (no `soleGate` opt-in) every `allow` render
56
84
  * is `enforced === false` AND `exit_code === 0` — an "I have no objection"
57
85
  * verdict never renders as a block, on any adapter. At least one `allow`
@@ -67,7 +95,7 @@ export function assertToolAliasesCovered(fixturesList: any[], assert: any, adapt
67
95
  * assert further on.
68
96
  *
69
97
  * @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> }}
98
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, vetoableDenySeen: boolean, coverageClassesChecked: Set<string> }}
71
99
  */
72
100
  export function runAdapterConformance({ adapter, fixtures, assert }: {
73
101
  adapter: import("./control-plane.mjs").Adapter;
@@ -79,5 +107,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
79
107
  decisionsSeen: Set<string>;
80
108
  mutationSeen: boolean;
81
109
  enforcedDenySeen: boolean;
110
+ vetoableDenySeen: boolean;
82
111
  coverageClassesChecked: Set<string>;
83
112
  };
@@ -272,6 +272,32 @@ export const Decision: Readonly<{
272
272
  DENY: "deny";
273
273
  ASK: "ask";
274
274
  }>;
275
+ /**
276
+ * Every modeled tool, mapped to the input field a guardrail reads for it — the
277
+ * schema that renaming a native tool to this canonical name ADVERTISES.
278
+ *
279
+ * Canonicalizing the name while passing the native input dialect through is a
280
+ * silent bypass, and the worst kind: a judge written against `Read` reads
281
+ * `input.file_path`, an un-renamed Gemini payload supplies `absolute_path`, so
282
+ * the judge sees `undefined` and allows — having been told by `event.tool` that
283
+ * it was looking at a Read. Leaving the native name would at least fail
284
+ * visibly. `assertAliasedInputsCanonical` holds aliases to this map, so a new
285
+ * alias whose input dialect cannot be renamed faithfully fails conformance
286
+ * instead of shipping as a hole.
287
+ *
288
+ * This is also where {@link MODELED_TOOLS} comes from, rather than a second
289
+ * list beside it. A modeled tool missing its input key would make that guard
290
+ * skip it — re-opening the bypass one tool-add later — and deriving the names
291
+ * from the keys makes the two impossible to drift instead of asking a test to
292
+ * notice.
293
+ */
294
+ export const MODELED_TOOL_INPUT_KEYS: Readonly<{
295
+ Bash: "command";
296
+ Edit: "file_path";
297
+ Write: "file_path";
298
+ Read: "file_path";
299
+ WebFetch: "url";
300
+ }>;
275
301
  /**
276
302
  * Tools whose input shape the core models. Every other tool passes through
277
303
  * unmodelled — its input object is preserved verbatim and no field is required.
package/types/index.d.mts CHANGED
@@ -4,4 +4,4 @@ export { ampAdapter } from "./adapters/amp.mjs";
4
4
  export { claudeAdapter, HookEvent } from "./adapters/claude.mjs";
5
5
  export { geminiAdapter, GEMINI_TOOL_ALIASES } from "./adapters/gemini.mjs";
6
6
  export { ADAPTERS, AGENT_IDS, adapterFor, assertRegistryConsistent } from "./registry.mjs";
7
- export { runAdapterConformance, assertCoverageWellFormed, assertToolAliasesCovered } from "./conformance.mjs";
7
+ export { runAdapterConformance, assertCoverageWellFormed, assertToolAliasesCovered, assertAliasedInputsCanonical } from "./conformance.mjs";