claude-mem-lite 3.7.1 → 3.8.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.7.1",
13
+ "version": "3.8.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.7.1",
3
+ "version": "3.8.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
@@ -172,6 +172,8 @@ npx github:sdsrss/claude-mem-lite
172
172
 
173
173
  Source files are automatically copied to `~/.claude-mem-lite/` for persistence.
174
174
 
175
+ > **Note:** `npx github:…` installs from the repo's **default branch (HEAD)**, which can be ahead of the latest published release. For the stable released version use the npm package (`npx claude-mem-lite`), or pin a release tag: `npx github:sdsrss/claude-mem-lite#vX.Y.Z`.
176
+
175
177
  ### Method 3: git clone
176
178
 
177
179
  ```bash
package/hook-update.mjs CHANGED
@@ -15,6 +15,7 @@ import { debugCatch, debugLog } from './utils.mjs';
15
15
  import { SOURCE_FILES as LOCAL_SOURCE_FILES, HOOK_SCRIPT_FILES as LOCAL_HOOK_SCRIPT_FILES } from './source-files.mjs';
16
16
  import { acquireLock } from './lib/proc-lock.mjs';
17
17
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
18
+ import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
18
19
 
19
20
  // ── Configuration ──────────────────────────────────────────
20
21
  const GITHUB_REPO = 'sdsrss/claude-mem-lite';
@@ -77,7 +78,7 @@ export async function checkForUpdate(options = {}) {
77
78
  if (hasUpdate) {
78
79
  debugLog('DEBUG', 'hook-update', `Update available: ${currentVersion} → ${latest.version}`);
79
80
  const canInstall = !pluginMode && Boolean(allowInstall);
80
- const success = canInstall ? await downloadAndInstall(latest.tarballUrl, latest.version) : false;
81
+ const success = canInstall ? await downloadAndInstall(latest.tarballUrl, latest.version, latest.assets) : false;
81
82
  const newState = {
82
83
  lastCheck: new Date().toISOString(),
83
84
  installedVersion: success ? latest.version : currentVersion,
@@ -206,6 +207,7 @@ async function fetchLatestRelease() {
206
207
  version: result.tag_name.replace(/^v/, ''),
207
208
  tarballUrl: result.tarball_url,
208
209
  releaseUrl: result.html_url,
210
+ assets: Array.isArray(result.assets) ? result.assets : [],
209
211
  };
210
212
  }
211
213
 
@@ -221,6 +223,7 @@ async function fetchLatestRelease() {
221
223
  version: tag.name.replace(/^v/, ''),
222
224
  tarballUrl: `https://api.github.com/repos/${GITHUB_REPO}/tarball/${tag.name}`,
