[BUG]

Status Open
Reported on v2.1.233
Maintainer reply None cached
Activity 2 comments · opened Aug 17, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Friction Report — claudeMdExcludes: undocumented scope and symlink resolution

Date: 2026-08-16
Claude Code version: 2.1.233
Platform: macOS (darwin 25.2.0)
Reporter: Sascha Holfeld (AIDEERS)
Severity: low (no data loss, no crash) — but it silently defeats a governance boundary, and the failure mode looks like success.

---

Context

We run two separate Claude Code "worlds" on one machine, for two companies. Company A's work uses CLAUDE_CONFIG_DIR=$HOME/.claude-titan; company B uses the default $HOME/.claude, where a third-party toolkit installs its own CLAUDE.md and a rules/ directory of symlinks.

We expected CLAUDE_CONFIG_DIR to isolate the two. It does not, by design: the ancestor walk from CWD upward also loads CLAUDE.md and .claude/rules/*.md from $HOME/.claude. That part is documented behaviour and we accept it — a repo below $HOME sees $HOME/.claude.

claudeMdExcludes is the documented remedy, and it works. Getting it to work took two rounds and a custom hook, because of two properties the docs do not mention.

What we hit

1. The docs describe claudeMdExcludes for CLAUDE.md memory only, but it also governs .claude/rules/*.md.

The settings reference reads: "Glob patterns or absolute paths of CLAUDE.md files to skip when loading memory." Nothing indicates that rules files are in scope. We treated the rules as out of scope and planned a fallback mechanism (a blocking hook) for them. That fallback turned out to be unnecessary — but we only learned that by measuring.

**2. Patterns match the resolved path, not the path as loaded.**

~/.claude/rules/ in our setup is a directory of symlinks:

~/.claude/rules/git.md -> ~/.onedot-devkit/current/content/rules/git.md
                          (current -> versions/0.21.0)

Our first exclude set was:

"claudeMdExcludes": [
  "/Users/<user>/.claude/CLAUDE.md",
  "/Users/<user>/.claude/rules/**"
]

Result: CLAUDE.md was excluded, all 10 rules files still loaded. Adding a third entry for the symlink target root fixed it:

"/Users/<user>/.onedot-devkit/**"

So the matcher sees /Users/<user>/.onedot-devkit/versions/0.21.0/content/rules/git.md. A pattern written against the link location can never match. For a symlinked rules directory — which is how at least one popular toolkit installs — the natural, obvious pattern is the one that silently does nothing.

Why this is worth a doc line rather than a code change

The behaviour itself is defensible: resolving before matching is the safer default. The problem is that the two facts together produce a silent partial success. Excluding CLAUDE.md visibly worked, so the configuration looked correct. Only a per-file check revealed that the larger half — ten rule files, several of which contained instructions actively wrong for this repo (e.g. "run <other-toolkit> quality-gate before push", where this repo uses make pre-pr) — was still in every session's context.

There is no obvious way to notice this from the outside. /context shows Memory files, which is where we eventually confirmed the fix, but until you know to look, a partially-applied exclude reads as an applied exclude.

What made it diagnosable

We registered a log-only InstructionsLoaded hook that appends every .md path from the payload to a file, classified against an allowlist. That log printed the resolved devkit paths, which is what turned "the setting doesn't work" into "the setting matches resolved paths". Before/after, same machine:

12:02:20Z   10x FOREIGN  ~/.onedot-devkit/versions/0.21.0/content/rules/*.md
13:11:11Z    4x OK       (own world + repo CLAUDE.md), 0x FOREIGN

InstructionsLoaded was excellent for this. It is the only reason the second round was targeted rather than trial and error.

A second, unrelated finding from the same day: permissions.deny pattern matching

Same repo, same version, while adding permissions.deny rules to block accidental pushes to protected branches. These six patterns behave exactly as expected:

"Bash(git push * develop)"        blocks: git push origin develop
                                          git push --dry-run origin develop
"Bash(git push * main)"           blocks: git push -u origin main
"Bash(git push *heads/develop*)"  blocks: git push origin refs/heads/develop
"Bash(git push * HEAD)"           blocks: git push origin HEAD
"Bash(git push * HEAD:*)"         blocks: git push origin HEAD:refs/heads/develop

And they correctly allow git push origin feature/lena-domain-scan — worth noting because our first attempt used substring patterns (*main*), which blocked that branch since "domain" contains "main".

But these two never match anything:

"Bash(git push *:develop)"
"Bash(git push *:main)"

Test, isolated, no HEAD or heads/ in the string to confound it:

git push --dry-run origin my-branch-name:develop

The command ran (git itself rejected it, non-fast-forward — the permission layer let it through). Reproduced on a second independent isolation test. We have no working theory for why *:develop fails while *heads/develop* and * HEAD:* match on the same command string, which ends in :develop.

We removed the two rules rather than guess further — a deny rule that never fires is worse than no rule, because it looks like coverage. The residual gap is covered by our pre-push hook, which reads remote_ref from stdin and so sees refs/heads/develop regardless of how the refspec was written.

Suggestion: whatever the intended semantics are, they'd be worth one line in the docs — specifically whether * spans word boundaries, and whether : is treated specially. The related suggestion below (flag patterns that matched nothing) would also have surfaced this on its own.

Suggestions, in order of cost

Happy to provide the full audit-log output or the setup if useful.

What Should Happen?

  1. One sentence in the claudeMdExcludes docs: patterns are matched against the resolved (symlink-followed) absolute path. Mention that .claude/rules/*.md is in scope, or state explicitly that it is not.
  2. A short example for the symlinked-rules case, since installing rules as symlinks into a versioned directory is a common toolkit pattern.
  3. Optional, larger: claude doctor (or /context) could flag a claudeMdExcludes entry that matched zero files in the last session. A pattern that never matches is nearly always a mistake, and it is invisible today.

Error Messages/Logs

Steps to Reproduce

Same repo, same version, while adding permissions.deny rules to block accidental pushes to protected branches. These six patterns behave exactly as expected:

"Bash(git push * develop)"        blocks: git push origin develop
                                          git push --dry-run origin develop
"Bash(git push * main)"           blocks: git push -u origin main
"Bash(git push *heads/develop*)"  blocks: git push origin refs/heads/develop
"Bash(git push * HEAD)"           blocks: git push origin HEAD
"Bash(git push * HEAD:*)"         blocks: git push origin HEAD:refs/heads/develop

And they correctly allow git push origin feature/lena-domain-scan — worth noting because our first attempt used substring patterns (*main*), which blocked that branch since "domain" contains "main".

But these two never match anything:

"Bash(git push *:develop)"
"Bash(git push *:main)"

Test, isolated, no HEAD or heads/ in the string to confound it:

git push --dry-run origin my-branch-name:develop

Claude Model

None

Is this a regression?

Yes, this worked in a previous version

Last Working Version

_No response_

Claude Code Version

2.1.233

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

_No response_

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