agent-control-plane-core 0.5.2 → 0.6.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.
@@ -76,63 +76,54 @@ export function assertGatedKinds(kinds: Set<string>, agent: string): void;
76
76
  export function isCoverageStatus(status: unknown): boolean;
77
77
  /**
78
78
  * Classify a tool call into a {@link CallClass} from what a single pre-tool
79
- * payload reveals. Only MCP is reliably detectable here — from the tool NAME
80
- * (`mcp__server__tool` / `mcp_server_tool`) or an explicit `mcp_context` object
81
- * on the native payload. Subagent- and resumed-session calls carry no universal
82
- * signal in a lone pre-tool event (detecting them needs the live probe of
83
- * item ⑤), so they are NOT distinguished here and fall through to BUILTIN — an
84
- * adapter whose host leaves those classes un-gated relies on the sandbox, not
85
- * this classifier. An adapter feeds the result into `coverageAllowsVeto(this.
86
- * COVERAGE[class])` so a call in an uncovered/unknown class parses non-vetoable.
79
+ * payload reveals. Two classes are detectable here:
80
+ *
81
+ * - MCP, from the tool NAME (`mcp__server__tool` / `mcp_server_tool`) or an
82
+ * explicit `mcp_context` object on the native payload. Checked FIRST: an MCP
83
+ * call made from inside a subagent is still routed through the host's MCP
84
+ * surface, and that is the axis a host most often leaves un-gated.
85
+ * - SUBAGENT, from a non-empty `agent_type` on the payload — the field a host
86
+ * stamps on a call made by a subagent rather than the main thread. Claude Code
87
+ * carries it (with `agent_id`) on its hook inputs, and a live Codex probe
88
+ * ([X3] in `docs/hook-coverage-matrix.md`) found both fields present on a
89
+ * subagent's `PreToolUse` and ABSENT on the main thread's, with the `agent_id`
90
+ * matching the one `SubagentStart` announced. Absence is what makes the field
91
+ * a DISCRIMINATOR rather than decoration, which is why it is read here.
92
+ *
93
+ * RESUMED is not detectable from a tool payload on any host measured so far.
94
+ * Codex does announce a resumed session — `SessionStart` carries
95
+ * `source: "resume"` ([X4]) — but that is a different event, and correlating it
96
+ * would mean keeping cross-event state this package deliberately does not have:
97
+ * `parse` is a pure function of one payload. So a resumed session's call falls
98
+ * through to BUILTIN (or MCP) and is judged by that row. An adapter feeds the
99
+ * result into `coverageAllowsVeto(this.COVERAGE[class])` so a call in an
100
+ * uncovered/unknown class parses non-vetoable.
101
+ *
102
+ * The fail-closed reading of a ❓ row therefore reaches BUILTIN, MCP, and — on a
103
+ * host that stamps `agent_type` — SUBAGENT. An adapter's RESUMED row is still
104
+ * never selected, so declaring it UNKNOWN would not make a resumed session's
105
+ * call non-vetoable: it is judged by the row its TOOL selects. Honouring that
106
+ * row would mean taking the minimum status across the classes this classifier
107
+ * cannot separate — which collapses BUILTIN to UNKNOWN and disables enforcement
108
+ * outright for every adapter that has a ❓ there, so it is deliberately not
109
+ * done. Where a host's resumed coverage is unmeasured, that gap is the
110
+ * sandbox's to cover, and the row stands as documentation.
87
111
  * @param {string|null} tool tool name (null for non-tool events)
88
- * @param {Record<string, unknown>} [native] the raw native payload, for `mcp_context`
112
+ * @param {Record<string, unknown>} [native] the raw native payload, for `mcp_context` and `agent_type`
89
113
  * @returns {string} a {@link CallClass} value
90
114
  */
91
115
  export function classifyCallClass(tool: string | null, native?: Record<string, unknown>): string;
