---
slug: aiagent-tool
title: AiAgent 外部工具插件开发
titleEn: AiAgent External Tool Plugin Development
summary: 给 AI 渗透代理扩展一个可调用工具：tool.json 清单、exec/wasm 运行时、签名与热重载。
summaryEn: 'Extend the AI agent with a callable tool: tool.json manifest, exec/wasm runtimes, signing and hot reload.'
category: AiAgent
order: 60
enabled: true
updatedAt: "2026-10-05"
---
# AiAgent 外部工具插件开发

[[toc]]

本文面向**第三方开发者**，讲清如何为 TestSecScan 的 AI 渗透代理（后文简称 AiAgent）编写一个可被大模型调用的**外部工具**。读完你将能独立产出一个带清单、签名、可热重载、能跑在 `exec` 或 `wasm` 两种运行时上的工具包。

本文改版重点：**每个字段一个独立小节**（作用 / 类型与是否必填 / 传什么 / 最小 JSON 示例 / 写错的后果），**每种运行时一个完整可运行示例**，并新增「入口在哪：AiAgent 怎么发现、验签并把它交给模型」与「调试手册」两章。所有字段名、常量值、目录路径、签名算法均为双方约定的契约，并与实现**逐字核对**。

> [!IMPORTANT]
> 本节描述的规则由 AiAgent 与控制器两侧共同实现，`tool.json` / `sig.json` 的字段即双方约定的协议。
> 本文所有字段名、常量值、目录路径、签名算法均以该协议为准；若文档与实际实现冲突，**以实际行为为准**。

---

## 一、它是什么

AiAgent 是 AI 渗透代理：它把一组工具（function calling）暴露给大模型，由模型在渗透/审计过程中自主决定何时调用。内置工具（`http_request`、`browser_*`、`builtin_detect`、`report_vuln` 等）随二进制发布、不可增删；**外部工具**则是你自行投放的扩展技能。

一个外部工具 = 一个目录，目录里放三样东西：

| 组成 | 文件 | 作用 |
|---|---|---|
| 清单 | `tool.json` | 声明工具名、描述、入口、运行时、参数 schema、权限等级、超时 |
| 签名 | `sig.json` | 控制器对"整包摘要"的 Ed25519 签名，是唯一准入闸门 |
| 入口 | 清单 `entry` 指向的文件 | 真正被执行的程序（可带任意同目录依赖，如 DLL / 资源） |

工具加载后，AiAgent 把它当作一个普通的可调用工具塞进 AI 工具表：模型看到的是 `name` + `description`（拼上 `usage`）+ `parameters`（JSON Schema），调用时 AiAgent 把你的程序当子进程（或 WASI 模块）执行，把参数 JSON 写进它的 stdin，把它的 stdout 文本作为观察结果回传给模型。

**一句话记忆**：外部工具 = 「一个目录 + 一份清单 + 一次控制器签名」，AI 在工具表里像对待内置工具一样调用它。

### 1.1 一次调用的完整时序（先建立整体印象）

在细看字段之前，先看一次调用的全貌。下面的时序描述的是 AiAgent 执行外部工具时的**真实行为**：

1. 会话创建时，AiAgent 把内存注册表里已加载的工具转成模型可见的工具条目注入工具表；
2. 模型决定调用某工具，返回 `tool_call(name, arguments)`；
3. AiAgent 按名字在注册表里找到该工具；
4. 执行前 AiAgent **先复验签名**（防 TOCTOU）；
5. 把 `arguments`（一个 JSON 对象）序列化成字节；
6. 按 `runtime` 起子进程（`exec`）或实例化 WASI 模块（`wasm`）；
7. 参数 JSON 写入其 **stdin**；
8. 读取其 **stdout**（为空时回退 stderr）；
9. 超时 / 输出截断处理；
10. 文本回传模型作为 observation。

后面每一章都会回到这条链路上的某个环节。

### 1.2 安全模型（你必须理解，否则工具装不上）

四条硬约束，缺一条这个机制就等于没有：

1. **无签名不加载**：只有用**控制器当前公钥**验签通过的工具才会进入 AI 工具注册表。未签名/被篡改的目录**根本不进工具表** —— 模型看不到它，也就不会反复试探调用（这比"看到了但拒绝执行"体验好得多，后者会诱导模型绕路浪费轮次）。
2. **验签用宿主持有的控制器公钥**：绝不信任 `sig.json` 里自带的 `userPubkey`。否则你自造一对密钥、自签一个工具就能通过，等同无签名。
3. **每次调用前复验**：加载（验签）与真正执行之间存在时间窗（TOCTOU），有人可能在这段时间往已签名目录里塞一个恶意 DLL。因此 AiAgent 在每次执行前都会重算包摘要并复验签名。
4. **公钥只在内存**：控制器公钥仅来自 AES-GCM 认证通道，绝不落盘 —— 磁盘副本会被本地攻击者替换（换成自己的公钥后即可用自己签的恶意工具通过验签），而任何同机器同权限的 hash/HMAC 记录都挡不住这种替换。

签名对象是**整包摘要**，不是单个文件。算法（两侧逐字节一致）：

```text
h := sha256.New()
for _, f := range 目录内全部文件（相对路径按字节升序，排除 sig.json）:
    h.Write(斜杠分隔相对路径); h.Write("\n")
    h.Write(sha256hex(文件内容)); h.Write("\n")
digest = h.Sum(nil)   // 32 字节
```

因此多文件工具里**任何一个文件被替换/新增/删除都会改变摘要 → 验签失败**。

> [!NOTE]
> 公钥**只在内存**，来自控制器的 AES-GCM 认证通道，绝不落盘。代价是"AiAgent 刚启动、控制器还没连上"这段窗口里外部工具不可用（判为 `verify_failed`、不注册、执行也被拒）；控制器连上并下发公钥（`AiExternalToolSetKey`）后重扫即恢复。失败方向是"不可用"而不是"放行了别人的工具"，属于正确的 fail-closed。

---

## 二、与其它三端「插件」的区别

TestSecScan 是四层架构，不同层有不同的扩展点，名字都带"插件"但**机制完全不同**，不要混用：

| 端 | 扩展点 | 运行时 / 语言 | 载体 | 谁能投放 |
|---|---|---|---|---|
| **AiAgent** | **外部工具**（本文） | `exec`（原生可执行）或 `wasm`（WASI 模块） | `<AiConfig>/externaltools/<toolDir>/` | 第三方开发者，经控制器签名 |
| 控制器 | 控制器插件 | Go 原生（编译进控制器或动态加载） | `CtlConfig/` | 仅官方 / 控制器维护者 |
| 扫描节点 | 扫描应用插件 / 宿主函数 | 纯插件路线，经 `T.HttpUrl` 等宿主函数 | 节点插件目录 | 官方插件（如 `plugin-exttools`） |
| 扫描节点 | WASM POC 热加载 | `wasm`（wazero） | POC 商店 | 用户经 GUI 下发 |

**要点区分**：

- AiAgent 外部工具**不是** wasm 插件钩子：那说的是扫描节点上的 WASM POC / 插件宿主函数，跟 AiAgent 无关。
- AiAgent 外部工具**不是**控制器插件：控制器插件跑在控制器进程里、用 Go 编写、由官方维护；外部工具跑在 AiAgent 上、可以是任意语言编译出的可执行文件或 wasm 模块，由你自行投放。
- 外部工具**保持 AiAgent 纯 Go（`CGO_ENABLED=0`）**：所以不支持 `dlopen` 原生动态库。Go 的 `plugin` 包需要 CGo 且不支持 Windows，也不是这里的方案。你需要多文件依赖时，请用"入口 exe + 同目录 DLL，由 exe 自己加载"的 `exec` 形态，或改用 `wasm`。
- 与扫描节点的 WASM 签名机制**关键差异**：WASM 插件验签信任 `sig.json` 内嵌的 `userPubkey`；外部工具**强制**用控制器当前公钥验签，自造密钥对自签无法通过。WASM 签名对象是单个 wasm 字节流；外部工具签名对象是**整包摘要**（多 DLL 工具必须如此）。

---

## 三、目录结构与安装位置

### 3.1 安装位置

AiAgent 侧外部工具根目录：

```text
<AiConfig>/externaltools/
├── <toolDir>/            # 一个已安装的工具（正式目录）
│   ├── tool.json         # 清单（签名覆盖内容的一部分）
│   ├── sig.json          # 签名（唯一被摘要排除的文件）
│   └── <entry 及依赖>     # 入口文件 + 同目录 DLL / 资源
├── <toolDir2>/
└── .staging/             # 安装暂存目录（以 . 开头，扫描时跳过）
```

其中 `<AiConfig>` 是 AiAgent 的配置目录（位于其进程工作目录下）。控制器侧对应路径是 `<CtlConfig>/externaltools/`。

### 3.2 目录命名规则

`<toolDir>` 必须满足以下规则（控制器与 AiAgent 两侧口径一致）：

- 先 `strings.ToLower(strings.TrimSpace(name))` 转小写去空白；
- 长度 **3 ~ 64**；
- **首字符必须是 `a`~`z`**；
- 其余字符只允许 `[a-z0-9_]`。

非法示例：`ab`（太短）、`1abc`（数字开头）、`a-b`（连字符）、`a.b`（点号）、`../x`（路径穿越）。规范化后 `Nmap_Scan` → `nmap_scan`。

此外工具名不得与内置工具或保留前缀冲突（控制器会做保留名校验）：精确保留名有 `finish`、`report_vuln`、`http_request`、`browser_navigate`、`crawl_site`、`builtin_detect`、`builtin_payloads`、`batch_request`、`exttool_call`；保留前缀有 `browser_`、`file_`、`audit_`、`oob_`、`exttool_`。

> [!WARNING]
> 清单里的 `name` 与目录名**必须一致**（控制器下发时会强制把 `tool.json` 的 `name` 写成 `toolDir`；AiAgent 安装时会核对"载荷清单名 == 目录名"，不一致直接拒绝，报错为"载荷清单名(%s)与工具目录名(%s)不一致"）。这是为了避免 AI 调用时按名字找不到工具。

### 3.3 安装流程（先暂存 → 验签 → 原子改名）

你不必手动实现安装流程（走 GUI/控制器下发即可），但理解它有助于排错。**控制器侧**：

1. 接收 GUI 上传的 base64 工具包（单文件或 zip），按 `overwrite` 判断是否允许覆盖同名工具；
2. 解压/写入暂存目录 `<root>/.staging/<toolDir>-<随机后缀>/`；
3. 组装 `tool.json`（GUI 字段优先，缺项从包内既有 `tool.json` 继承，`name` 强制写成 `toolDir`，`entry` 可自动探测）；
4. **检查"仅大小写不同"的路径**，有冲突立即拒绝；
5. 原子改名到正式目录 `<root>/<toolDir>/`；
6. 用控制器私钥签名（`signSource` = `controller`），写入 `sig.json`；**签不了就删除正式目录**，不留未签名噪音；
7. 打包（排除 `sig.json`）后广播给所有在线 AiAgent。

**AiAgent 侧**：解压到它自己的 `.staging` → 检查大小写冲突 → 写入随包下发的 `sig.json` → **在暂存区内验签** → 校验包内 `tool.json` / `entry` 存在 → 原子改名进正式目录 → 重扫并重建注册表。

**关键顺序**：AiAgent 从来不"先落盘再验签" —— 未验签的工具绝不能短暂出现在扫描范围内。因此 `.staging` 以 `.` 开头，扫描时被跳过。

> [!IMPORTANT]
> AiAgent 侧验签之后**绝不改写包内任何文件**。包内 `tool.json` 就是签名覆盖的内容；如果用下发载荷里的清单重新序列化写回，字节可能与控制器写的不一致（字段归一化/缩进差异）→ 摘要失配 → 安装必然失败。所以**权威来源永远是包内的 `tool.json`**，下发载荷里的清单只用于日志与一致性核对。

---

## 四、`tool.json` 清单字段详解

清单结构体 `ExtToolManifest`（AiAgent 与控制器两侧字段/json 标签完全一致）：

```go
type ExtToolManifest struct {
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Usage       string         `json:"usage,omitempty"`
	Entry       string         `json:"entry"`
	Runtime     string         `json:"runtime"`
	Parameters  map[string]any `json:"parameters,omitempty"`
	Perm        int            `json:"perm"`
	TimeoutSec  int            `json:"timeoutSec,omitempty"`
	Version     string         `json:"version,omitempty"`
	Author      string         `json:"author,omitempty"`
	Args        []string       `json:"args,omitempty"`
	AddTime     string         `json:"addTime,omitempty"`
}
```

下面的每个字段都是一个独立小节，按「作用 → 类型与是否必填 → 传什么 → 示例 → 写错的后果」给出。

#### `ExtToolManifest`

`ExtToolManifest` 是 `tool.json` 映射的结构体，字段名即 `tool.json` 的键名（大小写敏感）。它一共 **12 个字段**，下面是**一份完整可复制的 `tool.json` 全文**，包含全部字段与真实取值：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测，返回开放状态与 banner 摘要。",
  "usage": "target 支持域名或 IP；port 为 1-65535 的整数。返回 JSON：{\"open\":true,\"banner\":\"...\"}。仅建立连接并读取少量 banner，不发送任何攻击载荷。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "target": { "type": "string", "description": "目标主机（域名或 IP）" },
      "port": { "type": "integer", "description": "TCP 端口号（1-65535）" }
    },
    "required": ["target", "port"]
  },
  "perm": 1,
  "timeoutSec": 30,
  "version": "1.0.0",
  "author": "security-team",
  "args": ["--json"],
  "addTime": "2026-10-05T10:00:00+08:00"
}
```

这份清单里每个字段的位置与作用一览（键名为大小写敏感的字面量）：

| 字段（json tag） | 在这份清单里的值 | 位置/作用 |
|---|---|---|
| `name` | `"port_probe"` | 顶层第 1 个键；工具名 = LLM function 名 = 目录名 |
| `description` | `"对目标主机的单个端口…"` | 顶层；模型据此决定何时调用 |
| `usage` | `"target 支持域名或 IP…"` | 顶层；拼进给模型的工具描述 |
| `entry` | `"port_probe.exe"` | 顶层；入口相对路径 |
| `runtime` | `"exec"` | 顶层；`exec` 或 `wasm` |
| `parameters` | 嵌套对象（含 `type`/`properties`/`required`） | 顶层；参数 JSON Schema |
| `perm` | `1` | 顶层；权限等级 0/1/2 |
| `timeoutSec` | `30` | 顶层；单次执行超时（秒） |
| `version` | `"1.0.0"` | 顶层；版本元数据 |
| `author` | `"security-team"` | 顶层；作者元数据 |
| `args` | `["--json"]` | 顶层；exec 固定命令行参数数组 |
| `addTime` | `"2026-10-05T10:00:00+08:00"` | 顶层；添加时间（RFC3339） |

> [!NOTE]
> `usage`、`parameters`、`timeoutSec`、`version`、`author`、`args`、`addTime` 是可省略项，`name`/`description`/`entry`/`runtime`/`perm` 为必填。无论是否省略，json 键名都必须按上表逐字书写（小驼峰，如 `timeoutSec`，**不是** Go 字段名 `TimeoutSec`）。

### 4.1 `name`

#### `name`

**作用**：工具名。它就是大模型 function calling 里的 **function 名**，也是 GUI 列表里显示的名字，同时**必须等于目录名**（AiAgent 会对它做归一化，注册时以它为注册键）。模型调用时用的就是这个名字。

**类型与是否必填**：`string`，**必填**（JSON 标签 `name`，无 `omitempty`）。缺少时 AiAgent 会用目录名兜底，但控制器下发/安装阶段会做强校验，因此不要省略。

**传什么**：归一化规则如下：

- 先 `strings.ToLower(strings.TrimSpace(name))`；
- 长度必须 **3 ~ 64**；
- **首字符必须是 `a`~`z`**；
- 其余字符只允许 `[a-z0-9_]`。

即只接受小写字母、数字、下划线，字母开头。`Nmap_Scan` 归一化后是 `nmap_scan`。

**示例**：一个最小清单，只有必填项时的完整长相：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec"
}
```

**写错的后果**：

- `name` 为 `ab`（<3）、`1abc`（数字开头）、`a-b`（连字符）、`a.b`（点号）、`../x`（越界）：规范化后为空串 → 安装/下发被拒；即便目录已存在，注册时也会回退成目录名，导致包内名字与目录名不一致的隐患。
- `name` 与目录名不一致：AiAgent 安装时核对不一致 → 报"载荷清单名与工具目录名不一致"，拒绝安装。
- `name` 与内置工具/保留前缀冲突（如 `http_request`、`browser_x`）：控制器会按保留名规则拒绝下发。
- 长度 > 64：归一化返回空串，被拒。

### 4.2 `description`

#### `description`

**作用**：工具用途的一句话描述。模型**依据它决定何时调用**这个工具。它会作为给模型看的工具描述的第一段。

**类型与是否必填**：`string`，**必填**（JSON 标签 `description`，无 `omitempty`）。控制器在下发与组装清单时都会显式校验：为空直接拒绝，报"工具描述不能为空（模型依赖描述决定何时调用）"。

**传什么**：一句完整、可判断触发条件的话。建议写明：做什么、对什么、返回什么、有哪些副作用边界。不要写"这是一个工具"这类无信息量的句子。

**示例**：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测，返回开放状态与 banner 摘要。",
  "entry": "port_probe.exe",
  "runtime": "exec"
}
```

**写错的后果**：

- 为空：控制器下发/安装直接失败（"工具描述不能为空"）。
- 描述含糊（如"处理目标"）：模型无法判断何时该调用，可能永不调用，或在不该调用时乱调。
- 描述与实际行为不符：模型会按描述误用工具（例如把写操作描述成"只读检查"），造成误判。

### 4.3 `usage`

#### `usage`

**作用**：使用方法补充。会被拼进给模型的工具描述，加在 `description` 之后，以 `【使用方法】` 段落呈现。适合写参数含义、取值范围、返回格式、注意事项。

**类型与是否必填**：`string`，**可选**（JSON 标签 `usage,omitempty`）。为空则工具描述里不出现 `【使用方法】` 段。

**传什么**：多行文本。通常写：每个参数怎么传、返回什么格式、有什么副作用、失败时返回什么。不要在这里重复 `parameters` 的 schema（schema 会单独给模型）。

**示例**：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "usage": "target 支持域名或 IP；port 为 1-65535 的整数。返回 JSON：{\"open\":true,\"banner\":\"...\"}。仅建立连接并读取少量 banner，不发送任何攻击载荷。",
  "entry": "port_probe.exe",
  "runtime": "exec"
}
```

**写错的后果**：

- 省略：模型只能靠 `description` 与 `parameters` 猜测用法，容易漏传可选参数或误解返回格式。
- 写成与 schema 矛盾的说明（例如 usage 说"port 必填"，schema 里却可选）：模型可能构造出工具无法处理的参数。
- 写太啰嗦：占用模型上下文，且 `description` 是每次请求都带上的固定开销。

### 4.4 `entry`

#### `entry`

**作用**：入口文件在工具目录内的**相对路径**。AiAgent 用它拼出工具目录内的绝对路径并执行 —— 这是工具真正被启动的那个文件。

**类型与是否必填**：`string`，**必填**（JSON 标签 `entry`，无 `omitempty`）。控制器在打包时会自动探测（见下），但**包内 `tool.json` 必须最终含有它**；AiAgent 安装时若为空报"工具包内 tool.json 未声明 entry（入口文件）"。

**传什么**：

- 必须是**相对路径**，用**正斜杠 `/`** 分隔（如 `bin/tool.exe`、`tool.wasm`）；
- 必须满足路径安全检查：拒绝空串、`.`、`..`、前导 `/` 或 `\`、绝对路径、盘符前缀（`C:/x`）、`a/../../b` 等越界；
- 该文件必须**真实存在于包内**且不是目录（`os.Stat` 校验）；
- 打包时若 GUI 未填且包内 `tool.json` 也没有，控制器会按运行时自动探测：`wasm` 候选 `entry.wasm`/`main.wasm`/`tool.wasm`/`plugin.wasm`，`exec` 候选 `entry.exe`/`tool.exe`/`main.exe`/`run.exe`/`start.exe`/`entry.bat`/`entry.cmd`/`entry.ps1`/`entry.sh`/`entry.py`；候选都不命中且包内**只有一个文件**时直接用那个文件。

**示例**：入口在子目录里的完整最小清单：

```json
{
  "name": "demo_tool",
  "description": "演示工具。",
  "entry": "bin/tool.exe",
  "runtime": "exec"
}
```

**写错的后果**：

- 写成绝对路径（`C:/tools/tool.exe`）或含 `..`：路径检查不通过 → 下发报"入口路径非法"，安装报"入口路径非法"。
- 写成 Windows 反斜杠 `bin\tool.exe`：控制器会归一化为斜杠，但手工写清单时仍建议用 `/`，避免歧义。
- 路径笔误/文件不在包内：控制器打包时报"入口文件不存在: %s"；AiAgent 安装时报"入口文件不存在: %s"；已装目录在重扫时因入口文件不存在而**不注册**（工具表里看不到）。
- 只写文件名却把入口放在子目录（如实际是 `bin/tool.exe` 却写 `tool.exe`）：入口不存在 → 不注册。

### 4.5 `runtime`

#### `runtime`

**作用**：运行时类型，决定 AiAgent 走哪条分支：`exec`（子进程）还是 `wasm`（wazero WASI 模块）。见第六章 ABI 精讲。

**类型与是否必填**：`string`，**可选**（JSON 标签 `runtime`，无 `omitempty`，但空值会被归一化）。默认 `exec`。

**传什么**：归一化规则：

- 转小写去空白后是小写 `wasm` **或别名 `wasi`** → `"wasm"`；
- 其它任何值（包括空串、拼错的值）→ `"exec"`。

合法取值只有两种：`exec` 与 `wasm`。记住 `wasi` 是 `wasm` 的别名。

**示例**：

```json
{
  "name": "ip_calc",
  "description": "解析一个 CIDR 并返回网络地址、掩码位与可用主机位数。",
  "entry": "tool.wasm",
  "runtime": "wasm"
}
```

**写错的后果**：

- 把 wasm 模块写成 `exec`（或漏写）：AiAgent 会把它当原生可执行文件直接执行 → 失败（不是一个可执行格式）。工具表里能看到工具，但一调用就报"外部工具执行失败"。
- 把原生 exe 写成 `wasm`：wazero 编译模块失败 → 调用报"编译 wasm 失败"。
- 拼错（如 `wasmm`、`EXEC `）：不会报错，静默落到默认 `exec`，可能不是你要的运行时。
- 用 `wasi`：等价于 `wasm`，可正常工作，但建议统一写 `wasm` 以免后人误解。

### 4.6 `parameters`

#### `parameters`

**作用**：参数 JSON Schema，会**原样**作为模型的 function `parameters`（AiAgent 在会话创建时直接把它转成模型可见的 schema）。模型据此构造入参，AiAgent 再把模型给的参数对象序列化成 JSON 写进工具的 stdin。这是"参数怎么传进去"的核心约定。

**类型与是否必填**：`map[string]any`，**可选**（JSON 标签 `parameters,omitempty`）。

**传什么**：标准 JSON Schema 对象，常见形态是 `{"type":"object","properties":{...},"required":[...]}`。若整个字段缺失或为空，AiAgent 会兜底给一个宽松对象：

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": true
}
```

