標籤
白色卡片上的赭紅色 Claude 星形標誌與黑色字樣

用 CLAUDE_PROJECT_DIR 把詞彙表移出 Claude Code skill — 寫入 .claude/ 為何一律要核准

Claude Code 把 .claude/ 視為受保護路徑,允許規則無法事先核准那裡的寫入。這篇記錄如何用 CLAUDE_PROJECT_DIR 把會被改寫的詞彙表移出去。

本頁目錄

引言

這個部落格由一個 Claude Code skill 撰寫,它會把每篇文章翻成日文與繁體中文,而讓這些翻譯保持一致的,是一份詞彙表:244 個詞、三個欄位,每個詞只綁定一種譯法。skill 有一條規則:譯者只要做過判斷的詞,就要在同一次變更裡加進去。也就是說,這條流程幾乎每寫一篇文章都會寫入那個檔案。而每一次寫入都會停下來,要求我核准。

被打斷只是每天的小煩躁。真正讓我在意的,是發現這個檔案擺錯了地方。它躺在 .claude/skills/blog/ 裡,和那些寫著指示的檔案並排,但它並不是指示。它是流程會讀、也會往裡面追加的資料,卻一直待在設定用的目錄裡假裝自己是散文。

解法不是權限規則,因為沒有任何權限規則做得到。.claude/受保護路徑,把檔案移出去是唯一可行的答案。skill 之後透過 `${CLAUDE_PROJECT_DIR}` 指向它,這是官方文件給 skill 用來指稱「不屬於自己的檔案」的方式。

本文整理三件事:看起來最理所當然的解法為什麼行不通、我在出貨前攔下的那條彎路,以及這次搬移暴露出來的兩個重複常數。

skill 裡唯一會被寫入的檔案

blog skill 由 10 個 markdown 檔組成。其中 9 個在告訴模型該怎麼做:怎麼安排敘事、程式碼圍籬怎麼寫、每個語系有哪些必要標題。剩下那 1 個是一張查詢表。

這個差別在目錄清單上看不出來,但在 git 歷史裡立刻現形。最近 10 次動到 glossary.md 的提交,全部都是發布文章的提交,因為「寫回」這條約定在 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.

每跑一次就被追加內容的檔案,本質上已經是資料庫。**另外 9 個檔案由人刻意編輯,一年幾次;這一個由機器編輯,一個月幾次。**同一個目錄,生命週期正好相反。

權限規則無法事先核准受保護路徑

最直覺的第一步,是把它當成設定問題處理。Claude Code 有允許清單,寫入的目標又是固定的,那麼加一條指名該檔案的項目,核准提示應該就會停。代理人於是去找該寫哪一條規則。

這是錯的,而且錯在一個「把 settings.json 讀爛也看不出來」的地方。文件明講寫入 .claude/ 底下*「除了 bypassPermissions 模式以外,永遠不會自動核准」*,接著把繞道也一併堵死:

設定檔裡的 permissions.allow 規則不會事先核准對受保護路徑的寫入。安全檢查在 Claude Code 評估設定中的允許規則之前就會執行,所以即使在 ~/.claude/settings.json.claude/settings.json 裡放上像 Edit(.claude/**) 這樣的項目,上表中各模式的結果也不會改變。

**順序本身就是機制。**權限規則不是被受保護路徑檢查蓋過去,而是根本不會被讀到。為這些寫入寫的規則,會以一副正確的樣子留在檔案裡,永遠比對不到任何東西。

受保護路徑檢查比權限規則先執行兩個寫入進入同一條兩階段流程。第一階段是受保護路徑檢查,第二階段是 permissions.allow 權限規則。寫入 .claude/ 底下的檔案會停在第一階段,一律出現核准提示;標上 ✗ 的虛線表示它不會走到第二階段,因此任何權限規則都不適用。寫入 docs/ 底下的檔案通過第一階段後抵達第二階段,可以自動核准。寫入 .claude/底下的檔案寫入 docs/底下的檔案1受保護路徑檢查一律出現核准提示2permissions.allow 權限規則不會被讀取可以自動核准
寫入 .claude/ 底下的檔案會停在第一階段,所以原本該涵蓋它的規則永遠走不到。

還有哪些路徑受保護

這份清單上不只有 .claude/.git.vscode.idea.husky.cargo.devcontainer.yarn.mvn 同樣受保護,唯一的例外是 .claude/worktrees,因為那是 Claude Code 放自己 git worktree 的地方。

**所以根本沒有什麼設定可以寫。**只能把檔案搬走。

CLAUDE_PROJECT_DIR 指的是專案的檔案,不是 skill 的檔案

代理人第一版的搬移方案,是把每一處參照都改寫成 ../../../docs/glossary.md。我在那裡把它攔下來,請它去查實際的慣例而不是憑印象,事後證明這個判斷正確了兩次。

調查找到的第一件事,反而是不利於這個計畫的。Anthropic 的 skill 撰寫指南預設輔助檔案就放在 skill 目錄底下的 reference/scripts/ 裡,而一般建議是:把東西包在一起,才能讓 skill 保持可攜與自足。那一頁完全沒有考慮放在儲存庫其他地方的檔案。

第二件事才是官方認可的例外。Claude Code 會把三個變數替換進 skill,其中一個正是為了這種情況而存在:

變數 展開成什麼 從哪個版本開始
`${CLAUDE_SKILL_DIR}` 放著這個 skill 的 SKILL.md 的目錄 未載明
`${CLAUDE_PROJECT_DIR}` 專案根目錄 v2.1.196
`${CLAUDE_PLUGIN_ROOT}` 外掛的安裝目錄,只適用於外掛的 skill 未載明

**動用中間那個之前,先確認你的版本。**它是三個裡面最新的,在更舊的版本上根本不會被替換:它會以 `${CLAUDE_PROJECT_DIR}` 這串原文抵達模型,沒有錯誤,輸出裡也沒有任何線索說明路徑為什麼沒解析出來。與其之後才發現「skill 安靜地找不到自己的資料」,不如一開始就知道。

文件把中間這個變數描述為用來參照*「專案內的指令稿或檔案,且不受 skill 安裝位置影響」。這正是這裡的情況,而且嚴格來說比代理人原本想用的相對路徑更好,因為同一頁也寫著工作目錄會「在 Claude 執行 cd 時移動」*。相對路徑在有東西改變目錄之前都是對的;變數則一直都對。

**一開始就沒有在冒可攜性的險。**詞彙表裝的是這個儲存庫的用語,本來就不是能帶著走的內容。skill 依然可攜,而它所綁定的資料現在明確屬於這個專案,這正是那個變數的用途。

詞彙表搬移前後的對照兩欄以三列對照。搬移前,詞彙表位於 .claude/skills/blog/glossary.md,skill 以相對連結 glossary.md 指向它,每次寫入都會出現核准提示。搬移後,它位於 docs/glossary.md,skill 以 ${CLAUDE_PROJECT_DIR}/docs/glossary.md 指向它,寫入直接通過。搬移前存放位置.claude/skills/blog/glossary.mdskill 如何指向它[glossary.md](glossary.md)寫入時每次都要核准搬移後存放位置docs/glossary.mdskill 如何指向它${CLAUDE_PROJECT_DIR}/docs/glossary.md寫入時直接通過
檔案換了目錄,skill 換了指向它的方式。寫回的規則完全沒有變。

散落在五個 skill 檔案裡的七處參照,收斂成同一種寫法:

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

搬走這個檔案之後暴露出來的事

有兩件事,只因為檔案擺在原本的位置才安靜地成立。

**章節標題的第二份副本。三個標題在每個語系都是固定字串,而 scripts/prose-check.ts 自己也存了那九個字串,上面還有一行註解寫著「per the table in glossary.md」。這行註解同時點名了重複、也解釋了原因:指令稿沒有立場伸手進 skill 目錄,所以它讀不到那張表本身。沒有任何東西驗證這兩份副本一致。**它們確實一致,但那是運氣。**因為改詞彙表和改檢查器是兩個各自獨立的動作,少做其中一個,另一個不會察覺。搬到 .claude/ 外面之後,指令稿讀得到那張表,第二份副本就消失了。

**一個方向相反的 CI 缺口。**工作流程會略過 .claude/**,所以只動用語的提交從來不會觸發 CI。把檔案搬出去之後,為了出貨一個位元組完全相同的網站,安裝、檢查、建置到正式環境部署會整套跑起來。我在同一次變更裡把 docs/** 加進兩個 paths-ignore 區塊,並用註解寫下什麼條件會讓這個項目變成錯的。

接著,消除重複這件事在隔壁的檔案又上演了一次。代理人寫的解析器宣告了一張欄位標題對應語系的表:

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

這三個語言自稱早就以 LOCALE_LABEL 的形式存在於 src/i18n/config.ts,一個位元組都不差,而且就在這個新檔案往上四行才剛 import 的模組裡。一個目的就是刪掉重複常數的變更,自己帶進了一個重複常數。程式碼審查在合併前抓到了,現在它改成從既有的紀錄推導出來。不過真正值得記住的是這件事:消除重複往往只是把它搬到別處,而不是真的移除,而搬過去的那份副本最難被看見的時刻,就是它落在讓它變得多餘的那行 import 旁邊。

與其相信版本號,不如驗證替換本身

`${CLAUDE_PROJECT_DIR}` 需要 Claude Code v2.1.196 以上。當時這台機器是 2.1.193,差了三個修訂號,所以第一版的每一處參照都附了一段備援說明,寫出從儲存庫根目錄算起的路徑。

升到 2.1.233 之後,從版本數字看,這個功能應該可用了。但那只證明功能有出貨,不代表它在 skill 的 markdown 裡真的會觸發,於是代理人在一個暫時的專案裡做了一個用完就丟的 skill,裡面只寫那兩個變數,然後呼叫它:

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

兩個都以真實的絕對路徑回來了,這表示那段備援文字描述的是一個再也不會發生的分支。我請它把那段刪掉。描述不可能狀態的指示並不是免費的:SKILL.md 每次呼叫 /blog 都會載入,而寫著不可能發生的情況的說明,正是讀者學會略過那些可能發生的說明的原因。

過程中還冒出一個這次探測原本沒打算找的細節。撰寫本文時,代理人在呼叫 skill 時把 `${CLAUDE_PROJECT_DIR}` 當成字面引數傳進去,結果它抵達時已經展開成絕對路徑。替換不只作用在檔案裡寫下的內容,也作用在被插進 skill 內容裡的引數。

第二條什麼也沒做的權限規則

受保護路徑帶來的結論是:一條規則可以躺在設定檔裡,卻從來沒被讀過。這個念頭一旦成形,就會想看看還有沒有別的。

這個儲存庫有兩個設定檔:納入版控的 settings.json,以及被 gitignore 排除的 settings.local.json。後者已經漂移成前者的近乎副本,94 行裡裝著自己的一份 84 條允許規則。其中有兩條在納入版控的那個檔案裡並不存在:

只存在於 local 檔案的規則 它實際上在做什麼
Bash(pkill -f "astro preview") 什麼也沒做,因為真正的命令列是 astro.mjs preview
Bash(pnpm exec *) 悄悄把版控那份收窄成 Bash(pnpm exec astro *) 的規則又放寬回去

有意思的是第二條。權限規則會跨設定檔合併,所以只在其中一個檔案裡收窄規則,只要另一個檔案還留著更寬的那份副本,就完全沒有效果。

依這個讀法,版控裡的 pnpm exec astro * 什麼也沒做,因為 pnpm exec 能觸及的一切,早就被隔壁那條規則事先核准了。結論成立,理由卻不成立,而那就是下一節。

一個設定檔裡的寬規則,把另一個設定檔裡的窄規則重新放寬兩個設定檔並排。納入版控的 settings.json 有範圍窄的規則 Bash(pnpm exec astro *);被 gitignore 排除的 settings.local.json 有範圍寬的規則 Bash(pnpm exec *)。因為權限規則會跨設定檔合併,兩者都流入合併區塊。依這個讀法,合併後的結果會自動核准 pnpm exec 能觸及的一切,所以範圍窄的那條規則沒有發揮作用,而單看任何一個檔案都看不出這件事。文章接下來會找到版控那條規則失效的另一個、也更根本的理由。settings.json已納入版控Bash(pnpm exec astro *)範圍窄settings.local.json被 gitignore 排除Bash(pnpm exec *)範圍寬權限規則會跨設定檔合併pnpm exec 能觸及的一切都會自動核准範圍窄的那條規則沒有發揮作用
單看任何一個檔案都看不出這件事,所以那條收窄的規則看起來像是有在運作。

於是我請它把 local 檔案削到只剩它獨有的那一項 additionalDirectories。代理人不是靠閱讀驗證,而是靠集合運算:刪掉的 84 條裡有 82 條仍然存在於版控的檔案中,不存在的那 2 條,正好就是那條死掉的規則和那個過寬的授權。然後它回報說這次清理是安全的。

驗證量到的是另一個性質

下一個瀏覽器自動化的呼叫要求核准。再下一個也是。

settings.local.json 還原之後,核准提示就停了,而這期間 settings.json 完全沒動過。如果兩個檔案裝著同一條規則,這個結果就很奇怪。答案在權限文件裡:

專案的 .claude/settings.json 裡的 permissions.allow 規則與 permissions.additionalDirectories 項目會授予能力,因此 Claude Code 只會在你接受該資料夾的工作區信任對話框之後才套用它們。

這個儲存庫從來沒有被信任過。~/.claude.json 裡它的 hasTrustDialogAcceptedfalse,也就是說**版控那個檔案裡的每一條允許規則都被擱置著。**被 gitignore 排除的那個檔案扛著的,是兩邊共有的那 82 條;只存在於版控檔案裡的其餘幾條,哪裡都沒有生效。local 那份副本能繞過這道關卡,有一個明確的理由:Claude Code 會執行 git,用來分辨哪個是你自己的檔案、哪個是儲存庫提供的檔案;而一旦 local 檔案也被納入追蹤,它的規則同樣會被擱置。

為什麼把 local 檔案納入版控也沒用

這個細節否定掉了看起來最整齊的解法。為了不讓兩個檔案繼續漂移而把 settings.local.json 納入版控,只會讓它變成儲存庫提供的檔案,被同一道關卡擋在後面,最後兩個檔案都被擱置,沒有一條規則生效。

**那次集合運算是正確的,也是沒有用的。**它比較的是兩個檔案的內容,並因為某條規則出現在另一個檔案裡,就判定它有被涵蓋。但「一個檔案的規則到底會不會被套用」並不是它內容的性質,所以再怎麼比對內容都抓不到這件事。這道關卡還是非對稱的,而那正是值得記住的部分:deny 在任何範圍都不會被擱置。不被信任的儲存庫可以限制你能做什麼,卻永遠不能放寬。

要察覺這件事之所以中間隔了兩次核准提示,是有原因的。被人核准過的提示,和一開始就事先核准的呼叫,從代理人這一側看是分不出來的,兩者都回傳一個成功的工具結果。代理人針對一個我剛剛親手核准的呼叫,回報說「沒有出現提示」,直到我把這件事講出來才被修正。

總結

  • **.claude/ 是受保護路徑。**除了 bypassPermissions 模式,那裡的寫入永遠不會自動核准,而且安全檢查跑在讀取 permissions.allow 之前,所以像 Edit(.claude/**) 這樣的規則不是被蓋過去,而是根本沒被讀到。
  • **流程會寫入的檔案,不該放在 skill 目錄裡。**一年被人編輯幾次的 9 個指示檔案,和一個月被機器編輯幾次的 1 張表,就算在清單上長得一樣,生命週期也是相反的。
  • **`${CLAUDE_PROJECT_DIR}` 是官方認可的出路。**它指向專案裡的檔案而不受 skill 安裝位置影響,也不像相對路徑那樣怕工作目錄被改。需要 v2.1.196 以上。
  • **驗證替換本身,不要從版本號推論。**一個只寫著那個變數、用完就丟的 skill,一次呼叫就能給你答案。
  • **不去確認的話,消除重複只是搬家。**用來取代重複常數的東西,在讓它變得多餘的那行 import 下方四行,又重新宣告了一個既有的常數。
  • **權限規則會合併,所以收窄的規則可能在別處被悄悄放寬。**任何一個設定檔單獨看,都不代表實際生效的狀態。
  • **納入版控的 .claude/settings.json 在資料夾被信任之前什麼都不會給。**它的 allow 會被擱置,deny 則在任何範圍都不會。在斷定某條規則有生效之前,先確認 hasTrustDialogAccepted;也不要想用把 settings.local.json 納入版控的方式繞過這道關卡,那只會把那個檔案一起拉到關卡後面。
  • **比對檔案內容無法告訴你什麼正在生效。**某條規則存在於另一個檔案,不代表它因此就有作用,而這個差別對任何只讀檔案的檢查來說都是看不見的。

參考連結

分享這篇文章