AiAgent External Tool Plugin Development
Extend the AI agent with a callable tool: tool.json manifest, exec/wasm runtimes, signing and hot reload.
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:
| Component | File | Purpose |
|---|---|---|
| Manifest | tool.json | Declares the tool name, description, entry, runtime, parameter schema, permission level and timeout |
| Signature | sig.json | The controller's Ed25519 signature over the "whole-package digest"; the only admission gate |
| Entry | The file pointed to by entry in the manifest | The program that is actually executed (may carry arbitrary same-directory dependencies such as DLLs / resources) |
Once a tool is loaded, AiAgent places it into the AI tool table as an ordinary callable tool: what the model sees is name + description (with usage appended) + parameters (JSON Schema). On a call, AiAgent runs your program as a child process (or a WASI module), writes the parameter JSON to its stdin, and returns its stdout text to the model as the observation.
One-sentence memory aid: an external tool = "one directory + one manifest + one controller signature"; the AI calls it from the tool table just like a built-in tool.
1.1 The Complete Lifecycle of One Call (Build the Big Picture First)
Before examining the fields in detail, look at the whole picture of a single call. The sequence below describes AiAgent's actual behavior when executing an external tool:
- 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;
- The model decides to call a tool and returns
tool_call(name, arguments); - AiAgent looks up the tool by name in the registry;
- Before executing, AiAgent re-verifies the signature first (TOCTOU protection);
- It serializes
arguments(a JSON object) into bytes; - It starts a child process (
exec) or instantiates a WASI module (wasm) according toruntime; - The parameter JSON is written to its stdin;
- Its stdout is read (falling back to stderr when empty);
- Timeout / output truncation is handled;
- 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:
- 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).
- Verification uses the controller public key held by the host: never trust the
userPubkeycarried insig.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. - 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.
- 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:
| Tier | Extension point | Runtime / language | Carrier | Who can deploy |
|---|---|---|---|---|
| AiAgent | External tool (this article) | exec (native executable) or wasm (WASI module) | <AiConfig>/externaltools/<toolDir>/ | Third-party developers, signed by the controller |
| Controller | Controller plugin | Native Go (compiled into the controller or dynamically loaded) | CtlConfig/ | Official / controller maintainers only |
| Scanning node | Scanning app plugin / host functions | Pure plugin route, via host functions such as T.HttpUrl | Node plugin directory | Official plugins (e.g. plugin-exttools) |
| Scanning node | WASM POC hot reload | wasm (wazero) | POC store | Delivered by users through the GUI |
Key distinctions:
- An AiAgent external tool is not a wasm plugin hook: that refers to WASM POCs / plugin host functions on the scanning node, and has nothing to do with AiAgent.
- An AiAgent external tool is not a controller plugin: controller plugins run inside the controller process, are written in Go and maintained by the official team; external tools run on AiAgent, can be executables or wasm modules compiled from any language, and are deployed by you.
- External tools keep AiAgent pure Go (
CGO_ENABLED=0): thereforedlopen-style native dynamic libraries are not supported. Go'spluginpackage requires CGo and does not support Windows, so it is not an option here either. When you need multi-file dependencies, use theexecform of "entry exe + same-directory DLLs loaded by the exe itself", or switch towasm. - Key difference from the scanning node's WASM signing mechanism: WASM plugin verification trusts the
userPubkeyembedded insig.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:
- 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; - Extract/write into the staging directory
<root>/.staging/<toolDir>-<random suffix>/; - Assemble
tool.json(GUI fields take precedence; missing items are inherited from the existingtool.jsonin the package;nameis forcibly written astoolDir;entrycan be detected automatically); - Check for paths that differ only in case; reject immediately on any conflict;
- Atomically rename into the formal directory
<root>/<toolDir>/; - Sign with the controller private key (
signSource=controller) and writesig.json; if signing fails, delete the formal directory so that no unsigned noise is left behind; - 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 manifest | Position/role |
|---|---|---|
name | "port_probe" | Top level, 1st key; tool name = LLM function name = directory name |
description | "对目标主机的单个端口…" | Top level; the model decides when to call based on it |
usage | "target 支持域名或 IP…" | Top level; appended to the tool description given to the model |
entry | "port_probe.exe" | Top level; entry relative path |
runtime | "exec" | Top level; exec or wasm |
parameters | Nested object (with type/properties/required) | Top level; parameter JSON Schema |
perm | 1 | Top level; permission level 0/1/2 |
timeoutSec | 30 | Top level; per-execution timeout (seconds) |
version | "1.0.0" | Top level; version metadata |
author | "security-team" | Top level; author metadata |
args | ["--json"] | Top level; fixed exec command-line argument array |
addTime | "2026-10-05T10:00:00+08:00" | Top level; add time (RFC3339) |
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:
nameequal toab(<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.nameinconsistent with the directory name: AiAgent detects the mismatch during installation → reports "payload manifest name does not match tool directory name" and refuses to install.nameconflicting with a built-in tool / reserved prefix (such ashttp_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
descriptionandparameters, and may easily miss optional parameters or misunderstand the return format. - Written contradicting the schema (for example,
usagesays "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
descriptionis 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/../../band 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.jsondoes not have it either, the controller auto-detects it by runtime: forwasmthe candidates areentry.wasm/main.wasm/tool.wasm/plugin.wasm; forexecthe candidates areentry.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.exebuttool.exeis 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
wasmor the aliaswasi→"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 defaultexec, which may not be the runtime you wanted. - Using
wasi: equivalent towasmand works, but it is recommended to writewasmconsistently 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,
propertieswritten 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. requiredinconsistent 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
jsontags 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
authobject 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 execution1→ high-risk notice, non-blocking2→ 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:
perm | Semantics | AiAgent behavior | GUI behavior | Suitable for |
|---|---|---|---|---|
0 | Full access | Executes automatically and directly, no interruption | No special notice | Read-only probing (reading pages, parsing, computation) |
1 | High-risk notice | Sends a warn event before execution to highlight it in red in the GUI, but does not block, then executes as usual | Receives the warn event, highlighted in red | Outbound side effects that do not change the target state (packet-sending probes) |
2 | Full user confirmation | Write operations that change the target state require manual GUI confirmation before execution; unattended tasks are handled per UnattendedPolicy | Pops up a manual confirmation | Write operations / exploitation |
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 perUnattendedPolicyand 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 to2/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→ default60;0 < sec < 5→ clamped to5;sec > 600→ clamped to600;- 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
0or 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,
execreturns "external tool execution timed out (%ds), terminated." and appends stderr;wasmreturns "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
versionwithout re-signing: the content oftool.jsonchanges → the package digest changes → the signature becomes invalid → calls report "signature verification failed". (This applies to any field change, not justversion.)
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:argsis 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
argsand 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
argswithruntime: wasm: it has no effect. The module'sargv[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.jsonback during a rescan,addTimeis 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.json | Position/role |
|---|---|---|
toolName | "port_probe" | Top level; redundant tool name, for easier manual cross-checking |
packageHash | "bf3c6f85…2140" | Top level; package digest sha256 hex (participates in verification) |
userPubkey | base64 public key | Top level; issuer public key (recorded only, not used for verification) |
userSignature | base64 signature | Top level; the controller private key's Ed25519 signature over the digest (participates in verification) |
signStatus | "verified" | Top level; verified/verify_failed/unsigned |
signSource | "controller" | Top level; controller/manual |
fileCount | 4 | Top level; number of files covered by the digest |
signTime | "2026-10-05T10:00:00+08:00" | Top level; signing time (RFC3339) |
signNote | "GUI 下发外部工具自动签名" | Top level; note about the signing scenario |
signPassState | "" | Top level; note about the authorization method (never records the passphrase itself) |
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
userPubkeyintosig.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.Verifyfails →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
signStatustoverified: useless. The status is recomputed on the spot; averify_failedtool is not admitted just because this field saysverified. - 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: asig.jsondeployed by hand.
Example:
{
"signSource": "controller"
}
Consequences of getting it wrong:
- A hand-deployed tool labeled
manualbut not verifiable with the controller public key: it is still judgedverify_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 modifyingsig.jsondoes 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):
- Walk all files in the directory (skip directories, skip
sig.json); - Convert each file's relative path to slash-separated form and run the path security check;
- Sort globally by the relative path string in ascending byte order;
- For each file: write the
relative path+"\n", then writesha256hex(file content)+"\n"; - Run one SHA-256 over the whole written stream to get a 32-byte digest;
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.jsonitself 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:
| Situation | Status | Result |
|---|---|---|
No sig.json, or userSignature is empty | unsigned | Not registered; the user must authorize in the GUI and the controller re-signs |
| A signature exists but the host has no controller public key yet | verify_failed | Not admitted (better unavailable than admitted without credentials) |
packageHash does not match the actual digest | verify_failed | Files in the directory were added/removed/changed (typically: an extra DLL was dropped in) |
userSignature fails to verify the digest | verify_failed | Not issued by this machine's controller key (self-signing is rejected here) |
| The digest matches and the signature verifies | verified | Can be registered for AI calls |
5.14 Handling Paths for the Three Branches
verified: registered normally; the model can call it.verify_failed: rejected; usually "changed after loading" or "not issued by this machine's controller key". Re-deliver it or use GUI authorization to re-sign.unsigned: requires explicit user authorization. After authorization the controller re-signs:- If the tool is local to the controller: the controller directly recomputes the digest over the directory and signs it, then broadcasts to all AiAgents;
- If the tool is on a remote AiAgent: the controller cannot read the bytes, so the node first reports
(toolDir, packageHash), and after the passphrase check passes the controller issues and delivers a triple of "digest signature + authorization token + expiry time" (commandAiExternalToolSignGrant). The node writessig.jsononly after all three steps pass:- The authorization token was issued by the controller for this
toolDir+ this digest + this expiry time and has not expired (the token payload isTestSecScan-ExtTool-Sign-v1|<toolDir>|<hash>|<expire>, identical word for word on both sides); - The locally recomputed digest must match the
packageHashin the authorization (to prevent files from being replaced after authorization); - The signature verifies against the digest.
- The authorization token was issued by the controller for this
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:
- 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 asunsigned, and once the GUI starts it aggregates unsigned items and, after you enter the signing passphrase (at least 6 characters), the controller re-signs.
- Delivery: upload the tool package (single file or zip); the controller extracts it → writes
- 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):
- Delete
sig.jsonfirst from the tool directory (it does not participate in the digest, but this avoids confusion); - Use
digest(dir)above to compute the hex; - Compare it with
packageHashinsig.json(EqualFold, case-insensitive); - If they differ, the directory content changed after packaging — common causes: one extra file,
tool.jsonreformatted by an editor, line endings changed from LF to CRLF, or hidden files added; - 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:
| Runtime | runtime value | Execution method | Suitable scenario |
|---|---|---|---|
| Native executable | exec | The entry file is executed as a child process | Native programs compiled from any language; multi-DLL tools |
| WASI module | wasm | wazero loads a WASI command module | Tools that want no platform dependency and sandboxing |
6.1 Unified ABI: stdin In / stdout Out
Both runtimes share the same ABI (the stdin/stdout protocol idea is identical); you do not need to learn a new host-function set:
- Input: AiAgent serializes the parameter object into JSON and writes it to your process/module's stdin. If the model gave no parameters (or serialization failed), the host writes
{}. - Output: write the result text to stdout; AiAgent captures it and returns it to the model as the observation.
- Fallback: in
exec, if stdout is blank while stderr is not empty, the content of stderr is used instead (some tools are used to writing results to stderr, and the host does not discard it); the same applies towasm(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
argsfrom 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 (theargsfield 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: default60seconds, range[5, 600];<=0takes the default,<5is clamped to5,>600is clamped to600. - 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)
- 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. - 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_failedand 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.
- AiAgent startup (at this point the in-memory public key is empty → signed tools are temporarily judged
- Read manifest + compute digest + verify signature: for each directory: read
tool.json(if missing, recorderror="缺少 tool.json") → compute the package digest → verify with the controller public key (theuserPubkeycarried insig.jsonis not trusted; when the host does not yet have the controller public key, everything is judgedverify_failed). - Only
verifiedenters the registry: registration requires all three — the signature status isverifiedand the manifest exists and there is no error andentryis non-empty, safe, inside the directory and actually present. Otherwise it only appears in the on-disk reality list and is not registered. - 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
permlevel; - the entry carries
external=trueandtoolDir; - the call closure captures the tool directory and manifest, and goes through pre-call re-verification.
- tool name = the normalized
Tool names are sorted lexicographically to keep the order of the tool table the model sees stable.
- 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.
permaffects confirmation:0executes automatically;1sends a warn event with red highlighting first but does not block;2requires manual GUI confirmation (unattended handled perUnattendedPolicy).- Sequence during a model call:
- the model returns
tool_call(name, arguments); - 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;
- check that the
entrypath is safe and the file exists; - serialize
argsinto JSON (write{}on failure/empty); - start a child process (
exec) or instantiate wasm (wasm); - write the parameter JSON to stdin → read stdout (fall back to stderr if empty);
- timeout / output truncation;
- return the output to the model.
- If the signature has become invalid at call time, it returns "external tool [name] signature verification failed (status), execution refused. The tool may have been modified after loading; please re-deliver it or authorize signing in the GUI." and logs a warn.
- the model returns
- 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
verifieddirectories enter the model-visible tool table; - Call: the model calls a tool by name → re-verify the signature before execution → start a child process or wasm module per
runtime→ stdin in / stdout out → handle timeout and output truncation; - Report: after actions such as install, delete, key change, re-signing and refresh, report the on-disk reality list to the controller and GUI.
7.3 AiAgent-Side Command Handling
| Command | Direction | AiAgent behavior |
|---|---|---|
AiExternalToolInstall | Controller→AiAgent | Install the tool package → reply AiExternalToolInstallAck + report the list |
AiExternalToolDelete | Controller→AiAgent | Delete the tool → receipt + report |
AiExternalToolSetKey | Controller→AiAgent | Update the controller public key in memory → rescan and report |
AiExternalToolSignGrant | Controller→AiAgent | Three-step re-signing validation → write sig.json → rescan and report |
AiExternalToolQuery | Controller→AiAgent | Rescan + report the list |
| Proactive list reporting | AiAgent→Controller | Report the list (the GUI unsigned warning depends on it, so the GUI can see "possibly a malicious tool") |
AiExternalToolInstallAck | AiAgent→Controller | Install/delete receipt (the controller forwards it to the GUI) |
After every handling, the list is reported once so that what the controller/GUI shows stays consistent with the node's actual state.
7.4 Key Difference: Registry vs On-Disk Reality
| View | Source | Includes unsigned items? | Purpose |
|---|---|---|---|
| AI tool registry (model-visible) | The in-memory set of loaded tools | No (only verified) | Model function calling |
| Reported/directory list | The on-disk reality obtained by rescanning | Yes | GUI display of signature status, unsigned warnings |
| Skill library merged report | Registry entries + on-disk reality, deduplicated | Yes | Skill library page display |
In the merged report, the signature status of registry entries is always verified ("anything that enters the registry must have passed verification"); only the on-disk reality list may contain unsigned / verify_failed. External tool entry fields: external=true, toolDir, signStatus, runtime, usage.
8. Hot Reload
AiAgent rescans the root directory and rebuilds the in-memory registry and the on-disk reality list, without restarting AiAgent.
Triggers:
| Trigger | Behavior |
|---|---|
| AiAgent startup | Rescan (at this point the in-memory public key is empty → signed tools are temporarily judged verify_failed and not registered) |
| Install/overwrite a tool package | Rescan automatically |
| Delete a tool | Rescan automatically (idempotent: succeeds even if the directory does not exist) |
| Controller delivers the signing public key | Rescan automatically (after a key change, old signatures are immediately re-judged verify_failed) |
| Authorized re-signing | Rescan automatically |
| Controller requests a refresh | Rescan + report the list (AiExternalToolQuery) |
"Change code → re-verify → reload" is an intentional flow: the signature covers the whole directory, so changing any file in the package (including recompiling the entry) changes the digest and invalidates the signature. This is not a bug but a necessary consequence of the security model — anything modified must be re-signed to be trusted. During development, after changing the code just go through delivery or re-signing again; restarting AiAgent is not necessary.
9. Limits and Quotas
| Item | Limit | Notes |
|---|---|---|
| Tool package (zip) size | 64MB | Before delivery there is also a circuit breaker on the base64 size (the controller throttles at 96MB) |
| Single file size | 32MB | Extraction reads with "limit + 1" so that exceeding the limit raises an error instead of silently truncating |
| File count | 512 | File count in a directory / zip |
| Output returned to the model | 64KB | Excess is truncated and \n...[输出过长已截断] is appended |
| Tool name length | 64 | Also the limit for the model-side function name |
| Default timeout | 60s | Can be overridden by timeoutSec, range [5,600] |
| Authorization token validity | 10 minutes | Expires and requires re-authorization |
| Minimum signing passphrase length | 6 characters | Controller side only, used for automatic signing authorization |
| Authorization token signing scope | TestSecScan-ExtTool-Sign-v1 | Token payload scope|toolDir|hash|expire |
Path security: relative paths inside the package must pass the security check — empty paths, ., .., a leading / or \, absolute paths, drive-letter prefixes (C:/x), and a/../../b escapes are rejected. Extraction has a second gate: it verifies that the actual joined destination is still inside the target directory and guards against Zip Slip.
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
| Layer | Location | What to look at |
|---|---|---|
| AiAgent log | AiAgent process log | Scan summary [AiAgent] external tool scan: %d dir(s), %d registered (signature verified); install installed (name=… runtime=… entry=…); delete; re-signing signed by controller grant (packageHash=…); call refused refused at call time: signature status=%s; public key sync controller signing pubkey synced |
| GUI external tool list | GUI "AI pentest agent → External tool invocation" tab | Each tool's signStatus (verified/verify_failed/unsigned), packageHash, fileCount, runtime, entry, error; controller-local items and items reported by each online AiAgent; unsigned/verifyFailed aggregate warnings; signPub, keyIntegrity |
| Controller delivery result receipts | Return values of GUI delivery/delete/authorization | Delivery result (success/toolDir/packageHash/fileCount/agents/message); node install receipt (success/signStatus/error); delete result; automatic signing result (signed/failed/results[]) |
11.2 Five-Minute Minimal Verification Flow
- Write the tool: create a directory
<toolDir>/, writetool.jsonandmain.go(you can copy the minimal example from Chapter 6); - Build: for
execuseCGO_ENABLED=0 go build -o <entry> .; forwasmuseGOOS=wasip1 GOARCH=wasm go build -o tool.wasm .; - Self-test the signature: compute
packageHashlocally with the algorithm in 5.12 and compare it with thesig.jsonthe controller writes later, to confirm that packaging did not change the content; - Put it in the directory: copy it into
<AiConfig>/externaltools/<toolDir>/(remote node) or use GUI delivery (recommended, signs automatically); - Trigger reload/query: click refresh in the GUI, or have the controller send
AiExternalToolQuery; - Check the signature status in the GUI: confirm the item is
verified; ifunsigned, enter the signing passphrase to authorize re-signing; ifverify_failed, look aterror; - Have the AI call it once: in an AI session, have the model call the tool (or construct a call directly);
- Check the return: see whether the observation the model receives is your stdout; if it reports "signature verification failed", the files were changed after loading.
11.3 "Symptom → Cause → Solution" Quick Reference
| # | Symptom | Cause | Solution |
|---|---|---|---|
| 1 | The tool is invisible in the AI tool table | Verification did not pass (unsigned/verify_failed), tool.json missing, entry does not exist, illegal tool name | Check the signature status and error in the GUI; re-sign or fix the manifest/entry |
| 2 | sig.json exists but it is still not registered / is rejected | Self-signed public keys are not trusted: verification uses only the controller public key; the userPubkey in sig.json is ignored | Use controller delivery or GUI passphrase-authorized re-signing; do not generate your own key |
| 3 | Visible after installation, but every call is refused | Changed after loading (TOCTOU): pre-call re-verification finds a digest mismatch | Re-deliver or authorize re-signing; check whether someone dropped a DLL into the directory |
| 4 | Reports "entry file does not exist: X" | The file pointed to by entry is not in the package / was deleted / the path is wrong | Check entry against the actual file; subdirectories need the path (bin/tool.exe) |
| 5 | Reports "paths that differ only in case" | bin/Lib.dll and bin/lib.dll coexist in the package and overwrite each other when extracting on Windows | Rename the conflicting files and repackage |
| 6 | Reports "tool package exceeds the 64MB limit" / "file exceeds 32MB" / "file count exceeds the limit of 512" | The corresponding quota is exceeded (see Chapter 9) | Trim dependencies, separate large resources, or split the tool |
| 7 | Execution is terminated with "execution timed out (60s)" | timeoutSec exceeded (default 60, range 5~600) | Raise timeoutSec (≤600) or optimize the tool; exec terminates the process, wasm closes the context |
| 8 | The result seems lost or garbled | The result was written to stderr | Write results to stdout; when stdout is empty the host falls back to stderr, but stdout takes precedence, so explicitly use stdout |
| 9 | Output truncated with "output too long, truncated" appended | The returned content exceeds 64KB (see the quota table in Chapter 9) | Return only key conclusions (structured JSON / one-line summary); write large logs to files instead of dumping them to stdout |
| 10 | The child process cannot find a DLL/resource/config | The misconception that the working directory is not the tool directory | The exec working directory is fixed to the tool directory; relative paths work; put DLLs/resources in the same directory as the entry |
| 11 | The model probes repeatedly / stops at every step | perm is set too high (2 = full confirmation, or a read-only tool wrongly set to 2) | Change read-only probes to perm: 0; use 1 for side effects that do not change the target state |
| 12 | The model passes parameters randomly / omits required ones | parameters is missing, or the schema does not match the fields the code reads | Complete parameters (including required) and align field names with the code's json tags |
| 13 | A wasm call reports "failed to compile wasm" | runtime is wrong (a native program treated as wasm), or the wasm target is not a WASI command module | Build with GOOS=wasip1 GOARCH=wasm; write exec for native tools |
| 14 | wasm cannot read resources | Only /tool is mounted | Put resources in the tool directory and access them via /tool/...; do not access other paths |
| 15 | On Linux, exec reports permission denied | Extraction writes mode 0644 (no executable bit by default) | Check the entry permissions; switch to wasm if necessary; Windows does not have this problem |
| 16 | Delivery rejected with "tool name conflicts with a built-in tool or reserved prefix" | The name hits a reserved name/prefix | Rename to avoid finish/report_vuln/http_request/builtin_* and the prefixes browser_/file_/audit_/oob_/exttool_ |
| 17 | Delivery/installation rejected with "manifest name does not match directory name" | The name in tool.json differs from <toolDir> | The two must match; controller delivery automatically writes name as toolDir |
| 18 | All tools suddenly become verify_failed | Old signatures became invalid after the controller changed its key, or the node does not yet have the controller public key | Re-sign; confirm that the controller has connected and delivered the public key (AiExternalToolSetKey) |
11.4 Simulate a Call by Hand from the Command Line
You can verify the tool's ABI without starting an AI session:
# 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) orecho %errorlevel%(Windows) — a non-zero code is treated as an execution failure by the host; - wasm tools cannot use this command (they need a WASI runner, such as
wasmtime run --dir .::/tool tool.wasm); for routine verification it is recommended to call it directly through AiAgent; - This command does not go through signature verification; it only verifies the ABI (parameters in, results out, exit code, output size and elapsed time). For signature problems, check the status in the GUI.
12. Checklist of Pitfalls
| Pitfall | Symptom | Avoidance |
|---|---|---|
Forgetting to write the entry relative path | The controller/node reports "entry file does not exist" or "entry not declared" | Use a slash-separated relative path for entry, such as bin/tool.exe, tool.wasm; subdirectories must be included |
Wrong runtime | A .wasm file is executed as exec, or a .exe fails to compile as wasm | Use wasm for WASI modules and exec for native programs; an empty value defaults to exec, and wasi normalizes to wasm |
| Not signed / self-signed | The tool does not enter the tool table (unsigned), or self-signing is judged verify_failed | Use controller delivery or GUI passphrase-authorized re-signing; do not generate your own key to self-sign |
| Paths in the package that differ only in case | The node reports "verification failed" (actually a cross-platform overwrite) | Rename the conflicting files and repackage (bin/Lib.dll and bin/lib.dll cannot coexist) |
| Output goes to stderr | The result seems lost | Write results to stdout; when stdout is empty the host falls back to stderr, but explicitly using stdout is still recommended |
| Returned content is too long | Truncated (64KB) | Return only key conclusions; avoid dumping full responses/logs |
Improper perm setting | A read-only tool set to 2 → the AI repeatedly stops for confirmation; a write tool set to 0 → dangerous actions execute silently | Choose 0/1/2 according to side effects; external tools default to the most conservative 2 |
| Dependent DLLs and the working directory | DLL loading fails | Put dependencies in the same directory as the entry; the exec working directory is the tool directory, so reference them by relative path |
Mixing args with stdin | The tool reads both command-line arguments and stdin, and arguments get misaligned | args are fixed command-line arguments; dynamic parameters are read only from the JSON on stdin; do not stuff dynamic parameters into args |
| Continuing to use an old signature after changing files | Calls report "signature verification failed" | After changing code/resources you must re-sign: re-deliver or re-sign, then hot reload |
| Tool name conflicts with a built-in/reserved name | Delivery rejected | Avoid finish/report_vuln/http_request/builtin_* and the prefixes browser_, file_, audit_, oob_, exttool_ |
Manifest name differs from the directory name | Installation rejected | The two must match; when generating tool.json on controller delivery, name is written as toolDir |
| Depending on CGo / native dynamic libraries | Cannot load | AiAgent is CGO_ENABLED=0 and does not support dlopen; for multiple dependencies use "exe + same-directory DLLs" or switch to wasm |
description is empty | Delivery/installation rejected with "tool description must not be empty" | Write one complete description from which the trigger condition can be judged |
parameters inconsistent with the code fields | The tool cannot read the values passed by the model | Align schema field names word for word with the code's json tags; list only what is truly required in required |
Writing a passphrase into signPassState | Serious security problem | This field holds only a state description; never write the passphrase; the signing passphrase is kept separately by the controller and never stored in plaintext |
Related documents: Plugin Development Overview, Controller Plugin Development, Application Plugin Development, WASM POC Template Development, Go Hot-Load POC Development.