# Step 59: Bash 风险分类器

一句话导读:step57 的规则堵得住「Read 读 .env」,堵不住「Bash cat .env」。本步加一个命令风险分类器,判据从「工具名 + 参数」升级到「命令语义」——两段式(启发式黑名单 + LLM 兜底),插在规则判定之前。


# 一、这一步做了什么(What)

新增 utils/permissions/bashClassifier.ts,导出一个 classifyBash(command, deepCheck),把任意 Bash 命令分成 safe / risky / dangerous 三档:

  1. Stage 1 启发式:一张正则黑名单(heuristicClassify),命中 rm -rf、curl|sh、读密钥、sudo… 直接判级。快、免费。
  2. 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())"  ← 干脆不用「读命令词」
1
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: "强制推送" },
  // ...
];
1
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
}
1
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
}
1
2
3
4
5

# 数据流:分类器如何插进权限判定

模型请求 Bash({ command: "type .env" })
   ↓
① checkPath(Bash 无路径参数,跳过)
   ↓
② classifyBash("type .env", deepScan)
     Stage 1 命中「读取密钥/敏感文件」→ risk = dangerous
   ↓
checkPerm 直接 return false(即便 accept 模式),根本不走到 ③ 规则判定
1
2
3
4
5
6
7
8

对照 checkPerm 里的真实接入顺序:

① 路径校验(step57)
② Bash 风险分类 ←★本步:dangerous→拒 | risky→forceAsk | safe→继续
③ deny 规则(step57)
④ allow 规则 / accept 短路(forceAsk 时也强制问)
⑤ 问用户
1
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 补上隔离层——安全沙箱兜底。