25 命令工具谱系与声明式工具(CommandToolBase / 描述文件装配 / ExecTool 特殊形态)

摘要:本文提出将 ExecTool 与包装类工具统一为「命令工具谱系」,统一视角为「命令模板 + 可变部分」,安全强度由模板自由度决定。设计下沉执行内核 CommandExecKernel(全谱系共用),抽象基类 CommandToolBase(代码形态,骨架首 token 即白名单),并支持声明式工具(描述文件装配,格式定为 JSON)。五层防线按归属重排:白名单/黑名单前移至类定义期与装载期,操作符扫描与任意代码入口仅保留给 ExecTool,cwd/超时/截断下沉至内核。附带错误码 613/614、测试矩阵与测试先行的实施步骤(C1–C4)。

层归属:组件段(Ws\Http\NanoGpt\)。使用者:命令工具作者(内建/自定义)、声明式工具发布者(描述文件)、壳装配层。
背景:design/21 §8 的 ExecTool(五层防线)解决"能不能安全跑命令";本设计回答"命令能力该怎么组织"——评审发现(2026-09-21):ExecTool 与"包装类工具"做的是同一件事(受控跑白名单命令),差别只在约束施加时机,应当统一为一个谱系,而不是两类并存。
核心原则:安全强度 ∝ 模板自由度——命令骨架写死得越多、模型可填的自由度越小,安全保证越强。所有 shell 能力共用此谱系,审计者看一个维度即可理解全部。

简单路径(使用者加一个命令工具,两种形态各一行注册):

// 形态 A:代码(子类,~15 行)
$agent->tools()->register(new GrepTool());          // grep -rn {pattern} {path}

// 形态 B:声明(描述文件,零代码):放进 tools/ 目录即被装载
//   .work/tools/search-text.tool.yaml
//   name: search_text
//   command: grep -rn {pattern} {path}
//   arguments: { pattern: {...}, path: {...} }

简单路径使用者不需要知道:执行内核/骨架白名单提取/参数槽元字符拒绝/描述文件装载器——见 §2–§4。

1. 概念与边界

谱系模型(本设计的统一视角):一切“跑命令”的工具 = 命令模板 + 可变部分,安全强度由可变部分的自由度决定:

形态骨架可变部分安全保证示例
语义化命令工具(CommandToolBase 子类)子类模板写死参数槽(schema 约束 + 元字符拒绝)结构性安全(模型无法表达骨架外内容)GrepTool: grep -rn {pattern} {path}
描述文件工具(§4)yaml 的 command 字段同上(装载期与代码形态同源校验)同上search-text.tool.yaml
ExecTool(特殊形态)空(仅首 token 二进制名)其余全部(自由文本 cmd)尽力保证(五层防线补偿)探索性命令

不是(防过度设计):

  • 不做 Tool Store / 网络分发——发布的单位是"一个描述文件"(用户放进 tools/ 目录),不做包管理/签名/版本化/远端发现;触发条件:出现 ≥2 个真实跨项目复用场景再立项;
  • 不做 Skill(层 3,design/26 单独落需求;层 3 依赖本设计的声明式装载基建,接口形状在 §4.3 预留);
  • 不做参数槽的复杂类型系统(string/number/enum 即够;嵌套对象/条件 schema 不做);
  • 不做命令编排(管道/链式)——保持"一次一条简单命令"(与 ExecTool 既有语义一致)。

2. CommandExecKernel(执行内核,下沉)

从 ExecTool 抽出无 ToolInterface 的执行内核,被谱系全部形态复用:

namespace Ws\Http\NanoGpt;

final class CommandExecKernel
{
    /** @param string $cwd 工作目录(壳传 .runtime 草稿区) */
    public function __construct(string $cwd, int $timeout = 30, int $maxOutput = 2048);

