@armadra/agent 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/CHANGELOG.zh-CN.md +18 -0
- package/README.md +5 -0
- package/README.zh-CN.md +4 -0
- package/dist/bundle/ama.cjs +1606 -423
- package/dist/cli/compose.d.ts +3 -0
- package/dist/cli/compose.js +1 -0
- package/dist/drivers/agents.js +1 -1
- package/dist/drivers/host-runners.d.ts +26 -1
- package/dist/drivers/host-runners.js +147 -0
- package/dist/hooks/matcher.d.ts +2 -2
- package/dist/hooks/matcher.js +3 -3
- package/dist/host/api-impl.d.ts +3 -0
- package/dist/host/api-impl.js +7 -2
- package/dist/host/types.d.ts +37 -1
- package/dist/host/types.js +5 -0
- package/dist/host.d.ts +1 -1
- package/dist/host.js +1 -1
- package/dist/i18n/catalog.d.ts +5 -0
- package/dist/i18n/messages/approval.d.ts +5 -0
- package/dist/i18n/messages/approval.js +5 -0
- package/dist/i18n/messages/drivers.d.ts +2 -0
- package/dist/i18n/messages/drivers.js +2 -0
- package/dist/i18n/messages/interactive-view.d.ts +5 -0
- package/dist/i18n/messages/interactive-view.js +5 -0
- package/dist/i18n/messages/interactive.d.ts +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/modes/commands-core.d.ts +7 -0
- package/dist/modes/commands-core.js +23 -5
- package/dist/modes/interactive/approval-dialog.js +17 -4
- package/dist/modes/interactive/commands.d.ts +2 -0
- package/dist/modes/interactive/commands.js +5 -1
- package/dist/modes/interactive/interactive-mode.js +2 -2
- package/dist/modes/interactive/tool-summary.d.ts +2 -0
- package/dist/modes/interactive/tool-summary.js +27 -0
- package/dist/modes/rpc/commands.js +4 -4
- package/dist/permissions/classifier.js +1 -1
- package/dist/permissions/network-class.d.ts +28 -0
- package/dist/permissions/network-class.js +91 -0
- package/dist/permissions/pipeline.d.ts +1 -1
- package/dist/permissions/pipeline.js +24 -7
- package/dist/permissions/preview.js +3 -0
- package/dist/permissions/rules.d.ts +1 -0
- package/dist/permissions/rules.js +5 -1
- package/dist/tools/presets.d.ts +1 -1
- package/dist/tools/presets.js +2 -2
- package/dist/tools/registry.d.ts +3 -1
- package/dist/tools/registry.js +4 -2
- package/dist/tools/types.d.ts +4 -1
- package/dist/tools/web/fetch.d.ts +48 -0
- package/dist/tools/web/fetch.js +307 -0
- package/dist/tools/web/html-to-markdown.d.ts +31 -0
- package/dist/tools/web/html-to-markdown.js +522 -0
- package/dist/tools/web/url-guard.d.ts +59 -0
- package/dist/tools/web/url-guard.js +221 -0
- package/docs/en/guides/permissions.md +23 -1
- package/docs/en/reference/host-api.md +61 -19
- package/docs/en/reference/rpc.md +7 -7
- package/docs/guides/agents.md +14 -0
- package/docs/guides/hooks.md +86 -8
- package/docs/guides/permissions.md +23 -1
- package/docs/guides/sandbox.md +6 -0
- package/docs/reference/host-api.md +61 -19
- package/docs/reference/rpc.md +7 -7
- package/package.json +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `web_fetch` 的地址守卫(docs/guides/permissions.md「网络类」):URL 校验、私网 / 元数据地址判定、
|
|
3
|
+
* DNS 钉住与重定向策略。纯 `node:*`,模型侧固定英文。
|
|
4
|
+
*
|
|
5
|
+
* - URL:只收 `http:` / `https:`;拒 userinfo;全文 ≤ 2 048 字符、查询串 ≤ 1 024 字符;主机名必须含 `.`
|
|
6
|
+
* (`localhost`、`*.localhost` 与无点主机一律拒——本机服务请走 bash 审批);IP 字面量当场判定。
|
|
7
|
+
* - 地址:解析出的**全部**地址都不得落在环回、未指定、链路本地(含云元数据 `169.254.169.254`)、
|
|
8
|
+
* RFC 1918、运营商 NAT `100.64/10`、基准测试 `198.18/15`、保留与广播、组播、IPv6 ULA `fc00::/7`、
|
|
9
|
+
* 站点本地 `fec0::/10`、链路本地 `fe80::/10`、组播 `ff00::/8`、未指定与已弃用的兼容段 `::/96`;IPv4 映射 / 兼容、NAT64 `64:ff9b::/96`、
|
|
10
|
+
* 6to4 `2002::/16` 里嵌的 IPv4 按 IPv4 再判一次。有一个落在里面就拒(防「多地址里夹一个内网」)。
|
|
11
|
+
* - DNS 钉住:连接用 `pinnedLookup()` 只返回已检查过的地址,防检查与连接之间被重新解析(DNS 重绑定)。
|
|
12
|
+
* - 重定向:同主机(主机名与端口相同、不从 https 降到 http)由调用方跟随并逐跳重检;跨主机不跟。
|
|
13
|
+
*/
|
|
14
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
15
|
+
import { BlockList, isIP } from "node:net";
|
|
16
|
+
export const MAX_URL_LENGTH = 2048;
|
|
17
|
+
export const MAX_QUERY_LENGTH = 1024;
|
|
18
|
+
export const MAX_SAME_HOST_REDIRECTS = 5;
|
|
19
|
+
const V4_BLOCKED = [
|
|
20
|
+
["0.0.0.0", 8],
|
|
21
|
+
["10.0.0.0", 8],
|
|
22
|
+
["100.64.0.0", 10],
|
|
23
|
+
["127.0.0.0", 8],
|
|
24
|
+
["169.254.0.0", 16],
|
|
25
|
+
["172.16.0.0", 12],
|
|
26
|
+
["192.0.0.0", 24],
|
|
27
|
+
["192.0.2.0", 24],
|
|
28
|
+
["192.168.0.0", 16],
|
|
29
|
+
["198.18.0.0", 15],
|
|
30
|
+
["198.51.100.0", 24],
|
|
31
|
+
["203.0.113.0", 24],
|
|
32
|
+
["224.0.0.0", 4],
|
|
33
|
+
["240.0.0.0", 4],
|
|
34
|
+
];
|
|
35
|
+
const V6_BLOCKED = [
|
|
36
|
+
["::", 96],
|
|
37
|
+
["::1", 128],
|
|
38
|
+
["100::", 64],
|
|
39
|
+
["2001:db8::", 32],
|
|
40
|
+
["fc00::", 7],
|
|
41
|
+
["fe80::", 10],
|
|
42
|
+
["fec0::", 10],
|
|
43
|
+
["ff00::", 8],
|
|
44
|
+
];
|
|
45
|
+
/** 常见云元数据地址:错误说明里单独点名。 */
|
|
46
|
+
const METADATA = new Set(["169.254.169.254", "100.100.100.200", "fd00:ec2::254"]);
|
|
47
|
+
const blocked = new BlockList();
|
|
48
|
+
for (const [net, prefix] of V4_BLOCKED)
|
|
49
|
+
blocked.addSubnet(net, prefix, "ipv4");
|
|
50
|
+
for (const [net, prefix] of V6_BLOCKED)
|
|
51
|
+
blocked.addSubnet(net, prefix, "ipv6");
|
|
52
|
+
/** IPv6 文本 → 8 个 16 位组;不合法返回 undefined。支持 `::` 与末尾点分 IPv4。 */
|
|
53
|
+
export function ipv6Groups(address) {
|
|
54
|
+
let text = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
55
|
+
const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
56
|
+
if (v4 !== null) {
|
|
57
|
+
const parts = v4[1].split(".").map(Number);
|
|
58
|
+
if (parts.some((p) => p > 255))
|
|
59
|
+
return undefined;
|
|
60
|
+
const [a, b, c, d] = parts;
|
|
61
|
+
text = `${text.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
62
|
+
}
|
|
63
|
+
const halves = text.split("::");
|
|
64
|
+
if (halves.length > 2)
|
|
65
|
+
return undefined;
|
|
66
|
+
const parse = (part) => part === ""
|
|
67
|
+
? []
|
|
68
|
+
: part.split(":").map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN));
|
|
69
|
+
const head = parse(halves[0]);
|
|
70
|
+
const tail = halves.length === 2 ? parse(halves[1]) : [];
|
|
71
|
+
const fill = 8 - head.length - tail.length;
|
|
72
|
+
if (halves.length === 1 ? fill !== 0 : fill < 1)
|
|
73
|
+
return undefined;
|
|
74
|
+
const groups = [...head, ...new Array(halves.length === 2 ? fill : 0).fill(0), ...tail];
|
|
75
|
+
return groups.some((g) => Number.isNaN(g)) ? undefined : groups;
|
|
76
|
+
}
|
|
77
|
+
function v4FromGroups(hi, lo) {
|
|
78
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
79
|
+
}
|
|
80
|
+
/** IPv6 里嵌的 IPv4(映射 / 兼容 / NAT64 / 6to4);没有返回 undefined。 */
|
|
81
|
+
export function embeddedIPv4(address) {
|
|
82
|
+
const g = ipv6Groups(address);
|
|
83
|
+
if (g === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
const zeros = (from, to) => g.slice(from, to).every((x) => x === 0);
|
|
86
|
+
// 映射 ::ffff:a.b.c.d;兼容 ::a.b.c.d 只认首段非 0 的(`::`、`::1` 留给 IPv6 段表)
|
|
87
|
+
if (zeros(0, 5) && (g[5] === 0xffff || (g[5] === 0 && g[6] >= 0x100)))
|
|
88
|
+
return v4FromGroups(g[6], g[7]);
|
|
89
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && zeros(2, 6))
|
|
90
|
+
return v4FromGroups(g[6], g[7]);
|
|
91
|
+
if (g[0] === 0x2002)
|
|
92
|
+
return v4FromGroups(g[1], g[2]);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 地址是否落在禁止段;落在里面返回英文原因(进工具结果),否则 undefined。
|
|
97
|
+
* 不是 IP 文本的一律按禁止(调用方只该传解析结果)。
|
|
98
|
+
*/
|
|
99
|
+
export function blockedAddressReason(address) {
|
|
100
|
+
const bare = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
101
|
+
const family = isIP(bare);
|
|
102
|
+
if (family === 0)
|
|
103
|
+
return `${address} is not an IP address`;
|
|
104
|
+
const lower = bare.toLowerCase();
|
|
105
|
+
if (METADATA.has(lower))
|
|
106
|
+
return `${bare} is a cloud metadata address`;
|
|
107
|
+
if (family === 4) {
|
|
108
|
+
if (bare === "255.255.255.255" || blocked.check(bare, "ipv4"))
|
|
109
|
+
return `${bare} is a private, loopback, link-local or reserved address`;
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const inner = embeddedIPv4(lower);
|
|
113
|
+
if (inner !== undefined) {
|
|
114
|
+
const reason = blockedAddressReason(inner);
|
|
115
|
+
if (reason !== undefined)
|
|
116
|
+
return `${bare} embeds ${inner}: ${reason}`;
|
|
117
|
+
}
|
|
118
|
+
if (blocked.check(lower, "ipv6"))
|
|
119
|
+
return `${bare} is a private, loopback, link-local or multicast address`;
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
/** 主机名:去方括号与末尾点,小写。 */
|
|
123
|
+
export function bareHostname(url) {
|
|
124
|
+
return url.hostname
|
|
125
|
+
.replace(/^\[|\]$/g, "")
|
|
126
|
+
.replace(/\.$/, "")
|
|
127
|
+
.toLowerCase();
|
|
128
|
+
}
|
|
129
|
+
/** 静态校验(不查 DNS):协议、userinfo、长度、主机名形状、IP 字面量。 */
|
|
130
|
+
export function checkFetchUrl(raw) {
|
|
131
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
132
|
+
return { ok: false, reason: "url is empty" };
|
|
133
|
+
const text = raw.trim();
|
|
134
|
+
if (text.length > MAX_URL_LENGTH)
|
|
135
|
+
return { ok: false, reason: `URL is longer than ${MAX_URL_LENGTH} characters` };
|
|
136
|
+
let url;
|
|
137
|
+
try {
|
|
138
|
+
url = new URL(text);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { ok: false, reason: "not a valid absolute URL" };
|
|
142
|
+
}
|
|
143
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
144
|
+
return { ok: false, reason: `only http and https URLs are supported (got ${url.protocol})` };
|
|
145
|
+
if (url.username !== "" || url.password !== "")
|
|
146
|
+
return { ok: false, reason: "URLs with credentials (user:pass@) are not allowed" };
|
|
147
|
+
if (url.search.length > MAX_QUERY_LENGTH + 1)
|
|
148
|
+
return { ok: false, reason: `query string is longer than ${MAX_QUERY_LENGTH} characters` };
|
|
149
|
+
const hostname = bareHostname(url);
|
|
150
|
+
if (isIP(hostname) !== 0) {
|
|
151
|
+
const reason = blockedAddressReason(hostname);
|
|
152
|
+
return reason === undefined ? { ok: true, url, hostname } : { ok: false, reason };
|
|
153
|
+
}
|
|
154
|
+
if (!hostname.includes(".") || hostname === "localhost" || hostname.endsWith(".localhost"))
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: `${hostname} is a local host name; web_fetch only reaches public hosts`,
|
|
158
|
+
};
|
|
159
|
+
return { ok: true, url, hostname };
|
|
160
|
+
}
|
|
161
|
+
/** 缺省解析:系统解析器,返回全部地址。 */
|
|
162
|
+
export const systemLookup = (hostname) => new Promise((resolve, reject) => {
|
|
163
|
+
dnsLookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
|
|
164
|
+
if (err)
|
|
165
|
+
reject(err);
|
|
166
|
+
else
|
|
167
|
+
resolve(addresses);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
/** 解析主机名并检查全部地址;IP 字面量直接返回。 */
|
|
171
|
+
export async function resolvePublicAddresses(hostname, lookup = systemLookup) {
|
|
172
|
+
const family = isIP(hostname);
|
|
173
|
+
if (family !== 0) {
|
|
174
|
+
const reason = blockedAddressReason(hostname);
|
|
175
|
+
return reason === undefined
|
|
176
|
+
? { ok: true, addresses: [{ address: hostname, family }] }
|
|
177
|
+
: { ok: false, reason };
|
|
178
|
+
}
|
|
179
|
+
let addresses;
|
|
180
|
+
try {
|
|
181
|
+
addresses = await lookup(hostname);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const code = err.code;
|
|
185
|
+
return { ok: false, reason: `cannot resolve ${hostname}${code ? ` (${code})` : ""}` };
|
|
186
|
+
}
|
|
187
|
+
if (addresses.length === 0)
|
|
188
|
+
return { ok: false, reason: `cannot resolve ${hostname}` };
|
|
189
|
+
for (const { address } of addresses) {
|
|
190
|
+
const reason = blockedAddressReason(address);
|
|
191
|
+
if (reason !== undefined)
|
|
192
|
+
return { ok: false, reason: `${hostname} resolves to ${reason}` };
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, addresses };
|
|
195
|
+
}
|
|
196
|
+
/** `http.request({ lookup })` 用:只返回已检查过的地址(兼容 `all: true` 与单地址两种调用)。 */
|
|
197
|
+
export function pinnedLookup(addresses) {
|
|
198
|
+
return (_hostname, options, callback) => {
|
|
199
|
+
const cb = (typeof options === "function" ? options : callback);
|
|
200
|
+
const all = typeof options === "object" && options !== null && options.all;
|
|
201
|
+
const first = addresses[0];
|
|
202
|
+
if (all === true)
|
|
203
|
+
cb(null, [...addresses]);
|
|
204
|
+
else
|
|
205
|
+
cb(null, first.address, first.family);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* 重定向能否自动跟:同主机名,且同协议同端口或 http → https 升级。其余(跨主机、换端口、
|
|
210
|
+
* https 降到 http)不跟:交给模型再调一次 web_fetch,下一次调用重新过权限。
|
|
211
|
+
*/
|
|
212
|
+
export function redirectPolicy(from, to) {
|
|
213
|
+
if (bareHostname(from) !== bareHostname(to))
|
|
214
|
+
return "cross-host";
|
|
215
|
+
if (from.protocol === "http:" && to.protocol === "https:")
|
|
216
|
+
return "follow";
|
|
217
|
+
return from.protocol === to.protocol && portOf(from) === portOf(to) ? "follow" : "cross-host";
|
|
218
|
+
}
|
|
219
|
+
function portOf(url) {
|
|
220
|
+
return url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
|
|
221
|
+
}
|
|
@@ -39,7 +39,7 @@ Read-only commands are a static list (next section) shared by `plan` and `allowl
|
|
|
39
39
|
|
|
40
40
|
### Origin labels in the approval dialog
|
|
41
41
|
|
|
42
|
-
Approvals do not only come from the main session. The dialog (and RPC `permission_request.context`) shows where a request comes from; the options are always just "allow / allow this kind for the session / deny":
|
|
42
|
+
Approvals do not only come from the main session. The dialog (and RPC `permission_request.context`) shows where a request comes from; the options are always just "allow / allow this kind for the session / deny" (for `web_fetch` the second one is "allow `<host>` for this session"):
|
|
43
43
|
|
|
44
44
|
| Origin | Title prefix | Body | Who decides |
|
|
45
45
|
| --------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
@@ -73,6 +73,27 @@ Read-only commands (`src/permissions/readonly-bash.ts`): first they pass auto's
|
|
|
73
73
|
|
|
74
74
|
This is narrower than auto's safe list: no test / build runners (`npm test` and `cargo build` run project scripts), and no `env` / `printenv` (they would print secrets in environment variables into the context). Reading secret paths (`cat .env`) is not read-only.
|
|
75
75
|
|
|
76
|
+
## Network class: web_fetch
|
|
77
|
+
|
|
78
|
+
`web_fetch` is the only network tool (it reads one URL; there is no search), with permission class `network`:
|
|
79
|
+
|
|
80
|
+
| Mode | `web_fetch` |
|
|
81
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `default`, `auto-edit` | Ask; the second option is "allow `<host>` for this session", after which the same host (any path, port or letter case) no longer asks |
|
|
83
|
+
| `plan` | Ask (read-only, but it can carry data out; research often needs docs, so it is not denied). Denied inside read-only sub-agents |
|
|
84
|
+
| `allowlist` | Only calls matched by an allow rule; everything else is denied |
|
|
85
|
+
| `auto` | Rule tier: a URL that is not public http(s) (local names, private / metadata IPs, `file:`, credentials) is denied; allow rules, hook allow and session grants allow; the rest goes to the classifier (public docs and package-registry pages are routine; a query string that looks like a key or a long random token asks) |
|
|
86
|
+
| `full-auto` | Allow |
|
|
87
|
+
|
|
88
|
+
Unattended (`-p`), every ask becomes a deny; use `--allow 'web_fetch(<domain>)'` to let it through.
|
|
89
|
+
|
|
90
|
+
- **Rules** `web_fetch(<pattern>)` match `host/path` (no scheme, port or query string; lower-case host). A pattern without `/` compares the host only: a bare domain `example.com` matches itself and all subdomains, `*.example.com` only subdomains. A pattern with `/` is a glob over the whole `host/path`, where `*` does not cross `/` and `**` does (`docs.example.com/api/**`). Deny rules are checked first in every mode: `permission.deny: ["web_fetch(*.internal.example.com)"]`.
|
|
91
|
+
- **Execution guard** (every mode, `full-auto` included): only `http:` / `https:`, no URLs with credentials; URL ≤ 2048 characters, query string ≤ 1024; the host name must contain a `.` (`localhost`, `*.localhost` and dotless hosts are refused; reach local services through an approved bash call). **Every** resolved address must be outside loopback, `0.0.0.0/8`, RFC 1918, `100.64/10`, link-local (including the cloud metadata addresses `169.254.169.254`, `100.100.100.200`, `fd00:ec2::254`), reserved and multicast ranges, IPv6 ULA `fc00::/7`, `fe80::/10`, `fec0::/10`, `ff00::/8`; IPv4 embedded in mapped / NAT64 / 6to4 addresses is checked as IPv4. The connection is pinned to the checked addresses so a re-resolution cannot swap in an internal one (DNS rebinding).
|
|
92
|
+
- **Redirects**: same host (same scheme and port, or an http → https upgrade) is followed up to 5 hops, re-checked at each hop; a cross-host redirect is not followed — the result tells the model the target URL, and calling it again goes through permissions again.
|
|
93
|
+
- **Limits**: 30 s total, 15 s to the first byte, response body (after decompression) ≤ 5 MB; GET only, no cookies or custom headers. Output is cut at 50 000 characters by default (200 000 at most); when cut, the full text goes to the session's `outputs/<call id>.txt` and the model continues with `offset`. The first line is always `Content fetched from <URL> (untrusted):` to mark external content.
|
|
94
|
+
- **Turning it off**: `tools.disabled: ["web_fetch"]` (user, profile or project config), `tools.default: ["-web_fetch"]`, host `tools.disable("web_fetch")`, or `permission.deny: ["web_fetch"]`. No new config keys or environment variables.
|
|
95
|
+
- `web_fetch` runs inside the ama process: the bash OS sandbox and `sandbox.network` do not apply, and it does not use `HTTPS_PROXY` (a proxy resolves host names itself, so the address could not be pinned); see [sandbox.md](../../guides/sandbox.md) "Known bypasses" (Chinese).
|
|
96
|
+
|
|
76
97
|
## Decision order
|
|
77
98
|
|
|
78
99
|
One tool call (issued directly by the model or nested inside codemode / task, alike):
|
|
@@ -133,6 +154,7 @@ No model call; a match asks (denied when unattended):
|
|
|
133
154
|
| Secret paths | Reading or writing `.env`, `.env.*` (except `.env.example` / `.sample` / `.template` / `.dist`), `.ssh/`, `.gnupg/`, `.aws/`, `.kube/config`, `.docker/config.json`, `.netrc`, `.pgpass`, private keys (`id_rsa` etc., `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.jks`), ama's `auth.json` |
|
|
134
155
|
| Protected writes | Writing inside `.git/`, the project's `.ama/` (could change hooks and config), or any path outside the project directory (except `/dev/null` and the like) |
|
|
135
156
|
| Network commands | `curl`, `wget`, `ssh`, `scp`, `rsync`, `nc`, `gh`; `git push / pull / fetch / clone`; `npm / pnpm / yarn / bun install / add / ci / update / publish / dlx`, `npx`; `pip install`, `cargo install / publish`, `go get / install`, `brew / apt install`, `docker pull / push / login` and so on |
|
|
157
|
+
| Web fetch | A `web_fetch` URL that is not public http(s) is **denied** outright (no prompt; see [Network class](#network-class-web_fetch)) |
|
|
136
158
|
| Deletion and rollback | `rm -r` / `rm -f`, `find -delete`, `git clean`, `git checkout -- …` / `git restore`, `git stash drop / clear`, `shred`, `truncate` |
|
|
137
159
|
| Paths in bash | Redirect targets (`>`, `>>`, `&>`, `tee`), and targets of `cp` / `mv` / `mkdir` / `touch` / `ln` / `chmod` outside the project or protected; secret paths among command arguments (`cat .env`) |
|
|
138
160
|
|
|
@@ -5,7 +5,7 @@ English · [简体中文](../../reference/host-api.md)
|
|
|
5
5
|
> Translated from the Chinese [docs/reference/host-api.md](../../reference/host-api.md) as of commit `979608b`. When the two differ, the Chinese
|
|
6
6
|
> version is authoritative.
|
|
7
7
|
|
|
8
|
-
A host adapter is a local JS module that ama loads at startup and hands a `HostApi`. With it the adapter can register tools, append to the system prompt, observe events, answer approvals, inject user messages and show notifications and status in the interface. The Armadra canvas plugs in as a host adapter (the canvas tools `canvas_*` / `context_*` are all registered by the adapter). The types are defined in `src/host/types.ts` and exported from `@armadra/agent/host`; `HOST_API_VERSION = 1
|
|
8
|
+
A host adapter is a local JS module that ama loads at startup and hands a `HostApi`. With it the adapter can register tools, append to the system prompt, observe events, answer approvals, inject user messages and show notifications and status in the interface. The Armadra canvas plugs in as a host adapter (the canvas tools `canvas_*` / `context_*` are all registered by the adapter). The types are defined in `src/host/types.ts` and exported from `@armadra/agent/host`; `HOST_API_VERSION = 1`, `HOST_API_MINOR = 1` (host-api 1.1, see "Versioning"). The design rationale is in [design.md](../../design/design.md) §6.2, §6.3 and §11.1 step 13 (Chinese).
|
|
9
9
|
|
|
10
10
|
## Module shape
|
|
11
11
|
|
|
@@ -51,26 +51,40 @@ export function create(api) {
|
|
|
51
51
|
| `create()` throws or does not return within 10 seconds | 6 |
|
|
52
52
|
| The returned adapter has no `id` | 6 |
|
|
53
53
|
|
|
54
|
+
## Versioning
|
|
55
|
+
|
|
56
|
+
- **Major** `HOST_API_VERSION` (`api.version`): the host's exported `hostApi` must equal it exactly. It changes only for breaking changes (removing or changing existing members or semantics) and then requires a breaking ama release (`pnpm release:check`).
|
|
57
|
+
- **Minor** `HOST_API_MINOR` (`api.minor`): bumped once per batch of additive optional members; existing hosts are unaffected and the major version stays. Older runtimes have no `api.minor`, which reads as 0.
|
|
58
|
+
- Hosts feature-detect by member presence (for example `typeof api.runners?.onDone === "function"`), which is more reliable than comparing `api.agent.version`.
|
|
59
|
+
|
|
60
|
+
| Minor | First ama version | Added |
|
|
61
|
+
| ----- | ----------------- | -------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| 0 | — | Every existing member (optional facets such as `cache?` and `runners?.provide` still need a check) |
|
|
63
|
+
| 1 | Unreleased | `minor`, `runners?.onDone` (see "Runner done event") |
|
|
64
|
+
|
|
54
65
|
## HostApi
|
|
55
66
|
|
|
56
|
-
| Member | Description
|
|
57
|
-
| ----------------------------------------------- |
|
|
58
|
-
| `version` | `HOST_API_VERSION`
|
|
59
|
-
| `agent` | `{ name: "ama", version }`
|
|
60
|
-
| `env` | A frozen copy of the environment variables at startup
|
|
61
|
-
| `mode` | `interactive` / `line` / `print` / `rpc`
|
|
62
|
-
| `session.id()` / `file()` / `cwd()` / `model()` | The current session (follows the new session after a switch); `file()` is `undefined` until the first request is written to disk
|
|
63
|
-
| `tools.register(tool)` | Register a tool (shape below); the name must match `^[a-z][a-z0-9_]{1,63}$`, and an existing name throws `tool_exists`. A prefix is recommended (`canvas_*`)
|
|
64
|
-
| `tools.disable(name)` | Hide a built-in tool (Armadra disables `task`, for example); the tools section of the system prompt stops listing it
|
|
65
|
-
| `tools.list()` | All current tool names
|
|
66
|
-
| `instructions.add(source)` | Append to the final `host` section of the system prompt; `{ kind: "file", path }` or `{ kind: "text", text, name? }`
|
|
67
|
-
| `events.on(name, handler)` | Observe events (table below); returns an unsubscribe function
|
|
68
|
-
| `approvals.setBroker(broker)` | Set the approval answerer (see "Approvals")
|
|
69
|
-
| `messages.sendUser(text, origin?)` | Inject a user message: when idle it starts a run (`"started"`), while running it is queued as a steer (`"queued"`); `origin` defaults to `"host"`, is persisted on the message and shown as `↳ host`
|
|
70
|
-
| `ui.notify(message, level?)` | Goes to the message area in interactive / line mode; becomes a `notification` event in rpc mode (with a copy on stderr); written to stderr in print mode
|
|
71
|
-
| `ui.setStatus(key, text?)` | A host item in the status bar; an empty or missing `text` removes the key
|
|
72
|
-
| `log(level, message, detail?)` | Logging; `warn` / `error` go to stderr
|
|
73
|
-
| `cache?.onWarmingDecision(handler)` | Veto hook for cache warming (see "Cache warming"); an optional facet missing in older runtimes, so check `api.cache !== undefined` before use
|
|
67
|
+
| Member | Description |
|
|
68
|
+
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
69
|
+
| `version` | `HOST_API_VERSION` |
|
|
70
|
+
| `agent` | `{ name: "ama", version }` |
|
|
71
|
+
| `env` | A frozen copy of the environment variables at startup |
|
|
72
|
+
| `mode` | `interactive` / `line` / `print` / `rpc` |
|
|
73
|
+
| `session.id()` / `file()` / `cwd()` / `model()` | The current session (follows the new session after a switch); `file()` is `undefined` until the first request is written to disk |
|
|
74
|
+
| `tools.register(tool)` | Register a tool (shape below); the name must match `^[a-z][a-z0-9_]{1,63}$`, and an existing name throws `tool_exists`. A prefix is recommended (`canvas_*`) |
|
|
75
|
+
| `tools.disable(name)` | Hide a built-in tool (Armadra disables `task`, for example); the tools section of the system prompt stops listing it |
|
|
76
|
+
| `tools.list()` | All current tool names |
|
|
77
|
+
| `instructions.add(source)` | Append to the final `host` section of the system prompt; `{ kind: "file", path }` or `{ kind: "text", text, name? }` |
|
|
78
|
+
| `events.on(name, handler)` | Observe events (table below); returns an unsubscribe function |
|
|
79
|
+
| `approvals.setBroker(broker)` | Set the approval answerer (see "Approvals") |
|
|
80
|
+
| `messages.sendUser(text, origin?)` | Inject a user message: when idle it starts a run (`"started"`), while running it is queued as a steer (`"queued"`); `origin` defaults to `"host"`, is persisted on the message and shown as `↳ host` |
|
|
81
|
+
| `ui.notify(message, level?)` | Goes to the message area in interactive / line mode; becomes a `notification` event in rpc mode (with a copy on stderr); written to stderr in print mode |
|
|
82
|
+
| `ui.setStatus(key, text?)` | A host item in the status bar; an empty or missing `text` removes the key |
|
|
83
|
+
| `log(level, message, detail?)` | Logging; `warn` / `error` go to stderr |
|
|
84
|
+
| `cache?.onWarmingDecision(handler)` | Veto hook for cache warming (see "Cache warming"); an optional facet missing in older runtimes, so check `api.cache !== undefined` before use |
|
|
85
|
+
| `runners?.provide(runner)` | Injects a sub-agent runner: `id` is the name in `task(agent=<id>)` and `description` goes into the task tool description; with a host, ama starts no external CLI of its own and only uses runners injected here; returns an unsubscribe function |
|
|
86
|
+
| `runners?.onDone(handler)` | Called whenever a round of a host runner ends (see "Runner done event"); returns an unsubscribe function. Since host-api 1.1, so check `typeof api.runners?.onDone === "function"` before use |
|
|
87
|
+
| `minor?` | `HOST_API_MINOR` (see "Versioning"); missing in older runtimes |
|
|
74
88
|
|
|
75
89
|
During `create()` the session is not assembled yet: `session.*` returns the values fixed at startup, and `sendUser` is rejected with `busy`. To send a message at startup, wait for the `session_start` event.
|
|
76
90
|
|
|
@@ -104,6 +118,8 @@ interface ToolResult {
|
|
|
104
118
|
|
|
105
119
|
Host tools take the same path as built-in tools: schema validation → command hook PreToolUse → permission pipeline (classified by `permission`) → approval → execution → PostToolUse. They can also be called from codemode scripts as `tools.<name>()`.
|
|
106
120
|
|
|
121
|
+
The exported `ToolPermission` type gains `"network"` (used by the built-in `web_fetch`; judged as execute and asks in plan, see [permissions.md](../guides/permissions.md) "Network class"); tools registered by a host through `tools.register` still accept only `read` / `write` / `execute`, and `HOST_API_VERSION` is unchanged.
|
|
122
|
+
|
|
107
123
|
## Events
|
|
108
124
|
|
|
109
125
|
`events.on` handlers only observe: a throw is just logged and does not affect the run; handlers are called and awaited in order, and `session_shutdown` is awaited (so you can clean up before exit).
|
|
@@ -157,6 +173,32 @@ interface WarmDecision {
|
|
|
157
173
|
|
|
158
174
|
Return `"warm"` / `"stop"` (a Promise is fine). `"stop"` skips the request and stops this warming round (when the built-in decision was warm, the stop reason is recorded as `declined`); `"warm"` can override a built-in stop. If the handler fails, the built-in decision applies. With several handlers, the last one registered and not unsubscribed wins; the return value is an unsubscribe function. The warming mechanism itself is described in [providers.md](../guides/providers.md) "Caching".
|
|
159
175
|
|
|
176
|
+
## Runner done event
|
|
177
|
+
|
|
178
|
+
`api.runners.onDone(handler)` (host-api 1.1): every time a runner injected through `runners.provide` finishes a round, `handler` is called once with the payload below. A round is the first one after `start()`, or the next one after `send` / `interrupt`; it ends when ama's `wait()` for it settles (returns, throws, or the task is aborted).
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
interface RunnerDoneEvent {
|
|
182
|
+
runner: string; // host runner id (the name in task(agent=<id>))
|
|
183
|
+
taskId?: string; // ama task id (SubagentRunRequest.taskId); missing when the runner is called directly
|
|
184
|
+
sessionId: string; // the runner's own session id (RunnerHandle.id)
|
|
185
|
+
round: number; // round number, starting at 1
|
|
186
|
+
status: "completed" | "failed" | "aborted" | "max_turns" | "interrupted";
|
|
187
|
+
isError: boolean; // status is failed or aborted
|
|
188
|
+
text?: string; // result text returned by wait()
|
|
189
|
+
error?: string; // error message thrown by wait()
|
|
190
|
+
startedAt: number; // when the round began (start / send / interrupt), epoch milliseconds
|
|
191
|
+
endedAt: number; // when it settled
|
|
192
|
+
durationMs: number;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- `status` follows the task registry: the task's `signal` aborted or `stop()` called → `aborted`; otherwise the terminal status the result reports (`max_turns`, `interrupted`, …); otherwise `failed` / `completed` by `isError`. A `wait()` that throws without an abort → `failed` with `error`.
|
|
197
|
+
- Observe-only: handlers are not awaited; a throw (including a rejected Promise) is logged once at error level through `log` and affects neither the `wait()` result nor other handlers.
|
|
198
|
+
- ama may call `wait()` several times in one round (the task entry and the registry each once); the event is sent once. Calling `wait()` again after the round settled, without a new `send` / `interrupt`, sends nothing. An `interrupt` while running is followed to the end by the current round instead of starting another.
|
|
199
|
+
- Only host-injected runners are covered; ama's own sub-sessions (`general` / `explore` / `plan`) still report through `subagent_start` / `subagent_end`.
|
|
200
|
+
- Use: the host no longer needs to poll whether the coordinator is still waiting on a task — the event says ama's side of it has ended.
|
|
201
|
+
|
|
160
202
|
## Exit
|
|
161
203
|
|
|
162
204
|
- Process exit: `session_shutdown` event (awaited) → SessionEnd hook (`reason: "exit"`) → `adapter.dispose()` → session dispose. A throw from `dispose` is only logged as a warning.
|
package/docs/en/reference/rpc.md
CHANGED
|
@@ -124,13 +124,13 @@ After a session switch the server re-subscribes to events and sends `session_sta
|
|
|
124
124
|
|
|
125
125
|
### Tools, permissions, discovery
|
|
126
126
|
|
|
127
|
-
| Command | Parameters | `data`
|
|
128
|
-
| --------------------- | ---------------------------------------------------------------------- |
|
|
129
|
-
| `get_tools` | — | `{ tools: { name, description, parameters, permission, active }[] }` (every tool in the registry; `active` means the model sees it now)
|
|
130
|
-
| `set_active_tools` | `names: string[]` | `{ names }` (active tool names after the change)
|
|
131
|
-
| `set_permission_mode` | `mode: plan \| allowlist \| default \| auto-edit \| auto \| full-auto` | `{ mode }` (unknown mode → `invalid_arguments`)
|
|
132
|
-
| `get_commands` | — | `{ commands: { name, description?, source: "builtin" \| "template" \| "skill" }[] }`; Skill names are written `skill:<name
|
|
133
|
-
| `get_skills` | — | `{ skills: { name, description, location, … }[] }` (discovered Skills; `location` is the SKILL.md path)
|
|
127
|
+
| Command | Parameters | `data` |
|
|
128
|
+
| --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
129
|
+
| `get_tools` | — | `{ tools: { name, description, parameters, permission, active }[] }` (every tool in the registry; `active` means the model sees it now) |
|
|
130
|
+
| `set_active_tools` | `names: string[]` | `{ names }` (active tool names after the change) |
|
|
131
|
+
| `set_permission_mode` | `mode: plan \| allowlist \| default \| auto-edit \| auto \| full-auto` | `{ mode }` (unknown mode → `invalid_arguments`) |
|
|
132
|
+
| `get_commands` | — | `{ commands: { name, description?, source: "builtin" \| "template" \| "skill" }[] }`; Skill names are written `skill:<name>`; only built-in commands that are currently available are listed (no `memory` while memory is off, no `tasks` when task cannot be called) |
|
|
133
|
+
| `get_skills` | — | `{ skills: { name, description, location, … }[] }` (discovered Skills; `location` is the SKILL.md path) |
|
|
134
134
|
|
|
135
135
|
### Plans and tasks (wave 5)
|
|
136
136
|
|
package/docs/guides/agents.md
CHANGED
|
@@ -208,6 +208,20 @@ RPC `get_tasks` / `get_agents` 返回任务快照与可用类型(来源、定
|
|
|
208
208
|
| `agents.<类型>.model` | 某个类型的模型(如让 `explore` 用便宜模型) |
|
|
209
209
|
| `models.aliases.fast` / `.strong` | 定义文件里 `model: fast / strong` 的映射 |
|
|
210
210
|
|
|
211
|
+
按角色选模型只要三行配置(`config.json`):别名 `fast` 指向便宜模型,`explore` 类型用它,外部 Agent `claude` 用它自己的模型名(原样交给该 CLI):
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"models": { "aliases": { "fast": "<provider>/<cheap-model>" } },
|
|
216
|
+
"agents": {
|
|
217
|
+
"explore": { "model": "fast" },
|
|
218
|
+
"claude": { "model": "sonnet" }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
内置类型不带缺省的便宜模型;不配置时按上面的顺序回落,最终继承父会话的模型。
|
|
224
|
+
|
|
211
225
|
### 限制
|
|
212
226
|
|
|
213
227
|
- 子 Agent 不能再委派(深度 1);协调多个 Agent 的场景交给宿主(如 Armadra 画布)。
|
package/docs/guides/hooks.md
CHANGED
|
@@ -54,6 +54,12 @@
|
|
|
54
54
|
|
|
55
55
|
每个事件只接受上表的决策;`deny` 与 `block` 在两类事件间互换(`UserPromptSubmit` 返回 `deny` 视为 `block`,`PreToolUse` 返回 `block` 视为 `deny`),其它不接受的决策忽略并记 warning。
|
|
56
56
|
|
|
57
|
+
**范围**:
|
|
58
|
+
|
|
59
|
+
- `task` 子会话(`depth: 1`)以自己的 `sessionId` / `sessionFile` 跑 Hook:它内部的每次工具调用照常触发 PreToolUse / PostToolUse,将要结束时触发 `SubagentStop`(不是 `Stop`);它**不触发** `SessionStart` / `SessionEnd`——子会话的开始与结束由父会话的 `PreToolUse(task)` 与子会话的 `SubagentStop` 表达。
|
|
60
|
+
- 外部 Agent(`task(agent="claude")` 等)内部的工具调用在外部 CLI 自己的策略下运行,**不经 ama 的 Hook**;ama 这边只有父会话的 `PreToolUse(task)` / `PostToolUse(task)`,外部 Agent 也不触发 `SubagentStop`。
|
|
61
|
+
- codemode 脚本里的每次内层调用逐个触发(见下文「输入」的 codemode 段)。
|
|
62
|
+
|
|
57
63
|
任何事件的 stdout 都可以带 `"continue": false`:请求结束当前运行(reason 显示给用户)。`"suppressOutput": true` 被接受并合并,但当前界面本来就不显示 Hook 的 stdout,暂无可见效果。
|
|
58
64
|
|
|
59
65
|
## 输入(stdin)
|
|
@@ -146,14 +152,15 @@ Hook 在权限管线之前,是因为它是用户策略(可以改输入、可
|
|
|
146
152
|
|
|
147
153
|
只对 `PreToolUse` / `PostToolUse` 生效,其它事件忽略 matcher。
|
|
148
154
|
|
|
149
|
-
| 写法
|
|
150
|
-
|
|
|
151
|
-
| 缺省、`""`、`*`
|
|
152
|
-
| `bash`
|
|
153
|
-
| `write\|edit`
|
|
154
|
-
| `canvas_*`
|
|
155
|
-
| `bash(git push*)`
|
|
156
|
-
|
|
|
155
|
+
| 写法 | 匹配 |
|
|
156
|
+
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
157
|
+
| 缺省、`""`、`*` | 全部工具 |
|
|
158
|
+
| `bash` | 精确匹配工具名 |
|
|
159
|
+
| `write\|edit` | 多选(括号外的 `\|` 分隔) |
|
|
160
|
+
| `canvas_*` | glob:`*` 任意字符串、`?` 单个字符 |
|
|
161
|
+
| `bash(git push*)` | 先匹配工具名,再对参数文本做 glob:bash 是 `command`,文件类工具是 `path`,其它工具是输入的 JSON 文本 |
|
|
162
|
+
| `web_fetch(*docs.example.org*)` | `web_fetch` 的参数文本是整个 `url`(含协议与查询串,`*` 跨 `/`);与权限规则 `web_fetch(<pattern>)` 的 `host/path` 语义不同 |
|
|
163
|
+
| `/regex/flags` | 对工具名做正则 |
|
|
157
164
|
|
|
158
165
|
## 运行环境
|
|
159
166
|
|
|
@@ -171,6 +178,77 @@ Hook 在权限管线之前,是因为它是用户策略(可以改输入、可
|
|
|
171
178
|
|
|
172
179
|
每条 Hook 结束后发 `hook_executed{event, command, exitCode, durationMs}`(RPC 事件与宿主事件都有),超时或被信号杀死时 `exitCode` 为 null。
|
|
173
180
|
|
|
181
|
+
## 按能力接入
|
|
182
|
+
|
|
183
|
+
各项能力都用上面的事件接入,没有专门的事件。下表是对应关系,再往下是一份把各行放在一起的 hooks.json。
|
|
184
|
+
|
|
185
|
+
| 能力 | 事件与 matcher | 能做什么 |
|
|
186
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
187
|
+
| `web_fetch` | `PreToolUse` / `PostToolUse`,matcher `web_fetch` 或 `web_fetch(*docs.example.org*)`(括号内对 `toolInput.url` 文本做 glob) | PreToolUse 拒绝、改为询问或用 `updatedInput` 改 URL;PostToolUse 用 `additionalContext` 给抓回的正文加备注,`decision: "block"` 整页拒收 |
|
|
188
|
+
| 子 Agent 开始 | `PreToolUse`,matcher `task`;按类型筛用 `task(*"agent":"explore"*)`(括号内是输入的 JSON 文本) | 输入含 `agent`、`prompt`、`model`、`background`:可以 deny,也可以用 `updatedInput` 改 prompt 或换 agent |
|
|
189
|
+
| 子 Agent 结束 | `SubagentStop`(输入带 `lastAssistantText`) | `decision: "block"` + `reason`(或退出码 2)让子会话带着 reason 再跑一轮 |
|
|
190
|
+
| 子 Agent 内部的调用 | 照常的 `PreToolUse` / `PostToolUse`,输入 `depth: 1` | 与主会话相同;脚本按 `depth` 区分主会话与子会话 |
|
|
191
|
+
| 后台任务完成 | `SubagentStop`(后台的 ama 子会话结束时同样触发) | 桌面通知、记日志;没有单独的「后台任务完成」通知种类 |
|
|
192
|
+
| 外部 Agent | `PreToolUse` / `PostToolUse`,matcher `task(*"agent":"claude"*)` | 拦截或改写委派本身;外部 Agent 内部的调用不经 Hook(见「范围」) |
|
|
193
|
+
| codemode 编排子 Agent | 脚本里每个 `tools.task(...)` 都是一对 `PreToolUse(task)` / `PostToolUse(task)`,输入带 `viaCodemode` 与 `parentToolCallId` | 与模型直接调用 `task` 相同;按 `parentToolCallId` 把同一个脚本发起的任务归到一起 |
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"version": 1,
|
|
198
|
+
"hooks": {
|
|
199
|
+
"PreToolUse": [
|
|
200
|
+
{
|
|
201
|
+
"matcher": "web_fetch",
|
|
202
|
+
"hooks": [{ "type": "command", "command": "./.ama/fetch-guard.sh" }]
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"matcher": "task(*\"agent\":\"claude\"*)",
|
|
206
|
+
"hooks": [{ "type": "command", "command": "./.ama/log-delegation.sh" }]
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
"PostToolUse": [
|
|
210
|
+
{
|
|
211
|
+
"matcher": "web_fetch(*docs.example.org*)",
|
|
212
|
+
"hooks": [
|
|
213
|
+
{
|
|
214
|
+
"type": "command",
|
|
215
|
+
"command": "echo '{\"additionalContext\":\"docs.example.org 的页面对应最新版本,旧版本另查\"}'"
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
],
|
|
220
|
+
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "./.ama/subagent-tests.sh" }] }]
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`web_fetch` 行随 `web_fetch` 工具一起提供(matcher 括号内取 `url` 的改动在同一批里)。它的 matcher 只认 URL 文本上的 `*` / `?` glob;权限规则 `web_fetch(<glob>)` 是另一套写法(对 `host/path` 匹配,裸域名含子域),见 [permissions.md](permissions.md)。只想放行少数站点时优先用权限规则或 `allowlist` 模式,Hook 适合需要脚本判断的情形。
|
|
226
|
+
|
|
227
|
+
只允许抓取文档站(`.ama/fetch-guard.sh`,配在上面的 `PreToolUse` `web_fetch` 下):
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
#!/bin/sh
|
|
231
|
+
url=$(node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).toolInput.url))')
|
|
232
|
+
case "$url" in
|
|
233
|
+
https://docs.example.org/*|https://registry.npmjs.org/*) exit 0 ;;
|
|
234
|
+
esac
|
|
235
|
+
echo "只允许抓取文档站:$url" >&2
|
|
236
|
+
exit 2
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
子任务结束时跑测试,失败就让它接着修(`.ama/subagent-tests.sh`,配在 `SubagentStop` 下):
|
|
240
|
+
|
|
241
|
+
```sh
|
|
242
|
+
#!/bin/sh
|
|
243
|
+
input=$(cat)
|
|
244
|
+
# 已经被本 Hook 续跑过一次:放行,避免循环
|
|
245
|
+
case "$input" in *'"stopHookActive":true'*) exit 0 ;; esac
|
|
246
|
+
out=$(npm test --silent 2>&1) && exit 0
|
|
247
|
+
echo "Tests failed; fix them before reporting back:" >&2
|
|
248
|
+
printf '%s\n' "$out" | tail -n 20 >&2
|
|
249
|
+
exit 2
|
|
250
|
+
```
|
|
251
|
+
|
|
174
252
|
## 示例
|
|
175
253
|
|
|
176
254
|
阻止推送到 main:
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
### 审批对话框的来源标注
|
|
36
36
|
|
|
37
|
-
审批不只来自主会话。对话框(以及 RPC `permission_request.context`)标出请求从哪来,选项都只有「允许 / 本会话允许同类 /
|
|
37
|
+
审批不只来自主会话。对话框(以及 RPC `permission_request.context`)标出请求从哪来,选项都只有「允许 / 本会话允许同类 / 拒绝」(`web_fetch` 的第二项是「本会话允许 `<主机>`」):
|
|
38
38
|
|
|
39
39
|
| 来源 | 标题前缀 | 正文 | 谁来判定 |
|
|
40
40
|
| ------------------------------------------ | ----------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
|
|
@@ -68,6 +68,27 @@ plan 模式的第 ③ 步看输入(实现 `src/permissions/pipeline.ts` 的 `p
|
|
|
68
68
|
|
|
69
69
|
比 auto 的安全名单窄:不含测试 / 构建运行器(`npm test`、`cargo build` 会执行项目脚本),也不含 `env` / `printenv`(会把环境变量里的密钥打进上下文)。读机密路径(`cat .env`)不算只读。
|
|
70
70
|
|
|
71
|
+
## 网络类:web_fetch
|
|
72
|
+
|
|
73
|
+
`web_fetch` 是唯一的联网工具(只读取一个 URL,不做搜索),权限类 `network`:
|
|
74
|
+
|
|
75
|
+
| 模式 | `web_fetch` |
|
|
76
|
+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
77
|
+
| `default`、`auto-edit` | 询问;审批第二项是「本会话允许 `<主机>`」,之后同一主机(不分路径、端口与大小写)不再询问 |
|
|
78
|
+
| `plan` | 询问(只读但能把数据带出去;调研常要看文档,所以不是拒绝)。只读子 Agent 里按拒绝 |
|
|
79
|
+
| `allowlist` | 只放行 allow 规则命中的,其余拒绝 |
|
|
80
|
+
| `auto` | 规则层:URL 不是公网 http(s)(本机名、私网 / 元数据 IP、`file:`、带账号密码)直接拒绝;allow 规则、Hook allow、会话记忆放行;其余交分类器(公开文档、包注册表页算常规,查询串像密钥或长随机串时询问) |
|
|
81
|
+
| `full-auto` | 放行 |
|
|
82
|
+
|
|
83
|
+
无人值守(`-p`)时询问一律拒绝,用 `--allow 'web_fetch(<域名>)'` 放行。
|
|
84
|
+
|
|
85
|
+
- **规则** `web_fetch(<pattern>)` 对 `host/path`(不含协议、端口与查询串,主机名小写)匹配:不含 `/` 的只比主机,裸域名 `example.com` 命中它自己与全部子域,`*.example.com` 只命中子域;含 `/` 的对整个 `host/path` 做 glob,`*` 不跨 `/`、`**` 跨(`docs.example.com/api/**`)。deny 规则在所有模式下先判定:`permission.deny: ["web_fetch(*.internal.example.com)"]`。
|
|
86
|
+
- **执行层守卫**(所有模式都生效,`full-auto` 也一样):只收 `http:` / `https:`,拒带账号密码的 URL;URL ≤ 2048 字符、查询串 ≤ 1024;主机名必须含 `.`(`localhost`、`*.localhost`、无点主机一律拒绝,本机服务请走 bash 审批);解析出的**每一个**地址都不得落在环回、`0.0.0.0/8`、RFC 1918、`100.64/10`、链路本地(含云元数据 `169.254.169.254`、`100.100.100.200`、`fd00:ec2::254`)、保留与组播段、IPv6 ULA `fc00::/7`、`fe80::/10`、`fec0::/10`、`ff00::/8`,IPv4 映射 / NAT64 / 6to4 里嵌的 IPv4 按 IPv4 再判;连接钉在检查过的地址上,防解析后被换成内网(DNS 重绑定)。
|
|
87
|
+
- **重定向**:同主机(同协议同端口,或 http → https 升级)自动跟,最多 5 跳,每跳重新检查;跨主机不跟,结果告诉模型目标 URL,再调用一次要重新过权限。
|
|
88
|
+
- **限制**:总超时 30 s、首字节 15 s、响应体(解压后)≤ 5 MB;只发 GET,不带 cookie 与自定义头;输出缺省截到 50 000 字符(最多 200 000),截断时全文写到会话的 `outputs/<调用 id>.txt`,模型用 `offset` 续读。结果首行固定 `Content fetched from <URL> (untrusted):`,提醒模型这是外部内容。
|
|
89
|
+
- **关闭**:`tools.disabled: ["web_fetch"]`(用户、profile、项目级都可以)、`tools.default: ["-web_fetch"]`、宿主 `tools.disable("web_fetch")`,或 `permission.deny: ["web_fetch"]`。没有新增配置键与环境变量。
|
|
90
|
+
- `web_fetch` 在 ama 进程里运行,不受 bash 的 OS 沙箱与 `sandbox.network` 约束,也不走 `HTTPS_PROXY`(代理会自己解析主机名,钉不住地址);见 [sandbox.md](sandbox.md)「已知绕过」。
|
|
91
|
+
|
|
71
92
|
## 判定顺序
|
|
72
93
|
|
|
73
94
|
一次工具调用(模型直接发起或 codemode / task 里嵌套发起都一样):
|
|
@@ -132,6 +153,7 @@ default / auto-edit 下,满足下面全部条件的 bash 调用免审批(`Pe
|
|
|
132
153
|
| 机密路径 | 读或写 `.env`、`.env.*`(`.env.example` / `.sample` / `.template` / `.dist` 除外)、`.ssh/`、`.gnupg/`、`.aws/`、`.kube/config`、`.docker/config.json`、`.netrc`、`.pgpass`、私钥(`id_rsa` 等、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`*.jks`)、ama 的 `auth.json` |
|
|
133
154
|
| 受保护写入 | 写 `.git/` 内部、项目里的 `.ama/`(可能改 Hook 与配置)、项目目录外的任何路径(`/dev/null` 等除外) |
|
|
134
155
|
| 网络命令 | `curl`、`wget`、`ssh`、`scp`、`rsync`、`nc`、`gh`;`git push / pull / fetch / clone`;`npm / pnpm / yarn / bun install / add / ci / update / publish / dlx`、`npx`;`pip install`、`cargo install / publish`、`go get / install`、`brew / apt install`、`docker pull / push / login` 等 |
|
|
156
|
+
| 联网读取 | `web_fetch` 的 URL 不是公网 http(s):直接**拒绝**(不询问,见[网络类](#网络类web_fetch)) |
|
|
135
157
|
| 删除与回退 | `rm -r` / `rm -f`、`find -delete`、`git clean`、`git checkout -- …` / `git restore`、`git stash drop / clear`、`shred`、`truncate` |
|
|
136
158
|
| bash 里的路径 | 重定向目标(`>`、`>>`、`&>`、`tee`)、`cp` / `mv` / `mkdir` / `touch` / `ln` / `chmod` 的目标在项目外或受保护;命令参数里出现机密路径(`cat .env`) |
|
|
137
159
|
|