@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.
- package/CHANGELOG.md +150 -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/subagent.d.ts +4 -0
- package/dist/agents/subagent.js +1 -1
- 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 +214 -5
- package/dist/core/mcp.js +315 -30
- 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-rule-model.d.ts +140 -21
- package/dist/core/permission-rule-model.js +76 -17
- package/dist/core/permission-rule-org.d.ts +4 -3
- package/dist/core/permission-rule-org.js +12 -3
- 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 +2 -2
- package/dist/index.js +2 -2
- package/dist/orchestration/workflow-types.d.ts +16 -6
- package/dist/orchestration/workflow-types.js +10 -4
- package/dist/orchestration/workflow.js +32 -6
- package/dist/prompts/default.d.ts +12 -2
- package/dist/stores/file/background-agent-store.js +1 -0
- package/dist/stores/file/checkpoint-store.d.ts +6 -2
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/fs-atomic.d.ts +151 -10
- package/dist/stores/file/fs-atomic.js +208 -32
- package/dist/stores/file/index.d.ts +26 -3
- package/dist/stores/file/index.js +25 -2
- package/dist/stores/file/shared-ledger.d.ts +40 -5
- package/dist/stores/file/shared-ledger.js +24 -8
- package/dist/stores/file/workflow-run-store.d.ts +8 -1
- package/dist/stores/file/workflow-run-store.js +1 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
- package/dist/tools/fs/bash-readonly-classifier.js +58 -47
- package/dist/tools/fs/index.d.ts +3 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +30 -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)
|
|
@@ -7,19 +7,36 @@
|
|
|
7
7
|
* semantics; who may mint one, where it is stored, and where in the gate it is consumed live in
|
|
8
8
|
* `permission-rule-store.ts`, `permission-rule-consent.ts` and `hooks.ts` respectively.
|
|
9
9
|
*
|
|
10
|
-
* ## The floor:
|
|
10
|
+
* ## The floor: what a rule may name, and what a rule may match
|
|
11
11
|
*
|
|
12
|
-
* Everything a shell can use to run a
|
|
13
|
-
*
|
|
14
|
-
* entirely: it falls back to the pre-existing chain and asks.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* Everything a shell can use to run a program the text does not NAME — substitution, subshells,
|
|
13
|
+
* backgrounding, escapes, line breaks, and **redirection** — puts the command outside this lane
|
|
14
|
+
* entirely: it falls back to the pre-existing chain and asks. Redirection specifically is a deliberate
|
|
15
|
+
* registered divergence from upstream (upstream strips redirections before matching; with this repo's
|
|
16
|
+
* write gate not covering the shell tool, stripping would turn a rule as innocuous as `Bash(ls)` into a
|
|
17
|
+
* licence for `ls > ~/.ssh/authorized_keys`).
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* CONNECTORS are the one construct the lane does speak for, and only in the EXACT form. `./gradlew
|
|
20
|
+
* build && ./gradlew test` is one thing a person reads and approves in one glance, and the whole of it
|
|
21
|
+
* is written into the rule; nothing is admitted that the rule text does not spell out end to end. The
|
|
22
|
+
* PREFIX form stays single-command on both sides — as a rule BODY (`Bash(a && b:*)` is refused) and as
|
|
23
|
+
* a MATCH (`Bash(npm:*)` does not admit `npm test && curl evil.example`, which is the whole reason the
|
|
24
|
+
* two forms are separated here rather than sharing one matcher arm). Upstream reaches the same
|
|
25
|
+
* placement through a per-segment evaluation of its full decision chain, where a segment DENY is
|
|
26
|
+
* returned strictly before any whole-string allow; this lane's equivalent is that the deny/ask layer
|
|
27
|
+
* (`permission-rule-org.ts`) judges every segment and runs ahead of the allow lane at the gate.
|
|
28
|
+
*
|
|
29
|
+
* The floor is `parseLeadingCommandName` + `splitShellCompoundSegments` — the one simple-command parser
|
|
30
|
+
* and the one segmentation, both already shared with the read-only classifier, the reversibility probe,
|
|
31
|
+
* the coarse command-name policy and the skill tool specifier. A second tokenizer would drift, and drift
|
|
32
|
+
* on a loosening face shows up as a circumvention rather than as a test failure.
|
|
33
|
+
*
|
|
34
|
+
* argv[0] may carry a PATH prefix here (`./gradlew`, `/usr/bin/git`) — opt-in at the shared parser and
|
|
35
|
+
* used by this lane alone. The bare-name requirement belongs to the argv[0]-NAME filters, which compare
|
|
36
|
+
* one token against a name set; this lane compares the whole command line, so a path prefix is not a way
|
|
37
|
+
* past anything — it IS the text that was approved. No basename folding follows from it: `./gradlew`
|
|
38
|
+
* and `gradlew` stay two different commands to the matcher. The single place a basename is taken is the
|
|
39
|
+
* interpreter refusal below, which must read `/usr/bin/node` as `node`.
|
|
23
40
|
*
|
|
24
41
|
* ## Normalization order is load-bearing
|
|
25
42
|
*
|
|
@@ -121,11 +138,64 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
|
|
|
121
138
|
* any program you like" — that is not a shape a single click can be understood to have authorized.
|
|
122
139
|
*
|
|
123
140
|
* The check is on argv[0] of the prefix, not on the prefix being exactly one word: `node -e:*` is the
|
|
124
|
-
* same licence spelled longer.
|
|
141
|
+
* same licence spelled longer. It is on argv[0]'s BASENAME, not its spelling: `/usr/bin/node:*` and
|
|
142
|
+
* `./node:*` grant precisely what `node:*` grants, and a table consulted with the full path would be a
|
|
143
|
+
* table any path prefix walks around (upstream takes the same basename before consulting its own
|
|
144
|
+
* interpreter set). Deliberately strict-side — it costs the ability to persist a rule like
|
|
125
145
|
* `python manage.py migrate:*` (which an import reports as skipped rather than dropping silently), and
|
|
126
146
|
* an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
|
|
147
|
+
*
|
|
148
|
+
* TWO groups, and the distinction matters when the table is next edited:
|
|
149
|
+
* · LANGUAGE interpreters (`node`, `python`, `ruby`, …) — the argument IS a program. This half is
|
|
150
|
+
* wider than upstream's own set, deliberately, and is the argument the paragraph above makes.
|
|
151
|
+
* · LAUNCHERS — a program whose argument is another program to run: the shells, the environment and
|
|
152
|
+
* privilege wrappers, the schedulers and resource wrappers, the tracers, and the shell BUILTINS
|
|
153
|
+
* that dispatch (`command`, `builtin`). This half is upstream's set, adopted verbatim rather than
|
|
154
|
+
* reasoned out row by row: it is the same question upstream answers with the same mechanism (its
|
|
155
|
+
* set is consulted on argv[0]'s basename too), and every row passes this table's own first axis —
|
|
156
|
+
* a body whose use is naming a program to execute is refused. They were missing, and each was one
|
|
157
|
+
* token in front of the licence the language half already refuses: `command node:*`, `timeout 9
|
|
158
|
+
* node:*` and `nice node:*` all grant exactly what `node:*` grants.
|
|
159
|
+
*
|
|
160
|
+
* KNOWN RESIDUAL, stated rather than hidden, and it is a property of the instrument rather than of this
|
|
161
|
+
* particular list: a positive list of NAMES cannot be made complete, and two independent review rounds
|
|
162
|
+
* enumerated escapes faster than rows could be added. Three shapes, none of which a longer list fixes:
|
|
163
|
+
* · a version- or distribution-suffixed spelling of a listed interpreter (`python3.12`, `nodejs`);
|
|
164
|
+
* · a launcher nobody listed — the set of programs that run another program has no boundary, and each
|
|
165
|
+
* round produced more of them;
|
|
166
|
+
* · a spelling that defeats the NAME question entirely: `/proc/self/exe` (basename `exe`, the running
|
|
167
|
+
* shell), a symlink or a renamed binary, a BusyBox applet, a dynamic loader invoked directly.
|
|
168
|
+
* Matching a family would need a pattern rather than a set, with its own false-positive surface
|
|
169
|
+
* (`node-gyp`, `timeout-monitor`), and no pattern addresses the third shape at all. What carries the
|
|
170
|
+
* residue is therefore NOT this table: it is the three standing fences — the org deny/ask layer runs
|
|
171
|
+
* ahead of this lane and cannot be silenced by it, a mandated ask is not rule-clearable, and the
|
|
172
|
+
* narrower exact candidate (plus minting nothing) is always on the same card. This table's job is to
|
|
173
|
+
* keep the OBVIOUS one-token licence off a card a person clicks once, not to be a boundary.
|
|
127
174
|
*/
|
|
128
175
|
export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
|
|
176
|
+
/**
|
|
177
|
+
* Shell KEYWORDS, refused as a segment's `argv[0]` everywhere this lane reads a command.
|
|
178
|
+
*
|
|
179
|
+
* The floor this lane stands on extracts the first TOKEN of a segment and calls it the command name.
|
|
180
|
+
* That identification is what every comparison here rests on — the interpreter refusal, the deny/ask
|
|
181
|
+
* layer's per-segment judgement, and a person's reading of the rule text. For a control structure it is
|
|
182
|
+
* simply false. `for x in once; do curl evil.example; done` splits into three segments whose first
|
|
183
|
+
* tokens are `for`, `do` and `done`; all three are ordinary bare words, so the floor accepts each,
|
|
184
|
+
* `curl` is named by nothing, and a published `deny Bash(curl:*)` matches none of them while bash runs
|
|
185
|
+
* curl. A whole loop body — any number of programs — hides behind three tokens that name no program.
|
|
186
|
+
*
|
|
187
|
+
* Refusing the keyword puts the whole command outside the lane, which is the honest answer: no rule can
|
|
188
|
+
* be minted for it and no rule matches it, so it asks. Recovering these shapes properly needs a real
|
|
189
|
+
* shell grammar (upstream has one — it parses to a syntax tree and reads the commands out of the
|
|
190
|
+
* structure, so a keyword is never mistaken for a program); a token-level lane cannot, and pretending
|
|
191
|
+
* otherwise is the loosening direction.
|
|
192
|
+
*
|
|
193
|
+
* `!` is here for the same reason with a sharper edge: it is a keyword that PREFIXES a real command, so
|
|
194
|
+
* `! node -e …` is the `node` licence the interpreter table exists to refuse, wearing one extra token.
|
|
195
|
+
* `{`/`}`/`[[`/`]]` are already refused by the floor's argv[0] metacharacter rule; listed anyway,
|
|
196
|
+
* because that rule belongs to another module and is not this lane's to depend on.
|
|
197
|
+
*/
|
|
198
|
+
export declare const SHELL_RESERVED_WORDS: ReadonlySet<string>;
|
|
129
199
|
/**
|
|
130
200
|
* design/185 §1 — the reviewed command/subcommand grammar the PREFIX suggestion is generated from
|
|
131
201
|
* (exactly the "reviewed command/subcommand grammar" the generator's history note names as the one
|
|
@@ -215,10 +285,46 @@ export declare function formatAllowRuleText(command: string, match: PersistedRul
|
|
|
215
285
|
* Does this rule's command pattern admit `command`?
|
|
216
286
|
*
|
|
217
287
|
* `command` is the raw tool argument: the floor and the folding happen HERE, in that order, so no
|
|
218
|
-
* caller can
|
|
219
|
-
*
|
|
288
|
+
* caller can match a command this lane has not read. Returns false for every redirection, substitution,
|
|
289
|
+
* subshell, backgrounded or escaped form.
|
|
290
|
+
*
|
|
291
|
+
* **A PREFIX rule never admits a compound.** This is the single load-bearing line of the connector
|
|
292
|
+
* widening, and it is checked on the MATCH side rather than left to the mint side: `Bash(npm:*)` is an
|
|
293
|
+
* ordinary, legitimately mintable rule, and if the "does this command start with `npm `" arm were
|
|
294
|
+
* allowed to see a compound at all, that rule would admit `npm test && curl evil.example` — one stored
|
|
295
|
+
* yes to a build command turned into a standing yes to whatever is chained behind it. An EXACT rule has
|
|
296
|
+
* no such reach by construction: it admits one string, the one it spells.
|
|
220
297
|
*/
|
|
221
298
|
export declare function ruleAdmitsCommand(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
|
|
299
|
+
/**
|
|
300
|
+
* The same match, asked the DENY/ASK layer's question — "does this rule speak about this program run?".
|
|
301
|
+
*
|
|
302
|
+
* Split from {@link ruleAdmitsCommand} because the two differ on one axis that decides real cases: a
|
|
303
|
+
* quoted operator. `curl "https://x/?a=1&b=2"` is ONE command bash runs, and the `&` in a query string
|
|
304
|
+
* is not a connector; the matching side refuses it anyway (a rule text is a spelling with no operator
|
|
305
|
+
* characters in it at all — the historical rule-face contract), and the deny side inheriting that
|
|
306
|
+
* refusal made `deny Bash(curl:*)` silent on the commonest spelling of the very program it names
|
|
307
|
+
* (adversarial round 3).
|
|
308
|
+
*
|
|
309
|
+
* The asymmetry only ever runs one way: this predicate reads MORE commands than the matching one, never
|
|
310
|
+
* fewer. A shape only the ALLOW side could read would be a standing approval no published policy could
|
|
311
|
+
* see — the inversion this whole ticket exists to prevent.
|
|
312
|
+
*/
|
|
313
|
+
export declare function ruleAdmitsProgramRun(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
|
|
314
|
+
/**
|
|
315
|
+
* The segments of `command` as the deny/ask layer must judge them, or `undefined` for a command this
|
|
316
|
+
* lane cannot read.
|
|
317
|
+
*
|
|
318
|
+
* A tightening rule speaks about a PROGRAM RUN, and a compound runs several. `Bash(curl:*)` published
|
|
319
|
+
* as a deny means "this machine does not make that call", and reading `npm test && curl evil.example`
|
|
320
|
+
* as one unmatched blob answered that with silence — the shape the widening above would otherwise make
|
|
321
|
+
* permanently approvable. Exported (rather than folded into a matcher here) because the layer that
|
|
322
|
+
* needs it holds the rules: this module owns what a command IS, `permission-rule-org.ts` owns what the
|
|
323
|
+
* organization says about each part of it.
|
|
324
|
+
*
|
|
325
|
+
* A single simple command yields a one-element list, so a deny that matched before matches identically.
|
|
326
|
+
*/
|
|
327
|
+
export declare function ruleLaneSegmentsOf(command: string): readonly string[] | undefined;
|
|
222
328
|
/**
|
|
223
329
|
* Is `path` inside (or equal to) `root`? Word-boundary containment on the path separator, so `/a` does
|
|
224
330
|
* not contain `/ab`. Both sides are expected to be canonical already.
|
|
@@ -271,13 +377,26 @@ export interface RuleSuggestion {
|
|
|
271
377
|
* shear a quoted segment — harmless in this direction, because the sheared pieces carry quote
|
|
272
378
|
* characters and can never equal a bare lexicon word; every suspicious shape lands on "no prefix".
|
|
273
379
|
*
|
|
274
|
-
* Every produced candidate must survive the round trip — parse as a rule
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
380
|
+
* Every produced candidate must survive the round trip — parse as a rule, come back as the match form
|
|
381
|
+
* this seat is offering, AND admit the very command it was minted from. Enforced on BOTH seats,
|
|
382
|
+
* fail-closed: a candidate that would not round-trip is silently not offered, since offering an option
|
|
383
|
+
* redemption would refuse is worse than offering one fewer.
|
|
384
|
+
*
|
|
385
|
+
* The FORM half of that check is not decoration. A command may end in the rule grammar's own prefix
|
|
386
|
+
* marker — `rm :*` is a legal thing to type — and wrapping it as an exact rule produces the text
|
|
387
|
+
* `Bash(rm :*)`, which the validator reads back as the PREFIX rule `Bash(rm:*)` over the command `rm`.
|
|
388
|
+
* Trusting the exact seat's own intent and labelling that result `match: "exact"` put a blanket
|
|
389
|
+
* every-`rm` rule on the card under the narrowest option's name, one click from being persisted (and,
|
|
390
|
+
* for a lexicon command like `git status :*`, emitted twice — once mislabelled, once as the real prefix
|
|
391
|
+
* candidate). The seat therefore believes the PARSER about what it got back, never its own request.
|
|
392
|
+
*
|
|
393
|
+
* A COMPOUND (`./gradlew build && ./gradlew test`) fills the exact seat and only that one: the offered
|
|
394
|
+
* rule spells the whole chain and admits exactly it. That is the shape this seat was missing — the
|
|
395
|
+
* ordinary build invocation is a connector chain, and a card that could offer nothing for it made every
|
|
396
|
+
* such command a fresh question forever, with no way for an answer to accumulate.
|
|
279
397
|
*
|
|
280
|
-
* Returns an empty array for anything the rule lane cannot speak for (
|
|
281
|
-
*
|
|
398
|
+
* Returns an empty array for anything the rule lane cannot speak for (redirections, substitutions,
|
|
399
|
+
* subshells, backgrounding) — the card then simply carries no "don't ask again" option, which is the
|
|
400
|
+
* honest answer.
|
|
282
401
|
*/
|
|
283
402
|
export declare function suggestRulesForCommand(command: string): RuleSuggestion[];
|