agent-control-plane-core
One shape for "a coding agent is about to run a tool" and "here's what my guardrail decided" — so you write your tool once and it works across every agent.
The problem this solves
Coding agents — Claude Code, Codex, Gemini CLI, opencode, Amp, and friends — all let you hook into their tool-call loop: before a shell command runs, after a file is written, when a prompt is submitted. That's where you'd plug in a security guardrail, an audit log, a secret redactor, a policy engine.
But every agent speaks its own protocol. One sends you tool_name +
tool_input on stdin and wants {"permissionDecision":"deny"} back plus exit
code 2. Another names the fields differently, blocks by throwing inside a plugin,
or can only observe and not veto. The field names differ, the "block this"
signal differs, and each one drifts on its own release cadence.
So if you build a tool that touches more than one agent, you end up writing and maintaining N copies of the same logic — one per agent — and re-testing all of them every time an agent ships a new field. That's the tax this package removes.
What it gives you
ToolCallEvent— one normalized, agent-agnostic view of "an agent event" (a tool about to run, a tool that just ran, a prompt, a session start).Verdict— one normalized way to say what your guardrail decided:allow/deny/ask, optionally with a rewritten input or extra context.- Adapters — a thin
{ parse, render }translator per agent, plus the declaration of what that agent's transport cannot carry.parseturns that agent's raw hook payload into aToolCallEvent;renderturns yourVerdictback into the real native signal that agent enforces (the right JSON body, exit code and stderr reason — not just a print).
You write your logic once against the normalized types. Adding support for a new agent is a new adapter (~one file + fixtures), not a rewrite of your tool. The agent-specific field names live only inside that agent's adapter.
Who wants this
Anyone building a tool that sits in the tool-call loop of more than one coding agent, and doesn't want to fork it per agent:
- Security guardrails — deny-listers, command/path sanitizers, permission gates.
- Observability & audit — log or replay every tool call in a uniform shape.
- Redaction / DLP — strip secrets from tool inputs or outputs before they move.
- Policy engines — one rule set enforced identically no matter which agent runs.
If you only ever target a single agent, you don't need this — just use that agent's native hooks. The value is entirely in the N-agents case.
Not a security boundary. A
Verdictis advisory: a useful first filter, never the last line of defense. Some agents can't enforce adenyat all (the adapter marks thoseobserve_only), so a real deployment still needs a sandbox underneath. Treat the control plane as a policy layer on top of isolation, not a replacement for it.
Install
pnpm add agent-control-plane-core
The model
// ToolCallEvent — a normalized, agent-agnostic view of one agent event.
{
schema_version: 1,
event: "pre_tool" | "post_tool" | "prompt_submit" | "session_start" | "unknown",
tool: string | null, // "Bash" | "Edit" | "Write" | "Read" | "WebFetch" | <passthrough> | null
input: object, // tool input; a submitted prompt folds into input.prompt
response?: unknown, // post_tool only
this_call_vetoable: boolean, // false ⇒ a deny here is advisory only; the guardrail cannot block THIS call
meta: {
agent: string, // "claude" | "codex" | …
native_event: string, // original native event name, preserved verbatim
session_id?, cwd?, permission_mode?, transcript_path?,
passthrough: object, // every unmodelled native top-level field, verbatim
}
}
// Verdict — a normalized guardrail decision.
{
decision: "allow" | "deny" | "ask",
mutated_input?: object, // replacement tool input (pre_tool)
mutated_output?: unknown, // replacement tool output the model sees (post_tool)
additional_context?: string, // extra context to splice into the agent's stream
reason?: string, // shown on deny/ask
}
An Adapter is
{ AGENT, COVERAGE, UNRENDERED_FIELDS, NATIVE_ASK_TIER, parse, render }:
parse(nativeEvent) -> ToolCallEvent // never throws on unknown input
render(verdict, event) -> nativeResponse // the agent's native shape
// Which Verdict content fields this host has NO native channel for, per event
// kind. Every EventKind needs a row: an omission would read as "every field
// reaches a channel here", which is the reverse of the truth on a transport
// that carries none.
UNRENDERED_FIELDS = Object.freeze({
[EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
[EventKind.POST_TOOL]: readonlySet(["mutated_output"]),
[EventKind.PROMPT_SUBMIT]: UNRENDERED_ON_UNKNOWN,
[EventKind.SESSION_START]: UNRENDERED_ON_UNKNOWN,
[EventKind.UNKNOWN]: UNRENDERED_ON_UNKNOWN,
})
// Does this host carry a distinct "ask the human" tier that it honours?
// true ⇒ a rendered `ask` suspends the call in front of a person
// (Claude Code's permissionDecision: "ask", Amp's exit 1).
// false ⇒ the host has no such tier, so `render` collapses `ask` onto another
// signal (Gemini CLI's advisory decision: "deny") and an un-escalated
// ask lets the tool run.
NATIVE_ASK_TIER = false;
Read NATIVE_ASK_TIER instead of keeping a list of which agents to escalate an
ask to a deny on: a guardrail that must not let an ask through escalates it
where the flag is false, and leaves it alone where the host does the asking.
renderEscalated(adapter, verdict, event, { soleGate }) applies that rule: it
re-renders as a deny any non-allow verdict that would render unenforced on a
vetoable call, except an ask on a host whose flag is true. A non-allow
verdict on a payload that names no event at all blocks through the host's exit
code and stderr, since render has no channel for it, unless the event parsed
observe-only (a Codex below its enforcing version), which cannot veto.
It describes the HOST, not the verdict — ask renders as enforced: false
everywhere — and it names the pre-tool gate, since a tool that already ran has
nothing left to suspend. Conformance probes your real render against it, so a
true whose ask renders as your abstaining allow fails, and so does a false
whose ask renders as anything but your own advisory deny.
readonlySet([]) is the row for a kind that carries all three;
UNRENDERED_ON_UNKNOWN is the row for one that carries none. Both come from
agent-control-plane-core/contract. Build every row with readonlySet — a
plain Set exposes clear(), so a consumer could empty a declaration after
conformance certified it, and Object.freeze does not stop that. Freeze the map
itself too: an immutable row still leaves a consumer free to swap a whole row
for one claiming another channel. A render that ships a field the row
declares dropped fails conformance, and so does a row that declares a channel
the render does not carry.
Usage
import { claudeAdapter } from "agent-control-plane-core/claude";
import { readStdin, emit } from "agent-control-plane-core/runtime";
// 1. Normalize the agent's raw hook payload.
const event = claudeAdapter.parse(JSON.parse(await readStdin()));
// 2. Your guardrail decides, using only the normalized types.
const verdict =
event.tool === "Bash" && /rm -rf/.test(String(event.input.command))
? { decision: "deny", reason: "destructive command blocked" }
: { decision: "allow" };
// 3. Send the native response: the stdout body, the block reason on stderr,
// and the exit code (2 on this deny), which is what makes Claude Code block.
emit(claudeAdapter.render(verdict, event));
render returns the whole native response, not a body to print. Writing
JSON.stringify(render(...)) to stdout exits 0, so the host never blocks.
agent-control-plane-core/runtime publishes the rest of the plumbing the four
hook entries share. runHookEntry(adapter, judge) is a whole entry: each
bin/<host>-hook.mjs is that one call. renderHookResponse runs parse → judge
→ renderEscalated, so a judge's ask on a host with no ask tier blocks a
vetoable call instead of running it. It returns the failure response THAT host
expects when the payload does not parse. emit
drains the whole body before it exits, so a large deny body is never truncated
on a non-blocking pipe. The entries ship as acp-claude-hook, acp-codex-hook,
acp-amp-hook and acp-gemini-hook. Codex's hook stdin carries no version, so
set ACP_CODEX_VERSION to the Codex version your launcher pins (or call
createCodexAdapter({ version })); without it, Codex calls parse advisory.
Each entry fails the way its host expects when the hook itself breaks: claude,
codex and gemini run the call, and amp asks. Set ACP_FAIL_CLOSED=1 to block
instead, on every host. The entry then exits 2 with the reason on stderr when
the payload does not parse, or when the judge throws or answers no decision.
Register a fail-closed entry on tool-call events only. On a Stop event, exit 2
means "keep working".
A host that kills a hook on its timeout runs the call. Set
ACP_HOOK_TIMEOUT_MS below the host's hook timeout, and the entry gives its
failure response at that deadline instead. A judge that blocks the event loop
past the deadline still loses to the host's timeout. Nothing in the entry can
answer for a process that never starts (no node, a module that fails to
load). Claude Code covers that case with "onFailure": "block" on the hook
entry. Codex has no such option, so it still runs the call.
The core contract and every adapter (claude, codex, amp, gemini) are
also on the default entry:
import {
EventKind,
Decision,
claudeAdapter,
codexAdapter,
ampAdapter,
geminiAdapter,
runAdapterConformance,
} from "agent-control-plane-core";
Drift discipline (the point of the seam)
Agent protocols drift additively and independently — N agents, N release
cadences. So parse never throws on an event type or tool-input field it
does not model: an unrecognized event becomes EventKind.UNKNOWN with its native
name kept in meta.native_event, and every unmodelled field survives in input
or meta.passthrough. An additive upstream change is a no-op, not an outage.
The core models only the stable middle — four event kinds, three decisions, and
the Bash/Edit/Write/Read/WebFetch tool inputs (MODELED_TOOLS). Exotic
per-agent tools pass through untouched.
Writing a new adapter
An adapter for cursor/cline/gemini-cli/aider is a thin, independently-pinned translator with its own golden fixtures. It must pass the conformance suite:
import { test } from "node:test";
import assert from "node:assert/strict";
import { runAdapterConformance } from "agent-control-plane-core/conformance";
import { myAdapter } from "./my-adapter.mjs";
import fixtures from "./fixtures/my-agent.json" with { type: "json" };
test("my adapter conforms", () => {
runAdapterConformance({ adapter: myAdapter, fixtures, assert });
});
The harness pins both directions and rejects a vacuous suite:
parse(native)deep-equals the fixture's goldenevent.render(verdict, event)deep-equals the fixture's golden native response, for each verdict scenario.- The fixture set collectively renders an
allow, adeny, anask, and amutated_input— so a suite can't pass while skipping a decision the contract requires every adapter to express. - Every
Verdictcontent field either reaches this host's wire or is declared inUNRENDERED_FIELDSfor that event kind, checked both ways on a synthesized verdict — so an adapter with no channel for a field must say so, and a declared drop that still ships the value fails. Set the optionalNATIVE_EVENT_FORto have a kind your fixtures skip probed on its real native event rather than on your unrecognized-event branch. - A deny the adapter cannot enforce (
this_call_vetoable: false) must render as something OTHER than that host's allow signal — an ask, or an advisory body. The harness probes this itself on a non-vetoable variant of every pre-tool fixture event, so an adapter that collapses the objection onto "run it" fails even with no non-vetoable fixture of its own.observe_onlyrenders are exempt: that transport has no pre-emption channel to differ in. - Your
NATIVE_ASK_TIERdeclaration matches whatrenderdoes with anask, probed on every pre-tool fixture event (both directions, described above).
See src/fixtures/claude.json and src/fixtures/codex.json for the format.
Validating from another language
A guardrail that is not JavaScript cannot import ToolCallEvent, so it
hand-writes the field names it reads. It then drifts in the quietest direction:
the contract renames a field, the hand-written reader sees undefined, and it
takes its no-value branch instead of failing. The package ships the same
contract as JSON Schema (draft 2020-12), so that reader validates against the
seam instead of against its own copy.
import json, pathlib, jsonschema # a Python guardrail reading the event
base = pathlib.Path("node_modules/agent-control-plane-core/schema/control-plane/v1")
schema = json.loads((base / "tool-call-event.schema.json").read_text())
jsonschema.validate(event, schema) # raises on a field the contract does not model
tool-input-keys.json sits beside them and publishes the input field a judge
should read per canonical tool (Bash → command, Read → file_path), so a
consumer looks that up rather than hard-coding it. It is plain data, not a
schema: a validator refuses an unknown keyword under its default options, so a
document carrying that map inside itself would not compile for the consumer who
installed it.
The event document also refuses two claims a producer must not make, each one a
veto the host will never perform: a this_call_vetoable unknown event, and a
this_call_vetoable event on an observe_only transport.
pnpm gen:schema generates the files from src/control-plane.mjs, and
test/json-schema.test.mjs byte-compares the result, so a stale document reds
the suite. The same suite validates every adapter's live output against the
published bytes, so a renamed contract field reds too. A JavaScript caller
builds the documents in memory from agent-control-plane-core/json-schema
instead of reading the files.
Read the schema from the installed package rather than pinning a copy by hand. A document is one release's rendering of the wire version, and an added optional field (backward-compatible, below) appears in the next release's document.
Versioning
src/control-plane.mjs is the frozen contract and its own single source of
truth. SCHEMA_VERSION / CONTROL_PLANE_SCHEMA are pinned by a test. Adding
an optional field or a modeled tool is backward-compatible and stays at v1;
renaming/removing a field or changing a decision/event vocabulary is breaking
and bumps the version.
Development
./setup.sh # install deps + git hooks
pnpm test # node --test
pnpm test:coverage # c8, the per-file floor .c8rc.json sets
pnpm check # tsc --noEmit (JSDoc types)
pnpm gen:schema # regenerate schema/ from the contract
pnpm lint # eslint
pnpm build # emit .d.mts declarations into types/
License
MIT