agent-control-plane-core 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hook-runtime.mjs +21 -3
- package/package.json +1 -1
- package/src/adapters/gemini.mjs +42 -12
- package/src/conformance.mjs +75 -4
- package/src/control-plane.mjs +30 -7
- package/src/fixtures/gemini.json +3 -3
- package/src/index.mjs +1 -0
- package/types/adapters/gemini.d.mts +8 -6
- package/types/conformance.d.mts +33 -4
- package/types/control-plane.d.mts +26 -0
- package/types/index.d.mts +1 -1
package/bin/hook-runtime.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -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.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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,
|
package/src/conformance.mjs
CHANGED
|
@@ -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.
|
|
127
|
-
* false, EVERY render for that case must be
|
|
128
|
-
* guardrail that cannot veto this call must never
|
|
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
|
}
|
package/src/control-plane.mjs
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
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);
|
package/src/fixtures/gemini.json
CHANGED
|
@@ -291,7 +291,7 @@
|
|
|
291
291
|
"event": "pre_tool",
|
|
292
292
|
"tool": "Read",
|
|
293
293
|
"input": {
|
|
294
|
-
"
|
|
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
|
|
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": "
|
|
374
|
+
"tool": "web_fetch",
|
|
375
375
|
"input": {
|
|
376
376
|
"prompt": "summarize https://example.com"
|
|
377
377
|
},
|
package/src/index.mjs
CHANGED
|
@@ -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.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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>>;
|
package/types/conformance.d.mts
CHANGED
|
@@ -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.
|
|
53
|
-
* false, EVERY render for that case must be
|
|
54
|
-
* guardrail that cannot veto this call must never
|
|
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";
|