agent-control-plane-core 0.6.4 → 0.8.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/README.md +69 -1
- package/bin/amp-hook.mjs +1 -1
- package/bin/claude-hook.mjs +1 -1
- package/bin/codex-hook.mjs +1 -1
- package/bin/gemini-hook.mjs +1 -1
- package/package.json +13 -2
- package/schema/control-plane/v1/tool-call-event.schema.json +152 -0
- package/schema/control-plane/v1/tool-input-keys.json +7 -0
- package/schema/control-plane/v1/verdict.schema.json +34 -0
- package/src/adapters/amp.mjs +54 -25
- package/src/adapters/claude.mjs +24 -2
- package/src/adapters/codex.mjs +25 -3
- package/src/adapters/gemini.mjs +11 -0
- package/src/conformance.mjs +156 -21
- package/src/control-plane.mjs +10 -6
- package/src/fixtures/claude.json +4 -2
- package/src/fixtures/codex.json +8 -6
- package/src/json-schema.mjs +331 -0
- package/{bin/hook-runtime.mjs → src/runtime.mjs} +62 -27
- package/types/adapters/amp.d.mts +32 -0
- package/types/adapters/claude.d.mts +11 -1
- package/types/adapters/codex.d.mts +11 -0
- package/types/adapters/gemini.d.mts +9 -0
- package/types/conformance.d.mts +22 -1
- package/types/control-plane.d.mts +14 -6
- package/types/json-schema.d.mts +77 -0
- package/types/runtime.d.mts +79 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema (draft 2020-12) for the two wire shapes, for a consumer that is
|
|
3
|
+
* not JavaScript.
|
|
4
|
+
*
|
|
5
|
+
* A Python guardrail cannot import {@link ToolCallEvent} from this package, so
|
|
6
|
+
* it hand-writes the field names it reads and drifts silently: the contract
|
|
7
|
+
* renames a field, the hand-written reader sees `undefined`, and it takes its
|
|
8
|
+
* no-value branch instead of failing. These documents are the machine-readable
|
|
9
|
+
* form of the same contract, so that reader validates against the seam rather
|
|
10
|
+
* than against its own copy.
|
|
11
|
+
*
|
|
12
|
+
* EVERY ENUMERATED VALUE IS DERIVED, never typed a second time — each one is
|
|
13
|
+
* read off the frozen export that owns it: {@link EventKind},
|
|
14
|
+
* {@link Decision}, {@link IntegrationMode}, {@link STANDARD_META_FIELDS},
|
|
15
|
+
* {@link SCHEMA_VERSION}, {@link MODELED_TOOL_INPUT_KEYS}. The files under
|
|
16
|
+
* `schema/` are generated output that `json-schema.test.mjs` regenerates and
|
|
17
|
+
* byte-compares, not a second contract.
|
|
18
|
+
*
|
|
19
|
+
* FIELD NAMES ARE WRITTEN HERE, because the contract exports no list of them —
|
|
20
|
+
* the root fields and the always-present `EventMeta` fields are typedefs, which
|
|
21
|
+
* do not survive to runtime. So a renamed contract field is caught rather than
|
|
22
|
+
* declared: `json-schema.test.mjs` re-parses every golden native payload through
|
|
23
|
+
* the LIVE adapter and validates the result, and `additionalProperties: false`
|
|
24
|
+
* turns the rename into a rejection. {@link VERDICT_CONTENT_FIELDS} is the one
|
|
25
|
+
* list that does exist, and {@link assertVerdictFieldsDeclared} holds the
|
|
26
|
+
* verdict document to it — in the ADDING direction only. `reason` is outside
|
|
27
|
+
* that list (it is not probeable from one abstaining verdict), so its schema
|
|
28
|
+
* here rests on the `Verdict` typedef alone.
|
|
29
|
+
*
|
|
30
|
+
* SCOPE — the documents describe the contract's DECLARED shapes, which `tsc`
|
|
31
|
+
* checks at build time. `normalizeVerdict` re-checks only `decision` at run
|
|
32
|
+
* time, so a JavaScript caller who ignores the types can hand it a numeric
|
|
33
|
+
* `reason` and get one back; that value fails this document, correctly.
|
|
34
|
+
*
|
|
35
|
+
* A tool `input` stays unconstrained on purpose: an adapter forwards a native
|
|
36
|
+
* input object verbatim, so a schema demanding a string `command` on every Bash
|
|
37
|
+
* event would reject events the library legitimately emits — one golden Codex
|
|
38
|
+
* fixture carries `command` as an array. The per-tool field a judge should read
|
|
39
|
+
* is published beside the documents as data instead
|
|
40
|
+
* ({@link MODELED_TOOL_INPUT_KEYS}, in `tool-input-keys.json`).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
CONTROL_PLANE_SCHEMA,
|
|
45
|
+
Decision,
|
|
46
|
+
EventKind,
|
|
47
|
+
IntegrationMode,
|
|
48
|
+
MODELED_TOOL_INPUT_KEYS,
|
|
49
|
+
SCHEMA_VERSION,
|
|
50
|
+
STANDARD_META_FIELDS,
|
|
51
|
+
VERDICT_CONTENT_FIELDS,
|
|
52
|
+
} from "./control-plane.mjs";
|
|
53
|
+
|
|
54
|
+
/** The JSON Schema dialect these documents are written in. */
|
|
55
|
+
const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Package-relative directory the generated files live in, versioned by the WIRE
|
|
59
|
+
* version. A breaking shape change bumps {@link CONTROL_PLANE_SCHEMA} and so
|
|
60
|
+
* writes a new directory, leaving the previous one in place for a consumer
|
|
61
|
+
* still on it.
|
|
62
|
+
*
|
|
63
|
+
* A document is THIS RELEASE's rendering of that wire version, not a statement
|
|
64
|
+
* about every release that will carry it. Adding an optional field is
|
|
65
|
+
* backward-compatible and stays at v1 (see the README's Versioning section), so
|
|
66
|
+
* the next release's document may carry a property this one does not. That is
|
|
67
|
+
* why the files ship inside the package: a consumer reads the schema from the
|
|
68
|
+
* same installed version that produced the event, and the two move together.
|
|
69
|
+
* Validate against the installed file, never a copy pinned by hand.
|
|
70
|
+
*
|
|
71
|
+
* Neither document carries an `$id`. A URL naming a mutable branch would resolve
|
|
72
|
+
* to whatever the contract says LATER, and two installed versions declaring one
|
|
73
|
+
* `$id` collide inside a single validator instance. `$ref` here is
|
|
74
|
+
* document-relative, so nothing needs one.
|
|
75
|
+
*/
|
|
76
|
+
export const SCHEMA_DIR = `schema/${CONTROL_PLANE_SCHEMA}`;
|
|
77
|
+
|
|
78
|
+
/** Basename of each generated document, keyed by the shape it describes. */
|
|
79
|
+
export const SCHEMA_FILES = Object.freeze({
|
|
80
|
+
ToolCallEvent: "tool-call-event.schema.json",
|
|
81
|
+
Verdict: "verdict.schema.json",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Basename of the per-tool input-key map, published as plain DATA rather than
|
|
86
|
+
* inside the event document. A vendor keyword there (`x-modeled-tool-input-keys`)
|
|
87
|
+
* is legal JSON Schema, but ajv refuses an unknown keyword under its default
|
|
88
|
+
* options, so the document a JavaScript consumer installed would not compile
|
|
89
|
+
* until that consumer registered a vocabulary it has no reason to know about.
|
|
90
|
+
*/
|
|
91
|
+
export const TOOL_INPUT_KEYS_FILE = "tool-input-keys.json";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The schema for each optional {@link Verdict} field. Kept beside the
|
|
95
|
+
* assertion below rather than inlined, so a content field added to
|
|
96
|
+
* {@link VERDICT_CONTENT_FIELDS} fails loud here instead of quietly dropping
|
|
97
|
+
* out of the published document — a consumer would then read a field the
|
|
98
|
+
* schema calls forbidden and reject a verdict this library produces.
|
|
99
|
+
*/
|
|
100
|
+
const VERDICT_FIELD_SCHEMAS = Object.freeze({
|
|
101
|
+
mutated_input: {
|
|
102
|
+
type: "object",
|
|
103
|
+
description:
|
|
104
|
+
"Replacement tool input (pre_tool). A data channel, carried verbatim.",
|
|
105
|
+
},
|
|
106
|
+
mutated_output: {
|
|
107
|
+
description:
|
|
108
|
+
"Replacement tool output (post_tool): a string or the tool's structured output, verbatim. Any JSON value.",
|
|
109
|
+
},
|
|
110
|
+
additional_context: {
|
|
111
|
+
type: "string",
|
|
112
|
+
description: "Extra context to splice into the agent's stream.",
|
|
113
|
+
},
|
|
114
|
+
reason: {
|
|
115
|
+
type: "string",
|
|
116
|
+
description: "Human-readable rationale, shown on deny and ask.",
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Assert every content field the contract models has a schema above. Throws
|
|
122
|
+
* (fail loud) on the first that does not: `additionalProperties: false` makes an
|
|
123
|
+
* undeclared field a REJECTION rather than an omission, so a consumer would
|
|
124
|
+
* refuse a verdict this library produces. Called at import against the live
|
|
125
|
+
* {@link VERDICT_CONTENT_FIELDS}; exported so a test can drive both branches.
|
|
126
|
+
* @param {readonly string[]} fields
|
|
127
|
+
* @param {Record<string, unknown>} declared
|
|
128
|
+
*/
|
|
129
|
+
export function assertVerdictFieldsDeclared(fields, declared) {
|
|
130
|
+
for (const field of fields)
|
|
131
|
+
if (!Object.hasOwn(declared, field))
|
|
132
|
+
throw new Error(
|
|
133
|
+
`json-schema: verdict content field ${JSON.stringify(field)} has no declared schema — add one to VERDICT_FIELD_SCHEMAS`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
assertVerdictFieldsDeclared(VERDICT_CONTENT_FIELDS, VERDICT_FIELD_SCHEMAS);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The `EventMeta` subschema. The optional string fields are
|
|
141
|
+
* {@link STANDARD_META_FIELDS} plus `native_tool`; `string` is not a guess for
|
|
142
|
+
* them, it is what `baseMeta` enforces (a non-string value leaves the field
|
|
143
|
+
* absent rather than stamping a number onto a field consumers read as text).
|
|
144
|
+
* @returns {Record<string, unknown>}
|
|
145
|
+
*/
|
|
146
|
+
function eventMetaSchema() {
|
|
147
|
+
/** @type {Record<string, unknown>} */
|
|
148
|
+
const properties = {
|
|
149
|
+
agent: {
|
|
150
|
+
type: "string",
|
|
151
|
+
description: 'Producing agent id ("claude", "codex", …).',
|
|
152
|
+
},
|
|
153
|
+
native_event: {
|
|
154
|
+
type: "string",
|
|
155
|
+
description: "Original native event name, preserved verbatim.",
|
|
156
|
+
},
|
|
157
|
+
integration_mode: {
|
|
158
|
+
enum: Object.values(IntegrationMode),
|
|
159
|
+
description: "How the guardrail attaches to the host.",
|
|
160
|
+
},
|
|
161
|
+
primary_gate_present: {
|
|
162
|
+
type: "boolean",
|
|
163
|
+
description:
|
|
164
|
+
"The agent's own native gate already ran, so this verdict is a second opinion.",
|
|
165
|
+
},
|
|
166
|
+
passthrough: {
|
|
167
|
+
type: "object",
|
|
168
|
+
description: "Unmodelled native top-level fields, verbatim.",
|
|
169
|
+
},
|
|
170
|
+
native_tool: {
|
|
171
|
+
type: "string",
|
|
172
|
+
description:
|
|
173
|
+
"Original native tool name, present iff the event carries a tool.",
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
for (const field of STANDARD_META_FIELDS)
|
|
177
|
+
properties[field] = { type: "string" };
|
|
178
|
+
return {
|
|
179
|
+
type: "object",
|
|
180
|
+
required: [
|
|
181
|
+
"agent",
|
|
182
|
+
"native_event",
|
|
183
|
+
"integration_mode",
|
|
184
|
+
"primary_gate_present",
|
|
185
|
+
"passthrough",
|
|
186
|
+
],
|
|
187
|
+
properties,
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The document describing a normalized {@link ToolCallEvent}.
|
|
194
|
+
*
|
|
195
|
+
* `additionalProperties: false` is honest here rather than strict: `makeEvent`
|
|
196
|
+
* builds every event, and the unmodelled remainder of a native payload lands in
|
|
197
|
+
* `meta.passthrough`, so an extra key at the root is a hand-rolled event, not a
|
|
198
|
+
* newer library's.
|
|
199
|
+
* @returns {Record<string, unknown>}
|
|
200
|
+
*/
|
|
201
|
+
export function toolCallEventSchema() {
|
|
202
|
+
return {
|
|
203
|
+
$schema: JSON_SCHEMA_DIALECT,
|
|
204
|
+
title: "ToolCallEvent",
|
|
205
|
+
description: `A normalized, agent-agnostic view of one agent event (${CONTROL_PLANE_SCHEMA}).`,
|
|
206
|
+
type: "object",
|
|
207
|
+
required: [
|
|
208
|
+
"schema_version",
|
|
209
|
+
"event",
|
|
210
|
+
"tool",
|
|
211
|
+
"input",
|
|
212
|
+
"this_call_vetoable",
|
|
213
|
+
"meta",
|
|
214
|
+
],
|
|
215
|
+
properties: {
|
|
216
|
+
schema_version: { const: SCHEMA_VERSION },
|
|
217
|
+
event: { enum: Object.values(EventKind) },
|
|
218
|
+
tool: {
|
|
219
|
+
type: ["string", "null"],
|
|
220
|
+
description:
|
|
221
|
+
"Canonical tool name; null for prompt and session events. The raw native name is on meta.native_tool.",
|
|
222
|
+
},
|
|
223
|
+
input: {
|
|
224
|
+
type: "object",
|
|
225
|
+
description:
|
|
226
|
+
"Passthrough tool input, verbatim; a submitted prompt is folded into input.prompt.",
|
|
227
|
+
},
|
|
228
|
+
response: {
|
|
229
|
+
description: "Tool output, post_tool only: any JSON value, verbatim.",
|
|
230
|
+
},
|
|
231
|
+
this_call_vetoable: {
|
|
232
|
+
type: "boolean",
|
|
233
|
+
description:
|
|
234
|
+
"false ⇒ the guardrail cannot veto THIS call, so a deny must degrade to a notification.",
|
|
235
|
+
},
|
|
236
|
+
meta: { $ref: "#/$defs/EventMeta" },
|
|
237
|
+
},
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
// Two claims a producer must not make, both the same fail-open: a veto the
|
|
240
|
+
// host will never perform, read downstream as a block that happened.
|
|
241
|
+
//
|
|
242
|
+
// An event the adapter could not name has no host response to veto, so a
|
|
243
|
+
// vetoable UNKNOWN is that claim. `makeEvent` throws on one. An OBSERVE_ONLY
|
|
244
|
+
// transport reads a transcript and cannot pre-empt anything, so a vetoable
|
|
245
|
+
// event on one is the same claim by a different route — every shipped
|
|
246
|
+
// adapter derives both flags from one "can this host enforce" answer, but
|
|
247
|
+
// `makeEvent` does NOT refuse it, so this document is the only place a
|
|
248
|
+
// producer is held to it.
|
|
249
|
+
allOf: [
|
|
250
|
+
vetoImpossibleWhen({ event: { const: EventKind.UNKNOWN } }),
|
|
251
|
+
vetoImpossibleWhen({
|
|
252
|
+
meta: {
|
|
253
|
+
required: ["integration_mode"],
|
|
254
|
+
properties: {
|
|
255
|
+
integration_mode: { const: IntegrationMode.OBSERVE_ONLY },
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
}),
|
|
259
|
+
],
|
|
260
|
+
$defs: { EventMeta: eventMetaSchema() },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A conditional subschema forcing `this_call_vetoable: false` wherever `match`
|
|
266
|
+
* holds. `required` names every key `match` constrains, because an absent key
|
|
267
|
+
* satisfies a bare `properties` and would leave the `then` branch dead.
|
|
268
|
+
* @param {Record<string, unknown>} match
|
|
269
|
+
* @returns {Record<string, unknown>}
|
|
270
|
+
*/
|
|
271
|
+
function vetoImpossibleWhen(match) {
|
|
272
|
+
return {
|
|
273
|
+
if: { required: Object.keys(match), properties: match },
|
|
274
|
+
then: { properties: { this_call_vetoable: { const: false } } },
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The document describing a normalized {@link Verdict}. Only `decision` is
|
|
280
|
+
* required; `normalizeVerdict` emits the optional fields only when present.
|
|
281
|
+
* @returns {Record<string, unknown>}
|
|
282
|
+
*/
|
|
283
|
+
export function verdictSchema() {
|
|
284
|
+
/** @type {Record<string, unknown>} */
|
|
285
|
+
const properties = { decision: { enum: Object.values(Decision) } };
|
|
286
|
+
// Cloned, not aliased. `Object.freeze` on VERDICT_FIELD_SCHEMAS freezes the
|
|
287
|
+
// outer object only, so assigning the nested `{type, description}` objects
|
|
288
|
+
// hands every caller the same two levels down: a consumer that tightens
|
|
289
|
+
// `properties.reason` on the document it was given would silently retighten
|
|
290
|
+
// every document produced afterwards. The other builders return fresh
|
|
291
|
+
// structures already.
|
|
292
|
+
for (const [field, schema] of Object.entries(VERDICT_FIELD_SCHEMAS))
|
|
293
|
+
properties[field] = structuredClone(schema);
|
|
294
|
+
return {
|
|
295
|
+
$schema: JSON_SCHEMA_DIALECT,
|
|
296
|
+
title: "Verdict",
|
|
297
|
+
description: `A normalized guardrail decision (${CONTROL_PLANE_SCHEMA}).`,
|
|
298
|
+
type: "object",
|
|
299
|
+
required: ["decision"],
|
|
300
|
+
properties,
|
|
301
|
+
additionalProperties: false,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Every generated file, keyed by its package-relative path — the SSOT the
|
|
307
|
+
* generator writes and the freshness test reads, so neither carries its own
|
|
308
|
+
* list of filenames. The two `.schema.json` entries are JSON Schema documents;
|
|
309
|
+
* `tool-input-keys.json` is the data map {@link TOOL_INPUT_KEYS_FILE} describes.
|
|
310
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
311
|
+
*/
|
|
312
|
+
export function jsonSchemaDocuments() {
|
|
313
|
+
return {
|
|
314
|
+
[`${SCHEMA_DIR}/${SCHEMA_FILES.ToolCallEvent}`]: toolCallEventSchema(),
|
|
315
|
+
[`${SCHEMA_DIR}/${SCHEMA_FILES.Verdict}`]: verdictSchema(),
|
|
316
|
+
[`${SCHEMA_DIR}/${TOOL_INPUT_KEYS_FILE}`]: { ...MODELED_TOOL_INPUT_KEYS },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The bytes a generated file is written as: 2-space JSON with a trailing newline.
|
|
322
|
+
* This is NOT Prettier's rendering — Prettier collapses a short array onto one
|
|
323
|
+
* line — so `schema/` is in `.prettierignore` and this function owns the bytes
|
|
324
|
+
* the freshness test compares. A formatter allowed to rewrite them would red
|
|
325
|
+
* that test on a change to nothing.
|
|
326
|
+
* @param {Record<string, unknown>} document
|
|
327
|
+
* @returns {string}
|
|
328
|
+
*/
|
|
329
|
+
export function serializeSchema(document) {
|
|
330
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
331
|
+
}
|
|
@@ -1,21 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* multiplexer. Each host has its OWN entry
|
|
4
|
-
* its adapter and declares its OWN
|
|
5
|
-
*
|
|
6
|
-
* parse→judge→render pipe with
|
|
7
|
-
* emission). The thing item ②
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* The EXTERNAL_HOOK runtime, published at `agent-control-plane-core/runtime` —
|
|
3
|
+
* NOT a runtime host multiplexer. Each host has its OWN entry
|
|
4
|
+
* (`bin/<host>-hook.mjs`) that hardcodes its adapter and declares its OWN
|
|
5
|
+
* failure posture; this module only holds the transport mechanics they
|
|
6
|
+
* genuinely share (stdin read, the demo judge, the parse→judge→render pipe with
|
|
7
|
+
* a host-supplied fail-safe, and stdout+exit emission). The thing item ②
|
|
8
|
+
* deleted was the `--agent` switch that let one binary impersonate every host
|
|
9
|
+
* and so had to pick a single shared failure behavior; sharing pure plumbing
|
|
10
|
+
* across distinct entries is not that.
|
|
11
|
+
*
|
|
12
|
+
* It is exported because every consumer writing its own hook entry had to
|
|
13
|
+
* re-implement the same two things, and both fail silently when they are wrong:
|
|
14
|
+
* draining stdin before parsing, and flushing the response IN FULL before
|
|
15
|
+
* `process.exit` (see {@link writeAllSync}).
|
|
10
16
|
*/
|
|
11
17
|
import { writeSync } from "node:fs";
|
|
12
|
-
import { Decision, sanitizeVerdict } from "
|
|
18
|
+
import { Decision, sanitizeVerdict } from "./control-plane.mjs";
|
|
13
19
|
|
|
14
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Read all of stdin to a string.
|
|
22
|
+
* @returns {Promise<string>}
|
|
23
|
+
*/
|
|
15
24
|
export function readStdin() {
|
|
16
25
|
return new Promise((resolve, reject) => {
|
|
26
|
+
/** @type {Buffer[]} */
|
|
17
27
|
const chunks = [];
|
|
18
|
-
|
|
28
|
+
// A consumer that set an encoding on stdin hands STRINGS to this listener, and
|
|
29
|
+
// Buffer.concat throws on one. That throw escapes from the stream's callback,
|
|
30
|
+
// not from this promise, so it never reaches the caller's fail-safe: the hook
|
|
31
|
+
// dies with no response and a fail-open host runs the unjudged call.
|
|
32
|
+
process.stdin.on("data", (chunk) =>
|
|
33
|
+
chunks.push(
|
|
34
|
+
typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
19
37
|
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
20
38
|
process.stdin.on("error", reject);
|
|
21
39
|
});
|
|
@@ -25,8 +43,8 @@ export function readStdin() {
|
|
|
25
43
|
* The DEMO policy standing in for a real guardrail judge: deny any command
|
|
26
44
|
* matching /rm -rf/, allow everything else. A real deployment swaps this for its
|
|
27
45
|
* own judge over the normalized {@link ToolCallEvent}.
|
|
28
|
-
* @param {import("
|
|
29
|
-
* @returns {import("
|
|
46
|
+
* @param {import("./control-plane.mjs").ToolCallEvent} event
|
|
47
|
+
* @returns {import("./control-plane.mjs").Verdict}
|
|
30
48
|
*/
|
|
31
49
|
export function demoJudge(event) {
|
|
32
50
|
const command =
|
|
@@ -54,11 +72,11 @@ export function demoJudge(event) {
|
|
|
54
72
|
* `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
|
|
55
73
|
* so the guardrail is supplied here rather than by forking this file. Whatever
|
|
56
74
|
* it returns is treated as untrusted (see the clamp below).
|
|
57
|
-
* @param {import("
|
|
75
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
58
76
|
* @param {string} rawInput
|
|
59
|
-
* @param {import("
|
|
60
|
-
* @param {(event: import("
|
|
61
|
-
* @returns {import("
|
|
77
|
+
* @param {import("./control-plane.mjs").NativeResponse} onFailure
|
|
78
|
+
* @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
|
|
79
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
62
80
|
*/
|
|
63
81
|
export function renderHookResponse(
|
|
64
82
|
adapter,
|
|
@@ -102,6 +120,24 @@ export function renderHookResponse(
|
|
|
102
120
|
*/
|
|
103
121
|
const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
104
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Whether a failed `writeSync` is the one recoverable case: EAGAIN, meaning the
|
|
125
|
+
* non-blocking pipe is momentarily full because the host has not drained it
|
|
126
|
+
* yet. The caller sleeps 1ms and retries — the same wait a blocking write would
|
|
127
|
+
* have done in the kernel. Every other errno (EPIPE, EBADF, ...) propagates.
|
|
128
|
+
*
|
|
129
|
+
* A non-Error throw is NOT retryable, and is checked separately rather than
|
|
130
|
+
* left to the `code` comparison: a plain object carrying `code: "EAGAIN"` is
|
|
131
|
+
* not a write that the kernel asked us to repeat, and retrying one forever is a
|
|
132
|
+
* hang rather than a short write. Exported so a test can drive every branch —
|
|
133
|
+
* the real `writeSync` only ever throws an Error.
|
|
134
|
+
* @param {unknown} err
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function isRetryableWriteError(err) {
|
|
138
|
+
return err instanceof Error && /** @type {any} */ (err).code === "EAGAIN";
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
/**
|
|
106
142
|
* Write `text` to `fd` IN FULL, looping until every byte lands.
|
|
107
143
|
*
|
|
@@ -114,24 +150,23 @@ const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
|
114
150
|
* `mutated_input`) was silently truncated and the process still exited 0/2 with
|
|
115
151
|
* a half-written JSON the host cannot parse: an enforced deny degrading to a
|
|
116
152
|
* run. Looping restores the blocking-write semantics the caller assumes.
|
|
153
|
+
*
|
|
154
|
+
* Exported so a test can drive the propagation path against a real closed
|
|
155
|
+
* descriptor; {@link emit} is the only caller a consumer needs.
|
|
117
156
|
* @param {number} fd
|
|
118
157
|
* @param {string} text
|
|
119
158
|
*/
|
|
120
|
-
function writeAllSync(fd, text) {
|
|
159
|
+
export function writeAllSync(fd, text) {
|
|
121
160
|
const buf = Buffer.from(text, "utf8");
|
|
122
161
|
let written = 0;
|
|
123
162
|
while (written < buf.length) {
|
|
124
163
|
try {
|
|
125
164
|
written += writeSync(fd, buf, written);
|
|
126
165
|
} catch (err) {
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
// a cap would reintroduce exactly the silent truncation this loop exists to
|
|
132
|
-
// kill, and a host that never reads would have blocked us forever anyway.
|
|
133
|
-
if (!(err instanceof Error) || /** @type {any} */ (err).code !== "EAGAIN")
|
|
134
|
-
throw err;
|
|
166
|
+
// There is deliberately no retry cap: a cap would reintroduce exactly the
|
|
167
|
+
// silent truncation this loop exists to kill, and a host that never reads
|
|
168
|
+
// would have blocked us forever anyway.
|
|
169
|
+
if (!isRetryableWriteError(err)) throw err;
|
|
135
170
|
Atomics.wait(RETRY_SLEEP, 0, 0, 1);
|
|
136
171
|
}
|
|
137
172
|
}
|
|
@@ -140,7 +175,7 @@ function writeAllSync(fd, text) {
|
|
|
140
175
|
/**
|
|
141
176
|
* Emit a {@link NativeResponse} to the host: write the native stdout body when
|
|
142
177
|
* the transport has one, then exit with the transport's exit code.
|
|
143
|
-
* @param {import("
|
|
178
|
+
* @param {import("./control-plane.mjs").NativeResponse} response
|
|
144
179
|
* @returns {never}
|
|
145
180
|
*/
|
|
146
181
|
export function emit(response) {
|
package/types/adapters/amp.d.mts
CHANGED
|
@@ -3,6 +3,30 @@
|
|
|
3
3
|
* @returns {ToolCallEvent}
|
|
4
4
|
*/
|
|
5
5
|
export function parse(native: any): ToolCallEvent;
|
|
6
|
+
/**
|
|
7
|
+
* Totality check: every `decision` in `decisions` must have a row in `table`
|
|
8
|
+
* and every row both vetoable columns. A decision added to the contract with
|
|
9
|
+
* no matching row breaks loudly instead of silently defaulting to exit 0.
|
|
10
|
+
* Called at import against {@link Decision} and {@link EXIT_CODE_BY_DECISION};
|
|
11
|
+
* exported so a test can drive both branches.
|
|
12
|
+
* @param {readonly string[]} decisions
|
|
13
|
+
* @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
|
|
14
|
+
*/
|
|
15
|
+
export function assertExitCodeTableTotal(decisions: readonly string[], table: Readonly<Record<string, Readonly<Record<string, number>>>>): void;
|
|
16
|
+
/**
|
|
17
|
+
* Look up the exit code for `decision`/`vetoable` in `table`. Throws instead of
|
|
18
|
+
* returning `undefined`: the alternative is `exit_code: undefined`, which
|
|
19
|
+
* `process.exit` renders as 0 — the exact silent allow this table exists to
|
|
20
|
+
* eliminate. Unreachable from {@link render} on the real
|
|
21
|
+
* {@link EXIT_CODE_BY_DECISION} while {@link assertExitCodeTableTotal} holds at
|
|
22
|
+
* load; exported so a test can drive both branches against a synthetic,
|
|
23
|
+
* deliberately incomplete table.
|
|
24
|
+
* @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
|
|
25
|
+
* @param {string} decision
|
|
26
|
+
* @param {boolean} vetoable
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function exitCodeFor(table: Readonly<Record<string, Readonly<Record<string, number>>>>, decision: string, vetoable: boolean): number;
|
|
6
30
|
/**
|
|
7
31
|
* Render into Amp's pure exit-code transport: the decision is the exit code,
|
|
8
32
|
* with no stdout body. An ENFORCED deny also carries its `reason` on
|
|
@@ -72,6 +96,14 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
|
72
96
|
* @type {Record<string, ReadonlySet<string>|undefined>}
|
|
73
97
|
*/
|
|
74
98
|
export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
|
|
99
|
+
/**
|
|
100
|
+
* Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
|
|
101
|
+
* treats as neither the allow (0) nor the reject (2). {@link render} emits it
|
|
102
|
+
* for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
|
|
103
|
+
* The table below leans on the same tier for the non-vetoable deny, which is
|
|
104
|
+
* only honest because the tier is real.
|
|
105
|
+
*/
|
|
106
|
+
export const NATIVE_ASK_TIER: true;
|
|
75
107
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
76
108
|
export const ampAdapter: import("../control-plane.mjs").Adapter;
|
|
77
109
|
export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
|
|
@@ -9,7 +9,8 @@ export function parse(native: any): ToolCallEvent;
|
|
|
9
9
|
* Render into Claude Code's native external-hook transport: a
|
|
10
10
|
* `hookSpecificOutput` JSON body on stdout plus the exit code that carries the
|
|
11
11
|
* decision (deny ⇒ exit 2). A deny only counts as `enforced` when the event's
|
|
12
|
-
* `this_call_vetoable` holds
|
|
12
|
+
* `this_call_vetoable` holds, and an enforced deny also carries its `reason` on
|
|
13
|
+
* `NativeResponse.stderr` (see below).
|
|
13
14
|
*
|
|
14
15
|
* `soleGate` (default `false`) is a dangerous, explicit opt-in: when `true` AND
|
|
15
16
|
* the verdict is `allow`, the render emits Claude Code's REAL
|
|
@@ -65,6 +66,15 @@ export const COVERAGE: import("../control-plane.mjs").CoverageMap;
|
|
|
65
66
|
* @type {Record<string, ReadonlySet<string>|undefined>}
|
|
66
67
|
*/
|
|
67
68
|
export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* Claude Code honours a distinct ask tier: `hookSpecificOutput.permissionDecision
|
|
71
|
+
* = "ask"` suspends the call and puts it in front of the user, and {@link render}
|
|
72
|
+
* emits exactly that (see {@link gatingBody}). So a consumer must NOT escalate an
|
|
73
|
+
* `ask` to a `deny` here — the human already sees it. `render` still reports the
|
|
74
|
+
* ask as `enforced: false`, because this guardrail did not block the call; the
|
|
75
|
+
* host did, pending an answer.
|
|
76
|
+
*/
|
|
77
|
+
export const NATIVE_ASK_TIER: true;
|
|
68
78
|
/** Claude Code native hook event names (the `hook_event_name` field). */
|
|
69
79
|
export const HookEvent: Readonly<{
|
|
70
80
|
PRE_TOOL_USE: "PreToolUse";
|
|
@@ -113,6 +113,17 @@ export const MIN_ENFORCING_VERSION: readonly number[];
|
|
|
113
113
|
* @type {Record<string, string|undefined>}
|
|
114
114
|
*/
|
|
115
115
|
export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
116
|
+
/**
|
|
117
|
+
* Codex has NO ask tier. Its pre-tool output parser decodes
|
|
118
|
+
* `permissionDecision: "ask"`, classifies it unsupported, sets no block reason
|
|
119
|
+
* and runs the tool — upstream's own unit test for that path is named
|
|
120
|
+
* `unsupported_permission_decision_fails_open` (read at `rust-v0.155.1`, and
|
|
121
|
+
* byte-identical at `rust-v0.151.0`). `PermissionRequest` is no ask tier either:
|
|
122
|
+
* its behavior enum carries allow and deny only. So a verdict of `ask` has no
|
|
123
|
+
* channel here, and {@link render} sends it down the deny path rather than
|
|
124
|
+
* naming a key the host discards.
|
|
125
|
+
*/
|
|
126
|
+
export const NATIVE_ASK_TIER: false;
|
|
116
127
|
export const DEFAULT_DENY_REASON: "blocked by monitor";
|
|
117
128
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
118
129
|
export const codexAdapter: import("../control-plane.mjs").Adapter;
|
|
@@ -117,6 +117,15 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
|
117
117
|
* @type {Readonly<Record<string, string>>}
|
|
118
118
|
*/
|
|
119
119
|
export const GEMINI_TOOL_ALIASES: Readonly<Record<string, string>>;
|
|
120
|
+
/**
|
|
121
|
+
* Gemini CLI has NO ask tier: its hook vocabulary is `decision: "allow"` /
|
|
122
|
+
* `decision: "deny"` plus the exit-2 System Block, and nothing there suspends a
|
|
123
|
+
* call for a human. {@link applyAdvisoryDeny} therefore renders `ask` as the
|
|
124
|
+
* advisory `decision: "deny"`, byte-identical to a deny this call cannot veto.
|
|
125
|
+
* A consumer that must not let an ask through has to escalate it to a deny
|
|
126
|
+
* here, because an un-escalated ask lets Gemini run the tool.
|
|
127
|
+
*/
|
|
128
|
+
export const NATIVE_ASK_TIER: false;
|
|
120
129
|
/** @type {import("../control-plane.mjs").Adapter} */
|
|
121
130
|
export const geminiAdapter: import("../control-plane.mjs").Adapter;
|
|
122
131
|
export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
|
package/types/conformance.d.mts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A field added to VERDICT_CONTENT_FIELDS with no probe value would be dropped
|
|
3
|
+
* by normalizeVerdict and then reported as "reaches no native channel" against
|
|
4
|
+
* every adapter, naming the adapter for the harness's own omission. Called at
|
|
5
|
+
* import against the live {@link CONTENT_PROBE_VALUES}; exported so a test can
|
|
6
|
+
* drive both branches.
|
|
7
|
+
* @param {readonly string[]} fields
|
|
8
|
+
* @param {Record<string, readonly unknown[]>} values
|
|
9
|
+
*/
|
|
10
|
+
export function assertContentProbeValuesComplete(fields: readonly string[], values: Record<string, readonly unknown[]>): void;
|
|
1
11
|
/**
|
|
2
12
|
* Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
|
|
3
13
|
* hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
|
|
@@ -111,13 +121,23 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
|
|
|
111
121
|
* that DID reach a wire come back as `contentFieldsSeen`; a suite covering
|
|
112
122
|
* several adapters asserts their union to keep the positive half of the
|
|
113
123
|
* rule non-vacuous, since no single host has a channel for all three.
|
|
124
|
+
* 11. ask-tier honesty: the adapter declares whether this host carries a
|
|
125
|
+
* distinct "ask the human" tier it honours (`NATIVE_ASK_TIER`), and the
|
|
126
|
+
* render is held to that declaration on every pre-tool event. A declared
|
|
127
|
+
* tier whose `ask` renders as the host's abstaining allow fails — the
|
|
128
|
+
* consumer is told a human will see the call while the tool just runs —
|
|
129
|
+
* and so does a declared-absent tier whose `ask` renders as anything but
|
|
130
|
+
* the adapter's own advisory deny. A consumer reads the declaration to
|
|
131
|
+
* decide where an `ask` must be escalated to a `deny`, so a wrong one
|
|
132
|
+
* either lets a call through or denies every asked call on the hosts that
|
|
133
|
+
* do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
|
|
114
134
|
*
|
|
115
135
|
* `assert` is injected (node:assert/strict) so the harness stays test-framework
|
|
116
136
|
* neutral; it throws on the first mismatch. Returns a summary the caller can
|
|
117
137
|
* assert further on.
|
|
118
138
|
*
|
|
119
139
|
* @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
|
|
120
|
-
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
|
|
140
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number }}
|
|
121
141
|
*/
|
|
122
142
|
export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
123
143
|
adapter: import("./control-plane.mjs").Adapter;
|
|
@@ -134,4 +154,5 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
|
134
154
|
unknownKindSeen: boolean;
|
|
135
155
|
coverageClassesChecked: Set<string>;
|
|
136
156
|
unenforceableDenyChecks: number;
|
|
157
|
+
askTierChecks: number;
|
|
137
158
|
};
|