@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -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,12 +63,14 @@ 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,
68
70
  ESSENTIAL_ENV,
69
71
  executablePath,
70
72
  helperEnv,
73
+ holdSecret,
71
74
  taskShell,
72
75
  grantPrefix,
73
76
  isTmpdirRefusal,
@@ -81,9 +84,11 @@ import {
81
84
  bindableReads,
82
85
  bindableWrites,
83
86
  buildCustomConfig,
87
+ linkedWriteBinds,
84
88
  readOnlyMasks,
85
89
  scratchWrites,
86
90
  widenedEntries,
91
+ withLinkedWrites,
87
92
  } from './sandbox-binds.js'
88
93
  import {
89
94
  atOrUnder,
@@ -109,6 +114,7 @@ import {
109
114
  } from './sandbox-violations.js'
110
115
  import {
111
116
  closeSignalChannel,
117
+ groupAlive,
112
118
  killTree,
113
119
  releaseGroup,
114
120
  signalThrough,
@@ -664,6 +670,124 @@ function hostHelpers(): { dirs: string[]; files: string[] } {
664
670
  return { dirs: unique(dirs), files: files.map(toRealPath) }
665
671
  }
666
672
 
673
+ /**
674
+ * What a hermetic task reads outside the workspace without naming it: the
675
+ * programs vx and the runtime run around its command (the runtime's bash,
676
+ * the task shell, setsid, strace, env, socat, the seccomp helper, and the
677
+ * grep and sleep its network wait runs) and what the
678
+ * loader maps for them. Anything else outside the workspace is the task's
679
+ * to grant. Learned once per PATH: the files a host's shell links are
680
+ * fixed for the process.
681
+ */
682
+ interface HermeticReads {
683
+ /** What is denied: `/`, spelled as the runtime can mount it. */
684
+ deny: string[]
685
+ allow: string[]
686
+ }
687
+ let hermeticMemo: { path: string; reads: Promise<HermeticReads> } | undefined
688
+ function hermeticReads(): Promise<HermeticReads> {
689
+ const PATH = process.env['PATH'] ?? ''
690
+ if (hermeticMemo?.path !== PATH) hermeticMemo = { path: PATH, reads: learnHermeticReads() }
691
+ return hermeticMemo.reads
692
+ }
693
+
694
+ async function learnHermeticReads(): Promise<HermeticReads> {
695
+ return { deny: rootDenies(), allow: await hermeticAllows() }
696
+ }
697
+
698
+ /**
699
+ * `/` as the runtime can deny it. Given `/`, it lays a tmpfs over every
700
+ * entry of the root, and bwrap cannot mount one on a link: a merged-/usr
701
+ * host's `/sbin -> usr/sbin` failed every hermetic task. A link at the
702
+ * root leads into a directory denied here, so it is left as it is. `/proc`,
703
+ * `/dev` and `/sys` are the runtime's own mounts; macOS denies by path, so
704
+ * `/` stands.
705
+ */
706
+ function rootDenies(): string[] {
707
+ if (process.platform !== 'linux') return ['/']
708
+ const skip = new Set(['proc', 'dev', 'sys'])
709
+ return readdirSync('/', { withFileTypes: true })
710
+ .filter((e) => !skip.has(e.name) && !e.isSymbolicLink())
711
+ .map((e) => `/${e.name}`)
712
+ }
713
+
714
+ async function hermeticAllows(): Promise<string[]> {
715
+ const programs: string[] = []
716
+ for (const name of ['bash', 'setsid', 'strace', 'env', 'socat', 'grep', 'sleep']) {
717
+ try {
718
+ programs.push(executablePath(name))
719
+ } catch {
720
+ // The runtime's dependency check names a missing one.
721
+ }
722
+ }
723
+ programs.push(taskShell())
724
+ if (process.platform === 'darwin') {
725
+ // dyld and the shared cache every macOS binary maps.
726
+ return unique([...programs.flatMap(bothSpellings), '/usr/lib', '/System'])
727
+ }
728
+ const { getApplySeccompBinaryPath } =
729
+ await import('@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.js')
730
+ const seccomp = getApplySeccompBinaryPath(undefined)
731
+ if (seccomp) programs.push(seccomp)
732
+ const files = [...programs]
733
+ // The loader and the libraries each program links, by `ldd`'s answer.
734
+ let ldd: string
735
+ try {
736
+ ldd = executablePath('ldd')
737
+ } catch {
738
+ throw new UserError(
739
+ 'exec.sandbox.hermetic: no `ldd` on PATH, so vx cannot tell which libraries the ' +
740
+ "task's shell needs — install it (glibc's libc-bin), or drop hermetic",
741
+ )
742
+ }
743
+ const r = Bun.spawnSync({ cmd: [ldd, ...programs], stdout: 'pipe', stderr: 'ignore' })
744
+ for (const m of r.stdout.toString().matchAll(/(\/\S+)\s+\(0x/g)) files.push(m[1]!)
745
+ return unique(files.flatMap(bothSpellings))
746
+ }
747
+
748
+ /**
749
+ * Every spelling the loader walks from `p` to its file: a link (the
750
+ * loader's `/lib64/ld-linux-x86-64.so.2` is one, then `/usr/lib64`'s is
751
+ * another) is bound as the file it reaches, so each hop needs its own
752
+ * grant, named under its directory's real path, where the mount lands.
753
+ */
754
+ function bothSpellings(p: string): string[] {
755
+ const hops: string[] = []
756
+ for (let at = p; hops.length < 40;) {
757
+ const here = path.join(toRealPath(path.dirname(at)), path.basename(at))
758
+ hops.push(here)
759
+ let next: string
760
+ try {
761
+ if (!lstatSync(here).isSymbolicLink()) break
762
+ next = path.resolve(path.dirname(here), readlinkSync(here))
763
+ } catch {
764
+ break
765
+ }
766
+ at = next
767
+ }
768
+ return hops
769
+ }
770
+
771
+ /**
772
+ * The runtime's own sockets a hermetic task's command reaches: the write
773
+ * observer's and the network proxy's, under the host's temp directory.
774
+ * The runtime binds each before the read denies, so the deny's tmpfs over
775
+ * that directory hid them (`connect …/srt-obs-…: No such file`), and no
776
+ * call names the observer's. They are read off the wrap, once per run
777
+ * each: the observer listens after the runtime starts, so a later wrap may
778
+ * name one the first did not.
779
+ */
780
+ const runtimeOwn = new Set<string>()
781
+ function runtimeBinds(wrapped: string): string[] {
782
+ const tmp = toRealPath(os.tmpdir()) + path.sep
783
+ const out: string[] = []
784
+ for (const m of wrapped.matchAll(/--bind (?:'([^']*)'|(\S+)) (?:'([^']*)'|(\S+))/g)) {
785
+ const from = m[1] ?? m[2]!
786
+ if (from === (m[3] ?? m[4]) && from.startsWith(tmp)) out.push(from)
787
+ }
788
+ return out
789
+ }
790
+
667
791
  function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
668
792
  if (process.platform !== 'linux') return {}
669
793
  const paths: { bwrapPath?: string; socatPath?: string } = {}
@@ -815,16 +939,14 @@ function assertDomains(
815
939
  * @param opts.allowedDomains every domain any sandboxed task in this run
816
940
  * declared. SRT's filtering proxy is per-RUN and reads its allowlist from
817
941
  * this call, never from the per-task config, so the union is the only
818
- * place a domain list can take effect. Per-task precision survives where
819
- * it matters: a task that declared none is never handed the proxy port.
942
+ * place a domain list can take effect; each task's own front
943
+ * (`ownProxy`) narrows it to the task's list.
820
944
  */
821
945
  export async function initSandbox(opts?: {
822
946
  allowedDomains?: readonly string[]
823
947
  /**
824
- * Every domain any sandboxed task of the run denies. The proxy is the
825
- * run's, so a deny refuses it for every task, as the allowlist is the
826
- * union's; before B-21 the list was always empty and a `deny.network`
827
- * refused nothing.
948
+ * Every domain any sandboxed task of the run denies, checked here and
949
+ * never handed to the run's proxy: each task's front refuses its own.
828
950
  */
829
951
  deniedDomains?: readonly string[]
830
952
  /**
@@ -853,7 +975,9 @@ export async function initSandbox(opts?: {
853
975
  const config: Parameters<typeof SandboxManager.initialize>[0] = {
854
976
  network: {
855
977
  allowedDomains: [...(opts?.allowedDomains ?? [])],
856
- deniedDomains: [...(opts?.deniedDomains ?? [])],
978
+ // Each task's own front refuses its own denies; the run's proxy
979
+ // refusing the union denied one task's host to every task.
980
+ deniedDomains: [],
857
981
  ...(opts?.allowAllUnixSockets === true ? { allowAllUnixSockets: true } : {}),
858
982
  },
859
983
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
@@ -862,6 +986,8 @@ export async function initSandbox(opts?: {
862
986
  ...(scopedDenyScan ? scopedScanConfig() : {}),
863
987
  ...bundledJavaAgent(),
864
988
  }
989
+ runAllowed = config.network.allowedDomains
990
+ runAllowsHosts = runAllowed.length > 0
865
991
  const wasUp = srtUp
866
992
  if (!wasUp) await unlinkStaleMuxSockets()
867
993
  const listening = srtCleanupAdopted ? undefined : cleanupListeners()
@@ -886,6 +1012,11 @@ export async function initSandbox(opts?: {
886
1012
  // reload of exactly these fields; the proxy and the wrapper read them
887
1013
  // through getters, so the run's config is what every task sees.
888
1014
  SandboxManager.updateConfig(config)
1015
+ // The proxy's session token rides into a networked task's env
1016
+ // (CLOUDSDK_PROXY_PASSWORD, the proxy URLs' password): a task printing
1017
+ // its env put it in the terminal, the cached log and `vx last --log`.
1018
+ const token = SandboxManager.getProxyAuthToken()
1019
+ if (token !== undefined) holdSecret(token)
889
1020
  }
890
1021
 
891
1022
  /**
@@ -920,6 +1051,7 @@ export async function resetSandbox(): Promise<void> {
920
1051
 
921
1052
  function srtDown(): void {
922
1053
  srtUp = false
1054
+ runtimeOwn.clear()
923
1055
  perTaskRun = undefined
924
1056
  availabilityCache.clear()
925
1057
  straceAvailableCache = undefined
@@ -1098,6 +1230,8 @@ export interface ResolvedSandboxConfig {
1098
1230
  * no mount holds them. `scratchWrites` decides which still cover a write.
1099
1231
  */
1100
1232
  pendingWrites?: readonly string[]
1233
+ /** Linux: write grants spelled through a link in the project, bound there too (`linkedWriteBinds`). */
1234
+ linkedWrites?: readonly { readonly link: string; readonly target: string }[]
1101
1235
  network?: true | readonly string[]
1102
1236
  denyNetwork?: readonly string[]
1103
1237
  systemInfo?: readonly string[]
@@ -1108,6 +1242,8 @@ export interface ResolvedSandboxConfig {
1108
1242
  gitConfig?: boolean
1109
1243
  weakerWhenNested?: boolean
1110
1244
  weakerNetworkIsolation?: boolean
1245
+ /** Reads outside the workspace are refused too (`hermeticReads`). */
1246
+ hermetic?: boolean
1111
1247
  /** macOS: the walls a glob grant reaches, by kind (`darwinWallRules`). */
1112
1248
  wallsReached?: { read: readonly string[]; write: readonly string[] }
1113
1249
  ignore?: {
@@ -1193,6 +1329,31 @@ function assertWriteStaysHome(grant: string, real: string, projectDir: string):
1193
1329
  )
1194
1330
  }
1195
1331
 
1332
+ /**
1333
+ * Linux: a literal write grant spelled through a link in the project
1334
+ * (`gen/` over `gen -> real`), as the task names it: the project's real
1335
+ * directory with the grant's own components, links unresolved. The grant
1336
+ * binds at its real path and bwrap mounts no link, so without a second
1337
+ * bind there the task met "Directory nonexistent", or, under a read
1338
+ * grant of the project, the link bound read-only over the same directory
1339
+ * ("Read-only file system"). Seatbelt judges the resolved path, so macOS
1340
+ * needs none.
1341
+ */
1342
+ function linkSpelling(
1343
+ grant: string,
1344
+ real: string,
1345
+ projectDir: string,
1346
+ ): { link: string; target: string } | undefined {
1347
+ if (process.platform !== 'linux' || grant.startsWith('~') || path.isAbsolute(grant)) return
1348
+ // `gen/**` is the directory whole (`expandGrants`).
1349
+ const whole = /\/\*\*(?:\/\*)?$/
1350
+ const lexical = path.resolve(projectDir, grant.replace(whole, ''))
1351
+ const target = real.replace(whole, '')
1352
+ if (!atOrUnder(lexical, projectDir) || !isMountableLiteral(target)) return
1353
+ const link = path.join(toRealPath(projectDir), path.relative(projectDir, lexical))
1354
+ return link === target ? undefined : { link, target }
1355
+ }
1356
+
1196
1357
  /** Where `p` lands through every link on it, a dangling last one included. */
1197
1358
  function throughLinks(p: string, hops = 0): string {
1198
1359
  if (hops > 40) return p
@@ -1227,12 +1388,15 @@ export function resolveSandboxConfig(
1227
1388
  }
1228
1389
  const a = cfg.allow ?? {}
1229
1390
  const pending: string[] = []
1391
+ const linked: Array<{ link: string; target: string }> = []
1230
1392
  const r: ResolvedSandboxConfig = {
1231
1393
  allowRead: expandGrants((a.read ?? []).map(resolve), walls),
1232
1394
  allowWrite: expandGrants(
1233
1395
  (a.write ?? []).map((p) => {
1234
1396
  const real = resolve(p)
1235
1397
  assertWriteStaysHome(p, real, projectDir)
1398
+ const link = linkSpelling(p, real, projectDir)
1399
+ if (link !== undefined) linked.push(link)
1236
1400
  // A directory a write glob matches is writable whole, as Linux's
1237
1401
  // bind of the hit makes it: `<glob>/**` collapses below into the
1238
1402
  // glob and its subtree. SRT compiles a glob as an exact regex, so
@@ -1249,6 +1413,7 @@ export function resolveSandboxConfig(
1249
1413
  ),
1250
1414
  }
1251
1415
  if (pending.length > 0) r.pendingWrites = pending
1416
+ if (linked.length > 0) r.linkedWrites = linked
1252
1417
  if (process.platform === 'darwin') {
1253
1418
  const read = wallsGlobsReach(r.allowRead, walls)
1254
1419
  const write = wallsGlobsReach(r.allowWrite, walls)
@@ -1266,6 +1431,7 @@ export function resolveSandboxConfig(
1266
1431
  if (cfg.weakerNetworkIsolation !== undefined) {
1267
1432
  r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
1268
1433
  }
1434
+ if (cfg.hermetic === true) r.hermetic = true
1269
1435
  if (cfg.ignore !== undefined) {
1270
1436
  // Relative patterns anchor at the project dir, `~` ones at the home
1271
1437
  // directory, as a grant's are (a `~` pattern kept as written matched no
@@ -1320,6 +1486,11 @@ export interface SandboxViolation {
1320
1486
  * leaves it hidden.
1321
1487
  */
1322
1488
  via?: string
1489
+ /**
1490
+ * Linux: what a refused `openat`'s flags asked for; for an `openat2`,
1491
+ * whose flags no record names, a read.
1492
+ */
1493
+ opened?: 'read' | 'write'
1323
1494
  ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
1324
1495
  }
1325
1496
 
@@ -1434,6 +1605,11 @@ async function wrapIn(
1434
1605
  baselines.cwd,
1435
1606
  )
1436
1607
  customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
1608
+ if (args.config.hermetic === true) {
1609
+ const reads = await hermeticReads()
1610
+ customConfig!.filesystem!.denyRead!.push(...reads.deny)
1611
+ customConfig!.filesystem!.allowRead!.push(...reads.allow, ...runtimeOwn)
1612
+ }
1437
1613
  const helpers = hostHelpers()
1438
1614
  customConfig!.filesystem!.denyRead!.push(...helpers.dirs)
1439
1615
  customConfig!.filesystem!.allowRead!.push(...helpers.files)
@@ -1482,11 +1658,13 @@ async function wrapIn(
1482
1658
  ? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
1483
1659
  : { command: taggedCommand, forwards: false, traced: false }
1484
1660
  // The tag leads: SRT keys a record by the wrap's first 100 characters,
1485
- // and the proxy wait below is the same 190 for every networked task.
1661
+ // and the proxy wait below is the same 190 for every task.
1486
1662
  const inner = [
1487
1663
  process.platform === 'linux' ? `: 'vx-${tag}';` : '',
1488
1664
  ports.length > 0 ? portBridgeInner(ports, tag) : '',
1489
- process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
1665
+ process.platform === 'linux' && SandboxManager.getLinuxHttpSocketPath() !== undefined
1666
+ ? PROXY_BRIDGE_WAIT
1667
+ : '',
1490
1668
  grouped.command,
1491
1669
  ]
1492
1670
  .filter((part) => part !== '')
@@ -1502,6 +1680,29 @@ async function wrapIn(
1502
1680
  ports.length > 0 || asksUnixSockets(args.config),
1503
1681
  args.config.gitConfig === true,
1504
1682
  )
1683
+ if (process.platform === 'linux' && args.config.hermetic === true) {
1684
+ const own = runtimeBinds(wrapped).filter((p) => !runtimeOwn.has(p))
1685
+ if (own.length > 0) {
1686
+ for (const p of own) runtimeOwn.add(p)
1687
+ customConfig!.filesystem!.allowRead!.push(...own)
1688
+ wrapped = await wrapForTask(
1689
+ SandboxManager,
1690
+ inner,
1691
+ literalReadPaths(customConfig),
1692
+ ports.length > 0 || asksUnixSockets(args.config),
1693
+ args.config.gitConfig === true,
1694
+ )
1695
+ }
1696
+ }
1697
+ if (runAllowsHosts) {
1698
+ try {
1699
+ wrapped = await ownProxy(SandboxManager, wrapped, inner, tag, args.config)
1700
+ } catch (err) {
1701
+ releaseBridges(tag)
1702
+ afterCommand(SandboxManager)
1703
+ throw err
1704
+ }
1705
+ }
1505
1706
  if (process.platform === 'darwin') {
1506
1707
  const rules = [
1507
1708
  ...macProfileRules(args.config),
@@ -1534,7 +1735,7 @@ async function wrapIn(
1534
1735
  )
1535
1736
  wrapped =
1536
1737
  (gitGranted ? 'GIT_DISCOVERY_ACROSS_FILESYSTEM=${GIT_DISCOVERY_ACROSS_FILESYSTEM-1} ' : '') +
1537
- `exec ${readOnlyMasks(wrapped, scratch)}`
1738
+ `exec ${readOnlyMasks(withLinkedWrites(wrapped, linkedWriteBinds(args.config.linkedWrites)), scratch)}`
1538
1739
  }
1539
1740
  if (process.platform === 'linux' && !hostHasIpv6())
1540
1741
  wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
@@ -1661,16 +1862,23 @@ function ownGroupCommand(
1661
1862
  }
1662
1863
 
1663
1864
  /**
1664
- * What strace stops on: the reads, the execs and access probes (a refused
1665
- * `./gen.sh || fallback` passed unreported), and what moves or makes a
1666
- * process's cwd. Not the stat family: a stop per stat cost oxlint 19% and
1667
- * `git status` 30%, where the execs and probes cost nothing measurable, and
1865
+ * What strace stops on: the reads (`openat`, and `open`, musl's open():
1866
+ * traced as openat alone, a sibling under a widened write grant was read
1867
+ * unreported and cached; arm64 has no `open`; an `openat2` never reaches
1868
+ * strace, SRT's write observer takes each one first: `refusedWrites`),
1869
+ * the execs and access probes (a refused
1870
+ * `./gen.sh || fallback` passed unreported), `readlink` (git hashes a link
1871
+ * by its target string: `readlink cfg > out.txt` under a widened grant
1872
+ * cached `v1` and replayed it after `cfg` pointed at `v2`; a stop costs
1873
+ * ~9 µs, 37 ms on 2,000 links, below the A/A noise on oxlint and vx), and
1874
+ * what moves or makes a process's cwd. Not the stat family: a stop per
1875
+ * stat cost oxlint 19% and `git status` 30%, where the execs and probes cost nothing measurable, and
1668
1876
  * vx's own no-op run 69% (pr-bench, 495 → 835 ms, 2026-10-23). So a tool
1669
1877
  * that stats before it opens, as Ubuntu 26's cat (uutils) does, refused
1670
1878
  * nothing the trace sees.
1671
1879
  */
1672
1880
  const TRACED_CALLS =
1673
- 'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
1881
+ 'trace=openat,?open,execve,?access,faccessat,?faccessat2,?readlink,?readlinkat,chdir,fchdir,clone,?clone3,?fork,?vfork'
1674
1882
 
1675
1883
  /** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
1676
1884
  const TRACE_FD = 5
@@ -1817,10 +2025,88 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
1817
2025
  * command, until both listen in the task's network namespace, read off
1818
2026
  * /proc/net (IPv4 or IPv6, state 0A). Bounded at ~5 s: a bridge that never
1819
2027
  * listens leaves the command to meet the refusal it met before.
2028
+ *
2029
+ * Every task the run's bridge reaches waits, not only one that lists a
2030
+ * network: one that lists none dials the same proxy, which refuses and
2031
+ * names the host, and that refusal fails it. Waited for only by networked
2032
+ * tasks, it met the closed port instead under load, swallowed the error
2033
+ * and passed with nothing reported (2026-11-06). The wait costs ~4 ms, the
2034
+ * socat's own start.
1820
2035
  */
1821
2036
  const PROXY_BRIDGE_WAIT =
1822
2037
  "( 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
2038
 
2039
+ /** Each networked task's own proxy front, by tag, closed when its bridges go. */
2040
+ const taskProxies = new Map<string, TaskProxy>()
2041
+ /** Whether the run's proxy allows any host: one that allows none refuses every task alike, front or not. */
2042
+ let runAllowsHosts = false
2043
+ /** The run's union: what a `network: true` task's front allows. */
2044
+ let runAllowed: readonly string[] = []
2045
+
2046
+ /**
2047
+ * A networked task's own proxy front (task-proxy.ts). The runtime's proxy
2048
+ * is the run's and filters by the union of every task's lists, so a task
2049
+ * reached a host only another task of the run declared. The front judges
2050
+ * by this task's own lists and passes what it allows on to the run's
2051
+ * proxy. Linux: it listens on a socket in the task's tmpdir, bound over
2052
+ * the runtime's bridge socket, so the task's side dials the same path.
2053
+ * macOS: it listens on a loopback port of its own, and the task's proxy
2054
+ * variables and profile name that port in place of the run's, so the
2055
+ * profile refuses the run's. A task that lists no network gets a front
2056
+ * that refuses every host; `network: true` gets one that allows the
2057
+ * run's union. Each front refuses only its own task's denies. Fails closed: a task the front cannot take does not run.
2058
+ */
2059
+ async function ownProxy(
2060
+ SandboxManager: SrtModule['SandboxManager'],
2061
+ wrapped: string,
2062
+ inner: string,
2063
+ tag: string,
2064
+ config: ResolvedSandboxConfig,
2065
+ ): Promise<string> {
2066
+ const port = SandboxManager.getProxyPort()
2067
+ const token = SandboxManager.getProxyAuthToken()
2068
+ const linux = process.platform === 'linux'
2069
+ const bridge = linux ? SandboxManager.getLinuxHttpSocketPath() : undefined
2070
+ const bind = bridge === undefined ? undefined : `--bind ${bridge} ${bridge}`
2071
+ const runPort = new RegExp(`localhost:${port}(?!\\d)`, 'g')
2072
+ const handed =
2073
+ port !== undefined &&
2074
+ token !== undefined &&
2075
+ (linux ? bind !== undefined && wrapped.includes(bind) : runPort.test(wrapped))
2076
+ // A task that lists no network is handed the run's proxy too when another
2077
+ // task of the run lists some; it gets a front that refuses every host.
2078
+ if (!handed) {
2079
+ if (config.network === undefined) return wrapped
2080
+ throw new Error('sandbox: the runtime gave this networked task no proxy to replace')
2081
+ }
2082
+ const socket = path.join(taskTmpdir(tag), 'vx-net.sock')
2083
+ const key = Buffer.from(inner.slice(0, 100)).toString('base64')
2084
+ const front = await startTaskProxy({
2085
+ endpoint: linux ? socket : 0,
2086
+ allowed:
2087
+ config.network === true
2088
+ ? runAllowed
2089
+ : config.network === undefined
2090
+ ? []
2091
+ : (config.network as string[]),
2092
+ denied: config.denyNetwork ?? [],
2093
+ runProxyPort: port!,
2094
+ token: token!,
2095
+ encodedCommand: key,
2096
+ refused: (line) => collecting.get(key)?.push({ line, timestamp: new Date() }),
2097
+ })
2098
+ taskProxies.set(tag, front)
2099
+ if (!linux) return wrapped.replace(runPort, `localhost:${front.port}`)
2100
+ // After the root's read-only bind, which would mask a bind made before it
2101
+ // with the host's own file: the runtime's socket, reachable as itself.
2102
+ const own = `--bind ${socket} ${bridge}`
2103
+ const root = ' --ro-bind / / '
2104
+ const at = wrapped.indexOf(root)
2105
+ return at === -1
2106
+ ? wrapped.replace(bind!, own)
2107
+ : `${wrapped.slice(0, at + root.length)}${own} ${wrapped.slice(at + root.length)}`
2108
+ }
2109
+
1824
2110
  /** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
1825
2111
  export function portBridgeSocket(tag: string, port: number): string {
1826
2112
  return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
@@ -1934,8 +2220,8 @@ async function hostBridgesListen(ports: readonly number[], tag: string): Promise
1934
2220
  if (!procfsIsOwn()) return
1935
2221
  const want = ports.map((p) => `0100007F:${p.toString(16).toUpperCase().padStart(4, '0')}`)
1936
2222
  const procs = hostBridges.get(tag)?.procs ?? []
1937
- const until = Date.now() + 5_000
1938
- while (Date.now() < until && procs.every((p) => p.exitCode === null)) {
2223
+ const until = performance.now() + 5_000
2224
+ while (performance.now() < until && procs.every((p) => p.exitCode === null)) {
1939
2225
  let table: string
1940
2226
  try {
1941
2227
  table = readFileSync('/proc/net/tcp', 'utf8')
@@ -2004,6 +2290,12 @@ export function releaseBridges(tag: string): void {
2004
2290
  afterCommand(srtLoaded!.SandboxManager)
2005
2291
  if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
2006
2292
  }
2293
+ const front = taskProxies.get(tag)
2294
+ if (front !== undefined) {
2295
+ taskProxies.delete(tag)
2296
+ // quiet: a front that will not close holds only its own socket, gone with the tmpdir
2297
+ void front.close().catch(() => undefined)
2298
+ }
2007
2299
  const bridges = hostBridges.get(tag)
2008
2300
  if (bridges === undefined) return
2009
2301
  hostBridges.delete(tag)
@@ -2028,6 +2320,22 @@ export function releaseBridges(tag: string): void {
2028
2320
  const TRACER_RETRY_LINE =
2029
2321
  "[vx] the sandbox's tracer (strace) failed on its own; running the task again\n"
2030
2322
 
2323
+ /** What a task's output says when a mount went away as bwrap set the sandbox up. */
2324
+ const MOUNT_RACE_RETRY_LINE =
2325
+ '[vx] a mount went away as the sandbox was set up; running the task again\n'
2326
+
2327
+ /**
2328
+ * bwrap's own refusal of a mount gone between its read of the mount table
2329
+ * and its remount: a container runtime's transient mount under /tmp
2330
+ * (`/newroot/tmp/containerd-mount…`, a `docker build` beside the task on
2331
+ * CI). The command never started. Or a path the wrap walls, gone before
2332
+ * bwrap mounts on it: bwrap makes the mount point anew under a read-only
2333
+ * parent (`Can't mkdir /tmp/vx-noscan-0: Read-only file system`, the no-scan
2334
+ * directory removed by another process).
2335
+ */
2336
+ const BWRAP_MOUNT_GONE =
2337
+ /^bwrap: Can't (?:.*: No such file or directory|mkdir .*: Read-only file system)$/
2338
+
2031
2339
  /**
2032
2340
  * Run a single task wrapped in the sandbox. Caller must have called
2033
2341
  * `initSandbox()` first.
@@ -2053,20 +2361,21 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
2053
2361
  if (chunk.length > 0) midLine = !chunk.endsWith('\n')
2054
2362
  on?.(chunk)
2055
2363
  }
2056
- const { tracerFailed, ranMs, ...first } = await runSandboxedOnce({
2364
+ const { tracerFailed, mountGone, ranMs, ...first } = await runSandboxedOnce({
2057
2365
  ...args,
2058
2366
  onStdout: seen(args.onStdout),
2059
2367
  onStderr: seen(args.onStderr),
2060
2368
  })
2061
2369
  // A stopping run has killed the children it holds; a retry would be one
2062
2370
  // spawned after that kill.
2063
- if (!tracerFailed || args.signal?.aborted === true) return first
2064
- const notice = (midLine ? '\n' : '') + TRACER_RETRY_LINE
2371
+ if (!(tracerFailed || mountGone) || args.signal?.aborted === true) return first
2372
+ const notice = (midLine ? '\n' : '') + (mountGone ? MOUNT_RACE_RETRY_LINE : TRACER_RETRY_LINE)
2065
2373
  args.onStderr?.(notice)
2066
2374
  // `timeout` bounds the task's whole run (schema.md), so the retry gets
2067
2375
  // what the first attempt left of it, not a fresh window.
2068
2376
  const {
2069
2377
  tracerFailed: _again,
2378
+ mountGone: _gone,
2070
2379
  ranMs: _ran,
2071
2380
  ...second
2072
2381
  } = await runSandboxedOnce(
@@ -2088,18 +2397,21 @@ let collectorOn = false
2088
2397
 
2089
2398
  /**
2090
2399
  * Start collecting SRT's store records for `command`; the returned function
2091
- * stops and hands them over. The store is a 100-record ring the whole run
2092
- * shares, and on Linux its write observer reports every write any task
2400
+ * stops and hands them over, with `settle` once every record the task's
2401
+ * Linux observer (`observer`, its socket) sent is read. The store is a
2402
+ * 100-record ring the whole run shares, and on Linux its write observer
2403
+ * reports every write any task
2093
2404
  * makes, so a refused write followed by 150 declared ones was gone before
2094
2405
  * the task's exit read it (B-7). One subscription takes each record as it
2095
2406
  * arrives and keeps it only for a command still running. SRT keys a record
2096
2407
  * by the base64 of the command's first 100 characters
2097
2408
  * (`encodeSandboxedCommand`, not exported).
2098
2409
  */
2099
- function collectRecords(
2410
+ export function collectRecords(
2100
2411
  store: ReturnType<SrtModule['SandboxManager']['getSandboxViolationStore']>,
2101
2412
  command: string,
2102
- ): () => SandboxViolation[] {
2413
+ observer: string | undefined,
2414
+ ): (settle: boolean) => Promise<SandboxViolation[]> {
2103
2415
  if (!collectorOn) {
2104
2416
  collectorOn = true
2105
2417
  let seen = store.getTotalCount()
@@ -2115,12 +2427,44 @@ function collectRecords(
2115
2427
  const key = Buffer.from(command.slice(0, 100)).toString('base64')
2116
2428
  const list: SandboxViolation[] = []
2117
2429
  collecting.set(key, list)
2118
- return () => {
2430
+ return async (settle) => {
2431
+ if (settle && observer !== undefined) await observerCaughtUp(observer)
2119
2432
  collecting.delete(key)
2120
2433
  return list
2121
2434
  }
2122
2435
  }
2123
2436
 
2437
+ /**
2438
+ * The Unix socket SRT's Linux write observer listens on, from the command
2439
+ * it wrapped (`--setenv SRT_OBSERVE_SOCK <path>`); none when the listener
2440
+ * failed or on macOS.
2441
+ */
2442
+ function observerSocket(wrapped: string): string | undefined {
2443
+ return /--setenv SRT_OBSERVE_SOCK (?:'([^']*)'|(\S+))/.exec(wrapped)?.slice(1).find(Boolean)
2444
+ }
2445
+
2446
+ /**
2447
+ * Resolves once SRT has read every record sent to `socket` before the call.
2448
+ * A task's observer sends its records before the task exits, but they are
2449
+ * read when this process gets to that connection: a fixed count of event
2450
+ * loop turns after the exit (2) lost 4 of 24 refusals on a loaded CI
2451
+ * worker. So a connection of ours goes in after them: the kernel queues
2452
+ * connections in order, so SRT accepts it no earlier than the task's, and
2453
+ * the task's bytes were already there when it did. SRT's server is not
2454
+ * half-open, so it ends ours once it reads our end, and that end coming
2455
+ * back means the task's bytes were read first. A socket that is gone
2456
+ * answers at once with an error.
2457
+ */
2458
+ function observerCaughtUp(socket: string): Promise<void> {
2459
+ return new Promise((resolve) => {
2460
+ const conn = createConnection(socket)
2461
+ conn.on('connect', () => conn.end())
2462
+ conn.on('error', () => resolve())
2463
+ conn.on('close', () => resolve())
2464
+ conn.resume()
2465
+ })
2466
+ }
2467
+
2124
2468
  /**
2125
2469
  * strace's own message, a line of a traced task's stderr. strace names
2126
2470
  * itself by its argv[0], the absolute path vx runs it by
@@ -2140,8 +2484,9 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
2140
2484
  */
2141
2485
  async function runSandboxedOnce(
2142
2486
  args: SandboxedRunArgs,
2143
- ): Promise<SandboxedRunResult & { tracerFailed: boolean; ranMs: number }> {
2144
- const start = Date.now()
2487
+ ): Promise<SandboxedRunResult & { tracerFailed: boolean; mountGone: boolean; ranMs: number }> {
2488
+ // Elapsed time, not wall time: see runCommand.
2489
+ const start = performance.now()
2145
2490
  const { SandboxManager } = await loadSrt()
2146
2491
  // Linux: SRT's store sees only writes (below), so read denials need
2147
2492
  // the command traced and the trace parsed for denied syscalls. The trace
@@ -2149,7 +2494,7 @@ async function runSandboxedOnce(
2149
2494
  // share a stream. Skipped when strace isn't on PATH — bwrap still
2150
2495
  // enforces structurally; we just lose the structured violation list.
2151
2496
  //
2152
- // We trace `openat`, the read itself, plus `execve` and the access
2497
+ // We trace the opens, the read itself, plus `execve` and the access
2153
2498
  // probes (`TRACED_CALLS`). Not `statx` / `newfstatat`: a stop per stat
2154
2499
  // is a tax on every stat-heavy task. A probe's ENOENT counts only where
2155
2500
  // the host has the path (`parseStraceViolations`), so a PATH walk past
@@ -2167,29 +2512,34 @@ async function runSandboxedOnce(
2167
2512
  // one gets the slow form rather than no detection.
2168
2513
  const useStrace = await wantsStraceDetection()
2169
2514
  // Before the spawn: what the task creates under a widened grant is its own.
2170
- const widened = useStrace ? widenedEntries(args.config.allowWrite) : undefined
2515
+ const widened = widenedEntries(args.config.allowWrite)
2171
2516
  const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
2172
2517
  await wrapSandboxedCommand({
2173
2518
  ...args,
2174
2519
  ...(useStrace ? { trace: useStrace } : {}),
2175
- ...(widened !== undefined && widened.size > 0 ? { tracePaths: true } : {}),
2520
+ ...(useStrace && widened.size > 0 ? { tracePaths: true } : {}),
2176
2521
  })
2177
- const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
2522
+ const takeRecords = collectRecords(
2523
+ SandboxManager.getSandboxViolationStore(),
2524
+ srtCommand,
2525
+ observerSocket(wrapped),
2526
+ )
2178
2527
  // A stop that landed during the awaits above leaves nothing to kill yet:
2179
2528
  // spawned now, the task ran after the teardown swept the run's children.
2180
2529
  if (args.signal?.aborted === true) {
2181
2530
  releaseBridges(tag)
2182
- takeRecords()
2531
+ void takeRecords(false)
2183
2532
  afterCommand(SandboxManager)
2184
2533
  const signal = stopSignal(args.signal.reason)
2185
2534
  return {
2186
2535
  exitCode: signalExitCode(signal),
2187
- durationMs: Date.now() - start,
2536
+ durationMs: Math.round(performance.now() - start),
2188
2537
  stdout: '',
2189
2538
  stderr: '',
2190
2539
  signal,
2191
2540
  violations: [],
2192
2541
  tracerFailed: false,
2542
+ mountGone: false,
2193
2543
  ranMs: 0,
2194
2544
  }
2195
2545
  }
@@ -2231,19 +2581,20 @@ async function runSandboxedOnce(
2231
2581
  rmSync(straceLog, { force: true })
2232
2582
  liveTempFiles.delete(straceLog)
2233
2583
  }
2234
- const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
2584
+ const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped, args.env)
2235
2585
  args.onStderr?.(stderr)
2236
2586
  releaseBridges(tag)
2237
- takeRecords()
2587
+ void takeRecords(false)
2238
2588
  afterCommand(SandboxManager)
2239
2589
  return {
2240
2590
  exitCode: 127,
2241
- durationMs: Date.now() - start,
2591
+ durationMs: Math.round(performance.now() - start),
2242
2592
  stdout: '',
2243
2593
  stderr,
2244
2594
  spawnFailed: true,
2245
2595
  violations: [],
2246
2596
  tracerFailed: false,
2597
+ mountGone: false,
2247
2598
  ranMs: 0,
2248
2599
  }
2249
2600
  } finally {
@@ -2256,11 +2607,12 @@ async function runSandboxedOnce(
2256
2607
  args.onSpawn?.(proc.pid)
2257
2608
  const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
2258
2609
  const timeout = armTimeout(proc, args.timeoutMs)
2259
- const armedAt = Date.now()
2610
+ const armedAt = performance.now()
2260
2611
  const ac = new AbortController()
2261
2612
  // The unfinished last line of stderr, and whether a line was strace's.
2262
2613
  let partial = ''
2263
2614
  let straceSpoke = false
2615
+ let mountGone = false
2264
2616
  const [out, err] = pipes.streams(proc)
2265
2617
  const ends = lineEnds(args.onStdout, args.onStderr)
2266
2618
  const streams = Promise.all([
@@ -2273,6 +2625,7 @@ async function runSandboxedOnce(
2273
2625
  const lines = (partial + chunk).split('\n')
2274
2626
  partial = (lines.pop() ?? '').slice(0, 512)
2275
2627
  if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
2628
+ if (lines.some((l) => BWRAP_MOUNT_GONE.test(l))) mountGone = true
2276
2629
  ends.onStderr?.(chunk)
2277
2630
  },
2278
2631
  ac.signal,
@@ -2283,7 +2636,7 @@ async function runSandboxedOnce(
2283
2636
  // OR a clean exit that backgrounds a process) can't hang the run — timeout
2284
2637
  // aborts at once, otherwise drainOrAbort bounds the post-exit drain.
2285
2638
  await proc.exited
2286
- const ranMs = Date.now() - armedAt
2639
+ const ranMs = Math.round(performance.now() - armedAt)
2287
2640
  const looked = watch?.stop()
2288
2641
  await timeout.settle()
2289
2642
  let cut = false
@@ -2294,6 +2647,9 @@ async function runSandboxedOnce(
2294
2647
  const cutLine = ends.own(POST_EXIT_CUT_LINE)
2295
2648
  if (cut) args.onStderr?.(cutLine)
2296
2649
  const stderr = cut ? streamed + cutLine : streamed
2650
+ // As runCommand: a leftover holding no pipe is still in the group. Under
2651
+ // Linux's pid namespace it died with the command; macOS keeps it.
2652
+ const left = cut || groupAlive(proc)
2297
2653
  args.liveChildren?.delete(proc)
2298
2654
  releaseGroup(proc)
2299
2655
  closeSignalChannel(proc)
@@ -2324,15 +2680,9 @@ async function runSandboxedOnce(
2324
2680
  // (2026-09-05). A sandboxed task that fails must say what it was denied;
2325
2681
  // deciding on the user's behalf that a record was "just traversal" is how
2326
2682
  // a failure ends up with an empty violations section and no explanation.
2327
- // Linux: the write observer's records reach the store over a Unix socket
2328
- // this process serves, not with the child's exit. The observer sent them
2329
- // before the task exited, but a task that ends quickly is seen to exit
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()
2683
+ // Linux: the write observer's records arrive over a socket, not with the
2684
+ // exit; `takeRecords(true)` waits until all it sent are read.
2685
+ const records = await takeRecords(true)
2336
2686
  // Read as is on darwin only. Since SRT 0.0.75 the store is fed on Linux
2337
2687
  // too, by the seccomp helper's write observer — but SRT judges those
2338
2688
  // reports against the GLOBAL `filesystem.allowWrite` from `initialize`
@@ -2378,6 +2728,7 @@ async function runSandboxedOnce(
2378
2728
  bindableWrites(args.config.allowWrite),
2379
2729
  scratch,
2380
2730
  [...baselines.allowRead, ...bindableReads(args.config.allowRead)],
2731
+ { entries: widened, granted: args.config.allowWrite },
2381
2732
  ),
2382
2733
  ...refusedConnections(records.map((v) => v.line)),
2383
2734
  ]
@@ -2453,20 +2804,22 @@ async function runSandboxedOnce(
2453
2804
  config: args.config,
2454
2805
  skip: [taskTmpRoot()],
2455
2806
  })
2456
- if (hidden.length > 0) violations.push(hiddenReadsHint(hidden, args.reportWithin))
2807
+ if (hidden.length > 0)
2808
+ violations.push(hiddenReadsHint(hidden, baselines.denyRead, args.reportWithin))
2457
2809
  }
2458
2810
 
2459
2811
  afterCommand(SandboxManager)
2460
2812
 
2461
2813
  return {
2462
2814
  exitCode,
2463
- durationMs: Date.now() - start,
2815
+ durationMs: Math.round(performance.now() - start),
2464
2816
  stdout,
2465
2817
  stderr,
2466
2818
  violations,
2467
2819
  ...(typeof proc.signalCode === 'string' ? { signal: proc.signalCode } : {}),
2468
2820
  ...(timeout.timedOut() ? { timedOut: true } : {}),
2469
- ...(cut ? { leftRunning: true } : {}),
2821
+ ...(timeout.sigkilled() ? { killedAfterGrace: true } : {}),
2822
+ ...(left ? { leftRunning: true } : {}),
2470
2823
  // Linux: the task's CPU and peak never reach this wait. bwrap runs it
2471
2824
  // in a pid namespace (`--unshare-pid`), and what the namespace's
2472
2825
  // processes used is not folded into bwrap's usage: a 500 ms busy loop
@@ -2481,6 +2834,7 @@ async function runSandboxedOnce(
2481
2834
  ? {}
2482
2835
  : { cpuMs: looked.cpuMs, peakRssBytes: looked.rssBytes }),
2483
2836
  ranMs,
2837
+ mountGone: exitCode !== 0 && (mountGone || BWRAP_MOUNT_GONE.test(partial)),
2484
2838
  tracerFailed:
2485
2839
  straceLog !== undefined &&
2486
2840
  !timeout.timedOut() &&
@@ -2510,15 +2864,7 @@ function outsideWritesHint(
2510
2864
  ): SandboxViolation {
2511
2865
  const shown = paths.slice(0, 5).join(', ')
2512
2866
  const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
2513
- const home = toRealPath(os.homedir())
2514
- const dir = path.dirname(paths[0]!)
2515
- // In the workspace, from the project, as a committed config spells it:
2516
- // the absolute path held only on the machine that printed it.
2517
- const spelled = walled.some((w) => atOrUnder(dir, toRealPath(w)))
2518
- ? path.relative(toRealPath(within), dir) || '.'
2519
- : atOrUnder(dir, home)
2520
- ? `~${dir.slice(home.length)}`
2521
- : dir
2867
+ const spelled = grantSpelling(path.dirname(paths[0]!), walled, within)
2522
2868
  const refused =
2523
2869
  `vx: the sandbox refused writes outside the project, which are not reported as ` +
2524
2870
  `violations: ${shown}${more}.`
@@ -2584,19 +2930,52 @@ function hostTempRoots(): string[] {
2584
2930
  return [...new Set(['/tmp', '/var/tmp', os.tmpdir()].map(toRealPath))]
2585
2931
  }
2586
2932
 
2587
- function hiddenReadsHint(paths: readonly string[], within: string): SandboxViolation {
2933
+ /**
2934
+ * The hint for reads the wall hid outside the project. A path under a
2935
+ * deny entry other than the ones holding the project (a credential store
2936
+ * or its link's target, vx's own store, the repository's git data outside
2937
+ * the workspace) is walled on purpose and never the suggested grant: an
2938
+ * agent applies a suggestion as given, and `cat ~/.npmrc` was told to
2939
+ * grant `../../home/.npmrc`.
2940
+ */
2941
+ function hiddenReadsHint(
2942
+ paths: readonly string[],
2943
+ walled: readonly string[],
2944
+ within: string,
2945
+ ): SandboxViolation {
2588
2946
  const shown = paths.slice(0, 5).join(', ')
2589
2947
  const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
2948
+ const project = toRealPath(within)
2949
+ const deny = walled.map(toRealPath)
2950
+ const grantable = paths.find((p) => !deny.some((w) => atOrUnder(p, w) && !atOrUnder(project, w)))
2951
+ const said =
2952
+ `vx: the sandbox hid paths outside the project that exist on this machine, which are ` +
2953
+ `not reported as violations: ${shown}${more}.`
2590
2954
  return {
2591
2955
  timestamp: new Date(),
2592
2956
  hint: true,
2593
2957
  line:
2594
- `vx: the sandbox hid paths outside the project that exist on this machine, which are ` +
2595
- `not reported as violations: ${shown}${more}. If the task reads one, grant it, e.g. ` +
2596
- `\`allow: { read: [${jsString(path.relative(toRealPath(within), paths[0]!))}] }\`.`,
2958
+ grantable === undefined
2959
+ ? `${said} They are walled on purpose, so vx suggests no grant for them.`
2960
+ : `${said} If the task reads one, grant it, e.g. ` +
2961
+ `\`allow: { read: [${jsString(grantSpelling(grantable, walled, within))}] }\`.`,
2597
2962
  }
2598
2963
  }
2599
2964
 
2965
+ /**
2966
+ * A path as a committed config spells its grant: from the project inside
2967
+ * the walls (the absolute path holds only on the machine that printed
2968
+ * it), `~/…` under home, else absolute.
2969
+ */
2970
+ function grantSpelling(p: string, walled: readonly string[], within: string): string {
2971
+ const home = toRealPath(os.homedir())
2972
+ return walled.some((w) => atOrUnder(p, toRealPath(w)))
2973
+ ? path.relative(toRealPath(within), p) || '.'
2974
+ : atOrUnder(p, home)
2975
+ ? `~${p.slice(home.length)}`
2976
+ : p
2977
+ }
2978
+
2600
2979
  /**
2601
2980
  * Is `dir` inside a path the sandbox granted for reading? bwrap binds and
2602
2981
  * seatbelt rules are both prefix-based, so a grant covers the subtree.
@@ -2634,7 +3013,7 @@ function sbplToken(value: string, field: string): string {
2634
3013
  *
2635
3014
  * `systemInfo` has no SRT field at any level. The rest DO have fields,
2636
3015
  * but SRT reads `network.allowLocalBinding` / `allowUnixSockets` /
2637
- * `allowMachLookup` off the config given to `initialize()` and never off
3016
+ * `allowMachLookup` / `enableWeakerNetworkIsolation` off the config given to `initialize()` and never off
2638
3017
  * the per-call one (`sandbox-manager.js` getters, 0.0.75 and 0.0.76) — so a per-task
2639
3018
  * grant passed there is silently dropped. vx is per-task by definition,
2640
3019
  * so it emits them itself. The rule text mirrors SRT's own.
@@ -2657,6 +3036,9 @@ export function macProfileRules(c: ResolvedSandboxConfig): string[] {
2657
3036
  rules.push('(allow network-inbound (local ip "*:*"))')
2658
3037
  rules.push('(allow network-outbound (remote ip "localhost:*"))')
2659
3038
  }
3039
+ if (c.weakerNetworkIsolation === true) {
3040
+ rules.push('(allow mach-lookup (global-name "com.apple.trustd.agent"))')
3041
+ }
2660
3042
  if (c.unixSockets === true) {
2661
3043
  rules.push('(allow system-socket (socket-domain AF_UNIX))')
2662
3044
  rules.push('(allow network-bind (local unix-socket (path-regex #"^/")))')