---
slug: scan-app-plugin
title: 扫描节点 · 应用插件开发
titleEn: Scan Node Application Plugin Development
summary: 以常驻业务插件钩住任务启动、TCP 收发与 MITM 流量：全部 应用_ 钩子与 app_* 宿主函数。
summaryEn: 'Hook task start, TCP I/O and MITM traffic as a resident plugin: all 应用_ hooks and app_* host functions.'
category: 扫描节点
order: 40
enabled: true
updatedAt: "2026-10-05"
---
# 扫描节点 · 应用插件开发

应用插件（Application Plugin）是扫描节点的**常驻业务插件**：它不扫描单个流量包，而是以 WASI wasm 模块的形式随扫描节点加载，通过**导出以 `应用_` 前缀命名的函数**来"钩住"扫描节点的关键业务点——任务启动、TCP 收发、MITM HTTP/WS/SSE 流量、任务流量包与任务收口。

本文面向第三方开发者，**逐个函数精讲**：每个钩子、每个宿主函数、SDK 里每个公开符号都给出签名、参数表、真实 JSON 样例与可直接复制的 Go 代码。读完应能独立完成"建工程 → 编译 → 放目录 → 触发 → 调试"的全流程。

> [!NOTE]
> 一句话选型：想"按流量包产出漏洞"→ 用 WASM POC 模板（`scan_*`）；想"常驻并钩住扫描节点业务点"→ 用应用插件（`app_*`）。两者宿主函数**互不通用**，详见第一章。

[[toc]]

---

## 一、它是什么

应用插件由控制器从插件商店下载后经 `AddPluginPackage` 下发到扫描节点的 `scan-poc/plugin/<uuid>/` 目录，扫描节点启动时由插件管理器扫描并加载。每个插件只实现自己需要的钩子，宿主只调用**确实导出了**的钩子函数。

### 1.1 与 WASM POC 的本质区别

| 维度 | WASM POC | 应用插件 |
|---|---|---|
| 目录 | `scan-poc/<lang>/wasm/<VulnIde>/` | `scan-poc/plugin/<uuid>/` |
| 入口 | `_start`（`func main`），每次处理一个流量包 | `应用_` 前缀的**导出函数**，按钩子触发调用 |
| 能力检测 | 固定（stdin JSON + `_start`） | **导出表检测**：宿主只调用实际导出的钩子 |
| 构建模式 | 普通 wasip1 命令模块 | **必须 `-buildmode=c-shared`**（宿主需 `_initialize` + 直调导出函数） |
| 宿主命名空间 | `scan_http` / `scan_log` / `scan_report` / `scan_call` / `scan_t` / `scan_config` / `scan_set_timeout` | `app_log` / `app_send_tcp*` / `app_call` / `app_spawn_tool` / … |
| 生效场景 | 仅在 **POC 模板列表**中 `PocType=wasm` 的条目 | 插件商店安装的扫描节点应用插件 |
| 适用场景 | 漏洞检测、单包请求响应分析 | 任务预处理、流量过滤、MITM 改写、TCP 指令扩展、外部工具集成 |
| SDK 文件 | `scan.go` | `app.go` |

> 表中两个 SDK 文件都在下载的 SDK 包里提供，复制到插件工程的 `app/` 子目录即可使用（见 5、6.3 节）。

### 1.2 两套宿主函数互不通用（核心约束）

扫描节点为两类 WASM 模块注入的是**两套完全不重叠的宿主函数**：

- WASM POC 路径只注册 `scan_*` 一组；
- 应用插件路径只注册 `app_*` 一组（由宿主注入到 wasm 的 `env` 模块）。

> [!WARNING]
> 把 `app.ExecTool(...)` 写进 WASM POC，或把 `scan.HTTP(...)` 写进应用插件，都会因为宿主**没有导出对应函数**而**实例化失败**（wasm 导入无法解析）。判断方法：你的产物在"POC 模板列表"里被当漏洞扫描 → 用 `scan.*`；在"插件商店/应用插件目录"里作为常驻插件跑 → 用 `app.*`。控制器插件用 `ctl_*`，AiAgent 外部工具用 stdin/stdout JSON，四套互不通用。

### 1.3 能力检测机制（核心）

宿主在**首次需要调用某插件**（或加载时探测 `应用_Init`）时，编译该插件的 wasm，然后枚举模块的导出函数，凡是**以 `应用_` 前缀命名**的导出全部收进该插件的能力表 `caps`：

```text
caps = { name | name 以 "应用_" 开头 }
```

调用任意钩子前，宿主先查该钩子是否在实际导出表内；**没有导出的钩子一律跳过，不产生任何实例化/执行开销**（这正是"只实现需要的钩子"能省资源的原因）。编译一次后缓存，后续每次钩子调用仅新建模块实例（重置 wasm 全局状态）。

### 1.4 双通道判定

除导出表外，还有一条与控制器应用插件一致的声明通道 `[INIT]`：

| 通道 | 来源 | 作用 |
|---|---|---|
| 导出表（`Capabilities`） | 编译后枚举 `应用_` 前缀导出 | **最终是否调用以它为准** |
| `[INIT]` 声明（`Declared`） | 插件在任意钩子开头调用 `Declare()` 输出的 `[INIT] {...}` 行 | 供门控（如 `tools.exec` 授权检查）与一致性元数据 |

判定逻辑：能力判定 = 导出表（Capabilities）∪ `[INIT]` 声明（Declared）；但真正执行仍以**实际导出**为准。若插件只在 `[INIT]` 里声明了某能力却**没有导出对应钩子函数**，宿主会记录一条日志并跳过，**永远不会调用**：

```text
插件[<uuid>] 声明了能力 应用_OnTaskStart 但未导出对应钩子函数，无法调用（跳过）
```

### 1.5 数据通道与行协议

宿主与插件通过 **stdin / stdout** 通信：

| 方向 | 内容 |
|---|---|
| 宿主 → 插件 | 请求 JSON 写入 **stdin** |
| 插件 → 宿主 | stdout 逐行：`[RESP] <JSON>` 结果行、`[LOG] <文本>` 调试日志、`[INIT] <JSON>` 能力声明 |

每次调用流程：实例化模块 → 调用 `_initialize`（Go wasip1 运行时初始化）→ 调用目标 `应用_xxx` 导出函数 → 解析 stdout 行协议。**`WriteResp` 必须输出单行 `[RESP] <JSON>`**；`Log` 走 `app_log` 主机函数（与 `[LOG]` 行一起被收集）。

---

## 二、入口在哪：宿主怎么发现、编译并调用你的插件

这一章把从"代码编译好"到"钩子被真正调用"的完整链路按步拆开。读完你就知道为什么忘了某个开关插件就"毫无反应"。

### 2.1 第一步：目录与文件必须放对

宿主扫描的是扫描节点运行目录下的插件根目录，最终定位每个插件的构建产物：

```text
<运行目录>/scan-poc/plugin/<uuid>/
├── build/
│   └── scan.wasm              # 构建产物（必填；宿主按 build/*.wasm 定位，优先 scan.wasm）
├── plugin.config.json         # 插件 Key/Value 配置（可缺省，app_config 读它）
├── language-cn.json           # 中文语言包（可缺省，app_t 读它）
├── language-en.json           # 英文语言包（可缺省）
├── sig.json                   # 可选 Ed25519 签名信息
└── tools/<name>/              # 外部工具（可选；声明 + GUI 授权后可用）
    ├── bin/<name>[.exe]       # 可执行文件
    └── <name>.py / <name>.jar # python / java 工具入口
```

控制器从插件商店获取打包源，经"安装到 GUI"后广播到各扫描节点；节点收到 `AddPluginPackage` 下发的 zip 后解压到 `scan-poc/plugin/<uuid>/` 并重载插件。

> [!IMPORTANT]
> 宿主兼容两种布局：标准 `root/<uuid>/build/scan.wasm` 与历史 `root/<uuid>/Plugin/scan/<uuid>/build/scan.wasm`。`build/` 下找不到 `.wasm` 的目录会被直接忽略。若存在 `sig.json` 且验签失败（`verify_failed`），插件禁止加载；无签名记为 `unsigned`（信任控制器 AES-GCM 下发通道）。

### 2.2 第二步：必须用 `-buildmode=c-shared` 构建

```bash
GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .
```

为什么必须是 c-shared：

- 命令模块模式的入口是 `_start`，Go wasip1 会执行 `main()`，`main` 一返回就 `proc_exit(0)` 关闭模块——**宿主还没调用你的钩子，模块就已经退出了**。
- c-shared 库模式导出 `_initialize`（运行时初始化），宿主在每次钩子调用时：先清空自动 `_start`，实例化后**手动调用 `_initialize`**，再调用 `应用_xxx` 导出函数。
- `//go:wasmexport` 导出中文前缀函数、`//go:wasmimport env ...` 导入宿主函数，都依赖 c-shared 库模式。

`func main() {}` 必须保留（c-shared 链接要求，空实现即可）。

### 2.3 第三步：宿主何时编译（lazy + 缓存 + poisoned 重建）

- **lazy 编译**：加载阶段只读取元数据（路径、签名、配置），不触碰 wasm 内容；真正的编译推迟到**首次需要调用该插件**时进行。
- **编译一次并缓存**：编译产物按插件 UUID 缓存。之后每次钩子调用只新建模块实例（重置 wasm 全局状态），不重复编译。
- **加载时探测 `应用_Init`**：加载阶段对导出了 `应用_Init` 的插件主动调用一次（顺带触发首次编译），请求 JSON 为 `{}`，用于收集能力与工具清单。
- **poisoned 重建**：若某次执行被 watchdog 判超时，该插件运行时被标记为 poisoned；下次调用时自动丢弃旧运行时、重新编译，保证卡死插件不拖垮后续调用。

```text
插件加载阶段
  └─ 扫描插件根目录，构造每个插件的元数据
  └─ 对每个插件：
        if 导出了 "应用_Init"：            // 触发首次编译
              调用 "应用_Init"（请求 JSON 为 "{}"）
  汇总声明的 tools → 向 GUI 发起工具授权请求（ToolAuthRequest）
```

### 2.4 第四步：导出表检测（没导出的钩子永不调用）

编译完成后，宿主枚举模块的导出函数并收进该插件的能力表：

```text
caps = { name | name 以 "应用_" 开头 }
```

执行任一钩子前先判"该钩子是否在实际导出表内"：

```text
if 未导出：
    if 插件曾声明过该能力：
        记录日志：「插件[<uuid>] 声明了能力 应用_OnXxx 但未导出对应钩子函数，无法调用（跳过）」
    跳过本次调用
```

所以：**没导出 = 不调用**，不报错、不占资源；**只声明没导出 = 记一条日志后跳过**。

### 2.5 第五步：每次钩子调用的时序

```text
1. 宿主业务代码触发钩子（如收到 TCP 数据 → 触发收包钩子）
2. 快速过滤：没有任何插件实现该钩子，则整类跳过
3. 对每个插件判断是否实现了该钩子；未实现则跳过
4. 执行一次钩子调用：
   a. 取/建运行时（首次会触发编译）
   b. 该插件正在执行 → 本次直接跳过（防重入/死锁）
   c. 起 goroutine + watchdog 定时器（timeout，默认 30s）
   d. 实际调用：
        - 重置本次执行环境
        - stdin = 请求 JSON；stdout/stderr = 内存缓冲
        - 清空自动 _start
        - 新建模块实例
        - 调用 _initialize（若导出）
        - 调用 应用_xxx（若未导出则报错，正常不会走到）
        - 扫描 stdout：[RESP] → 响应/消费标记/错误；[LOG] → 日志；[INIT] → 能力与工具声明
        - 合并 app_log 收集到的日志
   e. 执行成功后增量合并 [INIT] 声明（能力与工具）
   f. 超时 → 标记该插件运行时 poisoned，返回错误
5. 业务代码按响应 JSON 决定后续行为（见 2.6）
```

请求 JSON 由宿主 `json.Marshal` 后写入 stdin，你在插件里用 `app.LoadRequest(&req)` 读出来即可；响应由你的 `app.WriteResp(...)` 写到 stdout，宿主解析 `[RESP]` 行。

### 2.6 第六步：钩子返回值如何影响主流程

| 钩子 | 宿主如何使用返回 |
|---|---|
| `应用_OnTcpDataReceived` | `handled=true` → 消费本次事件，跳过内置处理；`data` 非空且与原数据不同 → 用新数据**重新解析后再分发** |
| `应用_OnTcpDataSend` | 只取 `data`：非空且不同 → 替换待发送内容；`handled` 不生效 |
| `应用_OnTaskStart` | 取 `task`（`TaskStartModify`）：非 nil / 非空字段替换任务对应字段 |
| `应用_OnTaskFilterVulns` | 取 `filterIds`：并入"需剔除的 vulnIde"集合（多插件取并集） |
| `应用_OnTaskFilterFlows` | 取 `filterIds`：并入"需剔除的流量包 ide"集合 |
| `应用_OnMitmHttpRequest` | 取 `request`（`MitmHttpModify`）合并进请求（后写覆盖先写，空字段不覆盖） |
| `应用_OnMitmHttpResponse` | 取 `response`（`MitmHttpModify`）合并进响应 |
| `应用_OnMitmWsMessage` | 取 `message.content`：非空即替换消息内容 |
| `应用_OnMitmSseEvent` | 取 `event.data`：非空即替换事件数据 |
| `应用_OnTaskPacket` | **整体忽略**（通知型） |
| `应用_OnTaskEnd` | 取 `done`：`drain` 阶段全部插件都 `done=true` 才停止轮询；`close` 阶段忽略 `done` |
| `应用_Init` | 取 `[INIT]` 行：`capabilities` 与 `tools` 声明 |

---

## 三、12 个钩子逐函数精讲

钩子导出名统一为 `应用_` + 业务点名。下表汇总触发时机与 `handled` 是否真正生效：

| 导出函数 | 触发时机 | 可否修改数据 | `handled=true` 是否生效 |
|---|---|---|---|
| `应用_Init` | 插件加载后探测一次 | 输出能力/工具声明 | 不适用 |
| `应用_OnTcpDataReceived` | TCP 数据解析后、内置分发前 | 可改 `data` | **生效**：消费本次事件，跳过内置处理 |
| `应用_OnTcpDataSend` | 数据发送前 | 可改 `data` | 不生效（只取 `data`） |
| `应用_OnTaskStart` | 任务解析后 | 可改任务过滤字段 | 解析但不生效（只看 `task`） |
| `应用_OnTaskFilterVulns` | 任务启动前 | 返回剔除的漏洞标识 | 不生效（只看 `filterIds`） |
| `应用_OnTaskFilterFlows` | 任务启动前 | 返回剔除的流量标识 | 不生效（只看 `filterIds`） |
| `应用_OnMitmHttpRequest` | MITM 捕获 HTTP 请求 | 可改 method/url/headers/body | 解析但当前不生效（只看 `request`） |
| `应用_OnMitmHttpResponse` | MITM 捕获 HTTP 响应 | 可改 statusCode/headers/body | 解析但当前不生效（只看 `response`） |
| `应用_OnMitmWsMessage` | MITM 捕获 WS 帧 | 可改消息内容 | 解析但当前不生效（只看 `message`） |
| `应用_OnMitmSseEvent` | MITM 捕获 SSE 事件 | 可改事件数据 | 解析但当前不生效（只看 `event`） |
| `应用_OnTaskPacket` | 扫描分发循环每个原始数据包 | 通知型，无返回语义 | 响应整体被忽略 |
| `应用_OnTaskEnd` | 任务收口（drain 轮询 + close 清理） | 上报 done / 清理 | 不参与，只看 `done` |

> [!TIP]
> 多个插件实现同一钩子时会**按插件顺序依次调用**：TCP 钩子中后一个插件能看到前一个插件改过的数据；MITM 与过滤钩子的修改会合并（后写覆盖先写，`omitempty` 字段不覆盖）。单次调用是**每个插件串行**执行的。

#### `应用_Init`

**作用**：插件加载后由宿主在加载阶段主动调用一次（请求 JSON 为 `{}`），用于在其它钩子被调用前声明能力点与外部工具清单。

**签名 / 触发形式**：导出函数无参数、无返回值（返回 int 也可，宿主忽略）。

```go
//go:wasmexport 应用_Init
func OnInit() { /* ... */ }
```

**参数表**：无参数（宿主 stdin 写 `{}`）。

**返回 / 响应**：无业务返回体（宿主忽略 `[RESP]`），关键是 `[INIT]` 行。

宿主注入的请求 JSON：

```json
{}
```

你要输出的 `[INIT]` 行与响应：

```json
[INIT] {"capabilities":["应用_OnTaskStart","应用_OnTaskPacket","应用_OnTaskEnd","tools.exec","fs.read","fs.write"],"tools":[{"name":"nuclei","runtime":""}]}
[RESP] {"handled":false}
```

| 字段 | 含义 | 会不会生效 |
|---|---|---|
| `capabilities` | 声明的能力点（钩子名 + `tools.exec`/`fs.read`/`fs.write`） | 用于能力清单与 `tools.exec` 门控；**调用与否仍以导出表为准** |
| `tools` | 声明外部工具 `[{name,runtime}]` | 用于 GUI 授权弹窗清单 |

**代码片段**：

```go
package main

import "app"

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(
        app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd,
        app.CapToolsExec, app.CapFSRead, app.CapFSWrite,
    )
    app.DeclareTool("nuclei", "") // 工具位于 tools/nuclei/ 下；runtime="" 表示原生可执行文件
    app.Declare()                 // 输出 [INIT] {"capabilities":[...],"tools":[...]}
    app.WriteResp(map[string]any{"handled": false})
}

func main() {}
```

**注意事项**：

1. `应用_Init` 本身也必须在导出表中存在（`//go:wasmexport 应用_Init`），否则加载探测不会调用，`tools.exec` 等声明无从采集。
2. `Declare()` 必须在**钩子函数内部**调用，不能在包初始化或全局变量初始化时调用——`[INIT]` 行必须写进本次执行的 stdout。
3. 单靠 `[INIT]` 声明不足以让钩子被调用：对应 `应用_xxx` 必须真的导出（否则日志提示"声明了能力但未导出"）。

#### `应用_OnTcpDataReceived`

**作用**：TCP 数据完成解析后、宿主内置分发前触发。用于查看 / 修改 / 消费一条 TCP 消息。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { /* ... */ }
```

**参数表**：请求体为 `app.TcpDataRequest`，写在 stdin。

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `uuid` | string | 本连接/本节点标识 | TCP 数据包字段 | `"n-0a1b"` |
| `command1` | string | 一级指令（路由层） | 一级指令字段 | `"relayData"` |
| `command2` | string | 二级指令（模块/动作） | 二级指令字段 | `"TaskStart"` |
| `command3` | string | 三级指令（目标 UUID） | 三级指令字段 | `"gui"` |
| `command4` | string | 四级指令（来源 UUID） | 四级指令字段 | `"n-0a1b"` |
| `source` | string | 来源 `0`=GUI `1`=控制器 `2`=扫描节点 | 来源标识字段 | `"1"` |
| `commandA` | string | 业务动作名（Data 内 `CommandA`） | Data 段内字段 | `"SaveScanVuln"` |
| `data` | string | 原始 Data 段（`string([]byte)`） | 原始 Data 段 | `"{"a":1}"` |

**返回 / 响应**：`app.TcpDataResponse` → `{handled, error, data}`。

宿主注入的请求 JSON：

```json
{"uuid":"n-0a1b","command1":"relayData","command2":"Heartbeat","command3":"gui","command4":"n-0a1b","source":"1","commandA":"Heartbeat","data":"{\"tick\":1}"}
```

你要返回的响应 JSON：

```json
{"handled":false,"error":"","data":"{\"tick\":1}_seen"}
```

| 字段 | 语义 |
|---|---|
| `handled` | `true` → 插件已消费本次事件，**主程序跳过内置处理** |
| `error` | 处理错误信息，仅记录日志，不影响主流程 |
| `data` | **非空且与原数据不同** → 主程序用新数据重新解析后再分发；空/相同 → 不修改 |

**代码片段**：

```go
//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteError(errors.New("请求解析失败"))
        return
    }
    app.Logf("收到 TCP 数据 command2=%s commandA=%s", req.Command2, req.CommandA)

    if strings.Contains(req.Data, "need-sign") {
        // 值怎么传出去：把改过的 Data 放进响应
        app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data + "|signed"})
        return
    }
    // 只观察、不修改：返回空 data
    app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data})
}
```

**注意事项**：

1. 插件在钩子内调用 `app.ReinjectTcpData`（`app_tcp_received_data`）重注入的消息，**不会**再次回调本钩子（重注入来源的消息直接跳过），避免自触发死锁。
2. 想消费事件、跳过内置处理，返回 `Handled: true`；只想改数据则 `Data` 返回新值、`Handled` 保持 `false`。
3. 只有本钩子的 `handled=true` 才会跳过内置处理，其它钩子的 `handled` 不生效。

#### `应用_OnTcpDataSend`

**作用**：宿主发送 TCP 数据**之前**触发，可查看/替换待发送内容。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnTcpDataSend
func OnTcpDataSend() { /* ... */ }
```

**参数表**：请求体同样为 `app.TcpDataRequest`。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `uuid` | string | 当前节点 UUID | `"n-0a1b"` |
| `command1`~`command4` | string | 本次发送的四级指令 | `"relayData"` / `"TaskResult"` / `"gui"` / `"n-0a1b"` |
| `data` | string | 待发送内容 | `"{\"ok\":true}"` |
| `source` / `commandA` | string | 通常为空 | `""` |

**返回 / 响应**：`app.TcpDataResponse`，宿主**只取 `data`**，`handled` 不生效。

宿主注入的请求 JSON：

```json
{"uuid":"n-0a1b","command1":"relayData","command2":"TaskResult","command3":"gui","command4":"n-0a1b","source":"","commandA":"","data":"{\"ok\":true}"}
```

你要返回的响应 JSON（替换）：

```json
{"handled":false,"error":"","data":"{\"ok\":true,\"signed\":true}"}
```

**代码片段**：

```go
//go:wasmexport 应用_OnTcpDataSend
func OnTcpDataSend() {
    var req app.TcpDataRequest
    _ = app.LoadRequest(&req)
    if strings.Contains(req.Data, "need-sign") {
        app.WriteResp(app.TcpDataResponse{Data: req.Data + "|signed"})
        return
    }
    app.WriteResp(app.TcpDataResponse{}) // data 为空 → 不修改，原样发送
}
```

**注意事项**：

1. 返回空 `data` 或与原数据相同 → 原样发送；只有非空且不同才替换。
2. 防重入：插件在本钩子内调用 `app.SendTcpData` / `app.SendTcpDataJson` 会再次进入发送路径，宿主会直接跳过发送钩子，不会递归。
3. 本钩子只取 `data`，返回 `handled=true` 没有任何效果。

#### `应用_OnTaskStart`

**作用**：扫描节点解析任务配置之后、任务真正开跑之前触发，可修改任务的过滤/路由相关字段。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnTaskStart
func OnTaskStart() { /* ... */ }
```

**参数表**：请求体为 `app.TaskStartRequest`。

| 参数 | 类型 | 传什么 | 可否修改 | 示例值 |
|---|---|---|---|---|
| `taskIde` | string | 任务唯一标识 | 只读 | `"t-20261005-01"` |
| `taskName` | string | 任务名称 | 只读 | `"内网巡检"` |
| `proxyMode` | string | 代理模式 `auto/direct/tunnel` | 只读 | `"auto"` |
| `sourceUUID` | string | 任务来源（GUI）UUID | 只读 | `"g-1234"` |
| `targetUUID` | string | 任务下发目标 UUID | 只读 | `"n-0a1b"` |
| `selectedVulnIds` | []string | 实际需扫描的漏洞 POC 列表 | 可改 | `["poc-1","poc-2"]` |
| `selectedVulnIdsAdd` | []string | 模板外额外添加的漏洞 | 可改 | `["poc-9"]` |
| `httpSelectedScanUrlRowIde` | []string | 选中的 HTTP 数据包标识 | 可改 | `["flow-a"]` |
| `wsSelectedScanUrlRowIde` | []string | 选中的 WebSocket 数据包标识 | 可改 | `["ws-1"]` |
| `sseSelectedScanUrlRowIde` | []string | 选中的 SSE 数据包标识 | 可改 | `["sse-1"]` |
| `hostsContent` | string | hosts 映射内容 | 可改 | `"10.0.0.1 a.example"` |
| `scanningRange` | string | 限定扫描范围 | 只读 | `"10.0.0.0/24"` |
| `skipScanDomainIP` | string | 跳过扫描的域名或 IP | 只读 | `"10.0.0.5"` |

**返回 / 响应**：`app.TaskStartResponse` → `{handled, error, task}`，`task` 为 `app.TaskStartModify`。

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","taskName":"内网巡检","proxyMode":"auto","sourceUUID":"g-1234","targetUUID":"n-0a1b","selectedVulnIds":["poc-rce-1","poc-info-2"],"selectedVulnIdsAdd":[],"httpSelectedScanUrlRowIde":["flow-a","test-flow-b"],"wsSelectedScanUrlRowIde":[],"sseSelectedScanUrlRowIde":[],"hostsContent":"","scanningRange":"","skipScanDomainIP":""}
```

你要返回的响应 JSON（裁剪后）：

```json
{"handled":true,"error":"","task":{"selectedVulnIds":["poc-rce-1"],"httpSelectedScanUrlRowIde":["flow-a"]}}
```

| 字段 | 语义 |
|---|---|
| `task` | 非 nil / 非空的字段替换任务对应字段；`nil`/空表示不改（`omitempty` 空切片会被省略，**无法用于清空**） |
| `handled` | 会被解析但当前不影响流程（任务修改始终按 `task` 应用） |

**代码片段**：

```go
//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000) // 启动动作可能较慢，放大执行窗口
    var req app.TaskStartRequest
    _ = app.LoadRequest(&req)
    app.Logf("任务启动：%s(%s) 选中漏洞 %d 个", req.TaskName, req.TaskIde, len(req.SelectedVulnIds))

    // 只保留名称含 rce 的 POC
    keep := make([]string, 0, len(req.SelectedVulnIds))
    for _, id := range req.SelectedVulnIds {
        if strings.Contains(strings.ToLower(id), "rce") {
            keep = append(keep, id)
        }
    }
    // 剔除测试环境数据包
    httpKeep := make([]string, 0, len(req.HttpSelectedScanUrlRowIde))
    for _, ide := range req.HttpSelectedScanUrlRowIde {
        if !strings.HasPrefix(ide, "test-") {
            httpKeep = append(httpKeep, ide)
        }
    }
    app.WriteResp(app.TaskStartResponse{
        Handled: true,
        Task: app.TaskStartModify{
            SelectedVulnIds:           keep,
            HttpSelectedScanUrlRowIde: httpKeep,
        },
    })
}
```

**注意事项**：

1. `TaskStartModify` 使用 `omitempty`，**空切片会被省略而无法用于"清空"**，只能用非空值替换；清空需求请在插件内过滤后返回剩余项。
2. `SelectedVulnIds` 与 `SelectedVulnIdsAdd`、三种 `*SelectedScanUrlRowIde`、`HostsContent` 是可为 nil 的字段，nil 才表示"不修改"。
3. `handled` 不影响流程，别用它控制行为。

#### `应用_OnTaskFilterVulns`

**作用**：任务启动前，对当前任务选中的漏洞批量过滤，返回需要剔除的 `vulnIde`。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnTaskFilterVulns
func OnTaskFilterVulns() { /* ... */ }
```

**参数表**：请求体 `{taskIde, vulns}`，`vulns` 元素为 `app.TaskVulnBrief`。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `vulns` | []TaskVulnBrief | 当前任务选中的全部漏洞摘要 | 见下 |
| `vulns[].vulnIde` | string | 漏洞唯一标识 | `"poc-1"` |
| `vulns[].name` | string | 漏洞名称 | `"远程命令执行"` |
| `vulns[].level` | string | 等级 `4/3/2/1` | `"1"` |
| `vulns[].pocType` | string | `yaml/go/wasm` | `"yaml"` |

**返回 / 响应**：`app.TaskFilterResponse` → `{handled, error, filterIds}`。

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","vulns":[{"vulnIde":"poc-1","name":"远程命令执行","level":"4","pocType":"yaml"},{"vulnIde":"poc-2","name":"信息泄露","level":"1","pocType":"go"}]}
```

你要返回的响应 JSON（剔除低危）：

```json
{"handled":false,"error":"","filterIds":["poc-2"]}
```

| 字段 | 语义 |
|---|---|
| `filterIds` | 需要剔除的 `vulnIde` 集合；多插件结果取并集 |
| `handled` | 不生效，只看 `filterIds` |

**代码片段**：

```go
//go:wasmexport 应用_OnTaskFilterVulns
func OnTaskFilterVulns() {
    var req struct {
        TaskIde string              `json:"taskIde"`
        Vulns   []app.TaskVulnBrief `json:"vulns"`
    }
    _ = app.LoadRequest(&req)

    var drop []string
    for _, v := range req.Vulns {
        if v.Level == "1" || v.Level == "2" { // 剔除低危
            drop = append(drop, v.VulnIde)
        }
    }
    app.WriteResp(app.TaskFilterResponse{FilterIds: drop})
}
```

**注意事项**：

1. 返回的是"要剔除"的标识，不是"要保留"的。
2. `filterIds` 里空字符串会被忽略；返回 nil/空表示不过滤。
3. 请求里的 `vulns` 是当前任务全部选中漏洞（不是差集），过滤判断要基于全量。

#### `应用_OnTaskFilterFlows`

**作用**：任务启动前，按域名/URL 批量剔除流量包（例如跳过静态资源站点）。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnTaskFilterFlows
func OnTaskFilterFlows() { /* ... */ }
```

**参数表**：请求体 `{taskIde, flows}`，元素为 `app.TaskFlowBrief`。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `flows` | []TaskFlowBrief | 当前任务选中的全部流量包摘要 | 见下 |
| `flows[].ide` | string | 数据包唯一标识（`IdeTraffic`） | `"flow-a"` |
| `flows[].type` | string | `http/websocket/sse` | `"http"` |
| `flows[].method` | string | 请求方法 | `"GET"` |
| `flows[].url` | string | 请求 URL | `"/api/list"` |
| `flows[].domain` | string | 域名 | `"static.example.com"` |
| `flows[].tls` | string | `HTTP/HTTPS/WSS` | `"HTTPS"` |

**返回 / 响应**：`app.TaskFilterResponse` → `filterIds` 为需要剔除的 `ide`。

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","flows":[{"ide":"flow-a","type":"http","method":"GET","url":"/index.html","domain":"example.com","tls":"HTTPS"},{"ide":"flow-b","type":"http","method":"GET","url":"/a.css","domain":"static.example.com","tls":"HTTPS"}]}
```

你要返回的响应 JSON：

```json
{"handled":false,"error":"","filterIds":["flow-b"]}
```

**代码片段**：

```go
//go:wasmexport 应用_OnTaskFilterFlows
func OnTaskFilterFlows() {
    var req struct {
        TaskIde string              `json:"taskIde"`
        Flows   []app.TaskFlowBrief `json:"flows"`
    }
    _ = app.LoadRequest(&req)

    var drop []string
    for _, f := range req.Flows {
        d := strings.ToLower(f.Domain)
        if d == "example.com" || strings.HasSuffix(d, ".static.example.com") {
            drop = append(drop, f.Ide) // 剔除用 ide，不是 url
        }
    }
    app.WriteResp(app.TaskFilterResponse{FilterIds: drop})
}
```

**注意事项**：

1. `filterIds` 填的是 `flows[].ide`（`IdeTraffic`），不是 URL 或域名。
2. 与漏洞过滤一样，多插件并集、只看 `filterIds`。
3. 按域名剔除时注意后缀匹配边界（`strings.HasSuffix(d, ".static.example.com")` 而非裸后缀）。

#### `应用_OnMitmHttpRequest`

**作用**：MITM 捕获到 HTTP 请求时触发，可修改/替换请求的 method、URL、headers、body。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() { /* ... */ }
```

**参数表**：请求体为 `app.MitmHttpRequest`。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `method` | string | 请求方法 | `"POST"` |
| `url` | string | 完整 URL | `"https://example.com/api/login"` |
| `proto` | string | 协议版本 | `"HTTP/1.1"` |
| `host` | string | Host | `"example.com"` |
| `headers` | map[string][]string | 请求头 | `{"User-Agent":["curl/8"]}` |
| `body` | string | 已解压的请求体 | `"u=admin&p=123"` |
| `remoteIp` | string | 远端服务器 IP | `"93.184.216.34"` |
| `tls` | string | `HTTP/HTTPS` | `"HTTPS"` |

**返回 / 响应**：宿主解析的响应结构是**匿名结构**，不是 SDK 类型：

```go
var resp struct {
    Handled bool           `json:"handled"`
    Error   string         `json:"error"`
    Request MitmHttpModify `json:"request"`
}
```

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","method":"POST","url":"https://example.com/api/login","proto":"HTTP/1.1","host":"example.com","headers":{"User-Agent":["curl/8"]},"body":"u=admin&p=123","remoteIp":"93.184.216.34","tls":"HTTPS"}
```

