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.
- package/dist/RoForgeBridge.rbxm +0 -0
- package/package.json +1 -1
- package/src/bridge/server.js +8 -1
- package/src/config.js +14 -3
- package/src/plugins.js +97 -19
- package/src/tools/index.js +8 -3
package/dist/RoForgeBridge.rbxm
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/bridge/server.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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 (
|
|
327
|
-
const
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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.
|
|
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");
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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)
|