SPB Git

Repository Public

lab.ts 14.4 KB 186 lines typescript

1// V8 hardening — the ONE way the API starts a laboratory process (anything that imports or runs a strategy.py: the spot and2// perp signal runners, describe, the spot and perp backtests, the research evaluation, and the data helpers around them).3//4// Every call is one Python interpreter inside the deny-default macOS sandbox of @spw/core `labSandboxProfile`:5//   network   none (no loopback port, no Unix socket, no DNS) — data arrive on stdin or as files prepared by the API;6//   files     read: the laboratory's code (research/src, research/schema), its virtualenv, the interpreter's install prefix,7//             the ONE strategy directory of the call, the caller's read-only data; write: one scratch directory, fresh per call8//             (or owned by the caller when it needs the files afterwards), removed afterwards. Not the application checkout9//             beyond those research/ paths, not the home directory, not /tmp at large, not the PostgreSQL socket;10//   process   no fork / posix_spawn / exec of anything else: nothing can outlive the call, no helper program can be started;11//             other processes (their arguments, their ENVIRONMENT) are invisible;12//   limits    wall-clock timeout → SIGKILL of the process group; CPU seconds and written-file size by rlimit (set by a13//             /bin/sh wrapper OUTSIDE the sandbox, inherited across exec); resident memory by a watchdog of this process14//             (macOS refuses RLIMIT_AS / RLIMIT_DATA / RLIMIT_RSS: measured, see docs/v8/SANDBOX.md); bounded output.15//   env       built from nothing: no secret, no database URL, HOME and TMPDIR = the scratch directory.16//17// FAIL CLOSED. If sandbox-exec does not exist, the interpreter is missing, or the profile does not apply (start-up self-test,18// repeated after any sandbox error), `labExec` throws `LabUnavailable`: no signal is computed (the engine holds), no backtest19// runs. The only way around is the explicit development switch SPW_LAB_SANDBOX=off (default: on), which is logged loudly and20// reported by `labStatus()` (monitoring screen, alert `system.lab_sandbox_off`).21import { execFile, spawn } from 'node:child_process';22import { existsSync, lstatSync, mkdtempSync, readlinkSync, realpathSync, rmSync } from 'node:fs';23import { tmpdir } from 'node:os';24import path from 'node:path';25import { labSandboxProfile, SANDBOX_EXEC } from '@spw/core';2627// the bundled server runs from the repository root (dist/api.mjs); from source the module sits four levels below it28export const RESEARCH_DIR = path.resolve(process.env.SPW_RESEARCH_DIR ?? [path.join(process.cwd(), 'research'), path.join(import.meta.dirname, '../../../../research')].find((d) => existsSync(path.join(d, 'pyproject.toml'))) ?? path.join(process.cwd(), 'research'));29export const labPython = (): string => process.env.SPW_RESEARCH_PYTHON ?? path.join(RESEARCH_DIR, '.venv/bin/python');3031/** resident-memory ceiling of one signal call (a signal works on at most a few thousand bars) */32export const SIGNAL_MAX_RSS_MB = Number(process.env.SPW_LAB_SIGNAL_MAX_RSS_MB) || 2048;3334export type LabSandboxKind = 'sandbox-exec' | 'none';35export interface LabResult { code: number | null; out: string; err: string; sandbox: LabSandboxKind;36  /** why this process killed the child, if it did */37  killed: 'timeout' | 'memory' | 'output' | null; ms: number }38export class LabUnavailable extends Error {39  code = 'lab_sandbox_unavailable';40  constructor(reason: string) { super(`the strategy sandbox is not available (${reason}); nothing was run`); }41}4243export interface LabStatus {44  /** 'sandbox-exec' = confined; 'off' = the development switch is set (NOT confined); 'unavailable' = nothing runs */45  mode: 'sandbox-exec' | 'off' | 'unavailable';46  ok: boolean; reason: string | null; python: string; checkedAt: number | null;47  /** last sandbox error seen on a real call */48  lastFailureAt: number | null; lastFailure: string | null; calls: number; refused: number;49}50const ESCAPE = (): boolean => process.env.SPW_LAB_SANDBOX === 'off';51const state: { checkedAt: number | null; ok: boolean; reason: string | null; lastFailureAt: number | null; lastFailure: string | null; calls: number; refused: number; warned: boolean; testing: Promise<void> | null } =52  { checkedAt: null, ok: false, reason: 'not checked yet', lastFailureAt: null, lastFailure: null, calls: 0, refused: 0, warned: false, testing: null };53const SELFTEST_EVERY_MS = 10 * 60_000;5455function symlinksOnPath(p: string): string[] {56  const out: string[] = []; let todo = path.resolve(p).split(path.sep).filter(Boolean), cur: string = path.sep, hops = 0;57  while (todo.length) {58    const next = path.join(cur, todo.shift()!);59    let st; try { st = lstatSync(next); } catch { break; }60    if (st.isSymbolicLink() && hops++ < 40) { out.push(next); todo = [...path.resolve(cur, readlinkSync(next)).split(path.sep).filter(Boolean), ...todo]; cur = path.sep; } else cur = next;61  }62  return out;63}64const up = (p: string, n: number): string => { let x = p; for (let i = 0; i < n; i++) x = path.dirname(x); return x; };6566export interface LabOpts {67  timeoutMs: number;68  /** written to the child's stdin (the signal input) */69  stdin?: string;70  /** the strategy version directory this call may read (and nothing else under research/strategies) */71  strategyDir?: string;72  /** extra read-only paths (data prepared by the API) */73  readable?: string[];74  /** a caller-owned scratch directory (canonical path; the caller removes it). Default: a fresh one, removed after the call. */75  scratch?: string;76  /** stdout bound; beyond it the child is killed ('kill', the signal runner) or the rest is dropped ('truncate') */77  maxOut?: number; onOverflow?: 'kill' | 'truncate';78  /** resident-memory ceiling enforced by the watchdog (default SPW_LAB_MAX_RSS_MB or 4096) */79  maxRssMb?: number;80}8182/** paths of the laboratory installation, canonical. Throws when the interpreter is missing. */83function labPaths(): { python: string; real: string; lab: string; readable: string[]; exec: string[]; homebrew: boolean } {84  const python = labPython();85  if (!existsSync(python)) throw new LabUnavailable(`the laboratory's interpreter is missing: ${python} (run \`uv sync --frozen\` in research/)`);86  const real = realpathSync(python), lab = realpathSync(RESEARCH_DIR), links = symlinksOnPath(python);87  const venv = up(path.resolve(python), 2), prefix = up(real, 2);88  const readable = [path.join(lab, 'src'), path.join(lab, 'schema'), existsSync(venv) ? realpathSync(venv) : venv, prefix, ...links].filter((p) => existsSync(p));89  return { python, real, lab, readable, exec: [...new Set([real, path.resolve(python), ...links])], homebrew: /^\/(opt\/homebrew|usr\/local)\//.test(real) };90}91const childEnv = (lab: string, scratch: string): Record<string, string> => ({ PATH: '/usr/bin:/bin', HOME: scratch, TMPDIR: scratch, PYTHONPATH: path.join(lab, 'src'), PYTHONDONTWRITEBYTECODE: '1',92  JOBLIB_MULTIPROCESSING: '0', MPLCONFIGDIR: scratch, LANG: 'en_US.UTF-8', UV_OFFLINE: '1' });9394// rlimits are set by /bin/sh BEFORE sandbox-exec (they survive exec); a limit that cannot be set stops the call (exit 70)95const WRAPPER = 'ulimit -t "$1" || exit 70; ulimit -f "$2" || exit 70; shift 2; exec "$@"';96const SANDBOX_ERR = /^sandbox-exec: /m;9798function rssMb(pid: number): Promise<number | null> {99  return new Promise((resolve) => { execFile('/bin/ps', ['-o', 'rss=', '-p', String(pid)], { timeout: 3000 }, (e, out) => { const kb = Number(String(out).trim()); resolve(e || !Number.isFinite(kb) ? null : kb / 1024); }); });100}101102function run(argv: string[], env: Record<string, string>, cwd: string, o: LabOpts, sandbox: LabSandboxKind): Promise<LabResult> {103  const cpu = Math.max(60, Math.ceil((o.timeoutMs / 1000) * 4)), fileBlocks = 2 * 1024 * 1024; // CPU seconds over all threads; one file ≤ 2 GiB (1024-byte blocks in /bin/sh)104  const maxOut = o.maxOut ?? 2_000_000, maxRss = o.maxRssMb ?? (Number(process.env.SPW_LAB_MAX_RSS_MB) || 4096), t0 = Date.now();105  return new Promise((resolve) => {106    const ch = spawn('/bin/sh', ['-c', WRAPPER, 'spw-lab', String(cpu), String(fileBlocks), ...argv], { cwd, env, stdio: [o.stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], detached: true });107    let out = '', err = '', killed: LabResult['killed'] = null, done = false;108    const kill = (why: NonNullable<LabResult['killed']>): void => { if (done || killed) return; killed = why; try { process.kill(-ch.pid!, 'SIGKILL'); } catch { ch.kill('SIGKILL'); } };109    const timer = setTimeout(() => kill('timeout'), o.timeoutMs);110    const watch = setInterval(() => { if (ch.pid) void rssMb(ch.pid).then((mb) => { if (mb !== null && mb > maxRss) kill('memory'); }); }, 1000);111    const finish = (code: number | null, extra = ''): void => { if (done) return; done = true; clearTimeout(timer); clearInterval(watch); resolve({ code, out, err: err + extra, sandbox, killed, ms: Date.now() - t0 }); };112    ch.stdout!.on('data', (c: Buffer) => { if (out.length < maxOut) out += c; else if ((o.onOverflow ?? 'truncate') === 'kill') kill('output'); });113    ch.stderr!.on('data', (c: Buffer) => { err = (err + c).slice(-4000); });114    ch.on('error', (e) => finish(null, `could not start: ${e.message}`));115    ch.on('close', (code) => finish(code));116    if (o.stdin !== undefined) { ch.stdin!.on('error', () => {}); ch.stdin!.end(o.stdin); }117  });118}119120/** Does the profile really confine? One tiny interpreter run that must (a) start, (b) be refused the home directory, a121 *  network socket and a fork. Anything else = unavailable. */122async function selfTest(): Promise<void> {123  const now = Date.now();124  try {125    if (process.platform !== 'darwin' || !existsSync(SANDBOX_EXEC)) throw new LabUnavailable(`${SANDBOX_EXEC} does not exist on this platform (${process.platform})`);126    const p = labPaths(), scratch = scratchDir();127    try {128      const probe = 'import os,socket,sys\nr=[]\n'129        + 'for n,f in (("home",lambda:os.listdir(os.environ["SPW_PROBE_HOME"])),("net",lambda:socket.create_connection(("127.0.0.1",9),1)),("fork",lambda:os.fork() or os._exit(0)),("write",lambda:open(os.environ["SPW_PROBE_HOME"]+"/.spw-lab-probe","w"))):\n'130        + '    try:\n        f(); r.append(n)\n    except PermissionError:\n        pass\n    except OSError as e:\n        r.append(n+":"+str(e.errno))\nprint("LEAK "+",".join(r) if r else "CONFINED")\n';131      const profile = labSandboxProfile({ scratch, readable: p.readable, exec: p.exec, homebrew: p.homebrew });132      const r = await run([SANDBOX_EXEC, '-p', profile, p.python, '-P', '-s', '-B', '-c', probe], { ...childEnv(p.lab, scratch), SPW_PROBE_HOME: process.env.HOME ?? '/Users' }, scratch, { timeoutMs: 30_000 }, 'sandbox-exec');133      if (r.code !== 0 || r.out.trim() !== 'CONFINED') throw new LabUnavailable(r.out.startsWith('LEAK') ? `the profile does not confine: ${r.out.trim().slice(0, 80)}` : `the profile could not be applied: ${(r.err.trim().split('\n').pop() ?? `exit ${r.code}`).slice(0, 160)}`);134    } finally { dropScratch(scratch); }135    state.ok = true; state.reason = null;136  } catch (e) { state.ok = false; state.reason = (e as Error).message.replace(/^the strategy sandbox is not available \((.*)\); nothing was run$/, '$1').slice(0, 300); }137  state.checkedAt = now;138}139async function ensureChecked(force = false): Promise<void> {140  if (!force && state.checkedAt !== null && Date.now() - state.checkedAt < SELFTEST_EVERY_MS && state.ok) return;141  if (!force && state.checkedAt !== null && !state.ok && Date.now() - state.checkedAt < 30_000) return; // a broken sandbox is re-tested at most every 30 s142  state.testing ??= selfTest().finally(() => { state.testing = null; });143  await state.testing;144}145146/** Health of the confinement (monitoring, alerts, start-up log). `refresh` re-runs the self-test now. */147export async function labStatus(refresh = false): Promise<LabStatus> {148  await ensureChecked(refresh);149  const off = ESCAPE();150  return { mode: off ? 'off' : state.ok ? 'sandbox-exec' : 'unavailable', ok: !off && state.ok, reason: off ? 'SPW_LAB_SANDBOX=off: strategy code runs WITHOUT OS confinement (development only)' : state.reason,151    python: labPython(), checkedAt: state.checkedAt, lastFailureAt: state.lastFailureAt, lastFailure: state.lastFailure, calls: state.calls, refused: state.refused };152}153/** tests: forget the cached self-test */154export const resetLabStatus = (): void => { state.checkedAt = null; state.ok = false; state.reason = 'not checked yet'; state.warned = false; };155156/** Run `python -m <args>` of the laboratory, confined. Throws LabUnavailable (nothing ran) when it cannot be confined. */157export async function labExec(args: string[], o: LabOpts): Promise<LabResult> {158  state.calls += 1;159  const own = o.scratch === undefined, scratch = own ? scratchDir() : realpathSync(o.scratch!);160  try {161    if (ESCAPE()) {162      // explicit development switch: same interpreter, same empty environment, same limits — but NO OS confinement163      if (!state.warned) { state.warned = true; console.warn('[lab] SPW_LAB_SANDBOX=off — strategy code runs WITHOUT the OS sandbox. Development only; never set this on a machine that holds funds.'); }164      const p = labPaths();165      return await run([p.python, '-P', '-s', '-B', '-m', ...args], childEnv(p.lab, scratch), scratch, o, 'none');166    }167    await ensureChecked();168    if (!state.ok) { state.refused += 1; throw new LabUnavailable(state.reason ?? 'unknown'); }169    const p = labPaths();170    const readable = [...p.readable, ...(o.strategyDir ? [realpathSync(o.strategyDir)] : []), ...(o.readable ?? []).map((x) => realpathSync(x))];171    const profile = labSandboxProfile({ scratch, readable, exec: p.exec, homebrew: p.homebrew });172    const r = await run([SANDBOX_EXEC, '-p', profile, p.python, '-P', '-s', '-B', '-m', ...args], childEnv(p.lab, scratch), scratch, o, 'sandbox-exec');173    if ((r.code === 70 && !r.out) || (r.code !== 0 && !r.out && SANDBOX_ERR.test(r.err))) {174      // the wrapper or sandbox-exec itself failed: the interpreter never started confined. Re-test before the next call.175      state.lastFailureAt = Date.now(); state.lastFailure = (r.err.trim().split('\n').pop() ?? `exit ${r.code}`).slice(0, 200); state.refused += 1;176      await ensureChecked(true);177      throw new LabUnavailable(state.lastFailure);178    }179    return r;180  } finally { if (own) dropScratch(scratch); }181}182183export const scratchDir = (): string => realpathSync(mkdtempSync(path.join(tmpdir(), 'spw-lab-')));184export const dropScratch = (dir: string): void => { rmSync(dir, { recursive: true, force: true }); };185export const lastLine = (s: string, max = 300): string => (s.trim().split('\n').pop() ?? '').slice(0, max);186
Files main
Menu

Theme