AiAgent External Tool Plugin Development

Extend the AI agent with a callable tool: tool.json manifest, exec/wasm runtimes, signing and hot reload.

最后更新 2026-10-05

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

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

重要

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


1. What It Is

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

One external tool = one directory containing three things:

ComponentFilePurpose
Manifesttool.jsonDeclares the tool name, description, entry, runtime, parameter schema, permission level and timeout
Signaturesig.jsonThe controller's Ed25519 signature over the "whole-package digest"; the only admission gate
EntryThe file pointed to by entry in the manifestThe program that is actually executed (may carry arbitrary same-directory dependencies such as DLLs / resources)

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

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

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

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

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

Every chapter below returns to one link in this chain.

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

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

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

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

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

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

说明

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


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

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

TierExtension pointRuntime / languageCarrierWho can deploy
AiAgentExternal tool (this article)exec (native executable) or wasm (WASI module)<AiConfig>/externaltools/<toolDir>/Third-party developers, signed by the controller
ControllerController pluginNative Go (compiled into the controller or dynamically loaded)CtlConfig/Official / controller maintainers only
Scanning nodeScanning app plugin / host functionsPure plugin route, via host functions such as T.HttpUrlNode plugin directoryOfficial plugins (e.g. plugin-exttools)
Scanning nodeWASM POC hot reloadwasm (wazero)POC storeDelivered by users through the GUI

Key distinctions:

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

3. Directory Structure and Installation Location

3.1 Installation Location

The external tool root directory on the AiAgent side:

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

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

3.2 Directory Naming Rules

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

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

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

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

注意

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

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

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

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

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

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

重要

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


4. tool.json Manifest Fields in Detail

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

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

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

ExtToolManifest

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

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

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

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

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

4.1 name

name

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

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

What to pass: the normalization rules are as follows:

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

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

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

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

Consequences of getting it wrong:

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

4.2 description

description

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

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

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

Example:

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

Consequences of getting it wrong:

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

4.3 usage

usage

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

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

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

Example:

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

Consequences of getting it wrong:

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

4.4 entry

entry

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

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

What to pass:

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

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

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

Consequences of getting it wrong:

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

4.5 runtime

runtime

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

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

What to pass: normalization rules:

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

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

Example:

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

Consequences of getting it wrong:

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

4.6 parameters

parameters

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

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

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

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

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

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

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

Consequences of getting it wrong:

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

4.7 perm

perm

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

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

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

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

Example:

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

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

The concrete semantics and behavior of the three levels:

permSemanticsAiAgent behaviorGUI behaviorSuitable for
0Full accessExecutes automatically and directly, no interruptionNo special noticeRead-only probing (reading pages, parsing, computation)
1High-risk noticeSends a warn event before execution to highlight it in red in the GUI, but does not block, then executes as usualReceives the warn event, highlighted in redOutbound side effects that do not change the target state (packet-sending probes)
2Full user confirmationWrite operations that change the target state require manual GUI confirmation before execution; unattended tasks are handled per UnattendedPolicyPops up a manual confirmationWrite operations / exploitation
提示

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

Consequences of getting it wrong:

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

4.8 timeoutSec

timeoutSec

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

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

What to pass: normalization rules:

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

The usable range is [5, 600].

Example:

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

Consequences of getting it wrong:

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

4.9 version

version

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

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

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

Example:

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

Consequences of getting it wrong:

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

4.10 author

author

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

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

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

Example:

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

Consequences of getting it wrong:

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

4.11 args

args

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

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

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

Example:

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

Consequences of getting it wrong:

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

4.12 addTime

addTime

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

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

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

Example:

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

Consequences of getting it wrong:

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

4.13 Complete tool.json Example

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

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

5. sig.json Signature File Fields in Detail

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

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

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

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

ExtToolSigInfo

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

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

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

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

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

5.1 toolName

toolName

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

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

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

Example:

{
  "toolName": "port_probe"
}

Consequences of getting it wrong:

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

5.2 packageHash

packageHash

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

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

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

Example:

{
  "packageHash": "bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140"
}

Consequences of getting it wrong:

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

5.3 userPubkey

userPubkey

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

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

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

Example:

