Skip to content

macOS: a path that differs only in letter case from the workspace root is flagged as outside the workspace #881

Description

@franjorub

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

  1. On macOS, start a session in a workspace whose absolute path contains at least one uppercase
    letter, e.g. /Users/dev/Projects/my-app.
  2. 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.
  3. The external-directory gate fires (risk: { kind: "outside-workspace" }) and the call waits
    for human approval.
  4. 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_realpathrealpath(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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions