如果 Claude Code 提出一條帶有 sudo 的命令,原生核准畫面已會顯示 proposed command;但決策通常仍是「允許或拒絕」,未必把它會碰哪一層系統、備份是否可用、失敗後怎麼退整理成可檢查欄位。這篇要做的 Claude Code 高風險命令閘門,就是在核准前補上一張 Blast-Radius Receipt(影響半徑收據)。
2026 年 9 月,一位 Reddit 使用者自述把 AI 建議的日期指令手動貼進一般 Terminal,之後遇到 Mac 開機與復原問題。截至 2026 年 9 月 7 日,原帖未提供日誌、鑑識或可重現因果;而且手動在 Terminal 執行的命令,Claude Code Hook 根本看不到。因此我們不重播事故指令,也不宣稱這道閘門能「防住那次事故」。真正值得學的是:高影響操作的核准畫面,應該先交代影響範圍與退路。
本文專為第一次寫 Hook 的讀者:會提供可複製的 Python 範例、設定檔、完全不執行命令的合成測例,以及把真實破壞性測試移進 disposable VM(用完即丟的虛擬機)的流程。若你還不熟 Hook 生命週期,可先看Claude Code Hooks 三道閘門入門。
先說結論:核准不是按鈕,是一張行前收據
高風險核准=影響半徑+較小替代+備份查核狀態+復原查核提示+停手條件。
把它想成飛機起飛前的檢查單。Hook 不負責保證飛機不會壞;它負責在引擎啟動前,把「會影響誰、哪裡可以先試、出事怎麼停」放到同一個決策點。本文的策略是:只有純註解、完全不形成 command 的 payload 可 allow;這只是詞彙判定,仍不證明 Bash 啟動環境或 shell preamble 無副作用。任何會解析成 executable 的字樣至少先 ask;無邊界刪除、原始磁碟寫入等明確災難型態以高信心 deny。動態 eval、source 等無法證明邊界的輸入也會 fail closed,但收據會誠實標為「風險未定、低信心」,不冒充已證明的 critical 命中。
這些標籤是政策分類,不是「已證明無害」。連 pwd、printf 這類名稱也轉 ask,因為 function、alias、disabled builtin 或檔案系統仍可能改寫 runtime resolution;看似只讀的 git status 也可能啟動 fsmonitor、pager、textconv 或 external diff。

