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..c3444a35e2e --- /dev/null +++ b/docs/design/2026-07-25-pinned-memory-protection.md @@ -0,0 +1,78 @@ +# 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. 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 automated memory +maintenance. + +## Chosen design + +Treat a top-level `pinned/` directory inside a managed-memory root as protected +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 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 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 +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 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 + +- 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. +- 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` + +## 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..60367902df7 100644 --- a/docs/users/features/memory.md +++ b/docs/users/features/memory.md @@ -95,6 +95,33 @@ 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 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 +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. 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 +> 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..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, @@ -111,6 +112,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 +195,34 @@ describe('dreamAgentPlanner', () => { filePath: path.join(getAutoMemoryRoot(projectRoot), 'project.md'), }), ).resolves.toBe('allow'); + await expect( + pm.evaluate({ + toolName: ToolNames.EDIT, + filePath: path.join( + getAutoMemoryRoot(projectRoot), + AUTO_MEMORY_PINNED_DIRNAME, + 'architecture.md', + ), + }), + ).resolves.toBe('deny'); await expect( pm.evaluate({ toolName: ToolNames.WRITE_FILE, 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), + AUTO_MEMORY_PINNED_DIRNAME, + '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/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/indexer.test.ts b/packages/core/src/memory/indexer.test.ts index 6474590b902..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,6 +106,32 @@ describe('managed auto-memory indexer', () => { expect(index).toContain('The repo uses pnpm workspaces.'); }); + it('keeps a valid pinned document in the generated index', async () => { + const pinnedFile = getAutoMemoryFilePath( + projectRoot, + path.join(AUTO_MEMORY_PINNED_DIRNAME, '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..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,6 +114,239 @@ describe('createMemoryScopedAgentConfig', () => { ).resolves.toBe('deny'); }); + it('protects project pinned memory and aliases while leaving ordinary memory writable', async () => { + const memoryRoot = getAutoMemoryRoot(projectRoot); + const pinnedDir = path.join(memoryRoot, AUTO_MEMORY_PINNED_DIRNAME); + const pinnedFile = path.join(pinnedDir, 'architecture.md'); + 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); + + 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.WRITE_FILE, + filePath: path.join( + memoryRoot, + AUTO_MEMORY_PINNED_DIRNAME.toUpperCase(), + 'architecture.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, + `${AUTO_MEMORY_PINNED_DIRNAME}-notes`, + 'ordinary.md', + ), + }), + ).resolves.toBe('allow'); + await expect( + protectedPm.evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: path.join( + memoryRoot, + 'project', + AUTO_MEMORY_PINNED_DIRNAME, + 'notes.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.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(), + AUTO_MEMORY_PINNED_DIRNAME, + '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'); + }); + + 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, + }), + ); + 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, AUTO_MEMORY_PINNED_DIRNAME); + 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('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), + AUTO_MEMORY_PINNED_DIRNAME, + ); + 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 a778b349240..ca3784c3d4c 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,9 +35,15 @@ export interface MemoryScopedAgentConfigOptions { allowShell?: boolean; bypassBaseAskForScopedPaths?: boolean; includeUserMemory?: boolean; + protectPinnedMemory?: boolean; restrictReadsToMemoryPaths?: boolean; } +interface PinnedMemoryRoot { + literalPath: string; + resolvedPath: string | undefined; +} + function isScopedTool( toolName: string, opts: Required, @@ -81,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), @@ -90,8 +110,46 @@ 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 createPinnedMemoryRoots( + projectRoot: string, + includeUserMemory: boolean, +): PinnedMemoryRoot[] { + const memoryRoots = [getAutoMemoryRoot(projectRoot)]; + if (includeUserMemory) { + memoryRoots.push(getUserAutoMemoryRoot()); + } + 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), + }; + }); +} + +function isProtectedPinnedMemoryPath( + filePath: string | undefined, + pinnedRoots: readonly PinnedMemoryRoot[], + resolvedCandidate: string | undefined, +): boolean { + if (!filePath) return false; + const literalCandidate = path.resolve(filePath); + return pinnedRoots.some((pinnedRoot) => { + if (isWithinRootCaseInsensitive(literalCandidate, pinnedRoot.literalPath)) { + return true; + } + return ( + !!resolvedCandidate && + !!pinnedRoot.resolvedPath && + isWithinRootCaseInsensitive(resolvedCandidate, pinnedRoot.resolvedPath) + ); + }); } function realpathExistingOrNew(filePath: string): string | undefined { @@ -174,10 +232,18 @@ 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()); +} + async function evaluateScopedDecision( ctx: PermissionCheckContext, projectRoot: string, opts: Required, + pinnedRoots: readonly PinnedMemoryRoot[], ): Promise { switch (ctx.toolName) { case ToolNames.SHELL: { @@ -199,12 +265,24 @@ async function evaluateScopedDecision( ? 'allow' : 'deny'; case ToolNames.EDIT: - case ToolNames.WRITE_FILE: - return isAllowedMemoryPath(ctx.filePath, projectRoot, { + case ToolNames.WRITE_FILE: { + const resolvedCandidate = ctx.filePath + ? realpathExistingOrNew(ctx.filePath) + : undefined; + const isPinned = + opts.protectPinnedMemory && + isProtectedPinnedMemoryPath( + ctx.filePath, + pinnedRoots, + resolvedCandidate, + ); + if (isPinned) return 'deny'; + return isAllowedResolvedMemoryPath(resolvedCandidate, projectRoot, { includeUserMemory: opts.includeUserMemory, }) ? 'allow' : 'deny'; + } default: return 'default'; } @@ -214,6 +292,7 @@ function getScopedDenyRule( ctx: PermissionCheckContext, projectRoot: string, opts: Required, + pinnedRoots: readonly PinnedMemoryRoot[], ): string | undefined { const allowedRoots = opts.includeUserMemory ? `${getUserAutoMemoryRoot()} or ${getAutoMemoryRoot(projectRoot)}` @@ -235,9 +314,28 @@ function getScopedDenyRule( `ManagedAutoMemory(list_directory: only within ` + `${allowedRoots})` ); case ToolNames.EDIT: - return `ManagedAutoMemory(edit: only within ${allowedRoots})`; - case ToolNames.WRITE_FILE: - return `ManagedAutoMemory(write_file: only within ${allowedRoots})`; + case ToolNames.WRITE_FILE: { + const resolvedCandidate = ctx.filePath + ? realpathExistingOrNew(ctx.filePath) + : undefined; + const isAllowed = isAllowedResolvedMemoryPath( + resolvedCandidate, + projectRoot, + { includeUserMemory: opts.includeUserMemory }, + ); + if ( + isAllowed && + opts.protectPinnedMemory && + isProtectedPinnedMemoryPath( + ctx.filePath, + pinnedRoots, + resolvedCandidate, + ) + ) { + return `ManagedAutoMemory(${ctx.toolName}: pinned memory is read-only)`; + } + return `ManagedAutoMemory(${ctx.toolName}: only within ${allowedRoots})`; + } default: return undefined; } @@ -252,8 +350,12 @@ export function createMemoryScopedAgentConfig( allowShell: options.allowShell ?? false, bypassBaseAskForScopedPaths: options.bypassBaseAskForScopedPaths ?? false, includeUserMemory: options.includeUserMemory ?? true, + protectPinnedMemory: options.protectPinnedMemory ?? false, restrictReadsToMemoryPaths: options.restrictReadsToMemoryPaths ?? false, }; + const pinnedRoots = opts.protectPinnedMemory + ? createPinnedMemoryRoots(projectRoot, opts.includeUserMemory) + : []; const basePm = config.getPermissionManager?.(); const scopedPm: MemoryScopedPermissionManager = { hasRelevantRules(ctx: PermissionCheckContext): boolean { @@ -265,7 +367,7 @@ 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); if (scoped) { return scoped; } @@ -276,6 +378,7 @@ export function createMemoryScopedAgentConfig( ctx, projectRoot, opts, + pinnedRoots, ); if (!basePm) { return scopedDecision; 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';