    /**
     * 执行一条已通过上游校验的简单命令(无操作符——内核不再审,单一职责:执行)。
     * @return string "exit: N\noutput: ..." 形态(超时/截断语义与现 ExecTool 一致)
     */
    public function run(string $command): string;
    // proc_open / drain 到 EOF 取 exitcode(PHP 7.4 时序) / proc_terminate 超时 / 输出截断
}
  • 五层防线不进内核:内核只负责"把一条已被上游判定为合法的命令跑完"——防线归属见 §3/§5;
  • 现有 ExecTool 的执行体(exec/timeout/truncate 逻辑)原样迁移,行为不变(测试兜底)。

3. CommandToolBase(代码形态抽象基类)

namespace Ws\Http\NanoGpt\Tool;

abstract class CommandToolBase implements \Ws\Http\NanoGpt\ToolInterface
{
    /** 命令骨架:{slot} 占位;首 token 即骨架二进制(自动提取进白名单,子类不管) */
    abstract protected function template(): string;

    /**
     * 参数槽定义(模型只能填这些):
     * @return array<string, array{type: 'string'|'number', description: string, required?: bool, default?: string, enum?: string[]}>
     */
    abstract protected function arguments(): array;

    // —— 基类提供(子类免写) ——
    // jsonSchema(): 从 arguments() 自动生成(type/required/enum → JSON Schema)
    // execute(array $args):
    //   1. required 校验缺失 → error 字符串
    //   2. 槽位值元字符拒绝:含 空格引号/;|&$`>< 换行 → error(单槽位无法逃逸出骨架)
    //   3. enum 值域校验
    //   4. 骨架 + 槽位值拼装 → CommandExecKernel::run()
}
  • 参数槽 = 弱类型单值;含空格的参数值必须经模型先写文件再以路径引用(与 ExecTool 的"代码走脚本文件"同哲学);
  • 骨架首 token 自动提取:子类写错模板(二进制名即自由度)在类定义期暴露——Base 构造时校验首 token 不含元字符,且可被 Preset 层的 denyBinaries 二次拦截(黑名单贯穿谱系,§5)。

3.1 内建示例(对照 ExecTool 的能力集)

工具模板说明
GrepToolgrep -rn {pattern} {path}文本搜索(claude code 同款)
FindToolfind {path} -name {pattern}文件名查找
SedPrintToolsed -n {range}p {path}按行段打印(-n {range}p,非 -i 原地改;range 槽校验 ^\d+(,\d+)?$)

4. 声明式工具(描述文件装配)

4.1 描述文件格式与装载器

# .work/tools/search-text.tool.yaml(目录约定:tools/ 下 *.tool.yaml;--work 变更时随之)
name: search_text
description: Search text in workspace files
command: grep -rn {pattern} {path}
arguments:
  pattern:
    type: string
    description: Text pattern to search
    required: true
  path:
    type: string
    description: Directory or file to search
    default: "."
namespace Ws\Http\NanoGpt;

final class DescriptorToolLoader
{
    /** @param string $dir tools 目录(壳装配时传;运行时自动创建,常驻) */
    public function __construct(string $dir);

    /** 扫描 *.tool.yaml → 每个文件实例化一个 YamlCommandTool(CommandToolBase 的声明式实现) */
    public function loadAll(): array;   // @return ToolInterface[]

    // 校验与代码形态**同源**:name 冲突/模板首 token 元字符/槽位 schema 完整性
    // 非法文件 → AgentException 613(文件名进消息),跳过该文件继续其余(不整批失败)
}
  • 双形态同源:YamlCommandTool 内部就是把 yaml 字段喂给与 CommandToolBase 相同的校验/拼装路径——声明式不引入第二条安全实现(审计点单一);
  • yaml 解析:PHP ext-yaml 不可依赖(零依赖约束)→ 描述文件格式定为 JSON(扩展名 .tool.json,yaml 示例仅行文示例;JSON 是 PHP 原生 json_decode,且对"机器生成、人工可读"够用)。修订:格式 = JSON;
  • 装载时机:壳 bootstrap 时 loadAll() 逐个 register(name 冲突 → 613 提示哪个文件);
  • 发布/分享 = 复制这个 json 文件(触发条件到 Store 再升级,§1)。

