@armadra/agent 0.10.0 → 0.12.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CHANGELOG.zh-CN.md +31 -0
  3. package/README.md +5 -0
  4. package/README.zh-CN.md +4 -0
  5. package/dist/acp.d.ts +2 -2
  6. package/dist/acp.js +2 -2
  7. package/dist/bundle/ama.cjs +1730 -446
  8. package/dist/cli/compose-agents.js +8 -0
  9. package/dist/cli/compose.d.ts +3 -0
  10. package/dist/cli/compose.js +1 -0
  11. package/dist/drivers/acp/client.d.ts +54 -5
  12. package/dist/drivers/acp/client.js +96 -9
  13. package/dist/drivers/acp/types.d.ts +69 -3
  14. package/dist/drivers/acp/types.js +30 -1
  15. package/dist/drivers/agents.js +1 -1
  16. package/dist/drivers/host-runners.d.ts +26 -1
  17. package/dist/drivers/host-runners.js +147 -0
  18. package/dist/hooks/matcher.d.ts +2 -2
  19. package/dist/hooks/matcher.js +3 -3
  20. package/dist/host/api-impl.d.ts +3 -0
  21. package/dist/host/api-impl.js +7 -2
  22. package/dist/host/types.d.ts +37 -1
  23. package/dist/host/types.js +5 -0
  24. package/dist/host.d.ts +1 -1
  25. package/dist/host.js +1 -1
  26. package/dist/i18n/catalog.d.ts +5 -0
  27. package/dist/i18n/messages/approval.d.ts +5 -0
  28. package/dist/i18n/messages/approval.js +5 -0
  29. package/dist/i18n/messages/drivers.d.ts +2 -0
  30. package/dist/i18n/messages/drivers.js +2 -0
  31. package/dist/i18n/messages/interactive-view.d.ts +5 -0
  32. package/dist/i18n/messages/interactive-view.js +5 -0
  33. package/dist/i18n/messages/interactive.d.ts +4 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/modes/commands-core.d.ts +7 -0
  37. package/dist/modes/commands-core.js +23 -5
  38. package/dist/modes/interactive/approval-dialog.js +17 -4
  39. package/dist/modes/interactive/commands.d.ts +2 -0
  40. package/dist/modes/interactive/commands.js +5 -1
  41. package/dist/modes/interactive/interactive-mode.d.ts +7 -0
  42. package/dist/modes/interactive/interactive-mode.js +14 -6
  43. package/dist/modes/interactive/tool-summary.d.ts +2 -0
  44. package/dist/modes/interactive/tool-summary.js +27 -0
  45. package/dist/modes/rpc/commands.js +4 -4
  46. package/dist/permissions/auto-safe.d.ts +3 -0
  47. package/dist/permissions/auto-safe.js +10 -3
  48. package/dist/permissions/classifier.js +1 -1
  49. package/dist/permissions/network-class.d.ts +28 -0
  50. package/dist/permissions/network-class.js +91 -0
  51. package/dist/permissions/pipeline.d.ts +1 -1
  52. package/dist/permissions/pipeline.js +24 -7
  53. package/dist/permissions/preview.js +3 -0
  54. package/dist/permissions/readonly-bash.d.ts +2 -2
  55. package/dist/permissions/readonly-bash.js +2 -2
  56. package/dist/permissions/rules.d.ts +1 -0
  57. package/dist/permissions/rules.js +5 -1
  58. package/dist/tools/presets.d.ts +8 -1
  59. package/dist/tools/presets.js +12 -3
  60. package/dist/tools/registry.d.ts +3 -1
  61. package/dist/tools/registry.js +4 -2
  62. package/dist/tools/task-ctl.d.ts +9 -0
  63. package/dist/tools/task-ctl.js +22 -3
  64. package/dist/tools/types.d.ts +4 -1
  65. package/dist/tools/web/fetch.d.ts +48 -0
  66. package/dist/tools/web/fetch.js +307 -0
  67. package/dist/tools/web/html-to-markdown.d.ts +31 -0
  68. package/dist/tools/web/html-to-markdown.js +522 -0
  69. package/dist/tools/web/url-guard.d.ts +59 -0
  70. package/dist/tools/web/url-guard.js +221 -0
  71. package/docs/en/guides/permissions.md +33 -11
  72. package/docs/en/reference/acp.md +7 -3
  73. package/docs/en/reference/host-api.md +61 -19
  74. package/docs/en/reference/rpc.md +7 -7
  75. package/docs/guides/agents.md +18 -1
  76. package/docs/guides/hooks.md +86 -8
  77. package/docs/guides/permissions.md +33 -11
  78. package/docs/guides/sandbox.md +6 -0
  79. package/docs/reference/acp.md +7 -3
  80. package/docs/reference/host-api.md +61 -19
  81. package/docs/reference/rpc.md +7 -7
  82. package/package.json +1 -1
@@ -25,7 +25,7 @@ import { msg } from "../../i18n/index.js";
25
25
  import { autoLayerText, isPermissionMode, permissionModeLabel } from "../../permissions/modes.js";
26
26
  import { previewDisplayLines } from "../../permissions/preview.js";
