Skip to content

Guard hint folders in any letter case - #83

Merged
gabe4coding merged 4 commits into
mainfrom
harness/ccm-75-case-alias-guard
Oct 27, 2026
Merged

gabe4coding merged 4 commits into
mainfrom
harness/ccm-75-case-alias-guard

Conversation

@gabe-bots

@gabe-bots gabe-bots Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Intent

The guard denies Write, Edit and NotebookEdit on a guarded folder also when the path spells the folder in other letter case.
A case-insensitive volume, the default on macOS, opens ~/.claude/Code-Mode/hints as the hint folder.

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["The model calls Write, Edit or NotebookEdit"] --> B["Find where the path lands: as written and through links"]
  B --> C["Fold letter case and Unicode form of each path and each guarded folder"]
  C --> D{"Is a path inside a guarded folder?"}
  D -->|yes| E["Deny the call with the guard message"]
  D -->|no| F["Run the file tool"]
Loading

Drift

None.

Decisions

  • The guard folds letter case and Unicode form on every volume. It does not detect the case rule of each volume.
  • Real paths do not show the case on disk. On a case-insensitive exFAT volume, realpathSync.native keeps the spelling of the alias.
  • A probe per volume needs a folder that exists. A new file path often has no folder yet. Folding is lexical, so it works for such a path.
  • The cost: on a case-sensitive volume, the guard also denies a folder that differs from a guarded folder only in letter case. The model can use another name.
  • isUnder stays exact. Its other callers give access: readForProgram skips the Read prompt for saved files, and saveFunction accepts paths under pending/.
  • Unicode form: APFS also ignores the normalization form of a name, so the fold also makes the form the same (NFD).
  • The branch merges main at 548fddb (Normalize HOME in code-mode paths, so the guard sees a new hint folder #74, HOME normalization). The merge keeps the tests of both sides in tests/hints.test.ts.
  • A new test checks a case alias under a HOME with a trailing or double slash. It fails without Normalize HOME in code-mode paths, so the guard sees a new hint folder #74 and without this change.
  • Version 0.12.18, in its own commit after the merge, because main is at 0.12.17.
  • Door: two-way. A revert restores the exact comparison.

Evidence

Head: the merge of main 548fddb, then two commits. Tree 2422c9cc25a5e7c548fd2603c89d56ab1bb70ec6.

Real Claude Code 2.1.296 on a case-insensitive exFAT volume (FUSE on a loop device) on Linux.
Claude Code installs the plugin from a local git copy of the commit, so the plugin loads from ~/.claude/plugins/cache/.
A scripted Messages API on localhost sends the Write calls, because the box has no Claude Code login.
Claude Code, the plugin hooks, the Write tool and the volume are real.
Run A uses drive.mjs from the verify skill. Run B uses claude -p --permission-mode bypassPermissions.

$ mount | grep /ci
/dev/loop0 on /ci type fuseblk (rw,nosuid,nodev,relatime,user_id=0,group_id=0,default_permissions,allow_other,blksize=4096)
$ node -e '…'   # /ci/Probe/File.txt exists; open it as /ci/probe/file.txt
exists /ci/probe/file.txt true
realpathSync /ci/probe/file.txt
realpathSync.native /ci/probe/file.txt

Before (main 548fddb with #74, plugin 0.12.17). Long lines end in …:

== run A (drive.mjs)
Write <run>/cwd/.claude/Code-Mode/hints/x.md -> Claude requested permissions to edit … which is a sensitive file.
Write <run>/cwd/.claude/code-mode/hints/control.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
== run B (HOME=$HOME, installed plugin at $HOME/.claude/plugins/cache/claude-code-mode/code-mode/0.12.17)
Write $HOME/.claude/Code-Mode/hints/x.md -> File created successfully at: /ci/home-m2before/.claude/Code-Mode/hints/x.md …
Write $HOME/.claude/code-mode/FUNCTIONS/x.md -> File created successfully at: /ci/home-m2before/.claude/code-mode/FUNCTIONS/x.md …
Write <project>/.CLAUDE/code-mode/hints/x.md -> File created successfully at: /ci/runs-m2before/cwdB/.CLAUDE/code-mode/hints/x.md …
Write $HOME/.Claude/plugins/cache/claude-code-mode/code-mode/0.12.17/hooks/probe.mjs -> File created successfully at: …
Write $HOME/.claude/code-mode/hints/control.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write <project>/.claude/code-mode/hints/control.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write $HOME/.claude/plugins/cache/claude-code-mode/code-mode/0.12.17/hooks/control.mjs -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
== files on the volume after both runs (real names on disk):
$HOME/.claude/code-mode/functions/x.md
$HOME/.claude/code-mode/hints/x.md
$HOME/.claude/plugins/cache/claude-code-mode/code-mode/0.12.17/hooks/probe.mjs
<project>/.claude/code-mode/hints/x.md

In run A, the Claude Code prompt for sensitive files stops the alias, not the guard.
With that prompt approved or skipped, as in run B, the alias files land.
A second run with HOME=$HOME/ gives the same lines: #74 denies the lowercase controls, and the four aliases land.

After (this branch, plugin 0.12.18). The runs with HOME=$HOME and HOME=$HOME/ give the same lines:

== run A (drive.mjs)
Write <run>/cwd/.claude/Code-Mode/hints/x.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write <run>/cwd/.claude/code-mode/hints/control.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
== run B (HOME=$HOME/, installed plugin at $HOME/.claude/plugins/cache/claude-code-mode/code-mode/0.12.18)
Write $HOME/.claude/Code-Mode/hints/x.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write $HOME/.claude/code-mode/FUNCTIONS/x.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write <project>/.CLAUDE/code-mode/hints/x.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write $HOME/.Claude/plugins/cache/claude-code-mode/code-mode/0.12.18/hooks/probe.mjs -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write $HOME/.claude/code-mode/hints/control.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write <project>/.claude/code-mode/hints/control.md -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
Write $HOME/.claude/plugins/cache/claude-code-mode/code-mode/0.12.18/hooks/control.mjs -> code-mode: hint files and saved functions steer the model, so they cannot be written with file tools. …
== files on the volume after both runs (real names on disk):
(none)

The function-level check from the issue, for HOME /Users/me, /Users/me/ and /Users//me:

alias: /Users/me/.claude/Code-Mode/hints/x.md
main 548fddb:  dirs.some(d => isUnder(alias, d)): false            (all three HOME forms)
branch:        dirs.some(d => isUnderAnyCase(alias, d)): true      (all three HOME forms)

The new tests on main 548fddb (the import of the new function removed), then on this branch:

main:   248 pass, 9 fail   (each new case-alias test: Received "written" or "edited", or isUnderAnyCase is not defined)
branch: 257 pass, 0 fail

Reading map

  • Tests changed: tests/hints.test.ts lines 1554-1555, 1583-1620 and 1633-1649 add tests. No test is removed or weakened.
  • Config: .claude-plugin/plugin.json line 3, the version.
  • Public API: hooks/hints.ts lines 505-516 export isUnderAnyCase. hooks/register.tsx lines 817-825 use it in the guard.

Closes #75

A case-insensitive volume, the default on macOS, opens
~/.claude/Code-Mode/hints as the hint folder. The file-tool guard
compared paths as text, so a path in other letter case got past it.

The guard now compares with isUnderAnyCase, which folds letter case
and Unicode form. Other callers of isUnder give access, so they keep
the exact comparison.

Closes #75
@gabe-bots gabe-bots Bot added the needs-gabe Needs Gabe's decision before work continues label Oct 26, 2026
@gabe-bots

gabe-bots Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

gabrypavanello and others added 3 commits October 26, 2026 17:37
Co-authored-by: Gabe <gpavanello@thefork.com>
The guard normalizes HOME and folds letter case. This test checks both at once.
Main is at 0.12.17, so the case-alias guard needs a higher version.
@gabe-bots

gabe-bots Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

@gabe-bots

gabe-bots Bot commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

@greptile review

1 similar comment

Copy link
Copy Markdown
Owner

@greptile review

@greptile-apps

greptile-apps Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge.

Summary

The PR makes the hint-file guard compare paths without regard to letter case or Unicode form.

  • File tools block guarded folders in any letter case.

Acknowledged trade-off: gabe-bots[bot] explicitly accepts blocking distinct folders on case-sensitive volumes when their names differ only in letter case.

Reviews (1) · Last reviewed commit: "Bump the version to 0.12.18" · Reviewed by Greptile

@gabe4coding
gabe4coding merged commit aec602c into main Oct 27, 2026
18 checks passed
@gabe4coding
gabe4coding deleted the harness/ccm-75-case-alias-guard branch October 27, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-gabe Needs Gabe's decision before work continues risk:t2-soft

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hint-file guard: a path with other letter case is not guarded on macOS

2 participants