把 GH_TOKEN 換成假的,出網前再換回來:Claude Code 沙箱的憑證遮罩
寫於 2026 年 8 月 11 日,9 月才上線(部落格的發佈額度 8 月 10 日就用完了,稿子積壓了三週)。文中的版本號與「目前」都指 8 月 11 日的狀態,Claude Code 更新很快,你讀到時可能已經又改過了。
沙箱打開,gh pr create 跟著壞掉。
順序通常是這樣:你決定讓 agent 自己跑指令,開了 sandbox,然後想到一件事。它既然跑得動 shell,那你的 GitHub token 就躺在它讀得到的地方。於是你把該擋的都列進去:
1 | { |
deny 很誠實地做完了它答應的事。檔案讀不到,環境變數被 unset。代價是靠那個 token 認證的每一個工具,gh、npm、aws,全部一起躺平。
先講清楚我的位置。下面這些設定我沒有真的套到自己的 user settings 上跑過,那會動到全域設定檔。內容以官方 sandboxing 文件為準,我會標明哪些是文件寫的、哪些是我自己推的。我本機跑 claude --version 是 2.1.227,等一下提到的版本門檻都已經涵蓋。
兩條看起來合理、其實都不太行的路
第一條是把 token 放回去,反正 agent 也不會亂來。這條路的問題不用解釋。
第二條認真一點:另外申請一組權限縮到最小的 token,專門給沙箱用。方向是對的,但它把一次性的設定變成長期的維運成本。每個服務都要多開一組、要記得輪替、要在出事時分辨是哪一組流出去的。更麻煩的是,縮到最小的 token 仍然是一個能用的 token,agent 一樣會把它 echo 進 log、寫進錯誤訊息、貼進它自己產生的 issue 內容裡。
問題的形狀其實很清楚:你要的是「指令能認證成功」,你不要的是「指令握有憑證」。大部分人下意識把這兩件事當成同一件,因為在沒有中間人的世界裡它們確實是同一件。
換一個角度:讓真值只存在於出網那一刻
mode: "mask" 的做法是這樣。沙箱裡的指令去讀 GH_TOKEN,讀到的是一個每個 session 隨機生成的佔位值,文件叫它 sentinel。指令拿這個假值去發請求,請求在離開沙箱時會經過 proxy,proxy 對你允許的主機把 sentinel 換回真的 token 再送出去。
像是你把信交給櫃檯寄出,信封的寄件地址你只寫了一個代號,櫃檯在真正投遞前把代號換成真實地址。屋子裡的人從頭到尾只看過代號。
設定長這樣(環境變數的 mask 需要 v2.1.199 以上):
1 | { |
兩個變數的寫法差一個欄位,行為差很多。文件講得很直接:
GH_TOKENis substituted only on requests toapi.github.com, whileNPM_TOKENhas noinjectHostsand is substituted on requests to every host innetwork.allowedDomains.
也就是說沒寫 injectHosts 等於「凡是允許的網域都幫你換」。你發往 *.github.com 的請求裡,只要出現那個 sentinel,一樣會被換成真的 npm token。預設值選了方便的那一邊,代價要自己補上。
另外一個限制:injectHosts 裡的每個項目,本身也必須被 network.allowedDomains 涵蓋。
替換發生在 headers 跟 request body 兩個地方。
坑一:沒設 tlsTerminate,整套靜靜地不生效
network.tlsTerminate 是必要條件,不是選配。proxy 得先能看到請求內容才有得替換,TLS 沒終止它就只看得到一坨密文。
漏了會發生什麼?文件的原話是 masking「fails without exposing anything」。指令拿到的還是 sentinel,sentinel 原封不動送到伺服器,然後認證失敗。真憑證沒有外流,只是你的請求一個都過不了。
這個設計值得停下來看一眼。它壞的方向是「東西不能用」,不是「東西能用但沒保護」。後者才是真正危險的失敗模式,因為一切看起來都正常,你會以為防護生效了。Claude Code 另外會在啟動時就報這個組態錯誤,不會讓你跑到一半才發現。
坑二:macOS 上的檔案遮罩,其實就是 deny
檔案也可以 mask(需 v2.1.221 以上),例如把 gh 存 token 的那份 YAML 用正規表示式挖出來:
1 | { |
extract 只替換每個 match 的第一個捕捉群組,所以檔案的其他部分維持可讀,gh 還是解析得動。同樣的招數用在 DATABASE_URL 上,可以只遮掉密碼那一段、留下 host 跟 db 名稱讓程式繼續運作。文件要求 pattern 至少要有一個捕捉群組。
平台差異在這裡:Linux 跟 WSL2 是讀到 sentinel 副本、出網時替換;macOS 是完全讀不到那個檔案,不建 sentinel 副本、也沒有替換,效果等同 deny。而且它比 deny 還硬,文件說這個讀取封鎖「holds even when you disable filesystem isolation」。
我人在 macOS,所以這條對我來說等於「檔案版遮罩沒得用,環境變數版才有用」。文件建議的驗證方式是叫 Claude 在沙箱指令裡 cat 那個檔案,Linux/WSL2 會看到 sentinel,macOS 會讀取失敗。這個我沒實際跑(要先改 user settings),寫在這裡是文件說法,不是我的實測。
順帶一提,mask 遇到它無法安全處理的目標會退回 deny:目錄路徑、glob pattern、大於 8 MiB 的檔案、非 UTF-8 的檔案。四種都是「沒辦法保證只換掉該換的部分」的情況。
坑三:AWS 的兩把鑰匙必須一起遮
SigV4 的簽章涵蓋請求內容,所以 proxy 換掉憑證之後必須重新簽章。它靠 access key ID 的 sentinel 來認出「這是一個 SigV4 請求」。
於是 AWS_ACCESS_KEY_ID 跟 AWS_SECRET_ACCESS_KEY 一定要成對遮罩。只遮 secret 不遮 access key ID,proxy 認不出來,請求就帶著佔位符的簽章送到 AWS 然後失敗,這種情況 Claude Code 啟動時會警告你。反過來只遮 access key ID 不遮 secret,它不會警告。
有三種請求形式 proxy 重簽不了,文件列了完整的表。aws-chunked 串流上傳,每個 chunk 的簽章鏈接自 seed signature,重簽等於重寫整個 body。presigned URL 的簽章藏在網址裡,根本沒有 Authorization header。SigV4A 非對稱簽章則是沒有共享金鑰可以重算 HMAC。
真的需要放行,credentials.sigv4 可以個別設成 passthrough。
一個容易錯過的設計決定
mask 相關的設定——包含 mask entries、network.tlsTerminate、awsPairs、sigv4——只從 user settings、managed settings、--settings 這個 CLI 旗標生效。寫在 repo 裡的 .claude/settings.json 或 .claude/settings.local.json 一律被忽略。
理由文件寫得很清楚:masking 這個動作「authorizes the proxy to send your real credential to the listed hosts」。它是一個授權行為。既然是授權,就不能讓你 clone 下來的專案自己宣告。
對照組是 deny:任何 scope 都可以加,因為它只會收緊;而且沒有任何 scope 移除得掉別的 scope 加上的 deny。同一個變數同時被列為 deny 跟 mask 時,deny 贏。
這種「放寬只能由上層決定,收緊誰都可以做」的不對稱,是設定系統裡少數幾個一眼就知道有人認真想過的地方。
還有一句話你最好現在就知道
文件裡有一行講得很白:
There is no built-in credential deny list.
沒有預設清單。你沒列進去的東西就是沒有保護,預設的讀取政策仍然允許沙箱裡的指令讀 ~/.aws/credentials 跟 ~/.ssh/。
我認為這是整份文件最容易被跳過、後果卻最大的一句。「我開了沙箱」跟「我的憑證被保護了」是兩件不同的事,中間隔著一份你自己要寫的清單。
回到一開始壞掉的那個 gh
現在的處理方式是這樣。tlsTerminate 打開,allowedDomains 只放你真的要它連的網域。GH_TOKEN 設成 mask,並且指定 injectHosts 為 api.github.com。沙箱裡的 gh 拿到的是假的,它發出去的 PR 建立請求在出網那一刻變成真的。它 echo $GH_TOKEN 也只會印出一串沒有用的字。
如果你是 macOS 使用者,檔案型憑證就別指望遮罩了,該 deny 的就 deny,然後把需要的憑證改用環境變數餵進去。
最後補一個版本對照,因為這幾個欄位是分批進來的。credentials 區塊本身要 v2.1.187,環境變數 mask 要 v2.1.199,檔案 mask 要 v2.1.221。至於 extract、decode、maskClaims、awsPairs、sigv4 這幾個進階欄位,要 v2.1.224 才有,而這一批是 8 月 7 日才發布的,也就是我寫這篇的四天前。
如果你照著設定卻發現欄位不認得,先看版本。


























































































































































































