sage-mem 2.1.18 → 2.1.19

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: `2.1.18`** — 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: `2.1.19`** — 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.
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
- **当前发布版本:`2.1.18`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.19`** —— 顶部 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 是最直接的反馈。
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { constants } from 'node:fs'
6
6
  import { randomUUID } from 'node:crypto'
7
- import { copyFile, link, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
7
+ import { chmod, copyFile, link, lstat, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
8
8
 
9
9
  /**
10
10
  * 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
@@ -46,6 +46,31 @@ async function renameWithRetry(from, to, attempts = 8) {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * 新建文件(写进本原语的那些记忆与状态)的默认 mode:**只对本人可读写**。
51
+ *
52
+ * 为什么是明确的 0600 而不是「umask 默认」(2026-10-31 外部审查 Q18):记忆正文是隐私
53
+ * 内容,权限不该由开发机 / 服务器的 umask 环境决定(umask=022 时默认新建是 0644,
54
+ * 同机其它账号可读)。写进 `writeFileAtomic` 的既有文件一律继承原 mode,
55
+ * 所以这一条只影响**新建**。
56
+ */
57
+ const NEW_FILE_MODE = 0o600
58
+
59
+ /**
60
+ * 这次替换该用的 mode:**已有普通文件**继承它现在的权限位;新建文件用保守默认。
61
+ *
62
+ * ⚠️ 用 `lstat` 而不是 `stat`(2026-10-31 外部审查 Q18):目标是符号链接时不该跟随 ——
63
+ * `stat` 会拿到链接指向的**外部**文件的权限,等于把记忆文件的权限交给别处的文件决定;
64
+ * 而 rename 之后被替换的是链接本身(不是穿透),本来也不该继承外部的 mode。
65
+ *
66
+ * @returns {Promise<number>} mode 位(含 setuid/setgid/sticky:`& 0o7777`)
67
+ */
68
+ async function modeForTarget(target) {
69
+ const info = await lstat(target).catch(() => null)
70
+ if (!info || !info.isFile() || typeof info.mode !== 'number') return NEW_FILE_MODE
71
+ return info.mode & 0o7777
72
+ }
73
+
49
74
  /**
50
75
  * 原子写:先写同目录临时文件,再 rename 覆盖目标。
51
76
  *
@@ -53,13 +78,28 @@ async function renameWithRetry(from, to, attempts = 8) {
53
78
  * 缺半边,这条记忆从此解析不出来、检索不到,而文件看上去还在。
54
79
  * 同卷 rename 是原子的:读方要么看到旧全文、要么看到新全文,不存在中间态。
55
80
  * 任何一步失败都不碰目标文件,**自己那份**临时文件收尾清掉。
81
+ *
82
+ * ⚠️ **权限继承**(2026-10-31 外部审查 Q18):rename 替换的是**目录项**,新 inode
83
+ * 不会继承原文件的 mode。旧写法新建临时文件走默认 mode,于是一个被用户设成 0600 的
84
+ * 记忆,面板保存一次就变成 0644(umask=022),隐私被悄悄放宽,而且没有任何提示。
85
+ * 现在在 rename **之前**把 mode 贴到临时文件上:
86
+ * - 已有普通文件 → 继承它现在的权限(用 lstat,不跟随符号链接);
87
+ * - 新建文件 → 明确的保守模式 `NEW_FILE_MODE`(0600,不随 umask 漂)。
88
+ * 先 `writeFile(tmp, …, { mode })` 让临时文件**一开始就不会比目标宽**,再 `chmod` 成精确值
89
+ * (writeFile 的 mode 会被 umask 掩掉一部分,比如 0660 & ~022 = 0640)。
90
+ * 任何一步失败都发生在 rename 之前 → 目标文件一个字节、一个权限位都不会动。
91
+ * Windows 上 chmod 是 no-op,这里不依赖它能生效(行为断言在 POSIX 上跑)。
56
92
  */
57
93
  export async function writeFileAtomic(target, content) {
58
94
  tmpSeq += 1
59
95
  const uniq = `${process.pid}-${Date.now().toString(36)}-${tmpSeq.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
60
96
  const tmp = `${target}.${uniq}.tmp`
97
+ const mode = await modeForTarget(target)
61
98
  try {
62
- await writeFile(tmp, content, 'utf8')
99
+ await writeFile(tmp, content, { encoding: 'utf8', mode })
100
+ // 精确落位:writeFile 传的 mode 受 umask 掩码,chmod 不受 —— 两者都做才既「不会更宽」
101
+ // 又「不会被收紧」。放在 rename 之前,失败时目标仍是原样。
102
+ await chmod(tmp, mode)
63
103
  await renameWithRetry(tmp, target)
64
104
  } finally {
65
105
  // rename 成功后 tmp 已不存在(ENOENT),失败时把它清掉不留垃圾。
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": "2.1.18",
4
+ "version": "2.1.19",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",