4.2 与 FullPreset 的关系

  • FullPreset 增加可选注入:withCommandTools(array $tools)(代码形态批量)/ 装配层直接对 Agent::tools() register(描述文件形态由壳装载后注册);
  • ExecTool 的 withExec 语义不变(仍是显式授权 + 默认关);描述文件工具默认装载(它们结构性安全,与文件工具同级)——这是谱系原则的直接推论。

4.3 为 design/26(Skill)预留的接口形状(仅形状,不实现)

  • 描述文件装载器的扫描/校验/注册骨架与 skill 文件同构;skill 文件(command 槽换成 instruction 模板)复用同一装载基建、两种 schema——design/26 只需新增"渲染指令模板为 user 消息"的壳层逻辑;
  • Loader 命名与目录约定保留扩展位:tools/*.tool.json / skills/*.skill.json(本设计只实现前者)。

5. 五层防线的归属重排(评审结论,勿反复)

防线原 ExecTool 全有归属重排后
1 二进制白名单运行期查表类定义期(骨架首 token 即白名单);denyBinaries 黑名单贯穿谱系(Base 构造期 + 描述文件装载期拦截,rm 误写进模板同样拒)
2 兜底黑名单运行期同上(装载/构造期)
3 操作符扫描运行期(整条 cmd)仅 ExecTool(自由文本槽专属);CommandTool 槽位值元字符拒绝(含操作符)即覆盖
4 任意代码入口(-r/-e/-c)运行期 flag 表仅 ExecTool;CommandTool 骨架写死 flag,不存在此面
5 cwd 锁定/超时/截断运行期内核(CommandExecKernel,全谱系共用)

6. 错误码(600–699 追加)

含义
613描述文件非法(name 冲突/模板元字符/schema 不完整;消息含文件名)
614参数槽校验失败(缺失/enum 外/元字符;经工具返回字符串,不抛——仅装载期抛 613)

614 实为工具执行期返回值(错误字符串回填给模型),列在此处仅为编号登记;不新增异常路径。

7. 测试矩阵

用例组覆盖
CommandExecKernel正常执行/exit code(drain 时序)/超时 terminate/截断;迁移自 ExecTool 现有内核用例
CommandToolBasejsonSchema 自动生成(type/required/enum);required 缺失拒绝;槽位元字符拒绝(空格/引号/;/
内建示例工具GrepTool/FindTool/SedPrintTool 各一例真实执行(tmp 目录);SedPrintTool range 槽校验
DescriptorToolLoader合法文件装载(name/schema 正确);非法 JSON/模板元字符/name 冲突 → 613 且文件名在消息;多文件部分非法时其余照常装载;*.tool.json 通配
谱系贯穿denyBinaries 在 Base 构造期与装载期都拦截;ExecTool 作为 Base 子类后现有五层防线用例全部仍绿(回归)
装配FullPreset::withCommandTools;壳 bootstrap 装载 tools/ 目录;name 冲突提示

8. 实施步骤(测试先行)

#步骤交付物验证
C1内核下沉CommandExecKernel + ExecTool 改为其子类形态占位(行为不变)ExecTool 现有全部用例回归绿
C2谱系抽象CommandToolBase + GrepTool/FindTool/SedPrintToolBase/示例工具单测绿
C3声明式DescriptorToolLoader + YamlCommandTool(实为 JsonCommandTool)Loader 矩阵绿;双形态同能力对照用例
C4装配与整合FullPreset::withCommandTools + 壳 bootstrap 装载 + design/21 §8 状态指针更新 + design/26 需求文档(任务 #10)全量 phpunit + phpstan 绿;bin 冒烟(tools/ 装载路径)

风险与对策:① ExecTool 回归风险(重构动其执行路径)→ C1 行为不变原则 + 现有 ExecToolTest 全量回归;② 描述文件被误当 yaml(文档示例行文示例)→ 格式定 JSON 后统一扩展名与示例;③ 工具名与内建冲突 → 装载期 613(文件名报错),文档写明命名建议(动词_宾语)。

Logo

一站式 AI 云服务平台

更多推荐