agent-control-plane-core 0.2.10 → 0.2.12

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.
@@ -8,6 +8,7 @@
8
8
  * binary impersonate every host and so had to pick a single shared failure
9
9
  * behavior; sharing pure plumbing across distinct entries is not that.
10
10
  */
11
+ import { writeSync } from "node:fs";
11
12
  import { Decision } from "../src/control-plane.mjs";
12
13
 
13
14
  /** Read all of stdin to a string. */
@@ -57,7 +58,17 @@ export function demoJudge(event) {
57
58
  */
58
59
  export function renderHookResponse(adapter, rawInput, onFailure) {
59
60
  try {
60
- const event = adapter.parse(JSON.parse(rawInput));
61
+ const native = JSON.parse(rawInput);
62
+ // A non-object top-level payload (null, array, number, string) is malformed:
63
+ // the adapters' `asObject` would silently coerce it to `{}` and judge it as a
64
+ // benign no-op tool call — a rubber-stamp with no trace. Throw so it takes the
65
+ // host's declared failure posture AND leaves a greppable stderr diagnostic,
66
+ // instead of a silent coerce-to-empty.
67
+ if (native === null || typeof native !== "object" || Array.isArray(native))
68
+ throw new TypeError(
69
+ `hook payload must be a JSON object, got ${native === null ? "null" : Array.isArray(native) ? "array" : typeof native}`,
70
+ );
71
+ const event = adapter.parse(native);
61
72
  return adapter.render(demoJudge(event), event);
62
73
  } catch (err) {
63
74
  const detail = err instanceof Error ? (err.stack ?? err.message) : `${err}`;
@@ -73,7 +84,18 @@ export function renderHookResponse(adapter, rawInput, onFailure) {
73
84
  * @returns {never}
74
85
  */
75
86
  export function emit(response) {
87
+ // `writeSync` (not `process.stdout.write`) so the body is flushed to fd 1
88
+ // BEFORE `process.exit`. On a pipe — which stdout is when the host captures the
89
+ // hook — `process.stdout.write` is asynchronous and `process.exit` does not
90
+ // drain it, so an enforced-deny body (Claude/Codex `hookSpecificOutput`) could
91
+ // be truncated; the host then reads a block-less exit and runs the tool. A
92
+ // synchronous write closes that silent deny→allow window.
76
93
  if (response.stdout !== undefined)
77
- process.stdout.write(JSON.stringify(response.stdout));
94
+ writeSync(1, JSON.stringify(response.stdout));
95
+ // A host that reads the block reason from STDERR (Gemini's exit-2 System Block)
96
+ // gets it here — written synchronously to fd 2 before exit, for the same
97
+ // flush-before-exit reason as stdout above, so an enforced deny is never shown
98
+ // with no rationale.
99
+ if (response.stderr !== undefined) writeSync(2, response.stderr);
78
100
  process.exit(response.exit_code);
79
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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": {
@@ -21,6 +21,7 @@ import {
21
21
  classifyCallClass,
22
22
  coverageAllowsVeto,
23
23
  canonicalTool,
24
+ lookup,
24
25
  makeEvent,
25
26
  normalizeVerdict,
26
27
  nativeResponse,
@@ -146,8 +147,10 @@ export function parse(native) {
146
147
  const raw = asObject(native);
147
148
  const nativeEvent = asString(raw.hook_event_name, "");
148
149
  const kind =
149
- /** @type {Record<string, string>} */ (NATIVE_TO_KIND)[nativeEvent] ??
150
- EventKind.UNKNOWN;
150
+ lookup(
151
+ /** @type {Record<string, string>} */ (NATIVE_TO_KIND),
152
+ nativeEvent,
153
+ ) ?? EventKind.UNKNOWN;
151
154
  const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
152
155
  const nativeTool = claudeTool(kind, raw);
153
156
  const meta = claudeMeta(nativeEvent, raw);
@@ -83,6 +83,17 @@ const CONSUMED = new Set([
83
83
  * `semver.coerce` normalizes a patch/prerelease/build-tagged version to its
84
84
  * release core; anything it can't coerce to a valid version (missing, empty,
85
85
  * garbage) yields `null` and is treated as too old — fail closed to advisory.
86
+ *
87
+ * TRUST BOUNDARY (sandbox, not adapter): `version` is read from the hook stdin
88
+ * payload, so a payload that under-reports it downgrades enforcement to advisory
89
+ * (a deny renders exit 0). The adapter cannot distinguish a spoofed version from
90
+ * a genuinely old Codex — the ground-truth version lives outside the payload —
91
+ * so anti-spoofing belongs to the sandbox that controls what the hook is fed
92
+ * (the same "pin it from outside" posture the managed-config mount enforces for
93
+ * the hook binary; see docs/monitor-invariants.md §Invariant 1). Coercing an
94
+ * absent/garbage version to enforcing HERE would break the legitimate old-Codex
95
+ * case (rendering an exit-2 block a pre-0.135 Codex ignores, while dishonestly
96
+ * marking it `enforced`), so the adapter stays faithful to what the payload says.
86
97
  * @param {unknown} version
87
98
  * @returns {boolean}
88
99
  */
@@ -12,11 +12,20 @@
12
12
  * parsed as JSON (the path for allow / advisory / mutation); exit 2 → a "System
13
13
  * Block" that pre-empts the call, with the reason taken from STDERR (stdout is
14
14
  * ignored). So an ENFORCED deny renders as exit 2 (the only real block signal),
15
- * and its `reason` has no home in this stdout-only NativeResponse — it is
16
- * dropped here (a real hook wrapper also writes it to stderr), the same fidelity
17
- * gap Amp's exit-code transport has. Gemini has no native "ask" tier, so `ask`
18
- * maps to an exit-0 advisory `decision: "deny"` the model sees but that does not
19
- * hard-block.
15
+ * with its `reason` carried on `NativeResponse.stderr` (which `emit` writes to fd
16
+ * 2) so the System Block is never shown with no rationale. Gemini has no native
17
+ * "ask" tier, so `ask` maps to an exit-0 advisory `decision: "deny"` the model
18
+ * sees but that does not hard-block.
19
+ *
20
+ * PIN GAP (sandbox, not adapter — carried here because this is the module that
21
+ * knows the host is pin-dependent): Gemini CLI's system settings file wins over
22
+ * other settings FILES, but environment variables and CLI argv still override it,
23
+ * and `GEMINI_CLI_SYSTEM_SETTINGS_PATH` redirects WHERE the system file is read
24
+ * from (geminicli.com/docs/reference/configuration). So mounting the pinned
25
+ * system settings file read-only is NOT sufficient on Gemini CLI: an agent that
26
+ * controls its own env or launch argv routes around it. The launcher must ALSO
27
+ * fix `GEMINI_CLI_SYSTEM_SETTINGS_PATH` and the per-invocation env/argv the agent
28
+ * can't rewrite. See docs/monitor-invariants.md §Invariant 1.
20
29
  */
21
30
 
22
31
  import {
@@ -28,6 +37,7 @@ import {
28
37
  classifyCallClass,
29
38
  coverageAllowsVeto,
30
39
  canonicalTool,
40
+ lookup,
31
41
  assertAliasTargetsModeled,
32
42
  makeEvent,
33
43
  normalizeVerdict,
@@ -142,7 +152,7 @@ assertAliasTargetsModeled(GEMINI_TOOL_ALIASES);
142
152
  */
143
153
  function geminiCanonicalTool(nativeTool, callClass) {
144
154
  if (nativeTool !== null && callClass === CallClass.BUILTIN) {
145
- const scoped = GEMINI_TOOL_ALIASES[nativeTool];
155
+ const scoped = lookup(GEMINI_TOOL_ALIASES, nativeTool);
146
156
  if (scoped !== undefined) return scoped;
147
157
  }
148
158
  return canonicalTool(nativeTool);
@@ -178,8 +188,10 @@ export function parse(native) {
178
188
  const nativeEvent =
179
189
  typeof raw.hook_event_name === "string" ? raw.hook_event_name : "";
180
190
  const kind =
181
- /** @type {Record<string, string>} */ (NATIVE_TO_KIND)[nativeEvent] ??
182
- EventKind.UNKNOWN;
191
+ lookup(
192
+ /** @type {Record<string, string>} */ (NATIVE_TO_KIND),
193
+ nativeEvent,
194
+ ) ?? EventKind.UNKNOWN;
183
195
  const gating = kind === EventKind.PRE_TOOL || kind === EventKind.POST_TOOL;
184
196
  const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
185
197
  const nativeTool = gating ? asStringOrNull(raw.tool_name) : null;
@@ -216,13 +228,16 @@ export function render(verdict, event, { soleGate = false } = {}) {
216
228
  const vd = normalizeVerdict(verdict);
217
229
  const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
218
230
 
219
- // Enforced deny is a System Block: exit 2, reason carried on stderr (no home
220
- // in this stdout-only response), so no stdout body.
231
+ // Enforced deny is a System Block: exit 2, reason carried on STDERR (Gemini
232
+ // reads its block rationale from stderr, not the ignored stdout body). Carry it
233
+ // on NativeResponse.stderr so `emit` writes it to fd 2 — a genuine block is
234
+ // never surfaced to the user/model with no explanation.
221
235
  if (enforced)
222
236
  return nativeResponse({
223
237
  transport: INTEGRATION_MODE,
224
238
  exit_code: 2,
225
239
  enforced: true,
240
+ ...(vd.reason !== undefined ? { stderr: vd.reason } : {}),
226
241
  });
227
242
 
228
243
  const body =
@@ -54,6 +54,9 @@ export const EventKind = Object.freeze({
54
54
  UNKNOWN: "unknown",
55
55
  });
56
56
 
57
+ /** @type {Set<string>} the valid {@link EventKind} values, for membership checks. */
58
+ const EVENT_KIND_VALUES = new Set(Object.values(EventKind));
59
+
57
60
  /** The normalized verdict decisions a guardrail can return. */
58
61
  export const Decision = Object.freeze({
59
62
  ALLOW: "allow",
@@ -127,7 +130,27 @@ assertAliasTargetsModeled(TOOL_ALIASES);
127
130
  */
128
131
  export function canonicalTool(tool) {
129
132
  if (typeof tool !== "string") return tool;
130
- return /** @type {Record<string, string>} */ (TOOL_ALIASES)[tool] ?? tool;
133
+ return (
134
+ lookup(/** @type {Record<string, string>} */ (TOOL_ALIASES), tool) ?? tool
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Own-property lookup on an object used as a string-keyed map. Returns the value
140
+ * ONLY when `key` is an OWN property; an inherited `Object.prototype` member
141
+ * (`constructor`, `toString`, `valueOf`, `__proto__`, `hasOwnProperty`, …) that
142
+ * an untrusted key could name resolves to `undefined`, never the prototype
143
+ * function. EVERY map keyed by an untrusted native string — a tool name, an
144
+ * agent id, a native event name — MUST resolve through this: a bare `map[key]`
145
+ * index lets a payload named after a prototype member resolve a JS function
146
+ * instead of falling through to the default, silently reclassifying the call.
147
+ * @template T
148
+ * @param {Record<string, T>} map
149
+ * @param {string} key
150
+ * @returns {T|undefined}
151
+ */
152
+ export function lookup(map, key) {
153
+ return Object.hasOwn(map, key) ? map[key] : undefined;
131
154
  }
132
155
 
133
156
  /**
@@ -235,7 +258,11 @@ export function classifyCallClass(tool, native) {
235
258
  if (typeof tool === "string" && /^mcp__?[^_]/.test(tool))
236
259
  return CallClass.MCP;
237
260
  const ctx = native ? native.mcp_context : undefined;
238
- if (ctx !== null && typeof ctx === "object") return CallClass.MCP;
261
+ // A plain object mcp_context is the MCP marker; an array is NOT (typeof [] is
262
+ // "object", so a bare typeof test over-classifies an array-valued field as MCP
263
+ // and silently downgrades a legitimate veto).
264
+ if (ctx !== null && typeof ctx === "object" && !Array.isArray(ctx))
265
+ return CallClass.MCP;
239
266
  return CallClass.BUILTIN;
240
267
  }
241
268
 
@@ -262,6 +289,7 @@ export function classifyCallClass(tool, native) {
262
289
  * @property {number} exit_code process exit code carrying the decision (0 = proceed)
263
290
  * @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
264
291
  * @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
292
+ * @property {string} [stderr] text the host reads from STDERR — the block reason on a transport (e.g. Gemini CLI's exit-2 System Block) that takes its rationale from stderr rather than the stdout body. The caller writes it to fd 2 before exiting.
265
293
  */
266
294
 
267
295
  /**
@@ -314,6 +342,20 @@ export function makeEvent({
314
342
  this_call_vetoable,
315
343
  meta,
316
344
  }) {
345
+ // Fail loud on an internally-malformed event: an adapter that hands makeEvent a
346
+ // typo'd event kind ("pre-tool") or a non-boolean vetoable flag would otherwise
347
+ // ship an event that downstream `switch(event)` and the `=== false` veto-honesty
348
+ // guard silently mis-handle (a non-boolean `this_call_vetoable` reads as
349
+ // vetoable — a fail-open seam). These are produced in-process, so a bad value is
350
+ // a bug, not untrusted input to tolerate.
351
+ if (!EVENT_KIND_VALUES.has(event))
352
+ throw new Error(
353
+ `control-plane: makeEvent got an unmodeled event kind ${JSON.stringify(event)} (use EventKind.UNKNOWN for an unmapped native event)`,
354
+ );
355
+ if (typeof this_call_vetoable !== "boolean")
356
+ throw new TypeError(
357
+ `control-plane: makeEvent this_call_vetoable must be a boolean, got ${typeof this_call_vetoable}`,
358
+ );
317
359
  /** @type {ToolCallEvent} */
318
360
  const evt = {
319
361
  schema_version: SCHEMA_VERSION,
@@ -359,6 +401,23 @@ export function normalizeVerdict(verdict) {
359
401
  return out;
360
402
  }
361
403
 
404
+ /**
405
+ * Render an untrusted, rejected `decision` value for the clamp note. Prefers the
406
+ * JSON form (so a string reads back quoted, matching the note's contract) but
407
+ * falls back to `String(...)` for a value JSON cannot serialize — a BigInt would
408
+ * otherwise make `JSON.stringify` THROW, defeating the whole fail-to-ask purpose
409
+ * of the clamp on exactly the malformed input it exists to neutralize.
410
+ * @param {unknown} decision
411
+ * @returns {string}
412
+ */
413
+ function stringifyRejected(decision) {
414
+ try {
415
+ return JSON.stringify(decision) ?? String(decision);
416
+ } catch {
417
+ return String(decision);
418
+ }
419
+ }
420
+
362
421
  /**
363
422
  * Harden an UNTRUSTED {@link Verdict} — one authored by a separate
364
423
  * monitor/judge process (e.g. claude-guard's scrub-monitor-response) — before
@@ -437,7 +496,7 @@ export function sanitizeVerdict(verdict, sanitizeText) {
437
496
  if (!valid) {
438
497
  // The rejected value is itself untrusted text, so it is scrubbed before
439
498
  // being embedded in the clamp note.
440
- const rejected = scrub("decision", JSON.stringify(decision) ?? "undefined");
499
+ const rejected = scrub("decision", stringifyRejected(decision));
441
500
  const note = `[control-plane: invalid verdict decision ${rejected} clamped to "ask"]`;
442
501
  out.reason = out.reason === undefined ? note : `${out.reason} ${note}`;
443
502
  }
@@ -499,10 +558,16 @@ export function asString(value, fallback) {
499
558
  /**
500
559
  * Assemble a {@link NativeResponse}, omitting an absent `stdout` so a pure
501
560
  * exit-code transport carries no `stdout` key.
502
- * @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown }} parts
561
+ * @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown, stderr?: string }} parts
503
562
  * @returns {NativeResponse}
504
563
  */
505
- export function nativeResponse({ transport, exit_code, enforced, stdout }) {
564
+ export function nativeResponse({
565
+ transport,
566
+ exit_code,
567
+ enforced,
568
+ stdout,
569
+ stderr,
570
+ }) {
506
571
  /** @type {NativeResponse} */
507
572
  const out = {
508
573
  transport: /** @type {NativeResponse["transport"]} */ (transport),
@@ -510,5 +575,6 @@ export function nativeResponse({ transport, exit_code, enforced, stdout }) {
510
575
  enforced,
511
576
  };
512
577
  if (stdout !== undefined) out.stdout = stdout;
578
+ if (stderr !== undefined) out.stderr = stderr;
513
579
  return out;
514
580
  }
@@ -56,7 +56,8 @@
56
56
  "native": {
57
57
  "transport": "external_hook",
58
58
  "exit_code": 2,
59
- "enforced": true
59
+ "enforced": true,
60
+ "stderr": "blocked destructive command"
60
61
  }
61
62
  },
62
63
  "ask": {
@@ -238,7 +239,8 @@
238
239
  "native": {
239
240
  "transport": "external_hook",
240
241
  "exit_code": 2,
241
- "enforced": true
242
+ "enforced": true,
243
+ "stderr": "prompt requests credential exfiltration"
242
244
  }
243
245
  },
244
246
  "ask": {
package/src/registry.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  * is no second spelling to reconcile.
15
15
  */
16
16
 
17
+ import { lookup } from "./control-plane.mjs";
17
18
  import { claudeAdapter } from "./adapters/claude.mjs";
18
19
  import { codexAdapter } from "./adapters/codex.mjs";
19
20
  import { ampAdapter } from "./adapters/amp.mjs";
@@ -71,7 +72,7 @@ assertRegistryConsistent(ADAPTERS);
71
72
  * @returns {Adapter}
72
73
  */
73
74
  export function adapterFor(id) {
74
- const adapter = ADAPTERS[id];
75
+ const adapter = lookup(ADAPTERS, id);
75
76
  if (adapter === undefined)
76
77
  throw new Error(
77
78
  `registry: no adapter for agent id ${JSON.stringify(id)} (known: ${AGENT_IDS.join(", ")})`,
@@ -3,6 +3,17 @@
3
3
  * `semver.coerce` normalizes a patch/prerelease/build-tagged version to its
4
4
  * release core; anything it can't coerce to a valid version (missing, empty,
5
5
  * garbage) yields `null` and is treated as too old — fail closed to advisory.
6
+ *
7
+ * TRUST BOUNDARY (sandbox, not adapter): `version` is read from the hook stdin
8
+ * payload, so a payload that under-reports it downgrades enforcement to advisory
9
+ * (a deny renders exit 0). The adapter cannot distinguish a spoofed version from
10
+ * a genuinely old Codex — the ground-truth version lives outside the payload —
11
+ * so anti-spoofing belongs to the sandbox that controls what the hook is fed
12
+ * (the same "pin it from outside" posture the managed-config mount enforces for
13
+ * the hook binary; see docs/monitor-invariants.md §Invariant 1). Coercing an
14
+ * absent/garbage version to enforcing HERE would break the legitimate old-Codex
15
+ * case (rendering an exit-2 block a pre-0.135 Codex ignores, while dishonestly
16
+ * marking it `enforced`), so the adapter stays faithful to what the payload says.
6
17
  * @param {unknown} version
7
18
  * @returns {boolean}
8
19
  */
@@ -16,6 +16,21 @@ export function assertAliasTargetsModeled(aliases: Record<string, string>): void
16
16
  * @returns {string|null} the canonical name, or the native name verbatim
17
17
  */
18
18
  export function canonicalTool(tool: string | null): string | null;
19
+ /**
20
+ * Own-property lookup on an object used as a string-keyed map. Returns the value
21
+ * ONLY when `key` is an OWN property; an inherited `Object.prototype` member
22
+ * (`constructor`, `toString`, `valueOf`, `__proto__`, `hasOwnProperty`, …) that
23
+ * an untrusted key could name resolves to `undefined`, never the prototype
24
+ * function. EVERY map keyed by an untrusted native string — a tool name, an
25
+ * agent id, a native event name — MUST resolve through this: a bare `map[key]`
26
+ * index lets a payload named after a prototype member resolve a JS function
27
+ * instead of falling through to the default, silently reclassifying the call.
28
+ * @template T
29
+ * @param {Record<string, T>} map
30
+ * @param {string} key
31
+ * @returns {T|undefined}
32
+ */
33
+ export function lookup<T>(map: Record<string, T>, key: string): T | undefined;
19
34
  /**
20
35
  * Whether a coverage status PERMITS an adapter to mark a call in that class
21
36
  * `this_call_vetoable: true`. Only a hook confirmed to fire does: COVERED, or
@@ -70,6 +85,7 @@ export function classifyCallClass(tool: string | null, native?: Record<string, u
70
85
  * @property {number} exit_code process exit code carrying the decision (0 = proceed)
71
86
  * @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
72
87
  * @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
88
+ * @property {string} [stderr] text the host reads from STDERR — the block reason on a transport (e.g. Gemini CLI's exit-2 System Block) that takes its rationale from stderr rather than the stdout body. The caller writes it to fd 2 before exiting.
73
89
  */
74
90
  /**
75
91
  * A normalized, agent-agnostic view of one agent event.
@@ -188,14 +204,15 @@ export function asString(value: unknown, fallback: string): string;
188
204
  /**
189
205
  * Assemble a {@link NativeResponse}, omitting an absent `stdout` so a pure
190
206
  * exit-code transport carries no `stdout` key.
191
- * @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown }} parts
207
+ * @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown, stderr?: string }} parts
192
208
  * @returns {NativeResponse}
193
209
  */
194
- export function nativeResponse({ transport, exit_code, enforced, stdout }: {
210
+ export function nativeResponse({ transport, exit_code, enforced, stdout, stderr, }: {
195
211
  transport: string;
196
212
  exit_code: number;
197
213
  enforced: boolean;
198
214
  stdout?: unknown;
215
+ stderr?: string;
199
216
  }): NativeResponse;
200
217
  /**
201
218
  * The vendor-neutral control-plane contract.
@@ -379,6 +396,10 @@ export type NativeResponse = {
379
396
  * native JSON body to write to stdout, when the transport uses one
380
397
  */
381
398
  stdout?: unknown;
399
+ /**
400
+ * text the host reads from STDERR — the block reason on a transport (e.g. Gemini CLI's exit-2 System Block) that takes its rationale from stderr rather than the stdout body. The caller writes it to fd 2 before exiting.
401
+ */
402
+ stderr?: string | undefined;
382
403
  };
383
404
  /**
384
405
  * A normalized, agent-agnostic view of one agent event.