92
116
  /**
93
- * @typedef {object} EventMeta
94
- * @property {string} agent producing agent id ("claude", "codex", …)
95
- * @property {string} native_event original native event name, preserved verbatim
96
- * @property {string} [native_tool] original native tool name, preserved verbatim when `event.tool` was canonicalized (present iff the event carries a tool)
97
- * @property {"external_hook"|"in_process"|"observe_only"} integration_mode how the guardrail attaches
98
- * @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)
99
- * @property {string} [session_id]
100
- * @property {string} [cwd]
101
- * @property {string} [permission_mode]
102
- * @property {string} [transcript_path]
103
- * @property {Record<string, unknown>} passthrough unmodelled native top-level fields, verbatim
104
- */
105
- /**
106
- * The result of rendering a {@link Verdict} for a specific agent's transport —
107
- * the ATTACH mechanism, not the decision. A caller applies whichever channels
108
- * are present: write `stdout`, exit with `exit_code`, or `throw`.
109
- * @typedef {object} NativeResponse
110
- * @property {"external_hook"|"in_process"|"observe_only"} transport
111
- * @property {number} exit_code process exit code carrying the decision (0 = proceed)
112
- * @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
113
- * @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
114
- * @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.
115
- */
116
- /**
117
- * A normalized, agent-agnostic view of one agent event.
118
- * @typedef {object} ToolCallEvent
119
- * @property {number} schema_version stamped {@link SCHEMA_VERSION}
120
- * @property {"pre_tool"|"post_tool"|"prompt_submit"|"session_start"|"unknown"} event
121
- * @property {string|null} tool CANONICAL tool name — a native alias (e.g. Gemini's `run_shell_command`) is normalized to its {@link MODELED_TOOLS} canon (`Bash`); the raw native name is preserved on `meta.native_tool`. An unknown tool passes through verbatim. null for prompt/session events.
122
- * @property {Record<string, unknown>} input passthrough tool input; a submitted prompt is folded into `input.prompt`
123
- * @property {unknown} [response] tool output, post_tool only (string or structured), verbatim
124
- * @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`)
125
- * @property {EventMeta} meta
126
- */
127
- /**
128
- * A normalized guardrail decision.
129
- * @typedef {object} Verdict
130
- * @property {"allow"|"deny"|"ask"} decision
131
- * @property {Record<string, unknown>} [mutated_input] replacement tool input (pre_tool)
132
- * @property {unknown} [mutated_output] replacement tool output (post_tool) — the normalized channel for a PostToolUse content transform (redaction/sanitize); a string or the tool's structured output, verbatim. An adapter renders it into whatever native output-mutation channel the host has, or drops it when the host has none (the same per-adapter fidelity gap `reason` has on Amp).
133
- * @property {string} [additional_context] extra context to splice into the agent's stream
134
- * @property {string} [reason] human-readable rationale (shown on deny/ask)
117
+ * A genuinely immutable set of `values`, for a row a consumer can reach.
118
+ *
119
+ * `Object.freeze` does not freeze a Set's CONTENTS — `frozen.delete(x)` still
120
+ * succeeds — and these rows are shared between adapters, so one `delete` would
121
+ * make several declarations report a channel their renders still discard. The
122
+ * wrapper holds the Set privately and exposes only the read half, frozen.
123
+ * @param {Iterable<string>} values
124
+ * @returns {ReadonlySet<string>}
135
125
  */
126
+ export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
136
127
  /**
137
128
  * The translator for one agent's protocol. `parse` maps a native event to a
138
129
  * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
@@ -144,6 +135,8 @@ export function classifyCallClass(tool: string | null, native?: Record<string, u
144
135
  * @property {Record<string, "covered"|"partial"|"uncovered"|"unknown">} COVERAGE per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
145
136
  * @property {(native: any) => ToolCallEvent} parse
146
137
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
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 {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.
147
140
  */
148
141
  /**
149
142
  * Build a normalized {@link ToolCallEvent}, stamping the schema version. Pure —
@@ -208,6 +201,26 @@ export function sanitizeVerdict(verdict: unknown, sanitizeText: (text: string) =
208
201
  * @returns {Record<string, unknown>}
209
202
  */
