Hub Claude Code 教學

大師篇 · 第 20 章

Headless/CI 與安全硬化

前面學的都是「你坐在終端機前,跟 Claude 一來一回對話」。這一章把它整個翻過來:讓 Claude 變成自動化流水線裡沒人盯著也能跑的一步——一行 claude -p 餵進去、一段乾淨的結果吐出來。你會學到怎麼用 JSON schema 把輸出鎖成程式讀得懂的格式、怎麼即時監看它跑到哪、怎麼把多次呼叫串成一條鏈。最後,也是最重要的:當你把「會自己動手改 code、開 PR」的代理人放進 CI,等於開了一道新的攻擊門——這章教你照威脅模型,把它的權限收到最小。第 11 章已經帶你入門 GitHub Actions 與 PR 自動審;這章往深處走,講無人值守與安全硬化。

本章會用徽章標每段料的可信度

這一章混用不同來源,正文每個重點旁會掛一個 source-tag 徽章標明分級:官方 出自 Anthropic 官方文件;達人 是具名資安研究者或機構掛名發表的分析(例如揭露某個漏洞的研究員本人);社群 是社群實踐者整理;研究預覽 是前沿、官方標明可能變動、斟酌採用。看到徽章就知道這句話該信幾分。

20.1 Headless:讓 Claude 變成流水線裡的一步

到目前為止,你跟 Claude 都是「互動式」的——它問你、你答它,一來一回。(無頭模式)剛好相反:你給它一句指令,它不開對話、不等你回,直接跑完、把結果吐出來就結束。這正是自動化要的——CI 流程、pre-commit 檢查、批次處理,全都沒有「人」可以在旁邊敲鍵盤。

開關就是一個旗標:-p(也可寫成 --print)。後面接你要它做的事,它做完印出結果就退出。官方

建議執行:在任一專案資料夾,叫它一句話做完一件事

# -p:不開對話,做完印出結果就結束
claude -p "把 README.md 開頭的專案簡介翻成英文,只輸出翻譯結果"

預設它吐的是「給人看」的純文字。但流水線裡接手的是程式,程式要的是好解析的格式。所以 headless 有兩種機器友善的輸出格式,用 --output-format 切換:json 一次回一包完整 JSON、stream-json 邊跑邊串流(下面 20.4 細講)。

輸出格式 長什麼樣 什麼時候用
(預設) 純文字,給人讀。 你在終端機手動跑、看一眼結果。
--output-format json 一整包 JSON,跑完才回。 程式要拿結果、抽欄位(搭 jq)。
--output-format stream-json 一行一個事件的 NDJSON,邊跑邊吐。 要即時監看進度、做串流顯示。

最常見的 headless 用途是跨大量檔案做同一件事(fan-out)——比如把一整個資料夾的檔案逐一遷移、修語法。做法是寫個迴圈,對每個檔案跑一次 claude -p。但這裡有個鐵則:先拿兩三個檔案試,把 prompt 調到滿意,再放手讓它跑幾百個。否則 prompt 沒調好就大規模跑,等於把同一個錯誤複製幾百遍。官方

範例:對檔案清單批次處理,並用 --allowedTools 限定它只能做哪些事

