From 09c1f14b4759ff8d61c51b63f42345c9dc1944a0 Mon Sep 17 00:00:00 2001 From: destire-mio <248462155+destire-mio@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:53:55 +0800 Subject: [PATCH 1/4] feat(memory): protect pinned files during forked Dream --- .../2026-07-25-pinned-memory-protection.md | 67 ++++++++++ docs/users/features/memory.md | 21 +++ .../core/src/memory/dreamAgentPlanner.test.ts | 34 +++++ packages/core/src/memory/dreamAgentPlanner.ts | 14 +- packages/core/src/memory/indexer.test.ts | 26 ++++ .../memory/memory-scoped-agent-config.test.ts | 121 ++++++++++++++++++ .../src/memory/memory-scoped-agent-config.ts | 60 +++++++++ packages/core/src/memory/paths.ts | 1 + 8 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 docs/design/2026-07-25-pinned-memory-protection.md diff --git a/docs/design/2026-07-25-pinned-memory-protection.md b/docs/design/2026-07-25-pinned-memory-protection.md new file mode 100644 index 00000000000..50f48f6be44 --- /dev/null +++ b/docs/design/2026-07-25-pinned-memory-protection.md @@ -0,0 +1,67 @@ +# Pinned Managed-Memory Protection + +## Problem + +Managed auto-memory recursively discovers valid markdown topics below the +project and user memory roots, subject to the existing index limits. The Dream +consolidation agent can currently write or edit any path inside its allowed +memory root, so a hand-curated file can be overwritten or consolidated like an +automatically generated memory. + +The recursive scanner already discovers valid files below `pinned/`; the +missing behavior is deterministic mutation protection during Dream. + +## Chosen design + +Treat a top-level `pinned/` directory inside a managed-memory root as protected +records excluded from Dream consolidation: + +- Keep valid pinned documents readable to normal memory recall and discoverable + by the existing indexer under its normal limits. +- Deny Dream `write_file` and `edit` operations when the requested path is + lexically below `pinned/`. +- Also deny aliases that resolve through a symlink into `pinned/`. +- Keep the existing read-only shell gate, which already rejects `rm` and every + other mutating shell command. +- Teach the shared consolidation prompt to leave pinned documents out of + consolidation analysis and avoid intentionally removing their existing index + entries, subject to normal index limits. + +The path check compares both literal and resolved paths. Literal containment +protects `pinned/` even when that directory is itself a symlink. Resolved +containment prevents a writable-looking path elsewhere in memory from +symlinking back into `pinned/`. + +Protection is an explicit option on the existing memory-scoped agent +configuration and is enabled by the forked Dream planner. This covers scheduled +Dream and callers of the workspace-memory Dream endpoint. Extraction and +explicit remember operations retain their current behavior. + +## Scope boundaries + +- No scanner or indexer production change: recursive discovery already handles + project and user `pinned/` documents with the existing frontmatter schema. +- No new frontmatter field and no automatic creation of the directory. +- No `/memory` UI indicator. +- Explicit `/forget` requests keep their current behavior. +- The visible `/dream` slash-command turn receives the shared skip prompt rule, + but does not gain a deterministic tool gate in this change. The slash command + executes on the main Agent, which has no existing per-turn permission + override; adding one would be a separate cross-surface permission design. +- Forked Dream remains project-memory-only because its existing scoped + configuration excludes the global user-memory root. + +## Files affected + +- `packages/core/src/memory/paths.ts` +- `packages/core/src/memory/memory-scoped-agent-config.ts` +- `packages/core/src/memory/dreamAgentPlanner.ts` +- Collocated memory permission, prompt, and index tests +- `docs/users/features/memory.md` + +## Open question + +Whether the visible `/dream` slash command must receive the same deterministic +gate remains a maintainer scope decision. If required, it should be implemented +as a general per-turn permission override rather than by mutating the +session-wide permission manager around one asynchronous tool loop. diff --git a/docs/users/features/memory.md b/docs/users/features/memory.md index d9e37830e3e..687eb9cac87 100644 --- a/docs/users/features/memory.md +++ b/docs/users/features/memory.md @@ -95,6 +95,27 @@ Auto-memory files live at `~/.qwen/projects//memory/`. All branches of Everything saved is plain markdown — you can open, edit, or delete any file at any time. +#### Pinned memory + +Put hand-curated documents that Dream should preserve under `pinned/` in a +managed-memory directory, for example +`~/.qwen/projects//memory/pinned/architecture.md` or +`~/.qwen/memories/pinned/preferences.md`. Use the same frontmatter as other +memory documents. Valid pinned files are readable by Qwen and are included the +next time `MEMORY.md` is rebuilt, under the same size and file-count limits as +other memory documents. + +Dream is instructed to skip `pinned/` during consolidation. Forked Dream +workers, including background cleanup, additionally enforce that boundary on +their write and edit tools, including paths that resolve through a symlink into +`pinned/`; their existing read-only shell policy blocks command-line deletion. +You still control these files directly and can remove them with an explicit +`/forget` request. + +> **Note:** The visible `/dream` slash command runs on the main Agent. It +> receives the same skip instruction, but does not yet receive the forked +> worker's deterministic per-turn tool gate. + ### Periodic cleanup Qwen periodically goes through its saved memories to remove duplicates and clean up outdated entries. This runs automatically in the background once a day after enough sessions have accumulated. You can trigger it manually with `/dream` if you want it to run now. diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index 525b81a653b..cdf406e5d7e 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -111,6 +111,20 @@ describe('dreamAgentPlanner', () => { ); }); + it('excludes pinned memories from consolidation', () => { + const prompt = buildConsolidationTaskPrompt( + path.join(tempDir, 'memory'), + path.join(tempDir, 'transcripts'), + ); + + expect(prompt).toContain('`pinned/`'); + expect(prompt).toContain('Skip `pinned/` during Dream'); + expect(prompt).toContain( + 'Do not intentionally remove existing index entries for valid `pinned/` files', + ); + expect(prompt).toContain('normal index limits still apply'); + }); + it('returns the forked agent result', async () => { const mockResult: ForkedAgentResult = { status: 'completed', @@ -180,12 +194,32 @@ describe('dreamAgentPlanner', () => { filePath: path.join(getAutoMemoryRoot(projectRoot), 'project.md'), }), ).resolves.toBe('allow'); + await expect( + pm.evaluate({ + toolName: ToolNames.EDIT, + filePath: path.join( + getAutoMemoryRoot(projectRoot), + 'pinned', + 'architecture.md', + ), + }), + ).resolves.toBe('deny'); await expect( pm.evaluate({ toolName: ToolNames.WRITE_FILE, filePath: path.join(getUserAutoMemoryRoot(), 'user', 'a.md'), }), ).resolves.toBe('deny'); + await expect( + pm.evaluate({ + toolName: ToolNames.SHELL, + command: `rm ${path.join( + getAutoMemoryRoot(projectRoot), + 'pinned', + 'architecture.md', + )}`, + }), + ).resolves.toBe('deny'); }); it('throws when the agent fails', async () => { diff --git a/packages/core/src/memory/dreamAgentPlanner.ts b/packages/core/src/memory/dreamAgentPlanner.ts index 47d67be1859..bc1a02790a7 100644 --- a/packages/core/src/memory/dreamAgentPlanner.ts +++ b/packages/core/src/memory/dreamAgentPlanner.ts @@ -11,7 +11,11 @@ import { } from '../utils/forkedAgent.js'; import * as path from 'node:path'; import { Storage } from '../config/storage.js'; -import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot } from './paths.js'; +import { + AUTO_MEMORY_INDEX_FILENAME, + AUTO_MEMORY_PINNED_DIRNAME, + getAutoMemoryRoot, +} from './paths.js'; import { ToolNames } from '../tools/tool-names.js'; import { escapeShellArg, getShellConfiguration } from '../utils/shell-utils.js'; import { createMemoryScopedAgentConfig } from './memory-scoped-agent-config.js'; @@ -24,7 +28,9 @@ const DREAM_AGENT_SYSTEM_PROMPT = `You are performing a managed memory dream — Synthesize what you've learned recently into durable, well-organized memories so that future sessions can orient quickly. Rules: -- Merge semantically duplicate entries — if the same fact appears in multiple files, consolidate into one file and delete the rest. +- Treat files under the top-level \`${AUTO_MEMORY_PINNED_DIRNAME}/\` directory as protected read-only records. Never modify, overwrite, rename, merge into, or delete them. +- Leave \`${AUTO_MEMORY_PINNED_DIRNAME}/\` out of consolidation analysis; do not list, read, or compare its files during Dream. +- Merge semantically duplicate entries among writable topic files — if the same fact appears in multiple writable files, consolidate into one file and delete the rest. - Preserve all durable information; do not delete content that is still accurate. - Fix contradicted or stale facts only when the evidence is clear from the existing memory content or recent transcript signal. - Update the MEMORY.md index to accurately reflect surviving files. @@ -56,6 +62,7 @@ export function buildConsolidationTaskPrompt( '- List the memory directory to see what files exist', `- Read \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\` to understand the current index`, '- Skim topic subdirectories (`user/`, `project/`, `feedback/`, `reference/`)', + `- Skip \`${AUTO_MEMORY_PINNED_DIRNAME}/\` during Dream; do not list or read files there`, '- If `logs/` or `sessions/` subdirectories exist, review recent entries there', '', '## Phase 2 — Gather recent signal', @@ -73,6 +80,7 @@ export function buildConsolidationTaskPrompt( 'For each topic directory:', '- Identify duplicate or near-duplicate `.md` files (same fact expressed differently)', '- Merge duplicates: write the canonical version into one file, delete the redundant files', + `- Exclude \`${AUTO_MEMORY_PINNED_DIRNAME}/\` from duplicate, stale, and contradiction analysis; never use a pinned file as a merge target or deletion candidate`, '- Fix stale or contradicted facts when clear from the existing content', '- Convert relative dates (for example: "yesterday", "last week") to absolute dates when preserving them', '', @@ -81,6 +89,7 @@ export function buildConsolidationTaskPrompt( `Update \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\` to reflect surviving files.`, 'Each entry: `- [Title](relative/path.md) — one-line hook`', 'Keep the index under roughly 200 lines and ~25KB.', + `Do not intentionally remove existing index entries for valid \`${AUTO_MEMORY_PINNED_DIRNAME}/\` files during consolidation; normal index limits still apply.`, 'Remove pointers to deleted, stale, wrong, or superseded files. Add pointers to any newly created files.', 'If an index line is too verbose, shorten it and move the detail back into the memory file itself.', '', @@ -101,6 +110,7 @@ export async function planManagedAutoMemoryDreamByAgent( const scopedConfig = createMemoryScopedAgentConfig(config, projectRoot, { allowShell: true, includeUserMemory: false, + protectPinnedMemory: true, }); const result = await runForkedAgent({ name: 'managed-auto-memory-dreamer', diff --git a/packages/core/src/memory/indexer.test.ts b/packages/core/src/memory/indexer.test.ts index 6474590b902..7b1985eb059 100644 --- a/packages/core/src/memory/indexer.test.ts +++ b/packages/core/src/memory/indexer.test.ts @@ -102,6 +102,32 @@ describe('managed auto-memory indexer', () => { expect(index).toContain('The repo uses pnpm workspaces.'); }); + it('includes a valid pinned document in the generated index', async () => { + const pinnedFile = getAutoMemoryFilePath( + projectRoot, + path.join('pinned', 'architecture.md'), + ); + await fs.mkdir(path.dirname(pinnedFile), { recursive: true }); + await fs.writeFile( + pinnedFile, + [ + '---', + 'type: project', + 'name: Canonical Architecture', + 'description: The hand-curated architecture reference.', + '---', + '', + 'This document is maintained by the user.', + ].join('\n'), + 'utf-8', + ); + + const index = await rebuildManagedAutoMemoryIndex(projectRoot); + + expect(index).toContain('[Canonical Architecture](pinned/architecture.md)'); + expect(index).toContain('The hand-curated architecture reference.'); + }); + it('sanitizes attacker-controlled title/description before embedding', () => { // Team frontmatter is attacker-controlled and lands in every collaborator's // system prompt via the committed MEMORY.md — it must not inject structure. diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index 8d9568772c9..b03e98fa041 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -113,6 +113,127 @@ describe('createMemoryScopedAgentConfig', () => { ).resolves.toBe('deny'); }); + it('protects pinned memory and aliases while leaving ordinary memory writable', async () => { + const memoryRoot = getAutoMemoryRoot(projectRoot); + const pinnedDir = path.join(memoryRoot, 'pinned'); + const pinnedFile = path.join(pinnedDir, 'architecture.md'); + const pinnedAlias = path.join(memoryRoot, 'project', 'pinned-alias'); + await fs.mkdir(pinnedDir, { recursive: true }); + await fs.writeFile(pinnedFile, 'canonical architecture'); + await fs.symlink(pinnedDir, pinnedAlias); + + const protectedPm = permissionManager( + createMemoryScopedAgentConfig({} as Config, projectRoot, { + includeUserMemory: false, + protectPinnedMemory: true, + }), + ); + + await expect( + protectedPm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(memoryRoot, 'project', 'ordinary.md'), + }), + ).resolves.toBe('allow'); + await expect( + protectedPm.evaluate({ + toolName: ToolNames.EDIT, + filePath: pinnedFile, + }), + ).resolves.toBe('deny'); + await expect( + protectedPm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(pinnedDir, 'new.md'), + }), + ).resolves.toBe('deny'); + await expect( + protectedPm.evaluate({ + toolName: ToolNames.EDIT, + filePath: path.join(pinnedAlias, 'architecture.md'), + }), + ).resolves.toBe('deny'); + await expect( + protectedPm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(pinnedAlias, 'new.md'), + }), + ).resolves.toBe('deny'); + await expect( + protectedPm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(memoryRoot, 'pinned-notes', 'ordinary.md'), + }), + ).resolves.toBe('allow'); + + const userPinnedFile = path.join( + getUserAutoMemoryRoot(), + 'pinned', + 'preferences.md', + ); + await fs.mkdir(path.dirname(userPinnedFile), { recursive: true }); + await fs.writeFile(userPinnedFile, 'canonical preferences'); + const allMemoryPm = permissionManager( + createMemoryScopedAgentConfig({} as Config, projectRoot, { + protectPinnedMemory: true, + }), + ); + await expect( + allMemoryPm.evaluate({ + toolName: ToolNames.EDIT, + filePath: userPinnedFile, + }), + ).resolves.toBe('deny'); + await expect( + allMemoryPm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(getUserAutoMemoryRoot(), 'user', 'ordinary.md'), + }), + ).resolves.toBe('allow'); + + const unprotectedPm = permissionManager( + createMemoryScopedAgentConfig({} as Config, projectRoot, { + includeUserMemory: false, + }), + ); + await expect( + unprotectedPm.evaluate({ + toolName: ToolNames.EDIT, + filePath: pinnedFile, + }), + ).resolves.toBe('allow'); + }); + + it('protects a pinned directory symlink and its in-memory target', async () => { + const memoryRoot = getAutoMemoryRoot(projectRoot); + const targetDir = path.join(memoryRoot, 'project', 'shared'); + const targetFile = path.join(targetDir, 'architecture.md'); + const pinnedDir = path.join(memoryRoot, 'pinned'); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(targetFile, 'canonical architecture'); + await fs.symlink(targetDir, pinnedDir); + + const pm = permissionManager( + createMemoryScopedAgentConfig({} as Config, projectRoot, { + includeUserMemory: false, + protectPinnedMemory: true, + }), + ); + + await expect( + pm.evaluate({ + toolName: ToolNames.EDIT, + filePath: path.join(pinnedDir, 'architecture.md'), + }), + ).resolves.toBe('deny'); + await expect( + pm.evaluate({ + toolName: ToolNames.EDIT, + filePath: targetFile, + }), + ).resolves.toBe('deny'); + }); + it('allows creating new nested topic files inside memory roots', async () => { const pm = permissionManager( createMemoryScopedAgentConfig({} as Config, projectRoot), diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index a778b349240..818cb99aa8d 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -16,6 +16,7 @@ import { ToolNames } from '../tools/tool-names.js'; import { isShellCommandReadOnlyAST } from '../utils/shellAstParser.js'; import { stripShellWrapper } from '../utils/shell-utils.js'; import { + AUTO_MEMORY_PINNED_DIRNAME, getAutoMemoryRoot, getAutoMemoryTrustedAnchor, getUserAutoMemoryRoot, @@ -34,6 +35,7 @@ export interface MemoryScopedAgentConfigOptions { allowShell?: boolean; bypassBaseAskForScopedPaths?: boolean; includeUserMemory?: boolean; + protectPinnedMemory?: boolean; restrictReadsToMemoryPaths?: boolean; } @@ -94,6 +96,39 @@ export function isAllowedMemoryPath( return !!resolved && isAllowed(resolved); } +function isProtectedPinnedMemoryPath( + filePath: string | undefined, + projectRoot: string, + options: Pick = {}, +): boolean { + if (!filePath) return false; + + const memoryRoots = [getAutoMemoryRoot(projectRoot)]; + if (options.includeUserMemory ?? true) { + memoryRoots.push(getUserAutoMemoryRoot()); + } + + const literalCandidate = path.resolve(filePath); + const resolvedCandidate = realpathExistingOrNew(filePath); + + return memoryRoots.some((memoryRoot) => { + const literalPinnedRoot = path.resolve( + memoryRoot, + AUTO_MEMORY_PINNED_DIRNAME, + ); + if (isWithinRoot(literalCandidate, literalPinnedRoot)) { + return true; + } + + const resolvedPinnedRoot = realpathExistingOrNew(literalPinnedRoot); + return ( + !!resolvedCandidate && + !!resolvedPinnedRoot && + isWithinRoot(resolvedCandidate, resolvedPinnedRoot) + ); + }); +} + function realpathExistingOrNew(filePath: string): string | undefined { try { return fs.realpathSync(filePath); @@ -200,6 +235,14 @@ async function evaluateScopedDecision( : 'deny'; case ToolNames.EDIT: case ToolNames.WRITE_FILE: + if ( + opts.protectPinnedMemory && + isProtectedPinnedMemoryPath(ctx.filePath, projectRoot, { + includeUserMemory: opts.includeUserMemory, + }) + ) { + return 'deny'; + } return isAllowedMemoryPath(ctx.filePath, projectRoot, { includeUserMemory: opts.includeUserMemory, }) @@ -235,8 +278,24 @@ function getScopedDenyRule( `ManagedAutoMemory(list_directory: only within ` + `${allowedRoots})` ); case ToolNames.EDIT: + if ( + opts.protectPinnedMemory && + isProtectedPinnedMemoryPath(ctx.filePath, projectRoot, { + includeUserMemory: opts.includeUserMemory, + }) + ) { + return 'ManagedAutoMemory(edit: pinned memory is read-only)'; + } return `ManagedAutoMemory(edit: only within ${allowedRoots})`; case ToolNames.WRITE_FILE: + if ( + opts.protectPinnedMemory && + isProtectedPinnedMemoryPath(ctx.filePath, projectRoot, { + includeUserMemory: opts.includeUserMemory, + }) + ) { + return 'ManagedAutoMemory(write_file: pinned memory is read-only)'; + } return `ManagedAutoMemory(write_file: only within ${allowedRoots})`; default: return undefined; @@ -252,6 +311,7 @@ export function createMemoryScopedAgentConfig( allowShell: options.allowShell ?? false, bypassBaseAskForScopedPaths: options.bypassBaseAskForScopedPaths ?? false, includeUserMemory: options.includeUserMemory ?? true, + protectPinnedMemory: options.protectPinnedMemory ?? false, restrictReadsToMemoryPaths: options.restrictReadsToMemoryPaths ?? false, }; const basePm = config.getPermissionManager?.(); diff --git a/packages/core/src/memory/paths.ts b/packages/core/src/memory/paths.ts index 74cc9d8484c..3975590f9b6 100644 --- a/packages/core/src/memory/paths.ts +++ b/packages/core/src/memory/paths.ts @@ -12,6 +12,7 @@ import type { AutoMemoryType } from './types.js'; export const AUTO_MEMORY_DIRNAME = 'memory'; export const AUTO_MEMORY_INDEX_FILENAME = 'MEMORY.md'; +export const AUTO_MEMORY_PINNED_DIRNAME = 'pinned'; export const AUTO_MEMORY_METADATA_FILENAME = 'meta.json'; export const AUTO_MEMORY_EXTRACT_CURSOR_FILENAME = 'extract-cursor.json'; export const AUTO_MEMORY_CONSOLIDATION_LOCK_FILENAME = 'consolidation.lock'; From 67c90a9f6bd570cde8e84095c6d9740abff5e5dd Mon Sep 17 00:00:00 2001 From: destire-mio <248462155+destire-mio@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:37:22 +0800 Subject: [PATCH 2/4] fix(memory): harden pinned path protection --- .../2026-07-25-pinned-memory-protection.md | 10 +++-- docs/users/features/memory.md | 5 +++ packages/core/src/memory/indexer.test.ts | 10 +++-- .../memory/memory-scoped-agent-config.test.ts | 43 +++++++++++++++++++ .../src/memory/memory-scoped-agent-config.ts | 8 +++- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/docs/design/2026-07-25-pinned-memory-protection.md b/docs/design/2026-07-25-pinned-memory-protection.md index 50f48f6be44..090531b90de 100644 --- a/docs/design/2026-07-25-pinned-memory-protection.md +++ b/docs/design/2026-07-25-pinned-memory-protection.md @@ -20,6 +20,8 @@ records excluded from Dream consolidation: by the existing indexer under its normal limits. - Deny Dream `write_file` and `edit` operations when the requested path is lexically below `pinned/`. +- Match the reserved top-level directory name case-insensitively so the + deny-list cannot fail open on case-insensitive filesystems. - Also deny aliases that resolve through a symlink into `pinned/`. - Keep the existing read-only shell gate, which already rejects `rm` and every other mutating shell command. @@ -27,10 +29,10 @@ records excluded from Dream consolidation: consolidation analysis and avoid intentionally removing their existing index entries, subject to normal index limits. -The path check compares both literal and resolved paths. Literal containment -protects `pinned/` even when that directory is itself a symlink. Resolved -containment prevents a writable-looking path elsewhere in memory from -symlinking back into `pinned/`. +The path check compares both literal and resolved paths case-insensitively. +Literal containment protects `pinned/` even when that directory is itself a +symlink. Resolved containment prevents a writable-looking path elsewhere in +memory from symlinking back into `pinned/`. Protection is an explicit option on the existing memory-scoped agent configuration and is enabled by the forked Dream planner. This covers scheduled diff --git a/docs/users/features/memory.md b/docs/users/features/memory.md index 687eb9cac87..5bed8324f10 100644 --- a/docs/users/features/memory.md +++ b/docs/users/features/memory.md @@ -105,6 +105,11 @@ memory documents. Valid pinned files are readable by Qwen and are included the next time `MEMORY.md` is rebuilt, under the same size and file-count limits as other memory documents. +Only the top-level `pinned/` directory directly inside a managed-memory root is +protected; nested directories such as `memory/project/pinned/` are ordinary +writable memory. Dream workers match the reserved directory name +case-insensitively. + Dream is instructed to skip `pinned/` during consolidation. Forked Dream workers, including background cleanup, additionally enforce that boundary on their write and edit tools, including paths that resolve through a symlink into diff --git a/packages/core/src/memory/indexer.test.ts b/packages/core/src/memory/indexer.test.ts index 7b1985eb059..b3dae16ba13 100644 --- a/packages/core/src/memory/indexer.test.ts +++ b/packages/core/src/memory/indexer.test.ts @@ -8,7 +8,11 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getAutoMemoryFilePath, getAutoMemoryIndexPath } from './paths.js'; +import { + AUTO_MEMORY_PINNED_DIRNAME, + getAutoMemoryFilePath, + getAutoMemoryIndexPath, +} from './paths.js'; import { buildManagedAutoMemoryIndex, buildTeamAutoMemoryIndex, @@ -102,10 +106,10 @@ describe('managed auto-memory indexer', () => { expect(index).toContain('The repo uses pnpm workspaces.'); }); - it('includes a valid pinned document in the generated index', async () => { + it('keeps a valid pinned document in the generated index', async () => { const pinnedFile = getAutoMemoryFilePath( projectRoot, - path.join('pinned', 'architecture.md'), + path.join(AUTO_MEMORY_PINNED_DIRNAME, 'architecture.md'), ); await fs.mkdir(path.dirname(pinnedFile), { recursive: true }); await fs.writeFile( diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index b03e98fa041..a467e413971 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -147,6 +147,12 @@ describe('createMemoryScopedAgentConfig', () => { filePath: path.join(pinnedDir, 'new.md'), }), ).resolves.toBe('deny'); + await expect( + protectedPm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(memoryRoot, 'PINNED', 'architecture.md'), + }), + ).resolves.toBe('deny'); await expect( protectedPm.evaluate({ toolName: ToolNames.EDIT, @@ -165,6 +171,18 @@ describe('createMemoryScopedAgentConfig', () => { filePath: path.join(memoryRoot, 'pinned-notes', 'ordinary.md'), }), ).resolves.toBe('allow'); + expect( + protectedPm.findMatchingDenyRule({ + toolName: ToolNames.EDIT, + filePath: pinnedFile, + }), + ).toBe('ManagedAutoMemory(edit: pinned memory is read-only)'); + expect( + protectedPm.findMatchingDenyRule({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join(pinnedDir, 'new.md'), + }), + ).toBe('ManagedAutoMemory(write_file: pinned memory is read-only)'); const userPinnedFile = path.join( getUserAutoMemoryRoot(), @@ -234,6 +252,31 @@ describe('createMemoryScopedAgentConfig', () => { ).resolves.toBe('deny'); }); + it('protects paths below a dangling top-level pinned symlink', async () => { + const pinnedDir = path.join(getAutoMemoryRoot(projectRoot), 'pinned'); + await fs.symlink( + path.join(tempDir, 'missing-pinned-target'), + pinnedDir, + 'dir', + ); + + const pm = permissionManager( + createMemoryScopedAgentConfig({} as Config, projectRoot, { + includeUserMemory: false, + protectPinnedMemory: true, + }), + ); + const context = { + toolName: ToolNames.WRITE_FILE, + filePath: path.join(pinnedDir, 'new.md'), + }; + + await expect(pm.evaluate(context)).resolves.toBe('deny'); + expect(pm.findMatchingDenyRule(context)).toBe( + 'ManagedAutoMemory(write_file: pinned memory is read-only)', + ); + }); + it('allows creating new nested topic files inside memory roots', async () => { const pm = permissionManager( createMemoryScopedAgentConfig({} as Config, projectRoot), diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index 818cb99aa8d..ed5ca89b253 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -116,7 +116,7 @@ function isProtectedPinnedMemoryPath( memoryRoot, AUTO_MEMORY_PINNED_DIRNAME, ); - if (isWithinRoot(literalCandidate, literalPinnedRoot)) { + if (isWithinRootCaseInsensitive(literalCandidate, literalPinnedRoot)) { return true; } @@ -124,7 +124,7 @@ function isProtectedPinnedMemoryPath( return ( !!resolvedCandidate && !!resolvedPinnedRoot && - isWithinRoot(resolvedCandidate, resolvedPinnedRoot) + isWithinRootCaseInsensitive(resolvedCandidate, resolvedPinnedRoot) ); }); } @@ -209,6 +209,10 @@ function isWithinRoot(filePath: string, root: string): boolean { ); } +function isWithinRootCaseInsensitive(filePath: string, root: string): boolean { + return isWithinRoot(filePath.toLowerCase(), root.toLowerCase()); +} + async function evaluateScopedDecision( ctx: PermissionCheckContext, projectRoot: string, From 942fbdd75bcd2794dddce1d531fdd1a8b2f6280c Mon Sep 17 00:00:00 2001 From: destire-mio <248462155+destire-mio@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:58:04 +0800 Subject: [PATCH 3/4] perf(memory): avoid repeated pinned path resolution --- .../memory/memory-scoped-agent-config.test.ts | 6 + .../src/memory/memory-scoped-agent-config.ts | 113 ++++++++++++------ 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index a467e413971..f2a24b2300b 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -171,6 +171,12 @@ describe('createMemoryScopedAgentConfig', () => { 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'); expect( protectedPm.findMatchingDenyRule({ toolName: ToolNames.EDIT, diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index ed5ca89b253..57e1a3eef59 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -39,6 +39,11 @@ export interface MemoryScopedAgentConfigOptions { restrictReadsToMemoryPaths?: boolean; } +interface PinnedMemoryRoot { + literalPath: string; + resolvedPath: string | undefined; +} + function isScopedTool( toolName: string, opts: Required, @@ -83,6 +88,19 @@ export function isAllowedMemoryPath( options: Pick = {}, ): boolean { if (!filePath) return false; + return isAllowedResolvedMemoryPath( + realpathExistingOrNew(filePath), + projectRoot, + options, + ); +} + +function isAllowedResolvedMemoryPath( + resolvedPath: string | undefined, + projectRoot: string, + options: Pick = {}, +): boolean { + if (!resolvedPath) return false; const includeUserMemory = options.includeUserMemory ?? true; const projectMemoryRoot = resolveTrustedMemoryRoot( getAutoMemoryRoot(projectRoot), @@ -92,39 +110,41 @@ export function isAllowedMemoryPath( const isAllowed = (candidate: string): boolean => isWithinRoot(candidate, projectMemoryRoot) || (includeUserMemory && isWithinRoot(candidate, userMemoryRoot)); - const resolved = realpathExistingOrNew(filePath); - return !!resolved && isAllowed(resolved); + return isAllowed(resolvedPath); } -function isProtectedPinnedMemoryPath( - filePath: string | undefined, +function createPinnedMemoryRoots( projectRoot: string, - options: Pick = {}, -): boolean { - if (!filePath) return false; - + includeUserMemory: boolean, +): PinnedMemoryRoot[] { const memoryRoots = [getAutoMemoryRoot(projectRoot)]; - if (options.includeUserMemory ?? true) { + if (includeUserMemory) { memoryRoots.push(getUserAutoMemoryRoot()); } + return memoryRoots.map((memoryRoot) => { + const literalPath = path.resolve(memoryRoot, AUTO_MEMORY_PINNED_DIRNAME); + return { + literalPath, + resolvedPath: realpathExistingOrNew(literalPath), + }; + }); +} +function isProtectedPinnedMemoryPath( + filePath: string | undefined, + pinnedRoots: readonly PinnedMemoryRoot[], + resolvedCandidate = filePath ? realpathExistingOrNew(filePath) : undefined, +): boolean { + if (!filePath) return false; const literalCandidate = path.resolve(filePath); - const resolvedCandidate = realpathExistingOrNew(filePath); - - return memoryRoots.some((memoryRoot) => { - const literalPinnedRoot = path.resolve( - memoryRoot, - AUTO_MEMORY_PINNED_DIRNAME, - ); - if (isWithinRootCaseInsensitive(literalCandidate, literalPinnedRoot)) { + return pinnedRoots.some((pinnedRoot) => { + if (isWithinRootCaseInsensitive(literalCandidate, pinnedRoot.literalPath)) { return true; } - - const resolvedPinnedRoot = realpathExistingOrNew(literalPinnedRoot); return ( !!resolvedCandidate && - !!resolvedPinnedRoot && - isWithinRootCaseInsensitive(resolvedCandidate, resolvedPinnedRoot) + !!pinnedRoot.resolvedPath && + isWithinRootCaseInsensitive(resolvedCandidate, pinnedRoot.resolvedPath) ); }); } @@ -217,6 +237,8 @@ async function evaluateScopedDecision( ctx: PermissionCheckContext, projectRoot: string, opts: Required, + pinnedRoots: readonly PinnedMemoryRoot[], + pinnedDecisionCache: WeakMap, ): Promise { switch (ctx.toolName) { case ToolNames.SHELL: { @@ -238,20 +260,25 @@ async function evaluateScopedDecision( ? 'allow' : 'deny'; case ToolNames.EDIT: - case ToolNames.WRITE_FILE: - if ( + case ToolNames.WRITE_FILE: { + const resolvedCandidate = ctx.filePath + ? realpathExistingOrNew(ctx.filePath) + : undefined; + const isPinned = opts.protectPinnedMemory && - isProtectedPinnedMemoryPath(ctx.filePath, projectRoot, { - includeUserMemory: opts.includeUserMemory, - }) - ) { - return 'deny'; - } - return isAllowedMemoryPath(ctx.filePath, projectRoot, { + isProtectedPinnedMemoryPath( + ctx.filePath, + pinnedRoots, + resolvedCandidate, + ); + pinnedDecisionCache.set(ctx, isPinned); + if (isPinned) return 'deny'; + return isAllowedResolvedMemoryPath(resolvedCandidate, projectRoot, { includeUserMemory: opts.includeUserMemory, }) ? 'allow' : 'deny'; + } default: return 'default'; } @@ -261,6 +288,8 @@ function getScopedDenyRule( ctx: PermissionCheckContext, projectRoot: string, opts: Required, + pinnedRoots: readonly PinnedMemoryRoot[], + pinnedDecisionCache: WeakMap, ): string | undefined { const allowedRoots = opts.includeUserMemory ? `${getUserAutoMemoryRoot()} or ${getAutoMemoryRoot(projectRoot)}` @@ -284,9 +313,8 @@ function getScopedDenyRule( case ToolNames.EDIT: if ( opts.protectPinnedMemory && - isProtectedPinnedMemoryPath(ctx.filePath, projectRoot, { - includeUserMemory: opts.includeUserMemory, - }) + (pinnedDecisionCache.get(ctx) ?? + isProtectedPinnedMemoryPath(ctx.filePath, pinnedRoots)) ) { return 'ManagedAutoMemory(edit: pinned memory is read-only)'; } @@ -294,9 +322,8 @@ function getScopedDenyRule( case ToolNames.WRITE_FILE: if ( opts.protectPinnedMemory && - isProtectedPinnedMemoryPath(ctx.filePath, projectRoot, { - includeUserMemory: opts.includeUserMemory, - }) + (pinnedDecisionCache.get(ctx) ?? + isProtectedPinnedMemoryPath(ctx.filePath, pinnedRoots)) ) { return 'ManagedAutoMemory(write_file: pinned memory is read-only)'; } @@ -318,6 +345,10 @@ export function createMemoryScopedAgentConfig( protectPinnedMemory: options.protectPinnedMemory ?? false, restrictReadsToMemoryPaths: options.restrictReadsToMemoryPaths ?? false, }; + const pinnedRoots = opts.protectPinnedMemory + ? createPinnedMemoryRoots(projectRoot, opts.includeUserMemory) + : []; + const pinnedDecisionCache = new WeakMap(); const basePm = config.getPermissionManager?.(); const scopedPm: MemoryScopedPermissionManager = { hasRelevantRules(ctx: PermissionCheckContext): boolean { @@ -329,7 +360,13 @@ export function createMemoryScopedAgentConfig( return basePm?.hasMatchingAskRule(ctx) ?? false; }, findMatchingDenyRule(ctx: PermissionCheckContext): string | undefined { - const scoped = getScopedDenyRule(ctx, projectRoot, opts); + const scoped = getScopedDenyRule( + ctx, + projectRoot, + opts, + pinnedRoots, + pinnedDecisionCache, + ); if (scoped) { return scoped; } @@ -340,6 +377,8 @@ export function createMemoryScopedAgentConfig( ctx, projectRoot, opts, + pinnedRoots, + pinnedDecisionCache, ); if (!basePm) { return scopedDecision; From cd5219355d7a19ddefb0e4f773344e20b0e6b28b Mon Sep 17 00:00:00 2001 From: destire-mio <248462155+destire-mio@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:34:59 +0800 Subject: [PATCH 4/4] fix(memory): protect pinned memory during extraction --- .../2026-07-25-pinned-memory-protection.md | 37 +++++--- docs/users/features/memory.md | 23 ++--- .../core/src/memory/dreamAgentPlanner.test.ts | 7 +- .../src/memory/extractionAgentPlanner.test.ts | 92 ++++++++++++++++++- .../core/src/memory/extractionAgentPlanner.ts | 5 +- .../memory/memory-scoped-agent-config.test.ts | 82 +++++++++++++++-- .../src/memory/memory-scoped-agent-config.ts | 52 +++++------ 7 files changed, 234 insertions(+), 64 deletions(-) diff --git a/docs/design/2026-07-25-pinned-memory-protection.md b/docs/design/2026-07-25-pinned-memory-protection.md index 090531b90de..c3444a35e2e 100644 --- a/docs/design/2026-07-25-pinned-memory-protection.md +++ b/docs/design/2026-07-25-pinned-memory-protection.md @@ -3,31 +3,32 @@ ## Problem Managed auto-memory recursively discovers valid markdown topics below the -project and user memory roots, subject to the existing index limits. The Dream -consolidation agent can currently write or edit any path inside its allowed -memory root, so a hand-curated file can be overwritten or consolidated like an -automatically generated memory. +project and user memory roots, subject to the existing index limits. Automatic +extraction and Dream consolidation agents can write or edit paths inside their +allowed memory roots, so a hand-curated file can be overwritten or consolidated +like an automatically generated memory. The recursive scanner already discovers valid files below `pinned/`; the -missing behavior is deterministic mutation protection during Dream. +missing behavior is deterministic mutation protection during automated memory +maintenance. ## Chosen design Treat a top-level `pinned/` directory inside a managed-memory root as protected -records excluded from Dream consolidation: +from automatic-extraction mutation and excluded from Dream consolidation: - Keep valid pinned documents readable to normal memory recall and discoverable by the existing indexer under its normal limits. -- Deny Dream `write_file` and `edit` operations when the requested path is - lexically below `pinned/`. +- Deny automatic extraction and forked Dream `write_file` and `edit` operations + when the requested path is lexically below `pinned/`. - Match the reserved top-level directory name case-insensitively so the deny-list cannot fail open on case-insensitive filesystems. - Also deny aliases that resolve through a symlink into `pinned/`. - Keep the existing read-only shell gate, which already rejects `rm` and every other mutating shell command. -- Teach the shared consolidation prompt to leave pinned documents out of - consolidation analysis and avoid intentionally removing their existing index - entries, subject to normal index limits. +- Teach the automatic extraction and Dream prompts to leave pinned documents + unchanged and avoid intentionally removing their existing index entries, + subject to normal index limits. The path check compares both literal and resolved paths case-insensitively. Literal containment protects `pinned/` even when that directory is itself a @@ -35,9 +36,10 @@ symlink. Resolved containment prevents a writable-looking path elsewhere in memory from symlinking back into `pinned/`. Protection is an explicit option on the existing memory-scoped agent -configuration and is enabled by the forked Dream planner. This covers scheduled -Dream and callers of the workspace-memory Dream endpoint. Extraction and -explicit remember operations retain their current behavior. +configuration and is enabled by the automatic extraction and forked Dream +planners. This covers post-session extraction, scheduled Dream, and callers of +the workspace-memory Dream endpoint. Explicit remember operations retain their +current behavior. ## Scope boundaries @@ -46,18 +48,25 @@ explicit remember operations retain their current behavior. - No new frontmatter field and no automatic creation of the directory. - No `/memory` UI indicator. - Explicit `/forget` requests keep their current behavior. +- This path-based boundary does not detect pre-existing hard-link aliases to + pinned files. Automatic memory workers cannot create them with `write_file` + or `edit`, and their read-only shell policy blocks `ln`; a stronger threat + model would require a separate inode-based policy. - The visible `/dream` slash-command turn receives the shared skip prompt rule, but does not gain a deterministic tool gate in this change. The slash command executes on the main Agent, which has no existing per-turn permission override; adding one would be a separate cross-surface permission design. - Forked Dream remains project-memory-only because its existing scoped configuration excludes the global user-memory root. +- Automatic extraction continues to cover both project and global user-memory + roots, so both top-level `pinned/` directories receive the same protection. ## Files affected - `packages/core/src/memory/paths.ts` - `packages/core/src/memory/memory-scoped-agent-config.ts` - `packages/core/src/memory/dreamAgentPlanner.ts` +- `packages/core/src/memory/extractionAgentPlanner.ts` - Collocated memory permission, prompt, and index tests - `docs/users/features/memory.md` diff --git a/docs/users/features/memory.md b/docs/users/features/memory.md index 5bed8324f10..60367902df7 100644 --- a/docs/users/features/memory.md +++ b/docs/users/features/memory.md @@ -97,8 +97,8 @@ Everything saved is plain markdown — you can open, edit, or delete any file at #### Pinned memory -Put hand-curated documents that Dream should preserve under `pinned/` in a -managed-memory directory, for example +Put hand-curated documents that automatic memory maintenance should preserve +under `pinned/` in a managed-memory directory, for example `~/.qwen/projects//memory/pinned/architecture.md` or `~/.qwen/memories/pinned/preferences.md`. Use the same frontmatter as other memory documents. Valid pinned files are readable by Qwen and are included the @@ -107,15 +107,16 @@ other memory documents. Only the top-level `pinned/` directory directly inside a managed-memory root is protected; nested directories such as `memory/project/pinned/` are ordinary -writable memory. Dream workers match the reserved directory name -case-insensitively. - -Dream is instructed to skip `pinned/` during consolidation. Forked Dream -workers, including background cleanup, additionally enforce that boundary on -their write and edit tools, including paths that resolve through a symlink into -`pinned/`; their existing read-only shell policy blocks command-line deletion. -You still control these files directly and can remove them with an explicit -`/forget` request. +writable memory. Automatic extraction and Dream workers match the reserved +directory name case-insensitively. + +Automatic extraction is instructed to leave pinned records and their valid +index entries unchanged, while Dream is instructed to skip `pinned/` during +consolidation. Both automatic extraction and forked Dream workers, including +background cleanup, enforce the pinned-file boundary on their write and edit +tools, including paths that resolve through a symlink into `pinned/`; their +existing read-only shell policy blocks command-line deletion. You still control +these files directly and can remove them with an explicit `/forget` request. > **Note:** The visible `/dream` slash command runs on the main Agent. It > receives the same skip instruction, but does not yet receive the forked diff --git a/packages/core/src/memory/dreamAgentPlanner.test.ts b/packages/core/src/memory/dreamAgentPlanner.test.ts index cdf406e5d7e..fa5c0c328a8 100644 --- a/packages/core/src/memory/dreamAgentPlanner.test.ts +++ b/packages/core/src/memory/dreamAgentPlanner.test.ts @@ -16,6 +16,7 @@ import type { ForkedAgentResult } from '../utils/forkedAgent.js'; import { runForkedAgent } from '../utils/forkedAgent.js'; import { escapeShellArg, getShellConfiguration } from '../utils/shell-utils.js'; import { + AUTO_MEMORY_PINNED_DIRNAME, getAutoMemoryRoot, getUserAutoMemoryRoot, clearAutoMemoryRootCache, @@ -199,7 +200,7 @@ describe('dreamAgentPlanner', () => { toolName: ToolNames.EDIT, filePath: path.join( getAutoMemoryRoot(projectRoot), - 'pinned', + AUTO_MEMORY_PINNED_DIRNAME, 'architecture.md', ), }), @@ -210,12 +211,14 @@ describe('dreamAgentPlanner', () => { filePath: path.join(getUserAutoMemoryRoot(), 'user', 'a.md'), }), ).resolves.toBe('deny'); + // Pinned protection applies to write/edit; shell deletion is blocked by + // the pre-existing read-only shell policy. await expect( pm.evaluate({ toolName: ToolNames.SHELL, command: `rm ${path.join( getAutoMemoryRoot(projectRoot), - 'pinned', + AUTO_MEMORY_PINNED_DIRNAME, 'architecture.md', )}`, }), diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index ae58ca2e609..fe27628cee9 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -8,7 +8,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js'; import { scanAutoMemoryTopicDocuments } from './scan.js'; -import { getAutoMemoryRoot, getUserAutoMemoryRoot } from './paths.js'; +import { + AUTO_MEMORY_PINNED_DIRNAME, + getAutoMemoryRoot, + getUserAutoMemoryRoot, +} from './paths.js'; import { runForkedAgent, getCacheSafeParams } from '../utils/forkedAgent.js'; import { ToolNames } from '../tools/tool-names.js'; @@ -185,6 +189,92 @@ describe('runAutoMemoryExtractionByAgent', () => { ).toBe('deny'); }); + it('protects pinned memory in both managed-memory scopes', async () => { + vi.mocked(runForkedAgent).mockResolvedValue({ + status: 'completed', + finalText: '', + filesTouched: [], + }); + + await runAutoMemoryExtractionByAgent(mockConfig, '/tmp'); + + const call = vi.mocked(runForkedAgent).mock.calls[0]?.[0]; + const permissionManager = call?.config.getPermissionManager?.(); + expect(permissionManager).toBeDefined(); + await expect( + permissionManager!.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: `/tmp/auto-memory/${AUTO_MEMORY_PINNED_DIRNAME}/architecture.md`, + }), + ).resolves.toBe('deny'); + await expect( + permissionManager!.evaluate({ + toolName: ToolNames.EDIT, + filePath: `/tmp/auto-memory/${AUTO_MEMORY_PINNED_DIRNAME}/architecture.md`, + }), + ).resolves.toBe('deny'); + await expect( + permissionManager!.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: `/tmp/user-memory/${AUTO_MEMORY_PINNED_DIRNAME}/preferences.md`, + }), + ).resolves.toBe('deny'); + await expect( + permissionManager!.evaluate({ + toolName: ToolNames.EDIT, + filePath: `/tmp/user-memory/${AUTO_MEMORY_PINNED_DIRNAME}/preferences.md`, + }), + ).resolves.toBe('deny'); + await expect( + permissionManager!.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: '/tmp/auto-memory/project/ordinary.md', + }), + ).resolves.toBe('allow'); + await expect( + permissionManager!.evaluate({ + toolName: ToolNames.EDIT, + filePath: '/tmp/user-memory/user/ordinary.md', + }), + ).resolves.toBe('allow'); + await expect( + permissionManager!.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: `/tmp/auto-memory/project/${AUTO_MEMORY_PINNED_DIRNAME}/notes.md`, + }), + ).resolves.toBe('allow'); + await expect( + permissionManager!.evaluate({ + toolName: ToolNames.EDIT, + filePath: `/tmp/auto-memory/${AUTO_MEMORY_PINNED_DIRNAME}-notes/notes.md`, + }), + ).resolves.toBe('allow'); + }); + + it('instructs the extraction agent to preserve pinned memory', async () => { + vi.mocked(runForkedAgent).mockResolvedValue({ + status: 'completed', + finalText: '', + filesTouched: [], + }); + + await runAutoMemoryExtractionByAgent(mockConfig, '/tmp'); + + const call = vi.mocked(runForkedAgent).mock.calls[0]?.[0]; + expect(call?.taskPrompt).toContain( + `top-level \`${AUTO_MEMORY_PINNED_DIRNAME}/\` directory`, + ); + expect(call?.taskPrompt).toContain( + 'You may read them to avoid duplicates, but never modify, overwrite, rename, merge into, or delete', + ); + expect(call?.taskPrompt).toContain( + 'Prefer updating an existing writable memory file', + ); + expect(call?.taskPrompt).toContain( + 'do not intentionally remove their valid entries from `MEMORY.md`', + ); + }); + it('throws when getCacheSafeParams returns null', async () => { vi.mocked(getCacheSafeParams).mockReturnValue(null); await expect( diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index ee1e156bfbb..f4c058d49a8 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -16,6 +16,7 @@ import { } from './prompt.js'; import { AUTO_MEMORY_INDEX_FILENAME, + AUTO_MEMORY_PINNED_DIRNAME, getAutoMemoryRoot, getUserAutoMemoryRoot, } from './paths.js'; @@ -156,7 +157,8 @@ function buildTaskPrompt( '- You have a limited turn budget. `edit` requires a prior `read_file` of the same file, so the efficient strategy is: first issue all reads in parallel for every file you might update; then issue all `write_file`/`edit` calls in parallel. Do not interleave reads and writes across multiple turns.', '- You MUST only use content from the recent conversation history in your context plus the current managed memory files.', '- Do not inspect repository code, git history, or unrelated files.', - '- Prefer updating an existing memory file over creating a duplicate. Check both directories for an existing entry before creating a new one.', + `- Treat files under the top-level \`${AUTO_MEMORY_PINNED_DIRNAME}/\` directory in either managed memory root as protected read-only records. You may read them to avoid duplicates, but never modify, overwrite, rename, merge into, or delete them, and do not intentionally remove their valid entries from \`${AUTO_MEMORY_INDEX_FILENAME}\`.`, + '- Prefer updating an existing writable memory file over creating a duplicate. Check both directories for an existing entry before creating a new one.', '- Keep one durable memory per file under `user/`, `feedback/`, `project/`, or `reference/` inside the chosen directory.', '', '## How to save memories', @@ -259,6 +261,7 @@ export async function runAutoMemoryExtractionByAgent( const userMemoryRoot = getUserAutoMemoryRoot(); const scopedConfig = createMemoryScopedAgentConfig(config, projectRoot, { allowShell: true, + protectPinnedMemory: true, }); const result = await runForkedAgent({ diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index f2a24b2300b..a1978aab330 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -16,6 +16,7 @@ import { isAllowedMemoryPath, } from './memory-scoped-agent-config.js'; import { + AUTO_MEMORY_PINNED_DIRNAME, clearAutoMemoryRootCache, getAutoMemoryRoot, getUserAutoMemoryRoot, @@ -113,11 +114,15 @@ describe('createMemoryScopedAgentConfig', () => { ).resolves.toBe('deny'); }); - it('protects pinned memory and aliases while leaving ordinary memory writable', async () => { + it('protects project pinned memory and aliases while leaving ordinary memory writable', async () => { const memoryRoot = getAutoMemoryRoot(projectRoot); - const pinnedDir = path.join(memoryRoot, 'pinned'); + const pinnedDir = path.join(memoryRoot, AUTO_MEMORY_PINNED_DIRNAME); const pinnedFile = path.join(pinnedDir, 'architecture.md'); - const pinnedAlias = path.join(memoryRoot, 'project', 'pinned-alias'); + const pinnedAlias = path.join( + memoryRoot, + 'project', + `${AUTO_MEMORY_PINNED_DIRNAME}-alias`, + ); await fs.mkdir(pinnedDir, { recursive: true }); await fs.writeFile(pinnedFile, 'canonical architecture'); await fs.symlink(pinnedDir, pinnedAlias); @@ -150,7 +155,11 @@ describe('createMemoryScopedAgentConfig', () => { await expect( protectedPm.evaluate({ toolName: ToolNames.WRITE_FILE, - filePath: path.join(memoryRoot, 'PINNED', 'architecture.md'), + filePath: path.join( + memoryRoot, + AUTO_MEMORY_PINNED_DIRNAME.toUpperCase(), + 'architecture.md', + ), }), ).resolves.toBe('deny'); await expect( @@ -168,13 +177,22 @@ describe('createMemoryScopedAgentConfig', () => { await expect( protectedPm.evaluate({ toolName: ToolNames.WRITE_FILE, - filePath: path.join(memoryRoot, 'pinned-notes', 'ordinary.md'), + filePath: path.join( + memoryRoot, + `${AUTO_MEMORY_PINNED_DIRNAME}-notes`, + 'ordinary.md', + ), }), ).resolves.toBe('allow'); await expect( protectedPm.evaluate({ toolName: ToolNames.WRITE_FILE, - filePath: path.join(memoryRoot, 'project', 'pinned', 'notes.md'), + filePath: path.join( + memoryRoot, + 'project', + AUTO_MEMORY_PINNED_DIRNAME, + 'notes.md', + ), }), ).resolves.toBe('allow'); expect( @@ -183,16 +201,24 @@ describe('createMemoryScopedAgentConfig', () => { filePath: pinnedFile, }), ).toBe('ManagedAutoMemory(edit: pinned memory is read-only)'); + expect( + protectedPm.findMatchingDenyRule({ + toolName: ToolNames.EDIT, + filePath: path.join(pinnedAlias, 'architecture.md'), + }), + ).toBe('ManagedAutoMemory(edit: pinned memory is read-only)'); expect( protectedPm.findMatchingDenyRule({ toolName: ToolNames.WRITE_FILE, filePath: path.join(pinnedDir, 'new.md'), }), ).toBe('ManagedAutoMemory(write_file: pinned memory is read-only)'); + }); + it('protects user pinned memory when user memory is included', async () => { const userPinnedFile = path.join( getUserAutoMemoryRoot(), - 'pinned', + AUTO_MEMORY_PINNED_DIRNAME, 'preferences.md', ); await fs.mkdir(path.dirname(userPinnedFile), { recursive: true }); @@ -214,7 +240,16 @@ describe('createMemoryScopedAgentConfig', () => { filePath: path.join(getUserAutoMemoryRoot(), 'user', 'ordinary.md'), }), ).resolves.toBe('allow'); + }); + it('leaves pinned memory writable when protection is disabled', async () => { + const pinnedFile = path.join( + getAutoMemoryRoot(projectRoot), + AUTO_MEMORY_PINNED_DIRNAME, + 'architecture.md', + ); + await fs.mkdir(path.dirname(pinnedFile), { recursive: true }); + await fs.writeFile(pinnedFile, 'canonical architecture'); const unprotectedPm = permissionManager( createMemoryScopedAgentConfig({} as Config, projectRoot, { includeUserMemory: false, @@ -232,7 +267,7 @@ describe('createMemoryScopedAgentConfig', () => { const memoryRoot = getAutoMemoryRoot(projectRoot); const targetDir = path.join(memoryRoot, 'project', 'shared'); const targetFile = path.join(targetDir, 'architecture.md'); - const pinnedDir = path.join(memoryRoot, 'pinned'); + const pinnedDir = path.join(memoryRoot, AUTO_MEMORY_PINNED_DIRNAME); await fs.mkdir(targetDir, { recursive: true }); await fs.writeFile(targetFile, 'canonical architecture'); await fs.symlink(targetDir, pinnedDir); @@ -258,8 +293,37 @@ describe('createMemoryScopedAgentConfig', () => { ).resolves.toBe('deny'); }); + it('reports the outside-root reason for a pinned symlink target outside memory', async () => { + const memoryRoot = getAutoMemoryRoot(projectRoot); + const outsideDir = path.join(tempDir, 'outside-pinned-target'); + const outsideFile = path.join(outsideDir, 'architecture.md'); + const pinnedDir = path.join(memoryRoot, AUTO_MEMORY_PINNED_DIRNAME); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(outsideFile, 'external architecture'); + await fs.symlink(outsideDir, pinnedDir); + + const pm = permissionManager( + createMemoryScopedAgentConfig({} as Config, projectRoot, { + includeUserMemory: false, + protectPinnedMemory: true, + }), + ); + const context = { + toolName: ToolNames.EDIT, + filePath: path.join(pinnedDir, 'architecture.md'), + }; + + await expect(pm.evaluate(context)).resolves.toBe('deny'); + expect(pm.findMatchingDenyRule(context)).toBe( + `ManagedAutoMemory(edit: only within ${memoryRoot})`, + ); + }); + it('protects paths below a dangling top-level pinned symlink', async () => { - const pinnedDir = path.join(getAutoMemoryRoot(projectRoot), 'pinned'); + const pinnedDir = path.join( + getAutoMemoryRoot(projectRoot), + AUTO_MEMORY_PINNED_DIRNAME, + ); await fs.symlink( path.join(tempDir, 'missing-pinned-target'), pinnedDir, diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index 57e1a3eef59..ca3784c3d4c 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -123,6 +123,9 @@ function createPinnedMemoryRoots( } return memoryRoots.map((memoryRoot) => { const literalPath = path.resolve(memoryRoot, AUTO_MEMORY_PINNED_DIRNAME); + // Snapshot the resolved root for this agent run. Literal containment still + // protects the reserved path if it is created later; retargeting symlinks + // during a run is outside the automatic worker's capabilities. return { literalPath, resolvedPath: realpathExistingOrNew(literalPath), @@ -133,7 +136,7 @@ function createPinnedMemoryRoots( function isProtectedPinnedMemoryPath( filePath: string | undefined, pinnedRoots: readonly PinnedMemoryRoot[], - resolvedCandidate = filePath ? realpathExistingOrNew(filePath) : undefined, + resolvedCandidate: string | undefined, ): boolean { if (!filePath) return false; const literalCandidate = path.resolve(filePath); @@ -230,6 +233,9 @@ function isWithinRoot(filePath: string, root: string): boolean { } function isWithinRootCaseInsensitive(filePath: string, root: string): boolean { + // Lowercase the complete paths so case variants cannot fail open on a + // case-insensitive filesystem. This is deliberately fail-closed, and + // String.prototype.toLowerCase is locale-independent. return isWithinRoot(filePath.toLowerCase(), root.toLowerCase()); } @@ -238,7 +244,6 @@ async function evaluateScopedDecision( projectRoot: string, opts: Required, pinnedRoots: readonly PinnedMemoryRoot[], - pinnedDecisionCache: WeakMap, ): Promise { switch (ctx.toolName) { case ToolNames.SHELL: { @@ -271,7 +276,6 @@ async function evaluateScopedDecision( pinnedRoots, resolvedCandidate, ); - pinnedDecisionCache.set(ctx, isPinned); if (isPinned) return 'deny'; return isAllowedResolvedMemoryPath(resolvedCandidate, projectRoot, { includeUserMemory: opts.includeUserMemory, @@ -289,7 +293,6 @@ function getScopedDenyRule( projectRoot: string, opts: Required, pinnedRoots: readonly PinnedMemoryRoot[], - pinnedDecisionCache: WeakMap, ): string | undefined { const allowedRoots = opts.includeUserMemory ? `${getUserAutoMemoryRoot()} or ${getAutoMemoryRoot(projectRoot)}` @@ -311,23 +314,28 @@ function getScopedDenyRule( `ManagedAutoMemory(list_directory: only within ` + `${allowedRoots})` ); case ToolNames.EDIT: + case ToolNames.WRITE_FILE: { + const resolvedCandidate = ctx.filePath + ? realpathExistingOrNew(ctx.filePath) + : undefined; + const isAllowed = isAllowedResolvedMemoryPath( + resolvedCandidate, + projectRoot, + { includeUserMemory: opts.includeUserMemory }, + ); if ( + isAllowed && opts.protectPinnedMemory && - (pinnedDecisionCache.get(ctx) ?? - isProtectedPinnedMemoryPath(ctx.filePath, pinnedRoots)) - ) { - return 'ManagedAutoMemory(edit: pinned memory is read-only)'; - } - return `ManagedAutoMemory(edit: only within ${allowedRoots})`; - case ToolNames.WRITE_FILE: - if ( - opts.protectPinnedMemory && - (pinnedDecisionCache.get(ctx) ?? - isProtectedPinnedMemoryPath(ctx.filePath, pinnedRoots)) + isProtectedPinnedMemoryPath( + ctx.filePath, + pinnedRoots, + resolvedCandidate, + ) ) { - return 'ManagedAutoMemory(write_file: pinned memory is read-only)'; + return `ManagedAutoMemory(${ctx.toolName}: pinned memory is read-only)`; } - return `ManagedAutoMemory(write_file: only within ${allowedRoots})`; + return `ManagedAutoMemory(${ctx.toolName}: only within ${allowedRoots})`; + } default: return undefined; } @@ -348,7 +356,6 @@ export function createMemoryScopedAgentConfig( const pinnedRoots = opts.protectPinnedMemory ? createPinnedMemoryRoots(projectRoot, opts.includeUserMemory) : []; - const pinnedDecisionCache = new WeakMap(); const basePm = config.getPermissionManager?.(); const scopedPm: MemoryScopedPermissionManager = { hasRelevantRules(ctx: PermissionCheckContext): boolean { @@ -360,13 +367,7 @@ export function createMemoryScopedAgentConfig( return basePm?.hasMatchingAskRule(ctx) ?? false; }, findMatchingDenyRule(ctx: PermissionCheckContext): string | undefined { - const scoped = getScopedDenyRule( - ctx, - projectRoot, - opts, - pinnedRoots, - pinnedDecisionCache, - ); + const scoped = getScopedDenyRule(ctx, projectRoot, opts, pinnedRoots); if (scoped) { return scoped; } @@ -378,7 +379,6 @@ export function createMemoryScopedAgentConfig( projectRoot, opts, pinnedRoots, - pinnedDecisionCache, ); if (!basePm) { return scopedDecision;