@directive-run/sandbox 0.3.1 → 0.3.2

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/worker.js CHANGED
@@ -1,2 +1,4 @@
1
- import{performance as g}from"perf_hooks";import{parentPort as p}from"worker_threads";var w=new Set(["http:","https:"]);function f(t){let r=t.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);if(!r)return!1;let e=Number.parseInt(r[1],10),n=Number.parseInt(r[2],10),o=Number.parseInt(r[3],10),s=Number.parseInt(r[4],10);return e>255||n>255||o>255||s>255?!1:e===0||e===127||e===10||e===172&&n>=16&&n<=31||e===192&&n===168||e===169&&n===254||e===100&&n>=64&&n<=127||e>=224&&e<=239||e>=240}function b(t){let r=t.replace(/^\[|\]$/g,"").toLowerCase();if(r==="::1"||r==="::"||r.startsWith("fe80:")||r.startsWith("fe8")||r.startsWith("fc")||r.startsWith("fd"))return!0;let e=r.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);if(e&&f(e[1]))return!0;let n=r.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);if(n){let o=Number.parseInt(n[1],16),s=Number.parseInt(n[2],16);if(Number.isFinite(o)&&Number.isFinite(s)){let i=o>>8&255,l=o&255,c=s>>8&255,a=s&255;if(f(`${i}.${l}.${c}.${a}`))return!0}}return!1}function m(t){let r=t.toLowerCase();return!!(r==="localhost"||r==="localhost.localdomain"||r.endsWith(".local")||r.endsWith(".internal"))}function y(t){let r;try{r=new URL(t)}catch{return{allow:!1,reason:`not a valid URL: "${t}"`}}if(!w.has(r.protocol))return{allow:!1,reason:`protocol "${r.protocol}" is not allowed (only http:, https:)`};let e=r.hostname;return e?m(e)?{allow:!1,reason:`hostname "${e}" resolves to localhost or internal-only`}:f(e)?{allow:!1,reason:`IPv4 ${e} is in a private / loopback / link-local range`}:b(e)?{allow:!1,reason:`IPv6 ${e} is in a private / loopback / link-local range`}:{allow:!0}:{allow:!1,reason:"URL has no hostname"}}function h(){let t=globalThis.fetch??(async()=>{throw new Error("fetch is not available in this Node version")}),r=async(e,n)=>{let o=typeof e=="string"?e:e instanceof URL?e.toString():e.url,s=y(o);if(!s.allow)throw new Error(`sandbox: outbound fetch blocked \u2014 ${s.reason??"denied"}`);return t(e,n)};globalThis.fetch=r}if(!p)throw new Error("@directive-run/sandbox/worker must be loaded via worker_threads");h();var d=p;function k(t){if(typeof t=="string")return t;if(t==null)return String(t);if(typeof t=="object"){let r=t;if(r.$store&&typeof r.$store.toObject=="function")try{return JSON.stringify(r.$store.toObject())}catch{}if(typeof r.$snapshot=="function")try{return JSON.stringify(r.$snapshot())}catch{}}try{return JSON.stringify(t)}catch{return String(t)}}function v(t,r){let e={log:console.log,info:console.info,warn:console.warn,error:console.error,debug:console.debug},n=(o,s)=>{let i=s.map(l=>k(l)).join(" ");t.push(o?`${o} ${i}`:i)};return console.log=(...o)=>n(r.log,o),console.info=(...o)=>n(r.log,o),console.warn=(...o)=>n(r.warn,o),console.error=(...o)=>n(r.error,o),console.debug=(...o)=>n(r.log,o),()=>{console.log=e.log,console.info=e.info,console.warn=e.warn,console.error=e.error,console.debug=e.debug}}async function $(t){let r=[],e=[],n=v(r,{log:"[log]",warn:"[warn]",error:"[error]"});globalThis.__directiveSandbox_system__=null;let o=g.now();try{await import(t.bundlePath)}catch(a){e.push(a.message??String(a))}finally{n()}let s=g.now()-o,i={},l={},c=globalThis.__directiveSandbox_system__;if(c){try{i=c.facts.$store.toObject()}catch(a){e.push(`facts snapshot failed: ${a.message}`)}if(t.derivationKeys.length>0&&c.derive)for(let a of t.derivationKeys)try{let u=c.derive[a];u!==void 0&&(l[a]=u)}catch(u){e.push(`derivation "${a}" snapshot failed: ${u.message}`)}try{c.destroy()}catch{}globalThis.__directiveSandbox_system__=null}return{logs:r,facts:i,derived:l,errors:e,durationMs:s,timedOut:!1}}d.once("message",async t=>{try{let e={ok:!0,result:await $(t)};d.postMessage(e)}catch(r){let e={ok:!1,error:r.stack??r.message??String(r)};d.postMessage(e)}});
1
+ import{performance as w}from"perf_hooks";import{parentPort as b}from"worker_threads";var m=new Set(["http:","https:"]);function h(t){let e=t.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);if(!e)return!1;let r=Number.parseInt(e[1],10),n=Number.parseInt(e[2],10),o=Number.parseInt(e[3],10),s=Number.parseInt(e[4],10);return r>255||n>255||o>255||s>255?!1:r===0||r===127||r===10||r===172&&n>=16&&n<=31||r===192&&n===168||r===169&&n===254||r===100&&n>=64&&n<=127||r>=224&&r<=239||r>=240}function y(t){let e=t.replace(/^\[|\]$/g,"").toLowerCase();if(e==="::1"||e==="::"||e.startsWith("fe80:")||e.startsWith("fe8")||e.startsWith("fc")||e.startsWith("fd"))return!0;let r=e.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);if(r&&h(r[1]))return!0;let n=e.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);if(n){let o=Number.parseInt(n[1],16),s=Number.parseInt(n[2],16);if(Number.isFinite(o)&&Number.isFinite(s)){let c=o>>8&255,u=o&255,l=s>>8&255,i=s&255;if(h(`${c}.${u}.${l}.${i}`))return!0}}return!1}function k(t){let e=t.toLowerCase();return!!(e==="localhost"||e==="localhost.localdomain"||e.endsWith(".local")||e.endsWith(".internal"))}function $(t){let e;try{e=new URL(t)}catch{return{allow:!1,reason:`not a valid URL: "${t}"`}}if(!m.has(e.protocol))return{allow:!1,reason:`protocol "${e.protocol}" is not allowed (only http:, https:)`};let r=e.hostname;return r?k(r)?{allow:!1,reason:`hostname "${r}" resolves to localhost or internal-only`}:h(r)?{allow:!1,reason:`IPv4 ${r} is in a private / loopback / link-local range`}:y(r)?{allow:!1,reason:`IPv6 ${r} is in a private / loopback / link-local range`}:{allow:!0}:{allow:!1,reason:"URL has no hostname"}}function g(){let t=globalThis.fetch??(async()=>{throw new Error("fetch is not available in this Node version")}),e=async(r,n)=>{let o=typeof r=="string"?r:r instanceof URL?r.toString():r.url,s=$(o);if(!s.allow)throw new Error(`sandbox: outbound fetch blocked \u2014 ${s.reason??"denied"}`);return t(r,n)};globalThis.fetch=e}if(!b)throw new Error("@directive-run/sandbox/worker must be loaded via worker_threads");g();var p=b;function v(t){if(typeof t=="string")return t;if(t==null)return String(t);if(typeof t=="object"){let e=t;if(e.$store&&typeof e.$store.toObject=="function")try{return JSON.stringify(e.$store.toObject())}catch{}if(typeof e.$snapshot=="function")try{return JSON.stringify(e.$snapshot())}catch{}}try{return JSON.stringify(t)}catch{return String(t)}}function S(t,e){let r={log:console.log,info:console.info,warn:console.warn,error:console.error,debug:console.debug},n=(o,s)=>{let c=s.map(u=>v(u)).join(" ");t.push(o?`${o} ${c}`:c)};return console.log=(...o)=>n(e.log,o),console.info=(...o)=>n(e.log,o),console.warn=(...o)=>n(e.warn,o),console.error=(...o)=>n(e.error,o),console.debug=(...o)=>n(e.log,o),()=>{console.log=r.log,console.info=r.info,console.warn=r.warn,console.error=r.error,console.debug=r.debug}}async function _(t){let e=[],r=[],n=S(e,{log:"[log]",warn:"[warn]",error:"[error]"});globalThis.__directiveSandbox_system__=null;let o=w.now();try{await import(t.bundlePath)}catch(i){let a=i,f=[a.message??String(i)];if(a.code&&f.push(`[code: ${a.code}]`),a.stack&&f.push(a.stack),a.cause){let d=a.cause;f.push(`Cause: ${d.message??String(d)}`),d.stack&&f.push(d.stack)}r.push(f.join(`
2
+ `))}finally{n()}let s=w.now()-o,c={},u={},l=globalThis.__directiveSandbox_system__;if(l){try{c=l.facts.$store.toObject()}catch(i){r.push(`facts snapshot failed: ${i.message}
3
+ ${i.stack??""}`)}if(t.derivationKeys.length>0&&l.derive)for(let i of t.derivationKeys)try{let a=l.derive[i];a!==void 0&&(u[i]=a)}catch(a){r.push(`derivation "${i}" snapshot failed: ${a.message}`)}try{l.destroy()}catch{}globalThis.__directiveSandbox_system__=null}return{logs:e,facts:c,derived:u,errors:r,durationMs:s,timedOut:!1}}p.once("message",async t=>{try{let r={ok:!0,result:await _(t)};p.postMessage(r)}catch(e){let r={ok:!1,error:e.stack??e.message??String(e)};p.postMessage(r)}});
2
4
  //# sourceMappingURL=worker.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/worker.ts","../src/fetch-wrapper.ts"],"sourcesContent":["/**\n * Worker-thread entry. Receives a bundled ESM string + timeout, hooks\n * `console.*` to a capture buffer, evaluates the bundle via a data:\n * URL import (Node's native ESM loader handles top-level await for us),\n * then sends the SandboxResult back.\n *\n * The host (`./host.ts`) enforces the wall-clock budget by calling\n * `worker.terminate()` on overrun — there's no cooperative cancellation\n * from inside the worker. This keeps the worker code simple and means\n * even hostile snippets that try to outlast the budget get hard-killed.\n *\n * Why a data: URL import rather than `vm.runInContext`? `vm` doesn't\n * link ESM imports, so a snippet that imports `@directive-run/core`\n * fails at parse time. Data URL imports go through Node's real ESM\n * loader, which DOES link external imports — `@directive-run/core` is\n * already installed in the parent's node_modules, so the worker finds\n * it via standard resolution. The cost: data URL imports happen\n * exactly once per worker (which is what we want — one-shot execution).\n */\n\nimport { performance } from \"node:perf_hooks\";\nimport { parentPort } from \"node:worker_threads\";\nimport { installFetchWrapper } from \"./fetch-wrapper.js\";\nimport type {\n SandboxResult,\n WorkerInputMessage,\n WorkerOutputMessage,\n} from \"./types.js\";\n\nif (!parentPort) {\n throw new Error(\n \"@directive-run/sandbox/worker must be loaded via worker_threads\",\n );\n}\n\n// Phase A audit P0-S2: install the SSRF fetch wrapper BEFORE the\n// bundle is imported so @directive-run/query's / @directive-run/ai's\n// internal fetch calls (which the validator can't see because they\n// live in external module bodies) hit the loopback / private-IP /\n// IMDS blocklist.\ninstallFetchWrapper();\n\nconst port = parentPort;\n\n/**\n * Format a single console.log argument. Directive's `system.facts` is\n * a Proxy over a `FactsStore`; the audit (P0-DM1) found that\n * `JSON.stringify(system.facts)` produces `\"{}\"` (or store internals)\n * because the proxy has no `toJSON`. So `console.log(\"[start] facts:\",\n * system.facts)` rendered as `[start] facts: {}` in the transcript\n * while `result.facts` correctly showed the snapshot — two\n * contradictory views in the same response.\n *\n * Fix: detect Directive's facts/derive proxies via the `$store` or\n * `$snapshot` escape hatches the proxy DOES expose, snapshot via the\n * store's `toObject()` (the same call result.facts uses), and JSON-\n * stringify the snapshot. Falls back to JSON.stringify for any value\n * that doesn't look like a Directive proxy.\n */\nfunction serializeArg(arg: unknown): string {\n if (typeof arg === \"string\") {\n return arg;\n }\n if (arg === null || arg === undefined) {\n return String(arg);\n }\n // Directive facts/derive proxy fingerprints. `.$store` is the\n // internal FactsStore; `.$snapshot` is the alternate accessor.\n // Either returns a plain object when called.\n if (typeof arg === \"object\") {\n const obj = arg as {\n $store?: { toObject?: () => Record<string, unknown> };\n $snapshot?: () => Record<string, unknown>;\n };\n if (obj.$store && typeof obj.$store.toObject === \"function\") {\n try {\n return JSON.stringify(obj.$store.toObject());\n } catch {\n // fall through\n }\n }\n if (typeof obj.$snapshot === \"function\") {\n try {\n return JSON.stringify(obj.$snapshot());\n } catch {\n // fall through\n }\n }\n }\n try {\n return JSON.stringify(arg);\n } catch {\n return String(arg);\n }\n}\n\n/**\n * Replace console.{log,info,warn,error,debug} with capture functions\n * that push to a shared buffer. The original references are restored\n * in `finally` so this doesn't leak across hot-reloads in tests.\n */\nfunction captureConsole(\n buffer: string[],\n prefix: { log: string; warn: string; error: string },\n): () => void {\n const originals = {\n log: console.log,\n info: console.info,\n warn: console.warn,\n error: console.error,\n debug: console.debug,\n };\n const format = (label: string, args: unknown[]) => {\n const text = args.map((a) => serializeArg(a)).join(\" \");\n buffer.push(label ? `${label} ${text}` : text);\n };\n console.log = (...a: unknown[]) => format(prefix.log, a);\n console.info = (...a: unknown[]) => format(prefix.log, a);\n console.warn = (...a: unknown[]) => format(prefix.warn, a);\n console.error = (...a: unknown[]) => format(prefix.error, a);\n console.debug = (...a: unknown[]) => format(prefix.log, a);\n\n return () => {\n console.log = originals.log;\n console.info = originals.info;\n console.warn = originals.warn;\n console.error = originals.error;\n console.debug = originals.debug;\n };\n}\n\n/**\n * Look for a system on globalThis the snippet attached. The runner\n * scaffold emits `const system = createSystem({...}); system.start();`\n * at top level, but inside an ESM module those bindings are scoped to\n * the module's own scope — we can't read them from the worker.\n *\n * Workaround: the worker patches `createSystem` to also stash the\n * returned system on `globalThis.__directiveSandbox_system__`. After\n * the bundle finishes, we read facts from there.\n *\n * This monkey-patch happens via a tiny ESM module we prepend to the\n * bundle before evaluation (see `wrapForExecution`).\n */\ninterface SandboxSystem {\n facts: { $store: { toObject(): Record<string, unknown> } };\n derive: Record<string, unknown>;\n destroy(): void;\n}\n\ndeclare global {\n // eslint-disable-next-line no-var\n var __directiveSandbox_system__: SandboxSystem | null;\n}\n\nasync function runOne(message: WorkerInputMessage): Promise<SandboxResult> {\n const logs: string[] = [];\n const errors: string[] = [];\n const restoreConsole = captureConsole(logs, {\n log: \"[log]\",\n warn: \"[warn]\",\n error: \"[error]\",\n });\n globalThis.__directiveSandbox_system__ = null;\n const startMs = performance.now();\n try {\n // Import the bundle via file:// URL. Node's ESM loader resolves\n // `@directive-run/core` against node_modules relative to the\n // file path's anchor. The bundler appends a trailing assignment\n // to the entry module that lifts the runner's `system` binding\n // onto `globalThis.__directiveSandbox_system__` so we can read\n // it from here after execution.\n await import(message.bundlePath);\n } catch (err) {\n errors.push((err as Error).message ?? String(err));\n } finally {\n restoreConsole();\n }\n const durationMs = performance.now() - startMs;\n\n let facts: Record<string, unknown> = {};\n const derived: Record<string, unknown> = {};\n // Cast through unknown because TS narrows `globalThis.X` to `null`\n // after the earlier assignment, even though the bundle's epilogue\n // may have populated it before we read here.\n const system = globalThis.__directiveSandbox_system__ as SandboxSystem | null;\n if (system) {\n try {\n facts = system.facts.$store.toObject();\n } catch (err) {\n errors.push(`facts snapshot failed: ${(err as Error).message}`);\n }\n // Phase A audit P0-DM2: snapshot derivations. The host pre-extracts\n // the key names from the source files (system.derive's proxy has no\n // ownKeys trap) and passes them via message.derivationKeys; we read\n // each via `system.derive[key]`, swallowing per-key errors so one\n // bad derivation doesn't kill the whole snapshot.\n if (message.derivationKeys.length > 0 && system.derive) {\n for (const key of message.derivationKeys) {\n try {\n const value = (system.derive as Record<string, unknown>)[key];\n if (value !== undefined) {\n derived[key] = value;\n }\n } catch (err) {\n errors.push(\n `derivation \"${key}\" snapshot failed: ${(err as Error).message}`,\n );\n }\n }\n }\n try {\n system.destroy();\n } catch {\n // best-effort\n }\n globalThis.__directiveSandbox_system__ = null;\n }\n\n return {\n logs,\n facts,\n derived,\n errors,\n durationMs,\n timedOut: false,\n };\n}\n\nport.once(\"message\", async (msg: WorkerInputMessage) => {\n try {\n const result = await runOne(msg);\n const out: WorkerOutputMessage = { ok: true, result };\n port.postMessage(out);\n } catch (err) {\n const out: WorkerOutputMessage = {\n ok: false,\n error: (err as Error).stack ?? (err as Error).message ?? String(err),\n };\n port.postMessage(out);\n }\n});\n","/**\n * Outbound fetch wrapper installed in the worker BEFORE the user's\n * bundle imports anything. The validator already blocks `fetch` as a\n * free identifier AND `globalThis.fetch` property access, so the\n * user's snippet can't call fetch directly. But `@directive-run/query`\n * and `@directive-run/ai` are in the allowlist and their package code\n * calls global `fetch` internally — the validator never sees those\n * calls because they live in external module bodies.\n *\n * Phase A audit P0-S2: a snippet that builds a `createBaseQuery({\n * baseUrl: \"http://169.254.169.254\" })` triggers an outbound HTTP\n * request from the worker to AWS IMDS. On the directive.run/api/run-\n * sandbox Vercel surface, that's SSRF from a privileged egress.\n *\n * This wrapper rejects:\n *\n * - Loopback (127.0.0.0/8, [::1], localhost)\n * - Link-local (169.254.0.0/16 — includes IMDS at .169.254)\n * - RFC-1918 private (10.x, 172.16-31.x, 192.168.x)\n * - Multicast / broadcast / reserved ranges\n * - Non-HTTP(S) protocols (file://, ftp://, gopher://, data://, blob://)\n * - URLs without a hostname (relative paths, javascript:, etc.)\n *\n * Allows:\n *\n * - Any public IPv4 / IPv6 address\n * - Any hostname that resolves to public (resolution happens at request\n * time; we don't pre-resolve because Node's fetch doesn't expose the\n * resolved IP before sending. The host header is parsed for the\n * surface-level check; DNS rebinding is theoretical here because\n * external attackers can't control internal DNS, but documented as\n * a known gap.)\n */\n\nconst ALLOWED_PROTOCOLS = new Set([\"http:\", \"https:\"]);\n\nfunction isPrivateIPv4(host: string): boolean {\n // Match IPv4 dotted-quad. We don't accept octal/hex forms — Node's\n // URL parser rejects those for hostnames anyway.\n const m = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/);\n if (!m) return false;\n const a = Number.parseInt(m[1]!, 10);\n const b = Number.parseInt(m[2]!, 10);\n const c = Number.parseInt(m[3]!, 10);\n const d = Number.parseInt(m[4]!, 10);\n if (a > 255 || b > 255 || c > 255 || d > 255) {\n // Malformed — let it through; Node's fetch will reject the URL.\n return false;\n }\n // 0.0.0.0/8 — \"this network\", treated as unrouted/private here.\n if (a === 0) return true;\n // 127.0.0.0/8 — loopback.\n if (a === 127) return true;\n // 10.0.0.0/8 — RFC-1918 Class A.\n if (a === 10) return true;\n // 172.16.0.0/12 — RFC-1918 Class B.\n if (a === 172 && b >= 16 && b <= 31) return true;\n // 192.168.0.0/16 — RFC-1918 Class C.\n if (a === 192 && b === 168) return true;\n // 169.254.0.0/16 — link-local, includes AWS/GCP/Azure IMDS at\n // 169.254.169.254. THIS is the SSRF target we care most about.\n if (a === 169 && b === 254) return true;\n // 100.64.0.0/10 — carrier-grade NAT, not internet-routable.\n if (a === 100 && b >= 64 && b <= 127) return true;\n // 224.0.0.0/4 — multicast.\n if (a >= 224 && a <= 239) return true;\n // 240.0.0.0/4 — reserved / experimental.\n if (a >= 240) return true;\n return false;\n}\n\nfunction isPrivateIPv6(host: string): boolean {\n // Strip brackets the URL parser leaves in place.\n const stripped = host.replace(/^\\[|\\]$/g, \"\").toLowerCase();\n // Loopback.\n if (stripped === \"::1\") return true;\n // Unspecified / \"any\".\n if (stripped === \"::\") return true;\n // Link-local fe80::/10\n if (stripped.startsWith(\"fe80:\") || stripped.startsWith(\"fe8\")) return true;\n // Unique local fc00::/7 (fc and fd prefixes).\n if (stripped.startsWith(\"fc\") || stripped.startsWith(\"fd\")) return true;\n // IPv4-mapped IPv6 (::ffff:169.254.169.254) — extract the v4 tail\n // and re-check. Node's URL parser may normalize the literal-IPv4\n // form to its hex equivalent (`::ffff:a9fe:a9fe`), so handle both.\n const mappedV4 = stripped.match(/::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/);\n if (mappedV4 && isPrivateIPv4(mappedV4[1]!)) {\n return true;\n }\n const mappedHex = stripped.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);\n if (mappedHex) {\n const hi = Number.parseInt(mappedHex[1]!, 16);\n const lo = Number.parseInt(mappedHex[2]!, 16);\n if (Number.isFinite(hi) && Number.isFinite(lo)) {\n const a = (hi >> 8) & 0xff;\n const b = hi & 0xff;\n const c = (lo >> 8) & 0xff;\n const d = lo & 0xff;\n if (isPrivateIPv4(`${a}.${b}.${c}.${d}`)) {\n return true;\n }\n }\n }\n return false;\n}\n\nfunction isLocalHostname(host: string): boolean {\n const lower = host.toLowerCase();\n if (lower === \"localhost\") return true;\n if (lower === \"localhost.localdomain\") return true;\n if (lower.endsWith(\".local\")) return true;\n if (lower.endsWith(\".internal\")) return true;\n return false;\n}\n\ninterface UrlCheck {\n allow: boolean;\n reason?: string;\n}\n\nexport function checkSandboxFetchUrl(rawUrl: string): UrlCheck {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return { allow: false, reason: `not a valid URL: \"${rawUrl}\"` };\n }\n if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {\n return {\n allow: false,\n reason: `protocol \"${parsed.protocol}\" is not allowed (only http:, https:)`,\n };\n }\n const host = parsed.hostname;\n if (!host) {\n return { allow: false, reason: \"URL has no hostname\" };\n }\n if (isLocalHostname(host)) {\n return {\n allow: false,\n reason: `hostname \"${host}\" resolves to localhost or internal-only`,\n };\n }\n if (isPrivateIPv4(host)) {\n return {\n allow: false,\n reason: `IPv4 ${host} is in a private / loopback / link-local range`,\n };\n }\n if (isPrivateIPv6(host)) {\n return {\n allow: false,\n reason: `IPv6 ${host} is in a private / loopback / link-local range`,\n };\n }\n return { allow: true };\n}\n\n/**\n * Install the wrapper on `globalThis.fetch`. Called from the worker\n * BEFORE the bundle is imported so any `fetch` calls inside the user's\n * code or inside allowlisted packages (`@directive-run/query`,\n * `@directive-run/ai`) hit the wrapper.\n *\n * Safe to call when `fetch` is undefined (older Node); just installs\n * the rejection wrapper.\n */\nexport function installFetchWrapper(): void {\n const original =\n (globalThis as { fetch?: typeof fetch }).fetch ??\n ((async () => {\n throw new Error(\"fetch is not available in this Node version\");\n }) as typeof fetch);\n\n const wrapped: typeof fetch = async (\n input: RequestInfo | URL,\n init?: RequestInit,\n ): Promise<Response> => {\n const rawUrl =\n typeof input === \"string\"\n ? input\n : input instanceof URL\n ? input.toString()\n : (input as Request).url;\n const decision = checkSandboxFetchUrl(rawUrl);\n if (!decision.allow) {\n throw new Error(\n `sandbox: outbound fetch blocked — ${decision.reason ?? \"denied\"}`,\n );\n }\n return original(input, init);\n };\n\n (globalThis as { fetch: typeof fetch }).fetch = wrapped;\n}\n"],"mappings":"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","names":["performance","parentPort","ALLOWED_PROTOCOLS","isPrivateIPv4","host","m","a","b","c","d","isPrivateIPv6","stripped","mappedV4","mappedHex","hi","lo","isLocalHostname","lower","checkSandboxFetchUrl","rawUrl","parsed","installFetchWrapper","original","wrapped","input","init","decision","parentPort","installFetchWrapper","port","serializeArg","arg","obj","captureConsole","buffer","prefix","originals","format","label","args","text","a","runOne","message","logs","errors","restoreConsole","startMs","performance","err","durationMs","facts","derived","system","key","value","msg","out"]}
