Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 3 additions & 2 deletions docs/hints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions hooks/hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 4 additions & 2 deletions hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
hintApplies,
isApprovalKey,
isUnder,
isUnderAnyCase,
isUuidLike,
kindQuestion,
newHintFlags,
Expand Down Expand Up @@ -813,14 +814,15 @@ async function landings($: EngineInterface, full: string): Promise<string[]> {
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<boolean> {
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
Expand Down
58 changes: 58 additions & 0 deletions tests/hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
hintItems,
hintRef,
isUnder,
isUnderAnyCase,
parseDecision,
parseHint,
pendingFileName,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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']) {
Expand All @@ -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([
Expand Down
Loading