agent-control-plane-core 0.1.0 → 0.2.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/bin/amp-hook.mjs +0 -0
- package/bin/claude-hook.mjs +0 -0
- package/bin/codex-hook.mjs +0 -0
- package/bin/gemini-hook.mjs +0 -0
- package/package.json +23 -14
- package/src/adapters/amp.mjs +29 -3
- package/src/adapters/claude.mjs +42 -4
- package/src/adapters/codex.mjs +37 -3
- package/src/adapters/gemini.mjs +138 -13
- package/src/conformance.mjs +134 -5
- package/src/control-plane.mjs +253 -38
- package/src/fixtures/amp.json +3 -0
- package/src/fixtures/claude.json +23 -0
- package/src/fixtures/codex.json +68 -0
- package/src/fixtures/gemini.json +257 -4
- package/src/index.mjs +14 -3
- package/src/registry.mjs +80 -0
- package/types/adapters/amp.d.mts +38 -0
- package/types/adapters/claude.d.mts +56 -0
- package/types/adapters/codex.d.mts +56 -0
- package/types/adapters/gemini.d.mts +73 -0
- package/types/conformance.d.mts +83 -0
- package/types/control-plane.d.mts +454 -0
- package/types/index.d.mts +7 -0
- package/types/registry.d.mts +35 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a raw Gemini CLI hook payload into a normalized {@link ToolCallEvent}.
|
|
3
|
+
* Never throws on an unmodelled event type or tool-input field. A payload comes
|
|
4
|
+
* from a hooks-capable Gemini (v0.26.0+) whose `BeforeTool` can pre-empt builtin
|
|
5
|
+
* tools via exit 2. But MCP firing is only medium-confidence (COVERAGE.mcp is
|
|
6
|
+
* UNKNOWN), so an MCP-sourced call — flagged by `mcp_context` or an `mcp_`-named
|
|
7
|
+
* tool — parses non-vetoable until an item-⑤ probe confirms the hook fires.
|
|
8
|
+
* `BeforeAgent` (fires after the user submits a prompt, before planning) maps to
|
|
9
|
+
* `prompt_submit`: no tool, the submitted text folded into `input.prompt`.
|
|
10
|
+
* @param {any} native
|
|
11
|
+
* @returns {ToolCallEvent}
|
|
12
|
+
*/
|
|
13
|
+
export function parse(native: any): ToolCallEvent;
|
|
14
|
+
/**
|
|
15
|
+
* Render into Gemini CLI's native external-hook transport. An enforceable deny
|
|
16
|
+
* renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
|
|
17
|
+
* as "same as decision: deny" — it aborts the turn); everything else exits 0
|
|
18
|
+
* with a JSON decision body (or none, when `allow` abstains). `soleGate`
|
|
19
|
+
* (default false) is the same dangerous opt-in as the other adapters: it makes
|
|
20
|
+
* an `allow` emit the real `decision: "allow"` instead of abstaining (tool
|
|
21
|
+
* events only — BeforeAgent documents no allow behavior to opt into).
|
|
22
|
+
* @param {Verdict} verdict
|
|
23
|
+
* @param {ToolCallEvent} event
|
|
24
|
+
* @param {{ soleGate?: boolean }} [options]
|
|
25
|
+
* @returns {NativeResponse}
|
|
26
|
+
*/
|
|
27
|
+
export function render(verdict: Verdict, event: ToolCallEvent, { soleGate }?: {
|
|
28
|
+
soleGate?: boolean;
|
|
29
|
+
}): NativeResponse;
|
|
30
|
+
/** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
|
|
31
|
+
/** @typedef {import("../control-plane.mjs").Verdict} Verdict */
|
|
32
|
+
/** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
|
|
33
|
+
/** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
|
|
34
|
+
export const AGENT: "gemini";
|
|
35
|
+
export const INTEGRATION_MODE: "external_hook";
|
|
36
|
+
/**
|
|
37
|
+
* Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `BeforeTool` gates
|
|
38
|
+
* builtins on v0.26+ (COVERED). MCP routing through the same matcher is only
|
|
39
|
+
* MEDIUM-confidence and unproven, subagent firing for a loaded agent is
|
|
40
|
+
* undocumented, and resumed-session behavior has no source — all three are
|
|
41
|
+
* UNKNOWN, held at fail-closed until an item-⑤ probe upgrades them. A guessed ✅
|
|
42
|
+
* here would be a silent fail-open.
|
|
43
|
+
*/
|
|
44
|
+
/** @type {import("../control-plane.mjs").CoverageMap} */
|
|
45
|
+
export const COVERAGE: import("../control-plane.mjs").CoverageMap;
|
|
46
|
+
/** Gemini CLI native hook event names (the `hook_event_name` field). */
|
|
47
|
+
export const HookEvent: Readonly<{
|
|
48
|
+
BEFORE_TOOL: "BeforeTool";
|
|
49
|
+
AFTER_TOOL: "AfterTool";
|
|
50
|
+
BEFORE_AGENT: "BeforeAgent";
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* Adapter-scoped native-builtin → canonical tool aliases, applied ONLY when a
|
|
54
|
+
* call classifies as BUILTIN. These names are too generic for the global
|
|
55
|
+
* {@link TOOL_ALIASES} (an MCP server could export a `read_file`), but Gemini
|
|
56
|
+
* CLI removes the ambiguity at parse time: every MCP tool is unconditionally
|
|
57
|
+
* registered — and surfaced in hook payloads — under its fully qualified
|
|
58
|
+
* `mcp_{server}_{tool}` name (gemini-cli docs/tools/mcp-server.md), so a bare
|
|
59
|
+
* builtin name in `tool_name` can only be the builtin. The builtin's native
|
|
60
|
+
* input fields still pass through verbatim (e.g. read_file's `absolute_path`,
|
|
61
|
+
* not Read's `file_path`) — `meta.native_tool` tells a consumer which field
|
|
62
|
+
* dialect to expect. Targets are pinned to {@link MODELED_TOOLS} at import, and
|
|
63
|
+
* every entry must be witnessed by a gemini conformance fixture
|
|
64
|
+
* (`assertToolAliasesCovered`).
|
|
65
|
+
* @type {Readonly<Record<string, string>>}
|
|
66
|
+
*/
|
|
67
|
+
export const GEMINI_TOOL_ALIASES: Readonly<Record<string, string>>;
|
|
68
|
+
/** @type {import("../control-plane.mjs").Adapter} */
|
|
69
|
+
export const geminiAdapter: import("../control-plane.mjs").Adapter;
|
|
70
|
+
export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
|
|
71
|
+
export type Verdict = import("../control-plane.mjs").Verdict;
|
|
72
|
+
export type EventMeta = import("../control-plane.mjs").EventMeta;
|
|
73
|
+
export type NativeResponse = import("../control-plane.mjs").NativeResponse;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assert an adapter's {@link import("./control-plane.mjs").Adapter.COVERAGE}
|
|
3
|
+
* hook-coverage matrix is well-formed: it classifies EXACTLY the canonical
|
|
4
|
+
* {@link CALL_CLASSES} (no class missing, none unknown) and every value is a
|
|
5
|
+
* valid coverage status. This is the SSOT gate — adding a new call class to the
|
|
6
|
+
* contract fails every adapter until it declares that class, so a coverage hole
|
|
7
|
+
* can't be introduced by omission.
|
|
8
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
9
|
+
* @param {any} assert node:assert/strict (injected)
|
|
10
|
+
*/
|
|
11
|
+
export function assertCoverageWellFormed(adapter: import("./control-plane.mjs").Adapter, assert: any): void;
|
|
12
|
+
/**
|
|
13
|
+
* Assert every tool alias — the global {@link TOOL_ALIASES} entries AND each
|
|
14
|
+
* adapter-scoped alias map — is WITNESSED by a golden fixture: a case whose
|
|
15
|
+
* native tool name equals the alias key and whose normalized `event.tool`
|
|
16
|
+
* equals the canonical target. This is what ties the alias SSOTs to the
|
|
17
|
+
* fixtures: an alias added without a golden payload that exercises it fails
|
|
18
|
+
* here, so an alias "a new judge keys on" can't ship unproven. A global alias
|
|
19
|
+
* may be witnessed by any agent's fixtures; an adapter-scoped alias must be
|
|
20
|
+
* witnessed by THAT agent's fixtures (another agent's payload proves nothing
|
|
21
|
+
* about the scoping adapter's parse). Also cross-checks each witnessed pair:
|
|
22
|
+
* `event.tool` must be either the global {@link canonicalTool} result or the
|
|
23
|
+
* owning adapter's scoped target, so a fixture that preserved `native_tool` but
|
|
24
|
+
* mis-normalized `event.tool` is caught — including a scoped alias applied by
|
|
25
|
+
* the wrong agent's fixtures.
|
|
26
|
+
* @param {any[]} fixturesList the golden fixtures for every shipped adapter
|
|
27
|
+
* @param {any} assert node:assert/strict (injected)
|
|
28
|
+
* @param {Record<string, Record<string, string>>} [adapterAliases] agent id → that adapter's scoped alias map (e.g. `{ gemini: GEMINI_TOOL_ALIASES }`)
|
|
29
|
+
*/
|
|
30
|
+
export function assertToolAliasesCovered(fixturesList: any[], assert: any, adapterAliases?: Record<string, Record<string, string>>): void;
|
|
31
|
+
/**
|
|
32
|
+
* The control-plane conformance harness.
|
|
33
|
+
*
|
|
34
|
+
* Any adapter — the reference claude one, codex, or a future
|
|
35
|
+
* cursor/cline/gemini-cli/aider translator — must pass this against its own
|
|
36
|
+
* golden fixtures. It pins the two directions of the contract:
|
|
37
|
+
*
|
|
38
|
+
* 1. parse is golden: adapter.parse(native) deep-equals the fixture's
|
|
39
|
+
* normalized `event` (and never throws — an adapter that threw would fail
|
|
40
|
+
* here rather than in production).
|
|
41
|
+
* 2. render is golden: for each verdict scenario, adapter.render(verdict,
|
|
42
|
+
* parsedEvent) deep-equals the fixture's native response. An `allow` with
|
|
43
|
+
* the unmutated input renders to the agent's native allow; deny/ask/mutation
|
|
44
|
+
* each render to their native shape.
|
|
45
|
+
* 3. non-vacuity: the fixture set collectively renders an allow, a deny, an
|
|
46
|
+
* ask, AND a mutated_input, so a suite can't pass while silently skipping a
|
|
47
|
+
* decision the contract requires every adapter to express.
|
|
48
|
+
* 4. enforcement honesty: an enforceable deny (`rendered.enforced === true`)
|
|
49
|
+
* MUST carry a real block signal — a non-zero `exit_code` — not just a JSON
|
|
50
|
+
* body the agent is free to ignore. At least one enforced deny must appear,
|
|
51
|
+
* so the honesty check is never vacuous.
|
|
52
|
+
* 5. non-vetoable honesty: when the parsed event's `this_call_vetoable` is
|
|
53
|
+
* false, EVERY render for that case must be `enforced === false` — a
|
|
54
|
+
* guardrail that cannot veto this call must never render as if it did.
|
|
55
|
+
* 6. allow = abstain: by default (no `soleGate` opt-in) every `allow` render
|
|
56
|
+
* is `enforced === false` AND `exit_code === 0` — an "I have no objection"
|
|
57
|
+
* verdict never renders as a block, on any adapter. At least one `allow`
|
|
58
|
+
* must be rendered, so this is never vacuous.
|
|
59
|
+
* 7. coverage honesty: the adapter's COVERAGE matrix is well-formed (item ③),
|
|
60
|
+
* and a fixture case tagged with a `call_class` whose coverage does NOT
|
|
61
|
+
* permit a veto (uncovered/unknown — an ❓ is treated as ❌) MUST parse to
|
|
62
|
+
* `this_call_vetoable: false`. An adapter cannot claim a class is un-gated
|
|
63
|
+
* while parsing its calls as vetoable.
|
|
64
|
+
*
|
|
65
|
+
* `assert` is injected (node:assert/strict) so the harness stays test-framework
|
|
66
|
+
* neutral; it throws on the first mismatch. Returns a summary the caller can
|
|
67
|
+
* assert further on.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
|
|
70
|
+
* @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, enforcedDenySeen: boolean, coverageClassesChecked: Set<string> }}
|
|
71
|
+
*/
|
|
72
|
+
export function runAdapterConformance({ adapter, fixtures, assert }: {
|
|
73
|
+
adapter: import("./control-plane.mjs").Adapter;
|
|
74
|
+
fixtures: any;
|
|
75
|
+
assert: any;
|
|
76
|
+
}): {
|
|
77
|
+
cases: number;
|
|
78
|
+
renders: number;
|
|
79
|
+
decisionsSeen: Set<string>;
|
|
80
|
+
mutationSeen: boolean;
|
|
81
|
+
enforcedDenySeen: boolean;
|
|
82
|
+
coverageClassesChecked: Set<string>;
|
|
83
|
+
};
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-bearing use of MODELED_TOOLS: assert every alias target is a modeled
|
|
3
|
+
* tool, so the alias SSOT can never normalize a native name onto a canon the
|
|
4
|
+
* contract doesn't model. Throws (fail loud) on the first bad target — a typo'd
|
|
5
|
+
* target is a bug, not a silent passthrough. Called at import against
|
|
6
|
+
* {@link TOOL_ALIASES}, and by adapters against their adapter-scoped alias
|
|
7
|
+
* maps; exported so a test can drive both branches.
|
|
8
|
+
* @param {Record<string, string>} aliases
|
|
9
|
+
*/
|
|
10
|
+
export function assertAliasTargetsModeled(aliases: Record<string, string>): void;
|
|
11
|
+
/**
|
|
12
|
+
* Canonicalize a native tool name to its {@link MODELED_TOOLS} equivalent, or
|
|
13
|
+
* return it UNCHANGED when it is not a known alias — an unknown tool is never
|
|
14
|
+
* silently reclassified. `null` (a non-tool event) passes through as `null`.
|
|
15
|
+
* @param {string|null} tool native tool name
|
|
16
|
+
* @returns {string|null} the canonical name, or the native name verbatim
|
|
17
|
+
*/
|
|
18
|
+
export function canonicalTool(tool: string | null): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Whether a coverage status PERMITS an adapter to mark a call in that class
|
|
21
|
+
* `this_call_vetoable: true`. Only a hook confirmed to fire does: COVERED, or
|
|
22
|
+
* PARTIAL (for the tools in its covered subset). UNCOVERED and UNKNOWN both
|
|
23
|
+
* forbid it — an unknown is fail-closed to uncovered, which is the whole point
|
|
24
|
+
* of the matrix. Throws on an unrecognized status (fail loud — a typo must not
|
|
25
|
+
* quietly read as "permitted").
|
|
26
|
+
* @param {string} status a {@link CoverageStatus} value
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function coverageAllowsVeto(status: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* True when `status` is a recognized {@link CoverageStatus} value.
|
|
32
|
+
* @param {unknown} status
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isCoverageStatus(status: unknown): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Classify a tool call into a {@link CallClass} from what a single pre-tool
|
|
38
|
+
* payload reveals. Only MCP is reliably detectable here — from the tool NAME
|
|
39
|
+
* (`mcp__server__tool` / `mcp_server_tool`) or an explicit `mcp_context` object
|
|
40
|
+
* on the native payload. Subagent- and resumed-session calls carry no universal
|
|
41
|
+
* signal in a lone pre-tool event (detecting them needs the live probe of
|
|
42
|
+
* item ⑤), so they are NOT distinguished here and fall through to BUILTIN — an
|
|
43
|
+
* adapter whose host leaves those classes un-gated relies on the sandbox, not
|
|
44
|
+
* this classifier. An adapter feeds the result into `coverageAllowsVeto(this.
|
|
45
|
+
* COVERAGE[class])` so a call in an uncovered/unknown class parses non-vetoable.
|
|
46
|
+
* @param {string|null} tool tool name (null for non-tool events)
|
|
47
|
+
* @param {Record<string, unknown>} [native] the raw native payload, for `mcp_context`
|
|
48
|
+
* @returns {string} a {@link CallClass} value
|
|
49
|
+
*/
|
|
50
|
+
export function classifyCallClass(tool: string | null, native?: Record<string, unknown>): string;
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} EventMeta
|
|
53
|
+
* @property {string} agent producing agent id ("claude", "codex", …)
|
|
54
|
+
* @property {string} native_event original native event name, preserved verbatim
|
|
55
|
+
* @property {string} [native_tool] original native tool name, preserved verbatim when `event.tool` was canonicalized (present iff the event carries a tool)
|
|
56
|
+
* @property {"external_hook"|"in_process"|"observe_only"} integration_mode how the guardrail attaches
|
|
57
|
+
* @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)
|
|
58
|
+
* @property {string} [session_id]
|
|
59
|
+
* @property {string} [cwd]
|
|
60
|
+
* @property {string} [permission_mode]
|
|
61
|
+
* @property {string} [transcript_path]
|
|
62
|
+
* @property {Record<string, unknown>} passthrough unmodelled native top-level fields, verbatim
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* The result of rendering a {@link Verdict} for a specific agent's transport —
|
|
66
|
+
* the ATTACH mechanism, not the decision. A caller applies whichever channels
|
|
67
|
+
* are present: write `stdout`, exit with `exit_code`, or `throw`.
|
|
68
|
+
* @typedef {object} NativeResponse
|
|
69
|
+
* @property {"external_hook"|"in_process"|"observe_only"} transport
|
|
70
|
+
* @property {number} exit_code process exit code carrying the decision (0 = proceed)
|
|
71
|
+
* @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
|
|
72
|
+
* @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* A normalized, agent-agnostic view of one agent event.
|
|
76
|
+
* @typedef {object} ToolCallEvent
|
|
77
|
+
* @property {number} schema_version stamped {@link SCHEMA_VERSION}
|
|
78
|
+
* @property {"pre_tool"|"post_tool"|"prompt_submit"|"session_start"|"unknown"} event
|
|
79
|
+
* @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.
|
|
80
|
+
* @property {Record<string, unknown>} input passthrough tool input; a submitted prompt is folded into `input.prompt`
|
|
81
|
+
* @property {unknown} [response] tool output, post_tool only (string or structured), verbatim
|
|
82
|
+
* @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`)
|
|
83
|
+
* @property {EventMeta} meta
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* A normalized guardrail decision.
|
|
87
|
+
* @typedef {object} Verdict
|
|
88
|
+
* @property {"allow"|"deny"|"ask"} decision
|
|
89
|
+
* @property {Record<string, unknown>} [mutated_input] replacement tool input (pre_tool)
|
|
90
|
+
* @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).
|
|
91
|
+
* @property {string} [additional_context] extra context to splice into the agent's stream
|
|
92
|
+
* @property {string} [reason] human-readable rationale (shown on deny/ask)
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* The translator for one agent's protocol. `parse` maps a native event to a
|
|
96
|
+
* {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
|
|
97
|
+
* integration mode / enforcement flags on `meta`); `render` maps a {@link Verdict}
|
|
98
|
+
* to that agent's native transport ({@link NativeResponse}), not just a JSON body.
|
|
99
|
+
* @typedef {object} Adapter
|
|
100
|
+
* @property {string} AGENT
|
|
101
|
+
* @property {"external_hook"|"in_process"|"observe_only"} INTEGRATION_MODE
|
|
102
|
+
* @property {Record<string, "covered"|"partial"|"uncovered"|"unknown">} COVERAGE per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
|
|
103
|
+
* @property {(native: any) => ToolCallEvent} parse
|
|
104
|
+
* @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
|
|
105
|
+
*/
|
|
106
|
+
/**
|
|
107
|
+
* Build a normalized {@link ToolCallEvent}, stamping the schema version. Pure —
|
|
108
|
+
* adapters pass already-normalized parts. `response` is omitted unless defined
|
|
109
|
+
* so a pre_tool event has no `response` key at all.
|
|
110
|
+
* @param {{ event: string, tool: string|null, input: Record<string, unknown>, response?: unknown, this_call_vetoable: boolean, meta: EventMeta }} parts
|
|
111
|
+
* @returns {ToolCallEvent}
|
|
112
|
+
*/
|
|
113
|
+
export function makeEvent({ event, tool, input, response, this_call_vetoable, meta, }: {
|
|
114
|
+
event: string;
|
|
115
|
+
tool: string | null;
|
|
116
|
+
input: Record<string, unknown>;
|
|
117
|
+
response?: unknown;
|
|
118
|
+
this_call_vetoable: boolean;
|
|
119
|
+
meta: EventMeta;
|
|
120
|
+
}): ToolCallEvent;
|
|
121
|
+
/**
|
|
122
|
+
* Validate and normalize a {@link Verdict}. The decision must be one of
|
|
123
|
+
* allow/deny/ask — a verdict is produced internally, so an out-of-range
|
|
124
|
+
* decision is a bug and throws (fail loud), unlike the pass-through tolerance
|
|
125
|
+
* `parse` extends to untrusted upstream events. Returns a fresh object carrying
|
|
126
|
+
* only the modeled optional fields that are present; never mutates its input.
|
|
127
|
+
* @param {Verdict} verdict
|
|
128
|
+
* @returns {Verdict}
|
|
129
|
+
*/
|
|
130
|
+
export function normalizeVerdict(verdict: Verdict): Verdict;
|
|
131
|
+
/**
|
|
132
|
+
* Harden an UNTRUSTED {@link Verdict} — one authored by a separate
|
|
133
|
+
* monitor/judge process (e.g. claude-guard's scrub-monitor-response) — before
|
|
134
|
+
* it is rendered. Two defenses:
|
|
135
|
+
*
|
|
136
|
+
* 1. Decision clamp (fail-to-ask): a `decision` outside allow/deny/ask
|
|
137
|
+
* becomes `"ask"`, and the clamp is made observable by appending a
|
|
138
|
+
* bracketed note to `reason` naming the rejected value — a malformed
|
|
139
|
+
* monitor answer escalates to a human instead of throwing (the internal
|
|
140
|
+
* strictness of {@link normalizeVerdict}) or silently allowing.
|
|
141
|
+
* 2. Text scrubbing: the caller-supplied `sanitizeText` runs over every
|
|
142
|
+
* monitor-authored PROSE field present — `reason` and
|
|
143
|
+
* `additional_context` (a non-string value in either is dropped; prose
|
|
144
|
+
* channels carry strings). `mutated_input`/`mutated_output` are NOT
|
|
145
|
+
* sanitized: they are data channels (replacement tool input/output the
|
|
146
|
+
* guardrail computed, often deliberately containing the very bytes a text
|
|
147
|
+
* scrubber would mangle), not monitor-authored prose. `mutated_input` is
|
|
148
|
+
* shape-checked only (a non-object is dropped — see the inline note);
|
|
149
|
+
* `mutated_output` is carried verbatim.
|
|
150
|
+
*
|
|
151
|
+
* `sanitizeText` is injected so this module stays dependency-free. It must be
|
|
152
|
+
* a function and must return a string — a sanitizer that eats the value is a
|
|
153
|
+
* bug, so a non-string return throws. Returns a fresh normalized verdict;
|
|
154
|
+
* never mutates its input.
|
|
155
|
+
* @param {unknown} verdict the untrusted verdict object
|
|
156
|
+
* @param {(text: string) => string} sanitizeText
|
|
157
|
+
* @returns {Verdict}
|
|
158
|
+
*/
|
|
159
|
+
export function sanitizeVerdict(verdict: unknown, sanitizeText: (text: string) => string): Verdict;
|
|
160
|
+
/**
|
|
161
|
+
* Return a shallow copy of `native` with the `consumed` keys removed — the
|
|
162
|
+
* unmodelled remainder an adapter carries in `meta.passthrough` so an additive
|
|
163
|
+
* upstream field survives instead of being silently dropped.
|
|
164
|
+
* @param {Record<string, unknown>} native
|
|
165
|
+
* @param {Set<string>} consumed
|
|
166
|
+
* @returns {Record<string, unknown>}
|
|
167
|
+
*/
|
|
168
|
+
export function collectPassthrough(native: Record<string, unknown>, consumed: Set<string>): Record<string, unknown>;
|
|
169
|
+
/**
|
|
170
|
+
* The value if it is a plain (non-array) object, else `{}`.
|
|
171
|
+
* @param {unknown} value
|
|
172
|
+
* @returns {Record<string, unknown>}
|
|
173
|
+
*/
|
|
174
|
+
export function asObject(value: unknown): Record<string, unknown>;
|
|
175
|
+
/**
|
|
176
|
+
* The value if it is a string, else `null`.
|
|
177
|
+
* @param {unknown} value
|
|
178
|
+
* @returns {string|null}
|
|
179
|
+
*/
|
|
180
|
+
export function asStringOrNull(value: unknown): string | null;
|
|
181
|
+
/**
|
|
182
|
+
* The value if it is a string, else `fallback`.
|
|
183
|
+
* @param {unknown} value
|
|
184
|
+
* @param {string} fallback
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
export function asString(value: unknown, fallback: string): string;
|
|
188
|
+
/**
|
|
189
|
+
* Assemble a {@link NativeResponse}, omitting an absent `stdout` so a pure
|
|
190
|
+
* exit-code transport carries no `stdout` key.
|
|
191
|
+
* @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown }} parts
|
|
192
|
+
* @returns {NativeResponse}
|
|
193
|
+
*/
|
|
194
|
+
export function nativeResponse({ transport, exit_code, enforced, stdout }: {
|
|
195
|
+
transport: string;
|
|
196
|
+
exit_code: number;
|
|
197
|
+
enforced: boolean;
|
|
198
|
+
stdout?: unknown;
|
|
199
|
+
}): NativeResponse;
|
|
200
|
+
/**
|
|
201
|
+
* The vendor-neutral control-plane contract.
|
|
202
|
+
*
|
|
203
|
+
* This is the published seam between an agent's native hook/tool-call protocol
|
|
204
|
+
* and claude-guard's guardrails (monitor, deny-match, redaction, sanitizers).
|
|
205
|
+
* It defines TWO normalized shapes — {@link ToolCallEvent} (what an agent is
|
|
206
|
+
* about to / just did) and {@link Verdict} (what the guardrail decided) — plus
|
|
207
|
+
* the {@link Adapter} pair that translates a specific agent's protocol to and
|
|
208
|
+
* from them. Every downstream consumer imports THESE types, never an agent's
|
|
209
|
+
* raw hook JSON; the agent-specific field names live only in that agent's
|
|
210
|
+
* adapter (see adapter-claude.mjs / adapter-codex.mjs).
|
|
211
|
+
*
|
|
212
|
+
* DRIFT DISCIPLINE — the reason the seam earns its keep. An agent protocol
|
|
213
|
+
* drifts additively and independently (N agents, N release cadences). So an
|
|
214
|
+
* adapter's `parse` MUST NOT throw on an event type or tool-input field it does
|
|
215
|
+
* not model: the unmodelled remainder is carried through verbatim (an unknown
|
|
216
|
+
* event becomes {@link EventKind.UNKNOWN} with its native name preserved; extra
|
|
217
|
+
* top-level fields land in `meta.passthrough`). An additive upstream change is
|
|
218
|
+
* then a no-op here, not an outage. The core models ONLY the stable middle:
|
|
219
|
+
* four event kinds, three decisions, and the Bash/Edit/Write/Read/WebFetch tool
|
|
220
|
+
* inputs. Exotic per-agent tools pass through untouched.
|
|
221
|
+
*
|
|
222
|
+
* Dependency-free on purpose, so a fail-closed hook can import it without
|
|
223
|
+
* dragging in eager config-file reads.
|
|
224
|
+
*
|
|
225
|
+
* VERSIONING — this module IS the frozen contract (its own SSOT, no parallel
|
|
226
|
+
* schema file to drift). Adapters and guardrail consumers are built against it
|
|
227
|
+
* in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
|
|
228
|
+
* {@link MODELED_TOOLS}, and {@link TOOL_ALIASES} are frozen, and
|
|
229
|
+
* {@link SCHEMA_VERSION} / {@link CONTROL_PLANE_SCHEMA} are pinned
|
|
230
|
+
* (control-plane.test.mjs asserts the exact values, so any shape change is a
|
|
231
|
+
* deliberate, reviewed version bump). ADDING to the contract (a new optional
|
|
232
|
+
* field, a new modeled tool, a new tool alias) is backward-compatible and stays
|
|
233
|
+
* at v1; RENAMING or REMOVING a field, or changing a decision/event vocabulary,
|
|
234
|
+
* is breaking and bumps the version.
|
|
235
|
+
*/
|
|
236
|
+
/** Wire identifier for this schema version; bump on a breaking shape change. */
|
|
237
|
+
export const CONTROL_PLANE_SCHEMA: "control-plane/v1";
|
|
238
|
+
/** Numeric schema version stamped onto every {@link ToolCallEvent}. */
|
|
239
|
+
export const SCHEMA_VERSION: 1;
|
|
240
|
+
/**
|
|
241
|
+
* The normalized event kinds the core models. A native event that maps to none
|
|
242
|
+
* of these is carried as {@link EventKind.UNKNOWN} with its native name kept in
|
|
243
|
+
* `meta.native_event`.
|
|
244
|
+
*/
|
|
245
|
+
export const EventKind: Readonly<{
|
|
246
|
+
PRE_TOOL: "pre_tool";
|
|
247
|
+
POST_TOOL: "post_tool";
|
|
248
|
+
PROMPT_SUBMIT: "prompt_submit";
|
|
249
|
+
SESSION_START: "session_start";
|
|
250
|
+
UNKNOWN: "unknown";
|
|
251
|
+
}>;
|
|
252
|
+
/** The normalized verdict decisions a guardrail can return. */
|
|
253
|
+
export const Decision: Readonly<{
|
|
254
|
+
ALLOW: "allow";
|
|
255
|
+
DENY: "deny";
|
|
256
|
+
ASK: "ask";
|
|
257
|
+
}>;
|
|
258
|
+
/**
|
|
259
|
+
* Tools whose input shape the core models. Every other tool passes through
|
|
260
|
+
* unmodelled — its input object is preserved verbatim and no field is required.
|
|
261
|
+
*/
|
|
262
|
+
export const MODELED_TOOLS: readonly string[];
|
|
263
|
+
/**
|
|
264
|
+
* Native-tool-name → canonical {@link MODELED_TOOLS} name. The SSOT that lets a
|
|
265
|
+
* judge key on `event.tool` without a per-agent lookup: an agent whose native
|
|
266
|
+
* name for the shell tool is `run_shell_command` (Gemini) is normalized to the
|
|
267
|
+
* same `Bash` a Claude/Codex/Amp payload already carries, with the raw native
|
|
268
|
+
* name preserved on `meta.native_tool`. Only globally-unambiguous native
|
|
269
|
+
* BUILTIN names belong here — a name that also occurs as an MCP tool (e.g. a
|
|
270
|
+
* `read_file` MCP server) must NOT be aliased, or the normalization would
|
|
271
|
+
* reclassify an unrelated tool. An adapter whose host makes such a name
|
|
272
|
+
* unambiguous at parse time (Gemini's `mcp_{server}_{tool}` FQN discipline)
|
|
273
|
+
* may carry its own ADAPTER-SCOPED alias map instead (see the gemini adapter's
|
|
274
|
+
* `GEMINI_TOOL_ALIASES`), applied only to calls classified BUILTIN. An unknown
|
|
275
|
+
* tool is never in the map, so it passes through verbatim (see
|
|
276
|
+
* {@link canonicalTool}). Every target is a
|
|
277
|
+
* {@link MODELED_TOOLS} member (enforced at load below), and every entry is
|
|
278
|
+
* witnessed by a conformance fixture (see `assertToolAliasesCovered`), so an
|
|
279
|
+
* alias cannot be added without a golden payload that exercises it.
|
|
280
|
+
*/
|
|
281
|
+
export const TOOL_ALIASES: Readonly<{
|
|
282
|
+
run_shell_command: "Bash";
|
|
283
|
+
}>;
|
|
284
|
+
/**
|
|
285
|
+
* How a guardrail ATTACHES to an agent — the transport, kept separate from the
|
|
286
|
+
* normalized decision so the judge/Verdict core stays transport-agnostic:
|
|
287
|
+
* - EXTERNAL_HOOK: the agent shells out to a hook (stdin JSON → deny body /
|
|
288
|
+
* exit code). The decision can pre-empt the tool call.
|
|
289
|
+
* - IN_PROCESS: an embedded analyzer or a driven confirm/reject loop over the
|
|
290
|
+
* agent's own API. Can pre-empt.
|
|
291
|
+
* - OBSERVE_ONLY: transcript reader; cannot pre-empt, so its Verdict is
|
|
292
|
+
* advisory — the hard stop is the sandbox, not the hook.
|
|
293
|
+
*/
|
|
294
|
+
export const IntegrationMode: Readonly<{
|
|
295
|
+
EXTERNAL_HOOK: "external_hook";
|
|
296
|
+
IN_PROCESS: "in_process";
|
|
297
|
+
OBSERVE_ONLY: "observe_only";
|
|
298
|
+
}>;
|
|
299
|
+
/**
|
|
300
|
+
* The CLASSES of tool call a host may or may not route through its pre-tool
|
|
301
|
+
* guardrail hook. Whether the hook fires is orthogonal to the tool's INPUT shape
|
|
302
|
+
* (which {@link MODELED_TOOLS} covers) — a host can gate its builtin tools while
|
|
303
|
+
* an MCP-sourced or subagent-spawned call of the SAME tool never reaches the
|
|
304
|
+
* hook. Each adapter declares a coverage status per class (see the adapter
|
|
305
|
+
* `COVERAGE` maps and `docs/hook-coverage-matrix.md`); the conformance harness
|
|
306
|
+
* enforces that an uncovered class is never marked `this_call_vetoable`.
|
|
307
|
+
*/
|
|
308
|
+
export const CallClass: Readonly<{
|
|
309
|
+
BUILTIN: "builtin";
|
|
310
|
+
MCP: "mcp";
|
|
311
|
+
SUBAGENT: "subagent";
|
|
312
|
+
RESUMED: "resumed";
|
|
313
|
+
}>;
|
|
314
|
+
/** Canonical ordered list of {@link CallClass} values — the SSOT every adapter's `COVERAGE` must classify exactly. */
|
|
315
|
+
export const CALL_CLASSES: readonly ("builtin" | "mcp" | "subagent" | "resumed")[];
|
|
316
|
+
/**
|
|
317
|
+
* Whether a host's pre-tool hook fires for a {@link CallClass} — the machine
|
|
318
|
+
* form of the ✅/❌/⚠️/❓ matrix:
|
|
319
|
+
* - COVERED (✅): the hook fires for every tool in the class.
|
|
320
|
+
* - PARTIAL (⚠️): it fires for only SOME tools in the class (e.g. Codex gates
|
|
321
|
+
* Bash but not other builtins) — a call in the covered subset may be vetoed.
|
|
322
|
+
* - UNCOVERED (❌): it never fires; the call reaches the tool un-gated.
|
|
323
|
+
* - UNKNOWN (❓): undocumented — NOT yet proven to fire by a live probe.
|
|
324
|
+
* The doctrine (`docs/hook-coverage-matrix.md`): an UNKNOWN is treated as
|
|
325
|
+
* UNCOVERED until a probe proves ✅, because a guessed ✅ is a silent fail-open.
|
|
326
|
+
*/
|
|
327
|
+
export const CoverageStatus: Readonly<{
|
|
328
|
+
COVERED: "covered";
|
|
329
|
+
PARTIAL: "partial";
|
|
330
|
+
UNCOVERED: "uncovered";
|
|
331
|
+
UNKNOWN: "unknown";
|
|
332
|
+
}>;
|
|
333
|
+
export type EventMeta = {
|
|
334
|
+
/**
|
|
335
|
+
* producing agent id ("claude", "codex", …)
|
|
336
|
+
*/
|
|
337
|
+
agent: string;
|
|
338
|
+
/**
|
|
339
|
+
* original native event name, preserved verbatim
|
|
340
|
+
*/
|
|
341
|
+
native_event: string;
|
|
342
|
+
/**
|
|
343
|
+
* original native tool name, preserved verbatim when `event.tool` was canonicalized (present iff the event carries a tool)
|
|
344
|
+
*/
|
|
345
|
+
native_tool?: string | undefined;
|
|
346
|
+
/**
|
|
347
|
+
* how the guardrail attaches
|
|
348
|
+
*/
|
|
349
|
+
integration_mode: "external_hook" | "in_process" | "observe_only";
|
|
350
|
+
/**
|
|
351
|
+
* 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)
|
|
352
|
+
*/
|
|
353
|
+
primary_gate_present: boolean;
|
|
354
|
+
session_id?: string | undefined;
|
|
355
|
+
cwd?: string | undefined;
|
|
356
|
+
permission_mode?: string | undefined;
|
|
357
|
+
transcript_path?: string | undefined;
|
|
358
|
+
/**
|
|
359
|
+
* unmodelled native top-level fields, verbatim
|
|
360
|
+
*/
|
|
361
|
+
passthrough: Record<string, unknown>;
|
|
362
|
+
};
|
|
363
|
+
/**
|
|
364
|
+
* The result of rendering a {@link Verdict} for a specific agent's transport —
|
|
365
|
+
* the ATTACH mechanism, not the decision. A caller applies whichever channels
|
|
366
|
+
* are present: write `stdout`, exit with `exit_code`, or `throw`.
|
|
367
|
+
*/
|
|
368
|
+
export type NativeResponse = {
|
|
369
|
+
transport: "external_hook" | "in_process" | "observe_only";
|
|
370
|
+
/**
|
|
371
|
+
* process exit code carrying the decision (0 = proceed)
|
|
372
|
+
*/
|
|
373
|
+
exit_code: number;
|
|
374
|
+
/**
|
|
375
|
+
* whether THIS render actually blocks (false ⇒ advisory only)
|
|
376
|
+
*/
|
|
377
|
+
enforced: boolean;
|
|
378
|
+
/**
|
|
379
|
+
* native JSON body to write to stdout, when the transport uses one
|
|
380
|
+
*/
|
|
381
|
+
stdout?: unknown;
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* A normalized, agent-agnostic view of one agent event.
|
|
385
|
+
*/
|
|
386
|
+
export type ToolCallEvent = {
|
|
387
|
+
/**
|
|
388
|
+
* stamped {@link SCHEMA_VERSION}
|
|
389
|
+
*/
|
|
390
|
+
schema_version: number;
|
|
391
|
+
event: "pre_tool" | "post_tool" | "prompt_submit" | "session_start" | "unknown";
|
|
392
|
+
/**
|
|
393
|
+
* 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.
|
|
394
|
+
*/
|
|
395
|
+
tool: string | null;
|
|
396
|
+
/**
|
|
397
|
+
* passthrough tool input; a submitted prompt is folded into `input.prompt`
|
|
398
|
+
*/
|
|
399
|
+
input: Record<string, unknown>;
|
|
400
|
+
/**
|
|
401
|
+
* tool output, post_tool only (string or structured), verbatim
|
|
402
|
+
*/
|
|
403
|
+
response?: unknown;
|
|
404
|
+
/**
|
|
405
|
+
* false ⇒ the guardrail cannot veto THIS call; a monitor must auto-degrade deny to notify, and any render of it stays advisory (never `enforced`)
|
|
406
|
+
*/
|
|
407
|
+
this_call_vetoable: boolean;
|
|
408
|
+
meta: EventMeta;
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* A normalized guardrail decision.
|
|
412
|
+
*/
|
|
413
|
+
export type Verdict = {
|
|
414
|
+
decision: "allow" | "deny" | "ask";
|
|
415
|
+
/**
|
|
416
|
+
* replacement tool input (pre_tool)
|
|
417
|
+
*/
|
|
418
|
+
mutated_input?: Record<string, unknown> | undefined;
|
|
419
|
+
/**
|
|
420
|
+
* 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).
|
|
421
|
+
*/
|
|
422
|
+
mutated_output?: unknown;
|
|
423
|
+
/**
|
|
424
|
+
* extra context to splice into the agent's stream
|
|
425
|
+
*/
|
|
426
|
+
additional_context?: string | undefined;
|
|
427
|
+
/**
|
|
428
|
+
* human-readable rationale (shown on deny/ask)
|
|
429
|
+
*/
|
|
430
|
+
reason?: string | undefined;
|
|
431
|
+
};
|
|
432
|
+
/**
|
|
433
|
+
* The translator for one agent's protocol. `parse` maps a native event to a
|
|
434
|
+
* {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
|
|
435
|
+
* integration mode / enforcement flags on `meta`); `render` maps a {@link Verdict}
|
|
436
|
+
* to that agent's native transport ({@link NativeResponse}), not just a JSON body.
|
|
437
|
+
*/
|
|
438
|
+
export type Adapter = {
|
|
439
|
+
AGENT: string;
|
|
440
|
+
INTEGRATION_MODE: "external_hook" | "in_process" | "observe_only";
|
|
441
|
+
/**
|
|
442
|
+
* per-{@link CallClass} hook-coverage status; must classify every {@link CALL_CLASSES} entry
|
|
443
|
+
*/
|
|
444
|
+
COVERAGE: Record<string, "covered" | "partial" | "uncovered" | "unknown">;
|
|
445
|
+
parse: (native: any) => ToolCallEvent;
|
|
446
|
+
render: (verdict: Verdict, event: ToolCallEvent, options?: {
|
|
447
|
+
soleGate?: boolean;
|
|
448
|
+
}) => NativeResponse;
|
|
449
|
+
};
|
|
450
|
+
export type CoverageStatusValue = "covered" | "partial" | "uncovered" | "unknown";
|
|
451
|
+
/**
|
|
452
|
+
* a per-{@link CallClass} coverage map (an adapter's `COVERAGE`)
|
|
453
|
+
*/
|
|
454
|
+
export type CoverageMap = Record<string, CoverageStatusValue>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./control-plane.mjs";
|
|
2
|
+
export { codexAdapter } from "./adapters/codex.mjs";
|
|
3
|
+
export { ampAdapter } from "./adapters/amp.mjs";
|
|
4
|
+
export { claudeAdapter, HookEvent } from "./adapters/claude.mjs";
|
|
5
|
+
export { geminiAdapter, GEMINI_TOOL_ALIASES } from "./adapters/gemini.mjs";
|
|
6
|
+
export { ADAPTERS, AGENT_IDS, adapterFor, assertRegistryConsistent } from "./registry.mjs";
|
|
7
|
+
export { runAdapterConformance, assertCoverageWellFormed, assertToolAliasesCovered } from "./conformance.mjs";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assert every {@link ADAPTERS} key equals its adapter's own `AGENT`. A registry
|
|
3
|
+
* whose id disagrees with the adapter it resolves is a latent selector/dispatcher
|
|
4
|
+
* split, so this throws (fail loud) rather than resolving a surprising adapter.
|
|
5
|
+
* Called at import against {@link ADAPTERS}; exported so the conformance suite can
|
|
6
|
+
* drive it against a constructed-bad map too.
|
|
7
|
+
* @param {Record<string, Adapter>} adapters
|
|
8
|
+
*/
|
|
9
|
+
export function assertRegistryConsistent(adapters: Record<string, Adapter>): void;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve an agent id to its adapter. Throws (fail loud) on an unknown id — a
|
|
12
|
+
* selection signal that names no shipped adapter is a misconfiguration, not a
|
|
13
|
+
* reason to silently fall back to a default agent (which would run the wrong
|
|
14
|
+
* translator against the payloads and mis-render every verdict).
|
|
15
|
+
* @param {string} id agent id (a {@link AGENT_IDS} member)
|
|
16
|
+
* @returns {Adapter}
|
|
17
|
+
*/
|
|
18
|
+
export function adapterFor(id: string): Adapter;
|
|
19
|
+
/** @typedef {import("./control-plane.mjs").Adapter} Adapter */
|
|
20
|
+
/**
|
|
21
|
+
* Every shipped adapter. Adding an adapter to the package means adding it here —
|
|
22
|
+
* the registry is the enrollment point, and {@link assertRegistryConsistent}
|
|
23
|
+
* (run at import and in the conformance suite) fails if a key does not equal its
|
|
24
|
+
* adapter's `AGENT`, so a typo'd or mismatched id cannot ship.
|
|
25
|
+
* @type {Readonly<Record<string, Adapter>>}
|
|
26
|
+
*/
|
|
27
|
+
export const ADAPTERS: Readonly<Record<string, Adapter>>;
|
|
28
|
+
/**
|
|
29
|
+
* The canonical set of agent ids, frozen — the closed vocabulary a launch
|
|
30
|
+
* signal is validated against. Derived from {@link ADAPTERS} so it cannot list
|
|
31
|
+
* an id the registry does not resolve.
|
|
32
|
+
* @type {readonly string[]}
|
|
33
|
+
*/
|
|
34
|
+
export const AGENT_IDS: readonly string[];
|
|
35
|
+
export type Adapter = import("./control-plane.mjs").Adapter;
|