@sema-agent/core 5.55.0 → 5.56.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/send-message-tool.js +48 -2
  3. package/dist/agents/subagent.js +250 -89
  4. package/dist/core/auto-compaction.d.ts +17 -4
  5. package/dist/core/auto-compaction.js +3 -0
  6. package/dist/core/context-edit.d.ts +55 -6
  7. package/dist/core/context-edit.js +12 -1
  8. package/dist/core/hooks.d.ts +293 -11
  9. package/dist/core/hooks.js +158 -11
  10. package/dist/core/human-input-projection.d.ts +20 -2
  11. package/dist/core/human-input-projection.js +9 -0
  12. package/dist/core/permission-rules.d.ts +23 -15
  13. package/dist/core/permission-rules.js +40 -31
  14. package/dist/core/runner/prepare-task.d.ts +8 -0
  15. package/dist/core/runner/prepare-task.js +34 -23
  16. package/dist/core/runner/runtask.js +158 -21
  17. package/dist/core/runner/session-rule-policy.js +5 -5
  18. package/dist/core/session-reconcile.d.ts +32 -0
  19. package/dist/core/session-reconcile.js +15 -0
  20. package/dist/core/task-notification.d.ts +34 -7
  21. package/dist/core/task-notification.js +11 -1
  22. package/dist/core/task-registry-agent.d.ts +20 -3
  23. package/dist/core/task-registry-agent.js +31 -2
  24. package/dist/core/tool-policy.d.ts +14 -9
  25. package/dist/core/tool-policy.js +27 -22
  26. package/dist/core/types.d.ts +37 -11
  27. package/dist/core/untrusted-text.js +8 -0
  28. package/dist/engine/compaction/compaction.d.ts +77 -7
  29. package/dist/engine/compaction/compaction.js +98 -9
  30. package/dist/engine/compaction/utils.d.ts +4 -0
  31. package/dist/engine/compaction/utils.js +6 -0
  32. package/dist/engine/harness/agent-harness.d.ts +84 -0
  33. package/dist/engine/harness/agent-harness.js +88 -12
  34. package/dist/engine/harness/messages.d.ts +4 -2
  35. package/dist/engine/harness/messages.js +7 -2
  36. package/dist/engine/harness/types.d.ts +11 -5
  37. package/dist/engine/loop/types.d.ts +7 -0
  38. package/dist/engine/session/import-validate.js +10 -0
  39. package/dist/engine/session/session.js +2 -2
  40. package/dist/orchestration/run-spec.js +8 -1
  41. package/dist/prompts/default.d.ts +10 -4
  42. package/package.json +1 -1
@@ -6,6 +6,92 @@ import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
6
6
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
7
7
  import { createSafeNotifier } from "./safe-notify.js";
8
8
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
9
+ export const DEFAULT_HOOK_TIMEOUT_MS = 600_000;
10
+ const MAX_HOOK_TIMEOUT_MS = 2_147_483_647;
11
+ const POST_ABORT_GRACE_MS = 1_000;
12
+ export function postAbortGraceMs(remainingMs) {
13
+ return Math.max(0, Math.min(POST_ABORT_GRACE_MS, remainingMs));
14
+ }
15
+ const badHookTimeoutReported = new WeakSet();
16
+ export function resolveHookTimeoutMs(supplied, report, owner) {
17
+ if (supplied === undefined)
18
+ return DEFAULT_HOOK_TIMEOUT_MS;
19
+ if (Number.isFinite(supplied) && supplied >= 0 && supplied <= MAX_HOOK_TIMEOUT_MS)
20
+ return supplied;
21
+ if (report !== undefined && !(owner !== undefined && badHookTimeoutReported.has(owner))) {
22
+ if (owner !== undefined)
23
+ badHookTimeoutReported.add(owner);
24
+ try {
25
+ report(new Error(`Hooks.timeoutMs must be a non-negative finite number no greater than ${MAX_HOOK_TIMEOUT_MS} (got ${String(supplied)}) — ` +
26
+ `every hook seat falls back to the ${DEFAULT_HOOK_TIMEOUT_MS}ms default`));
27
+ }
28
+ catch {
29
+ }
30
+ }
31
+ return DEFAULT_HOOK_TIMEOUT_MS;
32
+ }
33
+ export async function runHookSeat(seat, bound, call) {
34
+ void seat;
35
+ const timeoutMs = resolveHookTimeoutMs(bound.timeoutMs, bound.onBadTimeout, bound.owner);
36
+ const taskSignal = bound.signal;
37
+ const controller = new AbortController();
38
+ const pending = Promise.resolve(call(controller.signal));
39
+ const startedAt = performance.now();
40
+ return await new Promise((resolve, reject) => {
41
+ let settled = false;
42
+ let listening = false;
43
+ let timer;
44
+ const clearTimer = () => {
45
+ if (timer !== undefined)
46
+ clearTimeout(timer);
47
+ timer = undefined;
48
+ };
49
+ const finish = (act) => {
50
+ if (settled)
51
+ return;
52
+ settled = true;
53
+ clearTimer();
54
+ if (listening)
55
+ taskSignal?.removeEventListener("abort", onAbort);
56
+ act();
57
+ };
58
+ const expire = (cause) => finish(() => {
59
+ void pending.then(undefined, () => {
60
+ });
61
+ resolve({ expired: true, cause });
62
+ queueMicrotask(() => {
63
+ try {
64
+ controller.abort();
65
+ }
66
+ catch {
67
+ }
68
+ });
69
+ });
70
+ const collapseToGrace = () => {
71
+ if (settled)
72
+ return;
73
+ clearTimer();
74
+ timer = setTimeout(() => expire("aborted"), postAbortGraceMs(timeoutMs - (performance.now() - startedAt)));
75
+ };
76
+ const onAbort = () => (bound.abortEnds === true ? expire("aborted") : collapseToGrace());
77
+ timer = setTimeout(() => expire("timeout"), timeoutMs);
78
+ if (taskSignal !== undefined) {
79
+ if (taskSignal.aborted) {
80
+ onAbort();
81
+ }
82
+ else {
83
+ taskSignal.addEventListener("abort", onAbort, { once: true });
84
+ listening = true;
85
+ }
86
+ }
87
+ pending.then((value) => finish(() => resolve({ expired: false, value })), (err) => finish(() => reject(err instanceof Error ? err : new Error(String(err)))));
88
+ });
89
+ }
90
+ export function hookSeatExpiredError(seat, timeoutMs, cause, consequence) {
91
+ return new Error(cause === "timeout"
92
+ ? `the deployment's ${seat} hook did not answer within its ${timeoutMs}ms bound (Hooks.timeoutMs) — ${consequence}`
93
+ : `the deployment's ${seat} hook had not answered when the task was cancelled — ${consequence}`);
94
+ }
9
95
  export function cloneObserverInput(input) {
10
96
  try {
11
97
  return structuredClone(input);
@@ -202,17 +288,35 @@ function preToolUseCrashReason(subject, err) {
202
288
  `This is a failure of the deployment's hook, NOT of the tool itself — an identical retry reaches the ` +
203
289
  `same hook and fails the same way. Hook error: ${cause || "(no message)"}`);
204
290
  }
291
+ function preToolUseUnansweredReason(subject, timeoutMs, cause) {
292
+ return cause === "timeout"
293
+ ? `a PreToolUse hook did not answer within its ${timeoutMs}ms bound while screening ${subject}; the call was ` +
294
+ `NOT executed (fail-closed). This is a failure of the deployment's hook, NOT of the tool itself — the ` +
295
+ `screening face never returned a verdict, so nothing was decided about this call either way.`
296
+ : `the task was cancelled while a PreToolUse hook was still screening ${subject}; the call was NOT executed ` +
297
+ `(fail-closed). No verdict was reached — this is the run ending, not the tool failing.`;
298
+ }
205
299
  function screenPreToolUseResult(r) {
206
300
  if (r === undefined)
207
301
  return undefined;
208
302
  return refuseOutOfContractDecision(r, { reasonIsNonInput: true });
209
303
  }
210
- export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
304
+ export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash, timeoutMs) {
211
305
  return brandPolicyAskClass({
212
306
  check: async (req) => {
213
307
  let r;
308
+ const boundMs = resolveHookTimeoutMs(timeoutMs, onCrash);
214
309
  try {
215
- r = screenPreToolUseResult(await preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, ...(env !== undefined ? { env } : {}) }));
310
+ const seat = await runHookSeat("preToolUse", { timeoutMs: boundMs }, (sig) => preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, signal: sig, ...(env !== undefined ? { env } : {}) }));
311
+ if (seat.expired) {
312
+ try {
313
+ onCrash?.(hookSeatExpiredError("preToolUse", boundMs, seat.cause, `the inherited screening of "${req.toolName}" was refused fail-closed`));
314
+ }
315
+ catch {
316
+ }
317
+ return { action: "deny", message: preToolUseUnansweredReason(`this call to "${req.toolName}"`, boundMs, seat.cause), decisionReason: "hook" };
318
+ }
319
+ r = screenPreToolUseResult(seat.value);
216
320
  }
217
321
  catch (err) {
218
322
  try {
@@ -284,9 +388,14 @@ export function persistedRuleMandateOf(marks) {
284
388
  export async function runToolGate(input) {
285
389
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
286
390
  const { toolCallId, toolName } = event;
287
- const hookCtx = () => ({
391
+ const hookCtx = (seatSignal) => ({
288
392
  toolCallId,
289
393
  toolName,
394
+ ...(seatSignal !== undefined ? { signal: seatSignal } : {}),
395
+ ...(() => {
396
+ const c = input.trackedCwd?.();
397
+ return typeof c === "string" ? { cwd: c } : {};
398
+ })(),
290
399
  ...(input.hookEnv !== undefined ? { env: input.hookEnv } : {}),
291
400
  ...(input.identity !== undefined ? { identity: input.identity } : {}),
292
401
  });
@@ -296,17 +405,47 @@ export async function runToolGate(input) {
296
405
  let parkFailed;
297
406
  let askDenyResolution;
298
407
  const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined);
408
+ const hookSeatMs = resolveHookTimeoutMs(input.hookTimeoutMs, (err) => traceHookCrash(input, err, notifier));
409
+ const seatBound = (abortEnds) => ({
410
+ timeoutMs: hookSeatMs,
411
+ ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}),
412
+ ...(abortEnds ? { abortEnds: true } : {}),
413
+ });
414
+ const preToolUseUnansweredBlock = async (subject, cause) => {
415
+ const reason = preToolUseUnansweredReason(subject, hookSeatMs, cause);
416
+ if (cause === "timeout") {
417
+ traceHookCrash(input, hookSeatExpiredError("preToolUse", hookSeatMs, cause, `${subject} was NOT executed (fail-closed) and the screening face was signalled to stop`), notifier);
418
+ }
419
+ return reason;
420
+ };
421
+ const notifyPermissionDeniedSeat = async (payload) => {
422
+ if (input.permissionDenied === undefined)
423
+ return;
424
+ await notifier.notifyAsync(async () => {
425
+ const seat = await runHookSeat("permissionDenied", seatBound(false), (sig) => input.permissionDenied?.({ ...payload, signal: sig }));
426
+ if (seat.expired)
427
+ throw hookSeatExpiredError("permissionDenied", hookSeatMs, seat.cause, "the deny observation was abandoned; the deny itself is unchanged");
428
+ }, "toolGate.permissionDenied");
429
+ };
299
430
  if (preToolUse) {
300
431
  let r;
432
+ let unanswered;
301
433
  try {
302
- r = screenPreToolUseResult(await preToolUse(toolName, currentInput, hookCtx()));
434
+ const seat = await runHookSeat("preToolUse", seatBound(true), (sig) => preToolUse(toolName, currentInput, hookCtx(sig)));
435
+ if (seat.expired)
436
+ unanswered = seat.cause;
437
+ else
438
+ r = screenPreToolUseResult(seat.value);
303
439
  }
304
440
  catch (err) {
305
441
  const reason = preToolUseCrashReason(`this call to "${toolName}"`, err);
306
442
  traceHookCrash(input, err, notifier);
307
- if (input.permissionDenied) {
308
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
309
- }
443
+ await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) });
444
+ return { block: true, reason: formatHookFeedback(reason, input.reminderMark), preToolContext };
445
+ }
446
+ if (unanswered !== undefined) {
447
+ const reason = await preToolUseUnansweredBlock(`this call to "${toolName}"`, unanswered);
448
+ await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) });
310
449
  return { block: true, reason: formatHookFeedback(reason, input.reminderMark), preToolContext };