同时给模型的描述里会追加一句"本工具未声明参数结构，请按【使用方法】中的说明构造字段。"

**示例**：带必填与类型说明的完整片段：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "target": { "type": "string", "description": "目标主机（域名或 IP）" },
      "port": { "type": "integer", "description": "TCP 端口号（1-65535）" }
    },
    "required": ["target", "port"]
  }
}
```

**写错的后果**：

- 缺失：宿主给宽松 schema，模型不知道有哪些字段，可能传一堆工具不认识的键，或漏传必需字段 → 工具解析失败。
- 结构写错（例如把 `properties` 写成数组）：本身不是合法 JSON Schema 时，模型侧行为不可预期，可能拒调或乱传。
- `required` 与工具实际读取的字段不一致：模型少传你实际用的字段，工具报参数解析失败。
- 描述里的字段名与代码读取的 `json` tag 不一致：模型传了 A，工具读 B，取到空值。
- 嵌套对象/数组没有描述：模型不知道内部结构（见示例三 `auth` 对象的写法）。

### 4.7 `perm`

#### `perm`

**作用**：权限等级。它直接决定 AiAgent 在模型调用时的行为：自动执行 / 高危提示 / 需人工确认。见 4.7.1 三档详解。

**类型与是否必填**：`int`，**可选**（JSON 标签 `perm`，无 `omitempty`，缺省即 0）。

**传什么**：只能是 `0` / `1` / `2`。AiAgent 的规范化规则：`p < 0` → `0`；`p > 2` → `2`；其余原样。也即越界会被夹到边界，不会报错。

- `0` → 完全访问，自动执行
- `1` → 高危提示，不阻塞
- `2` → 完全用户确认

**示例**：

```json
{
  "name": "upload_marker",
  "description": "向目标上传一个无害标记文件，验证文件上传漏洞（会写入目标，默认需确认）。",
  "entry": "bin/upload_marker.exe",
  "runtime": "exec",
  "perm": 2
}
```

#### 4.7.1 `perm` 三档在 GUI 与模型侧的表现

三档的具体语义与表现：

| `perm` | 语义 | AiAgent 行为 | GUI 表现 | 适用 |
|---|---|---|---|---|
| `0` | 完全访问 | 直接自动执行，不打扰 | 无特殊提示 | 只读探测（读页面、解析、计算） |
| `1` | 高危提示 | 执行前发一条 warn 事件**红色高亮提示 GUI，但不阻塞**，随后照常执行 | 收到 warn 事件，红色高亮 | 有外发副作用但不改目标状态（发包探测） |
| `2` | 完全用户确认 | 会改变目标状态的写操作，需 GUI 人工确认后才执行；**无人值守任务按 `UnattendedPolicy` 处理** | 弹出人工确认 | 写操作 / 利用类 |

> [!TIP]
> 外部工具是用户自带代码，**GUI 表单默认 `perm: 2`**（最保守）。请按工具的**实际副作用**设置：
> 只读探测用 `0`，有外发副作用但不改目标状态用 `1`，写操作/利用类用 `2`。
> 沙盒"只读模式"会**拒绝非 `0` 的工具**。

**写错的后果**：

- 只读工具错设 `2`：模型每一步调用都停下来等 GUI 确认（无人值守时按 `UnattendedPolicy` 处理，可能被打断或直接失败），体验极差，AI 还会因反复受阻而改道。
- 写操作错设 `0`：危险动作静默执行，绕过人工确认。
- 给个越界值（如 `9`、`-1`）：不会报错，被夹到 `2` / `0`，可能与你预期相反。

### 4.8 `timeoutSec`

#### `timeoutSec`

**作用**：单次执行超时（秒）。AiAgent 用它为本次执行设置超时上限：`exec` 超时是终止子进程，`wasm` 是关闭运行时上下文。

**类型与是否必填**：`int`，**可选**（JSON 标签 `timeoutSec,omitempty`）。默认 `60`。

**传什么**：规范化规则：

- `sec <= 0` → 默认 `60`；
- `0 < sec < 5` → 夹到 `5`；
- `sec > 600` → 夹到 `600`；
- 其余原样。

可用区间 `[5, 600]`。

**示例**：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "timeoutSec": 30
}
```

**写错的后果**：

- 漏写：用默认 60 秒；对慢速工具可能不够，对快速工具则浪费等待窗口。
- 设 `0` 或负数：同样落到默认 60。
- 设很小（如 `1`）：被夹到 5 秒，网络型工具极易超时。
- 设很大（如 `9999`）：被夹到 600 秒（10 分钟），一次卡死会占用 AI 会话很久。
- 执行超时后 `exec` 返回"外部工具执行超时（%ds），已终止。"并把 stderr 附上；`wasm` 返回"外部工具执行超时（%ds），已终止。"

### 4.9 `version`

#### `version`

**作用**：版本号，**纯元数据**。会随清单上报到控制器/GUI（在列表里可见版本号），不参与任何执行逻辑，也不参与签名算法本身（它作为 `tool.json` 的内容自然参与包摘要）。

**类型与是否必填**：`string`，**可选**（JSON 标签 `version,omitempty`）。

**传什么**：任意字符串，建议语义化版本，如 `1.0.0`。控制器组装清单时若 GUI 填了则用 GUI 的。

**示例**：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "version": "1.0.0"
}
```

**写错的后果**：

- 省略：可正常工作，但 GUI 列表里看不到版本，排障时难以区分新旧包。
- 改了 `version` 却没重新签名：`tool.json` 内容变了 → 包摘要变化 → 签名失效 → 调用时报"签名校验未通过"。（任何字段改动都会这样，不只是 `version`。）

### 4.10 `author`

#### `author`

**作用**：作者，**纯元数据**。随清单上报，不参与执行逻辑。

**类型与是否必填**：`string`，**可选**（JSON 标签 `author,omitempty`）。

**传什么**：任意字符串，建议写团队/个人标识或用例来源。

**示例**：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "author": "security-team"
}
```

**写错的后果**：

- 省略：功能不受影响，仅审计信息缺失。
- 与 `version` 一样，改动后必须重新签名，否则摘要失配。

### 4.11 `args`

#### `args`

**作用**：`exec` 模式追加的**固定命令行参数**。AiAgent 会把它们追加在入口程序之后作为命令行参数，每次调用都一样。

**类型与是否必填**：`string[]`，**可选**（JSON 标签 `args,omitempty`）。

**传什么**：字符串数组，每个元素是一个命令行参数（不要自己拼引号；数组元素之间由 exec 直接传递，不经 shell）。**只放固定参数**；动态参数必须从 stdin 的 JSON 读。

**示例**：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "args": ["--json", "--quiet"]
}
```

**写错的后果**：

- 把动态参数（如目标 IP）塞进 `args`：`args` 是静态的，无法引用本次调用的参数；工具收到的命令行参数永远是打包时写死的那份，模型传的真实参数被忽略。
- 同时依赖 `args` 与 stdin 但解析混乱：参数错位（工具以为第一个命令行参数是模式，实际是别的）。
- 在 `runtime: wasm` 上写 `args`：**不生效**。wazero 的模块 `argv[0]` 只是工具名，没有额外命令行参数。动态参数只能走 stdin。
- 参数里带空格/特殊字符：因为不进 shell，数组元素原样传递，一般安全；但工具内部若再自行拼接 shell 命令需自行转义。

### 4.12 `addTime`

#### `addTime`

**作用**：添加时间标记。控制器组装清单时在为空的情况下填入当前时间的 RFC3339 字符串。纯元数据，不参与执行逻辑。

**类型与是否必填**：`string`，**可选**（JSON 标签 `addTime,omitempty`）。

**传什么**：RFC3339 时间字符串，如 `"2026-10-05T10:00:00+08:00"`。手工打包可省略，由控制器自动补。

**示例**：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "addTime": "2026-10-05T10:00:00+08:00"
}
```

**写错的后果**：

- 省略：控制器打包时自动补当前时间，一般无害。
- 手工写死一个时间且未重新签名：改动导致摘要失配，签名失效（同上文所有字段）。
- 注意：若 AiAgent 侧重扫时不回写 `tool.json`，`addTime` 只在控制器打包时补；直接拷到节点目录的工具不会有自动补值。

### 4.13 完整 `tool.json` 示例

把上面各字段拼起来，一份信息齐全的清单长这样：

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测，返回开放状态与 banner 摘要。",
  "usage": "target 支持域名或 IP；port 为 1-65535 的整数。返回 JSON：{\"open\":true,\"banner\":\"...\"}。仅建立连接并读取少量 banner，不发送任何攻击载荷。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "target": { "type": "string", "description": "目标主机（域名或 IP）" },
      "port": { "type": "integer", "description": "TCP 端口号（1-65535）" }
    },
    "required": ["target", "port"]
  },
  "perm": 1,
  "timeoutSec": 30,
  "version": "1.0.0",
  "author": "security-team",
  "args": ["--json"],
  "addTime": "2026-10-05T10:00:00+08:00"
}
```

---

## 五、`sig.json` 签名文件字段详解

签名结构体 `ExtToolSigInfo`（AiAgent 与控制器两侧互为镜像）：

```go
type ExtToolSigInfo struct {
	ToolName      string `json:"toolName"`
	PackageHash   string `json:"packageHash"`
	UserPubkey    string `json:"userPubkey"`
	UserSignature string `json:"userSignature"`
	SignStatus    string `json:"signStatus"`
	SignSource    string `json:"signSource"`
	FileCount     int    `json:"fileCount"`
	SignTime      string `json:"signTime,omitempty"`
	SignNote      string `json:"signNote,omitempty"`
	SignPassState string `json:"signPassState,omitempty"`
}
```

> [!IMPORTANT]
> `sig.json` 由控制器签名后随包下发，**不要手工编辑**。改动它不会改变包摘要（它被摘要排除），但会破坏验签：`userSignature` 一旦被改就不再能对摘要验证通过。第三方开发者只需**理解**每个字段，不需要自己写。

下面的每个字段都是一个独立小节，按「作用 → 类型与是否必填 → 传什么 → 示例 → 写错的后果」给出。

#### `ExtToolSigInfo`

`ExtToolSigInfo` 是 `sig.json` 映射的结构体，字段名即 `sig.json` 的键名（大小写敏感）。它一共 **10 个字段**，下面是**一份完整可复制的 `sig.json` 全文**，包含全部字段与真实取值：

```json
{
  "toolName": "port_probe",
  "packageHash": "bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140",
  "userPubkey": "b3J5cHRvLWVkMjU1MTktcHVibGljLWtleS1iYXNlNjQ9=",
  "userSignature": "l7Yq3m...(base64 编码的 64 字节 Ed25519 签名)",
  "signStatus": "verified",
  "signSource": "controller",
  "fileCount": 4,
  "signTime": "2026-10-05T10:00:00+08:00",
  "signNote": "GUI 下发外部工具自动签名",
  "signPassState": ""
}
```

这份签名文件里每个字段的位置与作用一览（键名为大小写敏感的字面量）：

| 字段（json tag） | 在这份 sig.json 里的值 | 位置/作用 |
|---|---|---|
| `toolName` | `"port_probe"` | 顶层；冗余工具名，便于人工核对 |
| `packageHash` | `"bf3c6f85…2140"` | 顶层；包摘要 sha256 hex（**参与验签**） |
| `userPubkey` | base64 公钥 | 顶层；签发者公钥（**仅记录，不用于验签**） |
| `userSignature` | base64 签名 | 顶层；控制器私钥对摘要的 Ed25519 签名（**参与验签**） |
| `signStatus` | `"verified"` | 顶层；`verified`/`verify_failed`/`unsigned` |
| `signSource` | `"controller"` | 顶层；`controller`/`manual` |
| `fileCount` | `4` | 顶层；参与摘要的文件数 |
| `signTime` | `"2026-10-05T10:00:00+08:00"` | 顶层；签名时间（RFC3339） |
| `signNote` | `"GUI 下发外部工具自动签名"` | 顶层；签名场景备注 |
| `signPassState` | `""` | 顶层；授权方式备注（**不记录口令本身**） |

> [!NOTE]
> `signTime`、`signNote`、`signPassState` 是可省略项，其余 7 个字段为必填。json 键名一律小驼峰（`packageHash`、`userSignature`、`signPassState`），**不要**写成 Go 字段名（`PackageHash`、`UserSignature`、`SignPassState`）。

### 5.1 `toolName`

#### `toolName`

**作用**：冗余工具名，便于人工核对。控制器签名时从 `tool.json` 的 `name` 填入；补签名时也用清单名（缺失回退 `toolDir`）。**不参与验签计算**。

**类型与是否必填**：`string`，必填（JSON 标签 `toolName`，无 `omitempty`），但它只是元数据，缺失不影响验签判定。

**传什么**：工具名，与 `tool.json` 的 `name` 一致。

**示例**：

```json
{
  "toolName": "port_probe"
}
```

**写错的后果**：

- 写错名字：验签仍可通过（不参与计算），但人工核对/GUI 展示会误导。
- 与 `tool.json` 不一致：签名照常生效，但审计时无法对应。

### 5.2 `packageHash`

#### `packageHash`

**作用**：**包摘要的 sha256 十六进制**（32 字节摘要 → 64 个十六进制字符）。验签的第一步就是把它与实际算出的摘要做比对（大小写不敏感）。这是整包签名算法的关键产物。

**类型与是否必填**：`string`，必填。**参与验签**。

**传什么**：按规定算法算出的 64 位小写十六进制（比对时用 `EqualFold`，大小写不敏感，但规范输出是小写）。

**示例**：

```json
{
  "packageHash": "bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140"
}
```

**写错的后果**：

- 与实际目录摘要不符：判为 `verify_failed`（工具不注册，调用时也被拒），报"签名校验未通过"。
- 目录内文件被增删改（最常见：往已签名目录里塞了额外 DLL）：摘要变化 → 与 `packageHash` 不符 → `verify_failed`。
- 长度不是 64：补签名时的长度校验会失败（"节点上报的包摘要非法"）。

### 5.3 `userPubkey`

#### `userPubkey`

**作用**：签发者公钥（控制器本地公钥 base64），**仅作记录**。AiAgent 验签时**显式接收**宿主持有的控制器公钥，**绝不使用这个字段**。

**类型与是否必填**：`string`，必填（JSON 标签 `userPubkey`）。不参与本地验签判定。

**传什么**：控制器 Ed25519 公钥的 base64（32 字节）。签名时写入控制器公钥；补签名时写入本次验签用的控制器公钥 base64。

**示例**：

```json
{
  "userPubkey": "b3J5cHRvLWVkMjU1MTktcHVibGljLWtleS1iYXNlNjQ9="
}
```

**写错的后果**：

- 攻击者把自己伪造的 `userPubkey` 写进 `sig.json`：**无效**。AiAgent 只用宿主持有的控制器公钥验签，自签公钥被忽略 → `verify_failed`。这正是安全模型第 2 条要挡的攻击。
- 写错但不影响验签：因为该字段不参与判定，只误导人工核对。

### 5.4 `userSignature`

#### `userSignature`

**作用**：控制器私钥对**包摘要原始字节**的 Ed25519 签名（base64）。验签时用控制器公钥对包摘要做 Ed25519 验证。**这是唯一真正决定放行的字段之一**。

**类型与是否必填**：`string`，必填。**参与验签**。若为空，直接判 `unsigned`。

**传什么**：64 字节 Ed25519 签名（对包摘要原始字节签名）的 base64 标准编码。

**示例**：

```json
{
  "userSignature": "l7Yq3m...（base64，64 字节签名，通常 88 字符）"
}
```

**写错的后果**：

- 为空：判 `unsigned`（未签名），不注册，需 GUI 授权补签名。
- 被改一个字节 / 用别的密钥签：`ed25519.Verify` 失败 → `verify_failed`。
- 用自造密钥对签（自签）：用控制器公钥验签必失败 → `verify_failed`。

### 5.5 `signStatus`

#### `signStatus`

**作用**：冗余的签名状态字符串。**注意：AiAgent 验签时不读它**，而是现场计算并把结果填入上报条目。`sig.json` 里这个字段主要是记录签动作当时的状态。

**类型与是否必填**：`string`，必填（JSON 标签 `signStatus`）。不参与本地验签判定。

**传什么**：三态之一：

- `verified`：签名验证通过；
- `verify_failed`：签名验证不通过；
- `unsigned`：无签名。

**示例**：

```json
{
  "signStatus": "verified"
}
```

**写错的后果**：

- 手工把 `signStatus` 改成 `verified`：**没用**。状态由现场重算，`verify_failed` 的工具不会因为把这个字段写成 `verified` 而放行。
- 写了个非法值：不影响判定（不读它），但 GUI 展示可能异常。

### 5.6 `signSource`

#### `signSource`

**作用**：签名来源，记录是谁在什么场景签的。计算后写入上报条目，GUI 展示。

**类型与是否必填**：`string`，必填（JSON 标签 `signSource`）。不参与验签判定。

**传什么**：两值之一：

- `controller`：控制器自动签名（GUI 下发 / 用户授权自动签名）；
- `manual`：手工投放的 `sig.json`。

**示例**：

```json
{
  "signSource": "controller"
}
```

**写错的后果**：

- 手工投放的工具写 `manual` 但用控制器公钥无法验签：仍会被判 `verify_failed`（来源标注不改变验签结果）。
- 写错值：仅影响审计与展示。

### 5.7 `fileCount`

#### `fileCount`

**作用**：参与摘要的文件数（排除 `sig.json`）。上报条目会用它；若为 0，重扫时会尝试实时重算填充。

**类型与是否必填**：`int`，必填（JSON 标签 `fileCount`）。不参与验签判定（摘要由内容决定，不由数量决定）。

**传什么**：整数，等于目录内除 `sig.json` 外的文件数。例如一个含 5 个文件（含 `sig.json`）的目录，参与摘要的 `fileCount = 4`。

**示例**：

```json
{
  "fileCount": 4
}
```

**写错的后果**：

- 数值与实际不符：验签不受影响（数量不参与摘要），但 GUI 展示的文件数与实际不符，排障时可能被误导。
- 目录为空：摘要计算直接报"工具目录为空（无任何文件）"，验签判 `verify_failed`。

### 5.8 `signTime`

#### `signTime`

**作用**：签名时间（RFC3339），可选元数据。签名时写入当前时间的 RFC3339 字符串；补签名时同样写当前时间。该值会随上报条目展示。

**类型与是否必填**：`string`，**可选**（JSON 标签 `signTime,omitempty`）。不参与验签判定。

**传什么**：RFC3339 字符串，如 `"2026-10-05T10:00:00+08:00"`。

**示例**：

```json
{
  "signTime": "2026-10-05T10:00:00+08:00"
}
```

**写错的后果**：

- 省略：不影响验签与执行，仅审计信息缺失。
- 写错格式：不影响验签（不参与计算），但 GUI 展示可能解析失败。

### 5.9 `signNote`

#### `signNote`

**作用**：签名场景备注，可选。由签名动作的调用方传入；控制器下发路径默认写"GUI 下发外部工具自动签名"，授权补签名路径默认写"用户授权自动签名（GUI 输入签名口令）"。用于事后审计"是谁在什么场景签的"。

**类型与是否必填**：`string`，**可选**（JSON 标签 `signNote,omitempty`）。不参与验签判定。

**传什么**：任意说明文本。补签名时会把授权过程里的备注落盘到这里。

**示例**：

```json
{
  "signNote": "GUI 下发外部工具自动签名"
}
```

**写错的后果**：

- 省略：验签、执行都不受影响，仅审计追溯能力下降。
- 注意：它同样属于 `sig.json` 内容，**修改 `sig.json` 不影响包摘要**（被排除），所以改它不会导致验签失败 —— 但它也不改变验签结果。

### 5.10 `signPassState`

#### `signPassState`

**作用**：授权方式备注，**不记录口令本身**，可选。用于记录"这次补签名是通过口令授权完成的"这类状态说明。

**类型与是否必填**：`string`，**可选**（JSON 标签 `signPassState,omitempty`）。不参与验签判定。

**传什么**：只放"状态/方式"描述文本（如 `已通过签名口令授权`），**绝不能**放口令原文或口令哈希。签名口令由控制器单独保管（加盐哈希存储，不落明文），与 `sig.json` 无关。

**示例**：

```json
{
  "signPassState": "已通过签名口令授权"
}
```

**写错的后果**：

- 把口令明文写进这个字段：严重安全隐患，且会随包广播到所有节点与 GUI。**永远不要这样做**。
- 省略：不影响验签与执行。

### 5.11 完整 `sig.json` 示例

一份完整的签名文件长这样（由控制器写出，缩进 2 空格）：

```json
{
  "toolName": "port_probe",
  "packageHash": "bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140",
  "userPubkey": "b3J5cHRvLWVkMjU1MTktcHVibGljLWtleS1iYXNlNjQ9=",
  "userSignature": "l7Yq3m...(base64, 64 字节 Ed25519 签名)",
  "signStatus": "verified",
  "signSource": "controller",
  "fileCount": 4,
  "signTime": "2026-10-05T10:00:00+08:00",
  "signNote": "GUI 下发外部工具自动签名",
  "signPassState": ""
}
```

### 5.12 整包摘要的确切算法

`packageHash` 的计算规则（AiAgent 与控制器两侧**逐字节一致**）：

1. 遍历目录内**全部文件**（跳过目录，跳过 `sig.json`）；
2. 每个文件的相对路径转斜杠分隔，并做路径安全检查；
3. **按相对路径字符串字节升序全局排序**；
4. 对每个文件：写入 `相对路径` + `"\n"`，再写入 `sha256hex(文件内容)` + `"\n"`；
5. 对整个写入流做一次 SHA-256，得到 32 字节摘要；
6. `packageHash = hex.EncodeToString(digest)`。

伪代码：

```text
h := sha256.New()
for _, f := range 目录内全部文件（相对路径按字节升序，排除 sig.json）:
    h.Write([]byte(f.rel)); h.Write([]byte("\n"))
    h.Write([]byte(hex(sha256(f.content)))); h.Write([]byte("\n"))
digest = h.Sum(nil)   // 32 字节
```

去重边界：

- `sig.json` 自身**不参与**摘要（否则签名无法自洽）；
- 空目录（无任何参与文件）返回错误"工具目录为空（无任何文件）"；
- 文件数 > **512** 返回错误；
- 单文件 > **32MB** 返回错误。

> [!WARNING]
> **全局排序是承重结构**。普通目录遍历只保证每个目录内按字典序，递归进入子目录会打乱全局顺序（例如 `a/b.txt` 会排在 `a.txt` 之前）。两侧若有一侧漏掉全局排序，摘要就不一致、所有工具都装不上。

固定对照值（可用于核对你的实现是否一致）。对照目录为：

```text
tool.json      = {"name":"demo_tool"}
bin/tool.exe   = MZ-fake-exe
bin/lib.dll    = fake-dll
bin/helper.dll = helper
sig.json       = {"shouldBeIgnored":true}   # 被排除
```

正确摘要（`fileCount = 4`）：

```text
bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140
```

### 5.13 验签用谁的公钥

AiAgent 验签时**显式接收**宿主持有的控制器公钥，**绝不使用 `sig.json` 自带的 `userPubkey`**。判定分支：

| 情形 | 状态 | 结果 |
|---|---|---|
| 无 `sig.json`，或 `userSignature` 为空 | `unsigned` | 不注册，需用户在 GUI 授权后由控制器补签名 |
| 有签名但宿主尚无控制器公钥 | `verify_failed` | 不放行（宁可不可用，也不无凭据放行） |
| `packageHash` 与实际摘要不符 | `verify_failed` | 目录内文件被增删改（典型=塞了额外 DLL） |
| `userSignature` 对摘要验签不通过 | `verify_failed` | 非本机控制器密钥签发（自签在此被拒） |
| 摘要一致且签名验签通过 | `verified` | 可注册给 AI 调用 |

### 5.14 三分支的处理路径

- `verified`：正常注册，模型可调用。
- `verify_failed`：被拒，通常是"加载后被改动"或"非本机控制器密钥签发"。重新下发或用 GUI 授权补签名。
- `unsigned`：需用户显式授权。授权后由控制器补签名：
  - 工具在**控制器本地**：控制器直接对目录重算摘要并签名，随后广播给各 AiAgent；
  - 工具在**远端 AiAgent**：控制器读不到字节，故节点先上报 `(toolDir, packageHash)`，控制器在口令校验通过后签发"摘要签名 + 授权令牌 + 过期时间"三元组下发（指令 `AiExternalToolSignGrant`）。节点**三步全过**才落盘 `sig.json`：
    1. 授权令牌由控制器**为本 `toolDir` + 本摘要 + 本过期时间**签发且未过期（令牌原文 `TestSecScan-ExtTool-Sign-v1|<toolDir>|<hash>|<expire>`，两侧逐字一致）；
    2. 本地重算摘要必须与授权里的 `packageHash` 一致（防授权后文件被替换）；
    3. 签名对摘要验签有效。

> [!NOTE]
> 授权令牌绑定"工具名 + 摘要 + 过期时间"三元组，有效期 **10 分钟**。这样"控制器授权签名"不会被挪用为任意内容的签名预言机：截获一次授权也只能给当时那一份内容补签名，且很快失效。

### 5.15 开发者如何自测签名

外部工具**必须由控制器的私钥签名**（自签会被拒），所以第三方开发者不能离线"自己造一个能通过的签名"。实践中推荐两条路：

1. **官方流程（推荐）**：在装了控制器的开发环境里，打开 GUI「AI 渗透代理 → 外部工具调用」tab：
   - 走**下发**：上传工具包（单文件或 zip），控制器解压 → 写 `tool.json` → **下发即自动签名** → 广播全部在线 AiAgent；
   - 走**补签名**：把工具目录直接拷进 AiAgent 的 `<AiConfig>/externaltools/<toolDir>/`，AiAgent 扫描后上报为 `unsigned`，GUI 启动后会汇总未签名项并在你输入**签名口令**后由控制器补签名（口令最短 6 位）。
2. **自查包摘要**：签名是否自洽，先看摘要对不对。用与两侧实现逐字一致的算法本地算一遍：

```go
// 与两侧摘要算法逐字节一致
func digest(dir string) (string, int, error) {
	type e struct{ rel, path string }
	var es []e
	err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return err
		}
		rel, _ := filepath.Rel(dir, p)
		rel = filepath.ToSlash(rel)
		if rel == "sig.json" { // 签名文件自身不参与
			return nil
		}
		es = append(es, e{rel: rel, path: p})
		return nil
	})
	if err != nil {
		return "", 0, err
	}
	sort.Slice(es, func(i, j int) bool { return es[i].rel < es[j].rel }) // 全局排序承重！
	h := sha256.New()
	for _, it := range es {
		data, _ := os.ReadFile(it.path)
		sum := sha256.Sum256(data)
		h.Write([]byte(it.rel)); h.Write([]byte("\n"))
		h.Write([]byte(hex.EncodeToString(sum[:]))); h.Write([]byte("\n"))
	}
	return hex.EncodeToString(h.Sum(nil)), len(es), nil
}
```

自测步骤（分步）：

1. 在工具目录里**先删掉 `sig.json`**（它不参与摘要，但避免混淆）；
2. 用上面的 `digest(dir)` 算出 hex；
3. 与 `sig.json` 里的 `packageHash` 比对（`EqualFold`，大小写不敏感）；
4. 若不一致，说明打包后目录内容变了 —— 常见原因：多打了一个文件、`tool.json` 被编辑器重新格式化、行尾从 LF 变成 CRLF、多了隐藏文件；
5. 一致则摘要自洽，剩下的交给控制器验签（自造密钥签名一定失败，不要尝试）。

> [!WARNING]
> Windows 上文件遍历/读取会对同一个文件给出确定内容，但**换行符/编码差异**（CRLF vs LF，BOM）会让 `sha256hex(内容)` 不同 → 摘要不同。分发前请用 GUI 下发流程自带的打包方式生成 zip，不要用会改写文本内容的工具重新保存文件。

---

## 六、运行时 ABI 精讲

AiAgent 保持纯 Go（`CGO_ENABLED=0`，可交叉编译，无 CGo），因此运行时只有两种：

| 运行时 | `runtime` 值 | 执行方式 | 适用场景 |
|---|---|---|---|
| 原生可执行 | `exec` | 入口文件当**子进程**执行 | 任意语言编译的原生程序；多 DLL 工具 |
| WASI 模块 | `wasm` | wazero 加载 WASI 命令模块 | 免平台依赖、想沙箱化的工具 |

### 6.1 统一 ABI：stdin 进 / stdout 出

两种运行时**共用同一套 ABI**（stdin/stdout 行协议思路一致），你不必学习新的宿主函数集：

- **输入**：AiAgent 把参数对象序列化成 JSON，写入你的进程/模块的 **stdin**。若模型没给参数（或序列化失败），宿主会写 `{}`。
- **输出**：你把结果**文本**写到 **stdout**，AiAgent 捕获后作为观察结果回传模型。
- **回退**：`exec` 中若 stdout 为空白而 stderr 非空，则改用 stderr 的内容（有些工具习惯把结果写 stderr，宿主不丢弃）；`wasm` 同理（stdout 空则用 stderr）。
- **退出码非 0**（`exec`）视为失败，返回"外部工具执行失败: <err>\n<stderr>"。
- 输出超过 **64KB** 会被截断并追加 `\n...[输出过长已截断]`。

**宿主实际发过来的 JSON 长什么样**：就是模型填的 `arguments` 对象，字段来自你在 `parameters` 里声明的 schema。例如 `parameters` 声明了 `target` 与 `port`，模型调用时宿主写给你的 stdin 就是：

```json
{"target":"10.0.0.5","port":22}
```

**你要输出什么**：纯文本即可，会成为模型看到的 observation。推荐输出**结构化 JSON 或一行摘要**，便于模型解析，也避免超 64KB 被截断。

### 6.2 `exec` 细节

- 入口文件被**直接执行**（**不经过 shell**）；
- **工作目录固定为工具目录**，因此相对路径依赖（同目录 DLL、配置、资源）能被入口程序自行找到 —— 多 DLL 工具就是"入口 exe + 同目录 DLL，由 exe 自己加载"；
- `m.Args`（清单里的 `args`）作为固定命令行参数追加在可执行文件之后；
- 显式提供 stdin，防止子进程读到父进程终端输入；
- 超时 → 返回"外部工具执行超时（%ds），已终止。"，并把 stderr 附上；
- 退出码非 0 → 返回"外部工具执行失败: " + err + "\n" + stderr；
- 解压落盘权限是 `0644`（不默认给可执行位）。Windows 无执行位概念；Linux 上若你的工具依赖可执行位，请注意这点。

### 6.3 `wasm` 细节

- 用 wazero 执行 WASI 命令模块（`wasi_snapshot_preview1`），超时会关闭上下文并终止模块；
- **工具目录挂载到 `/tool`**，你可以读取随包自带的资源；
- 模块的 `argv[0]` 是工具名，**没有额外命令行参数**（`args` 字段在 wasm 下不生效）；
- stdin 写参数 JSON，stdout 读结果文本。

**读取自带资源文件的完整写法**：工具目录 `ip_calc/` 里放一个 `cidr_note.txt`，模块内这样读：

```go
b, err := os.ReadFile("/tool/cidr_note.txt")
if err == nil {
	note = string(b)
}
```

路径**必须是 `/tool/<相对路径>`**，例如工具目录下有 `data/dict.txt` 就读 `/tool/data/dict.txt`。访问其它路径（如 `/etc/passwd`、`/tmp`）会失败，因为只挂载了 `/tool`。

### 6.4 超时与输出截断

- 超时取规范化后的 `timeoutSec`：默认 `60` 秒，区间 `[5, 600]`；`<=0` 取默认，`<5` 夹到 `5`，`>600` 夹到 `600`。
- 回传给模型的输出上限 **64KB**，超出截断并追加 `\n...[输出过长已截断]`。

### 6.5 端到端最小示例（`exec`）：`sha256_text`

这是**完整可运行**的最小 exec 工具，专门演示 ABI。

**目录规划**：

```text
sha256_text/
├── tool.json
└── sha256_text.exe
```

**`main.go` 全文**：

```go
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
)

// 与 tool.json 的 parameters 一致：宿主把该 JSON 写入 stdin
type request struct {
	Text string `json:"text"`
}

type response struct {
	Sha256 string `json:"sha256"`
	Length int    `json:"length"`
	Error  string `json:"error,omitempty"`
}

func main() {
	var req request
	if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
		fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err)
		os.Exit(2)
	}
	if req.Text == "" {
		fmt.Fprintln(os.Stderr, "text 不能为空")
		os.Exit(2)
	}
	sum := sha256.Sum256([]byte(req.Text))
	out, _ := json.Marshal(response{Sha256: hex.EncodeToString(sum[:]), Length: len(req.Text)})
	fmt.Println(string(out)) // 结果写 stdout，宿主回传模型
}
```

**`tool.json` 全文**：

```json
{
  "name": "sha256_text",
  "description": "计算一段文本的 SHA-256 十六进制摘要与字节长度。纯计算，无网络、无文件副作用。",
  "usage": "text 为待计算的文本，UTF-8 编码。返回 JSON：{\"sha256\":\"...\",\"length\":3}。",
  "entry": "sha256_text.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "text": { "type": "string", "description": "待计算的文本" }
    },
    "required": ["text"]
  },
  "perm": 0,
  "timeoutSec": 10,
  "version": "1.0.0",
  "author": "third-party"
}
```

**构建命令**：

```bash
# Windows
set CGO_ENABLED=0
go build -o sha256_text.exe .

# Linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o sha256_text .
```

**放置路径**：把 `sha256_text/`（`tool.json` + 编译产物）打包 zip 走 GUI 下发，或直接拷到 `<AiConfig>/externaltools/sha256_text/` 后走补签名。

**端到端说明**：模型看到 `description`/`usage`/`parameters`，构造 `{"text":"hello"}` 调用 `sha256_text`；AiAgent 起子进程，工作目录为工具目录，stdin 收到 `{"text":"hello"}`，stdout 输出 `{"sha256":"2cf24dba...","length":5}`，模型拿到该 observation。

### 6.6 端到端最小示例（`wasm`）：`res_reader`

这是**完整可运行**的最小 wasm 工具，专门演示 `/tool` 资源读取。

**目录规划**：

```text
res_reader/
├── tool.json
├── tool.wasm
└── note.txt          # 随包资源（演示 /tool 挂载读取）
```

**`main.go` 全文**：

```go
package main

import (
	"encoding/json"
	"fmt"
	"os"
	"strings"
)

type request struct {
	Name string `json:"name"`
}

func main() {
	var req request
	_ = json.NewDecoder(os.Stdin).Decode(&req)
	if req.Name == "" {
		req.Name = "world"
	}

	// 工具目录被挂载到 /tool，可读取随包自带的资源
	note := ""
	if b, err := os.ReadFile("/tool/note.txt"); err == nil {
		note = strings.TrimSpace(string(b))
	}

	// 结果文本写 stdout 即被宿主回传
	fmt.Printf("hello %s | note=%s\n", req.Name, note)
}
```

**`tool.json` 全文**：

```json
{
  "name": "res_reader",
  "description": "读取随包自带的 note.txt 并拼一行问候，用于演示 WASI 沙箱内的 /tool 资源读取。",
  "usage": "name 为可选称呼。返回一行文本：hello <name> | note=<内容>。",
  "entry": "tool.wasm",
  "runtime": "wasm",
  "parameters": {
    "type": "object",
    "properties": {
      "name": { "type": "string", "description": "称呼（可选，默认 world）" }
    }
  },
  "perm": 0,
  "timeoutSec": 10
}
```

**构建命令**：

```bash
GOOS=wasip1 GOARCH=wasm go build -o tool.wasm .
```

**放置路径**：`res_reader/`（`tool.json` + `tool.wasm` + `note.txt`）打成 zip 下发，或拷入 `<AiConfig>/externaltools/res_reader/`。

**端到端说明**：模型用 `{"name":"testsec"}` 调用 `res_reader`；wazero 实例化模块（`argv[0]` = `res_reader`），stdin 收到该 JSON，模块从 `/tool/note.txt` 读到资源，stdout 返回 `hello testsec | note=<内容>`，宿主回传模型。因为是 WASI 模块，跨平台无需改编译目标；模块无系统调用权限，能读到的只有挂载到 `/tool` 的自带资源。

---

## 七、入口在哪：AiAgent 怎么发现、验签并把它交给模型

这一章把"从磁盘目录到模型可调用工具"的完整链路拆成分步。看懂它，你就知道工具为什么"装了却看不见"、为什么"改一下文件就报签名问题"。

### 7.1 完整链路（分步）

1. **目录落点**：工具放在 `<AiConfig>/externaltools/<toolDir>/`。`<toolDir>` 必须满足命名规则（小写字母开头、3~64、`[a-z0-9_]`）。以 `.` 开头的目录（如 `.staging`）会被扫描跳过。
2. **触发重扫**：以下任一时机会重扫目录并重建内存注册表与磁盘实况清单（无需重启 AiAgent）：
   - AiAgent 启动（此时内存公钥为空 → 已签名工具暂判 `verify_failed`、不注册）；
   - 安装/覆盖工具包；
   - 删除工具（幂等：目录不存在也回成功）；
   - 控制器下发签名公钥（**换钥后旧签名立刻重判 `verify_failed`**）；
   - 授权补签名；
   - 控制器要求刷新（`AiExternalToolQuery`）→ 重扫 + 上报清单。
3. **读清单 + 算摘要 + 验签**：对每个目录：读 `tool.json`（不存在记 `error="缺少 tool.json"`）→ 计算包摘要 → 用**控制器公钥**验签（`sig.json` 自带的 `userPubkey` **不被信任**；宿主还没有控制器公钥时一律判 `verify_failed`）。
4. **只有 `verified` 才进注册表**：注册条件是三重齐备 —— 签名状态为 `verified` **且** 清单存在 **且** 无错误 **且** `entry` 非空、安全、在目录内、真实存在。否则只进入磁盘实况清单，不注册。
5. **把它变成模型可见的工具**：会话创建时，AiAgent 把每个已加载工具转成模型可调用的工具条目：
   - 工具名 = 归一化后的 `name`；
   - 描述 = `description` + 可选 `【使用方法】` + `【参数】…` + `【返回】…` + wasm 时 `【运行时】…`；
   - 参数 = 你的 schema（无则宽松对象）；
   - 权限 = `perm` 归一化后的等级；
   - 该条目带 `external=true` 与 `toolDir`；
   - 调用时执行闭包捕获工具目录与清单，走调用前复验。
   工具名按字典序排序，保证模型看到的工具表顺序稳定。
6. **模型侧呈现**：AiAgent 把工具名 / 描述 / 参数转成 OpenAI tools 格式。**外部工具不受技能白名单裁剪**——技能库不可能预知用户自建工具的名字，被裁掉等于功能不可见。
7. **`perm` 影响确认**：`0` 自动执行；`1` 先发 warn 事件红色高亮但不阻塞；`2` 需 GUI 人工确认（无人值守按 `UnattendedPolicy`）。
8. **模型调用时的时序**：
   1. 模型返回 `tool_call(name, arguments)`；
   2. 执行前**再验一次签名**（仍用宿主持有的控制器公钥）——这是 TOCTOU 防护，加载后被改动会在此被拦；
   3. 校验 `entry` 路径安全且文件存在；
   4. `args` 序列化成 JSON（失败/为空写 `{}`）；
   5. 起子进程（`exec`）或实例化 wasm（`wasm`）；
   6. 参数 JSON 写 stdin → 读 stdout（空则回退 stderr）；
   7. 超时 / 输出截断；
   8. 输出回传模型。
   - 调用时若签名失效，返回"外部工具 [name] 签名校验未通过（status），已拒绝执行。该工具可能在加载后被修改，请在 GUI 中重新下发或授权签名。"并记 warn 日志。
9. **改文件后签名失效 → 需要有意的"重新签名 + reload"流程**：签名覆盖整个目录，改动包内任何文件（包括重新编译入口）都会让摘要变化、签名失效。这不是 bug，而是安全模型的必然结果。正确处置：重新走一次**下发**（控制器自动签名后广播，节点装回并重扫）或 **GUI 授权补签名**（授权通过后控制器补签名 → 节点落盘并重扫）。不必重启 AiAgent。

### 7.2 行为总览：安装 → 加载 → 调用 → 上报

下面按「安装 → 加载 → 调用 → 上报」四个阶段描述 AiAgent 的行为：

- **安装**：接收工具包 → 在暂存区完成验签与内容校验 → 原子改名进正式目录 → 重扫；
- **加载**：遍历工具目录 → 读清单、算包摘要、用控制器公钥验签 → 只有 `verified` 的目录进入模型可见的工具表；
- **调用**：模型点名调用 → 执行前复验签名 → 按 `runtime` 起子进程或 wasm 模块 → stdin 入 / stdout 出 → 超时与输出截断处理；
- **上报**：安装、删除、换钥、补签名、刷新等动作之后，把磁盘实况清单上报控制器与 GUI。

### 7.3 AiAgent 侧指令处理

| 命令 | 方向 | AiAgent 的行为 |
|---|---|---|
| `AiExternalToolInstall` | 控制器→AiAgent | 安装工具包 → 回 `AiExternalToolInstallAck` + 上报清单 |
| `AiExternalToolDelete` | 控制器→AiAgent | 删除工具 → 回执 + 上报 |
| `AiExternalToolSetKey` | 控制器→AiAgent | 更新内存中的控制器公钥 → 重扫并上报 |
| `AiExternalToolSignGrant` | 控制器→AiAgent | 补签名三步校验 → 落盘 `sig.json` → 重扫并上报 |
| `AiExternalToolQuery` | 控制器→AiAgent | 重扫 + 上报清单 |
| 清单主动上报 | AiAgent→控制器 | 上报清单（GUI 未签名告警依赖，让 GUI 看到"可能是恶意工具"） |
| `AiExternalToolInstallAck` | AiAgent→控制器 | 安装/删除回执（控制器转发 GUI） |

每次处理完都会上报一次清单，使控制器/GUI 的展示与节点实际状态保持一致。

### 7.4 关键差异：注册表 vs 磁盘实况

| 视图 | 来源 | 含未签名项？ | 用途 |
|---|---|---|---|
| AI 工具注册表（模型可见） | 内存中的已加载工具集合 | **否**（仅 `verified`） | 模型 function calling |
| 上报/目录清单 | 重扫得到的磁盘实况 | **是** | GUI 展示签名状态、`unsigned` 告警 |
| 技能库合并上报 | 注册表条目 + 磁盘实况去重 | 是 | 技能库页展示 |

> [!NOTE]
> 合并上报里，注册表条目的签名状态恒为 `verified`（"能进注册表的必已验签通过"）；磁盘实况清单里才可能出现 `unsigned` / `verify_failed`。外部工具条目字段：`external=true`、`toolDir`、`signStatus`、`runtime`、`usage`。

---

## 八、热重载

AiAgent 会重扫根目录、重建内存注册表与磁盘实况清单，**无需重启 AiAgent**。

触发时机：

| 触发 | 行为 |
|---|---|
| AiAgent 启动 | 重扫（此时内存公钥为空 → 已签名工具暂判 `verify_failed`、不注册） |
| 安装/覆盖工具包 | 自动重扫 |
| 删除工具 | 自动重扫（幂等：目录不存在也回成功） |
| 控制器下发签名公钥 | 自动重扫（**换钥后旧签名立刻重判 `verify_failed`**） |
| 授权补签名 | 自动重扫 |
| 控制器要求刷新 | 重扫 + 上报清单（`AiExternalToolQuery`） |

> [!IMPORTANT]
> **"改代码 → 重新验签 → reload"是有意设计的流程**：签名覆盖整个目录，你改动包内任何文件（包括重新编译入口）都会让摘要变化、签名失效。这不是 bug，而是安全模型的必然结果 —— 改动过的东西必须重新签名才被信任。开发时改完代码，重新走一次下发或补签名即可，不必重启 AiAgent。

---

## 九、限制与配额

| 项 | 限额 | 说明 |
|---|---|---|
| 工具包（zip）上限 | 64MB | 下发前按 base64 后的体积还会做熔断（控制器按 96MB 限流） |
| 单文件上限 | 32MB | 解压时按"上限+1"读取，超限报错而不是静默截断 |
| 文件数上限 | 512 | 目录 / zip 内文件数 |
| 回传模型输出上限 | 64KB | 超出截断并追加 `\n...[输出过长已截断]` |
| 工具名长度上限 | 64 | 同时也是模型侧 function 名上限 |
| 默认超时 | 60s | 可被 `timeoutSec` 覆盖，区间 `[5,600]` |
| 授权令牌有效期 | 10 分钟 | 到期即失效，需重新授权 |
| 签名口令最短长度 | 6 位 | 仅控制器侧，用于自动签名授权 |
| 授权令牌签名域 | `TestSecScan-ExtTool-Sign-v1` | 令牌原文 `scope|toolDir|hash|expire` |

**路径安全**：包内相对路径必须通过安全检查 —— 拒绝空路径、`.`、`..`、前导 `/` 或 `\`、绝对路径、盘符前缀（`C:/x`）、`a/../../b` 越界。解压时还有第二道闸：判断拼接后的实际落点是否仍在目标目录内，并防 Zip Slip。

> [!CAUTION]
> **仅大小写不同的路径会被拒绝**。Linux/macOS 上 `bin/Lib.dll` 与 `bin/lib.dll` 可以共存，但 Windows 解压时二者会**互相覆盖**只剩一个 → 节点上的文件集与签名内容不同 → 摘要失配 → 节点报"验签失败"（真正原因是跨平台大小写差异）。安装前的路径大小写冲突检查会拒绝并给出明确原因，请重命名后重新打包。注意：该检查不处理 Unicode 归一化（NFC/NFD）造成的同形路径。

---

## 十、完整示例

下面三个示例均可直接运行。示例中的源码放在**包根**，`tool.json` 放在同一层，按清单的 `entry` 指定入口。

### 10.1 示例一：Go 原生可执行工具（`exec`）

**目录规划**

```text
port_probe/
└── tool.json          # 清单
    port_probe.exe     # 入口（编译产物）
