From 319ba53c7d8f763e62185f432d68b014ec2524ea Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Tue, 27 Oct 2026 08:50:04 +0100 Subject: [PATCH 1/3] Resolve the nearest existing folder in the hint guard A write makes the missing folders of its path, so the folder that decides where it lands is the nearest one that exists. The guard now takes the real path of that folder, then adds the rest of the path. It does this for the path of the call and for each guarded folder, so a symbolic link in HOME, in the project or in a path to a new folder gives the same result. Version 0.12.18. --- .claude-plugin/plugin.json | 2 +- docs/hints.mdx | 4 ++- hooks/register.tsx | 26 +++++++++++------ tests/hints.test.ts | 58 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 68c9c0d..5e4a8d7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-mode", - "version": "0.12.17", + "version": "0.12.18", "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 2659128..e0483ce 100644 --- a/docs/hints.mdx +++ b/docs/hints.mdx @@ -227,7 +227,9 @@ 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. The guard also covers the plugin folder when Claude Code installed it under + in real form: the real path of the nearest folder that exists, through symbolic links, then the rest of the path. + 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.** diff --git a/hooks/register.tsx b/hooks/register.tsx index 1a39d1e..49f7965 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -119,7 +119,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 @@ -790,10 +790,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')] } @@ -804,13 +804,21 @@ 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, or in real form. 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)}`] - return candidates.filter((c): c is string => typeof c === 'string') + const real = await realFormOf($, full) + return real === undefined ? [full] : [full, real] } // True when a file tool's path lands in a hint folder. diff --git a/tests/hints.test.ts b/tests/hints.test.ts index 4221ad9..7078479 100644 --- a/tests/hints.test.ts +++ b/tests/hints.test.ts @@ -1603,6 +1603,64 @@ describe('the hint-file guard', () => { ]) }) + // Folders on disk and symbolic links (path -> target), for fs.stat: a path + // resolves when each link on it is followed and the end is a folder. + const disk = (on: On, folders: readonly string[], links: Record) => + 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 + } + } + if (!folders.includes(real)) return { deny: `ENOENT ${e.path}` } + return { value: { kind: 'dir', 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', + ) + }) + + 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') From 280c111d5c12ef3796c8ca05b86b30801f62c113 Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Tue, 27 Oct 2026 10:22:55 +0100 Subject: [PATCH 2/3] Keep the resolved parent in the hint guard A hint file can itself be a symbolic link out of its folder. A write by a linked folder to that file then names a file in the hint folder, but the real form of the file is outside it. The guard now keeps the real form of the parent folder with the file name as one more place where the path can land, as it did before the nearest folder step. It also keeps the path as written and the real form of the file. Gabriele Pavanello gabry.pavanello@gmail.com --- hooks/register.tsx | 14 +++++++++++--- tests/hints.test.ts | 28 +++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/hooks/register.tsx b/hooks/register.tsx index 49f7965..928ba7d 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -815,10 +815,18 @@ async function realFormOf($: EngineInterface, path: string): Promise { - const real = await realFormOf($, full) - return real === undefined ? [full] : [full, real] + const parent = full.slice(0, full.lastIndexOf('/')) || '/' + 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') } // True when a file tool's path lands in a hint folder. diff --git a/tests/hints.test.ts b/tests/hints.test.ts index 7078479..3a84128 100644 --- a/tests/hints.test.ts +++ b/tests/hints.test.ts @@ -1603,9 +1603,9 @@ describe('the hint-file guard', () => { ]) }) - // Folders on disk and symbolic links (path -> target), for fs.stat: a path - // resolves when each link on it is followed and the end is a folder. - const disk = (on: On, folders: readonly string[], links: Record) => + // 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; ) { @@ -1616,8 +1616,9 @@ describe('the hint-file guard', () => { changed = true } } - if (!folders.includes(real)) return { deny: `ENOENT ${e.path}` } - return { value: { kind: 'dir', size: 0, mtimeMs: 0, isLink: real !== e.path, realPath: real } } + 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 @@ -1639,6 +1640,23 @@ describe('the hint-file guard', () => { ) }) + // 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' }) From 07e8359421c9bca3995890c3b7739ce6270cfc36 Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Tue, 27 Oct 2026 10:32:09 +0100 Subject: [PATCH 3/3] Version 0.12.19 --- .claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": {