@staix/agent-hub 0.19.0 → 0.21.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. package/templates/packs/example.pack.json +13 -0
@@ -11,7 +11,7 @@ export interface CriteriaReceipt {
11
11
  /** A checklist stored under `phase` before #481 S8. Its task list is shown as ignored; board split-undo still treats it as a reference. */
12
12
  export interface LegacyPhaseReceipt extends CriteriaReceipt { tasks: { id: number; after: number[] }[]; }
13
13
  const name = (s: unknown): s is string => typeof s === "string" && /^[a-z][a-z0-9-]{0,47}$/.test(s);
14
- const criterionId = (s: unknown): s is string => typeof s === "string" && /^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(s);
14
+ export const criterionId = (s: unknown): s is string => typeof s === "string" && /^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(s);
15
15
  const hash = (s: unknown): s is string => typeof s === "string" && /^[0-9a-f]{40}$/.test(s);
16
16
  const fields = (v: unknown, keys: string[], label: string): Record<string, unknown> => {
17
17
  if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).some(k => !keys.includes(k))) throw new Error(`${label}: invalid fields`);
package/src/local/deny.ts CHANGED
@@ -163,6 +163,27 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit:
163
163
  ];
164
164
  }
165
165
 
166
+ /**
167
+ * Full SBPL rule lines for the shared object store `<common>/objects` (#864). Every checkout of the repository, and the hub's own host add/commit/push (#845), trusts what is
168
+ * there without rehashing it, so a sandboxed command may only ADD loose objects: `git add` and `git commit` keep working, and git skips writing an object that exists. Refused:
169
+ * every write under `objects/info` (alternates, http-alternates, commit-graph) and `objects/pack` (a new pack wins over a loose copy of the same id, since git reads packs
170
+ * first); the `objects`, `objects/info` and `objects/pack` entries themselves (no rename, replace or removal); removing or renaming a fan-out directory; and changing,
171
+ * removing, chmod-ing or renaming over an existing loose object. The cost: `git gc`, `git repack`, `git fetch`, `git maintenance` and `git commit-graph write` do not run in
172
+ * the sandbox. A hardlink to an existing loose object is refused under these rules (verified, into the project and into the command's own temp dir). Residual: an object id
173
+ * that does not exist yet can be planted before git writes it (the attacker must predict its exact content); a local checkout shows it, a push of it is rejected by the
174
+ * receiving side's rehash. Seatbelt ignores `{n}`
175
+ * repeats, so the two fan-out hex digits are written out (verified with the real sandbox-exec).
176
+ */
177
+ export function objectStoreRules(common: string): string[] {
178
+ const objects = `${common}/objects`, o = sbplEscape(objects);
179
+ const re = (s: string) => `(regex ${sbplString(s)})`;
180
+ return [
181
+ `(deny file-write* (subpath ${sbplString(`${objects}/info`)}) (subpath ${sbplString(`${objects}/pack`)}) ${[objects, `${objects}/info`, `${objects}/pack`].map((p) => `(literal ${sbplString(p)})`).join(" ")})`,
182
+ `(deny file-write-unlink ${re(`^${o}/[0-9a-f][0-9a-f]$`)})`,
183
+ `(deny file-write-data file-write-unlink file-write-mode file-write-flags ${re(`^${o}/[0-9a-f][0-9a-f]/[0-9a-f]+$`)})`,
184
+ ];
185
+ }
186
+
166
187
  /**
167
188
  * Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
168
189
  * anchored under the project root: a bare "private/" would otherwise match /private/var/... and deny the whole temp tree.
@@ -0,0 +1,69 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import { sanitize } from "../hub/envelope.ts";
4
+ import { matchesPolicy } from "../hub/path-policy.ts";
5
+ import { MAX_PROJECTION_INPUT, type ProjectedText } from "../hub/text-projection.ts";
6
+ import { OUTPUT_CAP } from "./sandbox.ts";
7
+
8
+ // agent-hub #515 (hub task #320): the local worker's `doc_read`. Spec: "On-prem web reading (#515)", AC2 and AC3 for doc_read.
9
+ // The model names a path and nothing else: the converter, its arguments and its sandbox are the person's `text_projection` config, run by `TextProjections`.
10
+
11
+ /** What the daemon hands the worker when a project opts in (`local.doc_read: true`). Getters, so a policy reload applies to the next call. */
12
+ export interface DocReadPort {
13
+ /** The projections of this worker's execution root: the same instance the hub's own facts and review projections use. */
14
+ projections: () => { handles(file: string): boolean; project(file: string): ProjectedText | undefined };
15
+ /** `pii.paths`: a document under it is PII. */
16
+ piiPaths: () => readonly string[];
17
+ /** `signals.pii_patterns`: a document whose text matches is PII. */
18
+ piiPatterns: () => readonly string[];
19
+ }
20
+ /** The port as a tool call sees it: with whether this turn is a PII task turn. */
21
+ export interface DocReadContext extends DocReadPort { pii: boolean }
22
+
23
+ export const DEFAULT_DOC_LINES = 2000;
24
+
25
+ /** The refusal text for a document the turn may not read, so the model is told why instead of retrying. */
26
+ const PII_PATH = "is under pii.paths: only a PII task turn on the on-prem worker may read it; ask for it in a PII task";
27
+ const PII_TEXT = "has text that matches signals.pii_patterns, so it is PII: only a PII task turn on the on-prem worker may read it; ask for it in a PII task";
28
+
29
+ /**
30
+ * Text of one document. `file` is already the real path `guardPath` returned (inside the project, not on the denylist, no link escapes). Never throws: every refusal
31
+ * is text for the model. Read-only: the converter runs in the projection sandbox on a private copy of the bytes and its cache lives in the hub state directory,
32
+ * so nothing is written beside the document, whatever `data.readonly` says.
33
+ * ponytail: the conversion is synchronous in the daemon process (bounded by the converter's 8 s timeout, then cached by content); upgrade path is an async spawn if long
34
+ * documents ever stall the hub.
35
+ */
36
+ export function docRead(file: string, shown: string, args: Record<string, unknown>, port: DocReadContext, cwd: string): string {
37
+ const projections = port.projections();
38
+ if (!projections.handles(file)) {
39
+ const ext = extname(file).toLowerCase() || "(no extension)";
40
+ return `error: no converter is configured for ${ext} files (text_projection in .agenthub/config.local.json names one per extension); use read for plain text`;
41
+ }
42
+ let st;
43
+ try { st = lstatSync(file); } catch { return `error: ${shown} cannot be read`; }
44
+ if (!st.isFile()) return `error: ${shown} is not a regular file`;
45
+ if (st.nlink !== 1) return `error: ${shown} has several names (a hard link), so its path policy cannot be checked; copy it first`;
46
+ if (st.size > MAX_PROJECTION_INPUT) return `error: ${shown} is larger than ${MAX_PROJECTION_INPUT / 1024 / 1024} MiB`;
47
+ // pii.paths decides by the document's own path first: a non-PII turn never even converts it.
48
+ if (!port.pii && matchesPolicy(cwd, file, [...port.piiPaths()])) return `error: ${shown} ${PII_PATH}`;
49
+ const projected = projections.project(file);
50
+ if (!projected) return `error: ${shown} could not be converted (the converter failed or timed out, it is not available here, or the sandbox is unavailable)`;
51
+ const text = projected.text.replace(/\0/g, "");
52
+ if (!port.pii) {
53
+ // A pattern that cannot be evaluated is not a clear verdict.
54
+ try { if (port.piiPatterns().some(pattern => new RegExp(pattern).test(text))) return `error: ${shown} ${PII_TEXT}`; }
55
+ catch { return `error: ${shown} could not be screened against signals.pii_patterns, so it is not shown`; }
56
+ }
57
+ const lines = sanitize(text).split(/\r?\n/);
58
+ const from = Math.max(1, Math.floor(Number(args.offset)) || 1), limit = Math.max(1, Math.floor(Number(args.limit)) || DEFAULT_DOC_LINES);
59
+ if (from > lines.length) return `error: offset ${from} is past the end (${lines.length} lines)`;
60
+ let out = "", shownTo = from - 1;
61
+ for (let i = from - 1; i < Math.min(lines.length, from - 1 + limit); i++) {
62
+ const row = `${i + 1}\t${lines[i]}\n`;
63
+ if (out.length + row.length > OUTPUT_CAP) { if (!out) out = row.slice(0, OUTPUT_CAP); break; }
64
+ out += row; shownTo = i + 1;
65
+ }
66
+ if (!shownTo || shownTo < from) shownTo = from;
67
+ out = out.replace(/\n$/, "");
68
+ return shownTo < lines.length ? `${out}\n(lines ${from}-${shownTo} of ${lines.length}; read again with offset=${shownTo + 1})` : out;
69
+ }
@@ -31,17 +31,8 @@ export interface EgressProxy {
31
31
  close(): Promise<void>;
32
32
  }
