@sema-agent/core 5.54.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 (85) hide show
  1. package/CHANGELOG.md +160 -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/send-message-tool.js +48 -2
  7. package/dist/agents/subagent.js +250 -89
  8. package/dist/agents/verify.d.ts +27 -3
  9. package/dist/agents/verify.js +7 -2
  10. package/dist/core/auto-compaction.d.ts +17 -4
  11. package/dist/core/auto-compaction.js +3 -0
  12. package/dist/core/context-edit.d.ts +55 -6
  13. package/dist/core/context-edit.js +12 -1
  14. package/dist/core/governance-codes.js +14 -0
  15. package/dist/core/hooks.d.ts +293 -11
  16. package/dist/core/hooks.js +159 -12
  17. package/dist/core/human-input-projection.d.ts +20 -2
  18. package/dist/core/human-input-projection.js +9 -0
  19. package/dist/core/lsp-diagnostics.d.ts +19 -17
  20. package/dist/core/lsp-diagnostics.js +11 -5
  21. package/dist/core/mcp.d.ts +46 -0
  22. package/dist/core/mcp.js +132 -6
  23. package/dist/core/memory-engine/consolidation.d.ts +378 -0
  24. package/dist/core/memory-engine/consolidation.js +342 -0
  25. package/dist/core/memory-engine/dual-root.js +3 -0
  26. package/dist/core/memory-engine/engine.d.ts +237 -4
  27. package/dist/core/memory-engine/engine.js +1111 -4
  28. package/dist/core/memory-engine/export-bundle.js +9 -0
  29. package/dist/core/memory-engine/file-backend.js +27 -1
  30. package/dist/core/memory-engine/frontmatter.d.ts +20 -1
  31. package/dist/core/memory-engine/frontmatter.js +111 -0
  32. package/dist/core/memory-engine/index.d.ts +4 -2
  33. package/dist/core/memory-engine/index.js +3 -1
  34. package/dist/core/memory-engine/memory-backend-contract.js +131 -0
  35. package/dist/core/memory-engine/sync-client.js +26 -0
  36. package/dist/core/memory-engine/tools.d.ts +9 -0
  37. package/dist/core/memory-engine/tools.js +57 -13
  38. package/dist/core/memory-engine/types.d.ts +99 -0
  39. package/dist/core/memory-recall.js +4 -3
  40. package/dist/core/memory.d.ts +33 -3
  41. package/dist/core/memory.js +6 -4
  42. package/dist/core/permission-rules.d.ts +30 -0
  43. package/dist/core/permission-rules.js +71 -8
  44. package/dist/core/reminder-disclosure.d.ts +29 -4
  45. package/dist/core/reminder-disclosure.js +60 -12
  46. package/dist/core/runner/prepare-memory.js +7 -2
  47. package/dist/core/runner/prepare-task.d.ts +39 -1
  48. package/dist/core/runner/prepare-task.js +63 -35
  49. package/dist/core/runner/runtask.d.ts +8 -1
  50. package/dist/core/runner/runtask.js +170 -31
  51. package/dist/core/runner/session-rule-policy.js +5 -3
  52. package/dist/core/runner/synthetic-tools.js +4 -2
  53. package/dist/core/runner/turn-attachments.d.ts +16 -6
  54. package/dist/core/runner/turn-attachments.js +34 -20
  55. package/dist/core/session-reconcile.d.ts +32 -0
  56. package/dist/core/session-reconcile.js +15 -0
  57. package/dist/core/task-notification.d.ts +34 -7
  58. package/dist/core/task-notification.js +11 -1
  59. package/dist/core/task-registry-agent.d.ts +20 -3
  60. package/dist/core/task-registry-agent.js +31 -2
  61. package/dist/core/tool-policy.d.ts +23 -0
  62. package/dist/core/tool-policy.js +29 -13
  63. package/dist/core/types.d.ts +126 -17
  64. package/dist/core/untrusted-egress.js +12 -2
  65. package/dist/core/untrusted-text.d.ts +189 -3
  66. package/dist/core/untrusted-text.js +424 -6
  67. package/dist/engine/compaction/compaction.d.ts +77 -7
  68. package/dist/engine/compaction/compaction.js +98 -9
  69. package/dist/engine/compaction/utils.d.ts +4 -0
  70. package/dist/engine/compaction/utils.js +6 -0
  71. package/dist/engine/harness/agent-harness.d.ts +84 -0
  72. package/dist/engine/harness/agent-harness.js +88 -12
  73. package/dist/engine/harness/messages.d.ts +4 -2
  74. package/dist/engine/harness/messages.js +7 -2
  75. package/dist/engine/harness/types.d.ts +11 -5
  76. package/dist/engine/loop/types.d.ts +14 -0
  77. package/dist/engine/session/import-validate.js +10 -0
  78. package/dist/engine/session/session.js +2 -2
  79. package/dist/index.d.ts +1 -1
  80. package/dist/index.js +1 -1
  81. package/dist/orchestration/run-spec.js +8 -1
  82. package/dist/prompts/default.d.ts +22 -6
  83. package/dist/tools/fs/index.d.ts +3 -1
  84. package/package.json +1 -1
  85. package/test/export-surface.snapshot.json +28 -1
@@ -1,9 +1,10 @@
1
1
  import { Type } from "typebox";
2
2
  import { errorResult } from "../tools.js";
