Tags
The Claude starburst logo in terracotta and the Claude wordmark in black on a white card

CLAUDE_PROJECT_DIR let me move my glossary out of a Claude Code skill — and stop approving every write

Claude Code treats .claude/ as a protected path, so allow rules cannot pre-approve writes there. Moving a mutable glossary out with CLAUDE_PROJECT_DIR.

On this page

Introduction

This blog is written by a Claude Code skill that translates every article into Japanese and Traditional Chinese, and the thing keeping those translations consistent is a glossary: 244 terms, three columns, one binding rendering each. The skill has a rule that any term the translator had to decide on gets added in the same change, which means the pipeline writes to that file on almost every article. Every one of those writes stopped and asked me to approve it.

The interruption was the daily irritation. What actually bothered me was realising the file was misfiled: it sat in .claude/skills/blog/ next to the instruction files, but it is not an instruction. It is data the pipeline reads and appends to, and it had been living in a configuration directory pretending to be prose.

The fix was not a permission rule, because no permission rule can do it. .claude/ is a protected path, and moving the file out is the only available answer. The skill still reaches it through `${CLAUDE_PROJECT_DIR}`, which is the documented way for a skill to name a file it does not own.

This article walks through why the obvious fix is impossible, the wrong turn I stopped before it shipped, and the two duplicated constants the move exposed.

The one file in the skill that gets written to

The blog skill is ten markdown files. Nine of them tell the model how to behave: how to shape a narrative, how to type a code fence, which headings are mandatory in each locale. The tenth is a lookup table.

That difference does not show up in a directory listing, but it shows up immediately in git history. Every one of the last ten commits touching glossary.md was an article-publishing commit, because the write-back contract is stated in four separate places across the skill:

.claude/skills/blog/SKILL.md
6. **Terminology comes from …** Read it before any
translate or review pass, and add terms you had to decide on.

A file the pipeline appends to on every run is a database. The other nine files are edited deliberately, a few times a year; this one is edited by a machine, several times a month. Same directory, opposite lifecycle.

A permission rule cannot pre-approve a protected path

The obvious first move is to treat this as a settings problem. Claude Code has an allow list, the writes were predictable, so an entry naming the file should stop the prompts. The agent went looking for the rule to write.

That is wrong, and it is wrong in a way that reading settings.json will never reveal. The documentation is explicit that writes under .claude/ are “never auto-approved except in bypassPermissions mode”, and then closes the door on the workaround:

permissions.allow rules in settings files do not pre-approve protected-path writes. The safety check runs before Claude Code evaluates allow rules from settings, so an entry such as Edit(.claude/**) in ~/.claude/settings.json or .claude/settings.json does not change the per-mode outcome in the table above.

The ordering is the mechanism. An allow rule is not overruled by the protected-path check; it is never consulted. A rule written to cover these writes would sit in the file looking correct, matching nothing, forever.

The protected-path check runs before permission rulesTwo writes enter the same two-stage pipeline. Stage 1 is the protected-path check and stage 2 is the permissions.allow rules. A write under .claude/ stops at stage 1 and always prompts; a dashed connector crossed with an ✗ shows it never reaches stage 2, so no allow rule applies to it. A write under docs/ passes stage 1, reaches stage 2, and can be pre-approved.Write under.claude/Write underdocs/1Protected-path checkAlways prompts2permissions.allow rulesnever consultedCan be pre-approved
A write under .claude/ stops at the first stage, so the rule that would have covered it is never reached.

What else is protected

.claude/ is not alone on that list. .git, .vscode, .idea, .husky, .cargo, .devcontainer, .yarn and .mvn are all protected too, with .claude/worktrees carved out as the single exception, since that is where Claude Code stores its own git worktrees.

So there was no configuration to write. The file had to move.

CLAUDE_PROJECT_DIR names a project file, not a skill file

The agent’s first draft of the move rewrote every reference to ../../../docs/glossary.md. I stopped it there and asked for the practice to be checked rather than assumed, which turned out to be the right call twice over.

The first thing the research found argued against the plan. Anthropic’s skill authoring guidance assumes supporting files live inside the skill directory, under reference/ or scripts/, and the general advice is that bundling is what keeps a skill portable and self-contained. Nothing in that page contemplates a file somewhere else in the repo.

The second thing found the supported exception. Claude Code substitutes three variables into a skill, and one of them exists for exactly this:

Variable What it resolves to Available from
`${CLAUDE_SKILL_DIR}` the directory holding this skill’s SKILL.md not stated
`${CLAUDE_PROJECT_DIR}` the project root v2.1.196
`${CLAUDE_PLUGIN_ROOT}` the plugin install directory, for plugin skills only not stated

Check your version before reaching for the middle one. It is the newest of the three, and on anything older the variable is not substituted at all: it reaches the model as the literal text `${CLAUDE_PROJECT_DIR}`, with no error and nothing in the output to say why the path did not resolve. That is worth knowing up front rather than discovering it as a skill that quietly cannot find its own data.

The docs describe the middle one as being for referencing “project-local scripts or files … independent of where the skill is installed”. That is the case here exactly, and it is strictly better than the relative path the agent had reached for, because the same page notes that the working directory “moves when Claude runs cd. A relative path is correct until something changes directory; the variable is correct regardless.

Portability was never the thing at risk. The glossary holds this repo’s terminology and was never portable content. The skill stays portable, and the data it binds to is now explicitly project-local, which is what the variable is for.

The glossary before and after the moveTwo columns compared over three rows. Before: the glossary sits at .claude/skills/blog/glossary.md, the skill names it with the relative markdown link glossary.md, and writing to it prompts every time. After: it sits at docs/glossary.md, the skill names it with ${CLAUDE_PROJECT_DIR}/docs/glossary.md, and writing to it goes through without a prompt.BeforeLocation.claude/skills/blog/glossary.mdHow the skill names it[glossary.md](glossary.md)Writing to itPrompts every timeAfterLocationdocs/glossary.mdHow the skill names it${CLAUDE_PROJECT_DIR}/docs/glossary.mdWriting to itGoes through
The file changed directory and the skill changed how it names it. Nothing about the write-back rule changed.

Seven references across five skill files became one form:

.claude/skills/blog/SKILL.md
6. **Terminology comes from `${CLAUDE_PROJECT_DIR}/docs/glossary.md`** — outside
this skill on purpose, because the pipeline appends to it and `.claude/`
writes always prompt.

What moving the file exposed

Two things had been quietly true only because of where the file was sitting.

A second copy of the section headings. Three headings are fixed strings per locale, and scripts/prose-check.ts carried its own hardcoded copy of all nine under a comment reading “per the table in glossary.md”. That comment names the duplication and explains it at once: a script has no business reaching into a skill directory, so it could not read the table itself. Nothing verified the two copies agreed. They did agree, by luck, because editing the glossary and editing the checker were separate acts and neither noticed the other being skipped. Outside .claude/, the script reads the table, and the second copy is gone.

A CI hole, pointing the other way. The workflow skips .claude/**, so terminology-only commits never ran CI. Moving the file out would have started triggering a full install, check, build and production redeploy to ship a byte-identical site. I added docs/** to both paths-ignore blocks in the same change, with a comment naming the condition that would make that entry wrong.

The deduplication then repeated itself one file over. The agent’s parser declared a map of column headers to locales:

scripts/lib/glossary.ts
const COLUMN_LOCALE: Record<string, Locale> = {
English: 'en',
日本語: 'ja',
繁體中文: 'zh-tw',
};

Those three endonyms already existed as LOCALE_LABEL in src/i18n/config.ts, byte for byte, in a module the new file was already importing four lines above. A change whose entire purpose was deleting a duplicated constant had introduced one. Code review caught it before merge and it now derives from the existing record, but the lesson is the one worth keeping: deduplication tends to relocate rather than remove, and the relocated copy is hardest to see when it lands next to the import that made it unnecessary.

Verifying the substitution instead of trusting the version number

`${CLAUDE_PROJECT_DIR}` needs Claude Code v2.1.196 or later. The machine was on 2.1.193, three patches short, so the first version of every reference carried a fallback naming the plain repo-root path.

After upgrading to 2.1.233 the version arithmetic said the feature was available. That proves the feature ships, not that it fires for skill markdown specifically, so the agent built a throwaway skill in a temporary project containing nothing but the two variables and invoked it:

PROJECT_DIR=…/scratchpad/probe-proj
SKILL_DIR=…/scratchpad/probe-proj/.claude/skills/probe

Both came back as real absolute paths, so the fallback text was describing a branch that could no longer be taken. I had it removed. An instruction that documents an impossible state is not free: SKILL.md loads on every /blog invocation, and guidance covering situations that cannot happen is how a reader learns to skim the guidance that can.

A detail turned up that the probe had not been built to find. Drafting this article, the agent passed `${CLAUDE_PROJECT_DIR}` as a literal argument when invoking the skill, and it arrived already expanded to the absolute path. Substitution applies to arguments interpolated into skill content, not only to text authored in the file.

A second permission rule that was doing nothing

The protected-path finding is that a rule can sit in a settings file and never be consulted. Once that is in your head, it is worth checking for others.

This repo has two settings files: settings.json, which is committed, and settings.local.json, which is gitignored. The local one had drifted into a near-duplicate of the committed one, 94 lines holding its own copy of 84 allow rules. Two of those rules did not exist in the committed file:

Rule, local file only What it was doing
Bash(pkill -f "astro preview") nothing, because the real command line is astro.mjs preview
Bash(pnpm exec *) silently re-widening a rule the committed file had narrowed to Bash(pnpm exec astro *)

The second is the interesting one. Allow rules merge across settings files, so narrowing a rule in one file achieves nothing while a wider copy survives in another.

On that reading the committed pnpm exec astro * was doing no work, because anything pnpm exec could reach was already pre-approved by its neighbour. The conclusion held and the reason did not, which is the next section.

A broad rule in one settings file re-widens a narrow rule in anotherTwo settings files side by side. The committed settings.json holds the narrow rule Bash(pnpm exec astro *); the gitignored settings.local.json holds the broad rule Bash(pnpm exec *). Both feed into a merge band, because allow rules merge across settings files. On this reading the merged result pre-approves anything pnpm exec can reach, so the narrow rule does no work and neither file shows this on its own. The article goes on to find a second and more fundamental reason the committed rule was inert.settings.jsoncommittedBash(pnpm exec astro *)narrowsettings.local.jsongitignoredBash(pnpm exec *)broadAllow rules merge across settings filesAnything pnpm exec can reach is pre-approvedthe narrow rule does no work
Neither file shows this on its own, which is what made the narrowed rule look like it was working.

So I had the local file cut down to the one thing it uniquely provided, additionalDirectories. The agent verified that by set arithmetic rather than by reading: of the 84 rules removed, 82 were still present in the committed file, and the only two that were not were exactly the dead rule and the over-grant. It reported the cleanup as safe.

The verification measured the wrong property

The next browser automation call asked for approval. So did the one after it.

Restoring settings.local.json stopped the prompts, with settings.json untouched throughout, which is a strange result if both files hold the same rule. The answer is in the permissions documentation:

permissions.allow rules and permissions.additionalDirectories entries in a project’s .claude/settings.json grant capability, so Claude Code applies them only after you accept the workspace trust dialog for that folder.

This repository had never been trusted. hasTrustDialogAccepted was false for it in ~/.claude.json, so every allow rule in the committed file was being held. The gitignored file was carrying the 82 they had in common; the handful that existed only in the committed file were in effect nowhere at all. The local copy escapes the gate for a specific reason: Claude Code runs git to tell your own file from a repository-supplied one, and holds the local file’s rules too if it ever becomes tracked.

Why committing the local file does not help

That detail rules out the tidy-looking fix. Committing settings.local.json to stop the two files drifting converts it into a repository-supplied file and gates it behind the same check, so both files end up held and nothing is in effect.

The set arithmetic was correct and useless. It compared the contents of two files and concluded a rule was covered because it appeared in the other one. Whether a file’s rules apply at all is not a property of its contents, so no amount of comparing them could have caught this. The gate is also asymmetric, which is the part worth remembering: deny is never held, from any scope. An untrusted repository can restrict what you may do and can never widen it.

There is a reason this took two prompts to notice rather than none. An approved prompt and a pre-approved call are indistinguishable from the agent’s side — both return a successful tool result. The agent reported “no prompt” about a call I had in fact just approved by hand, and only saying so out loud corrected it.

Summary

  • .claude/ is a protected path. Writes there are never auto-approved outside bypassPermissions mode, and the safety check runs before permissions.allow is read, so an Edit(.claude/**) rule is never consulted rather than being overruled.
  • A file your pipeline writes to does not belong in a skill directory. Nine instruction files edited a few times a year and one table edited by a machine several times a month have opposite lifecycles, whatever the directory listing suggests.
  • `${CLAUDE_PROJECT_DIR}` is the supported way out. It names a project file independently of where the skill is installed, and unlike a relative path it survives a change of working directory. It needs v2.1.196 or later.
  • Verify substitution rather than inferring it from a version number. A throwaway skill containing the bare variable answers the question in one invocation.
  • Deduplication relocates unless you check. The replacement for a duplicated constant re-declared an existing one four lines below the import that already had it.
  • Allow rules merge, so a narrowed rule can be silently re-widened elsewhere. Neither settings file shows the effective policy alone.
  • A committed .claude/settings.json grants nothing until the folder is trusted. Its allow rules are held; deny is not, from any scope. Check hasTrustDialogAccepted before concluding a rule is in effect, and do not try to escape the gate by committing settings.local.json, which only pulls that file behind the same check.
  • Comparing file contents cannot tell you what is in effect. A rule present in another file is not thereby active, and that distinction is invisible to any check that reads only the files.

References

Share this article