claude-mem-lite 3.97.0 → 3.98.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.97.0",
13
+ "version": "3.98.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.97.0",
3
+ "version": "3.98.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -809,6 +809,19 @@ claude-mem-lite.
809
809
  | `CLAUDE_MEM_NO_TEMPLATE_REFRESH` | `1` stops SessionStart from refreshing the adopted `CLAUDE.md` managed block when the shipped template changes. | _(refreshes)_ |
810
810
  | `MEM_QUIET_HOOKS` | See Core above — the broadest injection-volume switch. | _(disabled)_ |
811
811
 
812
+ ### Registry import bounds
813
+
814
+ `registry import-url` pulls from a third-party repository, so it is bounded. Entries past a
815
+ bound are refused, not truncated, and the refusal is printed with the import result. Set any
816
+ of these to `0` for the pre-v3.98 unlimited behavior; an unparseable or negative value keeps
817
+ the default rather than removing the bound.
818
+
819
+ | Variable | Description | Default |
820
+ |----------|-------------|---------|
821
+ | `CLAUDE_MEM_IMPORT_MAX_ITEMS` | Max skills/agents imported from one repository. | `200` |
822
+ | `CLAUDE_MEM_IMPORT_MAX_FILE_BYTES` | Max size of a single `SKILL.md`/`AGENT.md`. Oversized entries are skipped; the rest still import. | `2097152` (2 MB) |
823
+ | `CLAUDE_MEM_IMPORT_MAX_TOTAL_BYTES` | Byte budget for one import run. Exhausting it stops the walk and books the remainder as refused. | `52428800` (50 MB) |
824
+
812
825
  ### Retrieval tuning
813
826
 
814
827
  Prompt-time search (`UPS_*` = the UserPromptSubmit surface). Defaults are the values the
package/cli/common.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // relative-time formatting — every command imports from here so the CLI stays
9
9
  // consistent.
10
10
 
11
- import { neutralizeContextDelimiters } from '../format-utils.mjs';
11
+ import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from '../format-utils.mjs';
12
12
 
13
13
  // ─── Argument Parsing ────────────────────────────────────────────────────────
14
14
 
