codeep 2.18.0 → 2.19.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/README.md +53 -16
- package/dist/acp/commands.js +11 -55
- package/dist/acp/protocol.d.ts +34 -0
- package/dist/acp/server.d.ts +6 -1
- package/dist/acp/server.js +97 -2
- package/dist/api/index.js +9 -0
- package/dist/commands/core/index.d.ts +19 -0
- package/dist/commands/core/index.js +28 -0
- package/dist/commands/core/keysync.d.ts +2 -0
- package/dist/commands/core/keysync.js +34 -0
- package/dist/commands/core/telemetry.d.ts +2 -0
- package/dist/commands/core/telemetry.js +34 -0
- package/dist/config/index.js +2 -2
- package/dist/renderer/App.d.ts +9 -48
- package/dist/renderer/App.js +113 -338
- package/dist/renderer/Screen.d.ts +13 -0
- package/dist/renderer/Screen.js +22 -0
- package/dist/renderer/commands/registry.js +3 -3
- package/dist/renderer/commands.js +19 -51
- package/dist/renderer/components/CommandAutocomplete.d.ts +46 -0
- package/dist/renderer/components/CommandAutocomplete.js +103 -0
- package/dist/renderer/components/HunkPicker.d.ts +48 -0
- package/dist/renderer/components/HunkPicker.js +140 -0
- package/dist/renderer/components/MentionPicker.d.ts +60 -0
- package/dist/renderer/components/MentionPicker.js +111 -0
- package/dist/renderer/components/PasteDialog.d.ts +43 -0
- package/dist/renderer/components/PasteDialog.js +70 -0
- package/dist/renderer/layout.js +1 -0
- package/dist/renderer/main.js +15 -39
- package/dist/utils/agent.js +121 -26
- package/dist/utils/agentChat.d.ts +11 -4
- package/dist/utils/agentChat.js +53 -25
- package/dist/utils/codeepCloud.d.ts +3 -0
- package/dist/utils/codeepCloud.js +62 -7
- package/dist/utils/personalities.d.ts +63 -5
- package/dist/utils/personalities.js +583 -31
- package/dist/utils/shell.d.ts +11 -1
- package/dist/utils/shell.js +169 -82
- package/dist/utils/ssrfGuard.d.ts +18 -0
- package/dist/utils/ssrfGuard.js +83 -0
- package/dist/utils/taskPlanner.d.ts +7 -1
- package/dist/utils/taskPlanner.js +16 -7
- package/dist/utils/tokenTracker.d.ts +8 -1
- package/dist/utils/tokenTracker.js +38 -5
- package/dist/utils/toolExecution.d.ts +1 -0
- package/dist/utils/toolExecution.js +48 -88
- package/dist/utils/tools.d.ts +3 -3
- package/dist/utils/tools.js +18 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/utils/shell.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ export interface CommandOptions {
|
|
|
17
17
|
projectRoot?: string;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* Validate if a command is safe to execute
|
|
20
|
+
* Validate if a command is safe to execute (synchronous checks).
|
|
21
|
+
* See validateCommandAsync for the DNS-resolving SSRF checks.
|
|
21
22
|
*/
|
|
22
23
|
export declare function validateCommand(command: string, args: string[], options?: CommandOptions): {
|
|
23
24
|
valid: boolean;
|
|
@@ -27,6 +28,15 @@ export declare function validateCommand(command: string, args: string[], options
|
|
|
27
28
|
* Execute a shell command with safety checks
|
|
28
29
|
*/
|
|
29
30
|
export declare function executeCommand(command: string, args?: string[], options?: CommandOptions): CommandResult;
|
|
31
|
+
/**
|
|
32
|
+
* Async validation: everything in validateCommand plus the DNS-resolving
|
|
33
|
+
* SSRF check for URL-carrying commands (curl/wget/http/https). Split from
|
|
34
|
+
* the sync part because DNS lookups can't block the event loop.
|
|
35
|
+
*/
|
|
36
|
+
export declare function validateCommandAsync(command: string, args: string[], options?: CommandOptions): Promise<{
|
|
37
|
+
valid: boolean;
|
|
38
|
+
reason?: string;
|
|
39
|
+
}>;
|
|
30
40
|
/**
|
|
31
41
|
* Execute a shell command asynchronously (non-blocking)
|
|
32
42
|
*/
|
package/dist/utils/shell.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { spawnSync, spawn } from 'child_process';
|
|
5
5
|
import { resolve, relative, isAbsolute } from 'path';
|
|
6
6
|
import { existsSync } from 'fs';
|
|
7
|
+
import { assertFetchUrlAllowed } from './ssrfGuard.js';
|
|
7
8
|
// Dangerous command patterns that should never be executed
|
|
8
9
|
const BLOCKED_COMMANDS = new Set([
|
|
9
10
|
'sudo',
|
|
@@ -68,8 +69,13 @@ const ALLOWED_COMMANDS = new Set([
|
|
|
68
69
|
// Linting/Formatting
|
|
69
70
|
'eslint', 'prettier', 'black', 'rustfmt',
|
|
70
71
|
// Other common tools
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
// NOTE: `env` deliberately NOT whitelisted — it dumps process.env to
|
|
73
|
+
// stdout, which lands in the model's context. Provider API keys ride in
|
|
74
|
+
// env vars, so a single `env` call would exfiltrate every credential the
|
|
75
|
+
// CLI holds. `printenv` is excluded for the same reason. Run these
|
|
76
|
+
// yourself outside the agent if you need environment info.
|
|
77
|
+
'echo', 'pwd', 'which', 'date', 'sleep',
|
|
78
|
+
'curl', 'wget', // allowed but patterns + SSRF-checked
|
|
73
79
|
'tar', 'unzip', 'zip',
|
|
74
80
|
// HTTP tools
|
|
75
81
|
'http', 'https',
|
|
@@ -105,8 +111,62 @@ function hasInlineEval(command, args) {
|
|
|
105
111
|
}
|
|
106
112
|
return false;
|
|
107
113
|
}
|
|
114
|
+
// Commands whose arguments carry URLs that must pass the SSRF guard
|
|
115
|
+
// (private/loopback/metadata IP check) before execution. `fetch_url` already
|
|
116
|
+
// routes through assertFetchUrlAllowed; without this list the same model-
|
|
117
|
+
// controlled URL could just be passed to curl instead.
|
|
118
|
+
const URL_CARRYING_COMMANDS = new Set(['curl', 'wget', 'http', 'https']);
|
|
119
|
+
// Heuristic: extract URL-looking arguments. curl/wget accept URLs with or
|
|
120
|
+
// without a scheme (curl example.com works), and URLs may also ride in
|
|
121
|
+
// option values (`--url=…`, `-d @url`, header values like
|
|
122
|
+
// `Host: internal.corp`). We normalize scheme-less hosts so the guard sees
|
|
123
|
+
// what curl will actually connect to.
|
|
124
|
+
function extractUrlCandidates(args) {
|
|
125
|
+
const urls = [];
|
|
126
|
+
for (const arg of args) {
|
|
127
|
+
if (arg.startsWith('-')) {
|
|
128
|
+
// Option values: --url=x, --output=y are paths not URLs, but
|
|
129
|
+
// --header="Host: x" can smuggle a host. Keep it simple: only check
|
|
130
|
+
// --url= style options that plausibly carry a URL.
|
|
131
|
+
const m = arg.match(/^--url=(.+)$/i);
|
|
132
|
+
if (m)
|
|
133
|
+
urls.push(m[1]);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (/^https?:\/\//i.test(arg)) {
|
|
137
|
+
urls.push(arg);
|
|
138
|
+
}
|
|
139
|
+
else if (
|
|
140
|
+
// scheme-less host forms curl accepts: literal IPs (with optional
|
|
141
|
+
// port/path), 'localhost', and named hosts (example.com, internal.corp).
|
|
142
|
+
// Anything else (plain filenames, package names) is left alone.
|
|
143
|
+
/^(localhost([\/?#].*)?|\d{1,3}(\.\d{1,3}){3}(:\d+)?([\/?#].*)?|[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?([\/?#].*)?)$/i.test(arg)) {
|
|
144
|
+
// scheme-less host or host/path — what curl will connect to
|
|
145
|
+
urls.push(`http://${arg}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return urls;
|
|
149
|
+
}
|
|
150
|
+
// Exec-escapes: whitelisted utilities that can run ARBITRARY other commands
|
|
151
|
+
// as part of their arguments, silently bypassing the whitelist above.
|
|
152
|
+
// find . -exec <anything> \; → runs <anything>
|
|
153
|
+
// find . -execdir <anything> \;
|
|
154
|
+
// tar --to-command=<anything> → pipes each extracted file into it
|
|
155
|
+
// xargs <anything> → not whitelisted itself, but listed
|
|
156
|
+
// here for documentation; see note.
|
|
157
|
+
const EXEC_ESCAPE_SHORT = {
|
|
158
|
+
find: ['-exec', '-execdir', '-ok', '-okdir'],
|
|
159
|
+
tar: ['--to-command'],
|
|
160
|
+
};
|
|
161
|
+
function hasExecEscape(command, args) {
|
|
162
|
+
const flags = EXEC_ESCAPE_SHORT[command] ?? [];
|
|
163
|
+
if (flags.length === 0)
|
|
164
|
+
return false;
|
|
165
|
+
return args.some((a) => flags.includes(a) || flags.some((f) => a.startsWith(f + '=')));
|
|
166
|
+
}
|
|
108
167
|
/**
|
|
109
|
-
* Validate if a command is safe to execute
|
|
168
|
+
* Validate if a command is safe to execute (synchronous checks).
|
|
169
|
+
* See validateCommandAsync for the DNS-resolving SSRF checks.
|
|
110
170
|
*/
|
|
111
171
|
export function validateCommand(command, args, options) {
|
|
112
172
|
// Check if command is in blocked list
|
|
@@ -122,6 +182,11 @@ export function validateCommand(command, args, options) {
|
|
|
122
182
|
if (hasInlineEval(command, args)) {
|
|
123
183
|
return { valid: false, reason: `Inline code execution via '${command}' (e.g. -e/-c/--eval) is not allowed in agent mode — put the code in a file and run that, or run it yourself.` };
|
|
124
184
|
}
|
|
185
|
+
// Block whitelisted utilities whose flags spawn OTHER commands — that
|
|
186
|
+
// would bypass the whitelist entirely (find . -exec rm -rf / \;).
|
|
187
|
+
if (hasExecEscape(command, args)) {
|
|
188
|
+
return { valid: false, reason: `'${command}' with exec flags (-exec/-execdir/--to-command…) runs arbitrary commands and is not allowed in agent mode.` };
|
|
189
|
+
}
|
|
125
190
|
// Check full command string against dangerous patterns
|
|
126
191
|
const fullCommand = `${command} ${args.join(' ')}`;
|
|
127
192
|
for (const pattern of BLOCKED_PATTERNS) {
|
|
@@ -241,6 +306,25 @@ export function executeCommand(command, args = [], options) {
|
|
|
241
306
|
};
|
|
242
307
|
}
|
|
243
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Async validation: everything in validateCommand plus the DNS-resolving
|
|
311
|
+
* SSRF check for URL-carrying commands (curl/wget/http/https). Split from
|
|
312
|
+
* the sync part because DNS lookups can't block the event loop.
|
|
313
|
+
*/
|
|
314
|
+
export async function validateCommandAsync(command, args, options) {
|
|
315
|
+
const sync = validateCommand(command, args, options);
|
|
316
|
+
if (!sync.valid)
|
|
317
|
+
return sync;
|
|
318
|
+
if (URL_CARRYING_COMMANDS.has(command)) {
|
|
319
|
+
for (const url of extractUrlCandidates(args)) {
|
|
320
|
+
const blocked = await assertFetchUrlAllowed(url);
|
|
321
|
+
if (blocked) {
|
|
322
|
+
return { valid: false, reason: `Blocked URL in ${command} arguments: ${blocked}` };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { valid: true };
|
|
327
|
+
}
|
|
244
328
|
/**
|
|
245
329
|
* Execute a shell command asynchronously (non-blocking)
|
|
246
330
|
*/
|
|
@@ -249,88 +333,91 @@ export function executeCommandAsync(command, args = [], options) {
|
|
|
249
333
|
const startTime = Date.now();
|
|
250
334
|
const cwd = options?.cwd || process.cwd();
|
|
251
335
|
const timeout = options?.timeout || 60000;
|
|
252
|
-
// Validate command first
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// Ensure cwd exists
|
|
267
|
-
if (!existsSync(cwd)) {
|
|
268
|
-
resolve({
|
|
269
|
-
success: false,
|
|
270
|
-
stdout: '',
|
|
271
|
-
stderr: `Working directory does not exist: ${cwd}`,
|
|
272
|
-
exitCode: -1,
|
|
273
|
-
duration: 0,
|
|
274
|
-
command,
|
|
275
|
-
args,
|
|
276
|
-
});
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
const child = spawn(command, args, {
|
|
280
|
-
cwd,
|
|
281
|
-
env: { ...process.env, ...options?.env },
|
|
282
|
-
});
|
|
283
|
-
let stdout = '';
|
|
284
|
-
let stderr = '';
|
|
285
|
-
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
286
|
-
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
287
|
-
let settled = false;
|
|
288
|
-
const timer = setTimeout(() => {
|
|
289
|
-
if (settled)
|
|
336
|
+
// Validate command first — async because URL-carrying commands get a
|
|
337
|
+
// DNS-resolving SSRF check (private/loopback/metadata IP guard) that
|
|
338
|
+
// matches the one on the fetch_url tool.
|
|
339
|
+
validateCommandAsync(command, args, options).then((validation) => {
|
|
340
|
+
if (!validation.valid) {
|
|
341
|
+
resolve({
|
|
342
|
+
success: false,
|
|
343
|
+
stdout: '',
|
|
344
|
+
stderr: validation.reason || 'Command validation failed',
|
|
345
|
+
exitCode: -1,
|
|
346
|
+
duration: 0,
|
|
347
|
+
command,
|
|
348
|
+
args,
|
|
349
|
+
});
|
|
290
350
|
return;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}, timeout);
|
|
304
|
-
child.on('close', (code) => {
|
|
305
|
-
if (settled)
|
|
351
|
+
}
|
|
352
|
+
// Ensure cwd exists
|
|
353
|
+
if (!existsSync(cwd)) {
|
|
354
|
+
resolve({
|
|
355
|
+
success: false,
|
|
356
|
+
stdout: '',
|
|
357
|
+
stderr: `Working directory does not exist: ${cwd}`,
|
|
358
|
+
exitCode: -1,
|
|
359
|
+
duration: 0,
|
|
360
|
+
command,
|
|
361
|
+
args,
|
|
362
|
+
});
|
|
306
363
|
return;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
success: code === 0,
|
|
312
|
-
stdout,
|
|
313
|
-
stderr,
|
|
314
|
-
exitCode: code ?? -1,
|
|
315
|
-
duration,
|
|
316
|
-
command,
|
|
317
|
-
args,
|
|
364
|
+
}
|
|
365
|
+
const child = spawn(command, args, {
|
|
366
|
+
cwd,
|
|
367
|
+
env: { ...process.env, ...options?.env },
|
|
318
368
|
});
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
settled =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
369
|
+
let stdout = '';
|
|
370
|
+
let stderr = '';
|
|
371
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
372
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
373
|
+
let settled = false;
|
|
374
|
+
const timer = setTimeout(() => {
|
|
375
|
+
if (settled)
|
|
376
|
+
return;
|
|
377
|
+
settled = true;
|
|
378
|
+
child.kill('SIGTERM');
|
|
379
|
+
const duration = Date.now() - startTime;
|
|
380
|
+
resolve({
|
|
381
|
+
success: false,
|
|
382
|
+
stdout,
|
|
383
|
+
stderr: `Command timed out after ${timeout}ms`,
|
|
384
|
+
exitCode: -1,
|
|
385
|
+
duration,
|
|
386
|
+
command,
|
|
387
|
+
args,
|
|
388
|
+
});
|
|
389
|
+
}, timeout);
|
|
390
|
+
child.on('close', (code) => {
|
|
391
|
+
if (settled)
|
|
392
|
+
return;
|
|
393
|
+
settled = true;
|
|
394
|
+
clearTimeout(timer);
|
|
395
|
+
const duration = Date.now() - startTime;
|
|
396
|
+
resolve({
|
|
397
|
+
success: code === 0,
|
|
398
|
+
stdout,
|
|
399
|
+
stderr,
|
|
400
|
+
exitCode: code ?? -1,
|
|
401
|
+
duration,
|
|
402
|
+
command,
|
|
403
|
+
args,
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
child.on('error', (err) => {
|
|
407
|
+
if (settled)
|
|
408
|
+
return;
|
|
409
|
+
settled = true;
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
const duration = Date.now() - startTime;
|
|
412
|
+
resolve({
|
|
413
|
+
success: false,
|
|
414
|
+
stdout: '',
|
|
415
|
+
stderr: err.message,
|
|
416
|
+
exitCode: -1,
|
|
417
|
+
duration,
|
|
418
|
+
command,
|
|
419
|
+
args,
|
|
420
|
+
});
|
|
334
421
|
});
|
|
335
422
|
});
|
|
336
423
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF (Server-Side Request Forgery) guard, shared by the agent's network-
|
|
3
|
+
* touching surfaces.
|
|
4
|
+
*
|
|
5
|
+
* Used by:
|
|
6
|
+
* - toolExecution.ts → the `fetch_url` tool
|
|
7
|
+
* - shell.ts → curl/wget/http(s) arguments in execute_command
|
|
8
|
+
*
|
|
9
|
+
* The URLs in both cases originate from model output / page content
|
|
10
|
+
* (untrusted, prompt-injectable), so the agent must not be able to reach
|
|
11
|
+
* internal services or the cloud metadata endpoint (169.254.169.254).
|
|
12
|
+
* NOTE: this deliberately does NOT apply to user-configured provider base
|
|
13
|
+
* URLs (Ollama localhost, custom vLLM/Tailscale endpoints) — those are
|
|
14
|
+
* trusted config and never routed through agent tools.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isBlockedIp(ip: string): boolean;
|
|
17
|
+
/** Returns an error string if the URL must not be fetched, else null. */
|
|
18
|
+
export declare function assertFetchUrlAllowed(rawUrl: string): Promise<string | null>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF (Server-Side Request Forgery) guard, shared by the agent's network-
|
|
3
|
+
* touching surfaces.
|
|
4
|
+
*
|
|
5
|
+
* Used by:
|
|
6
|
+
* - toolExecution.ts → the `fetch_url` tool
|
|
7
|
+
* - shell.ts → curl/wget/http(s) arguments in execute_command
|
|
8
|
+
*
|
|
9
|
+
* The URLs in both cases originate from model output / page content
|
|
10
|
+
* (untrusted, prompt-injectable), so the agent must not be able to reach
|
|
11
|
+
* internal services or the cloud metadata endpoint (169.254.169.254).
|
|
12
|
+
* NOTE: this deliberately does NOT apply to user-configured provider base
|
|
13
|
+
* URLs (Ollama localhost, custom vLLM/Tailscale endpoints) — those are
|
|
14
|
+
* trusted config and never routed through agent tools.
|
|
15
|
+
*/
|
|
16
|
+
import { lookup as dnsLookup } from 'dns/promises';
|
|
17
|
+
export function isBlockedIp(ip) {
|
|
18
|
+
const s = ip.trim().toLowerCase();
|
|
19
|
+
if (s.includes(':')) {
|
|
20
|
+
// IPv6
|
|
21
|
+
if (s === '::1' || s === '::')
|
|
22
|
+
return true; // loopback / unspecified
|
|
23
|
+
if (s.startsWith('fe80') || s.startsWith('fc') || s.startsWith('fd'))
|
|
24
|
+
return true; // link-local / ULA
|
|
25
|
+
const mapped = s.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped
|
|
26
|
+
if (mapped)
|
|
27
|
+
return isBlockedIp(mapped[1]);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const parts = s.split('.').map(Number);
|
|
31
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255))
|
|
32
|
+
return false;
|
|
33
|
+
const [a, b] = parts;
|
|
34
|
+
if (a === 127)
|
|
35
|
+
return true; // loopback
|
|
36
|
+
if (a === 10)
|
|
37
|
+
return true; // RFC1918
|
|
38
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
39
|
+
return true; // RFC1918
|
|
40
|
+
if (a === 192 && b === 168)
|
|
41
|
+
return true; // RFC1918
|
|
42
|
+
if (a === 169 && b === 254)
|
|
43
|
+
return true; // link-local incl. metadata 169.254.169.254
|
|
44
|
+
if (a === 0)
|
|
45
|
+
return true; // 0.0.0.0/8
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/** Returns an error string if the URL must not be fetched, else null. */
|
|
49
|
+
export async function assertFetchUrlAllowed(rawUrl) {
|
|
50
|
+
let u;
|
|
51
|
+
try {
|
|
52
|
+
u = new URL(rawUrl);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return 'Invalid URL format';
|
|
56
|
+
}
|
|
57
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
58
|
+
return `Blocked: only http/https URLs can be fetched (got "${u.protocol}")`;
|
|
59
|
+
}
|
|
60
|
+
const host = u.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
|
|
61
|
+
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
62
|
+
return 'Blocked: localhost is not fetchable by the agent';
|
|
63
|
+
}
|
|
64
|
+
if (/^[0-9.]+$/.test(host) || host.includes(':')) {
|
|
65
|
+
// Literal IP — check directly.
|
|
66
|
+
if (isBlockedIp(host))
|
|
67
|
+
return `Blocked: ${host} is a private/loopback/link-local address`;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// Resolve and check every address (catches internal hostnames + single-record rebinding).
|
|
71
|
+
try {
|
|
72
|
+
const addrs = await dnsLookup(host, { all: true });
|
|
73
|
+
for (const a of addrs) {
|
|
74
|
+
if (isBlockedIp(a.address)) {
|
|
75
|
+
return `Blocked: ${host} resolves to a private/internal address (${a.address})`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// DNS failure — let curl attempt and fail naturally; not an SSRF risk.
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
@@ -12,6 +12,12 @@ export interface TaskPlan {
|
|
|
12
12
|
tasks: SubTask[];
|
|
13
13
|
estimatedIterations: number;
|
|
14
14
|
}
|
|
15
|
+
/** Per-run provider selection. Used by custom bots without mutating config. */
|
|
16
|
+
export interface TaskPlannerRuntime {
|
|
17
|
+
providerId?: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
protocol?: 'openai' | 'anthropic';
|
|
20
|
+
}
|
|
15
21
|
/**
|
|
16
22
|
* Ask AI to break down a complex task into subtasks
|
|
17
23
|
*/
|
|
@@ -19,7 +25,7 @@ export declare function planTasks(userPrompt: string, projectContext: {
|
|
|
19
25
|
name: string;
|
|
20
26
|
type: string;
|
|
21
27
|
structure: string;
|
|
22
|
-
}): Promise<TaskPlan>;
|
|
28
|
+
}, runtime?: TaskPlannerRuntime): Promise<TaskPlan>;
|
|
23
29
|
/**
|
|
24
30
|
* Check if a task's dependencies are completed
|
|
25
31
|
*/
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* Task Planning - breaks down complex tasks into subtasks
|
|
3
3
|
*/
|
|
4
4
|
import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
|
|
5
|
-
import { getProviderAuthHeader, requiresDefaultTemperature } from '../config/providers.js';
|
|
5
|
+
import { getProviderAuthHeader, isNoApiKeyProvider, requiresDefaultTemperature } from '../config/providers.js';
|
|
6
6
|
/**
|
|
7
7
|
* Ask AI to break down a complex task into subtasks
|
|
8
8
|
*/
|
|
9
|
-
export async function planTasks(userPrompt, projectContext) {
|
|
9
|
+
export async function planTasks(userPrompt, projectContext, runtime = {}) {
|
|
10
10
|
const systemPrompt = `You are a task planning expert. Break down user requests into clear, sequential subtasks.
|
|
11
11
|
|
|
12
12
|
RULES:
|
|
@@ -36,14 +36,17 @@ User Request: ${userPrompt}
|
|
|
36
36
|
|
|
37
37
|
Break this down into subtasks. Each task = one file or one logical unit. Respond with JSON only.`;
|
|
38
38
|
try {
|
|
39
|
-
const
|
|
39
|
+
const protocol = runtime.protocol ?? config.get('protocol');
|
|
40
|
+
const provider = runtime.providerId ?? config.get('provider');
|
|
41
|
+
const model = runtime.model ?? config.get('model');
|
|
42
|
+
const apiKey = getApiKey(provider) || (isNoApiKeyProvider(provider) ? 'ollama' : '');
|
|
40
43
|
if (!apiKey) {
|
|
41
44
|
throw new Error('No API key configured');
|
|
42
45
|
}
|
|
43
|
-
const protocol = config.get('protocol');
|
|
44
|
-
const provider = config.get('provider');
|
|
45
|
-
const model = config.get('model');
|
|
46
46
|
const baseUrl = resolveBaseUrl(provider, protocol);
|
|
47
|
+
if (!baseUrl) {
|
|
48
|
+
throw new Error(`No API base URL configured for ${provider}`);
|
|
49
|
+
}
|
|
47
50
|
const authHeaderType = getProviderAuthHeader(provider, protocol);
|
|
48
51
|
const messages = [
|
|
49
52
|
{ role: 'user', content: systemPrompt }
|
|
@@ -73,7 +76,13 @@ Break this down into subtasks. Each task = one file or one logical unit. Respond
|
|
|
73
76
|
else {
|
|
74
77
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
75
78
|
}
|
|
76
|
-
|
|
79
|
+
if (protocol === 'anthropic') {
|
|
80
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
81
|
+
}
|
|
82
|
+
const endpoint = protocol === 'anthropic'
|
|
83
|
+
? `${baseUrl}/v1/messages`
|
|
84
|
+
: `${baseUrl}/chat/completions`;
|
|
85
|
+
const response = await fetch(endpoint, {
|
|
77
86
|
method: 'POST',
|
|
78
87
|
headers,
|
|
79
88
|
body: JSON.stringify(requestBody),
|
|
@@ -111,8 +111,15 @@ export declare function getCostBreakdown(startIndex?: number): ProviderCostBreak
|
|
|
111
111
|
export interface CacheStats {
|
|
112
112
|
cacheCreationTokens: number;
|
|
113
113
|
cacheReadTokens: number;
|
|
114
|
-
/** Sum of estimatedSavings across
|
|
114
|
+
/** Sum of estimatedSavings across pay-per-use records only. */
|
|
115
115
|
estimatedSavingsUsd: number;
|
|
116
|
+
/** True when some cached tokens came from a flat-fee plan, whose "savings"
|
|
117
|
+
* are not a dollar amount at all. Lets the report say so instead of quoting
|
|
118
|
+
* a figure that silently covers only part of the session. */
|
|
119
|
+
hasFlatFeeCacheUsage: boolean;
|
|
120
|
+
/** True when EVERY cached token came from a flat-fee plan — there is no
|
|
121
|
+
* metered spend to have saved against. */
|
|
122
|
+
isEntirelyFlatFeeCache: boolean;
|
|
116
123
|
}
|
|
117
124
|
export declare function getCacheStats(): CacheStats;
|
|
118
125
|
/**
|
|
@@ -96,6 +96,12 @@ const MODEL_PRICING = {
|
|
|
96
96
|
'claude-sonnet-5': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
97
97
|
'claude-haiku-4-5-20251001': { inputPer1M: 1.00, outputPer1M: 5.00 },
|
|
98
98
|
// DeepSeek (cache-miss input pricing)
|
|
99
|
+
// DeepSeek moved to peak / off-peak billing on 2026-08-16, with off-peak at
|
|
100
|
+
// half these rates. This table holds one rate per model and has no notion of
|
|
101
|
+
// wall-clock time, so it keeps the PEAK figures: an over-estimate is the
|
|
102
|
+
// honest direction for a cost estimate, and rule 5 of the catalogue policy
|
|
103
|
+
// allows a clearly-labelled conservative approximation but never an invented
|
|
104
|
+
// number. Cache-miss input; cache hits are ~1/50th and not modelled here.
|
|
99
105
|
'deepseek-v4-pro': { inputPer1M: 0.435, outputPer1M: 0.87 },
|
|
100
106
|
'deepseek-v4-flash': { inputPer1M: 0.14, outputPer1M: 0.28 },
|
|
101
107
|
// Google
|
|
@@ -274,9 +280,20 @@ export function getCacheStats() {
|
|
|
274
280
|
let cacheCreate = 0;
|
|
275
281
|
let cacheRead = 0;
|
|
276
282
|
let savings = 0;
|
|
283
|
+
let flatFeeCached = 0;
|
|
284
|
+
let meteredCached = 0;
|
|
277
285
|
for (const record of currentRecords()) {
|
|
286
|
+
const cached = (record.cacheCreationTokens ?? 0) + (record.cacheReadTokens ?? 0);
|
|
278
287
|
cacheCreate += record.cacheCreationTokens ?? 0;
|
|
279
288
|
cacheRead += record.cacheReadTokens ?? 0;
|
|
289
|
+
// A plan bills a flat fee, so caching saves latency but not money — pricing
|
|
290
|
+
// its cached tokens would invent a dollar figure the same way the per-model
|
|
291
|
+
// cost lines used to. Count the tokens (measured), skip the arithmetic.
|
|
292
|
+
if (isFlatFeeProvider(record.provider)) {
|
|
293
|
+
flatFeeCached += cached;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
meteredCached += cached;
|
|
280
297
|
// Savings = what cache-read tokens would have cost at full input rate,
|
|
281
298
|
// minus what they actually cost at 0.1×. (Cache creation is a slight
|
|
282
299
|
// *penalty* of 0.25× — netted in for honest reporting.)
|
|
@@ -287,7 +304,13 @@ export function getCacheStats() {
|
|
|
287
304
|
savings += cReadSaved - cCreateCost;
|
|
288
305
|
}
|
|
289
306
|
}
|
|
290
|
-
return {
|
|
307
|
+
return {
|
|
308
|
+
cacheCreationTokens: cacheCreate,
|
|
309
|
+
cacheReadTokens: cacheRead,
|
|
310
|
+
estimatedSavingsUsd: Math.max(0, savings),
|
|
311
|
+
hasFlatFeeCacheUsage: flatFeeCached > 0,
|
|
312
|
+
isEntirelyFlatFeeCache: flatFeeCached > 0 && meteredCached === 0,
|
|
313
|
+
};
|
|
291
314
|
}
|
|
292
315
|
/**
|
|
293
316
|
* Get session stats
|
|
@@ -395,12 +418,22 @@ export function formatCostReport() {
|
|
|
395
418
|
const cache = getCacheStats();
|
|
396
419
|
if (cache.cacheReadTokens > 0 || cache.cacheCreationTokens > 0) {
|
|
397
420
|
lines.push('', '### Prompt caching');
|
|
398
|
-
|
|
421
|
+
// The billing multipliers only describe a metered account. On a plan
|
|
422
|
+
// nothing is billed per token, so quoting a rate there would be as invented
|
|
423
|
+
// as the per-model prices this report already refuses to show.
|
|
424
|
+
const readNote = cache.isEntirelyFlatFeeCache ? '' : ' (billed at 0.1× input rate)';
|
|
425
|
+
const writeNote = cache.isEntirelyFlatFeeCache ? '' : ' (billed at 1.25× input rate)';
|
|
426
|
+
lines.push(`**Cache reads:** ${formatTokenCount(cache.cacheReadTokens)} tokens${readNote}`);
|
|
399
427
|
if (cache.cacheCreationTokens > 0) {
|
|
400
|
-
lines.push(`**Cache writes:** ${formatTokenCount(cache.cacheCreationTokens)} tokens
|
|
428
|
+
lines.push(`**Cache writes:** ${formatTokenCount(cache.cacheCreationTokens)} tokens${writeNote}`);
|
|
429
|
+
}
|
|
430
|
+
if (cache.isEntirelyFlatFeeCache) {
|
|
431
|
+
lines.push('**Savings:** caching saves latency, not money — this session is on a plan');
|
|
401
432
|
}
|
|
402
|
-
if (cache.estimatedSavingsUsd > 0) {
|
|
403
|
-
|
|
433
|
+
else if (cache.estimatedSavingsUsd > 0) {
|
|
434
|
+
// Name the partial coverage rather than letting one figure look total.
|
|
435
|
+
const scope = cache.hasFlatFeeCacheUsage ? ' (pay-per-use models only)' : '';
|
|
436
|
+
lines.push(`**Estimated savings vs no caching:** $${cache.estimatedSavingsUsd.toFixed(4)}${scope}`);
|
|
404
437
|
}
|
|
405
438
|
}
|
|
406
439
|
lines.push('', ...formatResourceImpactReport(stats.totalTokens));
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* createActionLog() converts a ToolCall+ToolResult into a history ActionLog.
|
|
8
8
|
*/
|
|
9
9
|
import { ToolCall, ToolResult, ActionLog } from './tools';
|
|
10
|
+
export { isBlockedIp, assertFetchUrlAllowed } from './ssrfGuard';
|
|
10
11
|
/**
|
|
11
12
|
* Validate path is within project root.
|
|
12
13
|
* Uses realpathSync to resolve symlinks, preventing symlink traversal attacks
|