●COGNI — Cognizant and Anthropic expanded their partnership on July 27 to bring Claude to enterprise clients through a wider delivery footprint●TUNNEL — MCP connectors gained embedded UI, enterprise-managed auth, observability, and private network tunnels, so internal services can be reached without exposing them publicly●OSS — Anthropic said it does not support banning open-source AI, while calling for targeted legal and commercial frameworks to prevent illicit model distillation (July 28)●PACING — More than 1,100 employees across OpenAI, Anthropic, Google, and Meta signed a letter asking the US government to build tools for a verifiable international pacing mechanism●OPUS5 — Opus 5 reportedly doubles Opus 4.8 on Frontier-Bench v0.1 and surpasses Fable 5 on OSWorld 2.0 at roughly one-third the cost●FABLE5 — Fable 5 plan access was finalized on July 20: Max and Team Premium include it up to 50% of weekly limits, while Pro and Team Standard move to metered credits●COGNI — Cognizant and Anthropic expanded their partnership on July 27 to bring Claude to enterprise clients through a wider delivery footprint●TUNNEL — MCP connectors gained embedded UI, enterprise-managed auth, observability, and private network tunnels, so internal services can be reached without exposing them publicly●OSS — Anthropic said it does not support banning open-source AI, while calling for targeted legal and commercial frameworks to prevent illicit model distillation (July 28)●PACING — More than 1,100 employees across OpenAI, Anthropic, Google, and Meta signed a letter asking the US government to build tools for a verifiable international pacing mechanism●OPUS5 — Opus 5 reportedly doubles Opus 4.8 on Frontier-Bench v0.1 and surpasses Fable 5 on OSWorld 2.0 at roughly one-third the cost●FABLE5 — Fable 5 plan access was finalized on July 20: Max and Team Premium include it up to 50% of weekly limits, while Pro and Team Standard move to metered credits
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.
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.envfatal: ../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 --porcelainA .gitignoreA src/app.ts$ git -C root_b status --porcelainA secrets/prod.env ← staged in the added rootA 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 quietly 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, refrom pathlib import PurePosixPathdef 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 Nonefor 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.
Pattern
Path
fnmatch
Path.match
glob(**)
*.env
config/prod.env
True
True
False
*.env
ios/Config/secrets.env
True
True
False
**/*.env
config/prod.env
True
True
True
**/*.env
ios/Config/secrets.env
True
True
True
config/*.env
config/prod.env
True
True
True
config/*.env
ios/Config/secrets.env
False
False
False
**/config/*.env
config/prod.env
False
False
True
**/config/*.env
ios/Config/secrets.env
False
False
False
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.
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.
A Capital Letter Is Enough to Silently Void a Deny Rule
The added root taught me one more thing: ios/Config/.
iOS projects conventionally capitalise Config and Secrets. A deny written as **/config/** in lowercase does not match.
What makes it awkward is that macOS filesystems are case-insensitive by default. Type ls ios/config in a shell and the contents appear. To a human it reads as "it's right there", while pattern matching — which is case-sensitive — disagrees. Visible but unprotected.
Again, a single root hides this, because naming conventions inside one repository are consistent. Bring in a repository built under a different convention and it shows up immediately.
DirectoryAdded Is Where the Re-measurement Belongs
All three problems only exist in the instant after a root appears.
SessionStart is too early — the added root does not exist yet. PreToolUse runs on every tool call, so you pay the cost repeatedly for a boundary that moves a handful of times a year.
DirectoryAdded fires right after a working directory is registered via /add-dir or the SDK's register_repo_root control request. That is the right slot for a one-shot re-measurement.
The configuration follows the ordinary hook shape.
One implementation decision took some thought: which key carries the added path in the hook payload.
I chose not to hard-code it. The script walks a list of candidate keys in the stdin JSON and falls back to argv if none of them resolve to a directory. Hook payloads gain and lose fields across versions, and getting a single key name wrong would make the audit silently do nothing.
A silent no-op is worse than no audit at all. Believing a check exists is what stops you from looking.
The Scope-Delta Audit
Rather than rescanning everything, the script reports only where the existing assumptions fail to reach the added root.
Output is paths and reasons. Never file contents — hook output lands in logs, and piping secret values into a log to protect secret values defeats the purpose.
#!/usr/bin/env python3"""Report only the places where existing assumptions do not reach a newly addedworking root. Invoked from the DirectoryAdded hook. Prints paths and reasons only."""import json, re, subprocess, sysfrom pathlib import Path# The deny patterns you have been running against the original rootDENY_PATTERNS = ["**/*.env", "**/*.pem", "**/secrets/**", "**/.npmrc"]SECRET_HINT = re.compile( r"(secret|credential|token|apikey|api_key|\.env$|\.pem$|\.p12$|\.keystore$)", re.I)def glob_match(path: str, pat: str) -> bool: """Match with glob's ** semantics (zero or more directories). fnmatch is deliberately avoided here: its '*' crosses slashes.""" rx = (re.escape(pat) .replace(r"\*\*/", "(?:[^/]+/)*") .replace(r"\*\*", ".*") .replace(r"\*", "[^/]*") .replace(r"\?", "[^/]")) return re.fullmatch(rx, path) is not Nonedef list_files(root: Path): """List files with that root's own .gitignore applied. The parent root's ignore rules do not reach here, so git must be run inside the added root.""" try: out = subprocess.run( ["git", "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard"], capture_output=True, text=True, timeout=30) if out.returncode == 0: return [l for l in out.stdout.splitlines() if l] except (OSError, subprocess.SubprocessError): pass # not every root is a git repository; fall back to a plain walk return [p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file() and ".git/" not in p.as_posix()]def audit(base: Path, added: Path): findings = {"uncovered_secrets": [], "case_mismatch": [], "path_collisions": []} added_files = list_files(added) base_files = set(list_files(base)) for rel in added_files: if SECRET_HINT.search(rel): if not any(glob_match(rel, p) for p in DENY_PATTERNS): if any(glob_match(rel.lower(), p) for p in DENY_PATTERNS): findings["case_mismatch"].append(rel) else: findings["uncovered_secrets"].append(rel) if rel in base_files: findings["path_collisions"].append(rel) return findingsdef resolve_added_root() -> Path | None: """Do not hard-code the payload key. Fall back to argv if nothing resolves.""" try: payload = json.loads(sys.stdin.read() or "{}") except (json.JSONDecodeError, OSError): payload = {} for key in ("directory", "path", "added_directory", "root", "cwd"): value = payload.get(key) if isinstance(value, str) and Path(value).is_dir(): return Path(value) return Path(sys.argv[2]) if len(sys.argv) > 2 else Nonedef main() -> int: base = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() added = resolve_added_root() if added is None or not added.is_dir(): print("could not resolve the added root (audit did not run)", file=sys.stderr) return 2 # never let this pass as success added = added.resolve() f = audit(base, added) total = sum(len(v) for v in f.values()) print(f"# scope delta audit: {added}") for label, key in (("secret candidates not covered by deny", "uncovered_secrets"), ("fell out of deny on case alone", "case_mismatch"), ("path collides with the original root", "path_collisions")): print(f"\n[{label}] {len(f[key])}") for rel in sorted(f[key]): print(f" - {rel}") print(f"\ntotal findings: {total}") return 1 if total else 0if __name__ == "__main__": sys.exit(main())
The three-valued exit code is deliberate. 0 means no delta, 1 means a delta worth reviewing, 2 means the audit could not run.
Do not collapse 2 into 0. The moment "could not resolve the root" counts as success, the silent no-op is back.
What the Run Actually Reported
I added an iOS-shaped layout to the second root — ios/Config/, a capitalised Secrets/, and two paths (config/prod.env, src/app.ts) that also exist in the original root — and ran it.
# scope delta audit: /home/.../lab_scope/root_b
[secret candidates not covered by deny] 2
- ios/Config/Secrets.plist
- ios/Config/Secrets.swift
[fell out of deny on case alone] 1
- Secrets/signing.txt
[path collides with the original root] 2
- config/prod.env
- src/app.ts
total findings: 5
exit=1
ios/Config/Secrets.plist and Secrets.swift match neither **/*.env nor **/secrets/**. Wrong extension, capitalised directory. They have the word Secrets in the name and slip past every rule in the list.
Secrets/signing.txt matches **/secrets/** once lowercased, which is why it gets its own bucket. The fix differs: the first group needs a new pattern, the second only needs the existing pattern to be matched case-insensitively.
The config/prod.env and src/app.ts collisions are not a secrets problem. They are the paths where a relative reference stops being unambiguous. Once you know which ones collide, you can prefix the root name — or use absolute paths — for exactly those files instead of adopting a blanket rule.
Measure the Hook Cost Before You Commit to It
A hook interrupts the flow of work. If the audit took hundreds of milliseconds, the placement would need rethinking.
I built a 2,882-file tree in the added root — Swift, TypeScript, JSON, plist, mixed — and timed nine runs of each.
Operation
Median
Min
Delta audit (added root only)
21.8 ms
21.3 ms
Full rescan of both roots
21.6 ms
21.3 ms
At 21.8 ms per fire, the hook is invisible. That part matched expectations.
The second row did not. Delta audit and full rescan differ by less than 1%.
The reason is unglamorous: both run git ls-files once against the added root, and that single call dominates everything else. Rescanning the original root disappears into the noise.
So the argument for a delta audit is not speed. It is what ends up on the screen. A full scan buries two unprotected files inside two thousand lines of already-covered paths. A delta gives you two lines to read.
I had filed this design under "faster" when it was really "legible". I would not have caught the mislabel without timing it.
Field Notes That Are Not in the Documentation
Three things worth keeping.
Run the audit inside the added root. Peeking in from the parent with a relative path either gets refused by git or slides past the ignore rules entirely. Always go through git -C <added>.
Pin the matcher on your own side. Which matcher a tool uses can change between versions, but the matcher your audit script uses is your decision. I standardised on glob semantics, because they line up with what actually happens when you walk the filesystem. Importing fnmatch's slash-crossing * into an audit mostly produces false positives on paths that were already covered.
Suspect case before you add patterns. In the run above, only one genuinely new pattern family was required; the rest was solved by changing how an existing pattern is compared. Every pattern you add makes the next added root harder to reason about.
The Routine I Now Run After Adding a Root
Folded into steps, for my own future reference.
Run git -C <added> ls-files --cached --others --exclude-standard inside the added root to get a listing with that root's own ignore rules applied
Match that listing against the existing deny patterns using a matcher you have pinned to glob semantics, and print only what is uncovered
Split out the uncovered entries that would match if lowercased. When this bucket is large, make the existing patterns case-insensitive rather than adding new ones
List the relative paths that collide with the original root, and stop using those names on their own
Steps 3 and 4 stay separate on purpose. Step 3 is about how the configuration is compared; step 4 is about how you write instructions. Merge them and you end up with more patterns and the same ambiguity.
Broader Is Not the Same as Safer
The thing this exercise settled for me: writing deny patterns more broadly and being safer are not the same move.
Prefixing everything with **/ looks like the cheapest way to widen coverage. In practice, depending on the matcher, it drops the top level of the tree — and nothing anywhere reports that it did. The gesture meant to close a gap opened one.
What helped was not widening the patterns. It was putting a re-measurement at the moment the boundary moves. You might add a root only a few times a year, and some of these deltas are only observable on the day it happens.
Take one deny pattern you are currently running and feed it to the matcher comparison above. If fnmatch and glob disagree on it, that is the pattern to fix first.
I had never scrutinised "add a directory" this closely before. There are almost certainly assumptions here I have not measured yet, and I will write them up as I find them.
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.