@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.
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +744 -210
- package/dist/core/engine/prompts.js +61 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +818 -31
- package/dist/core/file-cache.js +113 -1
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +719 -29
- package/dist/core/repl/slash-commands.js +133 -9
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/settings.js +71 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +1588 -266
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/lsp.js +187 -5
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +206 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +69 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +276 -37
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +25 -6
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- 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
|
package/dist/tools/web-fetch.js
CHANGED
|
@@ -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(),
|