{
  "userPubkey": "b3J5cHRvLWVkMjU1MTktcHVibGljLWtleS1iYXNlNjQ9="
}

Consequences of getting it wrong:

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

5.4 userSignature

userSignature

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

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

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

Example:

{
  "userSignature": "l7Yq3m...(base64,64 字节签名,通常 88 字符)"
}

Consequences of getting it wrong:

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

5.5 signStatus

signStatus

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

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

What to pass: one of three states:

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

Example:

{
  "signStatus": "verified"
}

Consequences of getting it wrong:

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

5.6 signSource

signSource

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

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

What to pass: one of two values:

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

Example:

{
  "signSource": "controller"
}

Consequences of getting it wrong:

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

5.7 fileCount

fileCount

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

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

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

Example:

{
  "fileCount": 4
}

Consequences of getting it wrong:

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

5.8 signTime

signTime

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

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

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

Example:

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

Consequences of getting it wrong:

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

5.9 signNote

signNote

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

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

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

Example:

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

Consequences of getting it wrong:

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

5.10 signPassState

signPassState

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

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

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

Example:

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

Consequences of getting it wrong:

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

5.11 Complete sig.json Example

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

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

5.12 The Exact Whole-Package Digest Algorithm

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

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

Pseudocode:

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

Boundary cases:

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

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

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

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

The correct digest (fileCount = 4):

bf3c6f850ea7fd2deeddd647f9d17911e370a3cb81c49caf43fa8286c6f12140

5.13 Whose Public Key Is Used for Verification

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

SituationStatusResult
No sig.json, or userSignature is emptyunsignedNot registered; the user must authorize in the GUI and the controller re-signs
A signature exists but the host has no controller public key yetverify_failedNot admitted (better unavailable than admitted without credentials)
packageHash does not match the actual digestverify_failedFiles in the directory were added/removed/changed (typically: an extra DLL was dropped in)
userSignature fails to verify the digestverify_failedNot issued by this machine's controller key (self-signing is rejected here)
The digest matches and the signature verifiesverifiedCan be registered for AI calls

5.14 Handling Paths for the Three Branches

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

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

5.15 How Developers Can Self-Test Signing

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

  1. Official flow (recommended): in a development environment with a controller installed, open the GUI "AI pentest agent → External tool invocation" tab:
    • Delivery: upload the tool package (single file or zip); the controller extracts it → writes tool.json → signs automatically on delivery → broadcasts to all online AiAgents;
    • Re-signing: copy the tool directory directly into AiAgent's <AiConfig>/externaltools/<toolDir>/; after a scan AiAgent reports it as unsigned, and once the GUI starts it aggregates unsigned items and, after you enter the signing passphrase (at least 6 characters), the controller re-signs.
  2. Check the package digest yourself: whether a signature is self-consistent starts with whether the digest is right. Compute it locally with an algorithm that is word-for-word identical on both sides:
// 与两侧摘要算法逐字节一致
func digest(dir string) (string, int, error) {
	type e struct{ rel, path string }
	var es []e
	err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return err
		}
		rel, _ := filepath.Rel(dir, p)
		rel = filepath.ToSlash(rel)
		if rel == "sig.json" { // 签名文件自身不参与
			return nil
		}
		es = append(es, e{rel: rel, path: p})
		return nil
	})
	if err != nil {
		return "", 0, err
	}
	sort.Slice(es, func(i, j int) bool { return es[i].rel < es[j].rel }) // 全局排序承重!
	h := sha256.New()
	for _, it := range es {
		data, _ := os.ReadFile(it.path)
		sum := sha256.Sum256(data)
		h.Write([]byte(it.rel)); h.Write([]byte("\n"))
		h.Write([]byte(hex.EncodeToString(sum[:]))); h.Write([]byte("\n"))
	}
	return hex.EncodeToString(h.Sum(nil)), len(es), nil
}

Self-test steps (step by step):

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

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


6. Runtime ABI Deep Dive

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

Runtimeruntime valueExecution methodSuitable scenario
Native executableexecThe entry file is executed as a child processNative programs compiled from any language; multi-DLL tools
WASI modulewasmwazero loads a WASI command moduleTools that want no platform dependency and sandboxing

