◉CLAUDE LABJP
●2.1.312 — Fixes Claude overwriting very large CLAUDE.md files and skills going missing after a Bash cd●API — The Messages API maximum request size grew from 32 MB to 128 MB (10/28)●SONNET 4.5 — Retires on the Claude API on 11/30, 31 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…●2.1.312 — Fixes Claude overwriting very large CLAUDE.md files and skills going missing after a Bash cd●API — The Messages API maximum request size grew from 32 MB to 128 MB (10/28)●SONNET 4.5 — Retires on the Claude API on 11/30, 31 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-07-30Advanced

You Added a Second Working Root — Now Ask How Far Your Ignore Rules and Deny Patterns Actually Reach

Adding a working root mid-session does not carry your ignore rules or deny patterns with it. Measured evidence from git check-ignore and a three-way matcher comparison, plus the scope-delta audit script I now run from the DirectoryAdded hook.

Claude Code279hooks22MonorepoSecurity12Operations24

✦ Premium Article

I ran /add-dir mid-session to pull in a second repository, because I wanted the iOS build configuration side by side with what I was already editing.

Working as an indie developer with an app repository on one side and a site repository on the other, this kind of crossing comes up constantly.

The editing part went fine. What bothered me came after.

I ran my usual sweep for files that might carry credentials, and the count had not moved. The root I had just added contains a pile of configuration files. The number should have changed.

A few minutes of digging gave a boring answer. The sweep was still anchored to the first root. Adding a root widens what you can edit; it does not carry the assumptions hanging off the old root along with it.

Session healthy. No errors. Just a safety net stretched across the wrong ground.

An Added Root Is a Sibling, Not a Child

When you add a root with /add-dir or the SDK's register_repo_root, it feels like the working area expanded.

What actually exists is a second independent root sitting alongside the first. Siblings, not parent and child.

The distinction bites hardest on ignore rules. Git exclusion rules stop at the repository boundary. That is unremarkable as a specification, and it becomes an operational hole the moment you have two roots.

I rebuilt the situation locally to check. root_a gets a .gitignore that excludes secrets/; root_b gets none. Both contain the same secrets/prod.env.

# root_a: the rule applies
$ git -C root_a check-ignore -v secrets/prod.env
.gitignore:1:secrets/	secrets/prod.env
 
# asking root_a about a file in root_b is simply refused
$ cd root_a && git check-ignore -v ../root_b/secrets/prod.env
fatal: ../root_b/secrets/prod.env: '../root_b/secrets/prod.env' is outside repository
 
# in root_b, the same path is not excluded at all
$ git -C root_b check-ignore -v secrets/prod.env
(no output, exit code 1)

git status makes the gap visible.

$ git -C root_a status --porcelain
A  .gitignore
A  src/app.ts
 
$ git -C root_b status --porcelain
A  secrets/prod.env      ← staged in the added root
A  src/app.ts

In root_a the file never appears. In root_b it walks straight through to staged.

The practical rule: any judgement about an added root has to run git inside that root. Code that peeks in from the parent with a relative path returns a wrong answer, and the answer it returns is "safe".

The Same Deny Pattern Means Different Things to Different Matchers

Ignore rules were the first surprise. permissions.deny was the second.

Deny is a separate system — string pattern matching rather than git semantics. And here an assumption broke: the same pattern gives different results depending on which matcher reads it.

I lined up three that people actually reach for — fnmatch, Path.match, and glob's ** semantics — and pointed them at the same paths.

import fnmatch, re
from pathlib import PurePosixPath
 
def m_glob(p, pat):
    """Reproduce glob's ** semantics: zero or more directories."""
    rx = (re.escape(pat)
          .replace(r"\*\*/", "(?:[^/]+/)*")
          .replace(r"\*\*", ".*")
          .replace(r"\*", "[^/]*")
          .replace(r"\?", "[^/]"))
    return re.fullmatch(rx, p) is not None
 
for pat in ["*.env", "**/*.env", "config/*.env", "**/config/*.env"]:
    for p in ["config/prod.env", "ios/Config/secrets.env",
              ".vscode/settings.json", "scripts/build.sh"]:
        print(pat, p,
              fnmatch.fnmatch(p, pat),
              PurePosixPath(p).match(pat),
              m_glob(p, pat))

Here is what came back.

PatternPathfnmatchPath.matchglob(**)
*.envconfig/prod.envTrueTrueFalse
*.envios/Config/secrets.envTrueTrueFalse
**/*.envconfig/prod.envTrueTrueTrue
**/*.envios/Config/secrets.envTrueTrueTrue
config/*.envconfig/prod.envTrueTrueTrue
config/*.envios/Config/secrets.envFalseFalseFalse
**/config/*.envconfig/prod.envFalseFalseTrue
**/config/*.envios/Config/secrets.envFalseFalseFalse

Two rows deserve attention.

The *.env rows: under fnmatch and Path.match the pattern matches config/prod.env. The * crosses the slash. If you wrote it with glob in mind, thinking "top level only", it reaches further than intended.

The **/config/*.env rows: glob matches, the other two do not. **/ is read as "at least one directory segment" rather than "zero or more", so a config/ sitting directly under the root falls out.

That second one runs opposite to what I expected. I had been adding **/ prefixes on the assumption that they only ever widen coverage. Depending on the matcher they narrow it — and the thing they drop is the top level of the added root, which is exactly where configuration files cluster.

One caveat on the table: the values come from Python 3.10.12 on my machine. In that version Path.match treats ** the same as *. PurePath.full_match, added in 3.13, is a separate function that reads ** recursively, so if you are on a newer version, run the same code in your own environment and rebuild the table. The takeaway of this section is not to trust the table as printed.

With a single root you never meet this. There is only one directory tree, shallow paths are rare, and every spelling of the pattern agrees. Add a second, shallower tree and the disagreement surfaces.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Measured proof that ignore rules do not compose across roots — the same secrets/ directory is skipped in one root and staged in the other
✦A matcher comparison table showing the same deny pattern diverging — `*.env` crosses slashes, and `**/config/*.env` misses files directly under the added root
✦The ~70-line scope-delta audit run from DirectoryAdded, with its real output: 2 uncovered secret candidates, 1 case mismatch, 2 path collisions
✦Timed at 21.8 ms on a 2,882-file added root — and why the delta audit is not actually faster than a full rescan (under 1% apart)
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Claude Code2026-10-23
When a Nightly claude -p Said MCP Needed Authentication, I Added a Preflight That Splits Sleep From Keychain
Claude Code 2.1.307 fixed MCP failures caused by macOS sleep and a locked login keychain. Here is a preflight script that helps with or without the fix, and a way to match failures against sleep history.
⟐ Claude Code2026-10-15
The Hook That Was Meant to Guard My Japanese Folder Never Blocked Anything — Python Was Reading stdin as cp932
A PreToolUse hook guarding folders like 経理 or 議事録 silently lets edits through when Python's stdin defaults to cp932. How I reproduced it, the three-line fix, and one command to check your own hooks.
⟐ Claude Code2026-10-14
I Set onFailure to block, and the Docs' rm -rf Guard Still Let Everything Through Without jq
When a PreToolUse guard hook fails, does the action go ahead or stop? The docs' own rm -rf example exits 0 and waves everything through when jq is missing. Ten failure modes run for real, the fix, and a script that finds fail-open guards in settings.json.