311
450
  }
312
451
  if (r) {
@@ -714,8 +853,13 @@ export async function runToolGate(input) {
714
853
  }
715
854
  if (preToolUse) {
716
855
  let hr;
856
+ let hrUnanswered;
717
857
  try {
718
- hr = screenPreToolUseResult(await preToolUse(toolName, editArgs, hookCtx()));
858
+ const seat = await runHookSeat("preToolUse", seatBound(true), (sig) => preToolUse(toolName, editArgs, hookCtx(sig)));
859
+ if (seat.expired)
860
+ hrUnanswered = seat.cause;
861
+ else
862
+ hr = screenPreToolUseResult(seat.value);
719
863
  }
720
864
  catch (err) {
721
865
  traceHookCrash(input, err, notifier);
@@ -723,6 +867,11 @@ export async function runToolGate(input) {
723
867
  denySource = "hook";
724
868
  break;
725
869
  }
870
+ if (hrUnanswered !== undefined) {
871
+ editDenied = { action: "deny", message: await preToolUseUnansweredBlock(`the approved edit for "${toolName}"`, hrUnanswered) };
872
+ denySource = "hook";
873
+ break;
874
+ }
726
875
  if (hr) {
727
876
  if (hr.additionalContext)
728
877
  preToolContext.push(hr.additionalContext);
@@ -828,9 +977,7 @@ export async function runToolGate(input) {
828
977
  currentInput = decision.updatedInput;
829
978
  }
830
979
  const denyResolution = askDenyResolution ?? coreMintedResolutionOf(decision, { toolCallId, toolName });
831
- if (input.permissionDenied) {
832
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
833
- }
980
+ await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) });
834
981
  const denySettledBy = decision.settledBy;
835
982
  const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
836
983
  return {
@@ -4,8 +4,13 @@
4
4
  * One renderer covers every human-input carrier: the core-side five (objective / live steer /
5
5
  * nextTurn / the resume tail's parked-steer frames / the wake message) and a serving layer's own
6
6
  * carriers through the same contract. The division of labor is fixed (ruled 2026-08-05): the INGRESS
7
- * sanitizes text and mints the {@link ActorAssertion}; this module only PROJECTS — it renders the
8
- * speaker label ahead of the text, and it never sanitizes, truncates, or rewrites the text itself.
7
+ * sanitizes text and mints the {@link ActorAssertion}; `projectHumanInput` only PROJECTS — it renders
8
+ * the speaker label ahead of the text, and it never sanitizes, truncates, or rewrites the text itself.
9
+ *
10
+ * The module also owns the DELIVERY frame that tells the model where a human frame arrived
11
+ * ({@link frameMidTurnUserInput}, backlog #389). That is a separate concern from attribution and runs
12
+ * AFTER projection (the speaker label belongs to the text; the delivery frame belongs around it) — it is
13
+ * housed here so every model-facing wrapper for human input has one home.
9
14
  *
10
15
  * Envelope contract:
11
16
  * - no `actor` ⇒ BYTE-IDENTICAL passthrough. Every pre-171 caller and every single-user host
@@ -35,6 +40,19 @@ export interface HumanInputFrame {
35
40
  actor?: ActorAssertion;
36
41
  source: HumanInputSource;
37
42
  }
43
+ /**
44
+ * backlog #389 伴生 (V-3 of the steer/interrupt/lifecycle anchor audit) — wrap ONE live caller steer in
45
+ * the mid-turn delivery frame.
46
+ *
47
+ * Scope, deliberately: the UNTRUSTED caller lane only. A `trusted` steer already rides a
48
+ * `<system-reminder>` (supervisor authority, CC's own non-user-source framing), and a delegated child's
49
+ * operator steer carries its own explicit operator frame — the bare lane was exactly the most-travelled
50
+ * one: a person typing while the run works.
51
+ *
52
+ * Static strings (no per-call interpolation), and a blank projection is returned VERBATIM so the
53
+ * harness's empty-injection no-op stays a no-op — a frame must never materialize from nothing.
54
+ */
55
+ export declare function frameMidTurnUserInput(projected: string): string;
38
56
  /**
39
57
  * Render the speaker envelope for one human-input frame. Pure; see the module contract above.
40
58
  * Call BEFORE any trust framing (`formatHookFeedback` / `delimitUntrusted`) so the label stays
@@ -2,6 +2,15 @@ import { snapshotActorAssertion } from "../internal/llm.js";
2
2
  import { uuidv7 } from "../internal/harness.js";
3
3
  import { inlineUntrusted } from "./untrusted-text.js";
4
4
  import { attrEscape, EXTERNAL_SOURCE_MAX } from "./task-notification.js";
5
+ const MID_TURN_USER_INPUT_HEAD = "The user sent a new message while you were working:\n";
6
+ const MID_TURN_USER_INPUT_TAIL = "\n\nThis is how a message sent mid-turn is surfaced: within the turn that is still running, often " +
7
+ "alongside the next tool result, rather than as a separate conversation turn. Address the message " +
8
+ "above as you continue this turn.";
9
+ export function frameMidTurnUserInput(projected) {
10
+ if (projected.trim().length === 0)
11
+ return projected;
12
+ return `${MID_TURN_USER_INPUT_HEAD}${projected}${MID_TURN_USER_INPUT_TAIL}`;
13
+ }
5
14
  export function projectHumanInput(frame) {
6
15
  if (frame.actor === undefined || frame.source === "system")
7
16
  return frame.text;
@@ -46,6 +46,13 @@ export interface PermissionRuleIssue {
46
46
  /** design/179 D7: a Bash prefix rule under `bashPrefixLane:"rules-store"` — reported so a migration
47
47
  * report can tell "route this through the rule store" apart from "this is a mistake". */
48
48
  | "unsupported.bash_prefix_rules_store" | "unsupported.file_glob" | "unsupported.mcp_paren"
49
+ /** A parenthesised rule whose NAME is a covering spelling in a non-MCP protocol namespace
50
+ * (`a2a__<peer>__*(x:y)`, `a2a__<peer>(x:y)`). The param DSL is keyed by EXACT tool name, and a
51
+ * covering spelling is not one — no mounted tool can ever bear it, so the rule compiled clean and
52
+ * matched nothing. Additive member; the MCP spelling keeps its own {@link
53
+ * PermissionRuleIssue.code} `unsupported.mcp_paren` (that refusal is wider — it covers exact MCP
54
+ * names too, for CC parity — and fires first, so no MCP rule's reported code changes). */
55
+ | "unsupported.covering_paren"
49
56
  /** HRD-PRM-7: a param-level rule naming a parameter the target tool does not carry. Additive member —
50
57
  * the lane that used to compile silently and never match (see {@link FILE_TOOL_PARAMS}). */
51
58
  | "unsupported.unknown_param";
@@ -105,27 +112,28 @@ export declare function parsePermissionRule(rule: string): ParsedPermissionRule;
105
112
  */
106
113
  export declare function wildcardMatch(pattern: string, value: string): boolean;
107
114
  /**
108
- * Does an MCP-namespaced RULE name cover `toolName`? — the tail branch of CC's rule matcher `VRp`
115
+ * Does a namespaced RULE name cover `toolName`? — the tail branch of CC's rule matcher `VRp`
109
116
  * (:595780), which is UNCONDITIONAL there (its `globMatching` option gates only the whole-name glob
110
117
  * branch, so CC's allow lane — `mVo` :595803, which passes no options — matches MCP segments too).
111
- * Three covering forms, matching CC's three disjuncts:
112
- * · `mcp__<server>` — every tool of that server (rule tool segment absent);
113
- * · `mcp__<server>__*` — the same set, spelled with the star CC's own settings validator
114
- * recommends (`"MCP rules do not support patterns in parentheses …
115
- * use mcp__srv__*"`);
116
- * · `mcp__<server>__get_*` — a glob over the tool segment.
118
+ * Three covering forms, matching CC's three disjuncts (`<ns>` = any protocol-table prefix):
119
+ * · `<ns>__<peer>` — every tool of that peer (rule tool segment absent);
120
+ * · `<ns>__<peer>__*` — the same set, spelled with the star CC's own settings validator
121
+ * recommends (`"MCP rules do not support patterns in parentheses …
122
+ * use mcp__srv__*"`);
123
+ * · `<ns>__<peer>__get_*` — a glob over the tool segment.
117
124
  * A LITERAL tool segment is deliberately NOT covered here: it is an exact tool name, and the exact
118
125
  * table already answers it — the same split CC makes with its leading `ruleName === toolName`.
119
126
  *
120
- * Server segments compare EXACTLY (CC `s.serverName === a.serverName`). A rule must therefore spell
121
- * the server the way the mint does; that is the same string an operator reads off any mounted tool
122
- * name, and inventing a fuzzier comparison here would let one rule reach a server it does not name.
127
+ * Rule and target must be in the SAME namespace, and peer segments compare EXACTLY (CC
128
+ * `s.serverName === a.serverName`). A rule must therefore spell the peer the way the mint does; that
129
+ * is the same string an operator reads off any mounted tool name, and inventing a fuzzier comparison
130
+ * here would let one rule reach a peer — or a protocol — it does not name.
123
131
  */
124
- export declare function mcpRuleNameCovers(ruleName: string, toolName: string): boolean;
125
- /** True ⇔ this rule name is an MCP name whose reach is a SET of tools rather than one exact tool
126
- * i.e. the shapes {@link mcpRuleNameCovers} answers and an exact name table cannot. Every lane that
127
- * keys rules by exact tool name needs this to know which of its entries it must NOT key that way. */
128
- export declare function isMcpCoveringRuleName(name: string): boolean;
132
+ export declare function namespacedRuleNameCovers(ruleName: string, toolName: string): boolean;
133
+ /** True ⇔ this rule name is a namespaced name whose reach is a SET of tools rather than one exact tool
134
+ * i.e. the shapes {@link namespacedRuleNameCovers} answers and an exact name table cannot. Every
135
+ * lane that keys rules by exact tool name needs this to know which entries it must NOT key that way. */
136
+ export declare function isNamespacedCoveringRuleName(name: string): boolean;
129
137
  /** Exported for the lockstep guard only (see {@link FILE_TOOL_PARAMS}) — not part of the rule DSL. */
130
138
  export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
131
139
  /** Dry-run 校验/迁移报告(codex 127 审 B5):不 throw,返回全部 issues(含 `unsupported.*` 分类,
@@ -1,4 +1,4 @@
1
- import { MCP_NAMESPACE } from "./protocol-table.js";
1
+ import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
2
2
  export const BASH_GENERIC_PARAMS = new Set(["command", "timeout", "description", "run_in_background"]);
3
3
  const DEFAULT_CAPS = {
4
4
  maxRules: 256,
@@ -77,31 +77,34 @@ export function wildcardMatch(pattern, value) {
77
77
  p++;
78
78
  return p === pattern.length;
79
79
  }
80
- function parseMcpRuleName(name) {
81
- if (!name.startsWith(MCP_NAMESPACE.prefix))
80
+ function parseNamespacedRuleName(name) {
81
+ const ns = protocolOf(name);
82
+ if (ns === undefined)
82
83
  return undefined;
83
- const segments = name.slice(MCP_NAMESPACE.prefix.length).split("__");
84
- const server = segments[0];
85
- if (server === undefined || server === "")
84
+ const segments = name.slice(ns.prefix.length).split("__");
85
+ const peer = segments[0];
86
+ if (peer === undefined || peer === "")
86
87
  return undefined;
87
88
  const rest = segments.slice(1);
88
- return rest.length > 0 ? { server, tool: rest.join("__") } : { server };
89
+ return rest.length > 0 ? { protocol: ns.id, peer, tool: rest.join("__") } : { protocol: ns.id, peer };
89
90
  }
90
- export function mcpRuleNameCovers(ruleName, toolName) {
91
- const rule = parseMcpRuleName(ruleName);
91
+ export function namespacedRuleNameCovers(ruleName, toolName) {
92
+ const rule = parseNamespacedRuleName(ruleName);
92
93
  if (rule === undefined)
93
94
  return false;
94
- const target = parseMcpRuleName(toolName);
95
+ const target = parseNamespacedRuleName(toolName);
95
96
  if (target === undefined)
96
97
  return false;
97
- if (rule.server !== target.server)
98
+ if (rule.protocol !== target.protocol)
99
+ return false;
100
+ if (rule.peer !== target.peer)
98
101
  return false;
99
102
  if (rule.tool === undefined || rule.tool === "*")
100
103
  return true;
101
104
  return target.tool !== undefined && rule.tool.includes("*") && wildcardMatch(rule.tool, target.tool);
102
105
  }
103
- export function isMcpCoveringRuleName(name) {
104
- const parsed = parseMcpRuleName(name);
106
+ export function isNamespacedCoveringRuleName(name) {
107
+ const parsed = parseNamespacedRuleName(name);
105
108
  return parsed !== undefined && (parsed.tool === undefined || parsed.tool.includes("*"));
106
109
  }
107
110
  const PRIMARY_FIELDS = {
@@ -138,13 +141,13 @@ function countStars(s) {
138
141
  }
139
142
  function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
140
143
  const byTool = new Map();
141
- const mcpCovering = { deny: [], ask: [], allow: [] };
144
+ const namespacedCovering = { deny: [], ask: [], allow: [] };
142
145
  const issues = [];
143
146
  const bad = (rule, code, message) => {
144
147
  issues.push({ rule, code, message });
145
148
  };
146
149
  if (rules.length > caps.maxRules) {
147
- return { byTool, mcpCovering, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] };
150
+ return { byTool, namespacedCovering, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] };
148
151
  }
149
152
  for (const r of rules) {
150
153
  const text = r.rule;
@@ -171,14 +174,20 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
171
174
  }
172
175
  }
173
176
  const toolName = parsed.toolName;
174
- if (toolName.startsWith(MCP_NAMESPACE.prefix) && (parsed.ruleContent !== undefined || indexOfUnescaped(text, "(") !== -1)) {
177
+ const parenthesised = parsed.ruleContent !== undefined || indexOfUnescaped(text, "(") !== -1;
178
+ if (toolName.startsWith(MCP_NAMESPACE.prefix) && parenthesised) {
175
179
  bad(text, "unsupported.mcp_paren", "MCP rules do not support patterns in parentheses (CC parity); use the toolAxes system");
176
180
  continue;
177
181
  }
182
+ if (parenthesised && isNamespacedCoveringRuleName(toolName)) {
183
+ bad(text, "unsupported.covering_paren", `"${toolName}" reaches a SET of tools, and the generic param DSL is keyed by exact tool name — a parenthesised rule on it can never match. ` +
184
+ `Write the parenthesised rule against one exact tool name, or drop the parentheses to keep the covering rule.`);
185
+ continue;
186
+ }
178
187
  const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] } };
179
188
  if (parsed.ruleContent === undefined) {
180
- if (isMcpCoveringRuleName(toolName)) {
181
- mcpCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
189
+ if (isNamespacedCoveringRuleName(toolName)) {
190
+ namespacedCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
182
191
  continue;
183
192
  }
184
193
  entry.bare[r.behavior] = { ruleText: text, source: r.source };
@@ -238,7 +247,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
238
247
  entry.param[r.behavior].push({ param, pattern, behavior: r.behavior, ruleText: text, source: r.source });
239
248
  byTool.set(toolName, entry);
240
249
  }
241
- return { byTool, mcpCovering, issues };
250
+ return { byTool, namespacedCovering, issues };
242
251
  }
243
252
  export function validatePermissionRules(rules, opts) {
244
253
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
@@ -272,7 +281,7 @@ function ruleMessage(kind, ruleText, source) {
272
281
  }
273
282
  export function createPermissionRulePolicy(rules, opts) {
274
283
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
275
- const { byTool, mcpCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
284
+ const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
276
285
  const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === "");
277
286
  if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) {
278
287
  const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` +
@@ -307,23 +316,23 @@ export function createPermissionRulePolicy(rules, opts) {
307
316
  nameSets.allow.push(toolName);
308
317
  }
309
318
  for (const behavior of ["deny", "ask", "allow"]) {
310
- for (const r of mcpCovering[behavior])
319
+ for (const r of namespacedCovering[behavior])
311
320
  nameSets[behavior].push(r.ruleName);
312
321
  }
313
- const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => mcpRuleNameCovers(r.ruleName, toolName));
314
- const hasCovering = mcpCovering.deny.length > 0 || mcpCovering.ask.length > 0 || mcpCovering.allow.length > 0;
322
+ const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
323
+ const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
315
324
  return {
316
325
  nameSets: [nameSets],
317
326
  check(req) {
318
327
  const entry = byTool.get(req.toolName);
319
- const covering = hasCovering && req.toolName.startsWith(MCP_NAMESPACE.prefix);
328
+ const covering = hasCovering && protocolOf(req.toolName) !== undefined;
320
329
  if (entry?.bare.deny) {
321
330
  return { action: "deny", message: ruleMessage("denied", entry.bare.deny.ruleText, entry.bare.deny.source) };
322
331
  }
323
332
  if (covering) {
324
- const mcpDeny = coveringHit(mcpCovering.deny, req.toolName);
325
- if (mcpDeny)
326
- return { action: "deny", message: ruleMessage("denied", mcpDeny.ruleText, mcpDeny.source) };
333
+ const coveringDeny = coveringHit(namespacedCovering.deny, req.toolName);
334
+ if (coveringDeny)
335
+ return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
327
336
  }
328
337
  if (entry) {
329
338
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
@@ -335,9 +344,9 @@ export function createPermissionRulePolicy(rules, opts) {
335
344
  }
336
345
  }
337
346
  if (covering) {
338
- const mcpAsk = coveringHit(mcpCovering.ask, req.toolName);
339
- if (mcpAsk)
340
- return { action: "ask", message: ruleMessage("flagged", mcpAsk.ruleText, mcpAsk.source), matchedAskRule: mcpAsk.ruleText };
347
+ const coveringAsk = coveringHit(namespacedCovering.ask, req.toolName);
348
+ if (coveringAsk)
349
+ return { action: "ask", message: ruleMessage("flagged", coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
341
350
  }
342
351
  if (entry) {
343
352
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
@@ -348,7 +357,7 @@ export function createPermissionRulePolicy(rules, opts) {
348
357
  return { action: "allow" };
349
358
  }
350
359
  }
351
- if (covering && coveringHit(mcpCovering.allow, req.toolName)) {
360
+ if (covering && coveringHit(namespacedCovering.allow, req.toolName)) {
352
361
  return { action: "allow" };
353
362
  }
354
363
  if (defaultAction === "allow")
@@ -314,6 +314,14 @@ export interface Prepared {
314
314
  * leg always knows its identity.
315
315
  */
316
316
  hookIdentity: HookInvocationIdentity;
317
+ /**
318
+ * The per-invocation TIME BOUND every hook seat of this leg runs under (`Hooks.timeoutMs`, already
319
+ * validated — a garbage value was refused to the default and disclosed ONCE, here, rather than on
320
+ * every tool call). Published for the same reason `hookIdentity` is: the seats live in two files, and
321
+ * a number each station re-derived would be two answers to one wiring question — including two
322
+ * chances to re-refuse the same bad value.
323
+ */
324
+ hookTimeoutMs: number;
317
325
  promptManifest: {
318
326
  constitution: "core" | "replaced" | "provider-assembled";
319
327
  blocks: Array<{