6.1 Unified ABI: stdin In / stdout Out

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

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

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

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

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

6.2 exec Details

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

6.3 wasm Details

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

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

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

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

6.4 Timeout and Output Truncation

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

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

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

Directory layout:

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

Full main.go:

package main

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

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

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

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

Full tool.json:

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

Build commands:

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

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

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

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

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

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

Directory layout:

res_reader/
├── tool.json
├── tool.wasm
└── note.txt          # 随包资源(演示 /tool 挂载读取)

Full main.go:

package main

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

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

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

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

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

Full tool.json:

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

Build command:

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

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

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


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

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

7.1 The Complete Chain (Step by Step)

  1. Directory location: the tool lives in <AiConfig>/externaltools/<toolDir>/. <toolDir> must satisfy the naming rules (starts with a lowercase letter, 3~64, [a-z0-9_]). Directories starting with . (such as .staging) are skipped by scanning.
  2. Rescan triggers: any of the following occasions rescans the directory and rebuilds both the in-memory registry and the on-disk reality list (no AiAgent restart needed):
    • AiAgent startup (at this point the in-memory public key is empty → signed tools are temporarily judged verify_failed and not registered);
    • installing/overwriting a tool package;
    • deleting a tool (idempotent: succeeds even if the directory does not exist);
    • the controller delivering the signing public key (after a key change, old signatures are immediately re-judged verify_failed);
    • authorized re-signing;
    • the controller requesting a refresh (AiExternalToolQuery) → rescan + report the list.
  3. Read manifest + compute digest + verify signature: for each directory: read tool.json (if missing, record error="缺少 tool.json") → compute the package digest → verify with the controller public key (the userPubkey carried in sig.json is not trusted; when the host does not yet have the controller public key, everything is judged verify_failed).
  4. Only verified enters the registry: registration requires all three — the signature status is verified and the manifest exists and there is no error and entry is non-empty, safe, inside the directory and actually present. Otherwise it only appears in the on-disk reality list and is not registered.
  5. Turn it into a model-visible tool: when a session is created, AiAgent converts each loaded tool into a model-callable tool entry:
    • tool name = the normalized name;
    • description = description + optional 【使用方法】 + 【参数】… + 【返回】… + 【运行时】… for wasm;
    • parameters = your schema (a permissive object if none);
    • permission = the normalized perm level;
    • the entry carries external=true and toolDir;
    • the call closure captures the tool directory and manifest, and goes through pre-call re-verification.

Tool names are sorted lexicographically to keep the order of the tool table the model sees stable.

  1. Model-side presentation: AiAgent converts tool name / description / parameters into OpenAI tools format. External tools are not filtered by the skill whitelist — the skill library cannot possibly know in advance the names of user-built tools, and filtering them out would make the feature invisible.
  2. perm affects confirmation: 0 executes automatically; 1 sends a warn event with red highlighting first but does not block; 2 requires manual GUI confirmation (unattended handled per UnattendedPolicy).
  3. Sequence during a model call:
    1. the model returns tool_call(name, arguments);
    2. verify the signature once more before execution (still with the controller public key held by the host) — this is the TOCTOU protection; anything changed after loading is intercepted here;
    3. check that the entry path is safe and the file exists;
    4. serialize args into JSON (write {} on failure/empty);
    5. start a child process (exec) or instantiate wasm (wasm);
    6. write the parameter JSON to stdin → read stdout (fall back to stderr if empty);
    7. timeout / output truncation;
    8. return the output to the model.
    9. If the signature has become invalid at call time, it returns "external tool [name] signature verification failed (status), execution refused. The tool may have been modified after loading; please re-deliver it or authorize signing in the GUI." and logs a warn.
  4. Invalid signature after file changes → an intentional "re-sign + reload" flow is required: the signature covers the whole directory, so changing any file in the package (including recompiling the entry) changes the digest and invalidates the signature. This is not a bug but a necessary consequence of the security model. The correct handling: go through delivery again (the controller signs automatically and broadcasts; nodes reinstall and rescan) or GUI-authorized re-signing (after authorization the controller re-signs → the node writes the file and rescans). No AiAgent restart is needed.

