agent-control-plane-core 0.1.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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The vendor-neutral control-plane contract.
3
+ *
4
+ * This is the published seam between an agent's native hook/tool-call protocol
5
+ * and claude-guard's guardrails (monitor, deny-match, redaction, sanitizers).
6
+ * It defines TWO normalized shapes — {@link ToolCallEvent} (what an agent is
7
+ * about to / just did) and {@link Verdict} (what the guardrail decided) — plus
8
+ * the {@link Adapter} pair that translates a specific agent's protocol to and
9
+ * from them. Every downstream consumer imports THESE types, never an agent's
10
+ * raw hook JSON; the agent-specific field names live only in that agent's
11
+ * adapter (see adapter-claude.mjs / adapter-codex.mjs).
12
+ *
13
+ * DRIFT DISCIPLINE — the reason the seam earns its keep. An agent protocol
14
+ * drifts additively and independently (N agents, N release cadences). So an
15
+ * adapter's `parse` MUST NOT throw on an event type or tool-input field it does
16
+ * not model: the unmodelled remainder is carried through verbatim (an unknown
17
+ * event becomes {@link EventKind.UNKNOWN} with its native name preserved; extra
18
+ * top-level fields land in `meta.passthrough`). An additive upstream change is
19
+ * then a no-op here, not an outage. The core models ONLY the stable middle:
20
+ * four event kinds, three decisions, and the Bash/Edit/Write/Read/WebFetch tool
21
+ * inputs. Exotic per-agent tools pass through untouched.
22
+ *
23
+ * Dependency-free on purpose, so a fail-closed hook can import it without
24
+ * dragging in eager config-file reads.
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},
29
+ * and {@link MODELED_TOOLS} are frozen, and {@link SCHEMA_VERSION} /
30
+ * {@link CONTROL_PLANE_SCHEMA} are pinned (control-plane.test.mjs asserts the
31
+ * exact values, so any shape change is a deliberate, reviewed version bump).
32
+ * ADDING to the contract (a new optional field, a new modeled tool) is
33
+ * backward-compatible and stays at v1; RENAMING or REMOVING a field, or
34
+ * changing a decision/event vocabulary, is breaking and bumps the version.
35
+ */
36
+
37
+ /** Wire identifier for this schema version; bump on a breaking shape change. */
38
+ export const CONTROL_PLANE_SCHEMA = "control-plane/v1";
39
+
40
+ /** Numeric schema version stamped onto every {@link ToolCallEvent}. */
41
+ export const SCHEMA_VERSION = 1;
42
+
43
+ /**
44
+ * The normalized event kinds the core models. A native event that maps to none
45
+ * of these is carried as {@link EventKind.UNKNOWN} with its native name kept in
46
+ * `meta.native_event`.
47
+ */
48
+ export const EventKind = Object.freeze({
49
+ PRE_TOOL: "pre_tool",
50
+ POST_TOOL: "post_tool",
51
+ PROMPT_SUBMIT: "prompt_submit",
52
+ SESSION_START: "session_start",
53
+ UNKNOWN: "unknown",
54
+ });
55
+
56
+ /** The normalized verdict decisions a guardrail can return. */
57
+ export const Decision = Object.freeze({
58
+ ALLOW: "allow",
59
+ DENY: "deny",
60
+ ASK: "ask",
61
+ });
62
+
63
+ /**
64
+ * Tools whose input shape the core models. Every other tool passes through
65
+ * unmodelled — its input object is preserved verbatim and no field is required.
66
+ */
67
+ export const MODELED_TOOLS = Object.freeze([
68
+ "Bash",
69
+ "Edit",
70
+ "Write",
71
+ "Read",
72
+ "WebFetch",
73
+ ]);
74
+
75
+ /**
76
+ * How a guardrail ATTACHES to an agent — the transport, kept separate from the
77
+ * normalized decision so the judge/Verdict core stays transport-agnostic:
78
+ * - EXTERNAL_HOOK: the agent shells out to a hook (stdin JSON → deny body /
79
+ * exit code). The decision can pre-empt the tool call.
80
+ * - IN_PROCESS: an embedded analyzer or a driven confirm/reject loop over the
81
+ * agent's own API. Can pre-empt.
82
+ * - OBSERVE_ONLY: transcript reader; cannot pre-empt, so its Verdict is
83
+ * advisory — the hard stop is the sandbox, not the hook.
84
+ */
85
+ export const IntegrationMode = Object.freeze({
86
+ EXTERNAL_HOOK: "external_hook",
87
+ IN_PROCESS: "in_process",
88
+ OBSERVE_ONLY: "observe_only",
89
+ });
90
+
91
+ /**
92
+ * @typedef {object} EventMeta
93
+ * @property {string} agent producing agent id ("claude", "codex", …)
94
+ * @property {string} native_event original native event name, preserved verbatim
95
+ * @property {"external_hook"|"in_process"|"observe_only"} integration_mode how the guardrail attaches
96
+ * @property {boolean} primary_gate_present the agent's own native gate already ran (⇒ the monitor is a SECOND opinion; the LLM call can be skipped when the native gate already blocked)
97
+ * @property {string} [session_id]
98
+ * @property {string} [cwd]
99
+ * @property {string} [permission_mode]
100
+ * @property {string} [transcript_path]
101
+ * @property {Record<string, unknown>} passthrough unmodelled native top-level fields, verbatim
102
+ */
103
+
104
+ /**
105
+ * The result of rendering a {@link Verdict} for a specific agent's transport —
106
+ * the ATTACH mechanism, not the decision. A caller applies whichever channels
107
+ * are present: write `stdout`, exit with `exit_code`, or `throw`.
108
+ * @typedef {object} NativeResponse
109
+ * @property {"external_hook"|"in_process"|"observe_only"} transport
110
+ * @property {number} exit_code process exit code carrying the decision (0 = proceed)
111
+ * @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
112
+ * @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
113
+ * @property {{ message: string }} [throw_] a thrown-error block signal (plugin transports, e.g. opencode)
114
+ * @property {ConfigFallback} [fallback] an out-of-band enforcement the adapter ALSO requires (external pin / config deny-wildcards)
115
+ */
116
+
117
+ /**
118
+ * An enforcement the adapter cannot express in-band and so hands to the sandbox
119
+ * to apply out-of-band — because the in-agent hook has a documented hole:
120
+ * - `external_pin`: the agent can re-enable/override its own hook, so the
121
+ * sandbox must pin it from OUTSIDE (read-only config mount / PATH-shadow).
122
+ * - `config_deny`: an in-agent throw doesn't reach every path (e.g. opencode
123
+ * MCP tools / subagents), so a config-level deny-by-name wildcard is ALSO
124
+ * required. `uncovered` names paths still not covered (a logged, known gap).
125
+ * @typedef {object} ConfigFallback
126
+ * @property {"external_pin"|"config_deny"} kind
127
+ * @property {string} reason
128
+ * @property {string[]} [deny_globs] tool-NAME globs to deny at config level (config_deny)
129
+ * @property {string[]} [uncovered] paths this fallback still cannot reach (known gap)
130
+ */
131
+
132
+ /**
133
+ * A normalized, agent-agnostic view of one agent event.
134
+ * @typedef {object} ToolCallEvent
135
+ * @property {number} schema_version stamped {@link SCHEMA_VERSION}
136
+ * @property {"pre_tool"|"post_tool"|"prompt_submit"|"session_start"|"unknown"} event
137
+ * @property {string|null} tool tool name (null for prompt/session events)
138
+ * @property {Record<string, unknown>} input passthrough tool input; a submitted prompt is folded into `input.prompt`
139
+ * @property {unknown} [response] tool output, post_tool only (string or structured), verbatim
140
+ * @property {boolean} this_call_vetoable false ⇒ the guardrail cannot veto THIS call; a monitor must auto-degrade deny to notify, and any render of it stays advisory (never `enforced`)
141
+ * @property {EventMeta} meta
142
+ */
143
+
144
+ /**
145
+ * A normalized guardrail decision.
146
+ * @typedef {object} Verdict
147
+ * @property {"allow"|"deny"|"ask"} decision
148
+ * @property {Record<string, unknown>} [mutated_input] replacement tool input
149
+ * @property {string} [additional_context] extra context to splice into the agent's stream
150
+ * @property {string} [reason] human-readable rationale (shown on deny/ask)
151
+ */
152
+
153
+ /**
154
+ * The translator for one agent's protocol. `parse` maps a native event to a
155
+ * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
156
+ * integration mode / enforcement flags on `meta`); `render` maps a {@link Verdict}
157
+ * to that agent's native transport ({@link NativeResponse}), not just a JSON body.
158
+ * @typedef {object} Adapter
159
+ * @property {string} AGENT
160
+ * @property {"external_hook"|"in_process"|"observe_only"} INTEGRATION_MODE
161
+ * @property {(native: any) => ToolCallEvent} parse
162
+ * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
163
+ */
164
+
165
+ /**
166
+ * Build a normalized {@link ToolCallEvent}, stamping the schema version. Pure —
167
+ * adapters pass already-normalized parts. `response` is omitted unless defined
168
+ * so a pre_tool event has no `response` key at all.
169
+ * @param {{ event: string, tool: string|null, input: Record<string, unknown>, response?: unknown, this_call_vetoable: boolean, meta: EventMeta }} parts
170
+ * @returns {ToolCallEvent}
171
+ */
172
+ export function makeEvent({
173
+ event,
174
+ tool,
175
+ input,
176
+ response,
177
+ this_call_vetoable,
178
+ meta,
179
+ }) {
180
+ /** @type {ToolCallEvent} */
181
+ const evt = {
182
+ schema_version: SCHEMA_VERSION,
183
+ event: /** @type {ToolCallEvent["event"]} */ (event),
184
+ tool,
185
+ input,
186
+ this_call_vetoable,
187
+ meta,
188
+ };
189
+ if (response !== undefined) evt.response = response;
190
+ return evt;
191
+ }
192
+
193
+ /**
194
+ * Validate and normalize a {@link Verdict}. The decision must be one of
195
+ * allow/deny/ask — a verdict is produced internally, so an out-of-range
196
+ * decision is a bug and throws (fail loud), unlike the pass-through tolerance
197
+ * `parse` extends to untrusted upstream events. Returns a fresh object carrying
198
+ * only the modeled optional fields that are present; never mutates its input.
199
+ * @param {Verdict} verdict
200
+ * @returns {Verdict}
201
+ */
202
+ export function normalizeVerdict(verdict) {
203
+ const { decision } = verdict;
204
+ if (
205
+ decision !== Decision.ALLOW &&
206
+ decision !== Decision.DENY &&
207
+ decision !== Decision.ASK
208
+ ) {
209
+ throw new Error(
210
+ `control-plane: invalid verdict decision ${JSON.stringify(decision)}`,
211
+ );
212
+ }
213
+ /** @type {Verdict} */
214
+ const out = { decision };
215
+ if (verdict.mutated_input !== undefined)
216
+ out.mutated_input = verdict.mutated_input;
217
+ if (verdict.additional_context !== undefined)
218
+ out.additional_context = verdict.additional_context;
219
+ if (verdict.reason !== undefined) out.reason = verdict.reason;
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * Return a shallow copy of `native` with the `consumed` keys removed — the
225
+ * unmodelled remainder an adapter carries in `meta.passthrough` so an additive
226
+ * upstream field survives instead of being silently dropped.
227
+ * @param {Record<string, unknown>} native
228
+ * @param {Set<string>} consumed
229
+ * @returns {Record<string, unknown>}
230
+ */
231
+ export function collectPassthrough(native, consumed) {
232
+ /** @type {Record<string, unknown>} */
233
+ const rest = {};
234
+ for (const [key, val] of Object.entries(native)) {
235
+ if (!consumed.has(key)) rest[key] = val;
236
+ }
237
+ return rest;
238
+ }
239
+
240
+ // ─── Coercion primitives ─────────────────────────────────────────────────────
241
+ // Adapters read untrusted native JSON: a field may be absent, null, or the wrong
242
+ // type. These coerce to a safe shape (never throw) so a malformed field degrades
243
+ // to a well-defined default instead of taking the parse down.
244
+
245
+ /**
246
+ * The value if it is a plain (non-array) object, else `{}`.
247
+ * @param {unknown} value
248
+ * @returns {Record<string, unknown>}
249
+ */
250
+ export function asObject(value) {
251
+ return value !== null && typeof value === "object" && !Array.isArray(value)
252
+ ? /** @type {Record<string, unknown>} */ (value)
253
+ : {};
254
+ }
255
+
256
+ /**
257
+ * The value if it is a string, else `null`.
258
+ * @param {unknown} value
259
+ * @returns {string|null}
260
+ */
261
+ export function asStringOrNull(value) {
262
+ return typeof value === "string" ? value : null;
263
+ }
264
+
265
+ /**
266
+ * The value if it is a string, else `fallback`.
267
+ * @param {unknown} value
268
+ * @param {string} fallback
269
+ * @returns {string}
270
+ */
271
+ export function asString(value, fallback) {
272
+ return typeof value === "string" ? value : fallback;
273
+ }
274
+
275
+ /**
276
+ * Assemble a {@link NativeResponse}, omitting absent optional channels so a
277
+ * pure exit-code transport carries no `stdout` key and vice versa.
278
+ * @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown, throw_?: {message:string}, fallback?: ConfigFallback }} parts
279
+ * @returns {NativeResponse}
280
+ */
281
+ export function nativeResponse({
282
+ transport,
283
+ exit_code,
284
+ enforced,
285
+ stdout,
286
+ throw_,
287
+ fallback,
288
+ }) {
289
+ /** @type {NativeResponse} */
290
+ const out = {
291
+ transport: /** @type {NativeResponse["transport"]} */ (transport),
292
+ exit_code,
293
+ enforced,
294
+ };
295
+ if (stdout !== undefined) out.stdout = stdout;
296
+ if (throw_ !== undefined) out.throw_ = throw_;
297
+ if (fallback !== undefined) out.fallback = fallback;
298
+ return out;
299
+ }
@@ -0,0 +1,121 @@
1
+ {
2
+ "agent": "amp",
3
+ "cases": [
4
+ {
5
+ "name": "delegate Bash, exit-code transport, all verdicts",
6
+ "native": {
7
+ "tool": "Bash",
8
+ "input": {
9
+ "command": "ls -la"
10
+ },
11
+ "session_id": "a1",
12
+ "cwd": "/w"
13
+ },
14
+ "event": {
15
+ "schema_version": 1,
16
+ "event": "pre_tool",
17
+ "tool": "Bash",
18
+ "input": {
19
+ "command": "ls -la"
20
+ },
21
+ "this_call_vetoable": true,
22
+ "meta": {
23
+ "agent": "amp",
24
+ "native_event": "delegate",
25
+ "integration_mode": "external_hook",
26
+ "primary_gate_present": true,
27
+ "passthrough": {},
28
+ "session_id": "a1",
29
+ "cwd": "/w"
30
+ }
31
+ },
32
+ "render": {
33
+ "allow": {
34
+ "verdict": {
35
+ "decision": "allow"
36
+ },
37
+ "native": {
38
+ "transport": "external_hook",
39
+ "exit_code": 0,
40
+ "enforced": false
41
+ }
42
+ },
43
+ "deny": {
44
+ "verdict": {
45
+ "decision": "deny",
46
+ "reason": "blocked"
47
+ },
48
+ "native": {
49
+ "transport": "external_hook",
50
+ "exit_code": 2,
51
+ "enforced": true
52
+ }
53
+ },
54
+ "ask": {
55
+ "verdict": {
56
+ "decision": "ask",
57
+ "reason": "confirm"
58
+ },
59
+ "native": {
60
+ "transport": "external_hook",
61
+ "exit_code": 1,
62
+ "enforced": false
63
+ }
64
+ },
65
+ "mutation": {
66
+ "verdict": {
67
+ "decision": "allow",
68
+ "mutated_input": {
69
+ "command": "ls"
70
+ }
71
+ },
72
+ "native": {
73
+ "transport": "external_hook",
74
+ "exit_code": 0,
75
+ "enforced": false
76
+ }
77
+ }
78
+ }
79
+ },
80
+ {
81
+ "name": "delegate Read, bare meta + passthrough",
82
+ "native": {
83
+ "tool": "Read",
84
+ "input": {
85
+ "file_path": "/x"
86
+ },
87
+ "extra_meta": 7
88
+ },
89
+ "event": {
90
+ "schema_version": 1,
91
+ "event": "pre_tool",
92
+ "tool": "Read",
93
+ "input": {
94
+ "file_path": "/x"
95
+ },
96
+ "this_call_vetoable": true,
97
+ "meta": {
98
+ "agent": "amp",
99
+ "native_event": "delegate",
100
+ "integration_mode": "external_hook",
101
+ "primary_gate_present": true,
102
+ "passthrough": {
103
+ "extra_meta": 7
104
+ }
105
+ }
106
+ },
107
+ "render": {
108
+ "allow": {
109
+ "verdict": {
110
+ "decision": "allow"
111
+ },
112
+ "native": {
113
+ "transport": "external_hook",
114
+ "exit_code": 0,
115
+ "enforced": false
116
+ }
117
+ }
118
+ }
119
+ }
120
+ ]
121
+ }