agent-control-plane-core 0.8.2 → 0.9.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 +20 -12
- package/bin/amp-hook.mjs +0 -0
- package/bin/claude-hook.mjs +0 -0
- package/bin/codex-hook.mjs +11 -7
- package/bin/gemini-hook.mjs +0 -0
- package/package.json +7 -1
- package/src/adapters/amp.mjs +9 -5
- package/src/adapters/claude.mjs +11 -13
- package/src/adapters/codex.mjs +64 -38
- package/src/adapters/gemini.mjs +21 -26
- package/src/conformance.mjs +36 -1
- package/src/control-plane.mjs +141 -11
- package/src/fixtures/codex.json +18 -79
- package/src/index.mjs +1 -1
- package/src/json-schema.mjs +16 -28
- package/src/registry.mjs +23 -3
- package/src/runtime.mjs +46 -17
- package/types/adapters/codex.d.mts +33 -13
- package/types/conformance.d.mts +2 -1
- package/types/control-plane.d.mts +81 -2
- package/types/index.d.mts +1 -1
- package/types/registry.d.mts +6 -1
- package/types/runtime.d.mts +11 -8
package/src/control-plane.mjs
CHANGED
|
@@ -398,7 +398,8 @@ export function classifyCallClass(tool, native) {
|
|
|
398
398
|
/**
|
|
399
399
|
* The result of rendering a {@link Verdict} for a specific agent's transport —
|
|
400
400
|
* the ATTACH mechanism, not the decision. A caller applies whichever channels
|
|
401
|
-
* are present: write `stdout`, exit with `exit_code
|
|
401
|
+
* are present: write `stdout`, write `stderr`, then exit with `exit_code`
|
|
402
|
+
* (`emit` in `agent-control-plane-core/runtime` does all three).
|
|
402
403
|
* @typedef {object} NativeResponse
|
|
403
404
|
* @property {"external_hook"|"in_process"|"observe_only"} transport
|
|
404
405
|
* @property {number} exit_code process exit code carrying the decision (0 = proceed)
|
|
@@ -492,6 +493,30 @@ export function readonlySet(values) {
|
|
|
492
493
|
*/
|
|
493
494
|
export const UNRENDERED_ON_UNKNOWN = readonlySet(VERDICT_CONTENT_FIELDS);
|
|
494
495
|
|
|
496
|
+
/**
|
|
497
|
+
* PROBLEM CLASS — an event marked vetoable on a path where the host can never
|
|
498
|
+
* veto it, so a deny reads downstream as a block that never happened.
|
|
499
|
+
*
|
|
500
|
+
* Each entry names a field (`path` into the {@link ToolCallEvent}) whose `value`
|
|
501
|
+
* makes `this_call_vetoable: true` a false claim. This is the ONE list:
|
|
502
|
+
* {@link makeEvent} refuses an event that breaks an entry, and the JSON-schema
|
|
503
|
+
* generator writes one `if`/`then` rule per entry, so the runtime and the
|
|
504
|
+
* published schema cannot hold different invariants.
|
|
505
|
+
* @type {ReadonlyArray<{ path: readonly string[], value: string, why: string }>}
|
|
506
|
+
*/
|
|
507
|
+
export const VETO_IMPOSSIBLE = Object.freeze([
|
|
508
|
+
Object.freeze({
|
|
509
|
+
path: Object.freeze(["event"]),
|
|
510
|
+
value: EventKind.UNKNOWN,
|
|
511
|
+
why: "an unmodelled event has no host response to veto, so reporting one is a false block",
|
|
512
|
+
}),
|
|
513
|
+
Object.freeze({
|
|
514
|
+
path: Object.freeze(["meta", "integration_mode"]),
|
|
515
|
+
value: IntegrationMode.OBSERVE_ONLY,
|
|
516
|
+
why: "an observe-only transport reads a transcript and cannot pre-empt a call, so reporting a veto is a false block",
|
|
517
|
+
}),
|
|
518
|
+
]);
|
|
519
|
+
|
|
495
520
|
/**
|
|
496
521
|
* The translator for one agent's protocol. `parse` maps a native event to a
|
|
497
522
|
* {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
|
|
@@ -537,16 +562,6 @@ export function makeEvent({
|
|
|
537
562
|
throw new TypeError(
|
|
538
563
|
`control-plane: makeEvent this_call_vetoable must be a boolean, got ${typeof this_call_vetoable}`,
|
|
539
564
|
);
|
|
540
|
-
// The same fail-open seam, one step further: an event the adapter could not
|
|
541
|
-
// name is one whose host response nobody established, so a veto reported for
|
|
542
|
-
// it is a block the render claims and the host never performs. Refused HERE
|
|
543
|
-
// because every adapter builds its event through this call, so an adapter that
|
|
544
|
-
// computes the flag without `vetoableFor` cannot reach the wire either.
|
|
545
|
-
if (event === EventKind.UNKNOWN && this_call_vetoable)
|
|
546
|
-
throw new Error(
|
|
547
|
-
"control-plane: makeEvent got a vetoable UNKNOWN event — an unmodelled " +
|
|
548
|
-
"event has no host response to veto, so reporting one is a false block",
|
|
549
|
-
);
|
|
550
565
|
/** @type {ToolCallEvent} */
|
|
551
566
|
const evt = {
|
|
552
567
|
schema_version: SCHEMA_VERSION,
|
|
@@ -557,6 +572,18 @@ export function makeEvent({
|
|
|
557
572
|
meta,
|
|
558
573
|
};
|
|
559
574
|
if (response !== undefined) evt.response = response;
|
|
575
|
+
// Refused HERE because every adapter builds its event through this call, so an
|
|
576
|
+
// adapter that computes the flag without `vetoableFor` cannot reach the wire.
|
|
577
|
+
if (this_call_vetoable) {
|
|
578
|
+
const broken = VETO_IMPOSSIBLE.find(
|
|
579
|
+
({ path, value }) =>
|
|
580
|
+
path.reduce((/** @type {any} */ at, key) => at[key], evt) === value,
|
|
581
|
+
);
|
|
582
|
+
if (broken !== undefined)
|
|
583
|
+
throw new Error(
|
|
584
|
+
`control-plane: makeEvent got a vetoable event whose ${broken.path.join(".")} is ${JSON.stringify(broken.value)} — ${broken.why}`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
560
587
|
return evt;
|
|
561
588
|
}
|
|
562
589
|
|
|
@@ -828,3 +855,106 @@ export function nativeResponse({
|
|
|
828
855
|
if (stderr !== undefined) out.stderr = stderr;
|
|
829
856
|
return out;
|
|
830
857
|
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* The block reason a render writes when the judge gave none (or an empty one).
|
|
861
|
+
* Codex drops an enforced deny whose reason is empty and runs the tool, and on
|
|
862
|
+
* every host the model is refused with no rationale without one.
|
|
863
|
+
*/
|
|
864
|
+
export const DEFAULT_DENY_REASON = "blocked by monitor";
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* PROBLEM CLASS — an enforced block whose reason never reaches the host, or
|
|
868
|
+
* whose exit code is not the one the host treats as a block, because each
|
|
869
|
+
* adapter hand-wired its own stderr and exit code.
|
|
870
|
+
*
|
|
871
|
+
* One row per agent, describing that host's ENFORCED block:
|
|
872
|
+
* - `exit_code`: the exit status the host treats as a block.
|
|
873
|
+
* - `reason_stream`: the stream the host reads the block reason from on that
|
|
874
|
+
* exit. Every host here discards (or never gets) the stdout body on exit 2.
|
|
875
|
+
* - `reason_required`: the host drops a block whose reason is empty and runs
|
|
876
|
+
* the tool, so every body field that carries the reason must be non-empty.
|
|
877
|
+
*
|
|
878
|
+
* {@link gatedResponse} derives each adapter's enforced render from its row,
|
|
879
|
+
* and the conformance harness holds every enforced render to the same row.
|
|
880
|
+
* @type {Readonly<Record<string, Readonly<{ exit_code: number, reason_stream: "stderr", reason_required: boolean }>>>}
|
|
881
|
+
*/
|
|
882
|
+
export const BLOCK_TRANSPORT = Object.freeze({
|
|
883
|
+
claude: Object.freeze({
|
|
884
|
+
exit_code: 2,
|
|
885
|
+
reason_stream: "stderr",
|
|
886
|
+
reason_required: false,
|
|
887
|
+
}),
|
|
888
|
+
codex: Object.freeze({
|
|
889
|
+
exit_code: 2,
|
|
890
|
+
reason_stream: "stderr",
|
|
891
|
+
reason_required: true,
|
|
892
|
+
}),
|
|
893
|
+
amp: Object.freeze({
|
|
894
|
+
exit_code: 2,
|
|
895
|
+
reason_stream: "stderr",
|
|
896
|
+
reason_required: false,
|
|
897
|
+
}),
|
|
898
|
+
gemini: Object.freeze({
|
|
899
|
+
exit_code: 2,
|
|
900
|
+
reason_stream: "stderr",
|
|
901
|
+
reason_required: false,
|
|
902
|
+
}),
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The {@link BLOCK_TRANSPORT} row for `agent`. Throws for an agent with no row:
|
|
907
|
+
* a missing row would otherwise render an enforced block with no exit code.
|
|
908
|
+
* @param {string} agent
|
|
909
|
+
* @returns {Readonly<{ exit_code: number, reason_stream: "stderr", reason_required: boolean }>}
|
|
910
|
+
*/
|
|
911
|
+
export function blockTransportFor(agent) {
|
|
912
|
+
const row = lookup(BLOCK_TRANSPORT, agent);
|
|
913
|
+
if (row === undefined)
|
|
914
|
+
throw new Error(
|
|
915
|
+
`control-plane: no BLOCK_TRANSPORT row for agent ${JSON.stringify(agent)}`,
|
|
916
|
+
);
|
|
917
|
+
return row;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* `reason` when it holds visible text, else {@link DEFAULT_DENY_REASON}.
|
|
922
|
+
* @param {unknown} reason
|
|
923
|
+
* @returns {string}
|
|
924
|
+
*/
|
|
925
|
+
export function blockReason(reason) {
|
|
926
|
+
return typeof reason === "string" && reason.trim() !== ""
|
|
927
|
+
? reason
|
|
928
|
+
: DEFAULT_DENY_REASON;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Build an adapter's {@link NativeResponse} from its {@link BLOCK_TRANSPORT}
|
|
933
|
+
* row. When `enforced`, the exit code is the row's and the reason (or the
|
|
934
|
+
* default) goes on the row's reason stream. Otherwise `unblocked_exit_code`
|
|
935
|
+
* stands and no reason stream is written: nothing was blocked, so the host
|
|
936
|
+
* reads the exit-0 (or ask) body instead.
|
|
937
|
+
* @param {string} agent
|
|
938
|
+
* @param {{ transport: string, enforced: boolean, unblocked_exit_code: number, stdout?: unknown, reason?: string }} parts
|
|
939
|
+
* @returns {NativeResponse}
|
|
940
|
+
*/
|
|
941
|
+
export function gatedResponse(
|
|
942
|
+
agent,
|
|
943
|
+
{ transport, enforced, unblocked_exit_code, stdout, reason },
|
|
944
|
+
) {
|
|
945
|
+
if (!enforced)
|
|
946
|
+
return nativeResponse({
|
|
947
|
+
transport,
|
|
948
|
+
exit_code: unblocked_exit_code,
|
|
949
|
+
enforced,
|
|
950
|
+
stdout,
|
|
951
|
+
});
|
|
952
|
+
const row = blockTransportFor(agent);
|
|
953
|
+
return nativeResponse({
|
|
954
|
+
transport,
|
|
955
|
+
exit_code: row.exit_code,
|
|
956
|
+
enforced,
|
|
957
|
+
stdout,
|
|
958
|
+
[row.reason_stream]: blockReason(reason),
|
|
959
|
+
});
|
|
960
|
+
}
|
package/src/fixtures/codex.json
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
"agent": "codex",
|
|
3
3
|
"cases": [
|
|
4
4
|
{
|
|
5
|
-
"name": "PreToolUse
|
|
5
|
+
"name": "PreToolUse on an enforcing Codex, all verdicts",
|
|
6
6
|
"call_class": "builtin",
|
|
7
7
|
"native": {
|
|
8
8
|
"hook_event_name": "PreToolUse",
|
|
9
|
-
"version": "0.135.0",
|
|
10
9
|
"session_id": "c1",
|
|
11
10
|
"cwd": "/w",
|
|
12
11
|
"permission_mode": "default",
|
|
@@ -100,7 +99,8 @@
|
|
|
100
99
|
"permissionDecision": "deny",
|
|
101
100
|
"permissionDecisionReason": "blocked"
|
|
102
101
|
}
|
|
103
|
-
}
|
|
102
|
+
},
|
|
103
|
+
"stderr": "blocked"
|
|
104
104
|
}
|
|
105
105
|
},
|
|
106
106
|
"ask": {
|
|
@@ -117,7 +117,8 @@
|
|
|
117
117
|
"permissionDecision": "deny",
|
|
118
118
|
"permissionDecisionReason": "blocked by monitor"
|
|
119
119
|
}
|
|
120
|
-
}
|
|
120
|
+
},
|
|
121
|
+
"stderr": "blocked by monitor"
|
|
121
122
|
}
|
|
122
123
|
},
|
|
123
124
|
"mutation": {
|
|
@@ -145,11 +146,10 @@
|
|
|
145
146
|
}
|
|
146
147
|
},
|
|
147
148
|
{
|
|
148
|
-
"name": "PreToolUse
|
|
149
|
+
"name": "PreToolUse MCP tool is non-vetoable (hook fires for Bash only)",
|
|
149
150
|
"call_class": "mcp",
|
|
150
151
|
"native": {
|
|
151
152
|
"hook_event_name": "PreToolUse",
|
|
152
|
-
"version": "0.135.0",
|
|
153
153
|
"tool_name": "mcp__github__create_issue",
|
|
154
154
|
"tool_input": {
|
|
155
155
|
"title": "hi"
|
|
@@ -209,11 +209,10 @@
|
|
|
209
209
|
}
|
|
210
210
|
},
|
|
211
211
|
{
|
|
212
|
-
"name": "PreToolUse
|
|
212
|
+
"name": "PreToolUse from a subagent (agent_type present)",
|
|
213
213
|
"call_class": "subagent",
|
|
214
214
|
"native": {
|
|
215
215
|
"hook_event_name": "PreToolUse",
|
|
216
|
-
"version": "0.151.0",
|
|
217
216
|
"session_id": "c-sub",
|
|
218
217
|
"cwd": "/w",
|
|
219
218
|
"permission_mode": "default",
|
|
@@ -307,7 +306,8 @@
|
|
|
307
306
|
"permissionDecision": "deny",
|
|
308
307
|
"permissionDecisionReason": "subagent shell blocked"
|
|
309
308
|
}
|
|
310
|
-
}
|
|
309
|
+
},
|
|
310
|
+
"stderr": "subagent shell blocked"
|
|
311
311
|
}
|
|
312
312
|
},
|
|
313
313
|
"ask": {
|
|
@@ -324,7 +324,8 @@
|
|
|
324
324
|
"permissionDecision": "deny",
|
|
325
325
|
"permissionDecisionReason": "blocked by monitor"
|
|
326
326
|
}
|
|
327
|
-
}
|
|
327
|
+
},
|
|
328
|
+
"stderr": "blocked by monitor"
|
|
328
329
|
}
|
|
329
330
|
}
|
|
330
331
|
}
|
|
@@ -333,7 +334,6 @@
|
|
|
333
334
|
"name": "PermissionRequest round-trips the native event name",
|
|
334
335
|
"native": {
|
|
335
336
|
"hook_event_name": "PermissionRequest",
|
|
336
|
-
"version": "1.0.0",
|
|
337
337
|
"tool_name": "Bash",
|
|
338
338
|
"tool_input": {
|
|
339
339
|
"command": "rm -rf /"
|
|
@@ -372,79 +372,16 @@
|
|
|
372
372
|
"permissionDecision": "deny",
|
|
373
373
|
"permissionDecisionReason": "nope"
|
|
374
374
|
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
},
|
|
380
|
-
{
|
|
381
|
-
"name": "pre-v0.135 downgrades to observe_only (advisory deny)",
|
|
382
|
-
"native": {
|
|
383
|
-
"hook_event_name": "PreToolUse",
|
|
384
|
-
"version": "0.134.9",
|
|
385
|
-
"tool_name": "Bash",
|
|
386
|
-
"tool_input": {
|
|
387
|
-
"command": "curl evil"
|
|
388
|
-
}
|
|
389
|
-
},
|
|
390
|
-
"event": {
|
|
391
|
-
"schema_version": 1,
|
|
392
|
-
"event": "pre_tool",
|
|
393
|
-
"tool": "Bash",
|
|
394
|
-
"input": {
|
|
395
|
-
"command": "curl evil"
|
|
396
|
-
},
|
|
397
|
-
"this_call_vetoable": false,
|
|
398
|
-
"meta": {
|
|
399
|
-
"agent": "codex",
|
|
400
|
-
"native_event": "PreToolUse",
|
|
401
|
-
"native_tool": "Bash",
|
|
402
|
-
"integration_mode": "observe_only",
|
|
403
|
-
"primary_gate_present": true,
|
|
404
|
-
"passthrough": {}
|
|
405
|
-
}
|
|
406
|
-
},
|
|
407
|
-
"render": {
|
|
408
|
-
"allow": {
|
|
409
|
-
"verdict": {
|
|
410
|
-
"decision": "allow"
|
|
411
|
-
},
|
|
412
|
-
"native": {
|
|
413
|
-
"transport": "observe_only",
|
|
414
|
-
"exit_code": 0,
|
|
415
|
-
"enforced": false,
|
|
416
|
-
"stdout": {
|
|
417
|
-
"hookSpecificOutput": {
|
|
418
|
-
"hookEventName": "PreToolUse"
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
},
|
|
423
|
-
"deny": {
|
|
424
|
-
"verdict": {
|
|
425
|
-
"decision": "deny",
|
|
426
|
-
"reason": "advisory only"
|
|
427
|
-
},
|
|
428
|
-
"native": {
|
|
429
|
-
"transport": "observe_only",
|
|
430
|
-
"exit_code": 0,
|
|
431
|
-
"enforced": false,
|
|
432
|
-
"stdout": {
|
|
433
|
-
"hookSpecificOutput": {
|
|
434
|
-
"hookEventName": "PreToolUse",
|
|
435
|
-
"permissionDecision": "deny",
|
|
436
|
-
"permissionDecisionReason": "advisory only"
|
|
437
|
-
}
|
|
438
|
-
}
|
|
375
|
+
},
|
|
376
|
+
"stderr": "nope"
|
|
439
377
|
}
|
|
440
378
|
}
|
|
441
379
|
}
|
|
442
380
|
},
|
|
443
381
|
{
|
|
444
|
-
"name": "PostToolUse
|
|
382
|
+
"name": "PostToolUse blocks on the post-tool schema",
|
|
445
383
|
"native": {
|
|
446
384
|
"hook_event_name": "PostToolUse",
|
|
447
|
-
"version": "0.151.0",
|
|
448
385
|
"session_id": "c2",
|
|
449
386
|
"cwd": "/w",
|
|
450
387
|
"tool_name": "Bash",
|
|
@@ -543,7 +480,8 @@
|
|
|
543
480
|
},
|
|
544
481
|
"decision": "block",
|
|
545
482
|
"reason": "leaked credential"
|
|
546
|
-
}
|
|
483
|
+
},
|
|
484
|
+
"stderr": "leaked credential"
|
|
547
485
|
}
|
|
548
486
|
},
|
|
549
487
|
"deny_no_reason": {
|
|
@@ -560,7 +498,8 @@
|
|
|
560
498
|
},
|
|
561
499
|
"decision": "block",
|
|
562
500
|
"reason": "blocked by monitor"
|
|
563
|
-
}
|
|
501
|
+
},
|
|
502
|
+
"stderr": "blocked by monitor"
|
|
564
503
|
}
|
|
565
504
|
}
|
|
566
505
|
}
|
package/src/index.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
export * from "./control-plane.mjs";
|
|
21
21
|
export { claudeAdapter, HookEvent } from "./adapters/claude.mjs";
|
|
22
|
-
export { codexAdapter } from "./adapters/codex.mjs";
|
|
22
|
+
export { codexAdapter, createCodexAdapter } from "./adapters/codex.mjs";
|
|
23
23
|
export { ampAdapter } from "./adapters/amp.mjs";
|
|
24
24
|
export {
|
|
25
25
|
geminiAdapter,
|
package/src/json-schema.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
SCHEMA_VERSION,
|
|
50
50
|
STANDARD_META_FIELDS,
|
|
51
51
|
VERDICT_CONTENT_FIELDS,
|
|
52
|
+
VETO_IMPOSSIBLE,
|
|
52
53
|
} from "./control-plane.mjs";
|
|
53
54
|
|
|
54
55
|
/** The JSON Schema dialect these documents are written in. */
|
|
@@ -236,42 +237,29 @@ export function toolCallEventSchema() {
|
|
|
236
237
|
meta: { $ref: "#/$defs/EventMeta" },
|
|
237
238
|
},
|
|
238
239
|
additionalProperties: false,
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// transport reads a transcript and cannot pre-empt anything, so a vetoable
|
|
245
|
-
// event on one is the same claim by a different route — every shipped
|
|
246
|
-
// adapter derives both flags from one "can this host enforce" answer, but
|
|
247
|
-
// `makeEvent` does NOT refuse it, so this document is the only place a
|
|
248
|
-
// producer is held to it.
|
|
249
|
-
allOf: [
|
|
250
|
-
vetoImpossibleWhen({ event: { const: EventKind.UNKNOWN } }),
|
|
251
|
-
vetoImpossibleWhen({
|
|
252
|
-
meta: {
|
|
253
|
-
required: ["integration_mode"],
|
|
254
|
-
properties: {
|
|
255
|
-
integration_mode: { const: IntegrationMode.OBSERVE_ONLY },
|
|
256
|
-
},
|
|
257
|
-
},
|
|
258
|
-
}),
|
|
259
|
-
],
|
|
240
|
+
// One rule per VETO_IMPOSSIBLE entry — the same list `makeEvent` enforces.
|
|
241
|
+
allOf: VETO_IMPOSSIBLE.map(({ path, value }) => ({
|
|
242
|
+
if: pathMatch(path, value),
|
|
243
|
+
then: { properties: { this_call_vetoable: { const: false } } },
|
|
244
|
+
})),
|
|
260
245
|
$defs: { EventMeta: eventMetaSchema() },
|
|
261
246
|
};
|
|
262
247
|
}
|
|
263
248
|
|
|
264
249
|
/**
|
|
265
|
-
* A
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* @param {
|
|
250
|
+
* A subschema that holds where the field at `path` equals `value`. Each level
|
|
251
|
+
* names its key in `required`, because an absent key satisfies a bare
|
|
252
|
+
* `properties` and would leave the `then` branch dead.
|
|
253
|
+
* @param {readonly string[]} path
|
|
254
|
+
* @param {string} value
|
|
269
255
|
* @returns {Record<string, unknown>}
|
|
270
256
|
*/
|
|
271
|
-
function
|
|
257
|
+
function pathMatch([key, ...rest], value) {
|
|
272
258
|
return {
|
|
273
|
-
|
|
274
|
-
|
|
259
|
+
required: [key],
|
|
260
|
+
properties: {
|
|
261
|
+
[key]: rest.length === 0 ? { const: value } : pathMatch(rest, value),
|
|
262
|
+
},
|
|
275
263
|
};
|
|
276
264
|
}
|
|
277
265
|
|
package/src/registry.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { lookup } from "./control-plane.mjs";
|
|
18
18
|
import { claudeAdapter } from "./adapters/claude.mjs";
|
|
19
|
-
import { codexAdapter } from "./adapters/codex.mjs";
|
|
19
|
+
import { codexAdapter, createCodexAdapter } from "./adapters/codex.mjs";
|
|
20
20
|
import { ampAdapter } from "./adapters/amp.mjs";
|
|
21
21
|
import { geminiAdapter } from "./adapters/gemini.mjs";
|
|
22
22
|
|
|
@@ -63,19 +63,39 @@ export function assertRegistryConsistent(adapters) {
|
|
|
63
63
|
|
|
64
64
|
assertRegistryConsistent(ADAPTERS);
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The adapters that take launcher options, keyed by agent id. Only Codex does:
|
|
68
|
+
* its hook stdin carries no version, so the launcher pins one.
|
|
69
|
+
* @type {Readonly<Record<string, (options: any) => Adapter>>}
|
|
70
|
+
*/
|
|
71
|
+
const CONFIGURABLE = Object.freeze({
|
|
72
|
+
[codexAdapter.AGENT]: createCodexAdapter,
|
|
73
|
+
});
|
|
74
|
+
|
|
66
75
|
/**
|
|
67
76
|
* Resolve an agent id to its adapter. Throws (fail loud) on an unknown id — a
|
|
68
77
|
* selection signal that names no shipped adapter is a misconfiguration, not a
|
|
69
78
|
* reason to silently fall back to a default agent (which would run the wrong
|
|
70
79
|
* translator against the payloads and mis-render every verdict).
|
|
80
|
+
*
|
|
81
|
+
* `options` are the launcher's settings for that adapter (Codex:
|
|
82
|
+
* `{ version }`). Passing options to an adapter that takes none throws, so a
|
|
83
|
+
* setting the launcher believes it applied cannot be dropped in silence.
|
|
71
84
|
* @param {string} id agent id (a {@link AGENT_IDS} member)
|
|
85
|
+
* @param {Record<string, unknown>} [options]
|
|
72
86
|
* @returns {Adapter}
|
|
73
87
|
*/
|
|
74
|
-
export function adapterFor(id) {
|
|
88
|
+
export function adapterFor(id, options) {
|
|
75
89
|
const adapter = lookup(ADAPTERS, id);
|
|
76
90
|
if (adapter === undefined)
|
|
77
91
|
throw new Error(
|
|
78
92
|
`registry: no adapter for agent id ${JSON.stringify(id)} (known: ${AGENT_IDS.join(", ")})`,
|
|
79
93
|
);
|
|
80
|
-
return adapter;
|
|
94
|
+
if (options === undefined) return adapter;
|
|
95
|
+
const configure = lookup(CONFIGURABLE, id);
|
|
96
|
+
if (configure === undefined)
|
|
97
|
+
throw new Error(
|
|
98
|
+
`registry: adapter ${JSON.stringify(id)} takes no options, got ${JSON.stringify(options)}`,
|
|
99
|
+
);
|
|
100
|
+
return configure(options);
|
|
81
101
|
}
|
package/src/runtime.mjs
CHANGED
|
@@ -56,19 +56,22 @@ export function demoJudge(event) {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
|
|
59
|
-
* can't be parsed or the
|
|
59
|
+
* can't be parsed or the render throws, return `onFailure` — the host's OWN
|
|
60
60
|
* fail-safe response — so a crashed hook degrades the way THAT host expects
|
|
61
61
|
* (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
|
|
62
62
|
* single shared default. This is the deliberate, necessary recovery the whole
|
|
63
63
|
* per-host split exists for, not a blanket swallow.
|
|
64
64
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
65
|
+
* A judge that THROWS is not a malformed payload: the call parsed, and the judge
|
|
66
|
+
* failed to answer. That takes the same path as a judge that answers garbage —
|
|
67
|
+
* the clamp's `ask` (see below) — so a crashing guardrail puts the call in front
|
|
68
|
+
* of a human instead of taking an exit-0 fail-safe that runs it.
|
|
69
|
+
*
|
|
70
|
+
* Every error is written to STDERR (never stdout, which belongs to the host
|
|
71
|
+
* transport) so a regression that silently disables enforcement leaves a
|
|
72
|
+
* greppable trace. Every host tolerates hook stderr: claude/codex surface it
|
|
73
|
+
* only in verbose/debug on a 0 exit, amp forwards the helper's stderr, and
|
|
74
|
+
* gemini reads stderr as a reason only on exit 2.
|
|
72
75
|
* `judge` is the seam a real deployment fills — {@link demoJudge} is a stand-in,
|
|
73
76
|
* so the guardrail is supplied here rather than by forking this file. Whatever
|
|
74
77
|
* it returns is treated as untrusted (see the clamp below).
|
|
@@ -96,23 +99,49 @@ export function renderHookResponse(
|
|
|
96
99
|
`hook payload must be a JSON object, got ${native === null ? "null" : Array.isArray(native) ? "array" : typeof native}`,
|
|
97
100
|
);
|
|
98
101
|
const event = adapter.parse(native);
|
|
102
|
+
/** @type {unknown} */
|
|
103
|
+
let answer;
|
|
104
|
+
try {
|
|
105
|
+
answer = judge(event);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// No `decision`, so the clamp below turns this into an `ask`.
|
|
108
|
+
answer = { reason: `hook judge threw: ${describe(err).split("\n")[0]}` };
|
|
109
|
+
report("hook judge error", err);
|
|
110
|
+
}
|
|
99
111
|
// A judge's answer is clamped, not trusted. `normalizeVerdict` (inside
|
|
100
112
|
// every adapter's render) THROWS on a decision outside allow/deny/ask, and
|
|
101
|
-
// that throw
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
const verdict = sanitizeVerdict(judge(event), (text) => text);
|
|
113
|
+
// that throw would land in the catch below — the host's fail-safe, exit 0
|
|
114
|
+
// on claude/codex/gemini. So a judge that means DENY but spells it "denied"
|
|
115
|
+
// would let the tool run. Clamping to "ask" first keeps a malformed answer
|
|
116
|
+
// in front of a human. Prose is passed through: scrubbing monitor-authored
|
|
117
|
+
// text needs a scrubber this transport module has no business choosing.
|
|
118
|
+
const verdict = sanitizeVerdict(answer, (text) => text);
|
|
108
119
|
return adapter.render(verdict, event);
|
|
109
120
|
} catch (err) {
|
|
110
|
-
|
|
111
|
-
process.stderr.write(`[acp] hook pipeline error: ${detail}\n`);
|
|
121
|
+
report("hook pipeline error", err);
|
|
112
122
|
return onFailure;
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
|
|
126
|
+
/**
|
|
127
|
+
* A thrown value as readable text: an Error's stack (or message when it has no
|
|
128
|
+
* stack), or the value itself for a bare throw.
|
|
129
|
+
* @param {unknown} err
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function describe(err) {
|
|
133
|
+
return err instanceof Error ? (err.stack ?? err.message) : `${err}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Write one diagnostic line for `err` to stderr.
|
|
138
|
+
* @param {string} label
|
|
139
|
+
* @param {unknown} err
|
|
140
|
+
*/
|
|
141
|
+
function report(label, err) {
|
|
142
|
+
process.stderr.write(`[acp] ${label}: ${describe(err)}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
116
145
|
/**
|
|
117
146
|
* A one-millisecond synchronous sleep buffer for the EAGAIN retry in
|
|
118
147
|
* {@link writeAllSync}. Allocated once at module scope: the retry path must not
|
|
@@ -4,16 +4,12 @@
|
|
|
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
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* a
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
7
|
+
* WHERE THE VERSION COMES FROM: Codex's PreToolUse stdin carries no `version`
|
|
8
|
+
* field, so the launcher that pins the Codex binary supplies it, through
|
|
9
|
+
* {@link createCodexAdapter}. A payload `version` is a fallback only, and the
|
|
10
|
+
* payload is untrusted: one that under-reports downgrades a deny to advisory. Coercing an absent version to enforcing HERE would
|
|
11
|
+
* break the legitimate old-Codex case (an exit-2 block a pre-0.135 Codex
|
|
12
|
+
* ignores, dishonestly marked `enforced`), so an unknown version stays advisory.
|
|
17
13
|
* @param {unknown} version
|
|
18
14
|
* @returns {boolean}
|
|
19
15
|
*/
|
|
@@ -22,10 +18,18 @@ export function canEnforce(version: unknown): boolean;
|
|
|
22
18
|
* Parse a raw Codex hook payload. Never throws on unmodelled input. Marks the
|
|
23
19
|
* event observe-only (this_call_vetoable=false) when the Codex version
|
|
24
20
|
* predates hook enforcement.
|
|
21
|
+
*
|
|
22
|
+
* `version` is the Codex version the LAUNCHER pinned, and it wins. Codex's
|
|
23
|
+
* hook stdin documents no version field, so a payload `version` is read only
|
|
24
|
+
* when no launcher version was given: a wrapper that injects one into stdin
|
|
25
|
+
* keeps working. With neither, the event is advisory.
|
|
25
26
|
* @param {any} native
|
|
27
|
+
* @param {{ version?: string }} [options]
|
|
26
28
|
* @returns {ToolCallEvent}
|
|
27
29
|
*/
|
|
28
|
-
export function parse(native: any
|
|
30
|
+
export function parse(native: any, { version }?: {
|
|
31
|
+
version?: string;
|
|
32
|
+
}): ToolCallEvent;
|
|
29
33
|
/**
|
|
30
34
|
* Render into Codex's native external-hook transport: a JSON body preserving the
|
|
31
35
|
* native event name (`PreToolUse`/`PermissionRequest`/`PostToolUse`) plus exit 2
|
|
@@ -49,6 +53,18 @@ export function parse(native: any): ToolCallEvent;
|
|
|
49
53
|
export function render(verdict: Verdict, event: ToolCallEvent, { soleGate }?: {
|
|
50
54
|
soleGate?: boolean;
|
|
51
55
|
}): NativeResponse;
|
|
56
|
+
/**
|
|
57
|
+
* Build a Codex adapter whose `parse` judges every payload against `version`,
|
|
58
|
+
* the Codex version the launcher pinned (Codex's hook stdin carries none).
|
|
59
|
+
* Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
|
|
60
|
+
* deny renders advisory. With no `version`, `parse` falls back to a payload
|
|
61
|
+
* `version` (see {@link parse}).
|
|
62
|
+
* @param {{ version?: string }} [options]
|
|
63
|
+
* @returns {import("../control-plane.mjs").Adapter}
|
|
64
|
+
*/
|
|
65
|
+
export function createCodexAdapter({ version }?: {
|
|
66
|
+
version?: string;
|
|
67
|
+
}): import("../control-plane.mjs").Adapter;
|
|
52
68
|
/** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
|
|
53
69
|
/** @typedef {import("../control-plane.mjs").Verdict} Verdict */
|
|
54
70
|
/** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
|
|
@@ -124,8 +140,12 @@ export const NATIVE_EVENT_FOR: Record<string, string | undefined>;
|
|
|
124
140
|
* naming a key the host discards.
|
|
125
141
|
*/
|
|
126
142
|
export const NATIVE_ASK_TIER: false;
|
|
127
|
-
|
|
128
|
-
|
|
143
|
+
/**
|
|
144
|
+
* The Codex adapter with no launcher-pinned version. Real Codex sends no
|
|
145
|
+
* version, so every event it sends parses observe-only here. A deployment that
|
|
146
|
+
* runs Codex ≥ v0.135 builds its own with {@link createCodexAdapter}.
|
|
147
|
+
* @type {import("../control-plane.mjs").Adapter}
|
|
148
|
+
*/
|
|
129
149
|
export const codexAdapter: import("../control-plane.mjs").Adapter;
|
|
130
150
|
export type ToolCallEvent = import("../control-plane.mjs").ToolCallEvent;
|
|
131
151
|
export type Verdict = import("../control-plane.mjs").Verdict;
|