Summary
On macOS (case-insensitive filesystem), a file tool call whose absolute path differs only in
letter case from the workspace root is treated as outside the workspace and raises the
external-directory prompt, even though the path resolves to a file inside the project. Every
occurrence costs a human approval, and approving one grants the case-variant directory, so the
session root set slowly accumulates duplicates of the real root (visible in the prompt's
allowed: list).
Expected Behavior
A path that names an existing file inside the workspace — regardless of letter case, on a
case-insensitive volume — should be recognized as inside the workspace: no external-directory
prompt, no directory grant.
Actual Behavior
Workspace root /Users/dev/Projects/my-app (capital P, macOS). A grep call for
/Users/dev/projects/my-app/apps/web/README.md (lowercase projects) triggers:
Allow Command Code to use GREP on this file?
This action accesses a path outside the current project.
/Users/dev/projects/my-app/apps/web/README.md is outside workspace
(allowed: /Users/dev/Projects/my-app, /Users/dev/.commandcode, /tmp, /private/tmp, /var/folders, …)
Both spellings resolve to the same inode.
Steps to reproduce the issue
- On macOS, start a session in a workspace whose absolute path contains at least one uppercase
letter, e.g. /Users/dev/Projects/my-app.
- Trigger a file tool call on the same path spelled in lowercase (this happens organically
because the model occasionally re-types absolute paths instead of copying them):
grep with path: "/Users/dev/projects/my-app/apps/web/README.md", or
read_file / edit_file with the same lowercased path.
- The external-directory gate fires (
risk: { kind: "outside-workspace" }) and the call waits
for human approval.
- Approving grants the lowercased directory, so the allowed-roots list grows with case-variant
entries that duplicate the real root.
Root cause
src/permissions/workspace.ts (bundled in @commandcode/harness):
function getCanonicalPath(dirPath: string) {
try {
return process.platform === 'win32' ? fs.realpathSync.native(dirPath) : fs.realpathSync(dirPath);
} catch {
return path.normalize(dirPath);
}
}
canonicalizeForLookup() feeds this into isWithinRoot(), which decides containment with
path.relative(canonicalRoot, canonicalPath) — a case-sensitive string comparison.
On darwin, fs.realpathSync (the JS implementation — .native is only used for win32)
preserves the case of the input for every component that is not a symlink, so a lowercased path
stays lowercased. The native variant goes through libuv's uv_fs_realpath → realpath(3) and
returns the on-disk spelling.
Reproduction without Command Code (Node on macOS):
$ node -e 'const fs=require("fs"),path=require("path");const p="/users/dev/projects/my-app/apps/web/README.md";
console.log("js ", fs.realpathSync(p));
console.log("native", fs.realpathSync.native(p));
console.log("rel ", path.relative("/Users/dev/Projects/my-app", p));'
js /users/dev/projects/my-app/apps/web/README.md
native /Users/dev/Projects/my-app/apps/web/README.md
rel ../../../../users/dev/projects/my-app/apps/web/README.md # → isWithinRoot() === false
Notes: no user-side config fully fixes this
deny/ask rule matching already folds case on macOS/Windows, but the workspace boundary does not.
- The external-directory gate runs before the allow rules, so an
allow rule cannot silence it.
permissions.additionalDirectories is the only workaround, and it is itself case-sensitive —
one entry per observed case variant.
- Mods cannot help either:
cmd.hooks.beforeToolCall runs after permissions.check.
Suggested fix
Use the native realpath wherever it exists, or fold case only when the volume is
case-insensitive:
return fs.realpathSync.native?.(dirPath) ?? fs.realpathSync(dirPath);
The requirement is that two spellings of the same existing path land on the same side of the
workspace boundary.
Command Code Version
1.53.1
Operating System
macOS
Additional context
- macOS 27.0 (APFS, case-insensitive), Node v24.19.0, Command Code 1.53.1.
- Code locations:
getCanonicalPath / canonicalizeForLookup / isWithinRoot in
src/permissions/workspace.ts; the gate is the allowExternalDirectory branch of the
permission engine (risk: { kind: 'outside-workspace' }, message built by
formatOutsideWorkspaceMessage).
- A fix would also keep the prompt's
allowed: list clean: today it mixes both spellings of the
same directory once a case-variant path has been approved.
Fix prompt (optional)
In the harness permission engine, make workspace containment case-insensitive for existing paths
on case-insensitive volumes: getCanonicalPath should call fs.realpathSync.native whenever it
is available (not only on win32), so /users/dev/projects/my-app/... canonicalizes to
/Users/dev/Projects/my-app/.... Add a unit test asserting that a case-variant spelling of an
existing workspace path returns isPathInWorkspace() === true, while a genuinely external path
still returns false.
Summary
On macOS (case-insensitive filesystem), a file tool call whose absolute path differs only in
letter case from the workspace root is treated as outside the workspace and raises the
external-directory prompt, even though the path resolves to a file inside the project. Every
occurrence costs a human approval, and approving one grants the case-variant directory, so the
session root set slowly accumulates duplicates of the real root (visible in the prompt's
allowed:list).Expected Behavior
A path that names an existing file inside the workspace — regardless of letter case, on a
case-insensitive volume — should be recognized as inside the workspace: no external-directory
prompt, no directory grant.
Actual Behavior
Workspace root
/Users/dev/Projects/my-app(capitalP, macOS). Agrepcall for/Users/dev/projects/my-app/apps/web/README.md(lowercaseprojects) triggers:Both spellings resolve to the same inode.
Steps to reproduce the issue
letter, e.g.
/Users/dev/Projects/my-app.because the model occasionally re-types absolute paths instead of copying them):
grepwithpath: "/Users/dev/projects/my-app/apps/web/README.md", orread_file/edit_filewith the same lowercased path.risk: { kind: "outside-workspace" }) and the call waitsfor human approval.
entries that duplicate the real root.
Root cause
src/permissions/workspace.ts(bundled in@commandcode/harness):canonicalizeForLookup()feeds this intoisWithinRoot(), which decides containment withpath.relative(canonicalRoot, canonicalPath)— a case-sensitive string comparison.On darwin,
fs.realpathSync(the JS implementation —.nativeis only used forwin32)preserves the case of the input for every component that is not a symlink, so a lowercased path
stays lowercased. The native variant goes through libuv's
uv_fs_realpath→realpath(3)andreturns the on-disk spelling.
Reproduction without Command Code (Node on macOS):
Notes: no user-side config fully fixes this
deny/askrule matching already folds case on macOS/Windows, but the workspace boundary does not.allowrule cannot silence it.permissions.additionalDirectoriesis the only workaround, and it is itself case-sensitive —one entry per observed case variant.
cmd.hooks.beforeToolCallruns afterpermissions.check.Suggested fix
Use the native realpath wherever it exists, or fold case only when the volume is
case-insensitive:
The requirement is that two spellings of the same existing path land on the same side of the
workspace boundary.
Command Code Version
1.53.1
Operating System
macOS
Additional context
getCanonicalPath/canonicalizeForLookup/isWithinRootinsrc/permissions/workspace.ts; the gate is theallowExternalDirectorybranch of thepermission engine (
risk: { kind: 'outside-workspace' }, message built byformatOutsideWorkspaceMessage).allowed:list clean: today it mixes both spellings of thesame directory once a case-variant path has been approved.
Fix prompt (optional)
In the harness permission engine, make workspace containment case-insensitive for existing paths
on case-insensitive volumes:
getCanonicalPathshould callfs.realpathSync.nativewhenever itis available (not only on win32), so
/users/dev/projects/my-app/...canonicalizes to/Users/dev/Projects/my-app/.... Add a unit test asserting that a case-variant spelling of anexisting workspace path returns
isPathInWorkspace() === true, while a genuinely external pathstill returns
false.