```

**`main.go`**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net"
	"os"
	"time"
)

// 参数对象与 tool.json 的 parameters 一致：宿主把该 JSON 写入 stdin
type request struct {
	Target string `json:"target"`
	Port   int    `json:"port"`
}

type response struct {
	Open   bool   `json:"open"`
	Banner string `json:"banner"`
	Error  string `json:"error,omitempty"`
}

func main() {
	var req request
	if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
		// 出错时写 stderr；宿主在 stdout 为空时会回退读取 stderr
		fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err)
		os.Exit(2)
	}
	if req.Target == "" || req.Port <= 0 || req.Port > 65535 {
		fmt.Fprintln(os.Stderr, "target/port 非法")
		os.Exit(2)
	}

	addr := fmt.Sprintf("%s:%d", req.Target, req.Port)
	conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
	if err != nil {
		out, _ := json.Marshal(response{Open: false, Error: err.Error()})
		fmt.Println(string(out)) // 结论仍写 stdout，让模型拿到结构化结果
		return
	}
	defer conn.Close()

	banner := make([]byte, 128)
	_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
	n, _ := conn.Read(banner)
	out, _ := json.Marshal(response{Open: true, Banner: string(banner[:n])})
	fmt.Println(string(out))
}
```

**`tool.json`**

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测，返回开放状态与 banner 摘要。",
  "usage": "target 支持域名或 IP；port 为 1-65535 的整数。返回 JSON：{\"open\":true,\"banner\":\"...\"}。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "target": { "type": "string", "description": "目标主机（域名或 IP）" },
      "port": { "type": "integer", "description": "TCP 端口号（1-65535）" }
    },
    "required": ["target", "port"]
  },
  "perm": 1,
  "timeoutSec": 30,
  "version": "1.0.0",
  "author": "security-team"
}
```

**编译命令**

```bash
# Windows（在包目录内执行）
set CGO_ENABLED=0
go build -o port_probe.exe .

# Linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o port_probe .
```

**放置路径**：把 `port_probe/`（含 `tool.json` 与编译出的 `port_probe.exe`）打包为 zip，经 GUI 下发；或直接拷到 `<AiConfig>/externaltools/port_probe/` 后走补签名。目标是 Windows 节点就编译 `.exe`，是 Linux 节点就编译无扩展名的 ELF —— **`exec` 工具必须与宿主同平台/同架构**。

**模型会怎么调它**：模型读到 schema 后构造如下参数：

```json
{
  "target": "10.0.0.5",
  "port": 22
}
```

**你会收到什么**：子进程工作目录为 `port_probe/`，stdin 收到上面的 JSON。

**你要输出什么**：stdout 输出 `{"open":true,"banner":"SSH-2.0-OpenSSH_8.9"}`，宿主回传模型；模型据此判断端口开放与指纹。

### 10.2 示例二：Go WASI wasm 工具（`wasm`）

**目录规划**

```text
ip_calc/
├── tool.json          # 清单
├── tool.wasm          # 入口（WASI 编译产物）
└── cidr_note.txt      # 随包资源（演示 /tool 挂载读取）
```

**`main.go`**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net"
	"os"
)

type request struct {
	CIDR string `json:"cidr"`
}

func main() {
	var req request
	_ = json.NewDecoder(os.Stdin).Decode(&req)

	// 工具目录被挂载到 /tool，可读取随包自带的资源
	note := ""
	if b, err := os.ReadFile("/tool/cidr_note.txt"); err == nil {
		note = string(b)
	}

	_, ipnet, err := net.ParseCIDR(req.CIDR)
	if err != nil {
		fmt.Fprintf(os.Stderr, "CIDR 非法: %v\n", err)
		os.Exit(2)
	}
	ones, bits := ipnet.Mask.Size()
	// 结果文本写 stdout 即被宿主回传
	fmt.Printf("网络=%s 掩码位=%d/%d 主机位=%d 备注=%s\n",
		ipnet.IP.String(), ones, bits, bits-ones, note)
}
```

**`tool.json`**

```json
{
  "name": "ip_calc",
  "description": "解析一个 CIDR 并返回网络地址、掩码位与可用主机位数。",
  "usage": "cidr 形如 10.0.0.0/24。返回一行文本；随包附带的 cidr_note.txt 会一并回显。",
  "entry": "tool.wasm",
  "runtime": "wasm",
  "parameters": {
    "type": "object",
    "properties": {
      "cidr": { "type": "string", "description": "CIDR 字符串，如 10.0.0.0/24" }
    },
    "required": ["cidr"]
  },
  "perm": 0,
  "timeoutSec": 10
}
```

**编译命令**

```bash
GOOS=wasip1 GOARCH=wasm go build -o tool.wasm .
```

**放置路径**：`ip_calc/`（`tool.json` + `tool.wasm` + `cidr_note.txt`）打成 zip 下发，或拷入 `<AiConfig>/externaltools/ip_calc/`。

**模型会怎么调它**：

```json
{
  "cidr": "10.0.0.0/24"
}
```

**你会收到什么**：wasm 模块被实例化（`argv[0]` = `ip_calc`），stdin 收到上面 JSON，`/tool/cidr_note.txt` 可读。

**你要输出什么**：stdout 返回 `网络=10.0.0.0 掩码位=24/32 主机位=8 备注=...`。因为是 WASI 模块，跨平台无需改编译目标；模块无系统调用权限，读到的只是挂载到 `/tool` 的自带资源。

> [!TIP]
> 需要用 Go 标准库里的网络/文件能力时注意：WASI 沙箱只挂载了 `/tool`，写入或访问其他路径会失败。需要联网、发原始包的工具请用 `exec`。

### 10.3 示例三：带参数 schema 且 `perm: 2`（需用户确认）

**目录规划**

```text
upload_marker/
├── tool.json
└── bin/
    └── upload_marker.exe
```

**`main.go`**（要点在"读嵌套参数 + 写操作 + 结论写 stdout"）

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"mime/multipart"
	"net/http"
	"os"
	"time"
)

type auth struct {
	Cookie string `json:"cookie"`
	Bearer string `json:"bearer"`
}

type request struct {
	URL    string `json:"url"`
	Field  string `json:"field"`
	Marker string `json:"marker"`
	Auth   auth   `json:"auth"`
}

func main() {
	var req request
	if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
		fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err)
		os.Exit(2)
	}
	if req.Field == "" {
		req.Field = "file"
	}

	var body bytes.Buffer
	mw := multipart.NewWriter(&body)
	fw, _ := mw.CreateFormFile(req.Field, "marker.txt")
	_, _ = fw.Write([]byte(req.Marker))
	_ = mw.Close()

	httpReq, _ := http.NewRequest("POST", req.URL, &body)
	httpReq.Header.Set("Content-Type", mw.FormDataContentType())
	if req.Auth.Cookie != "" {
		httpReq.Header.Set("Cookie", req.Auth.Cookie)
	}
	if req.Auth.Bearer != "" {
		httpReq.Header.Set("Authorization", "Bearer "+req.Auth.Bearer)
	}
	client := &http.Client{Timeout: 20 * time.Second}
	resp, err := client.Do(httpReq)
	if err != nil {
		fmt.Fprintf(os.Stderr, "上传失败: %v\n", err)
		os.Exit(1)
	}
	defer resp.Body.Close()
	fmt.Printf("上传完成：HTTP %d（marker=%s）\n", resp.StatusCode, req.Marker)
}
```

**`tool.json`**（注意嵌套对象 schema 与 `perm: 2`）

```json
{
  "name": "upload_marker",
  "description": "向目标上传一个无害标记文件，验证文件上传漏洞是否可被利用（会在目标写入文件，默认需用户确认）。",
  "usage": "url 为上传接口；field 为文件表单字段名（默认 file）；marker 为写入内容，便于事后清理定位；auth 为可选认证信息。返回一行文本说明是否上传成功，不返回目标响应体全文。",
  "entry": "bin/upload_marker.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "url": { "type": "string", "description": "上传接口完整 URL" },
      "field": { "type": "string", "description": "文件表单字段名（默认 file）" },
      "marker": { "type": "string", "description": "写入目标的标记内容" },
      "auth": {
        "type": "object",
        "description": "认证信息（可选）",
        "properties": {
          "cookie": { "type": "string", "description": "Cookie 头原文" },
          "bearer": { "type": "string", "description": "Bearer Token" }
        }
      }
    },
    "required": ["url", "marker"]
  },
  "perm": 2,
  "timeoutSec": 45,
  "version": "1.0.0",
  "author": "security-team"
}
```

**编译命令**

```bash
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/upload_marker.exe .
```

**放置路径**：`upload_marker/`（`tool.json` + `bin/upload_marker.exe`）打包下发；因入口在 `bin/` 子目录，`entry` 必须写相对路径 `bin/upload_marker.exe`（斜杠分隔）。

**模型会怎么调用**：模型读到 schema 后，会构造如下参数并调用 `upload_marker`；注意它把可选对象 `auth` 展开为嵌套 JSON：

```json
{
  "url": "https://target.example.com/api/upload",
  "field": "file",
  "marker": "testsecscan-upload-check",
  "auth": { "cookie": "SESSION=abc123" }
}
```

**你会收到什么**：因为 `perm: 2`，AiAgent 先按"完全用户确认"处理 —— 在 GUI 弹出人工确认（无人值守任务按 `UnattendedPolicy`），确认后才执行；stdin 收到上述 JSON。

**你要输出什么**：stdout 返回 `上传完成：HTTP 200（marker=testsecscan-upload-check）`。

> [!CAUTION]
> `perm` 设置不当会直接影响体验：把只读探测错设为 `2`，会让 AI 每一步都停下来等确认（无人值守时更会被策略打断）；把写操作错设为 `0`，则会静默执行危险动作。请按实际副作用严格选择。

---

## 十一、调试手册

### 11.1 三类日志在哪看

| 层面 | 位置 | 看什么 |
|---|---|---|
| AiAgent 日志 | AiAgent 进程日志 | 扫描汇总 `[AiAgent] external tool scan: %d dir(s), %d registered (signature verified)`；安装 `installed (name=… runtime=… entry=…)`；删除；补签名 `signed by controller grant (packageHash=…)`；调用被拒 `refused at call time: signature status=%s`；公钥同步 `controller signing pubkey synced` |
| GUI 外部工具列表 | GUI「AI 渗透代理 → 外部工具调用」tab | 每个工具的 `signStatus`（`verified`/`verify_failed`/`unsigned`）、`packageHash`、`fileCount`、`runtime`、`entry`、`error`；控制器本地项与各在线 AiAgent 上报项；`unsigned`/`verifyFailed` 汇总告警；`signPub`、`keyIntegrity` |
| 控制器下发结果回执 | GUI 下发/删除/授权的返回 | 下发结果（`success`/`toolDir`/`packageHash`/`fileCount`/`agents`/`message`）；节点安装回执（`success`/`signStatus`/`error`）；删除结果；自动签名结果（`signed`/`failed`/`results[]`） |

### 11.2 五分钟最小验证流程

1. **写工具**：新建目录 `<toolDir>/`，写 `tool.json` 与 `main.go`（可照抄第六章最小示例）；
2. **构建**：`exec` 用 `CGO_ENABLED=0 go build -o <entry> .`；`wasm` 用 `GOOS=wasip1 GOARCH=wasm go build -o tool.wasm .`；
3. **自测签名**：本地按 5.12 算法算 `packageHash`，与稍后控制器写出的 `sig.json` 比对，确认打包未改动内容；
4. **放到目录**：拷入 `<AiConfig>/externaltools/<toolDir>/`（远端节点）或走 GUI 下发（推荐，自动签名）；
5. **触发 reload/查询**：GUI 点刷新，或控制器发 `AiExternalToolQuery`；
6. **在 GUI 看签名状态**：确认该项为 `verified`；若 `unsigned` 则输入签名口令授权补签名；若 `verify_failed` 看 `error`；
7. **让 AI 调用一次**：在 AI 会话里让模型调用该工具（或直接构造一次调用）；
8. **看回传**：查看模型收到的 observation 是否为你的 stdout；若报"签名校验未通过"说明加载后被改动。

### 11.3 「症状 → 原因 → 解决」速查表

| # | 症状 | 原因 | 解决 |
|---|---|---|---|
| 1 | AI 工具表里**看不到**工具 | 未验签通过（`unsigned`/`verify_failed`）、缺 `tool.json`、`entry` 不存在、工具名非法 | GUI 看签名状态与 `error`；补签名或修清单/入口 |
| 2 | 有 `sig.json` 但仍不注册/被拒 | **自签公钥不被信任**：验签只用控制器公钥，`sig.json` 的 `userPubkey` 被忽略 | 走控制器下发或 GUI 口令授权补签名，不要自造密钥 |
| 3 | 装好后能看见，一调用就拒绝 | **加载后被改动**（TOCTOU）：调用前复验发现摘要不符 | 重新下发或授权补签名；检查是否有人往目录塞了 DLL |
| 4 | 报"入口文件不存在: X" | `entry` 指向的文件不在包内/已删/路径写错 | 核对 `entry` 与实际文件；子目录要带路径（`bin/tool.exe`） |
| 5 | 报"仅大小写不同的路径" | 包内 `bin/Lib.dll` 与 `bin/lib.dll` 共存，Windows 解压互相覆盖 | 重命名冲突文件后重新打包 |
| 6 | 报"工具包超过 64MB 上限" / "文件超过 32MB" / "文件数超过上限 512" | 超过对应限额（见第九章） | 精简依赖、分离大资源、拆分工具 |
| 7 | 执行被终止并报"执行超时（60s）" | 超过 `timeoutSec`（默认 60，范围 5~600） | 提高 `timeoutSec`（≤600）或优化工具；`exec` 终止进程，`wasm` 关闭上下文 |
| 8 | 结果看似丢失或混乱 | 结果写到了 **stderr** | 结果写 stdout；stdout 为空时宿主会回退读 stderr，但 stdout 优先，建议显式走 stdout |
| 9 | 输出被截断并追加"输出过长已截断" | 回传超过 **64KB**（见第九章限额表） | **只回传关键结论**（结构化 JSON / 一行摘要），大日志写文件不 dump 到 stdout |
| 10 | 子进程找不到 DLL/资源/配置 | **工作目录不是工具目录**的误解 | `exec` 的工作目录**固定为工具目录**，用相对路径即可；DLL/资源与入口放同一目录 |
| 11 | 模型反复试探/每步都停 | `perm` 设太高（`2`=完全确认，或只读工具错设 `2`） | 只读探测改 `perm: 0`；有副作用但不改目标状态用 `1` |
| 12 | 模型乱传参/漏传必填项 | `parameters` 缺失或 schema 与代码读取字段不一致 | 补全 `parameters`（含 `required`），字段名与代码 `json` tag 对齐 |
| 13 | wasm 调用报"编译 wasm 失败" | `runtime` 写错（原生程序当 wasm），或 wasm 目标不是 WASI 命令模块 | 用 `GOOS=wasip1 GOARCH=wasm` 编译；原生工具写 `exec` |
| 14 | wasm 读不到资源 | 只有 `/tool` 被挂载 | 资源放工具目录内，用 `/tool/...` 访问；不要访问其它路径 |
| 15 | Linux 上 `exec` 报 permission denied | 解压落盘为 `0644`（不默认给可执行位） | 检查入口权限；必要时改用 `wasm`；Windows 无此问题 |
| 16 | 下发被拒"工具名与内置工具或保留前缀冲突" | 名字撞保留名/前缀 | 改名避开 `finish`/`report_vuln`/`http_request`/`builtin_*` 及前缀 `browser_`/`file_`/`audit_`/`oob_`/`exttool_` |
| 17 | 下发/安装被拒"清单名与目录名不一致" | `tool.json` 的 `name` 与 `<toolDir>` 不同 | 二者必须一致；控制器下发会自动把 `name` 写成 `toolDir` |
| 18 | 所有工具突然全 `verify_failed` | 控制器**换钥**后旧签名失效，或节点尚无控制器公钥 | 重新签名；确认控制器已连上并下发公钥（`AiExternalToolSetKey`） |

### 11.4 命令行手工模拟一次调用

不必启动 AI 会话，也能验证工具的 ABI：

```bash
# exec：把你的参数 JSON 直接喂给入口程序
cd /path/to/externaltools/sha256_text
echo '{"text":"hello"}' | ./sha256_text.exe

