agent-control-plane-core 0.7.0 → 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 +39 -0
- package/package.json +9 -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 +44 -25
- package/src/conformance.mjs +29 -14
- package/src/control-plane.mjs +5 -3
- package/src/json-schema.mjs +331 -0
- package/src/runtime.mjs +26 -9
- package/types/adapters/amp.d.mts +24 -0
- package/types/conformance.d.mts +10 -0
- package/types/control-plane.d.mts +5 -3
- package/types/json-schema.d.mts +77 -0
- package/types/runtime.d.mts +34 -0
package/README.md
CHANGED
|
@@ -234,6 +234,44 @@ The harness pins both directions and rejects a vacuous suite:
|
|
|
234
234
|
|
|
235
235
|
See `src/fixtures/claude.json` and `src/fixtures/codex.json` for the format.
|
|
236
236
|
|
|
237
|
+
## Validating from another language
|
|
238
|
+
|
|
239
|
+
A guardrail that is not JavaScript cannot import `ToolCallEvent`, so it
|
|
240
|
+
hand-writes the field names it reads. It then drifts in the quietest direction:
|
|
241
|
+
the contract renames a field, the hand-written reader sees `undefined`, and it
|
|
242
|
+
takes its no-value branch instead of failing. The package ships the same
|
|
243
|
+
contract as JSON Schema (draft 2020-12), so that reader validates against the
|
|
244
|
+
seam instead of against its own copy.
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
import json, pathlib, jsonschema # a Python guardrail reading the event
|
|
248
|
+
base = pathlib.Path("node_modules/agent-control-plane-core/schema/control-plane/v1")
|
|
249
|
+
schema = json.loads((base / "tool-call-event.schema.json").read_text())
|
|
250
|
+
jsonschema.validate(event, schema) # raises on a field the contract does not model
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`tool-input-keys.json` sits beside them and publishes the input field a judge
|
|
254
|
+
should read per canonical tool (`Bash` → `command`, `Read` → `file_path`), so a
|
|
255
|
+
consumer looks that up rather than hard-coding it. It is plain data, not a
|
|
256
|
+
schema: a validator refuses an unknown keyword under its default options, so a
|
|
257
|
+
document carrying that map inside itself would not compile for the consumer who
|
|
258
|
+
installed it.
|
|
259
|
+
|
|
260
|
+
The event document also refuses two claims a producer must not make, each one a
|
|
261
|
+
veto the host will never perform: a `this_call_vetoable` `unknown` event, and a
|
|
262
|
+
`this_call_vetoable` event on an `observe_only` transport.
|
|
263
|
+
|
|
264
|
+
`pnpm gen:schema` generates the files from `src/control-plane.mjs`, and
|
|
265
|
+
`test/json-schema.test.mjs` byte-compares the result, so a stale document reds
|
|
266
|
+
the suite. The same suite validates every adapter's live output against the
|
|
267
|
+
published bytes, so a renamed contract field reds too. A JavaScript caller
|
|
268
|
+
builds the documents in memory from `agent-control-plane-core/json-schema`
|
|
269
|
+
instead of reading the files.
|
|
270
|
+
|
|
271
|
+
Read the schema from the installed package rather than pinning a copy by hand. A
|
|
272
|
+
document is one release's rendering of the wire version, and an added optional
|
|
273
|
+
field (backward-compatible, below) appears in the next release's document.
|
|
274
|
+
|
|
237
275
|
## Versioning
|
|
238
276
|
|
|
239
277
|
`src/control-plane.mjs` is the frozen contract and its own single source of
|
|
@@ -249,6 +287,7 @@ and bumps the version.
|
|
|
249
287
|
pnpm test # node --test
|
|
250
288
|
pnpm test:coverage # c8, 100% lines/branches/functions per file
|
|
251
289
|
pnpm check # tsc --noEmit (JSDoc types)
|
|
290
|
+
pnpm gen:schema # regenerate schema/ from the contract
|
|
252
291
|
pnpm lint # eslint
|
|
253
292
|
pnpm build # emit .d.mts declarations into types/
|
|
254
293
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -60,12 +60,17 @@
|
|
|
60
60
|
"./runtime": {
|
|
61
61
|
"types": "./types/runtime.d.mts",
|
|
62
62
|
"import": "./src/runtime.mjs"
|
|
63
|
+
},
|
|
64
|
+
"./json-schema": {
|
|
65
|
+
"types": "./types/json-schema.d.mts",
|
|
66
|
+
"import": "./src/json-schema.mjs"
|
|
63
67
|
}
|
|
64
68
|
},
|
|
65
69
|
"files": [
|
|
66
70
|
"src",
|
|
67
71
|
"types",
|
|
68
|
-
"bin"
|
|
72
|
+
"bin",
|
|
73
|
+
"schema"
|
|
69
74
|
],
|
|
70
75
|
"keywords": [
|
|
71
76
|
"claude",
|
|
@@ -117,6 +122,7 @@
|
|
|
117
122
|
"@eslint/js": "^9.13.0",
|
|
118
123
|
"@types/node": "^22.7.0",
|
|
119
124
|
"@types/semver": "^7.5.8",
|
|
125
|
+
"ajv": "^8.20.0",
|
|
120
126
|
"c8": "^10.1.2",
|
|
121
127
|
"eslint": "^9.13.0",
|
|
122
128
|
"fast-check": "^3.22.0",
|
|
@@ -127,6 +133,7 @@
|
|
|
127
133
|
},
|
|
128
134
|
"scripts": {
|
|
129
135
|
"build": "tsc -p tsconfig.build.json",
|
|
136
|
+
"gen:schema": "node scripts/gen-json-schema.mjs",
|
|
130
137
|
"test": "node --test test/*.test.mjs",
|
|
131
138
|
"test:coverage": "c8 node --test test/*.test.mjs",
|
|
132
139
|
"check": "tsc --noEmit",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "ToolCallEvent",
|
|
4
|
+
"description": "A normalized, agent-agnostic view of one agent event (control-plane/v1).",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"schema_version",
|
|
8
|
+
"event",
|
|
9
|
+
"tool",
|
|
10
|
+
"input",
|
|
11
|
+
"this_call_vetoable",
|
|
12
|
+
"meta"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"schema_version": {
|
|
16
|
+
"const": 1
|
|
17
|
+
},
|
|
18
|
+
"event": {
|
|
19
|
+
"enum": [
|
|
20
|
+
"pre_tool",
|
|
21
|
+
"post_tool",
|
|
22
|
+
"prompt_submit",
|
|
23
|
+
"session_start",
|
|
24
|
+
"unknown"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"tool": {
|
|
28
|
+
"type": [
|
|
29
|
+
"string",
|
|
30
|
+
"null"
|
|
31
|
+
],
|
|
32
|
+
"description": "Canonical tool name; null for prompt and session events. The raw native name is on meta.native_tool."
|
|
33
|
+
},
|
|
34
|
+
"input": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"description": "Passthrough tool input, verbatim; a submitted prompt is folded into input.prompt."
|
|
37
|
+
},
|
|
38
|
+
"response": {
|
|
39
|
+
"description": "Tool output, post_tool only: any JSON value, verbatim."
|
|
40
|
+
},
|
|
41
|
+
"this_call_vetoable": {
|
|
42
|
+
"type": "boolean",
|
|
43
|
+
"description": "false ⇒ the guardrail cannot veto THIS call, so a deny must degrade to a notification."
|
|
44
|
+
},
|
|
45
|
+
"meta": {
|
|
46
|
+
"$ref": "#/$defs/EventMeta"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"additionalProperties": false,
|
|
50
|
+
"allOf": [
|
|
51
|
+
{
|
|
52
|
+
"if": {
|
|
53
|
+
"required": [
|
|
54
|
+
"event"
|
|
55
|
+
],
|
|
56
|
+
"properties": {
|
|
57
|
+
"event": {
|
|
58
|
+
"const": "unknown"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"then": {
|
|
63
|
+
"properties": {
|
|
64
|
+
"this_call_vetoable": {
|
|
65
|
+
"const": false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"if": {
|
|
72
|
+
"required": [
|
|
73
|
+
"meta"
|
|
74
|
+
],
|
|
75
|
+
"properties": {
|
|
76
|
+
"meta": {
|
|
77
|
+
"required": [
|
|
78
|
+
"integration_mode"
|
|
79
|
+
],
|
|
80
|
+
"properties": {
|
|
81
|
+
"integration_mode": {
|
|
82
|
+
"const": "observe_only"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"then": {
|
|
89
|
+
"properties": {
|
|
90
|
+
"this_call_vetoable": {
|
|
91
|
+
"const": false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"$defs": {
|
|
98
|
+
"EventMeta": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"required": [
|
|
101
|
+
"agent",
|
|
102
|
+
"native_event",
|
|
103
|
+
"integration_mode",
|
|
104
|
+
"primary_gate_present",
|
|
105
|
+
"passthrough"
|
|
106
|
+
],
|
|
107
|
+
"properties": {
|
|
108
|
+
"agent": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"description": "Producing agent id (\"claude\", \"codex\", …)."
|
|
111
|
+
},
|
|
112
|
+
"native_event": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"description": "Original native event name, preserved verbatim."
|
|
115
|
+
},
|
|
116
|
+
"integration_mode": {
|
|
117
|
+
"enum": [
|
|
118
|
+
"external_hook",
|
|
119
|
+
"in_process",
|
|
120
|
+
"observe_only"
|
|
121
|
+
],
|
|
122
|
+
"description": "How the guardrail attaches to the host."
|
|
123
|
+
},
|
|
124
|
+
"primary_gate_present": {
|
|
125
|
+
"type": "boolean",
|
|
126
|
+
"description": "The agent's own native gate already ran, so this verdict is a second opinion."
|
|
127
|
+
},
|
|
128
|
+
"passthrough": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"description": "Unmodelled native top-level fields, verbatim."
|
|
131
|
+
},
|
|
132
|
+
"native_tool": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"description": "Original native tool name, present iff the event carries a tool."
|
|
135
|
+
},
|
|
136
|
+
"session_id": {
|
|
137
|
+
"type": "string"
|
|
138
|
+
},
|
|
139
|
+
"cwd": {
|
|
140
|
+
"type": "string"
|
|
141
|
+
},
|
|
142
|
+
"permission_mode": {
|
|
143
|
+
"type": "string"
|
|
144
|
+
},
|
|
145
|
+
"transcript_path": {
|
|
146
|
+
"type": "string"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
"additionalProperties": false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Verdict",
|
|
4
|
+
"description": "A normalized guardrail decision (control-plane/v1).",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"decision"
|
|
8
|
+
],
|
|
9
|
+
"properties": {
|
|
10
|
+
"decision": {
|
|
11
|
+
"enum": [
|
|
12
|
+
"allow",
|
|
13
|
+
"deny",
|
|
14
|
+
"ask"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"mutated_input": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"description": "Replacement tool input (pre_tool). A data channel, carried verbatim."
|
|
20
|
+
},
|
|
21
|
+
"mutated_output": {
|
|
22
|
+
"description": "Replacement tool output (post_tool): a string or the tool's structured output, verbatim. Any JSON value."
|
|
23
|
+
},
|
|
24
|
+
"additional_context": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Extra context to splice into the agent's stream."
|
|
27
|
+
},
|
|
28
|
+
"reason": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Human-readable rationale, shown on deny and ask."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"additionalProperties": false
|
|
34
|
+
}
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -190,22 +190,52 @@ const EXIT_CODE_BY_DECISION = Object.freeze({
|
|
|
190
190
|
});
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* Totality check
|
|
194
|
-
* both vetoable columns. A decision added to the contract
|
|
195
|
-
*
|
|
193
|
+
* Totality check: every `decision` in `decisions` must have a row in `table`
|
|
194
|
+
* and every row both vetoable columns. A decision added to the contract with
|
|
195
|
+
* no matching row breaks loudly instead of silently defaulting to exit 0.
|
|
196
|
+
* Called at import against {@link Decision} and {@link EXIT_CODE_BY_DECISION};
|
|
197
|
+
* exported so a test can drive both branches.
|
|
198
|
+
* @param {readonly string[]} decisions
|
|
199
|
+
* @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
|
|
196
200
|
*/
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
export function assertExitCodeTableTotal(decisions, table) {
|
|
202
|
+
for (const decision of decisions) {
|
|
203
|
+
const row = lookup(table, decision);
|
|
204
|
+
if (row === undefined)
|
|
205
|
+
throw new Error(
|
|
206
|
+
`amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
|
|
207
|
+
);
|
|
208
|
+
for (const vetoable of ["true", "false"]) {
|
|
209
|
+
if (typeof lookup(row, vetoable) === "number") continue;
|
|
210
|
+
throw new Error(
|
|
211
|
+
`amp adapter: exit-code table row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
assertExitCodeTableTotal(Object.values(Decision), EXIT_CODE_BY_DECISION);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Look up the exit code for `decision`/`vetoable` in `table`. Throws instead of
|
|
221
|
+
* returning `undefined`: the alternative is `exit_code: undefined`, which
|
|
222
|
+
* `process.exit` renders as 0 — the exact silent allow this table exists to
|
|
223
|
+
* eliminate. Unreachable from {@link render} on the real
|
|
224
|
+
* {@link EXIT_CODE_BY_DECISION} while {@link assertExitCodeTableTotal} holds at
|
|
225
|
+
* load; exported so a test can drive both branches against a synthetic,
|
|
226
|
+
* deliberately incomplete table.
|
|
227
|
+
* @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
|
|
228
|
+
* @param {string} decision
|
|
229
|
+
* @param {boolean} vetoable
|
|
230
|
+
* @returns {number}
|
|
231
|
+
*/
|
|
232
|
+
export function exitCodeFor(table, decision, vetoable) {
|
|
233
|
+
const exit_code = lookup(lookup(table, decision) ?? {}, String(vetoable));
|
|
234
|
+
if (exit_code === undefined)
|
|
205
235
|
throw new Error(
|
|
206
|
-
`amp adapter: exit-code table
|
|
236
|
+
`amp adapter: exit-code table has no entry for decision ${JSON.stringify(decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
|
|
207
237
|
);
|
|
208
|
-
|
|
238
|
+
return exit_code;
|
|
209
239
|
}
|
|
210
240
|
|
|
211
241
|
/**
|
|
@@ -234,18 +264,7 @@ export function render(verdict, event) {
|
|
|
234
264
|
`amp adapter: this_call_vetoable must be a boolean, got ${JSON.stringify(vetoable)}`,
|
|
235
265
|
);
|
|
236
266
|
const enforced = vd.decision === Decision.DENY && vetoable;
|
|
237
|
-
const exit_code =
|
|
238
|
-
lookup(EXIT_CODE_BY_DECISION, vd.decision) ?? {},
|
|
239
|
-
String(vetoable),
|
|
240
|
-
);
|
|
241
|
-
// Unreachable while the import-time totality check and `normalizeVerdict` both
|
|
242
|
-
// hold; kept because the alternative to throwing is `exit_code: undefined`,
|
|
243
|
-
// which `process.exit` renders as 0 — the exact silent allow this table exists
|
|
244
|
-
// to eliminate.
|
|
245
|
-
if (exit_code === undefined)
|
|
246
|
-
throw new Error(
|
|
247
|
-
`amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
|
|
248
|
-
);
|
|
267
|
+
const exit_code = exitCodeFor(EXIT_CODE_BY_DECISION, vd.decision, vetoable);
|
|
249
268
|
return nativeResponse({
|
|
250
269
|
transport: INTEGRATION_MODE,
|
|
251
270
|
exit_code,
|
package/src/conformance.mjs
CHANGED
|
@@ -218,17 +218,23 @@ const CONTENT_PROBE_VALUES = Object.freeze({
|
|
|
218
218
|
]),
|
|
219
219
|
});
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
)
|
|
221
|
+
/**
|
|
222
|
+
* A field added to VERDICT_CONTENT_FIELDS with no probe value would be dropped
|
|
223
|
+
* by normalizeVerdict and then reported as "reaches no native channel" against
|
|
224
|
+
* every adapter, naming the adapter for the harness's own omission. Called at
|
|
225
|
+
* import against the live {@link CONTENT_PROBE_VALUES}; exported so a test can
|
|
226
|
+
* drive both branches.
|
|
227
|
+
* @param {readonly string[]} fields
|
|
228
|
+
* @param {Record<string, readonly unknown[]>} values
|
|
229
|
+
*/
|
|
230
|
+
export function assertContentProbeValuesComplete(fields, values) {
|
|
231
|
+
if (fields.some((field) => (lookup(values, field) ?? []).length < 2))
|
|
232
|
+
throw new Error(
|
|
233
|
+
`conformance: CONTENT_PROBE_VALUES needs two or more values for each of ${fields.join(", ")}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
assertContentProbeValuesComplete(VERDICT_CONTENT_FIELDS, CONTENT_PROBE_VALUES);
|
|
232
238
|
|
|
233
239
|
/**
|
|
234
240
|
* Rule ⑩'s first half: the adapter declares a row for EVERY {@link EventKind}.
|
|
@@ -601,8 +607,13 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
|
|
|
601
607
|
// nowhere to hide.
|
|
602
608
|
/** @param {string} field @param {Record<string, unknown>} held @param {string} shape */
|
|
603
609
|
const carries = (field, held, shape) => {
|
|
604
|
-
|
|
605
|
-
|
|
610
|
+
// `field` is always a VERDICT_CONTENT_FIELDS member (the only caller below
|
|
611
|
+
// iterates it), and assertContentProbeValuesComplete guarantees at import
|
|
612
|
+
// that every such member has two or more entries here — so neither a
|
|
613
|
+
// missing row nor a short one can reach this point.
|
|
614
|
+
const values = /** @type {readonly unknown[]} */ (
|
|
615
|
+
lookup(CONTENT_PROBE_VALUES, field)
|
|
616
|
+
);
|
|
606
617
|
const renders = values.map((value) =>
|
|
607
618
|
adapter.render(
|
|
608
619
|
{ decision: Decision.ALLOW, ...held, [field]: value },
|
|
@@ -680,7 +691,11 @@ function assertContentChannels(adapter, event, caseName, assert, seen) {
|
|
|
680
691
|
const held = others.reduce(
|
|
681
692
|
(combinations, name) =>
|
|
682
693
|
combinations.flatMap((carried) =>
|
|
683
|
-
|
|
694
|
+
// Same guarantee as `carries` above: `name` is a VERDICT_CONTENT_FIELDS
|
|
695
|
+
// member, so the row is always present.
|
|
696
|
+
/** @type {readonly unknown[]} */ (
|
|
697
|
+
lookup(CONTENT_PROBE_VALUES, name)
|
|
698
|
+
).map((value) => ({
|
|
684
699
|
...carried,
|
|
685
700
|
[name]: value,
|
|
686
701
|
})),
|
package/src/control-plane.mjs
CHANGED
|
@@ -23,9 +23,11 @@
|
|
|
23
23
|
* Dependency-free on purpose, so a fail-closed hook can import it without
|
|
24
24
|
* dragging in eager config-file reads.
|
|
25
25
|
*
|
|
26
|
-
* VERSIONING — this module IS the frozen contract
|
|
27
|
-
*
|
|
28
|
-
* in
|
|
26
|
+
* VERSIONING — this module IS the frozen contract and its own SSOT. The JSON
|
|
27
|
+
* Schema documents under `schema/` are GENERATED from it by `pnpm gen:schema`
|
|
28
|
+
* and byte-compared in `json-schema.test.mjs`, so they are output rather than a
|
|
29
|
+
* second contract; never hand-edit one. Adapters and guardrail consumers are
|
|
30
|
+
* built against this module in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
|
|
29
31
|
* {@link MODELED_TOOLS}, and {@link TOOL_ALIASES} are frozen, and
|
|
30
32
|
* {@link SCHEMA_VERSION} / {@link CONTROL_PLANE_SCHEMA} are pinned
|
|
31
33
|
* (control-plane.test.mjs asserts the exact values, so any shape change is a
|
|
@@ -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
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -120,6 +120,24 @@ export function renderHookResponse(
|
|
|
120
120
|
*/
|
|
121
121
|
const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
122
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
|
+
|
|
123
141
|
/**
|
|
124
142
|
* Write `text` to `fd` IN FULL, looping until every byte lands.
|
|
125
143
|
*
|
|
@@ -132,24 +150,23 @@ const RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
|
132
150
|
* `mutated_input`) was silently truncated and the process still exited 0/2 with
|
|
133
151
|
* a half-written JSON the host cannot parse: an enforced deny degrading to a
|
|
134
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.
|
|
135
156
|
* @param {number} fd
|
|
136
157
|
* @param {string} text
|
|
137
158
|
*/
|
|
138
|
-
function writeAllSync(fd, text) {
|
|
159
|
+
export function writeAllSync(fd, text) {
|
|
139
160
|
const buf = Buffer.from(text, "utf8");
|
|
140
161
|
let written = 0;
|
|
141
162
|
while (written < buf.length) {
|
|
142
163
|
try {
|
|
143
164
|
written += writeSync(fd, buf, written);
|
|
144
165
|
} catch (err) {
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
// a cap would reintroduce exactly the silent truncation this loop exists to
|
|
150
|
-
// kill, and a host that never reads would have blocked us forever anyway.
|
|
151
|
-
if (!(err instanceof Error) || /** @type {any} */ (err).code !== "EAGAIN")
|
|
152
|
-
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;
|
|
153
170
|
Atomics.wait(RETRY_SLEEP, 0, 0, 1);
|
|
154
171
|
}
|
|
155
172
|
}
|
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
|
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
|
|
@@ -279,9 +279,11 @@ export function nativeResponse({ transport, exit_code, enforced, stdout, stderr,
|
|
|
279
279
|
* Dependency-free on purpose, so a fail-closed hook can import it without
|
|
280
280
|
* dragging in eager config-file reads.
|
|
281
281
|
*
|
|
282
|
-
* VERSIONING — this module IS the frozen contract
|
|
283
|
-
*
|
|
284
|
-
* in
|
|
282
|
+
* VERSIONING — this module IS the frozen contract and its own SSOT. The JSON
|
|
283
|
+
* Schema documents under `schema/` are GENERATED from it by `pnpm gen:schema`
|
|
284
|
+
* and byte-compared in `json-schema.test.mjs`, so they are output rather than a
|
|
285
|
+
* second contract; never hand-edit one. Adapters and guardrail consumers are
|
|
286
|
+
* built against this module in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
|
|
285
287
|
* {@link MODELED_TOOLS}, and {@link TOOL_ALIASES} are frozen, and
|
|
286
288
|
* {@link SCHEMA_VERSION} / {@link CONTROL_PLANE_SCHEMA} are pinned
|
|
287
289
|
* (control-plane.test.mjs asserts the exact values, so any shape change is a
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assert every content field the contract models has a schema above. Throws
|
|
3
|
+
* (fail loud) on the first that does not: `additionalProperties: false` makes an
|
|
4
|
+
* undeclared field a REJECTION rather than an omission, so a consumer would
|
|
5
|
+
* refuse a verdict this library produces. Called at import against the live
|
|
6
|
+
* {@link VERDICT_CONTENT_FIELDS}; exported so a test can drive both branches.
|
|
7
|
+
* @param {readonly string[]} fields
|
|
8
|
+
* @param {Record<string, unknown>} declared
|
|
9
|
+
*/
|
|
10
|
+
export function assertVerdictFieldsDeclared(fields: readonly string[], declared: Record<string, unknown>): void;
|
|
11
|
+
/**
|
|
12
|
+
* The document describing a normalized {@link ToolCallEvent}.
|
|
13
|
+
*
|
|
14
|
+
* `additionalProperties: false` is honest here rather than strict: `makeEvent`
|
|
15
|
+
* builds every event, and the unmodelled remainder of a native payload lands in
|
|
16
|
+
* `meta.passthrough`, so an extra key at the root is a hand-rolled event, not a
|
|
17
|
+
* newer library's.
|
|
18
|
+
* @returns {Record<string, unknown>}
|
|
19
|
+
*/
|
|
20
|
+
export function toolCallEventSchema(): Record<string, unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* The document describing a normalized {@link Verdict}. Only `decision` is
|
|
23
|
+
* required; `normalizeVerdict` emits the optional fields only when present.
|
|
24
|
+
* @returns {Record<string, unknown>}
|
|
25
|
+
*/
|
|
26
|
+
export function verdictSchema(): Record<string, unknown>;
|
|
27
|
+
/**
|
|
28
|
+
* Every generated file, keyed by its package-relative path — the SSOT the
|
|
29
|
+
* generator writes and the freshness test reads, so neither carries its own
|
|
30
|
+
* list of filenames. The two `.schema.json` entries are JSON Schema documents;
|
|
31
|
+
* `tool-input-keys.json` is the data map {@link TOOL_INPUT_KEYS_FILE} describes.
|
|
32
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
33
|
+
*/
|
|
34
|
+
export function jsonSchemaDocuments(): Record<string, Record<string, unknown>>;
|
|
35
|
+
/**
|
|
36
|
+
* The bytes a generated file is written as: 2-space JSON with a trailing newline.
|
|
37
|
+
* This is NOT Prettier's rendering — Prettier collapses a short array onto one
|
|
38
|
+
* line — so `schema/` is in `.prettierignore` and this function owns the bytes
|
|
39
|
+
* the freshness test compares. A formatter allowed to rewrite them would red
|
|
40
|
+
* that test on a change to nothing.
|
|
41
|
+
* @param {Record<string, unknown>} document
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function serializeSchema(document: Record<string, unknown>): string;
|
|
45
|
+
/**
|
|
46
|
+
* Package-relative directory the generated files live in, versioned by the WIRE
|
|
47
|
+
* version. A breaking shape change bumps {@link CONTROL_PLANE_SCHEMA} and so
|
|
48
|
+
* writes a new directory, leaving the previous one in place for a consumer
|
|
49
|
+
* still on it.
|
|
50
|
+
*
|
|
51
|
+
* A document is THIS RELEASE's rendering of that wire version, not a statement
|
|
52
|
+
* about every release that will carry it. Adding an optional field is
|
|
53
|
+
* backward-compatible and stays at v1 (see the README's Versioning section), so
|
|
54
|
+
* the next release's document may carry a property this one does not. That is
|
|
55
|
+
* why the files ship inside the package: a consumer reads the schema from the
|
|
56
|
+
* same installed version that produced the event, and the two move together.
|
|
57
|
+
* Validate against the installed file, never a copy pinned by hand.
|
|
58
|
+
*
|
|
59
|
+
* Neither document carries an `$id`. A URL naming a mutable branch would resolve
|
|
60
|
+
* to whatever the contract says LATER, and two installed versions declaring one
|
|
61
|
+
* `$id` collide inside a single validator instance. `$ref` here is
|
|
62
|
+
* document-relative, so nothing needs one.
|
|
63
|
+
*/
|
|
64
|
+
export const SCHEMA_DIR: "schema/control-plane/v1";
|
|
65
|
+
/** Basename of each generated document, keyed by the shape it describes. */
|
|
66
|
+
export const SCHEMA_FILES: Readonly<{
|
|
67
|
+
ToolCallEvent: "tool-call-event.schema.json";
|
|
68
|
+
Verdict: "verdict.schema.json";
|
|
69
|
+
}>;
|
|
70
|
+
/**
|
|
71
|
+
* Basename of the per-tool input-key map, published as plain DATA rather than
|
|
72
|
+
* inside the event document. A vendor keyword there (`x-modeled-tool-input-keys`)
|
|
73
|
+
* is legal JSON Schema, but ajv refuses an unknown keyword under its default
|
|
74
|
+
* options, so the document a JavaScript consumer installed would not compile
|
|
75
|
+
* until that consumer registered a vocabulary it has no reason to know about.
|
|
76
|
+
*/
|
|
77
|
+
export const TOOL_INPUT_KEYS_FILE: "tool-input-keys.json";
|
package/types/runtime.d.mts
CHANGED
|
@@ -36,6 +36,40 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
|
|
|
36
36
|
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
37
37
|
*/
|
|
38
38
|
export function renderHookResponse(adapter: import("./control-plane.mjs").Adapter, rawInput: string, onFailure: import("./control-plane.mjs").NativeResponse, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): import("./control-plane.mjs").NativeResponse;
|
|
39
|
+
/**
|
|
40
|
+
* Whether a failed `writeSync` is the one recoverable case: EAGAIN, meaning the
|
|
41
|
+
* non-blocking pipe is momentarily full because the host has not drained it
|
|
42
|
+
* yet. The caller sleeps 1ms and retries — the same wait a blocking write would
|
|
43
|
+
* have done in the kernel. Every other errno (EPIPE, EBADF, ...) propagates.
|
|
44
|
+
*
|
|
45
|
+
* A non-Error throw is NOT retryable, and is checked separately rather than
|
|
46
|
+
* left to the `code` comparison: a plain object carrying `code: "EAGAIN"` is
|
|
47
|
+
* not a write that the kernel asked us to repeat, and retrying one forever is a
|
|
48
|
+
* hang rather than a short write. Exported so a test can drive every branch —
|
|
49
|
+
* the real `writeSync` only ever throws an Error.
|
|
50
|
+
* @param {unknown} err
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
export function isRetryableWriteError(err: unknown): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Write `text` to `fd` IN FULL, looping until every byte lands.
|
|
56
|
+
*
|
|
57
|
+
* A single `writeSync` is not enough. When the host captures the hook, fd 1/2 is
|
|
58
|
+
* a pipe, and libuv puts that pipe in NON-BLOCKING mode the moment anything
|
|
59
|
+
* initializes `process.stdout`/`process.stderr` (a `console.log` in a judge, the
|
|
60
|
+
* fail-safe stderr diagnostic in {@link renderHookResponse}). A non-blocking
|
|
61
|
+
* `write(2)` returns a SHORT COUNT once the kernel pipe buffer fills — measured
|
|
62
|
+
* at ~143 KiB here — so a deny body larger than that (a long `reason`, a big
|
|
63
|
+
* `mutated_input`) was silently truncated and the process still exited 0/2 with
|
|
64
|
+
* a half-written JSON the host cannot parse: an enforced deny degrading to a
|
|
65
|
+
* run. Looping restores the blocking-write semantics the caller assumes.
|
|
66
|
+
*
|
|
67
|
+
* Exported so a test can drive the propagation path against a real closed
|
|
68
|
+
* descriptor; {@link emit} is the only caller a consumer needs.
|
|
69
|
+
* @param {number} fd
|
|
70
|
+
* @param {string} text
|
|
71
|
+
*/
|
|
72
|
+
export function writeAllSync(fd: number, text: string): void;
|
|
39
73
|
/**
|
|
40
74
|
* Emit a {@link NativeResponse} to the host: write the native stdout body when
|
|
41
75
|
* the transport has one, then exit with the transport's exit code.
|