codeep 3.3.1 → 3.3.3

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.
@@ -241,9 +241,15 @@ export declare function isKeySyncEnabled(): boolean;
241
241
  */
242
242
  export declare function keySyncForcedOffByEnv(): boolean;
243
243
  /**
244
- * Clear API key for a specific provider
244
+ * Clear API key for a specific provider.
245
+ *
246
+ * Resolves `true` once the key is gone from secure storage. A keychain that
247
+ * refuses the delete is swallowed a layer down (keychain.ts logs it at debug),
248
+ * so the outcome is verified by reading the key back: when it is still there
249
+ * nothing is changed and this resolves `false` — the caller has to say so
250
+ * rather than report a logout that left the key on disk.
245
251
  */
246
- export declare function clearApiKey(providerId: string): Promise<void>;
252
+ export declare function clearApiKey(providerId: string): Promise<boolean>;
247
253
  export declare function isConfiguredAsync(providerId?: string): Promise<boolean>;
248
254
  export declare function isConfigured(providerId?: string): boolean;
249
255
  export declare function getCurrentProvider(): {
@@ -612,17 +612,25 @@ export function keySyncForcedOffByEnv() {
612
612
  return envForcesKeySyncOff();
613
613
  }
614
614
  /**
615
- * Clear API key for a specific provider
615
+ * Clear API key for a specific provider.
616
+ *
617
+ * Resolves `true` once the key is gone from secure storage. A keychain that
618
+ * refuses the delete is swallowed a layer down (keychain.ts logs it at debug),
619
+ * so the outcome is verified by reading the key back: when it is still there
620
+ * nothing is changed and this resolves `false` — the caller has to say so
621
+ * rather than report a logout that left the key on disk.
616
622
  */
617
623
  export async function clearApiKey(providerId) {
618
- // Clear from cache
619
- apiKeyCache.delete(providerId);
620
- // Clear from secure storage + the non-secret index
624
+ const store = secureKeyStore();
621
625
  try {
622
- await secureKeyStore().deleteApiKey(providerId);
626
+ await store.deleteApiKey(providerId);
623
627
  }
624
- catch { /* ignore */ }
628
+ catch { /* verified below */ }
629
+ if (await store.hasApiKey(providerId))
630
+ return false;
631
+ apiKeyCache.delete(providerId);
625
632
  removeConfiguredProviderId(providerId);
633
+ return true;
626
634
  }
627
635
  export async function isConfiguredAsync(providerId) {
628
636
  const key = await loadApiKey(providerId);
@@ -85,6 +85,10 @@ export interface StatsCache {
85
85
  cacheReadTokens: number;
86
86
  cacheCreationTokens: number;
87
87
  estimatedSavingsUsd: number;
88
+ /** Rates that applied, from getCacheStats. Absent → no rate is quoted rather
89
+ * than assuming Anthropic's 0.1×, which was wrong for DeepSeek, Kimi, Qwen
90
+ * and Fable 5.1. */
91
+ cacheReadRates?: number[];
88
92
  }
89
93
  export interface PricingRow {
90
94
  model: string;
@@ -8,6 +8,7 @@
8
8
  * coverage.
9
9
  */
10
10
  import { isFlatFeeProvider } from '../../config/providers.js';
11
+ import { formatCacheReadRates } from '../../utils/tokenTracker.js';
11
12
  /** Snippet window: chars of context before / after the match. */
12
13
  export const SEARCH_SNIPPET_BEFORE = 30;
13
14
  export const SEARCH_SNIPPET_AFTER = 50;
@@ -166,7 +167,7 @@ export function formatStatsReport(args) {
166
167
  }
167
168
  if (cache.cacheReadTokens > 0 || cache.cacheCreationTokens > 0) {
168
169
  lines.push('', '### Prompt caching');
169
- lines.push(`Cache reads: ${fmt(cache.cacheReadTokens)} tokens (billed at 0.1× input rate)`);
170
+ lines.push(`Cache reads: ${fmt(cache.cacheReadTokens)} tokens${formatCacheReadRates(cache.cacheReadRates ?? [])}`);
170
171
  if (cache.cacheCreationTokens > 0) {
171
172
  lines.push(`Cache writes: ${fmt(cache.cacheCreationTokens)} tokens (billed at 1.25× input rate)`);
172
173
  }
@@ -1142,27 +1142,39 @@ Format: use headers per category, only include categories where you found issues
1142
1142
  ctx.app.notify('No providers configured');
1143
1143
  return;
1144
1144
  }
1145
- ctx.app.showLogoutPicker(configuredProviders, (result) => {
1145
+ ctx.app.showLogoutPicker(configuredProviders, async (result) => {
1146
1146
  if (result === null)
1147
1147
  return;
1148
- if (result === 'all') {
1149
- for (const p of configuredProviders)
1150
- void clearApiKey(p.id);
1148
+ const targets = result === 'all' ? configuredProviders : configuredProviders.filter(p => p.id === result);
1149
+ const removed = [];
1150
+ const kept = [];
1151
+ // Awaited one by one: reporting "Logged out" before the keychain
1152
+ // answered is how a refused delete used to pass for a logout.
1153
+ for (const p of targets) {
1154
+ if (await clearApiKey(p.id))
1155
+ removed.push(p);
1156
+ else
1157
+ kept.push(p.name);
1158
+ }
1159
+ if (kept.length > 0) {
1160
+ ctx.app.notify(`Could not remove the stored key for ${kept.join(', ')} — still logged in. Delete the "codeep" item from your system keychain, then run /logout again.`);
1161
+ }
1162
+ if (removed.length === 0)
1163
+ return;
1164
+ if (result === 'all' && kept.length === 0) {
1151
1165
  ctx.app.notify('Logged out from all providers. Use /login to sign in.');
1152
1166
  }
1153
1167
  else {
1154
- void clearApiKey(result);
1155
- const provider = configuredProviders.find(p => p.id === result);
1156
- ctx.app.notify(`Logged out from ${provider?.name || result}`);
1157
- if (result === currentProvider.id) {
1158
- const remaining = configuredProviders.filter(p => p.id !== result);
1159
- if (remaining.length > 0) {
1160
- setProvider(remaining[0].id);
1161
- ctx.app.notify(`Switched to ${remaining[0].name}`);
1162
- }
1163
- else {
1164
- ctx.app.notify('No providers configured. Use /login to sign in.');
1165
- }
1168
+ ctx.app.notify(`Logged out from ${removed.map(p => p.name).join(', ')}`);
1169
+ }
1170
+ if (removed.some(p => p.id === currentProvider.id)) {
1171
+ const remaining = configuredProviders.filter(p => !removed.includes(p));
1172
+ if (remaining.length > 0) {
1173
+ setProvider(remaining[0].id);
1174
+ ctx.app.notify(`Switched to ${remaining[0].name}`);
1175
+ }
1176
+ else if (result !== 'all') {
1177
+ ctx.app.notify('No providers configured. Use /login to sign in.');
1166
1178
  }
1167
1179
  }
1168
1180
  });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `fetch_url`'s transport: curl, with redirects followed here rather than by
3
+ * curl, so every hop passes the SSRF guard.
4
+ *
5
+ * `curl -L` only let us check the URL the model asked for. A public page that
6
+ * answers `302 Location: http://169.254.169.254/…` (or any LAN/localhost
7
+ * service) was followed silently and its body handed back to the model —
8
+ * and `fetch_url` runs without a confirmation prompt. So each hop is resolved,
9
+ * checked, and the connection pinned to the checked address with `--resolve`,
10
+ * which also stops a second DNS answer (rebinding) from swapping in a private
11
+ * address between the check and curl's own lookup.
12
+ */
13
+ export type CurlRunner = (args: string[]) => Promise<{
14
+ success: boolean;
15
+ stdout: string;
16
+ stderr: string;
17
+ }>;
18
+ export type GuardedFetchResult = {
19
+ ok: true;
20
+ body: string;
21
+ finalUrl: string;
22
+ } | {
23
+ ok: false;
24
+ error: string;
25
+ };
26
+ export declare const MAX_FETCH_REDIRECTS = 5;
27
+ /** One budget for the whole chain — per hop, six slow 302s would hold a tool call for minutes. */
28
+ export declare const FETCH_BUDGET_MS = 30000;
29
+ export declare function fetchUrlGuarded(rawUrl: string, runCurl: CurlRunner, maxRedirects?: number): Promise<GuardedFetchResult>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `fetch_url`'s transport: curl, with redirects followed here rather than by
3
+ * curl, so every hop passes the SSRF guard.
4
+ *
5
+ * `curl -L` only let us check the URL the model asked for. A public page that
6
+ * answers `302 Location: http://169.254.169.254/…` (or any LAN/localhost
7
+ * service) was followed silently and its body handed back to the model —
8
+ * and `fetch_url` runs without a confirmation prompt. So each hop is resolved,
9
+ * checked, and the connection pinned to the checked address with `--resolve`,
10
+ * which also stops a second DNS answer (rebinding) from swapping in a private
11
+ * address between the check and curl's own lookup.
12
+ */
13
+ import { resolveFetchTarget } from './ssrfGuard.js';
14
+ export const MAX_FETCH_REDIRECTS = 5;
15
+ /** One budget for the whole chain — per hop, six slow 302s would hold a tool call for minutes. */
16
+ export const FETCH_BUDGET_MS = 30_000;
17
+ // Appended by curl after the body; the LAST occurrence is always curl's own,
18
+ // so a page that happens to contain the marker can't forge a redirect.
19
+ const META_MARKER = '\n__CODEEP_FETCH_META__';
20
+ export async function fetchUrlGuarded(rawUrl, runCurl, maxRedirects = MAX_FETCH_REDIRECTS) {
21
+ let current = rawUrl;
22
+ const deadline = Date.now() + FETCH_BUDGET_MS;
23
+ for (let hop = 0; hop <= maxRedirects; hop++) {
24
+ const secondsLeft = Math.ceil((deadline - Date.now()) / 1000);
25
+ if (secondsLeft <= 0)
26
+ return { ok: false, error: `Timed out after ${FETCH_BUDGET_MS / 1000}s following redirects` };
27
+ const check = await resolveFetchTarget(current);
28
+ if (!check.ok) {
29
+ return { ok: false, error: hop === 0 ? check.reason : `Refused redirect to ${current} — ${check.reason}` };
30
+ }
31
+ if (check.unresolved) {
32
+ // An unpinned request would let curl's own lookup decide the address.
33
+ return { ok: false, error: `Could not resolve host: ${check.url.hostname}` };
34
+ }
35
+ const { url, addresses } = check;
36
+ const args = [
37
+ '-s',
38
+ '--proto', '=http,https',
39
+ '-m', String(secondsLeft),
40
+ '-A', 'Codeep/1.0',
41
+ '--max-filesize', '1000000',
42
+ '-w', `${META_MARKER}%{http_code} %{redirect_url}`,
43
+ ];
44
+ if (addresses?.length) {
45
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80');
46
+ const pinned = addresses.map((a) => (a.includes(':') ? `[${a}]` : a)).join(',');
47
+ args.push('--resolve', `${url.hostname}:${port}:${pinned}`);
48
+ }
49
+ args.push(url.href);
50
+ const res = await runCurl(args);
51
+ const at = res.stdout.lastIndexOf(META_MARKER);
52
+ if (!res.success || at === -1) {
53
+ return { ok: false, error: res.stderr || 'Failed to fetch URL' };
54
+ }
55
+ const body = res.stdout.slice(0, at);
56
+ const meta = res.stdout.slice(at + META_MARKER.length).trim();
57
+ const space = meta.indexOf(' ');
58
+ const status = Number(space === -1 ? meta : meta.slice(0, space));
59
+ const location = space === -1 ? '' : meta.slice(space + 1).trim();
60
+ if (status >= 300 && status < 400 && location) {
61
+ current = location;
62
+ continue;
63
+ }
64
+ return { ok: true, body, finalUrl: url.href };
65
+ }
66
+ return { ok: false, error: `Too many redirects (more than ${maxRedirects})` };
67
+ }
@@ -4,7 +4,8 @@
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
+ import { isIP } from 'net';
8
+ import { assertFetchUrlAllowed, isBlockedIp } from './ssrfGuard.js';
8
9
  // Dangerous command patterns that should never be executed
9
10
  const BLOCKED_COMMANDS = new Set([
10
11
  'sudo',
@@ -116,37 +117,159 @@ function hasInlineEval(command, args) {
116
117
  // routes through assertFetchUrlAllowed; without this list the same model-
117
118
  // controlled URL could just be passed to curl instead.
118
119
  const URL_CARRYING_COMMANDS = new Set(['curl', 'wget', 'http', 'https']);
120
+ // curl options that consume the next argument as their value — generated
121
+ // from `curl --help all` (every entry with a <value>), plus the proxy flags
122
+ // whose value is shown without brackets. Knowing them is what lets a bare
123
+ // number be read as a host: `curl 2130706433:8080` connects to 127.0.0.1,
124
+ // while the `30` in `curl -m 30 …` is a timeout.
125
+ const CURL_VALUE_OPTIONS = new Set(('--abstract-unix-socket --alt-svc --aws-sigv4 --cacert --capath --cert --cert-type --ciphers --config ' +
126
+ '--connect-timeout --connect-to --continue-at --cookie --cookie-jar --create-file-mode --crlfile --curves ' +
127
+ '--data --data-ascii --data-binary --data-raw --data-urlencode --delegation --dns-interface --dns-ipv4-addr ' +
128
+ '--dns-ipv6-addr --dns-servers --doh-url --dump-header --egd-file --engine --etag-compare --etag-save ' +
129
+ '--expect100-timeout --form --form-string --ftp-account --ftp-alternative-to-user --ftp-method --ftp-port ' +
130
+ '--ftp-ssl-ccc-mode --happy-eyeballs-timeout-ms --haproxy-clientip --header --hostpubmd5 --hostpubsha256 ' +
131
+ '--hsts --interface --ipfs-gateway --json --keepalive-time --key --key-type --krb --libcurl --limit-rate ' +
132
+ '--local-port --login-options --mail-auth --mail-from --mail-rcpt --max-filesize --max-redirs --max-time ' +
133
+ '--netrc-file --noproxy --oauth2-bearer --output --output-dir --parallel-max --pass --pinnedpubkey --proto ' +
134
+ '--proto-default --proto-redir --proxy-cacert --proxy-capath --proxy-cert --proxy-cert-type --proxy-ciphers ' +
135
+ '--proxy-crlfile --proxy-header --proxy-key --proxy-key-type --proxy-pass --proxy-pinnedpubkey ' +
136
+ '--proxy-service-name --proxy-tls13-ciphers --proxy-tlsauthtype --proxy-tlspassword --proxy-tlsuser ' +
137
+ '--proxy-user --proxy1.0 --pubkey --quote --random-file --range --rate --referer --request --request-target ' +
138
+ '--resolve --retry --retry-delay --retry-max-time --sasl-authzid --service-name --socks4 --socks4a --socks5 ' +
139
+ '--socks5-gssapi-service --socks5-hostname --speed-limit --speed-time --stderr --telnet-option --tftp-blksize ' +
140
+ '--time-cond --tls-max --tls13-ciphers --tlsauthtype --tlspassword --tlsuser --trace --trace-ascii ' +
141
+ '--trace-config --unix-socket --upload-file --url --url-query --user --user-agent --variable --write-out ' +
142
+ '--proxy --preproxy').split(' '));
143
+ const CURL_VALUE_SHORT = new Set('ACDEFHKPQTUXYbcdemortuwxyz'.split(''));
144
+ // wget spells most values `--opt=value`; these are the ones commonly split.
145
+ const WGET_VALUE_OPTIONS = new Set(['-O', '-o', '-a', '-t', '-T', '-w', '-e', '-P', '-U', '-Q', '-l', '-A', '-R', '-D', '-X', '-I', '-i', '-B',
146
+ '--output-document', '--output-file', '--tries', '--timeout', '--wait', '--execute', '--directory-prefix',
147
+ '--user-agent', '--header', '--user', '--password', '--input-file', '--base', '--limit-rate', '--max-redirect']);
148
+ /** True when `arg` (an option) makes the NEXT argument its value. */
149
+ function optionTakesNextArg(command, arg) {
150
+ if (arg.includes('='))
151
+ return false;
152
+ if (command === 'curl') {
153
+ if (arg.startsWith('--'))
154
+ return CURL_VALUE_OPTIONS.has(arg);
155
+ // Short cluster: `-sm 30` — only the last letter may take the next arg;
156
+ // an earlier value-taking letter swallows the rest of the cluster (`-m30`).
157
+ for (let i = 1; i < arg.length; i++) {
158
+ if (CURL_VALUE_SHORT.has(arg[i]))
159
+ return i === arg.length - 1;
160
+ }
161
+ return false;
162
+ }
163
+ if (command === 'wget')
164
+ return WGET_VALUE_OPTIONS.has(arg);
165
+ return false;
166
+ }
167
+ const PORT_PATH = String.raw `(:\d+)?([\/?#].*)?`;
168
+ // Scheme-less host forms the tools accept: localhost, dotted/bracketed IP
169
+ // literals, named hosts, and the numeric spellings libc resolves —
170
+ // `2130706433`, `0x7f000001`, `017700000001`, `0` are all 127.0.0.1/0.0.0.0.
171
+ const SCHEMELESS_HOST = new RegExp('^(' + [
172
+ `localhost${PORT_PATH}`,
173
+ String.raw `\[[0-9a-f:.%]+\]` + PORT_PATH,
174
+ String.raw `(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+)){0,3}` + PORT_PATH,
175
+ String.raw `[a-z0-9-]+(\.[a-z0-9-]+)+` + PORT_PATH,
176
+ String.raw `[a-z0-9-]+:\d+([\/?#].*)?`,
177
+ ].join('|') + ')$', 'i');
119
178
  // Heuristic: extract URL-looking arguments. curl/wget accept URLs with or
120
179
  // 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) {
180
+ // option values (`--url …`). Scheme-less hosts are normalized so the guard
181
+ // sees what the tool will actually connect to; option values are skipped so
182
+ // a timeout or a data payload isn't mistaken for a host.
183
+ function extractUrlCandidates(command, args) {
125
184
  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]);
185
+ for (let i = 0; i < args.length; i++) {
186
+ const arg = args[i];
187
+ if (arg.startsWith('-') && arg.length > 1) {
188
+ const eq = arg.match(/^--url=(.+)$/i);
189
+ if (eq) {
190
+ urls.push(eq[1]);
191
+ continue;
192
+ }
193
+ if (optionTakesNextArg(command, arg)) {
194
+ if (arg === '--url' && args[i + 1])
195
+ urls.push(args[i + 1]);
196
+ i++; // the value is not a positional URL
197
+ }
134
198
  continue;
135
199
  }
136
200
  if (/^https?:\/\//i.test(arg)) {
137
201
  urls.push(arg);
138
202
  }
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
203
+ else if ((command === 'http' || command === 'https') && /^:\d*([\/?#].*)?$/.test(arg)) {
204
+ // httpie shorthand: `http :3000/api` is localhost:3000
205
+ urls.push(`http://localhost${arg}`);
206
+ }
207
+ else if (SCHEMELESS_HOST.test(arg)) {
145
208
  urls.push(`http://${arg}`);
146
209
  }
147
210
  }
148
211
  return urls;
149
212
  }
213
+ // curl options that change WHERE the connection goes without changing the
214
+ // URL the guard looks at: `--resolve example.com:80:127.0.0.1 http://example.com`
215
+ // passes a URL-only check and then talks to loopback. Each one is judged by
216
+ // the address it actually points curl at.
217
+ const CURL_TARGET_FLAGS = new Set(['--resolve', '--connect-to', '--unix-socket', '--abstract-unix-socket', '-x', '--proxy', '--preproxy', '--socks4', '--socks4a', '--socks5', '--socks5-hostname']);
218
+ function curlTargetOverrides(args) {
219
+ const out = [];
220
+ for (let i = 0; i < args.length; i++) {
221
+ const arg = args[i];
222
+ const eq = arg.indexOf('=');
223
+ if (arg.startsWith('--') && eq !== -1 && CURL_TARGET_FLAGS.has(arg.slice(0, eq))) {
224
+ out.push({ flag: arg.slice(0, eq), value: arg.slice(eq + 1) });
225
+ }
226
+ else if (CURL_TARGET_FLAGS.has(arg)) {
227
+ out.push({ flag: arg, value: args[i + 1] ?? '' });
228
+ i++;
229
+ }
230
+ else if (/^-x./.test(arg)) {
231
+ out.push({ flag: '-x', value: arg.slice(2) });
232
+ }
233
+ }
234
+ return out;
235
+ }
236
+ async function curlTargetOverrideProblem(args) {
237
+ for (const { flag, value } of curlTargetOverrides(args)) {
238
+ if (flag === '--unix-socket' || flag === '--abstract-unix-socket') {
239
+ return `${flag} is not allowed — it bypasses the network address check`;
240
+ }
241
+ if (flag === '--resolve') {
242
+ // [+]host:port:addr[,addr]… — every address must be public.
243
+ const addrs = value.replace(/^\+/, '').split(':').slice(2).join(':');
244
+ for (const addr of addrs.split(',')) {
245
+ if (!addr || isBlockedIp(addr))
246
+ return `--resolve points at a private/internal address (${addr || 'empty'})`;
247
+ if (!isIP(addr.replace(/^\[|\]$/g, '')))
248
+ return `--resolve needs a numeric address (got ${addr})`;
249
+ }
250
+ continue;
251
+ }
252
+ let target;
253
+ if (flag === '--connect-to') {
254
+ // HOST1:PORT1:HOST2:PORT2 — an empty HOST2 keeps the URL's host.
255
+ const m = value.match(/^(\[[^\]]*\]|[^:]*):[^:]*:(\[[^\]]*\]|[^:]*)(?::.*)?$/);
256
+ if (!m)
257
+ return `--connect-to value not understood: ${value}`;
258
+ if (!m[2])
259
+ continue;
260
+ target = `http://${m[2]}/`;
261
+ }
262
+ else {
263
+ // Proxy flags: judge the proxy host. Any scheme is rewritten to http so
264
+ // the guard parses the host the same way for socks5:// and friends.
265
+ target = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value.replace(/^[a-z][a-z0-9+.-]*:/i, 'http:') : `http://${value}`;
266
+ }
267
+ const blocked = await assertFetchUrlAllowed(target);
268
+ if (blocked)
269
+ return `${flag} ${blocked}`;
270
+ }
271
+ return null;
272
+ }
150
273
  // Exec-escapes: whitelisted utilities that can run ARBITRARY other commands
151
274
  // as part of their arguments, silently bypassing the whitelist above.
152
275
  // find . -exec <anything> \; → runs <anything>
@@ -316,7 +439,12 @@ export async function validateCommandAsync(command, args, options) {
316
439
  if (!sync.valid)
317
440
  return sync;
318
441
  if (URL_CARRYING_COMMANDS.has(command)) {
319
- for (const url of extractUrlCandidates(args)) {
442
+ if (command === 'curl') {
443
+ const problem = await curlTargetOverrideProblem(args);
444
+ if (problem)
445
+ return { valid: false, reason: `Blocked curl option: ${problem}` };
446
+ }
447
+ for (const url of extractUrlCandidates(command, args)) {
320
448
  const blocked = await assertFetchUrlAllowed(url);
321
449
  if (blocked) {
322
450
  return { valid: false, reason: `Blocked URL in ${command} arguments: ${blocked}` };
@@ -3,16 +3,39 @@
3
3
  * touching surfaces.
4
4
  *
5
5
  * Used by:
6
- * - toolExecution.ts → the `fetch_url` tool
6
+ * - toolExecution.ts → the `fetch_url` tool (every redirect hop, pinned)
7
7
  * - shell.ts → curl/wget/http(s) arguments in execute_command
8
+ * - webFetch.ts → redirect hops of a user's `@web` mention
8
9
  *
9
- * The URLs in both cases originate from model output / page content
10
+ * The URLs in the agent cases originate from model output / page content
10
11
  * (untrusted, prompt-injectable), so the agent must not be able to reach
11
12
  * internal services or the cloud metadata endpoint (169.254.169.254).
12
13
  * NOTE: this deliberately does NOT apply to user-configured provider base
13
14
  * URLs (Ollama localhost, custom vLLM/Tailscale endpoints) — those are
14
15
  * trusted config and never routed through agent tools.
15
16
  */
17
+ /**
18
+ * Whether an IP literal points somewhere the agent must not reach. Brackets
19
+ * are accepted (`[::1]`); anything that isn't an IP literal returns false —
20
+ * hostnames are resolved by `resolveFetchTarget`, not judged by spelling.
21
+ */
16
22
  export declare function isBlockedIp(ip: string): boolean;
23
+ export type FetchTargetCheck = {
24
+ ok: true;
25
+ url: URL;
26
+ /** Every checked address, to pin the connection to, so a second DNS
27
+ * answer can't swap in a private one (rebinding). All of them, not
28
+ * the first: a dual-stack host whose AAAA sorts first would otherwise
29
+ * be unreachable on a machine without a working IPv6 route. Undefined
30
+ * for IP literals — nothing to resolve — and when DNS failed. */
31
+ addresses?: string[];
32
+ /** True when the host was a name that did not resolve. */
33
+ unresolved?: boolean;
34
+ } | {
35
+ ok: false;
36
+ reason: string;
37
+ };
38
+ /** Validate a URL and resolve its host, returning the verified address. */
39
+ export declare function resolveFetchTarget(rawUrl: string): Promise<FetchTargetCheck>;
17
40
  /** Returns an error string if the URL must not be fetched, else null. */
18
41
  export declare function assertFetchUrlAllowed(rawUrl: string): Promise<string | null>;
@@ -3,10 +3,11 @@
3
3
  * touching surfaces.
4
4
  *
5
5
  * Used by:
6
- * - toolExecution.ts → the `fetch_url` tool
6
+ * - toolExecution.ts → the `fetch_url` tool (every redirect hop, pinned)
7
7
  * - shell.ts → curl/wget/http(s) arguments in execute_command
8
+ * - webFetch.ts → redirect hops of a user's `@web` mention
8
9
  *
9
- * The URLs in both cases originate from model output / page content
10
+ * The URLs in the agent cases originate from model output / page content
10
11
  * (untrusted, prompt-injectable), so the agent must not be able to reach
11
12
  * internal services or the cloud metadata endpoint (169.254.169.254).
12
13
  * NOTE: this deliberately does NOT apply to user-configured provider base
@@ -14,70 +15,147 @@
14
15
  * trusted config and never routed through agent tools.
15
16
  */
16
17
  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;
18
+ import { isIP } from 'net';
19
+ /** Strict dotted-quad → 4 octets, or null. */
20
+ function parseIPv4(s) {
21
+ if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s))
22
+ return null;
23
+ const octets = s.split('.').map(Number);
24
+ return octets.every((n) => n <= 255) ? octets : null;
25
+ }
26
+ /**
27
+ * IPv6 literal → 16 bytes, or null. Handles `::` compression, a trailing
28
+ * dotted quad (`::ffff:1.2.3.4`) and a zone id (`fe80::1%en0`).
29
+ *
30
+ * Classifying bytes rather than the string is the point: the WHATWG URL
31
+ * parser rewrites `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, so a guard that
32
+ * pattern-matches the dotted spelling never sees the address it has to block.
33
+ */
34
+ function parseIPv6(input) {
35
+ let s = input.toLowerCase();
36
+ const zone = s.indexOf('%');
37
+ if (zone !== -1)
38
+ s = s.slice(0, zone);
39
+ if (isIP(s) !== 6)
40
+ return null;
41
+ let tail = null;
42
+ const lastColon = s.lastIndexOf(':');
43
+ if (s.slice(lastColon + 1).includes('.')) {
44
+ tail = parseIPv4(s.slice(lastColon + 1));
45
+ if (!tail)
46
+ return null;
47
+ s = s.slice(0, lastColon + 1) + '0:0'; // two placeholder groups
48
+ }
49
+ let groups;
50
+ if (s.includes('::')) {
51
+ const [left, right] = s.split('::');
52
+ const l = left ? left.split(':') : [];
53
+ const r = right ? right.split(':') : [];
54
+ groups = [...l, ...Array(8 - l.length - r.length).fill('0'), ...r];
55
+ }
56
+ else {
57
+ groups = s.split(':');
58
+ }
59
+ if (groups.length !== 8)
60
+ return null;
61
+ const bytes = groups.flatMap((g) => {
62
+ const v = parseInt(g, 16);
63
+ return [v >> 8, v & 0xff];
64
+ });
65
+ if (tail)
66
+ bytes.splice(12, 4, ...tail);
67
+ return bytes;
68
+ }
69
+ function isBlockedIPv4([a, b, c]) {
70
+ return a === 0 // 0.0.0.0/8 "this network"
71
+ || a === 10 // RFC1918
72
+ || a === 127 // loopback
73
+ || (a === 100 && b >= 64 && b <= 127) // 100.64.0.0/10 CGNAT — Tailscale lives here
74
+ || (a === 169 && b === 254) // link-local incl. metadata 169.254.169.254
75
+ || (a === 172 && b >= 16 && b <= 31) // RFC1918
76
+ || (a === 192 && b === 0 && c === 0) // 192.0.0.0/24 IETF protocol assignments
77
+ || (a === 192 && b === 168) // RFC1918
78
+ || (a === 198 && (b === 18 || b === 19)) // 198.18.0.0/15 benchmarking
79
+ || a >= 224; // multicast, reserved, broadcast
80
+ }
81
+ function isBlockedIPv6(b) {
82
+ const zeroUpTo = (n) => b.slice(0, n).every((x) => x === 0);
83
+ // Forms that carry an IPv4 address — judge the embedded address.
84
+ if (zeroUpTo(10) && b[10] === 0xff && b[11] === 0xff)
85
+ return isBlockedIPv4(b.slice(12)); // ::ffff:0:0/96 mapped
86
+ if (zeroUpTo(12))
87
+ return isBlockedIPv4(b.slice(12)); // ::/96 compatible — also covers :: and ::1
88
+ if (b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b) {
89
+ if (b.slice(4, 12).every((x) => x === 0))
90
+ return isBlockedIPv4(b.slice(12)); // 64:ff9b::/96 NAT64
91
+ if (b[4] === 0x00 && b[5] === 0x01)
92
+ return true; // 64:ff9b:1::/48 local-use NAT64
29
93
  }
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
94
+ if (b[0] === 0x20 && b[1] === 0x02)
95
+ return isBlockedIPv4(b.slice(2, 6)); // 2002::/16 6to4
96
+ if ((b[0] & 0xfe) === 0xfc)
97
+ return true; // fc00::/7 unique local
98
+ if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80)
99
+ return true; // fe80::/10 link-local
100
+ if (b[0] === 0xfe && (b[1] & 0xc0) === 0xc0)
101
+ return true; // fec0::/10 site-local (deprecated)
102
+ if (b[0] === 0xff)
103
+ return true; // ff00::/8 multicast
46
104
  return false;
47
105
  }
48
- /** Returns an error string if the URL must not be fetched, else null. */
49
- export async function assertFetchUrlAllowed(rawUrl) {
106
+ /**
107
+ * Whether an IP literal points somewhere the agent must not reach. Brackets
108
+ * are accepted (`[::1]`); anything that isn't an IP literal returns false —
109
+ * hostnames are resolved by `resolveFetchTarget`, not judged by spelling.
110
+ */
111
+ export function isBlockedIp(ip) {
112
+ const s = ip.trim().replace(/^\[|\]$/g, '');
113
+ const v4 = parseIPv4(s);
114
+ if (v4)
115
+ return isBlockedIPv4(v4);
116
+ const v6 = parseIPv6(s);
117
+ return v6 ? isBlockedIPv6(v6) : false;
118
+ }
119
+ /** Validate a URL and resolve its host, returning the verified address. */
120
+ export async function resolveFetchTarget(rawUrl) {
50
121
  let u;
51
122
  try {
52
123
  u = new URL(rawUrl);
53
124
  }
54
125
  catch {
55
- return 'Invalid URL format';
126
+ return { ok: false, reason: 'Invalid URL format' };
56
127
  }
57
128
  if (u.protocol !== 'http:' && u.protocol !== 'https:') {
58
- return `Blocked: only http/https URLs can be fetched (got "${u.protocol}")`;
129
+ return { ok: false, reason: `Blocked: only http/https URLs can be fetched (got "${u.protocol}")` };
59
130
  }
60
131
  const host = u.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
61
132
  if (host === 'localhost' || host.endsWith('.localhost')) {
62
- return 'Blocked: localhost is not fetchable by the agent';
133
+ return { ok: false, reason: 'Blocked: localhost is not fetchable by the agent' };
63
134
  }
64
- if (/^[0-9.]+$/.test(host) || host.includes(':')) {
65
- // Literal IP — check directly.
135
+ if (isIP(host)) {
66
136
  if (isBlockedIp(host))
67
- return `Blocked: ${host} is a private/loopback/link-local address`;
68
- return null;
137
+ return { ok: false, reason: `Blocked: ${host} is a private/loopback/link-local address` };
138
+ return { ok: true, url: u };
69
139
  }
70
140
  // Resolve and check every address (catches internal hostnames + single-record rebinding).
141
+ let addrs;
71
142
  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
- }
143
+ addrs = await dnsLookup(host, { all: true });
78
144
  }
79
145
  catch {
80
- // DNS failure — let curl attempt and fail naturally; not an SSRF risk.
146
+ // DNS failure — not an SSRF risk by itself; callers decide whether an
147
+ // unpinned request is acceptable.
148
+ return { ok: true, url: u, unresolved: true };
149
+ }
150
+ for (const a of addrs) {
151
+ if (isBlockedIp(a.address)) {
152
+ return { ok: false, reason: `Blocked: ${host} resolves to a private/internal address (${a.address})` };
153
+ }
81
154
  }
82
- return null;
155
+ return { ok: true, url: u, addresses: addrs.map((a) => a.address) };
156
+ }
157
+ /** Returns an error string if the URL must not be fetched, else null. */
158
+ export async function assertFetchUrlAllowed(rawUrl) {
159
+ const check = await resolveFetchTarget(rawUrl);
160
+ return check.ok ? null : check.reason;
83
161
  }
@@ -93,6 +93,21 @@ export interface ProviderCostBreakdown {
93
93
  cacheReadTokens: number;
94
94
  estimatedCost: number;
95
95
  }
96
+ /**
97
+ * What a cached prompt token costs, as a fraction of the model's input rate.
98
+ * Model first (a property of the model), then provider, then the default.
99
+ *
100
+ * One lookup for everything that needs it. Cost used this chain while savings
101
+ * hardcoded 0.1, so every provider priced differently from Anthropic had a
102
+ * cost and a "saved" figure that disagreed with each other.
103
+ */
104
+ export declare function cacheReadRateFor(model: string, provider: string | undefined): number;
105
+ /**
106
+ * The rate note for a report. One rate reads as "0.02×"; a session mixing
107
+ * providers reads as a range, because any single number there would be wrong
108
+ * for part of it.
109
+ */
110
+ export declare function formatCacheReadRates(rates: readonly number[]): string;
96
111
  /**
97
112
  * Get cost breakdown grouped by provider/model.
98
113
  *
@@ -120,6 +135,9 @@ export interface CacheStats {
120
135
  /** True when EVERY cached token came from a flat-fee plan — there is no
121
136
  * metered spend to have saved against. */
122
137
  isEntirelyFlatFeeCache: boolean;
138
+ /** The read rate of each metered record that read from cache, so a report
139
+ * can state the rate that actually applied instead of assuming 0.1×. */
140
+ cacheReadRates: number[];
123
141
  }
124
142
  export declare function getCacheStats(): CacheStats;
125
143
  /**
@@ -127,9 +127,10 @@ const MODEL_PRICING = {
127
127
  // rate, so it carries PEAK — an over-estimate by design. The previous rows
128
128
  // (0.435/0.87 and 0.14/0.28) were two to four and a half times below today's
129
129
  // peak and so under-reported, which is the one direction this table must not
130
- // err in. Cache hits cost 2% of a miss, but DeepSeek reports them as
131
- // `prompt_cache_hit_tokens`, which this tracker does not read — so every input
132
- // token bills at the miss rate: an over-estimate again, not a gap.
130
+ // err in. Cache hits are read (DeepSeek reports them both nested as
131
+ // `prompt_tokens_details.cached_tokens` and top-level as
132
+ // `prompt_cache_hit_tokens`) and billed at DeepSeek's own hit rate — see
133
+ // CACHE_READ_RATE below.
133
134
  'deepseek-flash': { inputPer1M: 0.30, outputPer1M: 1.20 },
134
135
  // Retired V4 Flash is served by V4.1 Flash and billed at its price.
135
136
  'deepseek-v4-flash': { inputPer1M: 0.30, outputPer1M: 1.20 },
@@ -250,8 +251,12 @@ export function extractOpenAIUsage(data) {
250
251
  // later. Reading only the nested form zeroed every Kimi cache hit, so the
251
252
  // cached portion of a run billed at the full cache-miss rate — five times
252
253
  // what it costs — with nothing anywhere to say so.
254
+ // DeepSeek sends the same number twice — nested `cached_tokens` and
255
+ // top-level `prompt_cache_hit_tokens` — so the nested read already covers
256
+ // it; the top-level field is a last resort, never added to the other.
253
257
  const nested = data.usage.prompt_tokens_details?.cached_tokens;
254
- const cached = (typeof nested === 'number' ? nested : data.usage.cached_tokens) || 0;
258
+ const topLevel = data.usage.cached_tokens ?? data.usage.prompt_cache_hit_tokens;
259
+ const cached = (typeof nested === 'number' ? nested : topLevel) || 0;
255
260
  return {
256
261
  promptTokens: data.usage.prompt_tokens || 0,
257
262
  completionTokens: data.usage.completion_tokens || 0,
@@ -301,8 +306,16 @@ export function extractAnthropicUsage(data) {
301
306
  */
302
307
  const MODEL_CACHE_READ_RATE = {
303
308
  'claude-fable-5-1': 0.025,
309
+ // V4 Pro's own ratio: $0.044 hit against $1.32 miss (peak; off-peak halves
310
+ // both, so the ratio holds). Historical — V4 Pro routes to V4.1 Flash from
311
+ // 2026-09-14 and configs holding it are migrated.
312
+ 'deepseek-v4-pro': 0.044 / 1.32,
304
313
  };
305
314
  const CACHE_READ_RATE = {
315
+ // V4.1 Flash: $0.006 hit against $0.30 miss at peak, $0.003 against $0.15
316
+ // off-peak — 0.02 either way. Without an entry DeepSeek fell to the 0.1
317
+ // default and every cached token billed at five times its price.
318
+ 'deepseek': 0.02,
306
319
  'kimi': 0.2,
307
320
  'kimi-api': 0.2,
308
321
  'qwen': 0.2,
@@ -313,6 +326,33 @@ const CACHE_READ_RATE = {
313
326
  };
314
327
  /** Anthropic's ratio, and the safest guess for a provider we have not priced. */
315
328
  const DEFAULT_CACHE_READ_RATE = 0.1;
329
+ /**
330
+ * What a cached prompt token costs, as a fraction of the model's input rate.
331
+ * Model first (a property of the model), then provider, then the default.
332
+ *
333
+ * One lookup for everything that needs it. Cost used this chain while savings
334
+ * hardcoded 0.1, so every provider priced differently from Anthropic had a
335
+ * cost and a "saved" figure that disagreed with each other.
336
+ */
337
+ export function cacheReadRateFor(model, provider) {
338
+ return MODEL_CACHE_READ_RATE[model]
339
+ ?? CACHE_READ_RATE[provider?.trim().toLowerCase() ?? '']
340
+ ?? DEFAULT_CACHE_READ_RATE;
341
+ }
342
+ /**
343
+ * The rate note for a report. One rate reads as "0.02×"; a session mixing
344
+ * providers reads as a range, because any single number there would be wrong
345
+ * for part of it.
346
+ */
347
+ export function formatCacheReadRates(rates) {
348
+ const fmt = (r) => `${Number(r.toFixed(3))}×`;
349
+ const distinct = [...new Set(rates.map(r => Number(r.toFixed(4))))].sort((a, b) => a - b);
350
+ if (distinct.length === 0)
351
+ return '';
352
+ if (distinct.length === 1)
353
+ return ` (billed at ${fmt(distinct[0])} input rate)`;
354
+ return ` (billed at ${fmt(distinct[0])}–${fmt(distinct[distinct.length - 1])} input rate, by model)`;
355
+ }
316
356
  /**
317
357
  * Get cost breakdown grouped by provider/model.
318
358
  *
@@ -346,9 +386,7 @@ export function getCostBreakdown(startIndex = 0) {
346
386
  // prompt tokens bill at the standard 1.0× rate.
347
387
  const cacheCreate = record.cacheCreationTokens ?? 0;
348
388
  const cacheRead = record.cacheReadTokens ?? 0;
349
- const cacheReadRate = MODEL_CACHE_READ_RATE[record.model]
350
- ?? CACHE_READ_RATE[record.provider?.trim().toLowerCase()]
351
- ?? DEFAULT_CACHE_READ_RATE;
389
+ const cacheReadRate = cacheReadRateFor(record.model, record.provider);
352
390
  const uncachedPrompt = Math.max(0, record.promptTokens - cacheCreate - cacheRead);
353
391
  existing.estimatedCost +=
354
392
  (uncachedPrompt / 1_000_000) * pricing.inputPer1M
@@ -367,6 +405,7 @@ export function getCacheStats() {
367
405
  let savings = 0;
368
406
  let flatFeeCached = 0;
369
407
  let meteredCached = 0;
408
+ const readRates = [];
370
409
  for (const record of currentRecords()) {
371
410
  const cached = (record.cacheCreationTokens ?? 0) + (record.cacheReadTokens ?? 0);
372
411
  cacheCreate += record.cacheCreationTokens ?? 0;
@@ -380,11 +419,16 @@ export function getCacheStats() {
380
419
  }
381
420
  meteredCached += cached;
382
421
  // Savings = what cache-read tokens would have cost at full input rate,
383
- // minus what they actually cost at 0.1×. (Cache creation is a slight
384
- // *penalty* of 0.25× — netted in for honest reporting.)
422
+ // minus what they cost at the model's own read rate. This hardcoded 0.9
423
+ // (a 0.1 read) for every provider, so Kimi and Qwen (0.2) over-reported
424
+ // savings while DeepSeek (0.02) and Fable 5.1 (0.025) under-reported them.
425
+ // (Cache creation is a slight *penalty* of 0.25× — netted in.)
385
426
  const pricing = MODEL_PRICING[record.model];
386
427
  if (pricing) {
387
- const cReadSaved = ((record.cacheReadTokens ?? 0) / 1_000_000) * pricing.inputPer1M * 0.9;
428
+ const readRate = cacheReadRateFor(record.model, record.provider);
429
+ if ((record.cacheReadTokens ?? 0) > 0)
430
+ readRates.push(readRate);
431
+ const cReadSaved = ((record.cacheReadTokens ?? 0) / 1_000_000) * pricing.inputPer1M * (1 - readRate);
388
432
  const cCreateCost = ((record.cacheCreationTokens ?? 0) / 1_000_000) * pricing.inputPer1M * 0.25;
389
433
  savings += cReadSaved - cCreateCost;
390
434
  }
@@ -395,6 +439,7 @@ export function getCacheStats() {
395
439
  estimatedSavingsUsd: Math.max(0, savings),
396
440
  hasFlatFeeCacheUsage: flatFeeCached > 0,
397
441
  isEntirelyFlatFeeCache: flatFeeCached > 0 && meteredCached === 0,
442
+ cacheReadRates: readRates,
398
443
  };
399
444
  }
400
445
  /**
@@ -506,7 +551,7 @@ export function formatCostReport() {
506
551
  // The billing multipliers only describe a metered account. On a plan
507
552
  // nothing is billed per token, so quoting a rate there would be as invented
508
553
  // as the per-model prices this report already refuses to show.
509
- const readNote = cache.isEntirelyFlatFeeCache ? '' : ' (billed at 0.1× input rate)';
554
+ const readNote = cache.isEntirelyFlatFeeCache ? '' : formatCacheReadRates(cache.cacheReadRates);
510
555
  const writeNote = cache.isEntirelyFlatFeeCache ? '' : ' (billed at 1.25× input rate)';
511
556
  lines.push(`**Cache reads:** ${formatTokenCount(cache.cacheReadTokens)} tokens${readNote}`);
512
557
  if (cache.cacheCreationTokens > 0) {
@@ -21,7 +21,7 @@ import { isMcpToolName, callSessionTool, isVirtualMcpToolName, callSessionVirtua
21
21
  // shared with shell.ts for curl/wget URL checks. Re-exported here so the
22
22
  // existing tests that import it from toolExecution keep working.
23
23
  export { isBlockedIp, assertFetchUrlAllowed } from './ssrfGuard.js';
24
- import { assertFetchUrlAllowed } from './ssrfGuard.js';
24
+ import { fetchUrlGuarded } from './guardedFetch.js';
25
25
  const debug = (...args) => {
26
26
  if (process.env.CODEEP_DEBUG === '1') {
27
27
  logger.debug(args.map(String).join(' '));
@@ -554,19 +554,14 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
554
554
  const url = parameters.url;
555
555
  if (!url)
556
556
  return { success: false, output: '', error: 'Missing required parameter: url', tool, parameters };
557
- const blockedReason = await assertFetchUrlAllowed(url);
558
- if (blockedReason)
559
- return { success: false, output: '', error: blockedReason, tool, parameters };
560
- // Restrict to http/https on the initial request AND redirects, and cap
561
- // redirect hops — defends against protocol-smuggling and limits
562
- // redirect-based SSRF reach (initial host is already IP-checked above).
563
- const result = await executeCommandAsync('curl', ['-s', '-L', '--proto', '=http,https', '--proto-redir', '=http,https', '--max-redirs', '5', '-m', '30', '-A', 'Codeep/1.0', '--max-filesize', '1000000', url], {
557
+ // Every redirect hop is SSRF-checked and pinned — see guardedFetch.ts.
558
+ const result = await fetchUrlGuarded(url, (args) => executeCommandAsync('curl', args, {
564
559
  cwd: projectRoot,
565
560
  projectRoot,
566
561
  timeout: 35000,
567
- });
568
- if (result.success) {
569
- let content = result.stdout;
562
+ }));
563
+ if (result.ok) {
564
+ let content = result.body;
570
565
  if (content.includes('<html') || content.includes('<!DOCTYPE')) {
571
566
  content = htmlToText(content);
572
567
  }
@@ -574,7 +569,7 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
574
569
  content = content.substring(0, 10000) + '\n\n... (truncated)';
575
570
  return { success: true, output: content, tool, parameters };
576
571
  }
577
- return { success: false, output: '', error: result.stderr || 'Failed to fetch URL', tool, parameters };
572
+ return { success: false, output: '', error: result.error, tool, parameters };
578
573
  }
579
574
  // === Z.AI MCP Tools ===
580
575
  case 'web_search': {
@@ -20,6 +20,7 @@
20
20
  * The fetcher is async, so `expandWebMentions` is async — unlike the
21
21
  * sync `expandMentions` for files. Callers await it.
22
22
  */
23
+ import { assertFetchUrlAllowed, isBlockedIp } from './ssrfGuard.js';
23
24
  /** Max bytes of text we'll inline from a fetched page (32 KB). */
24
25
  export const MAX_WEB_BYTES = 32 * 1024;
25
26
  /** Fetch timeout — don't hang the chat on a slow server. */
@@ -175,24 +176,16 @@ export function webCacheStats() {
175
176
  * endpoint), and `.internal`-style names.
176
177
  *
177
178
  * A user typing `@web http://localhost:3000` is a documented, intended use, so
178
- * this is NOT a blanket block — it's only applied to where a fetch *ended up*
179
- * after redirects, so a public URL can't bounce us into the private network.
179
+ * this is NOT a blanket block — it only decides whether redirect hops get
180
+ * checked, so a public URL can't bounce us into the private network.
180
181
  */
181
182
  function isPrivateHost(hostname) {
182
183
  const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
183
184
  if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.internal') || h.endsWith('.local'))
184
185
  return true;
185
- if (h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:'))
186
- return true;
187
- const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
188
- if (!m)
189
- return false;
190
- const [a, b] = [Number(m[1]), Number(m[2])];
191
- return a === 127 || a === 10 || a === 0
192
- || (a === 192 && b === 168)
193
- || (a === 172 && b >= 16 && b <= 31)
194
- || (a === 169 && b === 254);
186
+ return isBlockedIp(h);
195
187
  }
188
+ const MAX_REDIRECTS = 5;
196
189
  /** Hard ceiling on bytes read from the network, before any text decoding. */
197
190
  const MAX_WEB_FETCH_BYTES = MAX_WEB_BYTES * 4;
198
191
  async function safeFetch(url, fetchImpl) {
@@ -202,6 +195,8 @@ async function safeFetch(url, fetchImpl) {
202
195
  // `finally` so a throw can't leak the timer either.
203
196
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
204
197
  try {
198
+ // Judged like the hops (names resolved, not just spelled), so a LAN host
199
+ // such as `nas.home` may still redirect within the LAN it lives on.
205
200
  const requestedPrivate = (() => {
206
201
  try {
207
202
  return isPrivateHost(new URL(url).hostname);
@@ -209,24 +204,40 @@ async function safeFetch(url, fetchImpl) {
209
204
  catch {
210
205
  return false;
211
206
  }
212
- })();
213
- const res = await fetchImpl(url, {
214
- signal: controller.signal,
215
- headers: { 'User-Agent': USER_AGENT, Accept: 'text/html, text/plain, */*' },
216
- redirect: 'follow',
217
- });
218
- if (!res.ok) {
219
- return { ok: false, reason: `HTTP ${res.status}` };
220
- }
221
- // SSRF guard: the user vouched for the host they typed, not for wherever
222
- // it redirected us. Refuse a public → private hop (cloud metadata, LAN).
223
- if (!requestedPrivate && res.url) {
207
+ })() || (await assertFetchUrlAllowed(url)) !== null;
208
+ // Redirects are followed here, not by fetch: the user vouched for the host
209
+ // they typed, not for wherever it redirects. With `redirect: 'follow'`
210
+ // only the final URL could be checked, after every hop — private ones
211
+ // included — had already been requested.
212
+ let current = url;
213
+ let res;
214
+ for (let hop = 0;; hop++) {
215
+ if (hop > 0 && !requestedPrivate && await assertFetchUrlAllowed(current)) {
216
+ return { ok: false, reason: 'redirected to a private/internal address — refused' };
217
+ }
218
+ res = await fetchImpl(current, {
219
+ signal: controller.signal,
220
+ headers: { 'User-Agent': USER_AGENT, Accept: 'text/html, text/plain, */*' },
221
+ redirect: 'manual',
222
+ });
223
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
224
+ if (!location)
225
+ break;
226
+ if (hop >= MAX_REDIRECTS)
227
+ return { ok: false, reason: `too many redirects (more than ${MAX_REDIRECTS})` };
224
228
  try {
225
- if (isPrivateHost(new URL(res.url).hostname)) {
226
- return { ok: false, reason: 'redirected to a private/internal address — refused' };
227
- }
229
+ await res.body?.cancel();
228
230
  }
229
- catch { /* unparseable res.url — fall through */ }
231
+ catch { /* nothing to drain */ }
232
+ try {
233
+ current = new URL(location, current).href;
234
+ }
235
+ catch {
236
+ return { ok: false, reason: `HTTP ${res.status} with an invalid redirect` };
237
+ }
238
+ }
239
+ if (!res.ok) {
240
+ return { ok: false, reason: `HTTP ${res.status}` };
230
241
  }
231
242
  const declared = Number(res.headers.get('content-length') ?? '');
232
243
  if (Number.isFinite(declared) && declared > MAX_WEB_FETCH_BYTES) {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "3.3.1";
1
+ export declare const VERSION = "3.3.3";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
2
2
  // Baked from package.json at build time so the bun-compiled binary reports
3
3
  // the right version (it has no package.json on disk to read at runtime).
4
- export const VERSION = '3.3.1';
4
+ export const VERSION = '3.3.3';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "3.3.1",
3
+ "version": "3.3.3",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -43,7 +43,7 @@
43
43
  "@napi-rs/keyring": "^1.3.0",
44
44
  "clipboardy": "^4.0.0",
45
45
  "conf": "^13.1.0",
46
- "js-yaml": "^4.3.1",
46
+ "js-yaml": "^4.3.2",
47
47
  "open": "^10.0.0"
48
48
  },
49
49
  "devDependencies": {