diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5e4a8d7..25969ca 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-mode", - "version": "0.12.18", + "version": "0.12.19", "description": "Code mode for MCP: the model writes one JavaScript program that calls MCP tools in a sandbox, instead of one tool call per step.", "types": "./types/index.d.ts", "author": { diff --git a/docs/hints.mdx b/docs/hints.mdx index c369ef3..4bed96e 100644 --- a/docs/hints.mdx +++ b/docs/hints.mdx @@ -227,9 +227,11 @@ model does with your MCP tools. These rules make a bad hint less probable: Bash writes waits in the band. - **The model cannot write hint files with its file tools.** A guard in the plugin denies Write, Edit and NotebookEdit on files in the hint folders, so the model gets an error at once. It checks the path as written and - its real path through symbolic links, in any letter case: a case-insensitive volume, the default on macOS, opens - `~/.claude/Code-Mode/hints` as the hint folder. The guard also covers the plugin folder when Claude Code installed - it under `~/.claude/plugins/`. A folder that you load with `--plugin-dir` is your own copy, and the guard does not cover it. + in real form: the real path of the nearest folder that exists, through symbolic links, then the rest of the path, + in any letter case. A case-insensitive volume, the default on macOS, opens `~/.claude/Code-Mode/hints` as the hint + folder. The hint folders get the same check, so a link in HOME or in a path to a new folder does not change the result. + The guard also covers the plugin folder when Claude Code installed it under + `~/.claude/plugins/`. A folder that you load with `--plugin-dir` is your own copy, and the guard does not cover it. - **Each hint shows its source file.** These rules are not a full security boundary. The hashes are in a JSON file in `~/.claude/plugins/store/`. A diff --git a/hooks/register.tsx b/hooks/register.tsx index f40be9e..aaae1dd 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -120,7 +120,7 @@ import { usedFunctions, type SavedFunction, } from './functions' -import { codeModePath, functionsFolder, guardedDirs, hintsFolder } from './paths' +import { codeModePath, functionsFolder, guardedDirs, hintsFolder, normalizePath } from './paths' import { SEARCH_LIMIT, TOOL, TOOL_NAMES, parseInput, registration } from './tools' const MAX_RESULT_CHARS = 20_000 @@ -791,10 +791,10 @@ function decidePending( } // The folders whose files steer the model (guardedDirs in paths.ts), each as -// written and as its real path. +// written and in real form: HOME or a root can be a symbolic link. async function guardedHintDirs($: EngineInterface, home: string): Promise { const dirs = guardedDirs(home, await $.session.root(), $.plugin.root) - const real = await Promise.all(dirs.map(d => realPathOf($, d))) + const real = await Promise.all(dirs.map(d => realFormOf($, d))) return [...dirs, ...real.filter((r): r is string => typeof r === 'string')] } @@ -805,12 +805,28 @@ function realPathOf($: EngineInterface, path: string): Promise { + for (let head = path; ; head = head.slice(0, head.lastIndexOf('/')) || '/') { + const real = await realPathOf($, head) + if (real !== undefined) return normalizePath(`${real}/${path.slice(head.length)}`) + if (head === '/') return undefined + } +} + +// Where an absolute path can land: as written, in real form, or under its +// parent's real form. The file itself can be a symbolic link out of the folder, +// and the write still names a file in that folder. async function landings($: EngineInterface, full: string): Promise { const parent = full.slice(0, full.lastIndexOf('/')) || '/' - const [self, dir] = await Promise.all([realPathOf($, full), realPathOf($, parent)]) - const candidates = [full, self, dir === undefined ? undefined : `${dir}/${full.slice(parent.length + 1)}`] + const [self, dir] = await Promise.all([realFormOf($, full), realFormOf($, parent)]) + const candidates = [ + full, + self, + dir === undefined ? undefined : normalizePath(`${dir}/${full.slice(parent.length + 1)}`), + ] return candidates.filter((c): c is string => typeof c === 'string') } diff --git a/tests/hints.test.ts b/tests/hints.test.ts index 5eb07ca..22124c2 100644 --- a/tests/hints.test.ts +++ b/tests/hints.test.ts @@ -1661,6 +1661,82 @@ describe('the hint-file guard', () => { ]) }) + // Folders and files on disk and symbolic links (path -> target), for fs.stat: + // a path resolves when each link on it is followed and the end exists. + const disk = (on: On, folders: readonly string[], links: Record, files: readonly string[] = []) => + on('fs.stat', ($, e) => { + let real = e.path + for (let changed = true; changed; ) { + changed = false + for (const [link, target] of Object.entries(links)) + if (real === link || real.startsWith(`${link}/`)) { + real = target + real.slice(link.length) + changed = true + } + } + const kind = folders.includes(real) ? 'dir' : files.includes(real) ? 'file' : undefined + if (kind === undefined) return { deny: `ENOENT ${e.path}` } + return { value: { kind, size: 0, mtimeMs: 0, isLink: real !== e.path, realPath: real } } + }) + + // A link to a folder that exists, and missing folders after it: the write + // makes them under the real folder, so the guard resolves that folder. + test('guards a path through a link whose next folders do not exist yet', async ($, on) => { + session(on) + disk(on, ['/', '/repo', '/home', HOME, `${HOME}/.claude`, `${HOME}/.claude/code-mode`], { + '/repo/cm': `${HOME}/.claude/code-mode`, + '/repo/up': HOME, + }) + for (const file_path of [ + 'cm/hints/x.md', + 'cm/hints/a/b/x.md', + 'cm/functions/x.md', + 'up/.claude/code-mode/hints/x.md', + ]) + expect(textOf(await $.tool.call({ tool: 'Write', file_path, content: 'x' }))).toContain( + 'hint files and saved functions steer the model', + ) + }) + + // A hint file that is itself a link out of the folder, reached by a linked + // folder: the write names a file in the hint folder, so the guard keeps the + // real form of the parent folder. + test('guards a hint file that is a link, through a linked folder', async ($, on) => { + session(on) + const hints = `${HOME}/.claude/code-mode/hints` + disk( + on, + ['/', '/repo', '/tmp', '/home', HOME, `${HOME}/.claude`, `${HOME}/.claude/code-mode`, hints], + { '/repo/cm': `${HOME}/.claude/code-mode`, [`${hints}/x.md`]: '/tmp/x.md' }, + ['/tmp/x.md'], + ) + expect(textOf(await $.tool.call({ tool: 'Write', file_path: 'cm/hints/x.md', content: 'x' }))).toContain( + 'hint files and saved functions steer the model', + ) + }) + + test('lets a path through a link to another folder', async ($, on) => { + session(on) + disk(on, ['/', '/repo', '/tmp', '/tmp/out'], { '/repo/out': '/tmp/out' }) + expect(textOf(await $.tool.call({ tool: 'Write', file_path: 'out/a/b/x.md', content: 'x' }))).toBe('written') + }) + + // HOME is a link and the hint folder does not exist yet: the guarded folder + // also has a real form, so a path by HOME's real path is guarded. + test('guards the folders by their real path when HOME is a link', async ($, on) => { + session(on, '/links/home') + disk(on, ['/', '/home', HOME, `${HOME}/.claude`], { '/links/home': HOME }) + for (const file_path of [ + `${HOME}/.claude/code-mode/hints/x.md`, + `${HOME}/.claude/code-mode/functions/x.md`, + '/links/home/.claude/code-mode/hints/x.md', + ]) + expect(textOf(await $.tool.call({ tool: 'Write', file_path, content: 'x' }))).toContain( + 'hint files and saved functions steer the model', + ) + expect(textOf(await $.tool.call({ tool: 'Write', file_path: `${HOME}/notes/x.md`, content: 'x' }))).toBe('written') + }) + test('lets other files through', async ($, on) => { session(on) expect(textOf(await $.tool.call({ tool: 'Write', file_path: '/repo/src/a.ts', content: 'x' }))).toBe('written')