1
+ {"version":3,"sources":["../src/worker.ts","../src/fetch-wrapper.ts"],"sourcesContent":["/**\n * Worker-thread entry. Receives a bundled ESM string + timeout, hooks\n * `console.*` to a capture buffer, evaluates the bundle via a data:\n * URL import (Node's native ESM loader handles top-level await for us),\n * then sends the SandboxResult back.\n *\n * The host (`./host.ts`) enforces the wall-clock budget by calling\n * `worker.terminate()` on overrun — there's no cooperative cancellation\n * from inside the worker. This keeps the worker code simple and means\n * even hostile snippets that try to outlast the budget get hard-killed.\n *\n * Why a data: URL import rather than `vm.runInContext`? `vm` doesn't\n * link ESM imports, so a snippet that imports `@directive-run/core`\n * fails at parse time. Data URL imports go through Node's real ESM\n * loader, which DOES link external imports — `@directive-run/core` is\n * already installed in the parent's node_modules, so the worker finds\n * it via standard resolution. The cost: data URL imports happen\n * exactly once per worker (which is what we want — one-shot execution).\n */\n\nimport { performance } from \"node:perf_hooks\";\nimport { parentPort } from \"node:worker_threads\";\nimport { installFetchWrapper } from \"./fetch-wrapper.js\";\nimport type {\n SandboxResult,\n WorkerInputMessage,\n WorkerOutputMessage,\n} from \"./types.js\";\n\nif (!parentPort) {\n throw new Error(\n \"@directive-run/sandbox/worker must be loaded via worker_threads\",\n );\n}\n\n// Phase A audit P0-S2: install the SSRF fetch wrapper BEFORE the\n// bundle is imported so @directive-run/query's / @directive-run/ai's\n// internal fetch calls (which the validator can't see because they\n// live in external module bodies) hit the loopback / private-IP /\n// IMDS blocklist.\ninstallFetchWrapper();\n\nconst port = parentPort;\n\n/**\n * Format a single console.log argument. Directive's `system.facts` is\n * a Proxy over a `FactsStore`; the audit (P0-DM1) found that\n * `JSON.stringify(system.facts)` produces `\"{}\"` (or store internals)\n * because the proxy has no `toJSON`. So `console.log(\"[start] facts:\",\n * system.facts)` rendered as `[start] facts: {}` in the transcript\n * while `result.facts` correctly showed the snapshot — two\n * contradictory views in the same response.\n *\n * Fix: detect Directive's facts/derive proxies via the `$store` or\n * `$snapshot` escape hatches the proxy DOES expose, snapshot via the\n * store's `toObject()` (the same call result.facts uses), and JSON-\n * stringify the snapshot. Falls back to JSON.stringify for any value\n * that doesn't look like a Directive proxy.\n */\nfunction serializeArg(arg: unknown): string {\n if (typeof arg === \"string\") {\n return arg;\n }\n if (arg === null || arg === undefined) {\n return String(arg);\n }\n // Directive facts/derive proxy fingerprints. `.$store` is the\n // internal FactsStore; `.$snapshot` is the alternate accessor.\n // Either returns a plain object when called.\n if (typeof arg === \"object\") {\n const obj = arg as {\n $store?: { toObject?: () => Record<string, unknown> };\n $snapshot?: () => Record<string, unknown>;\n };\n if (obj.$store && typeof obj.$store.toObject === \"function\") {\n try {\n return JSON.stringify(obj.$store.toObject());\n } catch {\n // fall through\n }\n }\n if (typeof obj.$snapshot === \"function\") {\n try {\n return JSON.stringify(obj.$snapshot());\n } catch {\n // fall through\n }\n }\n }\n try {\n return JSON.stringify(arg);\n } catch {\n return String(arg);\n }\n}\n\n/**\n * Replace console.{log,info,warn,error,debug} with capture functions\n * that push to a shared buffer. The original references are restored\n * in `finally` so this doesn't leak across hot-reloads in tests.\n */\nfunction captureConsole(\n buffer: string[],\n prefix: { log: string; warn: string; error: string },\n): () => void {\n const originals = {\n log: console.log,\n info: console.info,\n warn: console.warn,\n error: console.error,\n debug: console.debug,\n };\n const format = (label: string, args: unknown[]) => {\n const text = args.map((a) => serializeArg(a)).join(\" \");\n buffer.push(label ? `${label} ${text}` : text);\n };\n console.log = (...a: unknown[]) => format(prefix.log, a);\n console.info = (...a: unknown[]) => format(prefix.log, a);\n console.warn = (...a: unknown[]) => format(prefix.warn, a);\n console.error = (...a: unknown[]) => format(prefix.error, a);\n console.debug = (...a: unknown[]) => format(prefix.log, a);\n\n return () => {\n console.log = originals.log;\n console.info = originals.info;\n console.warn = originals.warn;\n console.error = originals.error;\n console.debug = originals.debug;\n };\n}\n\n/**\n * Look for a system on globalThis the snippet attached. The runner\n * scaffold emits `const system = createSystem({...}); system.start();`\n * at top level, but inside an ESM module those bindings are scoped to\n * the module's own scope — we can't read them from the worker.\n *\n * Workaround: the worker patches `createSystem` to also stash the\n * returned system on `globalThis.__directiveSandbox_system__`. After\n * the bundle finishes, we read facts from there.\n *\n * This monkey-patch happens via a tiny ESM module we prepend to the\n * bundle before evaluation (see `wrapForExecution`).\n */\ninterface SandboxSystem {\n facts: { $store: { toObject(): Record<string, unknown> } };\n derive: Record<string, unknown>;\n destroy(): void;\n}\n\ndeclare global {\n // eslint-disable-next-line no-var\n var __directiveSandbox_system__: SandboxSystem | null;\n}\n\nasync function runOne(message: WorkerInputMessage): Promise<SandboxResult> {\n const logs: string[] = [];\n const errors: string[] = [];\n const restoreConsole = captureConsole(logs, {\n log: \"[log]\",\n warn: \"[warn]\",\n error: \"[error]\",\n });\n globalThis.__directiveSandbox_system__ = null;\n const startMs = performance.now();\n try {\n // Import the bundle via file:// URL. Node's ESM loader resolves\n // `@directive-run/core` against node_modules relative to the\n // file path's anchor. The bundler appends a trailing assignment\n // to the entry module that lifts the runner's `system` binding\n // onto `globalThis.__directiveSandbox_system__` so we can read\n // it from here after execution.\n await import(message.bundlePath);\n } catch (err) {\n const e = err as Error & { code?: string; cause?: unknown };\n const parts = [e.message ?? String(err)];\n if (e.code) {\n parts.push(`[code: ${e.code}]`);\n }\n if (e.stack) {\n parts.push(e.stack);\n }\n if (e.cause) {\n const cause = e.cause as Error;\n parts.push(`Cause: ${cause.message ?? String(cause)}`);\n if (cause.stack) {\n parts.push(cause.stack);\n }\n }\n errors.push(parts.join(\"\\n\"));\n } finally {\n restoreConsole();\n }\n const durationMs = performance.now() - startMs;\n\n let facts: Record<string, unknown> = {};\n const derived: Record<string, unknown> = {};\n // Cast through unknown because TS narrows `globalThis.X` to `null`\n // after the earlier assignment, even though the bundle's epilogue\n // may have populated it before we read here.\n const system = globalThis.__directiveSandbox_system__ as SandboxSystem | null;\n if (system) {\n try {\n facts = system.facts.$store.toObject();\n } catch (err) {\n errors.push(\n `facts snapshot failed: ${(err as Error).message}\\n${(err as Error).stack ?? \"\"}`,\n );\n }\n // Phase A audit P0-DM2: snapshot derivations. The host pre-extracts\n // the key names from the source files (system.derive's proxy has no\n // ownKeys trap) and passes them via message.derivationKeys; we read\n // each via `system.derive[key]`, swallowing per-key errors so one\n // bad derivation doesn't kill the whole snapshot.\n if (message.derivationKeys.length > 0 && system.derive) {\n for (const key of message.derivationKeys) {\n try {\n const value = (system.derive as Record<string, unknown>)[key];\n if (value !== undefined) {\n derived[key] = value;\n }\n } catch (err) {\n errors.push(\n `derivation \"${key}\" snapshot failed: ${(err as Error).message}`,\n );\n }\n }\n }\n try {\n system.destroy();\n } catch {\n // best-effort\n }\n globalThis.__directiveSandbox_system__ = null;\n }\n\n return {\n logs,\n facts,\n derived,\n errors,\n durationMs,\n timedOut: false,\n };\n}\n\nport.once(\"message\", async (msg: WorkerInputMessage) => {\n try {\n const result = await runOne(msg);\n const out: WorkerOutputMessage = { ok: true, result };\n port.postMessage(out);\n } catch (err) {\n const out: WorkerOutputMessage = {\n ok: false,\n error: (err as Error).stack ?? (err as Error).message ?? String(err),\n };\n port.postMessage(out);\n }\n});\n","/**\n * Outbound fetch wrapper installed in the worker BEFORE the user's\n * bundle imports anything. The validator already blocks `fetch` as a\n * free identifier AND `globalThis.fetch` property access, so the\n * user's snippet can't call fetch directly. But `@directive-run/query`\n * and `@directive-run/ai` are in the allowlist and their package code\n * calls global `fetch` internally — the validator never sees those\n * calls because they live in external module bodies.\n *\n * Phase A audit P0-S2: a snippet that builds a `createBaseQuery({\n * baseUrl: \"http://169.254.169.254\" })` triggers an outbound HTTP\n * request from the worker to AWS IMDS. On the directive.run/api/run-\n * sandbox Vercel surface, that's SSRF from a privileged egress.\n *\n * This wrapper rejects:\n *\n * - Loopback (127.0.0.0/8, [::1], localhost)\n * - Link-local (169.254.0.0/16 — includes IMDS at .169.254)\n * - RFC-1918 private (10.x, 172.16-31.x, 192.168.x)\n * - Multicast / broadcast / reserved ranges\n * - Non-HTTP(S) protocols (file://, ftp://, gopher://, data://, blob://)\n * - URLs without a hostname (relative paths, javascript:, etc.)\n *\n * Allows:\n *\n * - Any public IPv4 / IPv6 address\n * - Any hostname that resolves to public (resolution happens at request\n * time; we don't pre-resolve because Node's fetch doesn't expose the\n * resolved IP before sending. The host header is parsed for the\n * surface-level check; DNS rebinding is theoretical here because\n * external attackers can't control internal DNS, but documented as\n * a known gap.)\n */\n\nconst ALLOWED_PROTOCOLS = new Set([\"http:\", \"https:\"]);\n\nfunction isPrivateIPv4(host: string): boolean {\n // Match IPv4 dotted-quad. We don't accept octal/hex forms — Node's\n // URL parser rejects those for hostnames anyway.\n const m = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/);\n if (!m) return false;\n const a = Number.parseInt(m[1]!, 10);\n const b = Number.parseInt(m[2]!, 10);\n const c = Number.parseInt(m[3]!, 10);\n const d = Number.parseInt(m[4]!, 10);\n if (a > 255 || b > 255 || c > 255 || d > 255) {\n // Malformed — let it through; Node's fetch will reject the URL.\n return false;\n }\n // 0.0.0.0/8 — \"this network\", treated as unrouted/private here.\n if (a === 0) return true;\n // 127.0.0.0/8 — loopback.\n if (a === 127) return true;\n // 10.0.0.0/8 — RFC-1918 Class A.\n if (a === 10) return true;\n // 172.16.0.0/12 — RFC-1918 Class B.\n if (a === 172 && b >= 16 && b <= 31) return true;\n // 192.168.0.0/16 — RFC-1918 Class C.\n if (a === 192 && b === 168) return true;\n // 169.254.0.0/16 — link-local, includes AWS/GCP/Azure IMDS at\n // 169.254.169.254. THIS is the SSRF target we care most about.\n if (a === 169 && b === 254) return true;\n // 100.64.0.0/10 — carrier-grade NAT, not internet-routable.\n if (a === 100 && b >= 64 && b <= 127) return true;\n // 224.0.0.0/4 — multicast.\n if (a >= 224 && a <= 239) return true;\n // 240.0.0.0/4 — reserved / experimental.\n if (a >= 240) return true;\n return false;\n}\n\nfunction isPrivateIPv6(host: string): boolean {\n // Strip brackets the URL parser leaves in place.\n const stripped = host.replace(/^\\[|\\]$/g, \"\").toLowerCase();\n // Loopback.\n if (stripped === \"::1\") return true;\n // Unspecified / \"any\".\n if (stripped === \"::\") return true;\n // Link-local fe80::/10\n if (stripped.startsWith(\"fe80:\") || stripped.startsWith(\"fe8\")) return true;\n // Unique local fc00::/7 (fc and fd prefixes).\n if (stripped.startsWith(\"fc\") || stripped.startsWith(\"fd\")) return true;\n // IPv4-mapped IPv6 (::ffff:169.254.169.254) — extract the v4 tail\n // and re-check. Node's URL parser may normalize the literal-IPv4\n // form to its hex equivalent (`::ffff:a9fe:a9fe`), so handle both.\n const mappedV4 = stripped.match(/::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/);\n if (mappedV4 && isPrivateIPv4(mappedV4[1]!)) {\n return true;\n }\n const mappedHex = stripped.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);\n if (mappedHex) {\n const hi = Number.parseInt(mappedHex[1]!, 16);\n const lo = Number.parseInt(mappedHex[2]!, 16);\n if (Number.isFinite(hi) && Number.isFinite(lo)) {\n const a = (hi >> 8) & 0xff;\n const b = hi & 0xff;\n const c = (lo >> 8) & 0xff;\n const d = lo & 0xff;\n if (isPrivateIPv4(`${a}.${b}.${c}.${d}`)) {\n return true;\n }\n }\n }\n return false;\n}\n\nfunction isLocalHostname(host: string): boolean {\n const lower = host.toLowerCase();\n if (lower === \"localhost\") return true;\n if (lower === \"localhost.localdomain\") return true;\n if (lower.endsWith(\".local\")) return true;\n if (lower.endsWith(\".internal\")) return true;\n return false;\n}\n\ninterface UrlCheck {\n allow: boolean;\n reason?: string;\n}\n\nexport function checkSandboxFetchUrl(rawUrl: string): UrlCheck {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return { allow: false, reason: `not a valid URL: \"${rawUrl}\"` };\n }\n if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {\n return {\n allow: false,\n reason: `protocol \"${parsed.protocol}\" is not allowed (only http:, https:)`,\n };\n }\n const host = parsed.hostname;\n if (!host) {\n return { allow: false, reason: \"URL has no hostname\" };\n }\n if (isLocalHostname(host)) {\n return {\n allow: false,\n reason: `hostname \"${host}\" resolves to localhost or internal-only`,\n };\n }\n if (isPrivateIPv4(host)) {\n return {\n allow: false,\n reason: `IPv4 ${host} is in a private / loopback / link-local range`,\n };\n }\n if (isPrivateIPv6(host)) {\n return {\n allow: false,\n reason: `IPv6 ${host} is in a private / loopback / link-local range`,\n };\n }\n return { allow: true };\n}\n\n/**\n * Install the wrapper on `globalThis.fetch`. Called from the worker\n * BEFORE the bundle is imported so any `fetch` calls inside the user's\n * code or inside allowlisted packages (`@directive-run/query`,\n * `@directive-run/ai`) hit the wrapper.\n *\n * Safe to call when `fetch` is undefined (older Node); just installs\n * the rejection wrapper.\n */\nexport function installFetchWrapper(): void {\n const original =\n (globalThis as { fetch?: typeof fetch }).fetch ??\n ((async () => {\n throw new Error(\"fetch is not available in this Node version\");\n }) as typeof fetch);\n\n const wrapped: typeof fetch = async (\n input: RequestInfo | URL,\n init?: RequestInit,\n ): Promise<Response> => {\n const rawUrl =\n typeof input === \"string\"\n ? input\n : input instanceof URL\n ? input.toString()\n : (input as Request).url;\n const decision = checkSandboxFetchUrl(rawUrl);\n if (!decision.allow) {\n throw new Error(\n `sandbox: outbound fetch blocked — ${decision.reason ?? \"denied\"}`,\n );\n }\n return original(input, init);\n };\n\n (globalThis as { fetch: typeof fetch }).fetch = wrapped;\n}\n"],"mappings":"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","names":["performance","parentPort","ALLOWED_PROTOCOLS","isPrivateIPv4","host","m","a","b","c","d","isPrivateIPv6","stripped","mappedV4","mappedHex","hi","lo","isLocalHostname","lower","checkSandboxFetchUrl","rawUrl","parsed","installFetchWrapper","original","wrapped","input","init","decision","parentPort","installFetchWrapper","port","serializeArg","arg","obj","captureConsole","buffer","prefix","originals","format","label","args","text","a","runOne","message","logs","errors","restoreConsole","startMs","performance","err","e","parts","cause","durationMs","facts","derived","system","key","value","msg","out"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directive-run/sandbox",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Execute Directive snippets server-side and return a structured transcript (logs + facts + errors). Consumed by @directive-run/mcp's run_in_sandbox tool and directive.run/playground's live DevTools panel. Uses worker_threads + esbuild bundling + an AST allowlist validator so user-supplied TypeScript runs with a bounded surface (allowlisted imports, allowlisted API calls, 5s wall clock, 32 MB heap).",
5
5
  "license": "(MIT OR Apache-2.0)",
6
6
  "author": "Jason Comes",
@@ -47,7 +47,7 @@
47
47
  "dist"
48
48
  ],
49
49
  "dependencies": {
50
- "@directive-run/core": "1.17.2"
50
+ "@directive-run/core": "1.18.0"
51
51
  },
52
52
  "optionalDependencies": {
53
53
  "esbuild": "^0.24.0",