pi agent 插件开发实录:读了 Anthropic 24 篇 agent 设计博客,我重构了 plan mode 插件
从 596 行 if/else 到 12 个 capability,一篇完整的技术拆解
上周干了一件挺折腾的事。
把 pi agent 上挂着的 plan mode 插件整个推倒重写了。
事情是这样的。
我之前的 plan mode 插件是基于一个 npm 包的,叫 @narumitw/pi-plan-mode。它里头有个 isSafeCommand 函数,596 行 if/else。我用了大半年,最近越用越觉得不对劲。
最明显的一个例子。我有个 agent 在只读 plan 模式下调研一个外部 API,它跑了一条 curl 命令。
curl https://api.github.com/users/octocat
没问题,GET 请求,只读操作。
然后它想看看那个 API 的 POST endpoint 长啥样,写了
curl -X POST -d '{"foo":"bar"}' https://api.example.com/webhook
通过了。
我当时就愣住了。。。
POST 请求?写操作?这也能过?
我回去翻了一下那个 596 行的 isSafeCommand。它对 curl 的处理就一行,curl 命令前缀通过。但 curl 能传 -X POST,能传 -d data,能传 --upload-file。这些它全不管。
我当时就觉得,这个东西维护起来不是人过的日子。
另一个更吓我的。
就在我研究 plan mode 实现的时候,顺手翻了下官方 example。
然后我发现了一个 bug,挺恶心那种。
官方 plan mode 的代码里,在 event.messages.filter(...) 的回调里有这么一段
if (msg.content.includes("[PLAN MODE ACTIVE]")) return false;
对,就是在 user message 里找「PLAN MODE ACTIVE」这个字符串,找到了就把这条消息删掉。
我当时一脸问号???
这个过滤的意图我大概能猜,开发者可能是想过滤掉某种 prompt injection 或者重复消息。但用字符串字面过滤做安全边界,这特么不是 bug 吗?
任何用户只要在 message 里写了「我们现在不在 PLAN MODE ACTIVE 模式」这种话,那部分历史就被吞了。
更糟的是,agent 自己只要在回复里写了「PLAN MODE ACTIVE」字样(甚至在解释自己的状态),相关的对话上下文也会被影响。
同一段代码还过滤掉了 customType === 'plan-mode-context' 的消息。双重过滤机制叠加在一起,碰到任何意外字符串组合都可能误删历史。
我看完这段代码,心里就一个想法,这玩意不能再用了。
我读了 Anthropic 24 篇工程博客
正好这段时间我把 Anthropic 从 2024 年到 2026 年发的 24 篇工程博客都啃了一遍。其中至少 6 篇直接跟权限控制、agent 安全、长任务执行相关,是 agent 设计领域的硬菜。
有几篇对我冲击特别大。
「How we built Claude Code auto mode」。讲他们怎么把权限判断从「一刀切 allow/deny」改成三层架构。第一层是 safe tool 白名单,比如读文件、文本搜索这些无脑放行的工具。第二层是项目内文件操作,自动放行不弹确认。第三层才是真正的 classifier,对可能有副作用的操作做判断。
而且他们反复强调一件事,prompt 不能用 if/else 硬规则。他们叫这个「Goldilocks altitude」,既不能太具体(brittle),也不能太模糊(agent 不知道怎么做),要刚刚好。
他们实测下来的数据,让我印象很深。合理的「自动放行 + 智能拦截」组合能让权限弹窗数量减少 84%,用户对弹窗的接受率反而提升到 93%。
「Effective harnesses for long-running agents」。专门讲长任务怎么不让 agent 卡住或者中断。核心观点是,harness 的设计应该把「上下文续传」「任务拆分」「checkpoint」这些做扎实,而不是指望 agent 自己记得所有事。
「How we built our multi-agent research system」。讲他们怎么让一个研究任务跑 30 分钟不中断,关键是他们给子 agent 的权限边界是预先定义好的,子 agent 在自己的研究领域里几乎不会被弹窗打断。
我当时看完这几篇,心里就有数了。
我的 plan mode 插件不能用 if/else 硬规则,得让 agent 知道自己的边界然后自己判断。权限应该是分层而不是一刀切。长任务跑 plan 模式调研不能被频繁中断。
这三点成了我重写 plan mode 插件的核心指南。
三件我从中学到的事
带着这三个 insight,我把 plan mode 插件整个重写了。新的版本不是技术升级,是三个产品决策。
一,yolo 模式,干掉烦人的权限审批
之前那个 npm 包的逻辑是「未识别的命令 → 默认拒绝 → 弹窗让用户确认」。听起来很安全对吧?
我实际用了大半年,最大感受是两个字,烦死。
为什么烦?因为命令组合永远穷举不完。git 有几十个子命令,gh 有几十个,npm、cargo、docker、kubectl、aws 都有自己的子命令树。每个组合都要单独配置安全规则,加一个新命令就要改代码。
更要命的是这种审批模式不区分场景。agent 在调研外部 API 时跑 curl -X POST 测试,那是个合理的探索行为,硬要弹窗就是打断心流。agent 在生产环境跑 rm -rf /,那才该拦截。
我这次干脆砍掉审批模式,就保留两个状态。Build 模式,啥都能干。Plan-Yolo 模式,只读 + HTTP GET。Tab 键切一次进 plan,再切一次回 build。
砍掉之后我自己用了两周,体验是这样的。没有任何命令被弹窗打断。碰到真正需要写的操作,agent 通过 plan_commit 工具提交一个计划给我看,我审批或者驳回。整条链路里没有「你确定要执行这个吗」这种废话。
背后的实现细节是这样的。我把命令分类从 if/else 换成了 capability 标签。
先定义一套 capability。我用了一个 12 个元素的 union 类型
type Capability =| "read:filesystem" | "write:filesystem"| "read:process" | "write:process"| "read:network" | "write:network"| "read:git" | "write:git"| "read:package" | "write:package"| "execute:subprocess"| "modify:environment";
读文件系统、写文件系统、读进程、写进程、读网络、写网络、读 git 状态、写 git 操作、读包信息、写包信息、执行子进程、修改环境变量。一共 12 个 capability,覆盖了大多数 bash 命令的能力维度。
Plan-Yolo 模式默认只允许 5 个只读 capability
const PLAN_DEFAULT_ALLOWED = new Set(["read:filesystem","read:process","read:network","read:git","read:package",]);
任何命令,只要触发 write:* / execute:subprocess / modify:environment 里的任何一个,就被拦截。读 + GET 网络操作全放行。
每条命令我给它写一个 profile。profile 长这样
interface CommandProfile {base: Capability[]; // 基础能力forbid?: Record<string, Capability>; // 触发额外能力的参数dynamic?: (subcommand: string) => CommandProfile | null; // 子命令动态 profile}
举几个真实的例子。
curl 的 profile
curl: {base: ["read:network"],forbid: {"-X": "write:network","-d": "write:network","--data": "write:network","--upload-file": "write:filesystem","-o": "write:filesystem","--output": "write:filesystem","-O": "write:filesystem",},},
curl 基础是读网络。但 -X POST / -d data 这些参数会触发写网络,-o file / --upload-file 触发写文件系统。
这里有一个挺关键的细节。curl URL 不带 -X 时默认是 GET(业界惯例),所以不加 write:network。但如果用户显式写了 curl -X GET,我的引擎会在参数扫描时识别 GET/HEAD/OPTIONS,覆盖掉 -X 触发的 write:network。这样既支持 curl URL(隐式 GET),也支持 curl -X GET URL(显式 GET),两种都放行。
find 的 profile
find: {base: ["read:filesystem"],forbid: {"-exec": "execute:subprocess","-execdir": "execute:subprocess","-ok": "execute:subprocess","-okdir": "execute:subprocess","-fprint": "write:filesystem","-fprint0": "write:filesystem","-delete": "write:filesystem",},},
find . -name "*.txt" 默认放行,是只读操作。但 find . -exec rm {} ; 触发 execute:subprocess,被拦截。find . -fprint output.txt 触发 write:filesystem,被拦截。
加新命令就是加一条 profile,10 行搞定。不用再翻 600 行 if/else。这就是 declarative 模式的核心好处。
引擎判断流程分三步。
第一步,parseShellWords 做词法分析。把 shell 命令切成 token 数组,正确处理单引号、双引号、反斜杠转义。
第二步,splitShellSegments 按 ;&|&&|| 分割命令链。一个 ls | grep foo 被切成两个 segment,分别判断。每个 segment 都得是允许的,整条命令才放行。
第三步,classifyArgs 走 profile 表。基础能力从 profile.base 取,再扫一遍参数看有没有触发 profile.forbid 里的额外 capability。未知命令(不在 COMMAND_PROFILES 里)默认加全部 write:* capability,等于直接 deny。
整个引擎 510 行代码,对比之前 npm 包的 596 行,代码量差不多,但可读性和可扩展性完全不在一个量级。
二,尽可能长完成任务,不打断探索
之前的 plan mode 经常把一个完整的调研任务切成好几段。
举个具体例子。agent 要调研一个 npm 包,跑了 npm view xxx 查版本,然后 npm pack xxx --dry-run 看里面有什么文件,再 curl -X GET https://registry.npmjs.org/xxx 看 metadata。
这三个命令都被拦了,理由是「npm pack 不在白名单 / curl 带了 -X 参数 / 等等需要审批」。
结果就是,agent 跑两步就被拦截一次,切回 build 模式继续,再切回 plan 模式调研。一个完整的调研任务被打散成四五段,每段切回来都要重新组织上下文,心流全断。
Anthropic 在 multi-agent 那篇文章里讲了一个观点,特别打动我。他们说,子 agent 在自己的研究领域里应该几乎不会被弹窗打断。因为频繁打断会让 agent 反复重读上下文、反复重做已经做完的事,效率损失远比「偶尔放过一个边角操作」大。
我这次的设计思路就是,能放行的尽量放行。
Plan-Yolo 模式下,agent 能看到的工具列表是这样的
const PLAN_TOOLS = ["read", "bash", "grep", "find", "ls","questionnaire", "plan_commit",] as const;
Build 模式的工具列表是
const BUILD_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls",] as const;
注意两个关键差异。
第一,Plan-Yolo 模式完全移除了 edit 和 write。LLM 看不到这两个工具,它根本不会尝试去调用。这是从工具列表层面做的硬约束,比 bash 命令拦截更彻底。
第二,Plan-Yolo 模式多了 questionnaire 和 plan_commit。这两个是我自己注册的工具。
plan_commit 是 commit 仪式。agent 在 Plan-Yolo 模式下做了足够调研之后,调用这个工具提交计划。工具长这样
pi.registerTool({name: "plan_commit",parameters: Type.Object({plan: Type.String({ minLength: 1, maxLength: 50_000 }),}),execute: async (params) => ({content: [{type: "text",text: `**Proposed Plan**\n\n${params.plan}`,}],details: { plan: params.plan },terminate: true,}),});
调用时返回一个 **Proposed Plan** 的 markdown 块,并且 terminate: true 强制终止当前 turn,等用户审阅。50_000 字符上限是参考 Anthropic 官方 plan_mode_complete 工具的设计定的,避免 agent 一口气写个万字 plan 把上下文撑爆。
questionnaire 是提问工具。这个我是直接从 @earendil-works/pi-coding-agent/examples/extensions/questionnaire.ts 复制的,448 行代码。一个工具同时支持单问题模式和多问题模式,单问题走选项列表,多问题走 tab 界面。这个设计挺优雅的,省去了注册两个不同工具的麻烦。
借鉴了 opencode 的思路。opencode 的 plan agent 把 edit 和 bash 都设成 ask 模式,但他们同时给 plan agent 大量预设的「自动放行」操作。opencode 不去对每个命令分类,而是对「操作类型」分类,类型对了就放行。
我把这个思路翻译到自己的设计里。Plan-Yolo 模式下,「读取类操作」「HTTP GET 类操作」「只读命令类操作」自动放行。只有当 agent 试图做「写文件」「执行脚本」「POST/PUT/DELETE」时,引擎才介入判断。
具体来说,agent 调研外部 API 时可以一路读到底,不用切来切去。
切换模式的实现是这样的。我用一个状态机来追踪当前模式
// state.tsinterface PlanModeState {enabled: boolean;enteredAt: number;}export function isPlanModeEnabled(ctx): boolean { ... }export function readState(ctx): PlanModeState { ... }export function writeState(pi, state: PlanModeState): void {pi.appendEntry<PlanModeState>("plan-mode-state", state);}
状态持久化通过 pi.appendEntry 写到 session branch entry 里。每次切换模式就追加一条 customType === 'plan-mode-state' 的 entry。读取时反向扫描取最后一条(last-write-wins)。
切换的实现
// extension.tsasync function togglePlanMode(pi, ctx) {if (isPlanModeEnabled(ctx)) {// Exit plan → restore build toolspi.setActiveTools(savedToolsBeforePlan ?? BUILD_TOOLS);writeState(pi, { enabled: false, enteredAt: 0 });} else {// Enter plan → switch to plan toolssavedToolsBeforePlan = pi.getAllTools().filter(t => t.sourceInfo?.source === 'builtin').map(t => t.name);pi.setActiveTools(PLAN_TOOLS);writeState(pi, { enabled: true, enteredAt: Date.now() });}}
pi.setActiveTools 是 pi-coding-agent 提供的 API,直接替换 LLM 当前能看到的工具集。savedToolsBeforePlan 保存进入 plan 之前的工具列表,退出时恢复。
三,Tab 切换 plan / build,简单好用
这个直接借鉴自 opencode。
opencode 的 plan agent 是个独立的 agent profile,有自己的权限边界和工具集。按 Tab 键切换 build 和 plan 两种模式,build 是默认的全权限 agent,plan 是只读权限的探索 agent。
参照 opencode 的 Tab 方便快捷,我也搬过来——两档。Build ↔ Plan-Yolo。
按 Tab 进 plan,再按 Tab 回 build。简单粗暴。
具体注册 Tab 键是这样
// extension.tspi.registerShortcut("tab", {description: "Toggle plan mode",handler: async (ctx) => togglePlanMode(pi, ctx),});
也支持 /plan-toggle slash 命令注册,两条路径走同一个 handler。
整个 plan mode 我拆成了一个独立的 src/plan-mode/ 目录。settings.json 里只注册一行
{"packages": ["src/plan-mode/index.ts"]}
src/plan-mode/ 下 7 个文件
state.ts ← 状态机(40 行)bash-policy.ts ← capability 命令分类器(510 行)bash-policy.test.ts ← 61 个测试用例(165 行)prompt.ts ← Goldilocks altitude 系统提示(58 行)questionnaire.ts ← 自注册提问工具(448 行,复制自官方 example)plan-commit.ts ← plan_commit 工具(78 行)index.ts ← 主入口,组合上述模块(170 行)
整个 plan mode 是完全独立、零 npm 依赖的模块。
依赖清理也很干脆
pi uninstall npm:@narumitw/pi-plan-mode# removed 2 packages
settings.json 里这个 npm 包彻底删了。本机不再依赖 @narumitw/pi-plan-mode,整个 plan mode 从能力引擎到 commit 仪式全是我自己写的。
四,prompt 怎么写,一段 Goldilocks altitude 的拆解
讲完三个产品决策,得回到一个最基础的问题。这套 yolo 模式,agent 在跑的时候到底知道什么?
我注入了一段 58 行的系统提示,叫 PLAN_PROMPT_YOLO。分五个区块。
第一个区块,「Available tools」,直白告诉 agent 现在能用 read、grep、find、ls、bash、questionnaire、plan_commit 这七个,edit 和 write 主动不可见。
这块设计上的关键不是「列工具名」,是「明确告诉 agent 哪些工具不存在」。如果只列允许的,agent 会去找 edit 找不到,可能误以为是自己权限不够。如果直接说「edit 和 write 不在列表里」,agent 就不会瞎找,体验顺得多。
第二个区块,「Allowed bash」。这块是真正体现 Goldilocks altitude 的地方。
我列出了 5 个默认允许的 capability,read:filesystem、read:process、read:network、read:git、read:package。然后列了 3 个默认禁止的 capability 类别,write 开头的一切、execute:subprocess、modify:environment。
但我没有列具体的命令。
这就是「既不具体也不模糊」的中间地带。我没说「curl 允许,wget 允许,POST 禁止」(太具体,太脆)。我也没说「agent 自己看着办」(太模糊,agent 不知道边界在哪)。
我说「read:network 允许,write:network 禁止」。
agent 拿到这个提示,自己去推。curl 默认 GET 是 read:network,POST 是 write:network。git 默认 log 是 read:git,commit 是 write:git。这套 reasoning agent 完全能搞定。
第三个区块,「How to behave」,四条行动指南。
第一条「Explore widely」,告诉 agent 调研的时候大胆读文件、跑只读命令、做 GET 请求。这条是反向设计的,因为之前那个 npm 包给了太严的边界,agent 调研两步就被打断一次,反而效率低。
第二条「Ask when uncertain」,引导 agent 用 questionnaire 问问题。明确写「不要问能从文件里读到的东西」,避免 agent 拿问卷套话。
第三条「Synthesize at the end」,提示 agent 调研够了就整合成 Markdown plan,然后用 plan_commit 提交。
第四条「Do not attempt to write files」,明确禁止在 plan 模式调用 edit 和 write 这两个工具。这条是兜底,因为 prompt 里写「tool 不可用」和「行为上不要尝试调用」是两回事。前者靠工具列表硬切,后者靠 agent 自己理解。
第四个区块,「When a bash command is blocked」。这条是给 agent 解释拦截逻辑的。
直接说「不要尝试绕过安全层」。原文是「no POST disguised as GET, no shell tricks」,翻译过来就是别用奇怪的方法绕我的判断。
然后给两条出路。一是找只读替代方案(如果就是想读数据,那一定有种只读的方式),二是写到 plan 里描述操作,让用户切回 build 模式执行。
这种「给路径但不给绕过」的措辞是关键。安抚了 agent「被拦了不要慌」,但坚决不让它动绕过的念头。
第五个区块,「When to commit」,调用 plan_commit 的三个条件。
不是「TBD」还有 → 不调。重大决策没问用户 → 不调。plan body 不能独立站住 → 不调。
这三个条件把「我准备好了」的判断标准从主观感觉变成了客观清单。agent 自己也知道什么时候该提交。
写完回头看,这 58 行 prompt 的核心就是一个判断,「用能力边界描述代替命令白名单」。前者是声明式的,agent 自己推;后者是命令式的,agent 只能照做。
跟 Anthropic 那个 template-based classifier prompt 是同一个思路。固定结构 + 几个可配置 slot,其他都是变量。
五,bash-policy 引擎怎么搭起来
prompt 解决的是「agent 怎么想」,bash-policy 解决的是「bash 命令怎么判」。这俩是配对的,prompt 给了 agent 边界感,引擎负责把边界感落地成机器执行。
整个引擎 613 行 TypeScript,分四块。
第一块,Capability 定义。12 个元素的 union 类型。
read:filesystem、write:filesystem、read:process、write:process、read:network、write:network、read:git、write:git、read:package、write:package、execute:subprocess、modify:environment。
为什么是这 12 个,不是 8 个也不是 20 个?
我是从「4 个资源 × 2 个动作」推导的。资源是 filesystem、process、network、git、package、environment 这几类 bash 命令实际能影响的对象。动作是 read(查询)和 write(修改)。
但 environment 只有 write 没有 read,因为 bash 命令查询环境变量本质是读 filesystem 的环境块,没有独立的 read:environment 需求。
execute:subprocess 是单拎出来的,因为有些命令自身只 read,但能 fork 别的进程(find -exec、bash -c),这是独立的能力维度,必须单独标注。
这一套推导下来,12 个刚好覆盖所有 bash 命令的能力维度,没有重叠也没有漏。
第二块,COMMAND_PROFILES。这是引擎的核心数据,70 多条命令的 profile 表。
profile 长这样
interface CommandProfile {base: Capability[]; // 命令基础能力forbid?: Record<string, Capability>; // 触发额外能力的参数dynamic?: (subcommand: string) => CommandProfile | null; // 子命令动态 profile}
base 是命令本身的能力。forbid 是参数扫描,碰到特定 flag 就升级能力。dynamic 是子命令动态判断,专门给 git、npm、yarn、pnpm 这种子命令区分读写的命令用。
举三个具体例子。
cat 的 profile 一行就写完,{ base: ["read:filesystem"] }。因为 cat 没有写模式参数,profile 就这么简单。
curl 的 profile 复杂一点。base 是 read:network,但 forbid 里有 14 个 flag 映射到 write:network 或 write:filesystem。比如 -d、--data、--data-raw、--data-binary、--data-urlencode、-F、--form、-X(除了显式 GET/HEAD/OPTIONS)、--request 全部触发 write:network。-o、-O、--output、--remote-name、-T、--upload-file 触发 write:filesystem。
git 的 profile 最特殊。base 是 read:git,但 dynamic 函数根据子命令返回不同 profile。
子命令在「READ_ONLY set」里(log、diff、show、status、branch、remote、config、ls-files、ls-tree、ls-remote、rev-parse、blame、describe、merge-base、cat-file、grep、shortlog、reflog、tag、stash、fetch、archive 等 20+ 个),返回 { base: ["read:git", "read:network"] }。
其他子命令(commit、push、add、checkout、reset、merge、rebase 等等),返回 { base: ["write:git", "read:filesystem"] }。
这里 read:network 加给 fetch、clone 等只读网络操作的命令,read:filesystem 加给 commit、push 等需要读工作区的命令。
第三块,shell 解析器。三个独立函数。
parseShellWords 做词法分析。把一行 shell 命令切成 token 数组。正确处理单引号、双引号、反斜杠转义。引号不平衡或转义不合法返回 null,调用方按「解析失败 → 拒绝」处理。
举几个 edge case。
echo "hello world" → ["echo", "hello world"],引号内的空格不算分隔符。
echo 'it''s ok' → ["echo", "its ok"],两段单引号拼接。
echo "a\"b" → ["echo", "a"b"],反斜杠在双引号内转义下一个字符。
失衡引号 echo "hello → 返回 null。
splitShellSegments 按 shell 分隔符切割。一行命令可能用 ;、&、|、&&、|| 串起来。
ls /home | grep foo → ["ls /home", "grep foo"],两个段各自独立判定。
引号内的分隔符不切割。echo "a;b" | grep a → ["echo \"a;b\"", "grep a"]。
containsWriteRedirect 检测输出重定向。>、>> 在引号外算重定向,引号内不算。
echo > /dev/null → true(重定向)。echo ">" file → false(> 在引号内)。
注意这条有个 bug 我发现得比较晚,stderr 重定向 2>/dev/null 也算重定向,会触发 write:filesystem。这个保守策略跟我的 deny-by-default 原则一致,宁可误拦不要漏放。
第四块,入口判定函数 isCommandAllowed。
function isCommandAllowed(command, allowed = PLAN_DEFAULT_ALLOWED): boolean {const trimmed = command.trim();if (!trimmed) return false;const segments = splitShellSegments(trimmed);if (!segments) return false;for (const seg of segments) {if (!seg) continue;const args = parseShellWords(seg);if (!args || args.length === 0) return false;const hasRedirect = containsWriteRedirect(seg);const caps = classifyArgs(args);if (!caps) return false;if (hasRedirect) caps.add("write:filesystem");for (const cap of caps) {if (!allowed.has(cap)) return false;}}return true;}
核心就是这 5 步流水线。
trim → split → parse → classify → check。每一层失败都直接 false(deny-by-default)。
deny-by-default 的三层防线。
第一层是「未识别命令 → 默认 deny」。classifyArgs 遇到不在 COMMAND_PROFILES 里的命令返回 null,引擎直接 false。
第二层是「任何 forbid 命中 → 加 cap」。参数扫描碰到 forbid 里的 flag 就往 caps 里加。哪怕 base 都在 allowed 里,加进来的 cap 不在 allowed 里也 false。
第三层是「段内任一 cap 不允许 → 整段 false」。一个段有 read:network(允许)和 write:filesystem(不允许),整段 false。
这三层防线的好处是规则简单。每条都是「不满足就拒绝」,不需要复杂的 reasoning。这就是 declarative vs imperative 的本质区别,命令不是被推理判断的,是被查询匹配的。
写到这里回头看,整个引擎最让我满意的设计是 COMMAND_PROFILES 表。它不是代码,是数据。
加新命令是加一条数据,不是改核心代码。改错了最多那条命令行为不对,不会波及其他命令。删命令是删一条数据,不会破坏引擎逻辑。
对比之前 npm 包那个 596 行 if/else,加新命令要在 if/else 链里加 case,改错了可能破坏既有判断,还得加测试覆盖。
这就是 declarative 模式的工程价值,也是我为什么觉得这种设计是「足够好、能维护、能演进」的方案。
实战踩坑:3 个发现的 bug 和修复
整个开发过程中我自己发现了 3 个 bug,挺有意思。
Bug 1: resolveProfile 在 stripGlobalFlags 之前执行
第一次写的时候,resolveProfile(cmd, args) 接收的是原始 args。对于 git -C /repo log,我直接把第一个非 flag 参数当作 subcommand。结果是 subcommand = "-C",触发了 dynamic profile 解析失败。
修复方案是把 resolveProfile 拆成两步
function resolveProfile(cmd, args): CommandProfile | null {// 1. 静态 profile(不依赖 subcommand)const staticProfile = COMMAND_PROFILES[cmd] ?? null;if (!staticProfile) return null;if (!staticProfile.dynamic) return staticProfile;// 2. 动态 profile(依赖 subcommand,subcommand 要从 stripGlobalFlags 之后的 args 取)const cleanedArgs = stripGlobalFlags(args);const subcommand = cleanedArgs[0];return staticProfile.dynamic(subcommand);}
stripGlobalFlags 把 -C / -c / --git-dir / --work-tree / --namespace / --no-pager 这些 git 全局 flag 剥掉,剩下的第一个 token 才是真正的子命令。
Bug 2: containsWriteRedirect 短路拦截
判断 shell 命令里有没有 > / >> 重定向时,我一开始写的是短路逻辑
if (containsWriteRedirect(segment)) {return { allowed: false, reason: "contains redirect" };}
问题来了。即使 allowed caps 包含 write:filesystem,短路逻辑也会拦截 echo hello > file。这意味着默认 Plan-Yolo 模式(不允许 write:filesystem)会拦截,但用户自定义的扩展模式(允许 write:filesystem)也会拦截。
修复方案是把短路换成 cap 检查
const caps = classifyArgs(cmd, args);if (containsWriteRedirect(segment)) {caps.add("write:filesystem");}if (!isCommandAllowed(caps, allowedCaps)) {return { allowed: false, reason: formatBlockReason(cmd, caps) };}
让重定向往 caps 里加 write:filesystem,再让正常的 cap 检查决定放不放行。这样如果用户配置了允许 write:filesystem,重定向就放行;否则拦截。
Bug 3: find -exec 内嵌命令静态分析
find . -exec rm {} ; 这种命令,我的引擎只能识别出 find 触发 execute:subprocess,无法静态分析 -exec 后面的 rm。这是个保守策略。理想方案是解析 -exec 后面的命令再走一遍 profile 表查 rm,但这复杂度爆炸,我没做。
折中方案是 execute:subprocess 直接进 deny set。Plan-Yolo 模式默认不允许 execute:subprocess,任何 -exec / --exec / bash -c 都拦。要用就得切回 build 模式。
测试覆盖:61 个 case
bash-policy.test.ts 跑下来 61 个 assertions 全过。覆盖了四个层面。
词法解析(10 个 case)。parseShellWords 处理 cmd "arg with space" / cmd 'arg' / cmd arg\ escaped / 失衡引号返回 null。splitShellSegments 处理 cmd1; cmd2 / cmd1 && cmd2 / cmd1 || cmd2 / cmd1 | cmd2 以及引号内的特殊字符不分割。
重定向检测(4 个 case)。echo > file 触发,echo ">" file 不触发(引号内的 > 不算),echo >> file 触发,echo 2>/dev/null 触发(stderr 重定向也是写)。
参数分类(13 个 case)。curl -X POST 触发 write:network,curl -X GET 不触发(覆盖默认),curl -d data 触发,curl -o file 触发 write:filesystem,curl -O 触发。find -exec 触发 execute:subprocess,find -delete 触发 write:filesystem。git log 静态走 read:git,git push 走 dynamic 触发 write:git。npm list 走 read:package,npm install 走 write:package。
端到端命令检查(34 个 case)。25 个允许的命令(包括 cat file / git log / curl URL / npm view xxx / ls -la / grep pattern file / head -n 10 file),36 个应该被拦截的命令(包括 rm -rf / / curl -X POST URL / git push origin master / npm install xxx / bash script.sh / python3 -c "import os" / ssh user@host)。
跑测试很简单
npx -y tsx src/plan-mode/bash-policy.test.ts# ✓ All bash-policy assertions passed (25 allowed + 36 blocked cases)
61 个 case 一次性过,让我对 capability 引擎有信心。后续加新命令时,加一条 profile,跑一遍测试,不会破坏既有 case。
一点更深的思考
聊到这里,我突然想起一个软件工程里的老话题,declarative vs imperative。
我之前的那个 npm 包是 imperative 的,每条命令一个 if/else 分支,每加一条命令就要在 if/else 链里加一个 case。这跟写一堆 if/else 处理业务逻辑是一样的,时间长了就是 spaghetti code。
我新的方案是 declarative 的,每个命令一个 profile,声明它的能力。引擎只负责 evaluate,不负责判断逻辑。
这个区别其实挺大的。
imperative 的写法,加新规则 = 改核心代码。你得保证改对了,不能破坏既有逻辑,还得加测试覆盖。
declarative 的写法,加新规则 = 加一条数据。引擎不动,改错了最多那条命令行为不对,不会波及其他命令。
这几年我自己的代码风格也在往 declarative 转。能用配置表达的就不用代码,能用数据表达的就不用逻辑。这不是说 imperative 不好,有些场景 imperative 更直接。但安全策略这种需要长期维护、又怕出错的场景,declarative 真的香。
Anthropic 写权限系统的时候也是这个思路,他们 classifier 的 prompt 是 template-based 的,固定部分处理解释工作,可变部分只有三个 slot,「信任的环境」「拦截的类别」「例外情况」。这个设计的好处是,默认配置大概率是对的,用户想自定义也容易,不用动核心 prompt。
挺值得借鉴的。
收尾
坦率的讲,我自己这个版本也不完美。
HTTP 方法的判断还是有漏的可能。比如 curl -G --data-urlencode "x=y" URL,这个 -G 把 POST 转成 GET,但带了数据。我的引擎没处理这个 case。
还有 find -exec 内嵌的命令,我现在的策略是只要看到 -exec 就拦截,比较保守。但 find . -exec cat {} ; 这种无害的命令也被拒了。理想方案是分析 -exec 后面的命令,但这复杂度就上来了,我没做。
shell 的边界情况更多。> 重定向我处理了,但 <() 进程替换、$(...) 命令替换、heredoc,这些我的解析器都不支持。碰到就 deny。这是个保守但安全的策略。
但起码比 596 行 if/else 好维护。
我估计未来还会再迭代一轮,把 capability 引擎做得更精细。比如:
~/.pi/agent/settings.json#plan-mode.allowedCapabilities,让用户能在不动代码的前提下调整 capability 白名单。但这次先把骨架立起来,核心安全边界和用户体验都有了,剩下的细节慢慢来。
有点感慨,软件工程这件事,没有银弹。每一代设计都在解决上一代的问题,又带来新的问题。重要的不是找到一个完美的方案,是找到一个「足够好、能维护、能演进」的方案,然后持续迭代。
我这次重写的 plan mode 插件就是这个思路。不追求完美,追求「让我能睡个好觉」。
以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~
谢谢你看你的文章,我们,下次再见。
🔗 项目地址:https://gitee.com/linbirg/pi-extentions.git
git clone https://gitee.com/linbirg/pi-extentions.git
作者:程序员学量化
夜雨聆风