agent-control-plane-core 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,6 +125,9 @@ NATIVE_ASK_TIER = false;
125
125
  Read `NATIVE_ASK_TIER` instead of keeping a list of which agents to escalate an
126
126
  `ask` to a `deny` on: a guardrail that must not let an ask through escalates it
127
127
  where the flag is `false`, and leaves it alone where the host does the asking.
128
+ `renderEscalated(adapter, verdict, event, { soleGate })` applies that rule: it
129
+ re-renders as a `deny` any non-allow verdict that would render unenforced on a
130
+ vetoable call, except an `ask` on a host whose flag is `true`.
128
131
  It describes the HOST, not the verdict — `ask` renders as `enforced: false`
129
132
  everywhere — and it names the pre-tool gate, since a tool that already ran has
130
133
  nothing left to suspend. Conformance probes your real `render` against it, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -159,7 +159,10 @@ export function parse(native) {
159
159
 
160
160
  /**
161
161
  * Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
162
- * treats as neither the allow (0) nor the reject (2). {@link render} emits it
162
+ * treats as neither the allow (0) nor the reject (2). Source: Amp's own notes,
163
+ * "`0` allows the tool call, `1` makes Amp ask, and `2` rejects the tool call"
164
+ * (https://ampcode.com/notes/permissions). No live probe has measured it, and
165
+ * no source says what exit 1 does in a headless run. {@link render} emits it
163
166
  * for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
164
167
  * The table below leans on the same tier for the non-vetoable deny, which is
165
168
  * only honest because the tier is real.
@@ -54,25 +54,25 @@ export const AGENT = "codex";
54
54
  export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
55
55
 
56
56
  /**
57
- * Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `PreToolUse`
58
- * intercepts the shell (Bash) tool ONLY — other builtins and MCP tools never
59
- * reach it — so builtin is PARTIAL and MCP is UNCOVERED. A live probe
60
- * ([X3]/[X4]) fired `PreToolUse` for a SUBAGENT's call and for a call in a
61
- * RESUMED session, so both are PARTIAL too: covered, under the same Bash-only
62
- * limit as [X1], rather than the ❓ the matrix carried before the probe ran.
57
+ * Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `PreToolUse` fires
58
+ * for the shell tools, `apply_patch`, every MCP tool call and every other
59
+ * function tool ([X1]/[X2], read from Codex's own source at the enforcement
60
+ * floor). It never fires for `write_stdin`, which types into a process an
61
+ * earlier call started, nor for a tool OpenAI runs on its own side, so builtin
62
+ * and MCP are both PARTIAL. A live probe ([X3]/[X4]) fired `PreToolUse` for a
63
+ * SUBAGENT's call and for a call in a RESUMED session, so both are PARTIAL too.
63
64
  *
64
65
  * These rows describe PRE-TOOL routing, which is the only thing the matrix's
65
66
  * [X1]/[X2] sources speak about. `parse` applies them to a pre-tool event only
66
- * (see {@link vetoableCall}); Codex's `PostToolUse` fires for tools `PreToolUse`
67
- * never sees, so a post-tool event judged by the MCP row would drop a block
68
- * Codex documents it honours.
67
+ * (see {@link vetoableCall}); a `PostToolUse` payload in hand is proof the
68
+ * post-tool hook fired for that call, whatever the pre-tool row says.
69
69
  *
70
70
  * The SUBAGENT row is the one the classifier now reaches on this host: the same
71
71
  * probe found `agent_id`/`agent_type` present on a subagent's `PreToolUse`
72
72
  * payload and ABSENT on the main thread's, so {@link classifyCallClass} answers
73
73
  * SUBAGENT from that payload alone. It is PARTIAL, so such a call stays vetoable
74
74
  * — the row's job here is to be honest, not to degrade. An MCP-named call takes
75
- * the UNCOVERED MCP row either way, subagent or not.
75
+ * the MCP row either way, subagent or not.
76
76
  *
77
77
  * The RESUMED row is DECLARATIVE ONLY, for a reason no longer about coverage: a
78
78
  * resumed session announces itself on `SessionStart` (`source: "resume"`), not on
@@ -84,7 +84,7 @@ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
84
84
  /** @type {import("../control-plane.mjs").CoverageMap} */
85
85
  export const COVERAGE = Object.freeze({
86
86
  [CallClass.BUILTIN]: CoverageStatus.PARTIAL,
87
- [CallClass.MCP]: CoverageStatus.UNCOVERED,
87
+ [CallClass.MCP]: CoverageStatus.PARTIAL,
88
88
  [CallClass.SUBAGENT]: CoverageStatus.PARTIAL,
89
89
  [CallClass.RESUMED]: CoverageStatus.PARTIAL,
90
90
  });
@@ -221,17 +221,13 @@ export function canEnforce(version) {
221
221
  /**
222
222
  * Whether an event of `kind` may be marked vetoable, given the tool it names.
223
223
  *
224
- * PRE_TOOL takes the coverage floor: `PreToolUse` fires for Bash only, so an
225
- * MCP-sourced call is un-vetoable regardless of version (COVERAGE.mcp).
224
+ * PRE_TOOL takes the coverage floor ({@link COVERAGE}).
226
225
  *
227
226
  * POST_TOOL does NOT, and that is deliberate: {@link COVERAGE} records which
228
227
  * calls reach `PreToolUse`, and a `PostToolUse` payload in hand is proof the
229
- * post-tool hook already fired for this call — Codex documents PostToolUse
230
- * firing for tools (apply_patch, MCP) that `PreToolUse` never sees. Judging it
231
- * by the pre-tool MCP row would degrade a block Codex honours to a notify,
232
- * which is a fail-CLOSED error but still a wrong one. It stays gated on
233
- * GATED_EVENTS and on the version floor, so nothing here can mark an unmodelled
234
- * or pre-0.135 event vetoable.
228
+ * post-tool hook already fired for this call. It stays gated on GATED_EVENTS
229
+ * and on the version floor, so nothing here can mark an unmodelled or pre-0.135
230
+ * event vetoable.
235
231
  * @param {string} kind an {@link EventKind} value
236
232
  * @param {string|null} nativeTool
237
233
  * @param {Record<string, unknown>} raw the native payload, for `mcp_context`
@@ -958,3 +958,34 @@ export function gatedResponse(
958
958
  [row.reason_stream]: blockReason(reason),
959
959
  });
960
960
  }
961
+
962
+ /**
963
+ * Render `verdict` on `adapter`'s host, escalating to a `deny` when the host
964
+ * would otherwise let the call run without the gate it asked for. A non-allow
965
+ * verdict that renders unenforced on a vetoable call is re-rendered as a deny,
966
+ * unless it is an `ask` and the host honours its own ask tier
967
+ * ({@link Adapter} `NATIVE_ASK_TIER`). Nothing changes for an allow, for a call
968
+ * this guardrail cannot veto (a deny there blocks nothing either), or for a
969
+ * render the host already enforces. The deny keeps the verdict's other fields.
970
+ * @param {Adapter} adapter
971
+ * @param {Verdict} verdict
972
+ * @param {ToolCallEvent} event
973
+ * @param {{ soleGate?: boolean }} [options] passed through to `render`
974
+ * @returns {NativeResponse}
975
+ */
976
+ export function renderEscalated(adapter, verdict, event, options = {}) {
977
+ const { decision } = normalizeVerdict(verdict);
978
+ const response = adapter.render(verdict, event, options);
979
+ if (
980
+ response.enforced ||
981
+ decision === Decision.ALLOW ||
982
+ !event.this_call_vetoable ||
983
+ (decision === Decision.ASK && adapter.NATIVE_ASK_TIER)
984
+ )
985
+ return response;
986
+ return adapter.render(
987
+ { ...verdict, decision: Decision.DENY },
988
+ event,
989
+ options,
990
+ );
991
+ }
@@ -146,7 +146,7 @@
146
146
  }
147
147
  },
