agent-control-plane-core 0.6.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -1
- package/bin/amp-hook.mjs +1 -1
- package/bin/claude-hook.mjs +1 -1
- package/bin/codex-hook.mjs +1 -1
- package/bin/gemini-hook.mjs +1 -1
- package/package.json +13 -2
- package/schema/control-plane/v1/tool-call-event.schema.json +152 -0
- package/schema/control-plane/v1/tool-input-keys.json +7 -0
- package/schema/control-plane/v1/verdict.schema.json +34 -0
- package/src/adapters/amp.mjs +54 -25
- package/src/adapters/claude.mjs +24 -2
- package/src/adapters/codex.mjs +25 -3
- package/src/adapters/gemini.mjs +11 -0
- package/src/conformance.mjs +156 -21
- package/src/control-plane.mjs +10 -6
- package/src/fixtures/claude.json +4 -2
- package/src/fixtures/codex.json +8 -6
- package/src/json-schema.mjs +331 -0
- package/{bin/hook-runtime.mjs → src/runtime.mjs} +62 -27
- package/types/adapters/amp.d.mts +32 -0
- package/types/adapters/claude.d.mts +11 -1
- package/types/adapters/codex.d.mts +11 -0
- package/types/adapters/gemini.d.mts +9 -0
- package/types/conformance.d.mts +22 -1
- package/types/control-plane.d.mts +14 -6
- package/types/json-schema.d.mts +77 -0
- package/types/runtime.d.mts +79 -0
|
@@ -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
|
|
282
|
-
*
|
|
283
|
-
* 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},
|
|
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
|
|
297
|
-
*
|
|
298
|
-
* and the
|
|
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;
|