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 +3 -0
- package/package.json +1 -1
- package/src/adapters/amp.mjs +4 -1
- package/src/adapters/codex.mjs +15 -19
- package/src/control-plane.mjs +31 -0
- package/src/fixtures/codex.json +6 -5
- package/types/adapters/amp.d.mts +4 -1
- package/types/adapters/codex.d.mts +10 -10
- package/types/control-plane.d.mts +17 -0
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.
|
|
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": {
|
package/src/adapters/amp.mjs
CHANGED
|
@@ -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).
|
|
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.
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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});
|
|
67
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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`
|
package/src/control-plane.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/fixtures/codex.json
CHANGED
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
}
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
|
-
"name": "PreToolUse MCP tool is
|
|
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":
|
|
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":
|
|
199
|
-
"enforced":
|
|
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
|
}
|
package/types/adapters/amp.d.mts
CHANGED
|
@@ -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).
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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});
|
|
85
|
-
*
|
|
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
|
|
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
|
*
|