210
203
  export function collectPassthrough(native: Record<string, unknown>, consumed: Set<string>): Record<string, unknown>;
204
+ /**
205
+ * Build the {@link EventMeta} base every adapter shares: the four required
206
+ * fields, whichever {@link STANDARD_META_FIELDS} the payload carries, and the
207
+ * unmodelled remainder in `passthrough`. A mapped field is consumed here too, so
208
+ * a value that reached `meta` can never also appear in `meta.passthrough`.
209
+ *
210
+ * A non-string value leaves its field ABSENT rather than stamping a number or
211
+ * null onto a contract field consumers read as text. The result is deliberately
212
+ * mutable: the caller adds the agent-specific `native_tool` after the base.
213
+ * @param {{ agent: string, native_event: string, integration_mode: string, primary_gate_present: boolean, native: Record<string, unknown>, consumed: Set<string> }} parts
214
+ * @returns {EventMeta}
215
+ */
216
+ export function baseMeta({ agent, native_event, integration_mode, primary_gate_present, native, consumed, }: {
217
+ agent: string;
218
+ native_event: string;
219
+ integration_mode: string;
220
+ primary_gate_present: boolean;
221
+ native: Record<string, unknown>;
222
+ consumed: Set<string>;
223
+ }): EventMeta;
211
224
  /**
212
225
  * The value if it is a plain (non-array) object, else `{}`.
213
226
  * @param {unknown} value
@@ -275,6 +288,14 @@ export function nativeResponse({ transport, exit_code, enforced, stdout, stderr,
275
288
  * field, a new modeled tool, a new tool alias) is backward-compatible and stays
276
289
  * at v1; RENAMING or REMOVING a field, or changing a decision/event vocabulary,
277
290
  * is breaking and bumps the version.
291
+ *
292
+ * That version covers the WIRE shapes an event or verdict carries, not the
293
+ * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
294
+ * member breaks every third-party adapter while every event on the wire stays
295
+ * 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.
278
299
  */
279
300
  /** Wire identifier for this schema version; bump on a breaking shape change. */
280
301
  export const CONTROL_PLANE_SCHEMA: "control-plane/v1";
@@ -399,6 +420,111 @@ export const CoverageStatus: Readonly<{
399
420
  UNCOVERED: "uncovered";
400
421
  UNKNOWN: "unknown";
401
422
  }>;