3
- import { defuseFenceMarkers, delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
3
+ import { defuseFenceMarkers, delimitUntrusted, FENCED_LANE_ENVELOPE_TAGS, inlineUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
4
4
  import { formatMemoryAge } from "../memory-recall.js";
5
5
  import { committedOriginOf } from "./frontmatter.js";
6
6
  import { MEMORY_EXPOSURE_BANNER, MEMORY_EXPOSURE_HANDLE_TAG, MEMORY_PROVENANCE_SEARCH_SENTENCE, MEMORY_SEARCH_BAND_ORDER_HEADER } from "./provenance-wording.js";
7
+ import { deriveSupersededSet, memorySupersededNote, MEMORY_SEARCH_SUPERSEDED_TAG } from "./consolidation.js";
7
8
  export const MEMORY_SEARCH_TOOL_NAME = "memory_search";
8
9
  export const MEMORY_GET_TOOL_NAME = "memory_get";
9
10
  export const MEMORY_ENGINE_TOOL_NAMES = [MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME];
@@ -122,6 +123,13 @@ export function createMemoryEngineTools(opts) {
122
123
  const scopeSet = new Set(plane.scopes);
123
124
  return (await plane.backend.getByIds(ids)).filter((e) => scopeSet.has(e.scope) && e.frontmatter.deleted !== true);
124
125
  };
126
+ const supersededOf = async (plane, exclusions) => {
127
+ if (plane.scopes.length === 0)
128
+ return new Map();
129
+ const face = plane.backend.retrievalView?.() ?? plane.backend;
130
+ const headers = await face.listHeaders(plane.scopes);
131
+ return deriveSupersededSet(headers, { excluded: (id) => exclusions?.has(id) === true });
132
+ };
125
133
  const searchTool = {
126
134
  name: MEMORY_SEARCH_TOOL_NAME,
127
135
  description: carry ? `${SEARCH_DESCRIPTION}\n\n${MEMORY_PROVENANCE_SEARCH_SENTENCE}` : SEARCH_DESCRIPTION,
@@ -129,13 +137,16 @@ export function createMemoryEngineTools(opts) {
129
137
  defer: true,
130
138
  offload: false,
131
139
  contentOrigin: "local",
132
- contract: { contractId: "core.memory_search@1", implementationRevision: "4" },
140
+ contract: { contractId: "core.memory_search@1", implementationRevision: "5" },
133
141
  parameters: Type.Object({
134
142
  query: Type.String({ description: "Keywords to look for (lexical match against entry names, descriptions and bodies)." }),
135
143
  limit: Type.Optional(Type.Number({ description: `Maximum hits to return (default ${MEMORY_SEARCH_DEFAULT_LIMIT}, max ${MEMORY_SEARCH_MAX_LIMIT}).` })),
144
+ includeSuperseded: Type.Optional(Type.Boolean({
145
+ description: "Also return entries a newer consolidated entry has superseded (kept as evidence; hidden from results by default). Superseded hits are tagged, with the superseding entry's id.",
146
+ })),
136
147
  }, { additionalProperties: false }),
137
148
  execute: async (args, ctx) => {
138
- const { query, limit: rawLimit } = args;
149
+ const { query, limit: rawLimit, includeSuperseded } = args;
139
150
  const signal = ctx.signal;
140
151
  if (query.trim() === "") {
141
152
  return refusedSearch("empty_query", "query must be non-empty — pass the concrete keywords a past memory entry would contain.");
@@ -150,17 +161,32 @@ export function createMemoryEngineTools(opts) {
150
161
  return refusedSearch("challenge_ledger_unavailable", "Memory search is unavailable: the challenge ledger for a mounted memory plane cannot be read (fail-closed). Report this to the operator.", "failed");
151
162
  }
152
163
  }
164
+ const supersededMaps = [];
165
+ try {
166
+ for (let i = 0; i < planes.length; i++) {
167
+ const plane = planes[i];
168
+ supersededMaps.push(plane === undefined ? new Map() : await supersededOf(plane, exclusions[i]));
169
+ }
170
+ }
171
+ catch (err) {
172
+ if (signal?.aborted === true)
173
+ throw err;
174
+ return refusedSearch("error", "Memory search is unavailable: the header listing behind the superseded-entry filter could not be read. Try again, and report the failure if it persists.", "failed");
175
+ }
153
176
  const merged = [];
154
177
  try {
155
178
  for (let i = 0; i < planes.length; i++) {
156
179
  const plane = planes[i];
157
180
  if (plane.scopes.length === 0)
158
181
  continue;
159
- const askFor = carry ? limit + (exclusions[i]?.size ?? 0) : limit;
182
+ const supersededSlots = includeSuperseded === true ? 0 : supersededMaps[i]?.size ?? 0;
183
+ const askFor = (carry ? limit + (exclusions[i]?.size ?? 0) : limit) + supersededSlots;
160
184
  const hits = await plane.backend.search(query, plane.scopes, { limit: askFor, ...(carry ? { exposureBands: true } : {}) });
161
185
  for (const h of hits) {
162
186
  if (exclusions[i]?.has(h.id))
163
187
  continue;
188
+ if (includeSuperseded !== true && supersededMaps[i]?.has(h.id))
189
+ continue;
164
190
  merged.push({ ...h, planeIndex: i });
165
191
  }
166
192
  }
@@ -219,10 +245,12 @@ export function createMemoryEngineTools(opts) {
219
245
  for (let i = 0; i < live.length; i++) {
220
246
  const h = live[i];
221
247
  const exposed = exposedFlags[i];
248
+ const supersededBy = supersededMaps[h.planeIndex]?.get(h.id);
249
+ const supersededSuffix = supersededBy !== undefined ? ` ${MEMORY_SEARCH_SUPERSEDED_TAG}` : "";
222
250
  if (exposed) {
223
- hits.push({ exposure: "external", id: h.id, scope: h.scope, score: h.score, mtimeMs: h.mtimeMs, sizeBytes: h.sizeBytes });
251
+ hits.push({ exposure: "external", id: h.id, scope: h.scope, score: h.score, mtimeMs: h.mtimeMs, sizeBytes: h.sizeBytes, ...(supersededBy !== undefined ? { supersededBy } : {}) });
224
252
  lines.push("");
225
- lines.push(`${i + 1}. [mem:${h.id}] ${MEMORY_EXPOSURE_HANDLE_TAG} (scope ${inlineUntrusted(h.scope, 80)}, score ${h.score.toFixed(3)}, ${ageOf(now, h.mtimeMs)}) — read it with ${MEMORY_GET_TOOL_NAME} id ${h.id}`);
253
+ lines.push(`${i + 1}. [mem:${h.id}] ${MEMORY_EXPOSURE_HANDLE_TAG}${supersededSuffix} (scope ${inlineUntrusted(h.scope, 80)}, score ${h.score.toFixed(3)}, ${ageOf(now, h.mtimeMs)}) — read it with ${MEMORY_GET_TOOL_NAME} id ${h.id}`);
226
254
  continue;
227
255
  }
228
256
  hits.push({
@@ -234,10 +262,11 @@ export function createMemoryEngineTools(opts) {
234
262
  score: h.score,
235
263
  mtimeMs: h.mtimeMs,
236
264
  sizeBytes: h.sizeBytes,
265
+ ...(supersededBy !== undefined ? { supersededBy } : {}),
237
266
  });
238
267
  const hook = h.description ? ` — ${inlineUntrusted(h.description, 200)}` : "";
239
268
  lines.push("");
240
- lines.push(`${i + 1}. ${entryPath(h.scope, h.slug)}${hook} (id ${h.id}, score ${h.score.toFixed(3)}, ${ageOf(now, h.mtimeMs)})`);
269
+ lines.push(`${i + 1}. ${entryPath(h.scope, h.slug)}${supersededSuffix}${hook} (id ${h.id}, score ${h.score.toFixed(3)}, ${ageOf(now, h.mtimeMs)})`);
241
270
  const body = (entryById.get(h.id)?.body ?? "").trim();
242
271
  if (body !== "")
243
272
  lines.push(delimitUntrusted(`memory entry ${h.slug}`, body, MEMORY_SEARCH_SNIPPET_CAP));
@@ -253,7 +282,7 @@ export function createMemoryEngineTools(opts) {
253
282
  defer: true,
254
283
  offload: false,
255
284
  contentOrigin: "local",
256
- contract: { contractId: "core.memory_get@1", implementationRevision: "4" },
285
+ contract: { contractId: "core.memory_get@1", implementationRevision: "5" },
257
286
  parameters: Type.Object({
258
287
  id: Type.Optional(Type.String({ description: "Entry id (exact lookup). Pass either id or slug, not both." })),
259
288
  slug: Type.Optional(Type.String({ description: "Entry slug (its file path without .md). Ambiguous across scopes unless scope is also passed." })),
@@ -352,6 +381,15 @@ export function createMemoryEngineTools(opts) {
352
381
  });
353
382
  }
354
383
  const exposed = carry && exposedEntry(entry);
384
+ let supersededBy;
385
+ if (entryPlane !== undefined) {
386
+ try {
387
+ supersededBy = (await supersededOf(entryPlane, entryPlane.challengeExclusions?.())).get(entry.id);
388
+ }
389
+ catch {
390
+ supersededBy = undefined;
391
+ }
392
+ }
355
393
  try {
356
394
  entryPlane?.recordRetrieved([entry.id]);
357
395
  }
@@ -365,6 +403,7 @@ export function createMemoryEngineTools(opts) {
365
403
  const head = [
366
404
  `Memory entry ${entryPath(entry.scope, entry.slug)} (id ${entry.id}${mtimeMs !== undefined ? `, ${ageOf(now, mtimeMs)}` : ""})`,
367
405
  ...(exposed ? [MEMORY_EXPOSURE_BANNER] : []),
406
+ ...(supersededBy !== undefined ? [memorySupersededNote(supersededBy)] : []),
368
407
  ...(fm.name !== undefined ? [`name: ${inlineUntrusted(fm.name, 120)}`] : []),
369
408
  ...(fm.description !== undefined ? [`description: ${inlineUntrusted(fm.description, 200)}`] : []),
370
409
  ...(fm.type !== undefined ? [`type: ${inlineUntrusted(fm.type, 40)}`] : []),
@@ -380,8 +419,11 @@ export function createMemoryEngineTools(opts) {
380
419
  return refusedGet("taint_mark_failed", `Memory entry ${entry.id.slice(0, 64)} was not delivered: its external-origin take-up could not be recorded for this session (fail-closed). Report this to the operator and retry.`, "failed", { id: entry.id });
381
420
  }
382
421
  }
422
+ const neutralLines = defuseFenceMarkers(sanitizeUntrustedText(entry.body, [...FENCED_LANE_ENVELOPE_TAGS])).split("\n");
423
+ if (neutralLines.length > 0 && neutralLines[neutralLines.length - 1] === "")
424
+ neutralLines.pop();
383
425
  if (lineCursor > 0 && totalLines > 0) {
384
- const line = defuseFenceMarkers(sanitizeUntrustedText(allLines[offset]));
426
+ const line = neutralLines[offset];
385
427
  const tail = skipBytes(line, lineCursor);
386
428
  const cut = cutToBytes(tail.text, MEMORY_GET_PAGE_CAP_BYTES);
387
429
  const shown = Buffer.byteLength(cut.text, "utf8");
@@ -405,23 +447,24 @@ export function createMemoryEngineTools(opts) {
405
447
  lineCursor: tail.skippedBytes,
406
448
  ...(nextLineCursor !== undefined ? { nextLineCursor } : {}),
407
449
  ...(exposed ? { exposure: "external" } : {}),
450
+ ...(supersededBy !== undefined ? { supersededBy } : {}),
408
451
  };
409
452
  return { content: head.join("\n"), details };
410
453
  }
411
454
  const page = [];
412
455
  let bytes = 0;
413
- for (let i = offset; i < Math.min(totalLines, offset + limit); i++) {
414
- const lineBytes = Buffer.byteLength(allLines[i], "utf8") + 1;
456
+ for (const neutralizedLine of neutralLines.slice(offset, offset + limit)) {
457
+ const lineBytes = Buffer.byteLength(neutralizedLine, "utf8") + 1;
415
458
  if (page.length > 0 && bytes + lineBytes > MEMORY_GET_PAGE_CAP_BYTES)
416
459
  break;
417
- page.push(allLines[i]);
460
+ page.push(neutralizedLine);
418
461
  bytes += lineBytes;
419
462
  if (bytes > MEMORY_GET_PAGE_CAP_BYTES)
420
463
  break;
421
464
  }
422
465
  const end = offset + page.length;
423
466
  head.push(`body lines ${totalLines === 0 ? 0 : offset + 1}-${end} of ${totalLines}:`);
424
- const neutralized = defuseFenceMarkers(sanitizeUntrustedText(page.join("\n")));
467
+ const neutralized = defuseFenceMarkers(sanitizeUntrustedText(page.join("\n"), [...FENCED_LANE_ENVELOPE_TAGS]));
425
468
  const cut = cutToBytes(neutralized, MEMORY_GET_PAGE_CAP_BYTES);
426
469
  head.push(delimitUntrusted(`memory entry ${entry.slug}`, cut.text));
427
470
  let nextLineCursor;
@@ -446,6 +489,7 @@ export function createMemoryEngineTools(opts) {
446
489
  totalLines,
447
490
  ...(nextLineCursor !== undefined ? { nextLineCursor } : {}),
448
491
  ...(exposed ? { exposure: "external" } : {}),
492
+ ...(supersededBy !== undefined ? { supersededBy } : {}),
449
493
  };
450
494
  return { content: head.join("\n"), details };
451
495
  },
@@ -27,6 +27,55 @@ export interface MemoryEntryOrigin {
27
27
  cause?: MemoryOriginCause;
28
28
  at: number;
29
29
  }
30
+ /**
31
+ * design/339 §1.4 — one input-lineage row of a distilled (consolidation-produced) entry.
32
+ * `superseded: true` rows are the SUPERSESSION EDGES (this product replaces that input in the
33
+ * default read face — rev-anchored, see {@link MemoryEntryDistilled}); rows without the flag are
34
+ * evidence-only inputs (episodic sources, net-new distillation material) that are never suppressed.
35
+ */
36
+ export interface MemoryEntryDistilledInput {
37
+ /** The input entry's immutable id. */
38
+ id: string;
39
+ /** The input's committed rev AT PLAN FREEZE — the edge's rev anchor (design/339 D-2a): the edge
40
+ * is valid only while the target's committed rev still equals this, so a later legitimate edit
41
+ * of the superseded entry REVIVES it (new information must never stay suppressed). */
42
+ rev: string;
43
+ /** Present ⇔ this row is a supersession edge (the product replaces this input in the active set). */
44
+ superseded?: true;
45
+ }
46
+ /**
47
+ * design/339 §1.4 — the ENGINE-MINTED supersession/lineage carrier riding ON the distilled product
48
+ * entry itself (typed frontmatter member, the `origin` carriage family). The block is authored
49
+ * exclusively by the consolidation protocol's plan freeze — a harvest never adopts one off the
50
+ * model-writable plane (stripped with a report warning, same law as `origin`), and once committed
51
+ * it is immutable: every later op must carry it forward DEEP-EQUAL (strip or member rewrite =
52
+ * malformed-refusal; the one legal exit is the committed tombstone). Riding on the product makes
53
+ * the edge publish ATOMIC for free (per-entry atomicity = edge atomicity; a partial apply can never
54
+ * suppress a target whose replacement is missing — the edge IS the replacement's bytes), and makes
55
+ * the lineage TRAVEL with the entry across backend/sync/bundle seams (retroactive challenge
56
+ * propagation stays mechanical on a deployment that never saw the plan file).
57
+ */
58
+ export interface MemoryEntryDistilled {
59
+ /** Minting plan (audit anchor; opaque on a deployment the entry traveled to). */
60
+ planId: string;
61
+ /** Mint time, ms epoch — fixed at plan freeze (a replay re-adds the exact same bytes). */
62
+ at: number;
63
+ /**
64
+ * The CARRIER-side rev anchor (design/339 r3-2): the product's own committed rev at plan freeze.
65
+ * NON-CIRCULAR BY CONSTRUCTION: this member is excluded from the rev's canonical tuple
66
+ * ({@link import("./frontmatter.js").computeEntryRev}), so at freeze it simply EQUALS the entry's
67
+ * committed rev. An edge is valid only while the carrier's committed rev still equals this —
68
+ * a legitimate edit of the product (which may rewrite or retract what it integrated) SUSPENDS
69
+ * its edges and revives the inputs (the only accurate copy must never hide outside the default
70
+ * read face). Idempotent re-adds / deep-carrying updates don't move the rev, so untouched
71
+ * carriers keep their edges. Tamper is closed by the immutability law: a rewrite of this member
72
+ * is a malformed-refusal regardless of the rev arithmetic.
73
+ */
74
+ carrierRev: string;
75
+ /** The product's COMPLETE input lineage (id + freeze-time rev; `superseded` rows are the edges).
76
+ * Bounded at freeze by `maxInputsPerProduct` (§6.1). */
77
+ inputs: MemoryEntryDistilledInput[];
78
+ }
30
79
  /**
31
80
  * Structured header fields of one memory entry (the CC frontmatter shape: `name`/`description`/
32
81
  * `metadata.type`). `extra` preserves any additional frontmatter lines VERBATIM (round-trip safety:
@@ -93,6 +142,19 @@ export interface MemoryEntryFrontmatter {
93
142
  * deliberately no `"clean"` spelling to upgrade to (the `trust: "untrusted"` single-value law).
94
143
  */
95
144
  origin?: MemoryEntryOrigin;
145
+ /**
146
+ * design/339 §1.4 — the consolidation lineage/supersession carrier (see
147
+ * {@link MemoryEntryDistilled}). Engine-minted at plan freeze ONLY; model-written spellings
148
+ * (typed or suspect `extra` form) are stripped at harvest with a report warning, and a committed
149
+ * block is immutable under every touching op (deep-equal carry-forward, the `origin` I-2 seat's
150
+ * third member). Threat direction is the INVERSE of `origin`'s (design/339 §1.4.4): the
151
+ * dangerous act is ADDING an edge (a forged edge suppresses good memory — the delete-attack
152
+ * surface add-only exists to close), so only the pristine TYPED seat ever produces edges —
153
+ * suspect `extra`-form bytes are preserved verbatim but INERT (they mint no edge; fewer edges
154
+ * is the safe direction), while the immutability law still protects the committed typed block
155
+ * (stripping it would whitewash the product's lineage out of retroactive challenge reach).
156
+ */
157
+ distilled?: MemoryEntryDistilled;
96
158
  /** Unknown frontmatter lines preserved verbatim (order kept), re-serialized on projection. */
97
159
  extra?: string[];
98
160
  }
@@ -134,6 +196,23 @@ export interface MemoryEntryHeader {
134
196
  * question. Absent ⇔ no marker (the entry was never judged exposed — not "proven clean").
135
197
  */
136
198
  exposure?: "external";
199
+ /**
200
+ * design/339 §4.1-3 — the committed DISTILLED fact, carried on BOTH header faces (`listHeaders`
201
+ * and `search`, one projection): present ⇔ the committed entry carries a typed `distilled` block.
202
+ * `supersedes` is the block's edge PROJECTION (the `superseded: true` input rows — the minimal
203
+ * set the engine's active-set derivation needs; the full lineage stays off the header, payload
204
+ * bounded), `carrierRev` the block's carrier-side rev anchor (edge validity needs it beside the
205
+ * header's own `rev`). A DATA fact, not a mode — the backend reports what is stored; active-set
206
+ * derivation and every read-face treatment live in the engine/tool layer (same law as
207
+ * {@link exposure}).
208
+ */
209
+ distilled?: {
210
+ carrierRev: string;
211
+ supersedes: Array<{
212
+ id: string;
213
+ rev: string;
214
+ }>;
215
+ };
137
216
  }
138
217
  /** A scored search hit (FileBackend = lexical floor; PgBackend = design/81 vector rungs, S3). */
139
218
  export interface ScoredMemoryEntry extends MemoryEntryHeader {
@@ -254,10 +333,19 @@ export interface MemoryBackend {
254
333
  * earlier delete in the same batch does not blank the baseline). The one legal exit is the
255
334
  * COMMITTED tombstone: after a delete commits, the marker's life ends with the id (a fresh id —
256
335
  * or the same id in a LATER batch — starts an unmarked life; the engine re-judges its session);
336
+ * - design/339 §4.1-1 `distilled` 不可变律 (the I-2 seat's third member): when the id's PRE-BATCH
337
+ * committed state carries a typed `frontmatter.distilled` block, ANY op spelling that touches
338
+ * the id (`update`, plain re-`add`, `guard: "absent"` add, same-batch delete-then-re-add) must
339
+ * carry it forward DEEP-EQUAL (planId + at + carrierRev + every input row, order included); a
340
+ * strip or member rewrite is refused with reason matching /malformed patch refused/. Judged
341
+ * against the PRE-BATCH committed state; the one legal exit is the COMMITTED tombstone (the
342
+ * block's life ends with the id). Suspect `extra`-form distilled bytes are NOT this law's
343
+ * subject (they are inert verbatim leftovers — they mint no edge and carry like any `extra`);
257
344
  * - PRECEDENCE (FAM-1 #5, every whitewash spelling): the malformed judgment answers BEFORE
258
345
  * guard/CAS arithmetic — a strip riding a stale `baseRev` (or a guard conflict) still answers
259
346
  * /malformed patch refused/, never the ordinary rev-mismatch conflict (a whitewash is illegal
260
347
  * at ANY rev, and the weaker conflict would tell the caller's ladder to rebase and retry it);
348
+ * design/339 c37: the same precedence covers the `distilled` law;
261
349
  * - design/336 §4 hold protocol: the backend is hold-UNAWARE — instruction holds live on the
262
350
  * ENGINE's control plane, so a held id keeps serving reads and ordinary add/update patches for
263
351
  * it apply plainly (no queueing, no refusal). The settlement-period write interaction resolves
@@ -460,6 +548,17 @@ export interface HarvestReport {
460
548
  * (`memory.hold_opened` / `memory.hold_released` / `memory.hold_disposed`) is derived from the
461
549
  * three hold seats.
462
550
  */
551
+ /**
552
+ * design/339 §2.2 — the consolidation RECOMMENDATION edge (advisory, additive): this harvest's
553
+ * engine-minted session count crossed the configured thresholds (time gate open ∧ enough
554
+ * distinct sessions ∧ the once-per-crossing edge armed). Present at most once per crossing;
555
+ * absent always when consolidation is OFF (the default — zero gate state exists then). The
556
+ * recommendation NEVER executes anything: the host owns the verbs.
557
+ */
558
+ consolidationRecommended?: {
559
+ scope: string;
560
+ sessionsSince: number;
561
+ };
463
562
  containment?: {
464
563
  /** The derived index (MEMORY.md) was restored to its materialize-time baseline this harvest
465
564
  * (the session's prose additions were captured and dropped). */
@@ -1,3 +1,4 @@
1
+ import { MEMORY_INJECTION_TAGS } from "./memory.js";
1
2
  import { sanitizeUntrustedText } from "./untrusted-text.js";
2
3
  export const DEFAULT_MAX_SELECTED = 5;
3
4
  export const DEFAULT_MAX_LINKED = 3;
@@ -107,8 +108,8 @@ export function composeSelectiveBody(manifestText, selected, nowMs, linked = [],
107
108
  const ageMs = nowMs - r.mtimeMs;
108
109
  const verify = r.timestampMissing || ageMs > ONE_DAY_MS ? " — verify it's still current" : "";
109
110
  const stale = r.timestampMissing ? ` (write time unknown${verify})` : ` (written ${formatMemoryAge(ageMs)}${verify})`;
110
- const prefix = label ? `${sanitizeUntrustedText(label, ["user_memory"])} ` : "";
111
- return `- ${prefix}${sanitizeUntrustedText(r.text, ["user_memory"])}${stale}`;
111
+ const prefix = label ? `${sanitizeUntrustedText(label, [...MEMORY_INJECTION_TAGS])} ` : "";
112
+ return `- ${prefix}${sanitizeUntrustedText(r.text, [...MEMORY_INJECTION_TAGS])}${stale}`;
112
113
  };
113
114
  let body = selected.map((r) => renderNote(r)).join("\n");
114
115
  if (linked.length > 0) {
@@ -126,7 +127,7 @@ export function composeSelectiveBody(manifestText, selected, nowMs, linked = [],
126
127
  const selectedSection = selected.length > 0 ? `\n\nRelevant notes:\n${body}` : "\n\n(no notes selected as relevant to this task)";
127
128
  const recallCall = recallToolName !== undefined ? `call ${recallToolName}` : `search the memory-recall tool bound to this scope`;
128
129
  const manifestSection = recallable
129
- ? `Memory index — to load a note in full, ${recallCall} with keywords from its description:\n${sanitizeUntrustedText(manifestText, ["user_memory"])}`
130
+ ? `Memory index — to load a note in full, ${recallCall} with keywords from its description:\n${sanitizeUntrustedText(manifestText, [...MEMORY_INJECTION_TAGS])}`
130
131
  : "";
131
132
  return `${manifestSection}${selectedSection}`;
132
133
  }
@@ -541,12 +541,42 @@ export declare function normalizeMemorySpec(input: MemorySpecInput | undefined):
541
541
  * Exported so the inject-all de-dup seeding (prepare-task.ts) can detect truncation and NOT seed dropped
542
542
  * (truncated-out) note ids as "already surfaced" — see the MINOR-1 audit fix there. */
543
543
  export declare const MAX_MEMORY_BYTES: number;
544
- /** The wrapper-tag family the memory injection fences neutralize (`<system-reminder>` is ALWAYS
544
+ /** The wrapper-tag family the memory ADMISSION gate rejects on (`<system-reminder>` is ALWAYS
545
545
  * included by {@link sanitizeUntrustedText} itself). Single source (design/138 S2-C, C-F10/O-F11):
546
546
  * the memory write-time scan (`memory-engine/scan.ts`) detects break-out markup by diffing
547
- * `sanitizeUntrustedText(text, MEMORY_WRAPPER_TAGS)` against the raw text, so the scan rules and
548
- * the ACTUAL injection boundary (this file's compose* fences) can never drift apart. */
547
+ * `sanitizeUntrustedText(text, MEMORY_WRAPPER_TAGS)` against the raw text.
548
+ *
549
+ * ADMISSION half — deliberately UNCHANGED at the single outer wrapper. See
550
+ * {@link MEMORY_INJECTION_TAGS} for the (wider) containment half and for why the two are allowed to
551
+ * differ in exactly one direction. */
549
552
  export declare const MEMORY_WRAPPER_TAGS: readonly string[];
553
+ /**
554
+ * The tag family the memory injection fences actually NEUTRALIZE — the whole authority-envelope
555
+ * census (untrusted-text.ts `ENGINE_ENVELOPES`), a deliberate SUPERSET of {@link MEMORY_WRAPPER_TAGS}.
556
+ *
557
+ * Two reproduced findings drove the widening:
558
+ * ① `<scope>` — {@link composeLayeredMemoryBlock} renders a `<scope name="…">` SUB-ENVELOPE per
559
+ * layer, but the neutralized list named only the outer wrapper, so a layer payload could close its
560
+ * own `</scope>` and open a fresh `<scope name="managed">`: a forged higher-authority layer landing
561
+ * at the END of the block, where this function's own contract puts the highest-priority layer.
562
+ * Project-scope content is repository-controlled by our own stated posture
563
+ * (PROJECT_CONTEXT_FRAMING), i.e. attacker-reachable.
564
+ * ② the SIBLING envelopes — nesting a forged `<task-notification>` or `<new-diagnostics>` inside
565
+ * `<user_memory>` does not make it inert: the TAG is what a model reads as harness speech, and
566
+ * this payload lands in the SYSTEM PROMPT. Widening is safe precisely here because the sanitizer
567
+ * runs on the PAYLOAD, before this file assembles the engine's own wrappers around it — it can
568
+ * never defuse our own fences.
569
+ *
570
+ * WHY THE ADMISSION LIST DID NOT WIDEN WITH IT (the asymmetry is the point). The write scan
571
+ * (`memory-engine/scan.ts`) diffs against {@link MEMORY_WRAPPER_TAGS} and REFUSES on a difference,
572
+ * and it runs on re-serialization / sync / import paths as well as on fresh writes. Widening it would
573
+ * retroactively refuse ALREADY-STORED entries — and `<scope>test</scope>` is a Maven POM element, so
574
+ * the false-rejection is not hypothetical. The drift therefore runs in the one safe direction:
575
+ * containment ⊇ admission. A note the gate accepts is neutralized on the way in to the prompt; the
576
+ * dangerous drift would be the reverse (admission accepting what containment does not defuse), and
577
+ * that one is structurally impossible while this constant is a superset.
578
+ */
579
+ export declare const MEMORY_INJECTION_TAGS: readonly string[];
550
580
  /**
551
581
  * Wrap memory content in a `<user_memory>` block. This is the **variable tail** of the system prompt
552
582
  * (per-user, scoped, timestamped) — the default prompt provider places it LAST so the stable base
@@ -1,7 +1,8 @@
1
1
  import { uuidv7 } from "../internal/harness.js";
2
2
  import { parseScopeKey } from "./memory-engine/scope-contract.js";
3
3
  import { termSet } from "./memory-vector.js";
4
- import { sanitizeUntrustedText } from "./untrusted-text.js";
4
+ import { attrEscape } from "./task-notification.js";
5
+ import { sanitizeUntrustedText, ENGINE_AUTHORITY_ENVELOPE_TAGS } from "./untrusted-text.js";
5
6
  export function supportsConsolidation(store) {
6
7
  return (typeof store.searchScored === "function" &&
7
8
  typeof store.update === "function" &&
@@ -402,6 +403,7 @@ export function normalizeMemorySpec(input) {
402
403
  }
403
404
  export const MAX_MEMORY_BYTES = 100 * 1024;
404
405
  export const MEMORY_WRAPPER_TAGS = ["user_memory"];
406
+ export const MEMORY_INJECTION_TAGS = [...ENGINE_AUTHORITY_ENVELOPE_TAGS];
405
407
  export function composeMemoryBlock(content, scope) {
406
408
  const trimmed = (content ?? "").trim();
407
409
  if (!trimmed) {
@@ -418,7 +420,7 @@ export function composeMemoryBlock(content, scope) {
418
420
  const head = buf.subarray(0, cut).toString("utf8");
419
421
  payload = `${head}\n<truncated bytes=${bytes - cut}>`;
420
422
  }
421
- return `<user_memory scope="${scope}">\n${sanitizeUntrustedText(payload, [...MEMORY_WRAPPER_TAGS])}\n</user_memory>`;
423
+ return `<user_memory scope="${attrEscape(scope)}">\n${sanitizeUntrustedText(payload, [...MEMORY_INJECTION_TAGS])}\n</user_memory>`;
422
424
  }
423
425
  export function composeLayeredMemoryBlock(layers) {
424
426
  const sections = [];
@@ -435,8 +437,8 @@ export function composeLayeredMemoryBlock(layers) {
435
437
  cut--;
436
438
  payload = `${buf.subarray(0, cut).toString("utf8")}\n<truncated bytes=${bytes - cut}>`;
437
439
  }
438
- sections.push(`<scope name="${sanitizeUntrustedText(layer.scope, [...MEMORY_WRAPPER_TAGS])}">\n` +
439
- `${sanitizeUntrustedText(payload, [...MEMORY_WRAPPER_TAGS])}\n` +
440
+ sections.push(`<scope name="${attrEscape(layer.scope)}">\n` +
441
+ `${sanitizeUntrustedText(payload, [...MEMORY_INJECTION_TAGS])}\n` +
440
442
  `</scope>`);
441
443
  }
442
444
  if (sections.length === 0)
@@ -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";
@@ -104,6 +111,29 @@ export declare function parsePermissionRule(rule: string): ParsedPermissionRule;
104
111
  * anchored `^…$`+dotAll construction: every non-`*` char is literal, `*` spans anything incl. newlines).
105
112
  */
106
113
  export declare function wildcardMatch(pattern: string, value: string): boolean;
114
+ /**
115
+ * Does a namespaced RULE name cover `toolName`? — the tail branch of CC's rule matcher `VRp`
116
+ * (:595780), which is UNCONDITIONAL there (its `globMatching` option gates only the whole-name glob
117
+ * branch, so CC's allow lane — `mVo` :595803, which passes no options — matches MCP segments too).
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.
124
+ * A LITERAL tool segment is deliberately NOT covered here: it is an exact tool name, and the exact
125
+ * table already answers it — the same split CC makes with its leading `ruleName === toolName`.
126
+ *
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.
131
+ */
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;
107
137
  /** Exported for the lockstep guard only (see {@link FILE_TOOL_PARAMS}) — not part of the rule DSL. */
108
138
  export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
109
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,6 +77,36 @@ export function wildcardMatch(pattern, value) {
77
77
  p++;
78
78
  return p === pattern.length;
79
79
  }
80
+ function parseNamespacedRuleName(name) {
81
+ const ns = protocolOf(name);
82
+ if (ns === undefined)
83
+ return undefined;
84
+ const segments = name.slice(ns.prefix.length).split("__");
85
+ const peer = segments[0];
86
+ if (peer === undefined || peer === "")
87
+ return undefined;
88
+ const rest = segments.slice(1);
89
+ return rest.length > 0 ? { protocol: ns.id, peer, tool: rest.join("__") } : { protocol: ns.id, peer };
90
+ }
91
+ export function namespacedRuleNameCovers(ruleName, toolName) {
92
+ const rule = parseNamespacedRuleName(ruleName);
93
+ if (rule === undefined)
94
+ return false;
95
+ const target = parseNamespacedRuleName(toolName);
96
+ if (target === undefined)
97
+ return false;
98
+ if (rule.protocol !== target.protocol)
99
+ return false;
100
+ if (rule.peer !== target.peer)
101
+ return false;
102
+ if (rule.tool === undefined || rule.tool === "*")
103
+ return true;
104
+ return target.tool !== undefined && rule.tool.includes("*") && wildcardMatch(rule.tool, target.tool);
105
+ }
106
+ export function isNamespacedCoveringRuleName(name) {
107
+ const parsed = parseNamespacedRuleName(name);
108
+ return parsed !== undefined && (parsed.tool === undefined || parsed.tool.includes("*"));
109
+ }
80
110
  const PRIMARY_FIELDS = {
81
111
  Bash: new Set(["command"]),
82
112
  Read: new Set(["file_path", "path"]),
@@ -111,12 +141,13 @@ function countStars(s) {
111
141
  }
112
142
  function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
113
143
  const byTool = new Map();
144
+ const namespacedCovering = { deny: [], ask: [], allow: [] };
114
145
  const issues = [];
115
146
  const bad = (rule, code, message) => {
116
147
  issues.push({ rule, code, message });
117
148
  };
118
149
  if (rules.length > caps.maxRules) {
119
- return { byTool, 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}` }] };
120
151
  }
121
152
  for (const r of rules) {
122
153
  const text = r.rule;
@@ -143,12 +174,22 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
143
174
  }
144
175
  }
145
176
  const toolName = parsed.toolName;
146
- 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) {
147
179
  bad(text, "unsupported.mcp_paren", "MCP rules do not support patterns in parentheses (CC parity); use the toolAxes system");
148
180
  continue;
149
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
+ }
150
187
  const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] } };
151
188
  if (parsed.ruleContent === undefined) {
189
+ if (isNamespacedCoveringRuleName(toolName)) {
190
+ namespacedCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
191
+ continue;
192
+ }
152
193
  entry.bare[r.behavior] = { ruleText: text, source: r.source };
153
194
  byTool.set(toolName, entry);
154
195
  continue;
@@ -206,7 +247,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
206
247
  entry.param[r.behavior].push({ param, pattern, behavior: r.behavior, ruleText: text, source: r.source });
207
248
  byTool.set(toolName, entry);
208
249
  }
209
- return { byTool, issues };
250
+ return { byTool, namespacedCovering, issues };
210
251
  }
211
252
  export function validatePermissionRules(rules, opts) {
212
253
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
@@ -240,7 +281,7 @@ function ruleMessage(kind, ruleText, source) {
240
281
  }
241
282
  export function createPermissionRulePolicy(rules, opts) {
242
283
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
243
- const { byTool, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
284
+ const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
244
285
  const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === "");
245
286
  if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) {
246
287
  const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` +
@@ -274,14 +315,26 @@ export function createPermissionRulePolicy(rules, opts) {
274
315
  if (entry.bare.allow !== undefined)
275
316
  nameSets.allow.push(toolName);
276
317
  }
318
+ for (const behavior of ["deny", "ask", "allow"]) {
319
+ for (const r of namespacedCovering[behavior])
320
+ nameSets[behavior].push(r.ruleName);
321
+ }
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;
277
324
  return {
278
325
  nameSets: [nameSets],
279
326
  check(req) {
280
327
  const entry = byTool.get(req.toolName);
328
+ const covering = hasCovering && protocolOf(req.toolName) !== undefined;
329
+ if (entry?.bare.deny) {
330
+ return { action: "deny", message: ruleMessage("denied", entry.bare.deny.ruleText, entry.bare.deny.source) };
331
+ }
332
+ if (covering) {
333
+ const coveringDeny = coveringHit(namespacedCovering.deny, req.toolName);
334
+ if (coveringDeny)
335
+ return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
336
+ }
281
337
  if (entry) {
282
- if (entry.bare.deny) {
283
- return { action: "deny", message: ruleMessage("denied", entry.bare.deny.ruleText, entry.bare.deny.source) };
284
- }
285
338
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
286
339
  if (paramDeny) {
287
340
  return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
@@ -289,6 +342,13 @@ export function createPermissionRulePolicy(rules, opts) {
289
342
  if (entry.bare.ask) {
290
343
  return { action: "ask", message: ruleMessage("flagged", entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
291
344
  }
345
+ }
346
+ if (covering) {
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 };
350
+ }
351
+ if (entry) {
292
352
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
293
353
  if (paramAsk) {
294
354
  return { action: "ask", message: ruleMessage("flagged", paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
@@ -297,6 +357,9 @@ export function createPermissionRulePolicy(rules, opts) {
297
357
  return { action: "allow" };
298
358
  }
299
359
  }
360
+ if (covering && coveringHit(namespacedCovering.allow, req.toolName)) {
361
+ return { action: "allow" };
362
+ }
300
363
  if (defaultAction === "allow")
301
364
  return { action: "allow" };
302
365
  return { action: defaultAction, message: `no permission rule matched "${req.toolName}" (default-${defaultAction} rule set)` };