@sema-agent/core 5.53.0 → 5.55.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 (80) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/dist/agents/cumulative-stats.d.ts +26 -0
  3. package/dist/agents/cumulative-stats.js +56 -0
  4. package/dist/agents/observer.d.ts +11 -7
  5. package/dist/agents/observer.js +2 -4
  6. package/dist/agents/subagent.d.ts +4 -0
  7. package/dist/agents/subagent.js +1 -1
  8. package/dist/agents/verify.d.ts +27 -3
  9. package/dist/agents/verify.js +7 -2
  10. package/dist/core/governance-codes.js +14 -0
  11. package/dist/core/hooks.js +1 -1
  12. package/dist/core/lsp-diagnostics.d.ts +19 -17
  13. package/dist/core/lsp-diagnostics.js +11 -5
  14. package/dist/core/mcp.d.ts +214 -5
  15. package/dist/core/mcp.js +315 -30
  16. package/dist/core/memory-engine/consolidation.d.ts +378 -0
  17. package/dist/core/memory-engine/consolidation.js +342 -0
  18. package/dist/core/memory-engine/dual-root.js +3 -0
  19. package/dist/core/memory-engine/engine.d.ts +237 -4
  20. package/dist/core/memory-engine/engine.js +1111 -4
  21. package/dist/core/memory-engine/export-bundle.js +9 -0
  22. package/dist/core/memory-engine/file-backend.js +27 -1
  23. package/dist/core/memory-engine/frontmatter.d.ts +20 -1
  24. package/dist/core/memory-engine/frontmatter.js +111 -0
  25. package/dist/core/memory-engine/index.d.ts +4 -2
  26. package/dist/core/memory-engine/index.js +3 -1
  27. package/dist/core/memory-engine/memory-backend-contract.js +131 -0
  28. package/dist/core/memory-engine/sync-client.js +26 -0
  29. package/dist/core/memory-engine/tools.d.ts +9 -0
  30. package/dist/core/memory-engine/tools.js +57 -13
  31. package/dist/core/memory-engine/types.d.ts +99 -0
  32. package/dist/core/memory-recall.js +4 -3
  33. package/dist/core/memory.d.ts +33 -3
  34. package/dist/core/memory.js +6 -4
  35. package/dist/core/permission-rule-model.d.ts +140 -21
  36. package/dist/core/permission-rule-model.js +76 -17
  37. package/dist/core/permission-rule-org.d.ts +4 -3
  38. package/dist/core/permission-rule-org.js +12 -3
  39. package/dist/core/permission-rules.d.ts +22 -0
  40. package/dist/core/permission-rules.js +60 -6
  41. package/dist/core/reminder-disclosure.d.ts +29 -4
  42. package/dist/core/reminder-disclosure.js +60 -12
  43. package/dist/core/runner/prepare-memory.js +7 -2
  44. package/dist/core/runner/prepare-task.d.ts +31 -1
  45. package/dist/core/runner/prepare-task.js +31 -14
  46. package/dist/core/runner/runtask.d.ts +8 -1
  47. package/dist/core/runner/runtask.js +12 -10
  48. package/dist/core/runner/session-rule-policy.js +5 -3
  49. package/dist/core/runner/synthetic-tools.js +4 -2
  50. package/dist/core/runner/turn-attachments.d.ts +16 -6
  51. package/dist/core/runner/turn-attachments.js +34 -20
  52. package/dist/core/tool-policy.d.ts +18 -0
  53. package/dist/core/tool-policy.js +19 -8
  54. package/dist/core/types.d.ts +89 -6
  55. package/dist/core/untrusted-egress.js +12 -2
  56. package/dist/core/untrusted-text.d.ts +189 -3
  57. package/dist/core/untrusted-text.js +416 -6
  58. package/dist/engine/loop/types.d.ts +7 -0
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +2 -2
  61. package/dist/orchestration/workflow-types.d.ts +16 -6
  62. package/dist/orchestration/workflow-types.js +10 -4
  63. package/dist/orchestration/workflow.js +32 -6
  64. package/dist/prompts/default.d.ts +12 -2
  65. package/dist/stores/file/background-agent-store.js +1 -0
  66. package/dist/stores/file/checkpoint-store.d.ts +6 -2
  67. package/dist/stores/file/checkpoint-store.js +1 -0
  68. package/dist/stores/file/fs-atomic.d.ts +151 -10
  69. package/dist/stores/file/fs-atomic.js +208 -32
  70. package/dist/stores/file/index.d.ts +26 -3
  71. package/dist/stores/file/index.js +25 -2
  72. package/dist/stores/file/shared-ledger.d.ts +40 -5
  73. package/dist/stores/file/shared-ledger.js +24 -8
  74. package/dist/stores/file/workflow-run-store.d.ts +8 -1
  75. package/dist/stores/file/workflow-run-store.js +1 -0
  76. package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
  77. package/dist/tools/fs/bash-readonly-classifier.js +58 -47
  78. package/dist/tools/fs/index.d.ts +3 -1
  79. package/package.json +1 -1
  80. package/test/export-surface.snapshot.json +30 -1
@@ -483,13 +483,22 @@ export interface Prepared {
483
483
  ownedEnv?: ExecutionEnv;
484
484
  /** design/45: a mutable holder the durable-suspend gate writes when a policy `ask` was checkpointed
485
485
  * (capture + abort). The run loop reads it to assemble `status:"suspended"`. Empty unless a suspension
486
- * fired this run. */
486
+ * fired this run.
487
+ *
488
+ * `gatedCallId` is the id of the tool call the committed gate is holding — read straight off the
489
+ * committed checkpoint's `tool_approval` pendingAction, so the id a contaminated sibling frame names
490
+ * and the id the checkpoint parks on are the SAME value by construction, not by convention. ABSENT
491
+ * (never guessed) for a park that binds no tool call at all — a `resource_limit` slice and a
492
+ * `plan_review` pause both have pendingActions with no tool call, so there is no causal id to name.
493
+ * Written unconditionally by the commit-side publisher precisely so a later park cannot inherit an
494
+ * earlier one's id. */
487
495
  suspendRef: {
488
496
  token?: CheckpointToken;
489
497
  checkpointId?: string;
490
498
  gate?: CheckpointGate;
491
499
  scope?: string;
492
500
  restoreMode?: "snapshot" | "park_only";
501
+ gatedCallId?: string;
493
502
  };
494
503
  /** Ruled 2026-08-05 (matrix ruling arm A): set true by the resume engine when this leg EXECUTES the
495
504
  * approved pending call — the restart-loop cap then counts from a fresh base (consecutive
@@ -511,6 +520,7 @@ export interface Prepared {
511
520
  gate?: CheckpointGate;
512
521
  scope?: string;
513
522
  restoreMode?: "snapshot" | "park_only";
523
+ gatedCallId?: string;
514
524
  };
515
525
  /** RB-439-a: the remote-workspace lifecycle failures this run hit, appended in call order and echoed
516
526
  * verbatim on `TaskResult.remoteEnvFailures`. A shared array (not a per-call return) because a suspend
@@ -859,6 +869,26 @@ export interface Prepared {
859
869
  droppedMessages: boolean;
860
870
  };
861
871
  }
872
+ /**
873
+ * WHICH tool call a committed durable park is holding this run — `undefined` when nothing parked, or
874
+ * when the park that did commit holds no call (a resource slice, a plan review).
875
+ *
876
+ * SINGLE derivation on purpose. Two consumers need this answer: the abort-classification seam, which
877
+ * stamps it into the `details` of the results the loop mints for the contaminated siblings, and the
878
+ * `tool_end` projection, which puts it on the wire. Reading it from one function keeps the two from
879
+ * disagreeing about WHICH HOLDER WINS or about what a holder with no call means. They are not otherwise
880
+ * interchangeable: the frame face applies strictly narrower conditions on top of this answer (see
881
+ * `tool_end.gatedCallId`), so a frame may omit an id this function returns — never the reverse.
882
+ *
883
+ * The frame side must not read the id back out of a tool RESULT even though the marker is there: a
884
+ * result's `details` is written by the tool (and replaceable by post-tool hooks), so lifting a
885
+ * cross-call attribution from it would let any failing tool name an arbitrary call and put a phantom
886
+ * approval wait on someone else's frame. Same rule, same reason, as `settledBy`.
887
+ *
888
+ * The two holders are never both set (the commit-side discriminant writes exactly one); they are read
889
+ * here in assemble-result's slot order so the winner is the same one the terminal status is built from.
890
+ */
891
+ export declare function gatedCallIdOf(p: Pick<Prepared, "suspendRef" | "reviewRef">): string | undefined;
862
892
  /**
863
893
  * design/45 resume inputs threaded into {@link prepareTask} to continue a suspended task. The Runner
864
894
  * builds it from the persisted {@link Checkpoint}: rewind the branch to the suspension leaf, skip the
@@ -39,6 +39,7 @@ import { STALL_CONNECT_MS, STALL_FIRST_TOKEN_MS, STALL_IDLE_MS, withBrainCallGua
39
39
  import { defineTool, isDefineToolProduct } from "../tools.js";
40
40
  import { RETIRED_TOOL_NAMES } from "../tool-name-aliases.js";
41
41
  import { protocolOf } from "../protocol-table.js";
42
+ import { isMcpCoveringRuleName, mcpRuleNameCovers } from "../permission-rules.js";
42
43
  import { pathToUri } from "../lsp-protocol.js";
43
44
  import { DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, createOffloadPersist, firstPartyOffloadPolicy, InMemoryToolResultStore, RunnerSharedToolResultStore, ScopedToolResultStore, isVolatileOffloadStore, OFFLOAD_TOOL_NAME, createReadToolResultTool, withToolResultOffload, } from "../tool-result-store.js";
44
45
  import { OUTPUT_TOOL_NAME, REPORT_FINDINGS_TOOL_NAME, SKILL_CONTENT_MAX_CHARS, SKILL_TOOL_NAME, createOutputTool, createReportBlockedTool, createReportFindingsTool, createSkillTool, normalizeSkills } from "./synthetic-tools.js";
@@ -240,6 +241,30 @@ class ParkRefusal extends Error {
240
241
  export { resolveCheckpointStore } from "../checkpoint-store.js";
241
242
  export { isFableFamilyModelId, resolveModelPromptTraits, resolveTaskLimits } from "./prepare-config-doors.js";
242
243
  export { rebaseWorkspacePath, rebaseWorkspacePathAcross } from "./prepare-workspace-restore.js";
244
+ export function gatedCallIdOf(p) {
245
+ if (p.suspendRef.token !== undefined)
246
+ return p.suspendRef.gatedCallId;
247
+ if (p.reviewRef.token !== undefined)
248
+ return p.reviewRef.gatedCallId;
249
+ return undefined;
250
+ }
251
+ function parkContaminationMarker(refs) {
252
+ if (refs.suspendRef.token === undefined && refs.reviewRef.token === undefined)
253
+ return undefined;
254
+ const gatedCallId = gatedCallIdOf(refs);
255
+ return { code: "gate.parked", ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
256
+ }
257
+ function publishCommittedSuspend(refs, token, gate, scope, remoteHandle, checkpointId, pendingAction) {
258
+ const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? refs.reviewRef : refs.suspendRef;
259
+ ref.token = token;
260
+ if (checkpointId !== undefined)
261
+ ref.checkpointId = checkpointId;
262
+ ref.gate = gate;
263
+ ref.gatedCallId = pendingAction?.kind === "tool_approval" ? pendingAction.toolCallId : undefined;
264
+ if (remoteHandle !== undefined)
265
+ ref.restoreMode = remoteHandle.restoreMode === "park_only" ? "park_only" : "snapshot";
266
+ ref.scope = scope;
267
+ }
243
268
  function hasConversationContent(branch) {
244
269
  return branch.some((e) => e.type === "message" || e.type === "custom_message" || e.type === "compaction");
245
270
  }
@@ -2611,7 +2636,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2611
2636
  stream: withBrainCallGuardrail((m, c, o) => deps.brain.stream(m, c, o), brainCallGuardrailMs, brainCallGuardrailRef),
2612
2637
  };
2613
2638
  const harness = new AgentHarness({
2614
- abortResultDetails: () => suspendRef.token !== undefined || reviewRef.token !== undefined ? { code: "gate.parked" } : undefined,
2639
+ abortResultDetails: () => parkContaminationMarker({ suspendRef, reviewRef }),
2615
2640
  ...(spec.limits?.maxOutputTokens !== undefined && spec.limits.maxOutputTokens > 0
2616
2641
  ? { maxOutputTokens: spec.limits.maxOutputTokens }
2617
2642
  : {}),
@@ -2686,6 +2711,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2686
2711
  for (const n of list ?? []) {
2687
2712
  if (known.has(n))
2688
2713
  continue;
2714
+ if (isMcpCoveringRuleName(n) && [...known].some((k) => mcpRuleNameCovers(n, k)))
2715
+ continue;
2689
2716
  const retired = RETIRED_TOOL_NAMES.get(n);
2690
2717
  if (retired !== undefined) {
2691
2718
  const err = new Error(`tool policy ${kind}-list entry "${n}" is a RETIRED tool name (${retired}) and matches nothing in this run's roster — legacy-name normalization was removed (RB-476-A), so this entry would silently guard nothing. Update the deployment's rule to the current name.`);
@@ -3748,16 +3775,6 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3748
3775
  return { ok: false, reason };
3749
3776
  }
3750
3777
  };
3751
- const publishCommittedSuspend = (token, gate, scope, remoteHandle, checkpointId) => {
3752
- const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? reviewRef : suspendRef;
3753
- ref.token = token;
3754
- if (checkpointId !== undefined)
3755
- ref.checkpointId = checkpointId;
3756
- ref.gate = gate;
3757
- if (remoteHandle !== undefined)
3758
- ref.restoreMode = remoteHandle.restoreMode === "park_only" ? "park_only" : "snapshot";
3759
- ref.scope = scope;
3760
- };
3761
3778
  const suspendLoopCapHit = (count, cap, detail) => {
3762
3779
  if (cap === undefined)
3763
3780
  return false;
@@ -3838,7 +3855,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3838
3855
  };
3839
3856
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)).ok)
3840
3857
  return false;
3841
- publishCommittedSuspend(token, gate, scope, remoteHandle, cp.checkpointId);
3858
+ publishCommittedSuspend({ suspendRef, reviewRef }, token, gate, scope, remoteHandle, cp.checkpointId, cp.pendingAction);
3842
3859
  try {
3843
3860
  await sessions.pin?.(sessionId);
3844
3861
  }
@@ -3924,7 +3941,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3924
3941
  };
3925
3942
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)).ok)
3926
3943
  return false;
3927
- publishCommittedSuspend(token, gate, scope, remoteHandle, cp.checkpointId);
3944
+ publishCommittedSuspend({ suspendRef, reviewRef }, token, gate, scope, remoteHandle, cp.checkpointId, cp.pendingAction);
3928
3945
  try {
3929
3946
  await sessions.pin?.(sessionId);
3930
3947
  }
@@ -4219,7 +4236,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4219
4236
  if (!committed.ok) {
4220
4237
  return committed.reason !== undefined ? { parkFailed: committed.reason } : undefined;
4221
4238
  }
4222
- publishCommittedSuspend(token, gate, cp.scope, remoteHandle, cp.checkpointId);
4239
+ publishCommittedSuspend({ suspendRef, reviewRef }, token, gate, cp.scope, remoteHandle, cp.checkpointId, cp.pendingAction);
4223
4240
  try {
4224
4241
  await sessions.pin?.(sessionId);
4225
4242
  }
@@ -87,12 +87,19 @@ settledBy?: ApprovalSettledBy,
87
87
  approver?: string,
88
88
  /** The ask resolver's deny-arm classification — same caller, same engine-owned channel and the same
89
89
  * never-derived-from-`result` posture as the two above. Omitted ⇒ not an ask-resolution deny. */
90
- resolution?: import("../tool-policy.js").AskDenyResolution): {
90
+ resolution?: import("../tool-policy.js").AskDenyResolution,
91
+ /** WHICH call this run's committed durable park is holding ({@link gatedCallIdOf}), for the frames the
92
+ * abort short-circuits. Same never-derived-from-`result` posture as the three above, and for the
93
+ * sharpest version of that reason: this one is an assertion about a DIFFERENT call, so a tool able to
94
+ * author it could point an approval UI at a call nobody is waiting on. Omitted ⇒ no park is holding a
95
+ * call (nothing parked, or the park that did holds none), and the frame then carries no id at all. */
96
+ gatedCallIdOfRun?: string): {
91
97
  output?: unknown;
92
98
  truncated?: boolean;
93
99
  totalChars?: number;
94
100
  structured?: unknown;
95
101
  errorCode?: string;
102
+ gatedCallId?: string;
96
103
  settledBy?: ApprovalSettledBy;
97
104
  approver?: string;
98
105
  resolution?: import("../tool-policy.js").AskDenyResolution;
@@ -31,9 +31,9 @@ import { toImageContent } from "./image.js";
31
31
  import { OUTPUT_TOOL_NAME, SKILLS_LISTING_PROBE_HEADER, resolveOutputRetries } from "./synthetic-tools.js";
32
32
  import { cacheFamilyOf, usageCostMicroUsd } from "./usage-accounting.js";
33
33
  import { assembleResult, errorCodeOf } from "./assemble-result.js";
34
- import { ATTACHMENT_BYTE_CAP, CHANGED_FILES_MAX, AGENT_LISTING_REMOVED_HEADER, SKILLS_LISTING_DELTA_HEADER, SKILLS_LISTING_REMOVED_HEADER, advanceCadenceClock, agentListingDeltaHeader, agentListingInitialHeader, replayAnnouncedListing, replayAnnouncedModels, clipToBytes, collectDateChange, collectDueAttachments, collectInstructionsChange, commitAgentListing, commitInstructionsChange, commitSkillsListing, createAttachmentState, rebaseCadenceWindows, reduceToolEnd, renderAgentListingDelta, renderMcpDroppedTools, renderMcpInstructionsDelta, renderOrphanedBackgroundTasks, selectMcpDroppedBatch, renderSkillsListingDelta, renderToolsDelta, stampWriteAnchor } from "./turn-attachments.js";
34
+ import { ATTACHMENT_BYTE_CAP, CHANGED_FILES_MAX, AGENT_LISTING_REMOVED_HEADER, SKILLS_LISTING_DELTA_HEADER, SKILLS_LISTING_REMOVED_HEADER, advanceCadenceClock, agentListingDeltaHeader, attachmentEnvelopeTags, agentListingInitialHeader, replayAnnouncedListing, replayAnnouncedModels, clipToBytes, collectDateChange, collectDueAttachments, collectInstructionsChange, commitAgentListing, commitInstructionsChange, commitSkillsListing, createAttachmentState, rebaseCadenceWindows, reduceToolEnd, renderAgentListingDelta, renderMcpDroppedTools, renderMcpInstructionsDelta, renderOrphanedBackgroundTasks, selectMcpDroppedBatch, renderSkillsListingDelta, renderToolsDelta, stampWriteAnchor } from "./turn-attachments.js";
35
35
  import { buildWorkingFileAttachments, centerAdoptionOption, emitInputTruncated, forkContextOption } from "./compaction-call-options.js";
36
- import { prepareTask, resolveCheckpointStore } from "./prepare-task.js";
36
+ import { gatedCallIdOf, prepareTask, resolveCheckpointStore } from "./prepare-task.js";
37
37
  import { settleTeardownLeg } from "./teardown-bounded.js";
38
38
  import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
39
39
  import { hasVerifiableStructureSignal } from "./grounding-signal.js";
@@ -41,7 +41,7 @@ import { hasDestroy, isIsolated } from "../remote-env.js";
41
41
  import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
42
42
  import { cloneObserverInput, formatHookFeedback } from "../hooks.js";
43
43
  import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
44
- import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText } from "../untrusted-text.js";
44
+ import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
45
45
  import { reconcileInterruptedSession } from "../session-reconcile.js";
