agent-control-plane-core 0.9.2 → 0.11.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 +22 -2
- package/bin/amp-hook.mjs +4 -10
- package/bin/claude-hook.mjs +4 -10
- package/bin/codex-hook.mjs +6 -8
- package/bin/gemini-hook.mjs +4 -9
- package/package.json +1 -1
- package/src/adapters/amp.mjs +4 -1
- package/src/control-plane.mjs +31 -0
- package/src/runtime.mjs +159 -12
- package/types/adapters/amp.d.mts +4 -1
- package/types/control-plane.d.mts +17 -0
- package/types/runtime.d.mts +57 -4
package/README.md
CHANGED
|
@@ -125,6 +125,9 @@ NATIVE_ASK_TIER = false;
|
|
|
125
125
|
Read `NATIVE_ASK_TIER` instead of keeping a list of which agents to escalate an
|
|
126
126
|
`ask` to a `deny` on: a guardrail that must not let an ask through escalates it
|
|
127
127
|
where the flag is `false`, and leaves it alone where the host does the asking.
|
|
128
|
+
`renderEscalated(adapter, verdict, event, { soleGate })` applies that rule: it
|
|
129
|
+
re-renders as a `deny` any non-allow verdict that would render unenforced on a
|
|
130
|
+
vetoable call, except an `ask` on a host whose flag is `true`.
|
|
128
131
|
It describes the HOST, not the verdict — `ask` renders as `enforced: false`
|
|
129
132
|
everywhere — and it names the pre-tool gate, since a tool that already ran has
|
|
130
133
|
nothing left to suspend. Conformance probes your real `render` against it, so a
|
|
@@ -165,14 +168,31 @@ emit(claudeAdapter.render(verdict, event));
|
|
|
165
168
|
`JSON.stringify(render(...))` to stdout exits 0, so the host never blocks.
|
|
166
169
|
|
|
167
170
|
`agent-control-plane-core/runtime` publishes the rest of the plumbing the four
|
|
168
|
-
hook entries share. `
|
|
169
|
-
|
|
171
|
+
hook entries share. `runHookEntry(adapter, judge)` is a whole entry: each
|
|
172
|
+
`bin/<host>-hook.mjs` is that one call. `renderHookResponse` runs parse → judge
|
|
173
|
+
→ render, and returns the failure response THAT host expects when the payload
|
|
174
|
+
does not parse. `emit`
|
|
170
175
|
drains the whole body before it exits, so a large deny body is never truncated
|
|
171
176
|
on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
|
|
172
177
|
`acp-amp-hook` and `acp-gemini-hook`. Codex's hook stdin carries no version, so
|
|
173
178
|
set `ACP_CODEX_VERSION` to the Codex version your launcher pins (or call
|
|
174
179
|
`createCodexAdapter({ version })`); without it, Codex calls parse advisory.
|
|
175
180
|
|
|
181
|
+
Each entry fails the way its host expects when the hook itself breaks: claude,
|
|
182
|
+
codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
|
|
183
|
+
instead, on every host. The entry then exits 2 with the reason on stderr when
|
|
184
|
+
the payload does not parse, or when the judge throws or answers no decision.
|
|
185
|
+
Register a fail-closed entry on tool-call events only. On a Stop event, exit 2
|
|
186
|
+
means "keep working".
|
|
187
|
+
|
|
188
|
+
A host that kills a hook on its timeout runs the call. Set
|
|
189
|
+
`ACP_HOOK_TIMEOUT_MS` below the host's hook timeout, and the entry gives its
|
|
190
|
+
failure response at that deadline instead. A judge that blocks the event loop
|
|
191
|
+
past the deadline still loses to the host's timeout. Nothing in the entry can
|
|
192
|
+
answer for a process that never starts (no `node`, a module that fails to
|
|
193
|
+
load). Claude Code covers that case with `"onFailure": "block"` on the hook
|
|
194
|
+
entry. Codex has no such option, so it still runs the call.
|
|
195
|
+
|
|
176
196
|
The core contract and every adapter (`claude`, `codex`, `amp`, `gemini`) are
|
|
177
197
|
also on the default entry:
|
|
178
198
|
|
package/bin/amp-hook.mjs
CHANGED
|
@@ -4,18 +4,12 @@
|
|
|
4
4
|
* delegate payload on stdin, applies the demo judge, and exits with Amp's
|
|
5
5
|
* decision code (0 allow / 1 ask / 2 reject).
|
|
6
6
|
*
|
|
7
|
-
* FAILURE POSTURE — fail to ASK (exit 1),
|
|
8
|
-
*
|
|
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.
|
|
7
|
+
* FAILURE POSTURE — fail to ASK (exit 1), its `HOST_FAILURE` row in
|
|
8
|
+
* `src/runtime.mjs`. `ACP_FAIL_CLOSED=1` blocks instead (exit 2).
|
|
12
9
|
*
|
|
13
10
|
* Usage: `node bin/amp-hook.mjs < payload.json`
|
|
14
11
|
*/
|
|
15
12
|
import { ampAdapter } from "../src/adapters/amp.mjs";
|
|
16
|
-
import {
|
|
13
|
+
import { runHookEntry } from "../src/runtime.mjs";
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
const FAIL_ASK = { transport: "external_hook", exit_code: 1, enforced: false };
|
|
20
|
-
|
|
21
|
-
emit(renderHookResponse(ampAdapter, await readStdin(), FAIL_ASK));
|
|
15
|
+
await runHookEntry(ampAdapter);
|
package/bin/claude-hook.mjs
CHANGED
|
@@ -3,18 +3,12 @@
|
|
|
3
3
|
* Claude Code EXTERNAL_HOOK entry. Reads a native PreToolUse payload on stdin,
|
|
4
4
|
* applies the demo judge, and emits Claude Code's native response.
|
|
5
5
|
*
|
|
6
|
-
* FAILURE POSTURE — fail OPEN
|
|
7
|
-
*
|
|
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.
|
|
6
|
+
* FAILURE POSTURE — fail OPEN (exit 0), its `HOST_FAILURE` row in
|
|
7
|
+
* `src/runtime.mjs`. `ACP_FAIL_CLOSED=1` blocks instead (exit 2).
|
|
11
8
|
*
|
|
12
9
|
* Usage: `node bin/claude-hook.mjs < payload.json`
|
|
13
10
|
*/
|
|
14
11
|
import { claudeAdapter } from "../src/adapters/claude.mjs";
|
|
15
|
-
import {
|
|
12
|
+
import { runHookEntry } from "../src/runtime.mjs";
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
|
|
19
|
-
|
|
20
|
-
emit(renderHookResponse(claudeAdapter, await readStdin(), FAIL_OPEN));
|
|
14
|
+
await runHookEntry(claudeAdapter);
|
package/bin/codex-hook.mjs
CHANGED
|
@@ -10,16 +10,14 @@
|
|
|
10
10
|
* deny renders advisory (exit 0). Below v0.135 the render is advisory too,
|
|
11
11
|
* because Codex has no hook veto there.
|
|
12
12
|
*
|
|
13
|
-
* FAILURE POSTURE — fail OPEN (exit 0),
|
|
14
|
-
*
|
|
13
|
+
* FAILURE POSTURE — fail OPEN (exit 0), its `HOST_FAILURE` row in
|
|
14
|
+
* `src/runtime.mjs`. `ACP_FAIL_CLOSED=1` blocks instead (exit 2).
|
|
15
15
|
*
|
|
16
16
|
* Usage: `ACP_CODEX_VERSION=0.151.0 node bin/codex-hook.mjs < payload.json`
|
|
17
17
|
*/
|
|
18
18
|
import { createCodexAdapter } from "../src/adapters/codex.mjs";
|
|
19
|
-
import {
|
|
19
|
+
import { runHookEntry } from "../src/runtime.mjs";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const adapter = createCodexAdapter({ version: process.env.ACP_CODEX_VERSION });
|
|
25
|
-
emit(renderHookResponse(adapter, await readStdin(), FAIL_OPEN));
|
|
21
|
+
await runHookEntry(
|
|
22
|
+
createCodexAdapter({ version: process.env.ACP_CODEX_VERSION }),
|
|
23
|
+
);
|
package/bin/gemini-hook.mjs
CHANGED
|
@@ -3,17 +3,12 @@
|
|
|
3
3
|
* Gemini CLI EXTERNAL_HOOK entry. Reads a native BeforeTool payload on stdin,
|
|
4
4
|
* applies the demo judge, and emits Gemini's native response.
|
|
5
5
|
*
|
|
6
|
-
* FAILURE POSTURE — fail OPEN (exit 0)
|
|
7
|
-
*
|
|
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.
|
|
6
|
+
* FAILURE POSTURE — fail OPEN (exit 0), its `HOST_FAILURE` row in
|
|
7
|
+
* `src/runtime.mjs`. `ACP_FAIL_CLOSED=1` blocks instead (exit 2).
|
|
10
8
|
*
|
|
11
9
|
* Usage: `node bin/gemini-hook.mjs < payload.json`
|
|
12
10
|
*/
|
|
13
11
|
import { geminiAdapter } from "../src/adapters/gemini.mjs";
|
|
14
|
-
import {
|
|
12
|
+
import { runHookEntry } from "../src/runtime.mjs";
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
|
|
18
|
-
|
|
19
|
-
emit(renderHookResponse(geminiAdapter, await readStdin(), FAIL_OPEN));
|
|
14
|
+
await runHookEntry(geminiAdapter);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -159,7 +159,10 @@ export function parse(native) {
|
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
161
|
* Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
|
|
162
|
-
* treats as neither the allow (0) nor the reject (2).
|
|
162
|
+
* treats as neither the allow (0) nor the reject (2). Source: Amp's own notes,
|
|
163
|
+
* "`0` allows the tool call, `1` makes Amp ask, and `2` rejects the tool call"
|
|
164
|
+
* (https://ampcode.com/notes/permissions). No live probe has measured it, and
|
|
165
|
+
* no source says what exit 1 does in a headless run. {@link render} emits it
|
|
163
166
|
* for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
|
|
164
167
|
* The table below leans on the same tier for the non-vetoable deny, which is
|
|
165
168
|
* only honest because the tier is real.
|
package/src/control-plane.mjs
CHANGED
|
@@ -958,3 +958,34 @@ export function gatedResponse(
|
|
|
958
958
|
[row.reason_stream]: blockReason(reason),
|
|
959
959
|
});
|
|
960
960
|
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Render `verdict` on `adapter`'s host, escalating to a `deny` when the host
|
|
964
|
+
* would otherwise let the call run without the gate it asked for. A non-allow
|
|
965
|
+
* verdict that renders unenforced on a vetoable call is re-rendered as a deny,
|
|
966
|
+
* unless it is an `ask` and the host honours its own ask tier
|
|
967
|
+
* ({@link Adapter} `NATIVE_ASK_TIER`). Nothing changes for an allow, for a call
|
|
968
|
+
* this guardrail cannot veto (a deny there blocks nothing either), or for a
|
|
969
|
+
* render the host already enforces. The deny keeps the verdict's other fields.
|
|
970
|
+
* @param {Adapter} adapter
|
|
971
|
+
* @param {Verdict} verdict
|
|
972
|
+
* @param {ToolCallEvent} event
|
|
973
|
+
* @param {{ soleGate?: boolean }} [options] passed through to `render`
|
|
974
|
+
* @returns {NativeResponse}
|
|
975
|
+
*/
|
|
976
|
+
export function renderEscalated(adapter, verdict, event, options = {}) {
|
|
977
|
+
const { decision } = normalizeVerdict(verdict);
|
|
978
|
+
const response = adapter.render(verdict, event, options);
|
|
979
|
+
if (
|
|
980
|
+
response.enforced ||
|
|
981
|
+
decision === Decision.ALLOW ||
|
|
982
|
+
!event.this_call_vetoable ||
|
|
983
|
+
(decision === Decision.ASK && adapter.NATIVE_ASK_TIER)
|
|
984
|
+
)
|
|
985
|
+
return response;
|
|
986
|
+
return adapter.render(
|
|
987
|
+
{ ...verdict, decision: Decision.DENY },
|
|
988
|
+
event,
|
|
989
|
+
options,
|
|
990
|
+
);
|
|
991
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The EXTERNAL_HOOK runtime, published at `agent-control-plane-core/runtime` —
|
|
3
3
|
* NOT a runtime host multiplexer. Each host has its OWN entry
|
|
4
|
-
* (`bin/<host>-hook.mjs`) that hardcodes its adapter
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and so had to pick a single shared failure behavior; sharing pure plumbing
|
|
10
|
-
* across distinct entries is not that.
|
|
4
|
+
* (`bin/<host>-hook.mjs`) that hardcodes its adapter; {@link runHookEntry}
|
|
5
|
+
* runs it with that host's OWN failure posture from {@link HOST_FAILURE}. The
|
|
6
|
+
* thing item ② deleted was the `--agent` switch that let one binary
|
|
7
|
+
* impersonate every host and so had to pick a single shared failure behavior;
|
|
8
|
+
* a per-host table keyed by the adapter's own AGENT is not that.
|
|
11
9
|
*
|
|
12
10
|
* It is exported because every consumer writing its own hook entry had to
|
|
13
11
|
* re-implement the same two things, and both fail silently when they are wrong:
|
|
@@ -15,7 +13,7 @@
|
|
|
15
13
|
* `process.exit` (see {@link writeAllSync}).
|
|
16
14
|
*/
|
|
17
15
|
import { writeSync } from "node:fs";
|
|
18
|
-
import { Decision, sanitizeVerdict } from "./control-plane.mjs";
|
|
16
|
+
import { Decision, gatedResponse, sanitizeVerdict } from "./control-plane.mjs";
|
|
19
17
|
|
|
20
18
|
/**
|
|
21
19
|
* Read all of stdin to a string.
|
|
@@ -57,10 +55,10 @@ export function demoJudge(event) {
|
|
|
57
55
|
/**
|
|
58
56
|
* Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
|
|
59
57
|
* can't be parsed or the render throws, return `onFailure` — the host's OWN
|
|
60
|
-
* fail-safe response — so a crashed hook degrades
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
58
|
+
* fail-safe response from {@link failurePosture} — so a crashed hook degrades
|
|
59
|
+
* the way THAT host expects instead of a single shared default. This is the
|
|
60
|
+
* deliberate, necessary recovery the whole per-host split exists for, not a
|
|
61
|
+
* blanket swallow.
|
|
64
62
|
*
|
|
65
63
|
* A judge that THROWS is not a malformed payload: the call parsed, and the judge
|
|
66
64
|
* failed to answer. That takes the same path as a judge that answers garbage —
|
|
@@ -108,6 +106,10 @@ export function renderHookResponse(
|
|
|
108
106
|
answer = { reason: `hook judge threw: ${describe(err).split("\n")[0]}` };
|
|
109
107
|
report("hook judge error", err);
|
|
110
108
|
}
|
|
109
|
+
// A host-supplied `onFailure` that BLOCKS (see {@link failurePosture}) wins
|
|
110
|
+
// over the clamp's ask: Codex and Gemini cannot enforce an ask, so the
|
|
111
|
+
// clamp would run a call no judge approved.
|
|
112
|
+
if (onFailure.enforced && !isDecision(answer)) return onFailure;
|
|
111
113
|
// A judge's answer is clamped, not trusted. `normalizeVerdict` (inside
|
|
112
114
|
// every adapter's render) THROWS on a decision outside allow/deny/ask, and
|
|
113
115
|
// that throw would land in the catch below — the host's fail-safe, exit 0
|
|
@@ -123,6 +125,151 @@ export function renderHookResponse(
|
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Whether `answer` carries one of the three decisions a judge may return.
|
|
130
|
+
* @param {unknown} answer
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
function isDecision(answer) {
|
|
134
|
+
const decision =
|
|
135
|
+
answer !== null && typeof answer === "object"
|
|
136
|
+
? /** @type {{ decision?: unknown }} */ (answer).decision
|
|
137
|
+
: undefined;
|
|
138
|
+
return Object.values(Decision).includes(/** @type {any} */ (decision));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The response that BLOCKS the call on `agent`'s host: its
|
|
143
|
+
* {@link BLOCK_TRANSPORT} row's exit code, with `reason` on the row's reason
|
|
144
|
+
* stream. Every row blocks, so this is the one fail-closed response for all hosts.
|
|
145
|
+
* @param {string} agent
|
|
146
|
+
* @param {string} reason
|
|
147
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
148
|
+
*/
|
|
149
|
+
export function failClosedResponse(agent, reason) {
|
|
150
|
+
return gatedResponse(agent, {
|
|
151
|
+
transport: "external_hook",
|
|
152
|
+
enforced: true,
|
|
153
|
+
unblocked_exit_code: 0,
|
|
154
|
+
reason,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @type {import("./control-plane.mjs").NativeResponse} */
|
|
159
|
+
const RUN = Object.freeze({
|
|
160
|
+
transport: "external_hook",
|
|
161
|
+
exit_code: 0,
|
|
162
|
+
enforced: false,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Each host's DEFAULT response when its hook breaks, which is how that host
|
|
167
|
+
* degrades a hook it cannot read:
|
|
168
|
+
* - claude, codex, gemini: exit 0, so the call runs. Each host treats a crashed
|
|
169
|
+
* or non-conforming hook as no objection, and a spurious block would wedge the
|
|
170
|
+
* user. The sandbox is the backstop.
|
|
171
|
+
* - amp: exit 1, which Amp's delegate reads as "ask the human". That neither
|
|
172
|
+
* runs nor rejects a call no judge saw.
|
|
173
|
+
* @type {Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>}
|
|
174
|
+
*/
|
|
175
|
+
export const HOST_FAILURE = Object.freeze({
|
|
176
|
+
claude: RUN,
|
|
177
|
+
codex: RUN,
|
|
178
|
+
gemini: RUN,
|
|
179
|
+
amp: Object.freeze({
|
|
180
|
+
transport: "external_hook",
|
|
181
|
+
exit_code: 1,
|
|
182
|
+
enforced: false,
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The failure response for `agent`: its {@link HOST_FAILURE} row, or
|
|
188
|
+
* {@link failClosedResponse} when `ACP_FAIL_CLOSED=1`. A blocking failure
|
|
189
|
+
* response also blocks a call whose judge threw or answered no decision.
|
|
190
|
+
* Register a fail-closed entry on tool-call events only: on a Stop event,
|
|
191
|
+
* exit 2 means "keep working". {@link runHookEntry} also answers a hang
|
|
192
|
+
* before the host's timeout. It cannot answer for a process that never starts
|
|
193
|
+
* or that the host kills for another reason; only the host can, as Claude
|
|
194
|
+
* Code's `"onFailure": "block"` does.
|
|
195
|
+
* @param {string} agent
|
|
196
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
197
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
198
|
+
*/
|
|
199
|
+
export function failurePosture(agent, env = process.env) {
|
|
200
|
+
if (!Object.hasOwn(HOST_FAILURE, agent))
|
|
201
|
+
throw new Error(
|
|
202
|
+
`runtime: no HOST_FAILURE row for agent ${JSON.stringify(agent)}`,
|
|
203
|
+
);
|
|
204
|
+
const flag = env.ACP_FAIL_CLOSED;
|
|
205
|
+
if (flag === undefined || flag === "" || flag === "0")
|
|
206
|
+
return HOST_FAILURE[agent];
|
|
207
|
+
if (flag !== "1")
|
|
208
|
+
throw new Error(
|
|
209
|
+
`ACP_FAIL_CLOSED must be "1" or "0", got ${JSON.stringify(flag)}`,
|
|
210
|
+
);
|
|
211
|
+
return failClosedResponse(
|
|
212
|
+
agent,
|
|
213
|
+
"hook failed; ACP_FAIL_CLOSED blocks the call",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The whole hook entry for `adapter`'s host: read stdin, run
|
|
219
|
+
* {@link renderHookResponse} with that host's {@link failurePosture}, and
|
|
220
|
+
* {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
|
|
221
|
+
* failure response also answers a stdin read error and, when
|
|
222
|
+
* {@link hookDeadlineMs} is set, a hook still running at that deadline.
|
|
223
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
224
|
+
* @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
|
|
225
|
+
* @returns {Promise<never>}
|
|
226
|
+
*/
|
|
227
|
+
export async function runHookEntry(adapter, judge = demoJudge) {
|
|
228
|
+
const onFailure = failurePosture(adapter.AGENT);
|
|
229
|
+
const deadline = hookDeadlineMs();
|
|
230
|
+
/** @param {string} what @param {unknown} err @returns {never} */
|
|
231
|
+
const fail = (what, err) => {
|
|
232
|
+
report(what, err);
|
|
233
|
+
return emit(onFailure);
|
|
234
|
+
};
|
|
235
|
+
// A host that kills a hung hook runs the call, so the hook answers first with
|
|
236
|
+
// its own failure response. A judge that blocks the event loop outlasts this.
|
|
237
|
+
if (deadline !== undefined)
|
|
238
|
+
setTimeout(
|
|
239
|
+
() =>
|
|
240
|
+
fail(
|
|
241
|
+
"hook deadline",
|
|
242
|
+
new Error(`no response within ACP_HOOK_TIMEOUT_MS=${deadline}`),
|
|
243
|
+
),
|
|
244
|
+
deadline,
|
|
245
|
+
);
|
|
246
|
+
/** @type {string} */
|
|
247
|
+
let raw;
|
|
248
|
+
try {
|
|
249
|
+
raw = await readStdin();
|
|
250
|
+
} catch (err) {
|
|
251
|
+
return fail("hook stdin error", err);
|
|
252
|
+
}
|
|
253
|
+
return emit(renderHookResponse(adapter, raw, onFailure, judge));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* `ACP_HOOK_TIMEOUT_MS` as a positive integer, or `undefined` when unset. Set it
|
|
258
|
+
* below the host's own hook timeout, so the hook's failure response lands
|
|
259
|
+
* before the host kills it.
|
|
260
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
261
|
+
* @returns {number|undefined}
|
|
262
|
+
*/
|
|
263
|
+
export function hookDeadlineMs(env = process.env) {
|
|
264
|
+
const value = env.ACP_HOOK_TIMEOUT_MS;
|
|
265
|
+
if (value === undefined || value === "") return undefined;
|
|
266
|
+
if (!/^[1-9]\d*$/u.test(value))
|
|
267
|
+
throw new Error(
|
|
268
|
+
`ACP_HOOK_TIMEOUT_MS must be a positive integer of milliseconds, got ${JSON.stringify(value)}`,
|
|
269
|
+
);
|
|
270
|
+
return Number(value);
|
|
271
|
+
}
|
|
272
|
+
|
|
126
273
|
/**
|
|
127
274
|
* A thrown value as readable text: an Error's stack (or message when it has no
|
|
128
275
|
* stack), or the value itself for a bare throw.
|
package/types/adapters/amp.d.mts
CHANGED
|
@@ -98,7 +98,10 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
|
98
98
|
export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
|
|
99
99
|
/**
|
|
100
100
|
* Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
|
|
101
|
-
* treats as neither the allow (0) nor the reject (2).
|
|
101
|
+
* treats as neither the allow (0) nor the reject (2). Source: Amp's own notes,
|
|
102
|
+
* "`0` allows the tool call, `1` makes Amp ask, and `2` rejects the tool call"
|
|
103
|
+
* (https://ampcode.com/notes/permissions). No live probe has measured it, and
|
|
104
|
+
* no source says what exit 1 does in a headless run. {@link render} emits it
|
|
102
105
|
* for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
|
|
103
106
|
* The table below leans on the same tier for the non-vetoable deny, which is
|
|
104
107
|
* only honest because the tier is real.
|
|
@@ -288,6 +288,23 @@ export function gatedResponse(agent: string, { transport, enforced, unblocked_ex
|
|
|
288
288
|
stdout?: unknown;
|
|
289
289
|
reason?: string;
|
|
290
290
|
}): NativeResponse;
|
|
291
|
+
/**
|
|
292
|
+
* Render `verdict` on `adapter`'s host, escalating to a `deny` when the host
|
|
293
|
+
* would otherwise let the call run without the gate it asked for. A non-allow
|
|
294
|
+
* verdict that renders unenforced on a vetoable call is re-rendered as a deny,
|
|
295
|
+
* unless it is an `ask` and the host honours its own ask tier
|
|
296
|
+
* ({@link Adapter} `NATIVE_ASK_TIER`). Nothing changes for an allow, for a call
|
|
297
|
+
* this guardrail cannot veto (a deny there blocks nothing either), or for a
|
|
298
|
+
* render the host already enforces. The deny keeps the verdict's other fields.
|
|
299
|
+
* @param {Adapter} adapter
|
|
300
|
+
* @param {Verdict} verdict
|
|
301
|
+
* @param {ToolCallEvent} event
|
|
302
|
+
* @param {{ soleGate?: boolean }} [options] passed through to `render`
|
|
303
|
+
* @returns {NativeResponse}
|
|
304
|
+
*/
|
|
305
|
+
export function renderEscalated(adapter: Adapter, verdict: Verdict, event: ToolCallEvent, options?: {
|
|
306
|
+
soleGate?: boolean;
|
|
307
|
+
}): NativeResponse;
|
|
291
308
|
/**
|
|
292
309
|
* The vendor-neutral control-plane contract.
|
|
293
310
|
*
|
package/types/runtime.d.mts
CHANGED
|
@@ -14,10 +14,10 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
|
|
|
14
14
|
/**
|
|
15
15
|
* Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
|
|
16
16
|
* can't be parsed or the render throws, return `onFailure` — the host's OWN
|
|
17
|
-
* fail-safe response — so a crashed hook degrades
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* fail-safe response from {@link failurePosture} — so a crashed hook degrades
|
|
18
|
+
* the way THAT host expects instead of a single shared default. This is the
|
|
19
|
+
* deliberate, necessary recovery the whole per-host split exists for, not a
|
|
20
|
+
* blanket swallow.
|
|
21
21
|
*
|
|
22
22
|
* A judge that THROWS is not a malformed payload: the call parsed, and the judge
|
|
23
23
|
* failed to answer. That takes the same path as a judge that answers garbage —
|
|
@@ -39,6 +39,48 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
|
|
|
39
39
|
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
40
40
|
*/
|
|
41
41
|
export function renderHookResponse(adapter: import("./control-plane.mjs").Adapter, rawInput: string, onFailure: import("./control-plane.mjs").NativeResponse, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): import("./control-plane.mjs").NativeResponse;
|
|
42
|
+
/**
|
|
43
|
+
* The response that BLOCKS the call on `agent`'s host: its
|
|
44
|
+
* {@link BLOCK_TRANSPORT} row's exit code, with `reason` on the row's reason
|
|
45
|
+
* stream. Every row blocks, so this is the one fail-closed response for all hosts.
|
|
46
|
+
* @param {string} agent
|
|
47
|
+
* @param {string} reason
|
|
48
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
49
|
+
*/
|
|
50
|
+
export function failClosedResponse(agent: string, reason: string): import("./control-plane.mjs").NativeResponse;
|
|
51
|
+
/**
|
|
52
|
+
* The failure response for `agent`: its {@link HOST_FAILURE} row, or
|
|
53
|
+
* {@link failClosedResponse} when `ACP_FAIL_CLOSED=1`. A blocking failure
|
|
54
|
+
* response also blocks a call whose judge threw or answered no decision.
|
|
55
|
+
* Register a fail-closed entry on tool-call events only: on a Stop event,
|
|
56
|
+
* exit 2 means "keep working". {@link runHookEntry} also answers a hang
|
|
57
|
+
* before the host's timeout. It cannot answer for a process that never starts
|
|
58
|
+
* or that the host kills for another reason; only the host can, as Claude
|
|
59
|
+
* Code's `"onFailure": "block"` does.
|
|
60
|
+
* @param {string} agent
|
|
61
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
62
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
63
|
+
*/
|
|
64
|
+
export function failurePosture(agent: string, env?: NodeJS.ProcessEnv): import("./control-plane.mjs").NativeResponse;
|
|
65
|
+
/**
|
|
66
|
+
* The whole hook entry for `adapter`'s host: read stdin, run
|
|
67
|
+
* {@link renderHookResponse} with that host's {@link failurePosture}, and
|
|
68
|
+
* {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
|
|
69
|
+
* failure response also answers a stdin read error and, when
|
|
70
|
+
* {@link hookDeadlineMs} is set, a hook still running at that deadline.
|
|
71
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
72
|
+
* @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
|
|
73
|
+
* @returns {Promise<never>}
|
|
74
|
+
*/
|
|
75
|
+
export function runHookEntry(adapter: import("./control-plane.mjs").Adapter, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): Promise<never>;
|
|
76
|
+
/**
|
|
77
|
+
* `ACP_HOOK_TIMEOUT_MS` as a positive integer, or `undefined` when unset. Set it
|
|
78
|
+
* below the host's own hook timeout, so the hook's failure response lands
|
|
79
|
+
* before the host kills it.
|
|
80
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
81
|
+
* @returns {number|undefined}
|
|
82
|
+
*/
|
|
83
|
+
export function hookDeadlineMs(env?: NodeJS.ProcessEnv): number | undefined;
|
|
42
84
|
/**
|
|
43
85
|
* Whether a failed `writeSync` is the one recoverable case: EAGAIN, meaning the
|
|
44
86
|
* non-blocking pipe is momentarily full because the host has not drained it
|
|
@@ -80,3 +122,14 @@ export function writeAllSync(fd: number, text: string): void;
|
|
|
80
122
|
* @returns {never}
|
|
81
123
|
*/
|
|
82
124
|
export function emit(response: import("./control-plane.mjs").NativeResponse): never;
|
|
125
|
+
/**
|
|
126
|
+
* Each host's DEFAULT response when its hook breaks, which is how that host
|
|
127
|
+
* degrades a hook it cannot read:
|
|
128
|
+
* - claude, codex, gemini: exit 0, so the call runs. Each host treats a crashed
|
|
129
|
+
* or non-conforming hook as no objection, and a spurious block would wedge the
|
|
130
|
+
* user. The sandbox is the backstop.
|
|
131
|
+
* - amp: exit 1, which Amp's delegate reads as "ask the human". That neither
|
|
132
|
+
* runs nor rejects a call no judge saw.
|
|
133
|
+
* @type {Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>}
|
|
134
|
+
*/
|
|
135
|
+
export const HOST_FAILURE: Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>;
|