@ijfw/memory-server 1.3.0

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.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,416 @@
1
+ /**
2
+ * runner.js -- Subprocess sandbox for the IJFW compute lever.
3
+ *
4
+ * Public API:
5
+ * runCompute({ language, script, projectRoot, timeoutMs, allowNet, vmOnly,
6
+ * allowedPaths, sessionId })
7
+ * -> { stdout, stderr, exitCode, signal, durationMs, timedOut, truncated,
8
+ * logPath, sandbox: { kind, available, degraded } }
9
+ *
10
+ * Behavior summary (V3-B1, V3-B2, V3-B3, V3-F6):
11
+ * - language must be 'js' or 'python'.
12
+ * - vmOnly + language='js' -> delegate to runner-vm.js (vm.Script path).
13
+ * vmOnly + language='python' -> throw VmOnlyJsError.
14
+ * - Per-invocation temp dir created; subprocess cwd points at it (NOT the
15
+ * project root by default -- temp dir keeps writes contained even if the
16
+ * OS-level wrapper is unavailable).
17
+ * - Env scrubbed to allowlist: PATH, HOME, IJFW_*, NODE_*, LANG, LC_*, TZ.
18
+ * - spawn(detached=true) so we can SIGKILL the entire process group on
19
+ * timeout (no orphaned children).
20
+ * - Hard timeout: timeoutMs param > IJFW_COMPUTE_TIMEOUT_MS env > 30_000ms.
21
+ * Hard cap: 300_000ms.
22
+ * - Output cap: 100 MB (truncated to caller; full preserved on disk).
23
+ * - Network defaults DENY. allowNet=true -> opt-in (set by IJFW_COMPUTE_NET=1
24
+ * at the caller layer).
25
+ * - Allowlist filesystem path-prefix check + OS sandbox wrapper:
26
+ * OS wrapper does the real enforcement. The path-prefix check is only a
27
+ * warning surface for callers that pass extra allowedPaths.
28
+ *
29
+ * All user-facing strings use "operation" / "task" / "compute" -- never "AI" // copy-lint:allow
30
+ * (Sean's no-AI-in-user-copy rule). // copy-lint:allow
31
+ *
32
+ * Zero external deps.
33
+ */
34
+
35
+ import { spawn } from 'child_process';
36
+ import {
37
+ existsSync, mkdirSync, writeFileSync, appendFileSync,
38
+ rmSync,
39
+ } from 'fs';
40
+ import { join, isAbsolute, resolve, sep } from 'path';
41
+ import { homedir, tmpdir } from 'os';
42
+ import { randomBytes } from 'crypto';
43
+
44
+ import { detectSandbox } from './sandbox-detect.js';
45
+ import { runVm, VmOnlyJsError } from './runner-vm.js';
46
+ import { resolvePython } from './python-resolver.js';
47
+
48
+ // --- Constants ----------------------------------------------------------
49
+ const HARD_CAP_MS = 300_000;
50
+ const DEFAULT_MS = 30_000;
51
+ const MAX_OUTPUT_BYTES = 100 * 1024 * 1024; // 100 MB
52
+ const MAX_RETURNED_BYTES = 1 * 1024 * 1024; // 1 MB returned to caller
53
+ const ENV_ALLOW_PREFIXES = ['IJFW_', 'NODE_'];
54
+ const ENV_ALLOW_KEYS = new Set([
55
+ 'PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP',
56
+ 'LANG', 'LC_ALL', 'LC_CTYPE', 'TZ',
57
+ 'PYTHONUNBUFFERED', 'PYTHONDONTWRITEBYTECODE',
58
+ ]);
59
+
60
+ // --- Errors -------------------------------------------------------------
61
+ export class ComputeLanguageError extends Error {
62
+ constructor(language) {
63
+ super(`[ijfw compute] Unsupported language "${language}". Use 'js' or 'python'.`);
64
+ this.name = 'ComputeLanguageError';
65
+ this.code = 'COMPUTE_BAD_LANGUAGE';
66
+ }
67
+ }
68
+ export { VmOnlyJsError };
69
+
70
+ // --- Helpers ------------------------------------------------------------
71
+ function clampTimeout(t) {
72
+ const fromEnv = Number(process.env.IJFW_COMPUTE_TIMEOUT_MS || 0);
73
+ let ms = Number(t || 0) || fromEnv || DEFAULT_MS;
74
+ if (!Number.isFinite(ms) || ms <= 0) ms = DEFAULT_MS;
75
+ if (ms > HARD_CAP_MS) ms = HARD_CAP_MS;
76
+ return Math.floor(ms);
77
+ }
78
+
79
+ // M2: env vars to ALWAYS drop even if they match the allow prefix list. These
80
+ // expose code-loading or debugging surfaces an untrusted compute child must
81
+ // not inherit (e.g. NODE_OPTIONS=--require could load a host module).
82
+ const ENV_DENY_KEYS = new Set([
83
+ 'NODE_OPTIONS',
84
+ 'NODE_DEBUG',
85
+ 'NODE_PATH',
86
+ 'NODE_REPL_HISTORY',
87
+ ]);
88
+
89
+ function scrubEnv() {
90
+ const out = Object.create(null);
91
+ for (const [k, v] of Object.entries(process.env)) {
92
+ if (typeof v !== 'string') continue;
93
+ if (ENV_DENY_KEYS.has(k)) continue; // M2: explicit drop list
94
+ if (ENV_ALLOW_KEYS.has(k)) { out[k] = v; continue; }
95
+ if (ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) { out[k] = v; continue; }
96
+ }
97
+ // Always mark as a sandboxed compute run so children can branch behavior.
98
+ out.IJFW_COMPUTE_SANDBOXED = '1';
99
+ return out;
100
+ }
101
+
102
+ function newSessionId() {
103
+ return randomBytes(8).toString('hex');
104
+ }
105
+
106
+ function newRunId() {
107
+ return Date.now().toString(36) + '-' + randomBytes(4).toString('hex');
108
+ }
109
+
110
+ // H1: log-root resolution with a fallback chain so an unwritable HOME does
111
+ // not hard-fail the runner. Order:
112
+ // 1) IJFW_COMPUTE_LOG_ROOT (explicit override)
113
+ // 2) ~/.ijfw/run/<sessionId>/compute
114
+ // 3) <projectRoot>/.ijfw/run/<sessionId>/compute
115
+ // 4) <os.tmpdir>/ijfw-run/<sessionId>/compute
116
+ // Returns { dir, ok } where ok=false signals "log to in-memory buffer only".
117
+ function resolveLogDir(sessionId, projectRoot) {
118
+ const candidates = [];
119
+ if (process.env.IJFW_COMPUTE_LOG_ROOT) {
120
+ candidates.push(join(process.env.IJFW_COMPUTE_LOG_ROOT, sessionId, 'compute'));
121
+ }
122
+ candidates.push(join(process.env.HOME || homedir(), '.ijfw', 'run', sessionId, 'compute'));
123
+ if (projectRoot) {
124
+ candidates.push(join(projectRoot, '.ijfw', 'run', sessionId, 'compute'));
125
+ }
126
+ candidates.push(join(tmpdir(), 'ijfw-run', sessionId, 'compute'));
127
+
128
+ for (const dir of candidates) {
129
+ try {
130
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ // Probe write permission with an O_CREAT touch.
132
+ const probe = join(dir, '.write-probe');
133
+ writeFileSync(probe, '', { mode: 0o600 });
134
+ try { rmSync(probe, { force: true }); } catch { /* nothing */ }
135
+ return { dir, ok: true };
136
+ } catch { /* try next */ }
137
+ }
138
+ return { dir: null, ok: false };
139
+ }
140
+
141
+ // Path-prefix check: warn the caller if they passed an allowedPaths entry
142
+ // that escapes the project root or cwd. The OS wrapper does real enforcement;
143
+ // this is purely a developer-experience surface.
144
+ function validateAllowedPaths({ projectRoot, cwd, allowedPaths }) {
145
+ const warnings = [];
146
+ for (const p of allowedPaths || []) {
147
+ if (!isAbsolute(p)) {
148
+ warnings.push(`allowedPaths entry "${p}" is not absolute -- ignoring.`);
149
+ continue;
150
+ }
151
+ const norm = resolve(p);
152
+ const inProject = projectRoot && norm.startsWith(projectRoot + sep);
153
+ const inCwd = cwd && norm.startsWith(cwd + sep);
154
+ if (!inProject && !inCwd && norm !== projectRoot && norm !== cwd) {
155
+ warnings.push(
156
+ `allowedPaths entry "${norm}" is outside cwd and projectRoot. ` +
157
+ 'OS sandbox enforcement may still block; this is a best-effort warning.'
158
+ );
159
+ }
160
+ }
161
+ return warnings;
162
+ }
163
+
164
+ function makeTempDir() {
165
+ const root = tmpdir();
166
+ const name = 'ijfw-compute-' + newRunId();
167
+ const dir = join(root, name);
168
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
169
+ return dir;
170
+ }
171
+
172
+ // Truncate buffer to at most `cap` bytes, preserving valid UTF-8 boundary.
173
+ function truncateUtf8(buf, cap) {
174
+ if (buf.length <= cap) return { out: buf, truncated: false };
175
+ // Step back if we landed in the middle of a multi-byte UTF-8 sequence.
176
+ let end = cap;
177
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
178
+ return { out: buf.subarray(0, end), truncated: true };
179
+ }
180
+
181
+ // --- Public API ---------------------------------------------------------
182
+
183
+ /**
184
+ * runCompute(opts) -> Promise<result>
185
+ *
186
+ * Required: language, script.
187
+ * Optional: projectRoot (defaults cwd), timeoutMs, allowNet (default false),
188
+ * vmOnly (default false), allowedPaths, sessionId.
189
+ */
190
+ export async function runCompute(opts = {}) {
191
+ const language = (opts.language || '').toLowerCase();
192
+ if (language !== 'js' && language !== 'python') {
193
+ throw new ComputeLanguageError(opts.language);
194
+ }
195
+ const script = String(opts.script || '');
196
+ if (!script) throw new TypeError('runCompute: `script` is required.');
197
+
198
+ const projectRoot = resolve(opts.projectRoot || process.cwd());
199
+ const timeoutMs = clampTimeout(opts.timeoutMs);
200
+ const allowNet = !!opts.allowNet;
201
+ const vmOnly = !!opts.vmOnly;
202
+ const allowedPaths = (opts.allowedPaths || []).map((p) => resolve(p));
203
+ const sessionId = String(opts.sessionId || newSessionId());
204
+
205
+ // vm.Script JS-only path -- short-circuit before spawn.
206
+ if (vmOnly) {
207
+ if (language !== 'js') {
208
+ throw new VmOnlyJsError(
209
+ '[ijfw compute] vmOnly mode supports JavaScript only. ' +
210
+ `Got "${language}". For Python, run without vmOnly to use the subprocess sandbox.`
211
+ );
212
+ }
213
+ const r = runVm({ script, timeoutMs, projectRoot });
214
+ return {
215
+ stdout: r.stdout,
216
+ stderr: r.stderr,
217
+ exitCode: 0,
218
+ signal: null,
219
+ durationMs: r.durationMs,
220
+ timedOut: false,
221
+ truncated: false,
222
+ logPath: null,
223
+ sandbox: { kind: 'vm.Script', available: true, degraded: false },
224
+ };
225
+ }
226
+
227
+ // --- Subprocess path ---------------------------------------------------
228
+ const tempDir = makeTempDir();
229
+ const runId = newRunId();
230
+ // H1: never hard-fail on log dir creation. Walk the fallback chain; if
231
+ // every candidate is unwritable, fall back to an in-memory log buffer.
232
+ const logResolution = resolveLogDir(sessionId, projectRoot);
233
+ let logPath = null;
234
+ let logBuffer = null; // in-memory fallback if disk write fails
235
+ if (logResolution.ok && logResolution.dir) {
236
+ logPath = join(logResolution.dir, `${runId}.log`);
237
+ try {
238
+ writeFileSync(logPath, '', { mode: 0o600 });
239
+ } catch {
240
+ // Last-resort: degrade to memory.
241
+ logPath = null;
242
+ logBuffer = [];
243
+ // Silent on success per IJFW rule -- but a degraded path warrants stderr.
244
+ try { process.stderr.write('[ijfw compute] log path unavailable; using in-memory buffer.\n'); } catch { /* nothing */ }
245
+ }
246
+ } else {
247
+ logBuffer = [];
248
+ try { process.stderr.write('[ijfw compute] no writable log directory; using in-memory buffer.\n'); } catch { /* nothing */ }
249
+ }
250
+ // Wrapper so the rest of the runner can append regardless of mode.
251
+ const writeLog = (chunk) => {
252
+ if (logPath) {
253
+ try { appendFileSync(logPath, chunk); } catch { /* disk full -- skip */ }
254
+ } else if (logBuffer) {
255
+ try { logBuffer.push(typeof chunk === 'string' ? chunk : String(chunk)); } catch { /* nothing */ }
256
+ }
257
+ };
258
+
259
+ // Resolve interpreter for the chosen language.
260
+ let baseCmd;
261
+ let baseArgs;
262
+ let scriptPath;
263
+ if (language === 'js') {
264
+ // Run via process.execPath so we always use the same Node we're running on.
265
+ scriptPath = join(tempDir, 'script.js');
266
+ writeFileSync(scriptPath, script, { mode: 0o600 });
267
+ baseCmd = process.execPath;
268
+ baseArgs = ['--no-warnings', scriptPath];
269
+ } else {
270
+ const py = resolvePython(projectRoot); // throws PythonNotFoundError on miss
271
+ scriptPath = join(tempDir, 'script.py');
272
+ writeFileSync(scriptPath, script, { mode: 0o600 });
273
+ baseCmd = py.interpreter;
274
+ baseArgs = ['-I', '-B', scriptPath]; // -I isolated mode, -B no .pyc
275
+ }
276
+
277
+ const env = scrubEnv();
278
+ // Best-effort warnings for caller-supplied allowedPaths.
279
+ const pathWarnings = validateAllowedPaths({ projectRoot, cwd: tempDir, allowedPaths });
280
+ for (const w of pathWarnings) {
281
+ writeLog(`[allowlist-warning] ${w}\n`);
282
+ }
283
+
284
+ // Apply OS-level sandbox.
285
+ const detect = await detectSandbox();
286
+ let cmd = baseCmd;
287
+ let args = baseArgs;
288
+ let degraded = !detect.available;
289
+ let profilePath = null;
290
+
291
+ if (detect.wrapper) {
292
+ const wrapped = detect.wrapper.wrap({
293
+ cmd: baseCmd,
294
+ args: baseArgs,
295
+ env,
296
+ cwd: tempDir,
297
+ allowNet,
298
+ allowedPaths,
299
+ projectRoot,
300
+ tempDir,
301
+ kind: detect.kind,
302
+ });
303
+ cmd = wrapped.cmd;
304
+ args = wrapped.args;
305
+ if (wrapped.profilePath) profilePath = wrapped.profilePath;
306
+ if (wrapped.degraded) degraded = true;
307
+ }
308
+
309
+ if (degraded) {
310
+ writeLog(
311
+ `[ijfw compute] Sandbox is best-effort on this host -- subprocess runs ` +
312
+ `with scrubbed env + path-prefix check only. See sandbox-detect.js.\n`
313
+ );
314
+ }
315
+
316
+ // Spawn -- detached:true so we own the process group for SIGKILL on timeout.
317
+ const start = Date.now();
318
+ let timedOut = false;
319
+ let totalBytes = 0;
320
+ let truncated = false;
321
+ const returnedChunks = [];
322
+
323
+ return new Promise((resolveP) => {
324
+ const child = spawn(cmd, args, {
325
+ cwd: tempDir,
326
+ env,
327
+ detached: true,
328
+ stdio: ['ignore', 'pipe', 'pipe'],
329
+ windowsHide: true,
330
+ shell: false,
331
+ });
332
+
333
+ const stderrChunks = [];
334
+
335
+ const onData = (chunk, isErr) => {
336
+ // Always preserve full output to disk up to MAX_OUTPUT_BYTES.
337
+ writeLog(chunk);
338
+ totalBytes += chunk.length;
339
+ if (totalBytes > MAX_OUTPUT_BYTES) {
340
+ truncated = true;
341
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* group gone */ }
342
+ return;
343
+ }
344
+ if (returnedChunks.reduce((n, c) => n + c.length, 0) < MAX_RETURNED_BYTES) {
345
+ if (isErr) stderrChunks.push(chunk);
346
+ else returnedChunks.push(chunk);
347
+ }
348
+ };
349
+
350
+ child.stdout.on('data', (c) => onData(c, false));
351
+ child.stderr.on('data', (c) => onData(c, true));
352
+
353
+ const timer = setTimeout(() => {
354
+ timedOut = true;
355
+ // Kill the entire process group so children/grandchildren go too.
356
+ try { process.kill(-child.pid, 'SIGKILL'); }
357
+ catch {
358
+ // Group might already be gone or detach may have failed; fall back.
359
+ try { child.kill('SIGKILL'); } catch { /* nothing more to do */ }
360
+ }
361
+ }, timeoutMs);
362
+
363
+ child.on('error', (err) => {
364
+ clearTimeout(timer);
365
+ cleanup();
366
+ resolveP({
367
+ stdout: '',
368
+ stderr: `spawn error: ${err && err.message}`,
369
+ exitCode: 1,
370
+ signal: null,
371
+ durationMs: Date.now() - start,
372
+ timedOut: false,
373
+ truncated: false,
374
+ logPath,
375
+ sandbox: {
376
+ kind: detect.kind,
377
+ available: detect.available,
378
+ degraded,
379
+ },
380
+ });
381
+ });
382
+
383
+ child.on('close', (code, signal) => {
384
+ clearTimeout(timer);
385
+ const stdoutBuf = Buffer.concat(returnedChunks);
386
+ const stderrBuf = Buffer.concat(stderrChunks);
387
+ const stdoutTrunc = truncateUtf8(stdoutBuf, MAX_RETURNED_BYTES);
388
+ const stderrTrunc = truncateUtf8(stderrBuf, MAX_RETURNED_BYTES);
389
+ cleanup();
390
+ resolveP({
391
+ stdout: stdoutTrunc.out.toString('utf8'),
392
+ stderr: stderrTrunc.out.toString('utf8'),
393
+ exitCode: code,
394
+ signal,
395
+ durationMs: Date.now() - start,
396
+ timedOut,
397
+ truncated: truncated || stdoutTrunc.truncated || stderrTrunc.truncated,
398
+ logPath,
399
+ sandbox: {
400
+ kind: detect.kind,
401
+ available: detect.available,
402
+ degraded,
403
+ },
404
+ });
405
+ });
406
+
407
+ function cleanup() {
408
+ try { rmSync(tempDir, { recursive: true, force: true, maxRetries: 2 }); } catch { /* nothing */ }
409
+ // profilePath lives inside tempDir, rm covers it.
410
+ void profilePath;
411
+ }
412
+ });
413
+ }
414
+
415
+ // Re-export for tests.
416
+ export { resolvePython, detectSandbox };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * sandbox-detect.js -- Runtime detection of available OS isolation mechanism.
3
+ *
4
+ * Returns a `wrap()` function from the appropriate platform module, or null
5
+ * with a user-visible warning when no OS-level isolation is available.
6
+ *
7
+ * Honest framing per V3-B1: never claim "no network egress" without an
8
+ * enforceable mechanism. Best-effort caveat is documented when null returned.
9
+ *
10
+ * Zero external dependencies -- Node.js built-ins only.
11
+ */
12
+
13
+ import { execFileSync } from 'child_process';
14
+ import { platform } from 'os';
15
+
16
+ // Cached probe results so repeated invocations don't reshell.
17
+ let _cache = null;
18
+
19
+ // Probe for a binary on PATH without invoking a shell.
20
+ // Bin name is hardcoded at every call site (no user input) -- execFileSync
21
+ // also gives us defense in depth.
22
+ function which(bin) {
23
+ try {
24
+ if (process.platform === 'win32') {
25
+ const out = execFileSync('where', [bin], {
26
+ stdio: ['ignore', 'pipe', 'ignore'],
27
+ windowsHide: true,
28
+ }).toString().trim();
29
+ return out ? out.split(/\r?\n/)[0] : null;
30
+ }
31
+ // POSIX: `command -v` is a builtin so we use /bin/sh -c with the bin
32
+ // name as a literal arg (passed via args, not interpolated into command).
33
+ // execFile semantics ensure no shell-metachar interpretation of `bin`.
34
+ const out = execFileSync('/bin/sh', ['-c', 'command -v "$1" 2>/dev/null || true', '--', bin], {
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ }).toString().trim();
37
+ return out || null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * detectSandbox() -> { wrapper, kind, available } + warning side effect.
45
+ *
46
+ * - kind: 'nsjail' | 'firejail' | 'bwrap' | 'sandbox-exec' | 'appcontainer' | null
47
+ * - wrapper: imported platform module exposing `wrap(...)`, or null on unknown OS
48
+ * - available: boolean (true when OS-level isolation is enforceable)
49
+ *
50
+ * On unsupported platforms, returns { wrapper: null, kind: null, available: false }
51
+ * and emits a console.warn with the documented best-effort caveat.
52
+ */
53
+ export async function detectSandbox() {
54
+ if (_cache) return _cache;
55
+
56
+ const plat = platform();
57
+ let result;
58
+
59
+ if (plat === 'linux') {
60
+ let kind = null;
61
+ if (which('nsjail')) kind = 'nsjail';
62
+ else if (which('firejail')) kind = 'firejail';
63
+ else if (which('bwrap')) kind = 'bwrap';
64
+
65
+ const mod = await import('./sandbox-linux.js');
66
+ if (kind) {
67
+ result = { wrapper: mod, kind, available: true };
68
+ } else {
69
+ console.warn(
70
+ '[ijfw compute] Sandbox: best-effort only on this Linux host -- ' +
71
+ 'install nsjail, firejail, or bwrap for OS-level isolation.'
72
+ );
73
+ result = { wrapper: mod, kind: null, available: false };
74
+ }
75
+ } else if (plat === 'darwin') {
76
+ const mod = await import('./sandbox-macos.js');
77
+ if (which('sandbox-exec')) {
78
+ result = { wrapper: mod, kind: 'sandbox-exec', available: true };
79
+ } else {
80
+ console.warn(
81
+ '[ijfw compute] Sandbox: best-effort only on this Darwin host -- ' +
82
+ 'sandbox-exec not found in PATH (unexpected; macOS ships it by default).'
83
+ );
84
+ result = { wrapper: mod, kind: null, available: false };
85
+ }
86
+ } else if (plat === 'win32') {
87
+ const mod = await import('./sandbox-windows.js');
88
+ if (which('powershell.exe') || which('pwsh.exe')) {
89
+ // available=false because AppContainer enforcement is partial; honest signal.
90
+ console.warn(
91
+ '[ijfw compute] Sandbox: best-effort only on Windows -- ' +
92
+ 'AppContainer wrapper has documented graceful-degrade; subprocess runs without ' +
93
+ 'OS-level network namespace isolation.'
94
+ );
95
+ result = { wrapper: mod, kind: 'appcontainer', available: false };
96
+ } else {
97
+ console.warn(
98
+ '[ijfw compute] Sandbox: best-effort only on this Windows host -- ' +
99
+ 'PowerShell not available; subprocess runs without OS-level isolation.'
100
+ );
101
+ result = { wrapper: mod, kind: null, available: false };
102
+ }
103
+ } else {
104
+ console.warn(
105
+ `[ijfw compute] Sandbox: best-effort only on this platform (${plat}) -- ` +
106
+ 'no OS-level isolation available. Subprocess runs with scrubbed env + ' +
107
+ 'allowlist path-prefix check only.'
108
+ );
109
+ result = { wrapper: null, kind: null, available: false };
110
+ }
111
+
112
+ _cache = result;
113
+ return result;
114
+ }
115
+
116
+ // Test hook: reset cache so probe re-runs (used by adversarial tests).
117
+ export function _resetSandboxDetectCache() {
118
+ _cache = null;
119
+ }
120
+
121
+ // Re-exported for tests + diagnostics.
122
+ export { which as _whichForTest };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * sandbox-linux.js -- Linux nsjail / firejail / bwrap wrapper.
3
+ *
4
+ * Honest caveats:
5
+ * - We do NOT mandate any one tool. The runner picks the first one detected
6
+ * by sandbox-detect.js.
7
+ * - When `kind` is null (no isolation tool installed), we fall back to a
8
+ * transparent passthrough -- subprocess runs without OS-level mount/network
9
+ * namespace. The runner emits the documented best-effort warning.
10
+ *
11
+ * All wrappers do their best to:
12
+ * - Mount cwd + projectRoot + tempDir read/write or read-only as appropriate
13
+ * - Hide the rest of the filesystem
14
+ * - Default-deny network unless `allowNet`
15
+ * - Inherit only the scrubbed env passed in
16
+ *
17
+ * Zero external deps.
18
+ */
19
+
20
+ import { existsSync } from 'fs';
21
+
22
+ /**
23
+ * wrap({ cmd, args, env, cwd, allowNet, allowedPaths, projectRoot, tempDir, kind })
24
+ * -> { cmd, args, env }
25
+ *
26
+ * `kind` is one of 'nsjail' | 'firejail' | 'bwrap' | null.
27
+ * Caller (runner.js) passes the kind value resolved by sandbox-detect.
28
+ */
29
+ export function wrap({ cmd, args, env, cwd, allowNet, allowedPaths, projectRoot, tempDir, kind }) {
30
+ const reads = uniq([projectRoot, cwd, tempDir, ...(allowedPaths || [])]).filter(Boolean);
31
+ const writes = uniq([cwd, tempDir, ...(allowedPaths || [])]).filter(Boolean);
32
+
33
+ if (kind === 'bwrap') {
34
+ return wrapBwrap({ cmd, args, env, cwd, allowNet, reads, writes });
35
+ }
36
+ if (kind === 'firejail') {
37
+ return wrapFirejail({ cmd, args, env, cwd, allowNet, reads, writes });
38
+ }
39
+ if (kind === 'nsjail') {
40
+ return wrapNsjail({ cmd, args, env, cwd, allowNet, reads, writes });
41
+ }
42
+
43
+ // Best-effort fallback: passthrough. Runner has already warned the user.
44
+ return { cmd, args, env };
45
+ }
46
+
47
+ function uniq(arr) {
48
+ return Array.from(new Set(arr));
49
+ }
50
+
51
+ // --- bwrap (bubblewrap) --------------------------------------------------
52
+ // bwrap is the simplest + most portable option (used by Flatpak under the hood).
53
+ function wrapBwrap({ cmd, args, env, cwd, allowNet, reads, writes }) {
54
+ const bwArgs = [
55
+ '--die-with-parent',
56
+ '--unshare-user-try',
57
+ '--unshare-pid',
58
+ '--unshare-ipc',
59
+ '--unshare-uts',
60
+ '--unshare-cgroup-try',
61
+ '--proc', '/proc',
62
+ '--dev', '/dev',
63
+ '--tmpfs', '/tmp',
64
+ ];
65
+
66
+ if (!allowNet) {
67
+ bwArgs.push('--unshare-net');
68
+ } else {
69
+ // Network namespace shared; still hide /etc except resolv.conf.
70
+ bwArgs.push('--ro-bind-try', '/etc/resolv.conf', '/etc/resolv.conf');
71
+ }
72
+
73
+ // Bind read-only system libs needed for any binary to run.
74
+ for (const p of ['/usr', '/lib', '/lib64', '/bin', '/sbin', '/etc/ld.so.cache', '/etc/ld.so.conf', '/etc/ld.so.conf.d']) {
75
+ if (existsSync(p)) bwArgs.push('--ro-bind-try', p, p);
76
+ }
77
+
78
+ // Read-only mounts.
79
+ for (const r of reads) {
80
+ bwArgs.push('--ro-bind-try', r, r);
81
+ }
82
+ // Read-write mounts (override the ro-bind for cwd + tempDir).
83
+ for (const w of writes) {
84
+ bwArgs.push('--bind-try', w, w);
85
+ }
86
+
87
+ bwArgs.push('--chdir', cwd, '--', cmd, ...args);
88
+
89
+ return { cmd: 'bwrap', args: bwArgs, env };
90
+ }
91
+
92
+ // --- firejail ------------------------------------------------------------
93
+ function wrapFirejail({ cmd, args, env, cwd, allowNet, reads, writes }) {
94
+ const fjArgs = [
95
+ '--quiet',
96
+ '--noprofile',
97
+ '--private-tmp',
98
+ '--private-dev',
99
+ '--caps.drop=all',
100
+ '--seccomp',
101
+ '--nonewprivs',
102
+ '--noroot',
103
+ '--shell=none',
104
+ '--blacklist=/home',
105
+ '--blacklist=/root',
106
+ ];
107
+
108
+ if (!allowNet) {
109
+ fjArgs.push('--net=none');
110
+ }
111
+
112
+ for (const r of reads) {
113
+ fjArgs.push(`--read-only=${r}`);
114
+ fjArgs.push(`--whitelist=${r}`);
115
+ }
116
+ for (const w of writes) {
117
+ fjArgs.push(`--read-write=${w}`);
118
+ fjArgs.push(`--whitelist=${w}`);
119
+ }
120
+
121
+ fjArgs.push('--', cmd, ...args);
122
+ return { cmd: 'firejail', args: fjArgs, env: { ...env, FJ_CHDIR: cwd } };
123
+ }
124
+
125
+ // --- nsjail --------------------------------------------------------------
126
+ function wrapNsjail({ cmd, args, env, cwd, allowNet, reads, writes }) {
127
+ const njArgs = [
128
+ '--mode', 'o', // execve mode (one-shot)
129
+ '--quiet',
130
+ '--disable_clone_newuser', // some hosts disable user namespaces
131
+ '--cwd', cwd,
132
+ '--time_limit', '0', // runner enforces timeout itself
133
+ '--rlimit_as', 'soft',
134
+ '--rlimit_cpu', 'soft',
135
+ '--rlimit_nofile', '256',
136
+ '--rlimit_fsize', '512', // 512 MB output file size cap
137
+ '--max_cpus', '2',
138
+ ];
139
+
140
+ if (!allowNet) {
141
+ // nsjail ISOLATES net by default by creating a fresh network namespace.
142
+ // We do NOT pass --disable_clone_newnet here -- that flag would DISABLE
143
+ // creating the new netns and leave the child on the host network.
144
+ // We do disable loopback too so even local sockets are denied.
145
+ njArgs.push('--iface_no_lo'); // no loopback either when net denied
146
+ } else {
147
+ // Share host net namespace -- explicitly disable creation of new netns.
148
+ njArgs.push('--disable_clone_newnet');
149
+ }
150
+
151
+ // Read-only bindmounts for system + project.
152
+ for (const p of ['/usr', '/lib', '/lib64', '/bin', '/sbin', '/etc']) {
153
+ if (existsSync(p)) njArgs.push('--bindmount_ro', `${p}:${p}`);
154
+ }
155
+ for (const r of reads) {
156
+ njArgs.push('--bindmount_ro', `${r}:${r}`);
157
+ }
158
+ for (const w of writes) {
159
+ njArgs.push('--bindmount', `${w}:${w}`);
160
+ }
161
+
162
+ njArgs.push('--', cmd, ...args);
163
+ return { cmd: 'nsjail', args: njArgs, env };
164
+ }