# 期望输出（stdout）
# {"sha256":"2cf24dba...","length":5}
```

要点：

- **参数 JSON 通过 stdin 传入**（管道即可，注意用单引号避免 shell 转义）；
- 输出直接看 stdout；若为空再看 stderr（宿主在 stdout 为空时回退读 stderr）；
- 检查退出码：`echo $?`（Linux）或 `echo %errorlevel%`（Windows）—— 非 0 会被宿主判为执行失败；
- wasm 工具不能用这条命令（需 WASI 运行器，如 `wasmtime run --dir .::/tool tool.wasm`）；日常验证建议直接走 AiAgent 调用；
- 该命令**不经过验签**，只验证 ABI（参数进、结果出、退出码、输出大小与耗时），验签问题请到 GUI 看状态。

---

## 十二、注意事项清单

| 坑 | 表现 | 规避 |
|---|---|---|
| 忘了写 `entry` 相对路径 | 控制器/节点报"入口文件不存在"或"未声明 entry" | `entry` 用斜杠分隔的相对路径，如 `bin/tool.exe`、`tool.wasm`；子目录必须带上 |
| `runtime` 写错 | `.wasm` 被当 `exec` 执行，或 `.exe` 当 wasm 编译失败 | `wasm` 模块用 `wasm`；原生程序用 `exec`；空值默认 `exec`，`wasi` 会归一为 `wasm` |
| 没签名 / 自签 | 工具不进工具表（`unsigned`），或自签被判 `verify_failed` | 走控制器下发或 GUI 口令授权补签名，**不要自造密钥自签** |
| 包内路径仅大小写不同 | 节点报"验签失败"（实为跨平台覆盖） | 重命名冲突文件后重新打包（`bin/Lib.dll` 与 `bin/lib.dll` 不能共存） |
| 输出走 stderr | 结果看似丢失 | 结果写 **stdout**；stdout 为空时宿主会回退读 stderr，但仍建议显式走 stdout |
| 返回内容过长 | 被截断（64KB） | 只回传关键结论，避免 dump 全量响应/日志 |
| `perm` 设置不当 | 只读工具 `2` → AI 反复停下等确认；写工具 `0` → 静默执行危险动作 | 按副作用选 `0`/`1`/`2`；外部工具默认 `2` 最保守 |
| 依赖 DLL 与工作目录 | DLL 加载失败 | 依赖与入口放同目录；`exec` 工作目录即工具目录，用相对路径引用 |
| `args` 与 stdin 混用 | 工具同时读命令行参数与 stdin，参数错位 | `args` 是**固定**命令行参数，动态参数**只从 stdin 的 JSON 读**；不要把动态参数塞进 `args` |
| 改动文件后继续用旧签名 | 调用时报"签名校验未通过" | 改完代码/资源必须重新签名：重新下发或补签名，然后热重载 |
| 工具名与内置/保留名冲突 | 下发被拒 | 避开 `finish`/`report_vuln`/`http_request`/`builtin_*` 及前缀 `browser_`、`file_`、`audit_`、`oob_`、`exttool_` |
| 清单 `name` 与目录名不一致 | 安装被拒 | 二者必须一致；控制器下发生成 `tool.json` 时会把 `name` 写成 `toolDir` |
| 依赖 CGo / 原生动态库 | 无法加载 | AiAgent 为 `CGO_ENABLED=0`，不支持 `dlopen`；多依赖用"exe + 同目录 DLL"或改 `wasm` |
| `description` 为空 | 下发/安装被拒"工具描述不能为空" | 写一句可判断触发条件的完整描述 |
| `parameters` 与代码字段不一致 | 模型传的值工具读不到 | schema 字段名与代码 `json` tag 逐字对齐，`required` 只列真正必需的 |
| `signPassState` 写入口令 | 严重安全问题 | 该字段只放状态描述，**绝不写口令**；签名口令由控制器另行保管，不落明文 |

---

> 相关文档：[插件开发总览](/docs/overview)、[控制器插件开发](/docs/controller-plugin)、[应用插件开发](/docs/scan-app-plugin)、[WASM POC 模板开发](/docs/scan-poc-wasm)、[Go 热加载 POC 开发](/docs/go-poc-hotload)。

<!-- en -->
# AiAgent External Tool Plugin Development

[[toc]]

This article is aimed at **third-party developers** and explains how to write an **external tool** for TestSecScan's AI penetration agent (hereafter "AiAgent") that can be called by the large language model. After reading it, you will be able to independently produce a tool package that comes with a manifest, a signature, hot-reload support, and the ability to run on either the `exec` or the `wasm` runtime.

Highlights of this revision: **one dedicated section per field** (purpose / type and whether required / what to pass / minimal JSON example / consequences of getting it wrong), **one complete runnable example per runtime**, plus two new chapters: "Where the Entry Point Is: How AiAgent Discovers, Verifies and Hands the Tool to the Model" and "Debugging Handbook". All field names, constant values, directory paths and the signing algorithm are a contract agreed by both sides, and have been **checked word by word** against the implementation.

> [!IMPORTANT]
> The rules described in this section are implemented jointly by AiAgent and the controller; the fields of `tool.json` / `sig.json` are the protocol agreed by both sides.
> All field names, constant values, directory paths and the signing algorithm in this article follow that protocol; if the documentation conflicts with the actual implementation, **the actual behavior prevails**.

---

## 1. What It Is

AiAgent is an AI penetration agent: it exposes a set of tools (function calling) to the large language model, which autonomously decides when to call them during penetration testing / auditing. Built-in tools (`http_request`, `browser_*`, `builtin_detect`, `report_vuln`, etc.) ship with the binary and cannot be added or removed; **external tools** are extension skills deployed by you.

One external tool = one directory containing three things:

| Component | File | Purpose |
|---|---|---|
| Manifest | `tool.json` | Declares the tool name, description, entry, runtime, parameter schema, permission level and timeout |
| Signature | `sig.json` | The controller's Ed25519 signature over the "whole-package digest"; the only admission gate |
| Entry | The file pointed to by `entry` in the manifest | The program that is actually executed (may carry arbitrary same-directory dependencies such as DLLs / resources) |

Once a tool is loaded, AiAgent places it into the AI tool table as an ordinary callable tool: what the model sees is `name` + `description` (with `usage` appended) + `parameters` (JSON Schema). On a call, AiAgent runs your program as a child process (or a WASI module), writes the parameter JSON to its stdin, and returns its stdout text to the model as the observation.

**One-sentence memory aid**: an external tool = "one directory + one manifest + one controller signature"; the AI calls it from the tool table just like a built-in tool.

### 1.1 The Complete Lifecycle of One Call (Build the Big Picture First)

Before examining the fields in detail, look at the whole picture of a single call. The sequence below describes AiAgent's **actual behavior** when executing an external tool:

1. When a session is created, AiAgent converts the tools already loaded in its in-memory registry into model-visible tool entries and injects them into the tool table;
2. The model decides to call a tool and returns `tool_call(name, arguments)`;
3. AiAgent looks up the tool by name in the registry;
4. Before executing, AiAgent **re-verifies the signature first** (TOCTOU protection);
5. It serializes `arguments` (a JSON object) into bytes;
6. It starts a child process (`exec`) or instantiates a WASI module (`wasm`) according to `runtime`;
7. The parameter JSON is written to its **stdin**;
8. Its **stdout** is read (falling back to stderr when empty);
9. Timeout / output truncation is handled;
10. The text is returned to the model as an observation.

Every chapter below returns to one link in this chain.

### 1.2 Security Model (You Must Understand It, or the Tool Will Not Install)

Four hard constraints; if any one of them is missing, the mechanism is as good as gone:

1. **No signature, no loading**: only tools that pass verification with the **controller's current public key** enter the AI tool registry. An unsigned/tampered directory **never enters the tool table at all** — the model cannot see it and therefore will not repeatedly probe it (this is much better than "it sees the tool but execution is refused", which induces the model to take detours and waste rounds).
2. **Verification uses the controller public key held by the host**: never trust the `userPubkey` carried in `sig.json`. Otherwise you could generate your own key pair and self-sign a tool to get it through, which is equivalent to having no signature at all.
3. **Re-verify before every call**: there is a time window (TOCTOU) between loading (verification) and actual execution, during which someone could drop a malicious DLL into an already signed directory. AiAgent therefore recomputes the package digest and re-verifies the signature before every execution.
4. **The public key lives only in memory**: the controller public key comes only from the AES-GCM authenticated channel and is never written to disk — an on-disk copy could be replaced by a local attacker (who could then pass verification with a malicious tool signed by their own key), and no hash/HMAC record on the same machine with the same privileges can prevent that substitution.

The signing target is the **whole-package digest**, not a single file. Algorithm (byte-for-byte identical on both sides):

```text
h := sha256.New()
for _, f := range 目录内全部文件（相对路径按字节升序，排除 sig.json）:
    h.Write(斜杠分隔相对路径); h.Write("\n")
    h.Write(sha256hex(文件内容)); h.Write("\n")
digest = h.Sum(nil)   // 32 字节
```

Therefore, in a multi-file tool, **replacing/adding/removing any single file changes the digest → verification fails**.

> [!NOTE]
> The public key is **memory-only**, delivered over the controller's AES-GCM authenticated channel, and is never written to disk. The cost is that external tools are unavailable during the window in which "AiAgent has just started and the controller has not connected yet" (they are judged `verify_failed`, not registered, and execution is also refused); once the controller connects and delivers the public key (`AiExternalToolSetKey`), a rescan restores them. The failure direction is "unavailable" rather than "let someone else's tool through", which is correct fail-closed behavior.

---

## 2. Differences from the "Plugins" of the Other Three Tiers

TestSecScan has a four-tier architecture; different tiers have different extension points. They all carry the word "plugin" but their **mechanisms are completely different**, so do not mix them up:

| Tier | Extension point | Runtime / language | Carrier | Who can deploy |
|---|---|---|---|---|
| **AiAgent** | **External tool** (this article) | `exec` (native executable) or `wasm` (WASI module) | `<AiConfig>/externaltools/<toolDir>/` | Third-party developers, signed by the controller |
| Controller | Controller plugin | Native Go (compiled into the controller or dynamically loaded) | `CtlConfig/` | Official / controller maintainers only |
| Scanning node | Scanning app plugin / host functions | Pure plugin route, via host functions such as `T.HttpUrl` | Node plugin directory | Official plugins (e.g. `plugin-exttools`) |
| Scanning node | WASM POC hot reload | `wasm` (wazero) | POC store | Delivered by users through the GUI |

**Key distinctions**:

- An AiAgent external tool is **not** a wasm plugin hook: that refers to WASM POCs / plugin host functions on the scanning node, and has nothing to do with AiAgent.
- An AiAgent external tool is **not** a controller plugin: controller plugins run inside the controller process, are written in Go and maintained by the official team; external tools run on AiAgent, can be executables or wasm modules compiled from any language, and are deployed by you.
- External tools **keep AiAgent pure Go (`CGO_ENABLED=0`)**: therefore `dlopen`-style native dynamic libraries are not supported. Go's `plugin` package requires CGo and does not support Windows, so it is not an option here either. When you need multi-file dependencies, use the `exec` form of "entry exe + same-directory DLLs loaded by the exe itself", or switch to `wasm`.
- **Key difference from the scanning node's WASM signing mechanism**: WASM plugin verification trusts the `userPubkey` embedded in `sig.json`; external tools **mandatorily** verify with the controller's current public key, so a self-generated key pair cannot sign its own way in. WASM signs a single wasm byte stream; the external tool signing target is the **whole-package digest** (a multi-DLL tool requires exactly that).

---

## 3. Directory Structure and Installation Location

### 3.1 Installation Location

The external tool root directory on the AiAgent side:

```text
<AiConfig>/externaltools/
├── <toolDir>/            # 一个已安装的工具（正式目录）
│   ├── tool.json         # 清单（签名覆盖内容的一部分）
│   ├── sig.json          # 签名（唯一被摘要排除的文件）
│   └── <entry 及依赖>     # 入口文件 + 同目录 DLL / 资源
├── <toolDir2>/
└── .staging/             # 安装暂存目录（以 . 开头，扫描时跳过）
```

Here `<AiConfig>` is AiAgent's configuration directory (located under its process working directory). The corresponding path on the controller side is `<CtlConfig>/externaltools/`.

### 3.2 Directory Naming Rules

`<toolDir>` must satisfy the following rules (identical on the controller and AiAgent sides):

- First apply `strings.ToLower(strings.TrimSpace(name))` to lowercase and trim whitespace;
- Length **3 ~ 64**;
- **The first character must be `a`~`z`**;
- All remaining characters may only be `[a-z0-9_]`.

Invalid examples: `ab` (too short), `1abc` (starts with a digit), `a-b` (hyphen), `a.b` (dot), `../x` (path traversal). After normalization, `Nmap_Scan` → `nmap_scan`.

In addition, the tool name must not conflict with built-in tools or reserved prefixes (the controller validates reserved names): the exact reserved names are `finish`, `report_vuln`, `http_request`, `browser_navigate`, `crawl_site`, `builtin_detect`, `builtin_payloads`, `batch_request`, `exttool_call`; the reserved prefixes are `browser_`, `file_`, `audit_`, `oob_`, `exttool_`.

> [!WARNING]
> The `name` in the manifest and the directory name **must match** (when delivering, the controller forcibly writes the `name` in `tool.json` to `toolDir`; when installing, AiAgent checks that "payload manifest name == directory name" and rejects any mismatch with the error "payload manifest name (%s) does not match tool directory name (%s)"). This avoids the AI being unable to find the tool by name at call time.

### 3.3 Installation Flow (Stage First → Verify Signature → Atomic Rename)

You do not have to implement the installation flow yourself (delivering through the GUI/controller is enough), but understanding it helps with troubleshooting. **Controller side**:

1. Receive the base64 tool package uploaded by the GUI (a single file or a zip) and decide, according to `overwrite`, whether overwriting a tool with the same name is allowed;
2. Extract/write into the staging directory `<root>/.staging/<toolDir>-<random suffix>/`;
3. Assemble `tool.json` (GUI fields take precedence; missing items are inherited from the existing `tool.json` in the package; `name` is forcibly written as `toolDir`; `entry` can be detected automatically);
4. **Check for paths that differ only in case**; reject immediately on any conflict;
5. Atomically rename into the formal directory `<root>/<toolDir>/`;
6. Sign with the controller private key (`signSource` = `controller`) and write `sig.json`; **if signing fails, delete the formal directory** so that no unsigned noise is left behind;
7. Package (excluding `sig.json`) and broadcast to all online AiAgents.

**AiAgent side**: extract into its own `.staging` → check for case conflicts → write the `sig.json` delivered with the package → **verify the signature inside the staging area** → check that `tool.json` / `entry` exist in the package → atomically rename into the formal directory → rescan and rebuild the registry.

**Critical ordering**: AiAgent never "writes to disk first and verifies later" — an unverified tool must never briefly appear in the scan scope. That is why `.staging` starts with `.` and is skipped during scans.

> [!IMPORTANT]
> After verification, the AiAgent side **never rewrites any file inside the package**. The `tool.json` inside the package is exactly what the signature covers; if it were re-serialized and written back from the manifest in the delivered payload, the bytes could differ from what the controller wrote (field normalization / indentation differences) → digest mismatch → installation would necessarily fail. Therefore **the authoritative source is always the `tool.json` inside the package**; the manifest in the delivered payload is used only for logs and consistency checks.

---

## 4. `tool.json` Manifest Fields in Detail

The manifest struct `ExtToolManifest` (the field names and json tags are completely identical on the AiAgent and controller sides):

```go
type ExtToolManifest struct {
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Usage       string         `json:"usage,omitempty"`
	Entry       string         `json:"entry"`
	Runtime     string         `json:"runtime"`
	Parameters  map[string]any `json:"parameters,omitempty"`
	Perm        int            `json:"perm"`
	TimeoutSec  int            `json:"timeoutSec,omitempty"`
	Version     string         `json:"version,omitempty"`
	Author      string         `json:"author,omitempty"`
	Args        []string       `json:"args,omitempty"`
	AddTime     string         `json:"addTime,omitempty"`
}
```

Each field below gets its own section, presented as "purpose → type and whether required → what to pass → example → consequences of getting it wrong".

#### `ExtToolManifest`

`ExtToolManifest` is the struct that `tool.json` maps to; its field names are exactly the keys of `tool.json` (case-sensitive). It has **12 fields** in total. Below is **a complete, copy-ready `tool.json`** containing every field with realistic values:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测，返回开放状态与 banner 摘要。",
  "usage": "target 支持域名或 IP；port 为 1-65535 的整数。返回 JSON：{\"open\":true,\"banner\":\"...\"}。仅建立连接并读取少量 banner，不发送任何攻击载荷。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "target": { "type": "string", "description": "目标主机（域名或 IP）" },
      "port": { "type": "integer", "description": "TCP 端口号（1-65535）" }
    },
    "required": ["target", "port"]
  },
  "perm": 1,
  "timeoutSec": 30,
  "version": "1.0.0",
  "author": "security-team",
  "args": ["--json"],
  "addTime": "2026-10-05T10:00:00+08:00"
}
```

This table gives an overview of every field's position and role in this manifest (the keys are case-sensitive literals):

| Field (json tag) | Value in this manifest | Position/role |
|---|---|---|
| `name` | `"port_probe"` | Top level, 1st key; tool name = LLM function name = directory name |
| `description` | `"对目标主机的单个端口…"` | Top level; the model decides when to call based on it |
| `usage` | `"target 支持域名或 IP…"` | Top level; appended to the tool description given to the model |
| `entry` | `"port_probe.exe"` | Top level; entry relative path |
| `runtime` | `"exec"` | Top level; `exec` or `wasm` |
| `parameters` | Nested object (with `type`/`properties`/`required`) | Top level; parameter JSON Schema |
| `perm` | `1` | Top level; permission level 0/1/2 |
| `timeoutSec` | `30` | Top level; per-execution timeout (seconds) |
| `version` | `"1.0.0"` | Top level; version metadata |
| `author` | `"security-team"` | Top level; author metadata |
| `args` | `["--json"]` | Top level; fixed exec command-line argument array |
| `addTime` | `"2026-10-05T10:00:00+08:00"` | Top level; add time (RFC3339) |

> [!NOTE]
> `usage`, `parameters`, `timeoutSec`, `version`, `author`, `args`, `addTime` are optional; `name`/`description`/`entry`/`runtime`/`perm` are required. Whether omitted or not, JSON keys must be written exactly as in the table above (lowerCamelCase, e.g. `timeoutSec`, **not** the Go field name `TimeoutSec`).

### 4.1 `name`

#### `name`

**Purpose**: the tool name. It is the **function name** used by the large language model in function calling, and also the name shown in the GUI list; at the same time it **must equal the directory name** (AiAgent normalizes it and uses it as the registry key). This is the name the model uses when calling.

**Type and whether required**: `string`, **required** (JSON tag `name`, no `omitempty`). If missing, AiAgent falls back to the directory name, but the controller's delivery/installation stage performs strict validation, so do not omit it.

**What to pass**: the normalization rules are as follows:

- First `strings.ToLower(strings.TrimSpace(name))`;
- Length must be **3 ~ 64**;
- **The first character must be `a`~`z`**;
- All remaining characters may only be `[a-z0-9_]`.

In other words, only lowercase letters, digits and underscores are accepted, and it must start with a letter. `Nmap_Scan` normalizes to `nmap_scan`.

**Example**: a minimal manifest, exactly as it looks with only the required fields:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec"
}
```

**Consequences of getting it wrong**:

- `name` equal to `ab` (<3), `1abc` (starts with a digit), `a-b` (hyphen), `a.b` (dot), `../x` (out of bounds): after normalization it becomes an empty string → delivery/installation is rejected; even if the directory already exists, registration falls back to the directory name, leaving a hidden risk of the in-package name differing from the directory name.
- `name` inconsistent with the directory name: AiAgent detects the mismatch during installation → reports "payload manifest name does not match tool directory name" and refuses to install.
- `name` conflicting with a built-in tool / reserved prefix (such as `http_request`, `browser_x`): the controller rejects the delivery according to the reserved-name rules.
- Length > 64: normalization returns an empty string and it is rejected.

### 4.2 `description`

#### `description`

**Purpose**: a one-sentence description of what the tool is for. The model **uses it to decide when to call** the tool. It serves as the first paragraph of the tool description shown to the model.

**Type and whether required**: `string`, **required** (JSON tag `description`, no `omitempty`). The controller explicitly validates it both when delivering and when assembling the manifest: an empty value is rejected outright with "tool description must not be empty (the model relies on the description to decide when to call)".

**What to pass**: one complete sentence from which the trigger condition can be judged. It is recommended to state: what it does, on what, what it returns, and where the side-effect boundaries are. Do not write uninformative sentences such as "this is a tool".

**Example**:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测，返回开放状态与 banner 摘要。",
  "entry": "port_probe.exe",
  "runtime": "exec"
}
```

**Consequences of getting it wrong**:

- Empty: delivery/installation fails immediately ("tool description must not be empty").
- Vague description (e.g. "process the target"): the model cannot tell when to call it, may never call it, or may call it at the wrong time.
- Description inconsistent with actual behavior: the model misuses the tool according to the description (for example, a write operation described as a "read-only check"), causing misjudgment.

### 4.3 `usage`

#### `usage`

**Purpose**: supplementary usage notes. They are appended to the tool description given to the model, after `description`, rendered as a `【使用方法】` section. This is the right place for parameter meanings, value ranges, return formats and caveats.

**Type and whether required**: `string`, **optional** (JSON tag `usage,omitempty`). If empty, no `【使用方法】` section appears in the tool description.

**What to pass**: multi-line text. Typically: how to pass each parameter, what format is returned, what side effects exist, and what is returned on failure. Do not repeat the `parameters` schema here (the schema is given to the model separately).

**Example**:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "usage": "target 支持域名或 IP；port 为 1-65535 的整数。返回 JSON：{\"open\":true,\"banner\":\"...\"}。仅建立连接并读取少量 banner，不发送任何攻击载荷。",
  "entry": "port_probe.exe",
  "runtime": "exec"
}
```

**Consequences of getting it wrong**:

- Omitted: the model can only guess the usage from `description` and `parameters`, and may easily miss optional parameters or misunderstand the return format.
- Written contradicting the schema (for example, `usage` says "port is required" while the schema makes it optional): the model may construct parameters the tool cannot handle.
- Too verbose: it consumes model context, and `description` is a fixed overhead carried on every request.

### 4.4 `entry`

#### `entry`

**Purpose**: the **relative path** of the entry file inside the tool directory. AiAgent uses it to build the absolute path inside the tool directory and execute it — this is the file that is actually launched.

**Type and whether required**: `string`, **required** (JSON tag `entry`, no `omitempty`). The controller auto-detects it when packaging (see below), but the `tool.json` **inside the package must contain it in the end**; if it is empty at installation time, AiAgent reports "the tool.json in the tool package does not declare entry (entry file)".

**What to pass**:

- It must be a **relative path**, separated with **forward slashes `/`** (e.g. `bin/tool.exe`, `tool.wasm`);
- It must pass the path security check: empty strings, `.`, `..`, a leading `/` or `\`, absolute paths, drive-letter prefixes (`C:/x`), `a/../../b` and similar escapes are rejected;
- The file must **actually exist inside the package** and not be a directory (verified by `os.Stat`);
- When packaging, if the GUI left it blank and the in-package `tool.json` does not have it either, the controller auto-detects it by runtime: for `wasm` the candidates are `entry.wasm`/`main.wasm`/`tool.wasm`/`plugin.wasm`; for `exec` the candidates are `entry.exe`/`tool.exe`/`main.exe`/`run.exe`/`start.exe`/`entry.bat`/`entry.cmd`/`entry.ps1`/`entry.sh`/`entry.py`; if none of the candidates match and the package contains **exactly one file**, that file is used directly.

**Example**: a complete minimal manifest with the entry in a subdirectory:

```json
{
  "name": "demo_tool",
  "description": "演示工具。",
  "entry": "bin/tool.exe",
  "runtime": "exec"
}
```

**Consequences of getting it wrong**:

- Written as an absolute path (`C:/tools/tool.exe`) or containing `..`: the path check fails → delivery reports "illegal entry path" and installation reports "illegal entry path".
- Written with Windows backslashes `bin\tool.exe`: the controller normalizes it to slashes, but when writing the manifest by hand it is still recommended to use `/` to avoid ambiguity.
- Path typo / file not in the package: the controller reports "entry file does not exist: %s" when packaging; AiAgent reports "entry file does not exist: %s" when installing; an installed directory is **not registered** during a rescan because the entry file does not exist (it is invisible in the tool table).
- Writing only the file name while placing the entry in a subdirectory (e.g. the real path is `bin/tool.exe` but `tool.exe` is written): the entry does not exist → not registered.

### 4.5 `runtime`

#### `runtime`

**Purpose**: the runtime type, which decides which branch AiAgent takes: `exec` (child process) or `wasm` (wazero WASI module). See Chapter 6, the ABI deep dive.

**Type and whether required**: `string`, **optional** (JSON tag `runtime`, no `omitempty`, but an empty value is normalized). Defaults to `exec`.

**What to pass**: normalization rules:

- Lowercased and trimmed, if it is lowercase `wasm` **or the alias `wasi`** → `"wasm"`;
- Any other value (including an empty string or a misspelled value) → `"exec"`.

There are only two legal values: `exec` and `wasm`. Remember that `wasi` is an alias for `wasm`.

**Example**:

```json
{
  "name": "ip_calc",
  "description": "解析一个 CIDR 并返回网络地址、掩码位与可用主机位数。",
  "entry": "tool.wasm",
  "runtime": "wasm"
}
```

**Consequences of getting it wrong**:

- Writing a wasm module as `exec` (or omitting it): AiAgent executes it directly as a native executable → failure (it is not an executable format). The tool is visible in the tool table, but every call reports "external tool execution failed".
- Writing a native exe as `wasm`: wazero fails to compile the module → the call reports "failed to compile wasm".
- A typo (such as `wasmm`, `EXEC `): no error is raised; it silently falls back to the default `exec`, which may not be the runtime you wanted.
- Using `wasi`: equivalent to `wasm` and works, but it is recommended to write `wasm` consistently so that later readers are not misled.

### 4.6 `parameters`

#### `parameters`

**Purpose**: the parameter JSON Schema, which is passed through **as-is** as the model's function `parameters` (AiAgent converts it directly into the model-visible schema when the session is created). The model constructs its arguments based on it, and AiAgent then serializes the parameter object given by the model into JSON and writes it to the tool's stdin. This is the core contract for "how parameters get in".

**Type and whether required**: `map[string]any`, **optional** (JSON tag `parameters,omitempty`).

**What to pass**: a standard JSON Schema object, usually `{"type":"object","properties":{...},"required":[...]}`. If the whole field is missing or empty, AiAgent falls back to a permissive object:

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": true
}
```