Claude Code 高風險命令閘門到底看什麼?
Claude Code Hooks reference 說明,PreToolUse 會在工具參數形成後、工具執行前收到 tool_name、tool_input 與 tool_use_id;Bash 的完整命令字串位於 tool_input.command。我們還讀取 cwd 與 permission_mode,但不把原始命令複製進收據,以免把 token、URL query 或機密參數再散播一次。
- system-wide:會改系統服務、啟動項、權限或共用設定。操作:標記系統能力與主機範圍;遇到
sudo/doas才另外標記privilege.escalation。靜態 Hook 只能給通用模板;核准者仍要在受控系統另行確認具名服務、負責人與停止方式。 - disk:會格式化、覆寫裝置或大量刪除。本範例只對已明列的 raw-device mutation、專案/系統樹與憑證目錄遞迴刪除簽章直接拒絕;未辨識的 mount 或路徑仍可能只是
ask。真正演練只進 disposable VM,主機不執行。 - time:會改主機時間或同步來源。操作:標記時間域並要求較小替代;做畫面測試時優先使用模擬器能力。驗收:主機時間保持不變。
- network:會改 DNS、路由、防火牆或代理。操作:收據把目標最小化成固定標籤或可連結的雜湊參照——這是假名化,不是匿名化——並把介面、域名、遠端副作用與復原值列為人工待驗證;任何一項未知時,Hook 不自動
allow,而是維持ask或deny。 - credential:會建立、刪除、匯出金鑰或登入狀態。操作:收據只記固定能力標籤、假名化參照與雜湊,不記 raw secret;它仍保存完整命令的 SHA-256,所以只是 data minimization/假名化,不是匿名化。驗收測例會把 sentinel 放進命令、路徑、permission mode 與 executable,並要求序列化輸出找不到原文;這不是取代正式的 secret scanner。
標題用 sudo 當大家熟悉的警示燈,但分類器不能只找這個字。sudo 既不是危險的必要條件,也不是充分條件:使用者權限就能刪掉的檔案不需要它;而 sudo whoami 的半徑又和原始磁碟寫入完全不同。這也是本文不做另一份關鍵字黑名單的原因。
Hook、permissions、sandbox:三層不是替代關係
- Hook 是應用層行前檢查:本文的
matcher: "Bash"只檢查 Claude 發出的 Bash tool call;一般 Terminal、使用者自己輸入的!shell-mode 命令,以及未納入 matcher 的其他工具,都不在這個分類器的涵蓋範圍。 - Permissions 是 Claude Code 的授權規則:
allow不會蓋過其他ask、deny或受管理政策;多個 Hook 的結果以deny > defer > ask > allow合併。 - Sandbox 是 OS 層隔離:限制 Bash 及子程序可讀寫的路徑與可連線的網域。官方也把 sandbox 與 permissions 定位為互補機制。需要完整隔離觀念,可接著讀AI Coding Agent 的 container/microVM 邊界。
還有一個常被漏掉的事實:command Hook 本身以使用者權限執行,不在 Bash sandbox 裡。若 Hook 超時、無法啟動或輸出無效,普通 command Hook 會回到正常 permission 流程;如果既有 permissions 或 sandbox auto-allow 已准許該命令,它仍可能不經另一個人工 prompt 就執行,而不是自動拒絕。所以下面的程式會在程序成功啟動時,把解析錯誤包成結構化 deny;它仍無法把「程序根本沒跑起來」變成硬邊界。
步驟 1:先鎖定 Claude Code 版本
先在專案根目錄執行 claude --version。本文在 2026 年 9 月 7 日以官方當時標記 Latest 的 v2.1.263 作為文件基準;本文只跑 Python 輸出契約與分類器測試,沒有在 Claude Code CLI、VS Code 或 Desktop 重跑真實 permission UI 整合測試。依 release notes,v2.1.211 修正 auto mode 覆蓋 unsandboxed Bash 的 PreToolUse ask,讓 ask 至少維持人工 prompt;v2.1.214 修正 Hook 以 exit code 2 結束、但 stdout JSON 不合 schema 時未如文件阻擋的問題;v2.1.248 則讓背景 session 的 invalid Hook answer 顯示 schema error,並把看似物件但不是有效 JSON 的 stdout 報為 parse error。故 v2.1.248 只是最低相容候選,不是本文實測最低版;版本不是 v2.1.263 時,請先升級,或在該精確版本重跑輸出契約與 permission UI 整合測試。
claude --version
mkdir -p .claude/hooks
步驟 2:建立 Bash 風險分類器與收據
把下面完整程式存成 .claude/hooks/bash-risk-gate.py。它只做靜態、詞彙層分析,不呼叫 shell,也不執行收到的命令。設計刻意保守:純註解才放行、可執行名稱一律至少轉人工,明確災難型態與無法可靠解析的動態執行路徑則拒絕;兩種 deny 會使用不同的風險與信心標籤。收據中的 SHA-256 用來把畫面與那次 command 字串對上;它沒有綁定 PATH、環境變數、symlink、遠端狀態或檢查到執行間的 TOCTOU 變化,也不是匿名化:低熵或可猜的命令仍可能被離線比對。
展開完整 Python Hook(可複製)
#!/usr/bin/env python3
"""Conservative Claude Code PreToolUse gate for Bash.
The gate performs static, lexical inspection only. It is defense in depth, not
a shell sandbox. It never executes the proposed command and never reproduces
the raw command in its blast-radius receipt.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shlex
import sys
import unicodedata
from dataclasses import dataclass, field
from typing import Iterable
MAX_STDIN_BYTES = 1_048_576
MAX_COMMAND_BYTES = 65_536
KNOWN_PERMISSION_MODES = {
"default",
"acceptEdits",
"plan",
"auto",
"dontAsk",
"bypassPermissions",
}
RANK = {"allow": 0, "ask": 1, "deny": 2}
CONTROL = {";", "&&", "||", "|", "|&", "&", "\n", "(", ")", "{", "}"}
PIPE_OPS = {"|", "|&"}
SHELLS = {"sh", "bash", "zsh", "dash", "ksh"}
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
SYSTEM_WIDE_TOOLS = {
"systemctl",
"service",
"launchctl",
"shutdown",
"reboot",
"halt",
"poweroff",
"sysctl",
"killall",
"pkill",
"apt",
"apt-get",
"dnf",
"yum",
"pacman",
"brew",
}
DISK_TOOLS = {
"diskutil",
"fdisk",
"sfdisk",
"parted",
"wipefs",
"mount",
"umount",
"losetup",
"zpool",
"zfs",
}
TIME_TOOLS = {
"timedatectl",
"systemsetup",
"hwclock",
"ntpdate",
"sntp",
}
NETWORK_TOOLS = {
"curl",
"wget",
"nc",
"netcat",
"ssh",
"scp",
"sftp",
"rsync",
"ftp",
"telnet",
"socat",
"nmap",
"iptables",
"nft",
"pfctl",
"route",
"ifconfig",
"networksetup",
"kubectl",
"terraform",
}
CREDENTIAL_TOOLS = {
"security",
"ssh-add",
"gpg",
"pass",
"op",
"vault",
"keyctl",
"secret-tool",
"aws-vault",
}
SENSITIVE_ARGUMENT = re.compile(
r"(?:^|[/._-])(?:\.env(?:\.|$)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)|"
r"credentials(?:\.|$)|secrets?(?:\.|$)|keychain(?:\.|$))|"
r"(?:password|passwd|token|api[_-]?key|private[_-]?key)",
re.IGNORECASE,
)
LEXICALLY_SIMPLE_COMMANDS = {
"pwd",
"true",
"false",
"printf",
"echo",
}
EXPLICIT_HIGH_IMPACT_SIGNALS = {
"RECURSIVE_DELETE_CRITICAL_SCOPE",
"UNBOUNDED_XARGS_DELETE",
"DESTRUCTIVE_FILE_OPERATION",
"MUTATING_FIND",
"FILESYSTEM_FORMATTER",
"RAW_DEVICE_MUTATION",
"RAW_DEVICE_WRITE",
"DD_WRITE",
"PERMISSION_OR_OWNER_CHANGE",
"GIT_CLEAN",
}
INVISIBLE = {
"\u061c",
"\u200b",
"\u200c",
"\u200d",
"\u200e",
"\u200f",
"\u202a",
"\u202b",
"\u202c",
"\u202d",
"\u202e",
"\u2066",
"\u2067",
"\u2068",
"\u2069",
"\ufeff",
}
NOT_REQUIRED_STATUS = "not_required_for_comment_only_no_command"
TEMPLATE_STATUS = "non_authoritative_domain_template"
# These are prompts for an operator's review, not claims that a backup,
# rollback, or safer procedure exists. They deliberately contain no commands.
DOMAIN_GUIDANCE = {
"general": {
"smaller": "Limit the proposal to one named disposable fixture and one observable effect.",
"safer": "Prefer read-only inspection or an authoritative dry-run in an isolated test environment.",
"recovery": [
"Identify the authoritative rollback owner and procedure.",
"Record pre-change state without exposing sensitive values.",
"Test restoration on a disposable fixture before execution.",
],
"stop": [
"Stop if the target, effect, owner, or privilege boundary is ambiguous.",
"Stop if restoration has not been independently demonstrated.",
],
},
"system-wide": {
"smaller": "Limit the proposal to one named service or setting on an isolated host.",
"safer": "Prefer a scoped reversible change covered by the operator's authoritative runbook.",
"recovery": [
"Identify the authoritative rollback for the named service or setting.",
"Capture current configuration without exposing sensitive values.",
"Test rollback on an isolated host before production use.",
],
"stop": [
"Stop if the host, service, privilege boundary, or owner is ambiguous.",
"Stop if maintenance approval or a tested rollback is missing.",
],
},
"disk": {
"smaller": "Limit inspection to one resolved disposable image or mock device.",
"safer": "Prefer read-only inspection of a disposable image over a live or shared volume.",
"recovery": [
"Resolve the exact device, mount, and symlink target before execution.",
"Verify an independent restorable backup or snapshot with its owner.",
"Complete a restore drill on disposable storage.",
],
"stop": [
"Stop if the device or mount identity is ambiguous or shared.",
"Stop if a restorable backup has not been independently verified.",
],
},
"time": {
"smaller": "Limit the proposal to reading one host's clock, timezone, and synchronization state.",
"safer": "Test one time-setting change on a disposable host with no external dependencies.",
"recovery": [
"Record the current timezone, time source, and synchronization policy.",
"Verify the authoritative resynchronization and restore procedure.",
"Identify authentication, certificate, and database dependencies.",
],
"stop": [
"Stop if the host or authoritative time source is unknown.",
"Stop if dependent systems are uncoordinated or rollback is untested.",
],
},
"network": {
"smaller": "Limit the proposal to a read-only request against an inert mock endpoint without credentials.",
"safer": "Stage the operation in an isolated network or provider test account.",
"recovery": [
"Record current routing, firewall, and remote-resource state if mutable.",
"Verify out-of-band access and the authoritative rollback procedure.",
"Confirm how remote-side effects would be identified and reversed.",
],
"stop": [
"Stop if the endpoint, account, region, or remote effect is ambiguous.",
"Stop if management connectivity could be lost or credentials exposed.",
],
},
"credential": {
"smaller": "Limit the proposal to metadata for one mock identifier without revealing a secret value.",
"safer": "Use a disposable test credential with minimum scope and no production access.",
"recovery": [
"Verify the authoritative revocation and rotation playbook with its owner.",
"Identify dependents and prepare a replacement rollout.",
"Ensure logs and receipts cannot capture secret material.",
],
"stop": [
"Stop if a secret may be printed, logged, transmitted, or persisted.",
"Stop if ownership, scope, dependents, or rotation ability is unknown.",
],
},
}
def operator_guidance(decision: str, risk_domains: Iterable[str]) -> dict:
"""Return review prompts without asserting backup or recovery verification."""
if decision == "allow":
return {
"smaller_alternative": {"status": NOT_REQUIRED_STATUS, "templates": []},
"safer_alternative": {"status": NOT_REQUIRED_STATUS, "templates": []},
"backup_evidence": {
"status": NOT_REQUIRED_STATUS,
"required_before_execution": False,
},
"recovery": {"status": NOT_REQUIRED_STATUS, "steps": []},
"recovery_steps": {"status": NOT_REQUIRED_STATUS, "steps": []},
"stop_conditions": {
"status": NOT_REQUIRED_STATUS,
"conditions": [],
},
}
domains = sorted(
domain for domain in set(risk_domains) if domain in DOMAIN_GUIDANCE
) or ["general"]
def templates(kind: str) -> list[dict[str, str]]:
return [
{"domain": domain, "text": DOMAIN_GUIDANCE[domain][kind]}
for domain in domains
]
recovery_steps = [
{"domain": domain, "text": step}
for domain in domains
for step in DOMAIN_GUIDANCE[domain]["recovery"]
]
stop_conditions = [
{"domain": domain, "text": condition}
for domain in domains
for condition in DOMAIN_GUIDANCE[domain]["stop"]
]
return {
"smaller_alternative": {
"status": TEMPLATE_STATUS,
"templates": templates("smaller"),
},
"safer_alternative": {
"status": TEMPLATE_STATUS,
"templates": templates("safer"),
},
"backup_evidence": {
"status": "unverified_by_static_hook",
"required_before_execution": True,
},
"recovery": {"status": "operator_must_verify", "steps": recovery_steps},
"recovery_steps": {
"status": "operator_must_verify",
"steps": recovery_steps,
},
"stop_conditions": {
"status": "operator_must_verify",
"conditions": stop_conditions,
},
}
@dataclass
class Assessment:
decision: str = "allow"
rules: set[str] = field(default_factory=set)
capabilities: set[str] = field(default_factory=set)
uncertainties: set[str] = field(default_factory=set)
features: set[str] = field(default_factory=set)
risk_domains: set[str] = field(default_factory=set)
scopes: set[str] = field(default_factory=set)
targets: list[str] = field(default_factory=list)
def add(
self,
decision: str,
rule: str,
*,
capability: str | None = None,
uncertainty: str | None = None,
scope: str | None = None,
target: str | None = None,
risk_domain: str | None = None,
) -> None:
if RANK[decision] > RANK[self.decision]:
self.decision = decision
self.rules.add(rule)
if capability:
self.capabilities.add(capability)
if uncertainty:
self.uncertainties.add(uncertainty)
if scope:
self.scopes.add(scope)
if target and target not in self.targets and len(self.targets) < 6:
self.targets.append(target[:160])
if risk_domain:
self.risk_domains.add(risk_domain)
def basename(token: str) -> str:
return token.rstrip("/").rsplit("/", 1)[-1]
def tokenize(command: str) -> list[str]:
lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|()<>\n")
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = "#"
return list(lexer)
def split_commands(tokens: Iterable[str]) -> list[tuple[str | None, list[str]]]:
result: list[tuple[str | None, list[str]]] = []
current: list[str] = []
incoming: str | None = None
for token in tokens:
if token in CONTROL:
if current:
result.append((incoming, current))
current = []
incoming = token
else:
current.append(token)
if current:
result.append((incoming, current))
return result
def active_shell_features(command: str) -> tuple[set[str], list[str]]:
"""Return quote-aware dynamic features and literal command substitutions."""
features: set[str] = set()
substitutions: list[str] = []
i = 0
quote: str | None = None
def consume_parens(start: int) -> tuple[str | None, int]:
depth = 1
j = start
inner_quote: str | None = None
while j < len(command):
char = command[j]
if char == "\\":
j += 2
continue
if inner_quote == "'":
if char == "'":
inner_quote = None
j += 1
continue
if inner_quote == '"':
if char == '"':
inner_quote = None
elif (
char == "$"
and j + 1 < len(command)
and command[j + 1] == "("
):
depth += 1
j += 2
continue
j += 1
continue
if char in {"'", '"'}:
inner_quote = char
elif char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
return command[start:j], j + 1
j += 1
return None, len(command)
while i < len(command):
char = command[i]
if char == "\\" and quote != "'":
i += 2
continue
if quote == "'":
if char == "'":
quote = None
i += 1
continue
if char == "'" and quote is None:
quote = "'"
i += 1
continue
if char == '"':
quote = None if quote == '"' else '"'
i += 1
continue
if char == "`":
features.add("command-substitution")
j = i + 1
while j < len(command):
if command[j] == "\\":
j += 2
continue
if command[j] == "`":
break
j += 1
if j < len(command):
substitutions.append(command[i + 1 : j])
else:
features.add("unbalanced-backtick")
i = min(j + 1, len(command))
continue
if char == "$" and i + 1 < len(command) and command[i + 1] == "(":
if i + 2 < len(command) and command[i + 2] == "(":
features.add("arithmetic-expansion")
i += 3
continue
features.add("command-substitution")
content, end = consume_parens(i + 2)
if content is None:
features.add("unbalanced-substitution")
else:
substitutions.append(content)
i = end
continue
if char in "<>" and i + 1 < len(command) and command[i + 1] == "(":
features.add("process-substitution")
if (
char == "$"
and i + 1 < len(command)
and (command[i + 1].isalpha() or command[i + 1] in "_{@*#?!$-")
):
features.add("parameter-expansion")
if char in "*?[" and quote is None:
features.add("glob-expansion")
if char in "{}" and quote is None:
features.add("brace-or-block")
i += 1
if quote is not None:
features.add("unbalanced-quote")
return features, substitutions
def is_ancestor(parent: str, child: str) -> bool:
try:
return os.path.commonpath([parent, child]) == parent
except ValueError:
return False
def opaque_ref(kind: str, value: str) -> str:
digest = hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
return f"<{kind}:sha256:{digest[:16]}>"
def redact_target(value: str) -> str:
fixed = {
"$PROJECT",
"$CWD",
"$HOME",
"$HOST_ROOT",
"$SYSTEM_TREE",
"$CREDENTIAL_STORE",
"<dynamic:path>",
"<unknown>",
"<stdin-derived>",
}
return value if value in fixed else opaque_ref("target", value)
def target_info(
raw: str,
cwd: str,
project: str,
home: str,
) -> tuple[str, str, bool]:
token = raw.strip()
symbolic = {
"$HOME": home,
"${HOME}": home,
"~": home,
"~/": home,
"$CLAUDE_PROJECT_DIR": project,
"${CLAUDE_PROJECT_DIR}": project,
".": cwd,
"./": cwd,
}
dynamic = any(char in token for char in "$`*?[]{}") or "$(" in token
candidate = symbolic.get(token)
if candidate is None and token.startswith("~/"):
candidate = os.path.join(home, token[2:])
if candidate is None:
prefix = re.split(r"[*?\[{]", token, maxsplit=1)[0]
if prefix != token and prefix.endswith("/"):
candidate = prefix.rstrip("/") or "/"
elif not dynamic:
candidate = token
if candidate is None:
return "<dynamic:path>", "unknown", False
if not os.path.isabs(candidate):
candidate = os.path.join(cwd, candidate)
resolved = os.path.normpath(candidate)
system_roots = (
"/etc",
"/bin",
"/sbin",
"/usr",
"/var",
"/System",
"/Library",
"/Applications",
)
credential_roots = (
os.path.join(home, ".ssh"),
os.path.join(home, ".gnupg"),
os.path.join(home, ".aws"),
os.path.join(home, ".config", "gcloud"),
os.path.join(home, ".config", "op"),
)
if resolved == project:
shown, scope, critical = "$PROJECT", "repository", True
elif resolved == cwd:
shown, scope, critical = "$CWD", "working-directory", True
elif resolved == home:
shown, scope, critical = "$HOME", "home", True
elif resolved == "/":
shown, scope, critical = "$HOST_ROOT", "host", True
elif resolved == "/dev" or resolved.startswith("/dev/"):
shown, scope, critical = opaque_ref("device", resolved), "device", True
elif any(
resolved == root or is_ancestor(root, resolved)
for root in credential_roots
):
shown, scope, critical = "$CREDENTIAL_STORE", "credential-store-or-file", True
elif any(
resolved == root or is_ancestor(root, resolved) for root in system_roots
):
shown, scope, critical = "$SYSTEM_TREE", "system-tree", True
elif is_ancestor(resolved, project) or is_ancestor(resolved, home):
shown, scope, critical = resolved, "host-or-ancestor", True
elif is_ancestor(project, resolved):
shown = "$PROJECT/" + os.path.relpath(resolved, project)
scope, critical = "subtree", False
elif is_ancestor(home, resolved):
shown = "$HOME/" + os.path.relpath(resolved, home)
scope, critical = "subtree", False
else:
shown, scope, critical = resolved, "external-path", False
if dynamic and critical:
scope += "-contents"
return shown[:160], scope, critical
def head(
tokens: list[str], assessment: Assessment
) -> tuple[str, list[str]] | None:
i = 0
keywords = {"if", "then", "else", "elif", "while", "until", "do", "!"}
while i < len(tokens):
if ASSIGNMENT.match(tokens[i]):
i += 1
continue
if tokens[i] in keywords:
assessment.add(
"ask",
"SHELL_GRAMMAR",
capability="shell.control",
uncertainty="compound-shell-grammar",
)
i += 1
continue
if any(char in tokens[i] for char in "<>"):
i = min(i + 2, len(tokens))
continue
break
while i < len(tokens):
executable = basename(tokens[i])
if executable in {"sudo", "doas"}:
assessment.add(
"ask",
"PRIVILEGE_WRAPPER",
capability="privilege.escalation",
uncertainty="wrapper-options",
risk_domain="system-wide",
)
i += 1
options_with_values = {
"-u",
"-g",
"-h",
"-p",
"-C",
"-T",
"-R",
"-D",
}
while i < len(tokens) and tokens[i].startswith("-"):
option = tokens[i]
i += 1
if option in options_with_values and i < len(tokens):
i += 1
continue
if executable == "env":
i += 1
while i < len(tokens) and (
tokens[i].startswith("-") or ASSIGNMENT.match(tokens[i])
):
i += 1
continue
if executable in {"command", "builtin", "exec", "nohup", "time"}:
i += 1
while i < len(tokens) and tokens[i].startswith("-"):
i += 1
continue
if executable == "timeout":
i += 1
while i < len(tokens) and tokens[i].startswith("-"):
i += 1
if i < len(tokens):
i += 1
continue
break
if i >= len(tokens):
return None
return tokens[i], tokens[i + 1 :]
def rm_operands(args: list[str]) -> tuple[bool, bool, list[str]]:
recursive = False
force = False
operands = False
targets: list[str] = []
for arg in args:
if not operands and arg == "--":
operands = True
continue
if not operands and arg.startswith("--"):
recursive |= arg == "--recursive"
force |= arg == "--force"
continue
if not operands and arg.startswith("-") and arg != "-":
flags = arg[1:]
recursive |= "r" in flags or "R" in flags
force |= "f" in flags
continue
targets.append(arg)
return recursive, force, targets
def git_subcommand(args: list[str]) -> tuple[str | None, list[str]]:
i = 0
options_with_values = {
"-C",
"-c",
"--git-dir",
"--work-tree",
"--namespace",
"--exec-path",
}
while i < len(args):
token = args[i]
if token in options_with_values:
i += 2
continue
if token.startswith(
("--git-dir=", "--work-tree=", "--namespace=", "--exec-path=")
):
i += 1
continue
if token.startswith("-"):
i += 1
continue
return token, args[i + 1 :]
return None, []
def inspect_segment(
tokens: list[str],
incoming: str | None,
assessment: Assessment,
cwd: str,
project: str,
home: str,
depth: int,
) -> None:
parsed = head(tokens, assessment)
if parsed is None:
assessment.add("ask", "NO_EXECUTABLE", uncertainty="no-executable")
return
raw_executable, args = parsed
executable = basename(raw_executable)
if "/" in raw_executable and not raw_executable.startswith(
("/bin/", "/usr/bin/")
):
assessment.add(
"ask",
"PATH_EXECUTABLE",
capability="code.execution",
uncertainty="mutable-executable-path",
)
# These families are capability signals, not proof of effect. In
# particular, sudo is handled as a wrapper above: it is neither required
# for a system-wide effect nor sufficient to prove one will occur.
if executable in SYSTEM_WIDE_TOOLS:
assessment.add(
"ask",
"SYSTEM_WIDE_CAPABLE_TOOL",
capability="system.configuration-or-process-control",
uncertainty="runtime-authority-and-subcommand",
scope="host",
risk_domain="system-wide",
)
if executable.startswith("mkfs") or executable in DISK_TOOLS:
assessment.add(
"ask",
"DISK_CAPABLE_TOOL",
capability="disk-or-filesystem-control",
uncertainty="device-mapping-and-runtime-flags",
scope="device-or-image",
risk_domain="disk",
)
raw_device = next((arg for arg in args if arg.startswith("/dev/")), None)
diskutil_mutators = {
"eraseDisk",
"partitionDisk",
"zeroDisk",
"secureErase",
"eraseVolume",
"deleteVolume",
"apfs",
}
if executable == "diskutil" and any(
arg in diskutil_mutators for arg in args
):
assessment.add(
"deny",
"RAW_DEVICE_MUTATION",
capability="storage.erase-or-repartition",
scope="device",
target=raw_device or "<unknown>",
risk_domain="disk",
)
return
if executable in {"fdisk", "sfdisk", "parted"} and raw_device:
explicitly_read_only = executable == "fdisk" and any(
arg in {"-l", "--list"} for arg in args
)
if not explicitly_read_only:
assessment.add(
"deny",
"RAW_DEVICE_MUTATION",
capability="storage.partition-table-write",
scope="device",
target=raw_device,
risk_domain="disk",
)
return
if executable == "date" or executable in TIME_TOOLS:
assessment.add(
"ask",
"TIME_CAPABLE_TOOL",
capability="clock-or-time-configuration",
uncertainty="runtime-authority-and-subcommand",
scope="host",
risk_domain="time",
)
if executable in TIME_TOOLS:
assessment.risk_domains.add("system-wide")
if executable in NETWORK_TOOLS:
assessment.add(
"ask",
"NETWORK_CAPABLE_TOOL",
capability="network-io-or-configuration",
uncertainty="remote-endpoint-and-server-side-effects",
scope="remote-or-host-network",
risk_domain="network",
)
credential_subcommand = executable in {
"gh",
"aws",
"gcloud",
"az",
"docker",
"npm",
"pnpm",
} and any(
marker in arg.lower()
for arg in args
for marker in ("auth", "login", "credential", "token", "configure")
)
sensitive_argument = any(SENSITIVE_ARGUMENT.search(arg) for arg in args)
if executable in CREDENTIAL_TOOLS or credential_subcommand or sensitive_argument:
assessment.add(
"ask",
"CREDENTIAL_CAPABLE_TOOL_OR_TARGET",
capability="credential-read-write-or-disclosure",
uncertainty="secret-store-and-runtime-output",
scope="credential-store-or-file",
risk_domain="credential",
)
if executable in {"rm", "unlink", "rmdir", "shred", "truncate", "find"}:
assessment.risk_domains.add("disk")
if executable in SHELLS:
if incoming in PIPE_OPS and "-c" not in args:
assessment.add(
"deny",
"OPAQUE_PIPE_TO_SHELL",
capability="code.execution",
uncertainty="stdin-generated-code",
scope="unknown",
)
return
if "-c" in args:
position = args.index("-c")
assessment.add(
"ask",
"SHELL_C_WRAPPER",
capability="code.execution",
uncertainty="second-parse",
)
if position + 1 < len(args):
program = args[position + 1]
if any(marker in program for marker in ("$(", "`", "${")):
assessment.add(
"deny",
"OPAQUE_DYNAMIC_SHELL_C",
capability="code.execution",
uncertainty="runtime-generated-program",
scope="unknown",
)
inspect_snippet(
program,
assessment,
cwd,
project,
home,
depth + 1,
)
else:
assessment.add(
"ask",
"SHELL_C_MISSING_PROGRAM",
uncertainty="missing-program",
)
else:
assessment.add(
"ask",
"SHELL_OR_SCRIPT_WRAPPER",
capability="code.execution",
uncertainty="script-or-stdin-not-inspected",
)
return
if executable in {"eval", "source", "."}:
assessment.add(
"deny",
"OPAQUE_REPARSE",
capability="code.execution",
uncertainty="runtime-reparse",
scope="unknown",
)
return
if executable == "xargs":
names = {basename(arg) for arg in args}
if names.intersection({"rm", "unlink", "rmdir", "shred"}):
assessment.add(
"deny",
"UNBOUNDED_XARGS_DELETE",
capability="filesystem.delete",
uncertainty="stdin-derived-targets",
scope="unknown",
target="<stdin-derived>",
risk_domain="disk",
)
else:
assessment.add(
"ask",
"XARGS_DYNAMIC_TARGETS",
capability="code.execution",
uncertainty="stdin-derived-arguments",
)
return
if executable == "rm":
recursive, force, targets = rm_operands(args)
if not targets:
assessment.add(
"ask",
"DELETE_WITHOUT_STATIC_TARGET",
capability="filesystem.delete",
uncertainty="missing-or-dynamic-target",
)
for target in targets:
shown, scope, critical = target_info(target, cwd, project, home)
if recursive and critical:
assessment.add(
"deny",
"RECURSIVE_DELETE_CRITICAL_SCOPE",
capability="filesystem.delete",
scope=scope,
target=shown,
risk_domain=(
"system-wide"
if scope in {"host", "host-or-ancestor", "system-tree"}
else (
"credential"
if scope == "credential-store-or-file"
else "disk"
)
),
)
else:
assessment.add(
"ask",
"FILESYSTEM_DELETE",
capability="filesystem.delete",
scope=scope,
target=shown,
uncertainty=(
"dynamic-target" if shown == "<dynamic:path>" else None
),
)
if force:
assessment.features.add("force")
if recursive:
assessment.features.add("recursive")
return
if executable in {"unlink", "rmdir", "shred", "truncate"}:
targets = [arg for arg in args if not arg.startswith("-")]
if not targets:
targets = ["<unknown>"]
for target in targets[:6]:
if target == "<unknown>":
shown, scope, critical = target, "unknown", False
else:
shown, scope, critical = target_info(target, cwd, project, home)
assessment.add(
"deny" if executable == "shred" and critical else "ask",
"DESTRUCTIVE_FILE_OPERATION",
capability="filesystem.delete-or-truncate",
scope=scope,
target=shown,
)
return
if executable == "find":
mutators = {
"-delete",
"-exec",
"-execdir",
"-ok",
"-okdir",
"-fprint",
"-fprint0",
}
if any(arg in mutators for arg in args):
roots: list[str] = []
for arg in args:
if arg.startswith("-") or arg in {"!", "("}:
break
roots.append(arg)
if not roots:
roots = ["."]
for target in roots:
shown, scope, critical = target_info(target, cwd, project, home)
assessment.add(
"deny" if critical else "ask",
"MUTATING_FIND",
capability="filesystem.bulk-mutation",
scope=scope,
target=shown,
)
return
if executable.startswith("mkfs") or executable == "wipefs":
assessment.add(
"deny",
"FILESYSTEM_FORMATTER",
capability="storage.erase",
scope="device-or-image",
risk_domain="disk",
)
return
if executable == "dd":
output = next((arg[3:] for arg in args if arg.startswith("of=")), "<unknown>")
if output.startswith("/dev/"):
assessment.add(
"deny",
"RAW_DEVICE_WRITE",
capability="storage.write",
scope="device",
target=opaque_ref("device", output),
risk_domain="disk",
)
else:
if output == "<unknown>":
shown, scope, critical = output, "unknown", False
else:
shown, scope, critical = target_info(output, cwd, project, home)
assessment.add(
"deny" if critical else "ask",
"DD_WRITE",
capability="filesystem.overwrite",
scope=scope,
target=shown,
risk_domain="disk",
)
return
if executable in {"chmod", "chown", "chgrp"}:
recursive = any(arg in {"-R", "--recursive"} for arg in args)
target = next(
(arg for arg in reversed(args) if not arg.startswith("-")),
"<unknown>",
)
if target == "<unknown>":
shown, scope, critical = target, "unknown", False
else:
shown, scope, critical = target_info(target, cwd, project, home)
assessment.add(
"deny" if recursive and critical else "ask",
"PERMISSION_OR_OWNER_CHANGE",
capability="filesystem.metadata-write",
scope=scope,
target=shown,
risk_domain="system-wide" if critical else "disk",
)
return
if executable == "git":
subcommand, rest = git_subcommand(args)
if subcommand == "clean":
dry_run = any(
arg in {"-n", "--dry-run"}
or (
arg.startswith("-")
and not arg.startswith("--")
and "n" in arg[1:]
)
for arg in rest
)
forced = any(
arg in {"-f", "--force"}
or (
arg.startswith("-")
and not arg.startswith("--")
and "f" in arg[1:]
)
for arg in rest
)
ignored = any(
arg in {"-x", "-X"}
or (
arg.startswith("-")
and not arg.startswith("--")
and ("x" in arg[1:] or "X" in arg[1:])
)
for arg in rest
)
if dry_run:
assessment.add(
"ask",
"GIT_RUNTIME_EXTENSIONS",
capability="repository.read-with-runtime-extensions",
scope="repository",
uncertainty="git-hooks-config-fsmonitor-or-pager",
)
return
assessment.add(
"deny" if forced and ignored else "ask",
"GIT_CLEAN",
capability="repository.delete",
scope="repository",
uncertainty=None if forced else "git-config-can-enable-force",
)
return
if subcommand in {
"reset",
"restore",
"checkout",
"rm",
"rebase",
"filter-branch",
"filter-repo",
"gc",
"reflog",
}:
assessment.add(
"ask",
"GIT_HISTORY_OR_WORKTREE_MUTATION",
capability="repository.rewrite",
scope="repository",
)
return
ref_mutation = any(
arg in {"--force", "-f", "-D", "-d", "--delete"}
or arg.startswith("--force-with-lease")
for arg in rest
)
if subcommand in {"push", "tag", "branch"} and ref_mutation:
assessment.add(
"ask",
"GIT_REF_MUTATION",
capability="remote-or-local-ref-write",
scope="repository-or-remote",
)
return
assessment.add(
"ask",
"GIT_RUNTIME_EXTENSIONS",
capability="repository-access-with-runtime-extensions",
scope="repository",
uncertainty=(
"git-subcommand-sha256:"
+ hashlib.sha256(
(subcommand or "unknown").encode("utf-8", "surrogatepass")
).hexdigest()[:16]
),
)
return
if executable == "rg" and any(
arg == "--pre" or arg.startswith("--pre=") for arg in args
):
assessment.add(
"ask",
"RG_PREPROCESSOR",
capability="code.execution",
uncertainty="external-preprocessor",
)
return
if executable in LEXICALLY_SIMPLE_COMMANDS:
assessment.add(
"ask",
"RUNTIME_RESOLUTION_UNVERIFIED",
capability="shell.command-resolution",
uncertainty="function-alias-builtin-or-filesystem-shadowing",
)
return
if executable in {"python", "python3", "node", "perl", "ruby", "php"}:
assessment.add(
"ask",
"GENERAL_INTERPRETER",
capability="code.execution",
uncertainty="program-semantics-not-inspected",
)
return
assessment.add(
"ask",
"UNKNOWN_EXECUTABLE",
capability="code.execution",
uncertainty=(
"executable-sha256:"
+ hashlib.sha256(
executable.encode("utf-8", "surrogatepass")
).hexdigest()[:16]
),
)
def inspect_snippet(
command: str,
assessment: Assessment,
cwd: str,
project: str,
home: str,
depth: int = 0,
) -> None:
if depth > 3:
assessment.add(
"ask", "RECURSION_LIMIT", uncertainty="nested-shell-depth"
)
return
features, substitutions = active_shell_features(command)
assessment.features.update(features)
if features.intersection(
{"unbalanced-quote", "unbalanced-substitution", "unbalanced-backtick"}
):
assessment.add(
"ask", "UNBALANCED_SHELL_SYNTAX", uncertainty="parse-incomplete"
)
dynamic = {
"command-substitution",
"process-substitution",
"arithmetic-expansion",
"parameter-expansion",
"glob-expansion",
"brace-or-block",
}
if features.intersection(dynamic):
assessment.add(
"ask",
"DYNAMIC_SHELL_EXPANSION",
capability="shell.dynamic",
uncertainty="runtime-expansion",
)
for inner in substitutions[:6]:
inspect_snippet(inner, assessment, cwd, project, home, depth + 1)
try:
tokens = tokenize(command)
except ValueError:
assessment.add(
"ask", "TOKENIZE_FAILURE", uncertainty="shell-parse-failed"
)
return
if not tokens:
if command.strip().startswith("#"):
assessment.rules.add("COMMENT_ONLY_NO_COMMAND")
else:
assessment.add("ask", "EMPTY_OR_COMMENT", uncertainty="no-command")
return
# Heredoc bodies require full shell grammar. Do not derive deny from body
# text because the apparent command can be inert data.
if any("<<" in token for token in tokens):
assessment.add(
"ask",
"HEREDOC_UNINSPECTED",
capability="shell.dynamic",
uncertainty="heredoc-body",
)
return
if any(any(char in token for char in "<>") for token in tokens):
assessment.add(
"ask",
"SHELL_REDIRECTION",
capability="filesystem-or-fd-write",
uncertainty="redirection-target",
)
for incoming, segment in split_commands(tokens):
inspect_segment(
segment, incoming, assessment, cwd, project, home, depth
)
def redact_context_path(value: str, project: str, home: str) -> str:
value = os.path.normpath(value)
if value == project:
return "$PROJECT"
if is_ancestor(project, value):
return opaque_ref("project-cwd", os.path.relpath(value, project))
if value == home:
return "$HOME"
if is_ancestor(home, value):
return opaque_ref("home-cwd", os.path.relpath(value, home))
return opaque_ref("external-cwd", value)
def classify_payload(
payload: object,
*,
project_dir: str | None = None,
home_dir: str | None = None,
) -> dict:
assessment = Assessment()
if not isinstance(payload, dict):
assessment.add(
"deny", "INVALID_PAYLOAD", uncertainty="top-level-not-object"
)
command = ""
cwd = os.getcwd()
permission_mode = "unknown"
else:
tool_name = payload.get("tool_name")
tool_input = payload.get("tool_input")
cwd_value = payload.get("cwd")
permission_mode = payload.get("permission_mode", "unknown")
if isinstance(cwd_value, str) and os.path.isabs(cwd_value):
cwd = os.path.normpath(cwd_value)
else:
cwd = os.getcwd()
assessment.add(
"ask", "INVALID_CWD", uncertainty="missing-or-relative-cwd"
)
command = (
tool_input.get("command") if isinstance(tool_input, dict) else None
)
if tool_name != "Bash":
assessment.add(
"deny",
"UNEXPECTED_TOOL",
uncertainty="matcher-misconfiguration",
)
if not isinstance(command, str) or not command:
assessment.add(
"deny",
"INVALID_COMMAND",
uncertainty="missing-or-nonstring-command",
)
command = ""
project = os.path.normpath(
project_dir or os.environ.get("CLAUDE_PROJECT_DIR") or cwd
)
home = os.path.normpath(home_dir or os.path.expanduser("~"))
encoded = command.encode("utf-8", "surrogatepass")
digest = hashlib.sha256(encoded).hexdigest()
if len(encoded) > MAX_COMMAND_BYTES:
assessment.add(
"deny", "COMMAND_TOO_LARGE", uncertainty="inspection-size-limit"
)
elif "\x00" in command:
assessment.add(
"deny", "NUL_IN_COMMAND", uncertainty="invalid-shell-string"
)
else:
if any(
char in INVISIBLE or unicodedata.category(char) == "Cf"
for char in command
):
assessment.add(
"ask",
"INVISIBLE_UNICODE",
uncertainty="visual-order-or-invisible-codepoint",
)
inspect_snippet(command, assessment, cwd, project, home)
explicit_high_impact = bool(
assessment.rules.intersection(EXPLICIT_HIGH_IMPACT_SIGNALS)
)
if assessment.decision == "deny" and explicit_high_impact:
risk, confidence, reversibility = "critical", "high", "hard"
decision_basis = "matched_explicit_high_impact_signature"
elif assessment.decision == "deny":
risk, confidence, reversibility = "indeterminate", "low", "unknown"
decision_basis = "inspection_uncertainty_fail_closed"
elif assessment.decision == "ask":
risk, confidence, reversibility = "medium", "medium", "uncertain"
decision_basis = "review_required_capability_or_uncertainty"
else:
risk, confidence, reversibility = "low", "high", "none"
decision_basis = "comment_only_no_command"
scope_priority = (
"host",
"host-or-ancestor",
"system-tree",
"home",
"credential-store-or-file",
"repository",
"working-directory",
"device",
"device-or-image",
"repository-or-remote",
"subtree",
"external-path",
"unknown",
)
scope = next(
(
candidate
for candidate in scope_priority
if any(
value == candidate or value == candidate + "-contents"
for value in assessment.scopes
)
),
"none" if assessment.decision == "allow" else "unknown",
)
receipt = {
"schema": "alphalab.bash-blast-radius/v1",
"decision": assessment.decision,
"decision_basis": decision_basis,
"risk": risk,
"confidence": confidence,
"policy_interpretation": (
"static_classification_not_proof_of_harmlessness_or_effect"
),
"command": {
"sha256": digest,
"sha256_purpose": "binding_and_integrity_only_not_anonymization",
"bytes": len(encoded),
"raw_included": False,
},
"context": {
"cwd": redact_context_path(cwd, project, home),
"permission_mode": (
permission_mode
if isinstance(permission_mode, str)
and permission_mode in KNOWN_PERMISSION_MODES
else "unknown"
),
},
"blast_radius": {
"scope": scope,
"capabilities": sorted(assessment.capabilities)[:8],
"targets": [redact_target(value) for value in assessment.targets],
"reversibility": reversibility,
},
"risk_domains": sorted(assessment.risk_domains),
"signals": sorted(assessment.rules)[:12],
"uncertainties": sorted(assessment.uncertainties)[:12],
"shell_features": sorted(assessment.features)[:12],
}
receipt.update(operator_guidance(assessment.decision, assessment.risk_domains))
return receipt
def hook_output(receipt: dict) -> dict:
# permissionDecisionReason must be a string, so serialize the receipt once
# inside Claude Code's official structured-output envelope.
reason = json.dumps(
{"type": "bash_blast_radius_receipt", "receipt": receipt},
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
)
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": receipt["decision"],
"permissionDecisionReason": reason,
}
}
# A deny reason is delivered to Claude. systemMessage requests a
# user-facing warning, but it is not specified as part of the permission card.
if receipt["decision"] == "deny":
output["systemMessage"] = reason
return output
def invalid_stdin_receipt(rule: str, raw: bytes) -> dict:
receipt = {
"schema": "alphalab.bash-blast-radius/v1",
"decision": "deny",
"decision_basis": "inspection_uncertainty_fail_closed",
"risk": "indeterminate",
"confidence": "low",
"policy_interpretation": (
"static_classification_not_proof_of_harmlessness_or_effect"
),
"command": {
"sha256": hashlib.sha256(raw).hexdigest(),
"sha256_purpose": "binding_and_integrity_only_not_anonymization",
"bytes": len(raw),
"raw_included": False,
},
"context": {"cwd": "<unknown>", "permission_mode": "unknown"},
"blast_radius": {
"scope": "unknown",
"capabilities": [],
"targets": [],
"reversibility": "unknown",
},
"risk_domains": [],
"signals": [rule],
"uncertainties": ["hook-input-unavailable"],
"shell_features": [],
}
receipt.update(operator_guidance("deny", []))
return receipt
def main() -> int:
raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1)
try:
if len(raw) > MAX_STDIN_BYTES:
receipt = invalid_stdin_receipt("STDIN_TOO_LARGE", raw)
else:
payload = json.loads(raw.decode("utf-8", "strict"))
receipt = classify_payload(payload)
except (UnicodeDecodeError, json.JSONDecodeError):
receipt = invalid_stdin_receipt("INVALID_STDIN_JSON", raw)
except Exception:
# Never echo exception text: it may contain input or local paths.
receipt = invalid_stdin_receipt("GATE_INTERNAL_ERROR", raw)
serialized = json.dumps(
hook_output(receipt),
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
)
sys.stdout.write(serialized)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
收據雖然有 smaller_alternative、backup_evidence、recovery_steps 與 stop_conditions,卻不假裝靜態分析已完成實地驗證。對 ask/deny,替代與復原內容明標 non_authoritative_domain_template/operator_must_verify,備份狀態固定是 unverified_by_static_hook;真正的備份 ID、runbook、負責人與復原演練結果,必須由另一個可信證據來源補上。
你會在輸出看到 hookSpecificOutput.permissionDecision 與字串型態的 permissionDecisionReason。官方 schema 要求原因是字串,所以程式把收據 JSON 序列化一次塞進去。ask 的原因會出現在使用者的 permission prompt;deny 的原因則回給 Claude。範例另外回傳 systemMessage,官方把它定義為給使用者的 warning;在 Agent SDK 或 stream-json 中也可能成為資訊事件,但不保證嵌在同一張 permission card,CLI、VS Code 與 Desktop 都應各自驗收呈現方式。stdout 只輸出一個 JSON object,避免除錯文字破壞協定。
步驟 3:用 exec form 掛上 PreToolUse
先把設定放在不提交的 .claude/settings.local.json 驗收;安全審查後才合併到團隊共用的 .claude/settings.json。若這個 local 檔是手動建立,請自行把它加入 .gitignore;官方只保證 Claude Code 自己建立時會加入 global git excludes。args 會讓 Hook 使用 exec form,不再經過第二層 shell 解析路徑。不要加 best-effort 的 if 篩選器,讓分類器收到每一個由 Claude 發出的 Bash tool call。
Exec form 只移除第二層 shell 解析;範例中的 python3 仍會從 PATH 解析。正式政策應使用受管理、不可由專案修改的絕對 executable 路徑;managed settings 若仍指向 repository 內可修改的 Python 檔,也不會讓分類器自動具備防竄改能力。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3",
"args": [
"${CLAUDE_PROJECT_DIR}/.claude/hooks/bash-risk-gate.py"
],
"timeout": 2,
"statusMessage": "Assessing Bash blast radius"
}
]
}
]
}
}
Hook 設定通常會在存檔後自動 hot reload;先用 /hooks 確認來源與 matcher,只有未刷新時才重新啟動 Claude Code。這份解析器只處理 Bash;如果環境也啟用 PowerShell,請做獨立的 PowerShell parser,不能把 Bash 的 quoting 規則直接套過去。要理解更一般的 runtime control,可搭配AI Agent Harness 心智模型。
步驟 4:只測字串,不測破壞
把下面 harness 存成 .claude/hooks/test_bash_risk_gate.py,再執行 python3 .claude/hooks/test_bash_risk_gate.py。這個 Claude Code 高風險命令閘門的所有高風險字樣都是 Python 字串;可疑 executable 指向不存在的 /__mock__/bin,測試只呼叫 classify_payload() 或 Python Hook 程序,從未把 tool_input.command 交給 shell。
展開完整非執行測試(可複製)
#!/usr/bin/env python3
"""Non-executing policy and output-contract tests for bash-risk-gate.py."""
import importlib.util
import hashlib
import json
import pathlib
import subprocess
import sys
import unittest
GATE = pathlib.Path(__file__).with_name("bash-risk-gate.py")
SPEC = importlib.util.spec_from_file_location("bash_risk_gate", GATE)
assert SPEC is not None and SPEC.loader is not None
gate = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = gate
SPEC.loader.exec_module(gate)
PROJECT = "/__alphalab_gate_mock__/project"
HOME = "/__alphalab_gate_mock__/home"
# These strings are data only. Potentially mutating executable paths point to
# a deliberately nonexistent /__mock__/bin. No fixture command is executed.
FIXTURES = [
("comment-only-no-command", "# inert fixture only", "allow", False),
("simple-pwd-runtime-review", "pwd", "ask", True),
("git-read-chain-review", "git status --short && git diff --stat", "ask", True),
(
"quoted-danger-text",
"printf '%s\\n' '/__mock__/bin/rm -rf " + PROJECT + "'",
"ask",
True,
),
(
"bounded-delete",
"/__mock__/bin/rm -f " + PROJECT + "/tmp/one",
"ask",
True,
),
("project-delete", "/__mock__/bin/rm -rf " + PROJECT, "deny", True),
(
"normalized-project-delete",
"/__mock__/bin/rm -rf " + PROJECT + "/cache/..",
"deny",
True,
),
(
"chain-delete",
"printf ok && /__mock__/bin/rm -rf " + PROJECT,
"deny",
True,
),
(
"newline-delete",
"printf ok\n/__mock__/bin/rm -rf " + PROJECT,
"deny",
True,
),
(
"env-wrapper",
"env MOCK=1 /__mock__/bin/rm -rf " + PROJECT,
"deny",
True,
),
(
"shell-c",
"bash -c '/__mock__/bin/rm -rf " + PROJECT + "'",
"deny",
True,
),
(
"command-substitution",
'echo "$(/__mock__/bin/rm -rf ' + PROJECT + ')"',
"deny",
True,
),
(
"wrapper-script",
"/__mock__/bin/mock-cleanup.sh --dry-run",
"ask",
True,
),
(
"harmless-encoded-pipe",
"printf 'bW9jayBwYXlsb2Fk' | /__mock__/bin/base64 -d | sh",
"deny",
True,
),
(
"xargs-delete",
"printf '%s\\n' /__alphalab_gate_mock__/one | "
"xargs /__mock__/bin/rm -rf",
"deny",
True,
),
(
"git-hard-reset-invalid-ref",
"/__mock__/bin/git reset --hard MOCK_NONEXISTENT_REF",
"ask",
True,
),
("git-clean-dry-run-review", "git clean -ndx", "ask", True),
(
"git-clean-force-mock-bin",
"/__mock__/bin/git clean -fdx",
"deny",
True,
),
(
"find-subtree",
"/__mock__/bin/find " + PROJECT + "/cache -type f -delete",
"ask",
True,
),
(
"find-project",
"/__mock__/bin/find " + PROJECT + " -type f -delete",
"deny",
True,
),
(
"redirection-to-missing-mock-path",
"printf ok > " + PROJECT + "/out",
"ask",
True,
),
(
"rg-pre-mock",
"rg --pre /__mock__/bin/reader needle .",
"ask",
True,
),
("harmless-interpreter", "python3 -c 'print(1)'", "ask", True),
(
"heredoc-inert-text",
"cat <<'MOCK'\n/__mock__/bin/rm -rf " + PROJECT + "\nMOCK",
"ask",
True,
),
("unbalanced-inert", "printf 'mock", "ask", True),
("unicode-bidi-inert", "printf '\u202emock'", "ask", True),
(
"relative-after-cd",
"cd /__alphalab_gate_mock__/elsewhere && "
"/__mock__/bin/rm -rf cache",
"ask",
True,
),
]
# Additional command strings are still data only. They exercise the explicit
# receipt taxonomy requested for host-level review. None of these executables
# exists at /__mock__/bin, and the harness never attempts to invoke them.
DOMAIN_FIXTURES = [
(
"system-wide-without-sudo",
"/__mock__/bin/systemctl restart mock.service",
"ask",
{"system-wide"},
),
(
"sudo-is-signal-not-proof",
"/__mock__/bin/sudo -n /__mock__/bin/printf ok",
"ask",
{"system-wide"},
),
(
"disk-formatter-mock-device",
"/__mock__/bin/mkfs.mock /dev/mock0",
"deny",
{"disk"},
),
(
"time-configuration-mock",
"/__mock__/bin/timedatectl set-time '2000-01-01 00:00:00'",
"ask",
{"system-wide", "time"},
),
(
"network-read-invalid-domain",
"/__mock__/bin/curl https://example.invalid/mock",
"ask",
{"network"},
),
(
"credential-store-mock-query",
"/__mock__/bin/security find-generic-password -s mock -w",
"ask",
{"credential"},
),
]
# Focused semantic regressions. Every possibly destructive command remains an
# inert string; only the Python classifier sees it.
SEMANTIC_FIXTURES = [
(
"diskutil-raw-device",
"/__mock__/bin/diskutil eraseDisk MOCK /__mock__/volume /dev/mock0",
"deny",
True,
),
(
"fdisk-raw-device",
"/__mock__/bin/fdisk /dev/mock0",
"deny",
True,
),
(
"recursive-system-tree",
"/__mock__/bin/rm -rf /etc",
"deny",
True,
),
(
"recursive-key-directory",
"/__mock__/bin/rm -rf " + HOME + "/.ssh",
"deny",
True,
),
("opaque-source", "source harmless-mock.sh", "deny", True),
("opaque-eval", "eval printf ok", "deny", True),
("git-status-runtime", "git status --short", "ask", True),
("git-diff-runtime", "git diff --stat", "ask", True),
("git-log-runtime", "git log -1", "ask", True),
("git-show-runtime", "git show MOCK_REF", "ask", True),
("narrow-builtin-chain", "pwd && printf '%s\\n' mock", "ask", True),
]
EXPECTED_RECEIPT_KEYS = {
"schema",
"decision",
"decision_basis",
"risk",
"confidence",
"policy_interpretation",
"command",
"context",
"blast_radius",
"risk_domains",
"signals",
"uncertainties",
"shell_features",
"smaller_alternative",
"safer_alternative",
"backup_evidence",
"recovery",
"recovery_steps",
"stop_conditions",
}
def payload(command):
return {
"session_id": "fixture-session",
"transcript_path": "/__mock__/transcript.jsonl",
"cwd": PROJECT,
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": command,
"description": "Non-executing fixture",
"timeout": 120000,
"run_in_background": False,
},
"tool_use_id": "fixture-tool-use",
}
class GateTests(unittest.TestCase):
def test_policy_fixtures(self):
for name, command, expected, _ in FIXTURES:
with self.subTest(name=name):
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
self.assertEqual(expected, receipt["decision"])
self.assertFalse(receipt["command"]["raw_included"])
self.assertNotIn(command, json.dumps(receipt))
def test_exact_decision_counts(self):
counts = {"allow": 0, "ask": 0, "deny": 0}
for _, command, _, _ in FIXTURES:
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
counts[receipt["decision"]] += 1
self.assertEqual({"allow": 1, "ask": 15, "deny": 11}, counts)
def test_confusion_matrix(self):
tp = fp = tn = fn = 0
for _, command, _, intervention_required in FIXTURES:
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
predicted_positive = receipt["decision"] != "allow"
if intervention_required and predicted_positive:
tp += 1
elif intervention_required:
fn += 1
elif predicted_positive:
fp += 1
else:
tn += 1
self.assertEqual((26, 0, 1, 0), (tp, fp, tn, fn))
def test_semantic_regression_fixtures(self):
for name, command, expected, _ in SEMANTIC_FIXTURES:
with self.subTest(name=name):
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
self.assertEqual(expected, receipt["decision"])
self.assertNotIn(command, json.dumps(receipt))
def test_required_risk_domains(self):
observed = set()
for name, command, expected, required_domains in DOMAIN_FIXTURES:
with self.subTest(name=name):
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
self.assertEqual(expected, receipt["decision"])
self.assertEqual(
sorted(receipt["risk_domains"]), receipt["risk_domains"]
)
self.assertTrue(
required_domains.issubset(set(receipt["risk_domains"]))
)
self.assertNotIn(command, json.dumps(receipt))
observed.update(receipt["risk_domains"])
self.assertTrue(
{"system-wide", "disk", "time", "network", "credential"}
.issubset(observed)
)
def test_sudo_is_neither_necessary_nor_sufficient(self):
without_sudo = gate.classify_payload(
payload("/__mock__/bin/systemctl restart mock.service"),
project_dir=PROJECT,
home_dir=HOME,
)
sudo_only = gate.classify_payload(
payload("/__mock__/bin/sudo -n /__mock__/bin/printf ok"),
project_dir=PROJECT,
home_dir=HOME,
)
self.assertIn("system-wide", without_sudo["risk_domains"])
self.assertEqual("ask", sudo_only["decision"])
self.assertIn("PRIVILEGE_WRAPPER", sudo_only["signals"])
self.assertNotEqual("deny", sudo_only["decision"])
def test_official_auto_permission_mode_is_preserved(self):
probe = payload("pwd")
probe["permission_mode"] = "auto"
receipt = gate.classify_payload(
probe, project_dir=PROJECT, home_dir=HOME
)
self.assertEqual("auto", receipt["context"]["permission_mode"])
def test_extended_counts_and_confusion_matrix(self):
counts = {"allow": 0, "ask": 0, "deny": 0}
tp = fp = tn = fn = 0
combined = FIXTURES + [
(name, command, expected, True)
for name, command, expected, _ in DOMAIN_FIXTURES
] + SEMANTIC_FIXTURES
for _, command, _, intervention_required in combined:
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
counts[receipt["decision"]] += 1
predicted_positive = receipt["decision"] != "allow"
if intervention_required and predicted_positive:
tp += 1
elif intervention_required:
fn += 1
elif predicted_positive:
fp += 1
else:
tn += 1
self.assertEqual({"allow": 1, "ask": 25, "deny": 18}, counts)
self.assertEqual((43, 0, 1, 0), (tp, fp, tn, fn))
def test_exact_receipt_guidance_schema_and_content(self):
combined = FIXTURES + [
(name, command, expected, True)
for name, command, expected, _ in DOMAIN_FIXTURES
] + SEMANTIC_FIXTURES
for name, command, expected, _ in combined:
with self.subTest(name=name):
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
self.assertEqual(expected, receipt["decision"])
self.assertEqual(EXPECTED_RECEIPT_KEYS, set(receipt))
self.assertEqual(
{"sha256", "sha256_purpose", "bytes", "raw_included"},
set(receipt["command"]),
)
self.assertEqual(
hashlib.sha256(command.encode()).hexdigest(),
receipt["command"]["sha256"],
)
self.assertEqual(
"binding_and_integrity_only_not_anonymization",
receipt["command"]["sha256_purpose"],
)
self.assertEqual(
"static_classification_not_proof_of_harmlessness_or_effect",
receipt["policy_interpretation"],
)
self.assertEqual(
sorted(receipt["risk_domains"]), receipt["risk_domains"]
)
if expected == "allow":
self.assertEqual(
"comment_only_no_command", receipt["decision_basis"]
)
self.assertEqual(
"not_required_for_comment_only_no_command",
gate.NOT_REQUIRED_STATUS,
)
self.assertEqual(
{
"status": gate.NOT_REQUIRED_STATUS,
"templates": [],
},
receipt["smaller_alternative"],
)
self.assertEqual(
{
"status": gate.NOT_REQUIRED_STATUS,
"templates": [],
},
receipt["safer_alternative"],
)
self.assertEqual(
{
"status": gate.NOT_REQUIRED_STATUS,
"required_before_execution": False,
},
receipt["backup_evidence"],
)
self.assertEqual(
{"status": gate.NOT_REQUIRED_STATUS, "steps": []},
receipt["recovery"],
)
self.assertEqual(
receipt["recovery"], receipt["recovery_steps"]
)
self.assertEqual(
{
"status": gate.NOT_REQUIRED_STATUS,
"conditions": [],
},
receipt["stop_conditions"],
)
else:
expected_domains = receipt["risk_domains"] or ["general"]
for field in ("smaller_alternative", "safer_alternative"):
self.assertEqual(
{"status", "templates"}, set(receipt[field])
)
self.assertEqual(
gate.TEMPLATE_STATUS, receipt[field]["status"]
)
self.assertEqual(
expected_domains,
[
item["domain"]
for item in receipt[field]["templates"]
],
)
self.assertTrue(
all(
set(item) == {"domain", "text"}
and item["text"]
for item in receipt[field]["templates"]
)
)
self.assertEqual(
{
"status": "unverified_by_static_hook",
"required_before_execution": True,
},
receipt["backup_evidence"],
)
self.assertEqual(
{"status", "steps"}, set(receipt["recovery"])
)
self.assertEqual(
"operator_must_verify", receipt["recovery"]["status"]
)
self.assertTrue(receipt["recovery"]["steps"])
self.assertEqual(
receipt["recovery"], receipt["recovery_steps"]
)
self.assertEqual(
{"status", "conditions"},
set(receipt["stop_conditions"]),
)
self.assertEqual(
"operator_must_verify",
receipt["stop_conditions"]["status"],
)
self.assertTrue(receipt["stop_conditions"]["conditions"])
output = gate.hook_output(receipt)
reason = output["hookSpecificOutput"][
"permissionDecisionReason"
]
self.assertNotIn(command, json.dumps(output, sort_keys=True))
self.assertLess(len(reason.encode("utf-8")), 10_000)
def test_deny_confidence_distinguishes_signature_from_uncertainty(self):
for command in (
"/__mock__/bin/diskutil eraseDisk MOCK /__mock__/volume /dev/mock0",
"/__mock__/bin/fdisk /dev/mock0",
"/__mock__/bin/rm -rf /etc",
"/__mock__/bin/rm -rf " + HOME + "/.ssh",
):
with self.subTest(command=command):
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
self.assertEqual("deny", receipt["decision"])
self.assertEqual("critical", receipt["risk"])
self.assertEqual("high", receipt["confidence"])
self.assertEqual(
"matched_explicit_high_impact_signature",
receipt["decision_basis"],
)
for command in ("source harmless-mock.sh", "eval printf ok"):
with self.subTest(command=command):
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
self.assertEqual("deny", receipt["decision"])
self.assertEqual("indeterminate", receipt["risk"])
self.assertEqual("low", receipt["confidence"])
self.assertEqual(
"inspection_uncertainty_fail_closed",
receipt["decision_basis"],
)
def test_all_git_commands_require_review(self):
for command in (
"git status --short",
"git diff --stat",
"git log -1",
"git show MOCK_REF",
"git clean -ndx",
):
with self.subTest(command=command):
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
self.assertEqual("ask", receipt["decision"])
self.assertIn("GIT_RUNTIME_EXTENSIONS", receipt["signals"])
def test_simple_command_names_require_runtime_review(self):
for command in ("pwd", "true", "false", "printf mock", "echo mock"):
with self.subTest(command=command):
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
self.assertEqual("ask", receipt["decision"])
self.assertIn(
"RUNTIME_RESOLUTION_UNVERIFIED", receipt["signals"]
)
def test_sensitive_derived_fields_are_opaque(self):
target_sentinel = "ALPHALAB_SENTINEL_SECRET_token_abc"
cwd_sentinel = "CLIENT_SENTINEL"
command = "/__mock__/bin/rm -f /tmp/" + target_sentinel
probe = payload(command)
probe["cwd"] = "/external/" + cwd_sentinel + "/work"
probe["permission_mode"] = "PERMISSION_SENTINEL_token_mode"
receipt = gate.classify_payload(
probe, project_dir=PROJECT, home_dir=HOME
)
serialized = json.dumps(gate.hook_output(receipt), sort_keys=True)
for forbidden in (
command,
target_sentinel,
"token_abc",
cwd_sentinel,
"/external/" + cwd_sentinel + "/work",
"PERMISSION_SENTINEL",
"token_mode",
):
self.assertNotIn(forbidden, serialized)
self.assertEqual("unknown", receipt["context"]["permission_mode"])
self.assertTrue(receipt["context"]["cwd"].startswith("<external-cwd:sha256:"))
self.assertTrue(
all(target.startswith("<target:sha256:") for target in receipt["blast_radius"]["targets"])
)
executable_sentinel = "EXECUTABLE_SENTINEL_token_xyz"
executable_command = "/__mock__/bin/" + executable_sentinel + " --mock"
executable_receipt = gate.classify_payload(
payload(executable_command), project_dir=PROJECT, home_dir=HOME
)
executable_output = json.dumps(
gate.hook_output(executable_receipt), sort_keys=True
)
for forbidden in (executable_command, executable_sentinel, "token_xyz"):
self.assertNotIn(forbidden, executable_output)
self.assertTrue(
any(
item.startswith("executable-sha256:")
for item in executable_receipt["uncertainties"]
)
)
def test_exact_deny_output_contract(self):
# This starts only the Python gate. It never executes tool_input.command.
command = "/__mock__/bin/rm -rf " + PROJECT
completed = subprocess.run(
[sys.executable, str(GATE)],
input=json.dumps(payload(command)),
text=True,
capture_output=True,
timeout=1,
check=False,
)
self.assertEqual(0, completed.returncode)
self.assertEqual("", completed.stderr)
output = json.loads(completed.stdout)
self.assertEqual({"hookSpecificOutput", "systemMessage"}, set(output))
specific = output["hookSpecificOutput"]
self.assertEqual(
{
"hookEventName",
"permissionDecision",
"permissionDecisionReason",
},
set(specific),
)
self.assertEqual("PreToolUse", specific["hookEventName"])
self.assertEqual("deny", specific["permissionDecision"])
embedded = json.loads(specific["permissionDecisionReason"])
self.assertEqual("bash_blast_radius_receipt", embedded["type"])
self.assertEqual("deny", embedded["receipt"]["decision"])
self.assertNotIn(command, completed.stdout)
self.assertLess(len(specific["permissionDecisionReason"]), 10_000)
def test_allow_output_has_no_system_message(self):
receipt = gate.classify_payload(
payload("# inert fixture only"), project_dir=PROJECT, home_dir=HOME
)
output = gate.hook_output(receipt)
self.assertEqual({"hookSpecificOutput"}, set(output))
self.assertEqual(
"allow", output["hookSpecificOutput"]["permissionDecision"]
)
def test_ask_output_has_no_system_message(self):
command = "python3 -c 'print(1)'"
receipt = gate.classify_payload(
payload(command), project_dir=PROJECT, home_dir=HOME
)
output = gate.hook_output(receipt)
self.assertEqual({"hookSpecificOutput"}, set(output))
self.assertEqual("ask", output["hookSpecificOutput"]["permissionDecision"])
self.assertNotIn(command, json.dumps(output))
def test_invalid_json_fails_closed_when_process_runs(self):
completed = subprocess.run(
[sys.executable, str(GATE)],
input="{not-json",
text=True,
capture_output=True,
timeout=1,
check=False,
)
output = json.loads(completed.stdout)
self.assertEqual(0, completed.returncode)
self.assertEqual(
"deny", output["hookSpecificOutput"]["permissionDecision"]
)
embedded = json.loads(
output["hookSpecificOutput"]["permissionDecisionReason"]
)
self.assertEqual(["INVALID_STDIN_JSON"], embedded["receipt"]["signals"])
def test_invalid_utf8_fails_closed_when_process_runs(self):
completed = subprocess.run(
[sys.executable, str(GATE)],
input=b"\xff\xfe",
capture_output=True,
timeout=1,
check=False,
)
output = json.loads(completed.stdout)
self.assertEqual(0, completed.returncode)
self.assertEqual(
"deny", output["hookSpecificOutput"]["permissionDecision"]
)
if __name__ == "__main__":
unittest.main(verbosity=2)
本次固定 corpus 共 44 條:allow 1、ask 25、deny 18;以「是否需要介入」的預先標籤計算,得到 TP 43、TN 1、FP 0、FN 0。這只證明同一版程式沒有弄壞這組已知測例,不是任意 shell 的零漏擋準確率。真正的誤擋/漏擋率,要用經過資料最小化、必要時假名化、分層抽樣與人工獨立標註的實際提案 corpus 另算。

Chained command、wrapper、路徑與編碼怎麼測?
- Chain:把 benign prefix、
&&、換行與關鍵範圍操作放在同一字串,確認最高風險結果勝出。 - Wrapper:對
bash -c、interpreter、專案外 script 與eval測「第二次解析」;看不透就 ask 或 deny,不能因外層名字友善而 allow。 - Path:同時測規範化前後路徑、
.、專案根目錄、子樹與cd後的相對目標。這個範例只做 lexical normalization,不追 symlink,也不會沿 command chain 更新cd後的 cwd;該 fixture 只驗證結果不會被allow,不能把收據裡推測的 target/scope 當真。 - Encoding:測 decoder pipe 到 shell、不可見 Unicode 與 heredoc。看到編碼不等於知道內容;最合理的輸出通常是不自動核准。
這也解釋了為什麼Token Shunt Hook與UltraCode 煞車系統雖同樣使用 PreToolUse,保護的資產不同:前兩者主要處理成本、路由與 agent fan-out;本文把核准資料結構鎖在主機狀態的潛在影響。
怎麼測 approval fatigue,而不是只數攔截次數?
Anthropic 在 2025 年的sandboxing 工程文章中說,其內部 permission prompt 量減少 84%;這是內部摩擦量,不等於安全性或注意力提高。2026 年 3 月的auto mode 工程報告又稱使用者核准 93% 的 prompts,但文章未交代這個比率的樣本數與估計方法。這些是 Anthropic 自己的產品資料,不是所有團隊都會重現的普遍定律;它們只說明「更多 prompt」不自然等於「更安全」。
- 先跑 shadow mode:預設只留聚合後的 decision、風險域與規則計數,不存原始命令或 SHA-256 digest。若研究設計非得跨事件連結,另用受管理金鑰的 HMAC,並設定輪替、存取權與保留期限;這不包含在本文範例內。比較每 100 個工具提案會產生多少 ask。
- 盲測兩種畫面:A 組看一般核准;B 組看影響半徑、替代方案、備份查核狀態、復原查核提示與停手條件。記錄拒絕/改走較小替代的比例與決策時間,不先假定哪組較好。
- 抽查 allow:人工複核所有疑似漏擋,再隨機抽一部分 allow。只看 deny 數量會完全漏掉最重要的 false negative。
- 按家族拆矩陣:system、disk、time、network、credential、wrapper 分開算;總平均可能掩蓋某一類全漏。
如果 ask 多到所有人秒按同意,先縮小權限面與 sandbox 邊界,再縮窄規則;不要把更多相同警告當作修復。官方 2026 年 auto mode 報告在「real overeager actions」測試集(n=52)上,公布部署的 Stage 1→Stage 2 完整流程為 17% false-negative rate;該小型資料集是從 Anthropic 員工拒絕、或事後標記的真實 session 整理而來,不是通用 shell 安全 benchmark。它提醒我們:即使是部署中的分類器,仍會漏掉部分越權動作;17% 不能外推成任意命令的漏擋率。
真正高風險測試:移進 disposable VM
- 建立一次性快照:VM 裡只放測試資料與測試帳號;不得掛載主機家目錄、真實 SSH agent、雲端憑證或 production network。
- 先寫停止條件:Hook 未載入、sandbox unavailable、receipt 缺欄位或目標超出測試樹,任一成立就停止。
- 保存復原收據:記錄 VM snapshot ID、預期受影響路徑、觀察點與 restore 步驟;再做受控 fault injection。
- 從乾淨快照重建:不要只確認「命令被擋」;也要演練 Hook 缺檔、timeout、invalid JSON 與設定未載入時的退路。
需要完整演練框架,可參照Coding Agent 事故復原演練;差別是那篇主攻事後取證與復原,本文主攻執行前核准內容。
上線前還要補的安全邊界
- Sandbox 必須真的可用:高要求環境可依官方 sandbox 文件設
enabled: true、failIfUnavailable: true、allowUnsandboxedCommands: false,並另外審查讀取路徑與網路規則。 - 預設可讀不等於沒有祕密:Sandbox 預設仍可讀整台電腦的大部分內容,包括
~/.aws、~/.ssh,Bash 子程序也會繼承未另行處理的 secret 環境變數;不要假設有內建 credential deny list。用sandbox.credentials,或以permissions.blockReadsOutsideWorkingDirectories/sandbox.filesystem.denyRead明確縮小。 - 盤點所有放寬入口:逐一審查
allowRead、allowWrite、permissions.additionalDirectories、網域 allowlist、excludedCommands與sandbox.filesystem.disabled。組織環境用allowManagedReadPathsOnly、allowManagedDomainsOnly鎖住可管理的陣列;excludedCommands沒有同等 managed-only 鎖,應保持極小。 - 檢查旁路:Linux/WSL2 缺少可選 seccomp filter 時,Unix domain socket 不會獲得那層額外阻擋;macOS 開啟
allowAppleEvents會移除 code-execution isolation,excludedCommands則直接在 sandbox 外執行。 - Sandbox 只包 Bash:
Read、Edit、Write走 permissions,不走 Bash sandbox;Computer Use 操作真實桌面。必須分別設定,不要拿一層替另一層背書。 - 不要把 blanket Bash allow 當成免費的安全性:它會略過一般 Bash permission prompt,但不會蓋過
PreToolUseHook 的ask或deny。真正代價是分類器的 false negative 可能直接執行;若採用官方所述的「Bash allow+精準 Hook 拒絕」模式,漏判測試與 sandbox 邊界就必須成為上線條件。 - 團隊強制策略放 managed settings:專案內 Hook 可被修改,也可能被停用;把它當可審查的 defense-in-depth,不當防竄改政策。
- Receipt renderer 要可信:不要採信模型自己寫的「這很安全」描述;收據只從工具 payload、程序的 cwd/
CLAUDE_PROJECT_DIR/home context 與固定政策產生。
這組 sandbox 設定約束的是 Claude 執行的命令;一般互動 session 中使用者輸入的 ! 命令通常仍在 sandbox 外,excludedCommands 也沒有 managed-only 鎖定機制,native Windows 則不支援這個 sandbox。
FAQ:Claude Code 高風險命令閘門常見問題
1. 這個 Hook 能擋住所有危險命令嗎?
不能。它只靜態檢查收到的 Bash 字串;wrapper、PATH shadowing、symlink、remote API、套件 lifecycle 與執行後狀態都可能改變真實效果。
2. 有 Hook 就可以關掉 sandbox 嗎?
不可以。Hook 做分類與核准;sandbox 才用 OS 機制約束 Bash 與子程序。兩者保護不同失敗模式。
3. 為什麼不直接 deny 所有 sudo?
因為風險不等於單一關鍵字。有些高影響操作不需要 sudo;同一個 sudo wrapper 後面也可能是低影響查詢或主機級變更。應分類能力、目標與可逆性。
4. 收據的雜湊能保證執行內容沒變嗎?
只能綁定收到的字串。它不綁環境、PATH、檔案內容、glob 展開、遠端 tag 或檢查與執行間的狀態,也不是匿名化;可猜的低熵命令仍可能被離線比對。
5. Hook 掛掉時會自動 fail closed 嗎?
不一定。程式成功啟動後的 parse error 可輸出 deny;但 command Hook timeout、缺 executable 或無效輸出通常回到正常 permission 流程。要用獨立 permissions、managed policy 與 sandbox 補洞。
6. 可以把每條原始命令存進 log 嗎?
不建議預設這樣做。命令常帶 token、路徑、query 或客戶資料。日常 telemetry 先留聚合後的 decision、風險域與規則計數,不預設保存 raw command 或可被猜測的 SHA-256 digest;需要跨事件連結時才使用受管理、可輪替金鑰的 HMAC,並套用存取與保留期限。
7. 為什麼 fixed fixtures 的 FN 是 0,仍不能說零漏擋?
因為測例是已知答案的小集合。它能抓 regression,不能代表未知 wrapper、不同 shell、實際環境與攻擊者會使用的全部輸入。
8. 什麼時候應直接用 disposable VM?
當預期行為包含主機時間、磁碟、系統服務、憑證或廣域網路變更時。VM 還必須與真實家目錄、憑證和 production network 解耦,否則只是換一個殼。
給新手的 6 個重點
- 把核准改寫成可檢查的資料結構,而不是「相信 AI 說安全」。
- 對每個 Bash 提案分類,不只搜尋
sudo。 - 收據不複製 raw command,並清楚標記 backup 未驗證。
- 合成 fixture 只當 regression,誤擋/漏擋要靠資料最小化、必要時假名化的真實 corpus。
- Hook timeout 不是 deny;permissions 與 sandbox 必須獨立存在。
- 任何真正可能改主機狀態的測試,都先搬進乾淨 disposable VM。
接著閱讀
左右滑動查看更多推薦
結語:讓一次核准只授權一次看得懂的影響
一張 Blast-Radius Receipt 不會把字串分析變成完整 shell verifier,也不會把 Hook 變成 sandbox。Claude Code 高風險命令閘門的價值更樸素:在你按下允許前,把「推測會影響誰、能否縮小、備份尚未驗證、復原要找誰、何時停手」集中到同一個決策點。先用本篇 fixture 把協定跑通,再用 shadow mode 找出自己的 prompt 密度;最後才在隔離 VM 裡做故障注入。想繼續建立 AI 系統,可以從AlphaLab AI 專區與完整課程往下學。






