@apolloyh/apollo-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.apollo/config.example.json +37 -0
- package/.env.example +17 -0
- package/README.md +62 -0
- package/dist/brand.d.ts +17 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +23 -0
- package/dist/cli-args.d.ts +13 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +50 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +190 -0
- package/dist/constants/spinner-verbs.d.ts +7 -0
- package/dist/constants/spinner-verbs.d.ts.map +1 -0
- package/dist/constants/spinner-verbs.js +21 -0
- package/dist/coordinator/workflow.d.ts +20 -0
- package/dist/coordinator/workflow.d.ts.map +1 -0
- package/dist/coordinator/workflow.js +120 -0
- package/dist/goal/goal-mode.d.ts +26 -0
- package/dist/goal/goal-mode.d.ts.map +1 -0
- package/dist/goal/goal-mode.js +141 -0
- package/dist/hooks/runner.d.ts +43 -0
- package/dist/hooks/runner.d.ts.map +1 -0
- package/dist/hooks/runner.js +94 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1716 -0
- package/dist/llm/anthropic.d.ts +46 -0
- package/dist/llm/anthropic.d.ts.map +1 -0
- package/dist/llm/anthropic.js +278 -0
- package/dist/mcp/manager.d.ts +14 -0
- package/dist/mcp/manager.d.ts.map +1 -0
- package/dist/mcp/manager.js +93 -0
- package/dist/memory/memdir.d.ts +19 -0
- package/dist/memory/memdir.d.ts.map +1 -0
- package/dist/memory/memdir.js +96 -0
- package/dist/plan/plan-mode.d.ts +14 -0
- package/dist/plan/plan-mode.d.ts.map +1 -0
- package/dist/plan/plan-mode.js +90 -0
- package/dist/project-instructions.d.ts +8 -0
- package/dist/project-instructions.d.ts.map +1 -0
- package/dist/project-instructions.js +33 -0
- package/dist/runtime/compaction.d.ts +5 -0
- package/dist/runtime/compaction.d.ts.map +1 -0
- package/dist/runtime/compaction.js +37 -0
- package/dist/runtime/context.d.ts +25 -0
- package/dist/runtime/context.d.ts.map +1 -0
- package/dist/runtime/context.js +75 -0
- package/dist/runtime/permissions.d.ts +11 -0
- package/dist/runtime/permissions.d.ts.map +1 -0
- package/dist/runtime/permissions.js +119 -0
- package/dist/runtime/prompt-builder.d.ts +27 -0
- package/dist/runtime/prompt-builder.d.ts.map +1 -0
- package/dist/runtime/prompt-builder.js +112 -0
- package/dist/runtime/query-engine.d.ts +136 -0
- package/dist/runtime/query-engine.d.ts.map +1 -0
- package/dist/runtime/query-engine.js +1182 -0
- package/dist/runtime/tool-policy.d.ts +13 -0
- package/dist/runtime/tool-policy.d.ts.map +1 -0
- package/dist/runtime/tool-policy.js +106 -0
- package/dist/sdk.d.ts +33 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +41 -0
- package/dist/session/resume-ui.d.ts +17 -0
- package/dist/session/resume-ui.d.ts.map +1 -0
- package/dist/session/resume-ui.js +64 -0
- package/dist/session/store.d.ts +66 -0
- package/dist/session/store.d.ts.map +1 -0
- package/dist/session/store.js +120 -0
- package/dist/skillify/skillify.d.ts +12 -0
- package/dist/skillify/skillify.d.ts.map +1 -0
- package/dist/skillify/skillify.js +52 -0
- package/dist/skills/skills.d.ts +26 -0
- package/dist/skills/skills.d.ts.map +1 -0
- package/dist/skills/skills.js +195 -0
- package/dist/status-ui.d.ts +37 -0
- package/dist/status-ui.d.ts.map +1 -0
- package/dist/status-ui.js +130 -0
- package/dist/thought-fold.d.ts +48 -0
- package/dist/thought-fold.d.ts.map +1 -0
- package/dist/thought-fold.js +156 -0
- package/dist/tools/builtin.d.ts +27 -0
- package/dist/tools/builtin.d.ts.map +1 -0
- package/dist/tools/builtin.js +898 -0
- package/dist/tools/registry.d.ts +17 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +86 -0
- package/dist/trace.d.ts +68 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +600 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +8 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +78 -0
- package/docs/sdk.md +259 -0
- package/package.json +53 -0
- package/src/skills/tech-research-skill/SKILL.md +200 -0
- package/src/skills/tech-research-skill/agents/openai.yaml +4 -0
- package/src/skills/tech-research-skill/repo-analyze +435 -0
- package/src/skills/tech-research-skill/repo-fetch +310 -0
- package/src/skills/tech-research-skill/report-generate +254 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { request as httpRequest } from "node:http";
|
|
5
|
+
import { request as httpsRequest } from "node:https";
|
|
6
|
+
import { isIP } from "node:net";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { structuredPatch } from "diff";
|
|
10
|
+
import { PRODUCT } from "../brand.js";
|
|
11
|
+
import { safeRealResolve, stringify, truncate } from "../utils.js";
|
|
12
|
+
import { ToolRegistry } from "./registry.js";
|
|
13
|
+
import { listMemories, saveMemory, searchMemories } from "../memory/memdir.js";
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
const EXA_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
16
|
+
const EXA_CACHE_MAX_ENTRIES = 100;
|
|
17
|
+
const FILE_PREVIEW_LINES = 10;
|
|
18
|
+
const FILE_DIFF_LINES = 40;
|
|
19
|
+
// Above this, skip the inline diff entirely rather than load the old file into memory.
|
|
20
|
+
const FILE_DIFF_MAX_BYTES = 1024 * 1024;
|
|
21
|
+
const exaCache = new Map();
|
|
22
|
+
export function createBuiltinRegistry(options) {
|
|
23
|
+
const registry = new ToolRegistry();
|
|
24
|
+
const { config, skillManager, mcpManager } = options;
|
|
25
|
+
registry.register({
|
|
26
|
+
name: "list_files",
|
|
27
|
+
description: "List files under the workspace. Can list outside the workspace only after human approval.",
|
|
28
|
+
risk: "low",
|
|
29
|
+
input_schema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
directory: { type: "string", description: "Relative directory path." },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
execute: async (input, context) => {
|
|
36
|
+
const directory = stringInput(input, "directory", ".");
|
|
37
|
+
const fullPath = await resolvePathWithBoundaryApproval(config, context, directory, "list directory");
|
|
38
|
+
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
|
39
|
+
return {
|
|
40
|
+
content: entries
|
|
41
|
+
.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${path.join(directory, entry.name)}`)
|
|
42
|
+
.join("\n"),
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
registry.register({
|
|
47
|
+
name: "read_file",
|
|
48
|
+
description: "Read a UTF-8 file. Files outside the workspace require human approval.",
|
|
49
|
+
risk: "low",
|
|
50
|
+
input_schema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
path: { type: "string" },
|
|
54
|
+
maxChars: { type: "number" },
|
|
55
|
+
},
|
|
56
|
+
required: ["path"],
|
|
57
|
+
},
|
|
58
|
+
execute: async (input, context) => {
|
|
59
|
+
const targetPath = requiredString(input, "path");
|
|
60
|
+
const fullPath = await resolvePathWithBoundaryApproval(config, context, targetPath, "read file");
|
|
61
|
+
await requireSensitiveReadApproval(context, fullPath);
|
|
62
|
+
const maxChars = numberInput(input, "maxChars", 20000);
|
|
63
|
+
return { content: truncate(await fs.readFile(fullPath, "utf8"), maxChars) };
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
registry.register({
|
|
67
|
+
name: "write_file",
|
|
68
|
+
description: "Write a UTF-8 file. Writes outside the workspace require explicit approval. Creates parent directories when needed.",
|
|
69
|
+
risk: "high",
|
|
70
|
+
input_schema: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
path: { type: "string" },
|
|
74
|
+
content: { type: "string" },
|
|
75
|
+
},
|
|
76
|
+
required: ["path", "content"],
|
|
77
|
+
},
|
|
78
|
+
execute: async (input, context) => {
|
|
79
|
+
const targetPath = requiredString(input, "path");
|
|
80
|
+
const fullPath = await resolvePathWithBoundaryApproval(config, context, targetPath, "write file");
|
|
81
|
+
const content = requiredString(input, "content");
|
|
82
|
+
// Read the old content only to diff it. Skip on large files so we don't hold
|
|
83
|
+
// previous+new+patch in memory just to render a preview that's capped anyway.
|
|
84
|
+
let previous;
|
|
85
|
+
const diffable = content.length <= FILE_DIFF_MAX_BYTES;
|
|
86
|
+
try {
|
|
87
|
+
const stat = diffable ? await fs.stat(fullPath) : undefined;
|
|
88
|
+
if (stat && stat.size <= FILE_DIFF_MAX_BYTES)
|
|
89
|
+
previous = await fs.readFile(fullPath, "utf8");
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code !== "ENOENT")
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
96
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
97
|
+
const displayPath = path.relative(config.workspaceRoot, fullPath);
|
|
98
|
+
return {
|
|
99
|
+
content: `Wrote ${displayPath}`,
|
|
100
|
+
fileChange: diffable ? fileChangeDisplay(displayPath, previous, content) : undefined,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
registry.register({
|
|
105
|
+
name: "shell_exec",
|
|
106
|
+
description: "Run a local shell command in the workspace for builds, tests, package scripts, or system inspection. Do not use for HTTP requests; use web_search or web_fetch instead.",
|
|
107
|
+
risk: "high",
|
|
108
|
+
input_schema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
command: { type: "string" },
|
|
112
|
+
timeoutMs: { type: "number" },
|
|
113
|
+
},
|
|
114
|
+
required: ["command"],
|
|
115
|
+
},
|
|
116
|
+
execute: async (input, context) => {
|
|
117
|
+
const command = requiredString(input, "command");
|
|
118
|
+
const timeout = numberInput(input, "timeoutMs", 30000);
|
|
119
|
+
try {
|
|
120
|
+
const { stdout, stderr } = await execFileAsync("zsh", ["-lc", command], {
|
|
121
|
+
cwd: config.workspaceRoot,
|
|
122
|
+
timeout,
|
|
123
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
124
|
+
signal: context.signal,
|
|
125
|
+
});
|
|
126
|
+
return { content: truncate([stdout, stderr].filter(Boolean).join("\n"), 12000) || "(no output)" };
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const execError = error;
|
|
130
|
+
const detail = [execError.stderr, execError.stdout]
|
|
131
|
+
.filter((value) => Boolean(value?.trim()))
|
|
132
|
+
.join("\n")
|
|
133
|
+
.trim();
|
|
134
|
+
throw new Error(truncate(detail || execError.message, 12000));
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
registry.register({
|
|
139
|
+
name: "web_search",
|
|
140
|
+
description: "Search the public web for current external information. Use when facts may have changed recently or the user asks for online research.",
|
|
141
|
+
risk: "medium",
|
|
142
|
+
input_schema: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
query: { type: "string", description: "Search query." },
|
|
146
|
+
maxResults: { type: "number", description: "Maximum number of results to return. Default 5, max 10." },
|
|
147
|
+
},
|
|
148
|
+
required: ["query"],
|
|
149
|
+
},
|
|
150
|
+
execute: async (input) => {
|
|
151
|
+
const query = requiredString(input, "query");
|
|
152
|
+
const maxResults = Math.min(Math.max(Math.trunc(numberInput(input, "maxResults", 5)), 1), 10);
|
|
153
|
+
const results = await webSearch(query, maxResults);
|
|
154
|
+
return { content: stringify({ query, results }, 12000) };
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
registry.register({
|
|
158
|
+
name: "web_fetch",
|
|
159
|
+
description: "Fetch a public HTTP/HTTPS URL and return readable text. Use for specific URLs, GitHub API endpoints, raw files, READMEs, and pages found by web_search. Do not use shell_exec with curl/wget/python for web requests.",
|
|
160
|
+
risk: "medium",
|
|
161
|
+
input_schema: {
|
|
162
|
+
type: "object",
|
|
163
|
+
properties: {
|
|
164
|
+
url: { type: "string", description: "Public HTTP or HTTPS URL to fetch." },
|
|
165
|
+
maxChars: { type: "number", description: "Maximum returned characters. Default 12000, max 30000." },
|
|
166
|
+
},
|
|
167
|
+
required: ["url"],
|
|
168
|
+
},
|
|
169
|
+
execute: async (input) => {
|
|
170
|
+
const url = requiredString(input, "url");
|
|
171
|
+
const maxChars = Math.min(Math.max(Math.trunc(numberInput(input, "maxChars", 12000)), 1000), 30000);
|
|
172
|
+
const fetched = await webFetch(url, maxChars, 30000);
|
|
173
|
+
return { content: stringify(fetched, maxChars + 1000) };
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
registry.register({
|
|
177
|
+
name: "git_status",
|
|
178
|
+
description: "Show git status for the workspace.",
|
|
179
|
+
risk: "low",
|
|
180
|
+
input_schema: { type: "object", properties: {} },
|
|
181
|
+
execute: () => git(config.workspaceRoot, ["status", "--short"]),
|
|
182
|
+
});
|
|
183
|
+
registry.register({
|
|
184
|
+
name: "git_diff",
|
|
185
|
+
description: "Show git diff for the workspace.",
|
|
186
|
+
risk: "low",
|
|
187
|
+
input_schema: {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
staged: { type: "boolean" },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
execute: (input) => git(config.workspaceRoot, input.staged === true ? ["diff", "--cached"] : ["diff"]),
|
|
194
|
+
});
|
|
195
|
+
registry.register({
|
|
196
|
+
name: "git_commit",
|
|
197
|
+
description: "Create a git commit for current staged changes.",
|
|
198
|
+
risk: "high",
|
|
199
|
+
input_schema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
message: { type: "string" },
|
|
203
|
+
},
|
|
204
|
+
required: ["message"],
|
|
205
|
+
},
|
|
206
|
+
execute: (input) => git(config.workspaceRoot, ["commit", "-m", requiredString(input, "message")]),
|
|
207
|
+
});
|
|
208
|
+
registry.register({
|
|
209
|
+
name: "email_send",
|
|
210
|
+
description: "Compose an email. By default writes an .eml file to .agent/outbox; sends only if AGENT_SENDMAIL_PATH is configured.",
|
|
211
|
+
risk: "high",
|
|
212
|
+
input_schema: {
|
|
213
|
+
type: "object",
|
|
214
|
+
properties: {
|
|
215
|
+
to: { type: "string" },
|
|
216
|
+
subject: { type: "string" },
|
|
217
|
+
body: { type: "string" },
|
|
218
|
+
},
|
|
219
|
+
required: ["to", "subject", "body"],
|
|
220
|
+
},
|
|
221
|
+
execute: async (input) => {
|
|
222
|
+
const to = requiredString(input, "to");
|
|
223
|
+
const subject = requiredString(input, "subject");
|
|
224
|
+
assertEmailHeader("to", to);
|
|
225
|
+
assertEmailHeader("subject", subject);
|
|
226
|
+
const body = requiredString(input, "body");
|
|
227
|
+
const outbox = path.join(config.workspaceRoot, ".agent", "outbox");
|
|
228
|
+
await fs.mkdir(outbox, { recursive: true });
|
|
229
|
+
const filename = `${new Date().toISOString().replace(/[:.]/g, "-")}.eml`;
|
|
230
|
+
const eml = `To: ${to}\nSubject: ${subject}\nContent-Type: text/plain; charset=utf-8\n\n${body}\n`;
|
|
231
|
+
const emlPath = path.join(outbox, filename);
|
|
232
|
+
await fs.writeFile(emlPath, eml, "utf8");
|
|
233
|
+
const sendmail = process.env.AGENT_SENDMAIL_PATH;
|
|
234
|
+
if (sendmail) {
|
|
235
|
+
await sendToProcess(sendmail, ["-t"], eml);
|
|
236
|
+
return { content: `Email sent and archived at ${path.relative(config.workspaceRoot, emlPath)}` };
|
|
237
|
+
}
|
|
238
|
+
return { content: `Email written to ${path.relative(config.workspaceRoot, emlPath)}. Set AGENT_SENDMAIL_PATH to send.` };
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
registry.register({
|
|
242
|
+
name: "request_human_approval",
|
|
243
|
+
description: "Ask the human to approve or decide a high-value action before continuing.",
|
|
244
|
+
risk: "low",
|
|
245
|
+
input_schema: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {
|
|
248
|
+
question: { type: "string" },
|
|
249
|
+
context: { type: "string" },
|
|
250
|
+
},
|
|
251
|
+
required: ["question"],
|
|
252
|
+
},
|
|
253
|
+
execute: async (input, context) => {
|
|
254
|
+
const question = requiredString(input, "question");
|
|
255
|
+
const approved = await context.requestApproval({
|
|
256
|
+
toolName: "request_human_approval",
|
|
257
|
+
risk: "high",
|
|
258
|
+
reason: question,
|
|
259
|
+
input,
|
|
260
|
+
});
|
|
261
|
+
return { content: approved ? "Human approved." : "Human rejected.", isError: !approved };
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
registry.register({
|
|
265
|
+
name: "skill_search",
|
|
266
|
+
description: "Search local skills by keyword.",
|
|
267
|
+
risk: "low",
|
|
268
|
+
input_schema: {
|
|
269
|
+
type: "object",
|
|
270
|
+
properties: {
|
|
271
|
+
query: { type: "string" },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
execute: async (input) => ({ content: stringify(await skillManager.search(stringInput(input, "query", ""))) }),
|
|
275
|
+
});
|
|
276
|
+
registry.register({
|
|
277
|
+
name: "skill_read",
|
|
278
|
+
description: "Read a skill file returned by skill_search.",
|
|
279
|
+
risk: "low",
|
|
280
|
+
input_schema: {
|
|
281
|
+
type: "object",
|
|
282
|
+
properties: {
|
|
283
|
+
path: { type: "string" },
|
|
284
|
+
},
|
|
285
|
+
required: ["path"],
|
|
286
|
+
},
|
|
287
|
+
execute: async (input) => ({ content: await skillManager.read(requiredString(input, "path")) }),
|
|
288
|
+
});
|
|
289
|
+
registry.register({
|
|
290
|
+
name: "skill_install",
|
|
291
|
+
description: "Install a local skill into the first configured skills directory. Source must be a SKILL.md file or a directory containing SKILL.md.",
|
|
292
|
+
risk: "high",
|
|
293
|
+
input_schema: {
|
|
294
|
+
type: "object",
|
|
295
|
+
properties: {
|
|
296
|
+
sourcePath: { type: "string", description: "Path to SKILL.md or a directory containing SKILL.md." },
|
|
297
|
+
name: { type: "string", description: "Optional installed skill name." },
|
|
298
|
+
},
|
|
299
|
+
required: ["sourcePath"],
|
|
300
|
+
},
|
|
301
|
+
execute: async (input) => {
|
|
302
|
+
const name = typeof input.name === "string" && input.name.trim() ? input.name : undefined;
|
|
303
|
+
return { content: stringify(await skillManager.installLocal(requiredString(input, "sourcePath"), name)) };
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
registry.register({
|
|
307
|
+
name: "mcp_list_tools",
|
|
308
|
+
description: "List tools exposed by configured MCP servers.",
|
|
309
|
+
risk: "medium",
|
|
310
|
+
input_schema: {
|
|
311
|
+
type: "object",
|
|
312
|
+
properties: {
|
|
313
|
+
server: { type: "string" },
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
execute: async (input) => ({ content: stringify(await mcpManager.listTools(stringInput(input, "server", ""))) }),
|
|
317
|
+
});
|
|
318
|
+
registry.register({
|
|
319
|
+
name: "mcp_call_tool",
|
|
320
|
+
description: "Call a tool on a configured MCP server.",
|
|
321
|
+
risk: "high",
|
|
322
|
+
input_schema: {
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
server: { type: "string" },
|
|
326
|
+
tool: { type: "string" },
|
|
327
|
+
arguments: { type: "object" },
|
|
328
|
+
},
|
|
329
|
+
required: ["server", "tool", "arguments"],
|
|
330
|
+
},
|
|
331
|
+
execute: async (input) => ({
|
|
332
|
+
content: await mcpManager.callTool(requiredString(input, "server"), requiredString(input, "tool"), objectInput(input, "arguments")),
|
|
333
|
+
}),
|
|
334
|
+
});
|
|
335
|
+
registry.register({
|
|
336
|
+
name: "memory_search",
|
|
337
|
+
description: "Search durable project memories under .apollo/memory. Use for past decisions, conventions, and user preferences.",
|
|
338
|
+
risk: "low",
|
|
339
|
+
input_schema: {
|
|
340
|
+
type: "object",
|
|
341
|
+
properties: {
|
|
342
|
+
query: { type: "string" },
|
|
343
|
+
limit: { type: "number" },
|
|
344
|
+
},
|
|
345
|
+
required: ["query"],
|
|
346
|
+
},
|
|
347
|
+
execute: async (input) => {
|
|
348
|
+
const q = requiredString(input, "query");
|
|
349
|
+
const limit = Math.min(Math.max(Math.trunc(numberInput(input, "limit", 8)), 1), 20);
|
|
350
|
+
const hits = await searchMemories(config.workspaceRoot, q, limit);
|
|
351
|
+
return { content: stringify({ query: q, count: hits.length, memories: hits }) };
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
registry.register({
|
|
355
|
+
name: "memory_save",
|
|
356
|
+
description: "Save a durable memory note for this project (title + content + optional tags).",
|
|
357
|
+
risk: "medium",
|
|
358
|
+
input_schema: {
|
|
359
|
+
type: "object",
|
|
360
|
+
properties: {
|
|
361
|
+
title: { type: "string" },
|
|
362
|
+
content: { type: "string" },
|
|
363
|
+
tags: { type: "array", items: { type: "string" } },
|
|
364
|
+
},
|
|
365
|
+
required: ["title", "content"],
|
|
366
|
+
},
|
|
367
|
+
execute: async (input) => {
|
|
368
|
+
const title = requiredString(input, "title");
|
|
369
|
+
const content = requiredString(input, "content");
|
|
370
|
+
const tags = Array.isArray(input.tags) ? input.tags.map(String) : [];
|
|
371
|
+
const note = await saveMemory(config.workspaceRoot, { title, content, tags });
|
|
372
|
+
return { content: stringify(note) };
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
registry.register({
|
|
376
|
+
name: "memory_list",
|
|
377
|
+
description: "List recent project memories.",
|
|
378
|
+
risk: "low",
|
|
379
|
+
input_schema: { type: "object", properties: {} },
|
|
380
|
+
execute: async () => ({ content: stringify(await listMemories(config.workspaceRoot)) }),
|
|
381
|
+
});
|
|
382
|
+
registry.register({
|
|
383
|
+
name: "ask_user_question",
|
|
384
|
+
description: "Ask the user a structured question when required information is missing. Provide a clear question and optional multiple-choice options. Prefer this over guessing.",
|
|
385
|
+
risk: "low",
|
|
386
|
+
input_schema: {
|
|
387
|
+
type: "object",
|
|
388
|
+
properties: {
|
|
389
|
+
question: { type: "string" },
|
|
390
|
+
options: { type: "array", items: { type: "string" }, description: "Optional choices" },
|
|
391
|
+
},
|
|
392
|
+
required: ["question"],
|
|
393
|
+
},
|
|
394
|
+
execute: async (input, context) => {
|
|
395
|
+
const question = requiredString(input, "question");
|
|
396
|
+
const options = Array.isArray(input.options) ? input.options.map(String) : undefined;
|
|
397
|
+
context.emit({ type: "user_question", question, options });
|
|
398
|
+
if (!context.askUser) {
|
|
399
|
+
return {
|
|
400
|
+
content: "No interactive user available. Rephrase the question in your final answer instead.",
|
|
401
|
+
isError: true,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const answer = await context.askUser(question, options);
|
|
405
|
+
context.emit({ type: "user_answer", answer });
|
|
406
|
+
return { content: `User answered: ${answer}` };
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
if (options.enableTask !== false && options.runTask) {
|
|
410
|
+
const typeNames = config.agents.map((a) => a.name).join(" | ");
|
|
411
|
+
registry.register({
|
|
412
|
+
name: "Task",
|
|
413
|
+
description: `Launch a fresh subagent (Claude Code Task model) for isolated multi-step work.
|
|
414
|
+
|
|
415
|
+
Use for broad exploration, independent research/review, or specialist roles. Do NOT use for simple Q&A, reading a known file, or small edits.
|
|
416
|
+
|
|
417
|
+
Parameters:
|
|
418
|
+
- description: 3–5 words shown to the user (e.g. "find auth handlers")
|
|
419
|
+
- prompt: full self-contained brief for the subagent (no chat history is shared)
|
|
420
|
+
- subagent_type: ${typeNames || "general-purpose"}
|
|
421
|
+
|
|
422
|
+
Results return to you only — synthesize the final user-facing answer yourself. Independent Tasks may run in parallel.`,
|
|
423
|
+
risk: "medium",
|
|
424
|
+
input_schema: {
|
|
425
|
+
type: "object",
|
|
426
|
+
properties: {
|
|
427
|
+
description: {
|
|
428
|
+
type: "string",
|
|
429
|
+
description: "Short 3–5 word user-visible label for this task.",
|
|
430
|
+
},
|
|
431
|
+
prompt: {
|
|
432
|
+
type: "string",
|
|
433
|
+
description: "Full task brief for the subagent. Include goal, context, constraints, and expected output.",
|
|
434
|
+
},
|
|
435
|
+
subagent_type: {
|
|
436
|
+
type: "string",
|
|
437
|
+
description: `Subagent type: ${typeNames}`,
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
required: ["description", "prompt", "subagent_type"],
|
|
441
|
+
},
|
|
442
|
+
execute: async (input, context) => {
|
|
443
|
+
if (!options.runTask) {
|
|
444
|
+
return { content: "Task is not available in this context.", isError: true };
|
|
445
|
+
}
|
|
446
|
+
// Accept Claude Code names; tolerate legacy worker/task keys.
|
|
447
|
+
const subagentType = stringInput(input, "subagent_type", "") ||
|
|
448
|
+
stringInput(input, "worker", "") ||
|
|
449
|
+
"general-purpose";
|
|
450
|
+
const prompt = stringInput(input, "prompt", "") ||
|
|
451
|
+
stringInput(input, "task", "");
|
|
452
|
+
if (!prompt)
|
|
453
|
+
throw new Error("Missing string input: prompt");
|
|
454
|
+
const description = stringInput(input, "description", `${subagentType} task`);
|
|
455
|
+
const agent = config.agents.find((candidate) => candidate.name.toLowerCase() === subagentType.toLowerCase());
|
|
456
|
+
if (!agent) {
|
|
457
|
+
const known = config.agents.map((a) => a.name).join(", ");
|
|
458
|
+
return {
|
|
459
|
+
content: `Unknown subagent_type "${subagentType}". Choose one of: ${known || "(none configured)"}`,
|
|
460
|
+
isError: true,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const taskId = `task-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
464
|
+
const startedAt = Date.now();
|
|
465
|
+
context.emit({
|
|
466
|
+
type: "task_start",
|
|
467
|
+
id: taskId,
|
|
468
|
+
subagentType: agent.name,
|
|
469
|
+
description,
|
|
470
|
+
});
|
|
471
|
+
const subEmit = (event) => {
|
|
472
|
+
switch (event.type) {
|
|
473
|
+
case "llm_request":
|
|
474
|
+
context.emit({
|
|
475
|
+
type: "task_progress",
|
|
476
|
+
id: taskId,
|
|
477
|
+
subagentType: agent.name,
|
|
478
|
+
message: `thinking · turn ${event.turn}`,
|
|
479
|
+
});
|
|
480
|
+
break;
|
|
481
|
+
case "tool_call":
|
|
482
|
+
context.emit({
|
|
483
|
+
type: "task_progress",
|
|
484
|
+
id: taskId,
|
|
485
|
+
subagentType: agent.name,
|
|
486
|
+
message: `${event.tool}`,
|
|
487
|
+
});
|
|
488
|
+
break;
|
|
489
|
+
case "tool_result":
|
|
490
|
+
context.emit({
|
|
491
|
+
type: "task_progress",
|
|
492
|
+
id: taskId,
|
|
493
|
+
subagentType: agent.name,
|
|
494
|
+
message: event.isError ? `${event.tool} failed` : `${event.tool} ✓`,
|
|
495
|
+
});
|
|
496
|
+
break;
|
|
497
|
+
default:
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const result = await options.runTask(agent, prompt, subEmit);
|
|
502
|
+
const durationMs = Date.now() - startedAt;
|
|
503
|
+
context.emit({
|
|
504
|
+
type: "task_finish",
|
|
505
|
+
id: taskId,
|
|
506
|
+
subagentType: agent.name,
|
|
507
|
+
description,
|
|
508
|
+
result: truncate(result, 1000),
|
|
509
|
+
durationMs,
|
|
510
|
+
});
|
|
511
|
+
return {
|
|
512
|
+
content: [
|
|
513
|
+
`## Task complete`,
|
|
514
|
+
`- type: ${agent.name}`,
|
|
515
|
+
`- description: ${description}`,
|
|
516
|
+
`- duration: ${(durationMs / 1000).toFixed(1)}s`,
|
|
517
|
+
``,
|
|
518
|
+
`### Report`,
|
|
519
|
+
result,
|
|
520
|
+
].join("\n"),
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
// Enforce subagent tool allowlist (and never expose Task unless enableTask)
|
|
526
|
+
const allowed = options.allowedTools;
|
|
527
|
+
if (allowed && allowed.length > 0 && !allowed.includes("*")) {
|
|
528
|
+
return registry.pick(allowed.filter((name) => name !== "Task"));
|
|
529
|
+
}
|
|
530
|
+
return registry;
|
|
531
|
+
}
|
|
532
|
+
async function git(cwd, args) {
|
|
533
|
+
const { stdout, stderr } = await execFileAsync("git", args, { cwd, timeout: 30000, maxBuffer: 1024 * 1024 * 4 });
|
|
534
|
+
return { content: truncate([stdout, stderr].filter(Boolean).join("\n"), 12000) || "(no output)" };
|
|
535
|
+
}
|
|
536
|
+
function fileChangeDisplay(path, previous, content) {
|
|
537
|
+
if (previous === undefined) {
|
|
538
|
+
const lines = visibleLines(content);
|
|
539
|
+
return {
|
|
540
|
+
path,
|
|
541
|
+
kind: "create",
|
|
542
|
+
added: lines.length,
|
|
543
|
+
removed: 0,
|
|
544
|
+
lines: lines.slice(0, FILE_PREVIEW_LINES),
|
|
545
|
+
omitted: Math.max(0, lines.length - FILE_PREVIEW_LINES),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const patch = structuredPatch(path, path, previous, content, "", "", { context: 3 });
|
|
549
|
+
const lines = patch.hunks.flatMap((hunk) => [
|
|
550
|
+
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
|
551
|
+
...hunk.lines,
|
|
552
|
+
]);
|
|
553
|
+
const added = patch.hunks.reduce((total, hunk) => total + hunk.lines.filter((line) => line.startsWith("+")).length, 0);
|
|
554
|
+
const removed = patch.hunks.reduce((total, hunk) => total + hunk.lines.filter((line) => line.startsWith("-")).length, 0);
|
|
555
|
+
return {
|
|
556
|
+
path,
|
|
557
|
+
kind: "update",
|
|
558
|
+
added,
|
|
559
|
+
removed,
|
|
560
|
+
lines: lines.slice(0, FILE_DIFF_LINES),
|
|
561
|
+
omitted: Math.max(0, lines.length - FILE_DIFF_LINES),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function visibleLines(content) {
|
|
565
|
+
if (!content)
|
|
566
|
+
return [];
|
|
567
|
+
const lines = content.split("\n");
|
|
568
|
+
if (content.endsWith("\n"))
|
|
569
|
+
lines.pop();
|
|
570
|
+
return lines;
|
|
571
|
+
}
|
|
572
|
+
async function resolvePathWithBoundaryApproval(config, context, targetPath, operation) {
|
|
573
|
+
let boundaryDetail = "";
|
|
574
|
+
try {
|
|
575
|
+
return await safeRealResolve(config.workspaceRoot, targetPath);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
if (!(error instanceof Error) || !error.message.startsWith("Path escapes workspace:")) {
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
boundaryDetail = error.message.slice("Path escapes workspace:".length).trim();
|
|
582
|
+
}
|
|
583
|
+
const resolvedPath = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(config.workspaceRoot, targetPath);
|
|
584
|
+
const approved = await context.requestApproval({
|
|
585
|
+
toolName: "workspace_boundary",
|
|
586
|
+
risk: "high",
|
|
587
|
+
reason: `Allow ${operation} outside workspace: ${boundaryDetail || resolvedPath}`,
|
|
588
|
+
input: {
|
|
589
|
+
operation,
|
|
590
|
+
path: targetPath,
|
|
591
|
+
resolvedPath,
|
|
592
|
+
workspaceRoot: config.workspaceRoot,
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
if (!approved) {
|
|
596
|
+
throw new Error(`Permission denied for ${operation} outside workspace: ${resolvedPath}`);
|
|
597
|
+
}
|
|
598
|
+
return resolvedPath;
|
|
599
|
+
}
|
|
600
|
+
async function webSearch(query, maxResults) {
|
|
601
|
+
const now = Date.now();
|
|
602
|
+
const key = `${query.trim().toLowerCase().replace(/\s+/g, " ")}\n${maxResults}`;
|
|
603
|
+
const cached = exaCache.get(key);
|
|
604
|
+
if (cached && cached.expiresAt > now)
|
|
605
|
+
return cached.results;
|
|
606
|
+
if (cached)
|
|
607
|
+
exaCache.delete(key);
|
|
608
|
+
const apiKey = process.env.EXA_API_KEY;
|
|
609
|
+
if (!apiKey)
|
|
610
|
+
throw new Error("Missing EXA_API_KEY. Set it in the shell or an explicit --env-file.");
|
|
611
|
+
const results = await searchExa(query, maxResults, apiKey);
|
|
612
|
+
// ponytail: bounded process-local cache; use a shared cache only if multi-process hit rate justifies it.
|
|
613
|
+
while (exaCache.size >= EXA_CACHE_MAX_ENTRIES) {
|
|
614
|
+
const oldest = exaCache.keys().next().value;
|
|
615
|
+
if (oldest === undefined)
|
|
616
|
+
break;
|
|
617
|
+
exaCache.delete(oldest);
|
|
618
|
+
}
|
|
619
|
+
exaCache.set(key, { expiresAt: now + EXA_CACHE_TTL_MS, results });
|
|
620
|
+
return results;
|
|
621
|
+
}
|
|
622
|
+
async function searchExa(query, maxResults, apiKey) {
|
|
623
|
+
const response = await fetch("https://api.exa.ai/search", {
|
|
624
|
+
method: "POST",
|
|
625
|
+
headers: {
|
|
626
|
+
"content-type": "application/json",
|
|
627
|
+
"x-api-key": apiKey,
|
|
628
|
+
},
|
|
629
|
+
body: JSON.stringify({ query, type: "auto", numResults: maxResults, contents: { highlights: true } }),
|
|
630
|
+
signal: AbortSignal.timeout(20000),
|
|
631
|
+
});
|
|
632
|
+
const raw = await response.text();
|
|
633
|
+
if (!response.ok)
|
|
634
|
+
throw new Error(`Exa returned HTTP ${response.status}: ${truncate(raw, 500)}`);
|
|
635
|
+
const payload = JSON.parse(raw);
|
|
636
|
+
return (payload.results ?? []).slice(0, maxResults).flatMap((result) => {
|
|
637
|
+
const title = typeof result.title === "string" ? result.title : "";
|
|
638
|
+
const url = typeof result.url === "string" ? result.url : "";
|
|
639
|
+
if (!url)
|
|
640
|
+
return [];
|
|
641
|
+
const highlights = Array.isArray(result.highlights)
|
|
642
|
+
? result.highlights.filter((value) => typeof value === "string").join(" ")
|
|
643
|
+
: "";
|
|
644
|
+
const text = typeof result.text === "string" ? result.text : "";
|
|
645
|
+
return [{ title: title || url, url, snippet: truncate(highlights || text, 1200) }];
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
async function webFetch(urlText, maxChars, timeoutMs) {
|
|
649
|
+
let url = new URL(urlText);
|
|
650
|
+
let response;
|
|
651
|
+
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
|
652
|
+
const pinned = await resolvePublicHttpAddress(url);
|
|
653
|
+
try {
|
|
654
|
+
response = await requestPinned(url, pinned, timeoutMs, maxChars);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError" || /timeout/i.test(error.message))) {
|
|
658
|
+
throw new Error(`web_fetch timed out after ${Math.round(timeoutMs / 1000)}s: ${url.toString()}`);
|
|
659
|
+
}
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
663
|
+
break;
|
|
664
|
+
const location = response.headers.get("location");
|
|
665
|
+
if (!location)
|
|
666
|
+
break;
|
|
667
|
+
if (redirects === 5)
|
|
668
|
+
throw new Error("web_fetch stopped after 5 redirects");
|
|
669
|
+
url = new URL(location, url);
|
|
670
|
+
}
|
|
671
|
+
if (!response)
|
|
672
|
+
throw new Error("web_fetch failed before receiving a response");
|
|
673
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
674
|
+
return {
|
|
675
|
+
url: response.url,
|
|
676
|
+
status: response.status,
|
|
677
|
+
contentType,
|
|
678
|
+
content: truncate(readableWebContent(response.body, contentType), maxChars),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
export async function resolvePublicHttpAddress(url, resolve = async (hostname) => await lookup(hostname, { all: true })) {
|
|
682
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
683
|
+
throw new Error(`Unsupported URL protocol: ${url.protocol}`);
|
|
684
|
+
}
|
|
685
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
686
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
687
|
+
throw new Error(`web_fetch blocked private address: ${hostname}`);
|
|
688
|
+
}
|
|
689
|
+
const literalFamily = isIP(hostname);
|
|
690
|
+
const addresses = literalFamily ? [{ address: hostname, family: literalFamily }] : await resolve(hostname);
|
|
691
|
+
if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
|
|
692
|
+
throw new Error(`web_fetch blocked private address: ${hostname}`);
|
|
693
|
+
}
|
|
694
|
+
const selected = addresses[0];
|
|
695
|
+
const address = normalizeIpAddress(selected.address);
|
|
696
|
+
const family = isIP(address);
|
|
697
|
+
if (family !== 4 && family !== 6)
|
|
698
|
+
throw new Error(`web_fetch blocked invalid address: ${hostname}`);
|
|
699
|
+
return { address, family };
|
|
700
|
+
}
|
|
701
|
+
async function requestPinned(url, pinned, timeoutMs, maxChars) {
|
|
702
|
+
const pinnedLookup = (_hostname, options, callback) => {
|
|
703
|
+
if (options.all)
|
|
704
|
+
callback(null, [pinned]);
|
|
705
|
+
else
|
|
706
|
+
callback(null, pinned.address, pinned.family);
|
|
707
|
+
};
|
|
708
|
+
// UTF-8 is at most 4 bytes/char, so this many bytes always yields >= maxChars chars after truncate.
|
|
709
|
+
const maxBytes = maxChars * 4 + 1024;
|
|
710
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
711
|
+
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
712
|
+
return await new Promise((resolve, reject) => {
|
|
713
|
+
const req = request(url, {
|
|
714
|
+
headers: {
|
|
715
|
+
"user-agent": `Mozilla/5.0 (compatible; ${PRODUCT.userAgent}; +https://localhost)`,
|
|
716
|
+
accept: "text/html,application/json,text/plain;q=0.9,*/*;q=0.8",
|
|
717
|
+
},
|
|
718
|
+
lookup: pinnedLookup,
|
|
719
|
+
signal,
|
|
720
|
+
}, (incoming) => {
|
|
721
|
+
const chunks = [];
|
|
722
|
+
let total = 0;
|
|
723
|
+
let capped = false;
|
|
724
|
+
const finish = () => {
|
|
725
|
+
const headers = new Headers();
|
|
726
|
+
for (let index = 0; index < incoming.rawHeaders.length; index += 2) {
|
|
727
|
+
headers.append(incoming.rawHeaders[index], incoming.rawHeaders[index + 1] ?? "");
|
|
728
|
+
}
|
|
729
|
+
resolve({
|
|
730
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
731
|
+
headers,
|
|
732
|
+
status: incoming.statusCode ?? 0,
|
|
733
|
+
url: url.toString(),
|
|
734
|
+
});
|
|
735
|
+
};
|
|
736
|
+
incoming.on("data", (chunk) => {
|
|
737
|
+
if (capped)
|
|
738
|
+
return;
|
|
739
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
740
|
+
total += chunk.length;
|
|
741
|
+
if (total >= maxBytes) {
|
|
742
|
+
capped = true;
|
|
743
|
+
req.destroy();
|
|
744
|
+
finish();
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
incoming.on("error", reject);
|
|
748
|
+
incoming.on("end", () => {
|
|
749
|
+
if (!capped)
|
|
750
|
+
finish();
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
req.on("error", (error) => {
|
|
754
|
+
// Ignore the synthetic abort from our own req.destroy() after capping.
|
|
755
|
+
if (error.code === "ECONNRESET")
|
|
756
|
+
return;
|
|
757
|
+
reject(error);
|
|
758
|
+
});
|
|
759
|
+
req.end();
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
function isPrivateAddress(address) {
|
|
763
|
+
if (address.includes(":")) {
|
|
764
|
+
const normalized = normalizeIpAddress(address);
|
|
765
|
+
if (isIP(normalized) !== 6)
|
|
766
|
+
return true;
|
|
767
|
+
const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
768
|
+
if (mapped)
|
|
769
|
+
return isPrivateAddress(mapped);
|
|
770
|
+
return normalized.startsWith("::") || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("ff") || /^fe[89a-f]/.test(normalized) || normalized.startsWith("2001:db8:");
|
|
771
|
+
}
|
|
772
|
+
if (isIP(address) !== 4)
|
|
773
|
+
return true;
|
|
774
|
+
const octets = address.split(".").map(Number);
|
|
775
|
+
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value)))
|
|
776
|
+
return true;
|
|
777
|
+
const [a, b, c] = octets;
|
|
778
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 ||
|
|
779
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
780
|
+
(a === 169 && b === 254) ||
|
|
781
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
782
|
+
(a === 192 && b === 168) ||
|
|
783
|
+
(a === 192 && b === 0 && (c === 0 || c === 2)) ||
|
|
784
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
785
|
+
(a === 198 && b === 51 && c === 100) ||
|
|
786
|
+
(a === 203 && b === 0 && c === 113);
|
|
787
|
+
}
|
|
788
|
+
function normalizeIpAddress(address) {
|
|
789
|
+
if (!address.includes(":"))
|
|
790
|
+
return address;
|
|
791
|
+
try {
|
|
792
|
+
return new URL(`http://[${address}]/`).hostname.slice(1, -1).toLowerCase();
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return address.toLowerCase();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function requireSensitiveReadApproval(context, fullPath) {
|
|
799
|
+
if (!isSensitivePath(fullPath))
|
|
800
|
+
return;
|
|
801
|
+
const approved = await context.requestApproval({
|
|
802
|
+
toolName: "sensitive_file_read",
|
|
803
|
+
risk: "high",
|
|
804
|
+
reason: `Allow reading sensitive file: ${fullPath}`,
|
|
805
|
+
input: { path: fullPath },
|
|
806
|
+
});
|
|
807
|
+
if (!approved)
|
|
808
|
+
throw new Error(`Permission denied for sensitive file: ${fullPath}`);
|
|
809
|
+
}
|
|
810
|
+
function isSensitivePath(filePath) {
|
|
811
|
+
const name = path.basename(filePath).toLowerCase();
|
|
812
|
+
if (name === ".env" || (name.startsWith(".env.") && name !== ".env.example"))
|
|
813
|
+
return true;
|
|
814
|
+
if (/^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/.test(name))
|
|
815
|
+
return true;
|
|
816
|
+
return /\.(key|pem|p12|pfx|jks|keystore)$/.test(name);
|
|
817
|
+
}
|
|
818
|
+
function assertEmailHeader(name, value) {
|
|
819
|
+
if (/[\r\n]/.test(value))
|
|
820
|
+
throw new Error(`Invalid email ${name}: line breaks are not allowed`);
|
|
821
|
+
}
|
|
822
|
+
function readableWebContent(raw, contentType) {
|
|
823
|
+
if (contentType.includes("application/json")) {
|
|
824
|
+
try {
|
|
825
|
+
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
return raw;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (contentType.includes("text/html")) {
|
|
832
|
+
return cleanHtml(raw
|
|
833
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
834
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
835
|
+
.replace(/<\/(p|div|li|h[1-6]|tr|section|article)>/gi, "\n"));
|
|
836
|
+
}
|
|
837
|
+
return raw;
|
|
838
|
+
}
|
|
839
|
+
function cleanHtml(value) {
|
|
840
|
+
return decodeHtml(value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim());
|
|
841
|
+
}
|
|
842
|
+
function decodeHtml(value) {
|
|
843
|
+
return value
|
|
844
|
+
.replace(/&#(\d+);/g, (_match, code) => String.fromCharCode(Number(code)))
|
|
845
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_match, code) => String.fromCharCode(Number.parseInt(code, 16)))
|
|
846
|
+
.replace(/&/g, "&")
|
|
847
|
+
.replace(/</g, "<")
|
|
848
|
+
.replace(/>/g, ">")
|
|
849
|
+
.replace(/"/g, '"')
|
|
850
|
+
.replace(/'/g, "'")
|
|
851
|
+
.replace(/'/g, "'")
|
|
852
|
+
.replace(///g, "/");
|
|
853
|
+
}
|
|
854
|
+
async function sendToProcess(command, args, input) {
|
|
855
|
+
await new Promise((resolve, reject) => {
|
|
856
|
+
const child = spawn(command, args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
857
|
+
let stderr = "";
|
|
858
|
+
const timer = setTimeout(() => {
|
|
859
|
+
child.kill("SIGTERM");
|
|
860
|
+
reject(new Error(`Timed out running ${command}`));
|
|
861
|
+
}, 30000);
|
|
862
|
+
child.stderr.on("data", (chunk) => {
|
|
863
|
+
stderr += chunk.toString("utf8");
|
|
864
|
+
});
|
|
865
|
+
child.on("error", (error) => {
|
|
866
|
+
clearTimeout(timer);
|
|
867
|
+
reject(error);
|
|
868
|
+
});
|
|
869
|
+
child.on("close", (code) => {
|
|
870
|
+
clearTimeout(timer);
|
|
871
|
+
if (code === 0)
|
|
872
|
+
resolve();
|
|
873
|
+
else
|
|
874
|
+
reject(new Error(`${command} exited with ${code}: ${stderr}`));
|
|
875
|
+
});
|
|
876
|
+
child.stdin.end(input);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
function requiredString(input, key) {
|
|
880
|
+
const value = input[key];
|
|
881
|
+
if (typeof value !== "string" || value.length === 0)
|
|
882
|
+
throw new Error(`Missing string input: ${key}`);
|
|
883
|
+
return value;
|
|
884
|
+
}
|
|
885
|
+
function stringInput(input, key, fallback) {
|
|
886
|
+
const value = input[key];
|
|
887
|
+
return typeof value === "string" ? value : fallback;
|
|
888
|
+
}
|
|
889
|
+
function numberInput(input, key, fallback) {
|
|
890
|
+
const value = input[key];
|
|
891
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
892
|
+
}
|
|
893
|
+
function objectInput(input, key) {
|
|
894
|
+
const value = input[key];
|
|
895
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
896
|
+
throw new Error(`Missing object input: ${key}`);
|
|
897
|
+
return value;
|
|
898
|
+
}
|