@@ -107,11 +107,19 @@ export function parseArgs(argv) {
107
107
  * The transform is idempotent (it strips brackets, it does not re-add them), so a
108
108
  * path that already defanged upstream — `context` → buildSessionContextLines — is
109
109
  * unaffected.
110
+ *
111
+ * `<skill-loaded>` is neutralized here too (audit 2026-09-05 R6 P1-2). It is deliberately
112
+ * OFF CONTEXT_DELIMITER_RE so the MCP `mem_use` load path can emit a real wrapper — but no
113
+ * CLI command emits one, while `registry search|list` DOES print third-party registry names
114
+ * (a GitHub frontmatter name, or `import --name`, which applies no charset filter). A crafted
115
+ * name therefore forged a complete skill block out of nothing in ordinary CLI output. The MCP
116
+ * twin closes the same hole at its own chokepoint (server.mjs defangResult); doing it on one
117
+ * face only is this repo's first-listed defect class.
110
118
  */
111
119
  export function out(text) {
112
- // String() first: neutralizeContextDelimiters coerces nullish to '', which would turn
113
- // a pre-existing `out(undefined)` line from "undefined" into an empty line.
114
- outVerbatim(neutralizeContextDelimiters(String(text)));
120
+ // String() first: the neutralizers coerce nullish to '', which would turn a pre-existing
121
+ // `out(undefined)` line from "undefined" into an empty line.
122
+ outVerbatim(neutralizeSkillDelimiters(neutralizeContextDelimiters(String(text))));
115
123
  }
116
124
 
117
125
  /**
package/mem-cli.mjs CHANGED
@@ -3531,12 +3531,17 @@ async function cmdImport(argv) {
3531
3531
  }
3532
3532
 
3533
3533
  try {
3534
- const { importFromGitHub } = await import('./registry-importer.mjs');
3534
+ const { importFromGitHub, formatImportSkips } = await import('./registry-importer.mjs');
3535
3535
  out(`[mem] Importing from ${url}...`);
3536
- const results = await importFromGitHub(rdb, url);
3536
+ // `skipped` sink + shared summary (R6 Q1) — same helper the MCP twin renders, so the
3537
+ // bounds cannot end up enforced-but-silent on one of the two faces.
3538
+ const skipped = [];
3539
+ const results = await importFromGitHub(rdb, url, { skipped });
3540
+ const refusal = formatImportSkips(skipped);
3537
3541
 
3538
3542
  if (results.length === 0) {
3539
3543
  out('[mem] No skills/agents found in this repository.');
3544
+ if (refusal) out(`[mem] ${refusal}`);
3540
3545
  return;
3541
3546
  }
3542
3547
 
@@ -3544,6 +3549,7 @@ async function cmdImport(argv) {
3544
3549
  for (const r of results) {
3545
3550
  out(` ${r.type === 'skill' ? 'S' : 'A'} ${r.name} (id=${r.id})`);
3546
3551
  }
3552
+ if (refusal) out(`[mem] ${refusal}`);
3547
3553
 
3548
3554
  if (flags.enrich) {
3549
3555
  out('[mem] Running LLM enrichment...');
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.97.0",
3
+ "version": "3.98.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.97.0",
9
+ "version": "3.98.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.30.0",
12
12
  "better-sqlite3": "^12.11.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.97.0",
3
+ "version": "3.98.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -34,25 +34,43 @@ export function parseGitHubUrl(url) {
34
34
  };
35
35
  }
36
36
 
37
+ // Percent-encode ONE path segment. Git ref names may legally contain `#` (git forbids `?`,
38
+ // not `#`), and so may file names — interpolated raw, that `#` opens a URL FRAGMENT and
39
+ // swallows the rest: `…/git/trees/feat#x?recursive=1` parses as hash `#x?recursive=1` with an
40
+ // EMPTY query, so GitHub answered a NON-recursive tree and every nested skills/*/SKILL.md went
41
+ // silently undiscovered; the raw content URL lost its whole path the same way (audit
42
+ // 2026-09-05 R6 Q2, measured). encodeURIComponent leaves the unreserved set — including the
43
+ // `.`, `-`, `_` and `~` that ordinary owners/repos/branches are made of — untouched.
44
+ const seg = (s) => encodeURIComponent(String(s ?? ''));
45
+
46
+ // A repo-relative file path is MANY segments: encode each one but keep the `/` separators.
47
+ // encodeURIComponent on the whole path would emit `skills%2Ffoo%2FSKILL.md` and 404 every
48
+ // ordinary import — the counter-case pinned in tests/registry-github.test.mjs.
49
+ const segPath = (p) =>
50
+ String(p ?? '')
51
+ .split('/')
52
+ .map(seg)
53
+ .join('/');
54
+
37
55
  /**
38
56
  * Build GitHub API tree URL (recursive).
39
57
  */
40
58
  export function buildTreeUrl(owner, repo, branch) {
41
- return `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
59
+ return `https://api.github.com/repos/${seg(owner)}/${seg(repo)}/git/trees/${seg(branch)}?recursive=1`;
42
60
  }
43
61
 
44
62
  /**
45
63
  * Build raw content URL for a file.
46
64
  */
47
65
  export function buildContentUrl(owner, repo, branch, path) {
48
- return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
66
+ return `https://raw.githubusercontent.com/${seg(owner)}/${seg(repo)}/${seg(branch)}/${segPath(path)}`;
49
67
  }
50
68
 
51
69
  /**
52
70
  * Build GitHub API repo metadata URL.
53
71
  */
54
72
  export function buildRepoUrl(owner, repo) {
55
- return `https://api.github.com/repos/${owner}/${repo}`;
73
+ return `https://api.github.com/repos/${seg(owner)}/${seg(repo)}`;
56
74
  }
57
75
 
58
76
  /**
@@ -22,6 +22,71 @@ import { DB_DIR } from './schema.mjs';
22
22
  // read them under CLAUDE_MEM_DIR relocation (D#29). Equals homedir when the env is unset.
23
23
  const MANAGED_DIR = join(DB_DIR, 'managed');
24
24
 
25
+ // ─── Import bounds (audit 2026-09-05 R6 Q1) ─────────────────────────────────
26
+ // Measured before these existed: a tree offering 500 `skills/*/SKILL.md` entries of 2 MB
27
+ // each imported all 500, issued 502 fetches and wrote 1000.0 MB in 20.1 s — from one
28
+ // `registry import-url`. There was no bound on count, per-file size, or run total, and the
29
+ // input is a third-party repository, so one URL could fill the user's data dir.
30
+ //
31
+ // USER-VISIBLE DEFAULT BEHAVIOR CHANGE: an import that used to be unbounded can now refuse
32
+ // entries. Each bound has an env opt-out and `0` means unlimited — the pre-cap behavior.
33
+ export const IMPORT_DEFAULT_LIMITS = {
34
+ items: 200,
35
+ fileBytes: 2 * 1024 * 1024,
36
+ totalBytes: 50 * 1024 * 1024,
37
+ };
38
+
39
+ // Module-private: both consumers (resolveImportLimits, formatImportSkips) live here, and
40
+ // exporting it would add a name to the knip unused-export baseline for nothing.
41
+ const IMPORT_LIMIT_ENV = {
42
+ items: 'CLAUDE_MEM_IMPORT_MAX_ITEMS',
43
+ fileBytes: 'CLAUDE_MEM_IMPORT_MAX_FILE_BYTES',
44
+ totalBytes: 'CLAUDE_MEM_IMPORT_MAX_TOTAL_BYTES',
45
+ };
46
+
47
+ const SKIP_REASON_TEXT = {
48
+ 'item-cap': 'beyond the per-import item cap',
49
+ 'file-too-large': 'over the per-file byte cap',
50
+ 'total-budget': 'past the total byte budget for this import',
51
+ };
52
+
53
+ /**
54
+ * Effective bounds: caller override (tests) < env < default.
55
+ * @param {object} [override] Partial {items,fileBytes,totalBytes}
56
+ * @param {object} [env] Env source (tests pass their own)
57
+ */
58
+ function resolveImportLimits(override = {}, env = process.env) {
59
+ const limits = {};
60
+ for (const key of Object.keys(IMPORT_DEFAULT_LIMITS)) {
61
+ const base = override[key] ?? IMPORT_DEFAULT_LIMITS[key];
62
+ const raw = env[IMPORT_LIMIT_ENV[key]];
63
+ if (raw === undefined || String(raw).trim() === '') {
64
+ limits[key] = base;
65
+ continue;
66
+ }
67
+ const n = Number(raw);
68
+ // `0` = unlimited, the documented opt-out. Anything unparseable or negative KEEPS the
69
+ // bound: the failure mode of a typo must be "the limit still applies", never "no limit"
70
+ // — the same fail-closed rule registryConfineEnabled states for its escape hatch.
71
+ limits[key] = Number.isFinite(n) && n >= 0 ? (n === 0 ? Infinity : n) : base;
72
+ }
73
+ return limits;
74
+ }
75
+
76
+ /**
77
+ * One-line refusal summary for the two import faces. Shared so the CLI and the MCP tool
78
+ * cannot drift into two spellings of the same refusal (this repo's first-listed defect class).
79
+ * @param {Array<{reason: string}>} skipped
80
+ * @returns {string} '' when nothing was refused.
81
+ */
82
+ export function formatImportSkips(skipped) {
83
+ if (!skipped || skipped.length === 0) return '';
84
+ const byReason = new Map();
85
+ for (const s of skipped) byReason.set(s.reason, (byReason.get(s.reason) || 0) + 1);
86
+ const parts = [...byReason].map(([reason, n]) => `${n} ${SKIP_REASON_TEXT[reason] || reason} (${reason})`);
87
+ return `Refused ${skipped.length}: ${parts.join('; ')}. Set ${IMPORT_LIMIT_ENV.items}/${IMPORT_LIMIT_ENV.fileBytes}/${IMPORT_LIMIT_ENV.totalBytes} (0 = unlimited) to change these bounds.`;
88
+ }
89
+
25
90
  // ─── Tree Discovery ─────────────────────────────────────────────────────────
26
91
 
27
92
  // Patterns: flat (skills/name/SKILL.md), plugin (plugins/x/skills/y/SKILL.md),
@@ -346,11 +411,30 @@ export async function importFromGitHub(db, url, opts = {}) {
346
411
  const discovered = discoverFromTree(treeData, pathFilter);
347
412
  if (discovered.length === 0) return [];
348
413
 
414
+ // 4b. Apply the import bounds (R6 Q1). `skipped` is a caller-supplied sink so both faces
415
+ // can render the refusal; callers that pass nothing keep the previous return shape.
416
+ const limits = resolveImportLimits(opts.limits, opts.env);
417
+ const skipped = opts.skipped ?? [];
418
+ let admitted = discovered;
419
+ if (discovered.length > limits.items) {
420
+ admitted = discovered.slice(0, limits.items);
421
+ for (const over of discovered.slice(limits.items)) {
422
+ skipped.push({ name: over.name, type: over.type, reason: 'item-cap' });
423
+ }
424
+ debugLog(
425
+ 'WARN',
426
+ 'importer',
427
+ `Item cap ${limits.items} reached; refused ${discovered.length - limits.items} entries`,
428
+ );
429
+ }
430
+
349
431
  const repoUrl = `https://github.com/${owner}/${repo}`;
350
432
  const results = [];
433
+ let totalBytes = 0;
351
434
 
352
435
  // 5. Process each discovered item
353
- for (const item of discovered) {
436
+ for (let i = 0; i < admitted.length; i++) {
437
+ const item = admitted[i];
354
438
  try {
355
439
  // 5a. Fetch content via raw GitHub URL
356
440
  const contentUrl = buildContentUrl(owner, repo, branch, item.filePath);
@@ -361,14 +445,45 @@ export async function importFromGitHub(db, url, opts = {}) {
361
445
  }
362
446
  const content = await contentResp.text();
363
447
 
448
+ // 5a-bis. Byte bounds, checked on the fetched body before anything is parsed or
449
+ // written. The per-file cap refuses ONE entry and keeps going; the run total is a
450
+ // budget, so exhausting it stops the walk and books every remaining entry as refused
451
+ // (a partial import must still account for what it did not take).
452
+ const bytes = Buffer.byteLength(content, 'utf8');
453
+ if (bytes > limits.fileBytes) {
454
+ skipped.push({ name: item.name, type: item.type, reason: 'file-too-large', bytes });
455
+ debugLog('WARN', 'importer', `Refused ${item.filePath}: ${bytes} B over cap ${limits.fileBytes}`);
456
+ continue;
457
+ }
458
+ if (totalBytes + bytes > limits.totalBytes) {
459
+ for (const rest of admitted.slice(i)) {
460
+ skipped.push({ name: rest.name, type: rest.type, reason: 'total-budget' });
461
+ }
462
+ debugLog('WARN', 'importer', `Total byte budget ${limits.totalBytes} exhausted at ${item.filePath}`);
463
+ break;
464
+ }
465
+ totalBytes += bytes;
466
+
364
467
  // 5b. Parse frontmatter
365
468
  const { frontmatter, body } = parseFrontmatter(content);
366
469
 
367
470
  // Root skill naming: use frontmatter name if present, else repo name for root, else discovered name
368
471
  const rawName = frontmatter.name || (item.name === 'root' ? repo : item.name);
369
472
  const name = rawName.replace(/[^a-zA-Z0-9._-]/g, '_');
370
- // Path traversal guard: reject names that would escape managed directory
371
473
  const typeDir = item.type === 'agent' ? 'agents' : 'skills';
474
+ // Segment guard, BEFORE the confinement check — which cannot catch these (audit
475
+ // 2026-09-05 R6 P3-2). `.` and `..` survive the charset filter (dot is allowed) and
476
+ // then PASS confinement, because join() resolves them away first: `<managed>/skills/..`
477
+ // IS `<managed>`, admitted on isPathConfined's `resolved === base` arm. Not a traversal
478
+ // — the write stays inside managedDir — but it lands outside the one-directory-per-
479
+ // resource layout (`<managed>/SKILL.md`, `<managed>/skills/SKILL.md`), where the flat
480
+ // scanner picks the latter up as a loose resource named `SKILL`, and two repos both
481
+ // declaring `name: .` overwrite each other. An empty name collapses the same way.
482
+ if (!name || name === '.' || name === '..') {
483
+ debugLog('WARN', 'importer', `Rejected non-segment name: ${rawName}`);
484
+ continue;
485
+ }
486
+ // Path traversal guard: reject names that would escape managed directory
372
487
  if (!isPathConfined(join(managedDir, typeDir, name), managedDir)) {
373
488
  debugLog('WARN', 'importer', `Rejected path-traversal name: ${rawName}`);
374
489
  continue;
package/server.mjs CHANGED
@@ -293,14 +293,28 @@ function applyArgAliases(args, pairs) {
293
293
  return next;
294
294
  }
295
295
 
296
- function defangResult(result) {
296
+ /**
297
+ * @param {object} result Tool result.
298
+ * @param {object} [opts]
299
+ * @param {boolean} [opts.skillBlocks=true] Also neutralize `<skill-loaded>`. Default ON:
300
+ * registry rows carry third-party text (a GitHub frontmatter name, or `import --name`,
301
+ * which applies no charset filter), and every registry render used to interpolate it raw —
302
+ * so a crafted name FORGED a whole skill block out of nothing in ordinary search/list
303
+ * output (audit 2026-09-05 R6 P1-2; F7 on a third face). Enumerating mem_registry found
304
+ * the same shape on seven branches plus the shared formatRegistryListLine, which is why
305
+ * this is a chokepoint default rather than seven call-site patches. `mem_use` — the one
306
+ * handler that must emit a REAL wrapper — turns it off explicitly and defangs its own
307
+ * untrusted pieces per call site instead (R6 P1-1).
308
+ */
309
+ function defangResult(result, { skillBlocks = true } = {}) {
297
310
  if (!result || !Array.isArray(result.content)) return result;
311
+ const scrub = skillBlocks
312
+ ? (t) => neutralizeSkillDelimiters(neutralizeContextDelimiters(t))
313
+ : neutralizeContextDelimiters;
298
314
  return {
299
315
  ...result,
300
316
  content: result.content.map((c) =>
301
- c && c.type === 'text' && typeof c.text === 'string'
302
- ? { ...c, text: neutralizeContextDelimiters(c.text) }
303
- : c,
317
+ c && c.type === 'text' && typeof c.text === 'string' ? { ...c, text: scrub(c.text) } : c,
304
318
  ),
305
319
  };
306
320
  }
@@ -311,14 +325,18 @@ function defangResult(result) {
311
325
  * @param {boolean} [opts.verbatim=false] Skip the defang pass. Only for payloads that
312
326
  * must round-trip byte-exact — `mem_export` feeds `restore`, so neutralizing it would
313
327
  * silently corrupt backups of any memory that legitimately discusses these tags.
328
+ * @param {boolean} [opts.emitsSkillBlock=false] This handler legitimately emits a real
329
+ * `<skill-loaded>` wrapper, so the chokepoint must not strip it. `mem_use` is the only
330
+ * one, and it neutralizes its own untrusted body/name/path per call site (R6 P1-1).
331
+ * Applies to the SUCCESS path only — an error message never emits a wrapper.
314
332
  */
315
- function safeHandler(fn, { verbatim = false } = {}) {
333
+ function safeHandler(fn, { verbatim = false, emitsSkillBlock = false } = {}) {
316
334
  return async (args, extra) => {
317
335
  try {
318
336
  lastMcpRequestTime = Date.now();
319
337
  idleCleanupRan = false;
320
338
  const result = await fn(args, extra);
321
- return verbatim ? result : defangResult(result);
339
+ return verbatim ? result : defangResult(result, { skillBlocks: !emitsSkillBlock });
322
340
  } catch (err) {
323
341
  return defangResult({ content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
324
342
  }
@@ -1683,11 +1701,16 @@ server.registerTool(
1683
1701
  if (!args.url) {
1684
1702
  return { content: [{ type: 'text', text: 'import_url requires a url parameter' }], isError: true };
1685
1703
  }
1686
- const { importFromGitHub } = await import('./registry-importer.mjs');
1704
+ const { importFromGitHub, formatImportSkips } = await import('./registry-importer.mjs');
1687
1705
  try {
1688
- const results = await importFromGitHub(rdb, args.url);
1706
+ // `skipped` sink + shared summary (R6 Q1): the import bounds must REFUSE visibly, and
1707
+ // the CLI twin renders the identical string from the identical helper.
1708
+ const skipped = [];
1709
+ const results = await importFromGitHub(rdb, args.url, { skipped });
1710
+ const refusal = formatImportSkips(skipped);
1689
1711
  if (results.length === 0) {
1690
- return { content: [{ type: 'text', text: `No skills/agents found in: ${args.url}` }] };
1712
+ const head = `No skills/agents found in: ${args.url}`;
1713
+ return { content: [{ type: 'text', text: refusal ? `${head}\n${refusal}` : head }] };
1691
1714
  }
1692
1715
 
1693
1716
  let enrichMsg = '';
@@ -1707,7 +1730,7 @@ server.registerTool(
1707
1730
  content: [
1708
1731
  {
1709
1732
  type: 'text',
1710
- text: `Imported ${results.length} resource(s) from ${args.url}:\n${lines.join('\n')}${enrichMsg}`,
1733
+ text: `Imported ${results.length} resource(s) from ${args.url}:\n${lines.join('\n')}${enrichMsg}${refusal ? `\n${refusal}` : ''}`,
1711
1734
  },
1712
1735
  ],
1713
1736
  };
@@ -1787,133 +1810,166 @@ server.registerTool(
1787
1810
  description: descriptionOf('mem_use'),
1788
1811
  inputSchema: memUseSchema,
1789
1812
  },
1790
- safeHandler(async (args) => {
1791
- const rdb = getRegistryDb();
1792
- if (!rdb) {
1793
- return { content: [{ type: 'text', text: 'Registry DB not available.' }], isError: true };
1794
- }
1813
+ safeHandler(
1814
+ async (args) => {
1815
+ const rdb = getRegistryDb();
1816
+ if (!rdb) {
1817
+ return { content: [{ type: 'text', text: 'Registry DB not available.' }], isError: true };
1818
+ }
1795
1819
 
1796
- const name = args.name.trim();
1797
- const type = args.type || 'skill';
1820
+ const name = args.name.trim();
1821
+ const type = args.type || 'skill';
1798
1822
 
1799
- // 1. Exact match by name or invocation_name — the ONLY path that loads content.
1800
- const row = rdb
1801
- .prepare(
1802
- `
1823
+ // 1. Exact match by name or invocation_name — the ONLY path that loads content.
1824
+ const row = rdb
1825
+ .prepare(
1826
+ `
1803
1827
  SELECT id, name, type, local_path, invocation_name, capability_summary
1804
1828
  FROM resources
1805
1829
  WHERE status = 'active' AND type = ?
1806
1830
  AND (name = ? OR invocation_name = ?)
1807
1831
  LIMIT 1
1808
1832
  `,
1809
- )
1810
- .get(type, name, name);
1811
-
1812
- // 2. Name miss → SUGGEST, never substitute. The FTS5 search still runs (it is what
1813
- // produces the candidate list), but its result is only ever rendered as names: loading
1814
- // the top hit under the caller's requested name shipped a different skill's body inside
1815
- // <skill-loaded> plus "Follow the instructions above to execute this <type>." — with
1816
- // nothing marking the swap, so an agent that asked for A executed B (audit F1,
1817
- // 2026-08-14: with only `deploy-rollback-runbook` registered, `deploy-notes` /
1818
- // `rollback-checklist` / `runbook-index` each returned its full body). Loading stays an
1819
- // exact-name decision the caller makes.
1820
- if (!row) {
1821
- let candidates = [];
1822
- try {
1823
- candidates = searchResources(rdb, name, { type, limit: 5 })
1824
- .map((r) => r.name)
1825
- .filter(Boolean);
1826
- } catch {
1827
- /* a suggestion is best-effort; the miss message below still stands */
1828
- }
1829
- // Every echo of the caller's own name below is bounded + delimiter-inert (audit F7):
1830
- // raw interpolation let a crafted `name` forge a <skill-loaded> block and the execute
1831
- // imperative inside this message, and the handler-wide defangResult cannot catch it —
1832
- // <skill-loaded> is off CONTEXT_DELIMITER_RE precisely so the real load path can emit
1833
- // it. `truncate` also folds newlines, so a multi-line name cannot fake block structure.
1834
- // Registered names are defanged too (a crafted one can be imported), but NOT truncated:
1835
- // the suggestion tells the caller to load one by its exact name, so it must stay exact.
1836
- const echoed = neutralizeSkillDelimiters(truncate(name, ECHO_NAME_MAX));
1837
- const echoedCandidates = candidates.map((n) => neutralizeSkillDelimiters(n));
1838
- const head = `No ${type} found for "${echoed}".`;
1839
- const browse = `mem_registry(action="search", query="${echoed}")`;
1840
- if (candidates.length === 0) {
1841
- return { content: [{ type: 'text', text: `${head} Try ${browse} to browse.` }] };
1833
+ )
1834
+ .get(type, name, name);
1835
+
1836
+ // 2. Name miss → SUGGEST, never substitute. The FTS5 search still runs (it is what
1837
+ // produces the candidate list), but its result is only ever rendered as names: loading
1838
+ // the top hit under the caller's requested name shipped a different skill's body inside
1839
+ // <skill-loaded> plus "Follow the instructions above to execute this <type>." — with
1840
+ // nothing marking the swap, so an agent that asked for A executed B (audit F1,
1841
+ // 2026-08-14: with only `deploy-rollback-runbook` registered, `deploy-notes` /
1842
+ // `rollback-checklist` / `runbook-index` each returned its full body). Loading stays an
1843
+ // exact-name decision the caller makes.
1844
+ if (!row) {
1845
+ let candidates = [];
1846
+ try {
1847
+ candidates = searchResources(rdb, name, { type, limit: 5 })
1848
+ .map((r) => r.name)
1849
+ .filter(Boolean);
1850
+ } catch {
1851
+ /* a suggestion is best-effort; the miss message below still stands */
1852
+ }
1853
+ // Every echo of the caller's own name below is bounded + delimiter-inert (audit F7):
1854
+ // raw interpolation let a crafted `name` forge a <skill-loaded> block and the execute
1855
+ // imperative inside this message, and the handler-wide defangResult cannot catch it —
1856
+ // <skill-loaded> is off CONTEXT_DELIMITER_RE precisely so the real load path can emit
1857
+ // it. `truncate` also folds newlines, so a multi-line name cannot fake block structure.
1858
+ // Registered names are defanged too (a crafted one can be imported), but NOT truncated:
1859
+ // the suggestion tells the caller to load one by its exact name, so it must stay exact.
1860
+ const echoed = neutralizeSkillDelimiters(truncate(name, ECHO_NAME_MAX));
1861
+ const echoedCandidates = candidates.map((n) => neutralizeSkillDelimiters(n));
1862
+ const head = `No ${type} found for "${echoed}".`;
1863
+ const browse = `mem_registry(action="search", query="${echoed}")`;
1864
+ if (candidates.length === 0) {
1865
+ return { content: [{ type: 'text', text: `${head} Try ${browse} to browse.` }] };
1866
+ }
1867
+ const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
1868
+ return {
1869
+ content: [
1870
+ {
1871
+ type: 'text',
1872
+ text:
1873
+ `${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
1874
+ `Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.`,
1875
+ },
1876
+ ],
1877
+ };
1842
1878
  }
1843
- const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
1844
- return {
1845
- content: [
1846
- {
1847
- type: 'text',
1848
- text:
1849
- `${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
1850
- `Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.`,
1851
- },
1852
- ],
1853
- };
1854
- }
1855
1879
 
1856
- // 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
1857
- let skillPath = row.local_path || '';
1858
- if (skillPath && !skillPath.endsWith('.md')) {
1859
- for (const candidate of [join(skillPath, 'SKILL.md'), join(skillPath, `skills/${row.name}/SKILL.md`)]) {
1860
- if (existsSync(candidate)) {
1861
- skillPath = candidate;
1862
- break;
1880
+ // 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
1881
+ let skillPath = row.local_path || '';
1882
+ if (skillPath && !skillPath.endsWith('.md')) {
1883
+ for (const candidate of [
1884
+ join(skillPath, 'SKILL.md'),
1885
+ join(skillPath, `skills/${row.name}/SKILL.md`),
1886
+ ]) {
1887
+ if (existsSync(candidate)) {
1888
+ skillPath = candidate;
1889
+ break;
1890
+ }
1863
1891
  }
1864
1892
  }
1865
- }
1866
1893
 
1867
- // 4. Path confinement check — prevent reading arbitrary files via crafted local_path.
1868
- // Base is the env-aware data dir (D#29): managed/ relocates with CLAUDE_MEM_DIR and
1869
- // equals homedir when unset, so this does not weaken the non-relocated confinement.
1870
- const managedBase = DB_DIR;
1871
- if (skillPath && !isPathConfined(skillPath, managedBase)) {
1872
- return {
1873
- content: [{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` }],
1874
- isError: true,
1875
- };
1876
- }
1894
+ // 4. Path confinement check — prevent reading arbitrary files via crafted local_path.
1895
+ // Base is the env-aware data dir (D#29): managed/ relocates with CLAUDE_MEM_DIR and
1896
+ // equals homedir when unset, so this does not weaken the non-relocated confinement.
1897
+ const managedBase = DB_DIR;
1898
+ if (skillPath && !isPathConfined(skillPath, managedBase)) {
1899
+ return {
1900
+ content: [
1901
+ { type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` },
1902
+ ],
1903
+ isError: true,
1904
+ };
1905
+ }
1877
1906
 
1878
- // 5. Read content
1879
- let content;
1880
- try {
1881
- content = readFileSync(skillPath, 'utf8');
1882
- } catch {
1883
- const msg = skillPath.endsWith('.md')
1884
- ? `Found ${type} "${row.name}" but cannot read file: ${skillPath}`
1885
- : `Found ${type} "${row.name}" but no .md file in: ${skillPath}`;
1886
- return { content: [{ type: 'text', text: msg }], isError: true };
1887
- }
1907
+ // 5. Read content
1908
+ let content;
1909
+ try {
1910
+ content = readFileSync(skillPath, 'utf8');
1911
+ } catch {
1912
+ const msg = skillPath.endsWith('.md')
1913
+ ? `Found ${type} "${row.name}" but cannot read file: ${skillPath}`
1914
+ : `Found ${type} "${row.name}" but no .md file in: ${skillPath}`;
1915
+ return { content: [{ type: 'text', text: msg }], isError: true };
1916
+ }
1888
1917
 
1889
- // 5. Record invocation
1890
- try {
1891
- rdb
1892
- .prepare(
1893
- `
1918
+ // 5. Record invocation
1919
+ try {
1920
+ rdb
1921
+ .prepare(
1922
+ `
1894
1923
  INSERT INTO invocations (resource_id, session_id, trigger, adopted, outcome)
1895
1924
  VALUES (?, ?, 'user_explicit', 1, 'success')
1896
1925
  `,
1897
- )
1898
- .run(row.id, process.env.CLAUDE_SESSION_ID || 'unknown');
1899
- } catch {
1900
- /* non-critical */
1901
- }
1926
+ )
1927
+ .run(row.id, process.env.CLAUDE_SESSION_ID || 'unknown');
1928
+ } catch {
1929
+ /* non-critical */
1930
+ }
1902
1931
 
1903
- const _home = homedir();
1904
- const portablePath =
1905
- skillPath && skillPath.startsWith(_home) ? '~' + skillPath.slice(_home.length) : skillPath || '';
1906
- const pathAttr = portablePath ? ` path="${portablePath}"` : '';
1907
- const reloadHint = portablePath ? ` Reload: Read("${portablePath}")` : '';
1908
- return {
1909
- content: [
1910
- {
1911
- type: 'text',
1912
- text: `<skill-loaded name="${row.name}" type="${row.type}"${pathAttr}>\n${content}\n</skill-loaded>\n\nFollow the instructions above to execute this ${row.type}.${reloadHint}`,
1913
- },
1914
- ],
1915
- };
1916
- }),
1932
+ const _home = homedir();
1933
+ const portablePath =
1934
+ skillPath && skillPath.startsWith(_home) ? '~' + skillPath.slice(_home.length) : skillPath || '';
1935
+
1936
+ // Defang the untrusted pieces before wrapping (audit 2026-09-05 R6 P1-1). All three come
1937
+ // from a third-party repo by way of the registry — `registry import-url` stores a body
1938
+ // verbatim, and `registry import --name` stores a name with no charset filter at all — so
1939
+ // this emitter is the containment boundary, not the import.
1940
+ //
1941
+ // The handler-wide defangResult only runs neutralizeContextDelimiters, and <skill-loaded>
1942
+ // is deliberately OFF that list (format-utils.mjs) so this very line can emit a real
1943
+ // wrapper. So the body needs the per-call-site neutralizer: a literal `</skill-loaded>`
1944
+ // in it closed the wrapper and forged a second block attributed to another skill, with
1945
+ // the "Follow the instructions above" sentence below landing after it as an endorsement.
1946
+ // Name and path are stripped rather than neutralized because they land in ATTRIBUTE
1947
+ // position, where a bare `"` breaks out of the tag regardless of any tag-shaped pattern.
1948
+ //
1949
+ // Same treatment the sibling face already applies (scripts/pre-skill-bridge.js, audit
1950
+ // 2026-08-14 M-4 + D#122 ③). The wrapper itself stays live — that is the counter-case
1951
+ // pinned by tests/audit-findings-20260814.test.mjs:605 and this file's last case.
1952
+ // `row.type` is not defanged: the resources CHECK constraint admits only 'skill'|'agent'.
1953
+ const attrSafe = (s) => String(s ?? '').replace(/["'<>]/g, '');
1954
+ const safeName = attrSafe(row.name);
1955
+ const safePath = attrSafe(portablePath);
1956
+ const safeBody = neutralizeSkillDelimiters(content);
1957
+ const pathAttr = safePath ? ` path="${safePath}"` : '';
1958
+ const reloadHint = safePath ? ` Reload: Read("${safePath}")` : '';
1959
+ return {
1960
+ content: [
1961
+ {
1962
+ type: 'text',
1963
+ text: `<skill-loaded name="${safeName}" type="${row.type}"${pathAttr}>\n${safeBody}\n</skill-loaded>\n\nFollow the instructions above to execute this ${row.type}.${reloadHint}`,
1964
+ },
1965
+ ],
1966
+ };
1967
+ // The one handler that emits a real <skill-loaded> wrapper, so the chokepoint's
1968
+ // skill-block pass is turned OFF here — see defangResult. Everything untrusted inside
1969
+ // the wrapper is neutralized above, per call site.
1970
+ },
1971
+ { emitsSkillBlock: true },
1972
+ ),
1917
1973
  );
1918
1974
 
1919
1975
  // ─── Tool: mem_update ────────────────────────────────────────────────────────