你要返回的响应 JSON（加一个请求头）：

```json
{"handled":false,"error":"","request":{"headers":{"User-Agent":["curl/8"],"X-Tss-Audit":["hook-audit"]}}}
```

| 字段 | 语义 |
|---|---|
| `request` | `app.MitmHttpModify`，合并进本次请求（后写覆盖先写，空字段不覆盖） |
| `request.method` / `request.url` | 非空替换方法 / URL |
| `request.headers` | 长度 > 0 时**整体替换**请求头 map |
| `request.body` | 非空替换请求体 |
| `handled` | 被解析但当前不参与流程 |

**代码片段**：

```go
//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() {
    var req app.MitmHttpRequest
    _ = app.LoadRequest(&req)

    headers := map[string][]string{}
    for k, v := range req.Headers {
        headers[k] = v
    }
    headers["X-Tss-Powered-By"] = []string{"TestSecScan-Plugin"}
    headers["X-Original-URL"] = []string{req.URL}

    resp := struct {
        Handled bool               `json:"handled"`
        Error   string             `json:"error"`
        Request app.MitmHttpModify `json:"request"`
    }{
        Request: app.MitmHttpModify{Headers: headers},
    }
    app.WriteResp(resp)
}
```

**注意事项**：

1. 返回键必须是 `request`（不是 `httpRequest`/`modify`）；字段名写错 → 响应能正常解析但没有修改生效。
2. `headers` 是 `map[string][]string`，替换是"整体替换"：想保留原头必须先拷贝再改。
3. 想让修改对**下一个插件**可见，宿主会把修改应用回请求快照；但最终是否写回真实流量取决于调用方对该 modify 的使用。

#### `应用_OnMitmHttpResponse`

**作用**：MITM 捕获到 HTTP 响应时触发，可修改/替换 `statusCode`、headers、body。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnMitmHttpResponse
func OnMitmHttpResponse() { /* ... */ }
```

**参数表**：请求体为 `app.MitmHttpResponse`。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `url` | string | 请求 URL | `"https://example.com/api/login"` |
| `method` | string | 请求方法 | `"POST"` |
| `statusCode` | int | 响应状态码 | `200` |
| `headers` | map[string][]string | 响应头 | `{"Content-Type":["application/json"]}` |
| `body` | string | 已解压的响应体 | `"{\"ok\":true}"` |
| `remoteIp` | string | 远端服务器 IP | `"93.184.216.34"` |
| `tls` | string | `HTTP/HTTPS` | `"HTTPS"` |

**返回 / 响应**：`{handled, error, response}`，`response` 为 `app.MitmHttpModify`（可含 `statusCode`）。

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","url":"https://example.com/api/login","method":"POST","statusCode":200,"headers":{"Content-Type":["application/json"]},"body":"{\"ok\":true}","remoteIp":"93.184.216.34","tls":"HTTPS"}
```

你要返回的响应 JSON（改状态码与响应体）：

```json
{"handled":false,"error":"","response":{"statusCode":403,"body":"{\"ok\":false}"}}
```

| 字段 | 语义 |
|---|---|
| `response` | `app.MitmHttpModify`，合并进响应 |
| `response.statusCode` | >0 时替换状态码（仅响应使用） |
| `response.headers` | 长度 > 0 时整体替换响应头 |
| `response.body` | 非空替换响应体 |
| `handled` | 被解析但当前不参与流程 |

**代码片段**：

```go
//go:wasmexport 应用_OnMitmHttpResponse
func OnMitmHttpResponse() {
    var resp app.MitmHttpResponse
    _ = app.LoadRequest(&resp)

    mod := app.MitmHttpModify{}
    if strings.Contains(resp.Body, "internal error") {
        code := 502
        mod.StatusCode = code
        mod.Body = `{"masked":true}`
    }
    out := struct {
        Handled  bool               `json:"handled"`
        Error    string             `json:"error"`
        Response app.MitmHttpModify `json:"response"`
    }{
        Response: mod,
    }
    app.WriteResp(out)
}
```

**注意事项**：

1. `statusCode` 为 0（或不返回）表示不修改；正数才覆盖。
2. 返回键是 `response`；`MitmHttpModify` 里 `method`/`url` 对响应侧无意义（响应侧不使用）。
3. `omitempty`：空 `body`、空 `headers` 不会覆盖原值。

#### `应用_OnMitmWsMessage`

**作用**：MITM 捕获到 WebSocket 消息帧时触发，可替换消息内容。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnMitmWsMessage
func OnMitmWsMessage() { /* ... */ }
```

**参数表**：请求体为 `app.MitmWsMessage`。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `url` | string | 连接 URL | `"wss://example.com/ws"` |
| `domain` | string | 域名 | `"example.com"` |
| `fromClient` | bool | `true`=客户端→服务器，`false`=服务器→客户端 | `true` |
| `statusType` | int | `1`=发送 `2`=接收 | `1` |
| `content` | string | 消息内容 | `"{\"cmd\":\"ping\"}"` |

**返回 / 响应**：`{handled, error, message}`，`message` 为 `app.MitmWsModify{content}`。

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","url":"wss://example.com/ws","domain":"example.com","fromClient":true,"statusType":1,"content":"{\"cmd\":\"ping\"}"}
```

你要返回的响应 JSON：

```json
{"handled":false,"error":"","message":{"content":"{\"cmd\":\"pong\"}"}}
```

| 字段 | 语义 |
|---|---|
| `message.content` | 非空即替换消息内容 |
| `handled` | 被解析但当前不参与流程 |

**代码片段**：

```go
//go:wasmexport 应用_OnMitmWsMessage
func OnMitmWsMessage() {
    var msg app.MitmWsMessage
    _ = app.LoadRequest(&msg)
    if strings.Contains(msg.Content, `"ping"`) {
        out := struct {
            Handled bool            `json:"handled"`
            Error   string          `json:"error"`
            Message app.MitmWsModify `json:"message"`
        }{
            Message: app.MitmWsModify{Content: strings.ReplaceAll(msg.Content, `"ping"`, `"pong"`)},
        }
        app.WriteResp(out)
        return
    }
    app.WriteResp(app.MitmWsModify{}) // 不修改
}
```

**注意事项**：

1. 返回键是 `message`；`message.content` 为空不覆盖。
2. `fromClient` 与 `statusType` 语义重复但都来自宿主，判断方向用 `fromClient` 更直观。
3. `content` 可能是二进制帧的文本化内容，注意别对非 JSON 内容做 JSON 解析。

#### `应用_OnMitmSseEvent`

**作用**：MITM 捕获到 SSE（Server-Sent Events）事件时触发，可替换事件数据。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnMitmSseEvent
func OnMitmSseEvent() { /* ... */ }
```

**参数表**：请求体为 `app.MitmSseEvent`。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `url` | string | 连接 URL | `"https://example.com/sse"` |
| `event` | string | `event:` 字段（默认 `message`） | `"message"` |
| `id` | string | `id:` 字段 | `"42"` |
| `data` | string | `data:` 字段 | `"{\"price\":10}"` |
| `retry` | int | `retry:` 字段（毫秒） | `3000` |

**返回 / 响应**：`{handled, error, event}`，`event` 为 `app.MitmSseModify{data}`。

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","url":"https://example.com/sse","event":"message","id":"42","data":"{\"price\":10}","retry":3000}
```

你要返回的响应 JSON：

```json
{"handled":false,"error":"","event":{"data":"{\"price\":99}"}}
```

| 字段 | 语义 |
|---|---|
| `event.data` | 非空即替换事件数据 |
| `handled` | 被解析但当前不参与流程 |

**代码片段**：

```go
//go:wasmexport 应用_OnMitmSseEvent
func OnMitmSseEvent() {
    var evt app.MitmSseEvent
    _ = app.LoadRequest(&evt)

    mod := app.MitmSseModify{}
    if strings.Contains(evt.Data, "secret") {
        mod.Data = strings.ReplaceAll(evt.Data, "secret", "***")
    }
    out := struct {
        Handled bool            `json:"handled"`
        Error   string          `json:"error"`
        Event   app.MitmSseModify `json:"event"`
    }{
        Event: mod,
    }
    app.WriteResp(out)
}
```

**注意事项**：

1. 返回键是 `event`；`data` 为空不覆盖。
2. `id`、`retry`、`event` 只读，`MitmSseModify` 只能改 `data`。
3. SSE 是长连接，事件钩子调用频繁，逻辑要快。

#### `应用_OnTaskPacket`

**作用**：扫描分发循环里**每个原始数据包**调用一次，用于把任务流量交给插件（典型：`app_http_replay` 重放给外部检测工具）。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() { /* ... */ }
```

**参数表**：请求体 `{taskIde, flow}`，`flow` 为请求侧精简字段。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `flow.method` | string | 请求方法 | `"GET"` |
| `flow.url` | string | 请求 URL | `"/api/user"` |
| `flow.domain` | string | 域名 | `"example.com"` |
| `flow.reqHeaders` | string | 原始请求头文本 | `"Host: example.com\nUser-Agent: curl/8"` |
| `flow.reqBody` | string | 请求体文本 | `""` |

**返回 / 响应**：**整体被忽略**（通知型）。仍建议写一行 `[RESP]` 让宿主确认执行完成。

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","flow":{"method":"GET","url":"/api/user","domain":"example.com","reqHeaders":"Host: example.com\nUser-Agent: curl/8","reqBody":""}}
```

建议返回（宿主忽略，仅作执行完成的信号）：

```json
{"handled":false}
```

**代码片段**：

```go
//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() {
    app.SetTimeout(30000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Flow    struct {
            Method     string `json:"method"`
            URL        string `json:"url"`
            Domain     string `json:"domain"`
            ReqHeaders string `json:"reqHeaders"`
            ReqBody    string `json:"reqBody"`
        } `json:"flow"`
    }
    _ = app.LoadRequest(&req)
    if req.Flow.URL == "" {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    // 快进快出：重放转后台（async=true），重活交给 应用_OnTaskEnd
    _ = replayAsync(req.Flow.Method, req.Flow.URL, req.Flow.ReqHeaders, req.Flow.ReqBody)
    app.WriteResp(map[string]any{"handled": false})
}
```

**注意事项**：

1. **必须快进快出**：这是每个数据包都走的路径，同步阻塞会把分发循环拖死；重放用 `app_http_replay` 的 `async=true`。
2. 返回值整体被忽略，不要依赖它影响流程。
3. 该钩子未导出时宿主完全不调用，零开销。

#### `应用_OnTaskEnd`

**作用**：任务收口。分两个阶段：`drain` 宿主**循环调用**（每轮间隔 2s）直到所有插件报 `done=true` 或超过 `drainLimit`；`close` 最后**一次性调用**做最终清理（关进程等）。

**签名 / 触发形式**：

```go
//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() { /* ... */ }
```

**参数表**：请求体为

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `cancelled` | bool | `true`=用户停止/取消（收口窗口应缩短） | `false` |
| `phase` | string | `drain`=收割一轮 / `close`=最终清理 | `"drain"` |

**返回 / 响应**：`{handled, done}`（宿主解析 `TaskEndResponse`）。

宿主注入的请求 JSON：

```json
{"taskIde":"t-20261005-01","cancelled":false,"phase":"drain"}
```

你要返回的响应 JSON（还有剩余，继续轮询）：

```json
{"handled":true,"done":false}
```

`close` 阶段的响应：

```json
{"handled":true,"done":true}
```

| 字段 | 语义 |
|---|---|
| `done` | `drain` 阶段 `true`=已无剩余待收割任务，宿主停止轮询；只要有一个插件 `done=false`，继续下一轮 |
| `handled` | 不参与收口判断（宿主只看 `done`） |

**代码片段**：

```go
//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() {
    app.SetTimeout(290000) // 接近宿主硬上限 5 分钟：一轮内完成收割/上报
    var req struct {
        TaskIde   string `json:"taskIde"`
        Cancelled bool   `json:"cancelled"`
        Phase     string `json:"phase"`
    }
    _ = app.LoadRequest(&req)

    if req.Phase == "close" {
        // 最终清理：停掉本插件拉起的全部工具进程（tool 空=全部）
        stopAllTools()
        app.WriteResp(map[string]any{"handled": true, "done": true})
        return
    }
    // drain：检查是否还有进程未退出
    if stillRunning() {
        app.Log("仍有工具进程未退出，等待下一轮")
        app.WriteResp(map[string]any{"handled": true, "done": false})
        return
    }
    reportResults() // 收集结果并上报
    app.WriteResp(map[string]any{"handled": true, "done": true})
}
```

**注意事项**：

1. `drain` 是靠**返回 `done=false` 请求下一轮**的：不要在这里阻塞式 `sleep` 等进程，应快速返回、让宿主 2s 后再拨。
2. 单轮可用 `app.SetTimeout(ms)` 放大执行窗口，硬上限 5 分钟；`close` 只调用一次，务必在里面关进程。
3. 未实现本钩子的插件不参与收口；`cancelled=true` 时应收短窗口（用户已取消）。

---

## 四、20 个宿主函数逐函数精讲

宿主把下列 20 个函数注入到 wasm 的 `env` 模块。SDK（`app.go`）只封装了其中 12 个；其余 8 个宿主已导出但 SDK 未封装，需用 `//go:wasmimport env <name>` 自行声明。

所有"输出型"函数共用同一 ABI 约定：

```text
func appXxx(in..., out unsafe.Pointer, outCap uint32) uint32
```

宿主把结果 JSON 写入 `out` 指向的线性内存（最多 `outCap` 字节），返回**实际写入字节数**；你用 `buf[:n]` 取出字符串再 `json.Unmarshal`。

> [!NOTE]
> 缓冲区要按结果量预留：`app_task_env`、`app_list_dir`、`app_exec_tool` 的返回可能较大（示例插件用 256KB）；`app_read_file` 单文件上限 4MB，base64 后需约 5.6MB 缓冲。

#### `app_log`

**作用**：输出调试日志，宿主收集到本次执行环境，随任务/GUI 调试可见。

**签名 / 触发形式**：

```go
//go:wasmimport env app_log
func appLog(msg string)
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `msg` | string | 日志文本 | 你的代码 | `"收到 TCP 数据"` |

**返回 / 响应**：无返回值。宿主把它收进本次执行的日志缓冲，执行结束后以 `插件[<uuid>] 日志: ...` 形式写入扫描节点日志。它不是 JSON。

**代码片段**：

```go
//go:wasmimport env app_log
func appLog(msg string)

func LogInfo(msg string) { appLog(msg) }

// 调用
func demo() { appLog("工具已启动：nuclei pid=" + strconv.Itoa(pid)) }
```

**注意事项**：

1. SDK 已封装为 `app.Log` / `app.Logf`，优先用它。
2. 日志每条只写一次、避免高频刷屏（`OnTaskPacket` 里尤其注意）。
3. 它不走 stdout，是独立的日志通道，不需要 `[LOG]` 前缀。

#### `app_send_tcp`

**作用**：向控制器/GUI/其它节点发送原始 TCP 数据（四级指令 + 原始数据段）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_send_tcp
func appSendTcp(cmd1, cmd2, cmd3, cmd4, data string)
```

宿主把五个参数按四级指令 + 原始数据段原样发出。

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `cmd1` | string | 一级指令 | 你的代码 | `"relayData"` |
| `cmd2` | string | 二级指令 | 你的代码 | `"Heartbeat"` |
| `cmd3` | string | 目标（`gui`/`scan`/`all`/节点 UUID） | 你的代码 | `"gui"` |
| `cmd4` | string | 回信 UUID（空自动填本节点） | 你的代码 | `""` |
| `data` | string | 原始数据段 | 你的代码 | `"{\"tick\":1}"` |

**返回 / 响应**：无返回值。SDK：`app.SendTcpData(cmd1, cmd2, cmd3, cmd4, data)`。

**代码片段**：

```go
//go:wasmimport env app_send_tcp
func appSendTcp(cmd1, cmd2, cmd3, cmd4, data string)

func notifyGui(data string) { appSendTcp("relayData", "MyEvent", "gui", "", data) }
```

**注意事项**：

1. `cmd3` 为空时只发给控制器，写 `all` 发给所有节点。
2. 传的是原始字符串，**业务数据请优先用 `app_send_tcp_json`** 做协议包裹。
3. 在 `应用_OnTcpDataSend` 内调用本函数，宿主会跳过发送钩子防重入。

#### `app_send_tcp_json`

**作用**：发送 TCP JSON 数据，完成 `{CommandA,Data}` 两层协议包裹。

**签名 / 触发形式**：

```go
//go:wasmimport env app_send_tcp_json
func appSendTcpJson(cmd1, cmd2, cmd3, cmd4, commandA, data string)
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `cmd1`~`cmd4` | string | 四级指令/目标/回信 | 你的代码 | `"scan"` / `"SaveScanVuln"` / `""` / `""` |
| `commandA` | string | 业务动作名 | 你的代码 | `"SaveScanVuln"` |
| `data` | string | **JSON 字符串**（宿主会再解析成对象） | 你的代码 | `"{\"VulnName\":\"x\"}"` |

**返回 / 响应**：无返回值。SDK：`app.SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`。

**代码片段**：

```go
// SDK 用法：直接传结构体/值，SDK 内部 json.Marshal
func reportVuln(v any) {
    app.SendTcpDataJson("scan", "SaveScanVuln", "", "", "SaveScanVuln", v)
}
```

**注意事项**：

1. **不要自己先 `json.Marshal` 成 `[]byte` 再传**：`[]byte` 会被编成 base64 字符串，宿主拿不到对象。传结构体或 `any`。
2. 只提供底层声明时，`data` 参数必须是 JSON 文本；宿主会 `json.Unmarshal` 成 `any` 后重新包裹。
3. `commandA` 与 `cmd2` 通常同名（如 `SaveScanVuln`），保持一致便于对端路由。

#### `app_tcp_received_data`

**作用**：把一条 TCP 数据重新注入宿主收包流程。宿主按 7 段 wire 格式重组后走正常解析与分发。

**签名 / 触发形式**：

```go
//go:wasmimport env app_tcp_received_data
func appTcpReceivedData(tcpDataJSON string)
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `tcpDataJSON` | string | 一条 TCP 数据 JSON（字段见下） | 你的代码 | 见下 |

宿主按 Go 字段名（无 json tag）解析，取 `UUID / Command1Str / Command2Str / Command3Str / Command4Str / Source / Data`，用分隔符 `<!|!>` 拼成 `uuid<!|!>cmd1<!|!>cmd2<!|!>cmd3<!|!>cmd4<!|!>source<!|!>data` 后投入正常收包解析流程。

宿主接受的 JSON：

```json
{"UUID":"n-0a1b","Command1Str":"relayData","Command2Str":"Heartbeat","Command3Str":"gui","Command4Str":"n-0a1b","Source":"2","Data":"eyJ0aWNrIjoxfQ=="}
```

（`Data` 是 `[]byte`，JSON 中以 base64 表示。）

**返回 / 响应**：无返回值。SDK：`app.ReinjectTcpData(td TcpDataRequest)`，内部 marshaling 的是 SDK 的 `app.TcpDataRequest`（字段 `uuid/command1..4/source/commandA/data`）。

**代码片段**：

```go
//go:wasmimport env app_tcp_received_data
func appTcpReceivedData(tcpDataJSON string)

func inject(data []byte) {
    // 直接按宿主结构注入（注意 Data 是 []byte，base64 表示）
    td := map[string]any{
        "UUID": "n-0a1b", "Command1Str": "relayData", "Command2Str": "MyEvent",
        "Command3Str": "gui", "Command4Str": "n-0a1b", "Source": "2",
        "Data": data, // Go json.Marshal([]byte) → base64 字符串
    }
    b, _ := json.Marshal(td)
    appTcpReceivedData(string(b))
}
```

**注意事项**：

1. **重注入深度上限 5 层**：超过上限的注入直接丢弃，防止插件之间无限循环转发。
2. 重注入期间**不会**回调 `应用_OnTcpDataReceived`（重注入来源的消息直接跳过），避免自触发死锁。
3. `Data` 是 `[]byte`：传文本时用 base64，否则宿主 `json.Unmarshal` 可能失败而不注入。

#### `app_call`

