SPB Git

Repository Public

lab-hostile.ts 11.7 KB 220 lines typescript

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
Files main
Menu

Theme