loom-agent 1.2.33 → 1.2.36

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/CHANGELOG.md CHANGED
@@ -4,6 +4,71 @@ All notable changes to **Loom Code** are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.2.36] — fix `loom web` / `loom acp` / `loom attach`
8
+
9
+ ### Fixed
10
+ - **`loom web` launched the TUI instead of the browser server.** Since 1.2.34
11
+ the npm shims prefer `bin/loom-bun.js`, whose non-TUI branch routed every
12
+ subcommand into `src/core/cli.js`'s interactive `main()` — which has no
13
+ handler for `web`/`acp`/`attach`, so control fell through to the full-screen
14
+ app (reproduced: splash painted, no HTTP listener). `bin/loom-bun.js` now
15
+ routes those three subcommands directly to their own entry points, exactly
16
+ like `bin/loom.js` always did. Verified end-to-end on a global install:
17
+ `/api/health` → 200, UI served, banner prints.
18
+
19
+ ## [1.2.35] — security hardening
20
+
21
+ ### Fixed
22
+ - **webfetch SSRF guard (`src/tools/index.js`).** The fetch tool is driven by
23
+ the model, and prompt-injected page content could steer it at cloud
24
+ metadata (`169.254.169.254`), localhost services, or internal hosts.
25
+ webfetch now allows only `http:`/`https:` (no `file:`/`ftp:`/`data:`),
26
+ refuses loopback, RFC1918, CGNAT, link-local/metadata, IPv6 ULA/link-local
27
+ and IPv4-mapped targets, **resolves DNS before connecting** so
28
+ hostname→internal-IP tricks fail, follows redirects manually and re-checks
29
+ every hop, and fails closed on unresolvable hosts.
30
+ - **MCP servers no longer inherit the full shell environment**
31
+ (`src/mcp/mcp-client.js`). Every spawned MCP server used to receive all of
32
+ `process.env` — provider API keys, cloud tokens, cookies — handing them to
33
+ whatever npm package a config starts. Servers now get a minimal OS/runtime
34
+ baseline (PATH, ComSpec, TEMP/HOME, proxy vars) plus only what their own
35
+ config explicitly declares (`cfg.env`). Secrets are opt-in per server.
36
+ - **Skills install name traversal blocked (`src/skills/skills-manager.js`).**
37
+ A remote-provided install name like `../../escapee` was joined raw onto
38
+ `~/.loom/skills`. Names are now a strict allowlist
39
+ (`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`); traversal attempts error instead of
40
+ silently renaming or escaping the skills directory.
41
+
42
+ ### Added
43
+ - **`loom web` session hardening (`src/web/web-server.js`).** Login tokens
44
+ previously lived until server restart; they now expire after **12 h**
45
+ (tunable via `LOOM_SERVER_TOKEN_TTL_MS`, cookie `Max-Age` kept in sync), a
46
+ lazy sweep drops expired tokens, and new **`POST /api/auth/logout`**
47
+ revokes the presented token immediately.
48
+ - **Security headers on every response:** `X-Content-Type-Options: nosniff`,
49
+ `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`; the browser UI
50
+ additionally ships a strict `Content-Security-Policy`
51
+ (`default-src 'none'; connect-src 'self'` — the page is self-contained).
52
+ - **LAN-exposure warning:** `loom web --hostname 0.0.0.0` (or `::`) without
53
+ `LOOM_SERVER_PASSWORD` now prints an explicit warning that anyone on the
54
+ network can run an agent and reach its tools/files.
55
+ - **New regression suite `src/tools/security.test.js`** plus five web-server
56
+ hardening tests (headers, CSP, token TTL expiry, Max-Age sync, logout).
57
+
58
+ ## [1.2.34] — bundle bun with the npm install
59
+
60
+ ### Added
61
+ - **`npm i -g loom-agent` now brings its own bun.** The `@oven/bun-<platform>`
62
+ binaries (pinned to bun 1.3.14, the version the project builds against) ship
63
+ as `optionalDependencies`; npm downloads only the one matching the user's
64
+ OS/arch, and no install scripts are involved (npm allow-scripts policies
65
+ cannot break it). The postinstall rewrites the `loom` shims to prefer the
66
+ bundled binary, then `bun` from PATH.
67
+ - **No-bun users are never stranded:** if neither the bundled binary nor a
68
+ PATH bun exists, the shim falls back to `node bin/loom.js` (the line-mode
69
+ REPL) with a hint — instead of dying with "'bun' is not recognized".
70
+ `findBun()` and the bin respawn paths check the bundled location first too.
71
+
7
72
  ## [1.2.33] — fix frozen splash on global installs + stable model picker
8
73
 
9
74
  ### Fixed
package/README.md CHANGED
@@ -1,9 +1,18 @@
1
- # Loom Code
1
+ ```
2
+ ██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
3
+ ██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
4
+ ██║ ██║ ██║██║ ██║██╔████╔██║ ██║ ██║ ██║██║ ██║█████╗
5
+ ██║ ██║ ██║██║ ██║██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██╔══╝
6
+ ███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║ ╚██████╗██████╔╝██████╔╝███████╗
7
+ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═════╝ ╚═════╝ ╚══════╝
8
+ ```
2
9
 
