agent-control-plane-core 0.6.3 → 0.7.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
@@ -91,7 +91,8 @@ pnpm add agent-control-plane-core
91
91
  }
92
92
  ```
93
93
 
94
- An **Adapter** is `{ AGENT, COVERAGE, UNRENDERED_FIELDS, parse, render }`:
94
+ An **Adapter** is
95
+ `{ AGENT, COVERAGE, UNRENDERED_FIELDS, NATIVE_ASK_TIER, parse, render }`:
95
96
 
96
97
  ```js
97
98
  parse(nativeEvent) -> ToolCallEvent // never throws on unknown input
@@ -110,6 +111,25 @@ UNRENDERED_FIELDS = Object.freeze({
110
111
  })
111
112
  ```
112
113
 
114
+ ```js
115
+ // Does this host carry a distinct "ask the human" tier that it honours?
116
+ // true ⇒ a rendered `ask` suspends the call in front of a person
117
+ // (Claude Code's permissionDecision: "ask", Amp's exit 1).
118
+ // false ⇒ the host has no such tier, so `render` collapses `ask` onto another
119
+ // signal (Gemini CLI's advisory decision: "deny") and an un-escalated
120
+ // ask lets the tool run.
121
+ NATIVE_ASK_TIER = false;
122
+ ```
123
+
124
+ Read `NATIVE_ASK_TIER` instead of keeping a list of which agents to escalate an
125
+ `ask` to a `deny` on: a guardrail that must not let an ask through escalates it
126
+ where the flag is `false`, and leaves it alone where the host does the asking.
127
+ It describes the HOST, not the verdict — `ask` renders as `enforced: false`
128
+ everywhere — and it names the pre-tool gate, since a tool that already ran has
129
+ nothing left to suspend. Conformance probes your real `render` against it, so a
130
+ `true` whose ask renders as your abstaining allow fails, and so does a `false`
131
+ whose ask renders as anything but your own advisory deny.
132
+
113
133
  `readonlySet([])` is the row for a kind that carries all three;
114
134
  `UNRENDERED_ON_UNKNOWN` is the row for one that carries none. Both come from
115
135
  `agent-control-plane-core/contract`. Build every row with `readonlySet` — a
@@ -138,6 +158,13 @@ const verdict =
138
158
  process.stdout.write(JSON.stringify(claudeAdapter.render(verdict, event)));
139
159
  ```
140
160
 
161
+ Writing your own hook entry? `agent-control-plane-core/runtime` publishes the
162
+ plumbing the four `bin/*-hook.mjs` entries share — `readStdin`,
163
+ `renderHookResponse` (parse → judge → render, falling back to the failure
164
+ response THAT host expects) and `emit`, whose fully-drained write is what stops
165
+ a large deny body being truncated on a non-blocking pipe and silently becoming
166
+ an allow.
167
+
141
168
  The core contract and every adapter (`claude`, `codex`, `amp`, `gemini`) are
142
169
  also on the default entry:
143
170
 
@@ -202,6 +229,8 @@ The harness pins both directions and rejects a vacuous suite:
202
229
  fixture event, so an adapter that collapses the objection onto "run it" fails
203
230
  even with no non-vetoable fixture of its own. `observe_only` renders are
204
231
  exempt: that transport has no pre-emption channel to differ in.
232
+ 6. Your `NATIVE_ASK_TIER` declaration matches what `render` does with an `ask`,
233
+ probed on every pre-tool fixture event (both directions, described above).
205
234
 
206
235
  See `src/fixtures/claude.json` and `src/fixtures/codex.json` for the format.
207
236
 
package/bin/amp-hook.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * Usage: `node bin/amp-hook.mjs < payload.json`
14
14
  */
15
15
  import { ampAdapter } from "../src/adapters/amp.mjs";
16
- import { readStdin, renderHookResponse, emit } from "./hook-runtime.mjs";
16
+ import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
17
17
 
18
18
  /** @type {import("../src/control-plane.mjs").NativeResponse} */
19
19
  const FAIL_ASK = { transport: "external_hook", exit_code: 1, enforced: false };
@@ -12,7 +12,7 @@
12
12
  * Usage: `node bin/claude-hook.mjs < payload.json`
13
13
  */
14
14
  import { claudeAdapter } from "../src/adapters/claude.mjs";
15
- import { readStdin, renderHookResponse, emit } from "./hook-runtime.mjs";
15
+ import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
16
16
 
17
17
  /** @type {import("../src/control-plane.mjs").NativeResponse} */
18
18
  const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
@@ -13,7 +13,7 @@
13
13
  * Usage: `node bin/codex-hook.mjs < payload.json`
14
14
  */
15
15
  import { codexAdapter } from "../src/adapters/codex.mjs";
16
- import { readStdin, renderHookResponse, emit } from "./hook-runtime.mjs";
16
+ import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
17
17
 
18
18
  /** @type {import("../src/control-plane.mjs").NativeResponse} */
19
19
  const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
@@ -11,7 +11,7 @@
11
11
  * Usage: `node bin/gemini-hook.mjs < payload.json`
12
12
  */
13
13
  import { geminiAdapter } from "../src/adapters/gemini.mjs";
14
- import { readStdin, renderHookResponse, emit } from "./hook-runtime.mjs";
14
+ import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
15
15
 
16
16
  /** @type {import("../src/control-plane.mjs").NativeResponse} */
17
17
  const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.6.3",
3
+ "version": "0.7.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": {
@@ -56,6 +56,10 @@
56
56
  "./registry": {
57
57
  "types": "./types/registry.d.mts",
58
58
  "import": "./src/registry.mjs"
59
+ },
60
+ "./runtime": {
61
+ "types": "./types/runtime.d.mts",
62
+ "import": "./src/runtime.mjs"
59
63
  }
60
64
  },