**作用**：内置工具函数统一入口，复用宿主内置的安全函数表（编码/哈希/JSON 读取等）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_call
func appCall(funcID uint32, args string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `funcID` | uint32 | 内置函数 ID，见下表 | `10` |
| `args` | string | 参数 JSON 数组 | `"[\"hello\"]"` |
| `out` | unsafe.Pointer | 输出缓冲 | `unsafe.Pointer(&buf[0])` |
| `outCap` | uint32 | 缓冲容量 | `uint32(len(buf))` |

内置函数 ID（SDK 已封装的）：

| funcID | SDK 封装 | 用途 |
|---|---|---|
| 10 | `app.Base64Encode` | Base64 编码 |
| 11 | `app.Base64Decode` | Base64 解码 |
| 20 | `app.MD5` | MD5（十六进制小写） |
| 21 | `app.SHA1` | SHA1 哈希 |
| 23 | `app.SHA256` | SHA256 哈希 |
| 50 | `app.JSONGet` | 按路径读 JSON 值（如 `a.b[0].c`） |

**返回 / 响应**：返回写入字节数。内容为函数结果字符串；出错时宿主写 `{"error":"..."}`。

```json
{"error":"未知的内置函数 ID: 99"}
```

**代码片段**：

```go
//go:wasmimport env app_call
func appCall(funcID uint32, args string, out unsafe.Pointer, outCap uint32) uint32

func callBuiltin(id uint32, args ...any) (string, bool) {
    b, _ := json.Marshal(args)
    var buf [64 * 1024]byte
    n := appCall(id, string(b), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n == 0 {
        return "", false
    }
    return string(buf[:n]), true
}

// 调用（等价 app.MD5）
func demo() { s, _ := callBuiltin(20, "hello") }
```

**注意事项**：

1. 通用入口 SDK 是 `app.CallBuiltin(funcID, args...) (string, bool)`；返回 `false` 表示宿主未产出结果。
2. 出错时返回的是 JSON `{"error":...}` 文本而不是空串，注意甄别。
3. `args` 必须是 JSON 数组，元素顺序与内置函数参数一致。

#### `app_t`

**作用**：读取语言包文本，键空间为 `<当前语言>.<插件UUID>.<key>`。

**签名 / 触发形式**：

```go
//go:wasmimport env app_t
func appT(key string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `key` | string | 语言包键（不含语言与 UUID 前缀） | `"VulnName"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — |

**返回 / 响应**：返回写入字节数，内容是**纯文本**（不是 JSON）。找不到时回退返回 key 本身。

```text
远程命令执行
```

**代码片段**：

```go
//go:wasmimport env app_t
func appT(key string, out unsafe.Pointer, outCap uint32) uint32

func T(key string) string {
    var buf [4096]byte
    n := appT(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

func demo() { name := T("VulnName") }
```

**注意事项**：

1. 语言包文件是插件目录下的 `language-<lang>.json`（`-cn` / `-en` 各一份），插件加载时由宿主读取。
2. 键空间带插件 UUID，源文件里写 `VulnName` 即可，宿主自动补 `<lang>.<uuid>.` 前缀。
3. 在英文模式下取不到英文键时会回退 key 本身，四端语言包要补齐（见工作区约定）。

#### `app_config`

**作用**：读取插件配置 `plugin.config.json` 中的 Key/Value（扁平静态键）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_config
func appConfig(key string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `key` | string | 配置键 | `"Enabled"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — |

**返回 / 响应**：返回写入字节数，内容是**配置值的纯文本**；键不存在返回空串。

```text
true
```

**代码片段**：

```go
//go:wasmimport env app_config
func appConfig(key string, out unsafe.Pointer, outCap uint32) uint32

func ConfigGet(key string) string {
    var buf [4096]byte
    n := appConfig(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

func demo() { enabled := ConfigGet("Enabled") == "true" }
```

**注意事项**：

1. `plugin.config.json` 是扁平 Key/Value（`map[string]string`），复杂配置常序列化到某个键（如 `ConfigJson`）。
2. 配置由 GUI 插件页保存 → 控制器 → 下发节点，重启/重载后生效。
3. 值不是 JSON，解析数字/布尔要自己转换。

#### `app_node_info`

**作用**：获取当前扫描节点信息，供状态上报与多节点区分。

**签名 / 触发形式**：

```go
//go:wasmimport env app_node_info
func appNodeInfo(out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — |

**返回 / 响应**：返回写入字节数，内容为 JSON：

```json
{"uuid":"n-0a1b","nodeName":"node-01","language":"cn","appId":"testsecscan","pluginId":"plugin-exttools"}
```

| 字段 | 含义 |
|---|---|
| `uuid` | 节点 UUID |
| `nodeName` | 节点名 |
| `language` | 当前语言 |
| `appId` | 应用 ID |
| `pluginId` | 当前插件 UUID（状态上报标识自己用） |

**代码片段**：

```go
//go:wasmimport env app_node_info
func appNodeInfo(out unsafe.Pointer, outCap uint32) uint32

// SDK 已封装为 app.GetNodeInfo()，但 SDK 的 NodeInfo 不含 pluginId，需自行声明结构体
type nodeInfo struct {
    UUID     string `json:"uuid"`
    NodeName string `json:"nodeName"`
    PluginID string `json:"pluginId"`
}

func fetchNodeInfo() *nodeInfo {
    var buf [4096]byte
    n := appNodeInfo(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var info nodeInfo
    if json.Unmarshal(buf[:n], &info) != nil {
        return nil
    }
    return &info
}
```

**注意事项**：

1. 宿主返回 **5 个字段**（比 SDK `app.NodeInfo` 多 `pluginId`）；用 SDK 的 `app.GetNodeInfo()` 会丢掉 `pluginId`。
2. 状态上报必须带 `pluginId` 与 `uuid`，否则控制器会按无效丢弃。

#### `app_free_port`

**作用**：分配一个当前空闲的本地端口，供外部工具把"监听端口"写进命令模板，避免并发任务/多实例撞端口。

**签名 / 触发形式**：

```go
//go:wasmimport env app_free_port
func appFreePort(out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：无入参（除输出缓冲）。

**返回 / 响应**：返回写入字节数，内容为 JSON：

```json
{"port":34567}
```

失败时：

```json
{"error":"连续 20 次未取到未占用的空闲端口"}
```

宿主在 `127.0.0.1:0` 上真实 bind 一次，读到内核分配端口后立即释放；分配结果在 30s 内不重复发放（防 TOCTOU）。

**代码片段**：

```go
//go:wasmimport env app_free_port
func appFreePort(out unsafe.Pointer, outCap uint32) uint32

func allocPort() int {
    var buf [4096]byte
    n := appFreePort(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Port  int    `json:"port"`
        Error string `json:"error"`
    }
    if json.Unmarshal(buf[:n], &resp) != nil || resp.Port <= 0 {
        return 0
    }
    return resp.Port
}
```

**注意事项**：

1. 端口是"瞬时 bind 后释放"的结果，拿到后要尽快交给外部工具；30s 去重窗口只是缓解、不是独占。
2. 每个进程实例（`Count>1`）应各自分配一个端口。
3. 只在回环上 bind+close，不接受入参、不发数据，不构成任意网络能力。

#### `app_set_timeout`

**作用**：动态设置本插件单次钩子执行超时（毫秒）；不调用默认 30s。

**签名 / 触发形式**：

```go
//go:wasmimport env app_set_timeout
func appSetTimeout(ms uint32)
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `ms` | uint32 | 超时毫秒；`<=0` 忽略 | `120000` |

**返回 / 响应**：无返回值。硬上限 5 分钟，超出按上限处理。SDK：`app.SetTimeout(ms int64)`。

**代码片段**：

```go
//go:wasmimport env app_set_timeout
func appSetTimeout(ms uint32)

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    appSetTimeout(120000) // 本钩子单次最多跑 2 分钟
    // ...
}
```

**注意事项**：

1. 每次钩子执行前宿主把超时重置回默认 30s，所以**每个钩子都要自己调**。
2. 设置超时的是"当前这次钩子执行"，不是插件的全局属性。
3. 宿主同时还有一层 5 分钟的硬上限，即使不设也是 5 分钟封顶。

#### `app_exec_tool`

**作用**：**同步**执行插件目录内外部工具并回收 stdout/stderr（跑完即退的命令行工具，如 nuclei 单次扫描）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_exec_tool
func appExecTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `tool` | string | 工具名（`tools/<name>/`） | 你的代码 | `"nuclei"` |
| `runtime` | string | `python`/`java`/`""` | 你的代码 | `""` |
| `argsJSON` | string | 参数 JSON 数组 | 你的代码 | `"[\"-silent\"]"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — | — |

**返回 / 响应**：返回写入字节数，内容为 `ExecResult` JSON：

```json
{"exitCode":0,"stdout":"[info] scan done\n","stderr":"","error":""}
```

执行失败/超时：

```json
{"exitCode":0,"stdout":"","stderr":"","error":"外部工具执行超时（1m0s）"}
```

**权限 / 能力要求**：必须在 `[INIT]` 声明 `tools.exec`（`app.CapToolsExec`）且工具经 **GUI 授权**，并走全局任务队列调度。

**代码片段**（SDK 已封装）：

```go
result := app.ExecTool("nuclei", "", "-l", "targets.txt", "-o", "out.txt")
var res struct {
    ExitCode int    `json:"exitCode"`
    Stdout   string `json:"stdout"`
    Stderr   string `json:"stderr"`
    Error    string `json:"error"`
}
_ = json.Unmarshal([]byte(result), &res)
if res.Error != "" { app.Logf("nuclei 失败: %s", res.Error) }
```

**注意事项**：

1. 未声明 `tools.exec` 会直接返回 `{"error":"插件未声明 tools.exec 能力，禁止调用外部工具"}`；未授权返回"未获用户授权"。
2. 工具必须在 `tools/<name>/` 下，禁调终端（`cmd`/`powershell`/`sh`/`bash` 等黑名单），不经 shell。
3. 默认超时 60s，stdout/stderr 各上限 4MB；队列并发默认 2、排队 32、等待 30s。

#### `app_read_file`

**作用**：读取插件沙箱内文件（相对路径），返回含 base64 内容的信封。

**签名 / 触发形式**：

```go
//go:wasmimport env app_read_file
func appReadFile(path string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `path` | string | 相对插件目录的路径 | `"data/toollogs/sqlmapapi.log"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — |

**返回 / 响应**：返回写入字节数，内容为 JSON：

```json
{"ok":true,"data":"aGVsbG8=","size":5,"error":""}
```

失败：

```json
{"ok":false,"size":0,"error":"路径穿越被拒绝: ../secret"}
```

**代码片段**：

```go
raw := app.ReadFile("data/ext-results/t-1/out.txt")
var env struct {
    OK    bool   `json:"ok"`
    Data  string `json:"data"`
    Error string `json:"error"`
}
_ = json.Unmarshal([]byte(raw), &env)
if env.OK {
    b, _ := base64.StdEncoding.DecodeString(env.Data)
    _ = b // 文件内容
}
```

**注意事项**：

1. `data` 是 **base64**，必须解码才是文件内容。
2. 单文件上限 4MB，超出截断；仅相对路径，拒绝绝对路径、`..`、符号链接。
3. 能力名 `fs.read`（`app.CapFSRead`）；硬约束是目录沙箱（见第八章）。

#### `app_write_file`

**作用**：写入插件沙箱内文件（自动创建父目录）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_write_file
func appWriteFile(path string, data unsafe.Pointer, dataLen uint32, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `path` | string | 相对插件目录的路径 | `"data/state-t1.json"` |
| `data` | unsafe.Pointer | 待写字节的指针 | `unsafe.Pointer(&b[0])` |
| `dataLen` | uint32 | 字节数 | `uint32(len(b))` |
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — |

**返回 / 响应**：返回写入字节数，内容为 JSON：

```json
{"ok":true,"size":128,"error":""}
```

**代码片段**：

```go
b, _ := json.Marshal(state)
var obuf [4096]byte
n := appWriteFile("data/exttools-state-t1.json",
    unsafe.Pointer(&b[0]), uint32(len(b)),
    unsafe.Pointer(&obuf[0]), uint32(len(obuf)))
_ = n
```

**注意事项**：

1. 宿主没有"删除文件"接口，清空用写 `{}` 再在读取侧判空（示例插件 `clearState` 就这么做）。
2. 父目录自动创建；拒绝符号链接与越界路径。
3. 能力名 `fs.write`（`app.CapFSWrite`）。

#### `app_spawn_tool`

**作用**：**后台**拉起 `tools/<tool>/` 下的工具进程（不等待退出），stdout/stderr 追加写 `data/toollogs/<key>.log`。适用于常驻服务型工具（sqlmapapi、xray 监听）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_spawn_tool
func appSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：`argsJSON` 为 `spawnRequest`。

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `tool` | string | 工具名 | `"xray"` |
| `runtime` | string | `python`/`java`/`""` | `""` |
| `entry` | string | 入口文件（相对 `tools/<tool>/`，空=默认定位），cwd 切到入口所在目录 | `"sqlmapapi.py"` |
| `args` | []string | 命令行参数数组 | `["--listen","127.0.0.1:1800"]` |
| `key` | string | 进程实例键；同工具多实例必须不同 key | `"xray#1-1"` |
| `dir` | string | 工作目录（绝对路径，须在插件沙箱内） | `"<output_dir>"` |
| `env` | map[string]string | 附加环境变量 | `{"X":"1"}` |
| `timeoutSec` | int | 超时秒数，超时自动 kill；`<=0` 不限时 | `900` |

**返回 / 响应**：`spawnResult`：

```json
{"pid":32140,"key":"xray#1-1","log":"data/toollogs/xray#1-1.log","error":""}
```

**权限 / 能力要求**：同 `app_exec_tool`（`tools.exec` + GUI 授权）；进程由插件自行管理，不占执行队列槽。

**代码片段**：

```go
//go:wasmimport env app_spawn_tool
func appSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32

func spawn(tool, runtime string, args map[string]any) (pid int, key, logRel, errMsg string) {
    b, _ := json.Marshal(args)
    var buf [256 * 1024]byte
    n := appSpawnTool(tool, runtime, string(b), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Pid   int    `json:"pid"`
        Key   string `json:"key"`
        Log   string `json:"log"`
        Error string `json:"error"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Pid, resp.Key, resp.Log, resp.Error
}
```

**注意事项**：

1. 同工具**同 key 重复拉起会先停旧进程**（"后启杀前启"）；多实例务必传不同 `key`（如 `nuclei#1`/`nuclei#2`）。
2. `entry` 必须仍落在 `tools/<tool>/` 内（防 `..`）；`dir` 必须在插件沙箱内，否则被忽略。
3. 日志文件路径在返回值 `log`，用 `app_read_file` 读取（例如解析 sqlmapapi 启动打印的 token）。

#### `app_stop_tool`

**作用**：停止本插件此前拉起的工具进程。

**签名 / 触发形式**：

```go
//go:wasmimport env app_stop_tool
func appStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `tool` | string | 空=全部；否则精确 key 或 `tool#`/`tool/` 前缀 | `"xray"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — |

**返回 / 响应**：`{"stopped":n}`。

```json
{"stopped":2}
```

**代码片段**：

```go
//go:wasmimport env app_stop_tool
func appStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32

func stopTool(key string) int {
    var buf [4096]byte
    n := appStopTool(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Stopped int `json:"stopped"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Stopped
}
```

**注意事项**：

1. 传工具名可停它的全部 `#n` 实例；传精确 key 只停单实例；空串停本插件全部进程。
2. 只能停"本插件"拉起的进程，不能操作别的插件或系统进程。
3. 插件重载/节点退出时宿主会兜底停止本插件的全部工具进程。

#### `app_tool_status`

**作用**：返回本插件进程存活状态（`drain` 阶段判断"还有没有进程没退"）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_tool_status
func appToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：`tool` 空=全部；tool 名匹配其全部实例，精确 key 只匹配单实例。

**返回 / 响应**：

```json
{"running":1,"exited":1,"list":[{"key":"xray#1-1","tool":"xray","pid":32140,"running":true,"startedAt":1759600000},{"key":"nuclei#2-1","tool":"nuclei","pid":32141,"running":false,"startedAt":1759599000}]}
```

**代码片段**：

```go
//go:wasmimport env app_tool_status
func appToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32

func anyRunning(waitKeys map[string]bool) bool {
    var buf [256 * 1024]byte
    n := appToolStatus("", unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var st struct {
        List []struct {
            Key     string `json:"key"`
            Running bool   `json:"running"`
        } `json:"list"`
    }
    _ = json.Unmarshal(buf[:n], &st)
    for _, p := range st.List {
        if p.Running && waitKeys[p.Key] {
            return true
        }
    }
    return false
}
```

**注意事项**：

1. 这是宿主侧"真值"，比插件自己记账可靠。
2. `running=false` 表示进程已退出（宿主已完成回收）。
3. `startedAt` 为 unix 秒，可用来和任务启动时间比对。

#### `app_http_request`

**作用**：向**本机回环地址**发 HTTP 请求，用于插件与自启的本地服务交互（如 sqlmapapi REST）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_http_request
func appHTTPRequest(reqJSON string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：`reqJSON` 为 `httpRequestRequest`。

| 字段 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `method` | string | 方法，空=GET | `"POST"` |
| `url` | string | 回环 URL | `"http://127.0.0.1:8775/task/new"` |
| `headers` | map[string]string | 请求头 | `{"Content-Type":"application/json"}` |
| `body` | string | 请求体 | `"{}"` |
| `timeoutMs` | int | 超时毫秒，默认 30s，上限 10 分钟 | `1500` |

**返回 / 响应**：`httpRequestResult`。

```json
{"status":200,"body":"{\"success\":true}","error":""}
```

**代码片段**：

```go
//go:wasmimport env app_http_request
func appHTTPRequest(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

func probe(host string, port int) bool {
    req, _ := json.Marshal(map[string]any{
        "method": "GET", "url": "http://" + host + ":" + strconv.Itoa(port) + "/", "timeoutMs": 1500,
    })
    var buf [256 * 1024]byte
    n := appHTTPRequest(string(req), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Status int    `json:"status"`
        Error  string `json:"error"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Status > 0
}
```

**注意事项**：

1. 仅允许 `127.0.0.1` / `localhost` / `::1`；其它地址一律拒绝，报"仅允许本机回环地址"。
2. 请求/响应体上限 8MB，超时默认 30s（上限 10 分钟）。
3. 想打外部站点用 `app_http_replay`，别用它。

#### `app_http_replay`

**作用**：把任务流量**经代理重放**给被扫描站点或外部被动分析器（如 xray 监听 7777）。目标是外部站点（不限回环）、经显式代理、响应体丢弃、HTTPS 忽略证书错误。

**签名 / 触发形式**：

```go
//go:wasmimport env app_http_replay
func appHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：`reqJSON` 为 `replayRequest`。

| 字段 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `method` | string | 方法，空=GET | `"GET"` |
| `url` | string | 目标 URL（须 http/https） | `"https://example.com/a"` |
| `headers` | map[string]string | 还原的请求头（Host/Content-Length/Connection 由库接管） | `{"User-Agent":"curl/8"}` |
| `body` | string | 请求体，上限 4MB | `""` |
| `proxy` | string | 重放代理，空=直连 | `"http://127.0.0.1:7777"` |
| `timeoutMs` | int | 默认 30s、上限 30s | `30000` |
| `async` | bool | `true`=后台发送立即返回 | `true` |

**返回 / 响应**：`replayResult`。

```json
{"started":true,"status":0,"error":""}
```

同步模式（`async=false`）成功：

```json
{"started":false,"status":200,"error":""}
```

**代码片段**：

```go
//go:wasmimport env app_http_replay
func appHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

func replayAsync(method, target string, headers map[string]string, body string) {
    req, _ := json.Marshal(map[string]any{
        "method": method, "url": target, "headers": headers,
        "proxy": "http://127.0.0.1:7777", "timeoutMs": 30000, "async": true,
    })
    var buf [64 * 1024]byte
    _ = appHTTPReplay(string(req), unsafe.Pointer(&buf[0]), uint32(len(buf)))
}
```

**注意事项**：

1. **`应用_OnTaskPacket` 必须用 `async=true`**：同步等待会把扫描分发循环拖死。
2. URL 缺 `http(s)://` 会报错；请求体超 4MB 被拒；超时上限 30s。
3. 代理未就绪/目标不可达是常态，插件侧应静默（示例插件不把失败当日志噪音）。

#### `app_task_env`

**作用**：返回当前任务的运行环境事实（目标清单、结果目录、路径锚点、运行时）。

**签名 / 触发形式**：

```go
//go:wasmimport env app_task_env
func appTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `taskIde` | string | 任务标识 | `"t-20261005-01"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — |

**返回 / 响应**：`taskEnvResult`：

```json
{"taskIde":"t-20261005-01","taskName":"内网巡检","nodeUuid":"n-0a1b","nodeName":"node-01","domain":"example.com","targetUrl":"https://example.com/a","domainsFile":"<pluginDir>/data/ext-results/t-20261005-01/targets-domains.txt","urlsFile":"<pluginDir>/data/ext-results/t-20261005-01/targets-urls.txt","outputDir":"<pluginDir>/data/ext-results/t-20261005-01","outputDirRel":"data/ext-results/t-20261005-01","pluginDir":"<pluginDir>","toolsDir":"<pluginDir>/tools","python":"C:\\py\\3.12\\python.exe","java":"java","secTestProxy":"http://127.0.0.1:8080"}
```

| 字段 | 含义 |
|---|---|
| `taskIde` / `taskName` | 任务标识/名称 |
| `nodeUuid` / `nodeName` | 节点标识/名称 |
| `domain` / `targetUrl` | 主域名 / 首个 URL（含协议） |
| `domainsFile` / `urlsFile` | 去重域名/URL 清单文件绝对路径（每行一个，供工具 `-l`/`-iL`） |
| `outputDir` / `outputDirRel` | 本任务结果目录绝对路径 / 相对插件目录（`app_read_file` 用） |
| `pluginDir` / `toolsDir` | 插件根目录 / 工具目录绝对路径 |
| `python` / `java` | 可执行文件路径（外部工具环境优先，回退 PATH 裸名） |
| `secTestProxy` | 本任务"安全测试代理设置"地址（未配置为空） |

**代码片段**：

```go
//go:wasmimport env app_task_env
func appTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32

type taskEnv struct {
    TaskIde      string `json:"taskIde"`
    OutputDir    string `json:"outputDir"`
    OutputDirRel string `json:"outputDirRel"`
    DomainsFile  string `json:"domainsFile"`
    ToolsDir     string `json:"toolsDir"`
    Python       string `json:"python"`
}

func fetchTaskEnv(taskIde string) *taskEnv {
    var buf [256 * 1024]byte
    n := appTaskEnv(taskIde, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var env taskEnv
    if json.Unmarshal(buf[:n], &env) != nil {
        return nil
    }
    return &env
}
```

**注意事项**：

1. 结果目录固定在插件沙箱内 `data/ext-results/<taskIde>/`，并会顺手清理 48h 前的旧目录。
2. `outputDir`/`domainsFile` 是**绝对路径**（工具参数用），`outputDirRel` 是**相对路径**（`app_read_file`/`app_list_dir` 用）。
3. 任务无流量时清单文件为空文件，其余字段仍返回。

#### `app_list_dir`

**作用**：列出插件沙箱内一个目录的直接子项（非递归，按名称排序），用于结果收集/状态检查。

**签名 / 触发形式**：

```go
//go:wasmimport env app_list_dir
func appListDir(path string, out unsafe.Pointer, outCap uint32) uint32
```

**参数表**：

| 参数 | 类型 | 传什么 | 示例值 |
|---|---|---|---|
| `path` | string | 相对插件目录的目录路径 | `"data/ext-results/t-1"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | 输出缓冲 | — |

**返回 / 响应**：`{ok,entries,error}`，每个条目为 `{name,size,modTime,isDir}`。

```json
{"ok":true,"entries":[{"name":"out.html","size":2048,"modTime":1759600100,"isDir":false},{"name":"sub","size":0,"modTime":1759600000,"isDir":true}]}
```

**代码片段**：

```go
//go:wasmimport env app_list_dir
func appListDir(path string, out unsafe.Pointer, outCap uint32) uint32

func listDir(rel string) ([]struct{ Name string; Size int64; IsDir bool }, bool) {
    var buf [256 * 1024]byte
    n := appListDir(rel, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        OK      bool `json:"ok"`
        Entries []struct {
            Name    string `json:"name"`
            Size    int64  `json:"size"`
            ModTime int64  `json:"modTime"`
            IsDir   bool   `json:"isDir"`
        } `json:"entries"`
        Error string `json:"error"`
    }
    if json.Unmarshal(buf[:n], &resp) != nil || !resp.OK {
        return nil, false
    }
    return resp.Entries, true
}
```

**注意事项**：

1. 只列**直接子项**（非递归）；`modTime` 是 unix 秒。
2. 能力名 `fs.read`；路径受目录沙箱约束。
3. 结果收集时常用 `size==0` 过滤空文件（空文件不算漏洞）。

---

## 五、SDK 全部导出标识符

SDK 包内的 `app.go`（Go 版，`package app`）。把它复制到插件工程的 `app/` 子目录，在 `go.mod` 加 `replace app => ./app`，然后 `import "app"` 即可。

### 5.1 常量

#### `HookTcpDataReceived`

**作用**：收到 TCP 数据的钩子导出名。值为 `"应用_OnTcpDataReceived"`。

```go
app.Capability(app.HookTcpDataReceived)
_ = app.HookTcpDataReceived // "应用_OnTcpDataReceived"
```

> 它是能力名常量，用 `Capability` 声明；真正被调用仍需导出同名函数。

#### `HookTcpDataSend`

**作用**：发送 TCP 数据的钩子导出名。值为 `"应用_OnTcpDataSend"`。

```go
app.Capability(app.HookTcpDataSend)
```

> 与 `HookTcpDataReceived` 成对，注意别混用。

#### `HookTaskStart`

**作用**：任务启动的钩子导出名。值为 `"应用_OnTaskStart"`。

```go
app.Capability(app.HookTaskStart)
```

> 任务钩子通常配合 `app.SetTimeout` 使用。

#### `HookTaskFilterVulns`

**作用**：任务启动前过滤漏洞的钩子导出名。值为 `"应用_OnTaskFilterVulns"`。

```go
app.Capability(app.HookTaskFilterVulns)
```

> 与 `HookTaskFilterFlows` 分别对应漏洞/流量两类过滤。

#### `HookTaskFilterFlows`

**作用**：任务启动前过滤流量包的钩子导出名。值为 `"应用_OnTaskFilterFlows"`。

```go
app.Capability(app.HookTaskFilterFlows)
```

> `filterIds` 填的是 `flows[].ide`。

#### `HookMitmHttpRequest`

**作用**：MITM HTTP 请求的钩子导出名。值为 `"应用_OnMitmHttpRequest"`。

```go
app.Capability(app.HookMitmHttpRequest)
```

> 返回键为 `request`。

#### `HookMitmHttpResponse`

**作用**：MITM HTTP 响应的钩子导出名。值为 `"应用_OnMitmHttpResponse"`。

```go
app.Capability(app.HookMitmHttpResponse)
```

> 返回键为 `response`。

#### `HookMitmWsMessage`

**作用**：MITM WebSocket 消息的钩子导出名。值为 `"应用_OnMitmWsMessage"`。

```go
app.Capability(app.HookMitmWsMessage)
```

> 返回键为 `message`。

#### `HookMitmSseEvent`

**作用**：MITM SSE 事件的钩子导出名。值为 `"应用_OnMitmSseEvent"`。

```go
app.Capability(app.HookMitmSseEvent)
```

> 返回键为 `event`。

#### `CapToolsExec`

**作用**：能力名 `"tools.exec"`，声明后才有资格调用外部工具。

```go
app.Capability(app.CapToolsExec)
app.DeclareTool("nuclei", "")
```

> 声明只是门票，还需 GUI 授权才能真的执行。

#### `CapFSRead`

**作用**：能力名 `"fs.read"`，声明读取插件目录内文件。

```go
app.Capability(app.CapFSRead)
```

> 文件访问的硬约束是目录沙箱，见第八章。

#### `CapFSWrite`

**作用**：能力名 `"fs.write"`，声明写入插件目录内文件。

```go
app.Capability(app.CapFSWrite)
```

> 与 `CapFSRead` 一起声明后即可跨钩子外化状态。

### 5.2 类型

#### `ToolDecl`

**作用**：外部工具声明项，用于 `[INIT]` 的 `tools` 数组与 GUI 授权清单。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `Name` | `name` | string | 工具名（`tools/<name>/` 目录） |
| `Runtime` | `runtime` | string | `python` / `java` / `""` |

```go
app.DeclareTool("sqlmap", "python")
_ = app.ToolDecl{Name: "sqlmap", Runtime: "python"}
```

> `Runtime` 决定宿主前置哪个运行时环境目录到 PATH。

#### `TcpDataRequest`

**作用**：TCP 收发钩子的请求体。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `UUID` | `uuid` | string | 本连接/本节点标识 |
| `Command1` | `command1` | string | 一级指令 |
| `Command2` | `command2` | string | 二级指令 |
| `Command3` | `command3` | string | 三级指令（目标 UUID） |
| `Command4` | `command4` | string | 四级指令（来源 UUID） |
| `Source` | `source` | string | `0`=GUI `1`=控制器 `2`=扫描节点 |
| `CommandA` | `commandA` | string | 业务动作名 |
| `Data` | `data` | string | 原始 Data 段 |

```go
var req app.TcpDataRequest
_ = app.LoadRequest(&req)
app.Log(req.Command2 + ":" + req.Data)
```

> 发送钩子里 `source`/`commandA` 通常为空。

#### `TcpDataResponse`

**作用**：TCP 收发钩子的响应体。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `Handled` | `handled` | bool | `true`=已消费（仅收到钩子生效） |
| `Error` | `error` | string | 错误信息（仅记录） |
| `Data` | `data` | string | 非空且不同 → 替换/重解析 |

```go
app.WriteResp(app.TcpDataResponse{Data: req.Data + "_x"})
```

> 空 `Data` 表示不修改。

#### `TaskStartRequest`

**作用**：`应用_OnTaskStart` 的请求体（只含过滤/路由相关字段）。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | 任务标识 |
| `TaskName` | `taskName` | string | 任务名称 |
| `ProxyMode` | `proxyMode` | string | `auto/direct/tunnel` |
| `SourceUUID` | `sourceUUID` | string | 来源（GUI）UUID |
| `TargetUUID` | `targetUUID` | string | 下发目标 UUID |
| `SelectedVulnIds` | `selectedVulnIds` | []string | 需扫描的漏洞 |
| `SelectedVulnIdsAdd` | `selectedVulnIdsAdd` | []string | 额外添加的漏洞 |
| `HttpSelectedScanUrlRowIde` | `httpSelectedScanUrlRowIde` | []string | 选中 HTTP 包 |
| `WsSelectedScanUrlRowIde` | `wsSelectedScanUrlRowIde` | []string | 选中 WS 包 |
| `SSESelectedScanUrlRowIde` | `sseSelectedScanUrlRowIde` | []string | 选中 SSE 包 |
| `HostsContent` | `hostsContent` | string | hosts 内容 |
| `ScanningRange` | `scanningRange` | string | 限定扫描范围 |
| `SkipScanDomainIP` | `skipScanDomainIP` | string | 跳过扫描的域名/IP |

```go
var req app.TaskStartRequest
_ = app.LoadRequest(&req)
app.Logf("任务 %s 漏洞数=%d", req.TaskName, len(req.SelectedVulnIds))
```

> 字段名区分大小写，JSON 里是 `selectedVulnIds`。

#### `TaskStartModify`

**作用**：`应用_OnTaskStart` 可修改的字段（`omitempty`，nil/空=不修改）。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `SelectedVulnIds` | `selectedVulnIds,omitempty` | []string | 替换漏洞列表 |
| `SelectedVulnIdsAdd` | `selectedVulnIdsAdd,omitempty` | []string | 替换附加漏洞 |
| `HttpSelectedScanUrlRowIde` | `httpSelectedScanUrlRowIde,omitempty` | []string | 替换 HTTP 选择 |
| `WsSelectedScanUrlRowIde` | `wsSelectedScanUrlRowIde,omitempty` | []string | 替换 WS 选择 |
| `SSESelectedScanUrlRowIde` | `sseSelectedScanUrlRowIde,omitempty` | []string | 替换 SSE 选择 |
| `HostsContent` | `hostsContent,omitempty` | string | 替换 hosts |

```go
app.WriteResp(app.TaskStartResponse{
    Handled: true,
    Task:    app.TaskStartModify{SelectedVulnIds: keep},
})
```

> `omitempty` 下空切片被省略，无法用空切片"清空"。

#### `TaskStartResponse`

**作用**：`应用_OnTaskStart` 的响应体。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `Handled` | `handled` | bool | 解析但当前不影响流程 |
| `Error` | `error` | string | 错误信息 |
| `Task` | `task` | TaskStartModify | 要应用的任务修改 |

```go
app.WriteResp(app.TaskStartResponse{Handled: true, Task: mod})
```

> 任务修改始终按 `task` 应用。

#### `TaskVulnBrief`

**作用**：漏洞过滤钩子的漏洞摘要。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `VulnIde` | `vulnIde` | string | 漏洞唯一标识 |
| `Name` | `name` | string | 漏洞名称 |
| `Level` | `level` | string | 等级 `4/3/2/1` |
| `PocType` | `pocType` | string | `yaml/go/wasm` |

```go
for _, v := range req.Vulns { app.Logf("%s %s", v.VulnIde, v.Name) }
```

> `VulnIde` 就是 `filterIds` 里要填的值。

#### `TaskFlowBrief`

**作用**：流量包过滤钩子的流量摘要。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `Ide` | `ide` | string | 数据包标识（`IdeTraffic`） |
| `Type` | `type` | string | `http/websocket/sse` |
| `Method` | `method` | string | 请求方法 |
| `URL` | `url` | string | 请求 URL |
| `Domain` | `domain` | string | 域名 |
| `TLS` | `tls` | string | `HTTP/HTTPS/WSS` |

```go
for _, f := range req.Flows { app.Logf("%s %s", f.Ide, f.Domain) }
```

> 剔除时 `filterIds` 填 `f.Ide`。

#### `TaskFilterResponse`

**作用**：过滤钩子统一响应。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `Handled` | `handled` | bool | 不生效 |
| `Error` | `error` | string | 错误信息 |
| `FilterIds` | `filterIds` | []string | 需要剔除的标识集合 |

```go
app.WriteResp(app.TaskFilterResponse{FilterIds: drop})
```

> 返回"要剔除"的，不是"要保留"的。

#### `MitmHttpRequest`

**作用**：MITM HTTP 请求快照。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | 任务标识 |
| `Method` | `method` | string | 请求方法 |
| `URL` | `url` | string | 完整 URL |
| `Proto` | `proto` | string | 协议版本 |
| `Host` | `host` | string | Host |
| `Headers` | `headers` | map[string][]string | 请求头 |
| `Body` | `body` | string | 已解压请求体 |
| `RemoteIP` | `remoteIp` | string | 远端服务器 IP |
| `TLS` | `tls` | string | `HTTP/HTTPS` |

```go
var req app.MitmHttpRequest
_ = app.LoadRequest(&req)
app.Logf("%s %s", req.Method, req.URL)
```

> `Headers` 是 `map[string][]string`。

#### `MitmHttpModify`

**作用**：HTTP 请求/响应可修改字段（`omitempty`）。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `Method` | `method,omitempty` | string | 替换方法 |
| `URL` | `url,omitempty` | string | 替换 URL |
| `Headers` | `headers,omitempty` | map[string][]string | 整体替换请求头 |
| `Body` | `body,omitempty` | string | 替换体 |
| `StatusCode` | `statusCode,omitempty` | int | 仅响应使用（>0 生效） |

```go
_ = app.MitmHttpModify{URL: req.URL, Body: "x"}
```

> 请求响应两侧共用，但响应侧不使用 method/url。

#### `MitmHttpResponse`

**作用**：MITM HTTP 响应快照。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | 任务标识 |
| `URL` | `url` | string | 请求 URL |
| `Method` | `method` | string | 请求方法 |
| `StatusCode` | `statusCode` | int | 响应状态码 |
| `Headers` | `headers` | map[string][]string | 响应头 |
| `Body` | `body` | string | 已解压响应体 |
| `RemoteIP` | `remoteIp` | string | 远端服务器 IP |
| `TLS` | `tls` | string | `HTTP/HTTPS` |

```go
var resp app.MitmHttpResponse
_ = app.LoadRequest(&resp)
app.Logf("status=%d", resp.StatusCode)
```

> 与请求快照的区别：无 `Proto`/`Host`，多了 `StatusCode`。

#### `MitmWsMessage`

**作用**：MITM WebSocket 消息帧。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | 任务标识 |
| `URL` | `url` | string | 连接 URL |
| `Domain` | `domain` | string | 域名 |
| `FromClient` | `fromClient` | bool | `true`=客户端→服务器 |
| `StatusType` | `statusType` | int | `1`=发送 `2`=接收 |
| `Content` | `content` | string | 消息内容 |

```go
var msg app.MitmWsMessage
_ = app.LoadRequest(&msg)
app.Logf("fromClient=%v content=%s", msg.FromClient, msg.Content)
```

> 方向判断优先用 `FromClient`。

#### `MitmWsModify`

**作用**：WebSocket 消息可修改字段。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `Content` | `content,omitempty` | string | 替换消息内容 |

```go
_ = app.MitmWsModify{Content: "pong"}
```

> `content` 为空不覆盖。

#### `MitmSseEvent`

**作用**：MITM SSE 事件。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | 任务标识 |
| `URL` | `url` | string | 连接 URL |
| `Event` | `event` | string | `event:` 字段（默认 message） |
| `ID` | `id` | string | `id:` 字段 |
| `Data` | `data` | string | `data:` 字段 |
| `Retry` | `retry` | int | `retry:` 字段（毫秒） |

```go
var evt app.MitmSseEvent
_ = app.LoadRequest(&evt)
app.Logf("event=%s id=%s", evt.Event, evt.ID)
```

> `ID` 对应 json `id`。

#### `MitmSseModify`

**作用**：SSE 事件可修改字段。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `Data` | `data,omitempty` | string | 替换事件数据 |

```go
_ = app.MitmSseModify{Data: "new-data"}
```

> `data` 为空不覆盖。

#### `NodeInfo`

**作用**：当前扫描节点信息（`GetNodeInfo` 返回）。

| 字段 | json | 类型 | 含义 |
|---|---|---|---|
| `UUID` | `uuid` | string | 节点 UUID |
| `NodeName` | `nodeName` | string | 节点名 |
| `Language` | `language` | string | 当前语言 |
| `AppID` | `appId` | string | 应用 ID |

```go
info := app.GetNodeInfo()
app.Logf("node=%s lang=%s", info.NodeName, info.Language)
```

> 宿主实际多返回 `pluginId`，SDK 的 `NodeInfo` 未含该字段。

### 5.3 函数

#### `Capability(name string)`

**作用**：声明单个能力点（自动去重）。

**签名 / 参数 / 返回**：`func Capability(name string)`；`name` 为能力名（钩子名或 `tools.exec` 等）；无返回。

```go
app.Capability(app.HookTaskStart)
```

> 空字符串被忽略。

#### `Capabilities(names ...string)`

**作用**：批量声明能力点。

**签名 / 参数 / 返回**：`func Capabilities(names ...string)`；无返回。

```go
app.Capabilities(app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd)
```

> 内部逐个调 `Capability`，可传任意个。

#### `DeclareTool(name, runtime string)`

**作用**：声明一个外部工具（去重）。

**签名 / 参数 / 返回**：`func DeclareTool(name, runtime string)`；`runtime` 为 `python`/`java`/`""`；无返回。

```go
app.DeclareTool("sqlmap", "python")
```

> 工具必须位于 `tools/<name>/` 下。

#### `DeclareTools(pairs ...string)`

**作用**：批量声明工具，格式 `"name=runtime"`。

**签名 / 参数 / 返回**：`func DeclareTools(pairs ...string)`；无返回。

```go
app.DeclareTools("sqlmap=python", "xray=java", "nuclei")
```

> 无 `=` 时 runtime 为空（原生可执行文件）。

#### `Declare()`

**作用**：把已声明的能力与工具序列化为 `[INIT]` 行写到 stdout。

**签名 / 参数 / 返回**：`func Declare()`；无返回；输出 `[INIT] {"capabilities":[...],"tools":[...]}`。

```go
app.Capability(app.HookTaskStart)
app.DeclareTool("nuclei", "")
app.Declare()
```

> 必须在钩子函数内部调用，否则该次执行的 stdout 里没有 `[INIT]`。

#### `LoadRequest(v any) error`

**作用**：从 stdin 读取宿主注入的请求 JSON 并解析到 `v`（每个钩子执行时调用一次）。

**签名 / 参数 / 返回**：`func LoadRequest(v any) error`；`v` 为请求结构体指针；出错返回 error。

```go
var req app.TcpDataRequest
if err := app.LoadRequest(&req); err != nil {
    app.WriteError(err)
    return
}
```

> 每个钩子只读一次 stdin（`io.ReadAll(os.Stdin)`）。

#### `WriteResp(v any)`

**作用**：把响应以 `[RESP] <JSON>` 单行写到 stdout（宿主解析）。

**签名 / 参数 / 返回**：`func WriteResp(v any)`；无返回。

```go
app.WriteResp(app.TcpDataResponse{Data: "x"})
```

> 值 `v` 会被 `json.Marshal`；务必是能编成对象的值。

#### `WriteHandled()`

**作用**：声明插件已消费本次事件，等价 `WriteResp({"handled":true})`。

**签名 / 参数 / 返回**：`func WriteHandled()`；无返回。

```go
//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { app.WriteHandled() }
```

> 只有收到钩子的 `handled=true` 会跳过内置处理。

#### `WriteError(err error)`

**作用**：返回错误信息（仅记录日志，不影响主程序流程）。

**签名 / 参数 / 返回**：`func WriteError(err error)`；无返回；输出 `{"error":"..."}`。

```go
app.WriteError(fmt.Errorf("请求解析失败：%v", err))
```

> 不要在错误路径直接 `return` 而不写任何响应，否则宿主看不到 `[RESP]`。

#### `Log(msg string)`

**作用**：输出调试日志（走 `app_log` 主机函数）。

**签名 / 参数 / 返回**：`func Log(msg string)`；无返回。

```go
app.Log("工具已启动")
```

> 与 `WriteResp` 无关，不进 stdout 行协议。

#### `Logf(format string, args ...any)`

**作用**：格式化日志，等价 `Log(fmt.Sprintf(...))`。

**签名 / 参数 / 返回**：`func Logf(format string, args ...any)`；无返回。

```go
app.Logf("pid=%d key=%s", pid, key)
```

> 高频路径注意控制日志量。

#### `SetTimeout(ms int64)`

**作用**：设置本插件单次钩子执行超时（毫秒）。

**签名 / 参数 / 返回**：`func SetTimeout(ms int64)`；无返回；硬上限 5 分钟。

```go
app.SetTimeout(290000)
```

> 每次钩子执行前超时重置为默认 30s，每个钩子都要自己设。

#### `ExecTool(tool, runtime string, args ...string) string`

**作用**：同步执行插件目录内外部工具，返回宿主 JSON `{exitCode,stdout,stderr,error}`。

**签名 / 参数 / 返回**：`func ExecTool(tool, runtime string, args ...string) string`；返回结果 JSON 文本。

```go
raw := app.ExecTool("nuclei", "", "-l", env.DomainsFile, "-o", out)
var res struct {
    ExitCode int    `json:"exitCode"`
    Stdout   string `json:"stdout"`
    Error    string `json:"error"`
}
_ = json.Unmarshal([]byte(raw), &res)
```

> 需 `tools.exec` + GUI 授权 + 全局队列调度。

#### `ReadFile(path string) string`

**作用**：读插件沙箱内文件，返回 `{ok,data(base64),size,error}`。

**签名 / 参数 / 返回**：`func ReadFile(path string) string`；返回信封 JSON。

```go
raw := app.ReadFile("data/out.txt")
var env struct{ OK bool `json:"ok"`; Data string `json:"data"`; Error string `json:"error"` }
_ = json.Unmarshal([]byte(raw), &env)
```

> `data` 是 base64，需解码。

#### `WriteFile(path string, data []byte) string`

**作用**：写插件沙箱内文件，返回 `{ok,size,error}`。

**签名 / 参数 / 返回**：`func WriteFile(path string, data []byte) string`；返回信封 JSON。

```go
_ = app.WriteFile("data/state.json", []byte(`{"n":1}`))
```

> 自动创建父目录；无删除接口。

#### `SendTcpData(cmd1, cmd2, cmd3, cmd4, data string)`

**作用**：发送原始 TCP 数据（四级指令 + 原始数据段）。

**签名 / 参数 / 返回**：`func SendTcpData(cmd1, cmd2, cmd3, cmd4, data string)`；无返回。

```go
app.SendTcpData("relayData", "MyEvent", "gui", "", "raw-data")
```

> `cmd3` 为目标，`cmd4` 为空自动填本节点。

#### `SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`

**作用**：发送 TCP JSON 数据（自动做 `{CommandA,Data}` 包裹）。

**签名 / 参数 / 返回**：`func SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`；无返回。

```go
app.SendTcpDataJson("scan", "SaveScanVuln", "", "", "SaveScanVuln", vuln)
```

> `data` 传结构体，**别自己 marshal 成 []byte**。

#### `ReinjectTcpData(td TcpDataRequest)`

**作用**：把一条 TCP 数据重新注入宿主收包流程（深度限制 5 层）。

**签名 / 参数 / 返回**：`func ReinjectTcpData(td TcpDataRequest)`；无返回。

```go
app.ReinjectTcpData(app.TcpDataRequest{
    UUID: "n-0a1b", Command1: "relayData", Command2: "MyEvent",
    Command3: "gui", Command4: "n-0a1b", Source: "2", Data: "raw",
})
```

> 重注入期间不再回调本钩子；深度超限丢弃。

#### `ConfigGet(key string) string`

**作用**：读 `plugin.config.json` 的 Key/Value。

**签名 / 参数 / 返回**：`func ConfigGet(key string) string`；返回值纯文本（非 JSON）。

```go
if app.ConfigGet("Enabled") == "true" { /* ... */ }
```

> 键不存在返回空串。

#### `T(key string) string`

**作用**：读取语言包文本（键空间 `<lang>.<uuid>.<key>`）。

**签名 / 参数 / 返回**：`func T(key string) string`；返回文本；缺失回退 key。

```go
title := app.T("VulnName")
```

> 语言包文件为 `language-cn.json` / `language-en.json`。

#### `GetNodeInfo() NodeInfo`

**作用**：获取当前扫描节点信息。

**签名 / 参数 / 返回**：`func GetNodeInfo() NodeInfo`；返回 `app.NodeInfo`。

```go
info := app.GetNodeInfo()
app.Logf("node=%s", info.NodeName)
```

> SDK 结构体不含宿主返回的 `pluginId`，需要时自行声明结构体读 `app_node_info`。

#### `CallBuiltin(funcID uint32, args ...any) (string, bool)`

**作用**：调用宿主内置工具函数（`app_call` 统一入口）。

**签名 / 参数 / 返回**：`func CallBuiltin(funcID uint32, args ...any) (string, bool)`；`false` 表示无结果。

```go
s, ok := app.CallBuiltin(20, "hello") // MD5
```

> funcID 见 4.5 表；出错时返回 `{"error":...}` 文本。

#### `Base64Encode(data string) string`

**作用**：Base64 编码（funcID 10）。

**签名 / 参数 / 返回**：`func Base64Encode(data string) string`。

```go
enc := app.Base64Encode("hello")
```

> 内部即 `CallBuiltin(10, data)`。

#### `Base64Decode(data string) string`

**作用**：Base64 解码（funcID 11）。

**签名 / 参数 / 返回**：`func Base64Decode(data string) string`。

```go
plain := app.Base64Decode(enc)
```

> 非法输入由宿主返回错误文本。

#### `MD5(data string) string`

**作用**：MD5 哈希（十六进制小写，funcID 20）。

**签名 / 参数 / 返回**：`func MD5(data string) string`。

```go
sum := app.MD5("hello")
```

> 内存模式哈希。

#### `SHA1(data string) string`

**作用**：SHA1 哈希（funcID 21）。

**签名 / 参数 / 返回**：`func SHA1(data string) string`。

```go
sum := app.SHA1(token)
```

> 用于去重 hash 等场景。

#### `SHA256(data string) string`

**作用**：SHA256 哈希（funcID 23）。

**签名 / 参数 / 返回**：`func SHA256(data string) string`。

```go
sum := app.SHA256(payload)
```

> 与宿主对工具文件做 SHA-256 的口径一致。

#### `JSONGet(data []byte, path string) string`

**作用**：按路径读取 JSON 值（funcID 50，路径如 `a.b[0].c`）。

**签名 / 参数 / 返回**：`func JSONGet(data []byte, path string) string`。

```go
name := app.JSONGet([]byte(`{"a":{"b":[{"c":"x"}]}}`), "a.b[0].c")
```

> 返回字符串形式的值。

---

## 六、构建、目录与 go.mod

### 6.1 插件目录结构

```text
scan-poc/plugin/<uuid>/
├── build/
│   └── scan.wasm              # 构建产物（必填；宿主按 build/*.wasm 定位，优先 scan.wasm）
├── plugin.config.json         # 插件 Key/Value 配置（可缺省）
├── language-cn.json           # 中文语言包（可缺省，键空间 <lang>.<uuid>.<key>）
├── language-en.json           # 英文语言包（可缺省）
├── sig.json                   # 可选 Ed25519 签名信息
└── tools/<name>/              # 外部工具（可选；声明并授权后可用）
    ├── bin/<name>[.exe]       # 可执行文件
    └── <name>.py / <name>.jar # python / java 工具入口
```

宿主兼容两种布局：标准 `root/<uuid>/build/scan.wasm`，以及历史 `root/<uuid>/Plugin/scan/<uuid>/build/scan.wasm`。若存在 `sig.json` 且验签失败（`verify_failed`），插件禁止加载；无签名记为 `unsigned`（信任控制器 AES-GCM 下发通道）。

### 6.2 构建命令

```bash
GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .
```

**必须使用 `-buildmode=c-shared`**：宿主需要调用 `_initialize` 初始化 Go 运行时后再直调 `应用_xxx` 导出函数。`func main() {}` 必须保留（c-shared 模式要求，空实现即可）。

### 6.3 go.mod 与 SDK 引用

```text
module my-plugin

go 1.22
```

把 SDK 包里的 `app.go` 放到工程的 `app/` 子目录后：

```text
require app v0.0.0

replace app => ./app
```

导出函数用 `//go:wasmexport` 指定**中文前缀名**：

```go
//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { /* ... */ }

func main() {}
```

> [!CAUTION]
> 导出函数名**必须**是 `应用_` 前缀（如 `应用_OnTaskStart`），且与宿主常量完全一致。写成 `OnTaskStart`（无前缀）宿主不会识别，插件会被视为"没有实现任何能力"。此外，`应用_` 前缀是能力检测的唯一依据，务必逐字正确。

---

## 七、外部工具高级能力

应用插件可以让平台侧**不含任何外部工具专属代码**地接入 nuclei / xray / sqlmap 等外部扫描器。核心门槛是**能力声明 + 用户授权**。

### 7.1 声明与授权

1. 插件在 `应用_Init` 中用 `DeclareTool` / `DeclareTools`（或直接构造 `[INIT]` 的 `tools` 数组）声明工具，工具必须位于 `tools/<name>/` 下；
2. 插件加载后，宿主计算工具可执行文件的 SHA-256，连同机器指纹向 GUI 发送 `ToolAuthRequest` 弹窗；
3. 用户在 GUI 确认后回 `ToolAuthConfirm`，宿主按 `pluginUUID/tool` 记录 `allowed`/`denied`（在节点侧持久化，按 GUI 用户区分决策）；
4. 后续调用 `app_exec_tool` / `app_spawn_tool` 前，宿主检查：**是否声明 `tools.exec`** + **授权状态是否为「已允许」**。

授权状态机：

| 状态 | 触发条件 |
|---|---|
| `unauthorized` | 默认；或工具 hash 变化；或换机器（指纹变化） |
| `allowed` | 用户允许；同机器 + 同 hash 下次启动**信任续用、跳过弹窗** |
| `denied` | 用户拒绝；该用户视角不再执行 |

多用户判定：任一 GUI 用户信任 → `allowed`；全部拒绝 → `denied`。未声明 `tools.exec` 会直接报"插件未声明 tools.exec 能力，禁止调用外部工具"。

### 7.2 `app_exec_tool` 与 `app_spawn_tool` 的区别

| 维度 | `app_exec_tool`（同步一次性） | `app_spawn_tool`（后台常驻） |
|---|---|---|
| 是否等待退出 | 是 | 否（立即返回 pid） |
| 输出 | `{exitCode,stdout,stderr,error}`（stdout/stderr 各上限 4MB） | 追加写 `data/toollogs/<key>.log`（可 `app_read_file`） |
| 适用 | 跑完即退的命令行工具（nuclei 单次扫描） | 常驻服务型工具（sqlmapapi、xray 监听） |
| 进程管理 | 无（执行完即结束） | `app_stop_tool` / `app_tool_status` / 超时自动 kill |
| 多实例 | 不适用 | 用不同 `key` 并存（`nuclei#1`、`nuclei#2`） |

### 7.3 全局任务队列调度

所有 `app_exec_tool` 调用经宿主的全局任务队列调度，防止插件一次性拉起大量进程拖垮主机：

- 全局并发上限**默认 2**；
- 排队深度上限**默认 32**，超出直接拒绝；
- 排队等待**默认 30s**，超时返回"队列繁忙"；
- `app_spawn_tool` 走同一套授权校验，但进程由插件自行管理（不占队列槽）。

### 7.4 结果回收

外部工具的结果文件必须落在 **`{output_dir}`**（`app_task_env` 返回，即插件沙箱内 `data/ext-results/<taskIde>/`）内，插件用 `app_read_file` / `app_list_dir` 以**相对路径**收集，再经 `app_send_tcp_json(..., "SaveScanVuln", ...)` 上报。命令模板与结果路径里可展开占位符；结果路径若越出 `{output_dir}` 会被忽略。

### 7.5 命令模板 18 个占位符

| 占位符 | 含义 |
|---|---|
| `{task_id}` | 任务 `taskIde` |
| `{task_name}` | 任务名 |
| `{node_id}` | 节点 UUID |
| `{node_name}` | 节点名 |
| `{domain}` | 主域名 |
| `{target_url}` | 首个 URL（含协议） |
| `{domains_file}` | 去重域名清单文件绝对路径 |
| `{urls_file}` | 去重 URL 清单文件绝对路径 |
| `{output_dir}` | 本任务结果输出目录绝对路径 |
| `{tools_dir}` | 插件工具目录绝对路径 |
| `{plugin_dir}` | 插件根目录绝对路径 |
| `{python}` | python 可执行文件（外部工具环境优先） |
| `{java}` | java 可执行文件（外部工具环境优先） |
| `{proxy}` | 本工具重放代理（如 `http://127.0.0.1:{port}`） |
| `{sec_proxy}` | 任务"安全测试代理设置"里的上游代理（未配置为空） |
| `{seq}` | 进程序号 |
| `{port}` | 本进程空闲端口（`app_free_port` 分配） |
| `{timestamp}` | 启动时间戳（`20060102-150405`） |

> [!TIP]
> `{port}` 与 `{seq}` 是**实例级**占位符：同一工具 `Count>1` 时，每个进程实例应分配各自端口，避免自身撞端口。并发任务之间也因端口与状态文件按任务隔离而互不干扰。

### 7.6 运行状态上报（可选）

示例插件 `plugin-exttools` 展示了通用 `PluginStatusReport` 上报：`app_node_info` 拿 `pluginId`/`uuid`，`app_tool_status` + 各任务状态文件汇总，经 `app_send_tcp_json("scan","PluginStatusReport","","","PluginStatusReport", payload)` 上报，GUI 插件页展示各扫描节点各工具运行状况。

```go
app.SendTcpDataJson("scan", "PluginStatusReport", "", "", "PluginStatusReport", map[string]any{
    "pluginId": info.PluginID, "nodeUuid": info.UUID, "nodeName": info.NodeName, "data": snapshot,
})
```

---

## 八、文件沙箱

`app_read_file`（`fs.read`）、`app_write_file`（`fs.write`）与 `app_list_dir`（`fs.read`）只能访问**插件自身目录内**的相对路径：

- 仅接受**相对路径**；拒绝绝对路径与前导 `/`；
- 拒绝 `../` 路径穿越；
- 拒绝符号链接（防逃逸）；
- 最终路径经 `filepath.Rel` 校验必须落在插件目录内，越界报"路径越界"。

`app_write_file` 会自动创建父目录。`app_read_file` 单文件上限 4MB，超出截断。`app_spawn_tool` 的 `dir` 参数（绝对路径）同样必须落在插件沙箱内，否则被忽略。

> [!WARNING]
> 沙箱拒绝是**硬拒绝**：试图用 `..\\`、绝对路径或符号链接读取插件目录外的文件会直接返回错误，不会降级放行。不要依赖"沙箱外读文件"的任何用法。

---

## 九、超时与稳定性

宿主对应用插件做了多层稳定性保障：

| 机制 | 说明 |
|---|---|
| 默认超时 | 单次钩子执行默认 **30s**；插件可 `app_set_timeout(ms)`（`SetTimeout`）重置 |
| 硬上限 | `app_set_timeout` 硬上限 **5 分钟**，超出按上限 |
| 串行执行 | 同一插件同时只允许一个执行；正在执行时再次触发的钩子**直接跳过**（防重入/死锁） |
| watchdog | 每次调用独立定时器，超时立即返回错误，不阻塞扫描进程 |
| poisoned 重建 | 超时后把运行时标记 `poisoned`，下次调用自动重建（卡死插件不影响后续） |
| 崩溃 recover | 执行 goroutine 内 `recover()`，插件 panic 被转为错误，不会拖垮宿主 |
| 编译保护 | 编译同样在带超时 context 下进行，畸形/恶意 wasm 不会卡死扫描进程 |
| 防重入 | `app_send_tcp` 触发的发送钩子、`app_tcp_received_data` 重注入（深度 5 层）都有防循环保护 |
| 插件上限 | 最多加载 **50** 个插件，超出跳过 |
| 进程回收 | 插件重载/节点退出时自动停止其拉起的全部常驻工具进程 |

> [!CAUTION]
> 重注入（`ReinjectTcpData`）深度上限为 **5 层**：如果插件转发逻辑形成闭环，第 6 层会被直接丢弃。请在插件内自行判断消息来源，避免把"已处理"的消息再次注入。同样，钩子执行中触发的同插件钩子会被跳过，不要设计依赖递归回调的逻辑。

---

## 十、调试手册

### 10.1 日志在哪里看

应用插件的日志有两条来源，最终都汇聚到**扫描节点日志**：

| 来源 | 产生方式 | 宿主落点 |
|---|---|---|
| `app.Log` / `app.Logf` | 调用 `app_log` 主机函数 | 收进本次执行的日志缓冲，最终以 `插件[<uuid>] 日志: ...` 形式写入扫描节点日志 |
| `[LOG]` 行或其它 stdout | 你直接 `os.Stdout.WriteString("[LOG] xxx\n")` | 宿主解析 `[LOG]` 前缀，与 `app_log` 一样收进本次执行的日志 |

宿主自身还会输出编译失败、钩子执行失败、超时、跳过等日志，例如：

```text
插件[plugin-x] 编译失败: 编译 wasm 模块失败: ...
插件[plugin-x] 应用_OnTaskStart 执行失败: 插件执行超时（默认 30s，可调用 app_set_timeout 调整）
插件[plugin-x] 声明了能力 应用_OnXxx 但未导出对应钩子函数，无法调用（跳过）
```

`[RESP]` 是**解析通道**不是日志：宿主解析到 `[RESP] <JSON>` 就立即用它，不打印原文。所以调试时除了看日志，还要确认插件"确实写了 `[RESP]`"。

### 10.2 5 分钟最小验证流程

1. **建工程**：新建目录，`go.mod` 写 `module my-plugin` + `go 1.22`，把 SDK 包里的 `app.go` 复制到 `app/` 子目录，加 `replace app => ./app`。
2. **写最小插件**（下面代码），保存为 `main.go`。
3. **编译**：`GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .`。
4. **放目录**：把 `scan.wasm` 放到 `scan-poc/plugin/plugin-demo-9999/build/scan.wasm`（开发机直接手动放置即可，无需走商店下发）。
5. **触发一次**：重启扫描节点（或让节点重载插件），随便触发一次任务/让节点收到一条 TCP 数据。
6. **看日志与 `[RESP]`**：日志里应出现"插件[plugin-demo-9999] 日志: [...]"，说明钩子被执行；若还有你改过的数据生效，则链路打通。

```go
package main

import "app"

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(app.HookTcpDataReceived)
    app.Declare()
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    app.Logf("hook ok: command2=%s", req.Command2)
    app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data})
}

func main() {}
```

### 10.3 症状 → 原因 → 解决

| 症状 | 原因 | 解决 |
|---|---|---|
| 插件完全无反应、日志里连编译/执行都没有 | 忘了 `-buildmode=c-shared`（命令模块自动跑完 `_start` 退出） | 构建加 `-buildmode=c-shared` |
| 链接失败 / 编译报错 | 漏写 `func main() {}` | 保留空的 `func main() {}` |
| 插件加载了但任何钩子都不调用 | 导出函数名不是 `应用_` 前缀 | `//go:wasmexport 应用_OnXxx`，逐字与宿主常量一致 |
| 只声明了能力，宿主不调用 | 只在 `[INIT]` 声明、没导出对应 `应用_xxx` 函数 | 导出表检测为准：必须真的 `//go:wasmexport` 对应钩子 |
| `应用_Init` 不执行、工具声明丢失 | `应用_Init` 没导出，或 `Declare()` 写在包初始化/全局变量里 | 导出 `应用_Init`，在钩子函数内部调用 `Declare()` |
| `app_exec_tool` 返回"未声明 tools.exec 能力" | 没声明 `tools.exec` | `app.Capability(app.CapToolsExec)`（`应用_Init` 中） |
| `app_exec_tool` 返回"未获用户授权" | 工具未在 GUI 确认授权，或 hash/机器变化 | 在 GUI 插件页确认；换机器/换工具文件需重新授权 |
| 宿主解析不到结果、仿佛无响应 | `WriteResp` 没输出 `[RESP]` 前缀（或自己拼了非单行 JSON） | 用 SDK `app.WriteResp`，输出单行 `[RESP] <JSON>` |
| `handled=true` 了但内置处理仍执行 | 用错了钩子：只有 `应用_OnTcpDataReceived` 的 `handled` 生效 | 按第 3 章"`handled` 是否生效"表使用 |
| 改了字段但没生效 | 请求/响应 JSON 字段名写错（大小写/驼峰，如 `selectedvulnids`、`filterids`） | 严格按 SDK 的字段名/json tag（`selectedVulnIds`、`filterIds`、`request`、`message`） |
| 重注入后数据被丢弃 | 重注入深度超过 5 层 | 判断消息来源，避免把已处理消息再次 `ReinjectTcpData`；深度上限 5 |
| `app_http_request` 报"仅允许本机回环地址" | 目标不是 `127.0.0.1`/`localhost`/`::1` | 打本地服务用 `app_http_request`；打外部站点用 `app_http_replay` |
| `app_read_file`/`app_list_dir` 报"路径越界/不允许绝对路径" | 用了绝对路径、`..` 或符号链接 | 只用插件目录内相对路径；绝对路径场景用 `app_task_env` 的 `outputDir` 交给外部工具 |
| 长任务被中断、报"插件执行超时" | 默认 30s 超时 | 钩子开头 `app.SetTimeout(ms)`，硬上限 5 分钟；`drain` 靠返回 `done=false` 分轮，不要 `sleep` |
| 超时后后续调用异常或变慢 | 运行时被标记 poisoned，正在重建 | 正常现象：下次调用自动重建运行时；持续排查为何超时 |
| 同工具多实例"后启杀前启" | `app_spawn_tool` 用了相同 `key`（默认按 tool 名记账） | 传不同 `key`（如 `nuclei#1`/`nuclei#2`） |
| 扫描变慢、分发循环卡住 | 在 `应用_OnTaskPacket` 里同步做重活 | 快进快出；重放 `app_http_replay` 用 `async=true`；重活留给 `应用_OnTaskEnd` |
| 对端收到 base64 字符串而不是 JSON 对象 | 自己先把结构体 `json.Marshal` 成 `[]byte` 再传 `SendTcpDataJson` | 直接传结构体/`any`，让 SDK 内部 marshal |
| 结果文件收集不到 | 结果写在 `{output_dir}` 之外 | 工具 `dir` 与结果路径锚定 `app_task_env` 的 `outputDir`，收集用 `outputDirRel` |

> [!IMPORTANT]
> 应用插件的宿主函数与 WASM POC 的 `scan_*` **完全不重叠**：写错一套的后果是**实例化失败**而非"函数返回错误"。发布前请确认插件的生效路径在"插件商店/应用插件目录"，而非"POC 模板列表"。

---

## 十一、完整示例

以下示例均可按第六章命令构建。示例一~三只需 SDK；示例四为外部工具进阶（需 `tools.exec` 与 GUI 授权）。

### 11.1 示例一：最小插件（只实现 TCP 收包）

```go
package main

import (
    "errors"

    "app"
)

//go:wasmexport 应用_Init
func OnInit() {
    app.Capability(app.HookTcpDataReceived)
    app.Declare() // 输出 [INIT]，声明本插件能力
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteError(errors.New("请求解析失败"))
        return
    }
    app.Log("收到 TCP 数据: " + req.Command2)
    // 在原数据后追加标记，宿主会用新数据重新解析后再分发
    app.WriteResp(app.TcpDataResponse{Data: req.Data + "_processed"})
}

func main() {}
```

> [!TIP]
> 若只想"观察"不想改数据，返回 `app.WriteResp(app.TcpDataResponse{})`（`data` 为空即不修改）；若要消费事件、跳过内置处理，返回 `app.WriteHandled()`。

### 11.2 示例二：`应用_OnTaskStart` 动态裁剪漏洞与流量

```go
package main

import (
    "strings"

    "app"
)

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000)
    app.Capability(app.HookTaskStart)
    app.Declare()
    var req app.TaskStartRequest
    _ = app.LoadRequest(&req)

    // 只扫描名称含 "rce" 的 POC
    keep := make([]string, 0, len(req.SelectedVulnIds))
    for _, id := range req.SelectedVulnIds {
        if strings.Contains(strings.ToLower(id), "rce") {
            keep = append(keep, id)
        }
    }
    // 剔除测试环境域名对应的数据包选择（按前缀判断）
    httpKeep := make([]string, 0, len(req.HttpSelectedScanUrlRowIde))
    for _, ide := range req.HttpSelectedScanUrlRowIde {
        if !strings.HasPrefix(ide, "test-") {
            httpKeep = append(httpKeep, ide)
        }
    }
    app.Logf("任务 %s：漏洞 %d→%d，HTTP 包 %d→%d",
        req.TaskName, len(req.SelectedVulnIds), len(keep),
        len(req.HttpSelectedScanUrlRowIde), len(httpKeep))
    app.WriteResp(app.TaskStartResponse{
        Handled: true,
        Task: app.TaskStartModify{
            SelectedVulnIds:           keep,
            HttpSelectedScanUrlRowIde: httpKeep,
        },
    })
}

func main() {}
```

### 11.3 示例三：`应用_OnMitmHttpRequest` 改写请求头

```go
package main

import "app"

//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() {
    app.Capability(app.HookMitmHttpRequest)
    app.Declare()
    var req app.MitmHttpRequest
    _ = app.LoadRequest(&req)

    headers := map[string][]string{}
    for k, v := range req.Headers {
        headers[k] = v
    }
    headers["X-Tss-Powered-By"] = []string{"TestSecScan-Plugin"}
    headers["X-Original-URL"] = []string{req.URL}

    resp := struct {
        Handled bool               `json:"handled"`
        Request app.MitmHttpModify `json:"request"`
    }{Request: app.MitmHttpModify{Headers: headers}}
    app.WriteResp(resp)
}

func main() {}
```

### 11.4 示例四：调用外部工具扫描并上报结果（进阶）

下列示例展示"启动时拉起工具 → 数据包重放 → 任务收口收集结果上报"的完整骨架，演示 `app_task_env`、`app_free_port`、`app_spawn_tool`、`app_http_replay`、`app_tool_status`、`app_list_dir`、`app_read_file` 与 `SaveScanVuln` 上报通道（SDK 未封装的宿主函数按 4.14~4.20 自行声明）。

```go
package main

import (
    "encoding/base64"
    "encoding/json"
    "unsafe"

    "app"
)

// ---- 未封装宿主函数声明 ----
//go:wasmimport env app_free_port
func hostFreePort(out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_spawn_tool
func hostSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_stop_tool
func hostStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_tool_status
func hostToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_http_replay
func hostHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_task_env
func hostTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_list_dir
func hostListDir(path string, out unsafe.Pointer, outCap uint32) uint32

const bufSize = 256 * 1024

func callOut(fn func(unsafe.Pointer, uint32) uint32) string {
    var buf [bufSize]byte
    n := fn(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n > uint32(len(buf)) {
        n = uint32(len(buf))
    }
    return string(buf[:n])
}

func callIn(fn func(string, unsafe.Pointer, uint32) uint32, in string) string {
    var buf [bufSize]byte
    n := fn(in, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

type taskEnv struct {
    TaskIde      string `json:"taskIde"`
    OutputDir    string `json:"outputDir"`
    OutputDirRel string `json:"outputDirRel"`
    DomainsFile  string `json:"domainsFile"`
    ToolsDir     string `json:"toolsDir"`
    Python       string `json:"python"`
}

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd, app.CapToolsExec, app.CapFSRead)
    app.DeclareTool("nuclei", "") // tools/nuclei/ 下的可执行文件
    app.Declare()
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000)
    var req struct {
        TaskIde string `json:"taskIde"`
    }
    _ = app.LoadRequest(&req)

    var env taskEnv
    _ = json.Unmarshal([]byte(callIn(hostTaskEnv, req.TaskIde)), &env)
    if env.OutputDir == "" {
        app.Log("任务环境不可用，跳过硬扫描")
        app.WriteResp(map[string]any{"handled": false})
        return
    }

    // 拉起 nuclei：结果写入当前任务结果目录（必须在 {output_dir} 内）
    args, _ := json.Marshal(map[string]any{
        "entry":      "nuclei.exe",
        "args":       []string{"-l", env.DomainsFile, "-o", env.OutputDir + "/nuclei.txt", "-silent"},
        "key":        "nuclei#1",
        "dir":        env.OutputDir,
        "timeoutSec": 900,
    })
    var resp struct {
        Pid   int    `json:"pid"`
        Error string `json:"error"`
    }
    _ = json.Unmarshal([]byte(callIn3(hostSpawnTool, "nuclei", "", string(args))), &resp)
    if resp.Error != "" {
        app.Logf("nuclei 启动失败：%s", resp.Error)
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    app.Logf("nuclei 已启动 pid=%d", resp.Pid)
    app.WriteResp(map[string]any{"handled": true})
}

//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() {
    app.SetTimeout(30000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Flow    struct {
            Method     string `json:"method"`
            URL        string `json:"url"`
            ReqHeaders string `json:"reqHeaders"`
            ReqBody    string `json:"reqBody"`
        } `json:"flow"`
    }
    _ = app.LoadRequest(&req)
    if req.Flow.URL == "" {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    // 把数据包异步重放给 xray（此处假设 xray 监听 127.0.0.1:7777）
    replay, _ := json.Marshal(map[string]any{
        "method": req.Flow.Method, "url": req.Flow.URL,
        "headers": headersOf(req.Flow.ReqHeaders), "body": req.Flow.ReqBody,
        "proxy": "http://127.0.0.1:7777", "async": true,
    })
    _ = callIn(hostHTTPReplay, string(replay))
    app.WriteResp(map[string]any{"handled": false}) // 通知型：不拦截
}

//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() {
    app.SetTimeout(290000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Phase   string `json:"phase"`
    }
    _ = app.LoadRequest(&req)
    if req.Phase != "close" {
        app.WriteResp(map[string]any{"handled": true, "done": true})
        return
    }
    _ = callIn(hostStopTool, "") // 收尾停止本插件全部工具进程
    app.WriteResp(map[string]any{"handled": true, "done": true})
}

// collectResult 读取沙箱内结果文件（解 base64 信封）
func collectResult(rel string) string {
    raw := app.ReadFile(rel)
    var env struct {
        OK   bool   `json:"ok"`
        Data string `json:"data"`
    }
    if json.Unmarshal([]byte(raw), &env) != nil || !env.OK {
        return ""
    }
    b, _ := base64.StdEncoding.DecodeString(env.Data)
    return string(b)
}

func headersOf(raw string) map[string]string {
    out := map[string]string{}
    for _, line := range splitLines(raw) {
        if i := indexByte(line, ':'); i > 0 {
            out[trim(line[:i])] = trim(line[i+1:])
        }
    }
    return out
}

func callIn3(fn func(string, string, string, unsafe.Pointer, uint32) uint32, a, b, c string) string {
    var buf [bufSize]byte
    n := fn(a, b, c, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n > uint32(len(buf)) {
        n = uint32(len(buf))
    }
    return string(buf[:n])
}

func main() {}
```

> [!NOTE]
> 上例省略了 `splitLines` / `trim` / `indexByte` 等字符串小工具，以及 `app_free_port`（分配端口）、`app_list_dir`/`app_tool_status`（drain 阶段等进程与枚举结果）的细节；`plugin-exttools` 示例插件给出了完整实现，可直接参考其"按任务隔离状态文件 + drain 轮询 + 结果收集"的写法。

---

## 十二、注意事项清单

| 坑 | 表现 | 规避 |
|---|---|---|
| 忘记 `-buildmode=c-shared` | 宿主无法 `_initialize` 或找不到导出函数，插件不生效 | 构建必须 `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .` |
| 缺少 `func main() {}` | c-shared 模式链接失败 | 保留空的 `func main() {}` |
| 导出名没有 `应用_` 前缀 | 能力检测不到，钩子永不调用 | `//go:wasmexport 应用_OnXxx`，逐字与宿主常量一致 |
| `WriteResp` 不写 `[RESP]` 行 | 宿主解析不到结果，视为无响应 | 用 SDK 的 `app.WriteResp`，输出单行 `[RESP] <JSON>` |
| 误以为所有钩子的 `handled` 都生效 | 返回 `handled=true` 却发现内置处理仍执行 | 只有 `应用_OnTcpDataReceived` 的 `handled=true` 才跳过内置处理 |
| 能力未声明就调外部工具 | `app_exec_tool` 返回"未声明 tools.exec 能力" | 在 `应用_Init` 中 `app.Capability(app.CapToolsExec)` 并 `DeclareTool`，再经 GUI 授权 |
| `Declare()` 写在包初始化/全局变量里 | `[INIT]` 未写入某次执行的 stdout，声明丢失 | 在钩子函数内部（通常 `应用_Init` 开头）调用 `Declare()` |
| 只声明 `[INIT]` 却没导出钩子 | 日志提示"声明了能力但未导出"，永不调用 | 声明只是元数据，必须真正 `//go:wasmexport` |
| `TaskStartModify` 用空切片想"清空" | `omitempty` 把空切片省略，实际未修改 | 该模型只能替换为非空值；清空需求请在插件内过滤后返回剩余项 |
| 重注入形成闭环 | 第 6 层被丢弃，逻辑不完整或空转 | 判断消息来源，避免把已处理消息再次 `ReinjectTcpData`；深度上限 5 |
| 在 `应用_OnTaskPacket` 里同步做重活 | 分发循环被阻塞，扫描变慢 | 钩子快进快出，重活交给 `应用_OnTaskEnd`；重放用 `app_http_replay` 的 `async=true` |
| 结果文件写到 `{output_dir}` 之外 | 收集阶段读不到，结果丢失 | 工具 `dir` 与结果路径都锚定 `app_task_env` 的 `outputDir`，收集用 `outputDirRel` |
| 用 `scan_*` 宿主函数 | 实例化失败（导入无法解析） | 应用插件只用 `app_*`；两套宿主函数互不通用 |
| 沙箱外读文件 | 返回"路径越界/不允许绝对路径" | `app_read_file`/`app_list_dir` 仅用插件目录内相对路径 |
| 同工具多实例共用默认 key | 后启动的进程杀掉先启动的 | `app_spawn_tool` 传不同 `key`（如 `nuclei#1`/`nuclei#2`） |
| 未设置超时做长任务 | 默认 30s 超时，任务被中断 | 在钩子开头 `app.SetTimeout(ms)`，硬上限 5 分钟 |
| 混淆 `app_http_request` 与 `app_http_replay` | 前者被"仅回环"拒绝，后者不适合打本地服务 | 打本地自启服务用 `app_http_request`；重放外部站点用 `app_http_replay` |
| 大小写/驼峰字段名写错 | `LoadRequest` 成功但字段全空 | 逐字对照 SDK 的 json tag（`selectedVulnIds`/`filterIds`/`request`/`message`） |

> [!IMPORTANT]
> 应用插件的宿主函数与 WASM POC 的 `scan_*` **完全不重叠**：写错一套的后果是**实例化失败**而非"函数返回错误"。发布前请确认插件的生效路径在"插件商店/应用插件目录"，而非"POC 模板列表"。

<!-- en -->
# Scan Node · Application Plugin Development

An application plugin is a **resident business plugin** of the scan node: rather than scanning a single packet, it is loaded alongside the scan node as a WASI wasm module and **exports functions named with the `应用_` prefix** to "hook" the key business points of the scan node — task start, TCP I/O, MITM HTTP/WS/SSE traffic, task packets, and task finalization.

This document is written for third-party developers and explains **every function one by one**: each hook, each host function, and each public SDK symbol is given with its signature, a parameter table, real JSON samples, and copy-paste-ready Go code. When you finish reading, you should be able to complete the whole flow — "create project → build → place files → trigger → debug" — on your own.

> [!NOTE]
> One-line selection guide: to "produce a vulnerability per packet" → use the WASM POC template (`scan_*`); to "stay resident and hook scan-node business points" → use an application plugin (`app_*`). The two sets of host functions are **not interchangeable**; see chapter 1 for details.

[[toc]]

---

## 1. What It Is

An application plugin is downloaded by the controller from the plugin store and delivered via `AddPluginPackage` into the scan node's `scan-poc/plugin/<uuid>/` directory, where the plugin manager scans and loads it when the scan node starts. Each plugin implements only the hooks it needs, and the host calls only the hook functions you **actually exported**.

### 1.1 Essential Differences from a WASM POC

| Dimension | WASM POC | Application Plugin |
|---|---|---|
| Directory | `scan-poc/<lang>/wasm/<VulnIde>/` | `scan-poc/plugin/<uuid>/` |
| Entry point | `_start` (`func main`), processes one packet per run | **Exported functions** with the `应用_` prefix, invoked per hook trigger |
| Capability detection | Fixed (stdin JSON + `_start`) | **Export-table detection**: the host only calls hooks that are actually exported |
| Build mode | Ordinary wasip1 command module | **Must use `-buildmode=c-shared`** (the host needs `_initialize` + direct calls to exported functions) |
| Host namespace | `scan_http` / `scan_log` / `scan_report` / `scan_call` / `scan_t` / `scan_config` / `scan_set_timeout` | `app_log` / `app_send_tcp*` / `app_call` / `app_spawn_tool` / … |
| Where it takes effect | Only entries with `PocType=wasm` in the **POC template list** | Scan-node application plugins installed from the plugin store |
| Use cases | Vulnerability detection, single request/response analysis | Task pre-processing, traffic filtering, MITM rewriting, TCP command extension, external tool integration |
| SDK file | `scan.go` | `app.go` |

> Both SDK files in the table are provided in the downloadable SDK package; copy them into the `app/` subdirectory of your plugin project and they are ready to use (see sections 5 and 6.3).

### 1.2 The Two Host-Function Sets Are Not Interchangeable (Core Constraint)

The scan node injects **two completely disjoint sets of host functions** for the two kinds of WASM modules:

- The WASM POC path registers only the `scan_*` group;
- The application plugin path registers only the `app_*` group (injected by the host into the wasm `env` module).

> [!WARNING]
> Putting `app.ExecTool(...)` into a WASM POC, or `scan.HTTP(...)` into an application plugin, will cause an **instantiation failure** because the host **does not export the corresponding function** (the wasm import cannot be resolved). How to tell: if your artifact is scanned as a vulnerability from the "POC template list" → use `scan.*`; if it runs as a resident plugin from the "plugin store / application plugin directory" → use `app.*`. Controller plugins use `ctl_*`, and AiAgent external tools use stdin/stdout JSON. The four sets are not interchangeable.

### 1.3 Capability Detection Mechanism (Core)

When the host **first needs to call a plugin** (or probes `应用_Init` at load time), it compiles that plugin's wasm, then enumerates the module's exported functions; every export **named with the `应用_` prefix** is collected into that plugin's capability table `caps`:

```text
caps = { name | name starts with "应用_" }
```

Before calling any hook, the host first checks whether that hook is in the actual export table; **hooks that were not exported are always skipped, incurring no instantiation or execution cost at all** (this is exactly why "implement only the hooks you need" saves resources). The module is compiled once and then cached; each subsequent hook call merely creates a new module instance (resetting wasm global state).

### 1.4 Dual-Channel Determination

Besides the export table, there is also a declaration channel `[INIT]`, consistent with controller application plugins:

| Channel | Source | Role |
|---|---|---|
| Export table (`Capabilities`) | Enumerate `应用_`-prefixed exports after compilation | **Whether a hook is called is ultimately decided by this** |
| `[INIT]` declaration (`Declared`) | The `[INIT] {...}` line printed by the plugin calling `Declare()` at the start of any hook | Used for gating (e.g. the `tools.exec` authorization check) and consistency metadata |

Decision logic: capability determination = export table (Capabilities) ∪ `[INIT]` declarations (Declared); but actual execution is still governed by **the real exports**. If a plugin declares a capability only in `[INIT]` but **does not export the corresponding hook function**, the host logs one line and skips it — it is **never called**:

```text
Plugin[<uuid>] declared capability 应用_OnTaskStart but did not export the corresponding hook function; cannot call (skipped)
```

### 1.5 Data Channel and Line Protocol

The host and the plugin communicate through **stdin / stdout**:

| Direction | Content |
|---|---|
| Host → plugin | The request JSON is written to **stdin** |
| Plugin → host | stdout line by line: `[RESP] <JSON>` result lines, `[LOG] <text>` debug logs, `[INIT] <JSON>` capability declarations |

Every call proceeds as follows: instantiate the module → call `_initialize` (Go wasip1 runtime initialization) → call the target `应用_xxx` export → parse the stdout line protocol. **`WriteResp` must emit a single `[RESP] <JSON>` line**; `Log` goes through the `app_log` host function (and is collected together with `[LOG]` lines).

---

## 2. Where the Entry Point Is: How the Host Discovers, Compiles, and Calls Your Plugin

This chapter breaks the complete chain from "code compiled" to "hook actually called" into steps. After reading it you will know why a plugin "does nothing at all" when you forget a certain switch.

### 2.1 Step One: The Directory and Files Must Be Placed Correctly

The host scans the plugin root directory under the scan node's run directory and finally locates each plugin's build artifact:

```text
<run dir>/scan-poc/plugin/<uuid>/
├── build/
│   └── scan.wasm              # build artifact (required; the host locates build/*.wasm, preferring scan.wasm)
├── plugin.config.json         # plugin Key/Value config (optional; read by app_config)
├── language-cn.json           # Chinese language pack (optional; read by app_t)
├── language-en.json           # English language pack (optional)
├── sig.json                   # optional Ed25519 signature information
└── tools/<name>/              # external tools (optional; usable after declaration + GUI authorization)
    ├── bin/<name>[.exe]       # executable
    └── <name>.py / <name>.jar # python / java tool entry point
```

The controller obtains the packaged source from the plugin store, and after "installing to the GUI" it broadcasts it to each scan node; upon receiving the zip delivered by `AddPluginPackage`, the node extracts it into `scan-poc/plugin/<uuid>/` and reloads the plugin.

> [!IMPORTANT]
> The host supports two layouts: the standard `root/<uuid>/build/scan.wasm` and the legacy `root/<uuid>/Plugin/scan/<uuid>/build/scan.wasm`. Directories without a `.wasm` under `build/` are ignored outright. If `sig.json` exists and signature verification fails (`verify_failed`), the plugin is refused loading; a missing signature is recorded as `unsigned` (trusting the controller's AES-GCM delivery channel).

### 2.2 Step Two: You Must Build with `-buildmode=c-shared`

```bash
GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .
```

Why c-shared is mandatory:

- In command-module mode the entry point is `_start`, Go wasip1 runs `main()`, and as soon as `main` returns it calls `proc_exit(0)` and closes the module — **the module has already exited before the host even calls your hook**.
- The c-shared library mode exports `_initialize` (runtime initialization). On every hook call the host first clears the automatic `_start`, instantiates the module, then **manually calls `_initialize`**, and only then calls the `应用_xxx` export.
- `//go:wasmexport` for exporting functions with a Chinese prefix and `//go:wasmimport env ...` for importing host functions both depend on the c-shared library mode.

`func main() {}` must be kept (required by c-shared linking; an empty implementation is fine).

### 2.3 Step Three: When the Host Compiles (lazy + cache + poisoned rebuild)

- **Lazy compilation**: the load phase only reads metadata (path, signature, configuration) and does not touch wasm contents; actual compilation is deferred until **the plugin first needs to be called**.
- **Compile once and cache**: build artifacts are cached per plugin UUID. Each later hook call only creates a new module instance (resetting wasm global state) without recompiling.
- **Probe `应用_Init` at load time**: during the load phase, plugins that export `应用_Init` are called once proactively (which also triggers the first compilation) with the request JSON `{}`, in order to collect capabilities and tool declarations.
- **Poisoned rebuild**: if one execution is judged timed out by the watchdog, that plugin's runtime is marked poisoned; the next call automatically discards the old runtime and recompiles, ensuring a stuck plugin does not drag down subsequent calls.

```text
plugin load phase
  └─ scan the plugin root directory, build metadata for each plugin
  └─ for each plugin:
        if it exports "应用_Init":            // triggers the first compilation
              call "应用_Init" (request JSON is "{}")
  aggregate declared tools → send a tool authorization request to the GUI (ToolAuthRequest)
```

### 2.4 Step Four: Export-Table Detection (a Hook That Was Not Exported Is Never Called)

After compilation, the host enumerates the module's exported functions and collects them into the plugin's capability table:

```text
caps = { name | name starts with "应用_" }
```

Before executing any hook it first checks "is this hook in the actual export table":

```text
if not exported:
    if the plugin once declared this capability:
        log: "Plugin[<uuid>] declared capability 应用_OnXxx but did not export the corresponding hook function; cannot call (skipped)"
    skip this call
```

Therefore: **not exported = not called**, with no error and no resource usage; **declared but not exported = one log line, then skipped**.

### 2.5 Step Five: The Timing of Each Hook Call

```text
1. Host business code triggers the hook (e.g. TCP data received → receive hook triggered)
2. Fast filtering: if no plugin implements the hook, the whole class is skipped
3. For each plugin, determine whether it implements the hook; if not, skip
4. Execute one hook call:
   a. Acquire/build the runtime (the first time triggers compilation)
   b. If this plugin is already executing → skip this call outright (anti-reentrancy/deadlock)
   c. Start a goroutine + watchdog timer (timeout, 30s by default)
   d. The actual call:
        - reset the execution environment for this run
        - stdin = request JSON; stdout/stderr = in-memory buffers
        - clear the automatic _start
        - create a new module instance
        - call _initialize (if exported)
        - call 应用_xxx (error if not exported; normally unreachable)
        - scan stdout: [RESP] → response/consumed flag/error; [LOG] → log; [INIT] → capability and tool declarations
        - merge in the logs collected by app_log
   e. On success, incrementally merge the [INIT] declarations (capabilities and tools)
   f. On timeout → mark that plugin's runtime poisoned and return an error
5. Business code decides what to do next based on the response JSON (see 2.6)
```

The request JSON is marshalled by the host and written to stdin; inside the plugin you just read it with `app.LoadRequest(&req)`. The response is written to stdout by your `app.WriteResp(...)`, and the host parses the `[RESP]` line.

### 2.6 Step Six: How Hook Return Values Affect the Main Flow

| Hook | How the host uses the return value |
|---|---|
| `应用_OnTcpDataReceived` | `handled=true` → consume this event and skip the built-in handling; if `data` is non-empty and differs from the original → **re-parse the new data before dispatching** |
| `应用_OnTcpDataSend` | Only `data` is used: if non-empty and different → replace the content to be sent; `handled` has no effect |
| `应用_OnTaskStart` | Uses `task` (`TaskStartModify`): non-nil / non-empty fields replace the corresponding task fields |
| `应用_OnTaskFilterVulns` | Uses `filterIds`: merged into the set of "vulnIde to exclude" (union across plugins) |
| `应用_OnTaskFilterFlows` | Uses `filterIds`: merged into the set of "packet ide to exclude" |
| `应用_OnMitmHttpRequest` | Uses `request` (`MitmHttpModify`) merged into the request (later writes override earlier ones; empty fields do not override) |
| `应用_OnMitmHttpResponse` | Uses `response` (`MitmHttpModify`) merged into the response |
| `应用_OnMitmWsMessage` | Uses `message.content`: if non-empty, replace the message content |
| `应用_OnMitmSseEvent` | Uses `event.data`: if non-empty, replace the event data |
| `应用_OnTaskPacket` | **Ignored entirely** (notification-only) |
| `应用_OnTaskEnd` | Uses `done`: in the `drain` phase, polling stops only when all plugins report `done=true`; in the `close` phase `done` is ignored |
| `应用_Init` | Uses the `[INIT]` lines: `capabilities` and `tools` declarations |

---

## 3. The 12 Hooks, Explained Function by Function

Hook export names are uniformly `应用_` plus the business point name. The table below summarizes trigger timing and whether `handled` really takes effect:

| Exported function | Trigger timing | Can modify data | Does `handled=true` take effect |
|---|---|---|---|
| `应用_Init` | Probed once after the plugin is loaded | Emits capability/tool declarations | Not applicable |
| `应用_OnTcpDataReceived` | After TCP data parsing, before built-in dispatching | `data` can be modified | **Yes**: consumes this event and skips built-in handling |
| `应用_OnTcpDataSend` | Before data is sent | `data` can be modified | No (only `data` is used) |
| `应用_OnTaskStart` | After the task is parsed | Task filter fields can be modified | Parsed but ineffective (only `task` is used) |
| `应用_OnTaskFilterVulns` | Before the task starts | Returns vulnerability identifiers to exclude | No (only `filterIds` is used) |
| `应用_OnTaskFilterFlows` | Before the task starts | Returns packet identifiers to exclude | No (only `filterIds` is used) |
| `应用_OnMitmHttpRequest` | MITM captures an HTTP request | method/url/headers/body can be modified | Parsed but currently ineffective (only `request` is used) |
| `应用_OnMitmHttpResponse` | MITM captures an HTTP response | statusCode/headers/body can be modified | Parsed but currently ineffective (only `response` is used) |
| `应用_OnMitmWsMessage` | MITM captures a WS frame | Message content can be modified | Parsed but currently ineffective (only `message` is used) |
| `应用_OnMitmSseEvent` | MITM captures an SSE event | Event data can be modified | Parsed but currently ineffective (only `event` is used) |
| `应用_OnTaskPacket` | Every raw packet in the scan dispatch loop | Notification-only, no return semantics | The response is ignored entirely |
| `应用_OnTaskEnd` | Task finalization (drain polling + close cleanup) | Report done / clean up | Not involved; only `done` is used |

> [!TIP]
> When multiple plugins implement the same hook they are **called in plugin order**: for the TCP hooks, a later plugin sees the data already modified by an earlier one; modifications from MITM and filter hooks are merged (later writes override earlier ones, `omitempty` fields do not override). A single call is **serial per plugin**.
#### `应用_Init`

**Purpose**: called once proactively by the host during the load phase after the plugin is loaded (the request JSON is `{}`), to declare capability points and the external tool inventory before any other hook is invoked.

**Signature / trigger form**: the exported function takes no parameters and returns no value (returning an int is also fine; the host ignores it).

```go
//go:wasmexport 应用_Init
func OnInit() { /* ... */ }
```

**Parameter table**: no parameters (the host writes `{}` to stdin).

**Return / response**: no business return value (the host ignores `[RESP]`); the `[INIT]` lines are what matters.

Request JSON injected by the host:

```json
{}
```

The `[INIT]` lines and response you should emit:

```json
[INIT] {"capabilities":["应用_OnTaskStart","应用_OnTaskPacket","应用_OnTaskEnd","tools.exec","fs.read","fs.write"],"tools":[{"name":"nuclei","runtime":""}]}
[RESP] {"handled":false}
```

| Field | Meaning | Takes effect? |
|---|---|---|
| `capabilities` | Declared capability points (hook names + `tools.exec`/`fs.read`/`fs.write`) | Used for the capability inventory and the `tools.exec` gate; **whether a hook is called is still governed by the export table** |
| `tools` | Declares external tools `[{name,runtime}]` | Used for the GUI authorization popup inventory |

**Code sample**:

```go
package main

import "app"

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(
        app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd,
        app.CapToolsExec, app.CapFSRead, app.CapFSWrite,
    )
    app.DeclareTool("nuclei", "") // tool lives under tools/nuclei/; runtime="" means a native executable
    app.Declare()                 // emits [INIT] {"capabilities":[...],"tools":[...]}
    app.WriteResp(map[string]any{"handled": false})
}

func main() {}
```

**Notes**:

1. `应用_Init` itself must also exist in the export table (`//go:wasmexport 应用_Init`); otherwise the load-time probe will not call it, and declarations such as `tools.exec` can never be collected.
2. `Declare()` must be called **inside a hook function**, not from package initialization or global variable initialization — the `[INIT]` lines must be written to this run's stdout.
3. An `[INIT]` declaration alone is not enough to make a hook get called: the corresponding `应用_xxx` must really be exported (otherwise the log says "declared capability but did not export").

#### `应用_OnTcpDataReceived`

**Purpose**: triggered after TCP data has been parsed and before the host's built-in dispatch. Use it to inspect / modify / consume one TCP message.

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { /* ... */ }
```

**Parameter table**: the request body is `app.TcpDataRequest`, written to stdin.

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `uuid` | string | Identifier of this connection/this node | TCP packet field | `"n-0a1b"` |
| `command1` | string | Level-1 command (routing layer) | Level-1 command field | `"relayData"` |
| `command2` | string | Level-2 command (module/action) | Level-2 command field | `"TaskStart"` |
| `command3` | string | Level-3 command (target UUID) | Level-3 command field | `"gui"` |
| `command4` | string | Level-4 command (source UUID) | Level-4 command field | `"n-0a1b"` |
| `source` | string | Source: `0`=GUI `1`=controller `2`=scan node | Source identifier field | `"1"` |
| `commandA` | string | Business action name (`CommandA` inside Data) | Field inside the Data section | `"SaveScanVuln"` |
| `data` | string | Raw Data section (`string([]byte)`) | Raw Data section | `"{"a":1}"` |

**Return / response**: `app.TcpDataResponse` → `{handled, error, data}`.

Request JSON injected by the host:

```json
{"uuid":"n-0a1b","command1":"relayData","command2":"Heartbeat","command3":"gui","command4":"n-0a1b","source":"1","commandA":"Heartbeat","data":"{\"tick\":1}"}
```

Response JSON you should return:

```json
{"handled":false,"error":"","data":"{\"tick\":1}_seen"}
```

| Field | Semantics |
|---|---|
| `handled` | `true` → the plugin has consumed this event and **the main program skips its built-in handling** |
| `error` | Processing error message; only logged, does not affect the main flow |
| `data` | **Non-empty and different from the original data** → the main program re-parses the new data and dispatches it; empty/identical → no modification |

**Code sample**:

```go
//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteError(errors.New("请求解析失败"))
        return
    }
    app.Logf("收到 TCP 数据 command2=%s commandA=%s", req.Command2, req.CommandA)

    if strings.Contains(req.Data, "need-sign") {
        // 值怎么传出去：把改过的 Data 放进响应
        app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data + "|signed"})
        return
    }
    // 只观察、不修改：返回空 data
    app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data})
}
```

**Notes**:

1. Messages that the plugin re-injects by calling `app.ReinjectTcpData` (`app_tcp_received_data`) inside the hook **will not** trigger this hook again (messages from the re-injection source are skipped outright), preventing self-triggered deadlock.
2. To consume the event and skip the built-in handling, return `Handled: true`; to only modify the data, return the new value in `Data` and keep `Handled` as `false`.
3. Only `handled=true` from this hook skips the built-in handling; `handled` from other hooks has no effect.

#### `应用_OnTcpDataSend`

**Purpose**: triggered **before** the host sends TCP data, so you can inspect/replace the content about to be sent.

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnTcpDataSend
func OnTcpDataSend() { /* ... */ }
```

**Parameter table**: the request body is likewise `app.TcpDataRequest`.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `uuid` | string | Current node UUID | `"n-0a1b"` |
| `command1`~`command4` | string | The four-level command of this send | `"relayData"` / `"TaskResult"` / `"gui"` / `"n-0a1b"` |
| `data` | string | Content about to be sent | `"{\"ok\":true}"` |
| `source` / `commandA` | string | Usually empty | `""` |

**Return / response**: `app.TcpDataResponse`; the host **only reads `data`**, and `handled` has no effect.

Request JSON injected by the host:

```json
{"uuid":"n-0a1b","command1":"relayData","command2":"TaskResult","command3":"gui","command4":"n-0a1b","source":"","commandA":"","data":"{\"ok\":true}"}
```

Response JSON you should return (replacement):

```json
{"handled":false,"error":"","data":"{\"ok\":true,\"signed\":true}"}
```

**Code sample**:

```go
//go:wasmexport 应用_OnTcpDataSend
func OnTcpDataSend() {
    var req app.TcpDataRequest
    _ = app.LoadRequest(&req)
    if strings.Contains(req.Data, "need-sign") {
        app.WriteResp(app.TcpDataResponse{Data: req.Data + "|signed"})
        return
    }
    app.WriteResp(app.TcpDataResponse{}) // empty data → no modification, send as is
}
```

**Notes**:

1. Returning empty `data` or data identical to the original → send as is; only non-empty and different data replaces it.
2. Anti-reentrancy: if the plugin calls `app.SendTcpData` / `app.SendTcpDataJson` inside this hook it re-enters the send path, but the host skips the send hook directly, so there is no recursion.
3. This hook only reads `data`; returning `handled=true` has no effect whatsoever.

#### `应用_OnTaskStart`

**Purpose**: triggered after the scan node has parsed the task configuration and before the task actually starts; it can modify fields related to task filtering/routing.

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnTaskStart
func OnTaskStart() { /* ... */ }
```

**Parameter table**: the request body is `app.TaskStartRequest`.

| Parameter | Type | What to pass | Modifiable? | Example value |
|---|---|---|---|---|
| `taskIde` | string | Unique task identifier | Read-only | `"t-20261005-01"` |
| `taskName` | string | Task name | Read-only | `"内网巡检"` |
| `proxyMode` | string | Proxy mode `auto/direct/tunnel` | Read-only | `"auto"` |
| `sourceUUID` | string | Task source (GUI) UUID | Read-only | `"g-1234"` |
| `targetUUID` | string | UUID of the task delivery target | Read-only | `"n-0a1b"` |
| `selectedVulnIds` | []string | Vulnerability POC list that will actually be scanned | Modifiable | `["poc-1","poc-2"]` |
| `selectedVulnIdsAdd` | []string | Vulnerabilities added beyond the template | Modifiable | `["poc-9"]` |
| `httpSelectedScanUrlRowIde` | []string | Selected HTTP packet identifiers | Modifiable | `["flow-a"]` |
| `wsSelectedScanUrlRowIde` | []string | Selected WebSocket packet identifiers | Modifiable | `["ws-1"]` |
| `sseSelectedScanUrlRowIde` | []string | Selected SSE packet identifiers | Modifiable | `["sse-1"]` |
| `hostsContent` | string | hosts mapping content | Modifiable | `"10.0.0.1 a.example"` |
| `scanningRange` | string | Restrict the scanning range | Read-only | `"10.0.0.0/24"` |
| `skipScanDomainIP` | string | Domains or IPs to skip | Read-only | `"10.0.0.5"` |

**Return / response**: `app.TaskStartResponse` → `{handled, error, task}`, where `task` is an `app.TaskStartModify`.

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","taskName":"内网巡检","proxyMode":"auto","sourceUUID":"g-1234","targetUUID":"n-0a1b","selectedVulnIds":["poc-rce-1","poc-info-2"],"selectedVulnIdsAdd":[],"httpSelectedScanUrlRowIde":["flow-a","test-flow-b"],"wsSelectedScanUrlRowIde":[],"sseSelectedScanUrlRowIde":[],"hostsContent":"","scanningRange":"","skipScanDomainIP":""}
```

Response JSON you should return (after trimming):

```json
{"handled":true,"error":"","task":{"selectedVulnIds":["poc-rce-1"],"httpSelectedScanUrlRowIde":["flow-a"]}}
```

| Field | Semantics |
|---|---|
| `task` | Non-nil / non-empty fields replace the corresponding task fields; `nil`/empty means no change (`omitempty` drops empty slices, so they **cannot be used to clear**) |
| `handled` | Parsed but does not currently affect the flow (task modifications are always applied from `task`) |

**Code sample**:

```go
//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000) // 启动动作可能较慢，放大执行窗口
    var req app.TaskStartRequest
    _ = app.LoadRequest(&req)
    app.Logf("任务启动：%s(%s) 选中漏洞 %d 个", req.TaskName, req.TaskIde, len(req.SelectedVulnIds))

    // 只保留名称含 rce 的 POC
    keep := make([]string, 0, len(req.SelectedVulnIds))
    for _, id := range req.SelectedVulnIds {
        if strings.Contains(strings.ToLower(id), "rce") {
            keep = append(keep, id)
        }
    }
    // 剔除测试环境数据包
    httpKeep := make([]string, 0, len(req.HttpSelectedScanUrlRowIde))
    for _, ide := range req.HttpSelectedScanUrlRowIde {
        if !strings.HasPrefix(ide, "test-") {
            httpKeep = append(httpKeep, ide)
        }
    }
    app.WriteResp(app.TaskStartResponse{
        Handled: true,
        Task: app.TaskStartModify{
            SelectedVulnIds:           keep,
            HttpSelectedScanUrlRowIde: httpKeep,
        },
    })
}
```

**Notes**:

1. `TaskStartModify` uses `omitempty`, so **empty slices are dropped and cannot be used to "clear" a field**; you can only replace with non-empty values. For a clearing requirement, filter inside the plugin and return the remaining items.
2. `SelectedVulnIds` and `SelectedVulnIdsAdd`, the three `*SelectedScanUrlRowIde` fields, and `HostsContent` are nilable: only `nil` means "do not modify".
3. `handled` does not affect the flow; do not use it to control behavior.

#### `应用_OnTaskFilterVulns`

**Purpose**: before the task starts, filter the vulnerabilities selected by the current task in bulk and return the `vulnIde`s to exclude.

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnTaskFilterVulns
func OnTaskFilterVulns() { /* ... */ }
```

**Parameter table**: the request body is `{taskIde, vulns}`, where each `vulns` element is an `app.TaskVulnBrief`.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `vulns` | []TaskVulnBrief | Summaries of all vulnerabilities selected by the current task | see below |
| `vulns[].vulnIde` | string | Unique vulnerability identifier | `"poc-1"` |
| `vulns[].name` | string | Vulnerability name | `"远程命令执行"` |
| `vulns[].level` | string | Level `4/3/2/1` | `"1"` |
| `vulns[].pocType` | string | `yaml/go/wasm` | `"yaml"` |

**Return / response**: `app.TaskFilterResponse` → `{handled, error, filterIds}`.

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","vulns":[{"vulnIde":"poc-1","name":"远程命令执行","level":"4","pocType":"yaml"},{"vulnIde":"poc-2","name":"信息泄露","level":"1","pocType":"go"}]}
```

Response JSON you should return (excluding low-severity items):

```json
{"handled":false,"error":"","filterIds":["poc-2"]}
```

| Field | Semantics |
|---|---|
| `filterIds` | Set of `vulnIde`s to exclude; results from multiple plugins are unioned |
| `handled` | No effect; only `filterIds` is read |

**Code sample**:

```go
//go:wasmexport 应用_OnTaskFilterVulns
func OnTaskFilterVulns() {
    var req struct {
        TaskIde string              `json:"taskIde"`
        Vulns   []app.TaskVulnBrief `json:"vulns"`
    }
    _ = app.LoadRequest(&req)

    var drop []string
    for _, v := range req.Vulns {
        if v.Level == "1" || v.Level == "2" { // 剔除低危
            drop = append(drop, v.VulnIde)
        }
    }
    app.WriteResp(app.TaskFilterResponse{FilterIds: drop})
}
```

**Notes**:

1. What you return are the identifiers to **exclude**, not to keep.
2. Empty strings inside `filterIds` are ignored; returning nil/empty means no filtering.
3. `vulns` in the request contains all vulnerabilities selected by the current task (not a difference set), so filtering decisions must be based on the full set.

#### `应用_OnTaskFilterFlows`

**Purpose**: before the task starts, exclude packets in bulk by domain/URL (e.g. skip static-resource sites).

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnTaskFilterFlows
func OnTaskFilterFlows() { /* ... */ }
```

**Parameter table**: the request body is `{taskIde, flows}`, whose elements are `app.TaskFlowBrief`.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `flows` | []TaskFlowBrief | Summaries of all packets selected by the current task | see below |
| `flows[].ide` | string | Unique packet identifier (`IdeTraffic`) | `"flow-a"` |
| `flows[].type` | string | `http/websocket/sse` | `"http"` |
| `flows[].method` | string | Request method | `"GET"` |
| `flows[].url` | string | Request URL | `"/api/list"` |
| `flows[].domain` | string | Domain | `"static.example.com"` |
| `flows[].tls` | string | `HTTP/HTTPS/WSS` | `"HTTPS"` |

**Return / response**: `app.TaskFilterResponse` → `filterIds` holds the `ide`s to exclude.

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","flows":[{"ide":"flow-a","type":"http","method":"GET","url":"/index.html","domain":"example.com","tls":"HTTPS"},{"ide":"flow-b","type":"http","method":"GET","url":"/a.css","domain":"static.example.com","tls":"HTTPS"}]}
```

Response JSON you should return:

```json
{"handled":false,"error":"","filterIds":["flow-b"]}
```

**Code sample**:

```go
//go:wasmexport 应用_OnTaskFilterFlows
func OnTaskFilterFlows() {
    var req struct {
        TaskIde string              `json:"taskIde"`
        Flows   []app.TaskFlowBrief `json:"flows"`
    }
    _ = app.LoadRequest(&req)

    var drop []string
    for _, f := range req.Flows {
        d := strings.ToLower(f.Domain)
        if d == "example.com" || strings.HasSuffix(d, ".static.example.com") {
            drop = append(drop, f.Ide) // 剔除用 ide，不是 url
        }
    }
    app.WriteResp(app.TaskFilterResponse{FilterIds: drop})
}
```

**Notes**:

1. `filterIds` must contain `flows[].ide` (`IdeTraffic`), not the URL or domain.
2. As with vulnerability filtering, results are unioned across plugins and only `filterIds` is read.
3. When excluding by domain, mind the suffix-match boundary (`strings.HasSuffix(d, ".static.example.com")` rather than a bare suffix).

#### `应用_OnMitmHttpRequest`

**Purpose**: triggered when MITM captures an HTTP request; the request method, URL, headers and body can be modified/replaced.

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() { /* ... */ }
```

**Parameter table**: the request body is `app.MitmHttpRequest`.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `method` | string | Request method | `"POST"` |
| `url` | string | Full URL | `"https://example.com/api/login"` |
| `proto` | string | Protocol version | `"HTTP/1.1"` |
| `host` | string | Host | `"example.com"` |
| `headers` | map[string][]string | Request headers | `{"User-Agent":["curl/8"]}` |
| `body` | string | Decompressed request body | `"u=admin&p=123"` |
| `remoteIp` | string | Remote server IP | `"93.184.216.34"` |
| `tls` | string | `HTTP/HTTPS` | `"HTTPS"` |

**Return / response**: the response structure the host parses is an **anonymous struct**, not an SDK type:

```go
var resp struct {
    Handled bool           `json:"handled"`
    Error   string         `json:"error"`
    Request MitmHttpModify `json:"request"`
}
```

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","method":"POST","url":"https://example.com/api/login","proto":"HTTP/1.1","host":"example.com","headers":{"User-Agent":["curl/8"]},"body":"u=admin&p=123","remoteIp":"93.184.216.34","tls":"HTTPS"}
```

Response JSON you should return (adding one request header):

```json
{"handled":false,"error":"","request":{"headers":{"User-Agent":["curl/8"],"X-Tss-Audit":["hook-audit"]}}}
```

| Field | Semantics |
|---|---|
| `request` | `app.MitmHttpModify`, merged into this request (later writes override earlier ones; empty fields do not override) |
| `request.method` / `request.url` | Non-empty replaces the method / URL |
| `request.headers` | When length > 0, **replaces the whole** request-header map |
| `request.body` | Non-empty replaces the request body |
| `handled` | Parsed but does not participate in the flow at present |

**Code sample**:

```go
//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() {
    var req app.MitmHttpRequest
    _ = app.LoadRequest(&req)

    headers := map[string][]string{}
    for k, v := range req.Headers {
        headers[k] = v
    }
    headers["X-Tss-Powered-By"] = []string{"TestSecScan-Plugin"}
    headers["X-Original-URL"] = []string{req.URL}

    resp := struct {
        Handled bool               `json:"handled"`
        Error   string             `json:"error"`
        Request app.MitmHttpModify `json:"request"`
    }{
        Request: app.MitmHttpModify{Headers: headers},
    }
    app.WriteResp(resp)
}
```

**Notes**:

1. The returned key must be `request` (not `httpRequest`/`modify`); if the field name is wrong the response parses fine but no modification takes effect.
2. `headers` is a `map[string][]string`, and replacement is "whole-map replacement": to keep the original headers you must copy them first and then modify.
3. To make the modification visible to the **next plugin**, the host applies it back to the request snapshot; whether it is ultimately written back to real traffic depends on how the caller uses that modify.

#### `应用_OnMitmHttpResponse`

**Purpose**: triggered when MITM captures an HTTP response; `statusCode`, headers and body can be modified/replaced.

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnMitmHttpResponse
func OnMitmHttpResponse() { /* ... */ }
```

**Parameter table**: the request body is `app.MitmHttpResponse`.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `url` | string | Request URL | `"https://example.com/api/login"` |
| `method` | string | Request method | `"POST"` |
| `statusCode` | int | Response status code | `200` |
| `headers` | map[string][]string | Response headers | `{"Content-Type":["application/json"]}` |
| `body` | string | Decompressed response body | `"{\"ok\":true}"` |
| `remoteIp` | string | Remote server IP | `"93.184.216.34"` |
| `tls` | string | `HTTP/HTTPS` | `"HTTPS"` |

**Return / response**: `{handled, error, response}`, where `response` is an `app.MitmHttpModify` (which may carry a `statusCode`).

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","url":"https://example.com/api/login","method":"POST","statusCode":200,"headers":{"Content-Type":["application/json"]},"body":"{\"ok\":true}","remoteIp":"93.184.216.34","tls":"HTTPS"}
```

Response JSON you should return (changing the status code and body):

```json
{"handled":false,"error":"","response":{"statusCode":403,"body":"{\"ok\":false}"}}
```

| Field | Semantics |
|---|---|
| `response` | `app.MitmHttpModify`, merged into the response |
| `response.statusCode` | When > 0, replaces the status code (used by responses only) |
| `response.headers` | When length > 0, replaces the whole response-header map |
| `response.body` | Non-empty replaces the response body |
| `handled` | Parsed but does not participate in the flow at present |

**Code sample**:

```go
//go:wasmexport 应用_OnMitmHttpResponse
func OnMitmHttpResponse() {
    var resp app.MitmHttpResponse
    _ = app.LoadRequest(&resp)

    mod := app.MitmHttpModify{}
    if strings.Contains(resp.Body, "internal error") {
        code := 502
        mod.StatusCode = code
        mod.Body = `{"masked":true}`
    }
    out := struct {
        Handled  bool               `json:"handled"`
        Error    string             `json:"error"`
        Response app.MitmHttpModify `json:"response"`
    }{
        Response: mod,
    }
    app.WriteResp(out)
}
```

**Notes**:

1. `statusCode` of 0 (or not returned) means no modification; only positive values override.
2. The returned key is `response`; `method`/`url` inside `MitmHttpModify` are meaningless on the response side (the response side does not use them).
3. `omitempty`: an empty `body` or empty `headers` will not override the original value.

#### `应用_OnMitmWsMessage`

**Purpose**: triggered when MITM captures a WebSocket message frame; the message content can be replaced.

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnMitmWsMessage
func OnMitmWsMessage() { /* ... */ }
```

**Parameter table**: the request body is `app.MitmWsMessage`.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `url` | string | Connection URL | `"wss://example.com/ws"` |
| `domain` | string | Domain | `"example.com"` |
| `fromClient` | bool | `true`=client→server, `false`=server→client | `true` |
| `statusType` | int | `1`=send `2`=receive | `1` |
| `content` | string | Message content | `"{\"cmd\":\"ping\"}"` |

**Return / response**: `{handled, error, message}`, where `message` is an `app.MitmWsModify{content}`.

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","url":"wss://example.com/ws","domain":"example.com","fromClient":true,"statusType":1,"content":"{\"cmd\":\"ping\"}"}
```

Response JSON you should return:

```json
{"handled":false,"error":"","message":{"content":"{\"cmd\":\"pong\"}"}}
```

| Field | Semantics |
|---|---|
| `message.content` | Non-empty replaces the message content |
| `handled` | Parsed but does not participate in the flow at present |

**Code sample**:

```go
//go:wasmexport 应用_OnMitmWsMessage
func OnMitmWsMessage() {
    var msg app.MitmWsMessage
    _ = app.LoadRequest(&msg)
    if strings.Contains(msg.Content, `"ping"`) {
        out := struct {
            Handled bool            `json:"handled"`
            Error   string          `json:"error"`
            Message app.MitmWsModify `json:"message"`
        }{
            Message: app.MitmWsModify{Content: strings.ReplaceAll(msg.Content, `"ping"`, `"pong"`)},
        }
        app.WriteResp(out)
        return
    }
    app.WriteResp(app.MitmWsModify{}) // 不修改
}
```

**Notes**:

1. The returned key is `message`; an empty `message.content` does not override.
2. `fromClient` and `statusType` carry overlapping semantics but both come from the host; using `fromClient` to judge direction is more intuitive.
3. `content` may be the textual form of a binary frame, so do not attempt JSON parsing on non-JSON content.

#### `应用_OnMitmSseEvent`

**Purpose**: triggered when MITM captures an SSE (Server-Sent Events) event; the event data can be replaced.

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnMitmSseEvent
func OnMitmSseEvent() { /* ... */ }
```

**Parameter table**: the request body is `app.MitmSseEvent`.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `url` | string | Connection URL | `"https://example.com/sse"` |
| `event` | string | The `event:` field (default `message`) | `"message"` |
| `id` | string | The `id:` field | `"42"` |
| `data` | string | The `data:` field | `"{\"price\":10}"` |
| `retry` | int | The `retry:` field (milliseconds) | `3000` |

**Return / response**: `{handled, error, event}`, where `event` is an `app.MitmSseModify{data}`.

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","url":"https://example.com/sse","event":"message","id":"42","data":"{\"price\":10}","retry":3000}
```

Response JSON you should return:

```json
{"handled":false,"error":"","event":{"data":"{\"price\":99}"}}
```

| Field | Semantics |
|---|---|
| `event.data` | Non-empty replaces the event data |
| `handled` | Parsed but does not participate in the flow at present |

**Code sample**:

```go
//go:wasmexport 应用_OnMitmSseEvent
func OnMitmSseEvent() {
    var evt app.MitmSseEvent
    _ = app.LoadRequest(&evt)

    mod := app.MitmSseModify{}
    if strings.Contains(evt.Data, "secret") {
        mod.Data = strings.ReplaceAll(evt.Data, "secret", "***")
    }
    out := struct {
        Handled bool            `json:"handled"`
        Error   string          `json:"error"`
        Event   app.MitmSseModify `json:"event"`
    }{
        Event: mod,
    }
    app.WriteResp(out)
}
```

**Notes**:

1. The returned key is `event`; an empty `data` does not override.
2. `id`, `retry` and `event` are read-only; `MitmSseModify` can only change `data`.
3. SSE is a long-lived connection and this event hook is invoked frequently, so the logic must be fast.

#### `应用_OnTaskPacket`

**Purpose**: called once for **every raw packet** in the scan dispatch loop, handing task traffic to the plugin (typically: replay via `app_http_replay` to an external detection tool).

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() { /* ... */ }
```

**Parameter table**: the request body is `{taskIde, flow}`, where `flow` holds the trimmed request-side fields.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `flow.method` | string | Request method | `"GET"` |
| `flow.url` | string | Request URL | `"/api/user"` |
| `flow.domain` | string | Domain | `"example.com"` |
| `flow.reqHeaders` | string | Raw request-header text | `"Host: example.com\nUser-Agent: curl/8"` |
| `flow.reqBody` | string | Request body text | `""` |

**Return / response**: **ignored entirely** (notification-only). It is still recommended to write one `[RESP]` line so the host can confirm the execution completed.

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","flow":{"method":"GET","url":"/api/user","domain":"example.com","reqHeaders":"Host: example.com\nUser-Agent: curl/8","reqBody":""}}
```

Suggested return (ignored by the host; only a signal that execution completed):

```json
{"handled":false}
```

**Code sample**:

```go
//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() {
    app.SetTimeout(30000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Flow    struct {
            Method     string `json:"method"`
            URL        string `json:"url"`
            Domain     string `json:"domain"`
            ReqHeaders string `json:"reqHeaders"`
            ReqBody    string `json:"reqBody"`
        } `json:"flow"`
    }
    _ = app.LoadRequest(&req)
    if req.Flow.URL == "" {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    // 快进快出：重放转后台（async=true），重活交给 应用_OnTaskEnd
    _ = replayAsync(req.Flow.Method, req.Flow.URL, req.Flow.ReqHeaders, req.Flow.ReqBody)
    app.WriteResp(map[string]any{"handled": false})
}
```

**Notes**:

1. **Must be fast in and fast out**: this path runs for every packet, and blocking synchronously will stall the dispatch loop; use `async=true` of `app_http_replay` for replays.
2. The return value is ignored entirely; do not rely on it to influence the flow.
3. When this hook is not exported the host does not call it at all, at zero cost.

#### `应用_OnTaskEnd`

**Purpose**: task finalization. It has two phases: `drain`, where the host **calls in a loop** (2s between rounds) until all plugins report `done=true` or `drainLimit` is exceeded; and `close`, a final **one-shot call** for final cleanup (killing processes, etc.).

**Signature / trigger form**:

```go
//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() { /* ... */ }
```

**Parameter table**: the request body is

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `cancelled` | bool | `true`=the user stopped/cancelled (the finalization window should be shortened) | `false` |
| `phase` | string | `drain`=one harvest round / `close`=final cleanup | `"drain"` |

**Return / response**: `{handled, done}` (the host parses a `TaskEndResponse`).

Request JSON injected by the host:

```json
{"taskIde":"t-20261005-01","cancelled":false,"phase":"drain"}
```

Response JSON you should return (work remains; keep polling):

```json
{"handled":true,"done":false}
```

Response in the `close` phase:

```json
{"handled":true,"done":true}
```

| Field | Semantics |
|---|---|
| `done` | In the `drain` phase, `true`=no tasks remain to harvest and the host stops polling; as long as one plugin reports `done=false`, the next round continues |
| `handled` | Does not participate in the finalization decision (the host only reads `done`) |

**Code sample**:

```go
//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() {
    app.SetTimeout(290000) // 接近宿主硬上限 5 分钟：一轮内完成收割/上报
    var req struct {
        TaskIde   string `json:"taskIde"`
        Cancelled bool   `json:"cancelled"`
        Phase     string `json:"phase"`
    }
    _ = app.LoadRequest(&req)

    if req.Phase == "close" {
        // 最终清理：停掉本插件拉起的全部工具进程（tool 空=全部）
        stopAllTools()
        app.WriteResp(map[string]any{"handled": true, "done": true})
        return
    }
    // drain：检查是否还有进程未退出
    if stillRunning() {
        app.Log("仍有工具进程未退出，等待下一轮")
        app.WriteResp(map[string]any{"handled": true, "done": false})
        return
    }
    reportResults() // 收集结果并上报
    app.WriteResp(map[string]any{"handled": true, "done": true})
}
```

**Notes**:

1. `drain` works by **returning `done=false` to request the next round**: do not block with `sleep` waiting for processes here; return quickly and let the host call again 2s later.
2. Within a single round you can enlarge the execution window with `app.SetTimeout(ms)`, up to the hard limit of 5 minutes; `close` is called only once, so make sure to kill processes there.
3. Plugins that do not implement this hook do not participate in finalization; when `cancelled=true` the window should be shortened (the user has already cancelled).

---

## 4. The 20 Host Functions, Explained Function by Function

The host injects the following 20 functions into the wasm `env` module. The SDK (`app.go`) wraps only 12 of them; the other 8 are exported by the host but not wrapped by the SDK, so you must declare them yourself with `//go:wasmimport env <name>`.

All "output-type" functions share the same ABI convention:

```text
func appXxx(in..., out unsafe.Pointer, outCap uint32) uint32
```

The host writes the result JSON into the linear memory pointed to by `out` (at most `outCap` bytes) and returns the **number of bytes actually written**; you take the string with `buf[:n]` and then `json.Unmarshal` it.

> [!NOTE]
> Size the buffer according to the expected result: `app_task_env`, `app_list_dir`, and `app_exec_tool` may return large payloads (the sample plugin uses 256KB); `app_read_file` has a 4MB per-file limit, which needs about 5.6MB of buffer after base64 encoding.

#### `app_log`

**Purpose**: emit debug logs; the host collects them into this execution's context, where they are visible in task/GUI debugging.

**Signature / trigger form**:

```go
//go:wasmimport env app_log
func appLog(msg string)
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `msg` | string | Log text | Your code | `"收到 TCP 数据"` |

**Return / response**: no return value. The host collects it into this execution's log buffer and, after execution, writes it into the scan node log as `插件[<uuid>] 日志: ...`. It is not JSON.

**Code sample**:

```go
//go:wasmimport env app_log
func appLog(msg string)

func LogInfo(msg string) { appLog(msg) }

// 调用
func demo() { appLog("工具已启动：nuclei pid=" + strconv.Itoa(pid)) }
```

**Notes**:

1. The SDK already wraps it as `app.Log` / `app.Logf`; prefer those.
2. Write each log line only once and avoid high-frequency flooding (especially inside `OnTaskPacket`).
3. It does not go through stdout; it is an independent log channel and does not need the `[LOG]` prefix.

#### `app_send_tcp`

**Purpose**: send raw TCP data to the controller/GUI/other nodes (four-level command + raw data section).

**Signature / trigger form**:

```go
//go:wasmimport env app_send_tcp
func appSendTcp(cmd1, cmd2, cmd3, cmd4, data string)
```

The host sends the five parameters as-is, as a four-level command plus the raw data section.

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `cmd1` | string | Level-1 command | Your code | `"relayData"` |
| `cmd2` | string | Level-2 command | Your code | `"Heartbeat"` |
| `cmd3` | string | Target (`gui`/`scan`/`all`/node UUID) | Your code | `"gui"` |
| `cmd4` | string | Reply UUID (empty fills in this node automatically) | Your code | `""` |
| `data` | string | Raw data section | Your code | `"{\"tick\":1}"` |

**Return / response**: no return value. SDK: `app.SendTcpData(cmd1, cmd2, cmd3, cmd4, data)`.

**Code sample**:

```go
//go:wasmimport env app_send_tcp
func appSendTcp(cmd1, cmd2, cmd3, cmd4, data string)

func notifyGui(data string) { appSendTcp("relayData", "MyEvent", "gui", "", data) }
```

**Notes**:

1. When `cmd3` is empty the message goes only to the controller; write `all` to send to every node.
2. It transmits a raw string — **for business data, prefer `app_send_tcp_json`** so the protocol wrapping is done for you.
3. If you call this function inside `应用_OnTcpDataSend`, the host skips the send hook to prevent reentrancy.

#### `app_send_tcp_json`

**Purpose**: send TCP JSON data with the complete `{CommandA,Data}` two-layer protocol wrapping.

**Signature / trigger form**:

```go
//go:wasmimport env app_send_tcp_json
func appSendTcpJson(cmd1, cmd2, cmd3, cmd4, commandA, data string)
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `cmd1`~`cmd4` | string | Four-level command/target/reply | Your code | `"scan"` / `"SaveScanVuln"` / `""` / `""` |
| `commandA` | string | Business action name | Your code | `"SaveScanVuln"` |
| `data` | string | **JSON string** (the host parses it into an object again) | Your code | `"{\"VulnName\":\"x\"}"` |

**Return / response**: no return value. SDK: `app.SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`.

**Code sample**:

```go
// SDK 用法：直接传结构体/值，SDK 内部 json.Marshal
func reportVuln(v any) {
    app.SendTcpDataJson("scan", "SaveScanVuln", "", "", "SaveScanVuln", v)
}
```

**Notes**:

1. **Do not `json.Marshal` into `[]byte` yourself and pass that**: a `[]byte` is encoded as a base64 string and the host never receives an object. Pass a struct or `any`.
2. If you only declare the low-level function, the `data` parameter must be JSON text; the host `json.Unmarshal`s it into `any` and re-wraps it.
3. `commandA` and `cmd2` usually share the same name (e.g. `SaveScanVuln`); keeping them consistent makes routing on the peer side easier.

#### `app_tcp_received_data`

**Purpose**: inject one TCP message back into the host's receive flow. The host reassembles it in the 7-segment wire format and then runs the normal parsing and dispatching.

**Signature / trigger form**:

```go
//go:wasmimport env app_tcp_received_data
func appTcpReceivedData(tcpDataJSON string)
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `tcpDataJSON` | string | JSON for one TCP message (fields below) | Your code | see below |

The host parses by Go field names (no json tags), taking `UUID / Command1Str / Command2Str / Command3Str / Command4Str / Source / Data`, joins them with the separator `<!|!>` into `uuid<!|!>cmd1<!|!>cmd2<!|!>cmd3<!|!>cmd4<!|!>source<!|!>data`, and feeds the result into the normal receive parsing flow.

JSON accepted by the host:

```json
{"UUID":"n-0a1b","Command1Str":"relayData","Command2Str":"Heartbeat","Command3Str":"gui","Command4Str":"n-0a1b","Source":"2","Data":"eyJ0aWNrIjoxfQ=="}
```

(`Data` is a `[]byte` and is represented as base64 in JSON.)

**Return / response**: no return value. SDK: `app.ReinjectTcpData(td TcpDataRequest)`, which internally marshals the SDK's `app.TcpDataRequest` (fields `uuid/command1..4/source/commandA/data`).

**Code sample**:

```go
//go:wasmimport env app_tcp_received_data
func appTcpReceivedData(tcpDataJSON string)

func inject(data []byte) {
    // 直接按宿主结构注入（注意 Data 是 []byte，base64 表示）
    td := map[string]any{
        "UUID": "n-0a1b", "Command1Str": "relayData", "Command2Str": "MyEvent",
        "Command3Str": "gui", "Command4Str": "n-0a1b", "Source": "2",
        "Data": data, // Go json.Marshal([]byte) → base64 字符串
    }
    b, _ := json.Marshal(td)
    appTcpReceivedData(string(b))
}
```

**Notes**:

1. **The re-injection depth is capped at 5 levels**: injections beyond the limit are discarded outright, preventing plugins from forwarding to each other in an infinite loop.
2. During re-injection `应用_OnTcpDataReceived` is **not** called again (messages from the re-injection source are skipped outright), avoiding self-triggered deadlock.
3. `Data` is a `[]byte`: when passing text, use base64; otherwise the host's `json.Unmarshal` may fail and nothing is injected.

#### `app_call`

**Purpose**: the unified entry point for built-in tool functions, reusing the host's built-in table of safe functions (encoding/hashing/JSON reading, etc.).

**Signature / trigger form**:

```go
//go:wasmimport env app_call
func appCall(funcID uint32, args string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `funcID` | uint32 | Built-in function ID, see the table below | `10` |
| `args` | string | JSON array of arguments | `"[\"hello\"]"` |
| `out` | unsafe.Pointer | Output buffer | `unsafe.Pointer(&buf[0])` |
| `outCap` | uint32 | Buffer capacity | `uint32(len(buf))` |

Built-in function IDs (those wrapped by the SDK):

| funcID | SDK wrapper | Purpose |
|---|---|---|
| 10 | `app.Base64Encode` | Base64 encoding |
| 11 | `app.Base64Decode` | Base64 decoding |
| 20 | `app.MD5` | MD5 (lowercase hex) |
| 21 | `app.SHA1` | SHA1 hash |
| 23 | `app.SHA256` | SHA256 hash |
| 50 | `app.JSONGet` | Read a JSON value by path (e.g. `a.b[0].c`) |

**Return / response**: returns the number of bytes written. The content is the function result string; on error the host writes `{"error":"..."}`.

```json
{"error":"未知的内置函数 ID: 99"}
```

**Code sample**:

```go
//go:wasmimport env app_call
func appCall(funcID uint32, args string, out unsafe.Pointer, outCap uint32) uint32

func callBuiltin(id uint32, args ...any) (string, bool) {
    b, _ := json.Marshal(args)
    var buf [64 * 1024]byte
    n := appCall(id, string(b), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n == 0 {
        return "", false
    }
    return string(buf[:n]), true
}

// 调用（等价 app.MD5）
func demo() { s, _ := callBuiltin(20, "hello") }
```

**Notes**:

1. The general-purpose SDK entry point is `app.CallBuiltin(funcID, args...) (string, bool)`; returning `false` means the host produced no result.
2. On error the returned text is JSON `{"error":...}` rather than an empty string, so check for that.
3. `args` must be a JSON array whose element order matches the built-in function's parameters.

#### `app_t`

**Purpose**: read language-pack text; the key space is `<current language>.<plugin UUID>.<key>`.

**Signature / trigger form**:

```go
//go:wasmimport env app_t
func appT(key string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `key` | string | Language-pack key (without the language and UUID prefix) | `"VulnName"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — |

**Return / response**: returns the number of bytes written; the content is **plain text** (not JSON). When the key is not found it falls back to returning the key itself.

```text
远程命令执行
```

**Code sample**:

```go
//go:wasmimport env app_t
func appT(key string, out unsafe.Pointer, outCap uint32) uint32

func T(key string) string {
    var buf [4096]byte
    n := appT(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

func demo() { name := T("VulnName") }
```

**Notes**:

1. The language-pack files are `language-<lang>.json` in the plugin directory (one each for `-cn` / `-en`), read by the host when the plugin is loaded.
2. The key space includes the plugin UUID, so in the source file you only write `VulnName`; the host automatically prefixes `<lang>.<uuid>.`.
3. In English mode, when an English key is missing it falls back to the key itself, so all four ends' language packs must be complete (see the workspace conventions).

#### `app_config`

**Purpose**: read Key/Value entries from the plugin configuration `plugin.config.json` (flat static keys).

**Signature / trigger form**:

```go
//go:wasmimport env app_config
func appConfig(key string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `key` | string | Configuration key | `"Enabled"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — |

**Return / response**: returns the number of bytes written; the content is the **plain text of the configuration value**; a missing key returns an empty string.

```text
true
```

**Code sample**:

```go
//go:wasmimport env app_config
func appConfig(key string, out unsafe.Pointer, outCap uint32) uint32

func ConfigGet(key string) string {
    var buf [4096]byte
    n := appConfig(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

func demo() { enabled := ConfigGet("Enabled") == "true" }
```

**Notes**:

1. `plugin.config.json` is a flat Key/Value map (`map[string]string`); complex configuration is often serialized into one key (e.g. `ConfigJson`).
2. Configuration is saved in the GUI plugin page → controller → delivered to the node, and takes effect after a restart/reload.
3. Values are not JSON, so parsing numbers/booleans requires your own conversion.

#### `app_node_info`

**Purpose**: obtain information about the current scan node, for status reporting and distinguishing multiple nodes.

**Signature / trigger form**:

```go
//go:wasmimport env app_node_info
func appNodeInfo(out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — |

**Return / response**: returns the number of bytes written; the content is JSON:

```json
{"uuid":"n-0a1b","nodeName":"node-01","language":"cn","appId":"testsecscan","pluginId":"plugin-exttools"}
```

| Field | Meaning |
|---|---|
| `uuid` | Node UUID |
| `nodeName` | Node name |
| `language` | Current language |
| `appId` | Application ID |
| `pluginId` | Current plugin UUID (used by the plugin to identify itself in status reports) |

**Code sample**:

```go
//go:wasmimport env app_node_info
func appNodeInfo(out unsafe.Pointer, outCap uint32) uint32

// SDK 已封装为 app.GetNodeInfo()，但 SDK 的 NodeInfo 不含 pluginId，需自行声明结构体
type nodeInfo struct {
    UUID     string `json:"uuid"`
    NodeName string `json:"nodeName"`
    PluginID string `json:"pluginId"`
}

func fetchNodeInfo() *nodeInfo {
    var buf [4096]byte
    n := appNodeInfo(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var info nodeInfo
    if json.Unmarshal(buf[:n], &info) != nil {
        return nil
    }
    return &info
}
```

**Notes**:

1. The host returns **5 fields** (one more than the SDK's `app.NodeInfo`: `pluginId`); using the SDK's `app.GetNodeInfo()` loses `pluginId`.
2. Status reports must carry `pluginId` and `uuid`; otherwise the controller discards them as invalid.

#### `app_free_port`

**Purpose**: allocate a currently free local port so an external tool can put a "listening port" into the command template, avoiding port collisions between concurrent tasks/multiple instances.

**Signature / trigger form**:

```go
//go:wasmimport env app_free_port
func appFreePort(out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**: no input parameters (besides the output buffer).

**Return / response**: returns the number of bytes written; the content is JSON:

```json
{"port":34567}
```

On failure:

```json
{"error":"连续 20 次未取到未占用的空闲端口"}
```

The host really binds once on `127.0.0.1:0`, reads the kernel-assigned port and releases it immediately; the same port is not handed out again within 30s (TOCTOU mitigation).

**Code sample**:

```go
//go:wasmimport env app_free_port
func appFreePort(out unsafe.Pointer, outCap uint32) uint32

func allocPort() int {
    var buf [4096]byte
    n := appFreePort(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Port  int    `json:"port"`
        Error string `json:"error"`
    }
    if json.Unmarshal(buf[:n], &resp) != nil || resp.Port <= 0 {
        return 0
    }
    return resp.Port
}
```

**Notes**:

1. The port is the result of an instantaneous bind-then-release; hand it to the external tool as soon as possible — the 30s deduplication window only mitigates, it does not reserve exclusively.
2. Each process instance (`Count>1`) should allocate its own port.
3. It only binds+closes on the loopback interface, accepts no input parameters and sends no data, so it does not constitute an arbitrary network capability.

#### `app_set_timeout`

**Purpose**: dynamically set this plugin's per-hook execution timeout (in milliseconds); if never called, the default is 30s.

**Signature / trigger form**:

```go
//go:wasmimport env app_set_timeout
func appSetTimeout(ms uint32)
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `ms` | uint32 | Timeout in milliseconds; `<=0` is ignored | `120000` |

**Return / response**: no return value. The hard limit is 5 minutes; values beyond it are capped. SDK: `app.SetTimeout(ms int64)`.

**Code sample**:

```go
//go:wasmimport env app_set_timeout
func appSetTimeout(ms uint32)

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    appSetTimeout(120000) // 本钩子单次最多跑 2 分钟
    // ...
}
```

**Notes**:

1. Before every hook execution the host resets the timeout to the default 30s, so **each hook must call this on its own**.
2. What you set is the timeout of "this one hook execution", not a global plugin property.
3. The host also enforces a 5-minute hard limit; even without setting anything the ceiling is 5 minutes.

#### `app_exec_tool`

**Purpose**: **synchronously** execute an external tool inside the plugin directory and collect stdout/stderr (a command-line tool that exits when done, e.g. a one-shot nuclei scan).

**Signature / trigger form**:

```go
//go:wasmimport env app_exec_tool
func appExecTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `tool` | string | Tool name (`tools/<name>/`) | Your code | `"nuclei"` |
| `runtime` | string | `python`/`java`/`""` | Your code | `""` |
| `argsJSON` | string | JSON array of arguments | Your code | `"[\"-silent\"]"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — | — |

**Return / response**: returns the number of bytes written; the content is `ExecResult` JSON:

```json
{"exitCode":0,"stdout":"[info] scan done\n","stderr":"","error":""}
```

Execution failure/timeout:

```json
{"exitCode":0,"stdout":"","stderr":"","error":"外部工具执行超时（1m0s）"}
```

**Permission / capability requirements**: you must declare `tools.exec` (`app.CapToolsExec`) in `[INIT]`, the tool must be **authorized in the GUI**, and the call is scheduled through the global task queue.

**Code sample** (already wrapped by the SDK):

```go
result := app.ExecTool("nuclei", "", "-l", "targets.txt", "-o", "out.txt")
var res struct {
    ExitCode int    `json:"exitCode"`
    Stdout   string `json:"stdout"`
    Stderr   string `json:"stderr"`
    Error    string `json:"error"`
}
_ = json.Unmarshal([]byte(result), &res)
if res.Error != "" { app.Logf("nuclei 失败: %s", res.Error) }
```

**Notes**:

1. Without the `tools.exec` declaration it returns `{"error":"插件未声明 tools.exec 能力，禁止调用外部工具"}`; without authorization it returns "未获用户授权".
2. The tool must be under `tools/<name>/`; invoking terminals is forbidden (`cmd`/`powershell`/`sh`/`bash` and other blacklisted names), and no shell is used.
3. The default timeout is 60s, with stdout/stderr capped at 4MB each; the queue defaults to concurrency 2, queue depth 32, wait 30s.

#### `app_read_file`

**Purpose**: read a file inside the plugin sandbox (relative path); returns an envelope containing base64 content.

**Signature / trigger form**:

```go
//go:wasmimport env app_read_file
func appReadFile(path string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `path` | string | Path relative to the plugin directory | `"data/toollogs/sqlmapapi.log"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — |

**Return / response**: returns the number of bytes written; the content is JSON:

```json
{"ok":true,"data":"aGVsbG8=","size":5,"error":""}
```

On failure:

```json
{"ok":false,"size":0,"error":"路径穿越被拒绝: ../secret"}
```

**Code sample**:

```go
raw := app.ReadFile("data/ext-results/t-1/out.txt")
var env struct {
    OK    bool   `json:"ok"`
    Data  string `json:"data"`
    Error string `json:"error"`
}
_ = json.Unmarshal([]byte(raw), &env)
if env.OK {
    b, _ := base64.StdEncoding.DecodeString(env.Data)
    _ = b // 文件内容
}
```

**Notes**:

1. `data` is **base64**; you must decode it to get the file content.
2. The per-file limit is 4MB, and anything beyond is truncated; only relative paths are accepted — absolute paths, `..` and symbolic links are rejected.
3. Capability name `fs.read` (`app.CapFSRead`); the hard constraint is the directory sandbox (see chapter 8).

#### `app_write_file`

**Purpose**: write a file inside the plugin sandbox (parent directories are created automatically).

**Signature / trigger form**:

```go
//go:wasmimport env app_write_file
func appWriteFile(path string, data unsafe.Pointer, dataLen uint32, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `path` | string | Path relative to the plugin directory | `"data/state-t1.json"` |
| `data` | unsafe.Pointer | Pointer to the bytes to write | `unsafe.Pointer(&b[0])` |
| `dataLen` | uint32 | Byte count | `uint32(len(b))` |
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — |

**Return / response**: returns the number of bytes written; the content is JSON:

```json
{"ok":true,"size":128,"error":""}
```

**Code sample**:

```go
b, _ := json.Marshal(state)
var obuf [4096]byte
n := appWriteFile("data/exttools-state-t1.json",
    unsafe.Pointer(&b[0]), uint32(len(b)),
    unsafe.Pointer(&obuf[0]), uint32(len(obuf)))
_ = n
```

**Notes**:

1. The host has no "delete file" interface; to clear state, write `{}` and treat it as empty on the reading side (the sample plugin's `clearState` does exactly this).
2. Parent directories are created automatically; symbolic links and out-of-bounds paths are rejected.
3. Capability name `fs.write` (`app.CapFSWrite`).

#### `app_spawn_tool`

**Purpose**: start a tool process under `tools/<tool>/` **in the background** (without waiting for it to exit); stdout/stderr are appended to `data/toollogs/<key>.log`. Suitable for resident service-type tools (sqlmapapi, an xray listener).

**Signature / trigger form**:

```go
//go:wasmimport env app_spawn_tool
func appSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**: `argsJSON` is a `spawnRequest`.

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `tool` | string | Tool name | `"xray"` |
| `runtime` | string | `python`/`java`/`""` | `""` |
| `entry` | string | Entry file (relative to `tools/<tool>/`; empty=default lookup); cwd switches to the entry's directory | `"sqlmapapi.py"` |
| `args` | []string | Command-line argument array | `["--listen","127.0.0.1:1800"]` |
| `key` | string | Process instance key; multiple instances of the same tool must use different keys | `"xray#1-1"` |
| `dir` | string | Working directory (absolute path, must be inside the plugin sandbox) | `"<output_dir>"` |
| `env` | map[string]string | Additional environment variables | `{"X":"1"}` |
| `timeoutSec` | int | Timeout in seconds; the process is killed on timeout; `<=0` means no limit | `900` |

**Return / response**: `spawnResult`:

```json
{"pid":32140,"key":"xray#1-1","log":"data/toollogs/xray#1-1.log","error":""}
```

**Permission / capability requirements**: the same as `app_exec_tool` (`tools.exec` + GUI authorization); processes are managed by the plugin itself and do not occupy queue slots.

**Code sample**:

```go
//go:wasmimport env app_spawn_tool
func appSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32

func spawn(tool, runtime string, args map[string]any) (pid int, key, logRel, errMsg string) {
    b, _ := json.Marshal(args)
    var buf [256 * 1024]byte
    n := appSpawnTool(tool, runtime, string(b), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Pid   int    `json:"pid"`
        Key   string `json:"key"`
        Log   string `json:"log"`
        Error string `json:"error"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Pid, resp.Key, resp.Log, resp.Error
}
```

**Notes**:

1. Starting the same tool again with **the same key stops the old process first** ("later start kills earlier start"); multiple instances must use different `key`s (e.g. `nuclei#1`/`nuclei#2`).
2. `entry` must still resolve inside `tools/<tool>/` (guarding against `..`); `dir` must be inside the plugin sandbox, otherwise it is ignored.
3. The log file path is returned in `log`; read it with `app_read_file` (for example to parse the token printed by sqlmapapi at startup).

#### `app_stop_tool`

**Purpose**: stop tool processes previously started by this plugin.

**Signature / trigger form**:

```go
//go:wasmimport env app_stop_tool
func appStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `tool` | string | Empty=all; otherwise an exact key or a `tool#`/`tool/` prefix | `"xray"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — |

**Return / response**: `{"stopped":n}`.

```json
{"stopped":2}
```

**Code sample**:

```go
//go:wasmimport env app_stop_tool
func appStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32

func stopTool(key string) int {
    var buf [4096]byte
    n := appStopTool(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Stopped int `json:"stopped"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Stopped
}
```

**Notes**:

1. Passing a tool name stops all of its `#n` instances; passing an exact key stops only that single instance; an empty string stops all processes of this plugin.
2. It can only stop processes started by **this plugin**; it cannot touch other plugins or system processes.
3. When the plugin is reloaded or the node exits, the host stops all of this plugin's tool processes as a fallback.

#### `app_tool_status`

**Purpose**: return the liveness status of this plugin's processes (used in the `drain` phase to determine "are any processes still running?").

**Signature / trigger form**:

```go
//go:wasmimport env app_tool_status
func appToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**: `tool` empty=all; a tool name matches all of its instances, an exact key matches only a single instance.

**Return / response**:

```json
{"running":1,"exited":1,"list":[{"key":"xray#1-1","tool":"xray","pid":32140,"running":true,"startedAt":1759600000},{"key":"nuclei#2-1","tool":"nuclei","pid":32141,"running":false,"startedAt":1759599000}]}
```

**Code sample**:

```go
//go:wasmimport env app_tool_status
func appToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32

func anyRunning(waitKeys map[string]bool) bool {
    var buf [256 * 1024]byte
    n := appToolStatus("", unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var st struct {
        List []struct {
            Key     string `json:"key"`
            Running bool   `json:"running"`
        } `json:"list"`
    }
    _ = json.Unmarshal(buf[:n], &st)
    for _, p := range st.List {
        if p.Running && waitKeys[p.Key] {
            return true
        }
    }
    return false
}
```

**Notes**:

1. This is the host-side "source of truth" and is more reliable than the plugin keeping its own bookkeeping.
2. `running=false` means the process has exited (the host has finished collecting it).
3. `startedAt` is in unix seconds and can be compared against the task start time.

#### `app_http_request`

**Purpose**: send an HTTP request to the **local loopback address**, for plugin interaction with a self-started local service (e.g. the sqlmapapi REST API).

**Signature / trigger form**:

```go
//go:wasmimport env app_http_request
func appHTTPRequest(reqJSON string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**: `reqJSON` is an `httpRequestRequest`.

| Field | Type | What to pass | Example value |
|---|---|---|---|
| `method` | string | Method; empty=GET | `"POST"` |
| `url` | string | Loopback URL | `"http://127.0.0.1:8775/task/new"` |
| `headers` | map[string]string | Request headers | `{"Content-Type":"application/json"}` |
| `body` | string | Request body | `"{}"` |
| `timeoutMs` | int | Timeout in milliseconds; default 30s, max 10 minutes | `1500` |

**Return / response**: `httpRequestResult`.

```json
{"status":200,"body":"{\"success\":true}","error":""}
```

**Code sample**:

```go
//go:wasmimport env app_http_request
func appHTTPRequest(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

func probe(host string, port int) bool {
    req, _ := json.Marshal(map[string]any{
        "method": "GET", "url": "http://" + host + ":" + strconv.Itoa(port) + "/", "timeoutMs": 1500,
    })
    var buf [256 * 1024]byte
    n := appHTTPRequest(string(req), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Status int    `json:"status"`
        Error  string `json:"error"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Status > 0
}
```

**Notes**:

1. Only `127.0.0.1` / `localhost` / `::1` are allowed; every other address is rejected with "仅允许本机回环地址".
2. Request/response bodies are capped at 8MB; the default timeout is 30s (max 10 minutes).
3. To hit external sites use `app_http_replay` instead.

#### `app_http_replay`

**Purpose**: **replay task traffic through a proxy** to the scanned site or to an external passive analyzer (e.g. xray listening on 7777). The target is an external site (not restricted to loopback), an explicit proxy is used, response bodies are discarded, and HTTPS ignores certificate errors.

**Signature / trigger form**:

```go
//go:wasmimport env app_http_replay
func appHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**: `reqJSON` is a `replayRequest`.

| Field | Type | What to pass | Example value |
|---|---|---|---|
| `method` | string | Method; empty=GET | `"GET"` |
| `url` | string | Target URL (must be http/https) | `"https://example.com/a"` |
| `headers` | map[string]string | Restored request headers (Host/Content-Length/Connection are handled by the library) | `{"User-Agent":"curl/8"}` |
| `body` | string | Request body, max 4MB | `""` |
| `proxy` | string | Replay proxy; empty=direct | `"http://127.0.0.1:7777"` |
| `timeoutMs` | int | Default 30s, max 30s | `30000` |
| `async` | bool | `true`=send in the background and return immediately | `true` |

**Return / response**: `replayResult`.

```json
{"started":true,"status":0,"error":""}
```

Synchronous mode (`async=false`) on success:

```json
{"started":false,"status":200,"error":""}
```

**Code sample**:

```go
//go:wasmimport env app_http_replay
func appHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

func replayAsync(method, target string, headers map[string]string, body string) {
    req, _ := json.Marshal(map[string]any{
        "method": method, "url": target, "headers": headers,
        "proxy": "http://127.0.0.1:7777", "timeoutMs": 30000, "async": true,
    })
    var buf [64 * 1024]byte
    _ = appHTTPReplay(string(req), unsafe.Pointer(&buf[0]), uint32(len(buf)))
}
```

**Notes**:

1. **`应用_OnTaskPacket` must use `async=true`**: synchronous waiting would stall the scan dispatch loop.
2. A URL without `http(s)://` reports an error; a request body over 4MB is rejected; the timeout ceiling is 30s.
3. A proxy that is not ready or an unreachable target is normal; the plugin should stay silent (the sample plugin does not treat failures as log noise).

#### `app_task_env`

**Purpose**: return the runtime environment facts of the current task (target list, result directory, path anchors, runtimes).

**Signature / trigger form**:

```go
//go:wasmimport env app_task_env
func appTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `taskIde` | string | Task identifier | `"t-20261005-01"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — |

**Return / response**: `taskEnvResult`:

```json
{"taskIde":"t-20261005-01","taskName":"内网巡检","nodeUuid":"n-0a1b","nodeName":"node-01","domain":"example.com","targetUrl":"https://example.com/a","domainsFile":"<pluginDir>/data/ext-results/t-20261005-01/targets-domains.txt","urlsFile":"<pluginDir>/data/ext-results/t-20261005-01/targets-urls.txt","outputDir":"<pluginDir>/data/ext-results/t-20261005-01","outputDirRel":"data/ext-results/t-20261005-01","pluginDir":"<pluginDir>","toolsDir":"<pluginDir>/tools","python":"C:\\py\\3.12\\python.exe","java":"java","secTestProxy":"http://127.0.0.1:8080"}
```

| Field | Meaning |
|---|---|
| `taskIde` / `taskName` | Task identifier/name |
| `nodeUuid` / `nodeName` | Node identifier/name |
| `domain` / `targetUrl` | Primary domain / first URL (with scheme) |
| `domainsFile` / `urlsFile` | Absolute paths of the deduplicated domain/URL list files (one per line, for tool `-l`/`-iL`) |
| `outputDir` / `outputDirRel` | Absolute path / plugin-relative path of this task's result directory (the latter for `app_read_file`) |
| `pluginDir` / `toolsDir` | Absolute paths of the plugin root directory / tools directory |
| `python` / `java` | Executable paths (the external-tool environment takes precedence, falling back to a bare name from PATH) |
| `secTestProxy` | Address from this task's "security test proxy settings" (empty when not configured) |

**Code sample**:

```go
//go:wasmimport env app_task_env
func appTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32

type taskEnv struct {
    TaskIde      string `json:"taskIde"`
    OutputDir    string `json:"outputDir"`
    OutputDirRel string `json:"outputDirRel"`
    DomainsFile  string `json:"domainsFile"`
    ToolsDir     string `json:"toolsDir"`
    Python       string `json:"python"`
}

func fetchTaskEnv(taskIde string) *taskEnv {
    var buf [256 * 1024]byte
    n := appTaskEnv(taskIde, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var env taskEnv
    if json.Unmarshal(buf[:n], &env) != nil {
        return nil
    }
    return &env
}
```

**Notes**:

1. The result directory is fixed at `data/ext-results/<taskIde>/` inside the plugin sandbox, and directories older than 48h are cleaned up along the way.
2. `outputDir`/`domainsFile` are **absolute paths** (for tool arguments), while `outputDirRel` is a **relative path** (for `app_read_file`/`app_list_dir`).
3. When a task has no traffic the list files are empty files, and the other fields are still returned.

#### `app_list_dir`

**Purpose**: list the direct children of a directory inside the plugin sandbox (non-recursive, sorted by name), for result collection/status checks.

**Signature / trigger form**:

```go
//go:wasmimport env app_list_dir
func appListDir(path string, out unsafe.Pointer, outCap uint32) uint32
```

**Parameter table**:

| Parameter | Type | What to pass | Example value |
|---|---|---|---|
| `path` | string | Directory path relative to the plugin directory | `"data/ext-results/t-1"` |
| `out` / `outCap` | unsafe.Pointer / uint32 | Output buffer | — |

**Return / response**: `{ok,entries,error}`, where each entry is `{name,size,modTime,isDir}`.

```json
{"ok":true,"entries":[{"name":"out.html","size":2048,"modTime":1759600100,"isDir":false},{"name":"sub","size":0,"modTime":1759600000,"isDir":true}]}
```

**Code sample**:

```go
//go:wasmimport env app_list_dir
func appListDir(path string, out unsafe.Pointer, outCap uint32) uint32

func listDir(rel string) ([]struct{ Name string; Size int64; IsDir bool }, bool) {
    var buf [256 * 1024]byte
    n := appListDir(rel, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        OK      bool `json:"ok"`
        Entries []struct {
            Name    string `json:"name"`
            Size    int64  `json:"size"`
            ModTime int64  `json:"modTime"`
            IsDir   bool   `json:"isDir"`
        } `json:"entries"`
        Error string `json:"error"`
    }
    if json.Unmarshal(buf[:n], &resp) != nil || !resp.OK {
        return nil, false
    }
    return resp.Entries, true
}
```

**Notes**:

1. It lists **direct children only** (non-recursive); `modTime` is in unix seconds.
2. Capability name `fs.read`; paths are constrained by the directory sandbox.
3. When collecting results, `size==0` is commonly used to filter out empty files (an empty file does not count as a vulnerability).

---

## 5. All Exported SDK Identifiers

The `app.go` inside the SDK package (Go version, `package app`). Copy it into the `app/` subdirectory of your plugin project, add `replace app => ./app` to `go.mod`, and then `import "app"`.

### 5.1 Constants

#### `HookTcpDataReceived`

**Purpose**: the hook export name for receiving TCP data. The value is `"应用_OnTcpDataReceived"`.

```go
app.Capability(app.HookTcpDataReceived)
_ = app.HookTcpDataReceived // "应用_OnTcpDataReceived"
```

> It is a capability-name constant, declared with `Capability`; to actually be called, a function with the same name must still be exported.

#### `HookTcpDataSend`

**Purpose**: the hook export name for sending TCP data. The value is `"应用_OnTcpDataSend"`.

```go
app.Capability(app.HookTcpDataSend)
```

> It pairs with `HookTcpDataReceived`; be careful not to mix them up.

#### `HookTaskStart`

**Purpose**: the hook export name for task start. The value is `"应用_OnTaskStart"`.

```go
app.Capability(app.HookTaskStart)
```

> Task hooks are usually used together with `app.SetTimeout`.

#### `HookTaskFilterVulns`

**Purpose**: the hook export name for filtering vulnerabilities before the task starts. The value is `"应用_OnTaskFilterVulns"`.

```go
app.Capability(app.HookTaskFilterVulns)
```

> Together with `HookTaskFilterFlows`, it covers the two filter kinds: vulnerabilities/traffic.

#### `HookTaskFilterFlows`

**Purpose**: the hook export name for filtering packets before the task starts. The value is `"应用_OnTaskFilterFlows"`.

```go
app.Capability(app.HookTaskFilterFlows)
```

> `filterIds` must contain `flows[].ide`.

#### `HookMitmHttpRequest`

**Purpose**: the hook export name for MITM HTTP requests. The value is `"应用_OnMitmHttpRequest"`.

```go
app.Capability(app.HookMitmHttpRequest)
```

> The returned key is `request`.

#### `HookMitmHttpResponse`

**Purpose**: the hook export name for MITM HTTP responses. The value is `"应用_OnMitmHttpResponse"`.

```go
app.Capability(app.HookMitmHttpResponse)
```

> The returned key is `response`.

#### `HookMitmWsMessage`

**Purpose**: the hook export name for MITM WebSocket messages. The value is `"应用_OnMitmWsMessage"`.

```go
app.Capability(app.HookMitmWsMessage)
```

> The returned key is `message`.

#### `HookMitmSseEvent`

**Purpose**: the hook export name for MITM SSE events. The value is `"应用_OnMitmSseEvent"`.

```go
app.Capability(app.HookMitmSseEvent)
```

> The returned key is `event`.

#### `CapToolsExec`

**Purpose**: the capability name `"tools.exec"`; only after declaring it are you allowed to call external tools.

```go
app.Capability(app.CapToolsExec)
app.DeclareTool("nuclei", "")
```

> The declaration is only a ticket; GUI authorization is still required for actual execution.

#### `CapFSRead`

**Purpose**: the capability name `"fs.read"`, declaring reads of files inside the plugin directory.

```go
app.Capability(app.CapFSRead)
```

> The hard constraint on file access is the directory sandbox; see chapter 8.

#### `CapFSWrite`

**Purpose**: the capability name `"fs.write"`, declaring writes of files inside the plugin directory.

```go
app.Capability(app.CapFSWrite)
```

> Declaring it together with `CapFSRead` lets you externalize state across hooks.

### 5.2 Types

#### `ToolDecl`

**Purpose**: an external tool declaration entry, used in the `tools` array of `[INIT]` and in the GUI authorization inventory.

| Field | json | Type | Meaning |
|---|---|---|---|
| `Name` | `name` | string | Tool name (the `tools/<name>/` directory) |
| `Runtime` | `runtime` | string | `python` / `java` / `""` |

```go
app.DeclareTool("sqlmap", "python")
_ = app.ToolDecl{Name: "sqlmap", Runtime: "python"}
```

> `Runtime` determines which runtime environment directory the host prepends to PATH.

#### `TcpDataRequest`

**Purpose**: the request body of the TCP receive/send hooks.

| Field | json | Type | Meaning |
|---|---|---|---|
| `UUID` | `uuid` | string | Identifier of this connection/this node |
| `Command1` | `command1` | string | Level-1 command |
| `Command2` | `command2` | string | Level-2 command |
| `Command3` | `command3` | string | Level-3 command (target UUID) |
| `Command4` | `command4` | string | Level-4 command (source UUID) |
| `Source` | `source` | string | `0`=GUI `1`=controller `2`=scan node |
| `CommandA` | `commandA` | string | Business action name |
| `Data` | `data` | string | Raw Data section |

```go
var req app.TcpDataRequest
_ = app.LoadRequest(&req)
app.Log(req.Command2 + ":" + req.Data)
```

> In the send hook, `source`/`commandA` are usually empty.

#### `TcpDataResponse`

**Purpose**: the response body of the TCP receive/send hooks.

| Field | json | Type | Meaning |
|---|---|---|---|
| `Handled` | `handled` | bool | `true`=consumed (only effective in the receive hook) |
| `Error` | `error` | string | Error message (logged only) |
| `Data` | `data` | string | Non-empty and different → replace/re-parse |

```go
app.WriteResp(app.TcpDataResponse{Data: req.Data + "_x"})
```

> Empty `Data` means no modification.

#### `TaskStartRequest`

**Purpose**: the request body of `应用_OnTaskStart` (containing only filter/routing-related fields).

| Field | json | Type | Meaning |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | Task identifier |
| `TaskName` | `taskName` | string | Task name |
| `ProxyMode` | `proxyMode` | string | `auto/direct/tunnel` |
| `SourceUUID` | `sourceUUID` | string | Source (GUI) UUID |
| `TargetUUID` | `targetUUID` | string | Delivery target UUID |
| `SelectedVulnIds` | `selectedVulnIds` | []string | Vulnerabilities to scan |
| `SelectedVulnIdsAdd` | `selectedVulnIdsAdd` | []string | Additionally added vulnerabilities |
| `HttpSelectedScanUrlRowIde` | `httpSelectedScanUrlRowIde` | []string | Selected HTTP packets |
| `WsSelectedScanUrlRowIde` | `wsSelectedScanUrlRowIde` | []string | Selected WS packets |
| `SSESelectedScanUrlRowIde` | `sseSelectedScanUrlRowIde` | []string | Selected SSE packets |
| `HostsContent` | `hostsContent` | string | hosts content |
| `ScanningRange` | `scanningRange` | string | Restrict the scanning range |
| `SkipScanDomainIP` | `skipScanDomainIP` | string | Domains/IPs to skip |

```go
var req app.TaskStartRequest
_ = app.LoadRequest(&req)
app.Logf("任务 %s 漏洞数=%d", req.TaskName, len(req.SelectedVulnIds))
```

> Field names are case-sensitive; in JSON it is `selectedVulnIds`.

#### `TaskStartModify`

**Purpose**: the fields `应用_OnTaskStart` can modify (`omitempty`, nil/empty=no modification).

| Field | json | Type | Meaning |
|---|---|---|---|
| `SelectedVulnIds` | `selectedVulnIds,omitempty` | []string | Replace the vulnerability list |
| `SelectedVulnIdsAdd` | `selectedVulnIdsAdd,omitempty` | []string | Replace the additional vulnerabilities |
| `HttpSelectedScanUrlRowIde` | `httpSelectedScanUrlRowIde,omitempty` | []string | Replace the HTTP selection |
| `WsSelectedScanUrlRowIde` | `wsSelectedScanUrlRowIde,omitempty` | []string | Replace the WS selection |
| `SSESelectedScanUrlRowIde` | `sseSelectedScanUrlRowIde,omitempty` | []string | Replace the SSE selection |
| `HostsContent` | `hostsContent,omitempty` | string | Replace hosts |

```go
app.WriteResp(app.TaskStartResponse{
    Handled: true,
    Task:    app.TaskStartModify{SelectedVulnIds: keep},
})
```

> With `omitempty` an empty slice is dropped, so an empty slice cannot be used to "clear" a field.

#### `TaskStartResponse`

**Purpose**: the response body of `应用_OnTaskStart`.

| Field | json | Type | Meaning |
|---|---|---|---|
| `Handled` | `handled` | bool | Parsed but does not affect the flow at present |
| `Error` | `error` | string | Error message |
| `Task` | `task` | TaskStartModify | The task modifications to apply |

```go
app.WriteResp(app.TaskStartResponse{Handled: true, Task: mod})
```

> Task modifications are always applied from `task`.

#### `TaskVulnBrief`

**Purpose**: the vulnerability summary used by the vulnerability filter hook.

| Field | json | Type | Meaning |
|---|---|---|---|
| `VulnIde` | `vulnIde` | string | Unique vulnerability identifier |
| `Name` | `name` | string | Vulnerability name |
| `Level` | `level` | string | Level `4/3/2/1` |
| `PocType` | `pocType` | string | `yaml/go/wasm` |

```go
for _, v := range req.Vulns { app.Logf("%s %s", v.VulnIde, v.Name) }
```

> `VulnIde` is exactly the value to put into `filterIds`.

#### `TaskFlowBrief`

**Purpose**: the packet summary used by the packet filter hook.

| Field | json | Type | Meaning |
|---|---|---|---|
| `Ide` | `ide` | string | Packet identifier (`IdeTraffic`) |
| `Type` | `type` | string | `http/websocket/sse` |
| `Method` | `method` | string | Request method |
| `URL` | `url` | string | Request URL |
| `Domain` | `domain` | string | Domain |
| `TLS` | `tls` | string | `HTTP/HTTPS/WSS` |

```go
for _, f := range req.Flows { app.Logf("%s %s", f.Ide, f.Domain) }
```

> When excluding, put `f.Ide` into `filterIds`.

#### `TaskFilterResponse`

**Purpose**: the unified response for filter hooks.

| Field | json | Type | Meaning |
|---|---|---|---|
| `Handled` | `handled` | bool | No effect |
| `Error` | `error` | string | Error message |
| `FilterIds` | `filterIds` | []string | Set of identifiers to exclude |

```go
app.WriteResp(app.TaskFilterResponse{FilterIds: drop})
```

> Return the ones to **exclude**, not the ones to keep.

#### `MitmHttpRequest`

**Purpose**: an MITM HTTP request snapshot.

| Field | json | Type | Meaning |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | Task identifier |
| `Method` | `method` | string | Request method |
| `URL` | `url` | string | Full URL |
| `Proto` | `proto` | string | Protocol version |
| `Host` | `host` | string | Host |
| `Headers` | `headers` | map[string][]string | Request headers |
| `Body` | `body` | string | Decompressed request body |
| `RemoteIP` | `remoteIp` | string | Remote server IP |
| `TLS` | `tls` | string | `HTTP/HTTPS` |

```go
var req app.MitmHttpRequest
_ = app.LoadRequest(&req)
app.Logf("%s %s", req.Method, req.URL)
```

> `Headers` is a `map[string][]string`.

#### `MitmHttpModify`

**Purpose**: the modifiable fields of an HTTP request/response (`omitempty`).

| Field | json | Type | Meaning |
|---|---|---|---|
| `Method` | `method,omitempty` | string | Replace the method |
| `URL` | `url,omitempty` | string | Replace the URL |
| `Headers` | `headers,omitempty` | map[string][]string | Replace the whole header map |
| `Body` | `body,omitempty` | string | Replace the body |
| `StatusCode` | `statusCode,omitempty` | int | Used by responses only (effective when >0) |

```go
_ = app.MitmHttpModify{URL: req.URL, Body: "x"}
```

> Shared by both request and response sides, but the response side does not use method/url.

#### `MitmHttpResponse`

**Purpose**: an MITM HTTP response snapshot.

| Field | json | Type | Meaning |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | Task identifier |
| `URL` | `url` | string | Request URL |
| `Method` | `method` | string | Request method |
| `StatusCode` | `statusCode` | int | Response status code |
| `Headers` | `headers` | map[string][]string | Response headers |
| `Body` | `body` | string | Decompressed response body |
| `RemoteIP` | `remoteIp` | string | Remote server IP |
| `TLS` | `tls` | string | `HTTP/HTTPS` |

```go
var resp app.MitmHttpResponse
_ = app.LoadRequest(&resp)
app.Logf("status=%d", resp.StatusCode)
```

> Compared with the request snapshot: no `Proto`/`Host`, plus `StatusCode`.

#### `MitmWsMessage`

**Purpose**: an MITM WebSocket message frame.

| Field | json | Type | Meaning |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | Task identifier |
| `URL` | `url` | string | Connection URL |
| `Domain` | `domain` | string | Domain |
| `FromClient` | `fromClient` | bool | `true`=client→server |
| `StatusType` | `statusType` | int | `1`=send `2`=receive |
| `Content` | `content` | string | Message content |

```go
var msg app.MitmWsMessage
_ = app.LoadRequest(&msg)
app.Logf("fromClient=%v content=%s", msg.FromClient, msg.Content)
```

> Prefer `FromClient` to judge direction.

#### `MitmWsModify`

**Purpose**: the modifiable fields of a WebSocket message.

| Field | json | Type | Meaning |
|---|---|---|---|
| `Content` | `content,omitempty` | string | Replace the message content |

```go
_ = app.MitmWsModify{Content: "pong"}
```

> An empty `content` does not override.

#### `MitmSseEvent`

**Purpose**: an MITM SSE event.

| Field | json | Type | Meaning |
|---|---|---|---|
| `TaskIde` | `taskIde` | string | Task identifier |
| `URL` | `url` | string | Connection URL |
| `Event` | `event` | string | The `event:` field (default message) |
| `ID` | `id` | string | The `id:` field |
| `Data` | `data` | string | The `data:` field |
| `Retry` | `retry` | int | The `retry:` field (milliseconds) |

```go
var evt app.MitmSseEvent
_ = app.LoadRequest(&evt)
app.Logf("event=%s id=%s", evt.Event, evt.ID)
```

> `ID` maps to the json `id`.

#### `MitmSseModify`

**Purpose**: the modifiable fields of an SSE event.

| Field | json | Type | Meaning |
|---|---|---|---|
| `Data` | `data,omitempty` | string | Replace the event data |

```go
_ = app.MitmSseModify{Data: "new-data"}
```

> An empty `data` does not override.

#### `NodeInfo`

**Purpose**: information about the current scan node (returned by `GetNodeInfo`).

| Field | json | Type | Meaning |
|---|---|---|---|
| `UUID` | `uuid` | string | Node UUID |
| `NodeName` | `nodeName` | string | Node name |
| `Language` | `language` | string | Current language |
| `AppID` | `appId` | string | Application ID |

```go
info := app.GetNodeInfo()
app.Logf("node=%s lang=%s", info.NodeName, info.Language)
```

> The host actually returns one extra field, `pluginId`, which the SDK's `NodeInfo` does not include.

### 5.3 Functions

#### `Capability(name string)`

**Purpose**: declare a single capability point (deduplicated automatically).

**Signature / parameters / return**: `func Capability(name string)`; `name` is the capability name (a hook name or `tools.exec` etc.); no return value.

```go
app.Capability(app.HookTaskStart)
```

> An empty string is ignored.

#### `Capabilities(names ...string)`

**Purpose**: declare capability points in bulk.

**Signature / parameters / return**: `func Capabilities(names ...string)`; no return value.

```go
app.Capabilities(app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd)
```

> Internally it calls `Capability` one by one; you can pass any number of names.

#### `DeclareTool(name, runtime string)`

**Purpose**: declare one external tool (deduplicated).

**Signature / parameters / return**: `func DeclareTool(name, runtime string)`; `runtime` is `python`/`java`/`""`; no return value.

```go
app.DeclareTool("sqlmap", "python")
```

> The tool must live under `tools/<name>/`.

#### `DeclareTools(pairs ...string)`

**Purpose**: declare tools in bulk, in the format `"name=runtime"`.

**Signature / parameters / return**: `func DeclareTools(pairs ...string)`; no return value.

```go
app.DeclareTools("sqlmap=python", "xray=java", "nuclei")
```

> Without `=` the runtime is empty (a native executable).

#### `Declare()`

**Purpose**: serialize the already declared capabilities and tools into `[INIT]` lines written to stdout.

**Signature / parameters / return**: `func Declare()`; no return value; emits `[INIT] {"capabilities":[...],"tools":[...]}`.

```go
app.Capability(app.HookTaskStart)
app.DeclareTool("nuclei", "")
app.Declare()
```

> It must be called inside a hook function, otherwise this run's stdout has no `[INIT]`.

#### `LoadRequest(v any) error`

**Purpose**: read the request JSON injected by the host from stdin and unmarshal it into `v` (called once per hook execution).

**Signature / parameters / return**: `func LoadRequest(v any) error`; `v` is a pointer to the request struct; returns an error on failure.

```go
var req app.TcpDataRequest
if err := app.LoadRequest(&req); err != nil {
    app.WriteError(err)
    return
}
```

> Each hook reads stdin only once (`io.ReadAll(os.Stdin)`).

#### `WriteResp(v any)`

**Purpose**: write the response to stdout as a single `[RESP] <JSON>` line (parsed by the host).

**Signature / parameters / return**: `func WriteResp(v any)`; no return value.

```go
app.WriteResp(app.TcpDataResponse{Data: "x"})
```

> The value `v` is `json.Marshal`ed; make sure it can be encoded as an object.

#### `WriteHandled()`

**Purpose**: declare that the plugin has consumed this event; equivalent to `WriteResp({"handled":true})`.

**Signature / parameters / return**: `func WriteHandled()`; no return value.

```go
//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { app.WriteHandled() }
```

> Only `handled=true` from the receive hook skips the built-in handling.

#### `WriteError(err error)`

**Purpose**: return an error message (only logged; it does not affect the main program flow).

**Signature / parameters / return**: `func WriteError(err error)`; no return value; emits `{"error":"..."}`.

```go
app.WriteError(fmt.Errorf("请求解析失败：%v", err))
```

> Do not simply `return` on an error path without writing any response, otherwise the host sees no `[RESP]`.

#### `Log(msg string)`

**Purpose**: emit a debug log (through the `app_log` host function).

**Signature / parameters / return**: `func Log(msg string)`; no return value.

```go
app.Log("工具已启动")
```

> It is unrelated to `WriteResp` and does not participate in the stdout line protocol.

#### `Logf(format string, args ...any)`

**Purpose**: formatted logging; equivalent to `Log(fmt.Sprintf(...))`.

**Signature / parameters / return**: `func Logf(format string, args ...any)`; no return value.

```go
app.Logf("pid=%d key=%s", pid, key)
```

> On high-frequency paths, keep the log volume under control.

#### `SetTimeout(ms int64)`

**Purpose**: set this plugin's per-hook execution timeout (in milliseconds).

**Signature / parameters / return**: `func SetTimeout(ms int64)`; no return value; the hard limit is 5 minutes.

```go
app.SetTimeout(290000)
```

> Before every hook execution the timeout is reset to the default 30s, so each hook must set it itself.

#### `ExecTool(tool, runtime string, args ...string) string`

**Purpose**: synchronously execute an external tool inside the plugin directory and return the host JSON `{exitCode,stdout,stderr,error}`.

**Signature / parameters / return**: `func ExecTool(tool, runtime string, args ...string) string`; returns the result JSON text.

```go
raw := app.ExecTool("nuclei", "", "-l", env.DomainsFile, "-o", out)
var res struct {
    ExitCode int    `json:"exitCode"`
    Stdout   string `json:"stdout"`
    Error    string `json:"error"`
}
_ = json.Unmarshal([]byte(raw), &res)
```

> Requires `tools.exec` + GUI authorization + global queue scheduling.

#### `ReadFile(path string) string`

**Purpose**: read a file inside the plugin sandbox; returns `{ok,data(base64),size,error}`.

**Signature / parameters / return**: `func ReadFile(path string) string`; returns the envelope JSON.

```go
raw := app.ReadFile("data/out.txt")
var env struct{ OK bool `json:"ok"`; Data string `json:"data"`; Error string `json:"error"` }
_ = json.Unmarshal([]byte(raw), &env)
```

> `data` is base64 and must be decoded.

#### `WriteFile(path string, data []byte) string`

**Purpose**: write a file inside the plugin sandbox; returns `{ok,size,error}`.

**Signature / parameters / return**: `func WriteFile(path string, data []byte) string`; returns the envelope JSON.

```go
_ = app.WriteFile("data/state.json", []byte(`{"n":1}`))
```

> Parent directories are created automatically; there is no delete interface.

#### `SendTcpData(cmd1, cmd2, cmd3, cmd4, data string)`

**Purpose**: send raw TCP data (four-level command + raw data section).

**Signature / parameters / return**: `func SendTcpData(cmd1, cmd2, cmd3, cmd4, data string)`; no return value.

```go
app.SendTcpData("relayData", "MyEvent", "gui", "", "raw-data")
```

> `cmd3` is the target; when `cmd4` is empty this node is filled in automatically.

#### `SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`

**Purpose**: send TCP JSON data (with the `{CommandA,Data}` wrapping done automatically).

**Signature / parameters / return**: `func SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`; no return value.

```go
app.SendTcpDataJson("scan", "SaveScanVuln", "", "", "SaveScanVuln", vuln)
```

> Pass a struct as `data`, **do not marshal it into []byte yourself**.

#### `ReinjectTcpData(td TcpDataRequest)`

**Purpose**: inject one TCP message back into the host's receive flow (depth limited to 5 levels).

**Signature / parameters / return**: `func ReinjectTcpData(td TcpDataRequest)`; no return value.

```go
app.ReinjectTcpData(app.TcpDataRequest{
    UUID: "n-0a1b", Command1: "relayData", Command2: "MyEvent",
    Command3: "gui", Command4: "n-0a1b", Source: "2", Data: "raw",
})
```

> During re-injection this hook is not called again; injections beyond the depth limit are discarded.

#### `ConfigGet(key string) string`

**Purpose**: read a Key/Value entry from `plugin.config.json`.

**Signature / parameters / return**: `func ConfigGet(key string) string`; returns plain text (not JSON).

```go
if app.ConfigGet("Enabled") == "true" { /* ... */ }
```

> A missing key returns an empty string.

#### `T(key string) string`

**Purpose**: read language-pack text (key space `<lang>.<uuid>.<key>`).

**Signature / parameters / return**: `func T(key string) string`; returns the text; falls back to the key when missing.

```go
title := app.T("VulnName")
```

> The language-pack files are `language-cn.json` / `language-en.json`.

#### `GetNodeInfo() NodeInfo`

**Purpose**: obtain information about the current scan node.

**Signature / parameters / return**: `func GetNodeInfo() NodeInfo`; returns `app.NodeInfo`.

```go
info := app.GetNodeInfo()
app.Logf("node=%s", info.NodeName)
```

> The SDK struct does not include the `pluginId` the host returns; when you need it, declare your own struct and read `app_node_info`.

#### `CallBuiltin(funcID uint32, args ...any) (string, bool)`

**Purpose**: call a host built-in tool function (the unified `app_call` entry point).

**Signature / parameters / return**: `func CallBuiltin(funcID uint32, args ...any) (string, bool)`; `false` means no result.

```go
s, ok := app.CallBuiltin(20, "hello") // MD5
```

> For funcID see the table in 4.5; on error it returns `{"error":...}` text.

#### `Base64Encode(data string) string`

**Purpose**: Base64 encoding (funcID 10).

**Signature / parameters / return**: `func Base64Encode(data string) string`.

```go
enc := app.Base64Encode("hello")
```

> Internally it is `CallBuiltin(10, data)`.

#### `Base64Decode(data string) string`

**Purpose**: Base64 decoding (funcID 11).

**Signature / parameters / return**: `func Base64Decode(data string) string`.

```go
plain := app.Base64Decode(enc)
```

> Invalid input is answered by the host with error text.

#### `MD5(data string) string`

**Purpose**: MD5 hash (lowercase hex, funcID 20).

**Signature / parameters / return**: `func MD5(data string) string`.

```go
sum := app.MD5("hello")
```

> In-memory hashing.

#### `SHA1(data string) string`

**Purpose**: SHA1 hash (funcID 21).

**Signature / parameters / return**: `func SHA1(data string) string`.

```go
sum := app.SHA1(token)
```

> Used for deduplication hashes and similar scenarios.

#### `SHA256(data string) string`

**Purpose**: SHA256 hash (funcID 23).

**Signature / parameters / return**: `func SHA256(data string) string`.

```go
sum := app.SHA256(payload)
```

> Consistent with how the host computes SHA-256 over tool files.

#### `JSONGet(data []byte, path string) string`

**Purpose**: read a JSON value by path (funcID 50; paths such as `a.b[0].c`).

**Signature / parameters / return**: `func JSONGet(data []byte, path string) string`.

```go
name := app.JSONGet([]byte(`{"a":{"b":[{"c":"x"}]}}`), "a.b[0].c")
```

> Returns the value in string form.

---

## 6. Build, Directory and go.mod

### 6.1 Plugin Directory Structure

```text
scan-poc/plugin/<uuid>/
├── build/
│   └── scan.wasm              # build artifact (required; the host locates build/*.wasm, preferring scan.wasm)
├── plugin.config.json         # plugin Key/Value config (optional)
├── language-cn.json           # Chinese language pack (optional; key space <lang>.<uuid>.<key>)
├── language-en.json           # English language pack (optional)
├── sig.json                   # optional Ed25519 signature information
└── tools/<name>/              # external tools (optional; usable after declaration and authorization)
    ├── bin/<name>[.exe]       # executable
    └── <name>.py / <name>.jar # python / java tool entry point
```

The host supports two layouts: the standard `root/<uuid>/build/scan.wasm` and the legacy `root/<uuid>/Plugin/scan/<uuid>/build/scan.wasm`. If `sig.json` exists and signature verification fails (`verify_failed`), the plugin is refused loading; a missing signature is recorded as `unsigned` (trusting the controller's AES-GCM delivery channel).

### 6.2 Build Command

```bash
GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .
```

**`-buildmode=c-shared` is mandatory**: the host needs to call `_initialize` to initialize the Go runtime and only then call the `应用_xxx` exports directly. `func main() {}` must be kept (required by c-shared mode; an empty implementation is enough).

### 6.3 go.mod and SDK Reference

```text
module my-plugin

go 1.22
```

After placing the SDK package's `app.go` into the project's `app/` subdirectory:

```text
require app v0.0.0

replace app => ./app
```

Exported functions use `//go:wasmexport` with a **Chinese-prefixed name**:

```go
//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { /* ... */ }

func main() {}
```

> [!CAUTION]
> The exported function name **must** carry the `应用_` prefix (e.g. `应用_OnTaskStart`) and must match the host constants exactly. Writing `OnTaskStart` (without the prefix) will not be recognized and the plugin will be treated as "implementing no capability at all". Moreover, the `应用_` prefix is the sole basis for capability detection, so it must be letter-perfect.

---

## 7. Advanced External-Tool Capabilities

An application plugin lets the platform integrate external scanners such as nuclei / xray / sqlmap **with no external-tool-specific code on the platform side**. The core gate is **capability declaration + user authorization**.

### 7.1 Declaration and Authorization

1. In `应用_Init` the plugin declares tools with `DeclareTool` / `DeclareTools` (or by directly building the `tools` array of `[INIT]`); tools must live under `tools/<name>/`;
2. After the plugin is loaded, the host computes the SHA-256 of the tool executable and, together with the machine fingerprint, sends a `ToolAuthRequest` popup to the GUI;
3. After the user confirms in the GUI, a `ToolAuthConfirm` comes back and the host records `allowed`/`denied` per `pluginUUID/tool` (persisted on the node side, with decisions kept per GUI user);
4. Before any later call to `app_exec_tool` / `app_spawn_tool`, the host checks: **is `tools.exec` declared** + **is the authorization state "allowed"**.

Authorization state machine:

| State | Trigger condition |
|---|---|
| `unauthorized` | Default; or the tool hash changed; or a different machine (fingerprint changed) |
| `allowed` | The user allowed it; on the same machine + same hash, the next start **trusts the existing grant and skips the popup** |
| `denied` | The user refused; it is no longer executed from that user's perspective |

Multi-user determination: if any GUI user trusts → `allowed`; if all refuse → `denied`. Without the `tools.exec` declaration it directly reports "插件未声明 tools.exec 能力，禁止调用外部工具".

### 7.2 Differences Between `app_exec_tool` and `app_spawn_tool`

| Dimension | `app_exec_tool` (synchronous, one-shot) | `app_spawn_tool` (background, resident) |
|---|---|---|
| Waits for exit? | Yes | No (returns the pid immediately) |
| Output | `{exitCode,stdout,stderr,error}` (stdout/stderr capped at 4MB each) | Appends to `data/toollogs/<key>.log` (readable with `app_read_file`) |
| Suitable for | Command-line tools that exit when done (a one-shot nuclei scan) | Resident service-type tools (sqlmapapi, an xray listener) |
| Process management | None (ends when execution ends) | `app_stop_tool` / `app_tool_status` / auto-kill on timeout |
| Multiple instances | Not applicable | Coexist with different `key`s (`nuclei#1`, `nuclei#2`) |

### 7.3 Global Task Queue Scheduling

All `app_exec_tool` calls are scheduled through the host's global task queue so a plugin cannot start a large number of processes at once and overwhelm the machine:

- The global concurrency cap is **2 by default**;
- The queue depth cap is **32 by default**; anything beyond is rejected outright;
- Queue waiting is **30s by default**; on timeout it returns "queue busy";
- `app_spawn_tool` goes through the same authorization checks, but processes are managed by the plugin itself (it does not occupy a queue slot).

### 7.4 Result Collection

External tool result files must land inside **`{output_dir}`** (returned by `app_task_env`, i.e. `data/ext-results/<taskIde>/` inside the plugin sandbox); the plugin collects them with `app_read_file` / `app_list_dir` using **relative paths**, then reports through `app_send_tcp_json(..., "SaveScanVuln", ...)`. Placeholders are expanded in the command template and in result paths; a result path that escapes `{output_dir}` is ignored.

### 7.5 The 18 Command-Template Placeholders

| Placeholder | Meaning |
|---|---|
| `{task_id}` | Task `taskIde` |
| `{task_name}` | Task name |
| `{node_id}` | Node UUID |
| `{node_name}` | Node name |
| `{domain}` | Primary domain |
| `{target_url}` | First URL (with scheme) |
| `{domains_file}` | Absolute path of the deduplicated domain list file |
| `{urls_file}` | Absolute path of the deduplicated URL list file |
| `{output_dir}` | Absolute path of this task's result output directory |
| `{tools_dir}` | Absolute path of the plugin tools directory |
| `{plugin_dir}` | Absolute path of the plugin root directory |
| `{python}` | python executable (the external-tool environment takes precedence) |
| `{java}` | java executable (the external-tool environment takes precedence) |
| `{proxy}` | This tool's replay proxy (e.g. `http://127.0.0.1:{port}`) |
| `{sec_proxy}` | Upstream proxy from the task's "security test proxy settings" (empty when not configured) |
| `{seq}` | Process sequence number |
| `{port}` | Free port for this process (allocated by `app_free_port`) |
| `{timestamp}` | Start timestamp (`20060102-150405`) |

> [!TIP]
> `{port}` and `{seq}` are **instance-level** placeholders: when the same tool runs with `Count>1`, each process instance should be allocated its own port to avoid colliding with itself. Concurrent tasks also stay out of each other's way because ports and state files are isolated per task.

### 7.6 Runtime Status Reporting (Optional)

The sample plugin `plugin-exttools` demonstrates generic `PluginStatusReport` reporting: take `pluginId`/`uuid` from `app_node_info`, aggregate `app_tool_status` plus each task's state files, and report through `app_send_tcp_json("scan","PluginStatusReport","","","PluginStatusReport", payload)`; the GUI plugin page then shows the running status of each tool on each scan node.

```go
app.SendTcpDataJson("scan", "PluginStatusReport", "", "", "PluginStatusReport", map[string]any{
    "pluginId": info.PluginID, "nodeUuid": info.UUID, "nodeName": info.NodeName, "data": snapshot,
})
```

---

## 8. File Sandbox

`app_read_file` (`fs.read`), `app_write_file` (`fs.write`) and `app_list_dir` (`fs.read`) can only access **relative paths inside the plugin's own directory**:

- Only **relative paths** are accepted; absolute paths and leading `/` are rejected;
- `../` path traversal is rejected;
- Symbolic links are rejected (escape prevention);
- The final path is checked with `filepath.Rel` and must land inside the plugin directory; otherwise it reports "path out of bounds".

`app_write_file` creates parent directories automatically. `app_read_file` is capped at 4MB per file, and anything beyond is truncated. The `dir` parameter of `app_spawn_tool` (an absolute path) must likewise land inside the plugin sandbox, otherwise it is ignored.

> [!WARNING]
> Sandbox rejection is a **hard rejection**: any attempt to read files outside the plugin directory with `..\\`, an absolute path or a symbolic link returns an error directly, with no fallback. Do not rely on any use of "reading files outside the sandbox".

---

## 9. Timeouts and Stability

The host provides multiple layers of stability protection for application plugins:

| Mechanism | Description |
|---|---|
| Default timeout | A single hook execution defaults to **30s**; the plugin can reset it with `app_set_timeout(ms)` (`SetTimeout`) |
| Hard limit | `app_set_timeout` has a **5-minute** hard limit; values beyond it are capped |
| Serial execution | Only one execution per plugin at a time; a hook triggered again while one is running is **skipped outright** (anti-reentrancy/deadlock) |
| Watchdog | An independent timer per call; on timeout it returns an error immediately without blocking the scan process |
| Poisoned rebuild | After a timeout the runtime is marked `poisoned` and rebuilt automatically on the next call (a stuck plugin does not affect subsequent ones) |
| Crash recover | `recover()` inside the execution goroutine turns a plugin panic into an error, so it cannot bring down the host |
| Compile protection | Compilation also runs under a context with a timeout, so a malformed/malicious wasm cannot hang the scan process |
| Anti-reentrancy | The send hook triggered by `app_send_tcp` and the re-injection of `app_tcp_received_data` (depth 5) both have loop protection |
| Plugin cap | At most **50** plugins are loaded; anything beyond is skipped |
| Process cleanup | On plugin reload/node exit, all resident tool processes it started are stopped automatically |

> [!CAUTION]
> The re-injection depth limit (`ReinjectTcpData`) is **5 levels**: if the plugin's forwarding logic forms a loop, the 6th level is discarded outright. Judge the message source inside the plugin yourself, and avoid injecting an "already processed" message again. Likewise, a hook of the same plugin triggered during hook execution is skipped, so do not design logic that depends on recursive callbacks.

---

## 10. Debugging Manual

### 10.1 Where to Find the Logs

Application plugin logs have two sources, and both end up in the **scan node log**:

| Source | How it is produced | Host destination |
|---|---|---|
| `app.Log` / `app.Logf` | Calls the `app_log` host function | Collected into this execution's log buffer, finally written into the scan node log as `插件[<uuid>] 日志: ...` |
| `[LOG]` lines or other stdout | You write `os.Stdout.WriteString("[LOG] xxx\n")` directly | The host parses the `[LOG]` prefix and collects it into this execution's log, just like `app_log` |

The host itself also emits logs about compile failures, hook execution failures, timeouts, skips, and so on, for example:

```text
插件[plugin-x] 编译失败: 编译 wasm 模块失败: ...
插件[plugin-x] 应用_OnTaskStart 执行失败: 插件执行超时（默认 30s，可调用 app_set_timeout 调整）
插件[plugin-x] 声明了能力 应用_OnXxx 但未导出对应钩子函数，无法调用（跳过）
```

`[RESP]` is a **parsing channel**, not a log: as soon as the host parses `[RESP] <JSON>` it uses it immediately and does not print the raw text. So when debugging, besides reading logs, confirm that the plugin "really wrote `[RESP]`".

### 10.2 The 5-Minute Minimal Verification Flow

1. **Create the project**: make a new directory, write `module my-plugin` + `go 1.22` in `go.mod`, copy the SDK package's `app.go` into the `app/` subdirectory, and add `replace app => ./app`.
2. **Write the minimal plugin** (code below) and save it as `main.go`.
3. **Build**: `GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .`.
4. **Place the files**: put `scan.wasm` at `scan-poc/plugin/plugin-demo-9999/build/scan.wasm` (on a development machine you can place it manually; no store delivery needed).
5. **Trigger once**: restart the scan node (or have the node reload plugins), then trigger a task / have the node receive one TCP message.
6. **Check the log and `[RESP]`**: the log should contain "插件[plugin-demo-9999] 日志: [...]", showing the hook was executed; if your modified data also takes effect, the chain works end to end.

```go
package main

import "app"

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(app.HookTcpDataReceived)
    app.Declare()
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    app.Logf("hook ok: command2=%s", req.Command2)
    app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data})
}

func main() {}
```

### 10.3 Symptom → Cause → Fix

| Symptom | Cause | Fix |
|---|---|---|
| The plugin does nothing at all; the log shows no compile/execute at all | Forgot `-buildmode=c-shared` (a command module runs `_start` to completion and exits) | Add `-buildmode=c-shared` to the build |
| Link failure / compile error | Missing `func main() {}` | Keep an empty `func main() {}` |
| The plugin loads but no hook is ever called | The exported function name lacks the `应用_` prefix | Use `//go:wasmexport 应用_OnXxx`, letter-for-letter identical to the host constant |
| The capability is declared but the host does not call it | Only declared in `[INIT]`, without exporting the corresponding `应用_xxx` function | Export-table detection governs: you must really `//go:wasmexport` the hook |
| `应用_Init` does not run and tool declarations are lost | `应用_Init` is not exported, or `Declare()` is written in package initialization/global variables | Export `应用_Init` and call `Declare()` inside the hook function |
| `app_exec_tool` returns "tools.exec capability not declared" | `tools.exec` was not declared | `app.Capability(app.CapToolsExec)` (inside `应用_Init`) |
| `app_exec_tool` returns "not authorized by the user" | The tool was not authorized in the GUI, or the hash/machine changed | Confirm in the GUI plugin page; a different machine or changed tool file requires re-authorization |
| The host cannot parse any result; it seems unresponsive | `WriteResp` did not emit the `[RESP]` prefix (or you assembled non-single-line JSON yourself) | Use the SDK's `app.WriteResp`, which emits a single `[RESP] <JSON>` line |
| `handled=true` but the built-in handling still runs | Wrong hook: only `应用_OnTcpDataReceived`'s `handled` takes effect | Follow the "does `handled` take effect" table in chapter 3 |
| A field was changed but has no effect | Wrong JSON field name in the request/response (case/camelCase, e.g. `selectedvulnids`, `filterids`) | Follow the SDK field names/json tags exactly (`selectedVulnIds`, `filterIds`, `request`, `message`) |
| Re-injected data is discarded | The re-injection depth exceeded 5 levels | Judge the message source and avoid `ReinjectTcpData` on already-processed messages; the depth limit is 5 |
| `app_http_request` reports "only local loopback addresses are allowed" | The target is not `127.0.0.1`/`localhost`/`::1` | For local services use `app_http_request`; for external sites use `app_http_replay` |
| `app_read_file`/`app_list_dir` reports "path out of bounds/absolute paths not allowed" | An absolute path, `..` or a symbolic link was used | Use only relative paths inside the plugin directory; when an absolute path is needed, pass `app_task_env`'s `outputDir` to an external tool |
| A long task is interrupted with "plugin execution timeout" | The default 30s timeout | Call `app.SetTimeout(ms)` at the start of the hook; the hard limit is 5 minutes; `drain` works in rounds by returning `done=false`, so do not `sleep` |
| Subsequent calls behave oddly or slow down after a timeout | The runtime was marked poisoned and is being rebuilt | Normal: the next call rebuilds the runtime automatically; keep investigating why it timed out |
| Multiple instances of the same tool "kill the earlier one on start" | `app_spawn_tool` used the same `key` (bookkeeping defaults to the tool name) | Pass different `key`s (`nuclei#1`/`nuclei#2`) |
| Scanning slows down and the dispatch loop stalls | Heavy synchronous work inside `应用_OnTaskPacket` | Be fast in and fast out; use `async=true` for `app_http_replay`; leave heavy work to `应用_OnTaskEnd` |
| The peer receives a base64 string instead of a JSON object | You `json.Marshal`ed the struct into a `[]byte` before passing it to `SendTcpDataJson` | Pass the struct/`any` directly and let the SDK marshal it |
| Result files cannot be collected | The results were written outside `{output_dir}` | Anchor the tool's `dir` and result paths to `app_task_env`'s `outputDir`; collect with `outputDirRel` |

> [!IMPORTANT]
> The host functions of an application plugin and the `scan_*` of a WASM POC **do not overlap at all**: using the wrong set results in an **instantiation failure**, not "a function returning an error". Before releasing, confirm that the plugin's effective path is the "plugin store/application plugin directory" rather than the "POC template list".

---

## 11. Complete Examples

All of the examples below can be built with the command from chapter 6. Examples one to three only need the SDK; example four is an external-tool advanced case (requires `tools.exec` and GUI authorization).

### 11.1 Example One: Minimal Plugin (implements only TCP receive)

```go
package main

import (
    "errors"

    "app"
)

//go:wasmexport 应用_Init
func OnInit() {
    app.Capability(app.HookTcpDataReceived)
    app.Declare() // 输出 [INIT]，声明本插件能力
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteError(errors.New("请求解析失败"))
        return
    }
    app.Log("收到 TCP 数据: " + req.Command2)
    // 在原数据后追加标记，宿主会用新数据重新解析后再分发
    app.WriteResp(app.TcpDataResponse{Data: req.Data + "_processed"})
}

func main() {}
```

> [!TIP]
> If you only want to "observe" rather than modify data, return `app.WriteResp(app.TcpDataResponse{})` (an empty `data` means no change); to consume the event and skip the built-in handling, return `app.WriteHandled()`.

### 11.2 Example Two: `应用_OnTaskStart` Dynamically Trimming Vulnerabilities and Traffic

```go
package main

import (
    "strings"

    "app"
)

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000)
    app.Capability(app.HookTaskStart)
    app.Declare()
    var req app.TaskStartRequest
    _ = app.LoadRequest(&req)

    // 只扫描名称含 "rce" 的 POC
    keep := make([]string, 0, len(req.SelectedVulnIds))
    for _, id := range req.SelectedVulnIds {
        if strings.Contains(strings.ToLower(id), "rce") {
            keep = append(keep, id)
        }
    }
    // 剔除测试环境域名对应的数据包选择（按前缀判断）
    httpKeep := make([]string, 0, len(req.HttpSelectedScanUrlRowIde))
    for _, ide := range req.HttpSelectedScanUrlRowIde {
        if !strings.HasPrefix(ide, "test-") {
            httpKeep = append(httpKeep, ide)
        }
    }
    app.Logf("任务 %s：漏洞 %d→%d，HTTP 包 %d→%d",
        req.TaskName, len(req.SelectedVulnIds), len(keep),
        len(req.HttpSelectedScanUrlRowIde), len(httpKeep))
    app.WriteResp(app.TaskStartResponse{
        Handled: true,
        Task: app.TaskStartModify{
            SelectedVulnIds:           keep,
            HttpSelectedScanUrlRowIde: httpKeep,
        },
    })
}

func main() {}
```

### 11.3 Example Three: `应用_OnMitmHttpRequest` Rewriting Request Headers

```go
package main

import "app"

//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() {
    app.Capability(app.HookMitmHttpRequest)
    app.Declare()
    var req app.MitmHttpRequest
    _ = app.LoadRequest(&req)

    headers := map[string][]string{}
    for k, v := range req.Headers {
        headers[k] = v
    }
    headers["X-Tss-Powered-By"] = []string{"TestSecScan-Plugin"}
    headers["X-Original-URL"] = []string{req.URL}

    resp := struct {
        Handled bool               `json:"handled"`
        Request app.MitmHttpModify `json:"request"`
    }{Request: app.MitmHttpModify{Headers: headers}}
    app.WriteResp(resp)
}

func main() {}
```

### 11.4 Example Four: Calling an External Tool to Scan and Report Results (Advanced)

The example below shows the complete skeleton of "start a tool at task start → replay packets → collect and report results at task finalization", demonstrating `app_task_env`, `app_free_port`, `app_spawn_tool`, `app_http_replay`, `app_tool_status`, `app_list_dir`, `app_read_file` and the `SaveScanVuln` reporting channel (host functions not wrapped by the SDK are declared by yourself as in 4.14–4.20).

```go
package main

import (
    "encoding/base64"
    "encoding/json"
    "unsafe"

    "app"
)

// ---- 未封装宿主函数声明 ----
//go:wasmimport env app_free_port
func hostFreePort(out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_spawn_tool
func hostSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_stop_tool
func hostStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_tool_status
func hostToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_http_replay
func hostHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_task_env
func hostTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_list_dir
func hostListDir(path string, out unsafe.Pointer, outCap uint32) uint32

const bufSize = 256 * 1024

func callOut(fn func(unsafe.Pointer, uint32) uint32) string {
    var buf [bufSize]byte
    n := fn(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n > uint32(len(buf)) {
        n = uint32(len(buf))
    }
    return string(buf[:n])
}

func callIn(fn func(string, unsafe.Pointer, uint32) uint32, in string) string {
    var buf [bufSize]byte
    n := fn(in, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

type taskEnv struct {
    TaskIde      string `json:"taskIde"`
    OutputDir    string `json:"outputDir"`
    OutputDirRel string `json:"outputDirRel"`
    DomainsFile  string `json:"domainsFile"`
    ToolsDir     string `json:"toolsDir"`
    Python       string `json:"python"`
}

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd, app.CapToolsExec, app.CapFSRead)
    app.DeclareTool("nuclei", "") // tools/nuclei/ 下的可执行文件
    app.Declare()
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000)
    var req struct {
        TaskIde string `json:"taskIde"`
    }
    _ = app.LoadRequest(&req)

    var env taskEnv
    _ = json.Unmarshal([]byte(callIn(hostTaskEnv, req.TaskIde)), &env)
    if env.OutputDir == "" {
        app.Log("任务环境不可用，跳过硬扫描")
        app.WriteResp(map[string]any{"handled": false})
        return
    }

    // 拉起 nuclei：结果写入当前任务结果目录（必须在 {output_dir} 内）
    args, _ := json.Marshal(map[string]any{
        "entry":      "nuclei.exe",
        "args":       []string{"-l", env.DomainsFile, "-o", env.OutputDir + "/nuclei.txt", "-silent"},
        "key":        "nuclei#1",
        "dir":        env.OutputDir,
        "timeoutSec": 900,
    })
    var resp struct {
        Pid   int    `json:"pid"`
        Error string `json:"error"`
    }
    _ = json.Unmarshal([]byte(callIn3(hostSpawnTool, "nuclei", "", string(args))), &resp)
    if resp.Error != "" {
        app.Logf("nuclei 启动失败：%s", resp.Error)
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    app.Logf("nuclei 已启动 pid=%d", resp.Pid)
    app.WriteResp(map[string]any{"handled": true})
}

//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() {
    app.SetTimeout(30000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Flow    struct {
            Method     string `json:"method"`
            URL        string `json:"url"`
            ReqHeaders string `json:"reqHeaders"`
            ReqBody    string `json:"reqBody"`
        } `json:"flow"`
    }
    _ = app.LoadRequest(&req)
    if req.Flow.URL == "" {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    // 把数据包异步重放给 xray（此处假设 xray 监听 127.0.0.1:7777）
    replay, _ := json.Marshal(map[string]any{
        "method": req.Flow.Method, "url": req.Flow.URL,
        "headers": headersOf(req.Flow.ReqHeaders), "body": req.Flow.ReqBody,
        "proxy": "http://127.0.0.1:7777", "async": true,
    })
    _ = callIn(hostHTTPReplay, string(replay))
    app.WriteResp(map[string]any{"handled": false}) // 通知型：不拦截
}

//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() {
    app.SetTimeout(290000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Phase   string `json:"phase"`
    }
    _ = app.LoadRequest(&req)
    if req.Phase != "close" {
        app.WriteResp(map[string]any{"handled": true, "done": true})
        return
    }
    _ = callIn(hostStopTool, "") // 收尾停止本插件全部工具进程
    app.WriteResp(map[string]any{"handled": true, "done": true})
}

// collectResult 读取沙箱内结果文件（解 base64 信封）
func collectResult(rel string) string {
    raw := app.ReadFile(rel)
    var env struct {
        OK   bool   `json:"ok"`
        Data string `json:"data"`
    }
    if json.Unmarshal([]byte(raw), &env) != nil || !env.OK {
        return ""
    }
    b, _ := base64.StdEncoding.DecodeString(env.Data)
    return string(b)
}

func headersOf(raw string) map[string]string {
    out := map[string]string{}
    for _, line := range splitLines(raw) {
        if i := indexByte(line, ':'); i > 0 {
            out[trim(line[:i])] = trim(line[i+1:])
        }
    }
    return out
}

func callIn3(fn func(string, string, string, unsafe.Pointer, uint32) uint32, a, b, c string) string {
    var buf [bufSize]byte
    n := fn(a, b, c, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n > uint32(len(buf)) {
        n = uint32(len(buf))
    }
    return string(buf[:n])
}

func main() {}
```

> [!NOTE]
> The example above omits small string helpers such as `splitLines` / `trim` / `indexByte`, as well as the details of `app_free_port` (allocating a port) and `app_list_dir`/`app_tool_status` (waiting for processes and enumerating results in the drain phase); the sample plugin `plugin-exttools` provides a complete implementation whose "per-task isolated state files + drain polling + result collection" pattern you can follow directly.

---

## 12. Pitfall Checklist

| Pitfall | Symptom | How to avoid it |
|---|---|---|
| Forgetting `-buildmode=c-shared` | The host cannot `_initialize` or cannot find the export, and the plugin has no effect | The build must be `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .` |
| Missing `func main() {}` | Linking fails in c-shared mode | Keep an empty `func main() {}` |
| The export name lacks the `应用_` prefix | Capability detection fails and the hook is never called | Use `//go:wasmexport 应用_OnXxx`, letter-for-letter identical to the host constant |
| `WriteResp` does not write a `[RESP]` line | The host cannot parse a result and treats it as no response | Use the SDK's `app.WriteResp`, which emits a single `[RESP] <JSON>` line |
| Assuming `handled` takes effect in every hook | You return `handled=true` but the built-in handling still runs | Only `handled=true` from `应用_OnTcpDataReceived` skips the built-in handling |
| Calling an external tool without declaring the capability | `app_exec_tool` returns "tools.exec capability not declared" | In `应用_Init`, call `app.Capability(app.CapToolsExec)` and `DeclareTool`, then get GUI authorization |
| `Declare()` written in package initialization/global variables | `[INIT]` is not written to a given execution's stdout and the declaration is lost | Call `Declare()` inside a hook function (usually at the start of `应用_Init`) |
| Declaring only in `[INIT]` without exporting the hook | The log says "declared capability but did not export", and it is never called | The declaration is only metadata; you must really `//go:wasmexport` it |
| Using an empty slice with `TaskStartModify` to "clear" | `omitempty` drops the empty slice and nothing is modified | This model only supports replacing with non-empty values; for a clearing requirement, filter inside the plugin and return the remaining items |
| Re-injection forms a loop | The 6th level is discarded, leaving the logic incomplete or spinning | Judge the message source and avoid `ReinjectTcpData` on already-processed messages; the depth limit is 5 |
| Doing heavy synchronous work in `应用_OnTaskPacket` | The dispatch loop is blocked and scanning slows down | Be fast in and fast out; leave heavy work to `应用_OnTaskEnd`; use `async=true` of `app_http_replay` for replays |
| Result files written outside `{output_dir}` | They cannot be read during collection and the results are lost | Anchor both the tool's `dir` and the result paths to `app_task_env`'s `outputDir`; collect with `outputDirRel` |
| Using `scan_*` host functions | Instantiation fails (the import cannot be resolved) | Application plugins use only `app_*`; the two host-function sets are not interchangeable |
| Reading files outside the sandbox | It returns "path out of bounds/absolute paths not allowed" | `app_read_file`/`app_list_dir` accept only relative paths inside the plugin directory |
| Multiple instances of the same tool sharing the default key | A later-starting process kills an earlier one | Pass different `key`s to `app_spawn_tool` (`nuclei#1`/`nuclei#2`) |
| Running a long task without setting a timeout | The default 30s timeout interrupts the task | Call `app.SetTimeout(ms)` at the start of the hook; the hard limit is 5 minutes |
| Confusing `app_http_request` with `app_http_replay` | The former is rejected with "loopback only"; the latter is unsuitable for local services | Use `app_http_request` for locally started services; use `app_http_replay` to replay to external sites |
| Wrong case/camelCase in field names | `LoadRequest` succeeds but every field is empty | Compare letter by letter with the SDK's json tags (`selectedVulnIds`/`filterIds`/`request`/`message`) |

> [!IMPORTANT]
> The host functions of an application plugin and the `scan_*` of a WASM POC **do not overlap at all**: using the wrong set results in an **instantiation failure**, not "a function returning an error". Before releasing, confirm that the plugin's effective path is the "plugin store/application plugin directory" rather than the "POC template list".
