feat(memory): protect pinned files during forked Dream - #7714
Conversation
|
Thanks for the PR — and for the rework since the last pass. The head moved ( Template: matches now ✓ — the body uses the expected headings ( Problem: real and requested, not theoretical. This implements #6801 (P2, Direction: aligned. Memory is core, and protecting user-curated files from automated consolidation is squarely within the feature's intent. The PR is honest about what it deliberately leaves out — the visible Size: Approach: the scope feels right and the change is fail-closed. It adds one opt-in boolean ( Risk: no elevated signals. None of the changed files match the high-revert paths (no streaming/shell/MCP/LSP/sandbox surface) — this is contained to the memory permission boundary. Moving on to code review. 🔍 中文说明感谢贡献,也感谢自上轮以来的修改。head 已更新( 模板:现已符合 ✓ —— 正文使用了预期的标题( 问题:真实且已被请求,并非理论性问题。本 PR 实现的是 #6801(P2, 方向:对齐。记忆是核心功能,保护用户手工维护的文件不被自动整合,完全在该功能的意图之内。PR 也诚实地说明了刻意不做的部分——可见的 规模: 方案:范围合理,且改动是 fail-closed 的。它在现有 memory-scoped config 上增加一个 opt-in 布尔值( 风险:无升级信号。改动文件均未命中高回滚路径(不涉及 streaming/shell/MCP/LSP/sandbox 面)——局限于记忆权限边界。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code ReviewIndependent proposal: I'd put a reserved Findings: no critical blockers. The implementation is clean, idiomatic, and — importantly for a permission boundary — fail-closed: it only ever adds new denials, never a new allow.
Two non-blocking observations: Test evidence (this PR's own CI — I did not run the code)Unattended run, so per the gate rules I'm reading the PR's CI signal rather than executing anything. All 46 check-runs on
The deny behavior itself is substantiated by that ubuntu suite — the symlink/case tests create real on-disk links and assert Sandboxed verification would still settle one thing the unit suite can't: 中文说明代码审查独立方案: 我会在 结论: 无关键阻塞。实现干净、符合惯例,而且——对一条权限边界来说很关键——是 fail-closed 的:它只新增拒绝,从不新增允许。
两个非阻塞观察: 测试证据(本 PR 自身的 CI——我未运行代码)无人值守运行,因此按门禁规则我读取的是 PR 的 CI 信号,而非执行任何代码。 拒绝行为本身由上述 ubuntu 套件证实——符号链接/大小写测试在磁盘上创建真实链接并断言 沙盒验证仍能解决一件单元测试套件无法解决的事: — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 4/5 — solid, correctly-scoped, and fail-closed; the only reservations are non-blocking (the deferred Stepping back: this is exactly the kind of contribution the gate should let through. The problem is real and was explicitly requested (#6801, P2) — hand-curated memory files get destroyed by automated consolidation today, and the PR closes that with a deterministic boundary instead of another prompt instruction. My independent proposal and the PR's approach are essentially the same; I didn't find a simpler path it missed. What earns the confidence: the change is fail-closed end to end. It only adds denials — the allow path is a behavior-preserving refactor, the flag defaults off, and only the two automated workers opt in. The bypass vectors that matter for a permission boundary (case variants, symlink aliases, What keeps it off 5/5: the visible Approving, pinned to the reviewed commit. ✅ 中文说明置信度:4/5 —— 扎实、范围正确、且 fail-closed;唯一的保留是非阻塞的(推迟的 退一步看:这正是门禁应当放行的那类贡献。问题是真实的,且已被明确请求(#6801,P2)——人工维护的记忆文件今天会被自动整合销毁,而本 PR 用一条确定性边界而非又一条提示指令解决了它。我的独立方案与 PR 的方案基本一致;我没有找到它遗漏的更简路径。 赢得这份置信度的是:改动从头到尾都是 fail-closed 的。它只新增拒绝——允许路径是行为保持的重构,开关默认关闭,且只有两个自动 worker 启用。对一条权限边界而言重要的绕过路径(大小写变体、符号链接别名、 未到 5/5 的原因:可见的 批准,固定到所审查的提交。✅ — Qwen Code · qwen3.8-max-preview Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.8-max-preview via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
— qwen3.8-max-preview via Qwen Code /review
| expect(index).toContain('The repo uses pnpm workspaces.'); | ||
| }); | ||
|
|
||
| it('includes a valid pinned document in the generated index', async () => { |
There was a problem hiding this comment.
[Suggestion] The new test 'includes a valid pinned document in the generated index' is inert — the test-efficacy probe confirmed all 8 tests in this file pass identically with the PR's source changes (paths.ts, memory-scoped-agent-config.ts, dreamAgentPlanner.ts) reverted. The indexer already scanned all subdirectories including pinned/ before this PR; the new AUTO_MEMORY_PINNED_DIRNAME constant is consumed by the permission gate in memory-scoped-agent-config.ts, not by the indexer. — Concrete cost: if a future change breaks pinned-directory indexing (e.g., the indexer stops scanning subdirectories or explicitly excludes pinned/), this test would still pass green, and the regression would ship undetected.
Consider either (a) making the test import and assert against AUTO_MEMORY_PINNED_DIRNAME so it fails when the constant is removed or the indexer's subdirectory scanning changes, or (b) moving the pinned-indexing assertion into the permission-gate test suite (memory-scoped-agent-config.test.ts) where the new code actually lives.
— qwen3.8-max-preview via Qwen Code /review
Review + Linux E2E verification report (real build, tmux)Verdict: the deterministic gate is correctly designed and holds up under adversarial probing. And this run produced direct empirical evidence for the PR's own scope caveat: prompt-level protection on the visible Code review reasoning
Verification evidence (Linux, commit 09c1f14, full build + bundle)Unit level: memory suite 475/476 passed. The 1 failure ( Adversarial probe (temporary test against the PR harness, then removed):
tmux E2E (real bundled CLI,
ImplicationThe gate (deterministic, tested, symlink-aware) held everywhere it applies. The one observed protection failure happened exactly in the layer that only has prompt-level protection. That is strong empirical support for resolving #6801 in favor of a per-turn deterministic gate for the visible |
Maintainer local verification — real-flow, no mocksVerified Verdict: the fix works and it is load-bearing. The pre-PR configuration demonstrably clobbers a pinned file; the PR configuration does not. I found one robustness gap and one documentation gap that I'd like addressed, neither of which blocks the core behavior. 1. Real-flow E2E — 9/9The important row is the last one. With the options Also confirmed there is no over-blocking: ordinary topic writes, brand-new topic files, and 2. Adversarial probes — 2 findingsF1 · Medium (non-blocking) — a case-variant path defeats the gate on case-insensitive filesystems
Root cause, measured directly: Both the lexical and the resolved containment checks are byte-exact, and Node's Worth noting why this is new rather than pre-existing: I'd call the practical exploitability low — the prompt and the directory listing both spell it lowercase, so a real Dream turn is unlikely to produce Suggested fix, contained to F2 · Low (docs) — nested
|
| gate | result |
|---|---|
npx vitest run src/memory |
475 passed, +4 net tests vs. base (matches the 4 new it() blocks) |
npx tsc --noEmit (core) |
exit 0 |
npx eslint (6 changed files) |
exit 0, no output |
npx prettier --check (8 changed files) |
All matched files use Prettier code style |
Note on the 1 failing test
memoryLifecycle.integration.test.ts fails identically on the merge-base (471 passed | 1 failed there, 475 passed | 1 failed here). It is an environment artifact — the real ~/.qwen/memories on my machine leaks into the recall assertion. Not caused by this PR.
5. Scope claims in the PR description — all check out
/dreamreally is ungated.packages/cli/src/ui/commands/dreamCommand.tsreturns{ type: 'submit_prompt' }on the main Agent, so no per-turn permission override applies. Accurate as described./dreamreally does receive the skip instruction.MemoryManager.buildConsolidationPrompt(manager.ts:1480) delegates to the samebuildConsolidationTaskPrompt, so it picks up all three new task-prompt lines. The two new system-prompt rules are forked-worker-only — worth knowing when you decide Feature: pinned/ memory directory - read-only files protected from /dream consolidation #6801./forgetis genuinely unaffected.forget.tsdeletes viafs.unlink/atomicWriteFiledirectly, never through the permission manager, so the documented "you can still remove them with/forget" holds.
Recommendation
Merge-worthy on behavior. I'd like F1 as a follow-up commit (a few lines in one function) since it undercuts the "deterministic" framing, F2 as a one-sentence doc clarification, and F3 as two small tests. None of them changes the verdict that this PR fixes a real data-loss path.
Harnesses are reproducible on request — three temporary vitest files under packages/core/src/memory/ plus a mutation script; nothing was committed to the branch.
中文版本(点击展开)
维护者本地验证 — 真实链路,无 mock
在隔离 worktree 中以 09c1f14 对比 merge-base 2049d50 验证。我没有采信单测里的 {} as Config 桩:下面所有结论都跑在 forked Dream worker 真实走的生产链路上 —— 真实 Config、真实工具注册表、真实 WriteFileTool/EditTool/ShellTool、与 runForkedAgent 相同的 createApprovalModeOverride(YOLO) 包装、真实 CoreToolScheduler.schedule() → 真实 evaluatePermissionFlow(),落在真实临时文件系统上。判定依据是磁盘上的字节,而不是某个决策字符串。
结论:修复有效且确实起作用。 合入前的配置能够真实覆盖 pinned 文件,本 PR 的配置则不会。我发现一个健壮性缺口和一个文档缺口,两者都不阻塞核心行为。
1. 真实链路 E2E — 9/9 通过
关键是最后一行:使用本 PR 之前 dreamAgentPlanner 所传的参数({ allowShell: true, includeUserMemory: false }),同一个 write_file 成功执行,pinned/architecture.md 变成了 "HIJACKED BY DREAM AGENT";加上 protectPinnedMemory: true 后被拒绝,字节保持不变。这就是本 PR 的全部价值,端到端得到证明。
同时确认没有误伤:普通主题文件写入、新建主题文件、MEMORY.md 重建索引均仍然成功(E2E-4/5/6),Dream 仍能正常工作。
2. 对抗性探测 — 2 个发现
F1 · 中等(不阻塞)— 大小写变体路径可绕过该防护(大小写不敏感文件系统)
向 <memory>/PINNED/architecture.md 写入返回 success,pinned 文件字节变为 "HIJACKED\n"(探测 P1,macOS APFS)。
直接实测的根因:
fs.realpathSync("<t>/pinned") -> /private/<t>/pinned
fs.realpathSync("<t>/PINNED") -> /private/<t>/PINNED # realpathSync.native -> "pinned"
path.relative(".../pinned", ".../PINNED/a.md") = "../PINNED/a.md" -> isWithinRoot false
字面比较与 realpath 比较都是逐字节的,而 Node 的 fs.realpathSync 会保留调用方的大小写(只有 realpathSync.native 会归一化)。但文件系统是大小写不敏感的 —— 于是写入落到了同一个 inode 上。
值得说明这为什么是新问题而非既有问题:isAllowedMemoryPath 有同样的大小写特性,但它是白名单,大小写保留会导致 fail-closed(安全侧);isProtectedPinnedMemoryPath 是基于这些 helper 的第一个黑名单,同样的特性变成了 fail-open。默认的 macOS(APFS)与 Windows(NTFS)都受影响;在大小写敏感的文件系统上 PINNED/ 只是另一个目录,不会覆盖任何内容。
实际可利用性我判断较低 —— 提示词和目录列表都是小写 pinned,真实 Dream 轮次不太可能产出 PINNED/。但设计文档把这个能力定位为确定性的写入防护,而目前一个字符的大小写差异就能绕过它。
建议修复(仅限 isProtectedPinnedMemoryPath 内部):对 memory 根目录下的第一段路径做大小写不敏感比较,或改用 fs.realpathSync.native 解析。在真正大小写敏感的文件系统上这会多拦截一个独立的 PINNED/ 目录 —— 对黑名单而言这是安全的方向。
F2 · 低(文档)— 嵌套 pinned/ 目录静默失去保护
memory/project/pinned/notes.md → 写入成功,内容被替换(探测 P3)。
设计文档确实写了顶层 pinned/,所以这是预期行为。我担心的是 docs/users/features/memory.md 的面向用户措辞:「Put hand-curated documents that Dream should preserve under pinned/ in a managed-memory directory」。用户若创建 memory/project/pinned/,会认为已满足条件,却得不到任何提示,并在下一次 Dream 时丢失文件。补一句明确说明即可,例如:「只有位于 managed-memory 目录根部的 pinned/ 受保护,…/project/pinned/ 不受保护。」
经受住攻击的部分: 点号回溯(project/../pinned/…)、重复分隔符、结尾斜杠、文件路径带结尾斜杠、相对 file_path、断链的 pinned/ 符号链接、指向 memory 根之外的 pinned/ 符号链接、以及直接对 pinned/ 目录本身写入 —— 全部被拒绝,字节全部完好。我最担心会被突破的是 shell 这一环(设计上直接依赖既有的只读闸门,本 PR 未做加固):18/18 全部拒绝,0/18 发生改动,覆盖 sed -i、perl -pi -e、python3 -c、node -e、find -delete、truncate、dd of=、tee 以及普通的 > 重定向。
3. 变异矩阵 — 11 项中捕获 8 项
F3 · 测试覆盖 — 3 项变异存活(都是真实分支,只是缺断言)
我对每个存活项都重跑了探测集,确认它们都不是死代码:
- M2 — 字面包含分支。 删掉它,全部测试依然绿。它确实可达:当
pinned/是断链符号链接时(探测 P6),确定性拒绝消失,写入只是偶然地以ENOENT: … mkdir '…/memory/pinned'失败。补一个对应 P6 的用例即可锁定。 - M5 / M6 — 两条
pinned memory is read-only拒绝消息。 删掉它们全部测试依然绿,但模型实际收到的消息会退化为ManagedAutoMemory(write_file: only within …/.qwen/memory)—— 这是错误的,因为该路径确实在那个根目录内。误导性的拒绝消息恰恰是会让模型反复重试的那类问题。各补一条对findMatchingDenyRule的断言即可。
都不是阻塞项,但三项加起来也就几行。
4. PR head 上的回归闸门
| 闸门 | 结果 |
|---|---|
npx vitest run src/memory |
475 通过,相对 base 净增 4 个用例(与新增的 4 个 it() 吻合) |
npx tsc --noEmit(core) |
exit 0 |
npx eslint(6 个改动文件) |
exit 0,无输出 |
npx prettier --check(8 个改动文件) |
全部符合 Prettier 风格 |
关于那 1 个失败用例:memoryLifecycle.integration.test.ts 在 merge-base 上同样失败(那边是 471 通过 | 1 失败,这边是 475 通过 | 1 失败)。这是环境产物 —— 我本机真实的 ~/.qwen/memories 泄漏进了 recall 断言。与本 PR 无关。
5. PR 描述中的范围声明 — 全部核实无误
/dream确实没有确定性闸门。packages/cli/src/ui/commands/dreamCommand.ts在主 Agent 上返回{ type: 'submit_prompt' },不存在每轮的权限覆盖。描述准确。/dream确实收到了 skip 指令。MemoryManager.buildConsolidationPrompt(manager.ts:1480)委托给同一个buildConsolidationTaskPrompt,因此拿到了全部三条新增的任务提示行。两条新增的系统提示规则则仅限 forked worker —— 在决定 Feature: pinned/ memory directory - read-only files protected from /dream consolidation #6801 时值得注意这一差异。/forget确实不受影响。forget.ts直接用fs.unlink/atomicWriteFile删除,完全不经过 permission manager,因此文档中「仍可用/forget删除」的说法成立。
建议
从行为上看可以合入。我希望把 F1 作为后续提交处理(单个函数内的几行改动),因为它削弱了「确定性」这一定位;F2 补一句文档说明;F3 补两个小测试。这些都不改变本 PR 修复了一条真实数据丢失路径这一结论。
验证脚手架可按需复现 —— packages/core/src/memory/ 下三个临时 vitest 文件加一个变异脚本,未向分支提交任何内容。
67c90a9
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
— qwen3.7-max via Qwen Code /review
| if ( | ||
| opts.protectPinnedMemory && | ||
| isProtectedPinnedMemoryPath(ctx.filePath, projectRoot, { | ||
| includeUserMemory: opts.includeUserMemory, | ||
| }) | ||
| ) { | ||
| return 'ManagedAutoMemory(edit: pinned memory is read-only)'; |
There was a problem hiding this comment.
[Suggestion] isProtectedPinnedMemoryPath is called a second time for the same context in the deny path — first by evaluateScopedDecision (which returned 'deny'), then again here by getScopedDenyRule via findMatchingDenyRule. — Failure scenario: each invocation performs synchronous realpathSync syscalls (up to 3 per call). The entire pinned-path check runs twice for every denied pinned-path operation.
Suggested fix: cache the pinned-check result so getScopedDenyRule does not re-evaluate what evaluateScopedDecision already computed, or restructure evaluate to return both the decision and the deny reason atomically.
— qwen3.7-max via Qwen Code /review
| if ( | ||
| opts.protectPinnedMemory && | ||
| isProtectedPinnedMemoryPath(ctx.filePath, projectRoot, { | ||
| includeUserMemory: opts.includeUserMemory, | ||
| }) | ||
| ) { | ||
| return 'deny'; | ||
| } | ||
| return isAllowedMemoryPath(ctx.filePath, projectRoot, { | ||
| includeUserMemory: opts.includeUserMemory, | ||
| }) |
There was a problem hiding this comment.
[Suggestion] When protectPinnedMemory is true and the path is NOT in pinned (the common case for Dream writes to ordinary memory files), realpathExistingOrNew(filePath) runs twice on the same file — once inside isProtectedPinnedMemoryPath and again inside isAllowedMemoryPath. — Failure scenario: a Dream consolidation session producing 50 write operations to ordinary memory paths executes 50 extra synchronous realpathSync calls, each blocking the event loop.
Suggested fix: resolve the candidate path once before calling either function and pass the pre-resolved path as an optional parameter.
— qwen3.7-max via Qwen Code /review
| const resolvedPinnedRoot = realpathExistingOrNew(literalPinnedRoot); | ||
| return ( | ||
| !!resolvedCandidate && | ||
| !!resolvedPinnedRoot && | ||
| isWithinRootCaseInsensitive(resolvedCandidate, resolvedPinnedRoot) | ||
| ); |
There was a problem hiding this comment.
[Suggestion] realpathExistingOrNew(literalPinnedRoot) resolves the pinned root's real path inside the .some() loop, once per memory root, on every write_file/edit evaluation. The pinned root directories are stable for the session's lifetime but are re-resolved from disk each time. — Failure scenario: during a Dream session with 2 memory roots and 100 tool evaluations, realpathSync is called ~200 times on stable directory paths that don't change.
Suggested fix: compute the resolved pinned root once at config creation time (in createMemoryScopedAgentConfig) and store it in the closure, or memoize it per memory root.
— qwen3.7-max via Qwen Code /review
ZijianZhang989
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.7-max via Qwen Code /review
ZijianZhang989
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
— qwen3.7-max via Qwen Code /review
| await expect( | ||
| protectedPm.evaluate({ | ||
| toolName: ToolNames.WRITE_FILE, | ||
| filePath: path.join(memoryRoot, 'pinned-notes', 'ordinary.md'), | ||
| }), | ||
| ).resolves.toBe('allow'); |
There was a problem hiding this comment.
[Suggestion] Missing test for nested pinned/ directory remaining writable
The design doc explicitly states "nested directories such as memory/project/pinned/ are ordinary writable memory". The existing pinned-notes prefix-boundary test guards against over-broad matching of a path component that merely starts with pinned, but there is no assertion covering a legitimate nested directory named pinned/ inside a topic folder (e.g. memory/project/pinned/notes.md).
Failure scenario: a future refactor broadens the containment check (e.g. matching pinned/ as a path component anywhere rather than only at the memory root). A Dream consolidation session silently denies edits to memory/project/pinned/notes.md — a legitimate topic file — and no test breaks.
| await expect( | |
| protectedPm.evaluate({ | |
| toolName: ToolNames.WRITE_FILE, | |
| filePath: path.join(memoryRoot, 'pinned-notes', 'ordinary.md'), | |
| }), | |
| ).resolves.toBe('allow'); | |
| await expect( | |
| protectedPm.evaluate({ | |
| toolName: ToolNames.WRITE_FILE, | |
| filePath: path.join(memoryRoot, 'pinned-notes', 'ordinary.md'), | |
| }), | |
| ).resolves.toBe('allow'); | |
| await expect( | |
| protectedPm.evaluate({ | |
| toolName: ToolNames.WRITE_FILE, | |
| filePath: path.join(memoryRoot, 'project', 'pinned', 'notes.md'), | |
| }), | |
| ).resolves.toBe('allow'); |
— qwen3.7-max via Qwen Code /review
Maintainer re-verification — round 2, hardening commit
|
| mutant | round 1 | now | killed by |
|---|---|---|---|
| revert case-insensitive compare (undo F1 fix) | n/a | killed | new PINNED/ deny assertion |
| M2 — delete literal-containment branch | survived | killed | new "dangling top-level pinned symlink" test |
M5 — gut edit pinned deny message |
survived | killed | new findMatchingDenyRule assertion |
M6 — gut write_file pinned deny message |
survived | killed | new findMatchingDenyRule assertions (both pinned tests) |
4. F2 (docs gap) — closed
docs/users/features/memory.md:108 now states explicitly that only the top-level pinned/ directory is protected, that nested memory/project/pinned/ is ordinary writable memory, and that matching is case-insensitive. The design doc got the matching sentences too.
5. Regression gates
| gate | result |
|---|---|
npx vitest run src/memory at head |
477 passed (33 files, 0 failed) |
test-merge into current origin/main (fceb755) + same suite |
merge clean, 477 passed |
npx tsc --noEmit (core), head vs 09c1f14 baseline |
byte-identical error sets — zero new errors (the single shared error is a stale-node_modules turndown artifact of my environment, unrelated to the PR) |
npx eslint (6 changed .ts files) |
exit 0 |
npx prettier --check (8 changed files) |
all match |
The env-dependent memoryLifecycle.integration.test.ts failure I noted in round 1 does not reproduce here (isolated HOME), consistent with it being a hermeticity issue, not a product one.
Recommendation
Everything I asked for in round 1 landed, each with a test that fails if the fix regresses. The /dream slash-command scope is unchanged and still correctly deferred to #6801. LGTM — good to merge.
Harness is a single tsx script (drives the real scheduler flow per probe in a fresh project root) plus a mutation script; nothing was committed to the branch. Reproducible on request.
中文版本(点击展开)
维护者复验 — 第二轮,加固提交 67c90a9f
延续上一份报告的方法学,对新 head e5f2636(= 67c90a9f "harden pinned path protection" + 干净合并 main)复验:走 forked Dream worker 的真实生产链路(真实 Config、真实工具注册表、runForkedAgent 所用的 createApprovalModeOverride(YOLO) 包装、真实 CoreToolScheduler.schedule() → evaluatePermissionFlow()),判定依据是磁盘字节。本轮聚焦增量:F1/F2/F3 是否真正被修复。
结论:第一轮的三个发现全部修复,且每个修复都有对应测试锁定(去掉修复该测试必然失败)。我这边认为可以合并。
1. F1(大小写变体绕过)— 已修复;在真实大小写不敏感文件系统上完成 A/B 证明
第一轮在 macOS APFS 上演示了 F1。本轮我在 Linux 上搭建了回归环境 —— ext4 -O casefold loopback 挂载(chattr +F,fs.existsSync(<memory>/PINNED/x) = true),两侧跑同一探针:
- 加固前
09c1f14(保护开启): 先读PINNED/architecture.md再write_file/edit→ 两者均成功,pinned 文件字节被替换(263af03ef287…→aafdcce62a25…/2bee135729d6…)。F1 在 Linux 复现,与 APFS 上相同的同 inode 别名机制。 - PR head: 两个请求均被拒绝(
execution_denied),字节逐字节一致,且模型收到明确原因:ManagedAutoMemory(write_file: pinned memory is read-only)。
一个值得记录的细节:不带预读时,加固前的写入只是被 read-before-write 校验(edit_requires_prior_read)偶然拦下,而不是权限门 —— 这进一步证明权限门本身是 fail-open 的。Dream 在整合时会先读文件,因此"先读后写"才是真实流程,而该流程下字节确实被覆盖。
修复对包含性检查两侧做了整路径小写化,比我建议的"仅比较该段"更宽 —— 后果是在真正大小写敏感的文件系统上,一个独立的 PINNED/ 目录现在也会被拒绝。对黑名单而言这是安全的过拦截方向,且已写入文档(docs/users/features/memory.md:"Dream workers match the reserved directory name case-insensitively")。我探测了碰撞边界:memory 根下名为 Pinned.md 的文件仍可写,过拦截止步于保留目录名。(范围说明:我只探测了 ASCII 大小写变体;保留名是 ASCII,在此范围 JS toLowerCase 与文件系统 casefold 行为一致。)
2. 对抗性探测 — 大小写敏感 ext4 13/13、casefold 5/5,零误伤
第一轮的全部拒绝类别在加固代码上依然成立(小写、大小写变体、符号链接别名、点号回溯、断链 pinned/ 符号链接、shell 重定向),且 Dream 的全部正当操作仍然成功(主题整合、新建主题、MEMORY.md 重建索引、pinned-notes/ 相似前缀、Pinned.md 同名文件)。
3. F3(存活变异)— 新测试全部击杀,包括 F1 修复本身
对 head 重跑变异矩阵(memory-scoped-agent-config.test.ts),4/4 击杀:
| 变异 | 第一轮 | 本轮 | 击杀者 |
|---|---|---|---|
| 回退大小写不敏感比较(撤销 F1 修复) | n/a | 击杀 | 新增 PINNED/ 拒绝断言 |
| M2 — 删除字面包含分支 | 存活 | 击杀 | 新增"dangling top-level pinned symlink"用例 |
M5 — 破坏 edit pinned 拒绝消息 |
存活 | 击杀 | 新增 findMatchingDenyRule 断言 |
M6 — 破坏 write_file pinned 拒绝消息 |
存活 | 击杀 | 新增 findMatchingDenyRule 断言(两个 pinned 用例) |
4. F2(文档缺口)— 已关闭
docs/users/features/memory.md:108 现已明确:只有 managed-memory 根部的顶层 pinned/ 受保护,嵌套的 memory/project/pinned/ 是普通可写内存,且匹配是大小写不敏感的。设计文档也补充了对应句子。
5. 回归闸门
| 闸门 | 结果 |
|---|---|
head 上 npx vitest run src/memory |
477 通过(33 个文件,0 失败) |
测试性合并到当前 origin/main(fceb755)+ 同一套件 |
合并干净,477 通过 |
npx tsc --noEmit(core),head 对比 09c1f14 基线 |
错误集逐字节一致 —— 零新增错误(两侧共有的唯一报错是我环境 node_modules 陈旧导致的 turndown 产物,与 PR 无关) |
npx eslint(6 个改动 .ts 文件) |
exit 0 |
npx prettier --check(8 个改动文件) |
全部符合 |
第一轮提到的环境相关 memoryLifecycle.integration.test.ts 失败在本机不复现(隔离 HOME),与其为封闭性问题、而非产品问题的判断一致。
建议
第一轮提出的每一项都已落地,且各自带有回归即失败的测试。/dream 斜杠命令的范围未变,仍正确地留给 #6801。LGTM,可以合并。
验证脚手架为单个 tsx 脚本(每个探针在全新 project root 中驱动真实调度链路)加一个变异脚本;未向分支提交任何内容,可按需复现。
|
|
|
The remaining review suggestions have been addressed in
The focused and full memory tests, build, typecheck, bundle, ESLint, and Prettier all pass. I have also updated the PR body to match the current repository template while preserving the documented The automatic review rerun failed before checkout because the self-hosted runner could not remove |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
中文说明
已审查。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
— qwen3.8-max-preview via Qwen Code /review
Review —
|
| Check | Result |
|---|---|
vitest run src/memory/{memory-scoped-agent-config,dreamAgentPlanner,indexer}.test.ts |
35/35 pass |
| Prettier on all 8 changed files | clean |
Mutation M1 — drop toLowerCase() in isWithinRootCaseInsensitive |
killed (case-variant assertion) |
Mutation M2 — return false for the resolved-alias branch |
killed (2 tests: alias + pinned-dir-symlink) |
| Mutation M3 — short-circuit the literal branch | killed (dangling-symlink test) |
| Shell surface: is the gate bypassable via redirection? | no — WRITE_REDIRECT_OPERATORS (shellAstParser.ts:161) covers > >> &> &>> >|, so echo x > pinned/f.md is not read-only ⇒ denied |
| Un-gated mutating tool in the Dream tool list? | no — list is exactly read_file, grep_search, glob, list_directory, run_shell_command, write_file, edit (dreamAgentPlanner.ts:112-119) |
Is the pinnedDecisionCache WeakMap dead code? |
no — permissionFlow.ts:78 and :104 pass the same pmCtx object identity to evaluate then findMatchingDenyRule |
All three sub-checks are independently load-bearing — good test design, not a redundant belt-and-braces.
TOCTOU probe (a concern the snapshot-at-creation optimization invites — it holds up): the PR caches pinnedRoots when the config is built. I probed creating pinned/ after config creation, and creating a symlink alias into it after config creation. Both still deny — because realpathExistingOrNew on a not-yet-existing pinned/ snapshots the would-be resolved path (nearest existing ancestor + literal suffix), so a later-created alias still resolves into it. Worth a one-line comment at createPinnedMemoryRoots, since it is load-bearing and non-obvious.
Findings
1. Medium — the other automated memory writer has neither the gate nor the prompt rule
extractionAgentPlanner.ts:260 builds its scoped config as:
const scopedConfig = createMemoryScopedAgentConfig(config, projectRoot, {
allowShell: true, // ← no protectPinnedMemory
});Facts I confirmed:
- The extractor carries
write_fileandedit(extractionAgentPlanner.ts:276-284), and covers both project and user memory roots (includeUserMemorydefaults totrue). - With those exact options,
evaluate({ write_file, <memoryRoot>/pinned/architecture.md })→allow, andedit→allow. (This PR's ownunprotectedPmassertion at the end of the big new test says the same thing.) grep -i pinned extractionAgentPlanner.ts remember.ts→ zero hits. No gate, and no prompt rule either.- Its prompt actively steers toward rewriting existing files: "Prefer updating an existing memory file over creating a duplicate. Check both directories for an existing entry before creating a new one." (
extractionAgentPlanner.ts:159) - And this PR deliberately keeps pinned files in the index — the new
indexer.test.tscase asserts[Canonical Architecture](pinned/architecture.md)lands inMEMORY.md. So the extractor reads that index and sees the pinned file precisely as an update candidate.
Net effect: extraction runs after every qualifying session — far more often than Dream — and can rewrite the exact file this feature promises to preserve. The design doc's "Extraction and explicit remember operations retain their current behavior" is an explicit scope call, but docs/users/features/memory.md doesn't carry that caveat; it says "Put hand-curated documents that Dream should preserve under pinned/", which a user will reasonably read as "protected".
Suggested resolution, in preference order:
- Set
protectPinnedMemory: trueatextractionAgentPlanner.ts:260and add one prompt line. Two lines. The gate already handles the user-memory root, so extraction's defaultincludeUserMemory: trueis covered for free — and it makes the~/.qwen/memories/pinned/example in the new docs section actually enforced by a production caller (today no production caller enablesprotectPinnedMemorywithincludeUserMemory: true, so that path is test-only). - Or, if extraction is intentionally out of scope, say so in
docs/users/features/memory.mdnext to the existing/dreamnote — the doc already carries one honest caveat, so a second one fits the established tone.
I'd leave explicit /remember unprotected either way: that's user-initiated intent, not an automated rewrite.
2. Low / informational — hardlink aliases are outside what path resolution can cover
Probe: hardlink memory/project/alias.md → memory/pinned/architecture.md, then evaluate({ write_file, alias }) → allow, and writing through the alias changed the pinned file's bytes (same inode). fs.realpathSync cannot see hardlinks, so this is not fixable by any path-resolution scheme.
Not a blocker, and not a doc error — the design doc correctly scopes its claim to "aliases that resolve through a symlink into pinned/". Practically unreachable for the Dream worker anyway: write_file/edit can't create hardlinks and the read-only shell gate blocks ln. Flagging it only so the boundary is written down before someone reuses this gate under a stronger threat model.
3. Low — the pinnedDecisionCache WeakMap costs more than it saves
It isn't dead code (verified above), but the ledger is thin:
- Saves: one
realpathSync— on a tool call that is already being denied. - Costs: two extra params threaded through
evaluateScopedDecisionandgetScopedDenyRule; a defaulted third parameter onisProtectedPinnedMemoryPaththat performs I/O (surprising in a default initializer); and a cached boolean that can silently disagree with a recomputation if the filesystem shifts between the two calls. - The other
findMatchingDenyRulecall site (coreToolScheduler.ts:2142) builds a fresh{ toolName }literal, so it is always a cache miss — the cache helps exactly one of two call sites.
Dropping the WeakMap and just calling isProtectedPinnedMemoryPath(ctx.filePath, pinnedRoots) in getScopedDenyRule removes ~15 lines, drops the defaulted-I/O param, and makes both call sites structurally identical. pinnedRoots threading is fine as-is — it matches how projectRoot/opts already flow.
4. Low — isWithinRootCaseInsensitive is broader than the design doc describes
The doc says "Match the reserved top-level directory name case-insensitively", but the implementation lowercases both full absolute paths. This is fail-closed — lowercasing both sides can only add matches, never remove them — and I could not construct a false-allow; the only reachable over-deny is a case variant of pinned itself directly under the memory root, which is the intent. String.prototype.toLowerCase is locale-independent (unlike toLocaleLowerCase), so the Turkish dotless-ı class of bug doesn't apply.
Worth two lines of comment on the helper stating exactly that, because the next reader will ask both questions.
Nits
dreamAgentPlanner.test.ts— the newrm <pinned>SHELL assertion passes onmaintoo. It's pre-existing read-only-shell behavior, not evidence of the new gate. Fine as a regression guard, but add a comment saying so, or a future reader will conclude the pinned gate covers shell.- The 170-line
protects pinned memory and aliases…case bundles ~15 assertions across three different permission managers (protectedPm,allMemoryPm,unprotectedPm). The first failure masks the rest — M1 above surfaced as one opaque red test. Splitting out theallMemoryPm(user-memory) andunprotectedPm(negative control) arms into their ownit()s would localize failures and matches the single-concern style of the file's other cases. The negative control in particular deserves its own name — it's the assertion that proves the flag is doing the work. - Constant usage is inconsistent across the three test files.
indexer.test.tsanddreamAgentPlanner.test.tsimportAUTO_MEMORY_PINNED_DIRNAME;memory-scoped-agent-config.test.tshardcodes'pinned'. Either convention is defensible — pick one per file. getScopedDenyRule: the pinned branch is duplicated verbatim forEDITandWRITE_FILE. Could collapse into one block keyed offctx.toolName, mirroring howallowedRootsis computed once above.- Deny-reason precedence: if
pinned/is a symlink pointing outside memory, a write to the resolved target now reportsManagedAutoMemory(edit: pinned memory is read-only)rather than the more accurateonly within <root>. Both deny, so cosmetic. - Design doc placement is correct —
docs/design/YYYY-MM-DD-*.mdmatches the existing 187 files, and design docs aren't indexed anywhere, so nothing to register. - Windows path-alias forms (8.3 short names, trailing dots, ADS) are untested, consistent with the PR body's "not tested locally".
pinnedis 6 chars so no 8.3 alias is generated; low risk.
Verdict
Correctness of what's implemented: good. Test quality: above average — every sub-check is mutation-verified load-bearing, and the negative control is present. Security posture: the mutating surface reachable by the forked Dream worker (write_file, edit, redirection-capable shell) is genuinely closed.
Requesting one change: resolve finding #1 — either enable the flag for the extraction agent (2 lines, and it makes the user-memory pinned example real) or add the caveat to docs/users/features/memory.md. Without one of those, pinned/ is protected against the rarer writer and open to the frequent one, and the user-facing doc doesn't say which.
Findings #2–#4 and the nits are non-blocking.
中文说明
审阅 — feat(memory): protect pinned files during forked Dream
在 fbecd38be4(main 合入 codex/pinned-memory-dream-protection)上审阅。我在隔离 worktree 中实际运行了三个改动的测试文件,并对新门禁做了变异测试,而不是只读 diff。
概述
为 createMemoryScopedAgentConfig 新增可选 protectPinnedMemory 开关,仅由 Forked Dream Planner 启用。启用后,对管理记忆根目录下顶层 pinned/ 目录中的路径拒绝 edit/write_file,通过三个相互独立的子检查实现:字面路径包含(不区分大小写)、符号链接解析后包含、以及用 WeakMap 在生成拒绝原因时复用判断结果。Pinned 文件仍可读、仍进入递归索引;Dream 提示词新增三条跳过规则。
设计是合理的,强制保护是真实生效的。我唯一实质性的顾虑是范围,而不是正确性。
本地验证结果
| 检查项 | 结果 |
|---|---|
vitest run src/memory/{memory-scoped-agent-config,dreamAgentPlanner,indexer}.test.ts |
35/35 通过 |
| 对 8 个改动文件跑 Prettier | 通过 |
变异 M1 — 去掉 isWithinRootCaseInsensitive 里的 toLowerCase() |
被杀死(大小写变体断言) |
变异 M2 — 解析别名分支改为 return false |
被杀死(2 个测试:别名 + pinned 目录符号链接) |
| 变异 M3 — 短路字面路径分支 | 被杀死(悬空符号链接测试) |
| Shell 层面能否用重定向绕过门禁? | 不能 — WRITE_REDIRECT_OPERATORS(shellAstParser.ts:161)覆盖 > >> &> &>> >|,因此 echo x > pinned/f.md 不算只读 ⇒ 被拒绝 |
| Dream 工具列表中是否有未被门禁覆盖的写工具? | 没有 — 列表正好是 read_file, grep_search, glob, list_directory, run_shell_command, write_file, edit(dreamAgentPlanner.ts:112-119) |
pinnedDecisionCache 这个 WeakMap 是不是死代码? |
不是 — permissionFlow.ts:78 与 :104 把同一个 pmCtx 对象先传给 evaluate 再传给 findMatchingDenyRule |
三个子检查各自都是必要的(都能被变异杀死),测试设计良好,不是冗余的多重保险。
TOCTOU 探测("创建时快照 pinned 根目录"这个优化会引出的疑问 — 结论是成立的): PR 在构建配置时缓存 pinnedRoots。我测试了在配置创建之后才创建 pinned/,以及在配置创建之后才创建指向它的符号链接别名。两种情况仍然 deny — 因为对尚不存在的 pinned/,realpathExistingOrNew 快照的是"将来的"解析路径(最近的已存在祖先 + 字面后缀),所以后创建的别名仍会解析进去。建议在 createPinnedMemoryRoots 处加一行注释,因为这个行为是承重的且不直观。
发现的问题
1. 中等 — 另一个自动记忆写入者既没有门禁也没有提示词规则
extractionAgentPlanner.ts:260 的 scoped 配置是:
const scopedConfig = createMemoryScopedAgentConfig(config, projectRoot, {
allowShell: true, // ← 没有 protectPinnedMemory
});我确认的事实:
- Extractor 同时带
write_file和edit(extractionAgentPlanner.ts:276-284),并且覆盖项目和用户两个记忆根目录(includeUserMemory默认为true)。 - 用这组完全相同的选项,
evaluate({ write_file, <memoryRoot>/pinned/architecture.md })→allow,edit→allow。(本 PR 自己新增测试末尾的unprotectedPm断言其实说的就是同一件事。) grep -i pinned extractionAgentPlanner.ts remember.ts→ 零命中。既没有门禁,也没有提示词规则。- 它的提示词还主动引导去改写已有文件:"Prefer updating an existing memory file over creating a duplicate. Check both directories for an existing entry before creating a new one."(
extractionAgentPlanner.ts:159) - 而本 PR 有意让 pinned 文件留在索引里 — 新增的
indexer.test.ts断言[Canonical Architecture](pinned/architecture.md)会进入MEMORY.md。于是 Extractor 读到该索引,正好把 pinned 文件当成待更新候选。
综合效果:Extraction 在每次符合条件的会话后都会运行 —— 频率远高于 Dream —— 而它可以改写本功能承诺要保护的那个文件。设计文档里 "Extraction and explicit remember operations retain their current behavior" 是一个明确的范围决定,但 docs/users/features/memory.md 没有带上这个说明;它写的是 "Put hand-curated documents that Dream should preserve under pinned/",用户会合理地理解为"受保护"。
建议的处理方式,按优先级:
- 在
extractionAgentPlanner.ts:260设置protectPinnedMemory: true,并加一行提示词。 两行改动。门禁已经能处理用户记忆根目录,所以 Extraction 默认的includeUserMemory: true顺带就被覆盖了 —— 同时也让新文档里~/.qwen/memories/pinned/这个例子真正被强制执行(目前没有任何生产调用方同时启用protectPinnedMemory和includeUserMemory: true,所以那条路径只存在于测试中)。 - 或者,如果确实有意把 Extraction 排除在范围外,就在
docs/users/features/memory.md里紧挨现有/dream注记写清楚 —— 文档已经带了一条诚实的说明,再加一条风格上完全吻合。
两种做法下我都建议显式 /remember 保持不受保护:那是用户主动意图,不是自动改写。
2. 低 / 参考性 — 硬链接别名超出路径解析能覆盖的范围
探测:把 memory/project/alias.md 硬链接到 memory/pinned/architecture.md,然后 evaluate({ write_file, alias }) → allow,并且通过别名写入确实改变了 pinned 文件的内容(同一个 inode)。fs.realpathSync 看不到硬链接,所以任何基于路径解析的方案都无法修复。
不是阻塞项,也不是文档错误 —— 设计文档正确地把结论限定为 "通过符号链接解析进 pinned/ 的别名"。对 Dream Worker 而言实际上也不可达:write_file/edit 无法创建硬链接,只读 Shell 门禁挡掉 ln。提出来只是为了在有人以更强威胁模型复用这个门禁之前,把边界写下来。
3. 低 — pinnedDecisionCache 这个 WeakMap 的代价大于收益
它不是死代码(前面已验证),但账面很薄:
- 省下: 一次
realpathSync—— 而且是在一个本来就要被拒绝的工具调用上。 - 代价: 两个额外参数穿过
evaluateScopedDecision和getScopedDenyRule;isProtectedPinnedMemoryPath上一个会执行 I/O 的默认参数(放在默认初始化式里很意外);以及一个缓存布尔值,如果两次调用之间文件系统发生变化,它可能与重新计算的结果静默不一致。 - 另一个
findMatchingDenyRule调用点(coreToolScheduler.ts:2142)构造的是全新的{ toolName }字面量,所以永远缓存未命中 —— 这个缓存只对两个调用点中的一个有用。
去掉 WeakMap、在 getScopedDenyRule 里直接调用 isProtectedPinnedMemoryPath(ctx.filePath, pinnedRoots),可以少约 15 行、去掉那个执行 I/O 的默认参数,并让两个调用点结构一致。pinnedRoots 的参数传递保持现状没问题 —— 它和现有的 projectRoot/opts 传递方式一致。
4. 低 — isWithinRootCaseInsensitive 的范围比设计文档描述的更宽
文档说 "不区分大小写地匹配保留的顶层目录名",但实现把两条完整绝对路径都转成了小写。这是 fail-closed 的 —— 两边同时转小写只会增加匹配,不会减少 —— 我也构造不出 false-allow;唯一可达的过度拒绝是记忆根目录下 pinned 本身的大小写变体,而那正是预期行为。String.prototype.toLowerCase 与 locale 无关(不同于 toLocaleLowerCase),所以土耳其语无点 ı 那类 bug 不适用。
建议在该 helper 上加两行注释把这两点写清楚,因为下一个读者一定会问这两个问题。
小问题
dreamAgentPlanner.test.ts新增的rm <pinned>SHELL 断言在main上同样通过。 它验证的是既有的只读 Shell 行为,不是新门禁的证据。作为回归保护没问题,但请加注释说明,否则后来的读者会以为 pinned 门禁覆盖了 Shell。- 那个 170 行的
protects pinned memory and aliases…用例把约 15 个断言、跨三个不同 permission manager(protectedPm、allMemoryPm、unprotectedPm)打包在一起。第一个失败会掩盖其余 —— 上面的 M1 表现为一个笼统的红色测试。把allMemoryPm(用户记忆)和unprotectedPm(负对照)两块拆成各自的it(),能定位失败,也更符合该文件其他用例的单一关注点风格。负对照尤其值得有自己的名字 —— 它是证明这个开关真正起作用的断言。 - 三个测试文件里常量用法不一致。
indexer.test.ts和dreamAgentPlanner.test.ts导入了AUTO_MEMORY_PINNED_DIRNAME;memory-scoped-agent-config.test.ts硬编码'pinned'。两种约定都说得通 —— 但每个文件内部请统一。 getScopedDenyRule:pinned 分支在EDIT和WRITE_FILE下逐字重复。 可以按ctx.toolName合并成一块,和上面allowedRoots只计算一次的写法保持一致。- 拒绝原因的优先级: 如果
pinned/是指向记忆目录外部的符号链接,对解析目标的写入现在会报ManagedAutoMemory(edit: pinned memory is read-only),而不是更准确的only within <root>。两者都拒绝,所以只是措辞问题。 - 设计文档位置正确 ——
docs/design/YYYY-MM-DD-*.md与现有 187 个文件一致,且设计文档没有任何索引需要登记。 - Windows 路径别名形式(8.3 短名、结尾点、ADS)未测试,与 PR 描述中"未在本机测试"一致。
pinned只有 6 个字符,不会生成 8.3 别名,风险很低。
结论
已实现部分的正确性:良好。测试质量:高于平均 —— 每个子检查都经变异验证是承重的,负对照也在。安全态势:Forked Dream Worker 可触及的写入面(write_file、edit、能重定向的 Shell)确实被封住了。
请求一处修改:处理发现 #1 —— 要么为 Extraction Agent 启用该开关(两行,并让用户记忆的 pinned 示例变成真的),要么在 docs/users/features/memory.md 里加上说明。否则 pinned/ 只防住了较少见的写入者,对高频写入者是敞开的,而用户文档没有说清是哪一个。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
中文说明
已审查。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
— qwen3.8-max-preview via Qwen Code /review
Local verification report — real end-to-end, not mockedI built a local harness to verify this PR against a real forked Dream agent and a real filesystem, because every test in the PR asserts on a Verdict: the boundary does what the PR claims. I'm happy to merge it. Details, evidence and a few non-blocking notes below. 1. The decisive test — a real Dream agent trying to overwrite pinned memoryI drove
4 of 4 pinned mutations land before the PR; 0 of 4 after. The control write lands in both arms — without it, "nothing was written" would be a vacuous pass. The agent is told exactly why: 2. Path-containment probe matrix — real symlinks, real dangling links25 probes against a real temp memory tree through the real Everything lands where it should, and the three "must stay writable" cases ( I also ran three configurations the PR never exercises — all correct:
(In that last case an ordinary file is also denied, but that is pre-existing 3. The second line of defence, and residual surfaceThe docs claim the pre-existing read-only shell policy blocks command-line mutation. I threw 17 mutation vectors at it — 4. Repository checks
Non-blocking notes
How to reproduceThe E2E harness is ~150 lines: create a temp project root with 中文说明本地验证报告 —— 真实端到端,非 Mock我在本地搭建了验证环境,用真实的 Forked Dream Agent 和真实文件系统来验证本 PR。原因是:PR 中所有测试断言的都是 结论:该边界确实实现了 PR 所声称的效果,我同意合并。 证据与若干非阻塞说明如下。 1. 决定性测试 —— 真实 Dream Agent 尝试覆盖 pinned memory我驱动了完整真实链路:
修改前 4 次 pinned 改写全部成功;修改后 0 次成功。对照组写入在两个分支都成功——如果没有这个对照,「没有任何文件被写入」就可能是一个空洞的通过。Agent 收到的拒绝原因也很明确: 2. 路径包含判定矩阵 —— 真实符号链接、真实悬空链接在真实临时 memory 目录树上,通过真实的 结果全部符合预期,三个「必须保持可写」的场景( 我还额外验证了 PR 从未覆盖的三种配置,结果均正确:
(最后一种情况下普通文件也会被拒绝,但这是 3. 第二道防线与残余攻击面文档声称既有的只读 Shell 策略可以阻止命令行改写。我用 17 种改写手段做了测试: 4. 仓库检查
非阻塞说明
复现方法E2E harness 约 150 行:创建临时 project root,把 Verified with Claude Code (Claude Opus 5, 1M context). |
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
|
Released in v0.21.3. |









What this PR does
This PR adds an opt-in managed-memory permission gate that prevents forked Dream workers from using
write_fileorediton files under a top-levelpinned/directory. The gate protects project and optional user-memory roots through case-insensitive literal-path checks and symlink-resolved checks, while ordinary memory paths—including nested directories such asmemory/project/pinned/—remain writable.Forked Dream enables the gate for scheduled cleanup and the workspace-memory Dream endpoint, keeps its existing project-memory-only scope, and receives an instruction to skip pinned content during consolidation. Pinned files remain available to the existing recursive memory index. The permission path also resolves each candidate once, caches stable pinned roots when the scoped configuration is created, and reuses the pinned decision when producing a deny reason.
Why it's needed
Pinned memory is intended for durable information that automated consolidation must not rewrite. Prompt instructions alone are not a deterministic boundary, and a lexical-only check could be bypassed through case variants or symlink aliases. The gate provides an enforcement boundary for forked Dream without making every directory named
pinnedread-only or changing normal memory indexing and read behavior.Reviewer Test Plan
How to verify
pinned/directory, then evaluatewrite_fileandedit; both operations should be denied with the matching pinned-memory reason.pinned/; the protected paths should remain denied.pinned-notes/directory, andmemory/project/pinned/notes.md; these paths should remain allowed.protectPinnedMemory: truewithincludeUserMemory: false, while pinned files continue to appear in the recursive memory index.The full memory suite passed 477 tests. Targeted ESLint and Prettier checks plus
git diff --checkalso passed.Evidence (Before & After)
write_fileoredit.pinned-notes/, and nestedproject/pinned/paths remain writable.Tested on
Environment (optional)
macOS on arm64 using the repository npm workspace toolchain.
Risk & Scope
/dreamslash command runs as a main-Agentsubmit_promptturn and remains prompt-protected rather than using the forked worker's deterministic gate. Explicit/forget, the/memoryUI, forked-Dream user-memory access, and local Windows/Linux execution are also out of scope.includeUserMemory: false.Linked Issues
Refs #6801
中文说明
本 PR 做了什么
本 PR 新增一个可选的受管理记忆权限门禁,阻止 Forked Dream Worker 对顶层
pinned/目录下的文件执行write_file或edit。该门禁通过不区分大小写的字面路径检查和符号链接解析后的路径检查保护项目记忆以及可选的用户记忆根目录;普通记忆路径仍可写,包括memory/project/pinned/这类嵌套目录。Forked Dream 会在定时清理和 workspace-memory Dream 端点中启用该门禁,保持现有的“仅项目记忆”范围,并在 consolidation 提示中明确跳过 pinned 内容。Pinned 文件仍会被现有递归记忆索引收录。权限路径还会让每个候选路径只解析一次,在创建 scoped 配置时缓存稳定的 pinned 根目录,并在生成拒绝原因时复用 pinned 判断结果。
为什么需要
Pinned memory 用于保存不应被自动 consolidation 改写的持久信息。仅靠提示词不能构成确定性的保护边界,只检查字面路径也可能被大小写变体或符号链接别名绕过。该门禁为 Forked Dream 提供强制保护,同时不会把所有名为
pinned的目录都变成只读,也不会改变正常的记忆索引和读取行为。审阅者测试计划
如何验证
pinned/目录下创建文件,然后检查write_file和edit;两种操作都应被拒绝,并返回对应的 pinned-memory 原因。pinned/下尚未创建的文件重复验证;这些受保护路径都应继续被拒绝。pinned-notes/目录和memory/project/pinned/notes.md;这些路径应继续允许写入。protectPinnedMemory: true和includeUserMemory: false,同时 pinned 文件仍会进入递归记忆索引。完整 memory 测试套件共 477 个测试通过;定向 ESLint、Prettier 和
git diff --check也均通过。修改前后证据
write_file或edit访问 pinned 文件。pinned-notes/和嵌套project/pinned/路径仍可写。测试平台
环境(可选)
macOS arm64,使用仓库的 npm workspace 工具链。
风险与范围
/dream命令以主 Agent 的submit_promptTurn 运行,仍只有提示词保护,不使用 Forked Worker 的确定性门禁。显式/forget、/memoryUI、Forked Dream 的用户记忆访问,以及 Windows/Linux 本地执行也不在本 PR 范围内。includeUserMemory: false。关联 Issue
Refs #6801