61
65
  "files": [
@@ -156,6 +156,15 @@ export function parse(native) {
156
156
  });
157
157
  }
158
158
 
159
+ /**
160
+ * Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
161
+ * treats as neither the allow (0) nor the reject (2). {@link render} emits it
162
+ * for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
163
+ * The table below leans on the same tier for the non-vetoable deny, which is
164
+ * only honest because the tier is real.
165
+ */
166
+ export const NATIVE_ASK_TIER = true;
167
+
159
168
  /**
160
169
  * The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
161
170
  * pure exit-code transport: 0 allow / 1 ask / 2 reject.
@@ -251,6 +260,7 @@ export const ampAdapter = {
251
260
  INTEGRATION_MODE,
252
261
  COVERAGE,
253
262
  UNRENDERED_FIELDS,
263
+ NATIVE_ASK_TIER,
254
264
  NATIVE_EVENT_FOR,
255
265
  parse,
256
266
  render,
@@ -7,7 +7,8 @@
7
7
  * agent shells out with stdin JSON and reads a deny body / exit code back.
8
8
  *
9
9
  * Deny signal (PreToolUse): `hookSpecificOutput.permissionDecision = "deny"`
10
- * AND exit 2. this_call_vetoable is true, but per the doctrine that is a
10
+ * AND exit 2. Exit 2 discards stdout, so an enforced deny repeats its reason on
11
+ * STDERR, which is where the host reads a block rationale. this_call_vetoable is true, but per the doctrine that is a
11
12
  * useful FIRST filter, never the boundary — the Verdict stays advisory to the
12
13
  * sandbox.
13
14
  */
@@ -114,6 +115,16 @@ export const UNRENDERED_FIELDS = Object.freeze({
114
115
  [EventKind.UNKNOWN]: UNRENDERED_ON_UNKNOWN,
115
116
  });
116
117
 
118
+ /**
119
+ * Claude Code honours a distinct ask tier: `hookSpecificOutput.permissionDecision
120
+ * = "ask"` suspends the call and puts it in front of the user, and {@link render}
121
+ * emits exactly that (see {@link gatingBody}). So a consumer must NOT escalate an
122
+ * `ask` to a `deny` here — the human already sees it. `render` still reports the
123
+ * ask as `enforced: false`, because this guardrail did not block the call; the
124
+ * host did, pending an answer.
125
+ */
126
+ export const NATIVE_ASK_TIER = true;
127
+
117
128
  /** Claude Code native hook event names (the `hook_event_name` field). */
