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 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.7.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,7 @@
1
+ {
2
+ "Bash": "command",
3
+ "Edit": "file_path",
4
+ "Write": "file_path",
5
+ "Read": "file_path",
6
+ "WebFetch": "url"
7
+ }
@@ -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
+ }
@@ -190,22 +190,52 @@ const EXIT_CODE_BY_DECISION = Object.freeze({
190
190
  });
191
191
 
192
192
  /**
193
- * Totality check at IMPORT: every {@link Decision} must have a row and every row
194
- * both vetoable columns. A decision added to the contract breaks this module
195
- * loudly at load instead of silently defaulting to exit 0.
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
- for (const decision of Object.values(Decision)) {
198
- const row = lookup(EXIT_CODE_BY_DECISION, decision);
199
- if (row === undefined)
200
- throw new Error(
201
- `amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
202
- );
203
- for (const vetoable of ["true", "false"]) {
204
- if (typeof lookup(row, vetoable) === "number") continue;
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 row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
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 = lookup(
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,
@@ -218,17 +218,23 @@ const CONTENT_PROBE_VALUES = Object.freeze({
218
218
  ]),
219
219
  });
220
220
 
221
- // A field added to VERDICT_CONTENT_FIELDS with no probe value would be dropped
222
- // by normalizeVerdict and then reported as "reaches no native channel" against
223
- // every adapter, naming the adapter for the harness's own omission.
224
- if (
225
- VERDICT_CONTENT_FIELDS.some(
226
- (field) => (lookup(CONTENT_PROBE_VALUES, field) ?? []).length < 2,
227
- )
228
- )
229
- throw new Error(
230
- `conformance: CONTENT_PROBE_VALUES needs two or more values for each of ${VERDICT_CONTENT_FIELDS.join(", ")}`,
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
- const values = lookup(CONTENT_PROBE_VALUES, field) ?? [];
605
- if (values.length < 2) return;
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
- (lookup(CONTENT_PROBE_VALUES, name) ?? []).map((value) => ({
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
  })),
@@ -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 (its own SSOT, no parallel
27
- * schema file to drift). Adapters and guardrail consumers are built against it
28
- * in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
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
- // EAGAIN is the one recoverable case: the non-blocking pipe is momentarily
146
- // full because the host has not drained it yet. Sleep 1ms and retry — the
147
- // same wait a blocking write would have done in the kernel. Every other
148
- // errno (EPIPE, EBADF, ...) propagates. There is deliberately no retry cap:
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
  }
@@ -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
@@ -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 (its own SSOT, no parallel
283
- * schema file to drift). Adapters and guardrail consumers are built against it
284
- * in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
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";
@@ -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.