@vzn/vx 0.0.595 → 0.0.597
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 +1 -1
- package/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +170 -99
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +99 -44
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +9 -6
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +12 -6
- package/src/cli/foreign-flags.ts +57 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +10 -4
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +73 -30
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +18 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +42 -21
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +12 -1
- package/src/exec/sandbox-runtime.ts +283 -88
- package/src/exec/sandbox-violations.ts +115 -15
- package/src/graph/scheduler.ts +30 -14
- package/src/graph/task-graph.ts +265 -44
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +85 -39
- package/src/orchestrator/failure-mode.ts +43 -28
- package/src/orchestrator/framed-output.ts +8 -8
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +21 -9
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +9 -13
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +7 -23
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +4 -3
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +22 -5
- package/src/orchestrator/sandbox-request.ts +96 -18
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +7 -13
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +24 -4
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -53
- package/src/workspace/config-schema.ts +56 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +19 -4
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +53 -24
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
existsSync,
|
|
34
34
|
mkdirSync,
|
|
35
35
|
openSync,
|
|
36
|
+
readdirSync,
|
|
36
37
|
readFileSync,
|
|
37
38
|
readlinkSync,
|
|
38
39
|
realpathSync,
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
isMountableLiteral,
|
|
82
83
|
localBindingOn,
|
|
83
84
|
MOUNT_WILDCARDS,
|
|
85
|
+
namedPath,
|
|
84
86
|
toRealPath,
|
|
85
87
|
unique,
|
|
86
88
|
} from './sandbox-paths.js'
|
|
@@ -103,6 +105,7 @@ import {
|
|
|
103
105
|
releaseGroup,
|
|
104
106
|
signalThrough,
|
|
105
107
|
guardLine,
|
|
108
|
+
guardSession,
|
|
106
109
|
spawnGuarded,
|
|
107
110
|
} from './kill-tree.js'
|
|
108
111
|
|
|
@@ -708,7 +711,8 @@ export async function initSandbox(opts?: {
|
|
|
708
711
|
...(scopedDenyScan ? scopedScanConfig() : {}),
|
|
709
712
|
...bundledJavaAgent(),
|
|
710
713
|
}
|
|
711
|
-
|
|
714
|
+
const wasUp = srtUp
|
|
715
|
+
if (!wasUp) await unlinkStaleMuxSockets()
|
|
712
716
|
const listening = srtCleanupAdopted ? undefined : cleanupListeners()
|
|
713
717
|
await SandboxManager.initialize(
|
|
714
718
|
config,
|
|
@@ -718,6 +722,10 @@ export async function initSandbox(opts?: {
|
|
|
718
722
|
)
|
|
719
723
|
if (listening !== undefined) adoptSrtCleanup(listening)
|
|
720
724
|
srtUp = true
|
|
725
|
+
if (!wasUp && process.platform === 'linux' && procfsIsOwn()) {
|
|
726
|
+
const { pids, paths } = sessionLeftovers(SandboxManager)
|
|
727
|
+
sessionRelease = guardSession(pids, paths)
|
|
728
|
+
}
|
|
721
729
|
perTaskRun = opts?.allowAllUnixSockets === true || opts?.gitConfig === true ? config : undefined
|
|
722
730
|
// `initialize()` returns early once SRT is up, and on Linux the
|
|
723
731
|
// availability probe brought it up with an EMPTY config before the run's
|
|
@@ -766,12 +774,81 @@ function srtDown(): void {
|
|
|
766
774
|
straceAvailableCache = undefined
|
|
767
775
|
}
|
|
768
776
|
|
|
777
|
+
/** Strikes the live session from the group guard's lists; set by `initSandbox`. */
|
|
778
|
+
let sessionRelease: (() => void) | undefined
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The runtime's session as the group guard must take it if vx dies
|
|
782
|
+
* (kill-tree.md): the bridge socats, vx's children named by the socket
|
|
783
|
+
* they listen on, and what a `kill -9` leaves in the temp directory — the
|
|
784
|
+
* bridge sockets and the `srt-*` sockets vx itself holds (the violation
|
|
785
|
+
* observer's directory, the mux). The runtime exposes none of the pids and
|
|
786
|
+
* not the observer's path, so procfs answers: vx's children, and its own
|
|
787
|
+
* socket inodes against `/proc/net/unix`. ~3 ms, once per session.
|
|
788
|
+
*/
|
|
789
|
+
function sessionLeftovers(SandboxManager: SrtModule['SandboxManager']): {
|
|
790
|
+
pids: number[]
|
|
791
|
+
paths: string[]
|
|
792
|
+
} {
|
|
793
|
+
const sockets = new Set(
|
|
794
|
+
[SandboxManager.getLinuxHttpSocketPath(), SandboxManager.getLinuxSocksSocketPath()].filter(
|
|
795
|
+
(p): p is string => p !== undefined,
|
|
796
|
+
),
|
|
797
|
+
)
|
|
798
|
+
const pids: number[] = []
|
|
799
|
+
const paths = [...sockets]
|
|
800
|
+
try {
|
|
801
|
+
for (const entry of readdirSync('/proc')) {
|
|
802
|
+
const first = entry.charCodeAt(0)
|
|
803
|
+
if (first < 48 || first > 57) continue
|
|
804
|
+
try {
|
|
805
|
+
const stat = readFileSync(`/proc/${entry}/stat`, 'utf8')
|
|
806
|
+
if (Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ', 2)[1]) !== process.pid) continue
|
|
807
|
+
const argv = readFileSync(`/proc/${entry}/cmdline`, 'utf8').split('\0')
|
|
808
|
+
if (argv.some((a) => [...sockets].some((s) => a.startsWith(`UNIX-LISTEN:${s},`)))) {
|
|
809
|
+
pids.push(Number(entry))
|
|
810
|
+
}
|
|
811
|
+
} catch {
|
|
812
|
+
// exited
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const inodes = new Set<string>()
|
|
816
|
+
for (const fd of readdirSync('/proc/self/fd')) {
|
|
817
|
+
try {
|
|
818
|
+
const link = readlinkSync(`/proc/self/fd/${fd}`)
|
|
819
|
+
if (link.startsWith('socket:[')) inodes.add(link.slice(8, -1))
|
|
820
|
+
} catch {
|
|
821
|
+
// closed
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const tmp = os.tmpdir()
|
|
825
|
+
for (const line of readFileSync('/proc/net/unix', 'utf8').split('\n')) {
|
|
826
|
+
const f = line.trim().split(/\s+/)
|
|
827
|
+
if (f.length < 8 || !inodes.has(f[6]!)) continue
|
|
828
|
+
const p = f.slice(7).join(' ')
|
|
829
|
+
const dir = path.dirname(p)
|
|
830
|
+
if (dir === tmp && path.basename(p).startsWith('srt-mux-')) paths.push(p)
|
|
831
|
+
else if (path.dirname(dir) === tmp && path.basename(dir).startsWith('srt-obs-')) {
|
|
832
|
+
paths.push(dir)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
// Best-effort, as the guard is: what was found is listed.
|
|
837
|
+
}
|
|
838
|
+
return { pids, paths }
|
|
839
|
+
}
|
|
840
|
+
|
|
769
841
|
/** `reset` is the one `initSandbox` waits out before it starts SRT again. */
|
|
770
842
|
function trackReset(reset: Promise<unknown>): void {
|
|
771
843
|
const settled = reset.then(
|
|
772
844
|
() => {},
|
|
773
845
|
() => {},
|
|
774
846
|
)
|
|
847
|
+
// Struck once the runtime has killed its bridges and removed its files;
|
|
848
|
+
// a reset `exit` starts never settles, and the guard takes the rest.
|
|
849
|
+
const release = sessionRelease
|
|
850
|
+
sessionRelease = undefined
|
|
851
|
+
if (release !== undefined) void settled.then(release)
|
|
775
852
|
resetting = settled
|
|
776
853
|
void settled.then(() => {
|
|
777
854
|
if (resetting === settled) resetting = undefined
|
|
@@ -1180,7 +1257,12 @@ async function wrapIn(
|
|
|
1180
1257
|
tmp: string,
|
|
1181
1258
|
): ReturnType<typeof wrapSandboxedCommand> {
|
|
1182
1259
|
// After the tag: SRT keys violations by the command's first 100 chars.
|
|
1183
|
-
|
|
1260
|
+
// TMP and TEMP pass through from the host, whose temp directory the
|
|
1261
|
+
// sandbox mounts read-only: a tool reading them failed to write there.
|
|
1262
|
+
const alsoTmp = ['TMP', 'TEMP'].filter((name) => args.env[name] !== undefined)
|
|
1263
|
+
const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${alsoTmp
|
|
1264
|
+
.map((name) => `export ${name}="$TMPDIR"; `)
|
|
1265
|
+
.join('')}${javaToolOptionsFix(
|
|
1184
1266
|
process.env['JAVA_TOOL_OPTIONS'],
|
|
1185
1267
|
args.env['JAVA_TOOL_OPTIONS'],
|
|
1186
1268
|
)}${userCommand}`
|
|
@@ -1226,6 +1308,10 @@ async function wrapIn(
|
|
|
1226
1308
|
`holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
|
|
1227
1309
|
)
|
|
1228
1310
|
}
|
|
1311
|
+
if (ports.length > 0) {
|
|
1312
|
+
hostBridges.set(tag, { ports, procs: [] })
|
|
1313
|
+
writeFileSync(portDialScript(tag), PORT_DIAL_SCRIPT)
|
|
1314
|
+
}
|
|
1229
1315
|
const grouped =
|
|
1230
1316
|
process.platform === 'linux'
|
|
1231
1317
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
@@ -1243,7 +1329,7 @@ async function wrapIn(
|
|
|
1243
1329
|
process.platform === 'linux'
|
|
1244
1330
|
? literalReadPaths(customConfig)
|
|
1245
1331
|
: process.platform === 'darwin'
|
|
1246
|
-
? seatbeltBrackets(customConfig)
|
|
1332
|
+
? seatbeltBrackets(customConfig, baselines.allowRead)
|
|
1247
1333
|
: customConfig,
|
|
1248
1334
|
ports.length > 0 || asksUnixSockets(args.config),
|
|
1249
1335
|
args.config.gitConfig === true,
|
|
@@ -1406,8 +1492,14 @@ function ownGroupCommand(
|
|
|
1406
1492
|
return { command: `${tag0} ${run} ${watch} ${wait}`, forwards: true, traced: true }
|
|
1407
1493
|
}
|
|
1408
1494
|
|
|
1409
|
-
/**
|
|
1410
|
-
|
|
1495
|
+
/**
|
|
1496
|
+
* What strace stops on: the reads, the execs and access probes (a refused
|
|
1497
|
+
* `./gen.sh || fallback` passed unreported), and what moves or makes a
|
|
1498
|
+
* process's cwd. Not the stat family: a stop per stat cost oxlint 19% and
|
|
1499
|
+
* `git status` 30%, where the execs and probes cost nothing measurable.
|
|
1500
|
+
*/
|
|
1501
|
+
const TRACED_CALLS =
|
|
1502
|
+
'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
1411
1503
|
|
|
1412
1504
|
/** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
|
|
1413
1505
|
const TRACE_FD = 5
|
|
@@ -1449,22 +1541,32 @@ function literalReadPaths(
|
|
|
1449
1541
|
* file and the route could not be granted. `[[]` is a class of one `[`; a
|
|
1450
1542
|
* lone `]` is plain text (B-65). A deny path is a real directory, never a
|
|
1451
1543
|
* pattern: a nested project's wall under `[legacy]/` compiled as a class,
|
|
1452
|
-
* matched nothing, and the root task read it.
|
|
1544
|
+
* matched nothing, and the root task read it. So is each of `names`, the
|
|
1545
|
+
* baseline reads: a linked dependency under `packages/[legacy]/` was a
|
|
1546
|
+
* class too, and as an exact regex even `[[]` grants the directory's entry
|
|
1547
|
+
* and none of its files, so its subtree is granted beside it (a trailing
|
|
1548
|
+
* `/**` is stripped before the compile).
|
|
1453
1549
|
*/
|
|
1454
1550
|
export function seatbeltBrackets(
|
|
1455
1551
|
config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
1552
|
+
names: readonly string[] = [],
|
|
1456
1553
|
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
1457
1554
|
const fs = config?.filesystem
|
|
1458
1555
|
if (fs === undefined) return config
|
|
1459
|
-
const literal = (
|
|
1460
|
-
|
|
1556
|
+
const literal = (p: string): string => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']')
|
|
1557
|
+
const bracketed = new Set(names.filter((n) => /[[\]]/.test(n)))
|
|
1558
|
+
const read = (p: string): string[] => {
|
|
1559
|
+
if (!bracketed.has(p)) return [literal(p)]
|
|
1560
|
+
const at = p.replaceAll('[', '[[]')
|
|
1561
|
+
return [at, `${at}/**/*`]
|
|
1562
|
+
}
|
|
1461
1563
|
return {
|
|
1462
1564
|
...config,
|
|
1463
1565
|
filesystem: {
|
|
1464
1566
|
...fs,
|
|
1465
1567
|
denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
|
|
1466
|
-
allowWrite:
|
|
1467
|
-
...(fs.allowRead !== undefined ? { allowRead:
|
|
1568
|
+
allowWrite: fs.allowWrite.map(literal),
|
|
1569
|
+
...(fs.allowRead !== undefined ? { allowRead: fs.allowRead.flatMap(read) } : {}),
|
|
1468
1570
|
},
|
|
1469
1571
|
}
|
|
1470
1572
|
}
|
|
@@ -1517,18 +1619,40 @@ export function portBridgeSocket(tag: string, port: number): string {
|
|
|
1517
1619
|
return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
|
|
1518
1620
|
}
|
|
1519
1621
|
|
|
1622
|
+
/**
|
|
1623
|
+
* Which loopback a bridged connection dials, chosen per connection from
|
|
1624
|
+
* the namespace's listen tables (`$1` the port, `$2` the tables' directory):
|
|
1625
|
+
* `::1` when only it holds the port, else 127.0.0.1. A server bound to
|
|
1626
|
+
* `localhost` on a host that resolves `::1` first (Vite's default) listens
|
|
1627
|
+
* on `::1` alone, and a fixed 127.0.0.1 dial was refused. Read before any
|
|
1628
|
+
* byte moves, so a connection is never retried halfway through.
|
|
1629
|
+
*/
|
|
1630
|
+
export const PORT_DIAL_SCRIPT = `p=$1 n=\${2:-/proc/net}
|
|
1631
|
+
h=$(printf %04X "$p")
|
|
1632
|
+
if grep -qsE "^ *[0-9]+: 0{24}01000000:$h [0-9A-F]+:0{4} 0A" "$n/tcp6" &&
|
|
1633
|
+
! grep -qsE "^ *[0-9]+: (0100007F|0{8}):$h [0-9A-F]+:0{4} 0A" "$n/tcp"; then
|
|
1634
|
+
exec socat - "TCP6:[::1]:$p"
|
|
1635
|
+
fi
|
|
1636
|
+
exec socat - "TCP4:127.0.0.1:$p"
|
|
1637
|
+
`
|
|
1638
|
+
|
|
1639
|
+
/** Where the dial script lives: beside the bridge's sockets, written by `wrapSandboxedCommand`. */
|
|
1640
|
+
function portDialScript(tag: string): string {
|
|
1641
|
+
return path.join(taskTmpdir(tag), `vx-port-dial-${tag}.sh`)
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1520
1644
|
/**
|
|
1521
1645
|
* The task's side of the bridge, in front of the user command inside the
|
|
1522
1646
|
* sandbox: one socat per port, listening on the unix socket and relaying
|
|
1523
|
-
* into the namespace's loopback
|
|
1524
|
-
* exactly as SRT starts its own proxy bridges.
|
|
1525
|
-
* socket a killed task left behind; `>/dev/null`
|
|
1526
|
-
* of the task's frame.
|
|
1647
|
+
* into the namespace's loopback through `PORT_DIAL_SCRIPT`. Backgrounded
|
|
1648
|
+
* and reaped with the shell, exactly as SRT starts its own proxy bridges.
|
|
1649
|
+
* `unlink-early` clears a socket a killed task left behind; `>/dev/null`
|
|
1650
|
+
* keeps its chatter out of the task's frame.
|
|
1527
1651
|
*/
|
|
1528
1652
|
export function portBridgeInner(ports: readonly number[], tag: string): string {
|
|
1529
1653
|
const cmds = ports.map(
|
|
1530
1654
|
(p) =>
|
|
1531
|
-
`socat UNIX-LISTEN:${shellQuote(portBridgeSocket(tag, p))},fork,unlink-early
|
|
1655
|
+
`socat UNIX-LISTEN:${shellQuote(portBridgeSocket(tag, p))},fork,unlink-early ${shellQuote(`SYSTEM:sh ${portDialScript(tag)} ${p}`)} >/dev/null 2>&1 &`,
|
|
1532
1656
|
)
|
|
1533
1657
|
return `${cmds.join(' ')} trap 'kill $(jobs -p) 2>/dev/null' EXIT;`
|
|
1534
1658
|
}
|
|
@@ -1559,7 +1683,14 @@ const hostBridges = new Map<
|
|
|
1559
1683
|
* (2026-10-03). Linux, own procfs only, as the wait.
|
|
1560
1684
|
*/
|
|
1561
1685
|
function portsHeld(ports: readonly number[]): number[] {
|
|
1562
|
-
if (ports.length === 0
|
|
1686
|
+
if (ports.length === 0) return []
|
|
1687
|
+
// This run's own bridges first: a wrap claims its ports before it awaits,
|
|
1688
|
+
// and two tasks granted one port both read the table before either
|
|
1689
|
+
// bridge bound, so the second's bind failed unseen and its clients
|
|
1690
|
+
// reached the first task.
|
|
1691
|
+
const claimed = new Set([...hostBridges.values()].flatMap((b) => b.ports))
|
|
1692
|
+
const ours = ports.filter((p) => claimed.has(p))
|
|
1693
|
+
if (ours.length > 0 || !procfsIsOwn()) return ours
|
|
1563
1694
|
const held = new Set<string>()
|
|
1564
1695
|
for (const [file, any, loop] of [
|
|
1565
1696
|
['/proc/net/tcp', '00000000', '0100007F'],
|
|
@@ -1615,7 +1746,7 @@ async function hostBridgesListen(ports: readonly number[], tag: string): Promise
|
|
|
1615
1746
|
}
|
|
1616
1747
|
|
|
1617
1748
|
function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
1618
|
-
const procs
|
|
1749
|
+
const procs = hostBridges.get(tag)!.procs
|
|
1619
1750
|
for (const p of ports) {
|
|
1620
1751
|
// A spawn failure (no socat on the host) is the task's to report:
|
|
1621
1752
|
// its own side dies the same way, in its frame.
|
|
@@ -1645,10 +1776,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1645
1776
|
// see above
|
|
1646
1777
|
}
|
|
1647
1778
|
}
|
|
1648
|
-
if (procs.length > 0)
|
|
1649
|
-
hostBridges.set(tag, { ports, procs })
|
|
1650
|
-
for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
|
|
1651
|
-
}
|
|
1779
|
+
if (procs.length > 0) for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
|
|
1652
1780
|
}
|
|
1653
1781
|
|
|
1654
1782
|
/**
|
|
@@ -1662,15 +1790,9 @@ export function releaseBridges(tag: string): void {
|
|
|
1662
1790
|
const tmp = taskTmpdir(tag)
|
|
1663
1791
|
if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
|
|
1664
1792
|
if (liveServers.delete(tag)) {
|
|
1665
|
-
// A server's wrap
|
|
1666
|
-
//
|
|
1667
|
-
|
|
1668
|
-
// task's stubs in the workspace until the reset.
|
|
1669
|
-
try {
|
|
1670
|
-
srtLoaded!.SandboxManager.cleanupAfterCommand()
|
|
1671
|
-
} catch {
|
|
1672
|
-
// best-effort, as a one-shot task's
|
|
1673
|
-
}
|
|
1793
|
+
// A server's wrap stays counted until it stops: one stopped server
|
|
1794
|
+
// kept every later task's stubs in the workspace until the reset.
|
|
1795
|
+
afterCommand(srtLoaded!.SandboxManager)
|
|
1674
1796
|
if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
|
|
1675
1797
|
}
|
|
1676
1798
|
const bridges = hostBridges.get(tag)
|
|
@@ -1713,12 +1835,22 @@ const TRACER_RETRY_LINE =
|
|
|
1713
1835
|
* declared, so a second run redoes, not doubles, it.
|
|
1714
1836
|
*/
|
|
1715
1837
|
export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
|
|
1716
|
-
const { tracerFailed, ...first } = await runSandboxedOnce(args)
|
|
1838
|
+
const { tracerFailed, ranMs, ...first } = await runSandboxedOnce(args)
|
|
1717
1839
|
// A stopping run has killed the children it holds; a retry would be one
|
|
1718
1840
|
// spawned after that kill.
|
|
1719
1841
|
if (!tracerFailed || args.signal?.aborted === true) return first
|
|
1720
1842
|
args.onStderr?.(TRACER_RETRY_LINE)
|
|
1721
|
-
|
|
1843
|
+
// `timeout` bounds the task's whole run (schema.md), so the retry gets
|
|
1844
|
+
// what the first attempt left of it, not a fresh window.
|
|
1845
|
+
const {
|
|
1846
|
+
tracerFailed: _again,
|
|
1847
|
+
ranMs: _ran,
|
|
1848
|
+
...second
|
|
1849
|
+
} = await runSandboxedOnce(
|
|
1850
|
+
args.timeoutMs === undefined
|
|
1851
|
+
? args
|
|
1852
|
+
: { ...args, timeoutMs: Math.max(0, args.timeoutMs - ranMs) },
|
|
1853
|
+
)
|
|
1722
1854
|
return {
|
|
1723
1855
|
...second,
|
|
1724
1856
|
durationMs: first.durationMs + second.durationMs,
|
|
@@ -1785,7 +1917,7 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
|
|
|
1785
1917
|
*/
|
|
1786
1918
|
async function runSandboxedOnce(
|
|
1787
1919
|
args: SandboxedRunArgs,
|
|
1788
|
-
): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
|
|
1920
|
+
): Promise<SandboxedRunResult & { tracerFailed: boolean; ranMs: number }> {
|
|
1789
1921
|
const start = Date.now()
|
|
1790
1922
|
const { SandboxManager } = await loadSrt()
|
|
1791
1923
|
// Linux: SRT's store sees only writes (below), so read denials need
|
|
@@ -1794,11 +1926,11 @@ async function runSandboxedOnce(
|
|
|
1794
1926
|
// share a stream. Skipped when strace isn't on PATH — bwrap still
|
|
1795
1927
|
// enforces structurally; we just lose the structured violation list.
|
|
1796
1928
|
//
|
|
1797
|
-
// We trace
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
1801
|
-
//
|
|
1929
|
+
// We trace `openat`, the read itself, plus `execve` and the access
|
|
1930
|
+
// probes (`TRACED_CALLS`). Not `statx` / `newfstatat`: a stop per stat
|
|
1931
|
+
// is a tax on every stat-heavy task. A probe's ENOENT counts only where
|
|
1932
|
+
// the host has the path (`parseStraceViolations`), so a PATH walk past
|
|
1933
|
+
// a missing `node_modules/.bin` entry reports nothing.
|
|
1802
1934
|
//
|
|
1803
1935
|
// `--seccomp-bpf` is what makes that filter cheap: without it strace
|
|
1804
1936
|
// ptrace-stops the tracee on EVERY syscall and discards the untraced
|
|
@@ -1808,7 +1940,7 @@ async function runSandboxedOnce(
|
|
|
1808
1940
|
// 24/24; under `strace -f -e trace=openat` the same four fail with
|
|
1809
1941
|
// medians 2.5–7× over budget; with `--seccomp-bpf` 24/24 again), and
|
|
1810
1942
|
// it taxed every other sandboxed task the same way. With the flag the
|
|
1811
|
-
// kernel filter stops only on
|
|
1943
|
+
// kernel filter stops only on the traced calls. strace ≥ 5.3 (2019); an older
|
|
1812
1944
|
// one gets the slow form rather than no detection.
|
|
1813
1945
|
const useStrace = await wantsStraceDetection()
|
|
1814
1946
|
// Before the spawn: what the task creates under a widened grant is its own.
|
|
@@ -1835,6 +1967,7 @@ async function runSandboxedOnce(
|
|
|
1835
1967
|
signal,
|
|
1836
1968
|
violations: [],
|
|
1837
1969
|
tracerFailed: false,
|
|
1970
|
+
ranMs: 0,
|
|
1838
1971
|
}
|
|
1839
1972
|
}
|
|
1840
1973
|
// Beside the task directories, which every sandbox replaces with its own:
|
|
@@ -1887,6 +2020,7 @@ async function runSandboxedOnce(
|
|
|
1887
2020
|
spawnFailed: true,
|
|
1888
2021
|
violations: [],
|
|
1889
2022
|
tracerFailed: false,
|
|
2023
|
+
ranMs: 0,
|
|
1890
2024
|
}
|
|
1891
2025
|
} finally {
|
|
1892
2026
|
// The child holds its own copy; ours would keep nothing but a descriptor.
|
|
@@ -1896,6 +2030,7 @@ async function runSandboxedOnce(
|
|
|
1896
2030
|
args.liveChildren?.add(proc)
|
|
1897
2031
|
args.onSpawn?.(proc.pid)
|
|
1898
2032
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
2033
|
+
const armedAt = Date.now()
|
|
1899
2034
|
const ac = new AbortController()
|
|
1900
2035
|
// The unfinished last line of stderr, and whether a line was strace's.
|
|
1901
2036
|
let partial = ''
|
|
@@ -1920,6 +2055,7 @@ async function runSandboxedOnce(
|
|
|
1920
2055
|
// OR a clean exit that backgrounds a process) can't hang the run — timeout
|
|
1921
2056
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
|
1922
2057
|
await proc.exited
|
|
2058
|
+
const ranMs = Date.now() - armedAt
|
|
1923
2059
|
await timeout.settle()
|
|
1924
2060
|
let cut = false
|
|
1925
2061
|
if (timeout.timedOut()) ac.abort()
|
|
@@ -1999,6 +2135,7 @@ async function runSandboxedOnce(
|
|
|
1999
2135
|
records.map((v) => v.line),
|
|
2000
2136
|
bindableWrites(args.config.allowWrite),
|
|
2001
2137
|
scratch,
|
|
2138
|
+
[...baselines.allowRead, ...bindableReads(args.config.allowRead)],
|
|
2002
2139
|
),
|
|
2003
2140
|
...refusedConnections(records.map((v) => v.line)),
|
|
2004
2141
|
]
|
|
@@ -2032,6 +2169,15 @@ async function runSandboxedOnce(
|
|
|
2032
2169
|
})
|
|
2033
2170
|
if (outside.length > 0)
|
|
2034
2171
|
violations.push(outsideWritesHint(outside, baselines.denyRead, args.reportWithin))
|
|
2172
|
+
if (process.platform === 'linux') {
|
|
2173
|
+
const removed = grantRemovalHint(
|
|
2174
|
+
`${stdout}\n${stderr}`,
|
|
2175
|
+
bindableWrites(args.config.allowWrite),
|
|
2176
|
+
args.cwd,
|
|
2177
|
+
args.reportWithin,
|
|
2178
|
+
)
|
|
2179
|
+
if (removed !== undefined) violations.push(removed)
|
|
2180
|
+
}
|
|
2035
2181
|
}
|
|
2036
2182
|
|
|
2037
2183
|
// The one denial macOS never logs. MEASURED 2026-09-05, same machine, two
|
|
@@ -2087,6 +2233,7 @@ async function runSandboxedOnce(
|
|
|
2087
2233
|
// that is someone else's. macOS's `sandbox-exec` execs the command, so
|
|
2088
2234
|
// its usage is the task's.
|
|
2089
2235
|
...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
|
|
2236
|
+
ranMs,
|
|
2090
2237
|
tracerFailed:
|
|
2091
2238
|
straceLog !== undefined &&
|
|
2092
2239
|
!timeout.timedOut() &&
|
|
@@ -2143,6 +2290,45 @@ function outsideWritesHint(
|
|
|
2143
2290
|
return { timestamp: new Date(), hint: true, line }
|
|
2144
2291
|
}
|
|
2145
2292
|
|
|
2293
|
+
/**
|
|
2294
|
+
* Linux: a directory write grant is a bind mount, and a mount point cannot
|
|
2295
|
+
* be removed or renamed, so `rm -rf dist` empties it and then fails with
|
|
2296
|
+
* "Read-only file system" (EBUSY under a writable parent). Binding the
|
|
2297
|
+
* parent instead would let the task write beside the grant, so the
|
|
2298
|
+
* removal stays refused; a failure whose output names a grant on such an
|
|
2299
|
+
* error line gets the spelling that works.
|
|
2300
|
+
*/
|
|
2301
|
+
function grantRemovalHint(
|
|
2302
|
+
output: string,
|
|
2303
|
+
roots: readonly string[],
|
|
2304
|
+
cwd: string,
|
|
2305
|
+
within: string,
|
|
2306
|
+
): SandboxViolation | undefined {
|
|
2307
|
+
const lines = output
|
|
2308
|
+
.split('\n')
|
|
2309
|
+
.filter((l) => /Read-only file system|EROFS|EBUSY|resource busy/i.test(l))
|
|
2310
|
+
if (lines.length === 0) return undefined
|
|
2311
|
+
const named = roots.filter((root) => {
|
|
2312
|
+
const rel = path.relative(cwd, root)
|
|
2313
|
+
const spellings = rel === '' ? [root] : [root, rel]
|
|
2314
|
+
return spellings.some((s) => {
|
|
2315
|
+
const re = new RegExp(`(^|[\\s'"\`(])(\\./)?${RegExp.escape(s)}/?($|[\\s'"\`:,)])`)
|
|
2316
|
+
return lines.some((l) => re.test(l))
|
|
2317
|
+
})
|
|
2318
|
+
})
|
|
2319
|
+
if (named.length === 0) return undefined
|
|
2320
|
+
const grants = named.map((r) => `'${path.relative(within, r) || '.'}/'`).join(', ')
|
|
2321
|
+
const contents = path.relative(cwd, named[0]!) || '.'
|
|
2322
|
+
return {
|
|
2323
|
+
timestamp: new Date(),
|
|
2324
|
+
hint: true,
|
|
2325
|
+
line:
|
|
2326
|
+
`vx: the write grant ${grants} is mounted in place on Linux: the task may empty it ` +
|
|
2327
|
+
`but not remove or rename it ("Read-only file system"). Remove its contents ` +
|
|
2328
|
+
`instead, e.g. \`rm -rf ${contents}/*\`.`,
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2146
2332
|
/** A path as a JS string literal a config can take: a quote in it is escaped. */
|
|
2147
2333
|
const jsString = (p: string): string => (p.includes("'") ? JSON.stringify(p) : `'${p}'`)
|
|
2148
2334
|
|
|
@@ -2208,8 +2394,12 @@ function sbplToken(value: string, field: string): string {
|
|
|
2208
2394
|
*/
|
|
2209
2395
|
export function macProfileRules(c: ResolvedSandboxConfig): string[] {
|
|
2210
2396
|
const rules: string[] = []
|
|
2397
|
+
// A name is an info type to one caller and a sysctl to another (Bun
|
|
2398
|
+
// reads `hw.optional.neon` with sysctl-read), so it grants both.
|
|
2211
2399
|
for (const t of c.systemInfo ?? []) {
|
|
2212
|
-
|
|
2400
|
+
const name = sbplToken(t, 'allow.systemInfo')
|
|
2401
|
+
rules.push(`(allow system-info (info-type "${name}"))`)
|
|
2402
|
+
rules.push(`(allow sysctl-read (sysctl-name "${name}"))`)
|
|
2213
2403
|
}
|
|
2214
2404
|
if (localBindingOn(c)) {
|
|
2215
2405
|
// `*:*`, not `localhost:*`: a dual-stack socket bound to 127.0.0.1 is
|
|
@@ -2323,29 +2513,13 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
|
2323
2513
|
}
|
|
2324
2514
|
|
|
2325
2515
|
/**
|
|
2326
|
-
* Grant paths, with globs handled per platform.
|
|
2327
|
-
*
|
|
2328
|
-
* macOS: SRT's own `pathFilter` turns a glob into `(regex …)` and a literal
|
|
2329
|
-
* into `(subpath …)`, so a pattern is passed through and seatbelt matches
|
|
2330
|
-
* it — including files created DURING the run.
|
|
2331
|
-
*
|
|
2332
|
-
* Linux: a grant is a bwrap bind mount, and you cannot mount a pattern.
|
|
2333
|
-
* The glob is expanded against the filesystem here, which means it covers
|
|
2334
|
-
* what exists when the task STARTS. A pattern matching a file the task
|
|
2335
|
-
* creates later grants nothing there — declare its directory instead.
|
|
2336
|
-
*
|
|
2337
|
-
* That last sentence is the whole contract, and until item 496 a task
|
|
2338
|
-
* that broke it learned so from its OWN tool. Measured, one task per
|
|
2339
|
-
* spelling, each writing files it declares:
|
|
2340
|
-
*
|
|
2341
|
-
* write: ['g/**
|
|
2342
2516
|
* The walls a glob grant can reach: each one under (or at) a glob's
|
|
2343
2517
|
* literal head. On Linux `expandGrants` drops such a hit before the bind
|
|
2344
2518
|
* (B-1); seatbelt matches a glob as a path regex, with no hit to drop.
|
|
2345
2519
|
*/
|
|
2346
2520
|
export function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
|
|
2347
2521
|
const heads = grants
|
|
2348
|
-
.filter((g) =>
|
|
2522
|
+
.filter((g) => namedPath(g) === undefined)
|
|
2349
2523
|
.map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
|
|
2350
2524
|
return walls.filter((w) => heads.some((h) => atOrUnder(w, h)))
|
|
2351
2525
|
}
|
|
@@ -2369,7 +2543,7 @@ export function darwinWallRules(
|
|
|
2369
2543
|
for (const w of walls) {
|
|
2370
2544
|
const at = `(subpath "${sbplResolvedPath(w, 'wall')}")`
|
|
2371
2545
|
const kept = literals
|
|
2372
|
-
.filter((l) =>
|
|
2546
|
+
.filter((l) => atOrUnder(l, w))
|
|
2373
2547
|
.map((l) => `(require-not (subpath "${sbplResolvedPath(l, 'grant')}"))`)
|
|
2374
2548
|
rules.push(
|
|
2375
2549
|
kept.length === 0
|
|
@@ -2378,12 +2552,31 @@ export function darwinWallRules(
|
|
|
2378
2552
|
)
|
|
2379
2553
|
}
|
|
2380
2554
|
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2555
|
+
// A baseline is a name the filesystem handed back, never a pattern: a
|
|
2556
|
+
// bracket in it does not make it a glob.
|
|
2557
|
+
const named = (grants: readonly string[]): string[] => grants.flatMap((g) => namedPath(g) ?? [])
|
|
2558
|
+
deny('file-read-data', c.wallsReached?.read ?? [], [...named(c.allowRead), ...baseAllowRead])
|
|
2559
|
+
deny('file-write*', c.wallsReached?.write ?? [], named(c.allowWrite))
|
|
2383
2560
|
return rules
|
|
2384
2561
|
}
|
|
2385
2562
|
|
|
2386
|
-
/**
|
|
2563
|
+
/**
|
|
2564
|
+
* Grant paths, with globs handled per platform.
|
|
2565
|
+
*
|
|
2566
|
+
* macOS: SRT's own `pathFilter` turns a glob into `(regex …)` and a literal
|
|
2567
|
+
* into `(subpath …)`, so a pattern is passed through and seatbelt matches
|
|
2568
|
+
* it — including files created DURING the run.
|
|
2569
|
+
*
|
|
2570
|
+
* Linux: a grant is a bwrap bind mount, and you cannot mount a pattern.
|
|
2571
|
+
* The glob is expanded against the filesystem here, which means it covers
|
|
2572
|
+
* what exists when the task STARTS. A pattern matching a file the task
|
|
2573
|
+
* creates later grants nothing there — declare its directory instead.
|
|
2574
|
+
*
|
|
2575
|
+
* That last sentence is the whole contract, and until item 496 a task
|
|
2576
|
+
* that broke it learned so from its OWN tool. Measured, one task per
|
|
2577
|
+
* spelling, each writing files it declares:
|
|
2578
|
+
*
|
|
2579
|
+
* write: ['g/**'] ok — collapsed to the directory
|
|
2387
2580
|
* write: ['g/a.txt'] ok — a literal is widened to its directory
|
|
2388
2581
|
* write: ['g/*'] FAILED: `bash: g/a.txt: Read-only file system`
|
|
2389
2582
|
* write: ['g/*.txt'] FAILED, same
|
|
@@ -2451,8 +2644,9 @@ function expandGrants(
|
|
|
2451
2644
|
// A hit that IS a wall, or lies inside one, was matched, not named:
|
|
2452
2645
|
// `read: ['*']` in a root project bound `.git` and `.vx`, and
|
|
2453
2646
|
// `packages/*` a nested project its key excludes (B-1). A grant that
|
|
2454
|
-
// names a wall literally never reaches this loop and stays
|
|
2455
|
-
|
|
2647
|
+
// names a wall literally never reaches this loop and stays, and one
|
|
2648
|
+
// spelled with escaped brackets names it as surely.
|
|
2649
|
+
if (namedPath(p) === undefined && walls.some((w) => atOrUnder(real, w))) continue
|
|
2456
2650
|
out.push(abs)
|
|
2457
2651
|
hits++
|
|
2458
2652
|
}
|
|
@@ -2542,10 +2736,16 @@ function grantSpelled(p: string, within: string): string {
|
|
|
2542
2736
|
* names what is missing when there is no form, for the warning and for
|
|
2543
2737
|
* `vx info`.
|
|
2544
2738
|
*/
|
|
2545
|
-
let straceAvailableCache: { form: false | 'plain' | 'seccomp'; why: string } | undefined
|
|
2546
|
-
|
|
2547
|
-
if (process.platform !== 'linux') return { form: false, why: '' }
|
|
2548
|
-
|
|
2739
|
+
let straceAvailableCache: Promise<{ form: false | 'plain' | 'seccomp'; why: string }> | undefined
|
|
2740
|
+
function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
|
|
2741
|
+
if (process.platform !== 'linux') return Promise.resolve({ form: false, why: '' })
|
|
2742
|
+
// The promise is the memo, not its answer: a run starts a wave of tasks
|
|
2743
|
+
// at once, and each asked before the first answer landed (one probe per task).
|
|
2744
|
+
straceAvailableCache ??= probeStrace()
|
|
2745
|
+
return straceAvailableCache
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
async function probeStrace(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
|
|
2549
2749
|
try {
|
|
2550
2750
|
const p = Bun.spawn([executablePath('strace'), '--version'], {
|
|
2551
2751
|
stdout: 'pipe',
|
|
@@ -2553,33 +2753,28 @@ async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why:
|
|
|
2553
2753
|
})
|
|
2554
2754
|
const out = await new Response(p.stdout).text()
|
|
2555
2755
|
await p.exited
|
|
2556
|
-
if (p.exitCode !== 0) {
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
if (refused
|
|
2568
|
-
refused = await traceRefusal('plain')
|
|
2569
|
-
if (refused === null) form = 'plain'
|
|
2570
|
-
}
|
|
2571
|
-
straceAvailableCache =
|
|
2572
|
-
refused === null
|
|
2573
|
-
? { form, why: '' }
|
|
2574
|
-
: { form: false, why: `strace cannot trace here (${refused.line})` }
|
|
2756
|
+
if (p.exitCode !== 0) return { form: false, why: `strace --version exited ${p.exitCode}` }
|
|
2757
|
+
const m = /version (\d+)\.(\d+)/.exec(out)
|
|
2758
|
+
const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
|
|
2759
|
+
let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
|
|
2760
|
+
let refused = await traceRefusal(form)
|
|
2761
|
+
// A strace that cannot check the seccomp filter says so and traces on
|
|
2762
|
+
// without it, exit 0. Inside the sandbox that line read as the trace
|
|
2763
|
+
// cut short, and every sandboxed task ran twice: the plain form, if
|
|
2764
|
+
// it is quiet, is the one that works here.
|
|
2765
|
+
if (refused?.warned === true && form === 'seccomp') {
|
|
2766
|
+
refused = await traceRefusal('plain')
|
|
2767
|
+
if (refused === null) form = 'plain'
|
|
2575
2768
|
}
|
|
2769
|
+
return refused === null
|
|
2770
|
+
? { form, why: '' }
|
|
2771
|
+
: { form: false, why: `strace cannot trace here (${refused.line})` }
|
|
2576
2772
|
} catch {
|
|
2577
2773
|
// Not on PATH. Said as the refused attach is: without it an undeclared
|
|
2578
2774
|
// read is denied but never reported, so a task that tolerates the miss
|
|
2579
2775
|
// passes and caches with no word of it (J's lead).
|
|
2580
|
-
|
|
2776
|
+
return { form: false, why: 'strace is not on PATH' }
|
|
2581
2777
|
}
|
|
2582
|
-
return straceAvailableCache
|
|
2583
2778
|
}
|
|
2584
2779
|
|
|
2585
2780
|
async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {
|