46
46
  import { RunnerSharedToolResultStore } from "../tool-result-store.js";
47
47
  import { formatDiagnosticsBlock } from "../lsp-diagnostics.js";
@@ -141,17 +141,19 @@ function resumeDecisionWasNegative(resume) {
141
141
  }
142
142
  const DEFERRED_REISSUE = "[DEFERRED] This tool call shared a batch with a call that suspended for durable approval, so it was " +
143
143
  "NOT executed on resume. If you still need it, issue it again now.";
144
- function toolEndBodyFrom(result, isError, settledBy, approver, resolution) {
144
+ function toolEndBodyFrom(result, isError, settledBy, approver, resolution, gatedCallIdOfRun) {
145
145
  const o = toolOutputFrom(result);
146
146
  const st = structuredFrom(result);
147
147
  const det = isError ? result?.details : undefined;
148
148
  const codeRaw = det?.code;
149
149
  const kindRaw = det?.errorKind;
150
150
  const code = typeof codeRaw === "string" ? codeRaw : typeof kindRaw === "string" ? kindRaw : undefined;
151
+ const gatedCallId = code === "gate.parked" ? gatedCallIdOfRun : undefined;
151
152
  return {
152
153
  ...(o !== undefined ? { output: o.output, ...(o.truncated ? { truncated: true } : {}), ...(o.totalChars !== undefined ? { totalChars: o.totalChars } : {}) } : {}),
153
154
  ...(st !== undefined ? { structured: st } : {}),
154
155
  ...(typeof code === "string" ? { errorCode: code } : {}),
156
+ ...(gatedCallId !== undefined ? { gatedCallId } : {}),
155
157
  ...(settledBy !== undefined ? { settledBy } : {}),
156
158
  ...(approver !== undefined ? { approver } : {}),
157
159
  ...(resolution !== undefined ? { resolution } : {}),
@@ -543,7 +545,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
543
545
  const files = prepared.lspDiagnostics.registry.drain(prepared.lspDiagnostics.runIdent);
544
546
  if (files.length > 0) {
545
547
  queue.push({ type: "diagnostics", files, isNew: true, ...ident() });
546
- const block = formatDiagnosticsBlock(files);
548
+ const block = mintSystemReminder(formatDiagnosticsBlock(files), prepared.reminderMark);
547
549
  void prepared.harness.steer(block, { provenance: "engine-note" }).catch(() => undefined);
548
550
  boundarySteered = true;
549
551
  }
@@ -746,7 +748,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
746
748
  }
747
749
  }
748
750
  if (due.length > 0) {
749
- attachmentsPayload = due.map((a) => mintSystemReminder(sanitizeUntrustedText(a.body), prepared.reminderMark)).join("\n");
751
+ attachmentsPayload = due.map((a) => mintSystemReminder(sanitizeUntrustedText(a.body, attachmentEnvelopeTags(a.source)), prepared.reminderMark)).join("\n");
750
752
  for (const a of due)
751
753
  boundaryAttachmentBytes += Buffer.byteLength(a.body, "utf8");
752
754
  rs.attach.attachmentsInjected += due.length;
@@ -767,7 +769,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
767
769
  try {
768
770
  const r = await postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined, { identity: prepared.hookIdentity });
769
771
  if (r?.additionalContext && injectedThisTurn === undefined) {
770
- const body = sanitizeUntrustedText(r.additionalContext);
772
+ const body = sanitizeUntrustedText(r.additionalContext, SHELLED_BODY_ENVELOPE_TAGS);
771
773
  const budget = ATTACHMENT_BYTE_CAP - boundaryAttachmentBytes;
772
774
  if (budget >= BATCH_CONTEXT_MIN_KEEP_BYTES) {
773
775
  const clipped = clipToBytes(body, budget) === body
@@ -1008,7 +1010,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
1008
1010
  return onTurnBoundary;
1009
1011
  }
1010
1012
  function wrapGitFrame(body, mark) {
1011
- return mintSystemReminder(sanitizeUntrustedText(body), mark);
1013
+ return mintSystemReminder(sanitizeUntrustedText(body, SHELLED_BODY_ENVELOPE_TAGS), mark);
1012
1014
  }
1013
1015
  async function resolveGitLegDelivery(prepared, cpMirror, report) {
1014
1016
  const ref = prepared.gitStatusRef;
@@ -1344,7 +1346,7 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
1344
1346
  toolName: event.toolName,
1345
1347
  ...(toolLabels.get(event.toolName) !== undefined ? { label: toolLabels.get(event.toolName) } : {}),
1346
1348
  isError: event.isError,
1347
- ...toolEndBodyFrom(event.result, event.isError, settlement?.settledBy, settlement?.approver, settlement?.resolution),
1349
+ ...toolEndBodyFrom(event.result, event.isError, settlement?.settledBy, settlement?.approver, settlement?.resolution, prepared.abortController.signal.aborted ? gatedCallIdOf(prepared) : undefined),
1348
1350
  ...ident(),
1349
1351
  });
1350
1352
  announceWorkspaceMove();
@@ -3258,7 +3260,7 @@ export class Runner {
3258
3260
  }
3259
3261
  }
3260
3262
  if (firstFrames.length > 0) {
3261
- effectiveObjective = `${firstFrames.map((f) => mintSystemReminder(sanitizeUntrustedText(f.body), prepared.reminderMark)).join("\n")}\n${effectiveObjective}`;
3263
+ effectiveObjective = `${firstFrames.map((f) => mintSystemReminder(sanitizeUntrustedText(f.body, attachmentEnvelopeTags(f.source)), prepared.reminderMark)).join("\n")}\n${effectiveObjective}`;
3262
3264
  }
3263
3265
  }
