From d71eb967f8f60ffbe15c6cd4f7c53bdd6bcbaa60 Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Mon, 26 Oct 2026 12:32:37 +0100 Subject: [PATCH 1/2] Normalize HOME in code-mode paths, so the guard sees a new hint folder guardedDirs built its folders from HOME as the environment gives it. With HOME=/home/u/ the hint folder was /home/u//.claude/code-mode/hints, and isUnder needs a normalized folder. The guard also checks the real path of each folder, but a folder that does not exist yet has none. So the first Write into a new hint or function folder got past the guard. A file written this way still did not load without an approval in the ledger. Move the loop that normalizes a path from resolvePath to normalizePath in paths.ts. codeModePath and guardedDirs use it, so the loaders and the guard name the same normalized folders for HOME and the project root. Raise the version to 0.12.17. A person whose HOME is not normalized must approve their user hints and saved functions again one time: the ledger key is the hash of the path. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/plugin.json | 2 +- hooks/hints.ts | 16 +++++----------- hooks/paths.ts | 33 ++++++++++++++++++++++++++------- tests/hints.test.ts | 38 +++++++++++++++++++++++++++++++++++--- 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 77df2b6..68c9c0d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-mode", - "version": "0.12.16", + "version": "0.12.17", "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/hooks/hints.ts b/hooks/hints.ts index 3cfd79e..51db563 100644 --- a/hooks/hints.ts +++ b/hooks/hints.ts @@ -13,6 +13,7 @@ // its body is that one hint. A change proposal is a removal with the new // text of the hint in `replace`. +import { normalizePath } from './paths' import { splitToolName } from './protocol' export type HintScope = 'bundled' | 'user' | 'project' @@ -491,17 +492,10 @@ export const staleDecisionKeys = (keys: readonly string[], key: string, max = MA } /** An absolute, normalized path: `~` is home, a relative path is under cwd, `.` and `..` are resolved. */ -export const resolvePath = (path: string, home: string, cwd: string): string => { - const full = - path === '~' || path.startsWith('~/') ? home + path.slice(1) : path.startsWith('/') ? path : `${cwd}/${path}` - const parts: string[] = [] - for (const part of full.split('/')) { - if (part === '' || part === '.') continue - if (part === '..') parts.pop() - else parts.push(part) - } - return `/${parts.join('/')}` -} +export const resolvePath = (path: string, home: string, cwd: string): string => + normalizePath( + path === '~' || path.startsWith('~/') ? home + path.slice(1) : path.startsWith('/') ? path : `${cwd}/${path}`, + ) /** True when `path` is `dir` or inside it (both absolute and normalized). */ export const isUnder = (path: string, dir: string): boolean => diff --git a/hooks/paths.ts b/hooks/paths.ts index 87670f4..8d28a19 100644 --- a/hooks/paths.ts +++ b/hooks/paths.ts @@ -7,8 +7,23 @@ const APP = 'code-mode' const HINTS = 'hints' const FUNCTIONS = 'functions' -/** A path in code-mode's folder under `root`: HOME or the project root. */ -export const codeModePath = (root: string, ...parts: string[]): string => [root, '.claude', APP, ...parts].join('/') +/** An absolute path with empty, `.` and `..` parts resolved. */ +export const normalizePath = (path: string): string => { + const parts: string[] = [] + for (const part of path.split('/')) { + if (part === '' || part === '.') continue + if (part === '..') parts.pop() + else parts.push(part) + } + return `/${parts.join('/')}` +} + +/** + * A path in code-mode's folder under `root`: HOME or the project root. It is + * normalized, as isUnder needs: HOME can end in a slash or hold a double one. + */ +export const codeModePath = (root: string, ...parts: string[]): string => + normalizePath([root, '.claude', APP, ...parts].join('/')) /** The hint folder under HOME or the project root. */ export const hintsFolder = (root: string): string => codeModePath(root, HINTS) @@ -25,8 +40,12 @@ export const functionsFolder = (home: string): string => codeModePath(home, FUNC * where Claude Code installed it, under HOME. A folder loaded with --plugin-dir * is the person's own checkout, which they edit. */ -export const guardedDirs = (home: string, project: string, pluginRoot = ''): string[] => [ - ...(home ? [hintsFolder(home), functionsFolder(home)] : []), - ...(project ? [hintsFolder(project)] : []), - ...(home && pluginRoot.startsWith(`${home}/.claude/plugins/`) ? [pluginRoot] : []), -] +export const guardedDirs = (home: string, project: string, pluginRoot = ''): string[] => { + const plugin = normalizePath(pluginRoot) + const plugins = normalizePath(`${home}/.claude/plugins`) + return [ + ...(home ? [hintsFolder(home), functionsFolder(home)] : []), + ...(project ? [hintsFolder(project)] : []), + ...(home && plugin.startsWith(`${plugins}/`) ? [plugin] : []), + ] +} diff --git a/tests/hints.test.ts b/tests/hints.test.ts index 046a20d..4221ad9 100644 --- a/tests/hints.test.ts +++ b/tests/hints.test.ts @@ -266,6 +266,12 @@ describe('hints in the tools', () => { expect(text).toContain('Mail hint body.') }) + test('a user hint loads when HOME ends in a slash', async ($, on) => { + const { env } = await approvedHome(on, hintFile, { [MAIL]: 'claude.ai Mail' }) + env.HOME = `${HOME}/` + expect(textOf(await $.tool.call({ tool: TOOL.search_tools, query: 'send' }))).toContain('Mail hint body.') + }) + test('search_tools finds hints by identify when the server is named by UUID', async ($, on) => { await approvedHome( on, @@ -1526,9 +1532,9 @@ describe('the hint-file guard', () => { expect(isUnder('/a/bc', '/a/b')).toBe(false) }) - const session = (on: On) => { - fakeHome(on, {}) - on('session.root', () => ({ value: '/repo' })) + const session = (on: On, home = HOME, root = '/repo') => { + fakeHome(on, {}).env.HOME = home + on('session.root', () => ({ value: root })) on('session.cwd', () => ({ value: '/repo' })) // stands for the file tools: a call that gets past the guard is written on('tool.call', { tool: 'Write' }, () => ({ result: 'written' })) @@ -1571,6 +1577,32 @@ describe('the hint-file guard', () => { }) } + // HOME and the project root as the environment gives them, with a trailing + // or double slash; the folders do not exist yet, so no real path helps. + for (const home of ['/home/u/', '/', '/home//u']) { + test(`guards the folders when HOME is ${home}`, async ($, on) => { + session(on, home, '/repo/') + for (const file_path of [ + '~/.claude/code-mode/hints/x.md', + '~/.claude/code-mode/functions/x.md', + '/repo/.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('guardedDirs normalizes its roots', () => { + const installed = '/home/u/.claude/plugins/cache/code-mode/1.0.0' + expect(guardedDirs('/home//u/', '/repo/', `${installed}/`)).toEqual([ + '/home/u/.claude/code-mode/hints', + '/home/u/.claude/code-mode/functions', + '/repo/.claude/code-mode/hints', + installed, + ]) + }) + 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 545629d51c4dfbdd4f37ba7289427232ec2d2014 Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Mon, 26 Oct 2026 14:20:15 +0100 Subject: [PATCH 2/2] Give resolvePath back its full path step, so it reads as before resolvePath built the full path in a nested ternary inside the call to normalizePath. Put the full path in a constant again, as before the change to normalizePath, and give it to normalizePath. The behavior does not change. Co-Authored-By: Claude Opus 5.5 --- hooks/hints.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hooks/hints.ts b/hooks/hints.ts index 51db563..0c7b676 100644 --- a/hooks/hints.ts +++ b/hooks/hints.ts @@ -492,10 +492,11 @@ export const staleDecisionKeys = (keys: readonly string[], key: string, max = MA } /** An absolute, normalized path: `~` is home, a relative path is under cwd, `.` and `..` are resolved. */ -export const resolvePath = (path: string, home: string, cwd: string): string => - normalizePath( - path === '~' || path.startsWith('~/') ? home + path.slice(1) : path.startsWith('/') ? path : `${cwd}/${path}`, - ) +export const resolvePath = (path: string, home: string, cwd: string): string => { + const full = + path === '~' || path.startsWith('~/') ? home + path.slice(1) : path.startsWith('/') ? path : `${cwd}/${path}` + return normalizePath(full) +} /** True when `path` is `dir` or inside it (both absolute and normalized). */ export const isUnder = (path: string, dir: string): boolean =>