From 0bed1fc9457a82e647d028aae2415725dcb2ed7b Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Mon, 26 Oct 2026 18:26:35 +0100 Subject: [PATCH 1/3] Guard hint folders in any letter case A case-insensitive volume, the default on macOS, opens ~/.claude/Code-Mode/hints as the hint folder. The file-tool guard compared paths as text, so a path in other letter case got past it. The guard now compares with isUnderAnyCase, which folds letter case and Unicode form. Other callers of isUnder give access, so they keep the exact comparison. Closes #75 --- .claude-plugin/plugin.json | 2 +- docs/hints.mdx | 5 +++-- hooks/hints.ts | 12 +++++++++++ hooks/register.tsx | 6 ++++-- tests/hints.test.ts | 41 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 5 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/docs/hints.mdx b/docs/hints.mdx index 2659128..c369ef3 100644 --- a/docs/hints.mdx +++ b/docs/hints.mdx @@ -227,8 +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 - `~/.claude/plugins/`. A folder that you load with `--plugin-dir` is your own copy, and the guard does not cover it. + 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. - **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/hints.ts b/hooks/hints.ts index 3cfd79e..60d3e4f 100644 --- a/hooks/hints.ts +++ b/hooks/hints.ts @@ -507,6 +507,18 @@ export const resolvePath = (path: string, home: string, cwd: string): string => export const isUnder = (path: string, dir: string): boolean => path === dir || path.startsWith(`${dir.replace(/\/$/, '')}/`) +// A path as a case-insensitive volume (the macOS default) compares it: one +// letter case, and one Unicode form, as APFS also ignores the form of a name. +const foldCase = (path: string): string => path.normalize('NFD').toUpperCase().toLowerCase().normalize('NFD') + +/** + * isUnder for any letter case: on a case-insensitive volume, `~/.claude/Code-Mode/hints` + * is the hint folder. Only the guard uses it. On a case-sensitive volume, it + * also matches another folder with that name in other case, so it denies too + * much, never too little. + */ +export const isUnderAnyCase = (path: string, dir: string): boolean => isUnder(foldCase(path), foldCase(dir)) + /** True for a server named by UUID, as the desktop app names claude.ai connectors: no name to read. */ export const isUuidLike = (server: string): boolean => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(server) diff --git a/hooks/register.tsx b/hooks/register.tsx index 1a39d1e..f40be9e 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -31,6 +31,7 @@ import { hintApplies, isApprovalKey, isUnder, + isUnderAnyCase, isUuidLike, kindQuestion, newHintFlags, @@ -813,14 +814,15 @@ async function landings($: EngineInterface, full: string): Promise { return candidates.filter((c): c is string => typeof c === 'string') } -// True when a file tool's path lands in a hint folder. +// True when a file tool's path lands in a hint folder, in any letter case +// (isUnderAnyCase): a case-insensitive volume opens the folder for each. async function touchesHints($: EngineInterface, filePath: string): Promise { const [home, cwd] = await Promise.all([homeOf($), $.session.cwd()]) const [candidates, dirs] = await Promise.all([ landings($, resolvePath(filePath, home, cwd)), guardedHintDirs($, home), ]) - return candidates.some(c => dirs.some(d => isUnder(c, d))) + return candidates.some(c => dirs.some(d => isUnderAnyCase(c, d))) } // What code-mode saw in the session that made a proposal: the runs with a diff --git a/tests/hints.test.ts b/tests/hints.test.ts index 046a20d..163488b 100644 --- a/tests/hints.test.ts +++ b/tests/hints.test.ts @@ -17,6 +17,7 @@ import { hintItems, hintRef, isUnder, + isUnderAnyCase, parseDecision, parseHint, pendingFileName, @@ -1544,6 +1545,8 @@ describe('the hint-file guard', () => { ['Edit', { file_path: '/repo/src/../.claude/code-mode/hints/a.md', old_string: 'a', new_string: 'b' }], ['Write', { file_path: '~/.claude/code-mode/functions/searchAll.md', content: 'x' }], ['Write', { file_path: `${HOME}/.claude/code-mode/functions/pending/x--a.md`, content: 'x' }], + ['Write', { file_path: '~/.claude/Code-Mode/hints/x.md', content: 'x' }], + ['Edit', { file_path: '.CLAUDE/code-mode/hints/a.md', old_string: 'a', new_string: 'b' }], ] as const for (const [tool, input] of denied) { test(`denies ${tool} ${JSON.stringify(input).slice(0, 60)}`, async ($, on) => { @@ -1571,6 +1574,44 @@ describe('the hint-file guard', () => { }) } + // A case-insensitive volume, the macOS default, opens a guarded folder in + // any letter case. + const caseAliases = (dir: string) => [ + dir.replace('/.claude/', '/.Claude/'), + dir.replace('/code-mode/', '/Code-Mode/'), + dir.toUpperCase(), + ] + for (const dir of guardedDirs(HOME, '/repo')) { + test(`guards ${dir} in other letter case`, async ($, on) => { + session(on) + for (const alias of caseAliases(dir)) { + const calls = [ + { tool: 'Write', file_path: `${alias}/a.md`, content: 'x' }, + { tool: 'Edit', file_path: `${alias}/pending/a.md`, old_string: 'a', new_string: 'b' }, + ] + for (const call of calls) + expect(textOf(await $.tool.call(call as never))).toContain('hint files and saved functions steer the model') + } + }) + } + + // The plugin folder of the test kit is not under HOME, so this test checks + // the comparison that the guard makes. + test('guards the installed plugin folder in other letter case', () => { + const installed = `${HOME}/.claude/plugins/cache/claude-code-mode/code-mode/0.12.11` + const dirs = guardedDirs(HOME, '/repo', installed) + for (const alias of caseAliases(installed)) + expect(dirs.some(d => isUnderAnyCase(resolvePath(`${alias}/hooks/x.mjs`, HOME, '/repo'), d))).toBe(true) + }) + + test('isUnderAnyCase ignores letter case and Unicode form, and isUnder does not', () => { + expect(isUnderAnyCase('/Users/Me/.Claude/Code-Mode/HINTS/x.md', '/Users/me/.claude/code-mode/hints')).toBe(true) + expect(isUnderAnyCase('/Users/Jose\u0301/x.md', '/Users/Jos\u00e9')).toBe(true) + expect(isUnderAnyCase('/a/BC', '/a/b')).toBe(false) + // the other callers of isUnder give access, so they keep the exact folder + expect(isUnder('/a/B/c', '/a/b')).toBe(false) + }) + 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 b8018e283bb432f0c980cab997316d2d444d6858 Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Mon, 26 Oct 2026 18:32:20 +0100 Subject: [PATCH 2/3] Test a case alias under a HOME with a trailing or double slash The guard normalizes HOME and folds letter case. This test checks both at once. --- tests/hints.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/hints.test.ts b/tests/hints.test.ts index 84acb38..5eb07ca 100644 --- a/tests/hints.test.ts +++ b/tests/hints.test.ts @@ -1634,6 +1634,23 @@ describe('the hint-file guard', () => { }) } + // A case alias under such a HOME: the guard normalizes the roots and folds + // letter case, both at once. + for (const home of ['/home/u/', '/home//u']) { + test(`guards a case alias 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', + '/HOME/U/.claude/code-mode/hints/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([ From 1b8363eb0b3a80bd3d6ba3e5454d3f1b18df96ca Mon Sep 17 00:00:00 2001 From: Gabriele Pavanello Date: Mon, 26 Oct 2026 18:32:20 +0100 Subject: [PATCH 3/3] Bump the version to 0.12.18 Main is at 0.12.17, so the case-alias guard needs a higher version. --- .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 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": {