agent-control-plane-core 0.10.0 → 0.11.1
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 +20 -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/runtime.mjs +169 -14
- package/types/runtime.d.mts +60 -5
package/README.md
CHANGED
|
@@ -168,14 +168,32 @@ emit(claudeAdapter.render(verdict, event));
|
|
|
168
168
|
`JSON.stringify(render(...))` to stdout exits 0, so the host never blocks.
|
|
169
169
|
|
|
170
170
|
`agent-control-plane-core/runtime` publishes the rest of the plumbing the four
|
|
171
|
-
hook entries share. `
|
|
172
|
-
|
|
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
|
+
→ `renderEscalated`, so a judge's `ask` on a host with no ask tier blocks a
|
|
174
|
+
vetoable call instead of running it. It returns the failure response THAT host
|
|
175
|
+
expects when the payload does not parse. `emit`
|
|
173
176
|
drains the whole body before it exits, so a large deny body is never truncated
|
|
174
177
|
on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
|
|
175
178
|
`acp-amp-hook` and `acp-gemini-hook`. Codex's hook stdin carries no version, so
|
|
176
179
|
set `ACP_CODEX_VERSION` to the Codex version your launcher pins (or call
|
|
177
180
|
`createCodexAdapter({ version })`); without it, Codex calls parse advisory.
|
|
178
181
|
|
|
182
|
+
Each entry fails the way its host expects when the hook itself breaks: claude,
|
|
183
|
+
codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
|
|
184
|
+
instead, on every host. The entry then exits 2 with the reason on stderr when
|
|
185
|
+
the payload does not parse, or when the judge throws or answers no decision.
|
|
186
|
+
Register a fail-closed entry on tool-call events only. On a Stop event, exit 2
|
|
187
|
+
means "keep working".
|
|
188
|
+
|
|
189
|
+
A host that kills a hook on its timeout runs the call. Set
|
|
190
|
+
`ACP_HOOK_TIMEOUT_MS` below the host's hook timeout, and the entry gives its
|
|
191
|
+
failure response at that deadline instead. A judge that blocks the event loop
|
|
192
|
+
past the deadline still loses to the host's timeout. Nothing in the entry can
|
|
193
|
+
answer for a process that never starts (no `node`, a module that fails to
|
|
194
|
+
load). Claude Code covers that case with `"onFailure": "block"` on the hook
|
|
195
|
+
entry. Codex has no such option, so it still runs the call.
|
|
196
|
+
|
|
179
197
|
The core contract and every adapter (`claude`, `codex`, `amp`, `gemini`) are
|
|
180
198
|
also on the default entry:
|
|
181
199
|
|
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.1",
|
|
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/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,12 @@
|
|
|
15
13
|
* `process.exit` (see {@link writeAllSync}).
|
|
16
14
|
*/
|
|
17
15
|
import { writeSync } from "node:fs";
|
|
18
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
Decision,
|
|
18
|
+
gatedResponse,
|
|
19
|
+
renderEscalated,
|
|
20
|
+
sanitizeVerdict,
|
|
21
|
+
} from "./control-plane.mjs";
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Read all of stdin to a string.
|
|
@@ -57,15 +60,17 @@ export function demoJudge(event) {
|
|
|
57
60
|
/**
|
|
58
61
|
* Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
|
|
59
62
|
* 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
|
-
*
|
|
63
|
+
* fail-safe response from {@link failurePosture} — so a crashed hook degrades
|
|
64
|
+
* the way THAT host expects instead of a single shared default. This is the
|
|
65
|
+
* deliberate, necessary recovery the whole per-host split exists for, not a
|
|
66
|
+
* blanket swallow.
|
|
64
67
|
*
|
|
65
68
|
* A judge that THROWS is not a malformed payload: the call parsed, and the judge
|
|
66
69
|
* failed to answer. That takes the same path as a judge that answers garbage —
|
|
67
70
|
* the clamp's `ask` (see below) — so a crashing guardrail puts the call in front
|
|
68
|
-
* of a human instead of taking an exit-0 fail-safe that runs it.
|
|
71
|
+
* of a human instead of taking an exit-0 fail-safe that runs it. The verdict
|
|
72
|
+
* renders through `renderEscalated`, so on a host with no ask tier that ask
|
|
73
|
+
* blocks a vetoable call rather than running it.
|
|
69
74
|
*
|
|
70
75
|
* Every error is written to STDERR (never stdout, which belongs to the host
|
|
71
76
|
* transport) so a regression that silently disables enforcement leaves a
|
|
@@ -108,6 +113,11 @@ export function renderHookResponse(
|
|
|
108
113
|
answer = { reason: `hook judge threw: ${describe(err).split("\n")[0]}` };
|
|
109
114
|
report("hook judge error", err);
|
|
110
115
|
}
|
|
116
|
+
// A host-supplied `onFailure` that BLOCKS (see {@link failurePosture}) wins
|
|
117
|
+
// over the clamp's ask. `renderEscalated` keeps an ask on a host that
|
|
118
|
+
// honours one (Claude, Amp), so without this a fail-closed entry would ask
|
|
119
|
+
// about a call no judge approved instead of blocking it.
|
|
120
|
+
if (onFailure.enforced && !isDecision(answer)) return onFailure;
|
|
111
121
|
// A judge's answer is clamped, not trusted. `normalizeVerdict` (inside
|
|
112
122
|
// every adapter's render) THROWS on a decision outside allow/deny/ask, and
|
|
113
123
|
// that throw would land in the catch below — the host's fail-safe, exit 0
|
|
@@ -116,13 +126,158 @@ export function renderHookResponse(
|
|
|
116
126
|
// in front of a human. Prose is passed through: scrubbing monitor-authored
|
|
117
127
|
// text needs a scrubber this transport module has no business choosing.
|
|
118
128
|
const verdict = sanitizeVerdict(answer, (text) => text);
|
|
119
|
-
return adapter
|
|
129
|
+
return renderEscalated(adapter, verdict, event);
|
|
120
130
|
} catch (err) {
|
|
121
131
|
report("hook pipeline error", err);
|
|
122
132
|
return onFailure;
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Whether `answer` carries one of the three decisions a judge may return.
|
|
138
|
+
* @param {unknown} answer
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
function isDecision(answer) {
|
|
142
|
+
const decision =
|
|
143
|
+
answer !== null && typeof answer === "object"
|
|
144
|
+
? /** @type {{ decision?: unknown }} */ (answer).decision
|
|
145
|
+
: undefined;
|
|
146
|
+
return Object.values(Decision).includes(/** @type {any} */ (decision));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The response that BLOCKS the call on `agent`'s host: its
|
|
151
|
+
* {@link BLOCK_TRANSPORT} row's exit code, with `reason` on the row's reason
|
|
152
|
+
* stream. Every row blocks, so this is the one fail-closed response for all hosts.
|
|
153
|
+
* @param {string} agent
|
|
154
|
+
* @param {string} reason
|
|
155
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
156
|
+
*/
|
|
157
|
+
export function failClosedResponse(agent, reason) {
|
|
158
|
+
return gatedResponse(agent, {
|
|
159
|
+
transport: "external_hook",
|
|
160
|
+
enforced: true,
|
|
161
|
+
unblocked_exit_code: 0,
|
|
162
|
+
reason,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @type {import("./control-plane.mjs").NativeResponse} */
|
|
167
|
+
const RUN = Object.freeze({
|
|
168
|
+
transport: "external_hook",
|
|
169
|
+
exit_code: 0,
|
|
170
|
+
enforced: false,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Each host's DEFAULT response when its hook breaks, which is how that host
|
|
175
|
+
* degrades a hook it cannot read:
|
|
176
|
+
* - claude, codex, gemini: exit 0, so the call runs. Each host treats a crashed
|
|
177
|
+
* or non-conforming hook as no objection, and a spurious block would wedge the
|
|
178
|
+
* user. The sandbox is the backstop.
|
|
179
|
+
* - amp: exit 1, which Amp's delegate reads as "ask the human". That neither
|
|
180
|
+
* runs nor rejects a call no judge saw.
|
|
181
|
+
* @type {Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>}
|
|
182
|
+
*/
|
|
183
|
+
export const HOST_FAILURE = Object.freeze({
|
|
184
|
+
claude: RUN,
|
|
185
|
+
codex: RUN,
|
|
186
|
+
gemini: RUN,
|
|
187
|
+
amp: Object.freeze({
|
|
188
|
+
transport: "external_hook",
|
|
189
|
+
exit_code: 1,
|
|
190
|
+
enforced: false,
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The failure response for `agent`: its {@link HOST_FAILURE} row, or
|
|
196
|
+
* {@link failClosedResponse} when `ACP_FAIL_CLOSED=1`. A blocking failure
|
|
197
|
+
* response also blocks a call whose judge threw or answered no decision.
|
|
198
|
+
* Register a fail-closed entry on tool-call events only: on a Stop event,
|
|
199
|
+
* exit 2 means "keep working". {@link runHookEntry} also answers a hang
|
|
200
|
+
* before the host's timeout. It cannot answer for a process that never starts
|
|
201
|
+
* or that the host kills for another reason; only the host can, as Claude
|
|
202
|
+
* Code's `"onFailure": "block"` does.
|
|
203
|
+
* @param {string} agent
|
|
204
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
205
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
206
|
+
*/
|
|
207
|
+
export function failurePosture(agent, env = process.env) {
|
|
208
|
+
if (!Object.hasOwn(HOST_FAILURE, agent))
|
|
209
|
+
throw new Error(
|
|
210
|
+
`runtime: no HOST_FAILURE row for agent ${JSON.stringify(agent)}`,
|
|
211
|
+
);
|
|
212
|
+
const flag = env.ACP_FAIL_CLOSED;
|
|
213
|
+
if (flag === undefined || flag === "" || flag === "0")
|
|
214
|
+
return HOST_FAILURE[agent];
|
|
215
|
+
if (flag !== "1")
|
|
216
|
+
throw new Error(
|
|
217
|
+
`ACP_FAIL_CLOSED must be "1" or "0", got ${JSON.stringify(flag)}`,
|
|
218
|
+
);
|
|
219
|
+
return failClosedResponse(
|
|
220
|
+
agent,
|
|
221
|
+
"hook failed; ACP_FAIL_CLOSED blocks the call",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The whole hook entry for `adapter`'s host: read stdin, run
|
|
227
|
+
* {@link renderHookResponse} with that host's {@link failurePosture}, and
|
|
228
|
+
* {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
|
|
229
|
+
* failure response also answers a stdin read error and, when
|
|
230
|
+
* {@link hookDeadlineMs} is set, a hook still running at that deadline.
|
|
231
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
232
|
+
* @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
|
|
233
|
+
* @returns {Promise<never>}
|
|
234
|
+
*/
|
|
235
|
+
export async function runHookEntry(adapter, judge = demoJudge) {
|
|
236
|
+
const onFailure = failurePosture(adapter.AGENT);
|
|
237
|
+
const deadline = hookDeadlineMs();
|
|
238
|
+
/** @param {string} what @param {unknown} err @returns {never} */
|
|
239
|
+
const fail = (what, err) => {
|
|
240
|
+
report(what, err);
|
|
241
|
+
return emit(onFailure);
|
|
242
|
+
};
|
|
243
|
+
// A host that kills a hung hook runs the call, so the hook answers first with
|
|
244
|
+
// its own failure response. A judge that blocks the event loop outlasts this.
|
|
245
|
+
if (deadline !== undefined)
|
|
246
|
+
setTimeout(
|
|
247
|
+
() =>
|
|
248
|
+
fail(
|
|
249
|
+
"hook deadline",
|
|
250
|
+
new Error(`no response within ACP_HOOK_TIMEOUT_MS=${deadline}`),
|
|
251
|
+
),
|
|
252
|
+
deadline,
|
|
253
|
+
);
|
|
254
|
+
/** @type {string} */
|
|
255
|
+
let raw;
|
|
256
|
+
try {
|
|
257
|
+
raw = await readStdin();
|
|
258
|
+
} catch (err) {
|
|
259
|
+
return fail("hook stdin error", err);
|
|
260
|
+
}
|
|
261
|
+
return emit(renderHookResponse(adapter, raw, onFailure, judge));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* `ACP_HOOK_TIMEOUT_MS` as a positive integer, or `undefined` when unset. Set it
|
|
266
|
+
* below the host's own hook timeout, so the hook's failure response lands
|
|
267
|
+
* before the host kills it.
|
|
268
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
269
|
+
* @returns {number|undefined}
|
|
270
|
+
*/
|
|
271
|
+
export function hookDeadlineMs(env = process.env) {
|
|
272
|
+
const value = env.ACP_HOOK_TIMEOUT_MS;
|
|
273
|
+
if (value === undefined || value === "") return undefined;
|
|
274
|
+
if (!/^[1-9]\d*$/u.test(value))
|
|
275
|
+
throw new Error(
|
|
276
|
+
`ACP_HOOK_TIMEOUT_MS must be a positive integer of milliseconds, got ${JSON.stringify(value)}`,
|
|
277
|
+
);
|
|
278
|
+
return Number(value);
|
|
279
|
+
}
|
|
280
|
+
|
|
126
281
|
/**
|
|
127
282
|
* A thrown value as readable text: an Error's stack (or message when it has no
|
|
128
283
|
* stack), or the value itself for a bare throw.
|
package/types/runtime.d.mts
CHANGED
|
@@ -14,15 +14,17 @@ 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 —
|
|
24
24
|
* the clamp's `ask` (see below) — so a crashing guardrail puts the call in front
|
|
25
|
-
* of a human instead of taking an exit-0 fail-safe that runs it.
|
|
25
|
+
* of a human instead of taking an exit-0 fail-safe that runs it. The verdict
|
|
26
|
+
* renders through `renderEscalated`, so on a host with no ask tier that ask
|
|
27
|
+
* blocks a vetoable call rather than running it.
|
|
26
28
|
*
|
|
27
29
|
* Every error is written to STDERR (never stdout, which belongs to the host
|
|
28
30
|
* transport) so a regression that silently disables enforcement leaves a
|
|
@@ -39,6 +41,48 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
|
|
|
39
41
|
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
40
42
|
*/
|
|
41
43
|
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;
|
|
44
|
+
/**
|
|
45
|
+
* The response that BLOCKS the call on `agent`'s host: its
|
|
46
|
+
* {@link BLOCK_TRANSPORT} row's exit code, with `reason` on the row's reason
|
|
47
|
+
* stream. Every row blocks, so this is the one fail-closed response for all hosts.
|
|
48
|
+
* @param {string} agent
|
|
49
|
+
* @param {string} reason
|
|
50
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
51
|
+
*/
|
|
52
|
+
export function failClosedResponse(agent: string, reason: string): import("./control-plane.mjs").NativeResponse;
|
|
53
|
+
/**
|
|
54
|
+
* The failure response for `agent`: its {@link HOST_FAILURE} row, or
|
|
55
|
+
* {@link failClosedResponse} when `ACP_FAIL_CLOSED=1`. A blocking failure
|
|
56
|
+
* response also blocks a call whose judge threw or answered no decision.
|
|
57
|
+
* Register a fail-closed entry on tool-call events only: on a Stop event,
|
|
58
|
+
* exit 2 means "keep working". {@link runHookEntry} also answers a hang
|
|
59
|
+
* before the host's timeout. It cannot answer for a process that never starts
|
|
60
|
+
* or that the host kills for another reason; only the host can, as Claude
|
|
61
|
+
* Code's `"onFailure": "block"` does.
|
|
62
|
+
* @param {string} agent
|
|
63
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
64
|
+
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
65
|
+
*/
|
|
66
|
+
export function failurePosture(agent: string, env?: NodeJS.ProcessEnv): import("./control-plane.mjs").NativeResponse;
|
|
67
|
+
/**
|
|
68
|
+
* The whole hook entry for `adapter`'s host: read stdin, run
|
|
69
|
+
* {@link renderHookResponse} with that host's {@link failurePosture}, and
|
|
70
|
+
* {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
|
|
71
|
+
* failure response also answers a stdin read error and, when
|
|
72
|
+
* {@link hookDeadlineMs} is set, a hook still running at that deadline.
|
|
73
|
+
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
74
|
+
* @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
|
|
75
|
+
* @returns {Promise<never>}
|
|
76
|
+
*/
|
|
77
|
+
export function runHookEntry(adapter: import("./control-plane.mjs").Adapter, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): Promise<never>;
|
|
78
|
+
/**
|
|
79
|
+
* `ACP_HOOK_TIMEOUT_MS` as a positive integer, or `undefined` when unset. Set it
|
|
80
|
+
* below the host's own hook timeout, so the hook's failure response lands
|
|
81
|
+
* before the host kills it.
|
|
82
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
83
|
+
* @returns {number|undefined}
|
|
84
|
+
*/
|
|
85
|
+
export function hookDeadlineMs(env?: NodeJS.ProcessEnv): number | undefined;
|
|
42
86
|
/**
|
|
43
87
|
* Whether a failed `writeSync` is the one recoverable case: EAGAIN, meaning the
|
|
44
88
|
* non-blocking pipe is momentarily full because the host has not drained it
|
|
@@ -80,3 +124,14 @@ export function writeAllSync(fd: number, text: string): void;
|
|
|
80
124
|
* @returns {never}
|
|
81
125
|
*/
|
|
82
126
|
export function emit(response: import("./control-plane.mjs").NativeResponse): never;
|
|
127
|
+
/**
|
|
128
|
+
* Each host's DEFAULT response when its hook breaks, which is how that host
|
|
129
|
+
* degrades a hook it cannot read:
|
|
130
|
+
* - claude, codex, gemini: exit 0, so the call runs. Each host treats a crashed
|
|
131
|
+
* or non-conforming hook as no objection, and a spurious block would wedge the
|
|
132
|
+
* user. The sandbox is the backstop.
|
|
133
|
+
* - amp: exit 1, which Amp's delegate reads as "ask the human". That neither
|
|
134
|
+
* runs nor rejects a call no judge saw.
|
|
135
|
+
* @type {Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>}
|
|
136
|
+
*/
|
|
137
|
+
export const HOST_FAILURE: Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>;
|