aether-code 0.29.0 → 0.30.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.
@@ -28,7 +28,7 @@ import {
28
28
  import readline from "node:readline";
29
29
  import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
30
30
 
31
- const VERSION = "0.29.0";
31
+ const VERSION = "0.30.0";
32
32
 
33
33
  /**
34
34
  * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Uncensored AI coding agent for your terminal — Claude Code alternative with MCP support. Reads code, writes files, runs commands. Drives IDA Pro, Roblox Studio, Wireshark, Blender, and any MCP server. No refusal layer.",
5
5
  "homepage": "https://trynoguard.com",
6
6
  "repository": {
package/src/repl.js CHANGED
@@ -16,7 +16,7 @@ import { c, errorLine, boxLines, sideBySide } from "./render.js";
16
16
  import { checkForUpdate } from "./update-check.js";
17
17
  import { promptBox, EXIT_SIGNAL } from "./box-input.js";
18
18
 
19
- const VERSION = "0.29.0";
19
+ const VERSION = "0.30.0";
20
20
  const MODEL_NAME = "Aether Core";
21
21
 
22
22
  const SHORTCUTS = `
package/src/skills.js CHANGED
@@ -134,6 +134,12 @@ export function loadSkillsFromDir(dir) {
134
134
  if (!name.endsWith(".md")) continue;
135
135
  const filePath = path.join(dir, name);
136
136
  const raw = fs.readFileSync(filePath, "utf8");
137
+ // Cap skill size: the body is injected into the prompt every turn it
138
+ // triggers, so a huge file would blow the context window / inflate cost.
139
+ if (raw.length > 50_000) {
140
+ process.stderr.write(` (skipping oversized skill ${name}: ${raw.length} bytes > 50KB)\n`);
141
+ continue;
142
+ }
137
143
  skills.push(parseSkill(raw, filePath));
138
144
  }
139
145
  return skills;
package/src/tools.js CHANGED
@@ -279,6 +279,7 @@ const MAX_GLOB_RESULTS = 300;
279
279
  // relative path. ** spans directory separators; * does not.
280
280
  export function globToRegExp(glob) {
281
281
  let re = "";
282
+ let braceDepth = 0;
282
283
  for (let i = 0; i < glob.length; i++) {
283
284
  const ch = glob[i];
284
285
  if (ch === "*") {
@@ -291,6 +292,16 @@ export function globToRegExp(glob) {
291
292
  }
292
293
  } else if (ch === "?") {
293
294
  re += "[^/]";
295
+ } else if (ch === "{") {
296
+ // Brace expansion: {js,ts} -> (js|ts). Common in .gitignore (*.{js,ts});
297
+ // treating it literally silently failed to ignore those files.
298
+ re += "(";
299
+ braceDepth++;
300
+ } else if (ch === "}" && braceDepth > 0) {
301
+ re += ")";
302
+ braceDepth--;
303
+ } else if (ch === "," && braceDepth > 0) {
304
+ re += "|";
294
305
  } else if ("\\^$+.()|{}[]".includes(ch)) {
295
306
  re += "\\" + ch;
296
307
  } else {
@@ -430,13 +441,22 @@ async function askUser(args, _opts) {
430
441
  function readFile(args, opts) {
431
442
  if (typeof args.path !== "string") return { ok: false, output: "path is required" };
432
443
  const abs = resolveSafe(args.path, opts);
433
- const stat = fs.statSync(abs);
444
+ let stat;
445
+ try { stat = fs.statSync(abs); } catch (e) {
446
+ return { ok: false, output: `Cannot access ${args.path}: ${e.code || e.message}` };
447
+ }
434
448
  if (stat.isDirectory()) return { ok: false, output: `${args.path} is a directory, not a file` };
449
+ // Block non-regular files (named pipes, sockets, devices) — reading them can
450
+ // hang forever (no timeout on readFileSync).
451
+ if (!stat.isFile()) return { ok: false, output: `${args.path} is not a regular file.` };
435
452
  if (stat.size > 1_000_000) {
436
453
  return { ok: false, output: `File too large (${stat.size} bytes). Aether refuses to read >1MB at once.` };
437
454
  }
438
- const text = fs.readFileSync(abs, "utf8");
439
- return { ok: true, output: text };
455
+ try {
456
+ return { ok: true, output: fs.readFileSync(abs, "utf8") };
457
+ } catch (e) {
458
+ return { ok: false, output: `Cannot read ${args.path}: ${e.code || e.message}` };
459
+ }
440
460
  }
441
461
 
442
462
  function listDir(args, opts) {
@@ -497,6 +517,10 @@ function searchFiles(args, opts) {
497
517
  } catch { continue; }
498
518
  const lines = content.split("\n");
499
519
  for (let i = 0; i < lines.length; i++) {
520
+ // Skip very long lines: the pattern is model-supplied and a
521
+ // pathological regex on a long line can backtrack catastrophically
522
+ // (ReDoS) and freeze the single-threaded process.
523
+ if (lines[i].length > 2000) continue;
500
524
  if (regex.test(lines[i])) {
501
525
  matches.push({ file: rel, line: i + 1, text: lines[i].slice(0, 300) });
502
526
  if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
@@ -648,12 +672,33 @@ async function webSearch(args, opts) {
648
672
  // Bounded fetch with a fixed timeout + size cap. Strips scripts/styles, removes
649
673
  // tags, decodes common HTML entities. Not a full HTML parser; good enough for
650
674
  // reading docs pages, GitHub READMEs, MDN, Stack Overflow answers, etc.
675
+ // SSRF guard: refuse loopback / link-local / private hosts so web_fetch can't
676
+ // be steered (directly or via a redirect) at localhost or cloud-metadata
677
+ // (169.254.169.254) or an internal service.
678
+ function isBlockedHost(hostname) {
679
+ const h = (hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
680
+ if (!h || h === "localhost" || h === "0.0.0.0" || h === "::1" || h === "::") return true;
681
+ if (h.endsWith(".localhost") || h.endsWith(".internal") || h.endsWith(".local")) return true;
682
+ if (/^127\./.test(h)) return true; // loopback
683
+ if (/^10\./.test(h)) return true; // private A
684
+ if (/^192\.168\./.test(h)) return true; // private C
685
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // private B
686
+ if (/^169\.254\./.test(h)) return true; // link-local incl. cloud metadata
687
+ if (/^(fe80:|fc|fd|::ffff:)/.test(h)) return true; // IPv6 link-local / ULA / mapped
688
+ return false;
689
+ }
690
+
651
691
  async function webFetch(args, opts) {
652
692
  void opts;
653
693
  if (typeof args.url !== "string") return { ok: false, output: "url is required" };
654
694
  if (!/^https?:\/\//i.test(args.url)) {
655
695
  return { ok: false, output: "Only http:// and https:// URLs are allowed." };
656
696
  }
697
+ let initialHost;
698
+ try { initialHost = new URL(args.url).hostname; } catch { return { ok: false, output: "Invalid URL." }; }
699
+ if (isBlockedHost(initialHost)) {
700
+ return { ok: false, output: "web_fetch blocked: refusing to fetch a private/internal/loopback address." };
701
+ }
657
702
  const controller = new AbortController();
658
703
  const timeout = setTimeout(() => controller.abort(), 15_000);
659
704
  let res;
@@ -674,6 +719,14 @@ async function webFetch(args, opts) {
674
719
  return { ok: false, output: `web_fetch error: ${e.name === "AbortError" ? "timed out after 15s" : e.message}` };
675
720
  }
676
721
  clearTimeout(timeout);
722
+ // Re-check the FINAL url after any redirects — a public URL can 302 to an
723
+ // internal target, which the initial-URL check wouldn't catch.
724
+ try {
725
+ if (isBlockedHost(new URL(res.url).hostname)) {
726
+ res.body?.cancel().catch(() => {});
727
+ return { ok: false, output: "web_fetch blocked: redirect to a private/internal address." };
728
+ }
729
+ } catch { /* res.url unparseable — fall through */ }
677
730
  if (!res.ok) {
678
731
  return { ok: false, output: `web_fetch HTTP ${res.status} ${res.statusText}` };
679
732
  }