Bash 工具实现和安全权限设计细节
一、Bash 工具的实现
Bash 工具是 Agent 很关键的基础工具,skill 规范中的脚本执行会需要它,随着应用逐渐 cli 化,未来的 Agent 调用外部应用也需要它,到时候只需要执行 cli 命令就可以。
同时有 bash 工具之后,很多操作都可以实现,一定程度上可以简化 Agent 工具列表,但是还是建议保持工具最小权利原则
TIP任务优先使用专用工具完成,例如:读取就使用 Read 工具、编辑就使用 Edit 工具等
1.1、Bash 工具的定义
export const BashTool = buildTool({
name: BASH_TOOL_NAME,
searchHint: 'execute shell commands',
description: getSimplePrompt(),
inputSchema: z.strictObject({
command: z.string().describe('要执行的命令'),
timeout: z.number().optional().describe('可选的超时时间,单位为毫秒'),
description: z.string().optional().describe('用主动语态简明扼要地描述该命令的作用'),
//全局禁用后台任务时隐藏
run_in_background: z.boolean().optional().describe('设置为 true 可在后台运行此命令'),
// 危险操作
dangerouslyDisableSandbox: z.boolean().optional().describe('设置为 true 将强制跳过沙箱模式执行命令'),
// 始终对模型隐藏
_simulatedSedEdit: z.object({ filePath: z.string(), newContent: z.string() }).optional().describe('内部字段:预览阶段预计算的 sed 编辑结果'),
}),
// ... 其他方法如 call、checkPermissions 等
})工具的参数:
- command:要执行的 bash 命令字符串
- timeout:命令执行的超时时间,防止 Agent 运行被卡死
- description:对命令的简短描述,UI 渲染给用户看的
- run_in_background:有一些命令运行需要很久,例如:构建和启动命令,所以将命令执行放入到后台,定时查询任务结果就可以
- dangerouslyDisableSandbox:一种安全防护的策略
- _simulatedSedEdit:预览阶段先计算好 sed 结果,用户批准之后直接向文件写入该结果,而不是再执行 sed 命令,将操作从”正则替换”变为”完整内容覆盖”,保证用户所见即所得
工具的描述:
Bash 工具完整描述(点击展开)
**执行给定的 bash 命令并返回其输出**
工作目录在命令之间是持久化的,但 shell 状态不持久化。shell 环境从用户的 profile(bash 或 zsh)初始化。
重要:避免使用此工具运行 find、grep、cat、head、tail、sed、awk 或 echo 命令,除非明确指示或已验证专用工具无法完成任务。相反,应使用适当的专用工具,因为这将为用户提供更好的体验。
# Instructions
- 如果命令将创建新目录或文件,首先使用此工具运行 ls 以验证父目录存在且位置正确。
- 始终对包含空格的文件路径使用双引号引用。
- 尽量通过使用绝对路径来保持当前工作目录不变,避免使用 cd。但如果用户明确要求,可以使用 cd。
- 可指定可选超时(毫秒,最多 10 分钟)。默认 2 分钟后超时。
- 可以设置 run_in_background 在后台运行命令。
- 执行多个命令时:
- 如果命令独立且可并行,在单条消息中发起多个 Bash 调用。
- 如果命令相互依赖必须顺序执行,用单个 Bash 调用配合 && 链式执行。
- 仅在需要顺序执行但不在乎前面命令是否失败时使用 ;。
- 不要用换行符分隔命令(换行在引号字符串内是可以的)。
- git 命令:优先创建新提交而非修改现有提交;破坏性操作前先考虑更安全的替代方案;不要跳过 hook。
- 避免不必要的 sleep:能立即执行的命令不要 sleep;长时间运行的命令用 run_in_background;不要用 sleep 循环重试失败的命令;等待后台任务时无需轮询。
- 沙箱说明(如果启用了沙箱):默认在沙箱中运行,限制可访问的目录和网络;临时文件要用 $TMPDIR 而非 /tmp。
- git 提交与 PR 的详细操作指南(很长的一段,包含具体的 git status / git diff / git log / gh pr create 等步骤和最佳实践)。1.2、工具的执行函数
在实现 Bash 工具的执行函数的时候,主要就是下面的三点核心思路:
- 采用 Generator 函数来实现,可以实时输出命令执行情况
- 生产级别的命令执行函数 exec 要封装好
- Bash 工具返回结果时,需要进行内容字符长度的判断,如果超出自定义的字符最大限制,就将完整内容写入文件,最终返回部分结果与文件路径
在 Bash 工具实现中,Generator 模式比 Promise 模式更优秀,Geneator 模式可以实时返回命令执行过程的中间态,但 Promise 模式在执行命令的时候需要持续等待,中间可能会有一大段空白的等待时间,这个过程没有任何输出。
下面这个代码是对于 Bash 工具的核心实现逻辑(简化版本),同时对比了 Generator 模式和 Promise 模式的实现区别
import { spawn } from 'child_process';
//使用 Promise 模式
function runWithPromise(command: string, args: string[]): Promise<{ stdout: string;
code: number }> {
return new Promise((resolve) => {
const proc = spawn(command, args);
let stdout = '';
// 只要子进程有输出,就拼接到 stdout 字符串里
proc.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
// 等子进程完全结束后,一次性 resolve
proc.on('close', (code) => {
resolve({ stdout, code: code ?? 0 });
});
});
}
const result = await runWithPromise('ping', ['-c', '5', 'google.com']);
console.log("==Promise==")
console.log(result.stdout); // 用户等了 5 秒,然后突然全部刷出来
//使用 Generator 模式
async function* runWithGenerator(command: string, args: string[]) {
const proc = spawn(command, args);
let fullOutput = '';
// 把"下一行输出"包装成一个 Promise
let resolveNextLine: ((value: string) => void) | null = null;
proc.stdout.on('data', (chunk) => {
const text = chunk.toString();
fullOutput += text;
if (resolveNextLine) {
resolveNextLine(text); // 唤醒 generator
resolveNextLine = null;
}
});
// 把"进程结束"包装成一个 Promise
let resolveExit: ((code: number) => void) | null = null;
proc.on('close', (code) => {
if (resolveExit) resolveExit(code ?? 0);
});
const exitPromise = new Promise<number>((resolve) => {
resolveExit = resolve;
});
// 核心循环:race 等待"新输出"或"进程结束"
while (true) {
const nextLinePromise = new Promise<string>((resolve) => {
resolveNextLine = resolve;
});
// 关键:同时监听两个事件,哪个先到就处理哪个
const winner = await Promise.race([
nextLinePromise.then(text => ({ type: 'output' as const, text })),
exitPromise.then(code => ({ type: 'exit' as const, code })),
]);
if (winner.type === 'exit') {
// 进程结束了,return 最终值
return { stdout: fullOutput, code: winner.code };
}
// 进程还在跑,有新输出,yield 进度
yield {
output: winner.text, // 这次新增的内容
fullOutput, // 截至目前全部内容
};
}
}
const gen = runWithGenerator('ping', ['-c', '5', 'google.com']);
while (true) {
const step = await gen.next();
if (step.done) {
console.log('Exit code:', step.value.code);
break;
}
console.log("==Generator==")
console.log(step.value.output);
process.stdout.write(step.value.output);
}在查看 ClaudeCode 的设计思路中,如果这个 bash 工具的实现要更完善一些,可以将执行命令的 exec 方法封装
// 调用底层 exec(file mode:stdout 直接写文件,不经过 JS data 事件)
const shellCommand = await exec(command, abortController.signal, 'bash', {
timeout: timeoutMs,
onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete) {
lastProgressOutput = lastLines;
lastTotalLines = totalLines;
lastTotalBytes = isIncomplete ? totalBytes : 0;
// 唤醒 race
if (resolveProgress) {
resolveProgress();
resolveProgress = null;
}
},
});exec 的主要封装的功能如下:
- 输出写入磁盘文件:程序运行内存中只有 4KB 左右的预览
- 主动中断:用户能够主动触发中断执行,使用 abortSignal 实现
- 超时处理:当运行超过 120 秒时,主动停止运行
- stdout 和 stderr 合并写入同一个文件:让 UI 层的显示和输出时序一致
- CWD 自动恢复:当前命令执行的目录不小心被删除啦,会自动回退到原始目录继续执行
Bash 工具的执行函数中,会对于执行命令的结果返回做了一层截断处理
const MAX_INLINE_SIZE = 128 * 1024; // 128KB:直接返回内容
async function resolveOutput(outputFilePath: string, taskId: string) {
const stat = await fsStat(outputFilePath);
const totalBytes = stat.size;
// 1. 小文件:直接返回
if (totalBytes <= MAX_INLINE_SIZE) {
const content = await readFile(outputFilePath, 'utf-8');
return {content,persistedPath: undefined};
}
// 2. 大文件:读取截断内容,复制文件,最终返回截断内容 + 完整文件路径
const preview = await readFileRange(outputFilePath, 0, MAX_INLINE_SIZE);
const dest = getToolResultPath(taskId, false);
await copyFile(outputFilePath, dest);
return {
content:`[Large output (${totalBytes} bytes). ` +`Showing first ${MAX_INLINE_SIZE} bytes. ` +`Use FileRead for full content.]\n${preview}`,
persistedPath: dest,
};
}1.3、工具的返回值
const data = {
stdout: compressedStdout, // 核心输出(可能含 stderr 混合内容)
stderr: stderrForShellReset, // 仅用于 cwd 重置提示
interrupted: wasInterrupted, // 是否被中断
isImage, // 是否是图片输出
returnCodeInterpretation: interpretationResult?.message, // 退出码语义解释
noOutputExpected: isSilentBashCommand(input.command), // 成功时是否应无输出
dangerouslyDisableSandbox: input.dangerouslyDisableSandbox, // 是否绕过沙箱
persistedOutputPath, // 大输出持久化路径
persistedOutputSize // 大输出字节数
};有几个核心的字段要留意:
- returnCodeInterpretation:对非零退出码的语义化的解释,可以让模型根据输出结果更好的推断如何进行下一步
- perisstedOutputPath:大输出的文件路径,这个很重要,返回给模型之后,模型可以根据上下文的情况来判断是否有必要读取完整的输出,而不是一股脑的把一堆输出放入到上下文中,这样做会导致上下文使用效率非常低
- stdout:这个就是 bash 工具执行的核心输出
1.4、工具权限验证流程
Bash 工具的执行范围非常大,所以它的危险性也是最高的,对于 Agent 和宿主机来说,权限验证是最复杂的
- 命令解析
- 静态规则检查
- 权限验证
- 模型验证
- 容器验证
其中关于静态规则检查和权限验证两点是最核心的,静态规则检查一共有 24 条规则检查,权限验证有三层结果,其中大部分无法确定的情况,都会在权限验证中输出 ask 模式,交给用户确认
NOTE静态规则实在是太多了,我就重点梳理了其中的 8 条我觉得比较核心的,静态规则部分的具体实现,可以借助 Agent CLI 工具,将 24 条规则作为上下文输入,由模型生产对应的验证代码 权限验证是非常有必要实现的,但是这么严格的静态规则检查是否有必要,开发者可以根据场景具体判断吧
🌴多说一点,静态规则的检查开发者可以根据场景自己判断,参考目前的一些优秀项目的做法是:
- ClaudeCode 的 Bash 工具实现中,静态规则检查时非常严格的
- OpenCode 的实现并没有这么严格的静态规则检查,只有权限验证
- Gemini-cli 的实现了部分静态规则检查,解析分段检查、危险命令验证、wrapper 去壳,同时也实现了权限验证
一、执行命令解析
要解析 bash 工具传入的 command 命令,可以直接使用tree-sitter 库,其会将 Bash 脚本解析成为结构化的 AST
TIP可以考虑使用 web-tree-sitter 库,这个是 WASM 版本的,不依赖平台预编译包,跨平台一致性, 比原生的 tree-sitter 要好用一些,原生的是 C++ 扩展的
解析例子:
// web-tree-sitter (WASM) 版本
import { Language, Parser } from 'web-tree-sitter';
async function main() {
await Parser.init();
const lang = await Language.load('./tree-sitter-bash.wasm');
const parser = new Parser();
parser.setLanguage(lang);
const command = 'grep -r "foo" . && cat file.txt | wc -l';
const tree = parser.parse(command);
// 收集所有 command 节点
const commands: string[] = [];
function walk(node: any) {
if (node.type === 'command') commands.push(node);
for (const child of node.children) walk(child);
}
walk(tree?.rootNode);
// 提取 argv
function argv(node: any) {
return node.children
.filter((c: any) => ['word', 'string', 'raw_string'].includes(c?.type))
.map((c: any) => c.text);
}
console.log(`Found ${commands.length} command(s):\n`);
commands.forEach((cmd: any, i: number) => {
console.log(`[${i + 1}] ${cmd.text.trim()}`);
console.log(` argv: ${JSON.stringify(argv(cmd))}\n`);
});
}
main();运行输出结果:
Found 3 command(s):
[1] grep -r "foo" .
argv: ["-r","\"foo\"","."]
[2] cat file.txt
argv: ["file.txt"]
[3] wc -l
argv: ["-l"]解析拿到最终 command 命令和 argv 参数,就可以进行下一步的静态检查啦
二、核心 8 条静态检查
2.1、控制字符与 Unicode 空白拒绝
这个可以放在解析之前执行,因为是为了清理执行命令,防止有人注入”恶意的字符”,所以先使用正则表达式进行匹配清理:
// 匹配控制字符
const CONTROL_CHAR_RE = /[\x00-\x08\x0B-\x1F\x7F]/
//匹配 Unicode 空白
const UNICODE_WHITESPACE_RE =/[\u00A0\u1680\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]/
//匹配反斜杠转义空白
const BACKSLASH_WHITESPACE_RE = /\\[ \t]|[^ \t\n\\]\\\n/具体的关于这三个规则详细解析,可以问问模型,向模型提问的模版可以这样,模型的回复会更详细
const CONTROL_CHAR_RE = /[\x00-\x08\x0B-\x1F\x7F]/
const UNICODE_WHITESPACE_RE =/[\u00A0\u1680\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]/
const BACKSLASH_WHITESPACE_RE = /\\[ \t]|[^\t\n\\]\\\n/,
这三条正则分别拦截了 tree-sitter 和 bash 的哪几种分词分歧?为什么放在 AST 遍历之前而不是之后?并且举一个实际例子🌴 我这里简单的总结一下:上面三条规则,每一条都对应一个已验证的 tree-sitter-bash 分歧

