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.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": {
Expand Down
8 changes: 5 additions & 3 deletions docs/hints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,11 @@ 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, 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.
in real form: the real path of the nearest folder that exists, through symbolic links, then the rest of the path,
in any letter case. A case-insensitive volume, the default on macOS, opens `~/.claude/Code-Mode/hints` as the hint
folder. 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.**

These rules are not a full security boundary. The hashes are in a JSON file in `~/.claude/plugins/store/`. A
Expand Down
30 changes: 23 additions & 7 deletions hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,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
Expand Down Expand Up @@ -791,10 +791,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<string[]> {
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')]
}

Expand All @@ -805,12 +805,28 @@ function realPathOf($: EngineInterface, path: string): Promise<string | undefine
)
}

// Where an absolute path can land: as written, its real path, or its parent's
// real path (a new file is not there yet).
// An absolute path in real form: the real path of its nearest folder that
// exists, then the rest of the path. A write makes the missing folders under
// that real folder, so a symbolic link above them decides where it lands.
async function realFormOf($: EngineInterface, path: string): Promise<string | undefined> {
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, in real form, or under its
// parent's real form. The file itself can be a symbolic link out of the folder,
// and the write still names a file in that folder.
async function landings($: EngineInterface, full: string): Promise<string[]> {
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)}`]
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')
}

Expand Down
76 changes: 76 additions & 0 deletions tests/hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,82 @@ describe('the hint-file guard', () => {
])
})

// 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<string, string>, files: readonly string[] = []) =>
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
}
}
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
// 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',
)
})

// 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' })
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')
Expand Down
Loading