Repository Public
1// Fixtures of the strategy-sandbox tests (lab-sandbox.test.ts): a laboratory copy whose purity lint is DISABLED — so that what2// stops a hostile strategy is the OS layer and nothing else — and the hostile strategy itself.3//4// The hostile strategy attempts every forbidden action twice: at module import (where the laboratory's runtime guard is not5// active at all) and inside target_weights (where it is — so the probes use the low-level entry points the guard does not6// patch: `posix.open`, `_socket.socket`, `posix.fork`, `ctypes`). Each attempt that SUCCEEDS sets a bit; three positive7// controls (it can write its scratch directory, read its own strategy file, import numpy) set bits 28-30 through the same8// primitives, so "no bit set" can never mean "the probe itself was broken".9// The result leaves the sandbox through the only channels a strategy has: its weights (signals) or its error text (backtests).10import { createHash } from 'node:crypto';11import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';12import { tmpdir } from 'node:os';13import path from 'node:path';14import { REPO_ROOT } from './test-db.ts';1516export const REAL_LAB = path.join(REPO_ROOT, 'research');17export const REAL_PYTHON = path.join(REAL_LAB, '.venv/bin/python');1819/** bit → what a set bit means (a LEAK for 0-27, a working control for 28-30) */20export const PROBES = [21 'read ~/.ssh', 'read ~/.config', 'list the home directory', 'read the application checkout (services/)', 'read a .env-like secret file outside', 'read a canary secret outside',22 'write outside the scratch directory', 'write into the laboratory code', 'write into its own strategy directory', 'TCP to the API port (loopback)', 'TCP to the signer relay port (loopback)',23 'TCP to PostgreSQL (127.0.0.1:5432)', 'TCP to an internet address', 'DNS', 'Unix socket (PostgreSQL / a local service)', 'UDP', 'spawn curl', 'fork', 'posix_spawn /bin/sh',24 'read another process\'s environment (sysctl kern.procargs2)', 'list processes', 'signal another process', 'read the PM2 logs directory', 'read another strategy\'s directory',25 'list /private/tmp', 'listen on a socket', 'read the repository root listing', 'setsid + fork (daemon)',26 'CONTROL write + read the scratch directory', 'CONTROL read its own strategy.py', 'CONTROL import numpy',27] as const;28export const CONTROL_MASK = (1 << 28) | (1 << 29) | (1 << 30);29export const leaks = (mask: number): string[] => PROBES.filter((_, i) => i < 28 && (mask & (1 << i)) !== 0);30export const controlsOk = (mask: number): boolean => (mask & CONTROL_MASK) >>> 0 === CONTROL_MASK >>> 0;3132export interface Targets {33 home: string; repo: string; canary: string; envFile: string; outside: string; labSrc: string; otherStrategy: string;34 apiPort: number; signerPort: number; unixSocket: string; envPid: number; envCanary: string;35 /** only local canaries and listeners owned by the test (used for the UNCONFINED control run: nothing of the owner is touched) */36 safeOnly: boolean;37}3839const probeSource = (t: Targets): string => `40import json as _json41_T = _json.loads(${JSON.stringify(JSON.stringify(t))})424344def _probe():45 import posix, _socket, ctypes46 bits = 04748 def leak(i, f):49 nonlocal bits50 try:51 if f() is not False:52 bits |= 1 << i53 except BaseException:54 pass5556 def read(p):57 fd = posix.open(p, posix.O_RDONLY)58 try:59 return posix.read(fd, 64)60 finally:61 posix.close(fd)6263 def write(p):64 fd = posix.open(p, posix.O_WRONLY | posix.O_CREAT, 0o600)65 try:66 posix.write(fd, b"x")67 finally:68 posix.close(fd)6970 def tcp(host, port, family=_socket.AF_INET):71 s = _socket.socket(family, _socket.SOCK_STREAM)72 try:73 s.settimeout(2.0)74 s.connect((host, port))75 finally:76 s.close()7778 def unix(p):79 s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)80 try:81 s.settimeout(2.0)82 s.connect(p)83 finally:84 s.close()8586 def spawn(argv):87 pid = posix.posix_spawn(argv[0], argv, {})88 posix.waitpid(pid, 0)8990 def fork(daemon=False):91 pid = posix.fork()92 if pid == 0:93 try:94 if daemon:95 posix.setsid()96 write(_T["outside"] + ".daemon")97 finally:98 posix._exit(0)99 posix.waitpid(pid, 0)100101 def procenv(pid):102 libc = ctypes.CDLL(None, use_errno=True)103 mib = (ctypes.c_int * 3)(1, 49, pid)104 size = ctypes.c_size_t(0)105 if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0:106 raise OSError("sysctl")107 buf = ctypes.create_string_buffer(size.value)108 if libc.sysctl(mib, 3, buf, ctypes.byref(size), None, 0) != 0:109 raise OSError("sysctl")110 return _T["envCanary"].encode() in buf.raw[: size.value]111112 def listpids():113 libc = ctypes.CDLL(None, use_errno=True)114 return libc.proc_listallpids(None, 0) > 0115116 def listen():117 s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)118 try:119 s.bind(("127.0.0.1", 0))120 s.listen(1)121 finally:122 s.close()123124 def udp():125 s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)126 try:127 s.sendto(b"x", ("127.0.0.1", _T["apiPort"]) if _T["safeOnly"] else ("1.1.1.1", 53))128 finally:129 s.close()130131 safe = _T["safeOnly"]132 if not safe:133 leak(0, lambda: posix.listdir(_T["home"] + "/.ssh"))134 leak(1, lambda: posix.listdir(_T["home"] + "/.config"))135 leak(2, lambda: posix.listdir(_T["home"]))136 leak(3, lambda: posix.listdir(_T["repo"] + "/services"))137 leak(12, lambda: tcp("1.1.1.1", 443))138 leak(13, lambda: _socket.getaddrinfo("example.com", 443))139 leak(14, lambda: unix("/tmp/.s.PGSQL.5432"))140 leak(11, lambda: tcp("127.0.0.1", 5432))141 leak(16, lambda: spawn(["/usr/bin/curl", "-s", "-m", "3", "http://127.0.0.1:%d/" % _T["apiPort"]]))142 leak(22, lambda: posix.listdir(_T["home"] + "/.pm2/logs"))143 leak(24, lambda: posix.listdir("/private/tmp"))144 leak(26, lambda: posix.listdir(_T["repo"]))145 leak(7, lambda: write(_T["labSrc"] + "/spq/zz_written_by_strategy.py"))146 leak(8, lambda: write(__file__ + ".written"))147 leak(4, lambda: read(_T["envFile"]))148 leak(5, lambda: read(_T["canary"]))149 leak(6, lambda: write(_T["outside"]))150 leak(9, lambda: tcp("127.0.0.1", _T["apiPort"]))151 leak(10, lambda: tcp("127.0.0.1", _T["signerPort"]))152 leak(14, lambda: unix(_T["unixSocket"]))153 leak(15, udp)154 leak(17, fork)155 leak(18, lambda: spawn(["/bin/sh", "-c", "true"]))156 leak(19, lambda: procenv(_T["envPid"]))157 leak(20, listpids)158 leak(21, lambda: posix.kill(_T["envPid"], 0))159 leak(23, lambda: read(_T["otherStrategy"] + "/strategy.py"))160 leak(25, listen)161 leak(27, lambda: fork(True))162163 def control_scratch():164 write("zz_scratch_ok.txt")165 return read("zz_scratch_ok.txt") == b"x"166167 leak(28, control_scratch)168 leak(29, lambda: len(read(__file__)) > 0)169 leak(30, lambda: __import__("numpy").float64(1.0) == 1.0)170 return bits171172173_IMPORT_BITS = _probe()174`;175176export type HostileBehaviour = 'weights' | 'raise' | 'burn' | 'memory' | 'flood' | 'sleep';177/** A strategy.py. `weights`: the two masks leave as the weights of the first two symbols (mask / 2^32 each). `raise`: they178 * leave in the error text. The others exhaust a resource instead of returning. */179export function hostileStrategy(o: { name: string; version: string; kind: 'spot' | 'perp'; universe: string[]; behaviour: HostileBehaviour; targets: Targets; lookback?: number }): string {180 const meta = { name: o.name, version: o.version, ...(o.kind === 'perp' ? { kind: 'perp' } : {}), universe: o.universe, interval: '1h', lookback: o.lookback ?? 50,181 ...(o.kind === 'perp' ? { max_leverage: 2.0, max_gross_exposure: 1.0 } : {}), params: {} };182 const body: Record<HostileBehaviour, string> = {183 weights: ' names = list(data)\n out = {s: 0.0 for s in names}\n out[names[0]] = _IMPORT_BITS / 4294967296.0\n out[names[1]] = _probe() / 4294967296.0\n return out\n',184 raise: ' raise ValueError("PROBE:%x:%x" % (_IMPORT_BITS, _probe()))\n',185 burn: ' while True:\n pass\n',186 memory: ' blocks = []\n while True:\n blocks.append(bytearray(32 * 1024 * 1024))\n',187 flood: ' import sys as _sys\n while True:\n _sys.__stdout__.write("x" * 65536)\n _sys.__stdout__.flush()\n',188 sleep: ' import select as _select\n _select.select([], [], [], 600)\n return {s: 0.0 for s in data}\n',189 };190 return `"""Hostile test strategy (sandbox tests). Not a trading rule."""\n${probeSource(o.targets)}\nMETA = ${JSON.stringify(meta, null, 4).replace(/\btrue\b/g, 'True')}\n\n\ndef target_weights(data, params):\n${body[o.behaviour]}`;191}192export const decodeWeights = (w: Record<string, number>, universe: string[]): { importMask: number; callMask: number } =>193 ({ importMask: Math.round(w[universe[0]!]! * 4294967296), callMask: Math.round(w[universe[1]!]! * 4294967296) });194export const decodeError = (text: string): { importMask: number; callMask: number } | null => { const m = /PROBE:([0-9a-f]+):([0-9a-f]+)/.exec(text); return m ? { importMask: parseInt(m[1]!, 16), callMask: parseInt(m[2]!, 16) } : null; };195196/** A laboratory directory for tests: the real code (copied) and virtualenv (by reference), its own strategies/ and results/.197 * `lint: 'off'` appends one line to spq/strategy.py that makes the static purity check accept everything. */198export function fixtureLab(o: { lint: 'off' | 'real' }): { dir: string; python: string; addStrategy(rel: string, source: string): { dir: string; sha256: string; path: string } } {199 const dir = realpathSync(mkdtempSync(path.join(tmpdir(), 'spw-lab-fixture-')));200 cpSync(path.join(REAL_LAB, 'src'), path.join(dir, 'src'), { recursive: true, filter: (p) => !p.endsWith('__pycache__') && !p.endsWith('.pyc') });201 cpSync(path.join(REAL_LAB, 'schema'), path.join(dir, 'schema'), { recursive: true });202 writeFileSync(path.join(dir, 'pyproject.toml'), readFileSync(path.join(REAL_LAB, 'pyproject.toml')));203 mkdirSync(path.join(dir, 'strategies')); mkdirSync(path.join(dir, 'results'));204 symlinkSync(path.join(REAL_LAB, '.venv'), path.join(dir, '.venv'));205 if (o.lint === 'off') { const f = path.join(dir, 'src/spq/strategy.py');206 writeFileSync(f, `${readFileSync(f, 'utf8')}\n\n# TEST FIXTURE (services/api/test/lab-hostile.ts): the static purity check is disabled so that only the OS sandbox is tested\ndef static_check(source): # noqa: F811\n return []\n`); }207 return { dir, python: REAL_PYTHON, addStrategy(rel, source) {208 const d = path.join(dir, 'strategies', rel); mkdirSync(d, { recursive: true }); writeFileSync(path.join(d, 'strategy.py'), source);209 return { dir: d, sha256: sha256(source), path: `strategies/${rel}` }; } };210}211export const sha256 = (s: string | Uint8Array): string => createHash('sha256').update(s).digest('hex');212213/** deterministic hourly bars ending at the last closed hour */214export function bars(n: number, seed: number, p0 = 100): { ts: number; open: number; high: number; low: number; close: number; volume: number }[] {215 const H = 3_600_000, end = Math.floor(Date.now() / H) * H - 2 * H; let s = seed >>> 0, close = p0;216 const rnd = (): number => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 2 ** 32; };217 return Array.from({ length: n }, (_, i) => { const open = close; close = open * Math.exp(0.0001 + 0.01 * (rnd() + rnd() + rnd() - 1.5));218 return { ts: end - (n - 1 - i) * H, open, high: Math.max(open, close) * 1.001, low: Math.min(open, close) * 0.999, close, volume: 1 + rnd() }; });219}220