# 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.

- Source: https://oharu121.com/blog/claude-code-project-dir-skill-glossary-approval-prompts/
- Published: 2026-08-17T10:58:35+09:00
- Tags: Claude Code, Developer Tooling, Automation

---
## 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:

```text title=".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.

*Figure — PermissionOrder: 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.

*Figure — GlossaryReference: 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:

```text title=".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:

```ts title="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:

```text
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.

*Figure — RuleShadowing: 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

- [Claude Code permission modes, including the protected paths section stating that allow rules are evaluated after the safety check](https://code.claude.com/docs/en/permission-modes)
- [Claude Code skills reference, with the table of string substitutions and the v2.1.196 requirement for CLAUDE_PROJECT_DIR](https://code.claude.com/docs/en/skills)
- [Anthropic's skill authoring best practices, which assumes supporting files are bundled inside the skill directory](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)