7.2 Behavior Overview: Install → Load → Call → Report

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

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

7.3 AiAgent-Side Command Handling

CommandDirectionAiAgent behavior
AiExternalToolInstallController→AiAgentInstall the tool package → reply AiExternalToolInstallAck + report the list
AiExternalToolDeleteController→AiAgentDelete the tool → receipt + report
AiExternalToolSetKeyController→AiAgentUpdate the controller public key in memory → rescan and report
AiExternalToolSignGrantController→AiAgentThree-step re-signing validation → write sig.json → rescan and report
AiExternalToolQueryController→AiAgentRescan + report the list
Proactive list reportingAiAgent→ControllerReport the list (the GUI unsigned warning depends on it, so the GUI can see "possibly a malicious tool")
AiExternalToolInstallAckAiAgent→ControllerInstall/delete receipt (the controller forwards it to the GUI)

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

7.4 Key Difference: Registry vs On-Disk Reality

ViewSourceIncludes unsigned items?Purpose
AI tool registry (model-visible)The in-memory set of loaded toolsNo (only verified)Model function calling
Reported/directory listThe on-disk reality obtained by rescanningYesGUI display of signature status, unsigned warnings
Skill library merged reportRegistry entries + on-disk reality, deduplicatedYesSkill library page display
说明

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


8. Hot Reload

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

Triggers:

TriggerBehavior
AiAgent startupRescan (at this point the in-memory public key is empty → signed tools are temporarily judged verify_failed and not registered)
Install/overwrite a tool packageRescan automatically
Delete a toolRescan automatically (idempotent: succeeds even if the directory does not exist)
Controller delivers the signing public keyRescan automatically (after a key change, old signatures are immediately re-judged verify_failed)
Authorized re-signingRescan automatically
Controller requests a refreshRescan + report the list (AiExternalToolQuery)
重要

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


9. Limits and Quotas

ItemLimitNotes
Tool package (zip) size64MBBefore delivery there is also a circuit breaker on the base64 size (the controller throttles at 96MB)
Single file size32MBExtraction reads with "limit + 1" so that exceeding the limit raises an error instead of silently truncating
File count512File count in a directory / zip
Output returned to the model64KBExcess is truncated and \n...[输出过长已截断] is appended
Tool name length64Also the limit for the model-side function name
Default timeout60sCan be overridden by timeoutSec, range [5,600]
Authorization token validity10 minutesExpires and requires re-authorization
Minimum signing passphrase length6 charactersController side only, used for automatic signing authorization
Authorization token signing scopeTestSecScan-ExtTool-Sign-v1Token payload scope|toolDir|hash|expire

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

警告

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


10. Complete Examples

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

10.1 Example 1: Native Go Executable Tool (exec)

Directory layout

port_probe/
└── tool.json          # 清单
    port_probe.exe     # 入口(编译产物)

main.go

package main

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

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

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

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

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

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

tool.json

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

Build commands

# Windows(在包目录内执行)
set CGO_ENABLED=0
go build -o port_probe.exe .

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

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

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

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

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

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

10.2 Example 2: Go WASI wasm Tool (wasm)

Directory layout

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

main.go

package main

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

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

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

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

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

tool.json

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

Build command

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

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

How the model will call it:

{
  "cidr": "10.0.0.0/24"
}

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

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

提示

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

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

Directory layout

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

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

package main

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

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

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

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

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

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

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

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

Build command

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

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

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

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

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

What you should output: stdout returns 上传完成:HTTP 200(marker=testsecscan-upload-check).

警告

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


11. Debugging Handbook

11.1 Where to Look at the Three Kinds of Logs

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

11.2 Five-Minute Minimal Verification Flow

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

11.3 "Symptom → Cause → Solution" Quick Reference

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

11.4 Simulate a Call by Hand from the Command Line

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

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

# 期望输出(stdout)
# {"sha256":"2cf24dba...","length":5}

Key points:

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

12. Checklist of Pitfalls

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

Related documents: Plugin Development Overview, Controller Plugin Development, Application Plugin Development, WASM POC Template Development, Go Hot-Load POC Development.

本文档随 SDK 源码同步更新;新增或修改公开接口后会同步到此页。