At the same time, the description given to the model has this sentence appended: "this tool does not declare a parameter structure; construct fields according to the notes in the `【使用方法】` section."

**Example**: a complete fragment with required fields and type descriptions:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "target": { "type": "string", "description": "目标主机（域名或 IP）" },
      "port": { "type": "integer", "description": "TCP 端口号（1-65535）" }
    },
    "required": ["target", "port"]
  }
}
```

**Consequences of getting it wrong**:

- Missing: the host provides a permissive schema, so the model does not know which fields exist; it may pass a bunch of keys the tool does not recognize, or omit required fields → parameter parsing fails in the tool.
- Malformed structure (for example, `properties` written as an array): when it is not a valid JSON Schema, model-side behavior is unpredictable; the model may refuse to call or pass garbage.
- `required` inconsistent with the fields the tool actually reads: the model omits a field you actually use, and the tool reports a parameter parsing failure.
- The field names in the description differing from the `json` tags read by the code: the model passes A, the tool reads B and gets an empty value.
- Nested objects/arrays without descriptions: the model does not know the internal structure (see how the `auth` object is written in Example 3).

### 4.7 `perm`

#### `perm`

**Purpose**: the permission level. It directly determines AiAgent's behavior when the model calls the tool: automatic execution / high-risk notice / manual confirmation. See 4.7.1 for the three levels in detail.

**Type and whether required**: `int`, **optional** (JSON tag `perm`, no `omitempty`, default 0 when absent).

**What to pass**: only `0` / `1` / `2`. AiAgent's normalization rules: `p < 0` → `0`; `p > 2` → `2`; anything else is kept as-is. In other words, out-of-range values are clamped to the boundary and do not raise an error.

- `0` → full access, automatic execution
- `1` → high-risk notice, non-blocking
- `2` → full user confirmation

**Example**:

```json
{
  "name": "upload_marker",
  "description": "向目标上传一个无害标记文件，验证文件上传漏洞（会写入目标，默认需确认）。",
  "entry": "bin/upload_marker.exe",
  "runtime": "exec",
  "perm": 2
}
```

#### 4.7.1 How the Three `perm` Levels Behave in the GUI and on the Model Side

The concrete semantics and behavior of the three levels:

| `perm` | Semantics | AiAgent behavior | GUI behavior | Suitable for |
|---|---|---|---|---|
| `0` | Full access | Executes automatically and directly, no interruption | No special notice | Read-only probing (reading pages, parsing, computation) |
| `1` | High-risk notice | Sends a warn event before execution to **highlight it in red in the GUI, but does not block**, then executes as usual | Receives the warn event, highlighted in red | Outbound side effects that do not change the target state (packet-sending probes) |
| `2` | Full user confirmation | Write operations that change the target state require manual GUI confirmation before execution; **unattended tasks are handled per `UnattendedPolicy`** | Pops up a manual confirmation | Write operations / exploitation |

> [!TIP]
> External tools are user-supplied code, so the **GUI form defaults to `perm: 2`** (the most conservative). Set it according to the tool's **actual side effects**:
> use `0` for read-only probing, `1` for outbound side effects that do not change the target state, and `2` for write operations / exploitation.
> The sandbox's "read-only mode" **rejects tools whose `perm` is not `0`**.

**Consequences of getting it wrong**:

- A read-only tool wrongly set to `2`: the model stops at every step to wait for GUI confirmation (unattended tasks are handled per `UnattendedPolicy` and may be interrupted or fail outright), a terrible experience that also makes the AI change course after repeated blockage.
- A write operation wrongly set to `0`: dangerous actions execute silently, bypassing manual confirmation.
- An out-of-range value (such as `9`, `-1`): no error is raised; it is clamped to `2` / `0`, possibly the opposite of what you intended.

### 4.8 `timeoutSec`

#### `timeoutSec`

**Purpose**: the per-execution timeout (seconds). AiAgent uses it to set the timeout limit for this execution: for `exec` it terminates the child process, and for `wasm` it closes the runtime context.

**Type and whether required**: `int`, **optional** (JSON tag `timeoutSec,omitempty`). Defaults to `60`.

**What to pass**: normalization rules:

- `sec <= 0` → default `60`;
- `0 < sec < 5` → clamped to `5`;
- `sec > 600` → clamped to `600`;
- anything else is kept as-is.

The usable range is `[5, 600]`.

**Example**:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "timeoutSec": 30
}
```

**Consequences of getting it wrong**:

- Omitted: the default of 60 seconds is used; this may be too short for slow tools and wastes a waiting window for fast ones.
- Set to `0` or a negative number: it also falls back to the default of 60.
- Set very small (such as `1`): clamped to 5 seconds, and network tools time out very easily.
- Set very large (such as `9999`): clamped to 600 seconds (10 minutes); a single hang occupies the AI session for a long time.
- After an execution timeout, `exec` returns "external tool execution timed out (%ds), terminated." and appends stderr; `wasm` returns "external tool execution timed out (%ds), terminated."

### 4.9 `version`

#### `version`

**Purpose**: the version number, **pure metadata**. It is reported with the manifest to the controller/GUI (the version is visible in the list); it does not participate in any execution logic, nor in the signing algorithm itself (as part of the content of `tool.json` it naturally participates in the package digest).

**Type and whether required**: `string`, **optional** (JSON tag `version,omitempty`).

**What to pass**: any string; a semantic version such as `1.0.0` is recommended. When the controller assembles the manifest, the GUI value is used if one was filled in.

**Example**:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "version": "1.0.0"
}
```

**Consequences of getting it wrong**:

- Omitted: works fine, but the version is not visible in the GUI list, making it hard to distinguish old packages from new ones when troubleshooting.
- Changing `version` without re-signing: the content of `tool.json` changes → the package digest changes → the signature becomes invalid → calls report "signature verification failed". (This applies to any field change, not just `version`.)

### 4.10 `author`

#### `author`

**Purpose**: the author, **pure metadata**. Reported with the manifest; does not participate in execution logic.

**Type and whether required**: `string`, **optional** (JSON tag `author,omitempty`).

**What to pass**: any string; a team/individual identifier or the source of the use case is recommended.

**Example**:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "author": "security-team"
}
```

**Consequences of getting it wrong**:

- Omitted: functionality is unaffected; only audit information is missing.
- Like `version`, it must be re-signed after any change, otherwise the digest mismatches.

### 4.11 `args`

#### `args`

**Purpose**: the **fixed command-line arguments** appended in `exec` mode. AiAgent appends them after the entry program as command-line arguments; they are the same on every call.

**Type and whether required**: `string[]`, **optional** (JSON tag `args,omitempty`).

**What to pass**: a string array where each element is one command-line argument (do not add quotes yourself; the elements are passed directly by exec, without going through a shell). **Only fixed arguments belong here**; dynamic arguments must be read from the JSON on stdin.

**Example**:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "args": ["--json", "--quiet"]
}
```

**Consequences of getting it wrong**:

- Putting dynamic arguments (such as the target IP) into `args`: `args` is static and cannot reference the parameters of the current call; the tool always receives the command-line arguments hard-coded at packaging time, and the real parameters passed by the model are ignored.
- Depending on both `args` and stdin with confused parsing: arguments get misaligned (the tool thinks the first command-line argument is the mode, when it is actually something else).
- Writing `args` with `runtime: wasm`: **it has no effect**. The module's `argv[0]` under wazero is just the tool name; there are no extra command-line arguments. Dynamic parameters can only go through stdin.
- Arguments containing spaces/special characters: because no shell is involved, array elements are passed as-is and this is generally safe; however, if the tool itself concatenates a shell command internally, it must handle escaping on its own.

### 4.12 `addTime`

#### `addTime`

**Purpose**: the add-time marker. When assembling the manifest, the controller fills in an RFC3339 string of the current time if the field is empty. Pure metadata; does not participate in execution logic.

**Type and whether required**: `string`, **optional** (JSON tag `addTime,omitempty`).

**What to pass**: an RFC3339 time string such as `"2026-10-05T10:00:00+08:00"`. It may be omitted when packaging by hand; the controller fills it in automatically.

**Example**:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "addTime": "2026-10-05T10:00:00+08:00"
}
```

**Consequences of getting it wrong**:

- Omitted: the controller fills in the current time when packaging; generally harmless.
- Hard-coding a time by hand without re-signing: the change causes a digest mismatch and invalidates the signature (as with every field above).
- Note: if the AiAgent side does not write `tool.json` back during a rescan, `addTime` is only filled in when the controller packages the tool; a tool copied directly into the node directory does not get an automatic value.

### 4.13 Complete `tool.json` Example

Putting all the fields above together, a fully populated manifest looks like this:

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测，返回开放状态与 banner 摘要。",
  "usage": "target 支持域名或 IP；port 为 1-65535 的整数。返回 JSON：{\"open\":true,\"banner\":\"...\"}。仅建立连接并读取少量 banner，不发送任何攻击载荷。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "target": { "type": "string", "description": "目标主机（域名或 IP）" },
      "port": { "type": "integer", "description": "TCP 端口号（1-65535）" }
    },
    "required": ["target", "port"]
  },
  "perm": 1,
  "timeoutSec": 30,
  "version": "1.0.0",
  "author": "security-team",
  "args": ["--json"],
  "addTime": "2026-10-05T10:00:00+08:00"
}
```

---

## 5. `sig.json` Signature File Fields in Detail

The signature struct `ExtToolSigInfo` (a mirror image on the AiAgent and controller sides):

```go
type ExtToolSigInfo struct {
	ToolName      string `json:"toolName"`
	PackageHash   string `json:"packageHash"`
	UserPubkey    string `json:"userPubkey"`
	UserSignature string `json:"userSignature"`
	SignStatus    string `json:"signStatus"`
	SignSource    string `json:"signSource"`
	FileCount     int    `json:"fileCount"`
	SignTime      string `json:"signTime,omitempty"`
	SignNote      string `json:"signNote,omitempty"`
	SignPassState string `json:"signPassState,omitempty"`
}
```

> [!IMPORTANT]
> `sig.json` is produced by the controller after signing and delivered with the package; **do not edit it by hand**. Changing it does not change the package digest (it is excluded from the digest), but it breaks verification: once `userSignature` is modified it can no longer verify the digest. Third-party developers only need to **understand** each field; they do not need to write it themselves.

Each field below gets its own section, presented as "purpose → type and whether required → what to pass → example → consequences of getting it wrong".

#### `ExtToolSigInfo`

`ExtToolSigInfo` is the struct that `sig.json` maps to; its field names are exactly the keys of `sig.json` (case-sensitive). It has **10 fields** in total. Below is **a complete, copy-ready `sig.json`** containing every field with realistic values:

```json
{
  "toolName": "port_probe",
  "packageHash": "bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140",
  "userPubkey": "b3J5cHRvLWVkMjU1MTktcHVibGljLWtleS1iYXNlNjQ9=",
  "userSignature": "l7Yq3m...(base64 编码的 64 字节 Ed25519 签名)",
  "signStatus": "verified",
  "signSource": "controller",
  "fileCount": 4,
  "signTime": "2026-10-05T10:00:00+08:00",
  "signNote": "GUI 下发外部工具自动签名",
  "signPassState": ""
}
```

This table gives an overview of every field's position and role in this signature file (the keys are case-sensitive literals):

| Field (json tag) | Value in this sig.json | Position/role |
|---|---|---|
| `toolName` | `"port_probe"` | Top level; redundant tool name, for easier manual cross-checking |
| `packageHash` | `"bf3c6f85…2140"` | Top level; package digest sha256 hex (**participates in verification**) |
| `userPubkey` | base64 public key | Top level; issuer public key (**recorded only, not used for verification**) |
| `userSignature` | base64 signature | Top level; the controller private key's Ed25519 signature over the digest (**participates in verification**) |
| `signStatus` | `"verified"` | Top level; `verified`/`verify_failed`/`unsigned` |
| `signSource` | `"controller"` | Top level; `controller`/`manual` |
| `fileCount` | `4` | Top level; number of files covered by the digest |
| `signTime` | `"2026-10-05T10:00:00+08:00"` | Top level; signing time (RFC3339) |
| `signNote` | `"GUI 下发外部工具自动签名"` | Top level; note about the signing scenario |
| `signPassState` | `""` | Top level; note about the authorization method (**never records the passphrase itself**) |

> [!NOTE]
> `signTime`, `signNote`, `signPassState` are optional; the other 7 fields are required. JSON keys are lowerCamelCase throughout (`packageHash`, `userSignature`, `signPassState`); **do not** write Go field names (`PackageHash`, `UserSignature`, `SignPassState`).

### 5.1 `toolName`

#### `toolName`

**Purpose**: a redundant tool name for easier manual cross-checking. The controller fills it in from the `name` in `tool.json` when signing; when re-signing it also uses the manifest name (falling back to `toolDir` if missing). It **does not participate in the verification computation**.

**Type and whether required**: `string`, required (JSON tag `toolName`, no `omitempty`), but it is only metadata; a missing value does not affect the verification verdict.

**What to pass**: the tool name, consistent with the `name` in `tool.json`.

**Example**:

```json
{
  "toolName": "port_probe"
}
```

**Consequences of getting it wrong**:

- A wrong name: verification still passes (it is not part of the computation), but manual cross-checking / GUI display will mislead.
- Inconsistent with `tool.json`: the signature still works, but it cannot be matched during an audit.

### 5.2 `packageHash`

#### `packageHash`

**Purpose**: **the sha256 hex of the package digest** (a 32-byte digest → 64 hex characters). The very first step of verification is comparing it with the actually computed digest (case-insensitive). This is the key product of the whole-package signing algorithm.

**Type and whether required**: `string`, required. **Participates in verification**.

**What to pass**: the 64-character lowercase hex computed by the specified algorithm (comparison uses `EqualFold`, so it is case-insensitive, but the canonical output is lowercase).

**Example**:

```json
{
  "packageHash": "bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140"
}
```

**Consequences of getting it wrong**:

- Not matching the actual directory digest: judged `verify_failed` (the tool is not registered and calls are also refused), reporting "signature verification failed".
- Files in the directory added/removed/changed (the most common case: an extra DLL dropped into an already signed directory): the digest changes → it does not match `packageHash` → `verify_failed`.
- Length not 64: the length check during re-signing fails ("invalid package digest reported by the node").

### 5.3 `userPubkey`

#### `userPubkey`

**Purpose**: the issuer public key (the controller's local public key in base64), **recorded only**. When verifying, AiAgent **explicitly receives** the controller public key held by the host and **never uses this field**.

**Type and whether required**: `string`, required (JSON tag `userPubkey`). Does not participate in the local verification verdict.

**What to pass**: the base64 of the controller's Ed25519 public key (32 bytes). When signing, the controller public key is written in; when re-signing, the base64 of the controller public key used for this verification is written in.

**Example**:

```json
{
  "userPubkey": "b3J5cHRvLWVkMjU1MTktcHVibGljLWtleS1iYXNlNjQ9="
}
```

**Consequences of getting it wrong**:

- An attacker writing their own forged `userPubkey` into `sig.json`: **ineffective**. AiAgent verifies only with the controller public key held by the host; the self-signed public key is ignored → `verify_failed`. This is exactly the attack that item 2 of the security model is designed to stop.
- A mistake that does not affect verification: because the field is not part of the verdict, it only misleads manual cross-checking.

### 5.4 `userSignature`

#### `userSignature`

**Purpose**: the controller private key's Ed25519 signature (base64) over the **raw bytes of the package digest**. During verification, the controller public key is used to run an Ed25519 verification over the package digest. **This is one of the only fields that truly decides admission**.

**Type and whether required**: `string`, required. **Participates in verification**. If empty, it is directly judged `unsigned`.

**What to pass**: the standard base64 encoding of the 64-byte Ed25519 signature (signed over the raw bytes of the package digest).

**Example**:

```json
{
  "userSignature": "l7Yq3m...（base64，64 字节签名，通常 88 字符）"
}
```

**Consequences of getting it wrong**:

- Empty: judged `unsigned` (unsigned), not registered; GUI authorization is needed to re-sign.
- A single byte changed / signed with another key: `ed25519.Verify` fails → `verify_failed`.
- Signed with a self-generated key pair (self-signed): verification with the controller public key necessarily fails → `verify_failed`.

### 5.5 `signStatus`

#### `signStatus`

**Purpose**: a redundant signature status string. **Note: AiAgent does not read it during verification**; instead it computes the result on the spot and fills it into the reported entry. This field in `sig.json` mainly records the status at the moment of signing.

**Type and whether required**: `string`, required (JSON tag `signStatus`). Does not participate in the local verification verdict.

**What to pass**: one of three states:

- `verified`: signature verification passed;
- `verify_failed`: signature verification did not pass;
- `unsigned`: no signature.

**Example**:

```json
{
  "signStatus": "verified"
}
```

**Consequences of getting it wrong**:

- Manually changing `signStatus` to `verified`: **useless**. The status is recomputed on the spot; a `verify_failed` tool is not admitted just because this field says `verified`.
- An illegal value: does not affect the verdict (it is not read), but GUI display may be abnormal.

### 5.6 `signSource`

#### `signSource`

**Purpose**: the signing source, recording who signed in what scenario. It is written into the reported entry after computation and shown in the GUI.

**Type and whether required**: `string`, required (JSON tag `signSource`). Does not participate in the verification verdict.

**What to pass**: one of two values:

- `controller`: signed automatically by the controller (GUI delivery / user-authorized automatic signing);
- `manual`: a `sig.json` deployed by hand.

**Example**:

```json
{
  "signSource": "controller"
}
```

**Consequences of getting it wrong**:

- A hand-deployed tool labeled `manual` but not verifiable with the controller public key: it is still judged `verify_failed` (the source label does not change the verification result).
- A wrong value: only affects auditing and display.

### 5.7 `fileCount`

#### `fileCount`

**Purpose**: the number of files covered by the digest (excluding `sig.json`). The reported entry uses it; if it is 0, a rescan attempts to recompute and fill it in live.

**Type and whether required**: `int`, required (JSON tag `fileCount`). Does not participate in the verification verdict (the digest is determined by content, not by count).

**What to pass**: an integer equal to the number of files in the directory other than `sig.json`. For example, a directory with 5 files (including `sig.json`) has `fileCount = 4` for the digest.

**Example**:

```json
{
  "fileCount": 4
}
```

**Consequences of getting it wrong**:

- A value inconsistent with reality: verification is unaffected (the count is not part of the digest), but the file count shown in the GUI differs from reality and may mislead troubleshooting.
- An empty directory: the digest computation directly reports "tool directory is empty (no files)" and verification is judged `verify_failed`.

### 5.8 `signTime`

#### `signTime`

**Purpose**: the signing time (RFC3339), optional metadata. At signing time the current time is written as an RFC3339 string; re-signing likewise writes the current time. The value is displayed with the reported entry.

**Type and whether required**: `string`, **optional** (JSON tag `signTime,omitempty`). Does not participate in the verification verdict.

**What to pass**: an RFC3339 string such as `"2026-10-05T10:00:00+08:00"`.

**Example**:

```json
{
  "signTime": "2026-10-05T10:00:00+08:00"
}
```

**Consequences of getting it wrong**:

- Omitted: verification and execution are unaffected; only audit information is missing.
- Wrong format: verification is unaffected (it is not part of the computation), but GUI display may fail to parse it.

### 5.9 `signNote`

#### `signNote`

**Purpose**: a note about the signing scenario, optional. It is passed in by the caller of the signing action; the controller delivery path writes `GUI 下发外部工具自动签名` (the literal product string for "GUI-delivered external tool, signed automatically") by default, and the authorized re-signing path writes `用户授权自动签名（GUI 输入签名口令）` ("user-authorized automatic signing; passphrase entered in the GUI") by default. It is used to audit afterwards "who signed in what scenario".

**Type and whether required**: `string`, **optional** (JSON tag `signNote,omitempty`). Does not participate in the verification verdict.

**What to pass**: arbitrary explanatory text. During re-signing, the note from the authorization process is written here.

**Example**:

```json
{
  "signNote": "GUI 下发外部工具自动签名"
}
```

**Consequences of getting it wrong**:

- Omitted: verification and execution are unaffected; only audit traceability is reduced.
- Note: it is also part of the content of `sig.json`, and **modifying `sig.json` does not affect the package digest** (it is excluded), so changing it does not cause verification to fail — nor does it change the verification result.

### 5.10 `signPassState`

#### `signPassState`

**Purpose**: a note about the authorization method; it **does not record the passphrase itself**, and is optional. It is used to record state descriptions such as "this re-signing was completed through passphrase authorization".

**Type and whether required**: `string`, **optional** (JSON tag `signPassState,omitempty`). Does not participate in the verification verdict.

**What to pass**: only "state/method" descriptive text (such as `已通过签名口令授权`); **never** the passphrase itself or a passphrase hash. The signing passphrase is kept separately by the controller (stored as a salted hash, never in plaintext) and has nothing to do with `sig.json`.

**Example**:

```json
{
  "signPassState": "已通过签名口令授权"
}
```

**Consequences of getting it wrong**:

- Writing the plaintext passphrase into this field: a serious security hazard, and it is broadcast with the package to all nodes and the GUI. **Never do this**.
- Omitted: verification and execution are unaffected.

### 5.11 Complete `sig.json` Example

A complete signature file looks like this (written by the controller with 2-space indentation):

```json
{
  "toolName": "port_probe",
  "packageHash": "bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140",
  "userPubkey": "b3J5cHRvLWVkMjU1MTktcHVibGljLWtleS1iYXNlNjQ9=",
  "userSignature": "l7Yq3m...(base64, 64 字节 Ed25519 签名)",
  "signStatus": "verified",
  "signSource": "controller",
  "fileCount": 4,
  "signTime": "2026-10-05T10:00:00+08:00",
  "signNote": "GUI 下发外部工具自动签名",
  "signPassState": ""
}
```

### 5.12 The Exact Whole-Package Digest Algorithm

The computation rules for `packageHash` (**byte-for-byte identical** on the AiAgent and controller sides):

1. Walk **all files** in the directory (skip directories, skip `sig.json`);
2. Convert each file's relative path to slash-separated form and run the path security check;
3. **Sort globally by the relative path string in ascending byte order**;
4. For each file: write the `relative path` + `"\n"`, then write `sha256hex(file content)` + `"\n"`;
5. Run one SHA-256 over the whole written stream to get a 32-byte digest;
6. `packageHash = hex.EncodeToString(digest)`.

Pseudocode:

```text
h := sha256.New()
for _, f := range 目录内全部文件（相对路径按字节升序，排除 sig.json）:
    h.Write([]byte(f.rel)); h.Write([]byte("\n"))
    h.Write([]byte(hex(sha256(f.content)))); h.Write([]byte("\n"))
