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 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. `renderHookResponse` runs parse → judge → render, and returns
169
- the failure response THAT host expects when the payload does not parse. `emit`
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), 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.
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 { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
13
+ import { runHookEntry } from "../src/runtime.mjs";
17
14
 
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));
15
+ await runHookEntry(ampAdapter);
@@ -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. 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.
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 { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
12
+ import { runHookEntry } from "../src/runtime.mjs";
16
13
 
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));
14
+ await runHookEntry(claudeAdapter);
@@ -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), like Claude Code: Codex treats a
14
- * non-conforming hook as non-blocking. The sandbox is the backstop.
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 { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
19
+ import { runHookEntry } from "../src/runtime.mjs";
20
20
 
21
- /** @type {import("../src/control-plane.mjs").NativeResponse} */
22
- const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
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
+ );
@@ -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). 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.
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 { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
12
+ import { runHookEntry } from "../src/runtime.mjs";
15
13
 
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));
14
+ await runHookEntry(geminiAdapter);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.9.2",
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": {
@@ -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). {@link render} emits it
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.
@@ -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 and declares its OWN
5
- * failure posture; this module only holds the transport mechanics they
6
- * genuinely share (stdin read, the demo judge, the parse→judge→render pipe with
7
- * a host-supplied fail-safe, and stdout+exit emission). The thing item ②
8
- * deleted was the `--agent` switch that let one binary impersonate every host
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 the way THAT host expects
61
- * (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
62
- * single shared default. This is the deliberate, necessary recovery the whole
63
- * per-host split exists for, not a blanket swallow.
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.
@@ -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). {@link render} emits it
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
  *
@@ -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 the way THAT host expects
18
- * (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
19
- * single shared default. This is the deliberate, necessary recovery the whole
20
- * per-host split exists for, not a blanket swallow.
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>>;