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..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 0c7b676..3f067ef 100644 --- a/hooks/hints.ts +++ b/hooks/hints.ts @@ -502,6 +502,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 4221ad9..5eb07ca 100644 --- a/tests/hints.test.ts +++ b/tests/hints.test.ts @@ -17,6 +17,7 @@ import { hintItems, hintRef, isUnder, + isUnderAnyCase, parseDecision, parseHint, pendingFileName, @@ -1550,6 +1551,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) => { @@ -1577,6 +1580,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) + }) + // 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']) { @@ -1593,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([