🎃 一个完整的例子:
假设有人诱导模型构建这样的命令:rm\u00A0-rf /
其中
\u00A0是一个看起来像空格但实际上不是 ASCII 空格的字符
- tree-sitter 的视角:
\u00A0不是普通空格,所以rm\u00A0-rf可能被解析成一个完整的命令名或参数,后续路径/只是单独的一个参数。从 AST 上看,这甚至不像是rm命令 - bash 的视角:
\u00A0属于空白字符,会被当作参数分隔符。于是实际执行的是:rm -rf /
结果:我们的静态规则(比如”检查 rm 命令的目标路径是否危险”)可能因为 AST 上根本看不出这是 rm 命令而漏检,但 bash 却实实在在地执行了危险删除
2.2、危险结构类型触发 ask 模式
AST 解析执行命令的时候,返回的结果中会有一个字段表示命令的节点类型,那么我们可以使用这个节点类型来判断命令是否是危险的
核心的节点类型验证规则如下:
const DANGEROUS_TYPES = new Set([
'command_substitution', // $(cmd) 或 `cmd`
'process_substitution', // <(cmd) 或 >(cmd)
'expansion', // ${VAR}
'simple_expansion', // $VAR
'brace_expression', // {a,b,c}
'subshell', // (cmd)
'compound_statement', // { cmd; cmd; }
'for_statement', // for i in ...; do ...; done
'while_statement', // while ...; do ...; done
'until_statement', // until ...; do ...; done
'if_statement', // if ...; then ...; fi
'case_statement', // case ... in ... esac
'function_definition', // foo() { ... }
'test_command', // [[ ... ]]
'ansi_c_string', // $'...'
'translated_string', // $"..."
'herestring_redirect', // <<< ...
'heredoc_redirect', // << ...
])判断之后的结果,有两种情况
- 如果命中啦,表示当前命令有点危险,不能直接执行,那么走 ask 模式询问用户,或者也可以直接拒绝(具体拒绝那些命令,这个是由用户自己配置的)
- 如果没有命中,表示当前阶段是安全的,那么进入到下一个解析阶段
核心的判断代码如下:
export function parseForSecurityFromAst(
cmd: string,
root: Node | typeof PARSE_ABORTED,
): ParseForSecurityResult {
// ── AST 遍历:核心安全检查 ──
// 直接 too-complex,不再进入后续的精细分析。
if (root.type === 'ERROR' || DANGEROUS_TYPES.has(root.type)) {
return tooComplex(root)
}
//.....
// ── 没有命中:结构化提取成功 ──
return { kind: 'simple', commands }
}2.3、wrapper 去壳后一致性检查
原始命令:timeout 5 eval "rm -rf /"
解析结果:
{
argv: ['timeout', '5', 'eval', 'rm -rf /'],
envVars: [],
redirects: [],
text: 'timeout 5 eval "rm -rf /"'
}这个阶段是去除 argv 中多余无关的参数值,将真正的执行命令暴露出来给下一个节点验证
如果没有这个阶段的话,我们可能会对无关的参数值进行验证,随意就通过啦
例如:timeout 这个命令本身就不是危险操作,只是一种简单的时间限制,如果我们只检查 argv[0]===‘timeout’就觉得安全了,那么后面的危险操作就会逃过下面静态规则的判断
WARNING攻击者完全可以把任意的危险命令”藏”在 timeout 后面,这样就会被执行,导致无法挽回的结果
所以 wrapper 就是去壳操作,暴露出真正的命令,去掉的外壳命令有下面几种:
time、nohup、timeout、nice、env、stdbuf
timeout 和 stdbuf 可能有点特殊要多留意一下,主要是以下几种:-、—、.5
timeout -k 5 10 eval ...:旧代码只处理 —long flag,没处理 -k,导致 eval 没被识别到timeout .5 eval "id":.5 不匹配旧的 duration 正则,导致 eval 被漏掉stdbuf --output 0 eval:旧代码只剥了一层,结果把 0 当成命令名,eval 被隐藏
🌴对于命令执行这个危险操作,要记住一个设计原则:未知的情况就直接拒绝
该静态节点完整的核心代码如下:
let a = cmd.argv
for (;;) {
if (a[0] === 'time' || a[0] === 'nohup') {
a = a.slice(1)
} else if (a[0] === 'timeout') {
// 遍历 GNU timeout 的 flag(--foreground, -k, -s...)
// 跳过 duration(5, 10s, 0.5...)
// 未知 flag 或无法识别的 duration → 直接拒绝
a = a.slice(i + 1) // 从 duration 之后开始
} else if (a[0] === 'nice') {
// 跳过 -n N 或 -N,然后 slice
// 如果参数包含 $((...)) 等 expansion → 直接拒绝
a = a.slice(...)
} else if (a[0] === 'env') {
// 跳过 VAR=val 赋值和已知 flag(-i, -0, -v, -u NAME)
// 遇到 -S / -C / -P 或任何未知 flag → 直接拒绝
a = a.slice(i)
} else if (a[0] === 'stdbuf') {
// 跳过 -o MODE / --output=MODE 等已知形式
// 未知 flag → 直接拒绝
a = a.slice(i)
} else {
break // 不是 wrapper 了,停止剥壳
}
}
const name = a[0] // 这才是真正要执行的命令名🎃 一个简单的 wrapper 的案例如下,原始命令:timeout 10 eval "rm -rf /"
如果不执行 wrapper,后面节点得到的就是:argv[0] = ‘timeout’,timeout 是安全的,放行
但是 Bash 实际执行的是:eval "rm -rf /"这种高危操作
2.4、命令名健壮性检查
在 wrapper 去壳之后,name 就被确定下来了,那么要对于这个 name 进行基础的准确性检查
- name 是否为空
- name 是否是一个占位符
- name 是否是一个完整的命令
核心的判断代码如下:
const name = a[0]
// 1. 空命令名
if (name === '') {
return { ok: false, reason: 'Empty command name — argv[0] may not reflect what
bash runs' }
}
// 2. 占位符命令名
if (name.includes("__CMDSUB_OUTPUT__") || name.includes("__TRACKED_VAR__")) {
return { ok: false, reason: 'Command name is runtime-determined (placeholder
argv[0])' }
}
// 3. 片段化命令(以操作符开头)
if (name.startsWith('-') || name.startsWith('|') || name.startsWith('&')) {
return { ok: false, reason: 'Command appears to be an incomplete fragment' }
}在前面解析步骤中,我们主动将两种动态命令替换为字符常量:
- $(…):替换成为”CMDSUB”
- $VAR:替换成为”VAR”
所以在这一步判断的时候,我们只需要检查 name 是否包含这些占位符,如果包含的话就说明 name 是运行时动态决定的
2.5、eval-like builtin 拦截
eval-like builtin 指的是一类 shell 内建命令:它们会把参数当”代码”再执行,或绕过普通 argv 安全假设
const EVAL_LIKE_BUILTINS = new Set([
'eval', // 直接求值字符串
'source', // 执行脚本文件
'.', // source 的别名
'exec', // 替换当前进程执行新程序
'command', // 绕过 alias/function 查找
'builtin', // 强制调用 builtin
'fc', // 编辑/重新执行历史命令
'coproc', // 协进程
'noglob', // zsh 前缀修饰符
'nocorrect', // zsh 前缀修饰符
'trap', // 设置信号处理程序
'enable', // 加载 .so 作为 builtin
'mapfile', // 带回调的数组读取
'readarray', // mapfile 的别名
'hash', // 污染命令查找缓存
'bind', // 绑定键盘回调
'complete', // 补全回调
'compgen', // 补全生成(可执行 -C 参数)
'alias', // 定义别名
'let', // 算术求值
])
//拦截逻辑:
if (EVAL_LIKE_BUILTINS.has(name)) {
// 几个特例放行:
// - command -v / command -V:只打印路径,不执行
// - fc -l / fc -ln:列出历史,安全
// - compgen -c/-f/-v:只列出补全,安全
// 其余全部拒绝
return { ok: false, reason: `'${name}' evaluates arguments as shell code` }
}验证这类 shell 内建命令,可以防止攻击者将危险命令藏在一个正常的字符串参数里面,利用运行时解析把它变成实际执行的命令
🎃例如:eval "rm -rf /"
AST 解析之后的 argv 参数是[ 'eval' , 'rm -rf / ' ],如果没有这一次拦截,那么 eval 就会被放行
那么 bash 执行的就是 "rm -rf /",也就是说 eval 会把后面的指令重新解析然后被执行
2.6、管道分段递归
如果检测到命令中包含管道符 | ,会分段处理,每一段都执行完整的权限验证
WARNING管道| 会将多个命令串在一起,如果静态检查只检查一次验证,那么可能就只会验证第一段命令,如果第一段命令符合就通过执行啦,后面的危险命令就可能会被漏掉被执行啦
所以要分段递归验证,只有所有的分段检查都是通过的,整体命令才会通过,否则只要有一段有问题就拒绝执行或者询问用户
🎃 例子:
原始命令:echo hello | rm -rf /
不分段验证:只看到了 echo 命令,就验证通过啦,bash 就会执行后面一段命令 rm -rf /
分段验证:
echo hello:这段命令是没有问题的,通过rm -rf /:这一段是危险的删除命令,拒绝执行或者询问用户
那么最后整段命令被拒绝执行,因为其中的第二段命令没有通过
2.7、cd + git 组合的危险判断
git 不是一个纯”只读”命令——它会读取当前目录下的 .git/config 并执行 hooks。如果 cd 把当前目录切换到了一个不可信的目录,那么任何 git 命令都可能成为代码执行的入口。
所以我们需要组合判断 cd+git 的情况
//核心代码
if (hasCd && hasGit) {
return { behavior: 'ask', reason: '...bare repository attacks' }
}2.8、危险删除路径拦截
对于 rm 和 rmdir 命令,要谨慎对待,这是删除命令,是有可能出现删除系统核心文件的
核心防护思路:对于 rm 和 rmdir 命令执行之前,先提取目标路径出来,然后进行匹配,如果匹配到系统核心文件,命令就直接拒绝执行
系统核心文件列表:
- 通配符删除:如
*、/*、/tmp/*,通配符范围不可控,存在批量误删风险 - 根目录:如
/,禁止对系统根目录执行删除操作 - Windows 驱动器根目录:如
C:\、D:\,禁止对磁盘根目录执行删除操作 - 用户主目录:如
~、/home/user,禁止删除用户主目录,避免丢失全部个人数据 - 根目录的直接子目录:如
/usr、/etc、/tmp,均为系统关键目录,删除将导致系统崩溃 - Windows 驱动器的直接子目录:如
C:\Windows、C:\Program Files,均为系统核心目录,删除将导致系统不可用
判断的核心代码:
export function isDangerousRemovalPath(resolvedPath: string): boolean
{
const forwardSlashed = resolvedPath.replace(/[\\/]+/g, '/')
// 1. 通配符删除当前目录全部内容
if (forwardSlashed === '*' || forwardSlashed.endsWith('/*')) return
true
const normalizedPath =forwardSlashed === '/' ? forwardSlashed :forwardSlashed.replace(/\/$/, '')
// 2. 根目录
if (normalizedPath === '/') return true
// 3. Windows 驱动器根目录
if (WINDOWS_DRIVE_ROOT_REGEX.test(normalizedPath)) return true
// 4. 用户主目录
const normalizedHome = homedir().replace(/[\\/]+/g, '/')
if (normalizedPath === normalizedHome) return true
// 5. 根目录的直接子目录(如 /usr, /tmp, /etc)
const parentDir = dirname(normalizedPath)
if (parentDir === '/') return true
// 6. Windows 驱动器的直接子目录(如 C:\Windows)
if (WINDOWS_DRIVE_CHILD_REGEX.test(normalizedPath)) return true
return false
}三、完整的 24 条静态验证规则:
| 序号 | 规则名称 | 核心作用 | 重点 |
|---|---|---|---|
| 1 | 控制字符与 Unicode 空白拒绝 | 原始字符串含控制字符或 Unicode 空白时,解析器与 bash 分词不一致,直接标记 too-complex | ⭐ |
| 2 | 危险结构类型(DANGEROUS_TYPES)触发 too-complex | AST 中出现 process substitution、subshell、控制流等无法静态证明安全的结构,直接拒绝 | ⭐ |
| 3 | wrapper 去壳后一致性检查 | timeout、nice、env、stdbuf、nohup、time 等 wrapper 会被层层剥掉,确保安全检查针对真正被执行的内层命令 | ⭐ |
| 4 | 命令名健壮性检查 | 拦截空命令名、占位符命令名(__CMDSUB__ / __VAR__)、以 - / | / & 开头的片段化命令名 | ⭐ |
| 5 | eval-like builtin 拦截 | eval、source、exec、command、trap、alias、let 等会二次解释参数为代码的 builtin 被统一拦截 | ⭐ |
| 6 | zsh 危险 builtin 拦截 | zmodload、zpty、ztcp 等可扩展 zsh 能力的 builtin 被拦截,防止 shell 能力绕过 | |
| 7 | 数组下标执行面(flag 触发) | 某些 builtin(如 printf -v)在 NAME 位置会算术求值数组下标,可能触发 $(...) 执行 | |
| 8 | read/unset 裸位置 NAME 下标执行面 | read、unset 等命令的裸 NAME 参数即使无危险 flag,也会把下标当作可执行表达式解析 | |
| 9 | [[ ... ]] 算术比较两侧操作数检查 | -eq、-gt 等算术比较操作符会对两侧操作数做算术求值,属于隐式执行入口 | |
| 10 | Shell 关键字误解析防御 | if、while、for 等关键字出现在 argv[0] 时,代表 AST 可能误解析,必须 fail-closed | |
| 11 | newline + # 注释错位防御 | 参数中若出现换行后紧跟 #,下游按行分词时会把 # 后内容当注释丢弃,造成参数隐藏 | |
| 12 | jq system() 与危险 flag 拦截 | jq 的 system() 函数及 --run-tests 等 flag 可成为代码执行与文件读取的桥接点 | |
| 13 | /proc/*/environ 敏感访问拦截 | 访问 /proc/self/environ 等路径可能泄露进程环境变量中的密钥与凭据 | |
| 14 | 复杂结构操作符检查(subshell/command group) | (cmd)、{ cmd; } 等组合结构可隐藏执行边界,必须先拦 | |
| 15 | 管道分段递归检查与跨段 cd+git 防护 | 管道 | 将命令分段后每段独立过权限检查,同时扫描所有段防止 cd+git 组合风险被拆开遗漏 | ⭐ |
| 16 | process substitution 路径层拦截(Legacy 路径) | 在 AST 不可用的 Legacy 路径下,对 <(cmd) / >(cmd) 做兜底拦截 | |
| 17 | 重定向目标安全检查(含危险 expansion) | 重定向目标若含变量展开或命令替换,可能写入任意文件 | |
| 18 | 危险删除路径拦截(rm/rmdir) | rm -rf /、rm -rf ~、rm -rf /* 等针对关键系统目录的删除强制人工确认 | ⭐ |
| 19 | cd + write 组合路径不确定性拦截 | 复合命令中 cwd 变化后,后续写操作的路径解析不确定,自动判定不可靠 | |
| 20 | -- 终止符与 flag 解析健壮性 | 正确处理 -- 后的参数,防止把 -- 后的路径误当 flag 丢弃导致漏检 | |
| 21 | 路径命令 wrapper 去壳后再校验 | 路径校验层对 timeout/env/nice 等再次去壳,防止外层绕过路径检查 | |
| 22 | Legacy 注入安全网(仅 AST 不可用) | AST 不可用时,用正则兜底已知注入/误解析模式 | |
| 23 | 安全 heredoc 例外重检 | 对无引号但内容纯字面量的 heredoc 做例外处理,减少误报同时不放松对注入的拦截 | |
| 24 | 子命令 fanout 上限防护 | 限制 $() 拆分或 heredoc 分段的数量上限,防止超大拆分触发 CPU 饥饿/DoS |
四、权限验证
权限策略状态一共有三种:allow(允许执行)、deny(拒绝执行)、ask(询问用户)
那么匹配这个权限策略状态的规则主要是这几种:
- 配置文件规则命中将执行相应的权限策略状态
- 静态规则检查命中的大部分结果都会是 ask 状态
- 一般只读命令是直接 allow 状态

配置文件的定义格式一般如下:
{
"permissions": {
"allow": ["Bash(git status:*)", "Bash(npm install:*)"],
"deny": ["Bash(rm:*)", "Bash(rm -rf:*)"],
"ask": ["Bash(docker:*)"]
}
}只读指令的判断标准一般如下:
- 命令是
ls、cat、head、tail、wc、find、grep、git status、git diff、git log等纯读取命令 - 不包含 cd
- 不包含输出重定向和管道中的写入操作符