roforge-cli 0.3.6 → 0.3.7

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roforge-cli",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "RoForge \u2014 Claude-Code-style local AI agent for Roblox Studio. BYOK, zero backend, zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,6 +7,7 @@
7
7
  // POST /v1/bridge/jobs/:id/result → {ok}
8
8
  // Auth: Authorization: Bearer <bridge token> (loopback-only by design).
9
9
  import http from "node:http";
10
+ import { timingSafeEqual } from "node:crypto";
10
11
  import { json } from "./wire.js";
11
12
 
12
13
  export class BridgeServer {
@@ -59,7 +60,13 @@ export class BridgeServer {
59
60
  _authorized(req) {
60
61
  const h = String(req.headers["authorization"] || "");
61
62
  const m = /^Bearer\s+(.+)$/i.exec(h);
62
- return Boolean(m && m[1].trim() === this.token);
63
+ if (!m) return false;
64
+ // Constant-time compare (defence in depth; the server is loopback-only).
65
+ const given = m[1].trim();
66
+ const a = Buffer.from(given);
67
+ const b = Buffer.from(this.token);
68
+ if (a.length !== b.length) return false;
69
+ return timingSafeEqual(a, b);
63
70
  }
64
71
 
65
72
  _handle(req, res) {
package/src/config.js CHANGED
@@ -133,14 +133,25 @@ export function loadFileConfig() {
133
133
  export function saveFileConfig(patch) {
134
134
  const current = loadFileConfig();
135
135
  const next = deepMerge(current, patch);
136
- fs.mkdirSync(configDir(), { recursive: true });
137
- fs.writeFileSync(configFile(), JSON.stringify(next, null, 2));
136
+ fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
137
+ fs.writeFileSync(configFile(), JSON.stringify(next, null, 2), { mode: 0o600 });
138
+ try {
139
+ fs.chmodSync(configFile(), 0o600); // writeFileSync mode is ignored on existing files
140
+ } catch {
141
+ /* best effort */
142
+ }
138
143
  return next;
139
144
  }
140
145
 
146
+ const POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]);
141
147
  function deepMerge(a, b) {
142
- const out = { ...a };
148
+ const out = Object.create(null);
149
+ for (const [k, v] of Object.entries(a)) {
150
+ if (POLLUTION_KEYS.has(k)) continue;
151
+ out[k] = v;
152
+ }
143
153
  for (const [k, v] of Object.entries(b)) {
154
+ if (POLLUTION_KEYS.has(k)) continue;
144
155
  out[k] = v && typeof v === "object" && !Array.isArray(v) && a[k] && typeof a[k] === "object" ? deepMerge(a[k], v) : v;
145
156
  }
146
157
  return out;
package/src/plugins.js CHANGED
@@ -41,7 +41,26 @@ const ACTION_TYPES = new Set(["http", "command", "read-file", "transform"]);
41
41
  const HTTP_METHODS = new Set(["GET", "POST", "PUT"]);
42
42
  const FORBIDDEN_ARG_CHARS = /[;|&`$<>\n\r]/;
43
43
  const PRIVATE_HOST_RE =
44
- /^(localhost|127\.[0-9.]+|0\.0\.0\.0|::1|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+|169\.254\.[0-9.]+|metadata\.google\.internal|.*\.local|.*\.internal)$/i;
44
+ /^(localhost|127\.[0-9.]+|0\.0\.0\.0|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+|169\.254\.[0-9.]+|metadata\.google\.internal|.*\.local|.*\.internal)$/i;
45
+
46
+ // Fail-closed host check. new URL() normalizes IPv4 numerics (2130706433 →
47
+ // 127.0.0.1) but keeps IPv6 bracketed, so bracketed/unknown IPv6 is rejected
48
+ // outright — public dev endpoints use domain names, not raw IPv6.
49
+ function isPublicHost(hostIn) {
50
+ let host = String(hostIn || "").toLowerCase();
51
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
52
+ if (host.includes(":")) {
53
+ if (host === "" || host === "::" || host === "::1") return false;
54
+ if (/^fe[89ab]/.test(host)) return false; // link-local fe80::/10
55
+ if (/^f[cd]/.test(host)) return false; // ULA fc00::/7
56
+ const mapped = /^::ffff:(.+)$/.exec(host); // IPv4-mapped ::ffff:a.b.c.d
57
+ if (mapped) return isPublicHost(mapped[1]);
58
+ return false; // any other IPv6: fail closed
59
+ }
60
+ if (!host || host.length > 253) return false;
61
+ if (PRIVATE_HOST_RE.test(host)) return false;
62
+ return true;
63
+ }
45
64
 
46
65
  function isObj(v) {
47
66
  return v !== null && typeof v === "object" && !Array.isArray(v);
@@ -162,8 +181,7 @@ function checkHttpAction(a, propNames) {
162
181
  return err(`http action.url is not a valid URL: ${a.url}`);
163
182
  }
164
183
  if (u.protocol !== "https:") return err("http action.url must be https (public web only)");
165
- const host = u.hostname.toLowerCase();
166
- if (PRIVATE_HOST_RE.test(host)) return err(`http action.url host "${host}" is not public (localhost/private ranges are forbidden)`);
184
+ if (!isPublicHost(u.hostname)) return err(`http action.url host "${u.hostname}" is not public (localhost/private ranges are forbidden)`);
167
185
  if (u.port !== "" && u.port !== "443") return err("http action.url may only use the default https port");
168
186
 
169
187
  let query = {};
@@ -180,7 +198,9 @@ function checkHttpAction(a, propNames) {
180
198
  const headers = a.headers !== undefined ? (isObj(a.headers) ? a.headers : err("http action.headers must be an object")) : {};
181
199
  if (isObj(a.headers)) {
182
200
  for (const [k, v] of Object.entries(a.headers)) {
201
+ if (typeof k !== "string" || /[\r\n]/.test(k)) return err(`http header key is invalid: ${JSON.stringify(k)}`);
183
202
  if (typeof v !== "string" || v.length > 500) return err(`http header "${k}" must be a short string`);
203
+ if (/[\r\n\u0000]/.test(v)) return err(`http header "${k}" value contains forbidden control characters`);
184
204
  }
185
205
  }
186
206
  let body;
@@ -229,10 +249,12 @@ function checkReadFileAction(a) {
229
249
  for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`read-file action: unknown key "${k}"`);
230
250
  if (typeof a.path !== "string" || a.path.length === 0 || a.path.length > 500) return err("read-file action.path must be a string");
231
251
  if (path.isAbsolute(a.path) || a.path.startsWith("~")) return err("read-file action.path must be relative to the project root");
232
- const norm = path.normalize(a.path);
233
- if (norm === ".." || norm.startsWith(`..${path.sep}`) || norm.split(path.sep).includes("..")) {
252
+ // Both separator styles: manifests are validated cross-platform, and a
253
+ // backslash path is traversal on Windows.
254
+ if (a.path.split(/[\\/]+/).includes("..")) {
234
255
  return err("read-file action.path may not traverse above the project root");
235
256
  }
257
+ const norm = path.normalize(a.path);
236
258
  const mb = a.max_bytes !== undefined
237
259
  ? typeof a.max_bytes === "number" && Number.isInteger(a.max_bytes) && a.max_bytes >= 1 && a.max_bytes <= MAX_READ_BYTES
238
260
  ? a.max_bytes
@@ -323,8 +345,9 @@ export function validateManifest(obj, allowedCommands = ALLOWED_COMMANDS) {
323
345
  return { ok: true, manifest: { name: name.value, version: obj.version, description: desc.value, tools } };
324
346
  }
325
347
 
326
- // Default allowlist; overridable per load call (tests / future config).
327
- const ALLOWED_COMMANDS = ["roforge"];
348
+ // Default allowlist; overridable per load call (config / tests).
349
+ export const DEFAULT_ALLOWED_COMMANDS = ["roforge"];
350
+ const ALLOWED_COMMANDS = DEFAULT_ALLOWED_COMMANDS;
328
351
 
329
352
  // ---- execution ----
330
353
 
@@ -340,15 +363,63 @@ export function pluginToolExecutor(plugin, tool) {
340
363
  args = isObj(args) ? args : {};
341
364
  try {
342
365
  if (a.kind === "http") {
343
- const url = new URL(a.url);
344
- for (const [k, tmpl] of Object.entries(a.query)) url.searchParams.set(k, fillTemplate(tmpl, args));
345
- const res = await fetch(url.toString(), {
346
- method: a.method,
347
- headers: a.headers,
348
- body: a.body !== undefined ? fillTemplate(a.body, args) : undefined,
349
- signal: AbortSignal.timeout(a.timeoutMs),
350
- });
351
- const text = await res.text();
366
+ // Manual redirects: every hop must re-pass the public-host guard
367
+ // (a 302 to a private address is an SSRF and gets refused).
368
+ const start = new URL(a.url);
369
+ for (const [k, tmpl] of Object.entries(a.query)) start.searchParams.set(k, fillTemplate(tmpl, args));
370
+ let res = null;
371
+ let cur = start;
372
+ for (let hop = 0; hop <= 3; hop++) {
373
+ res = await fetch(cur.toString(), {
374
+ method: hop === 0 ? a.method : "GET",
375
+ headers: a.headers,
376
+ body: hop === 0 && a.body !== undefined ? fillTemplate(a.body, args) : undefined,
377
+ redirect: "manual",
378
+ signal: AbortSignal.timeout(a.timeoutMs),
379
+ });
380
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
381
+ const loc = res.headers.get("location");
382
+ if (!loc) return "ERROR: redirect without Location";
383
+ let next;
384
+ try {
385
+ next = new URL(loc, cur);
386
+ } catch {
387
+ return "ERROR: invalid redirect Location";
388
+ }
389
+ if (next.protocol !== "https:" || !isPublicHost(next.hostname) || (next.port !== "" && next.port !== "443")) {
390
+ return "ERROR: redirect to a non-public https target is refused (SSRF guard)";
391
+ }
392
+ cur = next;
393
+ continue;
394
+ }
395
+ break;
396
+ }
397
+ if (!res) return "ERROR: too many redirects";
398
+ // Cap the response body (a hostile public host cannot stream gigabytes
399
+ // into memory): read up to 1 MiB, stop, discard the rest.
400
+ const MAX_BODY = 1024 * 1024;
401
+ const reader = res.body ? res.body.getReader() : null;
402
+ let text;
403
+ if (reader) {
404
+ const parts = [];
405
+ let total = 0;
406
+ for (;;) {
407
+ const { done, value } = await reader.read();
408
+ if (done) break;
409
+ const room = MAX_BODY - total;
410
+ if (value.length > room) {
411
+ parts.push(value.subarray(0, room));
412
+ total += room;
413
+ try { await reader.cancel(); } catch { /* ignore */ }
414
+ break;
415
+ }
416
+ parts.push(value);
417
+ total += value.length;
418
+ }
419
+ text = Buffer.concat(parts).toString("utf8");
420
+ } else {
421
+ text = await res.text();
422
+ }
352
423
  if (!res.ok) return `ERROR: HTTP ${res.status} ${res.statusText} from ${a.url}`;
353
424
  return capText(text, a.outputMax, "plugin output");
354
425
  }
@@ -362,10 +433,17 @@ export function pluginToolExecutor(plugin, tool) {
362
433
  return capText(out, a.outputMax, "plugin output");
363
434
  }
364
435
  if (a.kind === "read-file") {
365
- const root = process.cwd();
366
- const full = path.resolve(root, a.path);
436
+ const root = fs.realpathSync(process.cwd());
437
+ let full;
438
+ try {
439
+ full = fs.realpathSync(path.resolve(root, a.path));
440
+ } catch {
441
+ return `ERROR: file not found: ${a.path}`;
442
+ }
443
+ // realpath follows symlinks — a link pointing outside the project is
444
+ // rejected here, not at parse time.
367
445
  if (full !== root && !full.startsWith(root + path.sep)) return "ERROR: path escapes the project root";
368
- if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return `ERROR: file not found: ${a.path}`;
446
+ if (!fs.statSync(full).isFile()) return `ERROR: not a file: ${a.path}`;
369
447
  const st = fs.statSync(full);
370
448
  if (st.size > a.maxBytes) return `ERROR: file too large (${st.size} > ${a.maxBytes} bytes)`;
371
449
  return capText(fs.readFileSync(full, "utf8"), a.outputMax, "file output");
@@ -7,7 +7,7 @@ import { robloxTools } from "./roblox.js";
7
7
  import { projectTools } from "./project.js";
8
8
  import { bridgeTools, mcpToolsFromList, mcpCaptureNames } from "./studio.js";
9
9
  import { McpClient } from "../mcp.js";
10
- import { loadPlugins } from "../plugins.js";
10
+ import { loadPlugins, DEFAULT_ALLOWED_COMMANDS } from "../plugins.js";
11
11
  import { configDir } from "../config.js";
12
12
 
13
13
  export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
@@ -27,10 +27,15 @@ export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
27
27
  if (bridgeServer) add(bridgeTools(bridgeServer));
28
28
 
29
29
  // Strict declarative plugins (JSON only — no code fields, ever).
30
+ const pcfg = (cfg && cfg.plugins) || {};
31
+ const allowedCommands =
32
+ Array.isArray(pcfg.allowedCommands) && pcfg.allowedCommands.length
33
+ ? pcfg.allowedCommands.filter((c) => typeof c === "string" && /^[a-z0-9._-]{1,32}$/i.test(c))
34
+ : DEFAULT_ALLOWED_COMMANDS;
30
35
  const pluginInfo =
31
- cfg && cfg.plugins && cfg.plugins.enabled === false
36
+ pcfg.enabled === false
32
37
  ? { tools: [], plugins: [], pluginErrors: [] }
33
- : loadPlugins({ cwd, configDirBase: configDir() });
38
+ : loadPlugins({ cwd, configDirBase: configDir(), allowedCommands });
34
39
  add(pluginInfo.tools);
35
40
 
36
41
  // MCP tier (official, built into Studio)