@vzn/vx 0.0.651 → 0.0.652
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/README.md +2 -3
- package/package.json +5 -5
- package/schemas/show.json +14 -20
- package/src/bin.ts +12 -0
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +19 -5
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +74 -14
- package/src/cache/layer.ts +6 -6
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/run-history.ts +46 -17
- package/src/cache/schema.ts +4 -1
- package/src/cli/cache.ts +2 -2
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +24 -0
- package/src/cli/info.ts +1 -1
- package/src/cli/last.ts +34 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/show.ts +7 -10
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +11 -8
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +40 -1
- package/src/exec/index.ts +1 -0
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +314 -29
- package/src/exec/task-proxy.ts +93 -0
- package/src/graph/definition.ts +16 -3
- package/src/graph/scheduler.ts +3 -1
- package/src/graph/task-graph.ts +24 -23
- package/src/history/failure-mode.ts +24 -1
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +5 -5
- package/src/index.ts +4 -2
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +51 -18
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/miss-save.ts +27 -8
- package/src/orchestrator/persistent-task.ts +2 -0
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +2 -2
- package/src/orchestrator/plugin-host.ts +6 -2
- package/src/orchestrator/plugin.ts +6 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-failures.ts +61 -12
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +86 -67
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +92 -19
- package/src/orchestrator/sandbox-request.ts +50 -7
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +3 -3
- package/src/report/framed-output.ts +16 -7
- package/src/report/logger.ts +1 -1
- package/src/report/plain-output.ts +2 -0
- package/src/report/summary.ts +5 -5
- package/src/settings.ts +8 -8
- package/src/util/config-frame.ts +128 -6
- package/src/util/paths.ts +1 -1
- package/src/util/settle.ts +2 -1
- package/src/util/tail.ts +4 -1
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +66 -61
- package/src/workspace/filter.ts +8 -1
- package/src/workspace/migration.ts +12 -6
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
writeFileSync,
|
|
45
45
|
} from 'node:fs'
|
|
46
46
|
import { mkdir, unlink } from 'node:fs/promises'
|
|
47
|
+
import { createConnection } from 'node:net'
|
|
47
48
|
import type { SandboxConfig } from '../config.js'
|
|
48
49
|
import {
|
|
49
50
|
armTimeout,
|
|
@@ -62,6 +63,7 @@ import {
|
|
|
62
63
|
type RunResult,
|
|
63
64
|
} from './runner.js'
|
|
64
65
|
import { TaskPipes } from './pipes.js'
|
|
66
|
+
import { startTaskProxy, type TaskProxy } from './task-proxy.js'
|
|
65
67
|
import { watchTree } from './proc-sample.js'
|
|
66
68
|
import {
|
|
67
69
|
BUN_GLOB_WILDCARDS,
|
|
@@ -664,6 +666,124 @@ function hostHelpers(): { dirs: string[]; files: string[] } {
|
|
|
664
666
|
return { dirs: unique(dirs), files: files.map(toRealPath) }
|
|
665
667
|
}
|
|
666
668
|
|
|
669
|
+
/**
|
|
670
|
+
* What a hermetic task reads outside the workspace without naming it: the
|
|
671
|
+
* programs vx and the runtime run around its command (the runtime's bash,
|
|
672
|
+
* the task shell, setsid, strace, env, socat, the seccomp helper, and the
|
|
673
|
+
* grep and sleep its network wait runs) and what the
|
|
674
|
+
* loader maps for them. Anything else outside the workspace is the task's
|
|
675
|
+
* to grant. Learned once per PATH: the files a host's shell links are
|
|
676
|
+
* fixed for the process.
|
|
677
|
+
*/
|
|
678
|
+
interface HermeticReads {
|
|
679
|
+
/** What is denied: `/`, spelled as the runtime can mount it. */
|
|
680
|
+
deny: string[]
|
|
681
|
+
allow: string[]
|
|
682
|
+
}
|
|
683
|
+
let hermeticMemo: { path: string; reads: Promise<HermeticReads> } | undefined
|
|
684
|
+
function hermeticReads(): Promise<HermeticReads> {
|
|
685
|
+
const PATH = process.env['PATH'] ?? ''
|
|
686
|
+
if (hermeticMemo?.path !== PATH) hermeticMemo = { path: PATH, reads: learnHermeticReads() }
|
|
687
|
+
return hermeticMemo.reads
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function learnHermeticReads(): Promise<HermeticReads> {
|
|
691
|
+
return { deny: rootDenies(), allow: await hermeticAllows() }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* `/` as the runtime can deny it. Given `/`, it lays a tmpfs over every
|
|
696
|
+
* entry of the root, and bwrap cannot mount one on a link: a merged-/usr
|
|
697
|
+
* host's `/sbin -> usr/sbin` failed every hermetic task. A link at the
|
|
698
|
+
* root leads into a directory denied here, so it is left as it is. `/proc`,
|
|
699
|
+
* `/dev` and `/sys` are the runtime's own mounts; macOS denies by path, so
|
|
700
|
+
* `/` stands.
|
|
701
|
+
*/
|
|
702
|
+
function rootDenies(): string[] {
|
|
703
|
+
if (process.platform !== 'linux') return ['/']
|
|
704
|
+
const skip = new Set(['proc', 'dev', 'sys'])
|
|
705
|
+
return readdirSync('/', { withFileTypes: true })
|
|
706
|
+
.filter((e) => !skip.has(e.name) && !e.isSymbolicLink())
|
|
707
|
+
.map((e) => `/${e.name}`)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function hermeticAllows(): Promise<string[]> {
|
|
711
|
+
const programs: string[] = []
|
|
712
|
+
for (const name of ['bash', 'setsid', 'strace', 'env', 'socat', 'grep', 'sleep']) {
|
|
713
|
+
try {
|
|
714
|
+
programs.push(executablePath(name))
|
|
715
|
+
} catch {
|
|
716
|
+
// The runtime's dependency check names a missing one.
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
programs.push(taskShell())
|
|
720
|
+
if (process.platform === 'darwin') {
|
|
721
|
+
// dyld and the shared cache every macOS binary maps.
|
|
722
|
+
return unique([...programs.flatMap(bothSpellings), '/usr/lib', '/System'])
|
|
723
|
+
}
|
|
724
|
+
const { getApplySeccompBinaryPath } =
|
|
725
|
+
await import('@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.js')
|
|
726
|
+
const seccomp = getApplySeccompBinaryPath(undefined)
|
|
727
|
+
if (seccomp) programs.push(seccomp)
|
|
728
|
+
const files = [...programs]
|
|
729
|
+
// The loader and the libraries each program links, by `ldd`'s answer.
|
|
730
|
+
let ldd: string
|
|
731
|
+
try {
|
|
732
|
+
ldd = executablePath('ldd')
|
|
733
|
+
} catch {
|
|
734
|
+
throw new UserError(
|
|
735
|
+
'exec.sandbox.hermetic: no `ldd` on PATH, so vx cannot tell which libraries the ' +
|
|
736
|
+
"task's shell needs — install it (glibc's libc-bin), or drop hermetic",
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
const r = Bun.spawnSync({ cmd: [ldd, ...programs], stdout: 'pipe', stderr: 'ignore' })
|
|
740
|
+
for (const m of r.stdout.toString().matchAll(/(\/\S+)\s+\(0x/g)) files.push(m[1]!)
|
|
741
|
+
return unique(files.flatMap(bothSpellings))
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Every spelling the loader walks from `p` to its file: a link (the
|
|
746
|
+
* loader's `/lib64/ld-linux-x86-64.so.2` is one, then `/usr/lib64`'s is
|
|
747
|
+
* another) is bound as the file it reaches, so each hop needs its own
|
|
748
|
+
* grant, named under its directory's real path, where the mount lands.
|
|
749
|
+
*/
|
|
750
|
+
function bothSpellings(p: string): string[] {
|
|
751
|
+
const hops: string[] = []
|
|
752
|
+
for (let at = p; hops.length < 40;) {
|
|
753
|
+
const here = path.join(toRealPath(path.dirname(at)), path.basename(at))
|
|
754
|
+
hops.push(here)
|
|
755
|
+
let next: string
|
|
756
|
+
try {
|
|
757
|
+
if (!lstatSync(here).isSymbolicLink()) break
|
|
758
|
+
next = path.resolve(path.dirname(here), readlinkSync(here))
|
|
759
|
+
} catch {
|
|
760
|
+
break
|
|
761
|
+
}
|
|
762
|
+
at = next
|
|
763
|
+
}
|
|
764
|
+
return hops
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The runtime's own sockets a hermetic task's command reaches: the write
|
|
769
|
+
* observer's and the network proxy's, under the host's temp directory.
|
|
770
|
+
* The runtime binds each before the read denies, so the deny's tmpfs over
|
|
771
|
+
* that directory hid them (`connect …/srt-obs-…: No such file`), and no
|
|
772
|
+
* call names the observer's. They are read off the wrap, once per run
|
|
773
|
+
* each: the observer listens after the runtime starts, so a later wrap may
|
|
774
|
+
* name one the first did not.
|
|
775
|
+
*/
|
|
776
|
+
const runtimeOwn = new Set<string>()
|
|
777
|
+
function runtimeBinds(wrapped: string): string[] {
|
|
778
|
+
const tmp = toRealPath(os.tmpdir()) + path.sep
|
|
779
|
+
const out: string[] = []
|
|
780
|
+
for (const m of wrapped.matchAll(/--bind (?:'([^']*)'|(\S+)) (?:'([^']*)'|(\S+))/g)) {
|
|
781
|
+
const from = m[1] ?? m[2]!
|
|
782
|
+
if (from === (m[3] ?? m[4]) && from.startsWith(tmp)) out.push(from)
|
|
783
|
+
}
|
|
784
|
+
return out
|
|
785
|
+
}
|
|
786
|
+
|
|
667
787
|
function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
|
|
668
788
|
if (process.platform !== 'linux') return {}
|
|
669
789
|
const paths: { bwrapPath?: string; socatPath?: string } = {}
|
|
@@ -815,16 +935,14 @@ function assertDomains(
|
|
|
815
935
|
* @param opts.allowedDomains every domain any sandboxed task in this run
|
|
816
936
|
* declared. SRT's filtering proxy is per-RUN and reads its allowlist from
|
|
817
937
|
* this call, never from the per-task config, so the union is the only
|
|
818
|
-
* place a domain list can take effect
|
|
819
|
-
*
|
|
938
|
+
* place a domain list can take effect; each task's own front
|
|
939
|
+
* (`ownProxy`) narrows it to the task's list.
|
|
820
940
|
*/
|
|
821
941
|
export async function initSandbox(opts?: {
|
|
822
942
|
allowedDomains?: readonly string[]
|
|
823
943
|
/**
|
|
824
|
-
* Every domain any sandboxed task of the run denies
|
|
825
|
-
*
|
|
826
|
-
* union's; before B-21 the list was always empty and a `deny.network`
|
|
827
|
-
* refused nothing.
|
|
944
|
+
* Every domain any sandboxed task of the run denies, checked here and
|
|
945
|
+
* never handed to the run's proxy: each task's front refuses its own.
|
|
828
946
|
*/
|
|
829
947
|
deniedDomains?: readonly string[]
|
|
830
948
|
/**
|
|
@@ -853,7 +971,9 @@ export async function initSandbox(opts?: {
|
|
|
853
971
|
const config: Parameters<typeof SandboxManager.initialize>[0] = {
|
|
854
972
|
network: {
|
|
855
973
|
allowedDomains: [...(opts?.allowedDomains ?? [])],
|
|
856
|
-
|
|
974
|
+
// Each task's own front refuses its own denies; the run's proxy
|
|
975
|
+
// refusing the union denied one task's host to every task.
|
|
976
|
+
deniedDomains: [],
|
|
857
977
|
...(opts?.allowAllUnixSockets === true ? { allowAllUnixSockets: true } : {}),
|
|
858
978
|
},
|
|
859
979
|
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
|
|
@@ -862,6 +982,8 @@ export async function initSandbox(opts?: {
|
|
|
862
982
|
...(scopedDenyScan ? scopedScanConfig() : {}),
|
|
863
983
|
...bundledJavaAgent(),
|
|
864
984
|
}
|
|
985
|
+
runAllowed = config.network.allowedDomains
|
|
986
|
+
runAllowsHosts = runAllowed.length > 0
|
|
865
987
|
const wasUp = srtUp
|
|
866
988
|
if (!wasUp) await unlinkStaleMuxSockets()
|
|
867
989
|
const listening = srtCleanupAdopted ? undefined : cleanupListeners()
|
|
@@ -920,6 +1042,7 @@ export async function resetSandbox(): Promise<void> {
|
|
|
920
1042
|
|
|
921
1043
|
function srtDown(): void {
|
|
922
1044
|
srtUp = false
|
|
1045
|
+
runtimeOwn.clear()
|
|
923
1046
|
perTaskRun = undefined
|
|
924
1047
|
availabilityCache.clear()
|
|
925
1048
|
straceAvailableCache = undefined
|
|
@@ -1108,6 +1231,8 @@ export interface ResolvedSandboxConfig {
|
|
|
1108
1231
|
gitConfig?: boolean
|
|
1109
1232
|
weakerWhenNested?: boolean
|
|
1110
1233
|
weakerNetworkIsolation?: boolean
|
|
1234
|
+
/** Reads outside the workspace are refused too (`hermeticReads`). */
|
|
1235
|
+
hermetic?: boolean
|
|
1111
1236
|
/** macOS: the walls a glob grant reaches, by kind (`darwinWallRules`). */
|
|
1112
1237
|
wallsReached?: { read: readonly string[]; write: readonly string[] }
|
|
1113
1238
|
ignore?: {
|
|
@@ -1266,6 +1391,7 @@ export function resolveSandboxConfig(
|
|
|
1266
1391
|
if (cfg.weakerNetworkIsolation !== undefined) {
|
|
1267
1392
|
r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
|
|
1268
1393
|
}
|
|
1394
|
+
if (cfg.hermetic === true) r.hermetic = true
|
|
1269
1395
|
if (cfg.ignore !== undefined) {
|
|
1270
1396
|
// Relative patterns anchor at the project dir, `~` ones at the home
|
|
1271
1397
|
// directory, as a grant's are (a `~` pattern kept as written matched no
|
|
@@ -1434,6 +1560,11 @@ async function wrapIn(
|
|
|
1434
1560
|
baselines.cwd,
|
|
1435
1561
|
)
|
|
1436
1562
|
customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
|
|
1563
|
+
if (args.config.hermetic === true) {
|
|
1564
|
+
const reads = await hermeticReads()
|
|
1565
|
+
customConfig!.filesystem!.denyRead!.push(...reads.deny)
|
|
1566
|
+
customConfig!.filesystem!.allowRead!.push(...reads.allow, ...runtimeOwn)
|
|
1567
|
+
}
|
|
1437
1568
|
const helpers = hostHelpers()
|
|
1438
1569
|
customConfig!.filesystem!.denyRead!.push(...helpers.dirs)
|
|
1439
1570
|
customConfig!.filesystem!.allowRead!.push(...helpers.files)
|
|
@@ -1502,6 +1633,29 @@ async function wrapIn(
|
|
|
1502
1633
|
ports.length > 0 || asksUnixSockets(args.config),
|
|
1503
1634
|
args.config.gitConfig === true,
|
|
1504
1635
|
)
|
|
1636
|
+
if (process.platform === 'linux' && args.config.hermetic === true) {
|
|
1637
|
+
const own = runtimeBinds(wrapped).filter((p) => !runtimeOwn.has(p))
|
|
1638
|
+
if (own.length > 0) {
|
|
1639
|
+
for (const p of own) runtimeOwn.add(p)
|
|
1640
|
+
customConfig!.filesystem!.allowRead!.push(...own)
|
|
1641
|
+
wrapped = await wrapForTask(
|
|
1642
|
+
SandboxManager,
|
|
1643
|
+
inner,
|
|
1644
|
+
literalReadPaths(customConfig),
|
|
1645
|
+
ports.length > 0 || asksUnixSockets(args.config),
|
|
1646
|
+
args.config.gitConfig === true,
|
|
1647
|
+
)
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
if (runAllowsHosts) {
|
|
1651
|
+
try {
|
|
1652
|
+
wrapped = await ownProxy(SandboxManager, wrapped, inner, tag, args.config)
|
|
1653
|
+
} catch (err) {
|
|
1654
|
+
releaseBridges(tag)
|
|
1655
|
+
afterCommand(SandboxManager)
|
|
1656
|
+
throw err
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1505
1659
|
if (process.platform === 'darwin') {
|
|
1506
1660
|
const rules = [
|
|
1507
1661
|
...macProfileRules(args.config),
|
|
@@ -1821,6 +1975,77 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
|
|
|
1821
1975
|
const PROXY_BRIDGE_WAIT =
|
|
1822
1976
|
"( i=0; until grep -qsE ':0C38 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6 && grep -qsE ':0438 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6; do [ $i -ge 500 ] && break; i=$((i+1)); sleep 0.01; done );"
|
|
1823
1977
|
|
|
1978
|
+
/** Each networked task's own proxy front, by tag, closed when its bridges go. */
|
|
1979
|
+
const taskProxies = new Map<string, TaskProxy>()
|
|
1980
|
+
/** Whether the run's proxy allows any host: one that allows none refuses every task alike, front or not. */
|
|
1981
|
+
let runAllowsHosts = false
|
|
1982
|
+
/** The run's union: what a `network: true` task's front allows. */
|
|
1983
|
+
let runAllowed: readonly string[] = []
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* A networked task's own proxy front (task-proxy.ts). The runtime's proxy
|
|
1987
|
+
* is the run's and filters by the union of every task's lists, so a task
|
|
1988
|
+
* reached a host only another task of the run declared. The front judges
|
|
1989
|
+
* by this task's own lists and passes what it allows on to the run's
|
|
1990
|
+
* proxy. Linux: it listens on a socket in the task's tmpdir, bound over
|
|
1991
|
+
* the runtime's bridge socket, so the task's side dials the same path.
|
|
1992
|
+
* macOS: it listens on a loopback port of its own, and the task's proxy
|
|
1993
|
+
* variables and profile name that port in place of the run's, so the
|
|
1994
|
+
* profile refuses the run's. A task that lists no network gets a front
|
|
1995
|
+
* that refuses every host; `network: true` gets one that allows the
|
|
1996
|
+
* run's union. Each front refuses only its own task's denies. Fails closed: a task the front cannot take does not run.
|
|
1997
|
+
*/
|
|
1998
|
+
async function ownProxy(
|
|
1999
|
+
SandboxManager: SrtModule['SandboxManager'],
|
|
2000
|
+
wrapped: string,
|
|
2001
|
+
inner: string,
|
|
2002
|
+
tag: string,
|
|
2003
|
+
config: ResolvedSandboxConfig,
|
|
2004
|
+
): Promise<string> {
|
|
2005
|
+
const port = SandboxManager.getProxyPort()
|
|
2006
|
+
const token = SandboxManager.getProxyAuthToken()
|
|
2007
|
+
const linux = process.platform === 'linux'
|
|
2008
|
+
const bridge = linux ? SandboxManager.getLinuxHttpSocketPath() : undefined
|
|
2009
|
+
const bind = bridge === undefined ? undefined : `--bind ${bridge} ${bridge}`
|
|
2010
|
+
const runPort = new RegExp(`localhost:${port}(?!\\d)`, 'g')
|
|
2011
|
+
const handed =
|
|
2012
|
+
port !== undefined &&
|
|
2013
|
+
token !== undefined &&
|
|
2014
|
+
(linux ? bind !== undefined && wrapped.includes(bind) : runPort.test(wrapped))
|
|
2015
|
+
// A task that lists no network is handed the run's proxy too when another
|
|
2016
|
+
// task of the run lists some; it gets a front that refuses every host.
|
|
2017
|
+
if (!handed) {
|
|
2018
|
+
if (config.network === undefined) return wrapped
|
|
2019
|
+
throw new Error('sandbox: the runtime gave this networked task no proxy to replace')
|
|
2020
|
+
}
|
|
2021
|
+
const socket = path.join(taskTmpdir(tag), 'vx-net.sock')
|
|
2022
|
+
const key = Buffer.from(inner.slice(0, 100)).toString('base64')
|
|
2023
|
+
const front = await startTaskProxy({
|
|
2024
|
+
endpoint: linux ? socket : 0,
|
|
2025
|
+
allowed:
|
|
2026
|
+
config.network === true
|
|
2027
|
+
? runAllowed
|
|
2028
|
+
: config.network === undefined
|
|
2029
|
+
? []
|
|
2030
|
+
: (config.network as string[]),
|
|
2031
|
+
denied: config.denyNetwork ?? [],
|
|
2032
|
+
runProxyPort: port!,
|
|
2033
|
+
token: token!,
|
|
2034
|
+
encodedCommand: key,
|
|
2035
|
+
refused: (line) => collecting.get(key)?.push({ line, timestamp: new Date() }),
|
|
2036
|
+
})
|
|
2037
|
+
taskProxies.set(tag, front)
|
|
2038
|
+
if (!linux) return wrapped.replace(runPort, `localhost:${front.port}`)
|
|
2039
|
+
// After the root's read-only bind, which would mask a bind made before it
|
|
2040
|
+
// with the host's own file: the runtime's socket, reachable as itself.
|
|
2041
|
+
const own = `--bind ${socket} ${bridge}`
|
|
2042
|
+
const root = ' --ro-bind / / '
|
|
2043
|
+
const at = wrapped.indexOf(root)
|
|
2044
|
+
return at === -1
|
|
2045
|
+
? wrapped.replace(bind!, own)
|
|
2046
|
+
: `${wrapped.slice(0, at + root.length)}${own} ${wrapped.slice(at + root.length)}`
|
|
2047
|
+
}
|
|
2048
|
+
|
|
1824
2049
|
/** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
|
|
1825
2050
|
export function portBridgeSocket(tag: string, port: number): string {
|
|
1826
2051
|
return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
|
|
@@ -2004,6 +2229,12 @@ export function releaseBridges(tag: string): void {
|
|
|
2004
2229
|
afterCommand(srtLoaded!.SandboxManager)
|
|
2005
2230
|
if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
|
|
2006
2231
|
}
|
|
2232
|
+
const front = taskProxies.get(tag)
|
|
2233
|
+
if (front !== undefined) {
|
|
2234
|
+
taskProxies.delete(tag)
|
|
2235
|
+
// quiet: a front that will not close holds only its own socket, gone with the tmpdir
|
|
2236
|
+
void front.close().catch(() => undefined)
|
|
2237
|
+
}
|
|
2007
2238
|
const bridges = hostBridges.get(tag)
|
|
2008
2239
|
if (bridges === undefined) return
|
|
2009
2240
|
hostBridges.delete(tag)
|
|
@@ -2028,6 +2259,18 @@ export function releaseBridges(tag: string): void {
|
|
|
2028
2259
|
const TRACER_RETRY_LINE =
|
|
2029
2260
|
"[vx] the sandbox's tracer (strace) failed on its own; running the task again\n"
|
|
2030
2261
|
|
|
2262
|
+
/** What a task's output says when a mount went away as bwrap set the sandbox up. */
|
|
2263
|
+
const MOUNT_RACE_RETRY_LINE =
|
|
2264
|
+
'[vx] a mount went away as the sandbox was set up; running the task again\n'
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* bwrap's own refusal of a mount gone between its read of the mount table
|
|
2268
|
+
* and its remount: a container runtime's transient mount under /tmp
|
|
2269
|
+
* (`/newroot/tmp/containerd-mount…`, a `docker build` beside the task on
|
|
2270
|
+
* CI). The command never started.
|
|
2271
|
+
*/
|
|
2272
|
+
const BWRAP_MOUNT_GONE = /^bwrap: Can't .*: No such file or directory$/
|
|
2273
|
+
|
|
2031
2274
|
/**
|
|
2032
2275
|
* Run a single task wrapped in the sandbox. Caller must have called
|
|
2033
2276
|
* `initSandbox()` first.
|
|
@@ -2053,20 +2296,21 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
2053
2296
|
if (chunk.length > 0) midLine = !chunk.endsWith('\n')
|
|
2054
2297
|
on?.(chunk)
|
|
2055
2298
|
}
|
|
2056
|
-
const { tracerFailed, ranMs, ...first } = await runSandboxedOnce({
|
|
2299
|
+
const { tracerFailed, mountGone, ranMs, ...first } = await runSandboxedOnce({
|
|
2057
2300
|
...args,
|
|
2058
2301
|
onStdout: seen(args.onStdout),
|
|
2059
2302
|
onStderr: seen(args.onStderr),
|
|
2060
2303
|
})
|
|
2061
2304
|
// A stopping run has killed the children it holds; a retry would be one
|
|
2062
2305
|
// spawned after that kill.
|
|
2063
|
-
if (!tracerFailed || args.signal?.aborted === true) return first
|
|
2064
|
-
const notice = (midLine ? '\n' : '') + TRACER_RETRY_LINE
|
|
2306
|
+
if (!(tracerFailed || mountGone) || args.signal?.aborted === true) return first
|
|
2307
|
+
const notice = (midLine ? '\n' : '') + (mountGone ? MOUNT_RACE_RETRY_LINE : TRACER_RETRY_LINE)
|
|
2065
2308
|
args.onStderr?.(notice)
|
|
2066
2309
|
// `timeout` bounds the task's whole run (schema.md), so the retry gets
|
|
2067
2310
|
// what the first attempt left of it, not a fresh window.
|
|
2068
2311
|
const {
|
|
2069
2312
|
tracerFailed: _again,
|
|
2313
|
+
mountGone: _gone,
|
|
2070
2314
|
ranMs: _ran,
|
|
2071
2315
|
...second
|
|
2072
2316
|
} = await runSandboxedOnce(
|
|
@@ -2088,18 +2332,21 @@ let collectorOn = false
|
|
|
2088
2332
|
|
|
2089
2333
|
/**
|
|
2090
2334
|
* Start collecting SRT's store records for `command`; the returned function
|
|
2091
|
-
* stops and hands them over
|
|
2092
|
-
*
|
|
2335
|
+
* stops and hands them over, with `settle` once every record the task's
|
|
2336
|
+
* Linux observer (`observer`, its socket) sent is read. The store is a
|
|
2337
|
+
* 100-record ring the whole run shares, and on Linux its write observer
|
|
2338
|
+
* reports every write any task
|
|
2093
2339
|
* makes, so a refused write followed by 150 declared ones was gone before
|
|
2094
2340
|
* the task's exit read it (B-7). One subscription takes each record as it
|
|
2095
2341
|
* arrives and keeps it only for a command still running. SRT keys a record
|
|
2096
2342
|
* by the base64 of the command's first 100 characters
|
|
2097
2343
|
* (`encodeSandboxedCommand`, not exported).
|
|
2098
2344
|
*/
|
|
2099
|
-
function collectRecords(
|
|
2345
|
+
export function collectRecords(
|
|
2100
2346
|
store: ReturnType<SrtModule['SandboxManager']['getSandboxViolationStore']>,
|
|
2101
2347
|
command: string,
|
|
2102
|
-
|
|
2348
|
+
observer: string | undefined,
|
|
2349
|
+
): (settle: boolean) => Promise<SandboxViolation[]> {
|
|
2103
2350
|
if (!collectorOn) {
|
|
2104
2351
|
collectorOn = true
|
|
2105
2352
|
let seen = store.getTotalCount()
|
|
@@ -2115,12 +2362,44 @@ function collectRecords(
|
|
|
2115
2362
|
const key = Buffer.from(command.slice(0, 100)).toString('base64')
|
|
2116
2363
|
const list: SandboxViolation[] = []
|
|
2117
2364
|
collecting.set(key, list)
|
|
2118
|
-
return () => {
|
|
2365
|
+
return async (settle) => {
|
|
2366
|
+
if (settle && observer !== undefined) await observerCaughtUp(observer)
|
|
2119
2367
|
collecting.delete(key)
|
|
2120
2368
|
return list
|
|
2121
2369
|
}
|
|
2122
2370
|
}
|
|
2123
2371
|
|
|
2372
|
+
/**
|
|
2373
|
+
* The Unix socket SRT's Linux write observer listens on, from the command
|
|
2374
|
+
* it wrapped (`--setenv SRT_OBSERVE_SOCK <path>`); none when the listener
|
|
2375
|
+
* failed or on macOS.
|
|
2376
|
+
*/
|
|
2377
|
+
function observerSocket(wrapped: string): string | undefined {
|
|
2378
|
+
return /--setenv SRT_OBSERVE_SOCK (?:'([^']*)'|(\S+))/.exec(wrapped)?.slice(1).find(Boolean)
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/**
|
|
2382
|
+
* Resolves once SRT has read every record sent to `socket` before the call.
|
|
2383
|
+
* A task's observer sends its records before the task exits, but they are
|
|
2384
|
+
* read when this process gets to that connection: a fixed count of event
|
|
2385
|
+
* loop turns after the exit (2) lost 4 of 24 refusals on a loaded CI
|
|
2386
|
+
* worker. So a connection of ours goes in after them: the kernel queues
|
|
2387
|
+
* connections in order, so SRT accepts it no earlier than the task's, and
|
|
2388
|
+
* the task's bytes were already there when it did. SRT's server is not
|
|
2389
|
+
* half-open, so it ends ours once it reads our end, and that end coming
|
|
2390
|
+
* back means the task's bytes were read first. A socket that is gone
|
|
2391
|
+
* answers at once with an error.
|
|
2392
|
+
*/
|
|
2393
|
+
function observerCaughtUp(socket: string): Promise<void> {
|
|
2394
|
+
return new Promise((resolve) => {
|
|
2395
|
+
const conn = createConnection(socket)
|
|
2396
|
+
conn.on('connect', () => conn.end())
|
|
2397
|
+
conn.on('error', () => resolve())
|
|
2398
|
+
conn.on('close', () => resolve())
|
|
2399
|
+
conn.resume()
|
|
2400
|
+
})
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2124
2403
|
/**
|
|
2125
2404
|
* strace's own message, a line of a traced task's stderr. strace names
|
|
2126
2405
|
* itself by its argv[0], the absolute path vx runs it by
|
|
@@ -2140,7 +2419,7 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
|
|
|
2140
2419
|
*/
|
|
2141
2420
|
async function runSandboxedOnce(
|
|
2142
2421
|
args: SandboxedRunArgs,
|
|
2143
|
-
): Promise<SandboxedRunResult & { tracerFailed: boolean; ranMs: number }> {
|
|
2422
|
+
): Promise<SandboxedRunResult & { tracerFailed: boolean; mountGone: boolean; ranMs: number }> {
|
|
2144
2423
|
const start = Date.now()
|
|
2145
2424
|
const { SandboxManager } = await loadSrt()
|
|
2146
2425
|
// Linux: SRT's store sees only writes (below), so read denials need
|
|
@@ -2174,12 +2453,16 @@ async function runSandboxedOnce(
|
|
|
2174
2453
|
...(useStrace ? { trace: useStrace } : {}),
|
|
2175
2454
|
...(widened !== undefined && widened.size > 0 ? { tracePaths: true } : {}),
|
|
2176
2455
|
})
|
|
2177
|
-
const takeRecords = collectRecords(
|
|
2456
|
+
const takeRecords = collectRecords(
|
|
2457
|
+
SandboxManager.getSandboxViolationStore(),
|
|
2458
|
+
srtCommand,
|
|
2459
|
+
observerSocket(wrapped),
|
|
2460
|
+
)
|
|
2178
2461
|
// A stop that landed during the awaits above leaves nothing to kill yet:
|
|
2179
2462
|
// spawned now, the task ran after the teardown swept the run's children.
|
|
2180
2463
|
if (args.signal?.aborted === true) {
|
|
2181
2464
|
releaseBridges(tag)
|
|
2182
|
-
takeRecords()
|
|
2465
|
+
void takeRecords(false)
|
|
2183
2466
|
afterCommand(SandboxManager)
|
|
2184
2467
|
const signal = stopSignal(args.signal.reason)
|
|
2185
2468
|
return {
|
|
@@ -2190,6 +2473,7 @@ async function runSandboxedOnce(
|
|
|
2190
2473
|
signal,
|
|
2191
2474
|
violations: [],
|
|
2192
2475
|
tracerFailed: false,
|
|
2476
|
+
mountGone: false,
|
|
2193
2477
|
ranMs: 0,
|
|
2194
2478
|
}
|
|
2195
2479
|
}
|
|
@@ -2234,7 +2518,7 @@ async function runSandboxedOnce(
|
|
|
2234
2518
|
const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
|
|
2235
2519
|
args.onStderr?.(stderr)
|
|
2236
2520
|
releaseBridges(tag)
|
|
2237
|
-
takeRecords()
|
|
2521
|
+
void takeRecords(false)
|
|
2238
2522
|
afterCommand(SandboxManager)
|
|
2239
2523
|
return {
|
|
2240
2524
|
exitCode: 127,
|
|
@@ -2244,6 +2528,7 @@ async function runSandboxedOnce(
|
|
|
2244
2528
|
spawnFailed: true,
|
|
2245
2529
|
violations: [],
|
|
2246
2530
|
tracerFailed: false,
|
|
2531
|
+
mountGone: false,
|
|
2247
2532
|
ranMs: 0,
|
|
2248
2533
|
}
|
|
2249
2534
|
} finally {
|
|
@@ -2261,6 +2546,7 @@ async function runSandboxedOnce(
|
|
|
2261
2546
|
// The unfinished last line of stderr, and whether a line was strace's.
|
|
2262
2547
|
let partial = ''
|
|
2263
2548
|
let straceSpoke = false
|
|
2549
|
+
let mountGone = false
|
|
2264
2550
|
const [out, err] = pipes.streams(proc)
|
|
2265
2551
|
const ends = lineEnds(args.onStdout, args.onStderr)
|
|
2266
2552
|
const streams = Promise.all([
|
|
@@ -2273,6 +2559,7 @@ async function runSandboxedOnce(
|
|
|
2273
2559
|
const lines = (partial + chunk).split('\n')
|
|
2274
2560
|
partial = (lines.pop() ?? '').slice(0, 512)
|
|
2275
2561
|
if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
|
|
2562
|
+
if (lines.some((l) => BWRAP_MOUNT_GONE.test(l))) mountGone = true
|
|
2276
2563
|
ends.onStderr?.(chunk)
|
|
2277
2564
|
},
|
|
2278
2565
|
ac.signal,
|
|
@@ -2324,15 +2611,9 @@ async function runSandboxedOnce(
|
|
|
2324
2611
|
// (2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
2325
2612
|
// deciding on the user's behalf that a record was "just traversal" is how
|
|
2326
2613
|
// a failure ends up with an empty violations section and no explanation.
|
|
2327
|
-
// Linux: the write observer's records
|
|
2328
|
-
//
|
|
2329
|
-
|
|
2330
|
-
// in the turn its connection is accepted, before it is read: a turn to
|
|
2331
|
-
// accept and a turn to read take them in (40 fast refused mkdirs at
|
|
2332
|
-
// once: 38 of 200 unreported, 33 with one turn, none with two).
|
|
2333
|
-
if (process.platform === 'linux')
|
|
2334
|
-
for (let turn = 0; turn < 2; turn++) await new Promise((r) => setImmediate(r))
|
|
2335
|
-
const records = takeRecords()
|
|
2614
|
+
// Linux: the write observer's records arrive over a socket, not with the
|
|
2615
|
+
// exit; `takeRecords(true)` waits until all it sent are read.
|
|
2616
|
+
const records = await takeRecords(true)
|
|
2336
2617
|
// Read as is on darwin only. Since SRT 0.0.75 the store is fed on Linux
|
|
2337
2618
|
// too, by the seccomp helper's write observer — but SRT judges those
|
|
2338
2619
|
// reports against the GLOBAL `filesystem.allowWrite` from `initialize`
|
|
@@ -2481,6 +2762,7 @@ async function runSandboxedOnce(
|
|
|
2481
2762
|
? {}
|
|
2482
2763
|
: { cpuMs: looked.cpuMs, peakRssBytes: looked.rssBytes }),
|
|
2483
2764
|
ranMs,
|
|
2765
|
+
mountGone: exitCode !== 0 && (mountGone || BWRAP_MOUNT_GONE.test(partial)),
|
|
2484
2766
|
tracerFailed:
|
|
2485
2767
|
straceLog !== undefined &&
|
|
2486
2768
|
!timeout.timedOut() &&
|
|
@@ -2634,7 +2916,7 @@ function sbplToken(value: string, field: string): string {
|
|
|
2634
2916
|
*
|
|
2635
2917
|
* `systemInfo` has no SRT field at any level. The rest DO have fields,
|
|
2636
2918
|
* but SRT reads `network.allowLocalBinding` / `allowUnixSockets` /
|
|
2637
|
-
* `allowMachLookup` off the config given to `initialize()` and never off
|
|
2919
|
+
* `allowMachLookup` / `enableWeakerNetworkIsolation` off the config given to `initialize()` and never off
|
|
2638
2920
|
* the per-call one (`sandbox-manager.js` getters, 0.0.75 and 0.0.76) — so a per-task
|
|
2639
2921
|
* grant passed there is silently dropped. vx is per-task by definition,
|
|
2640
2922
|
* so it emits them itself. The rule text mirrors SRT's own.
|
|
@@ -2657,6 +2939,9 @@ export function macProfileRules(c: ResolvedSandboxConfig): string[] {
|
|
|
2657
2939
|
rules.push('(allow network-inbound (local ip "*:*"))')
|
|
2658
2940
|
rules.push('(allow network-outbound (remote ip "localhost:*"))')
|
|
2659
2941
|
}
|
|
2942
|
+
if (c.weakerNetworkIsolation === true) {
|
|
2943
|
+
rules.push('(allow mach-lookup (global-name "com.apple.trustd.agent"))')
|
|
2944
|
+
}
|
|
2660
2945
|
if (c.unixSockets === true) {
|
|
2661
2946
|
rules.push('(allow system-socket (socket-domain AF_UNIX))')
|
|
2662
2947
|
rules.push('(allow network-bind (local unix-socket (path-regex #"^/")))')
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// One task's own proxy: the runtime's proxy is the run's and judges every
|
|
2
|
+
// connection by the union of all tasks' network lists, so a task reached a
|
|
3
|
+
// host only another task of the run declared. Each networked task gets this
|
|
4
|
+
// front on its own socket, which judges by the task's lists and hands what
|
|
5
|
+
// it allows to the run's proxy as its parent (that proxy still applies the
|
|
6
|
+
// union and its address guard). A task names itself to a proxy by a
|
|
7
|
+
// username it writes, so only the socket it was given says which task it is.
|
|
8
|
+
|
|
9
|
+
import { BlockList } from 'node:net'
|
|
10
|
+
|
|
11
|
+
/** The deep modules of the runtime the front is built from: its own proxy code, not a copy. */
|
|
12
|
+
async function loadProxyParts() {
|
|
13
|
+
// Literal specifiers: `bun build --compile` embeds only what it can see.
|
|
14
|
+
const [http, socks, mux, pattern, parent] = await Promise.all([
|
|
15
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.js'),
|
|
16
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.js'),
|
|
17
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/mux-proxy.js'),
|
|
18
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/domain-pattern.js'),
|
|
19
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.js'),
|
|
20
|
+
])
|
|
21
|
+
return { http, socks, mux, pattern, parent }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TaskProxy {
|
|
25
|
+
close(): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Listen at `endpoint` (a unix socket path, or 0 for a localhost port) for
|
|
30
|
+
* one task. Returns the bound port when `endpoint` is 0.
|
|
31
|
+
*/
|
|
32
|
+
export async function startTaskProxy(opts: {
|
|
33
|
+
endpoint: string | 0
|
|
34
|
+
allowed: readonly string[]
|
|
35
|
+
denied: readonly string[]
|
|
36
|
+
/** The run's proxy: its port and the session token both legs carry. */
|
|
37
|
+
runProxyPort: number
|
|
38
|
+
token: string
|
|
39
|
+
/** The task's attribution key (`srt.<encodedCommand>`'s suffix). */
|
|
40
|
+
encodedCommand: string
|
|
41
|
+
/** A refusal this front made, in the runtime's own record form. */
|
|
42
|
+
refused(line: string): void
|
|
43
|
+
}): Promise<TaskProxy & { port: number | undefined }> {
|
|
44
|
+
const { http, socks, mux, pattern, parent } = await loadProxyParts()
|
|
45
|
+
const filter = (port: number, host: string): boolean => {
|
|
46
|
+
const deny = (reason: string) => {
|
|
47
|
+
opts.refused(`deny network-outbound ${host}:${port} (${reason})`)
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
if (!parent.isValidHost(host)) return deny('malformed host')
|
|
51
|
+
const canonical: string = parent.canonicalizeHost(host) ?? host
|
|
52
|
+
if (opts.denied.some((d) => pattern.matchesDomainPatternWithPort(canonical, port, d)))
|
|
53
|
+
return deny('host is on the deny list')
|
|
54
|
+
if (opts.allowed.some((a) => pattern.matchesDomainPatternWithPort(canonical, port, a)))
|
|
55
|
+
return true
|
|
56
|
+
return deny('host is not on the allow list')
|
|
57
|
+
}
|
|
58
|
+
const user = encodeURIComponent(`srt.${opts.encodedCommand}`)
|
|
59
|
+
const up = new URL(
|
|
60
|
+
`http://${user}:${encodeURIComponent(opts.token)}@127.0.0.1:${opts.runProxyPort}`,
|
|
61
|
+
)
|
|
62
|
+
// Every allowed connection goes on to the run's proxy, which applies the
|
|
63
|
+
// run's lists and address guard again; only a loopback host, which the
|
|
64
|
+
// runtime never sends to a parent, is dialed from here, and only one the
|
|
65
|
+
// task named.
|
|
66
|
+
const parentProxy = {
|
|
67
|
+
httpUrl: up,
|
|
68
|
+
httpsUrl: up,
|
|
69
|
+
noProxy: { all: false, suffixes: [], cidr: new BlockList() },
|
|
70
|
+
}
|
|
71
|
+
const httpServer = http.createHttpProxyServer({
|
|
72
|
+
filter: (port: number, host: string) => filter(port, host),
|
|
73
|
+
parentProxy,
|
|
74
|
+
proxyAuthToken: opts.token,
|
|
75
|
+
})
|
|
76
|
+
const socksServer = socks.createSocksProxyServer({
|
|
77
|
+
filter: (port: number, host: string) => filter(port, host),
|
|
78
|
+
parentProxy,
|
|
79
|
+
proxyAuthToken: opts.token,
|
|
80
|
+
})
|
|
81
|
+
const front = mux.createMuxProxyServer({
|
|
82
|
+
httpServer,
|
|
83
|
+
handleSocksConnection: (s) => socksServer.handleConnection(s),
|
|
84
|
+
})
|
|
85
|
+
await front.listenHttpBackend()
|
|
86
|
+
await new Promise<void>((resolve, reject) => {
|
|
87
|
+
front.server.once('error', reject)
|
|
88
|
+
if (opts.endpoint === 0) front.server.listen(0, '127.0.0.1', () => resolve())
|
|
89
|
+
else front.server.listen(opts.endpoint, () => resolve())
|
|
90
|
+
})
|
|
91
|
+
front.unref()
|
|
92
|
+
return { port: front.getPort(), close: () => front.close() }
|
|
93
|
+
}
|