33
33
 
34
- /** `host` or `host:port`; a name also matches its subdomains, an address only itself. Port 443 unless named. */
35
- export function allowed(list: string[], host: string, port: number): boolean {
36
- const h = host.toLowerCase().replace(/\.$/, "");
37
- return list.some((raw) => {
38
- const entry = raw.trim().toLowerCase();
39
- const at = entry.lastIndexOf(":");
40
- const [name, p] = at > 0 && !entry.includes("]") && /^\d+$/.test(entry.slice(at + 1)) ? [entry.slice(0, at), Number(entry.slice(at + 1))] : [entry, 443];
41
- if (!name || p !== port) return false;
42
- return h === name || (!isIP(name) && h.endsWith(`.${name}`));
43
- });
44
- }
34
+ import { allowed } from "./web-fetch-config.ts";
35
+ export { allowed };
45
36
 
46
37
  // Parsed, not prefix-matched: an IPv4-mapped IPv6 address in any spelling (`::ffff:7f00:1`) checks as its IPv4 address.
47
38
  const INTERNAL = new BlockList();
@@ -81,7 +72,7 @@ const hostOf = (target: string) => {
81
72
  }
82
73
  };
83
74
 
84
- export function startEgressProxy(opts: { allow: string[]; log: (line: string) => void }): Promise<EgressProxy> {
75
+ export function startEgressProxy(opts: { allow: string[]; log: (line: string) => void; /** Read at each CONNECT instead of `allow`, so a reloaded allowlist applies to the next request (the `web_fetch` proxy uses it). */ allowFn?: () => readonly string[] }): Promise<EgressProxy> {
85
76
  const sockets = new Set<Socket>();
86
77
  // Half-open on both sides: a client that sends its request and shuts its write side still gets the answer.
87
78
  const server: Server = createServer({ allowHalfOpen: true }, (client) => {
@@ -119,7 +110,7 @@ export function startEgressProxy(opts: { allow: string[]; log: (line: string) =>
119
110
  const host = m?.[1]!.replace(/^\[|\]$/g, "") ?? "";
120
111
  if (!m || !(isIP(host) || /^[a-z0-9.-]+$/i.test(host))) return refuse(client, 400, "bad CONNECT target", "a CONNECT request");
121
112
  const port = Number(m[2]);
122
- if (!allowed(opts.allow, host, port)) return refuse(client, 403, `${host}:${port} is not in local.network_allow`, `${host}:${port}`);
113
+ if (!allowed([...(opts.allowFn ? opts.allowFn() : opts.allow)], host, port)) return refuse(client, 403, `${host}:${port} is not in local.network_allow`, `${host}:${port}`);
123
114
  let address = host;
124
115
  if (!isIP(host)) {
125
116
  try {
@@ -4,7 +4,7 @@ import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { realPath } from "../hub/project.ts";
6
6
  import { hubGitSync } from "../hub/git.ts";
7
- import { denyRegexes, hubWriteRegexes, sbplString } from "./deny.ts";
7
+ import { denyRegexes, hubWriteRegexes, objectStoreRules, sbplString } from "./deny.ts";
8
8
 
9
9
  const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
10
10
  export const OUTPUT_CAP = 20_000;
@@ -184,6 +184,8 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
184
184
  // .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
185
185
  // hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
186
186
  `(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit, worktrees).join(" ")})`,
187
+ // The shared object store: only new loose objects may be added (#864).
188
+ ...(worktrees ? objectStoreRules(dirname(worktrees)) : []),
187
189
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
188
190
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
189
191
  // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
@@ -2,8 +2,10 @@ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "nod
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { realPath } from "../hub/project.ts";
4
4
  import { isHubWritePath } from "../hub/permission-mode.ts";
5
- import { matchesPolicy } from "../hub/path-policy.ts";
5
+ import { insideRoot, matchesPolicy } from "../hub/path-policy.ts";
6
6
  import { isDenied } from "./deny.ts";
7
+ import { docRead, type DocReadContext, type DocReadPort } from "./doc-read.ts";
8
+ import { webFetch, type WebFetchContext, type WebFetchPort } from "./web-fetch.ts";
7
9
  import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
8
10
 
9
11
  export { isDenied };
@@ -34,6 +36,10 @@ export interface ToolContext {
34
36
  permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown, policy?: ShellPermitPolicy) => Promise<ToolApproval>;
35
37
  /** Host-only synchronous recheck, immediately before a shell process can be spawned. */
36
38
  shellExecutionCheck?: (input: unknown) => ShellExecutionCheck;
39
+ /** #515: present only when the project opted in (`local.doc_read`), so `doc_read` exists for this worker and nothing else gets it. `pii`: this turn is a PII task turn. */
40
+ docRead?: DocReadPort & { pii: boolean };
41
+ /** #515b: present only when the project opted in (`local.web_fetch.allow` names a host), so `web_fetch` exists for this worker and nothing else gets it. `pii`: this turn is a PII task turn, for which it is refused. */
42
+ webFetch?: WebFetchPort & { pii: boolean };
37
43
  /** Trusted process status, independent of what its stdout says. */
38
44
  onProcessExit?: (code: number | null) => void;
39
45
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
@@ -66,10 +72,14 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny" | "readOnly">, p
66
72
  const real = join(realExisting, relative(existing, abs));
67
73
  const rel = relative(root, real);
68
74
  if (rel.startsWith("..") || resolve(root, rel) !== real) throw new Error(`${path} is outside the project directory`);
69
- if (isDenied(rel, ctx.deny)) throw new Error(`${path} is on the secrets denylist`);
75
+ // Never trust the resolved name alone (#541): on a hardlinked file Bun's realpath sometimes names another link of the same inode, so the
76
+ // name the caller typed is judged too. A refusal on either name stands; the typed name only ever adds refusals. Only a typed name INSIDE the project is judged
77
+ // (`insideRoot`, #555): a name outside it (`../o/.env`, a link into the project) is no project path for any policy to name, and it was never refused before #541.
78
+ const typed = relative(root, abs), inside = insideRoot(typed);
79
+ if (isDenied(rel, ctx.deny) || inside && isDenied(typed, ctx.deny)) throw new Error(`${path} is on the secrets denylist`);
70
80
  // Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
71
81
  // folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
72
- if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
82
+ if (mode === "write" && (isHubWritePath(rel) || inside && isHubWritePath(typed))) throw new Error(`${path} is not writable by the local worker`);
73
83
  // Identity refusal is stable even when realPath names a protected hardlink alias.
74
84
  if (mode === "write" && ctx.readOnly?.length && (lstatSync(real, { throwIfNoEntry: false })?.nlink ?? 1) > 1) throw new Error("hardlinked write target cannot be verified with data.readonly");
75
85
  if (mode === "write" && matchesPolicy(root, path, ctx.readOnly ?? [])) throw new Error("target is protected by data.readonly");
@@ -111,6 +121,20 @@ export const TOOL_SCHEMAS = [
111
121
  fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
112
122
  ];
113
123
 
124
+ /**
125
+ * #515: offered only to a worker whose project set `local.doc_read` (off by default), never part of TOOL_SCHEMAS. The model names a path and a line window; the converter
126
+ * is the person's `text_projection` entry for the extension, run in the projection sandbox, and the text is bounded and passed through `sanitize()`.
127
+ */
128
+ export const DOC_READ_SCHEMA = fn("doc_read", "Read a PDF, DOCX or HWPX document inside the project as plain text, through the converters the project configured. Returns numbered lines, bounded; read again with offset for more. Read-only.", { path: str, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 } }, ["path"]);
129
+ export type { DocReadContext, DocReadPort };
130
+
131
+ /**
132
+ * #515b: offered only to a worker whose project listed at least one host in `local.web_fetch.allow` (off by default), never part of TOOL_SCHEMAS. The model names a URL and a line
133
+ * window and nothing else: no method, header or body. It is refused for the whole turn of a PII task, and otherwise runs only through the egress proxy, to listed hosts.
134
+ */
135
+ export const WEB_FETCH_SCHEMA = fn("web_fetch", "Read one web page as plain text. https only, and only for hosts the project listed in local.web_fetch.allow; refused in a PII task. A plain GET with no headers or body of yours. Returns numbered lines, bounded; read again with offset for more. Never put anything private or from the task into the URL.", { url: str, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 } }, ["url"]);
136
+ export type { WebFetchContext, WebFetchPort };
137
+
114
138
  /** Every path-like argument of a call, for the memory capture filter. */
115
139
  export function touchedPaths(name: string, args: Record<string, unknown>): string[] {
116
140
  if (name === "read" || name === "write" || name === "edit") return [String(args.path ?? "")];
@@ -213,6 +237,16 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
213
237
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
214
238
  return `${res.output}\n(exit ${res.code})`;
215
239
  }
240
+ case "doc_read": {
241
+ if (!ctx.docRead) return "error: doc_read is not enabled for this project (local.doc_read in .agenthub/config.local.json)";
242
+ const file = guardPath(ctx, String(a.path), "read");
243
+ return docRead(file, String(a.path), a, ctx.docRead, ctx.cwd);
244
+ }
245
+ case "web_fetch": {
246
+ // The refusal for a PII turn is the first thing this tool does, before the URL is read (see `webFetch`); the opt-in check only says whether the tool exists.
247
+ if (!ctx.webFetch) return "error: web_fetch is not enabled for this project (local.web_fetch.allow in .agenthub/config.local.json)";
248
+ return webFetch(String(a.url ?? ""), a, ctx.webFetch, ctx.signal);
249
+ }
216
250
  case "hub_send":
217
251
  return a.text ? ctx.send(String(a.text), Array.isArray(a.to) ? a.to.map(String) : undefined) : "error: text is required";
218
252
  default:
@@ -0,0 +1,43 @@
1
+ // agent-hub #515 (hub task #332): the `local.web_fetch` config block and the host matcher both the tool and the egress proxy use. Pure on purpose: the config schema reaches the
2
+ // Claude channel bundle, so this file imports nothing that touches the file system, opens a socket or spawns a process. The tool is in `web-fetch.ts`, the proxy in `proxy.ts`.
3
+ import { isIP } from "node:net";
4
+
5
+ /** `host` or `host:port`; a name also matches its subdomains, an address only itself. Port 443 unless named. */
6
+ export function allowed(list: string[], host: string, port: number): boolean {
7
+ const h = host.toLowerCase().replace(/\.$/, "");
8
+ return list.some((raw) => {
9
+ const entry = raw.trim().toLowerCase();
10
+ const at = entry.lastIndexOf(":");
11
+ const [name, p] = at > 0 && !entry.includes("]") && /^\d+$/.test(entry.slice(at + 1)) ? [entry.slice(0, at), Number(entry.slice(at + 1))] : [entry, 443];
12
+ if (!name || p !== port) return false;
13
+ return h === name || (!isIP(name) && h.endsWith(`.${name}`));
14
+ });
15
+ }
16
+ /** At most this many hosts: a list is a small, deliberate thing. */
17
+ export const MAX_WEB_HOSTS = 64;
18
+ /**
19
+ * Why one `local.web_fetch.allow` entry is not accepted, or undefined. An entry is a host name, optionally `:port`: letters, digits, `-` and `.`, at least one dot, never an
20
+ * address, a URL, a path, a wildcard or a bare top-level name (`com` would open every site). Checked when the config loads, so a typo fails there and not in a turn.
21
+ */
22
+ export function webHostProblem(value: unknown): string | undefined {
23
+ if (typeof value !== "string" || !value || value.length > 253 + 6) return "a host is a non-empty name of at most 253 characters";
24
+ const match = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+)(?::([0-9]{1,5}))?$/.exec(value);
25
+ if (!match) return "a host is a name such as docs.example.org or docs.example.org:8443: no scheme, path, wildcard, spaces or bare top-level name";
26
+ if (isIP(match[1]!)) return "a host is a name, never an address";
27
+ if (match[2] !== undefined && (Number(match[2]) < 1 || Number(match[2]) > 65535)) return "a port is 1 to 65535";
28
+ return undefined;
29
+ }
30
+
31
+ /** The checked `local.web_fetch.allow`. Nothing here reads the network or the disk. */
32
+ export function webFetchAllow(input: unknown): string[] {
33
+ if (input === undefined) return [];
34
+ if (!Array.isArray(input) || input.length > MAX_WEB_HOSTS) throw new Error(`local.web_fetch.allow takes a list of at most ${MAX_WEB_HOSTS} hosts`);
35
+ const seen = new Set<string>(), out: string[] = [];
36
+ input.forEach((host, index) => {
37
+ const problem = webHostProblem(host);
38
+ if (problem) throw new Error(`local.web_fetch.allow[${index}]: ${problem}`);
39
+ const key = (host as string).toLowerCase();
40
+ if (!seen.has(key)) { seen.add(key); out.push(key); }
41
+ });
42
+ return out;
43
+ }
@@ -0,0 +1,183 @@
1
+ import { isIP } from "node:net";
2
+ import { sanitize } from "../hub/envelope.ts";
3
+ import { OUTPUT_CAP } from "./sandbox.ts";
4
+ import { allowed } from "./web-fetch-config.ts";
5
+ export { MAX_WEB_HOSTS, webFetchAllow, webHostProblem } from "./web-fetch-config.ts";
6
+
7
+ // agent-hub #515 (hub task #332): the local worker's `web_fetch`. Spec: "On-prem web reading (#515)", the owner decision of 2026-10-22.
8
+ // It is refused for the whole turn of a PII task, whatever the host: the URL or a query string could carry PII to the web, and `pii.paths` and `sanitize()` cover
9
+ // what comes back, not what goes out. Outside a PII turn it runs only through the egress proxy, to hosts the person listed for this project, and returns bounded,
10
+ // sanitized text only. Off by default.
11
+
12
+ /** The refusal for a PII turn. It is the same text for every URL, so it says nothing about the request. */
13
+ export const WEB_FETCH_PII = "error: web_fetch is not available in a PII task turn, whatever the host: the URL or a query could carry PII to the web. Use doc_read or read for what the project already holds";
14
+
15
+ /** What the daemon hands the worker when a project opts in (`local.web_fetch.allow` names at least one host). Getters, so a policy reload applies to the next call. */
16
+ export interface WebFetchPort {
17
+ /** `local.web_fetch.allow`: hosts (`host` or `host:port`, subdomains included for a name) the model may read. */
18
+ allow: () => readonly string[];
19
+ /** The loopback URL of the egress proxy that opens tunnels to `allow` and nothing else; undefined while it is not running. */
20
+ proxyUrl: () => string | undefined;
21
+ /** Test seam: the fetch to use. Production leaves it unset (the global `fetch`). */
22
+ fetch?: (url: string, init: { proxy: string; redirect: "manual"; signal: AbortSignal; headers: Record<string, string>; method: "GET" }) => Promise<Response>;
23
+ }
24
+ /** The port as a tool call sees it: with whether this turn is a PII task turn. */
25
+ export interface WebFetchContext extends WebFetchPort { pii: boolean }
26
+
27
+ export const WEB_FETCH_TIMEOUT_MS = 15_000;
28
+ /** Bytes read from a response, however large it says it is. Text past it is cut, never buffered. */
29
+ export const WEB_FETCH_MAX_BYTES = 512 * 1024;
30
+ export const WEB_FETCH_MAX_URL = 2000;
31
+ /** Redirects followed, each one re-checked against the allowlist before it is requested. */
32
+ export const WEB_FETCH_MAX_REDIRECTS = 3;
33
+ export const DEFAULT_WEB_LINES = 2000;
34
+
35
+ const TEXT_TYPE = /^(?:text\/[a-z0-9.+-]+|application\/(?:json|xml|xhtml\+xml|[a-z0-9.+-]+\+(?:json|xml)))(?:\s*;.*)?$/i;
36
+
37
+ /**
38
+ * Whether a URL may be requested at all: https, a host the list names, port 443 unless the entry says otherwise, no userinfo, no address literal, no fragment-only tricks.
39
+ * A reason string, never a throw. The reason names the host (the model already wrote it) and never anything the person configured beyond "not in the list".
40
+ */
41
+ export function checkWebUrl(raw: string, allow: readonly string[]): { ok: true; url: URL } | { ok: false; reason: string } {
42
+ if (typeof raw !== "string" || !raw.trim() || raw.length > WEB_FETCH_MAX_URL || /[\x00-\x20\x7f]/.test(raw)) return { ok: false, reason: "url must be one https URL of at most 2000 characters with no spaces or control characters" };
43
+ let url: URL;
44
+ try { url = new URL(raw); } catch { return { ok: false, reason: "url is not a valid URL" }; }
45
+ if (url.protocol !== "https:") return { ok: false, reason: "only https URLs are fetched" };
46
+ if (url.username || url.password) return { ok: false, reason: "a URL with a user name or password is not fetched" };
47
+ const host = url.hostname.replace(/^\[|\]$/g, "");
48
+ if (isIP(host)) return { ok: false, reason: "an address is not a host: name the site, and the person lists it in local.web_fetch.allow" };
49
+ const port = url.port ? Number(url.port) : 443;
50
+ if (!allowed([...allow], host, port)) return { ok: false, reason: `${host}:${port} is not in local.web_fetch.allow` };
51
+ url.hash = "";
52
+ return { ok: true, url };
53
+ }
54
+
55
+ /** The decoded text of a response body, read at most `WEB_FETCH_MAX_BYTES` bytes deep. */
56
+ async function readBounded(res: Response): Promise<{ text: string; cut: boolean }> {
57
+ const reader = res.body?.getReader();
58
+ if (!reader) return { text: "", cut: false };
59
+ const chunks: Uint8Array[] = [];
60
+ let total = 0, cut = false;
61
+ for (;;) {
62
+ const { done, value } = await reader.read();
63
+ if (done) break;
64
+ if (total + value.length > WEB_FETCH_MAX_BYTES) { chunks.push(value.subarray(0, WEB_FETCH_MAX_BYTES - total)); total = WEB_FETCH_MAX_BYTES; cut = true; void reader.cancel().catch(() => undefined); break; }
65
+ chunks.push(value); total += value.length;
66
+ }
67
+ return { text: new TextDecoder("utf-8", { fatal: false }).decode(Buffer.concat(chunks)), cut };
68
+ }
69
+
70
+ const SKIPPED_ELEMENTS = new Set(["script", "style", "noscript", "template", "svg", "head"]);
71
+ /** Their content is never markup, so an unclosed one swallows the rest of the page (what a browser does); the others lose only their tag. */
72
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style"]);
73
+ const BLOCK_CLOSERS = new Set(["p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article", "br", "pre", "blockquote", "table"]);
74
+ const isAsciiLetter = (code: number) => (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
75
+ const isNameCode = (code: number) => isAsciiLetter(code) || (code >= 48 && code <= 57) || code === 45;
76
+
77
+ /**
78
+ * Markup reduced to its text, enough to read a page: scripts and styles go, tags go, entities are decoded for the few that matter, runs of blank lines collapse.
79
+ * One forward pass over a body that a hostile page controls: every index only moves ahead, and a search that finds nothing is never repeated for the same
80
+ * target, so the work is linear in the body whatever is left unclosed (a repeated `<script>`, `<!--` or `<` costs one scan, not one per opener).
81
+ * The 512 KiB body cap bounds the input; this bounds the CPU, because an AbortSignal cannot interrupt synchronous code.
82
+ */
83
+ export function htmlText(html: string): string {
84
+ const out: string[] = [];
85
+ const closerMissing = new Set<string>(); // a failed search covered the whole suffix, so a later one for the same name would fail too
86
+ const n = html.length;
87
+ let i = 0, text = 0; // text: start of the plain run not yet copied
88
+ const flush = (to: number) => { if (to > text) out.push(html.slice(text, to)); };
89
+ while (i < n) {
90
+ const lt = html.indexOf("<", i);
91
+ if (lt === -1) break;
92
+ const next = html.charCodeAt(lt + 1);
93
+ if (html.startsWith("<!--", lt)) {
94
+ flush(lt);
95
+ const end = html.indexOf("-->", lt + 4);
96
+ if (end === -1) { text = i = n; break; } // an unterminated comment runs to the end of the page
97
+ out.push(" "); text = i = end + 3;
98
+ continue;
99
+ }
100
+ const closing = next === 47;
101
+ if (!(isAsciiLetter(next) || (closing && isAsciiLetter(html.charCodeAt(lt + 2))) || next === 33 || next === 63)) { i = lt + 1; continue; } // `a < b` is text
102
+ flush(lt);
103
+ const gt = html.indexOf(">", lt + 1);
104
+ if (gt === -1) { text = i = n; break; } // an unterminated tag at the end of the page is dropped
105
+ let nameStart = lt + (closing ? 2 : 1), nameEnd = nameStart;
106
+ while (nameEnd < gt && isNameCode(html.charCodeAt(nameEnd))) nameEnd++;
107
+ const name = html.slice(nameStart, nameEnd).toLowerCase(), rest = html.slice(nameEnd, gt).trim();
108
+ i = text = gt + 1;
109
+ if (closing) { out.push(BLOCK_CLOSERS.has(name) && rest === "" ? "\n" : " "); continue; }
110
+ if (name === "br" && (rest === "" || rest === "/")) { out.push("\n"); continue; }
111
+ out.push(" ");
112
+ if (SKIPPED_ELEMENTS.has(name) && !rest.endsWith("/")) {
113
+ if (closerMissing.has(name)) continue; // only a non-raw-text element gets here: an unclosed script or style already ended the page
114
+ const closer = new RegExp(`</${name}\\s*>`, "gi");
115
+ closer.lastIndex = i;
116
+ const found = closer.exec(html);
117
+ if (found) { i = text = found.index + found[0].length; }
118
+ else { closerMissing.add(name); if (RAW_TEXT_ELEMENTS.has(name)) i = text = n; }
119
+ }
120
+ }
121
+ flush(n);
122
+ return out.join("")
123
+ .replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, "&")
124
+ .replace(/[ \t\f\v]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
125
+ }
126
+
127
+ /**
128
+ * One page as text. Never throws: every refusal is text for the model. Order matters and is the contract:
129
+ * 1. a PII task turn is refused before the URL is read at all, whatever it says, so no host check, error or timing differs for it;
130
+ * 2. the URL must be an allowed https host, and the proxy must be running (without it there is no way out, so no request is made);
131
+ * 3. the request is a bare GET through the proxy, with no header or body from the model, and no redirect is followed without being checked again;
132
+ * 4. only a text type is read, bounded in bytes and time, then `sanitize()` quotes a forged hub header and the output is cut at the shared output cap.
133
+ */
134
+ export async function webFetch(url: string, args: Record<string, unknown>, port: WebFetchContext, signal?: AbortSignal): Promise<string> {
135
+ if (port.pii) return WEB_FETCH_PII;
136
+ const proxy = port.proxyUrl();
137
+ let current = checkWebUrl(url, port.allow());
138
+ if (!current.ok) return `error: ${current.reason}`;
139
+ if (!proxy) return "error: web_fetch has no egress proxy right now, so nothing was requested";
140
+ const doFetch = port.fetch ?? ((target, init) => fetch(target, init as RequestInit & { proxy: string }));
141
+ let response: Response | undefined;
142
+ try {
143
+ for (let hop = 0; ; hop++) {
144
+ const abort = AbortSignal.any([AbortSignal.timeout(WEB_FETCH_TIMEOUT_MS), ...(signal ? [signal] : [])]);
145
+ response = await doFetch(current.url.toString(), { method: "GET", proxy, redirect: "manual", signal: abort, headers: { accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.1", "user-agent": "agent-hub-web-fetch" } });
146
+ if (response.status < 300 || response.status >= 400) break;
147
+ const location = response.headers.get("location");
148
+ void response.body?.cancel().catch(() => undefined);
149
+ if (!location) return `error: the server answered ${response.status} with no location`;
150
+ if (hop >= WEB_FETCH_MAX_REDIRECTS) return `error: more than ${WEB_FETCH_MAX_REDIRECTS} redirects`;
151
+ let next: string;
152
+ try { next = new URL(location, current.url).toString(); } catch { return "error: the server sent a redirect that is not a URL"; }
153
+ const checked = checkWebUrl(next, port.allow());
154
+ if (!checked.ok) return `error: the page redirects to a URL that is not allowed (${checked.reason})`;
155
+ current = checked;
156
+ }
157
+ } catch (error) {
158
+ if (signal?.aborted) return "error: turn cancelled before the page was read";
159
+ const name = (error as { name?: string; code?: string })?.name, code = (error as { code?: string })?.code;
160
+ if (name === "TimeoutError" || name === "AbortError") return `error: the page did not answer within ${WEB_FETCH_TIMEOUT_MS / 1000} seconds`;
161
+ return `error: the page could not be fetched${typeof code === "string" && /^[A-Z_]{3,40}$/.test(code) ? ` (${code})` : ""}`;
162
+ }
163
+ if (!response.ok) { void response.body?.cancel().catch(() => undefined); return `error: the server answered ${response.status}`; }
164
+ const type = response.headers.get("content-type") ?? "";
165
+ if (!TEXT_TYPE.test(type)) { void response.body?.cancel().catch(() => undefined); return `error: ${type.split(";")[0] || "an unknown type"} is not text: only text, JSON and XML pages are read`; }
166
+ let body: { text: string; cut: boolean };
167
+ try { body = await readBounded(response); } catch { return "error: the page could not be read to the end"; }
168
+ const isHtml = /^text\/html|^application\/xhtml\+xml/i.test(type);
169
+ const text = sanitize((isHtml ? htmlText(body.text) : body.text).replace(/\0/g, ""));
170
+ const lines = text.split(/\r?\n/);
171
+ const from = Math.max(1, Math.floor(Number(args.offset)) || 1), limit = Math.max(1, Math.floor(Number(args.limit)) || DEFAULT_WEB_LINES);
172
+ if (from > lines.length) return `error: offset ${from} is past the end (${lines.length} lines)`;
173
+ let out = "", shownTo = from - 1;
174
+ for (let i = from - 1; i < Math.min(lines.length, from - 1 + limit); i++) {
175
+ const row = `${i + 1}\t${lines[i]}\n`;
176
+ if (out.length + row.length > OUTPUT_CAP) { if (!out) out = row.slice(0, OUTPUT_CAP); break; }
177
+ out += row; shownTo = i + 1;
178
+ }
179
+ if (!shownTo || shownTo < from) shownTo = from;
180
+ out = out.replace(/\n$/, "");
181
+ const more = shownTo < lines.length ? `(lines ${from}-${shownTo} of ${lines.length}; read again with offset=${shownTo + 1})` : body.cut ? `(the page was cut at ${WEB_FETCH_MAX_BYTES / 1024} KiB)` : "";
182
+ return more ? `${out}\n${more}` : out;
183
+ }