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.
Files changed (51) hide show
  1. package/README.md +53 -16
  2. package/dist/acp/commands.js +11 -55
  3. package/dist/acp/protocol.d.ts +34 -0
  4. package/dist/acp/server.d.ts +6 -1
  5. package/dist/acp/server.js +97 -2
  6. package/dist/api/index.js +9 -0
  7. package/dist/commands/core/index.d.ts +19 -0
  8. package/dist/commands/core/index.js +28 -0
  9. package/dist/commands/core/keysync.d.ts +2 -0
  10. package/dist/commands/core/keysync.js +34 -0
  11. package/dist/commands/core/telemetry.d.ts +2 -0
  12. package/dist/commands/core/telemetry.js +34 -0
  13. package/dist/config/index.js +2 -2
  14. package/dist/renderer/App.d.ts +9 -48
  15. package/dist/renderer/App.js +113 -338
  16. package/dist/renderer/Screen.d.ts +13 -0
  17. package/dist/renderer/Screen.js +22 -0
  18. package/dist/renderer/commands/registry.js +3 -3
  19. package/dist/renderer/commands.js +19 -51
  20. package/dist/renderer/components/CommandAutocomplete.d.ts +46 -0
  21. package/dist/renderer/components/CommandAutocomplete.js +103 -0
  22. package/dist/renderer/components/HunkPicker.d.ts +48 -0
  23. package/dist/renderer/components/HunkPicker.js +140 -0
  24. package/dist/renderer/components/MentionPicker.d.ts +60 -0
  25. package/dist/renderer/components/MentionPicker.js +111 -0
  26. package/dist/renderer/components/PasteDialog.d.ts +43 -0
  27. package/dist/renderer/components/PasteDialog.js +70 -0
  28. package/dist/renderer/layout.js +1 -0
  29. package/dist/renderer/main.js +15 -39
  30. package/dist/utils/agent.js +121 -26
  31. package/dist/utils/agentChat.d.ts +11 -4
  32. package/dist/utils/agentChat.js +53 -25
  33. package/dist/utils/codeepCloud.d.ts +3 -0
  34. package/dist/utils/codeepCloud.js +62 -7
  35. package/dist/utils/personalities.d.ts +63 -5
  36. package/dist/utils/personalities.js +583 -31
  37. package/dist/utils/shell.d.ts +11 -1
  38. package/dist/utils/shell.js +169 -82
  39. package/dist/utils/ssrfGuard.d.ts +18 -0
  40. package/dist/utils/ssrfGuard.js +83 -0
  41. package/dist/utils/taskPlanner.d.ts +7 -1
  42. package/dist/utils/taskPlanner.js +16 -7
  43. package/dist/utils/tokenTracker.d.ts +8 -1
  44. package/dist/utils/tokenTracker.js +38 -5
  45. package/dist/utils/toolExecution.d.ts +1 -0
  46. package/dist/utils/toolExecution.js +48 -88
  47. package/dist/utils/tools.d.ts +3 -3
  48. package/dist/utils/tools.js +18 -13
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +2 -1
@@ -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
  */
@@ -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
- 'echo', 'pwd', 'which', 'env', 'date', 'sleep',
72
- 'curl', 'wget', // allowed but patterns checked
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 (synchronous, fast)
253
- const validation = validateCommand(command, args, options);
254
- if (!validation.valid) {
255
- resolve({
256
- success: false,
257
- stdout: '',
258
- stderr: validation.reason || 'Command validation failed',
259
- exitCode: -1,
260
- duration: 0,
261
- command,
262
- args,
263
- });
264
- return;
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
- settled = true;
292
- child.kill('SIGTERM');
293
- const duration = Date.now() - startTime;
294
- resolve({
295
- success: false,
296
- stdout,
297
- stderr: `Command timed out after ${timeout}ms`,
298
- exitCode: -1,
299
- duration,
300
- command,
301
- args,
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
- settled = true;
308
- clearTimeout(timer);
309
- const duration = Date.now() - startTime;
310
- resolve({
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
- child.on('error', (err) => {
321
- if (settled)
322
- return;
323
- settled = true;
324
- clearTimeout(timer);
325
- const duration = Date.now() - startTime;
326
- resolve({
327
- success: false,
328
- stdout: '',
329
- stderr: err.message,
330
- exitCode: -1,
331
- duration,
332
- command,
333
- args,
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 apiKey = await getApiKey();
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
- const response = await fetch(`${baseUrl}/chat/completions`, {
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 all Anthropic-priced records. */
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 { cacheCreationTokens: cacheCreate, cacheReadTokens: cacheRead, estimatedSavingsUsd: Math.max(0, savings) };
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
- lines.push(`**Cache reads:** ${formatTokenCount(cache.cacheReadTokens)} tokens (billed at 0.1× input rate)`);
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 (billed at 1.25× input rate)`);
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
- lines.push(`**Estimated savings vs no caching:** $${cache.estimatedSavingsUsd.toFixed(4)}`);
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