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 +7 -3
- package/package.json +1 -1
- package/src/control-plane.mjs +19 -1
- package/src/runtime.mjs +13 -5
- package/types/control-plane.d.mts +7 -0
- package/types/runtime.d.mts +3 -1
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
|
-
→
|
|
174
|
-
|
|
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.
|
|
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": {
|
package/src/control-plane.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
111
|
-
//
|
|
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
|
|
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
|
package/types/runtime.d.mts
CHANGED
|
@@ -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
|