# Step 59: Bash 风险分类器
一句话导读:step57 的规则堵得住「Read 读 .env」,堵不住「Bash cat .env」。本步加一个命令风险分类器,判据从「工具名 + 参数」升级到「命令语义」——两段式(启发式黑名单 + LLM 兜底),插在规则判定之前。
# 一、这一步做了什么(What)
新增 utils/permissions/bashClassifier.ts,导出一个 classifyBash(command, deepCheck),把任意 Bash 命令分成 safe / risky / dangerous 三档:
- Stage 1 启发式:一张正则黑名单(
heuristicClassify),命中rm -rf、curl|sh、读密钥、sudo… 直接判级。快、免费。 - Stage 2 LLM 语义分类:Stage 1 判 safe、且用户开了深查时,调一次小模型给命令打标签(
llmClassify),看意图而非语法。
然后把它接进 checkPerm,插在规则判定之前:dangerous → 直接拒(即便 accept 模式);risky → 强制询问(覆盖 accept);safe → 继续走规则。新增 /classify 命令可开关 LLM 深查、或试分类一条命令而不执行。
# 二、面试官视角:为什么要做?(Why)
面试题:step57 已经有 allow/deny 规则了,为什么还要单独做一个 Bash 分类器?往规则里多加几条 deny 不就行了?
因为规则的判据是「工具 + 参数字符串」,而危险是「语义」——同一个危险语义有无穷多种字符串表达。
看这个绕过链:
deny Read(**/.env) ← step57 挡住了 Read 工具
Bash: cat .env ← 换 Bash 就绕过了(Bash 没对应 deny)
Bash: type .env ← Windows 上再换个命令,继续绕
Bash: python -c "print(open('.env').read())" ← 干脆不用「读命令词」
2
3
4
你每加一条 deny,模型就换一种等价写法。靠逐条 deny 堵等价路径,是打地鼠——你堵得完 cat,堵不完 type/gc/head/python/...,尤其跨平台后命令名翻倍。规则引擎的粒度虽然比「工具名」细,但仍停留在语法层,抓不住「这条命令到底想干嘛」。
| 判据层次 | 能表达的策略 | 抓不住的绕过 |
|---|---|---|
| 工具名(step19/35) | 「放行/拒绝整个 Bash」 | 任何具体命令区分 |
| 工具+参数(step57) | 「拦下 rm -rf、放行 git log」 | 换命令名达成同一目的 |
| 命令语义(step59) | 「拦下任何读密钥的意图」 | —— 兜底靠 LLM |
一句话:危险由「命令想干嘛」定义,而不是「用了哪个词」——判据必须爬到语义层。
# 三、原理:它是怎么工作的(How)
# 两段式分层:快而免费的在前,慢而智能的兜底
| Stage 1 启发式 | Stage 2 LLM 语义分类 | |
|---|---|---|
| 做法 | 正则/前缀黑名单 | 调一次小模型打 safe/risky/dangerous |
| 特点 | 快、免费、拦明显危险 | 慢/花钱,但看意图、抓新花样 |
| 缺点 | 列不全(尤其跨平台) | 有成本,默认只在 Stage 1 判 safe 时才开 |
# Stage 1:黑名单就是一张「模式 → 风险级 → 原因」表
const PATTERNS: Pattern[] = [
{ re: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\b/i, risk: "dangerous", why: "递归强制删除(rm -rf)" },
{ re: /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba)?sh\b/i, risk: "dangerous", why: "下载脚本直接执行(curl|sh)" },
// 读密钥:读文件命令是【平台相关】的——Unix 的 cat/less…,Windows 的 type/gc/get-content。
{ re: /\b(cat|less|more|head|tail|nl|xxd|od|strings|type|gc|get-content)\b[^\n]*(\.env|\.pem|\.key|id_rsa|...)/i,
risk: "dangerous", why: "读取密钥/敏感文件" },
{ re: /\bsudo\b/i, risk: "risky", why: "提权执行(sudo)" },
{ re: /\b(git)\b[^\n]*\bpush\b[^\n]*(--force|-f)\b/i, risk: "risky", why: "强制推送" },
// ...
];
2
3
4
5
6
7
8
9
10
heuristicClassify 遍历这张表,取危险级最高的命中(dangerous 一命中就 break),没命中返回 safe。
# Stage 2:让模型只吐一个词
const CLASSIFIER_SYS =
"你是一个 shell 命令安全分类器。只输出一个词:dangerous / risky / safe。...";
export async function llmClassify(command: string): Promise<ClassifyResult> {
let word = "safe";
try {
const out = await callAI([userText("命令:\n" + command)], CLASSIFIER_SYS);
word = /danger|risk|safe/i.exec(out)?.[0].toLowerCase() ?? "safe";
} catch {
return { risk: "safe", reason: "LLM 分类器不可用,跳过", stage: "llm" }; // fail-safe
}
// ...解析成 Risk
}
2
3
4
5
6
7
8
9
10
11
12
13
组合逻辑很省:Stage 1 已经拦住 / 或没开深查 → 直接返回,不花钱调 LLM:
export async function classifyBash(command, deepCheck = false) {
const h = heuristicClassify(command);
if (h.risk !== "safe" || !deepCheck) return h; // 已拦住 or 不深查
return llmClassify(command); // 只有 safe+深查才问 LLM
}
2
3
4
5
# 数据流:分类器如何插进权限判定
模型请求 Bash({ command: "type .env" })
↓
① checkPath(Bash 无路径参数,跳过)
↓
② classifyBash("type .env", deepScan)
Stage 1 命中「读取密钥/敏感文件」→ risk = dangerous
↓
checkPerm 直接 return false(即便 accept 模式),根本不走到 ③ 规则判定
2
3
4
5
6
7
8
对照 checkPerm 里的真实接入顺序:
① 路径校验(step57)
② Bash 风险分类 ←★本步:dangerous→拒 | risky→forceAsk | safe→继续
③ deny 规则(step57)
④ allow 规则 / accept 短路(forceAsk 时也强制问)
⑤ 问用户
2
3
4
5
分类器插在规则之前,且 risky 会置 forceAsk = true——所以哪怕你 allow Bash(*)、哪怕 accept 全自动,一条 rm -rf 也在第 ② 步就被拦下,allow Bash(*) 的短路轮不到执行。
# 四、深入追问(面试常见 follow-up)
Q:既然 LLM 分类更聪明,为什么不干脆每条命令都问 LLM,省掉那张黑名单?
A:成本和延迟。每条 Bash 都调一次模型,钱和等待都翻倍,而 99% 的命令(ls、git status)根本无害。分层的意义是用免费的快路径处理绝大多数,把昂贵的智能只花在启发式拿不准的地方——所以 LLM 默认只在 Stage 1 判 safe 且用户主动 /classify 开深查时才触发。这是典型的「便宜的在前,贵的兜底」。
Q:黑名单为什么「永远列不全」?举个它漏、LLM 补上的例子。
A:因为危险命令的表面形式是无穷的。本步实测:第一版启发式只列了 Unix 读命令(cat/less/head),模型在 Windows 用 type .env 就绕过了——补进 type/gc/get-content/findstr 才拦住,但这只是又打了一只地鼠。真正的反证是 python -c "print(open('.env').read())":它没有任何「读命令词」,启发式判 safe(漏),但开 LLM 深查判 dangerous。LLM 不看语法、只看意图,这才是黑名单列不全时的兜底。
Q:分类器故障(LLM 超时/报错)时怎么办?会不会因此漏放危险命令?
A:llmClassify 的 catch 分支退回 safe——注意这里 fail 的是 Stage 2,而 Stage 2 只在 Stage 1 已判 safe 时才跑。也就是说 LLM 挂了,命令回落到「启发式判的 safe」,再往下还有 step57 的 deny 规则和人工确认兜着,不会因为分类器故障就直接放行危险命令。真实版同理 fail-safe 到人工确认——安全判定的默认永远是「宁可多问,不可漏放」。
Q:risky 和 dangerous 为什么要分两档,都拦掉不行吗?
A:因为破坏性有梯度。dangerous(rm -rf、读密钥、curl|sh)是不可逆/泄密,直接拒、不给商量;risky(sudo、git push --force、chmod 777)是「有副作用但可能是用户真想做的」,所以是 forceAsk——覆盖 accept 模式强制弹一次确认,让人拍板。一刀切全拒会把正常的提权、强推也堵死,反而逼用户关掉整个安全层。
# 五、踩坑 / 设计权衡
rm -rf的正则要容忍乱序参数:rm -rf和rm -fr都得中,所以re写成(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r),而不是死板匹配-rf。- 读密钥模式必须跨平台列:
cat|less|type|gc|get-content一起列,这本身就是「黑名单打地鼠」的活证据,也顺势论证了为什么需要 Stage 2。 - 权衡:分类器只管 Bash。文件类工具的越界由 step57 路径校验管,分类器专注 Bash 这个「能力最杂、最容易换写法绕过」的工具,不铺开到所有工具。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
| 启发式黑名单 + LLM 两段 | dangerousPatterns.ts + 隐藏分类器(同样两段) |
{ risk, reason, matched, stage } | { matches, confidence, reason } + behavior deny/ask/allow |
| 只分类 Bash | 还结合 allow 规则安全性检查(防 Bash(python:*) 过宽 allow) |
| 分类器故障 → 退回 safe/人工 | 同样 fail-safe |
| 还缺:物理隔离 | seatbelt/bwrap/landlock(→ step58 沙箱) |
# 七、一句话总结
风险分类器 = 让判据从「命令字符串」爬到「命令语义」:Stage 1 黑名单免费拦住明显危险,Stage 2 LLM 兜住黑名单永远列不全的等价写法,插在规则之前让 dangerous 越过 accept 短路直接拒——这才真正堵上了 step57「换工具绕过」的缺口。但它仍是决策层(要不要放行),放行之后万一出事,还需要一层物理隔离兜底。
# 下一节预告
决策层(规则 + 分类器)已成形,但「放行了也可能出事」。step58 补上隔离层——安全沙箱兜底。