PluginWorld
Ag

agent-control-plane-core

Claude Code✓ SPEC VERIFIED

Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.

@AlexanderMattTurner · v0.11.2 · MIT · updated 5d ago

SECURITY

A

SCORE

80

INSTALLS

▲ 113.4K

PLUG IN

/plugin marketplace add AlexanderMattTurner/agent-control-plane-core
/plugin install agent-control-plane-core

README

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. parse turns that agent's raw hook payload into a ToolCallEvent; render turns your Verdict back 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 Verdict is advisory: a useful first filter, never the last line of defense. Some agents can't enforce a deny at all (the adapter marks those observe_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:

  1. parse(native) deep-equals the fixture's golden event.
  2. render(verdict, event) deep-equals the fixture's golden native response, for each verdict scenario.
  3. The fixture set collectively renders an allow, a deny, an ask, and a mutated_input — so a suite can't pass while skipping a decision the contract requires every adapter to express.
  4. Every Verdict content field either reaches this host's wire or is declared in UNRENDERED_FIELDS for 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 optional NATIVE_EVENT_FOR to have a kind your fixtures skip probed on its real native event rather than on your unrecognized-event branch.
  5. 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_only renders are exempt: that transport has no pre-emption channel to differ in.
  6. Your NATIVE_ASK_TIER declaration matches what render does with an ask, 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

SIMILAR PLUGINS