148
148
  {
149
- "name": "PreToolUse MCP tool is non-vetoable (hook fires for Bash only)",
149
+ "name": "PreToolUse MCP tool is vetoable (the hook fires for MCP calls)",
150
150
  "call_class": "mcp",
151
151
  "native": {
152
152
  "hook_event_name": "PreToolUse",
@@ -162,7 +162,7 @@
162
162
  "input": {
163
163
  "title": "hi"
164
164
  },
165
- "this_call_vetoable": false,
165
+ "this_call_vetoable": true,
166
166
  "meta": {
167
167
  "agent": "codex",
168
168
  "native_event": "PreToolUse",
@@ -195,15 +195,16 @@
195
195
  },
196
196
  "native": {
197
197
  "transport": "external_hook",
198
- "exit_code": 0,
199
- "enforced": false,
198
+ "exit_code": 2,
199
+ "enforced": true,
200
200
  "stdout": {
201
201
  "hookSpecificOutput": {
202
202
  "hookEventName": "PreToolUse",
203
203
  "permissionDecision": "deny",
204
204
  "permissionDecisionReason": "mcp blocked"
205
205
  }
206
- }
206
+ },
207
+ "stderr": "mcp blocked"
207
208
  }
208
209
  }
209
210
  }
@@ -98,7 +98,10 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
98
98
  export const UNRENDERED_FIELDS: Record<string, ReadonlySet<string> | undefined>;
