agent-control-plane-core 0.6.4 → 0.8.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,9 +229,49 @@ 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
 
237
+ ## Validating from another language
238
+
239
+ A guardrail that is not JavaScript cannot import `ToolCallEvent`, so it
240
+ hand-writes the field names it reads. It then drifts in the quietest direction:
241
+ the contract renames a field, the hand-written reader sees `undefined`, and it
242
+ takes its no-value branch instead of failing. The package ships the same
243
+ contract as JSON Schema (draft 2020-12), so that reader validates against the
244
+ seam instead of against its own copy.
245
+
246
+ ```python
247
+ import json, pathlib, jsonschema # a Python guardrail reading the event
248
+ base = pathlib.Path("node_modules/agent-control-plane-core/schema/control-plane/v1")
249
+ schema = json.loads((base / "tool-call-event.schema.json").read_text())
250
+ jsonschema.validate(event, schema) # raises on a field the contract does not model
251
+ ```
252
+
253
+ `tool-input-keys.json` sits beside them and publishes the input field a judge
254
+ should read per canonical tool (`Bash` → `command`, `Read` → `file_path`), so a
255
+ consumer looks that up rather than hard-coding it. It is plain data, not a
256
+ schema: a validator refuses an unknown keyword under its default options, so a
257
+ document carrying that map inside itself would not compile for the consumer who
258
+ installed it.
259
+
260
+ The event document also refuses two claims a producer must not make, each one a
261
+ veto the host will never perform: a `this_call_vetoable` `unknown` event, and a
262
+ `this_call_vetoable` event on an `observe_only` transport.
263
+
264
+ `pnpm gen:schema` generates the files from `src/control-plane.mjs`, and
265
+ `test/json-schema.test.mjs` byte-compares the result, so a stale document reds
266
+ the suite. The same suite validates every adapter's live output against the
267
+ published bytes, so a renamed contract field reds too. A JavaScript caller
268
+ builds the documents in memory from `agent-control-plane-core/json-schema`
269
+ instead of reading the files.
270
+
271
+ Read the schema from the installed package rather than pinning a copy by hand. A
272
+ document is one release's rendering of the wire version, and an added optional
273
+ field (backward-compatible, below) appears in the next release's document.
274
+
208
275
  ## Versioning
209
276
 
210
277
  `src/control-plane.mjs` is the frozen contract and its own single source of
@@ -220,6 +287,7 @@ and bumps the version.
220
287
  pnpm test # node --test
221
288
  pnpm test:coverage # c8, 100% lines/branches/functions per file
222
289
  pnpm check # tsc --noEmit (JSDoc types)
290
+ pnpm gen:schema # regenerate schema/ from the contract
223
291
  pnpm lint # eslint
224
292
  pnpm build # emit .d.mts declarations into types/
