agent-control-plane-core 0.11.0 → 0.11.2

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
@@ -127,7 +127,10 @@ Read `NATIVE_ASK_TIER` instead of keeping a list of which agents to escalate an
127
127
  where the flag is `false`, and leaves it alone where the host does the asking.
128
128
  `renderEscalated(adapter, verdict, event, { soleGate })` applies that rule: it
129
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`.
130
+ vetoable call, except an `ask` on a host whose flag is `true`. A non-allow
131
+ verdict on a payload that names no event at all blocks through the host's exit
132
+ code and stderr, since `render` has no channel for it, unless the event parsed
133
+ observe-only (a Codex below its enforcing version), which cannot veto.
131
134
  It describes the HOST, not the verdict — `ask` renders as `enforced: false`
132
135
  everywhere — and it names the pre-tool gate, since a tool that already ran has
133
136
  nothing left to suspend. Conformance probes your real `render` against it, so a
@@ -170,8 +173,9 @@ emit(claudeAdapter.render(verdict, event));
170
173
  `agent-control-plane-core/runtime` publishes the rest of the plumbing the four
171
174
  hook entries share. `runHookEntry(adapter, judge)` is a whole entry: each
172
175
  `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`
176
+ → `renderEscalated`, so a judge's `ask` on a host with no ask tier blocks a
177
+ vetoable call instead of running it. It returns the failure response THAT host
178
+ expects when the payload does not parse. `emit`
175
179
  drains the whole body before it exits, so a large deny body is never truncated
176
180
  on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
177
181
  `acp-amp-hook` and `acp-gemini-hook`. Codex's hook stdin carries no version, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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": {
@@ -967,6 +967,13 @@ export function gatedResponse(
967
967
  * ({@link Adapter} `NATIVE_ASK_TIER`). Nothing changes for an allow, for a call
968
968
  * this guardrail cannot veto (a deny there blocks nothing either), or for a
969
969
  * render the host already enforces. The deny keeps the verdict's other fields.
970
+ *
971
+ * A payload that names no event at all (`meta.native_event` empty) is the one
972
+ * exception: `render` has no channel for it, so a non-allow verdict there takes
973
+ * the host's {@link BLOCK_TRANSPORT} block, which stops whatever call the hook
974
+ * was run for. An observe-only parse renders that block unenforced (exit 0),
975
+ * since its host cannot veto. An event the payload names but no adapter models keeps the rule
976
+ * above: on Claude Code's Stop, a block would keep the agent running.
970
977
  * @param {Adapter} adapter
971
978
  * @param {Verdict} verdict
972
979
  * @param {ToolCallEvent} event
@@ -974,7 +981,18 @@ export function gatedResponse(
974
981
  * @returns {NativeResponse}
975
982
  */
976
983
  export function renderEscalated(adapter, verdict, event, options = {}) {
977
- const { decision } = normalizeVerdict(verdict);
984
+ const { decision, reason } = normalizeVerdict(verdict);
985
+ // This block is what stops a guardrail's deny on an unreadable payload from
986
+ // rendering as an empty exit 0, which every host runs.
987
+ if (event.meta.native_event === "" && decision !== Decision.ALLOW)
988
+ return gatedResponse(adapter.AGENT, {
989
+ transport: event.meta.integration_mode,
990
+ // An observe-only parse (a Codex below its enforcing version) has no
991
+ // veto, so claiming the block was enforced would report a false block.
992
+ enforced: event.meta.integration_mode !== IntegrationMode.OBSERVE_ONLY,
993
+ unblocked_exit_code: 0,
994
+ reason,
995
+ });
978
996
  const response = adapter.render(verdict, event, options);
979
997
  if (
980
998
  response.enforced ||
package/src/runtime.mjs CHANGED
@@ -13,7 +13,12 @@
13
13
  * `process.exit` (see {@link writeAllSync}).
14
14
  */
15
15
  import { writeSync } from "node:fs";
16
- import { Decision, gatedResponse, sanitizeVerdict } from "./control-plane.mjs";
16
+ import {
17
+ Decision,
18
+ gatedResponse,
19
+ renderEscalated,
20
+ sanitizeVerdict,
21
+ } from "./control-plane.mjs";
17
22
 
18
23
  /**
19
24
  * Read all of stdin to a string.
@@ -63,7 +68,9 @@ export function demoJudge(event) {
63
68
  * A judge that THROWS is not a malformed payload: the call parsed, and the judge
64
69
  * failed to answer. That takes the same path as a judge that answers garbage —
65
70
  * the clamp's `ask` (see below) — so a crashing guardrail puts the call in front
66
- * of a human instead of taking an exit-0 fail-safe that runs it.
71
+ * of a human instead of taking an exit-0 fail-safe that runs it. The verdict
72
+ * renders through `renderEscalated`, so on a host with no ask tier that ask
73
+ * blocks a vetoable call rather than running it.
67
74
  *
68
75
  * Every error is written to STDERR (never stdout, which belongs to the host
69
76
  * transport) so a regression that silently disables enforcement leaves a
@@ -107,8 +114,9 @@ export function renderHookResponse(
107
114
  report("hook judge error", err);
108
115
  }
109
116
  // 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.
117
+ // over the clamp's ask. `renderEscalated` keeps an ask on a host that
118
+ // honours one (Claude, Amp), so without this a fail-closed entry would ask
119
+ // about a call no judge approved instead of blocking it.
112
120
  if (onFailure.enforced && !isDecision(answer)) return onFailure;
113
121
  // A judge's answer is clamped, not trusted. `normalizeVerdict` (inside
114
122
  // every adapter's render) THROWS on a decision outside allow/deny/ask, and
@@ -118,7 +126,7 @@ export function renderHookResponse(
118
126
  // in front of a human. Prose is passed through: scrubbing monitor-authored
119
127
  // text needs a scrubber this transport module has no business choosing.
120
128
  const verdict = sanitizeVerdict(answer, (text) => text);
121
- return adapter.render(verdict, event);
129
+ return renderEscalated(adapter, verdict, event);
122
130
  } catch (err) {
123
131
  report("hook pipeline error", err);
124
132
  return onFailure;
@@ -296,6 +296,13 @@ export function gatedResponse(agent: string, { transport, enforced, unblocked_ex
296
296
  * ({@link Adapter} `NATIVE_ASK_TIER`). Nothing changes for an allow, for a call
297
297
  * this guardrail cannot veto (a deny there blocks nothing either), or for a
298
298
  * render the host already enforces. The deny keeps the verdict's other fields.
299
+ *
300
+ * A payload that names no event at all (`meta.native_event` empty) is the one
301
+ * exception: `render` has no channel for it, so a non-allow verdict there takes
302
+ * the host's {@link BLOCK_TRANSPORT} block, which stops whatever call the hook
303
+ * was run for. An observe-only parse renders that block unenforced (exit 0),
304
+ * since its host cannot veto. An event the payload names but no adapter models keeps the rule
305
+ * above: on Claude Code's Stop, a block would keep the agent running.
299
306
  * @param {Adapter} adapter
300
307
  * @param {Verdict} verdict
301
308
  * @param {ToolCallEvent} event
@@ -22,7 +22,9 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
22
22
  * A judge that THROWS is not a malformed payload: the call parsed, and the judge
23
23
  * failed to answer. That takes the same path as a judge that answers garbage —
24
24
  * the clamp's `ask` (see below) — so a crashing guardrail puts the call in front
25
- * of a human instead of taking an exit-0 fail-safe that runs it.
25
+ * of a human instead of taking an exit-0 fail-safe that runs it. The verdict
26
+ * renders through `renderEscalated`, so on a host with no ask tier that ask
27
+ * blocks a vetoable call rather than running it.
26
28
  *
27
29
  * Every error is written to STDERR (never stdout, which belongs to the host
28
30
  * transport) so a regression that silently disables enforcement leaves a