sage-mem 1.6.2 → 1.6.3

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/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  **Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
14
14
 
15
- **Current release: `1.6.2`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
15
+ **Current release: `1.6.3`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
16
16
  [frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
17
17
 
18
18
  If it is useful to you, a Star is the most direct feedback.
@@ -460,6 +460,8 @@ sage-mem does three things only, all of them on your own machine:
460
460
 
461
461
  Your memories are the files in that directory, openable at any time for confirmation — which is also why file-based was chosen.
462
462
 
463
+ **Trust boundary for the memory directory.** Anyone who can write the memory directory can change what gets injected into the model — that is **inside** the trust model, not a vulnerability in the plugin. The read path rejects symlinks, reserved-name aliases and files over 512 KB at check time, and re-verifies file identity when reading (a file swapped in after the check is refused instead of injected); it cannot defend against a writer who is already allowed to modify your memories. Keep the directory's permissions to your own account.
464
+
463
465
  ## Compatibility
464
466
 
465
467
  sage-mem is a pure DSH plugin and declares its host requirement through **`engines.dsh`** in `package.json`: `>=0.1.2-rc.1` — **a one-sided open interval with no upper bound**, so no future DSH version will be blocked by this declaration. `engines.node` requires `>= 18`.
@@ -524,6 +526,7 @@ npm test # all assertions (including the three guards)
524
526
  ```
525
527
 
526
528
  - The assertion scripts depend only on `node:` built-in modules and **introduce no test framework**; each script hand-writes `ok()` and ends with a summary line.
529
+ - This repository uses **npm** for install / test / CI. No `pnpm-lock.yaml` is kept: a stale one could not be frozen-installed from a fresh clone (its workspace file was untracked), so it was removed rather than left to mislead.
527
530
  - **Three guards** watch over "whether the tests themselves hold up": `suite-registry-test.mjs` (is there a suite that was written but never runs), `contract-binding-test.mjs` (is there a commitment that was made but never verified), `doc-consistency-test.mjs` (do the two documents characterize things identically line by line).
528
531
  - CI runs twice on `ubuntu-latest` (the second time with `TZ=UTC`) — a case-sensitive volume catches defects that are invisible on Windows.
529
532
  - When you change **user-visible behaviour**, please also update the corresponding line in the [contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md) and the CHANGELOG; a commitment you cannot write an assertion for can only be marked "best-effort".
package/README.zh.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  > File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
16
16
 
17
- **当前发布版本:`1.6.2`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`1.6.3`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
18
18
  [契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
19
19
 
20
20
  如果它对你有用,给个 Star 是最直接的反馈。
@@ -462,6 +462,8 @@ sage-mem 只做三件事,全都在你本机:
462
462
 
463
463
  记忆就是那个目录里的文件,随时可以打开确认 —— 这也是选文件式的原因。
464
464
 
465
+ **记忆目录的信任边界**:能写记忆目录的人就能改变注入给模型的内容 —— 这属于**信任模型内部**,不是插件的漏洞。读路径在检查时会拒绝符号链接、保留名别名与超过 512 KB 的文件,读取时还会复核文件身份(检查之后被换掉的文件会被拒绝,而不是照读);但它防不住一个已经被允许修改你记忆的人。把目录权限收到你自己的账号。
466
+
465
467
  ## 兼容性
466
468
 
467
469
  sage-mem 是纯 DSH 插件,用 `package.json` 的 **`engines.dsh`** 声明宿主要求:`>=0.1.2-rc.1` —— **单边开区间、不设上界**,所以任何未来的 DSH 版本都不会被这道声明挡住。`engines.node` 要求 `>= 18`。
@@ -526,6 +528,7 @@ npm test # 全部断言(含三条守卫)
526
528
  ```
527
529
 
528
530
  - 断言脚本只依赖 `node:` 内置模块,**不引测试框架**,各脚本手写 `ok()` 并以汇总行收尾。
531
+ - 本仓库统一用 **npm** 安装 / 测试 / CI。不留 `pnpm-lock.yaml`:旧的锁在 fresh clone 里 `--frozen-lockfile` 装不动(它依赖的 workspace 文件未入库),留着只会误导。
529
532
  - **三条守卫**盯的是「测试本身站不站得住」:`suite-registry-test.mjs`(有没有写了不跑的套件)、
530
533
  `contract-binding-test.mjs`(有没有说了不验的承诺)、`doc-consistency-test.mjs`(两份文档的定性是否逐行一致)。
531
534
  - CI 在 `ubuntu-latest` 上跑两遍(第二遍 `TZ=UTC`)—— 区分大小写的卷能逮到 Windows 上看不见的缺陷。
package/lib/index.js CHANGED
@@ -22,14 +22,14 @@ import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from './shared/md-name
22
22
  import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
23
23
  import { ageDays, bigrams, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
24
24
  import { isReserved, nameKey, safeName } from './memory/naming.js'
25
- import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
25
+ import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, readVerified, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
26
26
  import { archiveMemory, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
27
27
  import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
28
28
  import { warnLog, writeFileAtomic } from './memory/util.js'
29
29
  import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
30
30
  import { openMemoryFolder } from './memory/open-folder.js'
31
31
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
32
- import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
32
+ import { mkdir, unlink, writeFile } from 'node:fs/promises'
33
33
  import { join } from 'node:path'
34
34
  import { checkUpdate as checkUpdateImpl } from './update.js'
35
35
  import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, pendingLimits, rawName, readReservedFile, readSettingsFile, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
@@ -126,7 +126,7 @@ export class MemoryGateway extends TypertRemoteService {
126
126
  }
127
127
  const gate = await inspectMemoryEntry(full)
128
128
  if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
129
- const content = await readFile(full, 'utf8')
129
+ const content = await readVerified(full, gate, 'utf8')
130
130
  return { name: safe, content }
131
131
  }
132
132
 
@@ -284,7 +284,7 @@ export class MemoryGateway extends TypertRemoteService {
284
284
  if (gate.code === 'missing') throw new Error(`sage-mem: archived file not found: ${safe}`)
285
285
  throw new Error(`sage-mem: refusing to read archived ${safe}: ${gate.error}`)
286
286
  }
287
- const content = await readFile(full, 'utf8')
287
+ const content = await readVerified(full, gate, 'utf8')
288
288
  return { name: safe, content }
289
289
  }
290
290
 
@@ -327,7 +327,7 @@ export class MemoryGateway extends TypertRemoteService {
327
327
  const given = readArchivedMeta(content)
328
328
  let toWrite = content
329
329
  if (!given.archivedAt && !given.archivedReason) {
330
- const onDisk = readArchivedMeta(await readFile(full, 'utf8'))
330
+ const onDisk = readArchivedMeta(await readVerified(full, gate, 'utf8'))
331
331
  if (onDisk.archivedAt || onDisk.archivedReason) {
332
332
  toWrite = addArchiveMeta(content, onDisk.archivedReason, onDisk.archivedAt)
333
333
  }
@@ -518,7 +518,7 @@ export class MemoryGateway extends TypertRemoteService {
518
518
  if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
519
519
  // 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
520
520
  // 用户一存就把真索引顶掉。
521
- const content = await readFile(full, 'utf8')
521
+ const content = await readVerified(full, gate, 'utf8')
522
522
  return { name: safe, content }
523
523
  }
524
524
 
@@ -620,7 +620,15 @@ export class StarmapGateway extends TypertRemoteService {
620
620
  // MEMORY.md 与 session-log.md,且大小写不敏感。
621
621
  const safe = safeName(name)
622
622
  if (!safe) throw new Error('sage-starmap: invalid file name')
623
- const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
623
+ const full = join(MEMORY_DIR, safe)
624
+ // 2026-10-23 审查 R19:面板读原先只过 safeName,与注入链 / 工具读不是同一套闸门。
625
+ // 面板读不进模型上下文,但「同一个名字在不同入口有不同含义」本身就是缺陷面 —— 收口。
626
+ if (await shadowsReserved(full)) {
627
+ throw new Error(`sage-starmap: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
628
+ }
629
+ const gate = await inspectMemoryEntry(full)
630
+ if (!gate.ok) throw new Error(`sage-starmap: refusing to read ${safe}: ${gate.error}`)
631
+ const content = await readVerified(full, gate, 'utf8')
624
632
  return { name: safe, content }
625
633
  }
626
634
  }
@@ -16,10 +16,9 @@
16
16
  */
17
17
 
18
18
  import { MEMORY_DIR } from './config.js'
19
- import { inspectMemoryEntry, shadowsReserved } from './scan.js'
19
+ import { inspectMemoryEntry, readVerified, shadowsReserved } from './scan.js'
20
20
  import { safeName } from './naming.js'
21
21
  import { revisionOf } from './chunks.js'
22
- import { readFile } from 'node:fs/promises'
23
22
  import { join } from 'node:path'
24
23
 
25
24
  /** 一屏默认给多少字节。4 KB ≈ 一屏可读正文,比一次注入的 60 KB 会话预算小一个量级。 */
@@ -231,7 +230,9 @@ export async function readSlice(req = {}) {
231
230
  return { ok: false, code: 'denied', error: `拒绝读取 ${norm.name}:${gate.error}` }
232
231
  }
233
232
 
234
- const content = await readFile(full, 'utf8')
233
+ // 读的是闸门检查过的同一个句柄(SEC-01):路径在检查与读取之间被换掉会返回 changed,
234
+ // 而不是把没检查过的内容当记忆交出去。
235
+ const content = await readVerified(full, gate, 'utf8')
235
236
  const revision = revisionOf(content)
236
237
  /**
237
238
  * 带了 revision 就一定校验,且**不返回任何内容 / 行号**:过期行号比「读不到」危险得多 ——
@@ -284,6 +285,7 @@ export async function readSlice(req = {}) {
284
285
  } catch (err) {
285
286
  // 扫到读之间被删是正常状态(不是故障);其余一律出声,别让「读不到」变成无声空内容。
286
287
  if (err?.code === 'ENOENT') return { ok: false, code: 'not_found', error: '这份记忆在读取过程中被删掉了' }
288
+ if (err?.code === 'changed') return { ok: false, code: 'io_error', error: err.message }
287
289
  return { ok: false, code: 'io_error', error: `读取失败(${err?.code ?? err?.message ?? err})` }
288
290
  }
289
291
  }
@@ -15,7 +15,7 @@ import { parseBaseline, parseFrontmatter } from './frontmatter.js'
15
15
  import { isMemoryEntry, isReserved } from './naming.js'
16
16
  import { warnLog } from './util.js'
17
17
  import { createDirWatcher } from './watch.js'
18
- import { lstat, readFile, readdir } from 'node:fs/promises'
18
+ import { lstat, open, readFile, readdir } from 'node:fs/promises'
19
19
  import { join } from 'node:path'
20
20
 
21
21
  /** 归档区目录名。与 autodream 工具里的 ARCHIVE_DIR 同名同义(同一个目录)。 */
@@ -167,6 +167,56 @@ export function identityOf(info) {
167
167
  return info && info.ino ? `${info.dev}:${info.ino}` : null
168
168
  }
169
169
 
170
+ /**
171
+ * 读一份**已经过 `inspectMemoryEntry` 闸门**的文件,并确认「读到的就是闸门检查过的那一份」。
172
+ *
173
+ * 为什么需要它(2026-10-23 外部审查 SEC-01):`inspectMemoryEntry` 用的是 `lstat(路径)`,
174
+ * 而调用方随后用 `readFile(路径)` —— 两次**按路径**操作之间,另一个进程可以把路径换成
175
+ * 符号链接 / 另一个文件。窗口已被确定性复现:闸门看到 57 B 的正常文件,换入 5 MB 文件后
176
+ * `readFile` 实际读进 5,242,896 B,**512 KB 单文件上限被绕过**。
177
+ *
178
+ * 修法不是「再 stat 一次路径」(那只是把窗口挪小),而是:
179
+ * 1. `open(路径)` 拿到**文件句柄**;
180
+ * 2. 对**同一个句柄** `fstat`,用 `dev:ino` 与闸门给出的身份比对,并复核大小;
181
+ * 3. 只从这个句柄读内容。
182
+ * 这样即使路径在 open 之后被换掉,读的仍是 open 时的那个对象;若在 open 之前被换掉,
183
+ * 身份/大小对不上 → 拒绝,绝不把「没检查过的内容」交出去。
184
+ *
185
+ * 平台差异:Node 没有跨平台的 `O_NOFOLLOW`(Windows 上符号链接还要管理员权限),
186
+ * 所以这里用「身份比对」而不是「不跟随链接」;`inspectMemoryEntry` 的 `lstat` 仍负责
187
+ * 在 open 之前识别链接。两者合起来覆盖「链接被换成普通文件」「普通文件被换成链接/别的文件」
188
+ * 两个方向。拿不到 inode 的平台(`gate.id === null`)退化为只比大小。
189
+ *
190
+ * @param {string} full — 绝对路径
191
+ * @param {{id?: string|null, size?: number}} gate — `inspectMemoryEntry` 的成功返回值
192
+ * @param {string|false} [encoding] — `'utf8'` 返回字符串;`false` 返回 Buffer
193
+ * @returns {Promise<string|Buffer>}
194
+ */
195
+ export async function readVerified(full, gate, encoding = 'utf8') {
196
+ const handle = await open(full, 'r')
197
+ try {
198
+ const info = await handle.stat()
199
+ if (!info.isFile()) {
200
+ const err = new Error('文件在检查与读取之间变成了非普通文件')
201
+ err.code = 'changed'
202
+ throw err
203
+ }
204
+ if (gate?.id && identityOf(info) !== gate.id) {
205
+ const err = new Error('文件在检查与读取之间被替换(身份不一致)')
206
+ err.code = 'changed'
207
+ throw err
208
+ }
209
+ if (gate && Number.isFinite(gate.size) && info.size !== gate.size) {
210
+ const err = new Error(`文件在检查与读取之间变过(${gate.size} → ${info.size} 字节)`)
211
+ err.code = 'changed'
212
+ throw err
213
+ }
214
+ return encoding === false ? await handle.readFile() : await handle.readFile({ encoding })
215
+ } finally {
216
+ await handle.close().catch(() => {})
217
+ }
218
+ }
219
+
170
220
  /**
171
221
  * 保留名在磁盘上的**文件身份**集合(`dev:ino`)。
172
222
  *
@@ -394,7 +444,7 @@ async function fullScanMemoryDir(ctx, sessionId, dir) {
394
444
  return null
395
445
  }
396
446
  try {
397
- const content = await readFile(full, 'utf8')
447
+ const content = await readVerified(full, gate, 'utf8')
398
448
  const meta = parseFrontmatter(content)
399
449
  return {
400
450
  file: name,
@@ -455,7 +505,7 @@ async function readStarDir(dir, ctx, opts = {}) {
455
505
  return null
456
506
  }
457
507
  try {
458
- const content = await readFile(full, 'utf8')
508
+ const content = await readVerified(full, gate, 'utf8')
459
509
  return { name, content, bytes: gate.size, mtimeMs: gate.mtimeMs }
460
510
  } catch (err) {
461
511
  if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${full}: ${err?.message ?? err}`)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "1.6.2",
4
+ "version": "1.6.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {