痛點:本機 Mac 的 Claude Code 常卡在權限提示動不了。 每次 Bash、Edit、Write 都跳確認,互動式作業還能接受,夜間無人值守根本行不通。
這份指南提供兩層解法:
| 層次 | 技術 | 效果 |
|---|---|---|
| A. 即時防護層 | PreToolUse Hook | 白名單自動放行、黑名單直接擋、中間地帶請求確認 — 不卡又不失控 |
| B. 夜間自動化層 | claude -p + launchd | 人睡覺時跑任務佇列,worktree 隔離,用量監控,跑完通知 |
A 解決「互動時常被打斷」;B 解決「要睡覺但任務還沒跑」。兩者可以組合:Autopilot 跑的 session 同樣受 Hook 約束,雙重保護。
PreToolUse hook 在每個工具呼叫之前執行腳本,腳本讀取 stdin JSON、輸出決定 JSON,告訴 Claude Code 要 allow / deny / ask / defer。
Hook 設定寫在 settings.json,有兩個位置:
| 層級 | 路徑 | 作用範圍 |
|---|---|---|
| Project-level | .claude/settings.json(專案根目錄下) | 只對此專案生效 |
| User-level | ~/.claude/settings.json | 全機器所有 session 生效 |
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/bash-permission-gate.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/edit-protect-critical.sh"
}
]
}
]
}
}
| 欄位 | 說明 | 範例 |
|---|---|---|
matcher | 要攔截的工具名,支援正規表達式(| 分隔多個) | "Bash"、"Edit|Write"、"mcp__.*" |
type | Hook 類型,目前只有 command | "command" |
if | (可選)執行條件,用權限規則語法 | "Bash(rm *)" |
command | 腳本路徑(可用 ${CLAUDE_PROJECT_DIR} 變數) | "/path/to/gate.sh" |
args | (可選)傳給腳本的參數陣列 | [] 或 ["--strict"] |
Hook 觸發時,Claude Code 透過 stdin 傳 JSON 給腳本:
{
"session_id": "abc123xyz",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/home/user/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build"
}
}
| 欄位 | 型別 | 說明 |
|---|---|---|
tool_name | string | Bash、Edit、Write、Read… |
tool_input | object | 工具輸入。Bash → command;Edit/Write → file_path、content… |
cwd | string | 當前工作目錄絕對路徑 |
permission_mode | string | 當前 session 的權限模式 |
session_id | string | 唯一 session 識別碼 |
腳本把 JSON 輸出到 stdout,用正確的 exit code 結束:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive rm -rf blocked by safety policy",
"additionalContext": "Optional context for Claude"
}
}
| permissionDecision | 效果 | 使用場景 |
|---|---|---|
"allow" 放行 | 跳過權限對話,直接執行 | 安全操作白名單 |
"deny" 阻擋 | 阻止工具執行,顯示 reason | 危險操作黑名單 |
"ask" 詢問 | 彈出確認對話給使用者 | 中間地帶人工核准 |
"defer" 交出 | 使用預設流程(等同 exit 0 無輸出) | Hook 無法判斷時 |
| Exit Code | 行為 |
|---|---|
0 | 成功 — 解析 JSON 輸出的 permissionDecision |
2 | 阻擋錯誤 — 直接阻止工具執行;stderr 作為原因顯示 |
| 其他非零 | 非阻擋錯誤 — 工具繼續執行,JSON 不被解析 |
#!/bin/bash
# .claude/hooks/bash-permission-gate.sh
# 三段式:黑名單 deny → 白名單 allow → 其餘 ask
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null)
# ── 黑名單:直接拒絕 ──
DENY_PATTERNS=(
"rm -rf"
"git push origin main"
"git push.*--force"
"dd if="
"chmod 777"
"curl.*|.*sh"
)
for pattern in "${DENY_PATTERNS[@]}"; do
if [[ $COMMAND =~ $pattern ]]; then
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'deny',
'permissionDecisionReason': 'Dangerous pattern blocked: $pattern'
}
}))"
exit 0
fi
done
# ── 白名單:直接放行 ──
ALLOW_PATTERNS=(
"^git (status|log|diff|branch|fetch|pull)"
"^ls( |$)"
"^cat "
"^grep "
"^echo "
"^pwd$"
"^npm test"
"^npm run build"
"^python3? -m pytest"
"^find "
"^wc "
)
for pattern in "${ALLOW_PATTERNS[@]}"; do
if [[ $COMMAND =~ $pattern ]]; then
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'allow',
'permissionDecisionReason': 'Safe pattern'
}
}))"
exit 0
fi
done
# ── 預設:請求確認 ──
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'ask',
'permissionDecisionReason': 'Manual approval required'
}
}))"
exit 0
#!/bin/bash
# .claude/hooks/edit-protect-critical.sh
# 禁止修改 .env、secrets、credentials、私鑰等敏感檔案
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)
DENY_PATTERNS=(
"\.env$"
"\.env\."
"secrets"
"credentials"
"\.git/config"
"private_key"
"prod[-_]config"
"\.pem$"
"\.key$"
)
for pattern in "${DENY_PATTERNS[@]}"; do
if [[ $FILE_PATH =~ $pattern ]]; then
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'deny',
'permissionDecisionReason': 'Protected file: $FILE_PATH'
}
}))"
exit 0
fi
done
# 其他檔案交給預設流程
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'defer'
}
}))"
exit 0
#!/usr/bin/env python3
# .claude/hooks/permission_gate.py
import json, sys, re
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(0)
tool_name = data.get('tool_name', '')
tool_input = data.get('tool_input', {})
command = tool_input.get('command', '')
file_path = tool_input.get('file_path', '')
def respond(decision, reason=""):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason
}
}))
sys.exit(0)
# ── Bash 工具 ──
if tool_name == "Bash":
DANGEROUS = [
r'rm\s+-rf',
r'git\s+push\s+origin\s+main',
r'git\s+push.*--force',
r'dd\s+if=',
r'chmod\s+777',
r'curl\s+.*\|\s*(ba)?sh',
]
SAFE = [
r'^git\s+(status|log|diff|branch|fetch|pull)',
r'^ls(\s|$)',
r'^cat\s',
r'^grep\s',
r'^echo\s',
r'^pwd$',
r'^npm\s+test',
r'^npm\s+run\s+build',
r'^python3?\s+-m\s+pytest',
r'^find\s',
r'^wc\s',
]
for p in DANGEROUS:
if re.search(p, command):
respond("deny", f"Dangerous pattern: {p}")
for p in SAFE:
if re.match(p, command):
respond("allow", "Safe command")
respond("ask", f"Needs approval: {command[:100]}")
# ── Edit / Write 工具 ──
elif tool_name in ("Edit", "Write"):
PROTECTED = [
r'\.env(\.|$)', r'secrets', r'credentials',
r'private_key', r'\.git/config', r'\.(pem|key)$'
]
for p in PROTECTED:
if re.search(p, file_path):
respond("deny", f"Protected file: {file_path}")
respond("defer")
# ── 其他工具:不干預 ──
else:
respond("defer")
mkdir -p .claude/hooks
# bash 版
chmod +x .claude/hooks/bash-permission-gate.sh
chmod +x .claude/hooks/edit-protect-critical.sh
# python 版
chmod +x .claude/hooks/permission_gate.py
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/test"}}' \
| .claude/hooks/bash-permission-gate.sh
# 預期輸出:{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny",...}}
echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' \
| .claude/hooks/bash-permission-gate.sh
# 預期輸出:{"hookSpecificOutput":{"permissionDecision":"allow",...}}
echo '{"tool_name":"Edit","tool_input":{"file_path":"/home/user/.env"}}' \
| python3 .claude/hooks/permission_gate.py
# 預期輸出:{"hookSpecificOutput":{"permissionDecision":"deny",...}}
把上面的 settings.json 內容存入 .claude/settings.json(project-level)或 ~/.claude/settings.json(user-level),然後用 /exit 重啟 Claude Code。
.claude/settings.json 或 ~/.claude/settings.json 的 hooks 設定後,必須完全退出 Claude Code(用 /exit 或關閉終端機視窗)再重新開啟,新設定才會載入生效。~/.claude/settings.json(user-level)的修改影響所有新開的 session,但現有 session 不會自動套用。判斷 Hook 是否生效(兩段,都不必下危險指令):
deny。指令永遠不會被執行,完全不經過 Claude,這才是判斷黑白名單邏輯對不對的方法。DANGEROUS 清單最前面臨時加一條哨兵字串 r'HOOK_SELFTEST_DENY'(DANGEROUS 比 SAFE 先比對,所以會優先命中),然後在 session 內打 echo HOOK_SELFTEST_DENY — 被 deny 代表設定已載入;就算沒擋住,也只是印出一行字、零傷害。測完把哨兵移除即可。⚠ 不要拿 rm -rf、git push 這類指令當測試樣本。雖然 rm -rf /nonexistent 打到不存在的路徑其實不會造成傷害,但用「危險指令」測「擋危險的機制」會養成壞習慣 — 哪天 /nonexistent 手滑打成真路徑就出事了。測試樣本要選「就算洩漏也零傷害」的哨兵字串。
核心技術:claude -p headless 模式。不開互動 UI,直接下指令、跑完輸出結果、退出。配合 launchd 排程就成了夜間自動 loop。
# 最簡形式
claude -p "找出 auth.py 的 bug 並修復"
# 預核准特定工具
claude -p "跑測試並修復失敗的 case" \
--allowedTools "Bash,Read,Edit"
# 搭配權限模式
claude -p "套用 lint 自動修正" \
--permission-mode acceptEdits
# 輸出 JSON(含 total_cost_usd)
claude -p "分析程式碼品質" \
--output-format json
| Flag | 說明 | 範例值 |
|---|---|---|
--allowedTools | 預核准的工具清單(逗號分隔),支援 glob 限制指令範圍 | "Read,Edit,Bash"、"Bash(git *)" |
--permission-mode | 全域權限模式(見 B2) | "acceptEdits"、"bypassPermissions"… |
--output-format | 輸出格式 | "text"(預設)、"json"、"stream-json" |
--bare | 精簡模式:不載入 hooks/MCP/skills,節省 token 和啟動時間 | (無值) |
--append-system-prompt | 附加系統提示詞,限縮行為 | "不要執行任何刪除操作" |
--continue | 繼續最新 session | (無值) |
--resume | 恢復指定 session ID | "session-id-here" |
| 模式 | 行為 | Autopilot 建議用途 |
|---|---|---|
"default" | 首次使用時提示確認 | 互動式(Headless 會失敗) |
"acceptEdits" | 自動接受檔案編輯和常見 fs 操作(mkdir/mv/cp…) | 推薦:日常 Autopilot |
"plan" | 唯讀探索:讀檔 + 唯讀 shell,不寫入 | 分析、診斷、只產報告 |
"dontAsk" | 自動拒絕未在 allowedTools 的工具 | 嚴格 CI(只允許白名單) |
"bypassPermissions" | 跳過所有權限提示(除 rm -rf / 和 rm -rf ~) | 隔離 worktree / 容器專用 |
"auto" research preview | 後台安全檢查,自動核准符合請求的操作 | 生產前評估穩定性 |
網路上偶有提到 --dangerously-skip-permissions(也是某些文件或舊版本的寫法)。現行官方建議使用 --permission-mode bypassPermissions。這兩者效果等同 — 跳過所有提示,只在完全隔離的環境使用。請以你實際執行 claude --help 顯示的輸出為準(以實際版本為準)。
# 組合 1:日常任務(改 bug、格式化、跑測試)
claude -p "修復失敗的測試" \
--bare \
--allowedTools "Bash,Read,Edit" \
--permission-mode acceptEdits
# 組合 2:嚴格 CI(只跑測試,禁止寫入)
claude -p "執行測試並回報結果" \
--bare \
--permission-mode dontAsk \
--allowedTools "Bash(npm test),Read"
# 組合 3:唯讀分析(只讀不改)
claude -p "分析程式碼品質並回傳 JSON 報告" \
--bare \
--permission-mode plan \
--output-format json
| 場景 | 行為 | 結果 |
|---|---|---|
未設 --allowedTools,命令需要確認 | 提示被轉換為 exit 1 | 任務失敗 |
設了 --allowedTools,命令符合 | 自動執行(無提示) | 繼續執行 |
設了 --allowedTools,命令不符合 | 拒絕執行,exit 1 | 任務失敗 |
--permission-mode dontAsk | 自動拒絕未在 allow rules 的工具 | 任務失敗(除非明確允許) |
結論:設定 Autopilot 任務前,先在互動模式跑一遍確認用到哪些工具,再把它們全列進 --allowedTools。
--output-format json 的輸出包含 total_cost_usd 欄位,可用來追蹤每次任務成本:
RESULT=$(claude -p "修復 bug" \
--allowedTools "Bash,Read,Edit" \
--permission-mode acceptEdits \
--output-format json 2>&1)
COST=$(echo "$RESULT" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
print(d.get('total_cost_usd', 'N/A'))
except:
print('N/A')
")
echo "任務成本:\$$COST USD"
#!/bin/bash
# check-budget.sh — 在派新任務前呼叫
DAILY_BUDGET=5 # USD
# 用 ccusage 查今日用量
TODAY_COST=$(ccusage --today 2>/dev/null | grep -oP 'USD \K[\d.]+' | tail -1 || echo "0")
python3 -c "
cost = float('$TODAY_COST')
budget = $DAILY_BUDGET
if cost >= budget:
print(f'BUDGET_EXCEEDED: {cost:.2f} / {budget} USD')
exit(1)
else:
print(f'OK: {cost:.2f} / {budget} USD')
exit(0)
"
| 省錢策略 | 效果 |
|---|---|
加 --bare | 不載入 MCP/hooks/skills,減少 token |
| 任務拆小 | 單次 prompt 越短越省 |
| 先 plan 後 acceptEdits | 先唯讀分析確認方向,再給寫入權限 |
| 設每日預算上限 | 達上限停派任務 |
多 agent 並行時,每個任務開獨立 worktree,避免互相踩檔案、衝突。
#!/bin/bash
# run-in-worktree.sh "任務描述"
TASK="$1"
REPO_DIR="/path/to/main/repo"
TASK_ID="cc-$(date +%s)"
WORKTREE_DIR="/tmp/cc-worktrees/$TASK_ID"
mkdir -p /tmp/cc-worktrees
# 1. 建立隔離 worktree(基於 main)
cd "$REPO_DIR"
git worktree add "$WORKTREE_DIR" main
# 2. 在隔離環境執行 Claude
cd "$WORKTREE_DIR"
claude -p "$TASK" \
--bare \
--allowedTools "Bash,Read,Edit" \
--permission-mode acceptEdits \
--output-format json > /tmp/${TASK_ID}-result.json 2>&1
STATUS=$?
# 3. 查看變更(人工確認再 merge)
git diff --stat
# 4. 清理
cd "$REPO_DIR"
git worktree remove "$WORKTREE_DIR" --force
exit $STATUS
/tmp,不要放在 repo 根目錄下git worktree prune 清孤立 worktreebypassPermissions 只在 worktree 內用,從不在 main repo 直接跑Mac 上比 cron 更可靠的排程方式,支援睡眠補跑、環境變數注入:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.wt.claude-autopilot</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/wt/scripts/autopilot-runner.sh</string>
</array>
<!-- 每小時執行一次 -->
<key>StartInterval</key>
<integer>3600</integer>
<!-- 或改成每天凌晨 2 點(注釋掉 StartInterval,改用這個):
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>2</integer>
<key>Minute</key><integer>0</integer>
</dict>
-->
<key>StandardOutPath</key>
<string>/tmp/claude-autopilot.log</string>
<key>StandardErrorPath</key>
<string>/tmp/claude-autopilot-error.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
<!-- ANTHROPIC_API_KEY 建議用 Keychain 存,不要明文寫這裡 -->
</dict>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
# 載入(啟用排程)
launchctl load ~/Library/LaunchAgents/com.wt.claude-autopilot.plist
# 查狀態
launchctl list | grep claude-autopilot
# 手動觸發一次(測試)
launchctl start com.wt.claude-autopilot
# 停用
launchctl unload ~/Library/LaunchAgents/com.wt.claude-autopilot.plist
# 看日誌
tail -f /tmp/claude-autopilot.log
#!/bin/bash
# autopilot-runner.sh — 消化任務佇列,跑完通知
set -e
REPO_DIR="/path/to/repo"
TASK_QUEUE="$HOME/ralph-loop/task_queue.md"
DAILY_BUDGET=5 # USD
echo "=== Autopilot Run $(date) ==="
# 防止並行
LOCK="/tmp/claude-autopilot.lock"
[ -f "$LOCK" ] && { echo "已有任務在跑"; exit 0; }
touch "$LOCK"
trap "rm -f $LOCK" EXIT
# 預算檢查(用 ccusage,視你的安裝方式調整)
# TODAY_COST=$(ccusage ... ) — 視實際輸出格式調整
# 從 task_queue.md 撈第一個 pending 任務
# 格式:- [ ] 任務描述
TASK=$(grep -m1 '^\- \[ \]' "$TASK_QUEUE" 2>/dev/null \
| sed 's/^\- \[ \] //' \
| sed 's/ |.*//' \
|| echo "")
[ -z "$TASK" ] && { echo "沒有 pending 任務"; exit 0; }
echo "執行:$TASK"
# 建立隔離 worktree
WORKTREE="/tmp/cc-wt-$(date +%s)"
cd "$REPO_DIR"
git worktree add "$WORKTREE" main
cd "$WORKTREE"
# 跑任務
claude -p "$TASK" \
--bare \
--allowedTools "Bash,Read,Edit" \
--permission-mode acceptEdits \
--output-format json > /tmp/cc-latest-result.json 2>&1
CC_STATUS=$?
# 清理 worktree
cd "$REPO_DIR"
git worktree remove "$WORKTREE" --force
# 更新任務狀態
if [ $CC_STATUS -eq 0 ]; then
STATUS_MSG="DONE"
# 把第一個 - [ ] 改成 - [x]
sed -i '' '0,/^\- \[ \]/s/^\- \[ \]/- [x]/' "$TASK_QUEUE"
else
STATUS_MSG="FAILED (exit $CC_STATUS)"
fi
echo "結果:$STATUS_MSG"
# macOS 通知中心
osascript -e "display notification \"$STATUS_MSG\" with title \"Claude Autopilot\" subtitle \"$TASK\""
雲端 Hetzner 上的 Ralph Loop 已有骨架,本機只差這三塊:
| 雲端已有(ralph-loop) | 本機 Mac 要補的 |
|---|---|
patrol.sh(每 30 分鐘 cron 巡檢) | Launchd plist(取代 cron,Mac 原生) |
task_queue.md(任務佇列) | Runner 腳本讀 ~/ralph-loop/task_queue.md(同一份或 rsync 同步) |
checkpoint.md(斷點恢復) | Runner 在每個任務前寫 checkpoint,用 --continue 接續 |
| watchdog(systemd 兩層) | Launchd 本身即有 KeepAlive / RunAtLoad 機制 |
| Discord 進度回報 | Runner 腳本結束後呼叫 DC Webhook(或本機 MCP) |
最快接法: 本機 ~/ralph-loop/task_queue.md 直接手寫,或透過 DC 說「加到本機佇列:XXX」讓雲端 Claude 寫入(若 task_queue 有 rsync 同步),Runner 每小時自動撈來跑。
本機要補的三個核心模組:
rm -rf 等危險操作仍然被 Hook 擋住。# 1. 設定 Hook(A節),測試黑名單是否回傳 deny
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
| .claude/hooks/bash-permission-gate.sh
# 2. 設定 settings.json → 重啟 session → 確認 Hook 生效
# 3. 用 --bare + acceptEdits 測試 headless
claude -p "列出 src/ 目錄的檔案" \
--bare --allowedTools "Bash(ls *)" --permission-mode acceptEdits
# 4. 設定 launchd plist(B6節)
launchctl load ~/Library/LaunchAgents/com.wt.claude-autopilot.plist
# 5. 手動觸發一次,確認通知有收到
launchctl start com.wt.claude-autopilot
tail -f /tmp/claude-autopilot.log
本頁內容整理自 Claude Code 官方文件、實際測試與查證(2026-06-01)。CLI flag 名稱、JSON 格式、行為細節可能隨版本更動,請以實際 claude --help 輸出和最新官方文件為準。
母本:/tmp/cc_autopilot/findings.md(838 行)| 產出:2026-06-01