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.
- package/bin/amp-hook.mjs +0 -0
- package/bin/claude-hook.mjs +0 -0
- package/bin/codex-hook.mjs +0 -0
- package/bin/gemini-hook.mjs +0 -0
- package/package.json +23 -14
- package/src/adapters/amp.mjs +29 -3
- package/src/adapters/claude.mjs +42 -4
- package/src/adapters/codex.mjs +37 -3
- package/src/adapters/gemini.mjs +138 -13
- package/src/conformance.mjs +134 -5
- package/src/control-plane.mjs +253 -38
- package/src/fixtures/amp.json +3 -0
- package/src/fixtures/claude.json +23 -0
- package/src/fixtures/codex.json +68 -0
- package/src/fixtures/gemini.json +257 -4
- package/src/index.mjs +14 -3
- package/src/registry.mjs +80 -0
- package/types/adapters/amp.d.mts +38 -0
- package/types/adapters/claude.d.mts +56 -0
- package/types/adapters/codex.d.mts +56 -0
- package/types/adapters/gemini.d.mts +73 -0
- package/types/conformance.d.mts +83 -0
- package/types/control-plane.d.mts +454 -0
- package/types/index.d.mts +7 -0
- package/types/registry.d.mts +35 -0
package/src/conformance.mjs
CHANGED
|
@@ -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`
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
}
|
package/src/control-plane.mjs
CHANGED
|
@@ -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
|
-
*
|
|
30
|
-
* {@link CONTROL_PLANE_SCHEMA} are pinned
|
|
31
|
-
* exact values, so any shape change is a
|
|
32
|
-
* ADDING to the contract (a new optional
|
|
33
|
-
*
|
|
34
|
-
* changing a decision/event vocabulary,
|
|
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
|
|
277
|
-
*
|
|
278
|
-
* @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown
|
|
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
|
}
|
package/src/fixtures/amp.json
CHANGED
|
@@ -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": {
|
package/src/fixtures/claude.json
CHANGED
|
@@ -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",
|