@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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/launcher.cjs +7 -3
  3. package/package.json +5 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +170 -99
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/git-inputs.ts +23 -0
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/inputs.ts +99 -44
  10. package/src/cache/key-fold.ts +1 -1
  11. package/src/cache/layer.ts +9 -6
  12. package/src/cache/policy.ts +5 -0
  13. package/src/cache/zstd.ts +10 -4
  14. package/src/cli/cache.ts +12 -6
  15. package/src/cli/foreign-flags.ts +57 -3
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +10 -4
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +4 -4
  21. package/src/cli/run.ts +73 -30
  22. package/src/cli/select.ts +6 -3
  23. package/src/cli/show.ts +8 -2
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-filter.ts +63 -12
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch-judge.ts +15 -8
  28. package/src/cli/watch.ts +18 -11
  29. package/src/cli/workspace-config.ts +3 -1
  30. package/src/config.ts +42 -21
  31. package/src/exec/executor.ts +1 -1
  32. package/src/exec/kill-tree.ts +89 -18
  33. package/src/exec/proc-sample.ts +1 -1
  34. package/src/exec/runner.ts +74 -36
  35. package/src/exec/sandbox-paths.ts +12 -1
  36. package/src/exec/sandbox-runtime.ts +283 -88
  37. package/src/exec/sandbox-violations.ts +115 -15
  38. package/src/graph/scheduler.ts +30 -14
  39. package/src/graph/task-graph.ts +265 -44
  40. package/src/orchestrator/admission.ts +12 -3
  41. package/src/orchestrator/affected-tasks.ts +6 -2
  42. package/src/orchestrator/deferred-outputs.ts +3 -2
  43. package/src/orchestrator/doctor.ts +2 -2
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/excluded-keys.ts +2 -13
  46. package/src/orchestrator/execute-task.ts +85 -39
  47. package/src/orchestrator/failure-mode.ts +43 -28
  48. package/src/orchestrator/framed-output.ts +8 -8
  49. package/src/orchestrator/index.ts +0 -1
  50. package/src/orchestrator/keyed-projects.ts +6 -3
  51. package/src/orchestrator/logger.ts +21 -9
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +9 -13
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +7 -23
  56. package/src/orchestrator/plugin.ts +37 -16
  57. package/src/orchestrator/prepare.ts +13 -4
  58. package/src/orchestrator/run-artifacts.ts +4 -3
  59. package/src/orchestrator/run-lock.ts +25 -24
  60. package/src/orchestrator/run.ts +22 -5
  61. package/src/orchestrator/sandbox-request.ts +96 -18
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +7 -13
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/telemetry.ts +16 -7
  68. package/src/orchestrator/upstream.ts +11 -0
  69. package/src/util/hangup.ts +42 -0
  70. package/src/util/index.ts +2 -0
  71. package/src/util/paths.ts +24 -4
  72. package/src/workspace/affected.ts +79 -42
  73. package/src/workspace/config-eval.ts +27 -6
  74. package/src/workspace/config-imports.ts +8 -53
  75. package/src/workspace/config-schema.ts +56 -9
  76. package/src/workspace/filter.ts +19 -0
  77. package/src/workspace/lockfile.ts +2 -1
  78. package/src/workspace/migrate-scripts.ts +5 -3
  79. package/src/workspace/migration.ts +1 -1
  80. package/src/workspace/project-loader.ts +19 -4
  81. package/src/workspace/repo-id.ts +42 -4
  82. 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
- if (!srtUp) await unlinkStaleMuxSockets()
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
- const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
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
- /** What strace stops on: the reads, and what moves or makes a process's cwd. */
1410
- const TRACED_CALLS = 'trace=openat,chdir,fchdir,clone,?clone3,?fork,?vfork'
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 = (paths: readonly string[]): string[] =>
1460
- paths.map((p) => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']'))
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: literal(fs.allowWrite),
1467
- ...(fs.allowRead !== undefined ? { allowRead: literal(fs.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. Backgrounded and reaped with the shell,
1524
- * exactly as SRT starts its own proxy bridges. `unlink-early` clears a
1525
- * socket a killed task left behind; `>/dev/null` keeps its chatter out
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 TCP:127.0.0.1:${p} >/dev/null 2>&1 &`,
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 || !procfsIsOwn()) return []
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: Array<ReturnType<typeof Bun.spawn>> = []
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 counts as a live sandbox in SRT until this, and SRT
1666
- // removes bwrap's host stubs (`.bashrc`, `.vscode`, … under a write
1667
- // grant) only at a count of 0: one stopped server kept every later
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
- const { tracerFailed: _again, ...second } = await runSandboxedOnce(args)
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 only `openat` — it's the actual file-read attempt, the
1798
- // signal the user cares about. `statx` / `newfstatat` / `access`
1799
- // are mostly shell PATH-walking and stat probes that aren't
1800
- // actionable (we'd report every node_modules/.bin entry the shell
1801
- // checks before resolving a command).
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 `openat`. strace ≥ 5.3 (2019); an older
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
- rules.push(`(allow system-info (info-type "${sbplToken(t, 'allow.systemInfo')}"))`)
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) => !isMountableLiteral(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) => isMountableLiteral(l) && atOrUnder(l, w))
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
- deny('file-read-data', c.wallsReached?.read ?? [], [...c.allowRead, ...baseAllowRead])
2382
- deny('file-write*', c.wallsReached?.write ?? [], c.allowWrite)
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
- /**'] ok — collapsed to the directory
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
- if (walls.some((w) => atOrUnder(real, w))) continue
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
- async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
2547
- if (process.platform !== 'linux') return { form: false, why: '' }
2548
- if (straceAvailableCache !== undefined) return straceAvailableCache
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
- straceAvailableCache = { form: false, why: `strace --version exited ${p.exitCode}` }
2558
- } else {
2559
- const m = /version (\d+)\.(\d+)/.exec(out)
2560
- const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
2561
- let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
2562
- let refused = await traceRefusal(form)
2563
- // A strace that cannot check the seccomp filter says so and traces on
2564
- // without it, exit 0. Inside the sandbox that line read as the trace
2565
- // cut short, and every sandboxed task ran twice: the plain form, if
2566
- // it is quiet, is the one that works here.
2567
- if (refused?.warned === true && form === 'seccomp') {
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
- straceAvailableCache = { form: false, why: 'strace is not on PATH' }
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'> {