118
129
  export const HookEvent = Object.freeze({
119
130
  PRE_TOOL_USE: "PreToolUse",
@@ -221,7 +232,8 @@ export function parse(native) {
221
232
  * Render into Claude Code's native external-hook transport: a
222
233
  * `hookSpecificOutput` JSON body on stdout plus the exit code that carries the
223
234
  * decision (deny ⇒ exit 2). A deny only counts as `enforced` when the event's
224
- * `this_call_vetoable` holds.
235
+ * `this_call_vetoable` holds, and an enforced deny also carries its `reason` on
236
+ * `NativeResponse.stderr` (see below).
225
237
  *
226
238
  * `soleGate` (default `false`) is a dangerous, explicit opt-in: when `true` AND
227
239
  * the verdict is `allow`, the render emits Claude Code's REAL
@@ -253,6 +265,15 @@ export function render(verdict, event, { soleGate = false } = {}) {
253
265
  exit_code: enforced ? 2 : 0,
254
266
  enforced,
255
267
  stdout,
268
+ // An ENFORCED deny exits 2, and Claude Code parses hook stdout as JSON only
269
+ // on exit 0: on an exit-2 PreToolUse block it discards the body and reads
270
+ // STDERR instead. So `permissionDecisionReason` never reached the model on
271
+ // the one path that actually blocks, and the call was refused with no
272
+ // rationale. Carry the reason on `NativeResponse.stderr`, which `emit`
273
+ // writes to fd 2 — the same block-reason channel the Amp and Gemini
274
+ // adapters use. ONLY the enforced path: an allow, an ask, and a deny this
275
+ // call cannot veto have blocked nothing, and their exit-0 body is read.
276
+ ...(enforced && vd.reason !== undefined ? { stderr: vd.reason } : {}),
256
277
  });
257
278
  }
258
279
 
@@ -318,6 +339,7 @@ export const claudeAdapter = {
318
339
  INTEGRATION_MODE,
319
340
  COVERAGE,
320
341
  UNRENDERED_FIELDS,
342
+ NATIVE_ASK_TIER,
321
343
  NATIVE_EVENT_FOR,
322
344
  parse,
323
345
  render,
@@ -146,8 +146,9 @@ const HookEvent = Object.freeze({
146
146
 
147
147
  /**
148
148
  * Native event name → normalized {@link EventKind}. `PreToolUse` and
149
- * `PermissionRequest` both gate a tool call before it runs (PermissionRequest is
150
- * the ask-tier veto), so both normalize to `pre_tool`. Codex emits more events
149
+ * `PermissionRequest` both gate a tool call before it runs, so both normalize to
150
+ * `pre_tool`. Neither carries an ask tier: Codex's `PermissionRequestBehaviorWire`
151
+ * has two variants, allow and deny. Codex emits more events
151
152
  * than these — SessionStart/SessionEnd/Stop/PreCompact/… — and every one of them
152
153
  * still routes to `unknown`: this adapter models an event only once its host
153
154
  * response is established, and an unmodelled event parses non-vetoable.
@@ -171,6 +172,18 @@ export const NATIVE_EVENT_FOR = Object.freeze({
171
172
  [EventKind.POST_TOOL]: HookEvent.POST_TOOL_USE,
172
173
  });
173
174
 
175
+ /**
176
+ * Codex has NO ask tier. Its pre-tool output parser decodes
177
+ * `permissionDecision: "ask"`, classifies it unsupported, sets no block reason
178
+ * and runs the tool — upstream's own unit test for that path is named
179
+ * `unsupported_permission_decision_fails_open` (read at `rust-v0.155.1`, and
180
+ * byte-identical at `rust-v0.151.0`). `PermissionRequest` is no ask tier either:
181
+ * its behavior enum carries allow and deny only. So a verdict of `ask` has no
182
+ * channel here, and {@link render} sends it down the deny path rather than
183
+ * naming a key the host discards.
184
+ */
185
+ export const NATIVE_ASK_TIER = false;
186
+
174
187
  // Codex drops an enforced deny that carries no (or an empty) reason and runs the
175
188
  // tool, so a reasonless enforced deny still renders a non-empty one.
176
189
  export const DEFAULT_DENY_REASON = "blocked by monitor";
@@ -303,7 +316,15 @@ export function parse(native) {
303
316
  * @returns {NativeResponse}
304
317
  */
305
318
  export function render(verdict, event, { soleGate = false } = {}) {
306
- const vd = normalizeVerdict(verdict);
319
+ const normalized = normalizeVerdict(verdict);
320
+ // An `ask` becomes a deny on the PRE-TOOL path, because this host acts on no
321
+ // other objection: emitting its own `"ask"` leaves the tool running with the
322
+ // run marked failed, which is the fail-open {@link NATIVE_ASK_TIER} records.
323
+ // The post-tool path maps it to `block` on its own, below.
324
+ const vd =
325
+ normalized.decision === Decision.ASK && event.event !== EventKind.POST_TOOL
326
+ ? { ...normalized, decision: Decision.DENY }
327
+ : normalized;
307
328
  const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
308
329
  // The native name is preserved rather than derived from the kind, so a
309
330
  // PermissionRequest answers as itself; NATIVE_EVENT_FOR names the fallback.
@@ -392,6 +413,7 @@ export const codexAdapter = {
392
413
  INTEGRATION_MODE,
393
414
  COVERAGE,
394
415
  UNRENDERED_FIELDS,
416
+ NATIVE_ASK_TIER,
395
417
  NATIVE_EVENT_FOR,
396
418
  parse,
397
419
  render,
@@ -347,6 +347,16 @@ export function parse(native) {
347
347
  });
348
348
  }
349
349
 
350
+ /**
351
+ * Gemini CLI has NO ask tier: its hook vocabulary is `decision: "allow"` /
352
+ * `decision: "deny"` plus the exit-2 System Block, and nothing there suspends a
353
+ * call for a human. {@link applyAdvisoryDeny} therefore renders `ask` as the
354
+ * advisory `decision: "deny"`, byte-identical to a deny this call cannot veto.
355
+ * A consumer that must not let an ask through has to escalate it to a deny
356
+ * here, because an un-escalated ask lets Gemini run the tool.
357
+ */
358
+ export const NATIVE_ASK_TIER = false;
359
+
350
360
  /**
351
361
  * Render into Gemini CLI's native external-hook transport. An enforceable deny
352
362
  * renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
@@ -470,6 +480,7 @@ export const geminiAdapter = {
470
480
  INTEGRATION_MODE,
471
481
  COVERAGE,
472
482
  UNRENDERED_FIELDS,
483
+ NATIVE_ASK_TIER,
473
484
  NATIVE_EVENT_FOR,
474
485
  parse,
475
486
  render,
@@ -22,6 +22,22 @@ const UNENFORCEABLE_DENY_PROBE = Object.freeze({
22
22
  });
23
23
  const ABSTAINING_ALLOW_PROBE = Object.freeze({ decision: Decision.ALLOW });
24
24
 
25
+ /**
26
+ * The two probe verdicts rule ⑪ renders against the same event, to read whether
27
+ * this host's transport carries an ask tier of its own. They share one `reason`
28
+ * on purpose: the rule compares the two RENDERS, and a reason that differed
29
+ * would make them differ for a reason that is not the decision.
30
+ */
31
+ const ASK_TIER_REASON = "conformance probe: ask tier";
32
+ const ASK_TIER_ASK_PROBE = Object.freeze({
33
+ decision: Decision.ASK,
34
+ reason: ASK_TIER_REASON,
35
+ });
36
+ const ASK_TIER_DENY_PROBE = Object.freeze({
37
+ decision: Decision.DENY,
38
+ reason: ASK_TIER_REASON,
39
+ });
40
+
25
41
  /**
26
42
  * The payload rule ⑨ drifts an adapter with. No host emits that event name, so
27
43
  * an adapter that maps event names must answer {@link EventKind.UNKNOWN}.
@@ -454,6 +470,78 @@ function coherentEvent(adapter, byKind, kind) {
454
470
  };
455
471
  }
456
472
 
473
+ /**
474
+ * Rule ⑪, for one pre-tool event: the adapter's `NATIVE_ASK_TIER` declaration
475
+ * agrees with what `render` does with an `ask`.
476
+ *
477
+ * The declaration is what lets a consumer stop keeping a hand-typed list of
478
+ * which hosts honour an ask. A consumer that must not let an ask through
479
+ * escalates it to a deny on a host with no ask tier — and applying that
480
+ * uniformly turns every ask into a deny on the hosts that DO have one. So the
481
+ * declaration has to be checked against the render rather than believed.
482
+ *
483
+ * Probed on a NON-vetoable variant of the event, for rule ⑧'s reason and one
484
+ * more: it makes the deny comparison below an ADVISORY deny, which is the
485
+ * signal a host with no ask tier has left to spend on an ask.
486
+ *
487
+ * What a render can show, and what it cannot. Whether a host suspends the call
488
+ * for a human lives in the HOST, so no probe reads it off a render. What a probe
489
+ * does read is each direction's tell:
490
+ *
491
+ * - Declared `true`, rendered as the abstaining ALLOW: the consumer is told the
492
+ * human will see this call, and the transport carries nothing that says so —
493
+ * the tool just runs. That is the lie this rule exists to catch.
494
+ * - Declared `false`, rendered as something the adapter's own advisory deny is
495
+ * not: the render invents a third signal on a transport the declaration says
496
+ * has only two, so one of the two is wrong.
497
+ *
498
+ * An OBSERVE_ONLY render is exempt, as in rule ⑧: that transport has no
499
+ * pre-emption channel at all, so its ask, deny and allow coincide by
500
+ * construction and demanding a distinct ask would demand a fiction.
501
+ * @param {import("./control-plane.mjs").Adapter} adapter
502
+ * @param {import("./control-plane.mjs").ToolCallEvent} event a non-vetoable pre-tool event
503
+ * @param {import("./control-plane.mjs").ToolCallEvent} vetoable the fixture's own pre-tool event, whose veto the block half needs
504
+ * @param {string} caseName
505
+ * @param {any} assert
506
+ */
507
+ function assertAskTier(adapter, event, vetoable, caseName, assert) {
508
+ const asked = adapter.render(ASK_TIER_ASK_PROBE, event);
509
+ assert.equal(
510
+ asked.enforced,
511
+ false,
512
+ `an ask rendered as an enforced block: ${caseName}`,
513
+ );
514
+ if (asked.transport === IntegrationMode.OBSERVE_ONLY) return;
515
+ if (adapter.NATIVE_ASK_TIER) {
516
+ assert.notDeepEqual(
517
+ asked,
518
+ adapter.render(ABSTAINING_ALLOW_PROBE, event),
519
+ `${adapter.AGENT}: NATIVE_ASK_TIER is true, but an ask renders exactly as this host's abstaining allow — nothing in the transport asks anyone: ${caseName}`,
520
+ );
521
+ // The other way to declare an ask tier this host has not got: render the ask
522
+ // as the host's BLOCK. It differs from the allow, so the half above passes,
523
+ // and the consumer it tells to stop escalating then hands a call to a host
524
+ // that denies it outright rather than suspending it for a human. Probed on
525
+ // the vetoable event, because a block is the one signal a non-vetoable
526
+ // render cannot carry.
527
+ if (vetoable.this_call_vetoable) {
528
+ const blocked = adapter.render(ASK_TIER_DENY_PROBE, vetoable);
529
+ if (blocked.enforced)
530
+ assert.notDeepEqual(
531
+ adapter.render(ASK_TIER_ASK_PROBE, vetoable),
532
+ blocked,
533
+ `${adapter.AGENT}: NATIVE_ASK_TIER is true, but an ask renders exactly as this host's enforced deny — the call is blocked, not suspended: ${caseName}`,
534
+ );
535
+ }
536
+ return;
537
+ }
538
+ assert.deepEqual(
539
+ asked,
540
+ adapter.render(ASK_TIER_DENY_PROBE, event),
541
+ `${adapter.AGENT}: NATIVE_ASK_TIER is false, but an ask renders differently from this host's advisory deny — the render carries an ask signal the declaration says the host has not got: ${caseName}`,
542
+ );
543
+ }
544
+
457
545
  /**
458
546
  * Rule ⑩, for one parsed event: every {@link VERDICT_CONTENT_FIELDS} entry
459
547
  * either reaches this host's wire or is DECLARED unreachable in the adapter's
@@ -820,13 +908,23 @@ function assertAliasedInput(agent, caseName, event, assert) {
820
908
  * that DID reach a wire come back as `contentFieldsSeen`; a suite covering
821
909
  * several adapters asserts their union to keep the positive half of the
822
910
  * rule non-vacuous, since no single host has a channel for all three.
911
+ * 11. ask-tier honesty: the adapter declares whether this host carries a
912
+ * distinct "ask the human" tier it honours (`NATIVE_ASK_TIER`), and the
913
+ * render is held to that declaration on every pre-tool event. A declared
914
+ * tier whose `ask` renders as the host's abstaining allow fails — the
915
+ * consumer is told a human will see the call while the tool just runs —
916
+ * and so does a declared-absent tier whose `ask` renders as anything but
917
+ * the adapter's own advisory deny. A consumer reads the declaration to
918
+ * decide where an `ask` must be escalated to a `deny`, so a wrong one
919
+ * either lets a call through or denies every asked call on the hosts that
920
+ * do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
823
921
  *
824
922
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
825
923
  * neutral; it throws on the first mismatch. Returns a summary the caller can
826
924
  * assert further on.
827
925
  *
828
926
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
829
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
927
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number }}
830
928
  */
831
929
  export function runAdapterConformance({ adapter, fixtures, assert }) {
832
930
  assert.equal(
@@ -837,6 +935,14 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
837
935
 
838
936
  assertCoverageWellFormed(adapter, assert);
839
937
  assertEveryKindHasARow(adapter, assert);
938
+ // Named here rather than left to a `TypeError` inside rule ⑪: a third-party
939
+ // adapter written against the earlier contract has no such member, and its
940
+ // author is who reads this.
941
+ assert.equal(
942
+ typeof adapter.NATIVE_ASK_TIER,
943
+ "boolean",
944
+ `${adapter.AGENT}: adapter declares no NATIVE_ASK_TIER — every adapter needs a boolean saying whether this host honours a distinct "ask the human" tier (false when render collapses ask onto another signal)`,
945
+ );
840
946
 
841
947
  /** @type {Set<string>} */
842
948
  const decisionsSeen = new Set();
@@ -852,6 +958,7 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
852
958
  const parsedByKind = {};
853
959
  let preToolCases = 0;
854
960
  let unenforceableDenyChecks = 0;
961
+ let askTierChecks = 0;
855
962
  let renders = 0;
856
963
 
857
964
  for (const testCase of fixtures.cases) {
@@ -991,12 +1098,17 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
991
1098
  // An OBSERVE_ONLY render is exempt from the ≠-allow half: that transport has
992
1099
  // no pre-emption channel at all, so its deny and allow are identical by
993
1100
  // construction. Demanding a distinct signal there would demand a fiction.
994
- if (deniedRender.transport === IntegrationMode.OBSERVE_ONLY) continue;
995
- assert.notDeepEqual(
996
- deniedRender,
997
- adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
998
- `unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
999
- );
1101
+ if (deniedRender.transport !== IntegrationMode.OBSERVE_ONLY)
1102
+ assert.notDeepEqual(
1103
+ deniedRender,
1104
+ adapter.render(ABSTAINING_ALLOW_PROBE, unenforceable),
1105
+ `unenforceable deny renders identically to an abstaining allow — the objection is lost: ${testCase.name}`,
1106
+ );
1107
+ // Rule ⑪, on the same non-vetoable variant and AFTER rule ⑧: an adapter that
1108
+ // collapses both its deny and its ask onto the host's allow is reported as
1109
+ // the lost objection it is, not as a mis-declared ask tier.
1110
+ assertAskTier(adapter, unenforceable, parsed, testCase.name, assert);
1111
+ askTierChecks += 1;
1000
1112
  }
1001
1113
 
1002
1114
  for (const decision of ["allow", "deny", "ask"]) {
@@ -1055,6 +1167,13 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1055
1167
  preToolCases,
1056
1168
  `rule ⑧ (unenforceable deny ≠ allow) ran on ${unenforceableDenyChecks} of ${preToolCases} pre-tool cases`,
1057
1169
  );
1170
+ // Same reason as the count above: the rule is in the same loop, so a stray
1171
+ // `continue` would skip it silently on the later cases.
1172
+ assert.equal(
1173
+ askTierChecks,
1174
+ preToolCases,
1175
+ `rule ⑪ (ask-tier declaration) ran on ${askTierChecks} of ${preToolCases} pre-tool cases`,
1176
+ );
1058
1177
 
1059
1178
  return {
1060
1179
  cases: fixtures.cases.length,
@@ -1067,5 +1186,6 @@ export function runAdapterConformance({ adapter, fixtures, assert }) {
1067
1186
  unknownKindSeen,
1068
1187
  coverageClassesChecked,
1069
1188
  unenforceableDenyChecks,
1189
+ askTierChecks,
1070
1190
  };
1071
1191
  }
@@ -38,9 +38,10 @@
38
38
  * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
39
39
  * member breaks every third-party adapter while every event on the wire stays
40
40
  * byte-identical, so it is a package-semver break and leaves
41
- * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one: adding it is
42
- * a one-line change per adapter, and the conformance harness names the member
43
- * and the migration rather than throwing from inside itself.
41
+ * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one and
42
+ * `NATIVE_ASK_TIER` is the second: adding either is a one-line change per
43
+ * adapter, and the conformance harness names the member and the migration
44
+ * rather than throwing from inside itself.
44
45
  */
45
46
 
46
47
  /** Wire identifier for this schema version; bump on a breaking shape change. */
@@ -501,6 +502,7 @@ export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
501
502
  * @property {(native: any) => ToolCallEvent} parse
502
503
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
503
504
  * @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
505
+ * @property {boolean} NATIVE_ASK_TIER whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
504
506
  * @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
505
507
  */
506
508
 
@@ -67,7 +67,8 @@
67
67
  "permissionDecision": "deny",
68
68
  "permissionDecisionReason": "blocked rm"
69
69
  }
70
- }
70
+ },
71
+ "stderr": "blocked rm"
71
72
  }
72
73
  },
73
74
  "ask": {
@@ -244,7 +245,8 @@
244
245
  },
245
246
  "decision": "block",
246
247
  "reason": "leak"
247
- }
248
+ },
249
+ "stderr": "leak"
248
250
  }
249
251
  },
250
252
  "ask": {
@@ -109,12 +109,13 @@
109
109
  },
110
110
  "native": {
111
111
  "transport": "external_hook",
112
- "exit_code": 0,
113
- "enforced": false,
112
+ "exit_code": 2,
113
+ "enforced": true,
114
114
  "stdout": {
115
115
  "hookSpecificOutput": {
116
116
  "hookEventName": "PreToolUse",
117
- "permissionDecision": "ask"
117
+ "permissionDecision": "deny",
118
+ "permissionDecisionReason": "blocked by monitor"
118
119
  }
119
120
  }
120
121
  }
@@ -315,12 +316,13 @@
315
316
  },
316
317
  "native": {
317
318
  "transport": "external_hook",
318
- "exit_code": 0,
319
- "enforced": false,
319
+ "exit_code": 2,
320
+ "enforced": true,
320
321
  "stdout": {
321
322
  "hookSpecificOutput": {
322
323
  "hookEventName": "PreToolUse",
323
- "permissionDecision": "ask"
324
+ "permissionDecision": "deny",
325
+ "permissionDecisionReason": "blocked by monitor"
324
326
  }
325
327
  }
326
328
  }
@@ -1,21 +1,39 @@
1
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.
2
+ * The EXTERNAL_HOOK runtime, published at `agent-control-plane-core/runtime` —
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.
11
+ *
12
+ * It is exported because every consumer writing its own hook entry had to
13
+ * re-implement the same two things, and both fail silently when they are wrong:
14
+ * draining stdin before parsing, and flushing the response IN FULL before
15
+ * `process.exit` (see {@link writeAllSync}).
10
16
  */
11
17
  import { writeSync } from "node:fs";
12
- import { Decision, sanitizeVerdict } from "../src/control-plane.mjs";
18
+ import { Decision, sanitizeVerdict } from "./control-plane.mjs";
13
19
 
14
- /** Read all of stdin to a string. */
20
+ /**
21
+ * Read all of stdin to a string.
22
+ * @returns {Promise<string>}
23
+ */
15
24
  export function readStdin() {
16
25
  return new Promise((resolve, reject) => {
26
+ /** @type {Buffer[]} */
17
27
  const chunks = [];
18
- process.stdin.on("data", (chunk) => chunks.push(chunk));
28
+ // A consumer that set an encoding on stdin hands STRINGS to this listener, and
29
+ // Buffer.concat throws on one. That throw escapes from the stream's callback,
30
+ // not from this promise, so it never reaches the caller's fail-safe: the hook
31
+ // dies with no response and a fail-open host runs the unjudged call.
32
+ process.stdin.on("data", (chunk) =>
33
+ chunks.push(
34
+ typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk,
35
+ ),
36
+ );
19
37
  process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString()));
20
38
  process.stdin.on("error", reject);
21
39
  });
@@ -25,8 +43,8 @@ export function readStdin() {
25
43
  * The DEMO policy standing in for a real guardrail judge: deny any command
26
44
  * matching /rm -rf/, allow everything else. A real deployment swaps this for its
27
45
  * own judge over the normalized {@link ToolCallEvent}.
28
- * @param {import("../src/control-plane.mjs").ToolCallEvent} event
29
- * @returns {import("../src/control-plane.mjs").Verdict}
46
+ * @param {import("./control-plane.mjs").ToolCallEvent} event
47
+ * @returns {import("./control-plane.mjs").Verdict}
30
48
  */
31
49
  export function demoJudge(event) {
32
50
  const command =
@@ -54,11 +72,11 @@ export function demoJudge(event) {
54
72
  * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
55
73
  * so the guardrail is supplied here rather than by forking this file. Whatever
56
74
  * it returns is treated as untrusted (see the clamp below).
57
- * @param {import("../src/control-plane.mjs").Adapter} adapter
75
+ * @param {import("./control-plane.mjs").Adapter} adapter
58
76
  * @param {string} rawInput
59
- * @param {import("../src/control-plane.mjs").NativeResponse} onFailure
60
- * @param {(event: import("../src/control-plane.mjs").ToolCallEvent) => import("../src/control-plane.mjs").Verdict} [judge]
61
- * @returns {import("../src/control-plane.mjs").NativeResponse}
77
+ * @param {import("./control-plane.mjs").NativeResponse} onFailure
78
+ * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
79
+ * @returns {import("./control-plane.mjs").NativeResponse}
62
80
  */
63
81
  export function renderHookResponse(
64
82
  adapter,
@@ -140,7 +158,7 @@ function writeAllSync(fd, text) {
140
158
  /**
141
159
  * Emit a {@link NativeResponse} to the host: write the native stdout body when
142
160
  * the transport has one, then exit with the transport's exit code.
143
- * @param {import("../src/control-plane.mjs").NativeResponse} response
161
+ * @param {import("./control-plane.mjs").NativeResponse} response
144
162
  * @returns {never}
145
163
  */
146
164
  export function emit(response) {
@@ -72,6 +72,14 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
72
72
  * @type {Record<string, ReadonlySet<string>|undefined>}
73
73
  */
74
74
  export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
75
+ /**
76
+ * Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
77
+ * treats as neither the allow (0) nor the reject (2). {@link render} emits it
78
+ * for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
79
+ * The table below leans on the same tier for the non-vetoable deny, which is
80
+ * only honest because the tier is real.
81
+ */
82
+ export const NATIVE_ASK_TIER: true;
75
83
  /** @type {import("../control-plane.mjs").Adapter} */
76
84
  export const ampAdapter: import("../control-plane.mjs").Adapter;
77
85
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -9,7 +9,8 @@ export function parse(native: any): ToolCallEvent;
9
9
  * Render into Claude Code's native external-hook transport: a
10
10
  * `hookSpecificOutput` JSON body on stdout plus the exit code that carries the
11
11
  * decision (deny ⇒ exit 2). A deny only counts as `enforced` when the event's
12
- * `this_call_vetoable` holds.
12
+ * `this_call_vetoable` holds, and an enforced deny also carries its `reason` on
13
+ * `NativeResponse.stderr` (see below).
13
14
  *
14
15
  * `soleGate` (default `false`) is a dangerous, explicit opt-in: when `true` AND
15
16
  * the verdict is `allow`, the render emits Claude Code's REAL
@@ -65,6 +66,15 @@ export const COVERAGE: import("../control-plane.mjs").CoverageMap;
65
66
  * @type {Record<string, ReadonlySet<string>|undefined>}
66
67
  */
67
68
  export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
69
+ /**
70
+ * Claude Code honours a distinct ask tier: `hookSpecificOutput.permissionDecision
71
+ * = "ask"` suspends the call and puts it in front of the user, and {@link render}
72
+ * emits exactly that (see {@link gatingBody}). So a consumer must NOT escalate an
73
+ * `ask` to a `deny` here — the human already sees it. `render` still reports the
74
+ * ask as `enforced: false`, because this guardrail did not block the call; the
75
+ * host did, pending an answer.
76
+ */
77
+ export const NATIVE_ASK_TIER: true;
68
78
  /** Claude Code native hook event names (the `hook_event_name` field). */
69
79
  export const HookEvent: Readonly<{
70
80
  PRE_TOOL_USE: "PreToolUse";
@@ -113,6 +113,17 @@ export const MIN_ENFORCING_VERSION: readonly number[];
113
113
  * @type {Record<string, string|undefined>}
114
114
  */
115
115
  export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
116
+ /**
117
+ * Codex has NO ask tier. Its pre-tool output parser decodes
118
+ * `permissionDecision: "ask"`, classifies it unsupported, sets no block reason
119
+ * and runs the tool — upstream's own unit test for that path is named
120
+ * `unsupported_permission_decision_fails_open` (read at `rust-v0.155.1`, and
121
+ * byte-identical at `rust-v0.151.0`). `PermissionRequest` is no ask tier either:
122
+ * its behavior enum carries allow and deny only. So a verdict of `ask` has no
123
+ * channel here, and {@link render} sends it down the deny path rather than
124
+ * naming a key the host discards.
125
+ */
126
+ export const NATIVE_ASK_TIER: false;
116
127
  export const DEFAULT_DENY_REASON: "blocked by monitor";
117
128
  /** @type {import("../control-plane.mjs").Adapter} */
118
129
  export const codexAdapter: import("../control-plane.mjs").Adapter;
@@ -117,6 +117,15 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
117
117
  * @type {Readonly<Record<string, string>>}
118
118
  */
119
119
  export const GEMINI_TOOL_ALIASES: Readonly<Record<string, string>>;
120
+ /**
121
+ * Gemini CLI has NO ask tier: its hook vocabulary is `decision: "allow"` /
122
+ * `decision: "deny"` plus the exit-2 System Block, and nothing there suspends a
123
+ * call for a human. {@link applyAdvisoryDeny} therefore renders `ask` as the
124
+ * advisory `decision: "deny"`, byte-identical to a deny this call cannot veto.
125
+ * A consumer that must not let an ask through has to escalate it to a deny
126
+ * here, because an un-escalated ask lets Gemini run the tool.
127
+ */
128
+ export const NATIVE_ASK_TIER: false;
120
129
  /** @type {import("../control-plane.mjs").Adapter} */
121
130
  export const geminiAdapter: import("../control-plane.mjs").Adapter;
122
131
  export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
@@ -111,13 +111,23 @@ export function assertAliasedInputsCanonical(fixturesList: any[], assert: any):
111
111
  * that DID reach a wire come back as `contentFieldsSeen`; a suite covering
112
112
  * several adapters asserts their union to keep the positive half of the
113
113
  * rule non-vacuous, since no single host has a channel for all three.
114
+ * 11. ask-tier honesty: the adapter declares whether this host carries a
115
+ * distinct "ask the human" tier it honours (`NATIVE_ASK_TIER`), and the
116
+ * render is held to that declaration on every pre-tool event. A declared
117
+ * tier whose `ask` renders as the host's abstaining allow fails — the
118
+ * consumer is told a human will see the call while the tool just runs —
119
+ * and so does a declared-absent tier whose `ask` renders as anything but
120
+ * the adapter's own advisory deny. A consumer reads the declaration to
121
+ * decide where an `ask` must be escalated to a `deny`, so a wrong one
122
+ * either lets a call through or denies every asked call on the hosts that
123
+ * do ask. OBSERVE_ONLY renders are exempt, as in rule ⑧.
114
124
  *
115
125
  * `assert` is injected (node:assert/strict) so the harness stays test-framework
116
126
  * neutral; it throws on the first mismatch. Returns a summary the caller can
117
127
  * assert further on.
118
128
  *
119
129
  * @param {{ adapter: import("./control-plane.mjs").Adapter, fixtures: any, assert: any }} args
120
- * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number }}
130
+ * @returns {{ cases: number, renders: number, decisionsSeen: Set<string>, mutationSeen: boolean, contentFieldsSeen: Set<string>, enforcedDenySeen: boolean, vetoableDenySeen: boolean, unknownKindSeen: boolean, coverageClassesChecked: Set<string>, unenforceableDenyChecks: number, askTierChecks: number }}
121
131
  */
122
132
  export function runAdapterConformance({ adapter, fixtures, assert }: {
123
133
  adapter: import("./control-plane.mjs").Adapter;
@@ -134,4 +144,5 @@ export function runAdapterConformance({ adapter, fixtures, assert }: {
134
144
  unknownKindSeen: boolean;
135
145
  coverageClassesChecked: Set<string>;
136
146
  unenforceableDenyChecks: number;
147
+ askTierChecks: number;
137
148
  };
@@ -136,6 +136,7 @@ export function readonlySet(values: Iterable<string>): ReadonlySet<string>;
136
136
  * @property {(native: any) => ToolCallEvent} parse
137
137
  * @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
138
138
  * @property {Record<string, string|undefined>} [NATIVE_EVENT_FOR] the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
139
+ * @property {boolean} NATIVE_ASK_TIER whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
139
140
  * @property {Record<string, ReadonlySet<string>|undefined>} UNRENDERED_FIELDS per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
140
141
  */
141
142
  /**
@@ -293,9 +294,10 @@ export function nativeResponse({ transport, exit_code, enforced, stdout, stderr,
293
294
  * {@link Adapter} interface an integrator implements. A new REQUIRED adapter
294
295
  * member breaks every third-party adapter while every event on the wire stays
295
296
  * byte-identical, so it is a package-semver break and leaves
296
- * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one: adding it is
297
- * a one-line change per adapter, and the conformance harness names the member
298
- * and the migration rather than throwing from inside itself.
297
+ * {@link CONTROL_PLANE_SCHEMA} alone. `UNRENDERED_FIELDS` is one and
298
+ * `NATIVE_ASK_TIER` is the second: adding either is a one-line change per
299
+ * adapter, and the conformance harness names the member and the migration
300
+ * rather than throwing from inside itself.
299
301
  */
300
302
  /** Wire identifier for this schema version; bump on a breaking shape change. */
301
303
  export const CONTROL_PLANE_SCHEMA: "control-plane/v1";
@@ -515,6 +517,10 @@ export type Adapter = {
515
517
  * the native event name this host uses for each {@link EventKind}, for a conformance probe of a kind no fixture produced. Optional: a kind absent here (`unknown` always, plus any kind this transport does not carry) is probed with a marker name, which takes the adapter's unrecognized-event branch. Never read at runtime — `parse` stamps the real name on `meta.native_event`.
516
518
  */
517
519
  NATIVE_EVENT_FOR?: Record<string, string | undefined> | undefined;
520
+ /**
521
+ * whether this host's transport carries a DISTINCT "ask the human" tier that the host honours. `true` ⇒ a rendered `ask` suspends the call in front of a person (Claude Code's `permissionDecision: "ask"`, Amp's exit 1). `false` ⇒ the host has no such tier, so `render` collapses `ask` onto another signal (Gemini CLI writes its advisory `decision: "deny"`) and an un-escalated ask lets the tool run. A consumer that must not let an ask through reads this to decide where to escalate `ask` to `deny`, instead of keeping its own hand-typed list of agent ids. It is about the HOST, not the verdict: `ask` never renders as `enforced` on any adapter, and the tier it names is the PRE-TOOL gate — once a tool has run there is nothing left to suspend, so every host renders a post-tool `ask` as its block. The conformance harness probes the real `render` against it — a `true` whose ask renders as the host's abstaining allow fails, and so does a `false` whose ask differs from its own advisory deny.
522
+ */
523
+ NATIVE_ASK_TIER: boolean;
518
524
  /**
519
525
  * per-event-kind set of {@link VERDICT_CONTENT_FIELDS} this host has no native channel for, so `render` drops them. EVERY {@link EventKind} carries a row, including one this adapter's `parse` cannot emit — an omission would otherwise read as "every content field reaches a channel here", the reverse of the truth on a transport that carries none. The value type still admits `undefined` so a consumer handles a lookup miss rather than indexing straight into `.has(...)`; the conformance harness refuses a missing row. SCOPE — a row describes the ALLOW path only. An enforceable deny may drop more: Gemini's exit-2 System Block returns no stdout at all, so a deny there carries no `additional_context` whatever the row says. Do not read this map to infer what a deny delivers. The conformance harness fails an adapter whose renders disagree with its declaration either way, so a stale entry cannot survive.
520
526
  */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Read all of stdin to a string.
3
+ * @returns {Promise<string>}
4
+ */
5
+ export function readStdin(): Promise<string>;
6
+ /**
7
+ * The DEMO policy standing in for a real guardrail judge: deny any command
8
+ * matching /rm -rf/, allow everything else. A real deployment swaps this for its
9
+ * own judge over the normalized {@link ToolCallEvent}.
10
+ * @param {import("./control-plane.mjs").ToolCallEvent} event
11
+ * @returns {import("./control-plane.mjs").Verdict}
12
+ */
13
+ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): import("./control-plane.mjs").Verdict;
14
+ /**
15
+ * Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
16
+ * can't be parsed or the pipe 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.
21
+ *
22
+ * Before returning the fail-safe, the error is written to STDERR (never stdout,
23
+ * which belongs to the host transport) so a regression that silently disables
24
+ * enforcement leaves a greppable trace. Every host tolerates hook stderr:
25
+ * claude/codex surface it only in verbose/debug on a 0 exit, amp forwards the
26
+ * helper's stderr, and gemini reads stderr as a reason only on exit 2 — none of
27
+ * these fail-safe exits is a 2 for a stdout-carrying transport, and amp's ask
28
+ * (exit 1) shows the diagnostic alongside the prompt, which is the point.
29
+ * `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
30
+ * so the guardrail is supplied here rather than by forking this file. Whatever
31
+ * it returns is treated as untrusted (see the clamp below).
32
+ * @param {import("./control-plane.mjs").Adapter} adapter
33
+ * @param {string} rawInput
34
+ * @param {import("./control-plane.mjs").NativeResponse} onFailure
35
+ * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
36
+ * @returns {import("./control-plane.mjs").NativeResponse}
37
+ */
38
+ 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;
39
+ /**
40
+ * Emit a {@link NativeResponse} to the host: write the native stdout body when
41
+ * the transport has one, then exit with the transport's exit code.
42
+ * @param {import("./control-plane.mjs").NativeResponse} response
43
+ * @returns {never}
44
+ */
45
+ export function emit(response: import("./control-plane.mjs").NativeResponse): never;