agent-control-plane-core 0.2.11 → 0.2.12
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/bin/hook-runtime.mjs +24 -2
- package/package.json +1 -1
- package/src/adapters/claude.mjs +5 -2
- package/src/adapters/codex.mjs +11 -0
- package/src/adapters/gemini.mjs +25 -10
- package/src/control-plane.mjs +71 -5
- package/src/fixtures/gemini.json +4 -2
- package/src/registry.mjs +2 -1
- package/types/adapters/codex.d.mts +11 -0
- package/types/control-plane.d.mts +23 -2
package/bin/hook-runtime.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* binary impersonate every host and so had to pick a single shared failure
|
|
9
9
|
* behavior; sharing pure plumbing across distinct entries is not that.
|
|
10
10
|
*/
|
|
11
|
+
import { writeSync } from "node:fs";
|
|
11
12
|
import { Decision } from "../src/control-plane.mjs";
|
|
12
13
|
|
|
13
14
|
/** Read all of stdin to a string. */
|
|
@@ -57,7 +58,17 @@ export function demoJudge(event) {
|
|
|
57
58
|
*/
|
|
58
59
|
export function renderHookResponse(adapter, rawInput, onFailure) {
|
|
59
60
|
try {
|
|
60
|
-
const
|
|
61
|
+
const native = JSON.parse(rawInput);
|
|
62
|
+
// A non-object top-level payload (null, array, number, string) is malformed:
|
|
63
|
+
// the adapters' `asObject` would silently coerce it to `{}` and judge it as a
|
|
64
|
+
// benign no-op tool call — a rubber-stamp with no trace. Throw so it takes the
|
|
65
|
+
// host's declared failure posture AND leaves a greppable stderr diagnostic,
|
|
66
|
+
// instead of a silent coerce-to-empty.
|
|
67
|
+
if (native === null || typeof native !== "object" || Array.isArray(native))
|
|
68
|
+
throw new TypeError(
|
|
69
|
+
`hook payload must be a JSON object, got ${native === null ? "null" : Array.isArray(native) ? "array" : typeof native}`,
|
|
70
|
+
);
|
|
71
|
+
const event = adapter.parse(native);
|
|
61
72
|
return adapter.render(demoJudge(event), event);
|
|
62
73
|
} catch (err) {
|
|
63
74
|
const detail = err instanceof Error ? (err.stack ?? err.message) : `${err}`;
|
|
@@ -73,7 +84,18 @@ export function renderHookResponse(adapter, rawInput, onFailure) {
|
|
|
73
84
|
* @returns {never}
|
|
74
85
|
*/
|
|
75
86
|
export function emit(response) {
|
|
87
|
+
// `writeSync` (not `process.stdout.write`) so the body is flushed to fd 1
|
|
88
|
+
// BEFORE `process.exit`. On a pipe — which stdout is when the host captures the
|
|
89
|
+
// hook — `process.stdout.write` is asynchronous and `process.exit` does not
|
|
90
|
+
// drain it, so an enforced-deny body (Claude/Codex `hookSpecificOutput`) could
|
|
91
|
+
// be truncated; the host then reads a block-less exit and runs the tool. A
|
|
92
|
+
// synchronous write closes that silent deny→allow window.
|
|
76
93
|
if (response.stdout !== undefined)
|
|
77
|
-
|
|
94
|
+
writeSync(1, JSON.stringify(response.stdout));
|
|
95
|
+
// A host that reads the block reason from STDERR (Gemini's exit-2 System Block)
|
|
96
|
+
// gets it here — written synchronously to fd 2 before exit, for the same
|
|
97
|
+
// flush-before-exit reason as stdout above, so an enforced deny is never shown
|
|
98
|
+
// with no rationale.
|
|
99
|
+
if (response.stderr !== undefined) writeSync(2, response.stderr);
|
|
78
100
|
process.exit(response.exit_code);
|
|
79
101
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-control-plane-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
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/claude.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
classifyCallClass,
|
|
22
22
|
coverageAllowsVeto,
|
|
23
23
|
canonicalTool,
|
|
24
|
+
lookup,
|
|
24
25
|
makeEvent,
|
|
25
26
|
normalizeVerdict,
|
|
26
27
|
nativeResponse,
|
|
@@ -146,8 +147,10 @@ export function parse(native) {
|
|
|
146
147
|
const raw = asObject(native);
|
|
147
148
|
const nativeEvent = asString(raw.hook_event_name, "");
|
|
148
149
|
const kind =
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
lookup(
|
|
151
|
+
/** @type {Record<string, string>} */ (NATIVE_TO_KIND),
|
|
152
|
+
nativeEvent,
|
|
153
|
+
) ?? EventKind.UNKNOWN;
|
|
151
154
|
const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
|
|
152
155
|
const nativeTool = claudeTool(kind, raw);
|
|
153
156
|
const meta = claudeMeta(nativeEvent, raw);
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -83,6 +83,17 @@ const CONSUMED = new Set([
|
|
|
83
83
|
* `semver.coerce` normalizes a patch/prerelease/build-tagged version to its
|
|
84
84
|
* release core; anything it can't coerce to a valid version (missing, empty,
|
|
85
85
|
* garbage) yields `null` and is treated as too old — fail closed to advisory.
|
|
86
|
+
*
|
|
87
|
+
* TRUST BOUNDARY (sandbox, not adapter): `version` is read from the hook stdin
|
|
88
|
+
* payload, so a payload that under-reports it downgrades enforcement to advisory
|
|
89
|
+
* (a deny renders exit 0). The adapter cannot distinguish a spoofed version from
|
|
90
|
+
* a genuinely old Codex — the ground-truth version lives outside the payload —
|
|
91
|
+
* so anti-spoofing belongs to the sandbox that controls what the hook is fed
|
|
92
|
+
* (the same "pin it from outside" posture the managed-config mount enforces for
|
|
93
|
+
* the hook binary; see docs/monitor-invariants.md §Invariant 1). Coercing an
|
|
94
|
+
* absent/garbage version to enforcing HERE would break the legitimate old-Codex
|
|
95
|
+
* case (rendering an exit-2 block a pre-0.135 Codex ignores, while dishonestly
|
|
96
|
+
* marking it `enforced`), so the adapter stays faithful to what the payload says.
|
|
86
97
|
* @param {unknown} version
|
|
87
98
|
* @returns {boolean}
|
|
88
99
|
*/
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
* parsed as JSON (the path for allow / advisory / mutation); exit 2 → a "System
|
|
13
13
|
* Block" that pre-empts the call, with the reason taken from STDERR (stdout is
|
|
14
14
|
* ignored). So an ENFORCED deny renders as exit 2 (the only real block signal),
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* with its `reason` carried on `NativeResponse.stderr` (which `emit` writes to fd
|
|
16
|
+
* 2) so the System Block is never shown with no rationale. Gemini has no native
|
|
17
|
+
* "ask" tier, so `ask` maps to an exit-0 advisory `decision: "deny"` the model
|
|
18
|
+
* sees but that does not hard-block.
|
|
19
|
+
*
|
|
20
|
+
* PIN GAP (sandbox, not adapter — carried here because this is the module that
|
|
21
|
+
* knows the host is pin-dependent): Gemini CLI's system settings file wins over
|
|
22
|
+
* other settings FILES, but environment variables and CLI argv still override it,
|
|
23
|
+
* and `GEMINI_CLI_SYSTEM_SETTINGS_PATH` redirects WHERE the system file is read
|
|
24
|
+
* from (geminicli.com/docs/reference/configuration). So mounting the pinned
|
|
25
|
+
* system settings file read-only is NOT sufficient on Gemini CLI: an agent that
|
|
26
|
+
* controls its own env or launch argv routes around it. The launcher must ALSO
|
|
27
|
+
* fix `GEMINI_CLI_SYSTEM_SETTINGS_PATH` and the per-invocation env/argv the agent
|
|
28
|
+
* can't rewrite. See docs/monitor-invariants.md §Invariant 1.
|
|
20
29
|
*/
|
|
21
30
|
|
|
22
31
|
import {
|
|
@@ -28,6 +37,7 @@ import {
|
|
|
28
37
|
classifyCallClass,
|
|
29
38
|
coverageAllowsVeto,
|
|
30
39
|
canonicalTool,
|
|
40
|
+
lookup,
|
|
31
41
|
assertAliasTargetsModeled,
|
|
32
42
|
makeEvent,
|
|
33
43
|
normalizeVerdict,
|
|
@@ -142,7 +152,7 @@ assertAliasTargetsModeled(GEMINI_TOOL_ALIASES);
|
|
|
142
152
|
*/
|
|
143
153
|
function geminiCanonicalTool(nativeTool, callClass) {
|
|
144
154
|
if (nativeTool !== null && callClass === CallClass.BUILTIN) {
|
|
145
|
-
const scoped = GEMINI_TOOL_ALIASES
|
|
155
|
+
const scoped = lookup(GEMINI_TOOL_ALIASES, nativeTool);
|
|
146
156
|
if (scoped !== undefined) return scoped;
|
|
147
157
|
}
|
|
148
158
|
return canonicalTool(nativeTool);
|
|
@@ -178,8 +188,10 @@ export function parse(native) {
|
|
|
178
188
|
const nativeEvent =
|
|
179
189
|
typeof raw.hook_event_name === "string" ? raw.hook_event_name : "";
|
|
180
190
|
const kind =
|
|
181
|
-
|
|
182
|
-
|
|
191
|
+
lookup(
|
|
192
|
+
/** @type {Record<string, string>} */ (NATIVE_TO_KIND),
|
|
193
|
+
nativeEvent,
|
|
194
|
+
) ?? EventKind.UNKNOWN;
|
|
183
195
|
const gating = kind === EventKind.PRE_TOOL || kind === EventKind.POST_TOOL;
|
|
184
196
|
const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
|
|
185
197
|
const nativeTool = gating ? asStringOrNull(raw.tool_name) : null;
|
|
@@ -216,13 +228,16 @@ export function render(verdict, event, { soleGate = false } = {}) {
|
|
|
216
228
|
const vd = normalizeVerdict(verdict);
|
|
217
229
|
const enforced = vd.decision === Decision.DENY && event.this_call_vetoable;
|
|
218
230
|
|
|
219
|
-
// Enforced deny is a System Block: exit 2, reason carried on
|
|
220
|
-
//
|
|
231
|
+
// Enforced deny is a System Block: exit 2, reason carried on STDERR (Gemini
|
|
232
|
+
// reads its block rationale from stderr, not the ignored stdout body). Carry it
|
|
233
|
+
// on NativeResponse.stderr so `emit` writes it to fd 2 — a genuine block is
|
|
234
|
+
// never surfaced to the user/model with no explanation.
|
|
221
235
|
if (enforced)
|
|
222
236
|
return nativeResponse({
|
|
223
237
|
transport: INTEGRATION_MODE,
|
|
224
238
|
exit_code: 2,
|
|
225
239
|
enforced: true,
|
|
240
|
+
...(vd.reason !== undefined ? { stderr: vd.reason } : {}),
|
|
226
241
|
});
|
|
227
242
|
|
|
228
243
|
const body =
|
package/src/control-plane.mjs
CHANGED
|
@@ -54,6 +54,9 @@ export const EventKind = Object.freeze({
|
|
|
54
54
|
UNKNOWN: "unknown",
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
/** @type {Set<string>} the valid {@link EventKind} values, for membership checks. */
|
|
58
|
+
const EVENT_KIND_VALUES = new Set(Object.values(EventKind));
|
|
59
|
+
|
|
57
60
|
/** The normalized verdict decisions a guardrail can return. */
|
|
58
61
|
export const Decision = Object.freeze({
|
|
59
62
|
ALLOW: "allow",
|
|
@@ -127,7 +130,27 @@ assertAliasTargetsModeled(TOOL_ALIASES);
|
|
|
127
130
|
*/
|
|
128
131
|
export function canonicalTool(tool) {
|
|
129
132
|
if (typeof tool !== "string") return tool;
|
|
130
|
-
return
|
|
133
|
+
return (
|
|
134
|
+
lookup(/** @type {Record<string, string>} */ (TOOL_ALIASES), tool) ?? tool
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Own-property lookup on an object used as a string-keyed map. Returns the value
|
|
140
|
+
* ONLY when `key` is an OWN property; an inherited `Object.prototype` member
|
|
141
|
+
* (`constructor`, `toString`, `valueOf`, `__proto__`, `hasOwnProperty`, …) that
|
|
142
|
+
* an untrusted key could name resolves to `undefined`, never the prototype
|
|
143
|
+
* function. EVERY map keyed by an untrusted native string — a tool name, an
|
|
144
|
+
* agent id, a native event name — MUST resolve through this: a bare `map[key]`
|
|
145
|
+
* index lets a payload named after a prototype member resolve a JS function
|
|
146
|
+
* instead of falling through to the default, silently reclassifying the call.
|
|
147
|
+
* @template T
|
|
148
|
+
* @param {Record<string, T>} map
|
|
149
|
+
* @param {string} key
|
|
150
|
+
* @returns {T|undefined}
|
|
151
|
+
*/
|
|
152
|
+
export function lookup(map, key) {
|
|
153
|
+
return Object.hasOwn(map, key) ? map[key] : undefined;
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
/**
|
|
@@ -235,7 +258,11 @@ export function classifyCallClass(tool, native) {
|
|
|
235
258
|
if (typeof tool === "string" && /^mcp__?[^_]/.test(tool))
|
|
236
259
|
return CallClass.MCP;
|
|
237
260
|
const ctx = native ? native.mcp_context : undefined;
|
|
238
|
-
|
|
261
|
+
// A plain object mcp_context is the MCP marker; an array is NOT (typeof [] is
|
|
262
|
+
// "object", so a bare typeof test over-classifies an array-valued field as MCP
|
|
263
|
+
// and silently downgrades a legitimate veto).
|
|
264
|
+
if (ctx !== null && typeof ctx === "object" && !Array.isArray(ctx))
|
|
265
|
+
return CallClass.MCP;
|
|
239
266
|
return CallClass.BUILTIN;
|
|
240
267
|
}
|
|
241
268
|
|
|
@@ -262,6 +289,7 @@ export function classifyCallClass(tool, native) {
|
|
|
262
289
|
* @property {number} exit_code process exit code carrying the decision (0 = proceed)
|
|
263
290
|
* @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
|
|
264
291
|
* @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
|
|
292
|
+
* @property {string} [stderr] text the host reads from STDERR — the block reason on a transport (e.g. Gemini CLI's exit-2 System Block) that takes its rationale from stderr rather than the stdout body. The caller writes it to fd 2 before exiting.
|
|
265
293
|
*/
|
|
266
294
|
|
|
267
295
|
/**
|
|
@@ -314,6 +342,20 @@ export function makeEvent({
|
|
|
314
342
|
this_call_vetoable,
|
|
315
343
|
meta,
|
|
316
344
|
}) {
|
|
345
|
+
// Fail loud on an internally-malformed event: an adapter that hands makeEvent a
|
|
346
|
+
// typo'd event kind ("pre-tool") or a non-boolean vetoable flag would otherwise
|
|
347
|
+
// ship an event that downstream `switch(event)` and the `=== false` veto-honesty
|
|
348
|
+
// guard silently mis-handle (a non-boolean `this_call_vetoable` reads as
|
|
349
|
+
// vetoable — a fail-open seam). These are produced in-process, so a bad value is
|
|
350
|
+
// a bug, not untrusted input to tolerate.
|
|
351
|
+
if (!EVENT_KIND_VALUES.has(event))
|
|
352
|
+
throw new Error(
|
|
353
|
+
`control-plane: makeEvent got an unmodeled event kind ${JSON.stringify(event)} (use EventKind.UNKNOWN for an unmapped native event)`,
|
|
354
|
+
);
|
|
355
|
+
if (typeof this_call_vetoable !== "boolean")
|
|
356
|
+
throw new TypeError(
|
|
357
|
+
`control-plane: makeEvent this_call_vetoable must be a boolean, got ${typeof this_call_vetoable}`,
|
|
358
|
+
);
|
|
317
359
|
/** @type {ToolCallEvent} */
|
|
318
360
|
const evt = {
|
|
319
361
|
schema_version: SCHEMA_VERSION,
|
|
@@ -359,6 +401,23 @@ export function normalizeVerdict(verdict) {
|
|
|
359
401
|
return out;
|
|
360
402
|
}
|
|
361
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Render an untrusted, rejected `decision` value for the clamp note. Prefers the
|
|
406
|
+
* JSON form (so a string reads back quoted, matching the note's contract) but
|
|
407
|
+
* falls back to `String(...)` for a value JSON cannot serialize — a BigInt would
|
|
408
|
+
* otherwise make `JSON.stringify` THROW, defeating the whole fail-to-ask purpose
|
|
409
|
+
* of the clamp on exactly the malformed input it exists to neutralize.
|
|
410
|
+
* @param {unknown} decision
|
|
411
|
+
* @returns {string}
|
|
412
|
+
*/
|
|
413
|
+
function stringifyRejected(decision) {
|
|
414
|
+
try {
|
|
415
|
+
return JSON.stringify(decision) ?? String(decision);
|
|
416
|
+
} catch {
|
|
417
|
+
return String(decision);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
362
421
|
/**
|
|
363
422
|
* Harden an UNTRUSTED {@link Verdict} — one authored by a separate
|
|
364
423
|
* monitor/judge process (e.g. claude-guard's scrub-monitor-response) — before
|
|
@@ -437,7 +496,7 @@ export function sanitizeVerdict(verdict, sanitizeText) {
|
|
|
437
496
|
if (!valid) {
|
|
438
497
|
// The rejected value is itself untrusted text, so it is scrubbed before
|
|
439
498
|
// being embedded in the clamp note.
|
|
440
|
-
const rejected = scrub("decision",
|
|
499
|
+
const rejected = scrub("decision", stringifyRejected(decision));
|
|
441
500
|
const note = `[control-plane: invalid verdict decision ${rejected} clamped to "ask"]`;
|
|
442
501
|
out.reason = out.reason === undefined ? note : `${out.reason} ${note}`;
|
|
443
502
|
}
|
|
@@ -499,10 +558,16 @@ export function asString(value, fallback) {
|
|
|
499
558
|
/**
|
|
500
559
|
* Assemble a {@link NativeResponse}, omitting an absent `stdout` so a pure
|
|
501
560
|
* exit-code transport carries no `stdout` key.
|
|
502
|
-
* @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown }} parts
|
|
561
|
+
* @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown, stderr?: string }} parts
|
|
503
562
|
* @returns {NativeResponse}
|
|
504
563
|
*/
|
|
505
|
-
export function nativeResponse({
|
|
564
|
+
export function nativeResponse({
|
|
565
|
+
transport,
|
|
566
|
+
exit_code,
|
|
567
|
+
enforced,
|
|
568
|
+
stdout,
|
|
569
|
+
stderr,
|
|
570
|
+
}) {
|
|
506
571
|
/** @type {NativeResponse} */
|
|
507
572
|
const out = {
|
|
508
573
|
transport: /** @type {NativeResponse["transport"]} */ (transport),
|
|
@@ -510,5 +575,6 @@ export function nativeResponse({ transport, exit_code, enforced, stdout }) {
|
|
|
510
575
|
enforced,
|
|
511
576
|
};
|
|
512
577
|
if (stdout !== undefined) out.stdout = stdout;
|
|
578
|
+
if (stderr !== undefined) out.stderr = stderr;
|
|
513
579
|
return out;
|
|
514
580
|
}
|
package/src/fixtures/gemini.json
CHANGED
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"native": {
|
|
57
57
|
"transport": "external_hook",
|
|
58
58
|
"exit_code": 2,
|
|
59
|
-
"enforced": true
|
|
59
|
+
"enforced": true,
|
|
60
|
+
"stderr": "blocked destructive command"
|
|
60
61
|
}
|
|
61
62
|
},
|
|
62
63
|
"ask": {
|
|
@@ -238,7 +239,8 @@
|
|
|
238
239
|
"native": {
|
|
239
240
|
"transport": "external_hook",
|
|
240
241
|
"exit_code": 2,
|
|
241
|
-
"enforced": true
|
|
242
|
+
"enforced": true,
|
|
243
|
+
"stderr": "prompt requests credential exfiltration"
|
|
242
244
|
}
|
|
243
245
|
},
|
|
244
246
|
"ask": {
|
package/src/registry.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* is no second spelling to reconcile.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { lookup } from "./control-plane.mjs";
|
|
17
18
|
import { claudeAdapter } from "./adapters/claude.mjs";
|
|
18
19
|
import { codexAdapter } from "./adapters/codex.mjs";
|
|
19
20
|
import { ampAdapter } from "./adapters/amp.mjs";
|
|
@@ -71,7 +72,7 @@ assertRegistryConsistent(ADAPTERS);
|
|
|
71
72
|
* @returns {Adapter}
|
|
72
73
|
*/
|
|
73
74
|
export function adapterFor(id) {
|
|
74
|
-
const adapter = ADAPTERS
|
|
75
|
+
const adapter = lookup(ADAPTERS, id);
|
|
75
76
|
if (adapter === undefined)
|
|
76
77
|
throw new Error(
|
|
77
78
|
`registry: no adapter for agent id ${JSON.stringify(id)} (known: ${AGENT_IDS.join(", ")})`,
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
* `semver.coerce` normalizes a patch/prerelease/build-tagged version to its
|
|
4
4
|
* release core; anything it can't coerce to a valid version (missing, empty,
|
|
5
5
|
* garbage) yields `null` and is treated as too old — fail closed to advisory.
|
|
6
|
+
*
|
|
7
|
+
* TRUST BOUNDARY (sandbox, not adapter): `version` is read from the hook stdin
|
|
8
|
+
* payload, so a payload that under-reports it downgrades enforcement to advisory
|
|
9
|
+
* (a deny renders exit 0). The adapter cannot distinguish a spoofed version from
|
|
10
|
+
* a genuinely old Codex — the ground-truth version lives outside the payload —
|
|
11
|
+
* so anti-spoofing belongs to the sandbox that controls what the hook is fed
|
|
12
|
+
* (the same "pin it from outside" posture the managed-config mount enforces for
|
|
13
|
+
* the hook binary; see docs/monitor-invariants.md §Invariant 1). Coercing an
|
|
14
|
+
* absent/garbage version to enforcing HERE would break the legitimate old-Codex
|
|
15
|
+
* case (rendering an exit-2 block a pre-0.135 Codex ignores, while dishonestly
|
|
16
|
+
* marking it `enforced`), so the adapter stays faithful to what the payload says.
|
|
6
17
|
* @param {unknown} version
|
|
7
18
|
* @returns {boolean}
|
|
8
19
|
*/
|
|
@@ -16,6 +16,21 @@ export function assertAliasTargetsModeled(aliases: Record<string, string>): void
|
|
|
16
16
|
* @returns {string|null} the canonical name, or the native name verbatim
|
|
17
17
|
*/
|
|
18
18
|
export function canonicalTool(tool: string | null): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Own-property lookup on an object used as a string-keyed map. Returns the value
|
|
21
|
+
* ONLY when `key` is an OWN property; an inherited `Object.prototype` member
|
|
22
|
+
* (`constructor`, `toString`, `valueOf`, `__proto__`, `hasOwnProperty`, …) that
|
|
23
|
+
* an untrusted key could name resolves to `undefined`, never the prototype
|
|
24
|
+
* function. EVERY map keyed by an untrusted native string — a tool name, an
|
|
25
|
+
* agent id, a native event name — MUST resolve through this: a bare `map[key]`
|
|
26
|
+
* index lets a payload named after a prototype member resolve a JS function
|
|
27
|
+
* instead of falling through to the default, silently reclassifying the call.
|
|
28
|
+
* @template T
|
|
29
|
+
* @param {Record<string, T>} map
|
|
30
|
+
* @param {string} key
|
|
31
|
+
* @returns {T|undefined}
|
|
32
|
+
*/
|
|
33
|
+
export function lookup<T>(map: Record<string, T>, key: string): T | undefined;
|
|
19
34
|
/**
|
|
20
35
|
* Whether a coverage status PERMITS an adapter to mark a call in that class
|
|
21
36
|
* `this_call_vetoable: true`. Only a hook confirmed to fire does: COVERED, or
|
|
@@ -70,6 +85,7 @@ export function classifyCallClass(tool: string | null, native?: Record<string, u
|
|
|
70
85
|
* @property {number} exit_code process exit code carrying the decision (0 = proceed)
|
|
71
86
|
* @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
|
|
72
87
|
* @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
|
|
88
|
+
* @property {string} [stderr] text the host reads from STDERR — the block reason on a transport (e.g. Gemini CLI's exit-2 System Block) that takes its rationale from stderr rather than the stdout body. The caller writes it to fd 2 before exiting.
|
|
73
89
|
*/
|
|
74
90
|
/**
|
|
75
91
|
* A normalized, agent-agnostic view of one agent event.
|
|
@@ -188,14 +204,15 @@ export function asString(value: unknown, fallback: string): string;
|
|
|
188
204
|
/**
|
|
189
205
|
* Assemble a {@link NativeResponse}, omitting an absent `stdout` so a pure
|
|
190
206
|
* exit-code transport carries no `stdout` key.
|
|
191
|
-
* @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown }} parts
|
|
207
|
+
* @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown, stderr?: string }} parts
|
|
192
208
|
* @returns {NativeResponse}
|
|
193
209
|
*/
|
|
194
|
-
export function nativeResponse({ transport, exit_code, enforced, stdout }: {
|
|
210
|
+
export function nativeResponse({ transport, exit_code, enforced, stdout, stderr, }: {
|
|
195
211
|
transport: string;
|
|
196
212
|
exit_code: number;
|
|
197
213
|
enforced: boolean;
|
|
198
214
|
stdout?: unknown;
|
|
215
|
+
stderr?: string;
|
|
199
216
|
}): NativeResponse;
|
|
200
217
|
/**
|
|
201
218
|
* The vendor-neutral control-plane contract.
|
|
@@ -379,6 +396,10 @@ export type NativeResponse = {
|
|
|
379
396
|
* native JSON body to write to stdout, when the transport uses one
|
|
380
397
|
*/
|
|
381
398
|
stdout?: unknown;
|
|
399
|
+
/**
|
|
400
|
+
* text the host reads from STDERR — the block reason on a transport (e.g. Gemini CLI's exit-2 System Block) that takes its rationale from stderr rather than the stdout body. The caller writes it to fd 2 before exiting.
|
|
401
|
+
*/
|
|
402
|
+
stderr?: string | undefined;
|
|
382
403
|
};
|
|
383
404
|
/**
|
|
384
405
|
* A normalized, agent-agnostic view of one agent event.
|