3264
3266
  const gitQueuedChars = gitLegDelivered !== undefined && gitLegDelivered.standalone ? gitLegDelivered.wrapped.length : 0;
@@ -1,6 +1,6 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../../tools/fs/safety.js";
2
2
  import { isWinFormPath } from "../../tools/fs/safety.js";
3
- import { createCoarseCommandNamePolicy } from "../tool-policy.js";
3
+ import { createCoarseCommandNamePolicy, mcpCoveringEntries, mcpCoveringHit } from "../tool-policy.js";
4
4
  export const PATH_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
5
5
  export const PATH_CONFINABLE_WRITE_TOOLS = new Set([...PATH_WRITE_TOOLS, "NotebookEdit"]);
6
6
  export function isWithin(root, p) {
@@ -24,6 +24,8 @@ export function createSessionRulePolicy(rules, opts) {
24
24
  const { env, rootPath, toolEffects } = opts;
25
25
  const toolDeny = new Set(rules.toolDeny ?? []);
26
26
  const toolAllow = rules.toolAllow ? new Set(rules.toolAllow) : undefined;
27
+ const toolDenyCovering = mcpCoveringEntries(rules.toolDeny);
28
+ const toolAllowCovering = mcpCoveringEntries(rules.toolAllow);
27
29
  const cmdPolicy = rules.commandAllow || rules.commandDeny
28
30
  ? createCoarseCommandNamePolicy({
29
31
  ...(rules.commandAllow ? { allow: rules.commandAllow } : {}),
@@ -36,9 +38,9 @@ export function createSessionRulePolicy(rules, opts) {
36
38
  nameSets: [{ ...(rules.toolDeny?.length ? { deny: [...rules.toolDeny] } : {}), ...(rules.toolAllow?.length ? { allow: [...rules.toolAllow] } : {}) }],
37
39
  async check(req, signal) {
38
40
  const toolName = req.toolName;
39
- if (toolDeny.has(toolName))
41
+ if (toolDeny.has(toolName) || mcpCoveringHit(toolDenyCovering, toolName))
40
42
  return deny(`tool "${req.toolName}" is denied by a session rule`);
41
- if (toolAllow && !toolAllow.has(toolName)) {
43
+ if (toolAllow && !toolAllow.has(toolName) && !mcpCoveringHit(toolAllowCovering, toolName)) {
42
44
  return deny(`tool "${req.toolName}" is not in the session-rule allowlist`);
43
45
  }
44
46
  if (cmdPolicy) {
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool } from "../tools.js";
3
+ import { sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
3
4
  export const OUTPUT_TOOL_NAME = "StructuredOutput";
4
5
  export const REPORT_BLOCKED_TOOL_NAME = "ReportBlocked";
5
6
  export const REPORT_FINDINGS_TOOL_NAME = "ReportFindings";
@@ -123,7 +124,8 @@ export function skillListingLine(s) {
123
124
  const desc = s.description.length > SKILL_DESC_MAX_CHARS
124
125
  ? `${s.description.slice(0, SKILL_DESC_MAX_CHARS - 1)}…`
125
126
  : s.description;
126
- return `- ${s.name}: ${desc}${skillFilesSuffix(s.files)}`;
127
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
128
+ return `- ${safe(s.name)}: ${safe(desc)}${safe(skillFilesSuffix(s.files))}`;
127
129
  }
128
130
  const SKILLS_BLOCK_CAP_MARKER = "(block size cap reached — lower-priority skills are listed name-only; descriptions via the tool)";
129
131
  export function buildSkillsBlock(skills) {
@@ -141,7 +143,7 @@ export function buildSkillsBlock(skills) {
141
143
  const lineCost = (l) => bytes(l) + 1;
142
144
  const budget = SKILLS_BLOCK_MAX_BYTES - bytes(render([]));
143
145
  const fullLines = skills.map((s) => skillListingLine(s));
144
- const nameLines = skills.map((s) => `- ${s.name}`);
146
+ const nameLines = skills.map((s) => `- ${sanitizeUntrustedText(s.name, SHELLED_BODY_ENVELOPE_TAGS)}`);
145
147
  const fullTotal = fullLines.reduce((acc, l) => acc + lineCost(l), 0);
146
148
  if (fullTotal <= budget)
147
149
  return render(fullLines);
@@ -65,6 +65,7 @@ export interface BackgroundTaskSnapshot {
65
65
  description?: string;
66
66
  status: string;
67
67
  }
68
+ export declare function attachmentEnvelopeTags(source: AttachmentSource): string[];
68
69
  /** One due reminder. `body` is the inner text — the steerer adds the `<system-reminder>` wrapper (F3). */
69
70
  export interface TurnAttachment {
70
71
  source: AttachmentSource;
@@ -638,7 +639,9 @@ export declare function replayAnnouncedListing(texts: readonly string[], headers
638
639
  }): Set<string> | undefined;
639
640
  /** Removal-frame header for the agent-type roster (single-sourced for renderer + replay parser). */
640
641
  export declare const AGENT_LISTING_REMOVED_HEADER = "The following agent types are no longer available:";
641
- /** CC-verbatim roster-drift header — same single-source rationale as {@link agentListingInitialHeader}. */
642
+ /** CC-verbatim roster-drift header — same single-source rationale as {@link agentListingInitialHeader}.
643
+ * The interpolated tool name is neutralized on both headers: a caller renames the delegation tool, so
644
+ * the name is deployment text arriving on the marked-reminder lane like every other field here. */
642
645
  export declare function agentListingDeltaHeader(toolName: string): string;
643
646
  /**
644
647
  * G1 续批 — agent-type roster announce (CC 2.1.198 `agent_listing_delta`, bundle pretty.js:599199-599223,
@@ -745,11 +748,18 @@ export declare const MCP_INSTRUCTIONS_MAX_CHARS: number;
745
748
  * — fenced on the delta lane, RAW-INTERPOLATED (no fence, no bound) into the stable prompt. A single
746
749
  * source makes the divergence unrepresentable.
747
750
  *
748
- * Truncation is DISCLOSED (§25 honesty): `delimitUntrusted` clips silently with an ellipsis, which
749
- * reads to the model as the server's own text. The trailing note lives OUTSIDE the fence, on a
750
- * trusted line, so the model can tell "the server said less" from "we cut it". The over-length test
751
- * mirrors `delimitUntrusted`'s own internal condition exactly (both transforms are idempotent, so
752
- * re-running them here is a no-op and cannot drift from the clip actually applied).
751
+ * Truncation is DISCLOSED (§25 honesty): the fence clips silently with an ellipsis, which reads to the
752
+ * model as the server's own text. The trailing note lives OUTSIDE the fence, on a trusted line, so the
753
+ * model can tell "the server said less" from "we cut it".
754
+ *
755
+ * The clip verdict comes FROM the fence (`delimitUntrustedWithClip`), never from a length test
756
+ * re-derived here. The earlier shape did re-derive it — a local `sanitizeUntrustedText` pre-pass with
757
+ * the DEFAULT (reminder-only) tag set, measured against the cap — and that mirror silently stopped
758
+ * matching once the fence widened its neutralization to the whole authority-envelope family: each
759
+ * widened-family tag occurrence adds a ZWSP the pre-count never saw, so a server could land its text
760
+ * in the `(cap − N, cap]` window and get the body clipped with the honesty note withheld, N being its
761
+ * own choice of tag count. One measurement, taken where the cut happens, is the only shape that cannot
762
+ * drift.
753
763
  */
754
764
  export declare function fenceMcpServerInstructions(server: string, text: string): string;
755
765
  /**
@@ -1,5 +1,5 @@
1
1
  import { PRESENT_PLAN_TOOL_NAME } from "../present-plan-tool.js";
2
- import { defuseFenceMarkers, delimitUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
2
+ import { delimitUntrustedWithClip, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
3
3
  import { protocolOf } from "../protocol-table.js";
4
4
  import { buildSkillsBlock, skillListingLine } from "./synthetic-tools.js";
5
5
  import { TOOL_SEARCH_NAME as TOOL_SEARCH_TOOL_NAME } from "./tool-disclosure.js";
@@ -20,6 +20,11 @@ export const CHANGED_FILES_MTIME_EPS_MS = 2000;
20
20
  export const ATTACHMENT_BYTE_CAP = 8 * 1024;
21
21
  const PROJECTION_ITEMS_MAX = 50;
22
22
  const PROJECTION_CONTENT_MAX = 80;
23
+ const ATTACHMENT_TAGS_DEFAULT = [...SHELLED_BODY_ENVELOPE_TAGS];
24
+ const ATTACHMENT_TAGS_SKILLS_OWNER = SHELLED_BODY_ENVELOPE_TAGS.filter((t) => t !== "skills");
25
+ export function attachmentEnvelopeTags(source) {
26
+ return source === "skills_listing" ? ATTACHMENT_TAGS_SKILLS_OWNER : ATTACHMENT_TAGS_DEFAULT;
27
+ }
23
28
  export const INSTRUCTIONS_CHANGE_BYTE_CAP = 512;
24
29
  export function createAttachmentState() {
25
30
  return {
@@ -358,8 +363,9 @@ export function renderBudgetUsd(used, total) {
358
363
  return `USD budget: $${used}/$${total}; $${total - used} remaining`;
359
364
  }
360
365
  export function renderOrphanedBackgroundTasks(tasks) {
366
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
361
367
  return (`The container was restarted. The following background tasks were running and are now stopped:\n` +
362
- tasks.map((t) => `- ${t.description || "(no description)"} (task ${t.id})`).join("\n") +
368
+ tasks.map((t) => `- ${safe(t.description || "(no description)")} (task ${safe(t.id)})`).join("\n") +
363
369
  `\nRe-create them if still needed.`);
364
370
  }
365
371
  function renderChangedFiles(paths) {
@@ -449,7 +455,7 @@ export const AGENT_TOOLS_NOTE_DEFAULT = "All tools";
449
455
  export const AGENT_CONCURRENCY_NOTE = "When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.";
450
456
  export const AMBIENT_CONTEXT_NOTE = "This is ambient context — do not narrate it to the user unless they ask or it is directly relevant to their request.";
451
457
  export function agentListingInitialHeader(toolName) {
452
- return `Available agent types for the ${toolName} tool:`;
458
+ return `Available agent types for the ${sanitizeUntrustedText(toolName, SHELLED_BODY_ENVELOPE_TAGS)} tool:`;
453
459
  }
454
460
  export function replayAnnouncedListing(texts, headers) {
455
461
  let announced;
@@ -492,13 +498,17 @@ export function replayAnnouncedListing(texts, headers) {
492
498
  return announced;
493
499
  }
494
500
  export const AGENT_LISTING_REMOVED_HEADER = "The following agent types are no longer available:";
501
+ function listingIdentity(name) {
502
+ return sanitizeUntrustedText(name, SHELLED_BODY_ENVELOPE_TAGS);
503
+ }
495
504
  export function agentListingDeltaHeader(toolName) {
496
- return `New agent types are now available for the ${toolName} tool:`;
505
+ return `New agent types are now available for the ${sanitizeUntrustedText(toolName, SHELLED_BODY_ENVELOPE_TAGS)} tool:`;
497
506
  }
498
507
  export function renderAgentListingDelta(state, entries, toolName, models) {
508
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
499
509
  const line = (e) => {
500
- const base = e.description ? `- ${e.name}: ${e.description}` : `- ${e.name}`;
501
- return `${base} (Tools: ${e.tools ?? AGENT_TOOLS_NOTE_DEFAULT})`;
510
+ const base = e.description ? `- ${safe(e.name)}: ${safe(e.description)}` : `- ${safe(e.name)}`;
511
+ return `${base} (Tools: ${safe(e.tools ?? AGENT_TOOLS_NOTE_DEFAULT)})`;
502
512
  };
503
513
  const announced = state.announcedAgentTypes;
504
514
  if (announced === undefined) {
@@ -511,18 +521,22 @@ export function renderAgentListingDelta(state, entries, toolName, models) {
511
521
  blocks.push(modelsAvailableLine(models));
512
522
  return blocks.join("\n\n");
513
523
  }
514
- const added = entries.filter((e) => !announced.has(e.name)).toSorted((a, b) => a.name.localeCompare(b.name));
515
- const removed = [...announced.keys()].filter((n) => !entries.some((e) => e.name === n)).sort();
524
+ const announcedIds = new Set([...announced.keys()].map(listingIdentity));
525
+ const added = entries.filter((e) => !announcedIds.has(listingIdentity(e.name))).toSorted((a, b) => a.name.localeCompare(b.name));
526
+ const removed = [...announced.keys()]
527
+ .filter((n) => !entries.some((e) => listingIdentity(e.name) === listingIdentity(n)))
528
+ .sort();
516
529
  const modelsDrifted = state.announcedModels !== undefined &&
517
530
  models !== undefined &&
518
- (state.announcedModels.length !== models.length || state.announcedModels.some((m, i) => m !== models[i]));
531
+ (state.announcedModels.length !== models.length ||
532
+ state.announcedModels.some((m, i) => listingIdentity(m) !== listingIdentity(models[i])));
519
533
  if (added.length === 0 && removed.length === 0 && !modelsDrifted)
520
534
  return undefined;
521
535
  const blocks = [];
522
536
  if (added.length > 0)
523
537
  blocks.push(`${agentListingDeltaHeader(toolName)}\n${added.map(line).join("\n")}`);
524
538
  if (removed.length > 0) {
525
- blocks.push(`${AGENT_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${n}`).join("\n")}`);
539
+ blocks.push(`${AGENT_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${safe(n)}`).join("\n")}`);
526
540
  blocks.push(AMBIENT_CONTEXT_NOTE);
527
541
  }
528
542
  if (modelsDrifted)
@@ -531,7 +545,8 @@ export function renderAgentListingDelta(state, entries, toolName, models) {
531
545
  }
532
546
  export const MODELS_AVAILABLE_PREFIX = "Models available for the 'model' parameter: ";
533
547
  function modelsAvailableLine(models) {
534
- return `${MODELS_AVAILABLE_PREFIX}${models.length > 0 ? models.join(", ") : "(none)"}`;
548
+ const named = models.map((m) => sanitizeUntrustedText(m, SHELLED_BODY_ENVELOPE_TAGS));
549
+ return `${MODELS_AVAILABLE_PREFIX}${named.length > 0 ? named.join(", ") : "(none)"}`;
535
550
  }
536
551
  export function replayAnnouncedModels(texts) {
537
552
  let last;
@@ -563,15 +578,16 @@ export function renderSkillsListingDelta(state, entries) {
563
578
  return undefined;
564
579
  return buildSkillsBlock(entries);
565
580
  }
566
- const added = entries.filter((e) => !announced.has(e.name));
567
- const removed = [...announced.keys()].filter((n) => !entries.some((e) => e.name === n));
581
+ const announcedIds = new Set([...announced.keys()].map(listingIdentity));
582
+ const added = entries.filter((e) => !announcedIds.has(listingIdentity(e.name)));
583
+ const removed = [...announced.keys()].filter((n) => !entries.some((e) => listingIdentity(e.name) === listingIdentity(n)));
568
584
  if (added.length === 0 && removed.length === 0)
569
585
  return undefined;
570
586
  const blocks = [];
571
587
  if (added.length > 0)
572
588
  blocks.push(`${SKILLS_LISTING_DELTA_HEADER}\n${added.map((e) => skillListingLine(e)).join("\n")}`);
573
589
  if (removed.length > 0) {
574
- blocks.push(`${SKILLS_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${n}`).join("\n")}`);
590
+ blocks.push(`${SKILLS_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${sanitizeUntrustedText(n, SHELLED_BODY_ENVELOPE_TAGS)}`).join("\n")}`);
575
591
  blocks.push(AMBIENT_CONTEXT_NOTE);
576
592
  }
577
593
  return blocks.join("\n\n");
@@ -581,12 +597,10 @@ export function commitSkillsListing(state, entries) {
581
597
  }
582
598
  export const MCP_INSTRUCTIONS_MAX_CHARS = 8 * 1024;
583
599
  export function fenceMcpServerInstructions(server, text) {
584
- const neutralized = defuseFenceMarkers(sanitizeUntrustedText(text));
585
- const clipped = [...neutralized].length > MCP_INSTRUCTIONS_MAX_CHARS;
586
- const fenced = delimitUntrusted(`MCP server "${server}" instructions`, neutralized, MCP_INSTRUCTIONS_MAX_CHARS);
587
- return clipped
588
- ? `${fenced}\n(Truncated by the agent runtime: this server's instructions exceeded ${MCP_INSTRUCTIONS_MAX_CHARS} characters.)`
589
- : fenced;
600
+ const fence = delimitUntrustedWithClip(`MCP server "${server}" instructions`, text, MCP_INSTRUCTIONS_MAX_CHARS);
601
+ return fence.clipped
602
+ ? `${fence.text}\n(Truncated by the agent runtime: this server's instructions exceeded ${MCP_INSTRUCTIONS_MAX_CHARS} characters.)`
603
+ : fence.text;
590
604
  }
591
605
  export function renderMcpInstructionsDelta(added, removed) {
592
606
  const blocks = [];
@@ -383,6 +383,24 @@ export interface ToolPolicyProjection {
383
383
  */
384
384
  readonly requiresLiveRemainder: boolean;
385
385
  }
386
+ /**
387
+ * The entries of a tool-NAME list whose reach is a set of names rather than one name — the MCP
388
+ * covering spellings (`mcp__<server>`, `mcp__<server>__<glob>`) that CC's rule matcher resolves and
389
+ * an exact-membership test silently cannot. Every name-keyed lane in this file consults these
390
+ * ALONGSIDE its exact set: a covering spelling can never equal a minted tool name (`*` is outside the
391
+ * minted charset and a minted name always carries a tool segment), so leaving it in the exact set too
392
+ * costs nothing and keeps the projection/audit faces reporting the operator's own spelling.
393
+ *
394
+ * The list is usually empty, which is why every call site tests it before scanning.
395
+ *
396
+ * Exported for the OTHER name-keyed lane in this engine — the persisted session/ancestor rules in
397
+ * `session-rule-policy.ts`. Not part of the public API (`src/index.ts` re-exports by name and does not
398
+ * list these): all four publishers of {@link ToolPolicyNameSets} must resolve a covering entry the same
399
+ * way, because the prepare-time audit's exemption for these spellings speaks for all of them at once.
400
+ */
401
+ export declare function mcpCoveringEntries(entries: readonly string[] | undefined): readonly string[];
402
+ /** Does any covering entry reach `toolName`? See {@link mcpCoveringEntries}. */
403
+ export declare function mcpCoveringHit(covering: readonly string[], toolName: string): boolean;
386
404
  /**
387
405
  * Execute a persisted {@link ToolPolicyProjection} against a call (F-012 L1): returns the first
388
406
  * component's deny, or `undefined` when the projection has no opinion (it is deny-only by
@@ -5,7 +5,7 @@ import { join, normalize as normalizePath, posix as posixPath, sep, win32 as win
5
5
  import { BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName } from "../tools/fs/index.js";
6
6
  import { boundInputHashOf } from "./canonical-json.js";
7
7
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY } from "./untrusted-text.js";
8
- import { parsePermissionRule } from "./permission-rules.js";
8
+ import { isMcpCoveringRuleName, mcpRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
9
9
  import { isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
10
10
  const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
11
11
  const DECISION_REASON_SET = new Set(DECISION_REASONS);
@@ -36,16 +36,22 @@ export const ASK_EVIDENCE_ABSENCE_VALUES = ["not_wired", "not_adjudicated", "una
36
36
  export function decisionText(d) {
37
37
  return d.message;
38
38
  }
39
+ export function mcpCoveringEntries(entries) {
40
+ return (entries ?? []).filter(isMcpCoveringRuleName);
41
+ }
42
+ export function mcpCoveringHit(covering, toolName) {
43
+ return covering.length > 0 && covering.some((e) => mcpRuleNameCovers(e, toolName));
44
+ }
39
45
  export function checkToolPolicyProjection(projection, req) {
40
46
  for (const c of projection.components) {
41
47
  if (c.kind === "tool_deny") {
42
- if (c.names.includes(req.toolName)) {
48
+ if (c.names.includes(req.toolName) || mcpCoveringHit(mcpCoveringEntries(c.names), req.toolName)) {
43
49
  return { action: "deny", message: `tool "${req.toolName}" is denied by a frozen inherited policy projection` };
44
50
  }
45
51
  continue;
46
52
  }
47
53
  if (c.kind === "tool_allowlist") {
48
- if (!c.names.includes(req.toolName)) {
54
+ if (!c.names.includes(req.toolName) && !mcpCoveringHit(mcpCoveringEntries(c.names), req.toolName)) {
49
55
  return { action: "deny", message: `tool "${req.toolName}" is not in a frozen inherited policy projection's allowlist` };
50
56
  }
51
57
  continue;
@@ -218,6 +224,8 @@ export function createAllowDenyPolicy(opts) {
218
224
  opts = { ...opts, ...(screenedAllow ? { allow: screenedAllow } : {}), ...(screenedDeny ? { deny: screenedDeny } : {}) };
219
225
  const allow = opts.allow ? new Set(opts.allow) : undefined;
220
226
  const deny = new Set(opts.deny ?? []);
227
+ const denyCovering = mcpCoveringEntries(opts.deny);
228
+ const allowCovering = mcpCoveringEntries(opts.allow);
221
229
  return {
222
230
  projection: {
223
231
  components: [
@@ -229,10 +237,10 @@ export function createAllowDenyPolicy(opts) {
229
237
  nameSets: [{ ...(opts.allow ? { allow: [...opts.allow] } : {}), ...(opts.deny ? { deny: [...opts.deny] } : {}) }],
230
238
  check(req) {
231
239
  const toolName = req.toolName;
232
- if (deny.has(toolName)) {
240
+ if (deny.has(toolName) || mcpCoveringHit(denyCovering, toolName)) {
233
241
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
234
242
  }
235
- if (allow && !allow.has(toolName)) {
243
+ if (allow && !allow.has(toolName) && !mcpCoveringHit(allowCovering, toolName)) {
236
244
  return { action: "deny", message: `tool "${req.toolName}" is not in the allowlist` };
237
245
  }
238
246
  return ALLOW;
@@ -249,6 +257,9 @@ export function createApprovalPolicy(opts) {
249
257
  const need = new Set(opts.requireApproval);
250
258
  const deny = new Set(opts.deny ?? []);
251
259
  const auto = new Set(opts.autoAllow ?? []);
260
+ const needCovering = mcpCoveringEntries(opts.requireApproval);
261
+ const denyCovering = mcpCoveringEntries(opts.deny);
262
+ const autoCovering = mcpCoveringEntries(opts.autoAllow);
252
263
  return {
253
264
  projection: {
254
265
  components: [
@@ -266,10 +277,10 @@ export function createApprovalPolicy(opts) {
266
277
  ],
267
278
  async check(req, signal) {
268
279
  const toolName = req.toolName;
269
- if (deny.has(toolName)) {
280
+ if (deny.has(toolName) || mcpCoveringHit(denyCovering, toolName)) {
270
281
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
271
282
  }
272
- if (need.has(toolName)) {
283
+ if (need.has(toolName) || mcpCoveringHit(needCovering, toolName)) {
273
284
  if (signal?.aborted) {
274
285
  return withCoreMintedResolution({ action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, settledBy: "aborted" }, "task_aborted", req);
275
286
  }
@@ -303,7 +314,7 @@ export function createApprovalPolicy(opts) {
303
314
  }
304
315
  return withCoreMintedResolution({ action: "deny", message: `approval denied for "${req.toolName}"`, settledBy: "human" }, "human_refused", req);
305
316
  }
306
- if (opts.denyByDefault && !auto.has(toolName)) {
317
+ if (opts.denyByDefault && !auto.has(toolName) && !mcpCoveringHit(autoCovering, toolName)) {
307
318
  return { action: "deny", message: `tool "${req.toolName}" requires explicit allow` };
308
319
  }
309
320
  return ALLOW;