99
99
  /**
100
100
  * Amp honours a distinct ask tier: exit 1 is its "ask the user", a code Amp
101
- * treats as neither the allow (0) nor the reject (2). {@link render} emits it
101
+ * treats as neither the allow (0) nor the reject (2). Source: Amp's own notes,
102
+ * "`0` allows the tool call, `1` makes Amp ask, and `2` rejects the tool call"
103
+ * (https://ampcode.com/notes/permissions). No live probe has measured it, and
104
+ * no source says what exit 1 does in a headless run. {@link render} emits it
102
105
  * for every `ask`, so a consumer must NOT escalate an `ask` to a `deny` here.
103
106
  * The table below leans on the same tier for the non-vetoable deny, which is
104
107
  * only honest because the tier is real.
@@ -72,25 +72,25 @@ export function createCodexAdapter({ version }?: {
72
72
  export const AGENT: "codex";
73
73
  export const INTEGRATION_MODE: "external_hook";
74
74
  /**
75
- * Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `PreToolUse`
76
- * intercepts the shell (Bash) tool ONLY — other builtins and MCP tools never
77
- * reach it — so builtin is PARTIAL and MCP is UNCOVERED. A live probe
78
- * ([X3]/[X4]) fired `PreToolUse` for a SUBAGENT's call and for a call in a
79
- * RESUMED session, so both are PARTIAL too: covered, under the same Bash-only
80
- * limit as [X1], rather than the ❓ the matrix carried before the probe ran.
75
+ * Hook-coverage matrix row (`docs/hook-coverage-matrix.md`). `PreToolUse` fires
76
+ * for the shell tools, `apply_patch`, every MCP tool call and every other
77
+ * function tool ([X1]/[X2], read from Codex's own source at the enforcement
78
+ * floor). It never fires for `write_stdin`, which types into a process an
79
+ * earlier call started, nor for a tool OpenAI runs on its own side, so builtin
80
+ * and MCP are both PARTIAL. A live probe ([X3]/[X4]) fired `PreToolUse` for a
81
+ * SUBAGENT's call and for a call in a RESUMED session, so both are PARTIAL too.
81
82
  *
82
83
  * These rows describe PRE-TOOL routing, which is the only thing the matrix's
83
84
  * [X1]/[X2] sources speak about. `parse` applies them to a pre-tool event only
84
- * (see {@link vetoableCall}); Codex's `PostToolUse` fires for tools `PreToolUse`
85
- * never sees, so a post-tool event judged by the MCP row would drop a block
86
- * Codex documents it honours.
85
+ * (see {@link vetoableCall}); a `PostToolUse` payload in hand is proof the
86
+ * post-tool hook fired for that call, whatever the pre-tool row says.
87
87
  *
88
88
  * The SUBAGENT row is the one the classifier now reaches on this host: the same
89
89
  * probe found `agent_id`/`agent_type` present on a subagent's `PreToolUse`
90
90
  * payload and ABSENT on the main thread's, so {@link classifyCallClass} answers
91
91
  * SUBAGENT from that payload alone. It is PARTIAL, so such a call stays vetoable
92
92
  * — the row's job here is to be honest, not to degrade. An MCP-named call takes
93
- * the UNCOVERED MCP row either way, subagent or not.
93
+ * the MCP row either way, subagent or not.
94
94
  *
95
95
  * The RESUMED row is DECLARATIVE ONLY, for a reason no longer about coverage: a
96
96
  * resumed session announces itself on `SessionStart` (`source: "resume"`), not on
@@ -288,6 +288,23 @@ export function gatedResponse(agent: string, { transport, enforced, unblocked_ex
288
288
  stdout?: unknown;
289
289
  reason?: string;
290
290
  }): NativeResponse;
291
+ /**
292
+ * Render `verdict` on `adapter`'s host, escalating to a `deny` when the host
293
+ * would otherwise let the call run without the gate it asked for. A non-allow
294
+ * verdict that renders unenforced on a vetoable call is re-rendered as a deny,
295
+ * unless it is an `ask` and the host honours its own ask tier
296
+ * ({@link Adapter} `NATIVE_ASK_TIER`). Nothing changes for an allow, for a call
297
+ * this guardrail cannot veto (a deny there blocks nothing either), or for a
298
+ * render the host already enforces. The deny keeps the verdict's other fields.
299
+ * @param {Adapter} adapter
300
+ * @param {Verdict} verdict
301
+ * @param {ToolCallEvent} event
302
+ * @param {{ soleGate?: boolean }} [options] passed through to `render`
303
+ * @returns {NativeResponse}
304
+ */
305
+ export function renderEscalated(adapter: Adapter, verdict: Verdict, event: ToolCallEvent, options?: {
306
+ soleGate?: boolean;
307
+ }): NativeResponse;
291
308
  /**
292
309
  * The vendor-neutral control-plane contract.
293
310
  *