digest = h.Sum(nil)   // 32 字节
```

Boundary cases:

- `sig.json` itself **does not participate** in the digest (otherwise the signature could not be self-consistent);
- An empty directory (no participating files) returns the error "tool directory is empty (no files)";
- More than **512** files returns an error;
- A single file > **32MB** returns an error.

> [!WARNING]
> **Global sorting is load-bearing**. A normal directory walk only guarantees lexicographic order within each directory, and recursing into subdirectories breaks the global order (for example, `a/b.txt` would come before `a.txt`). If either side misses the global sort, the digests differ and no tool can be installed.

The fixed reference value (usable to check whether your implementation is consistent). The reference directory is:

```text
tool.json      = {"name":"demo_tool"}
bin/tool.exe   = MZ-fake-exe
bin/lib.dll    = fake-dll
bin/helper.dll = helper
sig.json       = {"shouldBeIgnored":true}   # 被排除
```

The correct digest (`fileCount = 4`):

```text
bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140
```

### 5.13 Whose Public Key Is Used for Verification

During verification, AiAgent **explicitly receives** the controller public key held by the host and **never uses the `userPubkey` carried in `sig.json`**. The verdict branches are:

| Situation | Status | Result |
|---|---|---|
| No `sig.json`, or `userSignature` is empty | `unsigned` | Not registered; the user must authorize in the GUI and the controller re-signs |
| A signature exists but the host has no controller public key yet | `verify_failed` | Not admitted (better unavailable than admitted without credentials) |
| `packageHash` does not match the actual digest | `verify_failed` | Files in the directory were added/removed/changed (typically: an extra DLL was dropped in) |
| `userSignature` fails to verify the digest | `verify_failed` | Not issued by this machine's controller key (self-signing is rejected here) |
| The digest matches and the signature verifies | `verified` | Can be registered for AI calls |

### 5.14 Handling Paths for the Three Branches

- `verified`: registered normally; the model can call it.
- `verify_failed`: rejected; usually "changed after loading" or "not issued by this machine's controller key". Re-deliver it or use GUI authorization to re-sign.
- `unsigned`: requires explicit user authorization. After authorization the controller re-signs:
  - If the tool is **local to the controller**: the controller directly recomputes the digest over the directory and signs it, then broadcasts to all AiAgents;
  - If the tool is on a **remote AiAgent**: the controller cannot read the bytes, so the node first reports `(toolDir, packageHash)`, and after the passphrase check passes the controller issues and delivers a triple of "digest signature + authorization token + expiry time" (command `AiExternalToolSignGrant`). The node writes `sig.json` only after **all three steps pass**:
    1. The authorization token was issued by the controller **for this `toolDir` + this digest + this expiry time** and has not expired (the token payload is `TestSecScan-ExtTool-Sign-v1|<toolDir>|<hash>|<expire>`, identical word for word on both sides);
    2. The locally recomputed digest must match the `packageHash` in the authorization (to prevent files from being replaced after authorization);
    3. The signature verifies against the digest.

> [!NOTE]
> The authorization token binds a triple of "tool name + digest + expiry time" and is valid for **10 minutes**. This way "controller-authorized signing" cannot be abused as a signing oracle for arbitrary content: an intercepted authorization can only re-sign the exact content it was issued for, and it expires quickly.

### 5.15 How Developers Can Self-Test Signing

External tools **must be signed by the controller's private key** (self-signing is rejected), so third-party developers cannot offline "craft a signature that passes". In practice two paths are recommended:

1. **Official flow (recommended)**: in a development environment with a controller installed, open the GUI "AI pentest agent → External tool invocation" tab:
   - **Delivery**: upload the tool package (single file or zip); the controller extracts it → writes `tool.json` → **signs automatically on delivery** → broadcasts to all online AiAgents;
   - **Re-signing**: copy the tool directory directly into AiAgent's `<AiConfig>/externaltools/<toolDir>/`; after a scan AiAgent reports it as `unsigned`, and once the GUI starts it aggregates unsigned items and, after you enter the **signing passphrase** (at least 6 characters), the controller re-signs.
2. **Check the package digest yourself**: whether a signature is self-consistent starts with whether the digest is right. Compute it locally with an algorithm that is word-for-word identical on both sides:

```go
// 与两侧摘要算法逐字节一致
func digest(dir string) (string, int, error) {
	type e struct{ rel, path string }
	var es []e
	err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return err
		}
		rel, _ := filepath.Rel(dir, p)
		rel = filepath.ToSlash(rel)
		if rel == "sig.json" { // 签名文件自身不参与
			return nil
		}
		es = append(es, e{rel: rel, path: p})
		return nil
	})
	if err != nil {
		return "", 0, err
	}
	sort.Slice(es, func(i, j int) bool { return es[i].rel < es[j].rel }) // 全局排序承重！
	h := sha256.New()
	for _, it := range es {
		data, _ := os.ReadFile(it.path)
		sum := sha256.Sum256(data)
		h.Write([]byte(it.rel)); h.Write([]byte("\n"))
		h.Write([]byte(hex.EncodeToString(sum[:]))); h.Write([]byte("\n"))
	}
	return hex.EncodeToString(h.Sum(nil)), len(es), nil
}
```

Self-test steps (step by step):

1. **Delete `sig.json` first** from the tool directory (it does not participate in the digest, but this avoids confusion);
2. Use `digest(dir)` above to compute the hex;
3. Compare it with `packageHash` in `sig.json` (`EqualFold`, case-insensitive);
4. If they differ, the directory content changed after packaging — common causes: one extra file, `tool.json` reformatted by an editor, line endings changed from LF to CRLF, or hidden files added;
5. If they match, the digest is self-consistent; the rest is up to the controller's verification (signing with a self-generated key will always fail, so do not try).

> [!WARNING]
> On Windows, file traversal/reading yields deterministic content for the same file, but **newline/encoding differences** (CRLF vs LF, BOM) make `sha256hex(content)` different → different digests. Before distributing, use the packaging method built into the GUI delivery flow to produce the zip; do not re-save files with tools that rewrite text content.

---

## 6. Runtime ABI Deep Dive

AiAgent stays pure Go (`CGO_ENABLED=0`, cross-compilable, no CGo), so there are only two runtimes:

| Runtime | `runtime` value | Execution method | Suitable scenario |
|---|---|---|---|
| Native executable | `exec` | The entry file is executed as a **child process** | Native programs compiled from any language; multi-DLL tools |
| WASI module | `wasm` | wazero loads a WASI command module | Tools that want no platform dependency and sandboxing |

### 6.1 Unified ABI: stdin In / stdout Out

Both runtimes **share the same ABI** (the stdin/stdout protocol idea is identical); you do not need to learn a new host-function set:

- **Input**: AiAgent serializes the parameter object into JSON and writes it to your process/module's **stdin**. If the model gave no parameters (or serialization failed), the host writes `{}`.
- **Output**: write the result **text** to **stdout**; AiAgent captures it and returns it to the model as the observation.
- **Fallback**: in `exec`, if stdout is blank while stderr is not empty, the content of stderr is used instead (some tools are used to writing results to stderr, and the host does not discard it); the same applies to `wasm` (when stdout is empty, stderr is used).
- **A non-zero exit code** (`exec`) is treated as failure, returning "external tool execution failed: <err>\n<stderr>".
- Output over **64KB** is truncated and `\n...[输出过长已截断]` is appended.

**What the JSON actually sent by the host looks like**: it is exactly the `arguments` object filled in by the model, with fields from the schema you declared in `parameters`. For example, if `parameters` declares `target` and `port`, the stdin the host writes to you when the model calls is:

```json
{"target":"10.0.0.5","port":22}
```

**What you should output**: plain text is enough; it becomes the observation the model sees. Outputting **structured JSON or a one-line summary** is recommended, as it is easy for the model to parse and avoids truncation beyond 64KB.

### 6.2 `exec` Details

- The entry file is **executed directly** (**without going through a shell**);
- **The working directory is fixed to the tool directory**, so relative-path dependencies (same-directory DLLs, configuration, resources) can be found by the entry program itself — a multi-DLL tool is exactly "entry exe + same-directory DLLs, loaded by the exe itself";
- The `args` from the manifest are appended after the executable as fixed command-line arguments;
- stdin is explicitly provided so that the child process cannot read the parent's terminal input;
- Timeout → returns "external tool execution timed out (%ds), terminated." and appends stderr;
- Non-zero exit code → returns "external tool execution failed: " + err + "\n" + stderr;
- Extracted files are written with mode `0644` (no executable bit by default). Windows has no concept of an executable bit; on Linux, note this if your tool relies on one.

### 6.3 `wasm` Details

- wazero executes a WASI command module (`wasi_snapshot_preview1`); on timeout the context is closed and the module is terminated;
- **The tool directory is mounted at `/tool`**, so you can read resources shipped with the package;
- The module's `argv[0]` is the tool name, and there are **no extra command-line arguments** (the `args` field has no effect under wasm);
- The parameter JSON is written to stdin and the result text is read from stdout.

**The complete way to read a bundled resource file**: put a `cidr_note.txt` in the tool directory `ip_calc/` and read it inside the module like this:

```go
b, err := os.ReadFile("/tool/cidr_note.txt")
if err == nil {
	note = string(b)
}
```

The path **must be `/tool/<relative path>`**; for example, if the tool directory has `data/dict.txt`, read `/tool/data/dict.txt`. Accessing other paths (such as `/etc/passwd`, `/tmp`) fails because only `/tool` is mounted.

### 6.4 Timeout and Output Truncation

- The timeout uses the normalized `timeoutSec`: default `60` seconds, range `[5, 600]`; `<=0` takes the default, `<5` is clamped to `5`, `>600` is clamped to `600`.
- The upper limit of the output returned to the model is **64KB**; excess is truncated and `\n...[输出过长已截断]` is appended.

### 6.5 End-to-End Minimal Example (`exec`): `sha256_text`

This is a **complete, runnable** minimal exec tool specifically demonstrating the ABI.

**Directory layout**:

```text
sha256_text/
├── tool.json
└── sha256_text.exe
```

**Full `main.go`**:

```go
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
)

// 与 tool.json 的 parameters 一致：宿主把该 JSON 写入 stdin
type request struct {
	Text string `json:"text"`
}

type response struct {
	Sha256 string `json:"sha256"`
	Length int    `json:"length"`
	Error  string `json:"error,omitempty"`
}

func main() {
	var req request
	if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
		fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err)
		os.Exit(2)
	}
	if req.Text == "" {
		fmt.Fprintln(os.Stderr, "text 不能为空")
		os.Exit(2)
	}
	sum := sha256.Sum256([]byte(req.Text))
	out, _ := json.Marshal(response{Sha256: hex.EncodeToString(sum[:]), Length: len(req.Text)})
	fmt.Println(string(out)) // 结果写 stdout，宿主回传模型
}
```

**Full `tool.json`**:

```json
{
  "name": "sha256_text",
  "description": "计算一段文本的 SHA-256 十六进制摘要与字节长度。纯计算，无网络、无文件副作用。",
  "usage": "text 为待计算的文本，UTF-8 编码。返回 JSON：{\"sha256\":\"...\",\"length\":3}。",
  "entry": "sha256_text.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "text": { "type": "string", "description": "待计算的文本" }
    },
    "required": ["text"]
  },
  "perm": 0,
  "timeoutSec": 10,
  "version": "1.0.0",
  "author": "third-party"
}
```

**Build commands**:

```bash
# Windows
set CGO_ENABLED=0
go build -o sha256_text.exe .

# Linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o sha256_text .
```

**Where to put it**: pack `sha256_text/` (`tool.json` + the build output) into a zip and deliver it through the GUI, or copy it directly into `<AiConfig>/externaltools/sha256_text/` and use re-signing.

**End-to-end walkthrough**: the model sees `description`/`usage`/`parameters`, constructs `{"text":"hello"}` and calls `sha256_text`; AiAgent starts a child process whose working directory is the tool directory, stdin receives `{"text":"hello"}`, stdout outputs `{"sha256":"2cf24dba...","length":5}`, and the model receives that observation.

### 6.6 End-to-End Minimal Example (`wasm`): `res_reader`

This is a **complete, runnable** minimal wasm tool specifically demonstrating `/tool` resource reading.

**Directory layout**:

```text
res_reader/
├── tool.json
├── tool.wasm
└── note.txt          # 随包资源（演示 /tool 挂载读取）
```

**Full `main.go`**:

```go
package main

import (
	"encoding/json"
	"fmt"
	"os"
	"strings"
)

type request struct {
	Name string `json:"name"`
}

func main() {
	var req request
	_ = json.NewDecoder(os.Stdin).Decode(&req)
	if req.Name == "" {
		req.Name = "world"
	}

	// 工具目录被挂载到 /tool，可读取随包自带的资源
	note := ""
	if b, err := os.ReadFile("/tool/note.txt"); err == nil {
		note = strings.TrimSpace(string(b))
	}

	// 结果文本写 stdout 即被宿主回传
	fmt.Printf("hello %s | note=%s\n", req.Name, note)
}
```

**Full `tool.json`**:

```json
{
  "name": "res_reader",
  "description": "读取随包自带的 note.txt 并拼一行问候，用于演示 WASI 沙箱内的 /tool 资源读取。",
  "usage": "name 为可选称呼。返回一行文本：hello <name> | note=<内容>。",
  "entry": "tool.wasm",
  "runtime": "wasm",
  "parameters": {
    "type": "object",
    "properties": {
      "name": { "type": "string", "description": "称呼（可选，默认 world）" }
    }
  },
  "perm": 0,
  "timeoutSec": 10
}
```

**Build command**:

```bash
GOOS=wasip1 GOARCH=wasm go build -o tool.wasm .
```

**Where to put it**: pack `res_reader/` (`tool.json` + `tool.wasm` + `note.txt`) into a zip and deliver it, or copy it into `<AiConfig>/externaltools/res_reader/`.

**End-to-end walkthrough**: the model calls `res_reader` with `{"name":"testsec"}`; wazero instantiates the module (`argv[0]` = `res_reader`), stdin receives that JSON, the module reads the resource from `/tool/note.txt`, stdout returns `hello testsec | note=<content>`, and the host returns it to the model. Because it is a WASI module, it is cross-platform without changing the build target; the module has no system-call privileges, so the only thing it can read is the bundled resource mounted at `/tool`.

---

## 7. Where the Entry Point Is: How AiAgent Discovers, Verifies and Hands the Tool to the Model

This chapter breaks the complete chain "from a directory on disk to a model-callable tool" into steps. Once you understand it, you will know why a tool "is installed but invisible" and why "editing a file triggers a signature problem".

### 7.1 The Complete Chain (Step by Step)

1. **Directory location**: the tool lives in `<AiConfig>/externaltools/<toolDir>/`. `<toolDir>` must satisfy the naming rules (starts with a lowercase letter, 3~64, `[a-z0-9_]`). Directories starting with `.` (such as `.staging`) are skipped by scanning.
2. **Rescan triggers**: any of the following occasions rescans the directory and rebuilds both the in-memory registry and the on-disk reality list (no AiAgent restart needed):
   - AiAgent startup (at this point the in-memory public key is empty → signed tools are temporarily judged `verify_failed` and not registered);
   - installing/overwriting a tool package;
   - deleting a tool (idempotent: succeeds even if the directory does not exist);
   - the controller delivering the signing public key (**after a key change, old signatures are immediately re-judged `verify_failed`**);
   - authorized re-signing;
   - the controller requesting a refresh (`AiExternalToolQuery`) → rescan + report the list.
3. **Read manifest + compute digest + verify signature**: for each directory: read `tool.json` (if missing, record `error="缺少 tool.json"`) → compute the package digest → verify with the **controller public key** (the `userPubkey` carried in `sig.json` is **not trusted**; when the host does not yet have the controller public key, everything is judged `verify_failed`).
4. **Only `verified` enters the registry**: registration requires all three — the signature status is `verified` **and** the manifest exists **and** there is no error **and** `entry` is non-empty, safe, inside the directory and actually present. Otherwise it only appears in the on-disk reality list and is not registered.
5. **Turn it into a model-visible tool**: when a session is created, AiAgent converts each loaded tool into a model-callable tool entry:
   - tool name = the normalized `name`;
   - description = `description` + optional `【使用方法】` + `【参数】…` + `【返回】…` + `【运行时】…` for wasm;
   - parameters = your schema (a permissive object if none);
   - permission = the normalized `perm` level;
   - the entry carries `external=true` and `toolDir`;
   - the call closure captures the tool directory and manifest, and goes through pre-call re-verification.
   Tool names are sorted lexicographically to keep the order of the tool table the model sees stable.
6. **Model-side presentation**: AiAgent converts tool name / description / parameters into OpenAI tools format. **External tools are not filtered by the skill whitelist** — the skill library cannot possibly know in advance the names of user-built tools, and filtering them out would make the feature invisible.
7. **`perm` affects confirmation**: `0` executes automatically; `1` sends a warn event with red highlighting first but does not block; `2` requires manual GUI confirmation (unattended handled per `UnattendedPolicy`).
8. **Sequence during a model call**:
   1. the model returns `tool_call(name, arguments)`;
   2. **verify the signature once more** before execution (still with the controller public key held by the host) — this is the TOCTOU protection; anything changed after loading is intercepted here;
   3. check that the `entry` path is safe and the file exists;
   4. serialize `args` into JSON (write `{}` on failure/empty);
   5. start a child process (`exec`) or instantiate wasm (`wasm`);
   6. write the parameter JSON to stdin → read stdout (fall back to stderr if empty);
   7. timeout / output truncation;
   8. return the output to the model.
   - If the signature has become invalid at call time, it returns "external tool [name] signature verification failed (status), execution refused. The tool may have been modified after loading; please re-deliver it or authorize signing in the GUI." and logs a warn.
9. **Invalid signature after file changes → an intentional "re-sign + reload" flow is required**: the signature covers the whole directory, so changing any file in the package (including recompiling the entry) changes the digest and invalidates the signature. This is not a bug but a necessary consequence of the security model. The correct handling: go through **delivery** again (the controller signs automatically and broadcasts; nodes reinstall and rescan) or **GUI-authorized re-signing** (after authorization the controller re-signs → the node writes the file and rescans). No AiAgent restart is needed.

### 7.2 Behavior Overview: Install → Load → Call → Report

The following describes AiAgent's behavior in four phases, "install → load → call → report":

- **Install**: receive the tool package → complete verification and content validation in the staging area → atomically rename into the formal directory → rescan;
- **Load**: walk the tool directories → read manifests, compute package digests, verify with the controller public key → only `verified` directories enter the model-visible tool table;
- **Call**: the model calls a tool by name → re-verify the signature before execution → start a child process or wasm module per `runtime` → stdin in / stdout out → handle timeout and output truncation;
- **Report**: after actions such as install, delete, key change, re-signing and refresh, report the on-disk reality list to the controller and GUI.

### 7.3 AiAgent-Side Command Handling

| Command | Direction | AiAgent behavior |
|---|---|---|
| `AiExternalToolInstall` | Controller→AiAgent | Install the tool package → reply `AiExternalToolInstallAck` + report the list |
| `AiExternalToolDelete` | Controller→AiAgent | Delete the tool → receipt + report |
| `AiExternalToolSetKey` | Controller→AiAgent | Update the controller public key in memory → rescan and report |
| `AiExternalToolSignGrant` | Controller→AiAgent | Three-step re-signing validation → write `sig.json` → rescan and report |
| `AiExternalToolQuery` | Controller→AiAgent | Rescan + report the list |
| Proactive list reporting | AiAgent→Controller | Report the list (the GUI unsigned warning depends on it, so the GUI can see "possibly a malicious tool") |
| `AiExternalToolInstallAck` | AiAgent→Controller | Install/delete receipt (the controller forwards it to the GUI) |

After every handling, the list is reported once so that what the controller/GUI shows stays consistent with the node's actual state.

### 7.4 Key Difference: Registry vs On-Disk Reality

| View | Source | Includes unsigned items? | Purpose |
|---|---|---|---|
| AI tool registry (model-visible) | The in-memory set of loaded tools | **No** (only `verified`) | Model function calling |
| Reported/directory list | The on-disk reality obtained by rescanning | **Yes** | GUI display of signature status, `unsigned` warnings |
| Skill library merged report | Registry entries + on-disk reality, deduplicated | Yes | Skill library page display |

> [!NOTE]
> In the merged report, the signature status of registry entries is always `verified` ("anything that enters the registry must have passed verification"); only the on-disk reality list may contain `unsigned` / `verify_failed`. External tool entry fields: `external=true`, `toolDir`, `signStatus`, `runtime`, `usage`.

---

## 8. Hot Reload

AiAgent rescans the root directory and rebuilds the in-memory registry and the on-disk reality list, **without restarting AiAgent**.

Triggers:

| Trigger | Behavior |
|---|---|
| AiAgent startup | Rescan (at this point the in-memory public key is empty → signed tools are temporarily judged `verify_failed` and not registered) |
| Install/overwrite a tool package | Rescan automatically |
| Delete a tool | Rescan automatically (idempotent: succeeds even if the directory does not exist) |
| Controller delivers the signing public key | Rescan automatically (**after a key change, old signatures are immediately re-judged `verify_failed`**) |
| Authorized re-signing | Rescan automatically |
| Controller requests a refresh | Rescan + report the list (`AiExternalToolQuery`) |

> [!IMPORTANT]
> **"Change code → re-verify → reload" is an intentional flow**: the signature covers the whole directory, so changing any file in the package (including recompiling the entry) changes the digest and invalidates the signature. This is not a bug but a necessary consequence of the security model — anything modified must be re-signed to be trusted. During development, after changing the code just go through delivery or re-signing again; restarting AiAgent is not necessary.

---

## 9. Limits and Quotas

| Item | Limit | Notes |
|---|---|---|
| Tool package (zip) size | 64MB | Before delivery there is also a circuit breaker on the base64 size (the controller throttles at 96MB) |
| Single file size | 32MB | Extraction reads with "limit + 1" so that exceeding the limit raises an error instead of silently truncating |
| File count | 512 | File count in a directory / zip |
| Output returned to the model | 64KB | Excess is truncated and `\n...[输出过长已截断]` is appended |
| Tool name length | 64 | Also the limit for the model-side function name |
| Default timeout | 60s | Can be overridden by `timeoutSec`, range `[5,600]` |
| Authorization token validity | 10 minutes | Expires and requires re-authorization |
| Minimum signing passphrase length | 6 characters | Controller side only, used for automatic signing authorization |
| Authorization token signing scope | `TestSecScan-ExtTool-Sign-v1` | Token payload `scope|toolDir|hash|expire` |

**Path security**: relative paths inside the package must pass the security check — empty paths, `.`, `..`, a leading `/` or `\`, absolute paths, drive-letter prefixes (`C:/x`), and `a/../../b` escapes are rejected. Extraction has a second gate: it verifies that the actual joined destination is still inside the target directory and guards against Zip Slip.

> [!CAUTION]
> **Paths that differ only in case are rejected**. On Linux/macOS `bin/Lib.dll` and `bin/lib.dll` can coexist, but when extracting on Windows they **overwrite each other** leaving only one → the file set on the node differs from the signed content → digest mismatch → the node reports "verification failed" (the real cause is a cross-platform case difference). The pre-install path case-conflict check rejects the package with a clear reason; rename the conflicting files and repackage. Note: this check does not handle homographic paths caused by Unicode normalization (NFC/NFD).

---

## 10. Complete Examples

The following three examples all run as-is. The source code in the examples goes in the **package root**, with `tool.json` at the same level, and the entry is specified by the manifest's `entry`.

### 10.1 Example 1: Native Go Executable Tool (`exec`)

**Directory layout**

```text
port_probe/
└── tool.json          # 清单
    port_probe.exe     # 入口（编译产物）