223
225
  releaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${tag.name}`,
226
+ assets: [],
224
227
  };
225
228
  }
226
229
 
@@ -301,7 +304,7 @@ async function loadReleaseManifest(sourceDir) {
301
304
 
302
305
  // ── Download & Install ─────────────────────────────────────
303
306
  // Direct file copy instead of running old install.mjs (avoids symlink overwrite in dev)
304
- async function downloadAndInstall(tarballUrl, expectedVersion) {
307
+ async function downloadAndInstall(tarballUrl, expectedVersion, assets = []) {
305
308
  const tmpDir = join(tmpdir(), `claude-mem-lite-update-${Date.now()}`);
306
309
  try {
307
310
  mkdirSync(tmpDir, { recursive: true });
@@ -324,6 +327,17 @@ async function downloadAndInstall(tarballUrl, expectedVersion) {
324
327
  return false;
325
328
  }
326
329
 
330
+ // P1 supply-chain: cryptographically verify the release before installing.
331
+ // Opportunistic + inert until keyed — ok=false ONLY on a real tampering
332
+ // signal (signature present but invalid, or a file hash mismatch). Missing
333
+ // key / missing signature assets / fetch failure / escape hatch all proceed,
334
+ // so this never bricks auto-update for unsigned or pre-key releases.
335
+ const authentic = await verifyReleaseAuthenticity(tmpDir, assets);
336
+ if (!authentic.ok) {
337
+ debugLog('WARN', 'hook-update', `Release authenticity check failed (${authentic.action}) — aborting update`);
338
+ return false;
339
+ }
340
+
327
341
  return await installExtractedRelease(tmpDir);
328
342
  } catch (err) {
329
343
  debugCatch(err, 'downloadAndInstall');
@@ -372,6 +386,92 @@ export function validateExtractedTarball(sourceDir, expectedVersion, expectedNam
372
386
  return { ok: true };
373
387
  }
374
388
 
389
+ // ── Release signature verification (P1 supply-chain hardening) ──────────────
390
+ // Embedded Ed25519 PUBLIC key (SPKI PEM). EMPTY = unconfigured → verification is
391
+ // INERT and auto-update behaves exactly as before. Activating it is a one-time
392
+ // ops step (no private key ever ships in the repo):
393
+ // 1. Generate a keypair (writes the PRIVATE key to a local file, prints PUBLIC):
394
+ // node -e "const c=require('crypto');const{publicKey,privateKey}=c.generateKeyPairSync('ed25519');process.stdout.write(publicKey.export({type:'spki',format:'pem'}));require('fs').writeFileSync('release-signing-key.pem',privateKey.export({type:'pkcs8',format:'pem'}))"
395
+ // 2. Paste the printed PUBLIC key between the backticks below.
396
+ // 3. Add the PRIVATE key (release-signing-key.pem contents) as the GitHub
397
+ // Actions secret RELEASE_SIGNING_KEY, then delete the local file.
398
+ // NEVER commit the private key.
399
+ // Once a signed release exists, clients verify it; unsigned/older releases still
400
+ // install (opportunistic). Signer: scripts/sign-release.mjs. Core: lib/release-digest.mjs.
401
+ const RELEASE_PUBLIC_KEY = '';
402
+ const MANIFEST_ASSET_NAME = 'release-manifest.json';
403
+ const SIGNATURE_ASSET_NAME = 'release-manifest.json.sig';
404
+
405
+ // Pure verifier (no I/O) — exported for unit testing. ok=true ONLY when the
406
+ // Ed25519 signature over `manifestBytes` is valid for `publicKeyPem` AND every
407
+ // file the manifest lists matches its sha256 under `extractedDir`.
408
+ export function verifyDownloadedRelease(extractedDir, manifestBytes, signatureB64, publicKeyPem = RELEASE_PUBLIC_KEY) {
409
+ if (!verifyManifestSignature(manifestBytes, signatureB64, publicKeyPem)) {
410
+ return { ok: false, reason: 'signature-invalid' };
411
+ }
412
+ let manifest;
413
+ try {
414
+ manifest = JSON.parse(Buffer.isBuffer(manifestBytes) ? manifestBytes.toString('utf8') : String(manifestBytes));
415
+ } catch {
416
+ return { ok: false, reason: 'manifest-unparseable' };
417
+ }
418
+ const files = verifyReleaseFiles(extractedDir, manifest);
419
+ if (!files.ok) {
420
+ return { ok: false, reason: `file-mismatch: ${[...files.mismatches, ...files.missing].slice(0, 5).join(', ')}` };
421
+ }
422
+ return { ok: true, reason: 'verified' };
423
+ }
424
+
425
+ // Fetch a GitHub Release asset as a Buffer. Host-locked to github.com (the asset
426
+ // browser_download_url); GitHub's own 302 to its CDN is followed by fetch.
427
+ async function fetchAssetBuffer(url) {
428
+ if (!/^https:\/\/github\.com\/[\w./%~-]+$/.test(url || '')) {
429
+ throw new Error(`rejected asset url: ${url}`);
430
+ }
431
+ const controller = new AbortController();
432
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
433
+ try {
434
+ const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
435
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
436
+ return Buffer.from(await res.arrayBuffer());
437
+ } finally {
438
+ clearTimeout(timeout);
439
+ }
440
+ }
441
+
442
+ // I/O gate called from downloadAndInstall after validateExtractedTarball.
443
+ // Opportunistic: returns ok=false ONLY on a genuine tampering signal. Missing
444
+ // embedded key, missing signature assets, asset-fetch failure, or the
445
+ // CLAUDE_MEM_SKIP_SIG_VERIFY escape hatch all return ok=true so a verification
446
+ // gap can never permanently brick auto-update.
447
+ export async function verifyReleaseAuthenticity(extractedDir, assets) {
448
+ if (process.env.CLAUDE_MEM_SKIP_SIG_VERIFY) return { ok: true, action: 'skipped-env' };
449
+ if (!RELEASE_PUBLIC_KEY) return { ok: true, action: 'skipped-no-pubkey' };
450
+
451
+ const list = Array.isArray(assets) ? assets : [];
452
+ const manifestAsset = list.find(a => a && a.name === MANIFEST_ASSET_NAME);
453
+ const sigAsset = list.find(a => a && a.name === SIGNATURE_ASSET_NAME);
454
+ if (!manifestAsset || !sigAsset) {
455
+ debugLog('WARN', 'hook-update', 'Release carries no signature assets — proceeding unverified (unsigned release)');
456
+ return { ok: true, action: 'skipped-no-signature' };
457
+ }
458
+
459
+ let manifestBytes, signatureB64;
460
+ try {
461
+ manifestBytes = await fetchAssetBuffer(manifestAsset.browser_download_url);
462
+ signatureB64 = (await fetchAssetBuffer(sigAsset.browser_download_url)).toString('utf8').trim();
463
+ } catch (e) {
464
+ // A flaky asset CDN is not a tampering signal — don't brick the update over it.
465
+ debugLog('WARN', 'hook-update', `Signature asset fetch failed (${e.message}) — proceeding unverified`);
466
+ return { ok: true, action: 'skipped-fetch-failed' };
467
+ }
468
+
469
+ const r = verifyDownloadedRelease(extractedDir, manifestBytes, signatureB64);
470
+ if (!r.ok) return { ok: false, action: r.reason };
471
+ debugLog('DEBUG', 'hook-update', 'Release signature verified');
472
+ return { ok: true, action: 'verified' };
473
+ }
474
+
375
475
  // opts.skipNpmInstall — copy + atomically switch the source files WITHOUT
376
476
  // running `npm install` in staging. Used by syncDataDirFromCache: when the
377
477
  // source is a local plugin-cache version (not a downloaded tarball), the
@@ -0,0 +1,106 @@
1
+ // lib/release-digest.mjs — shared release-signing core (P1 supply-chain hardening).
2
+ //
3
+ // One source of truth for BOTH sides of the auto-update authenticity check so the
4
+ // CI signer and the runtime verifier can never drift:
5
+ // * scripts/sign-release.mjs (CI) — builds the manifest, serializes it, signs
6
+ // the EXACT serialized bytes with an Ed25519 private key (a GitHub secret),
7
+ // and uploads release-manifest.json (+ .sig) as GitHub Release assets.
8
+ // * hook-update.mjs (client) — downloads those assets, verifies the signature
9
+ // over the downloaded manifest bytes with an EMBEDDED public key, then checks
10
+ // each extracted file's sha256 against the signed manifest.
11
+ //
12
+ // Why content hashes, not a tarball-byte hash: GitHub's on-the-fly git-archive
13
+ // tarballs are not guaranteed byte-stable over time, but the FILE CONTENTS at a
14
+ // tag are (they are the git blobs). Hashing the extracted files sidesteps the
15
+ // archive-byte-stability problem entirely.
16
+ //
17
+ // Why verify over the downloaded file bytes (not a re-serialization): the client
18
+ // verifies the signature against the manifest bytes exactly as downloaded and
19
+ // only THEN parses it — so there is no canonical-JSON drift between signer and
20
+ // verifier. serializeManifest() exists so CI writes precisely what it signs.
21
+ //
22
+ // Pure Node built-ins (node:crypto Ed25519) — zero dependencies, no `gh`/cosign
23
+ // needed on the user's machine, works in the silent SessionStart hook.
24
+
25
+ import { createHash, verify as cryptoVerify } from 'node:crypto';
26
+ import { readFileSync, existsSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ const PACKAGE_NAME = 'claude-mem-lite';
30
+
31
+ export function sha256Hex(buf) {
32
+ return createHash('sha256').update(buf).digest('hex');
33
+ }
34
+
35
+ export function sha256File(absPath) {
36
+ return sha256Hex(readFileSync(absPath));
37
+ }
38
+
39
+ /**
40
+ * Build a release manifest: { name, version, algo, files: { <relPath>: <sha256> } }.
41
+ * Only files that exist under rootDir are listed; keys are sorted so the
42
+ * serialization is deterministic regardless of fileList order.
43
+ *
44
+ * @param {string} rootDir Extracted release root (or CI checkout root)
45
+ * @param {string[]} fileList Relative paths to hash (typically SOURCE_FILES)
46
+ * @param {string} version Release version (for the manifest body)
47
+ */
48
+ export function buildReleaseManifest(rootDir, fileList, version) {
49
+ const files = {};
50
+ for (const rel of [...fileList].sort()) {
51
+ const abs = join(rootDir, rel);
52
+ if (existsSync(abs)) files[rel] = sha256File(abs);
53
+ }
54
+ return { name: PACKAGE_NAME, version, algo: 'sha256', files };
55
+ }
56
+
57
+ /**
58
+ * Deterministic byte serialization. CI writes EXACTLY this to
59
+ * release-manifest.json and signs these bytes; the client verifies the signature
60
+ * against the downloaded file bytes (it does not re-serialize), so signer and
61
+ * verifier cannot disagree on canonical form.
62
+ */
63
+ export function serializeManifest(manifest) {
64
+ return JSON.stringify(manifest, null, 2) + '\n';
65
+ }
66
+
67
+ /**
68
+ * Verify every file the manifest lists matches its signed sha256 on disk.
69
+ * Returns { ok, mismatches: string[], missing: string[] }.
70
+ * A content change → mismatches; a manifest-listed file absent → missing.
71
+ * Both are failures (ok=false).
72
+ */
73
+ export function verifyReleaseFiles(rootDir, manifest) {
74
+ const mismatches = [];
75
+ const missing = [];
76
+ const files = (manifest && manifest.files) || {};
77
+ for (const [rel, expected] of Object.entries(files)) {
78
+ const abs = join(rootDir, rel);
79
+ if (!existsSync(abs)) { missing.push(rel); continue; }
80
+ if (sha256File(abs) !== expected) mismatches.push(rel);
81
+ }
82
+ return { ok: mismatches.length === 0 && missing.length === 0, mismatches, missing };
83
+ }
84
+
85
+ /**
86
+ * Verify an Ed25519 signature over the manifest bytes. Never throws — a malformed
87
+ * key/signature/algorithm returns false so callers can treat "can't verify" the
88
+ * same as "invalid" without a try/catch at every call site.
89
+ *
90
+ * @param {Buffer|string} manifestBytes The EXACT manifest bytes that were signed
91
+ * @param {string} signatureB64 Base64-encoded Ed25519 signature
92
+ * @param {string} publicKeyPem SPKI PEM public key
93
+ * @returns {boolean}
94
+ */
95
+ export function verifyManifestSignature(manifestBytes, signatureB64, publicKeyPem) {
96
+ try {
97
+ if (!publicKeyPem || !signatureB64) return false;
98
+ const data = Buffer.isBuffer(manifestBytes) ? manifestBytes : Buffer.from(manifestBytes);
99
+ const sig = Buffer.from(signatureB64, 'base64');
100
+ if (sig.length === 0) return false;
101
+ // `null` algorithm = Ed25519 (the key type carries the algorithm in Node).
102
+ return cryptoVerify(null, data, publicKeyPem, sig);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.7.1",
3
+ "version": "3.8.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",
@@ -73,6 +73,7 @@
73
73
  "lib/binding-probe.mjs",
74
74
  "lib/proc-lock.mjs",
75
75
  "lib/atomic-write.mjs",
76
+ "lib/release-digest.mjs",
76
77
  "lib/mem-override.mjs",
77
78
  "lib/save-observation.mjs",
78
79
  "lib/observation-write.mjs",
package/scripts/setup.sh CHANGED
@@ -86,6 +86,7 @@ mark_deps_broken() {
86
86
  # having to re-derive them. Delegate JSON serialization to node so embedded
87
87
  # quotes / shell metachars in $ROOT or $reason can't produce an invalid file
88
88
  # (bash `printf '"..%s.."'` cannot escape arbitrary strings safely; v2.79.1 fix).
89
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; vars passed via env (MARK_*), not shell expansion
89
90
  MARK_REASON="$reason" MARK_ROOT="$ROOT" MARK_FLAG="$DEPS_FLAG" node -e '
90
91
  const fs = require("fs");
91
92
  const reason = process.env.MARK_REASON || "unknown";
@@ -141,6 +142,7 @@ fi
141
142
  # versions; same shape as the .deps-broken self-heal pattern.
142
143
  MCP_MIGRATION="$DATA_DIR/runtime/.mcp-dedup-v2.78"
143
144
  if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$MCP_MIGRATION" ]]; then
145
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; CLAUDE_JSON passed via env, not shell expansion
144
146
  CLAUDE_JSON="$HOME/.claude.json" node -e '
145
147
  const fs = require("fs");
146
148
  let changed = false;
package/source-files.mjs CHANGED
@@ -73,6 +73,10 @@ export const SOURCE_FILES = [
73
73
  // + auto-update lock). Must ship or a partial install/update skips them.
74
74
  'lib/proc-lock.mjs',
75
75
  'lib/atomic-write.mjs',
76
+ // P1 supply-chain: shared release-signing core (sha256 manifest + Ed25519
77
+ // verify). Imported by hook-update.mjs (verify) + scripts/sign-release.mjs (CI
78
+ // sign). Must ship or auto-update can't verify release signatures.
79
+ 'lib/release-digest.mjs',
76
80
  // v2.41 god-module split — mem-cli.mjs router + per-cmd handlers under cli/
77
81
  'cli/common.mjs',
78
82
  'cli/fts-check.mjs',