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 +65 -0
- package/README.md +10 -1
- package/bin/loom-bun.js +103 -67
- package/bin/loom-tui.js +21 -1
- package/package.json +10 -4
- package/scripts/postinstall.js +57 -5
- package/src/core/cli.js +26 -2
- package/src/mcp/mcp-client.js +42 -2
- package/src/skills/skills-manager.js +25 -2
- package/src/tools/index.js +99 -4
- package/src/web/web-server.js +72 -7
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
|
-
|
|
1
|
+
```
|
|
2
|
+
██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
3
|
+
██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
4
|
+
██║ ██║ ██║██║ ██║██╔████╔██║ ██║ ██║ ██║██║ ██║█████╗
|
|
5
|
+
██║ ██║ ██║██║ ██║██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██╔══╝
|
|
6
|
+
███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║ ╚██████╗██████╔╝██████╔╝███████╗
|
|
7
|
+
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═════╝ ╚═════╝ ╚══════╝
|
|
8
|
+
```
|
|
2
9
|
|
|
3
10
|
[](https://www.npmjs.com/package/loom-agent)
|
|
4
11
|
[](LICENSE)
|
|
5
12
|
[](#)
|
|
6
13
|
|
|
14
|
+

|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
124
|
+
"license": "MIT",
|
|
119
125
|
"type": "commonjs"
|
|
120
126
|
}
|
package/scripts/postinstall.js
CHANGED
|
@@ -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 "
|
|
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 (
|
|
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
|
-
"
|
|
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 (
|
|
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
|
-
'
|
|
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
|
-
//
|
|
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';
|
package/src/mcp/mcp-client.js
CHANGED
|
@@ -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
|
|
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
|
|
115
|
-
|
|
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
|
};
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
256
|
-
|
|
257
|
-
return
|
|
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 };
|
package/src/web/web-server.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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 ->
|
|
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 &&
|
|
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,
|
|
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,
|
|
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));
|