主题
Command Guard
本课交付结果
你将交付 classifyCommand({ executable, args }):允许只读 Git 与测试,询问 reset 和依赖安装,拒绝删除器、下载器、shell 与 force push。
岗位问题
“execute 已批准”不意味着任何命令都安全。git status 与 git push --force 的风险完全不同;若把整条字符串交给 shell,模型还可通过连接符加入第二条命令。
前置检查
前置知识快照
先验证权限三态:pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 11。命令必须已拆成固定 executable 和 argv,Guard 不解析 shell 语法。
executable 是要启动的程序,args 是保持边界的参数数组。Guard 消费结构化命令,不接收需要再次切词的字符串。若上游把 git status && rm -rf x 当作一段 shell 文本传入,本课规则无论多完整都无法可靠恢复原始参数边界;安全必须从命令合同开始。
上一课只知道 execute 通常需要询问,本课进一步理解具体命令语义。分类结果仍是 allow、ask、deny:allow 表示规则已明确识别低风险形态,ask 表示风险取决于用户意图,deny 表示产品不应提供该入口。Guard 只决策,不启动进程。
原理拆解
先按 basename 拒绝 bash、sh、rm、curl 等高风险入口,再识别 Git 子命令和参数,最后处理包管理器。未识别命令进入 ask,而不是 allow。-f、--force 和 --force=... 都需覆盖。
mermaid
flowchart TD
A[executable + argv] --> B[basename 与小写规范化]
B --> C{shell/删除器/下载器?}
C -- 是 --> D[deny]
C -- 否 --> E{git?}
E -- force push --> D
E -- status/diff/log --> F[allow]
E -- reset/其他 --> G[ask]
E -- 否 --> H{包管理器?}
H -- test --> F
H -- install/add --> G
H -- 未识别 --> G规则顺序就是安全语义。宽泛的“所有 Git 都允许”若先返回,后面的 force push 拒绝永远不会执行。安全组织通常先放明确 deny,再放窄 allow,最后用 ask 收口。代码结构应让评审者能从上到下验证这个优先级。
为什么先取 basename?调用方可能给出 /bin/bash、/usr/bin/rm 或平台路径;只比较完整字符串会漏掉带目录的危险程序。basename 统一程序身份,但真实生产还要解析受信可执行路径,避免 PATH 中恶意同名程序。名称分类与二进制来源是两层防线。
关闭 shell 并不等于没有参数注入。git push --force 不需要 shell 也具有破坏性,npm publish 也会产生外部副作用。Guard 必须理解目标程序的子命令和关键 flag。相反,不应把 argv 重新 join(" ") 后用正则,因为空格、转义和参数边界会再次丢失。
未知命令默认 ask 是可用性与安全的折中。完全 deny 会阻止新测试工具,默认 allow 又把 allowlist 变成不完整 blocklist。询问让用户看到精确程序与参数,同时为后续根据真实使用数据增加窄规则提供证据。
代码实验
失败实现
危险实现只看程序品牌,所有 Git 与包管理器命令都放行:
ts
function unsafe(command: Command): CommandDecision {
if (["git", "pnpm", "npm"].includes(command.executable)) {
return { decision: "allow", reason: "常用开发工具" };
}
return { decision: "deny", reason: "未知" };
}正确实现先归一程序名,再按窄规则匹配参数:
ts
export function classifyCommand(command: Command): CommandDecision {
const exe = basename(command.executable).toLowerCase();
const args = command.args;
if (["sh", "bash", "zsh", "fish", "rm", "curl", "wget"].includes(exe)) {
return answer("deny", `禁止执行 ${exe}`);
}
if (exe === "git") {
const sub = args[0];
const forced = args.some((arg) =>
arg === "-f" || arg === "--force" || arg.startsWith("--force=")
);
if (sub === "push" && forced) return answer("deny", "禁止 force push");
if (["status", "diff", "log"].includes(sub ?? "")) return answer("allow", `允许只读 git ${sub}`);
if (sub === "reset") return answer("ask", "git reset 需要确认");
return answer("ask", `git ${sub ?? ""} 需要确认`);
}
return answer("ask", `未明确允许:${exe}`);
}bash
pnpm --dir bootcamps/coding-agent/labs/12-command-guard/starter test
pnpm --dir bootcamps/coding-agent/labs/12-command-guard/solution testStarter 声明 实现破坏性 Git 分类;Solution 应通过 5 项测试,测试本身不会运行任何危险命令。
表驱动测试能防止等价 flag 漏洞:
ts
it.each([["--force"], ["-f"], ["--force=true"]])(
"拒绝 force push 参数 %s",
(flag) => {
expect(classifyCommand({ executable: "git", args: ["push", flag] }).decision)
.toBe("deny");
},
);关键实现讲解
比较的是结构化 argv,不是 join(" ") 后的字符串。程序路径先取 basename,防止 /bin/bash 绕过名称规则。规则按 deny、allow、ask 的安全优先级组织。
只读 Git 也要定义到子命令级,甚至参数级。git diff 默认只读,但某些外部 diff 配置可能启动程序;git log 可通过格式参数产生巨大输出。课程先建立分类概念,生产 Runtime 还要固定环境、禁用外部 helper、限制输出和超时。Guard 的 allow 代表“允许进入受限执行器”,不是宣称命令绝对无风险。
包管理器测试可 allow,依赖安装为 ask,因为安装会修改锁文件、下载网络内容并可能执行生命周期脚本。即使用户批准安装,后续 Executor 仍要限制环境和资源。策略、Guard 与执行器各守一层,不能因为前一层做了判断就删除后一层保护。
运行与验证
真实运行输出
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 12
pnpm --filter @coding-agent/sandbox test确认 force push 的长短 flag 都 deny,普通 status allow,reset/install ask,未知命令也不会自动放行。
实际运行中 Starter 的统一练习缺口使五项全部失败,Solution 五项全部通过:
text
starter: 5 failed, 0 passed
solution: 5 passed, 0 failed
覆盖: git status / 测试命令 / reset 与安装 / shell 删除下载器 / force push再用 /bin/bash、未知程序和 git push --force=true 做探针。前者必须在 basename 规范化后 deny,未知程序必须 ask,等价 force 形式不能因字符串匹配过窄而漏过。
常见失败与排查
故障案例 1
带路径的 shell 绕过程序黑名单。
症状:bash 被拒绝,但 /bin/bash -c ... 得到 ask 或 allow。
根因:规则比较完整 executable,没有规范化程序 basename。
定位:对每个危险程序加入裸名与绝对路径用例,比较决定是否一致。
修复:分类前提取并规范化 basename;生产环境再校验解析后的可信二进制路径。
故障案例 2
Git 品牌掩盖破坏性子命令。
症状:git status 与 git push --force 都因 executable 是 git 而自动执行。
根因:allow 规则粒度停在程序名,没有先检查子命令与危险 flag。
定位:输出命中 rule id,若宽泛 Git allow 在 force 规则之前返回,即可确认优先级错误。
修复:明确 deny 先行,窄只读 allow 后置,其他 Git 统一 ask;表驱动覆盖等价参数。
故障案例 3
新命令无需审批直接执行。
症状:仓库脚本引入陌生发布工具后,未配置规则却获得 allow。
根因:默认分支为了减少弹窗返回 allow,使不完整 blocklist 成为安全边界。
定位:随机生成未识别 executable,检查是否存在任何默认允许;审计规则覆盖率。
修复:未知项返回 ask,无人值守环境把 ask 映射为拒绝;根据审计数据再增加窄允许。
课后作业
加入 git clean、npm publish 和递归脚本检测,并用表驱动测试覆盖等价 flag;规则输出稳定 rule id。
进阶要求是给每条规则增加匹配证据:程序规范名、子命令、危险参数与规则版本。再实现一个离线回放器,把历史命令经过新旧规则分别分类,输出决定变化清单,避免规则升级意外扩大自动执行范围。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 输入 | 只消费 executable 与 argv | 解析可执行 shell 字符串 |
| 优先级 | 高风险规则先 deny | 宽泛 allow 覆盖 deny |
| 未知项 | 默认 ask | 默认执行 |
总项目增量
总项目包:@coding-agent/sandbox
总项目路径:packages/sandbox/src/command-guard.ts
总项目验证命令:pnpm --filter @coding-agent/sandbox test
Guard 决定命令是否可进入执行器;下一课实现真正有资源上限的进程边界。
延伸阅读
方案对比与工程取舍
完整 blocklist 容易快速上线,却永远追不上新程序、新参数与组合方式;严格 allowlist 最安全,但开发工具生态庞大,未列命令会频繁阻断。三态 Guard 采用“明确危险 deny、明确低风险 allow、其余 ask”,既保留安全底线,也允许用户处理长尾命令。无人值守环境再把 ask 映射为拒绝,形成严格模式。
字符串正则看似能覆盖整条命令,却无法可靠处理参数边界、引号、平台转义和嵌套 shell。结构化 argv 让 --force 就是一个字段,规则可以精确比较;代价是上游所有调用都必须从一开始保持结构化,不能接受用户随意输入命令文本后假装安全拆分。
程序 basename 分类实现简单,但不能证明实际启动的是受信二进制。仓库可修改 PATH,让名为 git 的恶意脚本先被找到。生产执行器可以使用最小 PATH、解析并固定受信程序路径,或维护 executable 到绝对路径的映射。Guard 判断语义,Executor 确保身份,两者需要同时成立。
命令别名与包装器也会改变风险。pnpm test 可能在 package.json 中映射到任意脚本,甚至下载或删除文件。课程把它列为 allow 是为了练习规则层级,正式产品应先读取脚本定义、限制生命周期脚本,或把项目自定义脚本降为 ask。不能因为命令名叫 test 就假设实现无副作用。
Git 只读子命令也有配置注入面。仓库配置可定义外部 diff、分页器或过滤器,环境变量也可改变行为。执行只读 Git 时可传 --no-pager、禁用外部 diff、使用受控环境并限制输出。Guard 的职责是分类,不代表后续层可以继承任意用户配置。
force push 的参数位置不固定,可能出现在子命令前后的全局与局部区域;长参数也可能使用等号。规则要基于真实 CLI 语法列举等价形式,而非只检查 args[1]。对复杂命令,使用专门解析器或保守 ask 比不断追加脆弱正则更可靠。
git reset 默认 ask,是因为不同模式风险不同。软重置主要移动引用,硬重置还会覆盖工作树;若未来细分规则,--hard 可以 deny,明确目标的 --soft 可以 ask,某些只取消暂存的形式也许 allow。规则细化前先用真实任务数据证明能减少摩擦,不要为了“智能”创建无法审计的条件树。
删除器直接 deny 并不意味着系统永远不能删除文件。更安全的能力是专用文件工具:限制工作区、展示补丁、支持恢复,并通过 write 权限审批。通用 rm 能递归、跟随参数和跨目录,难以从几个 flag 证明安全。用窄工具替代通用命令,是 Agent 安全设计的常见原则。
下载器与 shell 被拒绝也基于同一逻辑。curl 兼具网络、写文件、凭据和重定向能力,shell 可以组合任何程序并重新解释文本。若产品需要网络,应提供受限 HTTP 工具,执行域名、方法、响应大小和重定向策略;若需要脚本,应在真正容器或隔离账户内运行,而不是给应用层 Guard 增加几个字符串例外。
包管理器安装默认 ask 仍不是充分安全。用户批准后,安装过程可能执行第三方生命周期脚本、修改大量文件并联网。审批 UI 应显示包管理器、参数、工作区和可能副作用,执行器限制时间与环境,文件 Trace 记录锁文件变化。高风险环境可要求 --ignore-scripts,或只在临时容器中安装。
递归脚本检测是进阶难点。pnpm run verify 可能调用另一个脚本,最终执行 shell。Guard 可以读取 package.json 解析脚本图并设置最大深度、循环检测与危险 token;但 shell 文本仍难以静态证明。务实策略是项目脚本默认 ask,只有课程或受信仓库中明确签名的脚本 allow。
理由应告诉用户命中了什么风险。例如“依赖安装会修改锁文件并执行第三方代码”比“需要确认”更有用;“禁止 force push,因为它可能覆盖远端历史”比“危险命令”更可学习。稳定 rule id 用于审计,本地化 reason 用于人类理解,两者都不能由模型自由生成。
规则配置不应由当前不可信仓库任意放宽。项目可以建议常用测试命令,但组织级 deny 与用户安全默认必须拥有更高优先级。否则恶意仓库只需提交配置文件声明 bash 安全,就能绕过 Guard。配置来源、签名和覆盖关系属于规则模型的一部分。
审批缓存必须绑定结构化命令。对 pnpm install 的批准不能泛化成所有 pnpm,也不能仅按展示字符串比较;缓存键包含规范 executable、完整 argv、cwd、环境策略和规则版本。命令任何部分变化都重新分类和审批,避免先展示安全参数、执行前换成危险参数。
参数数组在分类后也应复制或冻结。调用方若在异步审批期间修改 args,Guard 审查的动作与 Executor 执行的动作就不同。下一课 Runtime 会复制命令,生产审批还应对规范载荷计算哈希,并在执行点再次验证。
工作目录影响同一命令的风险与效果。git reset 在临时夹具和真实仓库不是一件事,pnpm test 在工作区外可能读取其他项目。Guard 结果至少与可信 workspace 关联,Runtime 固定 cwd;模型不能通过 input 覆盖。若支持多工作区,审批载荷必须包含工作区身份。
环境变量也可能把普通命令变危险,例如代理、配置路径和凭据改变网络或文件目标。Guard 不必理解所有环境键,但执行器只传显式 allowlist,并把环境策略版本绑定审计。允许一个命令,不等于允许继承宿主的全部能力。
规则回放是升级前的重要工具。收集脱敏历史命令,分别用旧规则和新规则分类,统计 allow 变 ask、ask 变 allow、deny 变其他的数量。任何权限放宽都需要人工解释与对应测试。回放不能证明没有未知攻击,却能防止常见行为在重构中意外变化。
测试矩阵至少覆盖程序路径形式、大小写、空参数、未知子命令、每个危险 flag 变体、包管理器别名与默认兜底。再加性质测试:所有 shell 与下载器 basename 无论参数如何都不为 allow;所有未知 executable 都不为 allow;force push 的已知等价形式都为 deny。
可观测性记录命令摘要、rule id、决定、审批结果和最终执行关联,不保存可能含 token 的完整参数。可以按 Schema 标注敏感参数位置,Trace 用哈希或占位符。没有脱敏的命令日志本身可能成为凭据泄漏渠道。
评估 Guard 不能只看拦截率。过多 ask 会形成审批疲劳,过多 deny 会逼用户绕开 Agent,过多 allow 则扩大自动化风险。用真实任务统计各类命令、拒绝后替代方案和用户决策,逐步增加证据充分的窄 allow,保持 deny 底线稳定。
把五条课程命令逐个走一遍可以验证心智模型。git status --short 的 executable 为 git、子命令 status,命中窄只读 allow;pnpm test 命中测试 allow;git reset HEAD 可能改变索引,返回 ask;pnpm install 修改依赖并联网,返回 ask;git push --force 在任何已知 force flag 形式下都先命中 deny。分类只依赖结构化字段,相同输入始终得到相同结果。
若收到 git -c core.pager=cat status,简单把第一个参数当子命令会把 -c 误判成未知而 ask。这种保守误判不会自动执行危险动作,但会增加摩擦。若要支持 Git 全局选项,应写一个有限解析器跳过已允许的全局 flag,并拒绝能加载外部配置或改变执行器的选项;不要直接寻找数组里任意一个 status,否则恶意参数也可能伪装子命令。
类似地,包管理器脚本形式可能是 pnpm run test 或 npm test。规则需要明确接受的等价 argv,而不是对数组做 includes("test");pnpm run publish test 也包含 test,却不是测试命令。解析位置和语法比关键词出现更重要。每支持一种新形式,都用正例与近似反例成对测试。
短 flag 聚合会增加复杂度。Git 的 -f 可能作为独立参数,某些 CLI 允许与其他短 flag 合并。若无法证明解析正确,相关命令应降为 ask 或 deny。安全分类器不必成为所有 CLI 的完整解析器;它应对已证明的窄形态自动化,对其余保持保守。
Windows 可执行文件可能带 .exe、.cmd 或不同路径分隔符。单用 POSIX basename 与裸名集合可能漏掉。正式支持矩阵应对程序名做平台一致规范化,并谨慎处理脚本包装器;.cmd 往往经命令解释器执行,不能简单等同原生二进制。课程在当前 Node 环境聚焦核心结构,跨平台扩展必须有独立测试。
符号链接程序名也会欺骗 basename,例如一个链接叫 git 却指向 shell。执行器应解析真实路径并与受信安装位置或哈希比较。应用层工具若只允许少量程序,固定绝对路径比依赖 PATH 更简单。规则中记录逻辑名,执行审计同时记录实际解析路径与版本。
版本差异会改变参数语义。某个今天只读的子命令未来可能增加写入 flag,包管理器也会增加新别名。allow 规则应尽量限制已知参数集合,对未知 flag 返回 ask,并在升级工具链时跑命令契约测试。只锁程序名而不锁版本与参数,安全结论会随环境漂移。
用户提供的自由命令输入不应通过简单空格切分变成 argv。引号、转义、变量和重定向没有跨平台唯一解释,切分后展示与执行可能不同。产品可以提供受控命令表单,或明确把自由 shell 交给隔离终端而不走自动 Agent Guard。边界清晰比“尽量猜对”更安全。
Guard 与专用工具之间应有替代建议。拒绝 rm 时提示使用受限删除或补丁工具,拒绝 curl 时提示当前不支持网络,deny force push 时建议普通 push 或让用户在外部终端自行处理。拒绝若没有可行路径,用户更容易寻找绕过;安全设计也要提供合规完成任务的方式。
ask 的界面应展示解析后的结构,而不是一行可能误导的字符串:程序、逐项参数、工作区、风险说明和预计副作用分开呈现。敏感参数脱敏但参与哈希。用户修改命令时生成新请求,不能在原审批卡片上悄悄替换文本。
决策结果应是不可变快照。规则配置在审批等待期间更新时,旧决定要么按原版本执行并记录,要么强制重新分类;不能拿旧 allow 配新规则原因。高风险 deny 更新通常应立即使未执行批准失效。明确生命周期可以避免竞态。
进程退出后还可以反向验证 Guard 假设。例如标记为只读的命令若产生工作区文件变化,Trace 发出策略偏差告警并把该规则降级。文件快照无法捕获网络或外部状态,但能发现常见脚本副作用。策略可以从运行证据改进,而不是永远依赖静态名称。
团队规则评审应要求三类证据:为何该形态低风险、有哪些近似危险反例、后续执行层还限制什么。只有正例没有反例的 allow 很可能过宽;只有名称直觉没有执行边界的 allow 也不充分。把证据写进测试名称和规则注释,未来维护者才能理解安全假设。
最终验收可做变异测试:交换规则顺序、删除 basename、把默认 ask 改为 allow、漏掉一个 force 形式,测试套件都应变红。若某项危险修改仍全绿,说明门禁没有覆盖真正安全性质。好的安全测试不是证明当前代码跑通,而是能杀死典型错误实现。
上线前再导出一份人可读规则清单,逐条列出输入形态、决定、理由和替代方案,让安全、开发与产品共同签字。代码决定机器行为,清单建立团队共同预期;两者从同一规则源生成,避免文档与实现漂移。每次变更都同时比较自动测试和清单差异,才能把命令能力长期维持在可审计范围内。
当规则无法确定时,宁可诚实返回询问,也不要用看似聪明的猜测换取一次自动执行。可解释的保守边界,才是后续扩大能力的可靠起点。
下一课衔接
Guard 只决定命令能否进入执行阶段,还没有解决获准命令无限运行、输出海量日志或读取宿主密钥的问题。下一课实现 Process Executor:固定 spawn 与 shell:false,使用干净环境,给 stdout/stderr 共享字节预算,处理超时与取消,并把非零退出码保留为结构化数据。
- Allowlist 与 blocklist 的安全差异。
- 参数注入和 shell 注入。
- Git 破坏性操作的恢复边界。