27
27
  import { Box, defaultKeybindings, matchesKey, padToWidth, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "../../tui.js";
28
- import { displayPath, flat } from "./tool-summary.js";
28
+ import { displayPath, fetchHost, flat } from "./tool-summary.js";
29
29
  import { toolSummary } from "./tool-view.js";
30
30
  const COMMAND_LINES = 8;
31
31
  const EDIT_LINES = 3;
@@ -130,6 +130,13 @@ export function describeRequest(request, theme, options = {}) {
130
130
  if (hidden > 0)
131
131
  out.push(more(hidden));
132
132
  }
133
+ else if (request.toolName === "web_fetch" && typeof input["url"] === "string") {
134
+ // 完整 URL(含协议与查询串),按 72 列折行,不截断
135
+ const { shown, hidden } = lines(input["url"].replace(/(.{72})/g, "$1\n"), COMMAND_LINES);
136
+ out.push(...shown.map((l) => theme.fg("code", l)));
137
+ if (hidden > 0)
138
+ out.push(more(hidden));
139
+ }
133
140
  else if (request.toolName === "write" && typeof input["content"] === "string") {
134
141
  const count = lines(input["content"], Number.MAX_SAFE_INTEGER).shown.length;
135
142
  out.push(`${toolSummary("write", input, options.cwd)} ${theme.fg("dim", m.writeLines(count))}`);
@@ -293,10 +300,14 @@ class ApprovalDialog {
293
300
  const { theme } = this.host;
294
301
  const g = theme.glyphs;
295
302
  const keyWidth = compact ? 1 : Math.max(...APPROVAL_OPTIONS.map((o) => o.keys.length));
303
+ const host = fetchHost(this.request.toolName, this.request.input);
296
304
  return APPROVAL_OPTIONS.map((option, i) => {
297
305
  const selected = i === this.selected;
298
306
  const keys = compact ? option.keys.slice(0, 1) : option.keys;
299
- const left = `${selected ? g.prompt : " "} ${i + 1}. ${option.label}`;
307
+ const text = option.decision === "allow_session" && host !== undefined
308
+ ? msg().approval.option.allowSessionHost(host)
309
+ : option.label;
310
+ const left = `${selected ? g.prompt : " "} ${i + 1}. ${text}`;
300
311
  const right = keys.padEnd(keyWidth);
301
312
  const room = Math.max(1, width - visibleWidth(right) - 1);
302
313
  const label = selected ? theme.fg("accent", theme.bold(left)) : left;
@@ -361,8 +372,10 @@ export function approvalOutcomeText(request, outcome, taskAgent) {
361
372
  switch (outcome) {
362
373
  case "allow":
363
374
  return m.allow(what);
364
- case "allow_session":
365
- return m.allowSession(what);
375
+ case "allow_session": {
376
+ const host = fetchHost(request.toolName, request.input);
377
+ return host === undefined ? m.allowSession(what) : m.allowSessionHost(what, host);
378
+ }
366
379
  case "deny":
367
380
  return m.deny(what);
368
381
  case "cancelled":
@@ -27,6 +27,8 @@ import { type PickerSpec } from "./pickers.js";
27
27
  export declare const INTERACTIVE_COMMANDS: readonly CommandInfo[];
28
28
  /** 补全与 /help 用的完整命令表。 */
29
29
  export declare const ALL_COMMANDS: readonly CommandInfo[];
30
+ /** 补全用:完整命令表去掉能力关闭的(`availableCommands`)。 */
31
+ export declare function completionCommands(runtime: Pick<Runtime, "tools">, session: Pick<AgentSession, "getTools">): CommandInfo[];
30
32
  /** `/help` 末尾的按键说明(多行)。 */
31
33
  export declare function keyHints(): string;
32
34
  export interface CommandUi {
@@ -22,7 +22,7 @@ import { THINKING_LEVELS } from "../../ai/thinking.js";
22
22
  import { listSessions } from "../../cli/compose-store.js";
23
23
  import { hideFakeProvider } from "../../cli/fake-visibility.js";
24
24
  import { autoLayerText, permissionModeLabel } from "../../permissions/modes.js";
25
- import { BUILTIN_COMMANDS, parseSlash, runSlashCommand, } from "../commands-core.js";
25
+ import { BUILTIN_COMMANDS, availableCommands, parseSlash, runSlashCommand, } from "../commands-core.js";
26
26
  import { contextPanel } from "../context-report.js";
27
27
  import { contentText } from "./message-view.js";
28
28
  import { cachePanel, permissionsPanel, sessionPanel } from "./panels.js";
@@ -42,6 +42,10 @@ export const INTERACTIVE_COMMANDS = [
42
42
  ];
43
43
  /** 补全与 /help 用的完整命令表。 */
44
44
  export const ALL_COMMANDS = [...BUILTIN_COMMANDS, ...INTERACTIVE_COMMANDS];
45
+ /** 补全用:完整命令表去掉能力关闭的(`availableCommands`)。 */
46
+ export function completionCommands(runtime, session) {
47
+ return availableCommands(runtime, session, ALL_COMMANDS);
48
+ }
45
49
  /** `/help` 末尾的按键说明(多行)。 */
46
50
  export function keyHints() {
47
51
  return msg().interactive.commands.keyHints;
@@ -32,6 +32,11 @@ import { StatusArea } from "./status-area.js";
32
32
  import type { StatusBar } from "./status-bar.js";
33
33
  import { type ProgramStatusOptions } from "./program-status-ui.js";
34
34
  import { ToolTracker } from "./tool-view.js";
35
+ /** 底部提示行的清除计时器(测试注入手动时钟,帧黄金不再依赖墙钟)。 */
36
+ export interface HintTimers {
37
+ set(fn: () => void, ms: number): unknown;
38
+ clear(handle: unknown): void;
39
+ }
35
40
  export interface InteractiveModeOptions {
36
41
  /** 缺省 `ProcessTerminal`(测试注入 MemoryTerminal)。 */
37
42
  terminal?: Terminal;
@@ -49,6 +54,8 @@ export interface InteractiveModeOptions {
49
54
  onReady?(handle: InteractiveHandle): void;
50
55
  /** 剪贴板读取的注入(测试;缺省调系统命令)。 */
51
56
  clipboard?: ClipboardDeps;
57
+ /** 提示行清除计时器的注入(测试;缺省真实 `setTimeout`)。 */
58
+ hintTimers?: HintTimers;
52
59
  /** 终端程序状态 OSC 7501(缺省只在真实终端启用;测试注入终端时给了才启用)。 */
53
60
  programStatus?: ProgramStatusOptions;
54
61
  }
@@ -33,7 +33,7 @@ import { BYPASS_MODE, createBypassGate } from "../../permissions/bypass.js";
33
33
  import { onTerminationSignals } from "../shared.js";
34
34
  import { AgentUi, agentCommandHooks } from "./agent-ui.js";
35
35
  import { approvalOverlayHooks, createApprovalUi } from "./approval-ui.js";
36
- import { ALL_COMMANDS, runInteractiveCommand } from "./commands.js";
36
+ import { completionCommands, runInteractiveCommand } from "./commands.js";
37
37
  import { memoryPanelFor } from "./memory-panel.js";
38
38
  import { InteractiveCompletion } from "./completion.js";
39
39
  import { confirmBypass } from "./confirm-dialog.js";
@@ -56,6 +56,14 @@ import { loadKeys, processTerminal } from "./terminal-setup.js";
56
56
  import { ToolTracker } from "./tool-view.js";
57
57
  import { backgroundTasks, blockingTasks, keyLabel, runningBackground } from "./task-background.js";
58
58
  const HINT_MS = 2500;
59
+ const realHintTimers = {
60
+ set: (fn, ms) => {
61
+ const handle = setTimeout(fn, ms);
62
+ handle.unref?.();
63
+ return handle;
64
+ },
65
+ clear: (handle) => clearTimeout(handle),
66
+ };
59
67
  export function runInteractiveMode(runtime, context, options = {}) {
60
68
  const terminal = options.terminal ?? processTerminal();
61
69
  const ui = runtime.config.ui ?? {};
@@ -114,7 +122,7 @@ export function runInteractiveMode(runtime, context, options = {}) {
114
122
  ? undefined
115
123
  : (options.historyFile ?? join(runtime.paths.dataDir, "history"));
116
124
  const completion = new InteractiveCompletion({
117
- commands: () => ALL_COMMANDS,
125
+ commands: () => completionCommands(runtime, session),
118
126
  prompts: () => runtime.resources.prompts,
119
127
  skills: () => runtime.resources.skills,
120
128
  cwd: () => session.state.cwd,
@@ -142,6 +150,7 @@ export function runInteractiveMode(runtime, context, options = {}) {
142
150
  tui.addChild(area.quota);
143
151
  // ---- 小工具 ---------------------------------------------------------------
144
152
  let finished = false;
153
+ const hintTimers = options.hintTimers ?? realHintTimers;
145
154
  let hintTimer;
146
155
  let resolveExit = () => undefined;
147
156
  const render = () => tui.requestRender();
@@ -152,14 +161,13 @@ export function runInteractiveMode(runtime, context, options = {}) {
152
161
  const showHint = (text, ms = HINT_MS) => {
153
162
  hint.setText(text === "" ? "" : theme.fg("dim", text));
154
163
  if (hintTimer !== undefined)
155
- clearTimeout(hintTimer);
164
+ hintTimers.clear(hintTimer);
156
165
  hintTimer = undefined;
157
166
  if (text !== "") {
158
- hintTimer = setTimeout(() => {
167
+ hintTimer = hintTimers.set(() => {
159
168
  hint.setText("");
160
169
  render();
161
170
  }, ms);
162
- hintTimer.unref?.();
163
171
  }
164
172
  render();
165
173
  };
@@ -423,7 +431,7 @@ export function runInteractiveMode(runtime, context, options = {}) {
423
431
  finished = true;
424
432
  logoAnimation?.finish();
425
433
  if (hintTimer !== undefined)
426
- clearTimeout(hintTimer);
434
+ hintTimers.clear(hintTimer);
427
435
  loader.stop();
428
436
  area.dispose();
429
437
  osc7501.exit();
@@ -44,3 +44,5 @@ export interface SummaryInput {
44
44
  }
45
45
  /** 完成后的一行结果摘要(已着色,不含 `⎿`)。 */
46
46
  export declare function resultSummary(input: SummaryInput, theme: Theme): string;
47
+ /** web_fetch 请求的主机名(审批「本会话允许该主机」用);不是 web_fetch 或 URL 不合法返回 undefined。 */
48
+ export declare function fetchHost(toolName: string, input: unknown): string | undefined;
@@ -12,6 +12,7 @@ import { msg } from "../../i18n/index.js";
12
12
  import { isAbsolute, relative } from "node:path";
13
13
  import { stripAnsi } from "../../tui.js";
14
14
  import { formatTokens } from "./status-bar.js";
15
+ import { formatSize } from "../../tools/truncate.js";
15
16
  /** 工具输出 → 可安全显示的行。 */
16
17
  export function cleanLines(text) {
17
18
  const clean = stripAnsi(text)
@@ -60,6 +61,8 @@ export function toolSummary(name, args, cwd) {
60
61
  }
61
62
  case "task":
62
63
  return flat(str("description") ?? str("prompt") ?? "");
64
+ case "web_fetch":
65
+ return flat((str("url") ?? "").replace(/^https?:\/\//i, ""));
63
66
  default:
64
67
  if (path !== undefined)
65
68
  return displayPath(path, cwd);
@@ -216,7 +219,31 @@ export function resultSummary(input, theme) {
216
219
  : [];
217
220
  return muted([m.done, duration, ...tokens].join(" · "));
218
221
  }
222
+ case "web_fetch": {
223
+ const finalUrl = details["finalUrl"];
224
+ if (num(details["bytes"]) === 0 &&
225
+ typeof finalUrl === "string" &&
226
+ lines[0]?.startsWith("Redirected"))
227
+ return muted(m.fetchRedirect(finalUrl));
228
+ const title = typeof details["title"] === "string" ? flat(details["title"], 60) : undefined;
229
+ const format = typeof details["format"] === "string" ? details["format"] : "";
230
+ return muted(m.fetched(title, formatSize(num(details["bytes"]) ?? 0), format));
231
+ }
219
232
  default:
220
233
  return muted(count === 0 ? m.done : m.outputLines(count));
221
234
  }
222
235
  }
236
+ /** web_fetch 请求的主机名(审批「本会话允许该主机」用);不是 web_fetch 或 URL 不合法返回 undefined。 */
237
+ export function fetchHost(toolName, input) {
238
+ if (toolName !== "web_fetch")
239
+ return undefined;
240
+ const raw = record(input)["url"];
241
+ if (typeof raw !== "string")
242
+ return undefined;
243
+ try {
244
+ return new URL(raw.trim()).hostname.replace(/^\[|\]$/g, "").toLowerCase() || undefined;
245
+ }
246
+ catch {
247
+ return undefined;
248
+ }
249
+ }
@@ -24,7 +24,7 @@ import { switchSession } from "../../cli/compose-session.js";
24
24
  import { AmaError } from "../../errors.js";
25
25
  import { isPermissionMode } from "../../permissions/modes.js";
26
26
  import { PERMISSION_MODES_STRICT_FIRST } from "../../permissions/types.js";
27
- import { BUILTIN_COMMANDS } from "../commands-core.js";
27
+ import { availableCommands } from "../commands-core.js";
28
28
  import { msg } from "../../i18n/index.js";
29
29
  import { sessionTrace } from "../../trace/query-session.js";
30
30
  /** 审批 broker:等客户端 `permission_response`。 */
@@ -144,9 +144,9 @@ async function availableModels(runtime) {
144
144
  }
145
145
  return out;
146
146
  }
147
- function commands(runtime) {
147
+ function commands(runtime, session) {
148
148
  return [
149
- ...BUILTIN_COMMANDS.map((c) => ({
149
+ ...availableCommands(runtime, session).map((c) => ({
150
150
  name: c.name,
151
151
  description: c.description,
152
152
  source: "builtin",
@@ -308,7 +308,7 @@ export const handlers = {
308
308
  ctx.session().setPermissionMode(p.mode);
309
309
  return { mode: p.mode };
310
310
  },
311
- get_commands: async (_p, ctx) => ({ commands: commands(ctx.runtime) }),
311
+ get_commands: async (_p, ctx) => ({ commands: commands(ctx.runtime, ctx.session()) }),
312
312
  get_skills: async (_p, ctx) => ({ skills: ctx.runtime.resources.skills }),
313
313
  // [W5-F] 计划 / 任务命令(docs/history/wave5-plan.md §6.5、§7.5)
314
314
  plan_response: async (p, ctx) => {
@@ -8,6 +8,9 @@
8
8
  * - 写入目标:重定向(`>`、`>>`、`&>`)、tee、cp / mv / mkdir / touch / ln / chmod 等的目标在项目外或受保护;
9
9
  * - 参数里出现机密路径(`cat .env`、`< ~/.ssh/id_rsa`、`--env-file=.env`)。
10
10
  *
11
+ * `env` / `printenv` 不在安全名单里:子进程继承整个进程环境(供应商 API key 等),打印出来就会进
12
+ * tool_result 与会话文件。段里只剩环境赋值或包装命令(`env`、`env FOO=1`)同样不放行。
13
+ *
11
14
  * 静态判定(全部满足即放行):每一段都命中安全名单({@link AUTO_SAFE_COMMANDS} + 用户追加),整条命令
12
15
  * 没有命令替换、参数没有变量展开 `$X`、没有可能匹配点文件的通配、没有超深嵌套。
13
16
  *
@@ -8,6 +8,9 @@
8
8
  * - 写入目标:重定向(`>`、`>>`、`&>`)、tee、cp / mv / mkdir / touch / ln / chmod 等的目标在项目外或受保护;
9
9
  * - 参数里出现机密路径(`cat .env`、`< ~/.ssh/id_rsa`、`--env-file=.env`)。
10
10
  *
11
+ * `env` / `printenv` 不在安全名单里:子进程继承整个进程环境(供应商 API key 等),打印出来就会进
12
+ * tool_result 与会话文件。段里只剩环境赋值或包装命令(`env`、`env FOO=1`)同样不放行。
13
+ *
11
14
  * 静态判定(全部满足即放行):每一段都命中安全名单({@link AUTO_SAFE_COMMANDS} + 用户追加),整条命令
12
15
  * 没有命令替换、参数没有变量展开 `$X`、没有可能匹配点文件的通配、没有超深嵌套。
13
16
  *
@@ -38,7 +41,6 @@ export const AUTO_SAFE_COMMANDS = [
38
41
  "echo",
39
42
  "printf",
40
43
  "which",
41
- "printenv",
42
44
  "true",
43
45
  "false",
44
46
  "cut",
@@ -451,6 +453,7 @@ function cdTarget(argv, cwd) {
451
453
  function parseSegment(segment) {
452
454
  const redirects = extractRedirects(segment);
453
455
  return {
456
+ first: shellWords(redirects.rest.replace(/^[({\s]+/, ""))[0],
454
457
  argv: commandWords(redirects.rest),
455
458
  withSudo: commandWords(redirects.rest, true),
456
459
  redirects,
@@ -478,8 +481,12 @@ function ruleAsk(info, options) {
478
481
  }
479
482
  function safeSegment(info, extra) {
480
483
  const argv = info.withSudo;
481
- if (argv.length === 0)
482
- return undefined;
484
+ // 只剩环境赋值或包装命令:`env` / `env FOO=1` 会打印整个环境(含密钥),不放行。
485
+ if (argv.length === 0) {
486
+ return info.first === undefined
487
+ ? "empty command"
488
+ : `${base(info.first)} is not in the auto safe list`;
489
+ }
483
490
  for (const word of argv) {
484
491
  if (word.includes("$"))
485
492
  return `variable expansion in ${word}`;
@@ -25,7 +25,7 @@ export const CLASSIFIER_SYSTEM_PROMPT = [
25
25
  "- it stays inside the project directory;",
26
26
  "- it is read-only, or a routine and easily reversible development step (build, test, lint, format, generate code, run a project script);",
27
27
  "- it does not read or change secrets, credentials, keys or VCS internals;",
28
- "- it does not use the network, install software, publish, deploy, push, or delete data the user may need;",
28
+ "- it does not use the network, install software, publish, deploy, push, or delete data the user may need (a web_fetch of a public documentation or package-registry page is routine; answer ask when its query string looks like a key or a long random token);",
29
29
  "- it plausibly serves the user's latest request.",
30
30
  'Otherwise answer "ask". When unsure, answer "ask".',
31
31
  "",
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 权限类 `network`(`web_fetch` 用;docs/guides/permissions.md「网络类」)。照 `memory-class.ts` 的写法
3
+ * 折成已有的类走真值表:
4
+ *
5
+ * - 按 `execute` 判:default / auto-edit 询问(审批第三项「本会话允许该主机」)、allowlist 只放行 allow 规则
6
+ * 命中的、full-auto 放行、auto 交分类器;
7
+ * - plan 例外:**询问**(只读但可外泄;plan 的调研常要看文档),无人值守照常 ask → deny;
8
+ * - auto 规则层:URL 静态校验不过(非 http(s)、userinfo、本机名、私网 / 元数据 IP 字面量)直接 deny;
9
+ * 解析后的地址检查在执行层(url-guard.ts),这里不查 DNS。
10
+ *
11
+ * 规则 `web_fetch(<pattern>)` 对 `host/path`(不含协议、端口与查询串,主机名小写)匹配:
12
+ * - 不含 `/` 的 pattern 只比主机:裸域名 `example.com` 命中它自己与全部子域;带 `*` 时按 glob
13
+ * (`*.example.com`),任意路径;
14
+ * - 含 `/` 的 pattern 对整个 `host/path` 做 glob:`*` 不跨 `/`,`**` 跨(`docs.example.com/api/**`)。
15
+ */
16
+ import type { ToolPermission } from "../tools/types.js";
17
+ /** `network` 类在真值表里按哪一类判定(plan 例外由管线处理)。 */
18
+ export declare function networkEffectivePermission(): Exclude<ToolPermission, "memory" | "network">;
19
+ /** 输入里的 URL(`url` 字段,能解析的 http(s));没有返回 undefined。 */
20
+ export declare function inputUrl(input: unknown): URL | undefined;
21
+ /** 规则匹配的对象:`host/path`(主机名小写、无端口与查询串)。 */
22
+ export declare function urlSubject(url: URL): string;
23
+ /** `web_fetch(<pattern>)` 是否命中这次调用。 */
24
+ export declare function networkRuleMatches(pattern: string, input: unknown): boolean;
25
+ /** 会话记忆「本会话允许该主机」的归一化前缀:小写主机名。 */
26
+ export declare function networkGrantHost(input: unknown): string | undefined;
27
+ /** auto 规则层:URL 静态校验不过时的拒绝原因(不查 DNS)。 */
28
+ export declare function networkBlockReason(input: unknown): string | undefined;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 权限类 `network`(`web_fetch` 用;docs/guides/permissions.md「网络类」)。照 `memory-class.ts` 的写法
3
+ * 折成已有的类走真值表:
4
+ *
5
+ * - 按 `execute` 判:default / auto-edit 询问(审批第三项「本会话允许该主机」)、allowlist 只放行 allow 规则
6
+ * 命中的、full-auto 放行、auto 交分类器;
7
+ * - plan 例外:**询问**(只读但可外泄;plan 的调研常要看文档),无人值守照常 ask → deny;
8
+ * - auto 规则层:URL 静态校验不过(非 http(s)、userinfo、本机名、私网 / 元数据 IP 字面量)直接 deny;
9
+ * 解析后的地址检查在执行层(url-guard.ts),这里不查 DNS。
10
+ *
11
+ * 规则 `web_fetch(<pattern>)` 对 `host/path`(不含协议、端口与查询串,主机名小写)匹配:
12
+ * - 不含 `/` 的 pattern 只比主机:裸域名 `example.com` 命中它自己与全部子域;带 `*` 时按 glob
13
+ * (`*.example.com`),任意路径;
14
+ * - 含 `/` 的 pattern 对整个 `host/path` 做 glob:`*` 不跨 `/`,`**` 跨(`docs.example.com/api/**`)。
15
+ */
16
+ import { bareHostname, checkFetchUrl } from "../tools/web/url-guard.js";
17
+ /** `network` 类在真值表里按哪一类判定(plan 例外由管线处理)。 */
18
+ export function networkEffectivePermission() {
19
+ return "execute";
20
+ }
21
+ /** 输入里的 URL(`url` 字段,能解析的 http(s));没有返回 undefined。 */
22
+ export function inputUrl(input) {
23
+ const raw = typeof input === "object" && input !== null
24
+ ? input["url"]
25
+ : undefined;
26
+ if (typeof raw !== "string")
27
+ return undefined;
28
+ try {
29
+ const url = new URL(raw.trim());
30
+ return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ /** 规则匹配的对象:`host/path`(主机名小写、无端口与查询串)。 */
37
+ export function urlSubject(url) {
38
+ return `${bareHostname(url)}${url.pathname === "" ? "/" : url.pathname}`;
39
+ }
40
+ function globRegExp(pattern) {
41
+ let body = "";
42
+ for (let i = 0; i < pattern.length; i++) {
43
+ const ch = pattern[i];
44
+ if (ch === "*" && pattern[i + 1] === "*") {
45
+ body += ".*";
46
+ i++;
47
+ }
48
+ else if (ch === "*")
49
+ body += "[^/]*";
50
+ else if (ch === "?")
51
+ body += "[^/]";
52
+ else
53
+ body += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
54
+ }
55
+ return new RegExp(`^${body}$`, "i");
56
+ }
57
+ /** `web_fetch(<pattern>)` 是否命中这次调用。 */
58
+ export function networkRuleMatches(pattern, input) {
59
+ const url = inputUrl(input);
60
+ if (url === undefined)
61
+ return false;
62
+ const p = pattern
63
+ .trim()
64
+ .replace(/^https?:\/\//i, "")
65
+ .toLowerCase();
66
+ if (p === "*" || p === "**")
67
+ return true;
68
+ const host = bareHostname(url);
69
+ if (!p.includes("/")) {
70
+ if (p.includes("*") || p.includes("?"))
71
+ return globRegExp(p).test(host);
72
+ const bare = p.replace(/\.$/, "");
73
+ return host === bare || host.endsWith(`.${bare}`);
74
+ }
75
+ return globRegExp(p).test(urlSubject(url));
76
+ }
77
+ /** 会话记忆「本会话允许该主机」的归一化前缀:小写主机名。 */
78
+ export function networkGrantHost(input) {
79
+ const url = inputUrl(input);
80
+ return url === undefined ? undefined : bareHostname(url);
81
+ }
82
+ /** auto 规则层:URL 静态校验不过时的拒绝原因(不查 DNS)。 */
83
+ export function networkBlockReason(input) {
84
+ const raw = typeof input === "object" && input !== null
85
+ ? input["url"]
86
+ : undefined;
87
+ if (typeof raw !== "string")
88
+ return "url is missing";
89
+ const checked = checkFetchUrl(raw);
90
+ return checked.ok ? undefined : checked.reason;
91
+ }
@@ -60,7 +60,7 @@ export declare const UNATTENDED_MESSAGE = "This tool call requires approval, but
60
60
  export declare const ALLOWLIST_MESSAGE = "Not in the allowlist: permission mode \"allowlist\" only runs read-only tools and calls matched by an allow rule, and never asks.";
61
61
  interface SessionGrant {
62
62
  toolName: string;
63
- kind: "command" | "dir" | "any";
63
+ kind: "command" | "dir" | "host" | "any";
64
64
  prefix: string;
65
65
  }
66
66
  export declare function sessionGrantFor(toolName: string, input: unknown, cwd: string): SessionGrant;
@@ -36,6 +36,7 @@
36
36
  import { dirname, sep } from "node:path";
37
37
  import { isBackgroundJobQuery } from "../tools/background-jobs.js";
38
38
  import { memoryEffectivePermission } from "./memory-class.js";
39
+ import { networkBlockReason, networkEffectivePermission, networkGrantHost, } from "./network-class.js";
39
40
  import { findAllowRule, findDenyRule, inputCommand, inputPath, normalizeCommand, splitShellSegments, } from "./rules.js";
40
41
  import { collectNestedCommands, matchDangerous } from "./dangerous.js";
41
42
  import { analyzeBashForAuto, secretReferenceReason } from "./auto-safe.js";
@@ -110,7 +111,7 @@ function auditSummary(toolName, input) {
110
111
  const command = inputCommand(input);
111
112
  const raw = command ??
112
113
  (typeof input === "object" && input !== null
113
- ? input["path"]
114
+ ? (input["path"] ?? input["url"])
114
115
  : undefined);
115
116
  const text = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : toolName;
116
117
  return text.length > 80 ? `${text.slice(0, 79)}…` : text;
@@ -118,6 +119,9 @@ function auditSummary(toolName, input) {
118
119
  export function sessionGrantFor(toolName, input, cwd) {
119
120
  if (toolName === "memory")
120
121
  return { toolName, kind: "any", prefix: "" }; // [W6-M] 本会话允许全部写命令
122
+ const host = networkGrantHost(input); // web_fetch:本会话允许该主机
123
+ if (host !== undefined)
124
+ return { toolName, kind: "host", prefix: host };
121
125
  const command = inputCommand(input);
122
126
  if (command !== undefined) {
123
127
  const words = normalizeCommand(command).split(" ").slice(0, 2);
@@ -133,6 +137,8 @@ function grantCovers(grant, toolName, input, cwd) {
133
137
  return false;
134
138
  if (grant.kind === "any")
135
139
  return true;
140
+ if (grant.kind === "host")
141
+ return networkGrantHost(input) === grant.prefix;
136
142
  if (grant.kind === "command") {
137
143
  const command = inputCommand(input);
138
144
  if (command === undefined || /\$\(|`/.test(command))
@@ -218,9 +224,13 @@ export class PermissionPipeline {
218
224
  }
219
225
  evaluate(raw) {
220
226
  // [W6-C0] 权限类 memory 按命令折成 read / execute(memory-class.ts)
227
+ // network(web_fetch)按 execute 判,plan 例外为询问、auto 规则层先做 URL 静态校验(network-class.ts)
228
+ const network = raw.permission === "network";
221
229
  const input = raw.permission === "memory"
222
230
  ? { ...raw, permission: memoryEffectivePermission(raw.input) }
223
- : raw;
231
+ : network
232
+ ? { ...raw, permission: networkEffectivePermission() }
233
+ : raw;
224
234
  const { toolName, permission } = input;
225
235
  // ① deny
226
236
  const layers = layeredInputs(toolName, input.input);
@@ -265,13 +275,15 @@ export class PermissionPipeline {
265
275
  return verdict;
266
276
  }
267
277
  if (this.currentMode === "auto")
268
- return this.evaluateAuto(input, layers, command);
278
+ return this.evaluateAuto(input, layers, command, network);
269
279
  if (this.currentMode === "allowlist")
270
280
  return this.evaluateAllowlist(input, layers);
271
281
  // ③ 模式(plan 细化见 planDecision)
272
- const plan = this.currentMode === "plan"
273
- ? planDecision(input, this.planBashMode, this.cwd, this.projectRoot)
274
- : undefined;
282
+ const plan = this.currentMode !== "plan"
283
+ ? undefined
284
+ : network
285
+ ? { decision: "ask" }
286
+ : planDecision(input, this.planBashMode, this.cwd, this.projectRoot);
275
287
  const byMode = plan?.decision ?? modeDecision(this.currentMode, permission);
276
288
  if (byMode === "deny") {
277
289
  return {
@@ -387,12 +399,17 @@ export class PermissionPipeline {
387
399
  }
388
400
  return {};
389
401
  }
390
- evaluateAuto(input, layers, command) {
402
+ evaluateAuto(input, layers, command, network = false) {
391
403
  const rule = (decision, reason) => ({
392
404
  layer: "rule",
393
405
  decision,
394
406
  reason,
395
407
  });
408
+ const blocked = network ? networkBlockReason(input.input) : undefined;
409
+ if (blocked !== undefined) {
410
+ const message = `Auto mode refuses this fetch: ${blocked}`;
411
+ return { decision: "deny", step: "auto-rule", message, auto: rule("deny", blocked) };
412
+ }
396
413
  const checked = this.autoRuleReason(input, command);
397
414
  if (checked.ask !== undefined) {
398
415
  return {
@@ -444,6 +444,9 @@ export function previewAction(request, options) {
444
444
  else if (kind === "edit" && typeof input["path"] === "string") {
445
445
  previewEdit(input, request, c);
446
446
  }
447
+ else if (request.toolName === "web_fetch" && typeof input["url"] === "string") {
448
+ c.lines.push(`GET ${input["url"].replace(/\s+/g, "").slice(0, 2048)}`);
449
+ }
447
450
  else if (kind === "other") {
448
451
  const key = SUMMARY_KEYS.find((k) => typeof input[k] === "string" && input[k] !== "");
449
452
  if (key !== undefined)
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * 判定 = auto 的静态判定(`analyzeBashForAuto`:分词、嵌套展开、规则层的网络 / 删除 / 写入目标 /
5
5
  * 机密路径、命令替换、变量展开、点文件通配)**全部通过**,再加四条更窄的限制:
6
- * - 每段命令都命中 {@link READONLY_BASH}(比 auto 安全名单窄:去掉测试 / 构建运行器、`printenv`
7
- * 这类会把环境变量里的密钥打进上下文的命令);
6
+ * - 每段命令都命中 {@link READONLY_BASH}(比 auto 安全名单窄:去掉测试 / 构建运行器;`env` /
7
+ * `printenv` 这类会把环境变量里的密钥打进上下文的命令两边都没有);
8
8
  * - 没有输出重定向(`/dev/null` 等设备除外);
9
9
  * - 没有嵌套 shell(`sh -c`、`eval`、`xargs`、`find -exec`)与进程替换;
10
10
  * - 段首没有环境赋值或包装命令(`GIT_EXTERNAL_DIFF=… git diff`、`env …`、`nohup …`)。
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * 判定 = auto 的静态判定(`analyzeBashForAuto`:分词、嵌套展开、规则层的网络 / 删除 / 写入目标 /
5
5
  * 机密路径、命令替换、变量展开、点文件通配)**全部通过**,再加四条更窄的限制:
6
- * - 每段命令都命中 {@link READONLY_BASH}(比 auto 安全名单窄:去掉测试 / 构建运行器、`printenv`
7
- * 这类会把环境变量里的密钥打进上下文的命令);
6
+ * - 每段命令都命中 {@link READONLY_BASH}(比 auto 安全名单窄:去掉测试 / 构建运行器;`env` /
7
+ * `printenv` 这类会把环境变量里的密钥打进上下文的命令两边都没有);
8
8
  * - 没有输出重定向(`/dev/null` 等设备除外);
9
9
  * - 没有嵌套 shell(`sh -c`、`eval`、`xargs`、`find -exec`)与进程替换;
10
10
  * - 段首没有环境赋值或包装命令(`GIT_EXTERNAL_DIFF=… git diff`、`env …`、`nohup …`)。
@@ -6,6 +6,7 @@
6
6
  * - 带 `path` 的工具(read / write / edit / ls / grep / glob 及宿主工具):对路径做 glob(`**`)。
7
7
  * 相对模式匹配 cwd 内的相对路径;以 `**` 开头的模式也匹配绝对路径(例如 .ssh 目录规则命中 ~/.ssh 下的文件);
8
8
  * 绝对模式(`/…`、`~/…`)匹配绝对路径。缺 path 的调用以 cwd 为路径。
9
+ * - 带 `url` 的工具(web_fetch):对 `host/path` 匹配,裸域名含子域(network-class.ts);
9
10
  * - 其它工具带 pattern 的规则不匹配(无可比对的对象)。
10
11
  *
11
12
  * bash 复合命令:deny 规则命中整条或**任一**段即生效;allow 规则要求**每一**段都被某条 allow 覆盖,
@@ -6,6 +6,7 @@
6
6
  * - 带 `path` 的工具(read / write / edit / ls / grep / glob 及宿主工具):对路径做 glob(`**`)。
7
7
  * 相对模式匹配 cwd 内的相对路径;以 `**` 开头的模式也匹配绝对路径(例如 .ssh 目录规则命中 ~/.ssh 下的文件);
8
8
  * 绝对模式(`/…`、`~/…`)匹配绝对路径。缺 path 的调用以 cwd 为路径。
9
+ * - 带 `url` 的工具(web_fetch):对 `host/path` 匹配,裸域名含子域(network-class.ts);
9
10
  * - 其它工具带 pattern 的规则不匹配(无可比对的对象)。
10
11
  *
11
12
  * bash 复合命令:deny 规则命中整条或**任一**段即生效;allow 规则要求**每一**段都被某条 allow 覆盖,
@@ -19,6 +20,7 @@ import { PERMISSION_MODES_STRICT_FIRST } from "./types.js";
19
20
  import { globBody } from "../tools/glob.js";
20
21
  import { expandHome, resolvePath, toPosix } from "../tools/paths.js";
21
22
  import { memoryRuleMatches } from "./memory-class.js";
23
+ import { networkRuleMatches } from "./network-class.js";
22
24
  /** 内置缺省 deny 表(用户可经 `builtinDeny` 选项移除)。 */
23
25
  export const BUILTIN_DENY_RULES = [
24
26
  "write(**/.git/**)",
@@ -153,7 +155,9 @@ function matchesSubject(rule, toolName, input, cwd, command) {
153
155
  if (command !== undefined)
154
156
  return wildcardToRegExp(rule.pattern).test(command);
155
157
  const abs = inputPath(toolName, input, cwd);
156
- return abs !== undefined && pathMatches(rule.pattern, abs, cwd);
158
+ if (abs !== undefined)
159
+ return pathMatches(rule.pattern, abs, cwd);
160
+ return networkRuleMatches(rule.pattern, input);
157
161
  }
158
162
  /** 第一条命中的 deny 规则。 */
159
163
  export function findDenyRule(rules, toolName, input, cwd) {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * | 预设 | 模型直接看到 |
5
5
  * | ------------- | ----------------------------------------- |
6
- * | `default` | read、edit、write、bash、grep、glob |
6
+ * | `default` | read、edit、write、bash、grep、glob、web_fetch |
7
7
  * | `minimal` | read、edit、write、bash |
8
8
  * | `codemode-only` | 只有 codemode(其余工具在脚本里调用) |
9
9
  * | `coordinator` | read + 宿主注册的工具 |
@@ -23,6 +23,8 @@
23
23
  * `codemode.mode` 显式配置覆盖(缺省配置不写这个键,以后调整映射能惠及老用户)。
24
24
  * - `coordinator` 预设即使显式 `on`,脚本里能调用的工具也只限活动集(read + 宿主工具),见
25
25
  * `codemodeCallableFilter`:协调者「不写文件、不跑 bash」的约定不能经 codemode 绕过。
26
+ * - (#242)`coordinator` 预设不阻塞:`task` 缺省后台(无视 `subagents.background`,调用参数 `background`
27
+ * 与类型定义的 `background:` 仍优先)、`task_ctl wait` 的超时封顶 5 秒;其它预设不变。
26
28
  * - codemode 工具由 B10 经组装根的 `toolFactories` 注册(生效模式为 off 时不注册);不可用
27
29
  * (例如 `codemode.requireStrict` 而运行时 Node 不隔离网络)时 codemode-only 预设**回退到 default 并
28
30
  * warning**(不报错:零配置用户不应因此起不来),显式 `on` 同样忽略;跟随预设得到的 `on` 静默回退。
@@ -56,6 +58,11 @@ export declare function effectiveCodemodeMode(config: Pick<AmaConfig, "tools" |
56
58
  * 不限(`undefined`;`on` 模式下脚本还能调没直接暴露的 ls / task / todo 等)。
57
59
  */
58
60
  export declare function codemodeCallableFilter(config: Pick<AmaConfig, "tools">): "active" | undefined;
61
+ /**
62
+ * [#242] 是否 `coordinator` 预设:协调者只分派、不阻塞——`task` 缺省后台、`task_ctl wait` 超时封顶
63
+ * (组装根据此绑定,见 cli/compose-agents.ts)。其它预设不受影响。
64
+ */
65
+ export declare function isCoordinatorPreset(config: Pick<AmaConfig, "tools">): boolean;
59
66
  /**
60
67
  * `tools.default` 微调:不带前缀的名字整组替换 `base`,再依次应用 `+name` / `-name`。
61
68
  * 返回去重后的名字(保持首次出现顺序)。