225
293
  ```
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.4",
3
+ "version": "0.8.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,12 +56,21 @@
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"
63
+ },
64
+ "./json-schema": {
65
+ "types": "./types/json-schema.d.mts",
66
+ "import": "./src/json-schema.mjs"
59
67
  }
60
68
  },
61
69
  "files": [
62
70
  "src",
63
71
  "types",
64
- "bin"
72
+ "bin",
73
+ "schema"
65
74
  ],
66
75
  "keywords": [
67
76
  "claude",
@@ -113,6 +122,7 @@
113
122
  "@eslint/js": "^9.13.0",
114
123
  "@types/node": "^22.7.0",
115
124
  "@types/semver": "^7.5.8",
125
+ "ajv": "^8.20.0",
116
126
  "c8": "^10.1.2",
117
127
  "eslint": "^9.13.0",
118
128
  "fast-check": "^3.22.0",
@@ -123,6 +133,7 @@
123
133
  },
124
134
  "scripts": {
125
135
  "build": "tsc -p tsconfig.build.json",
136
+ "gen:schema": "node scripts/gen-json-schema.mjs",
126
137
  "test": "node --test test/*.test.mjs",
127
138
  "test:coverage": "c8 node --test test/*.test.mjs",
128
139
  "check": "tsc --noEmit",
@@ -0,0 +1,152 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "ToolCallEvent",
4
+ "description": "A normalized, agent-agnostic view of one agent event (control-plane/v1).",
5
+ "type": "object",
6
+ "required": [
7
+ "schema_version",
8
+ "event",
9
+ "tool",
10
+ "input",
11
+ "this_call_vetoable",
12
+ "meta"
13
+ ],
14
+ "properties": {
15
+ "schema_version": {
16
+ "const": 1
17
+ },
18
+ "event": {
19
+ "enum": [
20
+ "pre_tool",
21
+ "post_tool",
22
+ "prompt_submit",
23
+ "session_start",
24
+ "unknown"
25
+ ]
26
+ },
27
+ "tool": {
28
+ "type": [
29
+ "string",
30
+ "null"
31
+ ],
32
+ "description": "Canonical tool name; null for prompt and session events. The raw native name is on meta.native_tool."
33
+ },
34
+ "input": {
35
+ "type": "object",
36
+ "description": "Passthrough tool input, verbatim; a submitted prompt is folded into input.prompt."
37
+ },
38
+ "response": {
39
+ "description": "Tool output, post_tool only: any JSON value, verbatim."
40
+ },
41
+ "this_call_vetoable": {
42
+ "type": "boolean",
43
+ "description": "false ⇒ the guardrail cannot veto THIS call, so a deny must degrade to a notification."
44
+ },
45
+ "meta": {
46
+ "$ref": "#/$defs/EventMeta"
47
+ }
48
+ },
49
+ "additionalProperties": false,
50
+ "allOf": [
51
+ {
52
+ "if": {
53
+ "required": [
54
+ "event"
55
+ ],
56
+ "properties": {
57
+ "event": {
58
+ "const": "unknown"
59
+ }
60
+ }
61
+ },
62
+ "then": {
63
+ "properties": {
64
+ "this_call_vetoable": {
65
+ "const": false
66
+ }
67
+ }
68
+ }
69
+ },
70
+ {
71
+ "if": {
72
+ "required": [
73
+ "meta"
74
+ ],
75
+ "properties": {
76
+ "meta": {
77
+ "required": [
78
+ "integration_mode"
79
+ ],
80
+ "properties": {
81
+ "integration_mode": {
82
+ "const": "observe_only"
83
+ }
84
+ }
85
+ }
86
+ }
87
+ },
88
+ "then": {
89
+ "properties": {
90
+ "this_call_vetoable": {
91
+ "const": false
92
+ }
93
+ }
94
+ }
95
+ }
96
+ ],
97
+ "$defs": {
98
+ "EventMeta": {
99
+ "type": "object",
100
+ "required": [
101
+ "agent",
102
+ "native_event",
103
+ "integration_mode",
104
+ "primary_gate_present",
105
+ "passthrough"
106
+ ],
107
+ "properties": {
108
+ "agent": {
109
+ "type": "string",
110
+ "description": "Producing agent id (\"claude\", \"codex\", …)."
111
+ },
112
+ "native_event": {
113
+ "type": "string",
114
+ "description": "Original native event name, preserved verbatim."
115
+ },
116
+ "integration_mode": {
117
+ "enum": [
118
+ "external_hook",
119
+ "in_process",
120
+ "observe_only"
121
+ ],
122
+ "description": "How the guardrail attaches to the host."
123
+ },
124
+ "primary_gate_present": {
125
+ "type": "boolean",
126
+ "description": "The agent's own native gate already ran, so this verdict is a second opinion."
127
+ },
128
+ "passthrough": {
129
+ "type": "object",
130
+ "description": "Unmodelled native top-level fields, verbatim."
131
+ },
132
+ "native_tool": {
133
+ "type": "string",
134
+ "description": "Original native tool name, present iff the event carries a tool."
135
+ },
136
+ "session_id": {
137
+ "type": "string"
138
+ },
139
+ "cwd": {
140
+ "type": "string"
141
+ },
142
+ "permission_mode": {
143
+ "type": "string"
144
+ },
145
+ "transcript_path": {
146
+ "type": "string"
147
+ }
148
+ },
149
+ "additionalProperties": false
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "Bash": "command",
3
+ "Edit": "file_path",
4
+ "Write": "file_path",
5
+ "Read": "file_path",
6
+ "WebFetch": "url"
7
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Verdict",
4
+ "description": "A normalized guardrail decision (control-plane/v1).",
5
+ "type": "object",
6
+ "required": [
7
+ "decision"
8
+ ],
9
+ "properties": {
10
+ "decision": {
11
+ "enum": [
12
+ "allow",
13
+ "deny",
14
+ "ask"
15
+ ]
16
+ },
17
+ "mutated_input": {
18
+ "type": "object",
19
+ "description": "Replacement tool input (pre_tool). A data channel, carried verbatim."
20
+ },
21
+ "mutated_output": {
22
+ "description": "Replacement tool output (post_tool): a string or the tool's structured output, verbatim. Any JSON value."
23
+ },
24
+ "additional_context": {
25
+ "type": "string",
26
+ "description": "Extra context to splice into the agent's stream."
27
+ },
28
+ "reason": {
29
+ "type": "string",
30
+ "description": "Human-readable rationale, shown on deny and ask."
31
+ }
32
+ },
33
+ "additionalProperties": false
34
+ }
@@ -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.
@@ -181,22 +190,52 @@ const EXIT_CODE_BY_DECISION = Object.freeze({
181
190
  });
182
191
 
183
192
  /**
184
- * Totality check at IMPORT: every {@link Decision} must have a row and every row
185
- * both vetoable columns. A decision added to the contract breaks this module
186
- * loudly at load instead of silently defaulting to exit 0.
193
+ * Totality check: every `decision` in `decisions` must have a row in `table`
194
+ * and every row both vetoable columns. A decision added to the contract with
195
+ * no matching row breaks loudly instead of silently defaulting to exit 0.
196
+ * Called at import against {@link Decision} and {@link EXIT_CODE_BY_DECISION};
197
+ * exported so a test can drive both branches.
198
+ * @param {readonly string[]} decisions
199
+ * @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
187
200
  */
188
- for (const decision of Object.values(Decision)) {
189
- const row = lookup(EXIT_CODE_BY_DECISION, decision);
190
- if (row === undefined)
191
- throw new Error(
192
- `amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
193
- );
194
- for (const vetoable of ["true", "false"]) {
195
- if (typeof lookup(row, vetoable) === "number") continue;
201
+ export function assertExitCodeTableTotal(decisions, table) {
202
+ for (const decision of decisions) {
203
+ const row = lookup(table, decision);
204
+ if (row === undefined)
205
+ throw new Error(
206
+ `amp adapter: exit-code table has no row for decision ${JSON.stringify(decision)}`,
207
+ );
208
+ for (const vetoable of ["true", "false"]) {
209
+ if (typeof lookup(row, vetoable) === "number") continue;
210
+ throw new Error(
211
+ `amp adapter: exit-code table row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ assertExitCodeTableTotal(Object.values(Decision), EXIT_CODE_BY_DECISION);
218
+
219
+ /**
220
+ * Look up the exit code for `decision`/`vetoable` in `table`. Throws instead of
221
+ * returning `undefined`: the alternative is `exit_code: undefined`, which
222
+ * `process.exit` renders as 0 — the exact silent allow this table exists to
223
+ * eliminate. Unreachable from {@link render} on the real
224
+ * {@link EXIT_CODE_BY_DECISION} while {@link assertExitCodeTableTotal} holds at
225
+ * load; exported so a test can drive both branches against a synthetic,
226
+ * deliberately incomplete table.
227
+ * @param {Readonly<Record<string, Readonly<Record<string, number>>>>} table
228
+ * @param {string} decision
229
+ * @param {boolean} vetoable
230
+ * @returns {number}
231
+ */
232
+ export function exitCodeFor(table, decision, vetoable) {
233
+ const exit_code = lookup(lookup(table, decision) ?? {}, String(vetoable));
234
+ if (exit_code === undefined)
196
235
  throw new Error(
197
- `amp adapter: exit-code table row ${JSON.stringify(decision)} has no this_call_vetoable=${vetoable} column`,
236
+ `amp adapter: exit-code table has no entry for decision ${JSON.stringify(decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
198
237
  );
199
- }
238
+ return exit_code;
200
239
  }
201
240
 
202
241
  /**
@@ -225,18 +264,7 @@ export function render(verdict, event) {
225
264
  `amp adapter: this_call_vetoable must be a boolean, got ${JSON.stringify(vetoable)}`,
226
265
  );
227
266
  const enforced = vd.decision === Decision.DENY && vetoable;
228
- const exit_code = lookup(
229
- lookup(EXIT_CODE_BY_DECISION, vd.decision) ?? {},
230
- String(vetoable),
231
- );
232
- // Unreachable while the import-time totality check and `normalizeVerdict` both
233
- // hold; kept because the alternative to throwing is `exit_code: undefined`,
234
- // which `process.exit` renders as 0 — the exact silent allow this table exists
235
- // to eliminate.
236
- if (exit_code === undefined)
237
- throw new Error(
238
- `amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
239
- );
267
+ const exit_code = exitCodeFor(EXIT_CODE_BY_DECISION, vd.decision, vetoable);
240
268
  return nativeResponse({
241
269
  transport: INTEGRATION_MODE,
242
270
  exit_code,
@@ -251,6 +279,7 @@ export const ampAdapter = {
251
279
  INTEGRATION_MODE,
252
280
  COVERAGE,
253
281
  UNRENDERED_FIELDS,
282
+ NATIVE_ASK_TIER,
254
283
  NATIVE_EVENT_FOR,
255
284
  parse,
256
285
  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,