@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20

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 (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. package/package.json +9 -6
@@ -0,0 +1,96 @@
1
+ import { listSkills } from '../core/skills/loader.js';
2
+ import { hashSkillDir, verifyTrust } from '../core/skills/trust.js';
3
+ export const SKILL_BODY_CAP_BYTES = 32 * 1024;
4
+ export const SKILL_LIST_CAP = 100;
5
+ export function skillList(ctx, input) {
6
+ const scope = input.scope ?? 'all';
7
+ const all = [];
8
+ if (scope === 'all' || scope === 'global') {
9
+ all.push(...listSkills('global', ctx.workspaceRoot));
10
+ }
11
+ if (scope === 'all' || scope === 'workspace') {
12
+ all.push(...listSkills('workspace', ctx.workspaceRoot));
13
+ }
14
+ // Dedup by name, prefer workspace scope when both exist (workspace
15
+ // overrides global per skills loader convention).
16
+ const byName = new Map();
17
+ for (const skill of all) {
18
+ const prev = byName.get(skill.name);
19
+ if (!prev || skill.scope === 'workspace') {
20
+ byName.set(skill.name, skill);
21
+ }
22
+ }
23
+ return Array.from(byName.values())
24
+ .slice(0, SKILL_LIST_CAP)
25
+ .map((skill) => ({
26
+ name: skill.name,
27
+ description: skill.frontmatter.description,
28
+ scope: skill.scope,
29
+ }));
30
+ }
31
+ export async function skillInvoke(ctx, input) {
32
+ if (!input.name || typeof input.name !== 'string') {
33
+ throw new Error('skill: name is required');
34
+ }
35
+ // Defense-in-depth: skill loader already validates slugs but the
36
+ // tool surface is operator-controlled.
37
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(input.name)) {
38
+ throw new Error(`skill: invalid skill name shape: "${input.name}"`);
39
+ }
40
+ // Workspace scope wins over global (operator override). Mirrors
41
+ // SkillLoader convention.
42
+ const workspace = listSkills('workspace', ctx.workspaceRoot).find((s) => s.name === input.name);
43
+ const global = workspace
44
+ ? null
45
+ : listSkills('global', ctx.workspaceRoot).find((s) => s.name === input.name);
46
+ const skill = workspace ?? global;
47
+ if (!skill) {
48
+ throw new Error(`skill: not found: "${input.name}"`);
49
+ }
50
+ // β1a r1 (2026-05-26): re-verify the on-disk skill payload against
51
+ // the trust manifest sha256 on EVERY invoke, not just at install
52
+ // time. Before this fix a post-install swap (malicious npm dep that
53
+ // touches `~/.pugi/skills/<name>/SKILL.md` after the operator
54
+ // approved the install) would bypass the trust gate — `listSkills`
55
+ // reads the body fresh from disk and the loader does no integrity
56
+ // check. The skill body lands directly in the model's tool result,
57
+ // so a mutated body is a prompt-injection vector against the agent
58
+ // loop's tool surface.
59
+ //
60
+ // Posture:
61
+ // - `trusted` → proceed (body is hash-pinned).
62
+ // - `unsigned` → refuse: the operator never approved this skill.
63
+ // This catches the case where a skill directory was dropped in
64
+ // manually (no `pugi skills install`) and the loader picked it
65
+ // up. Refusing is fail-closed.
66
+ // - `mismatch` → refuse + surface the recorded vs actual hashes
67
+ // so the operator can decide between re-trust and revoke.
68
+ //
69
+ // Performance: `hashSkillDir` walks the skill directory on every
70
+ // invoke. Skills are small (median 4-8 files, <50KB total) so the
71
+ // cost is sub-millisecond on warm cache. The β1a r1 spec exercises
72
+ // a mutated-body case; the existing skill-tool.spec.ts cases for
73
+ // happy-path use the `recordTrust` helper to seed the registry.
74
+ const actualHash = hashSkillDir(skill.dir);
75
+ const verdict = await verifyTrust('skill', skill.scope, skill.name, actualHash);
76
+ if (verdict.status === 'unsigned') {
77
+ throw new Error(`skill: refused to invoke "${skill.name}" — no trust entry (run \`pugi skills trust ${skill.name}\` to approve)`);
78
+ }
79
+ if (verdict.status === 'mismatch') {
80
+ throw new Error(`skill: refused to invoke "${skill.name}" — sha256 mismatch (recorded ${verdict.recorded.slice(0, 12)}…, actual ${verdict.actual.slice(0, 12)}…). Re-trust via \`pugi skills trust ${skill.name}\`.`);
81
+ }
82
+ const body = skill.body;
83
+ const truncated = Buffer.byteLength(body, 'utf8') > SKILL_BODY_CAP_BYTES;
84
+ const cappedBody = truncated
85
+ ? body.slice(0, SKILL_BODY_CAP_BYTES) +
86
+ `\n\n(... truncated at ${SKILL_BODY_CAP_BYTES} bytes — see \`pugi skills info ${skill.name}\` for full text)`
87
+ : body;
88
+ return {
89
+ name: skill.name,
90
+ scope: skill.scope,
91
+ description: skill.frontmatter.description,
92
+ body: cappedBody,
93
+ truncated,
94
+ };
95
+ }
96
+ //# sourceMappingURL=skill-tool.js.map
@@ -0,0 +1,208 @@
1
+ /**
2
+ * task_* tool family — β1 T1/T6 (TodoWrite + agent task ledger).
3
+ *
4
+ * Mirrors Claude Code's TodoWrite tool surface so a model trained on
5
+ * the upstream tool grammar speaks Pugi's variant verbatim. Four ops:
6
+ *
7
+ * - `task_create` — append a new task to the session's todo ledger.
8
+ * Returns the assigned id.
9
+ * - `task_get` — fetch a single task by id.
10
+ * - `task_list` — list every task in the current session, ordered
11
+ * by createdAt ascending.
12
+ * - `task_update` — mutate status/title/notes of an existing task.
13
+ * Append-only journal — every mutation lands as a
14
+ * fresh JSONL line and the latest line per id wins
15
+ * on `task_list` / `task_get` reads.
16
+ *
17
+ * Persistence: append-only JSONL at
18
+ * `.pugi/sessions/<sessionId>/tasks.jsonl`. Append-only keeps crash
19
+ * recovery trivial — a partial write at the end of the file is the
20
+ * worst case and the parser drops the malformed tail line.
21
+ *
22
+ * Scope: this is the local-side ledger surface. Anvil-side mirror
23
+ * (cabinet `/projects/[id]/tasks` page) ships in β5 once the session-
24
+ * memory hook lands; until then the ledger is purely local.
25
+ */
26
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
28
+ import { randomUUID } from 'node:crypto';
29
+ function ledgerPath(ctx) {
30
+ // Defense-in-depth: the sessionId is supposed to be a UUID minted by
31
+ // openSession() but the tool surface is operator-facing. Validate the
32
+ // shape before composing a path — refuse anything that contains
33
+ // separators or shell wildcards.
34
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(ctx.sessionId)) {
35
+ throw new Error(`task_*: invalid sessionId shape: "${ctx.sessionId}"`);
36
+ }
37
+ return join(ctx.workspaceRoot, '.pugi', 'sessions', ctx.sessionId, 'tasks.jsonl');
38
+ }
39
+ function nowIso(ctx) {
40
+ return (ctx.now ? ctx.now() : new Date()).toISOString();
41
+ }
42
+ function ensureDir(path) {
43
+ // β1a r1 (2026-05-26): switched from POSIX-only
44
+ // `path.slice(0, path.lastIndexOf('/'))` to `path.dirname()` so
45
+ // Windows path separators (`\`) work. Also chmod the per-session
46
+ // directory to 0o700 — the tasks ledger carries operator-confidential
47
+ // brief text, status notes, and timing metadata that should not be
48
+ // world-readable through an inherited umask.
49
+ const dir = dirname(path);
50
+ if (!existsSync(dir)) {
51
+ mkdirSync(dir, { recursive: true });
52
+ try {
53
+ chmodSync(dir, 0o700);
54
+ }
55
+ catch {
56
+ // Best-effort. POSIX permission setting is a no-op on Windows
57
+ // NTFS, and the dir-creation race with another concurrent task
58
+ // tool call is the only realistic failure case. The 0o600 mode
59
+ // on the JSONL file itself remains the primary guard; the dir
60
+ // chmod is defense in depth for tools that walk `.pugi/`.
61
+ }
62
+ }
63
+ }
64
+ function readJournal(ctx) {
65
+ const path = ledgerPath(ctx);
66
+ if (!existsSync(path))
67
+ return [];
68
+ const raw = readFileSync(path, 'utf8');
69
+ const out = [];
70
+ for (const line of raw.split('\n')) {
71
+ if (!line.trim())
72
+ continue;
73
+ try {
74
+ const parsed = JSON.parse(line);
75
+ if ((parsed.op === 'create' || parsed.op === 'update') &&
76
+ typeof parsed.id === 'string' &&
77
+ typeof parsed.at === 'string') {
78
+ out.push(parsed);
79
+ }
80
+ }
81
+ catch {
82
+ // Drop malformed line (partial-write tail or external corruption).
83
+ // The append-only design guarantees only the LAST line can be bad
84
+ // — everything before it is whole.
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ function fold(journal) {
90
+ const out = new Map();
91
+ for (const entry of journal) {
92
+ if (entry.op === 'create') {
93
+ if (!entry.title)
94
+ continue;
95
+ out.set(entry.id, {
96
+ id: entry.id,
97
+ title: entry.title,
98
+ status: entry.status ?? 'pending',
99
+ ...(entry.notes !== undefined ? { notes: entry.notes } : {}),
100
+ createdAt: entry.at,
101
+ updatedAt: entry.at,
102
+ });
103
+ }
104
+ else {
105
+ const prev = out.get(entry.id);
106
+ if (!prev)
107
+ continue; // update before create — drop silently
108
+ out.set(entry.id, {
109
+ ...prev,
110
+ ...(entry.title !== undefined ? { title: entry.title } : {}),
111
+ ...(entry.status !== undefined ? { status: entry.status } : {}),
112
+ ...(entry.notes !== undefined ? { notes: entry.notes } : {}),
113
+ updatedAt: entry.at,
114
+ });
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+ function appendEntry(ctx, entry) {
120
+ const path = ledgerPath(ctx);
121
+ ensureDir(path);
122
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, {
123
+ encoding: 'utf8',
124
+ mode: 0o600,
125
+ });
126
+ }
127
+ export function taskCreate(ctx, input) {
128
+ const title = input.title?.trim();
129
+ if (!title) {
130
+ throw new Error('task_create: title is required');
131
+ }
132
+ if (title.length > 2_000) {
133
+ throw new Error('task_create: title exceeds 2000 char cap');
134
+ }
135
+ const status = input.status ?? 'pending';
136
+ if (!isValidStatus(status)) {
137
+ throw new Error(`task_create: invalid status "${status}"`);
138
+ }
139
+ const id = `task-${randomUUID()}`;
140
+ const at = nowIso(ctx);
141
+ const entry = {
142
+ op: 'create',
143
+ id,
144
+ title,
145
+ status,
146
+ at,
147
+ ...(input.notes !== undefined ? { notes: input.notes } : {}),
148
+ };
149
+ appendEntry(ctx, entry);
150
+ return {
151
+ id,
152
+ title,
153
+ status,
154
+ ...(input.notes !== undefined ? { notes: input.notes } : {}),
155
+ createdAt: at,
156
+ updatedAt: at,
157
+ };
158
+ }
159
+ export function taskGet(ctx, id) {
160
+ if (typeof id !== 'string' || id.length === 0) {
161
+ throw new Error('task_get: id is required');
162
+ }
163
+ const folded = fold(readJournal(ctx));
164
+ return folded.get(id) ?? null;
165
+ }
166
+ export function taskList(ctx) {
167
+ const folded = fold(readJournal(ctx));
168
+ return Array.from(folded.values()).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
169
+ }
170
+ export function taskUpdate(ctx, input) {
171
+ if (!input.id)
172
+ throw new Error('task_update: id is required');
173
+ const folded = fold(readJournal(ctx));
174
+ const existing = folded.get(input.id);
175
+ if (!existing) {
176
+ throw new Error(`task_update: unknown id "${input.id}"`);
177
+ }
178
+ if (input.status !== undefined && !isValidStatus(input.status)) {
179
+ throw new Error(`task_update: invalid status "${input.status}"`);
180
+ }
181
+ if (input.title !== undefined && input.title.trim().length === 0) {
182
+ throw new Error('task_update: title cannot be empty');
183
+ }
184
+ const at = nowIso(ctx);
185
+ const entry = {
186
+ op: 'update',
187
+ id: input.id,
188
+ at,
189
+ ...(input.title !== undefined ? { title: input.title } : {}),
190
+ ...(input.status !== undefined ? { status: input.status } : {}),
191
+ ...(input.notes !== undefined ? { notes: input.notes } : {}),
192
+ };
193
+ appendEntry(ctx, entry);
194
+ return {
195
+ ...existing,
196
+ ...(input.title !== undefined ? { title: input.title } : {}),
197
+ ...(input.status !== undefined ? { status: input.status } : {}),
198
+ ...(input.notes !== undefined ? { notes: input.notes } : {}),
199
+ updatedAt: at,
200
+ };
201
+ }
202
+ function isValidStatus(status) {
203
+ return (status === 'pending' ||
204
+ status === 'in_progress' ||
205
+ status === 'completed' ||
206
+ status === 'cancelled');
207
+ }
208
+ //# sourceMappingURL=tasks.js.map
@@ -34,7 +34,7 @@
34
34
  * Brand voice: brief / dispatch / ship / sentinel only. The
35
35
  * brandbook §08 forbidden-word list applies — see CLAUDE.md.
36
36
  */
37
- import { request } from 'undici';
37
+ import { request, Agent } from 'undici';
38
38
  import { Readability } from '@mozilla/readability';
39
39
  import { parseHTML } from 'linkedom';
40
40
  import TurndownService from 'turndown';
@@ -45,6 +45,72 @@ let activeLookup = async (hostname) => await dnsLookup(hostname, { all: true, ve
45
45
  export function _setLookupForTests(fn) {
46
46
  activeLookup = fn ?? (async (hostname) => await dnsLookup(hostname, { all: true, verbatim: true }));
47
47
  }
48
+ /**
49
+ * β1b #62 — DNS rebinding guard via pinned-address Dispatcher.
50
+ *
51
+ * Without this, the SSRF guard's `dns.lookup` and undici's `request()`
52
+ * connect(2) each issue independent DNS queries. A hostile resolver
53
+ * can answer "8.8.8.8" the first time (passes the SSRF guard) and
54
+ * "127.0.0.1" the second time (kernel connects to local metadata).
55
+ *
56
+ * Fix: resolve once, validate, then pin the resolved address into a
57
+ * per-call `Agent` via `connect.lookup`. The connect() path no longer
58
+ * touches DNS — it uses the IP we already approved.
59
+ *
60
+ * Test seam: spec suite uses MockAgent as the global dispatcher; the
61
+ * MockAgent path does not exercise real connect(), so pinning is both
62
+ * pointless and would break the MockAgent stub. Specs flip
63
+ * `_disablePinnedDispatcherForTests(true)` in beforeEach to keep the
64
+ * MockAgent flow intact while production hits the pinned path.
65
+ */
66
+ let pinnedDispatcherDisabled = false;
67
+ export function _disablePinnedDispatcherForTests(disabled) {
68
+ pinnedDispatcherDisabled = disabled;
69
+ }
70
+ /**
71
+ * Build a per-call undici Agent that always returns the pre-resolved
72
+ * `address` from its connect.lookup hook. Returns `undefined` when the
73
+ * test flag disabled pinning — caller then falls back to the global
74
+ * dispatcher (MockAgent or production default).
75
+ */
76
+ async function buildPinnedDispatcher(hostname) {
77
+ if (pinnedDispatcherDisabled)
78
+ return undefined;
79
+ // Skip pinning when hostname is already a literal IP — there is no
80
+ // DNS step to race in that case.
81
+ if (isIPv4(hostname) || isIPv6(hostname))
82
+ return undefined;
83
+ let answers;
84
+ try {
85
+ answers = await activeLookup(hostname);
86
+ }
87
+ catch {
88
+ // Best-effort — fall through without pinning; the SSRF guard will
89
+ // emit the canonical DNS-lookup-failed error on the caller's path.
90
+ return undefined;
91
+ }
92
+ const pinned = answers[0];
93
+ if (!pinned)
94
+ return undefined;
95
+ // β1b r1: close the DNS rebinding window the original guard could
96
+ // not see. `validateHostnameForFetch` already ran one lookup; the
97
+ // call above is a SECOND lookup whose answer feeds the pin. A
98
+ // hostile resolver can return a public address to the guard and a
99
+ // private address here — re-validate the pinned literal before we
100
+ // hand it to the Agent. Throws so the caller surfaces a security
101
+ // refusal rather than silently dispatching to the wrong host.
102
+ const ipCheck = validateIpLiteralForFetch(pinned.address, pinned.family);
103
+ if (ipCheck !== null) {
104
+ throw new Error(`ssrf_pinned_address_blocked: ${ipCheck}`);
105
+ }
106
+ return new Agent({
107
+ connect: {
108
+ lookup: (_h, _opts, cb) => {
109
+ cb(null, pinned.address, pinned.family);
110
+ },
111
+ },
112
+ });
113
+ }
48
114
  const FETCH_TIMEOUT_MS = 10_000;
49
115
  const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; // 5 MiB
50
116
  const MAX_REDIRECTS = 5;
@@ -231,6 +297,42 @@ function ipv4IsBlocked(ip) {
231
297
  }
232
298
  return false;
233
299
  }
300
+ /**
301
+ * Validate a single IP literal (v4 or v6) against the SSRF blocklist.
302
+ * Pure synchronous check — no DNS. Returns `null` on success (safe to
303
+ * connect), an error string when the address is blocked or not a
304
+ * recognized IP literal.
305
+ *
306
+ * Used by the pinned-dispatcher path (web-fetch + web-search) to
307
+ * RE-VALIDATE the address actually pinned into `connect.lookup` AFTER
308
+ * the second DNS round-trip. Without this check the original SSRF
309
+ * guard's lookup answers can diverge from the lookup answers that
310
+ * feed the pin (hostile resolver flips public→private between calls);
311
+ * re-checking the pinned literal closes that window.
312
+ *
313
+ * Exported for spec coverage.
314
+ */
315
+ export function validateIpLiteralForFetch(address, family) {
316
+ if (!address)
317
+ return 'empty address';
318
+ // Trust family hint when present (LookupAddress.family is 4 or 6),
319
+ // otherwise infer from the string shape.
320
+ const isV4 = family === 4 || (family === undefined && isIPv4(address));
321
+ const isV6 = family === 6 || (family === undefined && isIPv6(address));
322
+ if (isV4) {
323
+ if (ipv4IsBlocked(address)) {
324
+ return `IP ${address} is in a blocked range (SSRF guard)`;
325
+ }
326
+ return null;
327
+ }
328
+ if (isV6) {
329
+ if (ipv6IsBlocked(address)) {
330
+ return `IPv6 ${address} is in a blocked range (SSRF guard)`;
331
+ }
332
+ return null;
333
+ }
334
+ return `address ${address} is not a recognized IPv4/IPv6 literal`;
335
+ }
234
336
  /**
235
337
  * Resolve `hostname` via dns.lookup and reject if any answer maps to
236
338
  * a private/loopback/link-local/CGNAT range. Returns `null` on success
@@ -395,10 +497,34 @@ export async function webFetchTool(input, ctx) {
395
497
  let currentUrl = parsedUrl;
396
498
  let hops = 0;
397
499
  const controller = new AbortController();
500
+ // β1b #62: per-hop pinned Agent so the post-lookup connect(2) cannot
501
+ // be redirected to a private IP by a hostile resolver. Built lazily
502
+ // per hop because each redirect target may resolve to a different
503
+ // host. `undefined` falls back to the global dispatcher (spec
504
+ // MockAgent or production default), preserving the existing test
505
+ // path. The current Agent is closed at end-of-call so we do not leak
506
+ // open connections.
507
+ let activeAgent;
508
+ const closeActiveAgent = async () => {
509
+ if (activeAgent) {
510
+ try {
511
+ await activeAgent.close();
512
+ }
513
+ catch {
514
+ /* ignore — agent already closed */
515
+ }
516
+ activeAgent = undefined;
517
+ }
518
+ };
398
519
  try {
399
520
  while (true) {
521
+ // β1b #62: refresh the pinned Agent for the current hop.
522
+ await closeActiveAgent();
523
+ const hopHost = currentUrl.hostname.replace(/^\[|\]$/g, '');
524
+ activeAgent = await buildPinnedDispatcher(hopHost);
400
525
  response = await request(currentUrl.toString(), {
401
526
  method: 'GET',
527
+ ...(activeAgent ? { dispatcher: activeAgent } : {}),
402
528
  headers: {
403
529
  'user-agent': USER_AGENT,
404
530
  accept: 'text/html,application/xhtml+xml',
@@ -436,6 +562,7 @@ export async function webFetchTool(input, ctx) {
436
562
  /* socket already closed — nothing to do */
437
563
  }
438
564
  }
565
+ await closeActiveAgent();
439
566
  return { ok: false, error: `Exceeded ${MAX_REDIRECTS} redirect hops.` };
440
567
  }
441
568
  // Drain prior body so the socket can be reused.
@@ -445,9 +572,11 @@ export async function webFetchTool(input, ctx) {
445
572
  nextUrl = new URL(locStr, currentUrl);
446
573
  }
447
574
  catch {
575
+ await closeActiveAgent();
448
576
  return { ok: false, error: `Invalid redirect target: ${locStr}` };
449
577
  }
450
578
  if (nextUrl.protocol !== 'http:' && nextUrl.protocol !== 'https:') {
579
+ await closeActiveAgent();
451
580
  return {
452
581
  ok: false,
453
582
  error: `Refusing redirect to unsupported scheme ${nextUrl.protocol}.`,
@@ -456,6 +585,7 @@ export async function webFetchTool(input, ctx) {
456
585
  const nextHost = nextUrl.hostname.replace(/^\[|\]$/g, '');
457
586
  const guard = await validateHostnameForFetch(nextHost);
458
587
  if (guard) {
588
+ await closeActiveAgent();
459
589
  return { ok: false, error: `SSRF refused on redirect: ${guard}` };
460
590
  }
461
591
  currentUrl = nextUrl;
@@ -465,13 +595,23 @@ export async function webFetchTool(input, ctx) {
465
595
  }
466
596
  }
467
597
  catch (error) {
598
+ await closeActiveAgent();
468
599
  const message = error instanceof Error ? error.message : String(error);
600
+ // β1b r1: the pinned-dispatcher path throws `ssrf_pinned_address_blocked: …`
601
+ // when the second DNS lookup answered a private IP. Surface that as a
602
+ // first-class SSRF refusal so callers (and specs) can match on it
603
+ // without grovelling through `Fetch failed:` prefixes.
604
+ if (message.startsWith('ssrf_pinned_address_blocked')) {
605
+ return { ok: false, error: `SSRF refused: ${message}` };
606
+ }
469
607
  return { ok: false, error: `Fetch failed: ${message}` };
470
608
  }
471
609
  if (!response) {
610
+ await closeActiveAgent();
472
611
  return { ok: false, error: 'No response received.' };
473
612
  }
474
613
  if (response.statusCode < 200 || response.statusCode >= 300) {
614
+ await closeActiveAgent();
475
615
  return { ok: false, error: `HTTP ${response.statusCode} from ${currentUrl.toString()}` };
476
616
  }
477
617
  // content-length is advisory — never trust it for the size cap, but
@@ -489,6 +629,7 @@ export async function webFetchTool(input, ctx) {
489
629
  catch {
490
630
  /* ignore */
491
631
  }
632
+ await closeActiveAgent();
492
633
  return {
493
634
  ok: false,
494
635
  error: `Declared content-length ${n} exceeds ${MAX_RESPONSE_BYTES} byte cap.`,
@@ -499,11 +640,14 @@ export async function webFetchTool(input, ctx) {
499
640
  const contentType = Array.isArray(contentTypeRaw) ? contentTypeRaw[0] : contentTypeRaw;
500
641
  const mime = typeof contentType === 'string' ? contentType.split(';')[0]?.trim().toLowerCase() ?? '' : '';
501
642
  if (!ALLOWED_CONTENT_TYPES.includes(mime)) {
643
+ await closeActiveAgent();
502
644
  return { ok: false, error: `Disallowed content-type ${mime || '(none)'}; only HTML/XHTML/text.` };
503
645
  }
504
646
  const bodyResult = await readBodyWithCap(response.body, controller);
505
- if (!bodyResult.ok)
647
+ if (!bodyResult.ok) {
648
+ await closeActiveAgent();
506
649
  return bodyResult;
650
+ }
507
651
  const html = bodyResult.buffer.toString('utf8');
508
652
  // linkedom is the lightweight DOM Readability needs; jsdom would
509
653
  // add ~3 MB to the install footprint for the same surface.
@@ -524,6 +668,7 @@ export async function webFetchTool(input, ctx) {
524
668
  `Source: ${safeSource}\n\n` +
525
669
  `${scrubbedMarkdown}\n` +
526
670
  `</untrusted-content-${nonce}>`;
671
+ await closeActiveAgent();
527
672
  return {
528
673
  ok: true,
529
674
  url: currentUrl.toString(),