1. 为什么需要风险分级?
想象你是门卫,有个人要进大楼。你不认识他,怎么决定放不放?
最简单的办法:看他长什么样。
- 穿着工服、戴着工牌的 → 可能是员工(L0,放行)
- 穿着便装但看起来无害的 → 可能是访客(L2,登记一下)
- 拿着撬棍的 → 可能是小偷(L4,直接拦住)
policy.ts 就是这个门卫。它通过正则匹配命令文本来判断风险等级。
2. 五级风险等级
回顾一下:
| 等级 | 含义 | 典型命令 |
|---|---|---|
| L0 | 只读,无害 | ls、cat、git status |
| L1 | 低风险本地执行 | pnpm test、node script.js |
| L2 | 会写工作区 | rm、cp、git add、npm run |
| L3 | 网络/依赖/外部交互 | curl、npm install、git push |
| L4 | 高危破坏性 | rm -rf、sudo、git push --force |
3. 正则模式匹配
每种等级对应一组正则表达式。匹配时从高到低------先检查 L4,再检查 L3......如果都没命中,默认是 L2。
3.1 L4------高危
typescript
const L4_PATTERNS = [
/\brm\s+-rf\b/i, // rm -rf:递归强制删除
/\bsudo\b/i, // sudo:提权执行
/\bmkfs\b/i, // mkfs:格式化磁盘
/\bdd\s+if=/i, // dd:磁盘级读写
/\bshutdown\b/i, // shutdown:关机
/\breboot\b/i, // reboot:重启
/\bgit\s+push\s+--force\b/i, // git push --force:强推远端
/\bgit\s+reset\s+--hard\b/i, // git reset --hard:硬重置
] as const;
3.2 L3------网络/依赖
typescript
const L3_PATTERNS = [
/\bcurl\b/i, // curl:HTTP 请求
/\bwget\b/i, // wget:下载文件
/\bssh\b/i, // ssh:远程登录
/\bscp\b/i, // scp:远程拷贝
/\bgit\s+push\b/i, // git push:推送到远端
/\bgit\s+clone\b/i, // git clone:从远端克隆
/\bnpm\s+publish\b/i, // npm publish:发布包
/\bpnpm\s+publish\b/i,
/\bpip\s+install\b/i,
/\bpnpm\s+install\b/i,
/\bnpm\s+install\b/i,
/\byarn\s+install\b/i,
] as const;
3.3 L2------工作区写入
typescript
const L2_PATTERNS = [
/\brm\s+/i, // rm(不带 -rf 的普通删除也是 L2)
/\bmv\s+/i, // mv:移动/重命名
/\bcp\s+/i, // cp:拷贝
/\btouch\s+/i, // touch:创建文件
/\bmkdir\s+/i, // mkdir:创建目录
/\bchmod\b/i, // chmod:改权限
/\bchown\b/i, // chown:改所有者
/\bgit\s+add\b/i,
/\bgit\s+commit\b/i,
/\bgit\s+checkout\b/i,
/\bgit\s+merge\b/i,
/\bpnpm\b/i, // pnpm(子命令很多,整体视为 L2)
/\bnpm\s+run\b/i,
/\bnpx\b/i,
/\bvitest\b/i,
/\bjest\b/i,
/\beslint\b/i,
/\bprettier\b/i,
] as const;
3.4 L1------低风险本地
typescript
const L1_PATTERNS = [
/^pnpm\s+test\b/i,
/^npm\s+test\b/i,
/^vitest\s+run\b/i,
/^jest\b/i,
/^node\s+/i,
/^python3?\s+/i,
] as const;
注意 L1 用的是 ^(行首锚定),意思是"命令必须以这些开头"才算 L1。比如 echo hello && node script.js 不会命中 L1(因为有 echo 在前面),但会被 SHELL_META_PATTERN 升级到 L3。
3.5 L0------只读
typescript
const L0_PATTERNS = [
/^pwd$/i,
/^ls(\s|$)/i,
/^cat\s+/i,
/^head\s+/i,
/^tail\s+/i,
/^grep\s+/i,
/^rg\s+/i,
/^find\s+\./i,
/^git\s+status$/i,
/^git\s+diff(\s|$)/i,
/^git\s+log(\s|$)/i,
/^git\s+show(\s|$)/i,
/^echo\s+/i,
] as const;
L0 也用了 ^ 锚定------只有以这些命令开头的才是只读。ls && rm -rf / 不会被认为是 L0。
4. classifyCommandRisk------命令风险分级
typescript
export function classifyCommandRisk(command: string): CommandRiskLevel {
const line = command.trim();
if (L4_PATTERNS.some((rx) => rx.test(line))) return 'L4';
if (L3_PATTERNS.some((rx) => rx.test(line))) return 'L3';
if (L2_PATTERNS.some((rx) => rx.test(line))) return 'L2';
if (L1_PATTERNS.some((rx) => rx.test(line))) return 'L1';
if (L0_PATTERNS.some((rx) => rx.test(line))) return 'L0';
// 未知命令:不假设只读,按「可能写工作区」处理
return 'L2';
}
核心逻辑:从高到低匹配,先命中高危即返回。如果都不命中,默认 L2(保守策略)。
5. analyzeCommandSpec------结构化分析
classifyCommandRisk 只看命令字符串,但 analyzeCommandSpec 能区分 shell 和 exec 两种模式,产出更丰富的 SandboxPermissionIntent。
5.1 shell 模式
typescript
const SHELL_META_PATTERN = /(\|\||&&|[|;&<>`$()])/;
if (spec.kind === 'shell') {
let riskLevel = classifyCommandRisk(spec.displayCommand);
// shell 元字符(管道、重定向等)→ 至少 L3
if (SHELL_META_PATTERN.test(spec.shellCommand)) {
riskLevel = maxRiskLevel(riskLevel, 'L3');
}
// 第一个 token 是解释器(node/python/bash 等)?
const firstToken = spec.displayCommand.trim().split(/\s+/)[0]?.toLowerCase();
const usesInterpreter = firstToken ? SCRIPT_INTERPRETERS.has(firstToken) : false;
return {
kind: 'shell',
riskLevel,
usesShell: true,
usesInterpreter,
interpreter: usesInterpreter ? firstToken : undefined,
executableBase: firstToken,
usesNetwork: riskLevel === 'L3',
writesWorkspace: riskLevel === 'L2' || riskLevel === 'L4' || usesInterpreter,
touchesGit: /\bgit\b/i.test(spec.displayCommand),
highImpact: riskLevel === 'L4',
};
}
三个关键提升点:
- Shell 元字符 :如果命令包含
|、&&、;、>等,说明实际不止一条简单命令,至少 L3 - 解释器检测 :
node、python、bash等可以执行任意代码,风险更高 - Git 检测 :命令里出现
git就标记touchesGit: true
5.2 exec 模式
typescript
/** 解释器类可执行文件:即使 argv 看起来无害,也可能执行任意脚本 */
const SCRIPT_INTERPRETERS = new Set([
'node',
'nodejs',
'python',
'python3',
'bash',
'sh',
'zsh',
'ruby',
'perl',
'osascript',
]);
const executableBase = path.basename(spec.executable).toLowerCase();
const normalized = [executableBase, ...spec.args].join(' ').trim();
let level = classifyCommandRisk(normalized);
const usesInterpreter = SCRIPT_INTERPRETERS.has(executableBase);
if (usesInterpreter) {
level = maxRiskLevel(level, 'L2'); // 解释器本身视为可能写工作区
if (spec.args.some((arg) => arg === '-e' || arg === '-c')) {
level = maxRiskLevel(level, 'L3'); // node -e / bash -c → 至少 L3
}
}
exec 模式的特殊处理:
- 把
executable的 basename 和args拼成字符串,复用classifyCommandRisk------意思是这样:比如用户传入{ kind: 'exec', executable: '/usr/bin/git', args: ['push', '--force'] },exec 模式下可执行文件路径和参数是分开的数组,但classifyCommandRisk只接收一个字符串。所以我们先取路径的最后一段(git),再和参数拼成"git push --force",这样就能直接丢进classifyCommandRisk用正则去匹配了 - 如果是解释器,至少 L2(因为解释器能执行任意代码)
node -e或bash -c这种内联代码,至少 L3(因为代码内容不可预知)
6. 审批策略映射
6.1 isBlockedByDefault
typescript
export function isBlockedByDefault(level: CommandRiskLevel): boolean {
return level === 'L4';
}
L4 命令在默认策略下直接拦截,不走审批流程。只有调用方显式同意后才能执行。
6.2 riskLevelNeedsApproval
typescript
export function riskLevelNeedsApproval(level: CommandRiskLevel, policy: ApprovalPolicy): boolean {
if (policy === 'autonomous') return false; // 自主模式:一律不审
if (policy === 'untrusted') return level !== 'L0'; // 不信任模式:只有 L0 自动过
if (level === 'L0' || level === 'L1') return false; // on-request:L0/L1 自动
return true; // L2+ 要审
}
| 策略 | L0 | L1 | L2 | L3 | L4 |
|---|---|---|---|---|---|
autonomous |
不审 | 不审 | 不审 | 不审 | 拦截+审 |
untrusted |
不审 | 要审 | 要审 | 要审 | 拦截+审 |
on-request |
不审 | 不审 | 要审 | 要审 | 拦截+审 |
6.3 needsApproval
typescript
export function needsApproval(commandLine: string, policy: ApprovalPolicy) {
return riskLevelNeedsApproval(classifyCommandRisk(commandLine), policy);
}
这是一个便捷函数,把分级和策略判断合二为一。
7. describeCommandRisk------给人看的说明
typescript
export function describeCommandRisk(level: CommandRiskLevel, command: string): string {
const messages: Record<CommandRiskLevel, string> = {
L0: `只读命令,通常可自动执行:${command}`,
L1: `本地测试/脚本命令,可能影响进程状态:${command}`,
L2: `会写入工作区或变更仓库状态:${command}`,
L3: `涉及网络、依赖下载或外部系统交互:${command}`,
L4: `高危命令,默认强审批或拒绝:${command}`,
};
return messages[level];
}
这个函数产出的文本会显示在审批提示里,让用户知道"为什么要问我"。
8. 局限性
这个风险分级方案有一些天生的局限:
- 基于正则,不做 AST 解析 :
echo hello && rm -rf /中的rm -rf可能被识别为 L4,但如果命令更复杂,正则可能漏掉 - 不看命令上下文 :
node -e "require('fs').readFileSync('/etc/passwd')"命中了node -e升级为 L3,但实际读了什么只有执行后才知道 - 可被绕过 :
bash -c "rm -rf /"如果用 exec 模式传入,可能不会命中 L4 的正则(因为 displayCommand 是bash -c rm -rf /,没有rm -rf的精确匹配)
所以------风险分级只是第一道防线,不能替代 OS 级沙箱和审计。
9. 小结
| 函数 | 作用 |
|---|---|
classifyCommandRisk |
对命令字符串打 L0~L4 标签 |
analyzeCommandSpec |
对结构化命令规格做更丰富的权限意图分析 |
isBlockedByDefault |
L4 是否默认拦截 |
riskLevelNeedsApproval |
给定策略,该等级是否要审批 |
needsApproval |
便捷封装:分级 + 策略判断 |
describeCommandRisk |
给人看的风险说明文案 |
isClearlySafeReadOnlyCommand |
快速判断是否 L0 只读 |
核心原则:从高到低匹配,未知默认 L2,L4 默认拦截。