claude-mem-lite 3.96.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -0
- package/cli/common.mjs +12 -4
- package/hook-context.mjs +2 -2
- package/hook-shared.mjs +16 -7
- package/hook-update.mjs +24 -1
- package/hook.mjs +38 -11
- package/mem-cli.mjs +8 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/registry-github.mjs +21 -3
- package/registry-importer.mjs +117 -2
- package/scripts/hook-launcher.mjs +99 -5
- package/scripts/setup.sh +16 -1
- package/server.mjs +174 -118
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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:
|
|
113
|
-
//
|
|
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/hook-context.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from './utils.mjs';
|
|
23
23
|
import {
|
|
24
24
|
STALE_SESSION_MS,
|
|
25
|
-
|
|
25
|
+
RELATED_OBS_WINDOW_MS,
|
|
26
26
|
RUNTIME_DIR,
|
|
27
27
|
effectiveQuiet,
|
|
28
28
|
isQuietHooks,
|
|
@@ -459,7 +459,7 @@ export function buildSessionContextLines(
|
|
|
459
459
|
let fallbackObs = [];
|
|
460
460
|
if (observations.length < 3) {
|
|
461
461
|
const fbOneDayAgo = now.getTime() - STALE_SESSION_MS;
|
|
462
|
-
const fbSevenDaysAgo = now.getTime() -
|
|
462
|
+
const fbSevenDaysAgo = now.getTime() - RELATED_OBS_WINDOW_MS;
|
|
463
463
|
fallbackObs = db
|
|
464
464
|
.prepare(
|
|
465
465
|
`
|
package/hook-shared.mjs
CHANGED
|
@@ -54,18 +54,27 @@ export const SESSION_EXPIRY_MS = 12 * 60 * 60 * 1000; // 12h
|
|
|
54
54
|
export const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24h
|
|
55
55
|
export const STALE_LOCK_MS = 30000; // 30s
|
|
56
56
|
|
|
57
|
+
// Backstop for cleanStaleLockFiles(): a lock whose recorded pid is ALIVE is kept until it
|
|
58
|
+
// reaches this age, not STALE_LOCK_MS. Deliberately LONGER than proc-lock.mjs's own 5-min
|
|
59
|
+
// steal window, so the sweeper is never the more aggressive of the two — whatever it
|
|
60
|
+
// removes, the lock protocol itself would already have let the next caller steal. Its only
|
|
61
|
+
// job is to garbage-collect a leaked file whose pid was recycled onto an unrelated live
|
|
62
|
+
// process, which would otherwise pin the file forever. (A20260905-R5-P1-1)
|
|
63
|
+
export const ABANDONED_LOCK_MS = 10 * 60 * 1000; // 10 min
|
|
64
|
+
|
|
57
65
|
// The background-maintenance mutex, defined HERE next to the sweeper policy it has to
|
|
58
|
-
// escape. cleanStaleLockFiles()
|
|
59
|
-
//
|
|
60
|
-
// critical section, fatal for a maintenance pass that runs for seconds
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
66
|
+
// escape. cleanStaleLockFiles() sweeps every `*.lock` in RUNTIME_DIR; until
|
|
67
|
+
// A20260905-R5-P1-1 it did so on AGE ALONE once past STALE_LOCK_MS — right for the episode
|
|
68
|
+
// lock's millisecond critical section, fatal for a maintenance pass that runs for seconds
|
|
69
|
+
// to minutes. The sweeper now spares a live holder, but this mutex keeps the `.proclock`
|
|
70
|
+
// name: not being swept at all is a stronger guarantee than being spared by a liveness
|
|
71
|
+
// probe, and pid checks are meaningless across a shared homedir. `tests/auto-maintain-proc-lock.test.mjs`
|
|
72
|
+
// asserts that against THIS constant rather than a re-typed copy: the first version of that
|
|
73
|
+
// test built its own path from a literal, so renaming the lock left it green with the hazard
|
|
64
74
|
// back. proc-lock's own staleness policy (age OR provably-dead pid) is the correct one.
|
|
65
75
|
export const AUTO_MAINTAIN_LOCK = 'auto-maintain.proclock';
|
|
66
76
|
export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
|
|
67
77
|
export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
|
|
68
|
-
export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
|
|
69
78
|
// Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
|
|
70
79
|
// keyObs; each of the two sections then renders at most 5). The user-prompt
|
|
71
80
|
// exclude-set does NOT mirror this query — it reads the ids actually rendered
|
package/hook-update.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
rmSync,
|
|
17
17
|
renameSync,
|
|
18
18
|
chmodSync,
|
|
19
|
+
realpathSync,
|
|
19
20
|
} from 'node:fs';
|
|
20
21
|
import { join, dirname, resolve } from 'node:path';
|
|
21
22
|
import { pathToFileURL } from 'node:url';
|
|
@@ -1294,6 +1295,19 @@ export function clearCacheHookResidue() {
|
|
|
1294
1295
|
// ── Plugin Cache Pruning ──────────────────────────────────
|
|
1295
1296
|
const PLUGIN_CACHE_KEEP = 3;
|
|
1296
1297
|
|
|
1298
|
+
/**
|
|
1299
|
+
* Same-directory test that survives trailing slashes, `..` segments and symlinks.
|
|
1300
|
+
* realpathSync throws on a path that no longer exists → fall back to lexical resolve.
|
|
1301
|
+
*/
|
|
1302
|
+
function isSameDir(a, b) {
|
|
1303
|
+
if (!a || !b) return false;
|
|
1304
|
+
try {
|
|
1305
|
+
return realpathSync(a) === realpathSync(b);
|
|
1306
|
+
} catch {
|
|
1307
|
+
return resolve(a) === resolve(b);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1297
1311
|
export function prunePluginCache() {
|
|
1298
1312
|
const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
|
|
1299
1313
|
if (!existsSync(cacheBase)) return 0;
|
|
@@ -1304,11 +1318,20 @@ export function prunePluginCache() {
|
|
|
1304
1318
|
|
|
1305
1319
|
if (entries.length <= PLUGIN_CACHE_KEEP) return 0;
|
|
1306
1320
|
|
|
1321
|
+
// A20260905-R5-Q1: "not in the newest 3" is not the same question as "not in use".
|
|
1322
|
+
// CLAUDE_PLUGIN_ROOT is the version dir THIS process was launched from, and after a
|
|
1323
|
+
// marketplace rollback (a bad release withdrawn while >=3 newer dirs are already cached)
|
|
1324
|
+
// it is not among the newest 3 — so keep-latest-3 rm -rf'd the tree the running hooks and
|
|
1325
|
+
// MCP server import from. scripts/setup.sh step 8 carries the same guard for the same
|
|
1326
|
+
// reason; the two prune the same directory and must agree.
|
|
1327
|
+
const runningRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
1307
1328
|
const toRemove = entries.slice(PLUGIN_CACHE_KEEP);
|
|
1308
1329
|
let removed = 0;
|
|
1309
1330
|
for (const ver of toRemove) {
|
|
1331
|
+
const dir = join(cacheBase, ver);
|
|
1332
|
+
if (isSameDir(dir, runningRoot)) continue;
|
|
1310
1333
|
try {
|
|
1311
|
-
rmSync(
|
|
1334
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1312
1335
|
removed++;
|
|
1313
1336
|
} catch {}
|
|
1314
1337
|
}
|
package/hook.mjs
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
SESSION_EXPIRY_MS,
|
|
71
71
|
STALE_SESSION_MS,
|
|
72
72
|
STALE_LOCK_MS,
|
|
73
|
+
ABANDONED_LOCK_MS,
|
|
73
74
|
AUTO_MAINTAIN_LOCK,
|
|
74
75
|
STALE_EPISODE_BUFFER_AGE_MS,
|
|
75
76
|
HANDOFF_EXPIRY_CLEAR,
|
|
@@ -1945,14 +1946,15 @@ function scheduleSessionStartAutoMaintain(project) {
|
|
|
1945
1946
|
if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
|
|
1946
1947
|
}
|
|
1947
1948
|
|
|
1948
|
-
// The maintenance mutex deliberately does NOT end in `.lock
|
|
1949
|
-
// below
|
|
1950
|
-
//
|
|
1951
|
-
// critical section is milliseconds
|
|
1952
|
-
// snapshot, purge, decay, dedup over the whole DB)
|
|
1953
|
-
//
|
|
1954
|
-
//
|
|
1955
|
-
//
|
|
1949
|
+
// The maintenance mutex deliberately does NOT end in `.lock`, so cleanStaleLockFiles()
|
|
1950
|
+
// below never sees it at all. That sweeper used to unlink every `*.lock` past
|
|
1951
|
+
// STALE_LOCK_MS (30s) without consulting the holder's pid — a policy written for the
|
|
1952
|
+
// episode lock, whose critical section is milliseconds — and a maintenance pass is seconds
|
|
1953
|
+
// to minutes (VACUUM INTO snapshot, purge, decay, dedup over the whole DB). It now spares a
|
|
1954
|
+
// live holder (A20260905-R5-P1-1), but this escape stays: not being swept is a stronger
|
|
1955
|
+
// guarantee than being spared by a pid probe, and pids are meaningless across a shared
|
|
1956
|
+
// homedir. proc-lock brings its own staleness policy (age OR provably-dead pid), which is
|
|
1957
|
+
// the correct one here.
|
|
1956
1958
|
// Generous upper bound on one pass; a crashed holder is normally reclaimed sooner via the
|
|
1957
1959
|
// dead-pid check, so this only matters for a holder killed on another host.
|
|
1958
1960
|
const AUTO_MAINTAIN_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
@@ -2078,8 +2080,29 @@ function saveHandoffAndFastSummary(
|
|
|
2078
2080
|
}
|
|
2079
2081
|
}
|
|
2080
2082
|
|
|
2083
|
+
/**
|
|
2084
|
+
* Sweep abandoned `*.lock` files out of RUNTIME_DIR on SessionStart.
|
|
2085
|
+
*
|
|
2086
|
+
* "Stale" has to mean ABANDONED, not merely old. Until A20260905-R5-P1-1 this swept on AGE
|
|
2087
|
+
* ALONE (pid was consulted only for locks YOUNGER than STALE_LOCK_MS, i.e. exactly the ones
|
|
2088
|
+
* it was going to keep anyway), so a lock older than 30s was unlinked no matter who held it.
|
|
2089
|
+
* `runtime/install.lock` — lib/proc-lock.mjs, taken by `install.mjs repair`,
|
|
2090
|
+
* `install.mjs rebuild-binding`, hook-update.installExtractedRelease and scripts/launch.mjs —
|
|
2091
|
+
* guards a critical section that routinely runs 30s–2min (npm install in staging is capped at
|
|
2092
|
+
* 60s, npm rebuild in smoke at 120s). Any parallel Claude Code window starting up during that
|
|
2093
|
+
* span deleted the live holder's lock; the next installer then acquired it and began renaming
|
|
2094
|
+
* files into the same install dir, which is the torn mixed-version install (server vN + hook
|
|
2095
|
+
* vN+1) proc-lock.mjs's header exists to prevent.
|
|
2096
|
+
*
|
|
2097
|
+
* Policy now: a recorded pid that is ALIVE (or alive-but-not-ours, EPERM) is spared until
|
|
2098
|
+
* ABANDONED_LOCK_MS; a provably-dead pid (ESRCH) is swept at any age; a lock with no usable
|
|
2099
|
+
* pid falls back to STALE_LOCK_MS, as before.
|
|
2100
|
+
*
|
|
2101
|
+
* Liveness of the two real lock families does not depend on this sweeper, so tightening it
|
|
2102
|
+
* cannot wedge either: hook-episode.acquireLock() preempts a >30s episode lock itself at
|
|
2103
|
+
* acquire time, and proc-lock.acquireLock() steals on age OR dead pid at 5 min.
|
|
2104
|
+
*/
|
|
2081
2105
|
function cleanStaleLockFiles() {
|
|
2082
|
-
// Clean stale lock files in runtime dir
|
|
2083
2106
|
try {
|
|
2084
2107
|
for (const f of readdirSync(RUNTIME_DIR)) {
|
|
2085
2108
|
if (!f.endsWith('.lock')) continue;
|
|
@@ -2089,12 +2112,16 @@ function cleanStaleLockFiles() {
|
|
|
2089
2112
|
const info = JSON.parse(raw);
|
|
2090
2113
|
const age = Date.now() - (info.ts || 0);
|
|
2091
2114
|
let stale = age > STALE_LOCK_MS;
|
|
2092
|
-
if (
|
|
2115
|
+
if (info.pid) {
|
|
2116
|
+
let alive = false;
|
|
2093
2117
|
try {
|
|
2094
2118
|
process.kill(info.pid, 0);
|
|
2119
|
+
alive = true;
|
|
2095
2120
|
} catch (killErr) {
|
|
2096
|
-
|
|
2121
|
+
// EPERM = the process exists but belongs to another user — still a live holder.
|
|
2122
|
+
alive = killErr.code === 'EPERM';
|
|
2097
2123
|
}
|
|
2124
|
+
stale = alive ? age > ABANDONED_LOCK_MS : true;
|
|
2098
2125
|
}
|
|
2099
2126
|
if (stale) unlinkSync(lp);
|
|
2100
2127
|
} catch {
|
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
|
-
|
|
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...');
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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",
|
package/registry-github.mjs
CHANGED
|
@@ -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
|
/**
|
package/registry-importer.mjs
CHANGED
|
@@ -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 (
|
|
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;
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
// dir) or a missing bare dependency like better-sqlite3 (e.url is undefined and
|
|
15
15
|
// the importer named in the message is under the install dir) — run
|
|
16
16
|
// `install.mjs repair` (rate-limited via a 6h marker file under runtime/) and
|
|
17
|
-
// retry the import once.
|
|
17
|
+
// retry the import once. That repair runs at SESSION-START ONLY; every other
|
|
18
|
+
// event records the breakage and defers (A20260905-R5-Q2, see attemptHeal).
|
|
19
|
+
// If repair is unavailable, deferred or fails, degrade quietly:
|
|
18
20
|
// these are best-effort memory hooks, so a broken/missing dependency emits one
|
|
19
21
|
// clean recovery line and exits 0 rather than dumping a Node stack trace on
|
|
20
22
|
// every fire. On any other (foreign) exception, re-throw so Node's default
|
|
@@ -272,6 +274,37 @@ function clearBreakage() {
|
|
|
272
274
|
}
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
// Hot-path counterpart of attemptHeal (A20260905-R5-Q2).
|
|
278
|
+
//
|
|
279
|
+
// attemptHeal() runs `install.mjs repair` SYNCHRONOUSLY with a 300s timeout. The events
|
|
280
|
+
// this launcher fires on cannot host that: hooks/hooks.json gives PreToolUse 3s,
|
|
281
|
+
// PostToolUse 3s, UserPromptSubmit 2s, Stop and PreCompact 5s — only SessionStart's 15s is
|
|
282
|
+
// in the same order of magnitude as an npm run. Worse than being killed: recordHealAttempt()
|
|
283
|
+
// arms the 6h cooldown BEFORE the spawn, deliberately, as concurrent-fire rate limiting (see
|
|
284
|
+
// clearHealMarker below). So a repair the host killed at 2s still bought six hours of
|
|
285
|
+
// "Self-heal skipped" — including for the SessionStart fire that had the budget to finish it.
|
|
286
|
+
// The hot path now records the breakage for `doctor` and gets out of the way.
|
|
287
|
+
//
|
|
288
|
+
// This is the same rule healNativeBindingIfBroken() below already follows, for the same
|
|
289
|
+
// reason ("never on the per-tool hot path, where an npm run would stall the user's edit").
|
|
290
|
+
//
|
|
291
|
+
// Do NOT "fix" the cooldown by moving recordHealAttempt() after the spawn instead: that is
|
|
292
|
+
// the mutual-exclusion between concurrent fires, and the R5 report's first suggestion.
|
|
293
|
+
//
|
|
294
|
+
// Known gap this does NOT close, because it was already open: if the missing module sits on
|
|
295
|
+
// a DIFFERENT entry's import chain and session-start's own entry imports cleanly, nothing
|
|
296
|
+
// heals — a clean session-start clears the breakage marker without repairing. Narrow in
|
|
297
|
+
// practice (hook.mjs imports most of lib/), and closing it needs a detached, stdio-ignored
|
|
298
|
+
// spawn like the native-binding path, not this one.
|
|
299
|
+
function deferHealToSessionStart(reason) {
|
|
300
|
+
process.stderr.write(
|
|
301
|
+
`[claude-mem-lite] Broken install (${reason}) — self-heal deferred to the next SessionStart ` +
|
|
302
|
+
`(this hook has a 2-5s budget; repair needs minutes).\n` +
|
|
303
|
+
`[claude-mem-lite] Manual recovery: ${CLI_REPAIR}\n`,
|
|
304
|
+
);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
275
308
|
async function attemptHeal(reason) {
|
|
276
309
|
if (recentHealAttempt()) {
|
|
277
310
|
process.stderr.write(
|
|
@@ -298,6 +331,64 @@ async function attemptHeal(reason) {
|
|
|
298
331
|
return result.status === 0;
|
|
299
332
|
}
|
|
300
333
|
|
|
334
|
+
// Session-start heal driven by the BREAKAGE MARKER rather than by our own failed import
|
|
335
|
+
// (A20260905-R5-Q2, second half).
|
|
336
|
+
//
|
|
337
|
+
// Since the heal moved off the hot path, a hot-path fire that hits a missing module records
|
|
338
|
+
// the breakage and defers. But this launcher fronts several entries — hook.mjs plus
|
|
339
|
+
// pre-tool-recall.js, pre-skill-bridge.js, post-tool-recall.js, user-prompt-search.js — and
|
|
340
|
+
// the missing module may sit on one of THEIR import chains and not on hook.mjs's. Then
|
|
341
|
+
// session-start's own entry imports cleanly, the catch below never fires, and before this
|
|
342
|
+
// function existed the clean fire simply cleared the marker: nothing ever repaired it. (That
|
|
343
|
+
// gap predates the hot-path gate — a clean session-start always cleared the marker — but the
|
|
344
|
+
// gate is what makes it the ONLY remaining route, so it is closed here.)
|
|
345
|
+
//
|
|
346
|
+
// DETACHED with stdio ignored, exactly like healNativeBindingIfBroken() below and for the
|
|
347
|
+
// same two reasons: the fire is capped at 15s while `install.mjs repair` can take minutes,
|
|
348
|
+
// and install.mjs logs to STDOUT while SessionStart stdout is a JSON envelope Claude Code
|
|
349
|
+
// parses. That is also why this cannot reuse attemptHeal(), whose spawn is synchronous and
|
|
350
|
+
// inherits stdio — correct where the entry failed and nothing has been written yet, wrong
|
|
351
|
+
// here, where runEntry() has already emitted the envelope.
|
|
352
|
+
//
|
|
353
|
+
// Marker bookkeeping, and why it differs from the native-binding path: `install.mjs repair`
|
|
354
|
+
// does not know about `hook-launcher-broken` (only doctor reads it, only this file writes
|
|
355
|
+
// it), so no child can clear it on our behalf. Clearing it here after spawning keeps
|
|
356
|
+
// `doctor` honest — a repair that did not take is re-recorded by the very next failing fire
|
|
357
|
+
// — while the 6h cooldown, not the marker, is what bounds repair spawns to one per window.
|
|
358
|
+
// Within that window the marker is deliberately left in place so doctor still reports the
|
|
359
|
+
// unrepaired breakage.
|
|
360
|
+
function healRecordedBreakage() {
|
|
361
|
+
try {
|
|
362
|
+
if (!existsSync(BROKEN_MARKER)) {
|
|
363
|
+
// No fire has recorded a degraded exit since the last session-start → as healthy as
|
|
364
|
+
// this launcher can tell. Drop a stale cooldown so a LATER unrelated break heals
|
|
365
|
+
// immediately rather than waiting out a window earned by an old fault. (#6/#9)
|
|
366
|
+
clearHealMarker();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (recentHealAttempt()) return; // on cooldown — keep the marker, doctor should see it
|
|
370
|
+
const installer = join(INSTALL_DIR, 'install.mjs');
|
|
371
|
+
if (!existsSync(installer)) {
|
|
372
|
+
process.stderr.write(
|
|
373
|
+
`[claude-mem-lite] A hook fire degraded to exit 0 and install.mjs is missing — ${TARBALL_FALLBACK}\n`,
|
|
374
|
+
);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
recordHealAttempt();
|
|
378
|
+
process.stderr.write(
|
|
379
|
+
'[claude-mem-lite] A previous hook fire degraded to exit 0 — repairing in the background\n',
|
|
380
|
+
);
|
|
381
|
+
const child = spawn(process.execPath, [installer, 'repair'], {
|
|
382
|
+
detached: true,
|
|
383
|
+
stdio: 'ignore',
|
|
384
|
+
});
|
|
385
|
+
child.unref();
|
|
386
|
+
clearBreakage();
|
|
387
|
+
} catch {
|
|
388
|
+
/* best-effort — a heal failure must never stop the fire */
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
301
392
|
// Defense-in-depth for plugin-mode version drift: the plugin-cache MCP server
|
|
302
393
|
// (kept current by Claude Code) migrates the shared DB schema forward, while
|
|
303
394
|
// this data-dir code (the standalone CLI + these hooks) is only advanced by the
|
|
@@ -398,9 +489,12 @@ if (IS_SESSION_START) {
|
|
|
398
489
|
|
|
399
490
|
try {
|
|
400
491
|
await runEntry();
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
if
|
|
492
|
+
// Our entry imported cleanly, but that is NOT the same as "the install is whole": this
|
|
493
|
+
// launcher fronts five entries and the missing module may be on another one's chain. So
|
|
494
|
+
// act on the recorded breakage instead of just clearing it — background repair if there is
|
|
495
|
+
// one to do, clear either way. Gated to session-start so the hot path pays nothing.
|
|
496
|
+
// (Was an unconditional clearBreakage(); A20260905-R5-Q2.)
|
|
497
|
+
if (IS_SESSION_START) healRecordedBreakage();
|
|
404
498
|
// After the entry too: the fire that DISCOVERS the breakage is the one that
|
|
405
499
|
// records it, so a pre-entry-only check would leave the whole session dead and
|
|
406
500
|
// heal one session late.
|
|
@@ -408,7 +502,7 @@ try {
|
|
|
408
502
|
} catch (e) {
|
|
409
503
|
if (!isLocalModuleErr(e)) throw e;
|
|
410
504
|
const reason = describeFailure(e);
|
|
411
|
-
const healed = await attemptHeal(reason);
|
|
505
|
+
const healed = IS_SESSION_START ? await attemptHeal(reason) : deferHealToSessionStart(reason);
|
|
412
506
|
if (!healed) {
|
|
413
507
|
// Broken/missing dependency we can't repair right now (repair failed, or
|
|
414
508
|
// was skipped within the 6h cooldown). attemptHeal already wrote actionable
|
package/scripts/setup.sh
CHANGED
|
@@ -248,10 +248,25 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
|
|
|
248
248
|
done < <(for _d in "${_all_dirs[@]}"; do [[ -d "$_d" ]] && echo "${_d##*/}"; done | sort -t. -k1,1nr -k2,2nr -k3,3nr | tail -n +4)
|
|
249
249
|
unset _all_dirs _d
|
|
250
250
|
if [[ ${#OLD_VERS[@]} -gt 0 ]]; then
|
|
251
|
+
PRUNED=0
|
|
251
252
|
for ver in "${OLD_VERS[@]}"; do
|
|
253
|
+
# A20260905-R5-Q1: "not in the newest 3" is not the same question as "not in use".
|
|
254
|
+
# CLAUDE_PLUGIN_ROOT is the version dir this session is RUNNING from, and after a
|
|
255
|
+
# marketplace rollback (a bad release withdrawn while >=3 newer dirs sit in the
|
|
256
|
+
# cache) it falls outside the newest 3 — so this loop deleted the tree every hook
|
|
257
|
+
# and the MCP server import from, mid-session. -ef compares device+inode, so it is
|
|
258
|
+
# not fooled by a trailing slash, a `..` segment or a symlinked cache dir.
|
|
259
|
+
# hook-update.mjs prunePluginCache() carries the same guard; the two prune the same
|
|
260
|
+
# directory and must agree.
|
|
261
|
+
if [[ "$CACHE_DIR/$ver" -ef "$CLAUDE_PLUGIN_ROOT" ]]; then
|
|
262
|
+
continue
|
|
263
|
+
fi
|
|
252
264
|
rm -rf "${CACHE_DIR:?}/$ver" 2>/dev/null || true
|
|
265
|
+
PRUNED=$((PRUNED + 1))
|
|
253
266
|
done
|
|
254
|
-
|
|
267
|
+
if [[ $PRUNED -gt 0 ]]; then
|
|
268
|
+
log_ok "Plugin cache pruned: removed $PRUNED old version(s)"
|
|
269
|
+
fi
|
|
255
270
|
fi
|
|
256
271
|
fi
|
|
257
272
|
fi
|
package/server.mjs
CHANGED
|
@@ -293,14 +293,28 @@ function applyArgAliases(args, pairs) {
|
|
|
293
293
|
return next;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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
|
-
|
|
1797
|
-
|
|
1820
|
+
const name = args.name.trim();
|
|
1821
|
+
const type = args.type || 'skill';
|
|
1798
1822
|
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
skillPath
|
|
1862
|
-
|
|
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
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
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
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
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
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
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
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1926
|
+
)
|
|
1927
|
+
.run(row.id, process.env.CLAUDE_SESSION_ID || 'unknown');
|
|
1928
|
+
} catch {
|
|
1929
|
+
/* non-critical */
|
|
1930
|
+
}
|
|
1902
1931
|
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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 ────────────────────────────────────────────────────────
|