# 先測 2~3 個檔案,prompt 滿意了再換成完整清單
# --allowedTools:只准它改檔、只准它跑 git commit,別的工具一概不給
for f in src/legacy/*.js; do
  claude -p "把 $f 從 CommonJS 改成 ES Module 語法" \
    --allowedTools "Edit, Bash(git commit *)"
done

沒人盯著,權限就更要收緊

互動模式下它要動危險操作會問你;headless 模式沒有人可以問。所以一定要用 --allowedTools 把工具白名單列死——只給這次任務真正需要的那幾個。給得越少,它能闖的禍越小。

背景程序:它會不會卡住不退出?

第 10 章提過,headless 模式拿到最終答案後,會把期間開的背景程序(例如你順手叫它起的 dev server)強制關掉,不會讓它一直留著跑。這裡補一個對 CI 穩定性更關鍵的細節:官方 這個「關掉」不是說關就關——從 v2.1.163 起,claude -p 印出最終結果、stdin 關閉之後,會給背景程序一段大約 5 秒的緩衝期收尾,時間到才強制終止;在這之前的版本,一個「永遠不會自己結束」的背景程序,足以讓整支 claude -p 卡住、永遠不退出——這正是不少人第一次把 headless 排進 CI 時,「怎麼這次跑到 timeout 都沒動靜」的真兇。實際版本門檻請以 claude --help 或官方文件為準,這類數字更新得快。

另一個常被忽略的上限,管的是背景子代理/工作流本身(跟前面那個「背景 Bash 程序」是兩回事):從 v2.1.182 起,等一個背景 subagent 或 workflow 跑完,預設有 10 分鐘的上限,逾時就不再乾等;想調整就設環境變數 CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS(單位毫秒,設 0 代表無限等待)。官方 沒事先設好這個值,最怕的狀況是:一個卡住的背景任務,把整條 CI pipeline 的 timeout 慢慢吃光,最後只留下一句語焉不詳的逾時訊息,完全看不出真正卡在哪一步。

--permission-mode:沒人問你的時候,基準線設在哪

第 7 章教過互動模式下用 Shift + Tab 在「預設/接受編輯/計畫」之間循環切換;第 15 章給過完整的六階梯表。headless 模式沒有鍵盤可按,你得在啟動那一刻就用 --permission-mode 講清楚:遇到它想做、卻沒人在旁邊核准的事,基準線是「一律拒絕」還是「常見動作自動放行」。CI 裡最常用到的兩個基準是:

模式 沒人核准時,它怎麼辦
dontAsk 只有 permissions.allow 明確列出的、或內建的唯讀指令,才會放行;其餘一律直接拒絕。最鎖死,適合完全不信任的執行環境。
acceptEdits 檔案編輯、以及常見檔案系統類 Bash 指令(像 mkdirtouchmvcpsed)自動核准;但其他 Bash 指令與任何網路請求,仍然要 --allowedToolspermissions.allow 規則明確放行才行。

acceptEdits 不是「這次任務全部自動過」

最常見的誤會,是以為開了 acceptEdits,這次 headless 任務不管做什麼都會自動放行。實際上它只保證檔案編輯跟那幾類檔案系統指令會自動過——只要它想跑清單外的 Bash 指令、或碰網路,一樣得問,而 headless 沒人可以回答,整次執行就直接中止。真要讓「這次任務內會用到的東西全部自動放行」,還是得用 --allowedTools 把它會碰到的每一類工具都列清楚,別把 acceptEdits 當成萬用鑰匙。

20.2 Bare mode:每台機器跑出一模一樣的結果

claude -p 很方便,但它啟動時會自動撈一堆東西:你的 hooks、skills、plugins、MCP 連線、自動記憶、還有專案裡的 CLAUDE.md。這些在你自己電腦上很貼心,但在 CI 裡會出事——每台機器撈到的東西不一樣,結果就不一樣。今天在你電腦上過,明天在同事的 runner 上掛掉,查半天才發現是某個 local 設定在搞鬼。

--bare 就是來解這個的。加上它,Claude 跳過所有自動發現——hooks、skills、plugins、MCP、自動記憶、CLAUDE.md 全部不載入,只有你在指令裡明確傳的旗標才生效。換來的是:每台機器、每次執行,結果都一致。附帶好處是啟動快約 10 倍(因為省掉那堆撈取)。官方

範例:scripted/SDK 場景用 --bare 求確定性

# --bare:不撈 hooks/skills/MCP/CLAUDE.md,只認你明確傳的旗標
# 適合放進 CI、SDK、任何要求「每次都一樣」的腳本
claude -p --bare "跑 npm test,只回 PASS 或 FAIL" \
  --allowedTools "Bash(npm test)"

「未來會變成 -p 的預設」是規劃,不是現況

官方文件提到 --bare 將來可能成為 -p 的預設行為 官方。請注意這是一句規劃陳述,不是當下事實——目前它仍是要你自己加的選項。寫腳本時,要確定性就明確加上 --bare,別賭它預設已經幫你開了。

--bare 還有一個常被忽略的副作用:官方 它連認證方式都一併收緊了。第 3 章提過,排程腳本可以用 CLAUDE_CODE_OAUTH_TOKEN 這組長效憑證登入、省去每次手動登入;但那組憑證走的是一般訂閱額度的登入路徑,--bare 模式下不會讀取它--bare 只認兩種認證:環境變數 ANTHROPIC_API_KEY,或是 --settings 檔案裡設定的 apiKeyHelper(一支自訂腳本,跑出來的字串當 API key 用)。換句話說,你在自己電腦上登入好好的 Claude Code,直接搬進 --bare 腳本很可能會卡在認證失敗——這不是設定錯,是這個模式本來就不看那條路。

「沒噴錯」不等於「有照你想的跑」

--bare 跳過的不只是慢,是整批設定來源——hooks、skills、plugins、.mcp.json 裡設定的 MCP server、CLAUDE.md,全部不讀取。如果你的 CI 腳本是照本機互動模式的行為寫的,卻沒注意到自己用了 --bare,某些平常會觸發的 hook 或 MCP 工具,在 CI 裡會安安靜靜地完全沒有執行——因為 --bare 模式根本沒去讀那些設定檔,連個警告都不會印。要驗證這件事有沒有發生在自己身上,最直接的方法是先不加 --bare 跑一次、再加上跑一次,比對兩次的行為有沒有少了什麼。

20.3 用 JSON Schema 把輸出鎖成程式讀得懂的格式

讓程式去解析「一段自然語言回答」是惡夢——它今天回「結果是 3 個錯誤」、明天回「我找到了三個問題喔」,你的 grep 就崩了。解法是叫 Claude 把答案填進一個你規定好的 JSON 結構。你給它一份 (資料藍圖),它的輸出就保證長那個樣子。

做法是 --output-format json 再加 --json-schema 把藍圖傳進去。結果會落在回傳 JSON 的 structured_output 欄位,你用 jq 一抽就拿到乾淨的值。官方

範例:規定它回一個有 passed(布林)和 issues(清單)的物件

# --json-schema:規定輸出必須符合這份藍圖
# 最後用 jq 抽出 structured_output 裡的值
claude -p "檢查這個 PR 有沒有明顯 bug" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"passed":{"type":"boolean"},"issues":{"type":"array","items":{"type":"string"}}},"required":["passed","issues"]}' \
  | jq '.structured_output'

這一招把 Claude 變成流水線裡可機讀的一站:前一步餵它資料,它回結構化結果,後一步的程式接著做判斷(例如 passedfalse 就讓 CI 紅燈)。順帶一提,這包回傳 JSON 還附了 total_cost_usd(這次花了多少錢)和逐模型成本明細,要做成本監控的話直接抽來用。官方

給的 schema 本身不合法時,Claude 怎麼反應也是版本相依的細節,值得留意:官方 目前的行為是直接報錯退出,訊息類似「Error: --json-schema is not a valid JSON Schema」,還會附上驗證器的診斷細節,讓你知道錯在哪一個欄位。但這是修過的行為——v2.1.205 之前,遇到不合法的 schema 不會報錯,而是靜默放棄結構化輸出、退回一般文字,你以為自己拿到的是保證符合格式的 JSON,實際上收到的只是一段普通文字,程式接手解析時才爆炸;更麻煩的是那個版本連「schema 裡含 format 這個關鍵字」都會被誤判成不合法,平白讓一堆本來寫得好好的 schema 中招。實際版本門檻以官方文件或 claude --help 為準;手上的版本比較舊、或不確定自己用的版本,寫程式時多包一層「這包到底是不是 JSON」的檢查,別完全信任 structured_output 一定存在。

20.4 串流事件:即時監看它跑到哪、必要時讓 CI 失敗

--output-format json 要等它整個跑完才回一大包。如果任務很長、你想邊跑邊看,就用 stream-json——它邊做邊吐事件,一行一個 JSON(這種格式叫 NDJSON)。配 --verbose --include-partial-messages,連「正在打字」的片段都會即時送出。官方

範例:把串流裡的文字片段即時印出來

# stream-json 邊跑邊吐事件;用 jq 篩出文字片段做即時顯示
claude -p "重構這個模組並逐步說明" \
  --output-format stream-json --verbose --include-partial-messages \
  | jq -j 'select(.type=="text_delta") | .text'

串流事件還有一個 CI 專屬的妙用:開頭會有一個 system / init 事件,裡面列了這次載入了哪些 pluginplugins)以及哪些載入失敗plugin_errors)。你可以在 CI 裡檢查它——如果某個必要的 plugin 沒載成功,就主動讓這次建置失敗,而不是讓它帶著殘缺狀態默默跑完。官方

「沒壞」不等於「真的有載到」

CI 最怕的不是報錯,是該做的事悄悄沒做。檢查 plugin_errors 就是把「必要工具有沒有真的就位」變成一個會擋紅燈的硬條件——這跟本書一路強調的「拿證據說話、別假設成功」是同一套精神。

往回看那個開場的 system / init 事件,它裡面其實不只有 pluginsplugin_errors——這次呼叫用的模型model)、可以動用的工具清單tools)、以及連上了哪些 MCP server,都在同一個事件裡一次交代清楚。官方 對寫 CI 腳本的人來說,這個事件等於「這次執行環境的體檢報告」——與其等任務跑到一半才發現工具沒接上,不如在一開始就檢查這個事件的內容,環境不對就提早讓它失敗,省下白跑一輪的時間。

串流事件還有一種你多半只在網路不穩時才會遇到、但值得先認識的類型:重試事件。當 API 請求卡到「可以重試」的錯誤(例如伺服器忙線),Claude 會先發出一個 system / api_retry 事件才真的重試,裡面帶著第幾次重試attempt)、最多重試幾次max_retries)、這次要等多久retry_delay_ms)、以及錯誤本身的分類(error_statuserror)。官方 平常你根本不會注意到它在重試——CLI 自己處理掉了;但如果你在寫一個要顯示即時進度的介面,攔截這個事件就能告訴使用者「現在不是卡死,是在等 API 恢復」,而不是讓畫面停在那裡像當機了一樣。

20.5 把多次呼叫串成一條鏈:session resume

有時一件事得分好幾步、而且後面要記得前面的脈絡。headless 預設每跑一次就是全新一張白紙,記不得上一次。解法是接住上一次的 session、續上去

每次 headless 呼叫的回傳 JSON 裡都有一個 session_id。把它抓下來,下一次用 --resume "$session_id" 就能接著同一段對話跑;如果只是想續「最近那次」,更簡單,直接 --continue。這讓你能組出有狀態的多階段 CI——第一階段建上下文,後面幾階段接著用。官方

範例:第一步抓 session_id,第二步接著它跑

# 第一步:跑完,把 session_id 抓進變數
SID=$(claude -p "讀完整個 src/ 並摘要架構" --output-format json | jq -r '.session_id')

# 第二步:用 --resume 接住上一段脈絡,它還記得剛剛讀的架構
claude -p "根據剛剛的架構,找出最該補測試的三個模組" --resume "$SID"

換個資料夾,session_id 可能就找不到了

--resume 找 session 的範圍不是整台機器,而是限定在「目前的專案資料夾與它的 git worktree」。官方 這代表如果多階段 CI 腳本中間切換了工作目錄(例如第一階段在 repo 根目錄跑、第二階段卻換到子資料夾或另一個 worktree),拿著同一個 session_id--resume 可能會直接找不到,而不是接上錯的對話。寫多階段腳本時,記得把每一階段釘在同一個工作目錄,別讓 cd 悄悄把接續脈絡的路徑切斷。

20.6 把 Claude 變成專案專屬的 linter

一個很實用的小套路:把 headless 包進專案的 npm script,當成專案自己的檢查器。經典例子是「錯字 linter」——把這次改動的 diff 餵給 Claude,叫它只挑錯字和明顯筆誤。官方

這裡有個漂亮的安全細節:用 pipe 把 diff 餵進去git diff | claude -p),Claude 就不需要 Bash 權限去自己讀檔——資料是你「送」進它嘴邊的,它不必伸手去拿。再把權限模式鎖死(例如 dontAskacceptEdits),就成了一個安全的 scripted review。

範例:放進 package.json 的 scripts,一行 npm run lint:typo 就跑

# package.json 的 "scripts" 裡加一行:
# 把跟 main 的差異 pipe 給 Claude,它只當錯字檢查器,不碰其他檔
"lint:typo": "git diff main | claude -p 'You are a typo linter. Only report spelling and obvious typos in the diff. Output nothing if clean.'"

「pipe 餵資料」是個反覆好用的安全手法

讓 Claude 自己用工具去讀,它就需要讀檔/跑指令的權限;改成你把資料 pipe 進去,它只處理眼前這份、不必拿任何額外權限。能用 pipe 餵的,就別開權限讓它自己去抓——這在 headless 與 CI 裡特別划算。

第 5 章提過,headless 每次呼叫都是全新獨立的 session,沒有連續對話可以依賴「它讀過 CLAUDE.md」這件事;那裡留了一個伏筆,這裡接上:--append-system-prompt 可以把一段指示疊加進系統提示(不是整個換掉,是在原本的 system prompt 之後),保證等級比 CLAUDE.md 高,又不會丟掉 Claude Code 原生的工具使用與格式慣例。官方 拿錯字 linter 這個例子延伸:如果想讓同一支腳本在「錯字檢查」跟「安全掃描」兩種角色間切換,不用整套 prompt 重寫,加一句 --append-system-prompt "你是資安工程師,只挑漏洞,不管風格" 就能窄化這次的任務焦點,其餘行為照舊。

範例:同一份 diff,換一頂帽子重新審一次

# --append-system-prompt:疊加角色設定,不是取代整個 system prompt
git diff main | claude -p "檢查這份 diff" \
  --append-system-prompt "你是資安工程師,只挑安全漏洞,其餘一律不管"

diff 太大,餵進去反而幫倒忙

把整包 git diff 直接塞給 Claude 看似方便,但 diff 一大(社群估計超過約 10 萬字元就算大),很容易把 context window 撐爆,尤其是 PR 裡混了 vendor/、lockfile、自動產生的檔案這類「改動很多行、但沒有真的需要審查」的內容。想避開這個坑,先用副檔名或路徑的 glob pattern 把 diff 範圍濾過一輪,只把真正要審的原始碼餵進去,而不是整包 git diff 照單全收。

20.7 官方 GitHub Action:讓 @claude 進你的 PR 與 issue

前面都是你自己在腳本裡呼叫 headless。如果你想要的是「在 GitHub 上 @claude 一下,它就來幫忙審 PR、處理 issue」,官方有現成的 GitHub Action:anthropics/claude-code-action。它會自動判斷該用什麼模式回應——你在留言 @它、把 issue 指派給它、或在 workflow 裡寫明 prompt:,它都認得。安裝用 claude /install-github-app 一鍵搞定。官方

它附了一整套現成配方,挑你需要的接:

  • 自動 PR review:每開一個 PR 就自動審一輪。
  • 關鍵檔案加嚴審查:只有改到特定路徑(如 auth/payments/)才觸發更嚴的審查。
  • 外部貢獻者加嚴:來自 fork 的 PR 套更保守的權限。
  • cron 健康檢查:定時跑一輪健檢。
  • issue triage:新 issue 進來自動分類、貼標。
  • docs sync:code 改了提醒同步文件。
  • OWASP 安全 review:照安全清單掃一遍。

它支援 Anthropic API、Bedrock、Vertex、Foundry 多種後端,挑你公司在用的接。官方(這個 Action 在 2025-08-26 釋出 v1.0、目前持續維護。)這節是第 11 章 GitHub Actions 基礎的延伸——第 11 章教你接第一個 workflow,這裡給你一張「常見場景照這個配」的菜單。

20.8 用 cron 排程,讓它定時自己跑

想要「每天半夜自動做一件事」——比如三點檢查 staging 的 log,有錯就開 issue、沒錯就丟個 Slack 摘要——用系統的 claude -p 就行。設好就忘了它,時間到自己跑。社群

這一節講的是最陽春、你自己完全掌控的路線:作業系統本身的排程器。第 10 章介紹過官方的雲端 Routines,如果不想自己顧一台開著的機器、也不想手動維護 crontab,那條路你電腦關機它照跑;這裡教的 cron 路線,勝在完全掌控、能直接碰你本機的檔案與工具。兩條路不衝突,看情境選。

cron 不會載入你的 shell 設定,API key 必須自己明確帶

這是最常見的踩坑:你平常在 ~/.zshrc 設好的 ANTHROPIC_API_KEYcron 跑的時候根本讀不到——因為 cron 不會載入你的 shell profile。所以排程腳本裡必須明確 export 一次 API key,否則時間到了只會安靜地認證失敗。這跟把資料庫密碼用環境變數明確傳進容器、而不是假設它「本來就在」是同一個道理。

範例:cron 腳本裡明確帶 key,並用旗標限住爆炸半徑

# 放進 cron 跑的腳本:key 一定要在這裡明確 export,別假設它在
export ANTHROPIC_API_KEY="sk-ant-..."

# --max-turns / --max-budget-usd:限住它最多跑幾輪、最多花多少錢
# 無人值守時,這兩個是你的安全氣囊,防它失控燒爆預算
claude -p "檢查 staging.log 有沒有 error,有就開 GitHub issue,沒有就丟 Slack 摘要" \
  --allowedTools "Bash(gh issue *), Bash(cat staging.log)" \
  --max-turns 20 --max-budget-usd 2

這節的細節來自社群實踐者

cron 排 claude -p 的這套做法整理自社群(MindStudio 等實踐者)社群,不是 Anthropic 官方文件的標準流程。手法本身可靠且常見,但請當「社群怎麼做」的參考,依你自己的環境調整。

更完整的無人值守硬化組合

除了前面的 --max-turns--max-budget-usd,還有幾個環境變數專門為「沒人看著」的場景設計,可以一起疊上:API_TIMEOUT_MS 拉長單一 API 呼叫的逾時(避免長任務被切斷)、BASH_MAX_TIMEOUT_MS 拉長單一 Bash 指令的逾時、DISABLE_TELEMETRY 關遙測、CLAUDE_CODE_DISABLE_BACKGROUND_TASKS 乾脆整個禁用背景任務(不確定會不會有背景程序卡住風險時的保守選擇)、CLAUDE_CODE_SUBPROCESS_ENV_SCRUB 清洗子行程能繼承到的機密環境變數(下一節「Sandbox」會解釋它實際怎麼運作)。官方 這類環境變數清單會隨版本增補,實際可用的完整清單以官方文件為準,這裡列的是無人值守腳本最常用到的幾個。

# 放進排程腳本開頭,幾個常見的硬化環境變數
export API_TIMEOUT_MS="900000"
export BASH_MAX_TIMEOUT_MS="1800000"
export DISABLE_TELEMETRY=1
export CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1
export CLAUDE_CODE_SUBPROCESS_ENV_SCRUB="AWS_SECRET_ACCESS_KEY,GITHUB_TOKEN,DATABASE_PASSWORD"

20.9 安全硬化:把「會自己開 PR 的代理人」當成攻擊面

這是全章最該認真讀的一節。當你把一個會自己改 code、會自己開 PR 的代理人放進 CI,你等於開了一道全新的門——而門後面是你的程式碼庫。微軟資安團隊(MSRC)專門分析過這個場景,把它當成一個供應鏈攻擊面來看待。官方(MSRC 屬權威資安來源。)

核心觀念是:代理人的「能寫、能開 PR 的權限」加上「它被允許用的工具清單」,合起來就是攻擊者會盯上的東西。如果有人能誘導它跑惡意指令、或在它的工具範圍內動手腳,傷害就會順著它的權限流出去。對應的硬化做法是把每一面都收到最小:

收窄哪一面 具體怎麼做
GitHub App 權限 只給這個 Action 真正需要的 repo 權限,別給全帳號、別給用不到的範圍。
API key 存放 key 只放 GitHub Secrets,絕不寫進 workflow 檔、log 或 commit。
觸發條件 限定哪些 event、哪些人能觸發;外部貢獻者(fork PR)套更嚴的規則。
工具白名單 --allowedTools 把它能用的工具列死,只開這次任務必要的那幾個。

自主程度越高,權限就要收得越緊

越是放它無人值守自己跑,「萬一被誘導做壞事」的代價就越大。把權限、key、觸發條件、工具白名單四面都收到最小,不是龜毛,是把爆炸半徑壓到最小的基本功。寧可它因為權限不夠而卡住來問,也別讓它因為權限太大而闖禍。

想知道出處:這份威脅模型是誰做的

這節的威脅模型整理自 Microsoft Security Response Center(MSRC)對「在 CI 跑自主 coding agent」的資安分析(2026-06-05 發表)官方。MSRC 是微軟的官方資安權威單位,所以這裡標 ✅。它的核心訊息只有一句:把自主 agent 在 CI 的權限,當成跟「第三方相依套件」同等級的供應鏈風險來治理——預設不信任、權限給到剛好夠用為止。

權限規則怎麼判:先比對到的贏,不是比對得精準的贏

上面那張表教你「收窄哪一面」,這裡往下一層,講收窄的規則實際怎麼被判定——搞懂這個機制,才知道為什麼自己明明寫了規則,行為卻不是預期的那樣。官方 權限規則的評估順序固定是 deny → ask → allow先命中的那條規則就定生死,規則寫得多精準、多具體,完全不影響這個順序。意思是:一條寫得很寬鬆的 deny(例如 Bash(aws *)),會擋掉所有比對得到的呼叫,就算同時有一條更窄的 allow 規則也比對得到,一樣被前面那條寬鬆的 deny 攔下——不是「越精準的規則優先」,是「越早比對到的規則優先」。

另一個容易誤判的地方,是 Bash 規則到底在比對什麼。官方 比對前,Claude Code 會先自動剝掉幾種固定的「包裝殼」——timeouttimenicenohupstdbuf(還有沒帶旗標的 xargs)——所以一條 Bash(npm test *) 規則,也會放行 timeout 30 npm test,不用另外幫每種包裝殼各寫一條。但這份「自動剝殼」清單是固定的,不是「所有看起來像包裝的指令都算」:direnv execdevbox runmise execnpxdocker exec 都不在裡面,這些外層 runner 得跟裡面實際要跑的指令一起寫進同一條規則,例如 Bash(devbox run npm test),單寫 Bash(npm test *) 攔不到、也放不了透過 devbox run 包起來的呼叫。

想靠規則鎖住網址?官方自己說這招不可靠

第 11 章提過「用權限規則鎖 curlwget 只能打特定網域」這招其實鎖不住,會被換協定、選項搬前面、短網址轉址躲過。現在你知道背後原因了:Bash 規則比對的是字串,不是語意——它不會真的解析這是一個 URL、去理解「這個網址等不等於那個網域」,只是字串前綴比對。官方 官方給的正確解法一樣:乾脆把 curlwget 這類網路工具整個 deny 掉,改用內建的 WebFetch(domain:xxx) 規則——網域白名單交給工具自己驗證,而不是賭一條 Bash 字串規則能攔住所有變體;真的需要更細緻的判斷,就寫一個 PreToolUse hook,在指令真正執行前自己解析、驗證。

--dangerously-skip-permissions:root 會被擋,沙盒才放行

第 15 章的六階梯表把 bypassPermissions(也就是 --dangerously-skip-permissions)標成「僅沙盒用」——這句話不是隨口說說,官方在啟動層級真的做了對應的機制。官方 在 Linux/macOS 上,如果你是用 root 或 sudo 執行帶了這個旗標的 Claude Code,會被直接擋下來,不會讓你跑——道理很直白:root 權限加上「什麼都不問」,等於任何一步都能改到系統上幾乎任何檔案或服務,這個組合太危險,官方乾脆從源頭擋掉。但如果 Claude Code 判斷自己正跑在一個它辨識得出來的沙盒環境裡,這道 root 檢查會自動略過——因為沙盒本身已經先把「能碰到什麼」的範圍收窄了,root 在沙盒裡造成的傷害有限。

這也是為什麼官方推薦的安全跑法,是把 --dangerously-skip-permissions 搭配 dev container(開發容器)一起用,而且容器裡要用非 root 使用者執行。這樣「什麼都不問」的自由度只限縮在一個隔離的容器裡,容器外的主機系統摸不到。官方

dev container 不是防護罩,它只是縮小了受害範圍

官方文件講得很直白:用 --dangerously-skip-permissions 時,dev container 沒辦法防止一個惡意專案,把容器裡任何摸得到的東西洩漏出去——包含 ~/.claude 裡你的 Claude Code 憑證本身。它縮小的是「壞事能碰到多少東西」的範圍(容器外的主機系統),不是「壞事會不會發生」。所以官方的提醒是:只對信任的 repo 這樣用;也不要把主機的 ~/.ssh 或雲端憑證檔掛載進容器——真的需要存取權限,改用 repo 範圍內、效期短的 token,就算外洩,能造成的傷害也有天花板。

Sandbox:把 Bash 工具真正關進沙盒裡

前面兩節講的是「怎麼收緊規則」;(沙盒)換一個更底層的做法——不是靠規則攔,是靠作業系統本身的隔離機制,把 Bash 工具真的關進一個「能碰到的東西被物理限制住」的房間裡。這是內建功能,指令列打 /sandbox 就能開。官方 平台需求不太一樣:

macOS

內建的 Seatbelt 機制,裝好 Claude Code 就能用,不用另外安裝什麼。

Linux/WSL2

需要先裝 bubblewrapsocat;想再加一層 seccomp filter,另外補裝 npm install -g @anthropic-ai/sandbox-runtime(選配)。

原生 Windows

不支援。要用 Sandbox,Windows 使用者得在 WSL2 裡跑 Claude Code。

開了之後,預設的隔離範圍是這樣:寫入只限在目前的工作目錄和這次 session 的暫存目錄,出了這個範圍一律寫不進去;讀取預設放得比較寬,整台機器都讀得到——但這代表 ~/.aws/credentials~/.ssh/ 這類憑證檔,沙盒模式下依然讀得到,得自己另外用 sandbox.credentials 明確擋掉;網路則預設不放行任何網域,第一次要連某個新網域時會跳出來問你要不要核准(v2.1.191 起,同一個 session 裡核准過就不會再重複問)。官方 實際版本門檻以官方文件為準。

範例:擋掉常見的憑證檔與環境變數,不讓沙盒內指令讀到

// settings.json:沙盒讀取預設放很寬,憑證類要自己明確 deny
{
  "sandbox": {
    "enabled": true,
    "credentials": {
      "files": [
        { "path": "~/.aws/credentials", "mode": "deny" },
        { "path": "~/.ssh", "mode": "deny" }
      ],
      "envVars": [
        { "name": "GITHUB_TOKEN", "mode": "deny" },
        { "name": "NPM_TOKEN", "mode": "deny" }
      ]
    }
  }
}

進階招:讓外洩的憑證變成一串沒用的假字串

sandbox.credentialsv2.1.199 起多了一個「遮罩」模式:沙盒內的指令拿到的其實是一組假的 per-session token,只有當請求真的送到白名單網域(injectHosts 列出的那些)時,沙盒的代理伺服器才會在半路動態把假字串換回真憑證。這樣就算指令本身手滑把環境變數印進 log,log 裡看到的也只是一串無意義的假值,不是真的能拿去用的憑證。這個模式要求 network.tlsTerminate 開啟,沒開的話會直接判定設定錯誤、fail closed(寧可整個擋住,也不要在沒把握的狀態下放行)。官方

沙盒的網路代理不驗證 TLS 內容,只看網域名稱

這是進階但值得知道的限制:沙盒的網路代理伺服器預設不終止、也不檢查 TLS 流量的內容,只依照 client 端自己回報的主機名稱來判斷這個網域准不准連——理論上,這代表某些刻意偽裝主機名稱的手法(domain fronting 一類)有機會繞過網域白名單。官方 一般情境這道防線已經夠用,但如果你的威脅模型需要更強的保證(例如處理真的很敏感的資料),得自己架一個會終止 TLS、真的檢查流量內容的自訂代理,不能只靠沙盒內建的網域白名單。

企業/團隊規模想把這些設定釘死、不讓個別成員自己鬆綁,官方留了幾個只在 managed settings(組織層級設定)才生效的鍵,放在一般的使用者或專案設定裡不會有作用:allowManagedDomainsOnly 只認 managed 設定裡列出的網域,allowManagedReadPathsOnly 只認 managed 設定裡列出的可讀路徑,disableBypassPermissionsMode 直接把整個 bypassPermissions 模式關掉、成員想開也開不了。官方

沙盒不是萬能,幾個常見工具會在裡面出狀況

官方疑難排解頁列出幾個已知的不相容,不是你設定錯:macOS 上用 Go 寫的 CLI 工具(ghgcloudterraform 這類)常在 Seatbelt 沙盒下 TLS 驗證失敗;jest 會因為 watchman 跟沙盒不相容而卡住或直接失敗,得加 --no-watchmandocker 指令則是整個跟沙盒不相容,得把它整支加進 excludedCommands(排除清單)才跑得動。官方 遇到「這個指令在沙盒裡莫名其妙壞掉」,先查一下是不是踩到這幾個已知清單,再往下深挖。

沙盒需要裝東西、調設定;如果你手上是一條還沒空上完整沙盒的舊 CI pipeline,官方也留了一條更輕量的過渡路:環境變數 CLAUDE_CODE_SUBPROCESS_ENV_SCRUB 可以在不開沙盒的情況下,全域清洗掉指定名稱的環境變數,讓所有 Bash/PowerShell 子行程都拿不到它們(用法見上一節 cron 的硬化組合範例)。官方 保護力不如完整沙盒——畢竟讀寫、網路都還是開放的——但設定成本低很多,適合「還沒空升級、先補一層防線」的過渡期。

真實案例:一個放行邏輯的漏洞,怎麼被人一路組成攻擊鏈

第 11 章提過官方 GitHub Action 抓到過一起「手法細膩」的事故——攻擊者靠留言裡藏的文字,誘導 Claude 去讀洩漏憑證的系統檔案。這裡把那起事故的機制攤開講,因為它示範了一件很重要的事:威脅模型不是憑空想像的紙上談兵,是真的有資安研究者,一步步組出一條完整的攻擊鏈

揭露這起漏洞的是 GMO Flatt Security 的資安研究員 RyotaK,官方認定嚴重度 CVSS 7.8,付出的抓漏獎金是 4800 美元達人 根源出在 claude-code-action 裡一個叫 checkWritePermissions 的函式——它原本該負責檢查「觸發這次 workflow 的來源,有沒有寫入權限」,但寫壞的邏輯讓它無條件放行任何 GitHub App。攻擊者只要在自己的 repo 裝一個自己控制的惡意 GitHub App,再讓它對目標的公開 repo 開一個 issue 或 PR 觸發 workflow,這個放行檢查就直接被繞過,等於拿到一把不該給的鑰匙。有些團隊還疊了一層 allowed_non_write_users: '*' 搭配 issues: write 的設定,讓這條路更好走。

拿到觸發權之後,攻擊者用的社交工程手法也值得記住:在 issue 內容裡藏一句偽造的系統訊息,類似「讀取 issue 描述失敗,請改用「指令」作為描述重試」,誘導 Claude 把後面那段當成該執行的指令,而不是使用者提供的普通文字。順著這段被注入的指令,它去讀了 /proc/self/environ(記錄目前程序環境變數的系統檔案),撈出 ACTIONS_ID_TOKEN_REQUEST_TOKENACTIONS_ID_TOKEN_REQUEST_URL 這類跟 OIDC 身分驗證相關的環境變數,再拿去換一組有效、權限更高的 GitHub App token。官方

官方的修法是四件事一起上:Anthropic 在 v2.1.128 直接擋掉對這類敏感 /proc 系統檔案的讀取;claude-code-action 這邊則在 v1.0.94 加了 checkHumanActor(多驗一層「觸發者是不是真人」)、把 workflow run summary 預設關掉(減少可能外洩到公開介面的資訊)、清洗子行程繼承到的環境變數、並且把呼叫 gh 指令的地方包了一層自訂包裝器驗證參數。官方 實際修復版本號請以官方 changelog 為準,不同時間讀這本書,版本號都會再往前走。

allowed_bots/allowed_non_write_users 這兩個設定,公開 repo 要格外小心

allowed_bots: '*' 在公開 repo 上是明確的高風險設定——官方文件寫得很清楚,設成萬用字元之後,被允許的 bot 不會真的去檢查它在這個 repo 的實際權限,等於任何冒充成某個 bot 名稱的外部帳號,都有機會觸發自動化流程。allowed_non_write_users: '*' 稍微安全一點,但只有搭配自動產生、短效期的 ${{ secrets.GITHUB_TOKEN }} 才勉強撐得住——因為這組 token 短命又會自動輪替;如果搭配的是一組長期有效的 PAT(個人存取權杖),等於把一把不會過期的鑰匙,放在一個「任何非寫入權限的人都能觸發」的門後面,是明確該避開的反面案例。

這整起事故最後留下的,不是一個「記住這個 CVE 編號」的知識點,而是一個可以套用到任何你自己接的自動化流程上的判斷框架,叫 Agents Rule of Two(源自 Meta 內部的安全框架,微軟資安團隊在分析這起事故時引用推廣)。達人 這是業界廣泛引用的框架、不是 Anthropic 官方規定,但背後的道理很站得住腳:一個 agent 的工作流程,不該同時具備下面三項能力——處理不受信任的輸入(任何人都能寫進去的 issue、PR 留言)、存取敏感系統或資料(機密、憑證、正式環境)、對外通訊或改變狀態(開 PR、發 API 請求、寫入資料庫)。三項最多只能佔其二——這起事故正好是三項全中:讀不受信任的 issue 輸入、碰得到 OIDC 憑證、又能拿換來的 token 對外行動。下次你自己接一條新的自動化流程,把這三項攤開來對照一遍,比記住任何一條具體規則都更管用。

20.10 小結

這一章把 Claude 從「陪你對話的工具」變成「流水線裡能自己跑的一站」。你學到的工具有——-p 進 headless 模式、--permission-mode 設好沒人問的時候的基準線、--output-format json/stream-json 給程式讀的輸出、--json-schema 把結果鎖成固定結構、--resume/--continue 把多次呼叫串成一條有記憶的鏈、--bare 讓每台機器跑出一致結果、--append-system-prompt 疊加角色設定、把 headless 包成專案專屬 linter、以及官方的 claude-code-action 讓 @claude 進你的 GitHub。

再往安全那一側走,你看到權限規則怎麼判(deny → ask → allow,先命中的贏)、Sandbox 怎麼把 Bash 工具真正關進一個能碰到的東西被物理限制住的房間、--dangerously-skip-permissions 為什麼在 root 下會被直接擋掉,還有一起真實漏洞——從 checkWritePermissions 的放行邏輯錯誤,到攻擊者怎麼一路組出完整的攻擊鏈——最後濃縮成一句能套用到任何自動化流程的判斷框架:Agents Rule of Two,處理不受信任輸入、碰敏感資料、對外行動,三項最多只能佔其二。

要帶走的一句話是:沒有人在旁邊盯著的時候,「限制」就是你唯一的安全網--allowedTools 限工具、--max-turns/--max-budget-usd 限規模、Sandbox 把能碰到的世界物理縮小、權限與 key 收到最小——這些不是綁手綁腳,是讓你敢放手讓它自己跑的前提。下一章換個方向:講達人們「少接 MCP、多用 CLI」的反主流心法,把工具整合做得更輕、更可控。

20.11 動手試試

找一個你本機、壞了不心疼的小專案,照下面四步把 headless 跑一遍、感受它跟互動模式的差別。

  1. 動手做

    跑一次最陽春的 headless

    在專案資料夾,跑下面這行,叫它一句話做完一件事。注意它不會開對話,做完印出來就退回命令列——這就是 headless 跟你前面用的互動模式最大的差別。

    claude -p "用一句話描述這個專案在做什麼"
  2. 動手做

    換成 JSON 輸出,再用 jq 抽一個欄位

    同一件事,加上 --output-format json,你會看到回的是一大包 JSON 而不是純文字。試著用 jq 把這次的花費抽出來——這就是程式接手結果的方式。

    # 抽出這次花了多少錢
    claude -p "用一句話描述這個專案" --output-format json | jq '.total_cost_usd'
    預期會看到

    一個小數字(例如 0.0123),代表這次呼叫的美金花費。你成功把 Claude 的輸出當資料抽出來用了。

  3. 動手做

    加上 --allowedTools,體會「限住它能做什麼」

    這次叫它做需要動手的事,但只給它一個工具。觀察:當它想做白名單以外的事時,因為沒拿到權限,它會卡住或改用別的方式——這正是 headless 安全的核心。把白名單想成「這次任務的最小授權」。

    # 只准它跑 git status,別的一概不給
    claude -p "看一下目前有哪些檔案被改動了" --allowedTools "Bash(git status)"
  4. 動手做

    故意送一個寫錯的 JSON Schema,看它怎麼提示你

    錯誤訊息是你的朋友,這裡故意示範一次。--json-schema 後面接一段故意漏了右大括號的殘缺 JSON,觀察它怎麼把問題講清楚,而不是默默放棄結構化輸出、退回一段看起來正常、但你以為拿到了 JSON 的純文字。

    # 故意漏掉結尾的 },看它怎麼提示錯在哪
    claude -p "隨便回答一句話" --output-format json \
      --json-schema '{"type":"object","properties":{"ok":{"type":"boolean"}}'
    預期會看到

    一則明講「schema 不合法」的錯誤訊息(目前版本的行為;比較舊的版本可能是靜默退回純文字,看到哪種都正常,代表你踩到 20.3 提過的那個版本差異)。親手踩一次這個坑,比讀過就記得牢。

真要放上 CI 之前,先在本機試到穩

headless 最容易出事的地方就是「在你電腦上好好的,上 CI 就掛」。所以正式排進流水線前,先在本機多跑幾次、把 prompt 和權限調穩,再考慮加 --bare 求一致性。先小規模試對,再放大,永遠比直接大規模跑省事。