```

**`main.go`**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net"
	"os"
	"time"
)

// 参数对象与 tool.json 的 parameters 一致：宿主把该 JSON 写入 stdin
type request struct {
	Target string `json:"target"`
	Port   int    `json:"port"`
}

type response struct {
	Open   bool   `json:"open"`
	Banner string `json:"banner"`
	Error  string `json:"error,omitempty"`
}

func main() {
	var req request
	if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
		// 出错时写 stderr；宿主在 stdout 为空时会回退读取 stderr
		fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err)
		os.Exit(2)
	}
	if req.Target == "" || req.Port <= 0 || req.Port > 65535 {
		fmt.Fprintln(os.Stderr, "target/port 非法")
		os.Exit(2)
	}

	addr := fmt.Sprintf("%s:%d", req.Target, req.Port)
	conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
	if err != nil {
		out, _ := json.Marshal(response{Open: false, Error: err.Error()})
		fmt.Println(string(out)) // 结论仍写 stdout，让模型拿到结构化结果
		return
	}
	defer conn.Close()

	banner := make([]byte, 128)
	_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
	n, _ := conn.Read(banner)
	out, _ := json.Marshal(response{Open: true, Banner: string(banner[:n])})
	fmt.Println(string(out))
}
```

**`tool.json`**

```json
{
  "name": "port_probe",
  "description": "对目标主机的单个端口做 TCP 连通性探测，返回开放状态与 banner 摘要。",
  "usage": "target 支持域名或 IP；port 为 1-65535 的整数。返回 JSON：{\"open\":true,\"banner\":\"...\"}。",
  "entry": "port_probe.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "target": { "type": "string", "description": "目标主机（域名或 IP）" },
      "port": { "type": "integer", "description": "TCP 端口号（1-65535）" }
    },
    "required": ["target", "port"]
  },
  "perm": 1,
  "timeoutSec": 30,
  "version": "1.0.0",
  "author": "security-team"
}
```

**Build commands**

```bash
# Windows（在包目录内执行）
set CGO_ENABLED=0
go build -o port_probe.exe .

# Linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o port_probe .
```

**Where to put it**: pack `port_probe/` (containing `tool.json` and the compiled `port_probe.exe`) into a zip and deliver it through the GUI; or copy it directly into `<AiConfig>/externaltools/port_probe/` and use re-signing. If the target is a Windows node, compile a `.exe`; if it is a Linux node, compile an extension-less ELF — **an `exec` tool must match the host platform/architecture**.

**How the model will call it**: after reading the schema, the model constructs the following parameters:

```json
{
  "target": "10.0.0.5",
  "port": 22
}
```

**What you will receive**: the child process's working directory is `port_probe/`, and stdin receives the JSON above.

**What you should output**: stdout outputs `{"open":true,"banner":"SSH-2.0-OpenSSH_8.9"}`, which the host returns to the model; the model judges port openness and fingerprint from it.

### 10.2 Example 2: Go WASI wasm Tool (`wasm`)

**Directory layout**

```text
ip_calc/
├── tool.json          # 清单
├── tool.wasm          # 入口（WASI 编译产物）
└── cidr_note.txt      # 随包资源（演示 /tool 挂载读取）
```

**`main.go`**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net"
	"os"
)

type request struct {
	CIDR string `json:"cidr"`
}

func main() {
	var req request
	_ = json.NewDecoder(os.Stdin).Decode(&req)

	// 工具目录被挂载到 /tool，可读取随包自带的资源
	note := ""
	if b, err := os.ReadFile("/tool/cidr_note.txt"); err == nil {
		note = string(b)
	}

	_, ipnet, err := net.ParseCIDR(req.CIDR)
	if err != nil {
		fmt.Fprintf(os.Stderr, "CIDR 非法: %v\n", err)
		os.Exit(2)
	}
	ones, bits := ipnet.Mask.Size()
	// 结果文本写 stdout 即被宿主回传
	fmt.Printf("网络=%s 掩码位=%d/%d 主机位=%d 备注=%s\n",
		ipnet.IP.String(), ones, bits, bits-ones, note)
}
```

**`tool.json`**

```json
{
  "name": "ip_calc",
  "description": "解析一个 CIDR 并返回网络地址、掩码位与可用主机位数。",
  "usage": "cidr 形如 10.0.0.0/24。返回一行文本；随包附带的 cidr_note.txt 会一并回显。",
  "entry": "tool.wasm",
  "runtime": "wasm",
  "parameters": {
    "type": "object",
    "properties": {
      "cidr": { "type": "string", "description": "CIDR 字符串，如 10.0.0.0/24" }
    },
    "required": ["cidr"]
  },
  "perm": 0,
  "timeoutSec": 10
}
```

**Build command**

```bash
GOOS=wasip1 GOARCH=wasm go build -o tool.wasm .
```

**Where to put it**: pack `ip_calc/` (`tool.json` + `tool.wasm` + `cidr_note.txt`) into a zip and deliver it, or copy it into `<AiConfig>/externaltools/ip_calc/`.

**How the model will call it**:

```json
{
  "cidr": "10.0.0.0/24"
}
```

**What you will receive**: the wasm module is instantiated (`argv[0]` = `ip_calc`), stdin receives the JSON above, and `/tool/cidr_note.txt` is readable.

**What you should output**: stdout returns `网络=10.0.0.0 掩码位=24/32 主机位=8 备注=...`. Because it is a WASI module, it is cross-platform without changing the build target; the module has no system-call privileges, and what it can read is only the bundled resource mounted at `/tool`.

> [!TIP]
> When you need the network/file capabilities of the Go standard library, note: the WASI sandbox mounts only `/tool`, so writing to or accessing other paths fails. Tools that need networking or raw packet sending should use `exec`.

### 10.3 Example 3: With a Parameter Schema and `perm: 2` (Requires User Confirmation)

**Directory layout**

```text
upload_marker/
├── tool.json
└── bin/
    └── upload_marker.exe
```

**`main.go`** (the key points are "read nested parameters + write operation + write the conclusion to stdout")

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"mime/multipart"
	"net/http"
	"os"
	"time"
)

type auth struct {
	Cookie string `json:"cookie"`
	Bearer string `json:"bearer"`
}

type request struct {
	URL    string `json:"url"`
	Field  string `json:"field"`
	Marker string `json:"marker"`
	Auth   auth   `json:"auth"`
}

func main() {
	var req request
	if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
		fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err)
		os.Exit(2)
	}
	if req.Field == "" {
		req.Field = "file"
	}

	var body bytes.Buffer
	mw := multipart.NewWriter(&body)
	fw, _ := mw.CreateFormFile(req.Field, "marker.txt")
	_, _ = fw.Write([]byte(req.Marker))
	_ = mw.Close()

	httpReq, _ := http.NewRequest("POST", req.URL, &body)
	httpReq.Header.Set("Content-Type", mw.FormDataContentType())
	if req.Auth.Cookie != "" {
		httpReq.Header.Set("Cookie", req.Auth.Cookie)
	}
	if req.Auth.Bearer != "" {
		httpReq.Header.Set("Authorization", "Bearer "+req.Auth.Bearer)
	}
	client := &http.Client{Timeout: 20 * time.Second}
	resp, err := client.Do(httpReq)
	if err != nil {
		fmt.Fprintf(os.Stderr, "上传失败: %v\n", err)
		os.Exit(1)
	}
	defer resp.Body.Close()
	fmt.Printf("上传完成：HTTP %d（marker=%s）\n", resp.StatusCode, req.Marker)
}
```

**`tool.json`** (note the nested object schema and `perm: 2`)

```json
{
  "name": "upload_marker",
  "description": "向目标上传一个无害标记文件，验证文件上传漏洞是否可被利用（会在目标写入文件，默认需用户确认）。",
  "usage": "url 为上传接口；field 为文件表单字段名（默认 file）；marker 为写入内容，便于事后清理定位；auth 为可选认证信息。返回一行文本说明是否上传成功，不返回目标响应体全文。",
  "entry": "bin/upload_marker.exe",
  "runtime": "exec",
  "parameters": {
    "type": "object",
    "properties": {
      "url": { "type": "string", "description": "上传接口完整 URL" },
      "field": { "type": "string", "description": "文件表单字段名（默认 file）" },
      "marker": { "type": "string", "description": "写入目标的标记内容" },
      "auth": {
        "type": "object",
        "description": "认证信息（可选）",
        "properties": {
          "cookie": { "type": "string", "description": "Cookie 头原文" },
          "bearer": { "type": "string", "description": "Bearer Token" }
        }
      }
    },
    "required": ["url", "marker"]
  },
  "perm": 2,
  "timeoutSec": 45,
  "version": "1.0.0",
  "author": "security-team"
}
```

**Build command**

```bash
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/upload_marker.exe .
```

**Where to put it**: pack `upload_marker/` (`tool.json` + `bin/upload_marker.exe`) and deliver it; because the entry is in the `bin/` subdirectory, `entry` must be written as the relative path `bin/upload_marker.exe` (slash-separated).

**How the model will call it**: after reading the schema, the model constructs the following parameters and calls `upload_marker`; note that it expands the optional `auth` object into nested JSON:

```json
{
  "url": "https://target.example.com/api/upload",
  "field": "file",
  "marker": "testsecscan-upload-check",
  "auth": { "cookie": "SESSION=abc123" }
}
```

**What you will receive**: because `perm: 2`, AiAgent first handles it as "full user confirmation" — it pops up a manual confirmation in the GUI (unattended tasks follow `UnattendedPolicy`), and only executes after confirmation; stdin then receives the JSON above.

**What you should output**: stdout returns `上传完成：HTTP 200（marker=testsecscan-upload-check）`.

> [!CAUTION]
> An improper `perm` setting directly affects the experience: setting a read-only probe to `2` wrongly makes the AI stop at every step waiting for confirmation (and unattended runs are interrupted by policy even more); setting a write operation to `0` wrongly executes dangerous actions silently. Choose strictly according to the actual side effects.

---

## 11. Debugging Handbook

### 11.1 Where to Look at the Three Kinds of Logs

| Layer | Location | What to look at |
|---|---|---|
| AiAgent log | AiAgent process log | Scan summary `[AiAgent] external tool scan: %d dir(s), %d registered (signature verified)`; install `installed (name=… runtime=… entry=…)`; delete; re-signing `signed by controller grant (packageHash=…)`; call refused `refused at call time: signature status=%s`; public key sync `controller signing pubkey synced` |
| GUI external tool list | GUI "AI pentest agent → External tool invocation" tab | Each tool's `signStatus` (`verified`/`verify_failed`/`unsigned`), `packageHash`, `fileCount`, `runtime`, `entry`, `error`; controller-local items and items reported by each online AiAgent; `unsigned`/`verifyFailed` aggregate warnings; `signPub`, `keyIntegrity` |
| Controller delivery result receipts | Return values of GUI delivery/delete/authorization | Delivery result (`success`/`toolDir`/`packageHash`/`fileCount`/`agents`/`message`); node install receipt (`success`/`signStatus`/`error`); delete result; automatic signing result (`signed`/`failed`/`results[]`) |

### 11.2 Five-Minute Minimal Verification Flow

1. **Write the tool**: create a directory `<toolDir>/`, write `tool.json` and `main.go` (you can copy the minimal example from Chapter 6);
2. **Build**: for `exec` use `CGO_ENABLED=0 go build -o <entry> .`; for `wasm` use `GOOS=wasip1 GOARCH=wasm go build -o tool.wasm .`;
3. **Self-test the signature**: compute `packageHash` locally with the algorithm in 5.12 and compare it with the `sig.json` the controller writes later, to confirm that packaging did not change the content;
4. **Put it in the directory**: copy it into `<AiConfig>/externaltools/<toolDir>/` (remote node) or use GUI delivery (recommended, signs automatically);
5. **Trigger reload/query**: click refresh in the GUI, or have the controller send `AiExternalToolQuery`;
6. **Check the signature status in the GUI**: confirm the item is `verified`; if `unsigned`, enter the signing passphrase to authorize re-signing; if `verify_failed`, look at `error`;
7. **Have the AI call it once**: in an AI session, have the model call the tool (or construct a call directly);
8. **Check the return**: see whether the observation the model receives is your stdout; if it reports "signature verification failed", the files were changed after loading.

### 11.3 "Symptom → Cause → Solution" Quick Reference

| # | Symptom | Cause | Solution |
|---|---|---|---|
| 1 | The tool is **invisible** in the AI tool table | Verification did not pass (`unsigned`/`verify_failed`), `tool.json` missing, `entry` does not exist, illegal tool name | Check the signature status and `error` in the GUI; re-sign or fix the manifest/entry |
| 2 | `sig.json` exists but it is still not registered / is rejected | **Self-signed public keys are not trusted**: verification uses only the controller public key; the `userPubkey` in `sig.json` is ignored | Use controller delivery or GUI passphrase-authorized re-signing; do not generate your own key |
| 3 | Visible after installation, but every call is refused | **Changed after loading** (TOCTOU): pre-call re-verification finds a digest mismatch | Re-deliver or authorize re-signing; check whether someone dropped a DLL into the directory |
| 4 | Reports "entry file does not exist: X" | The file pointed to by `entry` is not in the package / was deleted / the path is wrong | Check `entry` against the actual file; subdirectories need the path (`bin/tool.exe`) |
| 5 | Reports "paths that differ only in case" | `bin/Lib.dll` and `bin/lib.dll` coexist in the package and overwrite each other when extracting on Windows | Rename the conflicting files and repackage |
| 6 | Reports "tool package exceeds the 64MB limit" / "file exceeds 32MB" / "file count exceeds the limit of 512" | The corresponding quota is exceeded (see Chapter 9) | Trim dependencies, separate large resources, or split the tool |
| 7 | Execution is terminated with "execution timed out (60s)" | `timeoutSec` exceeded (default 60, range 5~600) | Raise `timeoutSec` (≤600) or optimize the tool; `exec` terminates the process, `wasm` closes the context |
| 8 | The result seems lost or garbled | The result was written to **stderr** | Write results to stdout; when stdout is empty the host falls back to stderr, but stdout takes precedence, so explicitly use stdout |
| 9 | Output truncated with "output too long, truncated" appended | The returned content exceeds **64KB** (see the quota table in Chapter 9) | **Return only key conclusions** (structured JSON / one-line summary); write large logs to files instead of dumping them to stdout |
| 10 | The child process cannot find a DLL/resource/config | The misconception that **the working directory is not the tool directory** | The `exec` working directory **is fixed to the tool directory**; relative paths work; put DLLs/resources in the same directory as the entry |
| 11 | The model probes repeatedly / stops at every step | `perm` is set too high (`2` = full confirmation, or a read-only tool wrongly set to `2`) | Change read-only probes to `perm: 0`; use `1` for side effects that do not change the target state |
| 12 | The model passes parameters randomly / omits required ones | `parameters` is missing, or the schema does not match the fields the code reads | Complete `parameters` (including `required`) and align field names with the code's `json` tags |
| 13 | A wasm call reports "failed to compile wasm" | `runtime` is wrong (a native program treated as wasm), or the wasm target is not a WASI command module | Build with `GOOS=wasip1 GOARCH=wasm`; write `exec` for native tools |
| 14 | wasm cannot read resources | Only `/tool` is mounted | Put resources in the tool directory and access them via `/tool/...`; do not access other paths |
| 15 | On Linux, `exec` reports permission denied | Extraction writes mode `0644` (no executable bit by default) | Check the entry permissions; switch to `wasm` if necessary; Windows does not have this problem |
| 16 | Delivery rejected with "tool name conflicts with a built-in tool or reserved prefix" | The name hits a reserved name/prefix | Rename to avoid `finish`/`report_vuln`/`http_request`/`builtin_*` and the prefixes `browser_`/`file_`/`audit_`/`oob_`/`exttool_` |
| 17 | Delivery/installation rejected with "manifest name does not match directory name" | The `name` in `tool.json` differs from `<toolDir>` | The two must match; controller delivery automatically writes `name` as `toolDir` |
| 18 | All tools suddenly become `verify_failed` | Old signatures became invalid after the controller **changed its key**, or the node does not yet have the controller public key | Re-sign; confirm that the controller has connected and delivered the public key (`AiExternalToolSetKey`) |

### 11.4 Simulate a Call by Hand from the Command Line

You can verify the tool's ABI without starting an AI session:

```bash
# exec：把你的参数 JSON 直接喂给入口程序
cd /path/to/externaltools/sha256_text
echo '{"text":"hello"}' | ./sha256_text.exe

# 期望输出（stdout）
# {"sha256":"2cf24dba...","length":5}
```

Key points:

- **The parameter JSON is passed through stdin** (a pipe is enough; use single quotes to avoid shell escaping);
- Check stdout directly; if it is empty, check stderr (the host falls back to stderr when stdout is empty);
- Check the exit code: `echo $?` (Linux) or `echo %errorlevel%` (Windows) — a non-zero code is treated as an execution failure by the host;
- wasm tools cannot use this command (they need a WASI runner, such as `wasmtime run --dir .::/tool tool.wasm`); for routine verification it is recommended to call it directly through AiAgent;
- This command **does not go through signature verification**; it only verifies the ABI (parameters in, results out, exit code, output size and elapsed time). For signature problems, check the status in the GUI.

---

## 12. Checklist of Pitfalls

| Pitfall | Symptom | Avoidance |
|---|---|---|
| Forgetting to write the `entry` relative path | The controller/node reports "entry file does not exist" or "entry not declared" | Use a slash-separated relative path for `entry`, such as `bin/tool.exe`, `tool.wasm`; subdirectories must be included |
| Wrong `runtime` | A `.wasm` file is executed as `exec`, or a `.exe` fails to compile as wasm | Use `wasm` for WASI modules and `exec` for native programs; an empty value defaults to `exec`, and `wasi` normalizes to `wasm` |
| Not signed / self-signed | The tool does not enter the tool table (`unsigned`), or self-signing is judged `verify_failed` | Use controller delivery or GUI passphrase-authorized re-signing; **do not generate your own key to self-sign** |
| Paths in the package that differ only in case | The node reports "verification failed" (actually a cross-platform overwrite) | Rename the conflicting files and repackage (`bin/Lib.dll` and `bin/lib.dll` cannot coexist) |
| Output goes to stderr | The result seems lost | Write results to **stdout**; when stdout is empty the host falls back to stderr, but explicitly using stdout is still recommended |
| Returned content is too long | Truncated (64KB) | Return only key conclusions; avoid dumping full responses/logs |
| Improper `perm` setting | A read-only tool set to `2` → the AI repeatedly stops for confirmation; a write tool set to `0` → dangerous actions execute silently | Choose `0`/`1`/`2` according to side effects; external tools default to the most conservative `2` |
| Dependent DLLs and the working directory | DLL loading fails | Put dependencies in the same directory as the entry; the `exec` working directory is the tool directory, so reference them by relative path |
| Mixing `args` with stdin | The tool reads both command-line arguments and stdin, and arguments get misaligned | `args` are **fixed** command-line arguments; dynamic parameters are **read only from the JSON on stdin**; do not stuff dynamic parameters into `args` |
| Continuing to use an old signature after changing files | Calls report "signature verification failed" | After changing code/resources you must re-sign: re-deliver or re-sign, then hot reload |
| Tool name conflicts with a built-in/reserved name | Delivery rejected | Avoid `finish`/`report_vuln`/`http_request`/`builtin_*` and the prefixes `browser_`, `file_`, `audit_`, `oob_`, `exttool_` |
| Manifest `name` differs from the directory name | Installation rejected | The two must match; when generating `tool.json` on controller delivery, `name` is written as `toolDir` |
| Depending on CGo / native dynamic libraries | Cannot load | AiAgent is `CGO_ENABLED=0` and does not support `dlopen`; for multiple dependencies use "exe + same-directory DLLs" or switch to `wasm` |
| `description` is empty | Delivery/installation rejected with "tool description must not be empty" | Write one complete description from which the trigger condition can be judged |
| `parameters` inconsistent with the code fields | The tool cannot read the values passed by the model | Align schema field names word for word with the code's `json` tags; list only what is truly required in `required` |
| Writing a passphrase into `signPassState` | Serious security problem | This field holds only a state description; **never write the passphrase**; the signing passphrase is kept separately by the controller and never stored in plaintext |

---

> Related documents: [Plugin Development Overview](/docs/overview), [Controller Plugin Development](/docs/controller-plugin), [Application Plugin Development](/docs/scan-app-plugin), [WASM POC Template Development](/docs/scan-poc-wasm), [Go Hot-Load POC Development](/docs/go-poc-hotload).
