@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.
- package/CHANGELOG.md +66 -0
- package/dist/agents/send-message-tool.js +48 -2
- package/dist/agents/subagent.js +250 -89
- package/dist/core/auto-compaction.d.ts +17 -4
- package/dist/core/auto-compaction.js +3 -0
- package/dist/core/context-edit.d.ts +55 -6
- package/dist/core/context-edit.js +12 -1
- package/dist/core/hooks.d.ts +293 -11
- package/dist/core/hooks.js +158 -11
- package/dist/core/human-input-projection.d.ts +20 -2
- package/dist/core/human-input-projection.js +9 -0
- package/dist/core/permission-rules.d.ts +23 -15
- package/dist/core/permission-rules.js +40 -31
- package/dist/core/runner/prepare-task.d.ts +8 -0
- package/dist/core/runner/prepare-task.js +34 -23
- package/dist/core/runner/runtask.js +158 -21
- package/dist/core/runner/session-rule-policy.js +5 -5
- package/dist/core/session-reconcile.d.ts +32 -0
- package/dist/core/session-reconcile.js +15 -0
- package/dist/core/task-notification.d.ts +34 -7
- package/dist/core/task-notification.js +11 -1
- package/dist/core/task-registry-agent.d.ts +20 -3
- package/dist/core/task-registry-agent.js +31 -2
- package/dist/core/tool-policy.d.ts +14 -9
- package/dist/core/tool-policy.js +27 -22
- package/dist/core/types.d.ts +37 -11
- package/dist/core/untrusted-text.js +8 -0
- package/dist/engine/compaction/compaction.d.ts +77 -7
- package/dist/engine/compaction/compaction.js +98 -9
- package/dist/engine/compaction/utils.d.ts +4 -0
- package/dist/engine/compaction/utils.js +6 -0
- package/dist/engine/harness/agent-harness.d.ts +84 -0
- package/dist/engine/harness/agent-harness.js +88 -12
- package/dist/engine/harness/messages.d.ts +4 -2
- package/dist/engine/harness/messages.js +7 -2
- package/dist/engine/harness/types.d.ts +11 -5
- package/dist/engine/loop/types.d.ts +7 -0
- package/dist/engine/session/import-validate.js +10 -0
- package/dist/engine/session/session.js +2 -2
- package/dist/orchestration/run-spec.js +8 -1
- package/dist/prompts/default.d.ts +10 -4
- package/package.json +1 -1
package/dist/core/hooks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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};
|
|
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
|
|
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
|
-
* ·
|
|
113
|
-
* ·
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* ·
|
|
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
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
125
|
-
/** True ⇔ this rule name is
|
|
126
|
-
* i.e. the shapes {@link
|
|
127
|
-
* keys rules by exact tool name needs this to know which
|
|
128
|
-
export declare function
|
|
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
|
|
81
|
-
|
|
80
|
+
function parseNamespacedRuleName(name) {
|
|
81
|
+
const ns = protocolOf(name);
|
|
82
|
+
if (ns === undefined)
|
|
82
83
|
return undefined;
|
|
83
|
-
const segments = name.slice(
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
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 ? {
|
|
89
|
+
return rest.length > 0 ? { protocol: ns.id, peer, tool: rest.join("__") } : { protocol: ns.id, peer };
|
|
89
90
|
}
|
|
90
|
-
export function
|
|
91
|
-
const rule =
|
|
91
|
+
export function namespacedRuleNameCovers(ruleName, toolName) {
|
|
92
|
+
const rule = parseNamespacedRuleName(ruleName);
|
|
92
93
|
if (rule === undefined)
|
|
93
94
|
return false;
|
|
94
|
-
const target =
|
|
95
|
+
const target = parseNamespacedRuleName(toolName);
|
|
95
96
|
if (target === undefined)
|
|
96
97
|
return false;
|
|
97
|
-
if (rule.
|
|
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
|
|
104
|
-
const parsed =
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
181
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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) =>
|
|
314
|
-
const hasCovering =
|
|
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
|
|
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
|
|
325
|
-
if (
|
|
326
|
-
return { action: "deny", message: ruleMessage("denied",
|
|
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
|
|
339
|
-
if (
|
|
340
|
-
return { action: "ask", message: ruleMessage("flagged",
|
|
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(
|
|
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<{
|