25 命令工具谱系与声明式工具 - 自己做NanoGpt - ws-http
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 的能力集)
| 工具 | 模板 | 说明 |
|---|---|---|
| GrepTool | grep -rn {pattern} {path} | 文本搜索(claude code 同款) |
| FindTool | find {path} -name {pattern} | 文件名查找 |
| SedPrintTool | sed -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 现有内核用例 |
| CommandToolBase | jsonSchema 自动生成(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/SedPrintTool | Base/示例工具单测绿 |
| 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(文件名报错),文档写明命名建议(动词_宾语)。
更多推荐



所有评论(0)