3
10
  [![npm version](https://img.shields.io/npm/v/loom-agent?style=flat&color=blue)](https://www.npmjs.com/package/loom-agent)
4
11
  [![License: MIT](https://img.shields.io/npm/l/loom-agent?style=flat&color=green)](LICENSE)
5
12
  [![Platform: Win/Mac/Linux](https://img.shields.io/badge/platform-Win--Mac--Linux-orange)](#)
6
13
 
14
+ ![Loom Code splash — connected to NVIDIA](docs/screenshot.png)
15
+
7
16
  An AI-powered coding agent for the terminal with multi-provider support and a full terminal UI.
8
17
 
9
18
  ## Features
package/bin/loom-bun.js CHANGED
@@ -1,68 +1,104 @@
1
- #!/usr/bin/env bun
2
- // npm invokes bin targets through Node on Windows, so re-launch under Bun
3
- // when this file was not started by Bun itself.
4
- const path = require("path");
5
- const fs = require("fs");
6
- const { spawnSync } = require("child_process");
7
-
8
- // The Solid JSX transform must register in Bun's preload phase, so the
9
- // preloads are passed as ABSOLUTE paths. That lets Bun start directly in the
10
- // user's project directory — the old "spawn at the package root, then chdir
11
- // to the project inside tui-open.tsx" dance froze the TUI after the first
12
- // frame (splash paints once, then no repaints and no keyboard input; proven
13
- // by A/B-launching the identical entry with and without the mid-flight
14
- // process.chdir). No chdir ever happens now: LOOM_START_CWD always equals
15
- // the starting directory, so the restore in tui-open.tsx is a no-op kept
16
- // only as a safety net.
17
- const pkgRoot = path.join(__dirname, "..");
18
- const underBun = typeof Bun !== "undefined" && !!process.versions.bun;
19
- if (!underBun) {
20
- const result = spawnSync(
21
- process.platform === "win32" ? "bun.exe" : "bun",
22
- [
23
- "--preload", path.join(pkgRoot, "src", "tui-preload.js"),
24
- __filename,
25
- ...process.argv.slice(2),
26
- ],
27
- {
28
- stdio: "inherit",
29
- cwd: process.env.LOOM_START_CWD || process.cwd(),
30
- env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
31
- windowsHide: false,
32
- }
33
- );
34
- if (result.error) {
35
- console.error("[loom] Bun is required for the full CLI. Install it from https://bun.sh/");
36
- process.exit(1);
37
- }
38
- process.exit(result.status == null ? 1 : result.status);
39
- }
40
- process.title = "loom-code";
41
- (async () => {
42
- try {
43
- // The npm bin entry bypasses src/index.js, so load dotenv here for both
44
- // the package environment and the project from which `loom` was run.
45
- const dotenv = require("dotenv");
46
- dotenv.config({ path: path.join(__dirname, "..", ".env") });
47
- const startCwd = process.env.LOOM_START_CWD || process.cwd();
48
- const projectEnv = path.join(startCwd, ".env");
49
- if (fs.existsSync(projectEnv)) dotenv.config({ path: projectEnv, override: false });
50
- // The npm global shim can start Bun outside the package directory, so do
51
- // not depend on bunfig.toml discovery for Windows console setup.
52
- await import("../src/tui-preload.js");
53
- const args = process.argv.slice(2);
54
- const coreMode = args.includes("--basic") || args.includes("-p") || args.includes("--print")
55
- || args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v")
56
- || ["acp", "web", "attach", "graph"].includes(args[0]);
57
- if (!coreMode) {
58
- await import("@opentui/solid/preload");
59
- await import("../src/tui-open.tsx");
60
- return;
61
- }
62
- const { main } = require("../src/core/cli.js");
63
- await main();
64
- } catch (err) {
65
- console.error(err && err.message ? err.message : String(err));
66
- process.exit(1);
67
- }
1
+ #!/usr/bin/env bun
2
+ // npm invokes bin targets through Node on Windows, so re-launch under Bun
3
+ // when this file was not started by Bun itself.
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const { spawnSync } = require("child_process");
7
+
8
+ // The Solid JSX transform must register in Bun's preload phase, so the
9
+ // preloads are passed as ABSOLUTE paths. That lets Bun start directly in the
10
+ // user's project directory — the old "spawn at the package root, then chdir
11
+ // to the project inside tui-open.tsx" dance froze the TUI after the first
12
+ // frame (splash paints once, then no repaints and no keyboard input; proven
13
+ // by A/B-launching the identical entry with and without the mid-flight
14
+ // process.chdir). No chdir ever happens now: LOOM_START_CWD always equals
15
+ // the starting directory, so the restore in tui-open.tsx is a no-op kept
16
+ // only as a safety net.
17
+ const pkgRoot = path.join(__dirname, "..");
18
+ const underBun = typeof Bun !== "undefined" && !!process.versions.bun;
19
+ if (!underBun) {
20
+ // Prefer the bundled @oven bun binary (shipped as an optional dep), then
21
+ // whatever bun is on PATH. Without either, still respawn so the inner
22
+ // failure message can guide the user.
23
+ const OVEN = {
24
+ "win32-x64": "bun-windows-x64",
25
+ "win32-arm64": "bun-windows-aarch64",
26
+ "darwin-x64": "bun-darwin-x64",
27
+ "darwin-arm64": "bun-darwin-aarch64",
28
+ "linux-x64": "bun-linux-x64",
29
+ "linux-arm64": "bun-linux-aarch64",
30
+ };
31
+ let bunCmd = process.platform === "win32" ? "bun.exe" : "bun";
32
+ const short = OVEN[process.platform + "-" + process.arch];
33
+ const exe = process.platform === "win32" ? "bun.exe" : "bun";
34
+ const ovenCandidates = short ? [
35
+ path.join(pkgRoot, "node_modules", "@oven", short, "bin", exe),
36
+ path.join(pkgRoot, "..", "@oven", short, "bin", exe),
37
+ ] : [];
38
+ for (const c of ovenCandidates) {
39
+ if (fs.existsSync(c)) { bunCmd = c; break; }
40
+ }
41
+ const result = spawnSync(
42
+ bunCmd,
43
+ [
44
+ "--preload", path.join(pkgRoot, "src", "tui-preload.js"),
45
+ __filename,
46
+ ...process.argv.slice(2),
47
+ ],
48
+ {
49
+ stdio: "inherit",
50
+ cwd: process.env.LOOM_START_CWD || process.cwd(),
51
+ env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
52
+ windowsHide: false,
53
+ }
54
+ );
55
+ if (result.error) {
56
+ console.error("[loom] Bun is required for the full CLI. Install it from https://bun.sh/");
57
+ process.exit(1);
58
+ }
59
+ process.exit(result.status == null ? 1 : result.status);
60
+ }
61
+ process.title = "loom-code";
62
+ (async () => {
63
+ try {
64
+ // The npm bin entry bypasses src/index.js, so load dotenv here for both
65
+ // the package environment and the project from which `loom` was run.
66
+ const dotenv = require("dotenv");
67
+ dotenv.config({ path: path.join(__dirname, "..", ".env") });
68
+ const startCwd = process.env.LOOM_START_CWD || process.cwd();
69
+ const projectEnv = path.join(startCwd, ".env");
70
+ if (fs.existsSync(projectEnv)) dotenv.config({ path: projectEnv, override: false });
71
+ // The npm global shim can start Bun outside the package directory, so do
72
+ // not depend on bunfig.toml discovery for Windows console setup.
73
+ await import("../src/tui-preload.js");
74
+ const args = process.argv.slice(2);
75
+ const coreMode = args.includes("--basic") || args.includes("-p") || args.includes("--print")
76
+ || args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v")
77
+ || ["acp", "web", "attach", "graph"].includes(args[0]);
78
+ if (!coreMode) {
79
+ await import("@opentui/solid/preload");
80
+ await import("../src/tui-open.tsx");
81
+ return;
82
+ }
83
+ // Subcommands that must never reach the interactive CLI/TUI: cli.main()
84
+ // has no handler for them and would fall through to launching the full
85
+ // screen app (`loom web` painted the splash instead of serving HTTP).
86
+ if (args[0] === "web") {
87
+ require("../src/web/web-server.js").main();
88
+ return;
89
+ }
90
+ if (args[0] === "acp") {
91
+ require("../src/acp/acp-server.js").main();
92
+ return;
93
+ }
94
+ if (args[0] === "attach") {
95
+ require("../src/web/attach.js").main();
96
+ return;
97
+ }
98
+ const { main } = require("../src/core/cli.js");
99
+ await main();
100
+ } catch (err) {
101
+ console.error(err && err.message ? err.message : String(err));
102
+ process.exit(1);
103
+ }
68
104
  })();
package/bin/loom-tui.js CHANGED
@@ -10,8 +10,28 @@ const { spawnSync } = require("child_process");
10
10
  const pkgRoot = path.join(__dirname, "..");
11
11
  const underBun = typeof Bun !== "undefined" && !!process.versions.bun;
12
12
  if (!underBun) {
13
+ // Prefer the bundled @oven bun binary (shipped as an optional dep), then
14
+ // whatever bun is on PATH — same scheme as bin/loom-bun.js.
15
+ const OVEN = {
16
+ "win32-x64": "bun-windows-x64",
17
+ "win32-arm64": "bun-windows-aarch64",
18
+ "darwin-x64": "bun-darwin-x64",
19
+ "darwin-arm64": "bun-darwin-aarch64",
20
+ "linux-x64": "bun-linux-x64",
21
+ "linux-arm64": "bun-linux-aarch64",
22
+ };
23
+ let bunCmd = process.platform === "win32" ? "bun.exe" : "bun";
24
+ const short = OVEN[process.platform + "-" + process.arch];
25
+ const exe = process.platform === "win32" ? "bun.exe" : "bun";
26
+ const ovenCandidates = short ? [
27
+ path.join(pkgRoot, "node_modules", "@oven", short, "bin", exe),
28
+ path.join(pkgRoot, "..", "@oven", short, "bin", exe),
29
+ ] : [];
30
+ for (const c of ovenCandidates) {
31
+ if (fs.existsSync(c)) { bunCmd = c; break; }
32
+ }
13
33
  const result = spawnSync(
14
- process.platform === "win32" ? "bun.exe" : "bun",
34
+ bunCmd,
15
35
  [
16
36
  "--preload", path.join(pkgRoot, "src", "tui-preload.js"),
17
37
  __filename,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loom-agent",
3
- "version": "1.2.33",
3
+ "version": "1.2.36",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/toshalkumbhar8979-design/loomcode.git"
@@ -24,7 +24,7 @@
24
24
  "dev": "node --watch src/index.js",
25
25
  "setup": "node src/setup.js",
26
26
  "test": "bun run src/tui/test-interactive.tsx && bun run test:unit && bun run lint:core",
27
- "test:unit": "bun test src/core/agents.test.js src/core/hooks.test.js src/core/custom-commands.test.js src/core/background-tasks.test.js src/core/memory.test.js src/core/subagent-log.test.js src/core/session.test.js src/tools/index.test.js src/providers/providers.test.js src/providers/caching.test.js src/providers/caching-thinking.test.js src/providers/registry.test.js src/mcp/mcp-client.test.js src/mcp/mcp-manager.test.js src/core/session-store.test.js src/skills/skills-manager.test.js src/core/usage.test.js src/core/permissions.test.js src/core/format.test.js src/acp/acp-server.test.js src/web/web-server.test.js src/tui/keybinds.test.ts",
27
+ "test:unit": "bun test src/core/agents.test.js src/core/hooks.test.js src/core/custom-commands.test.js src/core/background-tasks.test.js src/core/memory.test.js src/core/subagent-log.test.js src/core/session.test.js src/tools/index.test.js src/tools/security.test.js src/providers/providers.test.js src/providers/caching.test.js src/providers/caching-thinking.test.js src/providers/registry.test.js src/mcp/mcp-client.test.js src/mcp/mcp-manager.test.js src/core/session-store.test.js src/skills/skills-manager.test.js src/core/usage.test.js src/core/permissions.test.js src/core/format.test.js src/acp/acp-server.test.js src/web/web-server.test.js src/tui/keybinds.test.ts",
28
28
  "lint:core": "node node_modules/typescript/bin/tsc -p tsconfig.core.json",
29
29
  "smoke:acp": "node scripts/acp-smoke.js",
30
30
  "tui": "bun bin/loom-tui.js",
@@ -62,7 +62,13 @@
62
62
  "@opentui/core-darwin-x64": "0.5.3",
63
63
  "@opentui/core-linux-arm64": "0.5.3",
64
64
  "@opentui/core-linux-x64": "0.5.3",
65
- "@opentui/core-win32-x64": "0.5.3"
65
+ "@opentui/core-win32-x64": "0.5.3",
66
+ "@oven/bun-darwin-aarch64": "1.3.14",
67
+ "@oven/bun-darwin-x64": "1.3.14",
68
+ "@oven/bun-linux-aarch64": "1.3.14",
69
+ "@oven/bun-linux-x64": "1.3.14",
70
+ "@oven/bun-windows-aarch64": "1.3.14",
71
+ "@oven/bun-windows-x64": "1.3.14"
66
72
  },
67
73
  "overrides": {
68
74
  "glob": "^13.0.6"
@@ -115,6 +121,6 @@
115
121
  "doc": "docs"
116
122
  },
117
123
  "author": "",
118
- "license": "ISC",
124
+ "license": "MIT",
119
125
  "type": "commonjs"
120
126
  }
@@ -9,6 +9,40 @@ const path = require("path");
9
9
 
10
10
  const pkgRoot = path.resolve(__dirname, "..");
11
11
 
12
+ // Bun, bundled: loom-agent lists the @oven/bun-<platform> binaries as
13
+ // optionalDependencies, so `npm i -g loom-agent` brings the REAL bun binary
14
+ // (pinned, matching the repo's bun) along even when the user has no bun
15
+ // installed. npm skips non-matching platforms via each package's os/cpu
16
+ // fields, and this resolution never needs install scripts (npm
17
+ // allow-scripts policies can't break it). Resolved here once at install
18
+ // time and baked into the shims as the preferred runtime; the shims still
19
+ // fall back to whatever `bun` is on PATH, and — if NO bun exists at all —
20
+ // to `node bin/loom.js` (line-mode REPL) instead of dying with
21
+ // "'bun' is not recognized".
22
+ function ovenBunPath() {
23
+ const PKGS = {
24
+ "win32-x64": "bun-windows-x64",
25
+ "win32-arm64": "bun-windows-aarch64",
26
+ "darwin-x64": "bun-darwin-x64",
27
+ "darwin-arm64": "bun-darwin-aarch64",
28
+ "linux-x64": "bun-linux-x64",
29
+ "linux-arm64": "bun-linux-aarch64",
30
+ };
31
+ const short = PKGS[process.platform + "-" + process.arch];
32
+ if (!short) return "";
33
+ const exe = process.platform === "win32" ? "bun.exe" : "bun";
34
+ const candidates = [
35
+ path.join(pkgRoot, "node_modules", "@oven", short, "bin", exe), // nested (npm default)
36
+ path.join(pkgRoot, "..", "@oven", short, "bin", exe), // hoisted
37
+ ];
38
+ for (var i = 0; i < candidates.length; i++) {
39
+ try { if (fs.existsSync(candidates[i])) return candidates[i]; } catch {}
40
+ }
41
+ return "";
42
+ }
43
+
44
+ var ovenPath = "";
45
+
12
46
  function shimDirs() {
13
47
  const dirs = new Set();
14
48
  if (path.basename(path.resolve(pkgRoot, "..")) !== "node_modules") return [];
@@ -29,8 +63,11 @@ function cmdShim(pkg, script) {
29
63
  "@ECHO off",
30
64
  "SETLOCAL",
31
65
  'SET "LOOM_START_CWD=%CD%"',
32
- 'IF EXIST "%~dp0bun.exe" (SET "_prog=%~dp0bun.exe") ELSE (SET "_prog=bun")',
66
+ 'IF EXIST "' + ovenPath + '" (SET "_prog=' + ovenPath + '") ELSE (SET "_prog=bun")',
33
67
  '"%_prog%" --preload "%~dp0' + pkgEscaped + '\\src\\tui-preload.js" "%~dp0' + pkgEscaped + '\\bin\\' + script + '" %*',
68
+ "IF %ERRORLEVEL% NEQ 9009 EXIT /b %ERRORLEVEL%",
69
+ "REM bun not found (errorlevel 9009): fall back to the Node REPL",
70
+ "node \"%~dp0" + pkgEscaped + "\\bin\\loom.js\" %*",
34
71
  "EXIT /b %ERRORLEVEL%",
35
72
  ""
36
73
  ].join("\r\n");
@@ -39,10 +76,13 @@ function cmdShim(pkg, script) {
39
76
  function psShim(pkg, script) {
40
77
  return [
41
78
  "#!/usr/bin/env pwsh",
42
- "# rewritten by loom-agent postinstall (no-cwd-change TUI launch)",
79
+ "# rewritten by loom-agent postinstall (bundled bun + node fallback)",
43
80
  "$env:LOOM_START_CWD = (Get-Location).Path",
44
81
  "$pkg = Join-Path $PSScriptRoot '" + pkg + "'",
45
- "& bun --preload (Join-Path $pkg 'src/tui-preload.js') (Join-Path $pkg 'bin/" + script + "') @args",
82
+ "$bun = '" + ovenPath + "'",
83
+ "if (-not ($bun -and (Test-Path $bun))) { $bun = 'bun' }",
84
+ "& $bun --preload (Join-Path $pkg 'src/tui-preload.js') (Join-Path $pkg 'bin/" + script + "') @args",
85
+ "if ($LASTEXITCODE -eq 9009) { node (Join-Path $pkg 'bin/loom.js') @args; exit $LASTEXITCODE }",
46
86
  "exit $LASTEXITCODE",
47
87
  ""
48
88
  ].join("\n");
@@ -51,16 +91,28 @@ function psShim(pkg, script) {
51
91
  function shShim(pkg, script) {
52
92
  return [
53
93
  "#!/bin/sh",
54
- "# rewritten by loom-agent postinstall (no-cwd-change TUI launch)",
94
+ "# rewritten by loom-agent postinstall (bundled bun + node fallback)",
55
95
  'LOOM_START_CWD="$(pwd)"',
56
96
  "export LOOM_START_CWD",
57
97
  'PKG="$(dirname "$0")/' + pkg + '"',
58
- 'exec bun --preload "$PKG/src/tui-preload.js" "$PKG/bin/' + script + '" "$@"',
98
+ 'BUN="' + ovenPath + '"',
99
+ '[ -x "$BUN" ] || BUN="$(command -v bun || true)"',
100
+ 'if [ -n "$BUN" ]; then',
101
+ ' exec "$BUN" --preload "$PKG/src/tui-preload.js" "$PKG/bin/' + script + '" "$@"',
102
+ "fi",
103
+ 'echo "[loom] full TUI needs bun - install with: npm i -g bun (https://bun.sh)" >&2',
104
+ 'exec node "$PKG/bin/loom.js" "$@"',
59
105
  ""
60
106
  ].join("\n");
61
107
  }
62
108
 
63
109
  try {
110
+ ovenPath = ovenBunPath();
111
+ if (ovenPath) {
112
+ console.log("[loom-agent] bundled bun found: " + ovenPath);
113
+ } else {
114
+ console.log("[loom-agent] bundled bun not present for " + process.platform + "-" + process.arch + " (optional dep skipped?) - shims will use bun from PATH, or fall back to the Node REPL");
115
+ }
64
116
  for (var i = 0; i < shimDirs().length; i++) {
65
117
  var dir = shimDirs()[i];
66
118
  var relPkg = path.relative(dir, pkgRoot).split(path.sep).join("/");
package/src/core/cli.js CHANGED
@@ -460,7 +460,31 @@ class LoomCLI {
460
460
  }
461
461
 
462
462
  function findBun() {
463
- // Check common installation paths
463
+ // 1. The bundled bun binary shipped via @oven/bun-<platform> optional deps
464
+ // (npm i -g loom-agent brings its own bun — works with zero setup).
465
+ try {
466
+ const PKGS = {
467
+ 'win32-x64': 'bun-windows-x64',
468
+ 'win32-arm64': 'bun-windows-aarch64',
469
+ 'darwin-x64': 'bun-darwin-x64',
470
+ 'darwin-arm64': 'bun-darwin-aarch64',
471
+ 'linux-x64': 'bun-linux-x64',
472
+ 'linux-arm64': 'bun-linux-aarch64',
473
+ };
474
+ const short = PKGS[process.platform + '-' + process.arch];
475
+ if (short) {
476
+ const exe = process.platform === 'win32' ? 'bun.exe' : 'bun';
477
+ const pkgRoot = path.join(__dirname, '..', '..');
478
+ const candidates = [
479
+ path.join(pkgRoot, 'node_modules', '@oven', short, 'bin', exe), // nested
480
+ path.join(pkgRoot, '..', '@oven', short, 'bin', exe), // hoisted
481
+ ];
482
+ for (const c of candidates) {
483
+ if (fs.existsSync(c)) return c;
484
+ }
485
+ }
486
+ } catch {}
487
+ // 2. Common installation paths
464
488
  const paths = [
465
489
  path.join(os.homedir(), '.bun', 'bin', 'bun.exe'),
466
490
  path.join(os.homedir(), '.bun', 'bin', 'bun'),
@@ -471,7 +495,7 @@ function findBun() {
471
495
  for (const p of paths) {
472
496
  if (fs.existsSync(p)) return p;
473
497
  }
474
- // Search PATH (Windows: where.exe, POSIX: which)
498
+ // 3. Search PATH (Windows: where.exe, POSIX: which)
475
499
  try {
476
500
  const { execSync } = require('child_process');
477
501
  const isWin = process.platform === 'win32';
@@ -1,4 +1,34 @@
1
1
  const { spawn } = require('child_process');
2
+ // Minimal OS/runtime baseline handed to every spawned MCP server (see note in
3
+ // connectToJson). Explicit cfg.env keys are layered on top per server.
4
+ const MCP_ENV_KEYS = [
5
+ 'PATH', 'PATHEXT', 'COMSPEC', 'SystemRoot', 'windir', 'SystemDrive',
6
+ 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
7
+ 'PROGRAMFILES', 'PROGRAMDATA', 'ALLUSERSPROFILE', 'COMMONPROGRAMFILES',
8
+ 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS', 'OS',
9
+ 'COMPUTERNAME', 'USERNAME', 'LANG', 'TZ', 'TERM', 'SHELL',
10
+ 'XDG_CONFIG_HOME', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
11
+ ];
12
+ /** @returns {Record<string,string>} filtered copy of process.env */
13
+ function buildMcpBaseEnv() {
14
+ const out = /** @type {Record<string,string>} */ ({});
15
+ for (const k of MCP_ENV_KEYS) {
16
+ const v = process.env[k];
17
+ if (v !== undefined) out[k] = v;
18
+ }
19
+ return out;
20
+ }
21
+ const MCP_BASE_ENV = buildMcpBaseEnv();
22
+
23
+ /**
24
+ * Compose the environment for one MCP server process: the filtered baseline
25
+ * plus whatever that server's config explicitly declares (cfg.env wins).
26
+ * @param {{env?: Record<string,string|undefined>|null}} cfg
27
+ * @returns {Record<string,string>}
28
+ */
29
+ function mcpSpawnEnv(cfg) {
30
+ return Object.assign({}, MCP_BASE_ENV, cfg.env || {});
31
+ }
2
32
  const { loadServers } = require('./mcp-manager');
3
33
 
4
34
  let toolCachePromise = null;
@@ -22,9 +52,19 @@ function killTree(child) {
22
52
 
23
53
  function connectToJson(cfg, timeoutMs) {
24
54
  return new Promise((resolve, reject) => {
55
+ // MCP servers are third-party processes. Inheriting the parent's FULL
56
+ // environment would hand every provider API key / cloud token / cookie in
57
+ // the user's shell to whatever npm package a config spawns. Instead pass a
58
+ // minimal OS/runtime baseline plus only what cfg.env explicitly declares —
59
+ // per-server secrets belong in the server's own env config, not leaked by
60
+ // inheritance. (network proxies forwarded; they carry routing, not auth.)
61
+ // Per-server environment via allowlist + explicit cfg.env only — see
62
+ // buildMcpBaseEnv/mcpSpawnEnv above; inheriting all of process.env would
63
+ // leak provider keys to every third-party server.
64
+ const env = mcpSpawnEnv(cfg);
25
65
  const child = spawn(cfg.command, cfg.args || [], {
26
66
  stdio: ['pipe', 'pipe', 'pipe'],
27
- env: Object.assign({}, process.env, cfg.env || {}),
67
+ env,
28
68
  // No console window for stdio MCP servers: on Windows spawn() would
29
69
  // otherwise pop a flashing console up and down while chats happen.
30
70
  windowsHide: true,
@@ -198,4 +238,4 @@ function getCachedTools() {
198
238
  return warm();
199
239
  }
200
240
 
201
- module.exports = { getTools, getCachedTools, clearCache, buildToolName, callTool, warm, callRpc, killTree };
241
+ module.exports = { getTools, getCachedTools, clearCache, buildToolName, callTool, warm, callRpc, killTree, buildMcpBaseEnv, mcpSpawnEnv };
@@ -11,6 +11,19 @@ function validateUrl(url) {
11
11
  } catch { return false; }
12
12
  }
13
13
 
14
+ /**
15
+ * Reduce an install target to a single safe folder name under ~/.loom/skills.
16
+ * Strict allowlist (alnum/_/./-, ≤64 chars): drives, separators and '..'
17
+ * segments are rejected outright, never silently collapsed, so remote-provided
18
+ * names can't rename unexpectedly or point outside the skills directory.
19
+ * @param {unknown} name
20
+ * @returns {string|null} cleaned single-segment name, or null when unusable
21
+ */
22
+ function safeSkillName(name) {
23
+ const s = typeof name === 'string' ? name.trim() : '';
24
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(s) ? s : null;
25
+ }
26
+
14
27
  const LOOM_DIR = path.join(os.homedir(), '.loom');
15
28
 
16
29
  function globalSkillsDir() {
@@ -111,8 +124,17 @@ function installFrom(srcDir, targetName) {
111
124
  if (!fs.existsSync(path.join(src, 'SKILL.md'))) {
112
125
  return { error: `No SKILL.md in ${src}` };
113
126
  }
114
- const name = targetName || path.basename(src);
115
- const dest = path.join(globalSkillsDir(), name);
127
+ const name = safeSkillName(targetName != null ? targetName : path.basename(src));
128
+ if (!name) {
129
+ return { error: `Invalid skill name: ${JSON.stringify(targetName)} — use letters/digits/-/_ only` };
130
+ }
131
+ const destRoot = globalSkillsDir();
132
+ const dest = path.join(destRoot, name);
133
+ // Paranoia check: `name` is already a sanitized single segment, so this can
134
+ // not trip short of path.join behavior changing underneath us.
135
+ if (path.resolve(dest) !== path.join(path.resolve(destRoot), name)) {
136
+ return { error: 'Invalid install location' };
137
+ }
116
138
  if (fs.existsSync(dest)) {
117
139
  fs.rmSync(dest, { recursive: true, force: true });
118
140
  }
@@ -210,4 +232,5 @@ module.exports = {
210
232
  globalSkillsDir,
211
233
  agentsSkillsDir,
212
234
  projectSkillsDir,
235
+ safeSkillName,
213
236
  };
@@ -5,6 +5,7 @@ const { execSync, spawn } = require('child_process');
5
5
  const { glob: globLib } = require('glob');
6
6
  const { commandRiskLabel } = require('../core/permissions');
7
7
  const { loadConfig } = require('../config/settings');
8
+ const dnsPromises = require('dns').promises;
8
9
 
9
10
  const cwd = process.cwd();
10
11
 
@@ -15,6 +16,74 @@ function globIgnore(full) {
15
16
  return ['node_modules', '.git'].map((n) => path.posix.join(base, '**', n, '**'));
16
17
  }
17
18
 
19
+ // ---------------------------------------------------------------------------
20
+ // SSRF guard (webfetch)
21
+ //
22
+ // The agent model decides which URLs webfetch opens, and prompt-injected page
23
+ // content can steer it toward "check http://169.254.169.254/latest/meta-data"
24
+ // or "read localhost:5984/_config". Those must fail closed:
25
+ // - only http/https, never file:/ftp:/data: transport tricks
26
+ // - loopback, link-local (cloud metadata!), RFC1918 private ranges, CGNAT,
27
+ // IPv6 ULA/link-local and IPv4-mapped addresses are refused
28
+ // - hostnames are resolved BEFORE connecting so a DNS name pointing into
29
+ // internal space is caught (checking the literal hostname is not enough)
30
+ // - redirects are followed manually and every hop re-checked, because a
31
+ // public URL can bounce straight at an internal one
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * True when an IPv4/IPv6 address string falls in a range the agent must never
35
+ * fetch (loopback, link-local/metadata, private, CGNAT, malformed).
36
+ * @param {string} ip
37
+ * @returns {boolean}
38
+ */
39
+ function isPrivateAddress(ip) {
40
+ const s = String(ip || '').toLowerCase();
41
+ // IPv6 handling first: exact forms and prefix families we care about.
42
+ if (s.includes(':')) {
43
+ let bare = s.replace(/^\[|\]$/g, '');
44
+ if (bare === '::' || bare === '::1') return true;
45
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
46
+ if (mapped) return isPrivateAddress(mapped[1]);
47
+ if (/^f[cd][0-9a-f]{2}:/.test(bare)) return true; // fc00::/7 ULA
48
+ if (/^fe[89ab][0-9a-f]:/.test(bare)) return true; // fe80::/10 link-local
49
+ if (/^2001:db8:/.test(bare)) return true; // documentation
50
+ return false;
51
+ }
52
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
53
+ if (!m) return false;
54
+ const o = m.slice(1).map(Number);
55
+ if (o.some((x) => x > 255)) return true; // malformed → refuse
56
+ const [a, b] = o;
57
+ return (
58
+ a === 0 || // this-network
59
+ a === 10 || // RFC1918
60
+ a === 127 || // loopback
61
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
62
+ (a === 169 && b === 254) || // link-local / metadata
63
+ (a === 172 && b >= 16 && b <= 31) || // RFC1918
64
+ (a === 192 && b === 168) // RFC1918
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Resolve a URL's host and report whether every resolved address is public.
70
+ * Unresolvable hosts fail closed (false).
71
+ * @param {URL} u
72
+ * @returns {Promise<boolean>}
73
+ */
74
+ async function urlIsPublic(u) {
75
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
76
+ if (!host || host === 'localhost' || host.endsWith('.localhost')) return false;
77
+ if (isPrivateAddress(host)) return false;
78
+ let addrs;
79
+ try {
80
+ addrs = /** @type {{address: string}[]} */ (await dnsPromises.lookup(host, { all: true }));
81
+ } catch {
82
+ return false;
83
+ }
84
+ return addrs.length > 0 && addrs.every((a) => !isPrivateAddress(a.address));
85
+ }
86
+
18
87
  const MODES = ['build', 'plan', 'chat'];
19
88
 
20
89
  // Tools that never mutate the filesystem/state — safe to expose in plan mode.
@@ -251,10 +320,36 @@ const TOOLS = {
251
320
  url: { type: 'string', required: true, description: 'URL to fetch' },
252
321
  },
253
322
  async execute(params) {
323
+ let u;
254
324
  try {
255
- const resp = await fetch(params.url, { signal: AbortSignal.timeout(15000) });
256
- const text = await resp.text();
257
- return text.slice(0, 10000);
325
+ u = new URL(String(params.url || ''));
326
+ } catch {
327
+ return { error: 'Invalid URL' };
328
+ }
329
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
330
+ return { error: 'Blocked by webfetch policy: only http/https URLs are allowed' };
331
+ }
332
+ try {
333
+ // Follow redirects manually so every hop passes the SSRF guard — a
334
+ // public URL may legitimately bounce into an internal address.
335
+ let current = u;
336
+ const signal = AbortSignal.timeout(15000);
337
+ for (let hop = 0; hop < 5; hop++) {
338
+ if (!(await urlIsPublic(current))) {
339
+ return { error: `Blocked by webfetch policy: ${current.hostname} resolves to a private/reserved address (SSRF guard)` };
340
+ }
341
+ const resp = await fetch(current.toString(), { redirect: 'manual', signal });
342
+ if ([301, 302, 303, 307, 308].includes(resp.status)) {
343
+ const loc = resp.headers.get('location');
344
+ try { resp.body && resp.body.cancel(); } catch {}
345
+ if (!loc) return { error: `Fetch failed: redirect ${resp.status} without Location header` };
346
+ current = new URL(loc, current);
347
+ continue;
348
+ }
349
+ const text = await resp.text();
350
+ return text.slice(0, 10000);
351
+ }
352
+ return { error: 'Fetch failed: too many redirects' };
258
353
  } catch (e) {
259
354
  return { error: `Fetch failed: ${e.message}` };
260
355
  }
@@ -540,4 +635,4 @@ async function getAllToolDefinitions(mode = 'build') {
540
635
  }
541
636
  }
542
637
 
543
- module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool };
638
+ module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress };
@@ -111,22 +111,47 @@ function readBody(req, limit = 1_048_576) {
111
111
 
112
112
  function sendJson(res, status, obj) {
113
113
  const body = JSON.stringify(obj);
114
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
114
+ res.writeHead(status, Object.assign(securityHeaders(), {
115
+ 'Content-Type': 'application/json; charset=utf-8',
116
+ 'Content-Length': Buffer.byteLength(body),
117
+ }));
115
118
  res.end(body);
116
119
  }
117
120
 
118
121
  function sendText(res, status, text, extra) {
119
- const headers = Object.assign({ 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(text) }, extra || {});
122
+ const headers = Object.assign(securityHeaders(), {
123
+ 'Content-Type': 'text/plain; charset=utf-8',
124
+ 'Content-Length': Buffer.byteLength(text),
125
+ }, extra || {});
120
126
  res.writeHead(status, headers);
121
127
  res.end(text);
122
128
  }
123
129
 
130
+ // Baseline hardening on every response: stops MIME sniffing, clickjacking
131
+ // and referrer leakage. The API is JSON-only and index.html is a single
132
+ // self-contained page (one inline script, no external assets), so this
133
+ // cannot break the UI.
134
+ const SECURITY_HEADERS = {
135
+ 'X-Content-Type-Options': 'nosniff',
136
+ 'X-Frame-Options': 'DENY',
137
+ 'Referrer-Policy': 'no-referrer',
138
+ };
139
+ const HTML_SECURITY_HEADERS = Object.assign({}, SECURITY_HEADERS, {
140
+ 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
141
+ });
142
+ /** @returns {Record<string,string>} fresh copy so callers can extend safely */
143
+ function securityHeaders() {
144
+ return Object.assign({}, SECURITY_HEADERS);
145
+ }
146
+
124
147
  // ── The web server ──────────────────────────────────────────────────────
125
148
 
126
149
  function createWebServer(opts) {
127
- const tokens = new Map(); // token -> true (in-memory auth, server-lifetime)
150
+ const tokens = new Map(); // token -> issued-at ms (auth is server-lifetime, per-token TTL)
128
151
  const password = process.env.LOOM_SERVER_PASSWORD || '';
129
152
  const username = process.env.LOOM_SERVER_USERNAME || DEFAULT_USERNAME;
153
+ const tokenTtlMs =
154
+ Number((opts && opts.tokenTtlMs) || process.env.LOOM_SERVER_TOKEN_TTL_MS) || 12 * 60 * 60 * 1000;
130
155
  const hub = new Map(); // sessionId -> { session, active }
131
156
  const stats = { requests: 0, sessionsCreated: 0, messagesRun: 0 };
132
157
  const authFails = new Map(); // ip -> { count, lockedUntil }
@@ -173,10 +198,27 @@ function createWebServer(opts) {
173
198
 
174
199
  function authOk(req) {
175
200
  if (!password) return true;
201
+ sweepTokens();
176
202
  const cookieHeader = req.headers.cookie || '';
177
203
  const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(cookieHeader)?.[1]
178
204
  || new URL(req.url, 'http://x').searchParams.get('token');
179
- return !!(token && tokens.has(token));
205
+ return !!(token && tokenIsLive(token));
206
+ }
207
+
208
+ // Tokens are stamped at issue time and expire after tokenTtlMs (default 12h,
209
+ // env LOOM_SERVER_TOKEN_TTL_MS). Sweep runs opportunistically on each auth
210
+ // check — the map holds only this server's logins, so it stays tiny.
211
+ function tokenIsLive(token) {
212
+ const issuedAt = tokens.get(token);
213
+ if (typeof issuedAt !== 'number') return false;
214
+ return Date.now() - issuedAt <= tokenTtlMs && Date.now() >= issuedAt - 60_000;
215
+ }
216
+ function sweepTokens() {
217
+ if (!tokens.size) return;
218
+ const now = Date.now();
219
+ for (const [tok, issuedAt] of tokens) {
220
+ if (typeof issuedAt === 'number' && now - issuedAt > tokenTtlMs) tokens.delete(tok);
221
+ }
180
222
  }
181
223
 
182
224
  function requireAuth(req, res) {
@@ -266,8 +308,8 @@ function createWebServer(opts) {
266
308
  if (body.username === username && passwordMatches(body.password, password)) {
267
309
  authFails.delete(ip);
268
310
  const token = crypto.randomBytes(24).toString('hex');
269
- tokens.set(token, true);
270
- res.setHeader('Set-Cookie', 'loom_token=' + token + '; Path=/; HttpOnly; SameSite=Strict');
311
+ tokens.set(token, Date.now());
312
+ res.setHeader('Set-Cookie', 'loom_token=' + token + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + Math.floor(tokenTtlMs / 1000));
271
313
  return sendJson(res, 200, { ok: true, username });
272
314
  }
273
315
  authAttemptFailed(ip);
@@ -277,6 +319,16 @@ function createWebServer(opts) {
277
319
  return sendJson(res, 200, { required: !password ? false : true, username: password ? username : null });
278
320
  }
279
321
 
322
+ // POST /api/auth/logout — revoke the presented session token immediately.
323
+ if (seg[0] === 'api' && seg.length === 3 && seg[1] === 'auth' && seg[2] === 'logout') {
324
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' });
325
+ const cookieHeader = req.headers.cookie || '';
326
+ const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(cookieHeader)?.[1];
327
+ if (token) tokens.delete(token);
328
+ res.setHeader('Set-Cookie', 'loom_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0');
329
+ return sendJson(res, 200, { ok: true });
330
+ }
331
+
280
332
  if (seg[0] === 'api' && seg[1] === 'health') return sendJson(res, 200, { ok: true });
281
333
 
282
334
  if (!requireAuth(req, res)) return;
@@ -371,7 +423,10 @@ function createWebServer(opts) {
371
423
  if (p === '/' || p === '/index.html') {
372
424
  fs.readFile(INDEX_FILE, 'utf8', (err, html) => {
373
425
  if (err) return sendText(res, 500, 'index.html missing: ' + err.message);
374
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(html) });
426
+ res.writeHead(200, Object.assign(HTML_SECURITY_HEADERS, {
427
+ 'Content-Type': 'text/html; charset=utf-8',
428
+ 'Content-Length': Buffer.byteLength(html),
429
+ }));
375
430
  res.end(html);
376
431
  });
377
432
  return;
@@ -445,6 +500,16 @@ async function main() {
445
500
 
446
501
  console.log('Loom Web — version ' + require('../../package.json').version);
447
502
  if (o.password) console.log('Authentication required — user: ' + o.username + ' (LOOM_SERVER_USERNAME)');
503
+ const exposedToLan = o.hostname === '0.0.0.0' || o.hostname === '::';
504
+ if (exposedToLan) {
505
+ console.log('! NETWORK EXPOSURE WARNING !');
506
+ if (!o.password) {
507
+ console.log('! Binding ' + o.hostname + ' WITHOUT a password: anyone on your LAN can run an AI agent ');
508
+ console.log('! on this machine and reach its tools/files. Set LOOM_SERVER_PASSWORD before exposing.');
509
+ } else {
510
+ console.log('! Bound to ' + o.hostname + ' — protected by login, but keep the port firewalled where possible.');
511
+ }
512
+ }
448
513
  for (const a of addresses) console.log(' ' + a.label + ': ' + a.url);
449
514
  if (o.mdns) {
450
515
  const m = advertiseMdns(o, port, (msg) => console.error('[loom web] ' + msg));