423
+ /**
424
+ * @typedef {object} EventMeta
425
+ * @property {string} agent producing agent id ("claude", "codex", …)
426
+ * @property {string} native_event original native event name, preserved verbatim
427
+ * @property {string} [native_tool] original native tool name, preserved verbatim when `event.tool` was canonicalized (present iff the event carries a tool)
428
+ * @property {"external_hook"|"in_process"|"observe_only"} integration_mode how the guardrail attaches
429
+ * @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)
430
+ * @property {string} [session_id]
431
+ * @property {string} [cwd]
432
+ * @property {string} [permission_mode]
433
+ * @property {string} [transcript_path]
434
+ * @property {Record<string, unknown>} passthrough unmodelled native top-level fields, verbatim
435
+ */
436
+ /**
437
+ * The result of rendering a {@link Verdict} for a specific agent's transport —
438
+ * the ATTACH mechanism, not the decision. A caller applies whichever channels
439
+ * are present: write `stdout`, exit with `exit_code`, or `throw`.
440
+ * @typedef {object} NativeResponse
441
+ * @property {"external_hook"|"in_process"|"observe_only"} transport
442
+ * @property {number} exit_code process exit code carrying the decision (0 = proceed)
443
+ * @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
444
+ * @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
445
+ * @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.
446
+ */
447
+ /**
448
+ * A normalized, agent-agnostic view of one agent event.
449
+ * @typedef {object} ToolCallEvent
450
+ * @property {number} schema_version stamped {@link SCHEMA_VERSION}
451
+ * @property {"pre_tool"|"post_tool"|"prompt_submit"|"session_start"|"unknown"} event
452
+ * @property {string|null} tool CANONICAL tool name — a native alias (e.g. Gemini's `run_shell_command`) is normalized to its {@link MODELED_TOOLS} canon (`Bash`); the raw native name is preserved on `meta.native_tool`. An unknown tool passes through verbatim. null for prompt/session events.
453
+ * @property {Record<string, unknown>} input passthrough tool input; a submitted prompt is folded into `input.prompt`
454
+ * @property {unknown} [response] tool output, post_tool only (string or structured), verbatim
455
+ * @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`)
456
+ * @property {EventMeta} meta
457
+ */
458
+ /**
459
+ * A normalized guardrail decision.
460
+ * @typedef {object} Verdict
461
+ * @property {"allow"|"deny"|"ask"} decision
462
+ * @property {Record<string, unknown>} [mutated_input] replacement tool input (pre_tool)
463
+ * @property {unknown} [mutated_output] replacement tool output (post_tool) — the normalized channel for a PostToolUse content transform (redaction/sanitize); a string or the tool's structured output, verbatim. An adapter renders it into whatever native output-mutation channel the host has, or drops it when the host has none (the same per-adapter fidelity gap `additional_context` has on Codex).
464
+ * @property {string} [additional_context] extra context to splice into the agent's stream
465
+ * @property {string} [reason] human-readable rationale (shown on deny/ask)
466
+ */
467
+ /**
468
+ * The {@link Verdict} fields that carry CONTENT into the agent's stream, as
469
+ * opposed to the decision itself. Every adapter either renders each one into a
470
+ * native channel or has no channel for it and drops it; `UNRENDERED_FIELDS`
471
+ * declares which, and the conformance harness holds each adapter to its own
472
+ * declaration. `reason` is not listed: it is only live on deny/ask, so it is not
473
+ * probeable from a single abstaining verdict the way these three are.
474
+ */
475
+ export const VERDICT_CONTENT_FIELDS: readonly string[];
476
+ /**
477
+ * The `UNRENDERED_FIELDS` row every adapter uses for {@link EventKind.UNKNOWN}.
478
+ * An event the adapter could not name is one whose host channels nobody
479
+ * established, so claiming any of them is the same fail-open `assertGatedKinds`
480
+ * refuses for the veto: the caller reads a mutation as applied while the host
481
+ * ignores the key it was written into.
482
+ */
483
+ export const UNRENDERED_ON_UNKNOWN: ReadonlySet<string>;
484
+ /**
485
+ * The optional {@link EventMeta} string fields a host may carry, and the SSOT
486
+ * {@link baseMeta} reads off every native payload.
487
+ *
488
+ * PROBLEM CLASS — an adapter drops a normalized metadata field. Each adapter
489
+ * hand-copied these, one copy omitted `permission_mode`, and a guardrail keyed
490
+ * on `event.meta.permission_mode` then read `undefined` for that agent alone and
491
+ * took its no-value branch. Nothing distinguished "this host never sends the
492
+ * field" from "this adapter forgot it". Every adapter now maps the WHOLE set
493
+ * through {@link baseMeta}, so a field a host does not send is simply absent and
494
+ * no adapter carries an omission of its own.
495
+ */
496
+ export const STANDARD_META_FIELDS: readonly string[];
497
+ /**
498
+ * The translator for one agent's protocol. `parse` maps a native event to a
499
+ * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
500
+ * integration mode / enforcement flags on `meta`); `render` maps a {@link Verdict}
501
+ * to that agent's native transport ({@link NativeResponse}), not just a JSON body.
502
+ */
503
+ export type Adapter = {
504
+ AGENT: string;
505
+ INTEGRATION_MODE: "external_hook" | "in_process" | "observe_only";
506
+ /**
507
+ * per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
508
+ */
509
+ COVERAGE: Record<string, "covered" | "partial" | "uncovered" | "unknown">;
510
+ parse: (native: any) => ToolCallEvent;
511
+ render: (verdict: Verdict, event: ToolCallEvent, options?: {
512
+ soleGate?: boolean;
513
+ }) => NativeResponse;
514
+ /**
515
+ * 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
+ */
517
+ NATIVE_EVENT_FOR?: Record<string, string | undefined> | undefined;
518
+ /**
519
+ * 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
+ */
521
+ UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
522
+ };
523
+ export type CoverageStatusValue = "covered" | "partial" | "uncovered" | "unknown";
524
+ /**
525
+ * a per-{@link CallClass} coverage map (an adapter's `COVERAGE`)
526
+ */
527
+ export type CoverageMap = Record<string, CoverageStatusValue>;
402
528
  export type EventMeta = {
403
529
  /**
404
530
  * producing agent id ("claude", "codex", …)
@@ -490,7 +616,7 @@ export type Verdict = {
490
616
  */
491
617
  mutated_input?: Record<string, unknown> | undefined;
492
618
  /**
493
- * replacement tool output (post_tool) — the normalized channel for a PostToolUse content transform (redaction/sanitize); a string or the tool's structured output, verbatim. An adapter renders it into whatever native output-mutation channel the host has, or drops it when the host has none (the same per-adapter fidelity gap `reason` has on Amp).
619
+ * replacement tool output (post_tool) — the normalized channel for a PostToolUse content transform (redaction/sanitize); a string or the tool's structured output, verbatim. An adapter renders it into whatever native output-mutation channel the host has, or drops it when the host has none (the same per-adapter fidelity gap `additional_context` has on Codex).
494
620
  */
495
621
  mutated_output?: unknown;
496
622
  /**
@@ -502,26 +628,3 @@ export type Verdict = {
502
628
  */
503
629
  reason?: string | undefined;
504
630
  };
