agent-control-plane-core 0.11.1 → 0.12.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 +18 -1
- package/package.json +1 -1
- package/schema/control-plane/v1/agents.json +174 -0
- package/src/adapters/codex.mjs +14 -2
- package/src/agent-manifest.mjs +75 -0
- package/src/control-plane.mjs +19 -1
- package/src/json-schema.mjs +4 -4
- package/src/registry.mjs +35 -11
- package/src/runtime.mjs +30 -10
- package/types/adapters/codex.d.mts +5 -2
- package/types/agent-manifest.d.mts +8 -0
- package/types/control-plane.d.mts +7 -0
- package/types/json-schema.d.mts +4 -4
- package/types/registry.d.mts +11 -3
- package/types/runtime.d.mts +23 -9
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
|
|
@@ -179,6 +182,20 @@ on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
|
|
|
179
182
|
set `ACP_CODEX_VERSION` to the Codex version your launcher pins (or call
|
|
180
183
|
`createCodexAdapter({ version })`); without it, Codex calls parse advisory.
|
|
181
184
|
|
|
185
|
+
A judge may return its verdict or a promise of one, so a judge can ask a
|
|
186
|
+
remote monitor before it answers. `renderHookResponse` returns a promise.
|
|
187
|
+
`adapterFor(id, options)` takes the options its `LAUNCHER_OPTIONS[id]` row
|
|
188
|
+
names, so a launcher passes a pinned version to whichever agents read one
|
|
189
|
+
without naming them.
|
|
190
|
+
|
|
191
|
+
A consumer that cannot import these modules (a bash hook wrapper, a Python
|
|
192
|
+
monitor) reads `schema/control-plane/v1/agents.json` instead. It is generated
|
|
193
|
+
from the adapters, with one row per agent: its block exit code and reason
|
|
194
|
+
stream, whether a block needs a reason, whether it has an ask tier, the entry's
|
|
195
|
+
default response when the hook breaks, its hook coverage, and the verdict
|
|
196
|
+
fields an allow cannot carry. Read the file from the installed package, so it
|
|
197
|
+
always matches the adapters that version ships.
|
|
198
|
+
|
|
182
199
|
Each entry fails the way its host expects when the hook itself breaks: claude,
|
|
183
200
|
codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
|
|
184
201
|
instead, on every host. The entry then exits 2 with the reason on stderr when
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
{
|
|
2
|
+
"claude": {
|
|
3
|
+
"integration_mode": "external_hook",
|
|
4
|
+
"native_ask_tier": true,
|
|
5
|
+
"block": {
|
|
6
|
+
"exit_code": 2,
|
|
7
|
+
"reason_stream": "stderr",
|
|
8
|
+
"reason_required": false
|
|
9
|
+
},
|
|
10
|
+
"host_failure": {
|
|
11
|
+
"exit_code": 0,
|
|
12
|
+
"enforced": false
|
|
13
|
+
},
|
|
14
|
+
"coverage": {
|
|
15
|
+
"builtin": "covered",
|
|
16
|
+
"mcp": "covered",
|
|
17
|
+
"subagent": "covered",
|
|
18
|
+
"resumed": "covered"
|
|
19
|
+
},
|
|
20
|
+
"unrendered_fields_on_allow": {
|
|
21
|
+
"pre_tool": [
|
|
22
|
+
"mutated_output"
|
|
23
|
+
],
|
|
24
|
+
"post_tool": [
|
|
25
|
+
"mutated_input"
|
|
26
|
+
],
|
|
27
|
+
"prompt_submit": [
|
|
28
|
+
"mutated_input",
|
|
29
|
+
"mutated_output"
|
|
30
|
+
],
|
|
31
|
+
"session_start": [
|
|
32
|
+
"mutated_input",
|
|
33
|
+
"mutated_output"
|
|
34
|
+
],
|
|
35
|
+
"unknown": [
|
|
36
|
+
"additional_context",
|
|
37
|
+
"mutated_input",
|
|
38
|
+
"mutated_output"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"codex": {
|
|
43
|
+
"integration_mode": "external_hook",
|
|
44
|
+
"native_ask_tier": false,
|
|
45
|
+
"block": {
|
|
46
|
+
"exit_code": 2,
|
|
47
|
+
"reason_stream": "stderr",
|
|
48
|
+
"reason_required": true
|
|
49
|
+
},
|
|
50
|
+
"host_failure": {
|
|
51
|
+
"exit_code": 0,
|
|
52
|
+
"enforced": false
|
|
53
|
+
},
|
|
54
|
+
"coverage": {
|
|
55
|
+
"builtin": "partial",
|
|
56
|
+
"mcp": "partial",
|
|
57
|
+
"subagent": "partial",
|
|
58
|
+
"resumed": "partial"
|
|
59
|
+
},
|
|
60
|
+
"unrendered_fields_on_allow": {
|
|
61
|
+
"pre_tool": [
|
|
62
|
+
"additional_context",
|
|
63
|
+
"mutated_output"
|
|
64
|
+
],
|
|
65
|
+
"post_tool": [
|
|
66
|
+
"mutated_input",
|
|
67
|
+
"mutated_output"
|
|
68
|
+
],
|
|
69
|
+
"prompt_submit": [
|
|
70
|
+
"additional_context",
|
|
71
|
+
"mutated_input",
|
|
72
|
+
"mutated_output"
|
|
73
|
+
],
|
|
74
|
+
"session_start": [
|
|
75
|
+
"additional_context",
|
|
76
|
+
"mutated_input",
|
|
77
|
+
"mutated_output"
|
|
78
|
+
],
|
|
79
|
+
"unknown": [
|
|
80
|
+
"additional_context",
|
|
81
|
+
"mutated_input",
|
|
82
|
+
"mutated_output"
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"amp": {
|
|
87
|
+
"integration_mode": "external_hook",
|
|
88
|
+
"native_ask_tier": true,
|
|
89
|
+
"block": {
|
|
90
|
+
"exit_code": 2,
|
|
91
|
+
"reason_stream": "stderr",
|
|
92
|
+
"reason_required": false
|
|
93
|
+
},
|
|
94
|
+
"host_failure": {
|
|
95
|
+
"exit_code": 1,
|
|
96
|
+
"enforced": false
|
|
97
|
+
},
|
|
98
|
+
"coverage": {
|
|
99
|
+
"builtin": "covered",
|
|
100
|
+
"mcp": "covered",
|
|
101
|
+
"subagent": "covered",
|
|
102
|
+
"resumed": "unknown"
|
|
103
|
+
},
|
|
104
|
+
"unrendered_fields_on_allow": {
|
|
105
|
+
"pre_tool": [
|
|
106
|
+
"additional_context",
|
|
107
|
+
"mutated_input",
|
|
108
|
+
"mutated_output"
|
|
109
|
+
],
|
|
110
|
+
"post_tool": [
|
|
111
|
+
"additional_context",
|
|
112
|
+
"mutated_input",
|
|
113
|
+
"mutated_output"
|
|
114
|
+
],
|
|
115
|
+
"prompt_submit": [
|
|
116
|
+
"additional_context",
|
|
117
|
+
"mutated_input",
|
|
118
|
+
"mutated_output"
|
|
119
|
+
],
|
|
120
|
+
"session_start": [
|
|
121
|
+
"additional_context",
|
|
122
|
+
"mutated_input",
|
|
123
|
+
"mutated_output"
|
|
124
|
+
],
|
|
125
|
+
"unknown": [
|
|
126
|
+
"additional_context",
|
|
127
|
+
"mutated_input",
|
|
128
|
+
"mutated_output"
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"gemini": {
|
|
133
|
+
"integration_mode": "external_hook",
|
|
134
|
+
"native_ask_tier": false,
|
|
135
|
+
"block": {
|
|
136
|
+
"exit_code": 2,
|
|
137
|
+
"reason_stream": "stderr",
|
|
138
|
+
"reason_required": false
|
|
139
|
+
},
|
|
140
|
+
"host_failure": {
|
|
141
|
+
"exit_code": 0,
|
|
142
|
+
"enforced": false
|
|
143
|
+
},
|
|
144
|
+
"coverage": {
|
|
145
|
+
"builtin": "covered",
|
|
146
|
+
"mcp": "unknown",
|
|
147
|
+
"subagent": "unknown",
|
|
148
|
+
"resumed": "unknown"
|
|
149
|
+
},
|
|
150
|
+
"unrendered_fields_on_allow": {
|
|
151
|
+
"pre_tool": [
|
|
152
|
+
"mutated_output"
|
|
153
|
+
],
|
|
154
|
+
"post_tool": [
|
|
155
|
+
"mutated_input",
|
|
156
|
+
"mutated_output"
|
|
157
|
+
],
|
|
158
|
+
"prompt_submit": [
|
|
159
|
+
"mutated_input",
|
|
160
|
+
"mutated_output"
|
|
161
|
+
],
|
|
162
|
+
"session_start": [
|
|
163
|
+
"additional_context",
|
|
164
|
+
"mutated_input",
|
|
165
|
+
"mutated_output"
|
|
166
|
+
],
|
|
167
|
+
"unknown": [
|
|
168
|
+
"additional_context",
|
|
169
|
+
"mutated_input",
|
|
170
|
+
"mutated_output"
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -411,16 +411,28 @@ function postToolBody(hookEventName, vd, enforced) {
|
|
|
411
411
|
return body;
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
/** The launcher option names {@link createCodexAdapter} reads. */
|
|
415
|
+
export const OPTION_NAMES = Object.freeze(["version"]);
|
|
416
|
+
|
|
414
417
|
/**
|
|
415
418
|
* Build a Codex adapter whose `parse` judges every payload against `version`,
|
|
416
419
|
* the Codex version the launcher pinned (Codex's hook stdin carries none).
|
|
417
420
|
* Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
|
|
418
421
|
* deny renders advisory. With no `version`, `parse` falls back to a payload
|
|
419
|
-
* `version` (see {@link parse}).
|
|
422
|
+
* `version` (see {@link parse}). An option outside {@link OPTION_NAMES} throws,
|
|
423
|
+
* so a misspelled setting is never dropped in silence.
|
|
420
424
|
* @param {{ version?: string }} [options]
|
|
421
425
|
* @returns {import("../control-plane.mjs").Adapter}
|
|
422
426
|
*/
|
|
423
|
-
export function createCodexAdapter(
|
|
427
|
+
export function createCodexAdapter(options = {}) {
|
|
428
|
+
const unread = Object.keys(options).filter(
|
|
429
|
+
(name) => !OPTION_NAMES.includes(name),
|
|
430
|
+
);
|
|
431
|
+
if (unread.length > 0)
|
|
432
|
+
throw new Error(
|
|
433
|
+
`codex: no option ${unread.map((name) => JSON.stringify(name)).join(", ")} (reads: ${OPTION_NAMES.join(", ")})`,
|
|
434
|
+
);
|
|
435
|
+
const { version } = options;
|
|
424
436
|
return {
|
|
425
437
|
AGENT,
|
|
426
438
|
INTEGRATION_MODE,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-agent manifest: what each agent's hook transport does, published as
|
|
3
|
+
* plain JSON at `schema/control-plane/v1/agents.json`.
|
|
4
|
+
*
|
|
5
|
+
* PROBLEM CLASS — a consumer that cannot call these adapters (a bash hook
|
|
6
|
+
* wrapper, a Python validator) re-types each agent's rules by hand: which exit
|
|
7
|
+
* code blocks, whether a block needs a reason, whether an `ask` has a tier.
|
|
8
|
+
* Each copy is one more place an agent's next release has to be chased into.
|
|
9
|
+
* `agents.json` is the copy they read instead, generated from the adapters'
|
|
10
|
+
* exports, so an adapter change reaches them on the next `pnpm gen:schema`.
|
|
11
|
+
*
|
|
12
|
+
* `pnpm gen:schema` writes every {@link generatedDocuments} entry, and
|
|
13
|
+
* `json-schema.test.mjs` regenerates each in-process and byte-compares.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { EventKind, blockTransportFor, lookup } from "./control-plane.mjs";
|
|
17
|
+
import { jsonSchemaDocuments, SCHEMA_DIR } from "./json-schema.mjs";
|
|
18
|
+
import { ADAPTERS, AGENT_IDS } from "./registry.mjs";
|
|
19
|
+
import { HOST_FAILURE } from "./runtime.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One agent's manifest row. Each field is read from the export it names:
|
|
23
|
+
* - `integration_mode`: the adapter's `INTEGRATION_MODE`.
|
|
24
|
+
* - `native_ask_tier`: `NATIVE_ASK_TIER`. `false` means an `ask` the host
|
|
25
|
+
* receives does not stop the call, so a consumer must block instead.
|
|
26
|
+
* - `block`: the `BLOCK_TRANSPORT` row: the exit code the host blocks on, the
|
|
27
|
+
* stream it reads the reason from, and whether an empty reason runs the call.
|
|
28
|
+
* - `host_failure`: the `HOST_FAILURE` row, the hook entry's DEFAULT response
|
|
29
|
+
* when the hook breaks (exit 0 runs the call; amp's exit 1 asks the human).
|
|
30
|
+
* `ACP_FAIL_CLOSED=1` replaces it with `block`.
|
|
31
|
+
* - `coverage`: `COVERAGE`, per call class.
|
|
32
|
+
* - `unrendered_fields_on_allow`: `UNRENDERED_FIELDS`, each set as a sorted
|
|
33
|
+
* list. It describes the allow path only: a deny can drop more.
|
|
34
|
+
* @param {string} id
|
|
35
|
+
* @returns {Record<string, unknown>}
|
|
36
|
+
*/
|
|
37
|
+
function manifestRow(id) {
|
|
38
|
+
const adapter = ADAPTERS[id];
|
|
39
|
+
const failure = HOST_FAILURE[id];
|
|
40
|
+
return {
|
|
41
|
+
integration_mode: adapter.INTEGRATION_MODE,
|
|
42
|
+
native_ask_tier: adapter.NATIVE_ASK_TIER,
|
|
43
|
+
block: blockTransportFor(id),
|
|
44
|
+
host_failure: { exit_code: failure.exit_code, enforced: failure.enforced },
|
|
45
|
+
coverage: adapter.COVERAGE,
|
|
46
|
+
unrendered_fields_on_allow: Object.fromEntries(
|
|
47
|
+
// The conformance harness refuses an adapter missing a row, and spreading
|
|
48
|
+
// a missing one throws here, so a gap never publishes as "every field renders".
|
|
49
|
+
Object.values(EventKind).map((kind) => [
|
|
50
|
+
kind,
|
|
51
|
+
[
|
|
52
|
+
.../** @type {ReadonlySet<string>} */ (
|
|
53
|
+
lookup(adapter.UNRENDERED_FIELDS, kind)
|
|
54
|
+
),
|
|
55
|
+
].sort(),
|
|
56
|
+
]),
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every generated file, keyed by its package-relative path: the JSON Schema
|
|
63
|
+
* documents and their data maps, plus `agents.json`, keyed by agent id in
|
|
64
|
+
* {@link AGENT_IDS} order. The SSOT the generator writes and the freshness test
|
|
65
|
+
* reads, so neither carries its own list of filenames.
|
|
66
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
67
|
+
*/
|
|
68
|
+
export function generatedDocuments() {
|
|
69
|
+
return {
|
|
70
|
+
...jsonSchemaDocuments(),
|
|
71
|
+
[`${SCHEMA_DIR}/agents.json`]: Object.fromEntries(
|
|
72
|
+
AGENT_IDS.map((id) => [id, manifestRow(id)]),
|
|
73
|
+
),
|
|
74
|
+
};
|
|
75
|
+
}
|
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/json-schema.mjs
CHANGED
|
@@ -291,10 +291,10 @@ export function verdictSchema() {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
/**
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* `
|
|
294
|
+
* The contract's generated files, keyed by package-relative path. The two
|
|
295
|
+
* `.schema.json` entries are JSON Schema documents; `tool-input-keys.json` is
|
|
296
|
+
* the data map {@link TOOL_INPUT_KEYS_FILE} describes. `agent-manifest.mjs`'s
|
|
297
|
+
* `generatedDocuments` adds the per-agent manifest to these.
|
|
298
298
|
* @returns {Record<string, Record<string, unknown>>}
|
|
299
299
|
*/
|
|
300
300
|
export function jsonSchemaDocuments() {
|
package/src/registry.mjs
CHANGED
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
import { lookup } from "./control-plane.mjs";
|
|
18
18
|
import { claudeAdapter } from "./adapters/claude.mjs";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
codexAdapter,
|
|
21
|
+
createCodexAdapter,
|
|
22
|
+
OPTION_NAMES as CODEX_OPTION_NAMES,
|
|
23
|
+
} from "./adapters/codex.mjs";
|
|
20
24
|
import { ampAdapter } from "./adapters/amp.mjs";
|
|
21
25
|
import { geminiAdapter } from "./adapters/gemini.mjs";
|
|
22
26
|
|
|
@@ -64,23 +68,43 @@ export function assertRegistryConsistent(adapters) {
|
|
|
64
68
|
assertRegistryConsistent(ADAPTERS);
|
|
65
69
|
|
|
66
70
|
/**
|
|
67
|
-
* The adapters that take launcher options, keyed by agent id
|
|
68
|
-
* its
|
|
69
|
-
*
|
|
71
|
+
* The adapters that take launcher options, keyed by agent id: the option names
|
|
72
|
+
* each reads, and its factory. Only Codex takes one: its hook stdin carries no
|
|
73
|
+
* version, so the launcher pins one.
|
|
74
|
+
* @type {Readonly<Record<string, Readonly<{ options: readonly string[], create: (options: any) => Adapter }>>>}
|
|
70
75
|
*/
|
|
71
76
|
const CONFIGURABLE = Object.freeze({
|
|
72
|
-
[codexAdapter.AGENT]:
|
|
77
|
+
[codexAdapter.AGENT]: Object.freeze({
|
|
78
|
+
options: CODEX_OPTION_NAMES,
|
|
79
|
+
create: createCodexAdapter,
|
|
80
|
+
}),
|
|
73
81
|
});
|
|
74
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The launcher option names each agent's adapter reads, for EVERY agent id (an
|
|
85
|
+
* empty list when it reads none). A launcher builds the options for any agent
|
|
86
|
+
* from this instead of naming the agents that take a version.
|
|
87
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
88
|
+
*/
|
|
89
|
+
export const LAUNCHER_OPTIONS = Object.freeze(
|
|
90
|
+
Object.fromEntries(
|
|
91
|
+
AGENT_IDS.map((id) => [
|
|
92
|
+
id,
|
|
93
|
+
lookup(CONFIGURABLE, id)?.options ?? Object.freeze([]),
|
|
94
|
+
]),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
75
98
|
/**
|
|
76
99
|
* Resolve an agent id to its adapter. Throws (fail loud) on an unknown id — a
|
|
77
100
|
* selection signal that names no shipped adapter is a misconfiguration, not a
|
|
78
101
|
* reason to silently fall back to a default agent (which would run the wrong
|
|
79
102
|
* translator against the payloads and mis-render every verdict).
|
|
80
103
|
*
|
|
81
|
-
* `options` are the launcher's settings for that adapter
|
|
82
|
-
*
|
|
83
|
-
*
|
|
104
|
+
* `options` are the launcher's settings for that adapter, named by its
|
|
105
|
+
* {@link LAUNCHER_OPTIONS} row. Options for an adapter that takes none throw
|
|
106
|
+
* here, and an option a configurable adapter does not read throws in its
|
|
107
|
+
* factory, so a setting the launcher believes it applied is never dropped.
|
|
84
108
|
* @param {string} id agent id (a {@link AGENT_IDS} member)
|
|
85
109
|
* @param {Record<string, unknown>} [options]
|
|
86
110
|
* @returns {Adapter}
|
|
@@ -92,10 +116,10 @@ export function adapterFor(id, options) {
|
|
|
92
116
|
`registry: no adapter for agent id ${JSON.stringify(id)} (known: ${AGENT_IDS.join(", ")})`,
|
|
93
117
|
);
|
|
94
118
|
if (options === undefined) return adapter;
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
119
|
+
const configurable = lookup(CONFIGURABLE, id);
|
|
120
|
+
if (configurable === undefined)
|
|
97
121
|
throw new Error(
|
|
98
122
|
`registry: adapter ${JSON.stringify(id)} takes no options, got ${JSON.stringify(options)}`,
|
|
99
123
|
);
|
|
100
|
-
return
|
|
124
|
+
return configurable.create(options);
|
|
101
125
|
}
|
package/src/runtime.mjs
CHANGED
|
@@ -20,6 +20,12 @@ import {
|
|
|
20
20
|
sanitizeVerdict,
|
|
21
21
|
} from "./control-plane.mjs";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A guardrail judge: one normalized event in, its verdict out, synchronously or
|
|
25
|
+
* as a promise. Its answer is untrusted either way (see {@link renderHookResponse}).
|
|
26
|
+
* @typedef {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict | Promise<import("./control-plane.mjs").Verdict>} Judge
|
|
27
|
+
*/
|
|
28
|
+
|
|
23
29
|
/**
|
|
24
30
|
* Read all of stdin to a string.
|
|
25
31
|
* @returns {Promise<string>}
|
|
@@ -77,16 +83,18 @@ export function demoJudge(event) {
|
|
|
77
83
|
* greppable trace. Every host tolerates hook stderr: claude/codex surface it
|
|
78
84
|
* only in verbose/debug on a 0 exit, amp forwards the helper's stderr, and
|
|
79
85
|
* gemini reads stderr as a reason only on exit 2.
|
|
80
|
-
* `judge` is the seam a real deployment fills
|
|
81
|
-
*
|
|
82
|
-
*
|
|
86
|
+
* `judge` is the seam a real deployment fills, so the guardrail is supplied here
|
|
87
|
+
* rather than by forking this file ({@link demoJudge} is a stand-in). It may
|
|
88
|
+
* return a promise (a judge that asks a remote monitor); a rejection takes the
|
|
89
|
+
* same path as a throw. Whatever it returns is treated as untrusted (see the
|
|
90
|
+
* clamp below).
|
|
83
91
|
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
84
92
|
* @param {string} rawInput
|
|
85
93
|
* @param {import("./control-plane.mjs").NativeResponse} onFailure
|
|
86
|
-
* @param {
|
|
87
|
-
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
94
|
+
* @param {Judge} [judge]
|
|
95
|
+
* @returns {Promise<import("./control-plane.mjs").NativeResponse>}
|
|
88
96
|
*/
|
|
89
|
-
export function renderHookResponse(
|
|
97
|
+
export async function renderHookResponse(
|
|
90
98
|
adapter,
|
|
91
99
|
rawInput,
|
|
92
100
|
onFailure,
|
|
@@ -107,7 +115,7 @@ export function renderHookResponse(
|
|
|
107
115
|
/** @type {unknown} */
|
|
108
116
|
let answer;
|
|
109
117
|
try {
|
|
110
|
-
answer = judge(event);
|
|
118
|
+
answer = await judge(event);
|
|
111
119
|
} catch (err) {
|
|
112
120
|
// No `decision`, so the clamp below turns this into an `ask`.
|
|
113
121
|
answer = { reason: `hook judge threw: ${describe(err).split("\n")[0]}` };
|
|
@@ -227,9 +235,11 @@ export function failurePosture(agent, env = process.env) {
|
|
|
227
235
|
* {@link renderHookResponse} with that host's {@link failurePosture}, and
|
|
228
236
|
* {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
|
|
229
237
|
* failure response also answers a stdin read error and, when
|
|
230
|
-
* {@link hookDeadlineMs} is set, a hook still running at that deadline.
|
|
238
|
+
* {@link hookDeadlineMs} is set, a hook still running at that deadline. It also
|
|
239
|
+
* answers a judge promise that never settles once nothing else keeps node alive,
|
|
240
|
+
* which would otherwise end the process with no response.
|
|
231
241
|
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
232
|
-
* @param {
|
|
242
|
+
* @param {Judge} [judge]
|
|
233
243
|
* @returns {Promise<never>}
|
|
234
244
|
*/
|
|
235
245
|
export async function runHookEntry(adapter, judge = demoJudge) {
|
|
@@ -258,7 +268,17 @@ export async function runHookEntry(adapter, judge = demoJudge) {
|
|
|
258
268
|
} catch (err) {
|
|
259
269
|
return fail("hook stdin error", err);
|
|
260
270
|
}
|
|
261
|
-
|
|
271
|
+
// An unsettled judge promise with no pending I/O drains the event loop, and
|
|
272
|
+
// node then exits with no response (13 under top-level await, 0 otherwise),
|
|
273
|
+
// which a fail-open host runs. Registered after stdin is read, so only the
|
|
274
|
+
// judge can be what never answered.
|
|
275
|
+
process.once("beforeExit", () =>
|
|
276
|
+
fail(
|
|
277
|
+
"hook judge never answered",
|
|
278
|
+
new Error("the judge's promise never settled"),
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
return emit(await renderHookResponse(adapter, raw, onFailure, judge));
|
|
262
282
|
}
|
|
263
283
|
|
|
264
284
|
/**
|
|
@@ -58,11 +58,12 @@ export function render(verdict: Verdict, event: ToolCallEvent, { soleGate }?: {
|
|
|
58
58
|
* the Codex version the launcher pinned (Codex's hook stdin carries none).
|
|
59
59
|
* Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
|
|
60
60
|
* deny renders advisory. With no `version`, `parse` falls back to a payload
|
|
61
|
-
* `version` (see {@link parse}).
|
|
61
|
+
* `version` (see {@link parse}). An option outside {@link OPTION_NAMES} throws,
|
|
62
|
+
* so a misspelled setting is never dropped in silence.
|
|
62
63
|
* @param {{ version?: string }} [options]
|
|
63
64
|
* @returns {import("../control-plane.mjs").Adapter}
|
|
64
65
|
*/
|
|
65
|
-
export function createCodexAdapter(
|
|
66
|
+
export function createCodexAdapter(options?: {
|
|
66
67
|
version?: string;
|
|
67
68
|
}): import("../control-plane.mjs").Adapter;
|
|
68
69
|
/** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
|
|
@@ -140,6 +141,8 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
|
140
141
|
* naming a key the host discards.
|
|
141
142
|
*/
|
|
142
143
|
export const NATIVE_ASK_TIER: false;
|
|
144
|
+
/** The launcher option names {@link createCodexAdapter} reads. */
|
|
145
|
+
export const OPTION_NAMES: readonly string[];
|
|
143
146
|
/**
|
|
144
147
|
* The Codex adapter with no launcher-pinned version. Real Codex sends no
|
|
145
148
|
* version, so every event it sends parses observe-only here. A deployment that
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every generated file, keyed by its package-relative path: the JSON Schema
|
|
3
|
+
* documents and their data maps, plus `agents.json`, keyed by agent id in
|
|
4
|
+
* {@link AGENT_IDS} order. The SSOT the generator writes and the freshness test
|
|
5
|
+
* reads, so neither carries its own list of filenames.
|
|
6
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
7
|
+
*/
|
|
8
|
+
export function generatedDocuments(): Record<string, Record<string, unknown>>;
|
|
@@ -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/json-schema.d.mts
CHANGED
|
@@ -25,10 +25,10 @@ export function toolCallEventSchema(): Record<string, unknown>;
|
|
|
25
25
|
*/
|
|
26
26
|
export function verdictSchema(): Record<string, unknown>;
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
28
|
+
* The contract's generated files, keyed by package-relative path. The two
|
|
29
|
+
* `.schema.json` entries are JSON Schema documents; `tool-input-keys.json` is
|
|
30
|
+
* the data map {@link TOOL_INPUT_KEYS_FILE} describes. `agent-manifest.mjs`'s
|
|
31
|
+
* `generatedDocuments` adds the per-agent manifest to these.
|
|
32
32
|
* @returns {Record<string, Record<string, unknown>>}
|
|
33
33
|
*/
|
|
34
34
|
export function jsonSchemaDocuments(): Record<string, Record<string, unknown>>;
|
package/types/registry.d.mts
CHANGED
|
@@ -13,9 +13,10 @@ export function assertRegistryConsistent(adapters: Record<string, Adapter>): voi
|
|
|
13
13
|
* reason to silently fall back to a default agent (which would run the wrong
|
|
14
14
|
* translator against the payloads and mis-render every verdict).
|
|
15
15
|
*
|
|
16
|
-
* `options` are the launcher's settings for that adapter
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* `options` are the launcher's settings for that adapter, named by its
|
|
17
|
+
* {@link LAUNCHER_OPTIONS} row. Options for an adapter that takes none throw
|
|
18
|
+
* here, and an option a configurable adapter does not read throws in its
|
|
19
|
+
* factory, so a setting the launcher believes it applied is never dropped.
|
|
19
20
|
* @param {string} id agent id (a {@link AGENT_IDS} member)
|
|
20
21
|
* @param {Record<string, unknown>} [options]
|
|
21
22
|
* @returns {Adapter}
|
|
@@ -37,4 +38,11 @@ export const ADAPTERS: Readonly<Record<string, Adapter>>;
|
|
|
37
38
|
* @type {readonly string[]}
|
|
38
39
|
*/
|
|
39
40
|
export const AGENT_IDS: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* The launcher option names each agent's adapter reads, for EVERY agent id (an
|
|
43
|
+
* empty list when it reads none). A launcher builds the options for any agent
|
|
44
|
+
* from this instead of naming the agents that take a version.
|
|
45
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
46
|
+
*/
|
|
47
|
+
export const LAUNCHER_OPTIONS: Readonly<Record<string, readonly string[]>>;
|
|
40
48
|
export type Adapter = import("./control-plane.mjs").Adapter;
|
package/types/runtime.d.mts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A guardrail judge: one normalized event in, its verdict out, synchronously or
|
|
3
|
+
* as a promise. Its answer is untrusted either way (see {@link renderHookResponse}).
|
|
4
|
+
* @typedef {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict | Promise<import("./control-plane.mjs").Verdict>} Judge
|
|
5
|
+
*/
|
|
1
6
|
/**
|
|
2
7
|
* Read all of stdin to a string.
|
|
3
8
|
* @returns {Promise<string>}
|
|
@@ -31,16 +36,18 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
|
|
|
31
36
|
* greppable trace. Every host tolerates hook stderr: claude/codex surface it
|
|
32
37
|
* only in verbose/debug on a 0 exit, amp forwards the helper's stderr, and
|
|
33
38
|
* gemini reads stderr as a reason only on exit 2.
|
|
34
|
-
* `judge` is the seam a real deployment fills
|
|
35
|
-
*
|
|
36
|
-
*
|
|
39
|
+
* `judge` is the seam a real deployment fills, so the guardrail is supplied here
|
|
40
|
+
* rather than by forking this file ({@link demoJudge} is a stand-in). It may
|
|
41
|
+
* return a promise (a judge that asks a remote monitor); a rejection takes the
|
|
42
|
+
* same path as a throw. Whatever it returns is treated as untrusted (see the
|
|
43
|
+
* clamp below).
|
|
37
44
|
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
38
45
|
* @param {string} rawInput
|
|
39
46
|
* @param {import("./control-plane.mjs").NativeResponse} onFailure
|
|
40
|
-
* @param {
|
|
41
|
-
* @returns {import("./control-plane.mjs").NativeResponse}
|
|
47
|
+
* @param {Judge} [judge]
|
|
48
|
+
* @returns {Promise<import("./control-plane.mjs").NativeResponse>}
|
|
42
49
|
*/
|
|
43
|
-
export function renderHookResponse(adapter: import("./control-plane.mjs").Adapter, rawInput: string, onFailure: import("./control-plane.mjs").NativeResponse, judge?:
|
|
50
|
+
export function renderHookResponse(adapter: import("./control-plane.mjs").Adapter, rawInput: string, onFailure: import("./control-plane.mjs").NativeResponse, judge?: Judge): Promise<import("./control-plane.mjs").NativeResponse>;
|
|
44
51
|
/**
|
|
45
52
|
* The response that BLOCKS the call on `agent`'s host: its
|
|
46
53
|
* {@link BLOCK_TRANSPORT} row's exit code, with `reason` on the row's reason
|
|
@@ -69,12 +76,14 @@ export function failurePosture(agent: string, env?: NodeJS.ProcessEnv): import("
|
|
|
69
76
|
* {@link renderHookResponse} with that host's {@link failurePosture}, and
|
|
70
77
|
* {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
|
|
71
78
|
* failure response also answers a stdin read error and, when
|
|
72
|
-
* {@link hookDeadlineMs} is set, a hook still running at that deadline.
|
|
79
|
+
* {@link hookDeadlineMs} is set, a hook still running at that deadline. It also
|
|
80
|
+
* answers a judge promise that never settles once nothing else keeps node alive,
|
|
81
|
+
* which would otherwise end the process with no response.
|
|
73
82
|
* @param {import("./control-plane.mjs").Adapter} adapter
|
|
74
|
-
* @param {
|
|
83
|
+
* @param {Judge} [judge]
|
|
75
84
|
* @returns {Promise<never>}
|
|
76
85
|
*/
|
|
77
|
-
export function runHookEntry(adapter: import("./control-plane.mjs").Adapter, judge?:
|
|
86
|
+
export function runHookEntry(adapter: import("./control-plane.mjs").Adapter, judge?: Judge): Promise<never>;
|
|
78
87
|
/**
|
|
79
88
|
* `ACP_HOOK_TIMEOUT_MS` as a positive integer, or `undefined` when unset. Set it
|
|
80
89
|
* below the host's own hook timeout, so the hook's failure response lands
|
|
@@ -135,3 +144,8 @@ export function emit(response: import("./control-plane.mjs").NativeResponse): ne
|
|
|
135
144
|
* @type {Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>}
|
|
136
145
|
*/
|
|
137
146
|
export const HOST_FAILURE: Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>;
|
|
147
|
+
/**
|
|
148
|
+
* A guardrail judge: one normalized event in, its verdict out, synchronously or
|
|
149
|
+
* as a promise. Its answer is untrusted either way (see {@link renderHookResponse}).
|
|
150
|
+
*/
|
|
151
|
+
export type Judge = (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict | Promise<import("./control-plane.mjs").Verdict>;
|