agent-control-plane-core 0.6.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -136,6 +136,7 @@ export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
136
136
  * @property {(native: any) => ToolCallEvent} parse
137
137
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
138
138
  * @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
139
+ * @property {boolean} NATIVE_ASK_TIER whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
139
140
  * @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
140
141
  */
141
142
  /**
@@ -278,9 +279,11 @@ export function nativeResponse({ transport, exit_code, enforced, stdout, stderr,
278
279
  * Dependency-free on purpose, so a fail-closed hook can import it without
279
280
  * dragging in eager config-file reads.
280
281
  *
281
- * VERSIONING — this module IS the frozen contract (its own SSOT, no parallel
282
- * schema file to drift). Adapters and guardrail consumers are built against it
283
- * 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},
284
287
  * {@link MODELED_TOOLS}, and {@link TOOL_ALIASES} are frozen, and
285
288
  * {@link SCHEMA_VERSION} / {@link CONTROL_PLANE_SCHEMA} are pinned
286
289
  * (control-plane.test.mjs asserts the exact values, so any shape change is a
@@ -293,9 +296,10 @@ export function nativeResponse({ transport, exit_code, enforced, stdout, stderr,
293
296
  * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
294
297
  * member breaks every third-party adapter while every event on the wire stays
295
298
  * byte-identical, so it is a package-semver break and leaves
296
- * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one: adding it is
297
- * a one-line change per adapter, and the conformance harness names the member
298
- * and the migration rather than throwing from inside itself.
299
+ * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one and
300
+ * `NATIVE_ASK_TIER` is the second: adding either is a one-line change per
301
+ * adapter, and the conformance harness names the member and the migration
302
+ * rather than throwing from inside itself.
299
303
  */
300
304
  /** Wire identifier for this schema version; bump on a breaking shape change. */
301
305
  export const CONTROL_PLANE_SCHEMA: "control-plane/v1";
@@ -515,6 +519,10 @@ export type Adapter = {
515
519
  * the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
516
520
  */
517
521
  NATIVE_EVENT_FOR?: Record<string, string | undefined> | undefined;
522
+ /**
523
+ * whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
524
+ */
525
+ NATIVE_ASK_TIER: boolean;
518
526
  /**
519
527
  * per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
520
528
  */
@@ -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";
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Read all of stdin to a string.
3
+ * @returns {Promise<string>}
4
+ */
5
+ export function readStdin(): Promise<string>;
6
+ /**
7
+ * The DEMO policy standing in for a real guardrail judge: deny any command
8
+ * matching /rm -rf/, allow everything else. A real deployment swaps this for its
9
+ * own judge over the normalized {@link ToolCallEvent}.
10
+ * @param {import("./control-plane.mjs").ToolCallEvent} event
11
+ * @returns {import("./control-plane.mjs").Verdict}
12
+ */
13
+ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): import("./control-plane.mjs").Verdict;
14
+ /**
15
+ * Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
16
+ * can't be parsed or the pipe throws, return `onFailure` — the host's OWN
17
+ * fail-safe response — so a crashed hook degrades the way THAT host expects
18
+ * (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
19
+ * single shared default. This is the deliberate, necessary recovery the whole
20
+ * per-host split exists for, not a blanket swallow.
21
+ *
22
+ * Before returning the fail-safe, the error is written to STDERR (never stdout,
23
+ * which belongs to the host transport) so a regression that silently disables
24
+ * enforcement leaves a greppable trace. Every host tolerates hook stderr:
25
+ * claude/codex surface it only in verbose/debug on a 0 exit, amp forwards the
26
+ * helper's stderr, and gemini reads stderr as a reason only on exit 2 — none of
27
+ * these fail-safe exits is a 2 for a stdout-carrying transport, and amp's ask
28
+ * (exit 1) shows the diagnostic alongside the prompt, which is the point.
29
+ * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
30
+ * so the guardrail is supplied here rather than by forking this file. Whatever
31
+ * it returns is treated as untrusted (see the clamp below).
32
+ * @param {import("./control-plane.mjs").Adapter} adapter
33
+ * @param {string} rawInput
34
+ * @param {import("./control-plane.mjs").NativeResponse} onFailure
35
+ * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
36
+ * @returns {import("./control-plane.mjs").NativeResponse}
37
+ */
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;
73
+ /**
74
+ * Emit a {@link NativeResponse} to the host: write the native stdout body when
75
+ * the transport has one, then exit with the transport's exit code.
76
+ * @param {import("./control-plane.mjs").NativeResponse} response
77
+ * @returns {never}
78
+ */
79
+ export function emit(response: import("./control-plane.mjs").NativeResponse): never;