505
- /**
506
- * The translator for one agent's protocol. `parse` maps a native event to a
507
- * {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
508
- * integration mode / enforcement flags on `meta`); `render` maps a {@link Verdict}
509
- * to that agent's native transport ({@link NativeResponse}), not just a JSON body.
510
- */
511
- export type Adapter = {
512
- AGENT: string;
513
- INTEGRATION_MODE: "external_hook" | "in_process" | "observe_only";
514
- /**
515
- * per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
516
- */
517
- COVERAGE: Record<string, "covered" | "partial" | "uncovered" | "unknown">;
518
- parse: (native: any) => ToolCallEvent;
519
- render: (verdict: Verdict, event: ToolCallEvent, options?: {
520
- soleGate?: boolean;
521
- }) => NativeResponse;
522
- };
523
- export type CoverageStatusValue = "covered" | "partial" | "uncovered" | "unknown";
524
- /**
525
- * a per-{@link CallClass} coverage map (an adapter's `COVERAGE`)
526
- */
527
- export type CoverageMap = Record<string, CoverageStatusValue>;
package/types/index.d.mts CHANGED
@@ -2,6 +2,6 @@ export * from "./control-plane.mjs";
2
2
  export { codexAdapter } from "./adapters/codex.mjs";
3
3
  export { ampAdapter } from "./adapters/amp.mjs";
4
4
  export { claudeAdapter, HookEvent } from "./adapters/claude.mjs";
5
- export { geminiAdapter, GEMINI_TOOL_ALIASES } from "./adapters/gemini.mjs";
5
+ export { geminiAdapter, GEMINI_TOOL_ALIASES, POST_TOOL_REDACTION_UNSUPPORTED } from "./adapters/gemini.mjs";
6
6
  export { ADAPTERS, AGENT_IDS, adapterFor, assertRegistryConsistent } from "./registry.mjs";
7
7
  export { runAdapterConformance, assertCoverageWellFormed, assertToolAliasesCovered, assertAliasedInputsCanonical } from "./conformance.mjs";