@sema-agent/core 5.54.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.
- package/CHANGELOG.md +94 -0
- package/dist/agents/cumulative-stats.d.ts +26 -0
- package/dist/agents/cumulative-stats.js +56 -0
- package/dist/agents/observer.d.ts +11 -7
- package/dist/agents/observer.js +2 -4
- package/dist/agents/verify.d.ts +27 -3
- package/dist/agents/verify.js +7 -2
- package/dist/core/governance-codes.js +14 -0
- package/dist/core/hooks.js +1 -1
- package/dist/core/lsp-diagnostics.d.ts +19 -17
- package/dist/core/lsp-diagnostics.js +11 -5
- package/dist/core/mcp.d.ts +46 -0
- package/dist/core/mcp.js +132 -6
- package/dist/core/memory-engine/consolidation.d.ts +378 -0
- package/dist/core/memory-engine/consolidation.js +342 -0
- package/dist/core/memory-engine/dual-root.js +3 -0
- package/dist/core/memory-engine/engine.d.ts +237 -4
- package/dist/core/memory-engine/engine.js +1111 -4
- package/dist/core/memory-engine/export-bundle.js +9 -0
- package/dist/core/memory-engine/file-backend.js +27 -1
- package/dist/core/memory-engine/frontmatter.d.ts +20 -1
- package/dist/core/memory-engine/frontmatter.js +111 -0
- package/dist/core/memory-engine/index.d.ts +4 -2
- package/dist/core/memory-engine/index.js +3 -1
- package/dist/core/memory-engine/memory-backend-contract.js +131 -0
- package/dist/core/memory-engine/sync-client.js +26 -0
- package/dist/core/memory-engine/tools.d.ts +9 -0
- package/dist/core/memory-engine/tools.js +57 -13
- package/dist/core/memory-engine/types.d.ts +99 -0
- package/dist/core/memory-recall.js +4 -3
- package/dist/core/memory.d.ts +33 -3
- package/dist/core/memory.js +6 -4
- package/dist/core/permission-rules.d.ts +22 -0
- package/dist/core/permission-rules.js +60 -6
- package/dist/core/reminder-disclosure.d.ts +29 -4
- package/dist/core/reminder-disclosure.js +60 -12
- package/dist/core/runner/prepare-memory.js +7 -2
- package/dist/core/runner/prepare-task.d.ts +31 -1
- package/dist/core/runner/prepare-task.js +31 -14
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/runner/runtask.js +12 -10
- package/dist/core/runner/session-rule-policy.js +5 -3
- package/dist/core/runner/synthetic-tools.js +4 -2
- package/dist/core/runner/turn-attachments.d.ts +16 -6
- package/dist/core/runner/turn-attachments.js +34 -20
- package/dist/core/tool-policy.d.ts +18 -0
- package/dist/core/tool-policy.js +19 -8
- package/dist/core/types.d.ts +89 -6
- package/dist/core/untrusted-egress.js +12 -2
- package/dist/core/untrusted-text.d.ts +189 -3
- package/dist/core/untrusted-text.js +416 -6
- package/dist/engine/loop/types.d.ts +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/prompts/default.d.ts +12 -2
- package/dist/tools/fs/index.d.ts +3 -1
- package/package.json +1 -1
- 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: "
|
|
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
|
|
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: "
|
|
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 =
|
|
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 (
|
|
414
|
-
const lineBytes = Buffer.byteLength(
|
|
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(
|
|
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, [
|
|
111
|
-
return `- ${prefix}${sanitizeUntrustedText(r.text, [
|
|
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, [
|
|
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
|
}
|
package/dist/core/memory.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
548
|
-
*
|
|
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
|
package/dist/core/memory.js
CHANGED
|
@@ -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 {
|
|
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, [...
|
|
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="${
|
|
439
|
-
`${sanitizeUntrustedText(payload, [...
|
|
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)
|
|
@@ -104,6 +104,28 @@ export declare function parsePermissionRule(rule: string): ParsedPermissionRule;
|
|
|
104
104
|
* anchored `^…$`+dotAll construction: every non-`*` char is literal, `*` spans anything incl. newlines).
|
|
105
105
|
*/
|
|
106
106
|
export declare function wildcardMatch(pattern: string, value: string): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Does an MCP-namespaced RULE name cover `toolName`? — the tail branch of CC's rule matcher `VRp`
|
|
109
|
+
* (:595780), which is UNCONDITIONAL there (its `globMatching` option gates only the whole-name glob
|
|
110
|
+
* branch, so CC's allow lane — `mVo` :595803, which passes no options — matches MCP segments too).
|
|
111
|
+
* Three covering forms, matching CC's three disjuncts:
|
|
112
|
+
* · `mcp__<server>` — every tool of that server (rule tool segment absent);
|
|
113
|
+
* · `mcp__<server>__*` — the same set, spelled with the star CC's own settings validator
|
|
114
|
+
* recommends (`"MCP rules do not support patterns in parentheses …
|
|
115
|
+
* use mcp__srv__*"`);
|
|
116
|
+
* · `mcp__<server>__get_*` — a glob over the tool segment.
|
|
117
|
+
* A LITERAL tool segment is deliberately NOT covered here: it is an exact tool name, and the exact
|
|
118
|
+
* table already answers it — the same split CC makes with its leading `ruleName === toolName`.
|
|
119
|
+
*
|
|
120
|
+
* Server segments compare EXACTLY (CC `s.serverName === a.serverName`). A rule must therefore spell
|
|
121
|
+
* the server the way the mint does; that is the same string an operator reads off any mounted tool
|
|
122
|
+
* name, and inventing a fuzzier comparison here would let one rule reach a server it does not name.
|
|
123
|
+
*/
|
|
124
|
+
export declare function mcpRuleNameCovers(ruleName: string, toolName: string): boolean;
|
|
125
|
+
/** True ⇔ this rule name is an MCP name whose reach is a SET of tools rather than one exact tool —
|
|
126
|
+
* i.e. the shapes {@link mcpRuleNameCovers} answers and an exact name table cannot. Every lane that
|
|
127
|
+
* keys rules by exact tool name needs this to know which of its entries it must NOT key that way. */
|
|
128
|
+
export declare function isMcpCoveringRuleName(name: string): boolean;
|
|
107
129
|
/** Exported for the lockstep guard only (see {@link FILE_TOOL_PARAMS}) — not part of the rule DSL. */
|
|
108
130
|
export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
|
|
109
131
|
/** Dry-run 校验/迁移报告(codex 127 审 B5):不 throw,返回全部 issues(含 `unsupported.*` 分类,
|
|
@@ -77,6 +77,33 @@ export function wildcardMatch(pattern, value) {
|
|
|
77
77
|
p++;
|
|
78
78
|
return p === pattern.length;
|
|
79
79
|
}
|
|
80
|
+
function parseMcpRuleName(name) {
|
|
81
|
+
if (!name.startsWith(MCP_NAMESPACE.prefix))
|
|
82
|
+
return undefined;
|
|
83
|
+
const segments = name.slice(MCP_NAMESPACE.prefix.length).split("__");
|
|
84
|
+
const server = segments[0];
|
|
85
|
+
if (server === undefined || server === "")
|
|
86
|
+
return undefined;
|
|
87
|
+
const rest = segments.slice(1);
|
|
88
|
+
return rest.length > 0 ? { server, tool: rest.join("__") } : { server };
|
|
89
|
+
}
|
|
90
|
+
export function mcpRuleNameCovers(ruleName, toolName) {
|
|
91
|
+
const rule = parseMcpRuleName(ruleName);
|
|
92
|
+
if (rule === undefined)
|
|
93
|
+
return false;
|
|
94
|
+
const target = parseMcpRuleName(toolName);
|
|
95
|
+
if (target === undefined)
|
|
96
|
+
return false;
|
|
97
|
+
if (rule.server !== target.server)
|
|
98
|
+
return false;
|
|
99
|
+
if (rule.tool === undefined || rule.tool === "*")
|
|
100
|
+
return true;
|
|
101
|
+
return target.tool !== undefined && rule.tool.includes("*") && wildcardMatch(rule.tool, target.tool);
|
|
102
|
+
}
|
|
103
|
+
export function isMcpCoveringRuleName(name) {
|
|
104
|
+
const parsed = parseMcpRuleName(name);
|
|
105
|
+
return parsed !== undefined && (parsed.tool === undefined || parsed.tool.includes("*"));
|
|
106
|
+
}
|
|
80
107
|
const PRIMARY_FIELDS = {
|
|
81
108
|
Bash: new Set(["command"]),
|
|
82
109
|
Read: new Set(["file_path", "path"]),
|
|
@@ -111,12 +138,13 @@ function countStars(s) {
|
|
|
111
138
|
}
|
|
112
139
|
function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
|
|
113
140
|
const byTool = new Map();
|
|
141
|
+
const mcpCovering = { deny: [], ask: [], allow: [] };
|
|
114
142
|
const issues = [];
|
|
115
143
|
const bad = (rule, code, message) => {
|
|
116
144
|
issues.push({ rule, code, message });
|
|
117
145
|
};
|
|
118
146
|
if (rules.length > caps.maxRules) {
|
|
119
|
-
return { byTool, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] };
|
|
147
|
+
return { byTool, mcpCovering, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] };
|
|
120
148
|
}
|
|
121
149
|
for (const r of rules) {
|
|
122
150
|
const text = r.rule;
|
|
@@ -149,6 +177,10 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
|
|
|
149
177
|
}
|
|
150
178
|
const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] } };
|
|
151
179
|
if (parsed.ruleContent === undefined) {
|
|
180
|
+
if (isMcpCoveringRuleName(toolName)) {
|
|
181
|
+
mcpCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
152
184
|
entry.bare[r.behavior] = { ruleText: text, source: r.source };
|
|
153
185
|
byTool.set(toolName, entry);
|
|
154
186
|
continue;
|
|
@@ -206,7 +238,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
|
|
|
206
238
|
entry.param[r.behavior].push({ param, pattern, behavior: r.behavior, ruleText: text, source: r.source });
|
|
207
239
|
byTool.set(toolName, entry);
|
|
208
240
|
}
|
|
209
|
-
return { byTool, issues };
|
|
241
|
+
return { byTool, mcpCovering, issues };
|
|
210
242
|
}
|
|
211
243
|
export function validatePermissionRules(rules, opts) {
|
|
212
244
|
const caps = { ...DEFAULT_CAPS, ...opts?.caps };
|
|
@@ -240,7 +272,7 @@ function ruleMessage(kind, ruleText, source) {
|
|
|
240
272
|
}
|
|
241
273
|
export function createPermissionRulePolicy(rules, opts) {
|
|
242
274
|
const caps = { ...DEFAULT_CAPS, ...opts?.caps };
|
|
243
|
-
const { byTool, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
|
|
275
|
+
const { byTool, mcpCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
|
|
244
276
|
const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === "");
|
|
245
277
|
if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) {
|
|
246
278
|
const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` +
|
|
@@ -274,14 +306,26 @@ export function createPermissionRulePolicy(rules, opts) {
|
|
|
274
306
|
if (entry.bare.allow !== undefined)
|
|
275
307
|
nameSets.allow.push(toolName);
|
|
276
308
|
}
|
|
309
|
+
for (const behavior of ["deny", "ask", "allow"]) {
|
|
310
|
+
for (const r of mcpCovering[behavior])
|
|
311
|
+
nameSets[behavior].push(r.ruleName);
|
|
312
|
+
}
|
|
313
|
+
const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => mcpRuleNameCovers(r.ruleName, toolName));
|
|
314
|
+
const hasCovering = mcpCovering.deny.length > 0 || mcpCovering.ask.length > 0 || mcpCovering.allow.length > 0;
|
|
277
315
|
return {
|
|
278
316
|
nameSets: [nameSets],
|
|
279
317
|
check(req) {
|
|
280
318
|
const entry = byTool.get(req.toolName);
|
|
319
|
+
const covering = hasCovering && req.toolName.startsWith(MCP_NAMESPACE.prefix);
|
|
320
|
+
if (entry?.bare.deny) {
|
|
321
|
+
return { action: "deny", message: ruleMessage("denied", entry.bare.deny.ruleText, entry.bare.deny.source) };
|
|
322
|
+
}
|
|
323
|
+
if (covering) {
|
|
324
|
+
const mcpDeny = coveringHit(mcpCovering.deny, req.toolName);
|
|
325
|
+
if (mcpDeny)
|
|
326
|
+
return { action: "deny", message: ruleMessage("denied", mcpDeny.ruleText, mcpDeny.source) };
|
|
327
|
+
}
|
|
281
328
|
if (entry) {
|
|
282
|
-
if (entry.bare.deny) {
|
|
283
|
-
return { action: "deny", message: ruleMessage("denied", entry.bare.deny.ruleText, entry.bare.deny.source) };
|
|
284
|
-
}
|
|
285
329
|
const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
|
|
286
330
|
if (paramDeny) {
|
|
287
331
|
return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
|
|
@@ -289,6 +333,13 @@ export function createPermissionRulePolicy(rules, opts) {
|
|
|
289
333
|
if (entry.bare.ask) {
|
|
290
334
|
return { action: "ask", message: ruleMessage("flagged", entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
|
|
291
335
|
}
|
|
336
|
+
}
|
|
337
|
+
if (covering) {
|
|
338
|
+
const mcpAsk = coveringHit(mcpCovering.ask, req.toolName);
|
|
339
|
+
if (mcpAsk)
|
|
340
|
+
return { action: "ask", message: ruleMessage("flagged", mcpAsk.ruleText, mcpAsk.source), matchedAskRule: mcpAsk.ruleText };
|
|
341
|
+
}
|
|
342
|
+
if (entry) {
|
|
292
343
|
const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
|
|
293
344
|
if (paramAsk) {
|
|
294
345
|
return { action: "ask", message: ruleMessage("flagged", paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
|
|
@@ -297,6 +348,9 @@ export function createPermissionRulePolicy(rules, opts) {
|
|
|
297
348
|
return { action: "allow" };
|
|
298
349
|
}
|
|
299
350
|
}
|
|
351
|
+
if (covering && coveringHit(mcpCovering.allow, req.toolName)) {
|
|
352
|
+
return { action: "allow" };
|
|
353
|
+
}
|
|
300
354
|
if (defaultAction === "allow")
|
|
301
355
|
return { action: "allow" };
|
|
302
356
|
return { action: defaultAction, message: `no permission rule matched "${req.toolName}" (default-${defaultAction} rule set)` };
|