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.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # agent-control-plane-core
2
+
3
+ **One shape for "a coding agent is about to run a tool" and "here's what my
4
+ guardrail decided" — so you write your tool once and it works across every
5
+ agent.**
6
+
7
+ ## The problem this solves
8
+
9
+ Coding agents — Claude Code, Codex, Gemini CLI, opencode, Amp, and friends — all
10
+ let you hook into their tool-call loop: before a shell command runs, after a file
11
+ is written, when a prompt is submitted. That's where you'd plug in a security
12
+ guardrail, an audit log, a secret redactor, a policy engine.
13
+
14
+ But **every agent speaks its own protocol.** One sends you `tool_name` +
15
+ `tool_input` on stdin and wants `{"permissionDecision":"deny"}` back plus exit
16
+ code 2. Another names the fields differently, blocks by throwing inside a plugin,
17
+ or can only _observe_ and not veto. The field names differ, the "block this"
18
+ signal differs, and each one drifts on its own release cadence.
19
+
20
+ So if you build a tool that touches more than one agent, you end up writing and
21
+ maintaining **N copies of the same logic** — one per agent — and re-testing all
22
+ of them every time an agent ships a new field. That's the tax this package
23
+ removes.
24
+
25
+ ## What it gives you
26
+
27
+ - **`ToolCallEvent`** — one normalized, agent-agnostic view of "an agent event"
28
+ (a tool about to run, a tool that just ran, a prompt, a session start).
29
+ - **`Verdict`** — one normalized way to say what your guardrail decided:
30
+ `allow` / `deny` / `ask`, optionally with a rewritten input or extra context.
31
+ - **Adapters** — a thin `{ parse, render }` translator per agent. `parse` turns
32
+ that agent's raw hook payload into a `ToolCallEvent`; `render` turns your
33
+ `Verdict` back into the **real** native signal that agent enforces (the right
34
+ JSON body, exit code, or thrown error — not just a print).
35
+
36
+ You write your logic **once** against the normalized types. Adding support for a
37
+ new agent is a new adapter (~one file + fixtures), not a rewrite of your tool.
38
+ The agent-specific field names live **only** inside that agent's adapter.
39
+
40
+ ## Who wants this
41
+
42
+ Anyone building a tool that sits in the tool-call loop of **more than one**
43
+ coding agent, and doesn't want to fork it per agent:
44
+
45
+ - **Security guardrails** — deny-listers, command/path sanitizers, permission gates.
46
+ - **Observability & audit** — log or replay every tool call in a uniform shape.
47
+ - **Redaction / DLP** — strip secrets from tool inputs or outputs before they move.
48
+ - **Policy engines** — one rule set enforced identically no matter which agent runs.
49
+
50
+ If you only ever target a single agent, you don't need this — just use that
51
+ agent's native hooks. The value is entirely in the _N-agents_ case.
52
+
53
+ > **Not a security boundary.** A `Verdict` is advisory: a useful first filter,
54
+ > never the last line of defense. Some agents can't enforce a `deny` at all (the
55
+ > adapter marks those `observe_only`), so a real deployment still needs a sandbox
56
+ > underneath. Treat the control plane as a policy layer on top of isolation, not
57
+ > a replacement for it.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pnpm add agent-control-plane-core
63
+ ```
64
+
65
+ ## The model
66
+
67
+ ```js
68
+ // ToolCallEvent — a normalized, agent-agnostic view of one agent event.
69
+ {
70
+ schema_version: 1,
71
+ event: "pre_tool" | "post_tool" | "prompt_submit" | "session_start" | "unknown",
72
+ tool: string | null, // "Bash" | "Edit" | "Write" | "Read" | "WebFetch" | <passthrough> | null
73
+ input: object, // tool input; a submitted prompt folds into input.prompt
74
+ response?: unknown, // post_tool only
75
+ this_call_vetoable: boolean, // false ⇒ a deny here is advisory only; the guardrail cannot block THIS call
76
+ meta: {
77
+ agent: string, // "claude" | "codex" | …
78
+ native_event: string, // original native event name, preserved verbatim
79
+ session_id?, cwd?, permission_mode?, transcript_path?,
80
+ passthrough: object, // every unmodelled native top-level field, verbatim
81
+ }
82
+ }
83
+
84
+ // Verdict — a normalized guardrail decision.
85
+ {
86
+ decision: "allow" | "deny" | "ask",
87
+ mutated_input?: object, // replacement tool input
88
+ additional_context?: string, // extra context to splice into the agent's stream
89
+ reason?: string, // shown on deny/ask
90
+ }
91
+ ```
92
+
93
+ An **Adapter** is a pair `{ AGENT, parse, render }`:
94
+
95
+ ```js
96
+ parse(nativeEvent) -> ToolCallEvent // never throws on unknown input
97
+ render(verdict, event) -> nativeResponse // the agent's native shape
98
+ ```
99
+
100
+ ## Usage
101
+
102
+ ```js
103
+ import { claudeAdapter } from "agent-control-plane-core/claude";
104
+
105
+ // 1. Normalize the agent's raw hook payload.
106
+ const event = claudeAdapter.parse(rawHookJson);
107
+
108
+ // 2. Your guardrail decides, using only the normalized types.
109
+ const verdict =
110
+ event.tool === "Bash" && /rm -rf/.test(String(event.input.command))
111
+ ? { decision: "deny", reason: "destructive command blocked" }
112
+ : { decision: "allow" };
113
+
114
+ // 3. Render back to the agent's native response.
115
+ process.stdout.write(JSON.stringify(claudeAdapter.render(verdict, event)));
116
+ ```
117
+
118
+ The core contract and every adapter (`claude`, `codex`, `amp`, `gemini`) are
119
+ also on the default entry:
120
+
121
+ ```js
122
+ import {
123
+ EventKind,
124
+ Decision,
125
+ claudeAdapter,
126
+ codexAdapter,
127
+ ampAdapter,
128
+ geminiAdapter,
129
+ runAdapterConformance,
130
+ } from "agent-control-plane-core";
131
+ ```
132
+
133
+ ## Drift discipline (the point of the seam)
134
+
135
+ Agent protocols drift **additively and independently** — N agents, N release
136
+ cadences. So `parse` **never throws** on an event type or tool-input field it
137
+ does not model: an unrecognized event becomes `EventKind.UNKNOWN` with its native
138
+ name kept in `meta.native_event`, and every unmodelled field survives in `input`
139
+ or `meta.passthrough`. An additive upstream change is a **no-op**, not an outage.
140
+
141
+ The core models only the stable middle — four event kinds, three decisions, and
142
+ the `Bash`/`Edit`/`Write`/`Read`/`WebFetch` tool inputs (`MODELED_TOOLS`). Exotic
143
+ per-agent tools pass through untouched.
144
+
145
+ ## Writing a new adapter
146
+
147
+ An adapter for cursor/cline/gemini-cli/aider is a thin, independently-pinned
148
+ translator with its own golden fixtures. It must pass the conformance suite:
149
+
150
+ ```js
151
+ import { test } from "node:test";
152
+ import assert from "node:assert/strict";
153
+ import { runAdapterConformance } from "agent-control-plane-core/conformance";
154
+ import { myAdapter } from "./my-adapter.mjs";
155
+ import fixtures from "./fixtures/my-agent.json" with { type: "json" };
156
+
157
+ test("my adapter conforms", () => {
158
+ runAdapterConformance({ adapter: myAdapter, fixtures, assert });
159
+ });
160
+ ```
161
+
162
+ The harness pins both directions and rejects a vacuous suite:
163
+
164
+ 1. `parse(native)` deep-equals the fixture's golden `event`.
165
+ 2. `render(verdict, event)` deep-equals the fixture's golden native response, for
166
+ each verdict scenario.
167
+ 3. The fixture set collectively renders an `allow`, a `deny`, an `ask`, **and** a
168
+ `mutated_input` — so a suite can't pass while skipping a decision the contract
169
+ requires every adapter to express.
170
+
171
+ See `src/fixtures/claude.json` and `src/fixtures/codex.json` for the format.
172
+
173
+ ## Versioning
174
+
175
+ `src/control-plane.mjs` is the frozen contract and its own single source of
176
+ truth. `SCHEMA_VERSION` / `CONTROL_PLANE_SCHEMA` are pinned by a test. **Adding**
177
+ an optional field or a modeled tool is backward-compatible and stays at v1;
178
+ **renaming/removing** a field or changing a decision/event vocabulary is breaking
179
+ and bumps the version.
180
+
181
+ ## Development
182
+
183
+ ```bash
184
+ ./setup.sh # install deps + git hooks
185
+ pnpm test # node --test
186
+ pnpm test:coverage # c8, 100% lines/branches/functions per file
187
+ pnpm check # tsc --noEmit (JSDoc types)
188
+ pnpm lint # eslint
189
+ pnpm build # emit .d.mts declarations into types/
190
+ ```
191
+
192
+ ## License
193
+
194
+ MIT
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Amp EXTERNAL_HOOK entry (pure exit-code transport, no stdout body). Reads a
4
+ * delegate payload on stdin, applies the demo judge, and exits with Amp's
5
+ * decision code (0 allow / 1 ask / 2 reject).
6
+ *
7
+ * FAILURE POSTURE — fail to ASK (exit 1), NOT allow. Amp's delegate reads exit 1
8
+ * as "ask the human," which is the safe degradation when the guardrail itself is
9
+ * broken: neither silently allowing (exit 0) nor hard-denying (exit 2) a call it
10
+ * never actually judged. This is the asymmetry the per-host split exists for —
11
+ * Amp has a safe "ask" failure code that Claude Code's transport does not.
12
+ *
13
+ * Usage: `node bin/amp-hook.mjs < payload.json`
14
+ */
15
+ import { ampAdapter } from "../src/adapters/amp.mjs";
16
+ import { readStdin, renderHookResponse, emit } from "./hook-runtime.mjs";
17
+
18
+ /** @type {import("../src/control-plane.mjs").NativeResponse} */
19
+ const FAIL_ASK = { transport: "external_hook", exit_code: 1, enforced: false };
20
+
21
+ emit(renderHookResponse(ampAdapter, await readStdin(), FAIL_ASK));
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code EXTERNAL_HOOK entry. Reads a native PreToolUse payload on stdin,
4
+ * applies the demo judge, and emits Claude Code's native response.
5
+ *
6
+ * FAILURE POSTURE — fail OPEN. Claude Code treats a hook that crashes, times out,
7
+ * or emits a non-conforming body as "no objection" and runs the tool anyway, so
8
+ * on any internal failure this exits 0 with no body rather than pretending to a
9
+ * decision it couldn't compute. A broken guardrail must not wedge the user; the
10
+ * sandbox is the real backstop, not this hook.
11
+ *
12
+ * Usage: `node bin/claude-hook.mjs < payload.json`
13
+ */
14
+ import { claudeAdapter } from "../src/adapters/claude.mjs";
15
+ import { readStdin, renderHookResponse, emit } from "./hook-runtime.mjs";
16
+
17
+ /** @type {import("../src/control-plane.mjs").NativeResponse} */
18
+ const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
19
+
20
+ emit(renderHookResponse(claudeAdapter, await readStdin(), FAIL_OPEN));
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codex CLI EXTERNAL_HOOK entry. Reads a native PreToolUse/PermissionRequest
4
+ * payload on stdin, applies the demo judge, and emits Codex's native response.
5
+ *
6
+ * FAILURE POSTURE — fail OPEN (exit 0), like Claude Code: Codex treats a
7
+ * non-conforming hook as non-blocking. On a pre-v0.135 Codex the render is
8
+ * advisory anyway (the adapter marks it observe-only), so a failure and a
9
+ * successful advisory both leave the tool to proceed — the sandbox is the
10
+ * backstop.
11
+ *
12
+ * Usage: `node bin/codex-hook.mjs < payload.json`
13
+ */
14
+ import { codexAdapter } from "../src/adapters/codex.mjs";
15
+ import { readStdin, renderHookResponse, emit } from "./hook-runtime.mjs";
16
+
17
+ /** @type {import("../src/control-plane.mjs").NativeResponse} */
18
+ const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
19
+
20
+ emit(renderHookResponse(codexAdapter, await readStdin(), FAIL_OPEN));
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI EXTERNAL_HOOK entry. Reads a native BeforeTool payload on stdin,
4
+ * applies the demo judge, and emits Gemini's native response.
5
+ *
6
+ * FAILURE POSTURE — fail OPEN (exit 0). Gemini CLI treats a non-2 / crashed hook
7
+ * exit as a non-fatal warning and continues, so on any internal failure this
8
+ * exits 0 rather than emitting a spurious System Block (only a clean deny exits
9
+ * 2). A broken guardrail must not wedge the user; the sandbox is the backstop.
10
+ *
11
+ * Usage: `node bin/gemini-hook.mjs < payload.json`
12
+ */
13
+ import { geminiAdapter } from "../src/adapters/gemini.mjs";
14
+ import { readStdin, renderHookResponse, emit } from "./hook-runtime.mjs";
15
+
16
+ /** @type {import("../src/control-plane.mjs").NativeResponse} */
17
+ const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
18
+
19
+ emit(renderHookResponse(geminiAdapter, await readStdin(), FAIL_OPEN));
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared plumbing for the per-host EXTERNAL_HOOK entries — NOT a runtime host
3
+ * multiplexer. Each host has its OWN entry (`bin/<host>-hook.mjs`) that hardcodes
4
+ * its adapter and declares its OWN failure posture; this module only holds the
5
+ * transport mechanics they genuinely share (stdin read, the demo judge, the
6
+ * parse→judge→render pipe with a host-supplied fail-safe, and stdout+exit
7
+ * emission). The thing item ② deleted was the `--agent` switch that let one
8
+ * binary impersonate every host and so had to pick a single shared failure
9
+ * behavior; sharing pure plumbing across distinct entries is not that.
10
+ */
11
+ import { Decision } from "../src/control-plane.mjs";
12
+
13
+ /** Read all of stdin to a string. */
14
+ export function readStdin() {
15
+ return new Promise((resolve, reject) => {
16
+ const chunks = [];
17
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
18
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString()));
19
+ process.stdin.on("error", reject);
20
+ });
21
+ }
22
+
23
+ /**
24
+ * The DEMO policy standing in for a real guardrail judge: deny any command
25
+ * matching /rm -rf/, allow everything else. A real deployment swaps this for its
26
+ * own judge over the normalized {@link ToolCallEvent}.
27
+ * @param {import("../src/control-plane.mjs").ToolCallEvent} event
28
+ * @returns {import("../src/control-plane.mjs").Verdict}
29
+ */
30
+ export function demoJudge(event) {
31
+ const command =
32
+ typeof event.input.command === "string" ? event.input.command : "";
33
+ return /rm\s+-rf/.test(command)
34
+ ? { decision: Decision.DENY, reason: "demo policy: rm -rf blocked" }
35
+ : { decision: Decision.ALLOW };
36
+ }
37
+
38
+ /**
39
+ * Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
40
+ * can't be parsed or the pipe throws, return `onFailure` — the host's OWN
41
+ * fail-safe response — so a crashed hook degrades the way THAT host expects
42
+ * (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
43
+ * single shared default. This is the deliberate, necessary recovery the whole
44
+ * per-host split exists for, not a blanket swallow.
45
+ * @param {import("../src/control-plane.mjs").Adapter} adapter
46
+ * @param {string} rawInput
47
+ * @param {import("../src/control-plane.mjs").NativeResponse} onFailure
48
+ * @returns {import("../src/control-plane.mjs").NativeResponse}
49
+ */
50
+ export function renderHookResponse(adapter, rawInput, onFailure) {
51
+ try {
52
+ const event = adapter.parse(JSON.parse(rawInput));
53
+ return adapter.render(demoJudge(event), event);
54
+ } catch {
55
+ return onFailure;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Emit a {@link NativeResponse} to the host: write the native stdout body when
61
+ * the transport has one, then exit with the transport's exit code.
62
+ * @param {import("../src/control-plane.mjs").NativeResponse} response
63
+ * @returns {never}
64
+ */
65
+ export function emit(response) {
66
+ if (response.stdout !== undefined)
67
+ process.stdout.write(JSON.stringify(response.stdout));
68
+ process.exit(response.exit_code);
69
+ }
package/package.json ADDED
@@ -0,0 +1,109 @@
1
+ {
2
+ "name": "agent-control-plane-core",
3
+ "version": "0.1.0",
4
+ "description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex) and a conformance suite.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "packageManager": "pnpm@11.8.0",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "main": "./src/index.mjs",
12
+ "types": "./types/index.d.mts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./types/index.d.mts",
16
+ "import": "./src/index.mjs"
17
+ },
18
+ "./claude": {
19
+ "types": "./types/adapters/claude.d.mts",
20
+ "import": "./src/adapters/claude.mjs"
21
+ },
22
+ "./codex": {
23
+ "types": "./types/adapters/codex.d.mts",
24
+ "import": "./src/adapters/codex.mjs"
25
+ },
26
+ "./amp": {
27
+ "types": "./types/adapters/amp.d.mts",
28
+ "import": "./src/adapters/amp.mjs"
29
+ },
30
+ "./gemini": {
31
+ "types": "./types/adapters/gemini.d.mts",
32
+ "import": "./src/adapters/gemini.mjs"
33
+ },
34
+ "./conformance": {
35
+ "types": "./types/conformance.d.mts",
36
+ "import": "./src/conformance.mjs"
37
+ }
38
+ },
39
+ "files": [
40
+ "src",
41
+ "types",
42
+ "bin"
43
+ ],
44
+ "keywords": [
45
+ "claude",
46
+ "claude-code",
47
+ "codex",
48
+ "agent",
49
+ "control-plane",
50
+ "hooks",
51
+ "adapter",
52
+ "guardrail"
53
+ ],
54
+ "scripts": {
55
+ "prepare": "git config core.hooksPath .hooks",
56
+ "build": "tsc -p tsconfig.build.json",
57
+ "prepublishOnly": "pnpm build",
58
+ "test": "node --test test/*.test.mjs",
59
+ "test:coverage": "c8 node --test test/*.test.mjs",
60
+ "check": "tsc --noEmit",
61
+ "lint": "eslint .",
62
+ "format": "prettier --write .",
63
+ "format:check": "prettier --check ."
64
+ },
65
+ "lint-staged": {
66
+ "*.{css,scss}": [
67
+ "prettier --write"
68
+ ],
69
+ "*.json": [
70
+ "prettier --write"
71
+ ],
72
+ "*.json.example": [
73
+ "prettier --write --parser json"
74
+ ],
75
+ "*.{js,jsx,ts,tsx,mjs}": [
76
+ "prettier --write",
77
+ "eslint --fix"
78
+ ],
79
+ "*.{yaml,yml}": [
80
+ "prettier --write"
81
+ ],
82
+ "*.md": [
83
+ "prettier --write"
84
+ ],
85
+ ".claude/skills/*/SKILL.md": [
86
+ ".hooks/lint-skills.sh"
87
+ ],
88
+ "{*.sh,.hooks/*}": [
89
+ "shfmt -i 2 -w"
90
+ ],
91
+ "*.py": [
92
+ "ruff check --fix",
93
+ "ruff format"
94
+ ]
95
+ },
96
+ "devDependencies": {
97
+ "@commitlint/cli": "^21.0.1",
98
+ "@commitlint/config-conventional": "^21.0.1",
99
+ "@eslint/js": "^9.13.0",
100
+ "@types/node": "^22.7.0",
101
+ "c8": "^10.1.2",
102
+ "eslint": "^9.13.0",
103
+ "fast-check": "^3.22.0",
104
+ "globals": "^15.11.0",
105
+ "lint-staged": "^17.0.5",
106
+ "prettier": "^3.0.0",
107
+ "typescript": "^5.6.0"
108
+ }
109
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The Amp adapter — EXTERNAL_HOOK translator with a pure exit-code transport.
3
+ *
4
+ * Amp's `amp.permissions` `delegate` rule invokes a helper on PATH; the helper's
5
+ * EXIT CODE is the whole decision — 0 allow / 1 ask / 2 reject — with no JSON
6
+ * body. Managed pin: /etc/ampcode/managed-settings.json overrides user+workspace
7
+ * (pin the delegate rule there). Transcript is server-canonical `--stream-json`.
8
+ *
9
+ * This adapter is what proves the transport split earns its keep: the normalized
10
+ * ToolCallEvent/Verdict are identical to Claude's, but `render` carries the
11
+ * decision in `exit_code` with no `stdout` at all.
12
+ */
13
+
14
+ import {
15
+ EventKind,
16
+ Decision,
17
+ IntegrationMode,
18
+ makeEvent,
19
+ normalizeVerdict,
20
+ nativeResponse,
21
+ collectPassthrough,
22
+ asObject,
23
+ asStringOrNull,
24
+ } from "../control-plane.mjs";
25
+
26
+ /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
27
+ /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
28
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
29
+ /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
30
+
31
+ export const AGENT = "amp";
32
+ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
33
+
34
+ // Amp invokes the delegate for a tool call; the payload carries the tool name +
35
+ // input and the session context. Pinned by fixtures/amp.json.
36
+ const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
37
+
38
+ /**
39
+ * @param {any} native
40
+ * @returns {ToolCallEvent}
41
+ */
42
+ export function parse(native) {
43
+ const raw = asObject(native);
44
+ /** @type {EventMeta} */
45
+ const meta = {
46
+ agent: AGENT,
47
+ native_event: "delegate",
48
+ integration_mode: INTEGRATION_MODE,
49
+ primary_gate_present: true,
50
+ passthrough: collectPassthrough(raw, CONSUMED),
51
+ };
52
+ if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
53
+ if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
54
+ return makeEvent({
55
+ event: EventKind.PRE_TOOL,
56
+ tool: asStringOrNull(raw.tool),
57
+ input: asObject(raw.input),
58
+ response: undefined,
59
+ this_call_vetoable: true,
60
+ meta,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Render into Amp's pure exit-code transport: the decision is the exit code,
66
+ * with no stdout body. `reason` has no native channel here, so it is dropped
67
+ * (Amp surfaces the helper's own stderr). No `soleGate` option — an allow
68
+ * already renders as exit 0 either way, so there's no distinct "real approve"
69
+ * signal for this transport to opt into.
70
+ * @param {Verdict} verdict
71
+ * @param {ToolCallEvent} event
72
+ * @returns {NativeResponse}
73
+ */
74
+ export function render(verdict, event) {
75
+ const vd = normalizeVerdict(verdict);
76
+ const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
77
+ const exit_code = enforced ? 2 : vd.decision === Decision.ASK ? 1 : 0;
78
+ return nativeResponse({ transport: INTEGRATION_MODE, exit_code, enforced });
79
+ }
80
+
81
+ /** @type {import("../control-plane.mjs").Adapter} */
82
+ export const ampAdapter = { AGENT, INTEGRATION_MODE, parse, render };