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 +194 -0
- package/bin/amp-hook.mjs +21 -0
- package/bin/claude-hook.mjs +20 -0
- package/bin/codex-hook.mjs +20 -0
- package/bin/gemini-hook.mjs +19 -0
- package/bin/hook-runtime.mjs +69 -0
- package/package.json +109 -0
- package/src/adapters/amp.mjs +82 -0
- package/src/adapters/claude.mjs +227 -0
- package/src/adapters/codex.mjs +149 -0
- package/src/adapters/gemini.mjs +175 -0
- package/src/conformance.mjs +121 -0
- package/src/control-plane.mjs +299 -0
- package/src/fixtures/amp.json +121 -0
- package/src/fixtures/claude.json +328 -0
- package/src/fixtures/codex.json +222 -0
- package/src/fixtures/gemini.json +187 -0
- package/src/index.mjs +17 -0
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
|
package/bin/amp-hook.mjs
ADDED
|
@@ -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 };
|