@vzn/vx 0.0.652 → 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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  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 +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -13,7 +13,8 @@ import {
13
13
  toRealPath,
14
14
  unique,
15
15
  } from './sandbox-paths.js'
16
- import type { SandboxedRunArgs } from './sandbox-runtime.js'
16
+ import { shellQuote } from './runner.js'
17
+ import type { ResolvedSandboxConfig, SandboxedRunArgs } from './sandbox-runtime.js'
17
18
  import * as ui from '../ui/index.js'
18
19
 
19
20
  type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
@@ -40,6 +41,48 @@ export function bindableWrites(paths: readonly string[]): string[] {
40
41
  return unique(paths.filter((p) => !holdsGlobChar(p)).map(bindOf))
41
42
  }
42
43
 
44
+ /**
45
+ * Linux: each write grant spelled through a link in the project, as the
46
+ * bind that puts it where the task names it: `[source, at]`, the grant's
47
+ * own bind and its twin under the link. A file grant binds its directory,
48
+ * so its twin is the link's; a link that is the file itself sits in the
49
+ * bound directory already and needs none. SRT skips a write path that is
50
+ * a sibling link (it reads one as leaving its tree), so vx lays these
51
+ * itself (`withLinkedWrites`).
52
+ */
53
+ export function linkedWriteBinds(
54
+ linked: ResolvedSandboxConfig['linkedWrites'],
55
+ ): Array<readonly [string, string]> {
56
+ if (process.platform !== 'linux' || linked === undefined) return []
57
+ const out: Array<readonly [string, string]> = []
58
+ for (const { link, target } of linked) {
59
+ if (holdsGlobChar(target) || holdsGlobChar(link)) continue
60
+ const source = bindOf(target)
61
+ const at = source === target ? link : path.dirname(link)
62
+ if (at !== source && !out.some(([, a]) => a === at)) out.push([source, at])
63
+ }
64
+ return out
65
+ }
66
+
67
+ /**
68
+ * Linux: SRT's bwrap line with each `linkedWriteBinds` pair bound last,
69
+ * before the `--` that ends bwrap's options: after the masks and the read
70
+ * grants, so none lands over it. The link's directory is then a mount of
71
+ * its target, writable as the grant is.
72
+ */
73
+ export function withLinkedWrites(
74
+ wrapped: string,
75
+ binds: ReadonlyArray<readonly [string, string]>,
76
+ ): string {
77
+ if (binds.length === 0) return wrapped
78
+ for (const m of wrapped.matchAll(/(?:'[^']*'|"[^"]*"|[^\s'"])+/g)) {
79
+ if (m[0] !== '--') continue
80
+ const words = binds.map(([s, a]) => `--bind ${shellQuote(s)} ${shellQuote(a)} `).join('')
81
+ return `${wrapped.slice(0, m.index)}${words}${wrapped.slice(m.index)}`
82
+ }
83
+ return wrapped
84
+ }
85
+
43
86
  function bindOf(p: string): string {
44
87
  if (!isMountableLiteral(p)) return p
45
88
  try {
@@ -162,7 +205,12 @@ const warnedSymlinkPunch = new Set<string>()
162
205
  * A grant with no write path under it is returned untouched — the common
163
206
  * case costs nothing, not even a readdir.
164
207
  */
165
- export function punchWritePaths(readPath: string, writePaths: readonly string[]): string[] {
208
+ export function punchWritePaths(
209
+ readPath: string,
210
+ writePaths: readonly string[],
211
+ /** Whether a link child's real path may be bound; one it may not is dropped. */
212
+ reachable: (real: string) => boolean = () => true,
213
+ ): string[] {
166
214
  // Linux only. The shadowing is a property of bwrap MOUNTS; macOS seatbelt
167
215
  // evaluates rules by precedence, so a read grant on a directory and a
168
216
  // write grant inside it coexist. Punching there costs the directory
@@ -193,25 +241,57 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
193
241
  // SRT's config carries no `--symlink`, so the only fix is the grant:
194
242
  // say which one, loudly.
195
243
  try {
196
- if (lstatSync(child).isSymbolicLink()) linked.push(child)
244
+ if (lstatSync(child).isSymbolicLink()) {
245
+ // Bound by its target, a link out to a denied place (`node_modules
246
+ // -> ..`, `shared -> ../b`) granted what the whole grant never did:
247
+ // bound unpunched, the link stays a link into the deny (item 1006's
248
+ // glob hit, through the punch).
249
+ if (!reachable(toRealPath(child))) continue
250
+ linked.push(child)
251
+ }
197
252
  } catch {
198
253
  // vanished between readdir and lstat: nothing to bind either way
199
254
  }
200
- out.push(...punchWritePaths(child, under))
255
+ out.push(...punchWritePaths(child, under, reachable))
201
256
  }
202
257
  if (linked.length > 0 && !warnedSymlinkPunch.has(readPath)) {
203
258
  warnedSymlinkPunch.add(readPath)
204
- ui.say(
205
- 'warn',
206
- `[vx] sandbox: a write grant under ${readPath} makes its ${linked.length} symlinked ` +
207
- `entr${linked.length === 1 ? 'y' : 'ies'} (${linked
208
- .slice(0, 3)
209
- .map((l) => path.basename(l))
210
- .join(', ')}${linked.length > 3 ? ', …' : ''}) plain directories inside the sandbox — ` +
211
- `a package resolved through one loses its siblings. Move the write grant (${under
212
- .map((w) => path.relative(readPath, w))
213
- .join(', ')}) out of it.\n`,
214
- )
259
+ // A link INTO a write grant is that grant under a second name, mounted
260
+ // read-only: the remedy is to grant the name (`linkedWriteBinds` binds
261
+ // it writable), not to move the grant.
262
+ const plain: string[] = []
263
+ for (const l of linked) {
264
+ const w = writePaths.find((w) => atOrUnder(toRealPath(l), w))
265
+ if (w === undefined) {
266
+ plain.push(l)
267
+ continue
268
+ }
269
+ let dir = false
270
+ try {
271
+ dir = statSync(l).isDirectory()
272
+ } catch {
273
+ // dangling: granted as a file
274
+ }
275
+ ui.say(
276
+ 'warn',
277
+ `[vx] sandbox: ${l} is a symlink into the write grant ${w}, and the Linux sandbox ` +
278
+ `mounts it read-only, so a write through it is refused. Grant the link itself to ` +
279
+ `write through it: ${l}${dir ? '/' : ''}\n`,
280
+ )
281
+ }
282
+ if (plain.length > 0) {
283
+ ui.say(
284
+ 'warn',
285
+ `[vx] sandbox: a write grant under ${readPath} makes its ${plain.length} symlinked ` +
286
+ `entr${plain.length === 1 ? 'y' : 'ies'} (${plain
287
+ .slice(0, 3)
288
+ .map((l) => path.basename(l))
289
+ .join(', ')}${plain.length > 3 ? ', …' : ''}) plain directories inside the sandbox — ` +
290
+ `a package resolved through one loses its siblings. Move the write grant (${under
291
+ .map((w) => path.relative(readPath, w))
292
+ .join(', ')}) out of it.\n`,
293
+ )
294
+ }
215
295
  }
216
296
  return out
217
297
  }
@@ -300,9 +380,20 @@ export function buildCustomConfig(
300
380
  const c = args.config
301
381
  const denyRead = unique([...baselines.denyRead])
302
382
  const allowWrite = bindableWrites(unique([...c.allowWrite]))
303
- const allowRead = unique(
304
- [...baselines.allowRead, ...c.allowRead].flatMap((r) => punchWritePaths(r, allowWrite)),
305
- )
383
+ // A link a write grant is spelled through is bound by vx, not SRT: a
384
+ // read grant above it is punched around it all the same.
385
+ const punched = [...allowWrite, ...linkedWriteBinds(c.linkedWrites).map(([, at]) => at)]
386
+ const reads = [...baselines.allowRead, ...c.allowRead]
387
+ // Asked only of a link a punch meets: a target under the deny anchor is
388
+ // bound only where some read grant already reaches it.
389
+ let fence: { denied: string[]; granted: string[] } | undefined
390
+ const reachable = (real: string): boolean => {
391
+ fence ??= { denied: denyRead.map(toRealPath), granted: reads.map(toRealPath) }
392
+ return (
393
+ !fence.denied.some((d) => atOrUnder(real, d)) || fence.granted.some((g) => atOrUnder(real, g))
394
+ )
395
+ }
396
+ const allowRead = unique(reads.flatMap((r) => punchWritePaths(r, punched, reachable)))
306
397
 
307
398
  const custom: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] = {
308
399
  filesystem: {
@@ -70,6 +70,7 @@ import {
70
70
  ESSENTIAL_ENV,
71
71
  executablePath,
72
72
  helperEnv,
73
+ holdSecret,
73
74
  taskShell,
74
75
  grantPrefix,
75
76
  isTmpdirRefusal,
@@ -83,9 +84,11 @@ import {
83
84
  bindableReads,
84
85
  bindableWrites,
85
86
  buildCustomConfig,
87
+ linkedWriteBinds,
86
88
  readOnlyMasks,
87
89
  scratchWrites,
88
90
  widenedEntries,
91
+ withLinkedWrites,
89
92
  } from './sandbox-binds.js'
90
93
  import {
91
94
  atOrUnder,
@@ -111,6 +114,7 @@ import {
111
114
  } from './sandbox-violations.js'
112
115
  import {
113
116
  closeSignalChannel,
117
+ groupAlive,
114
118
  killTree,
115
119
  releaseGroup,
116
120
  signalThrough,
@@ -1008,6 +1012,11 @@ export async function initSandbox(opts?: {
1008
1012
  // reload of exactly these fields; the proxy and the wrapper read them
1009
1013
  // through getters, so the run's config is what every task sees.
1010
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)
1011
1020
  }
1012
1021
 
1013
1022
  /**
@@ -1221,6 +1230,8 @@ export interface ResolvedSandboxConfig {
1221
1230
  * no mount holds them. `scratchWrites` decides which still cover a write.
1222
1231
  */
1223
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 }[]
1224
1235
  network?: true | readonly string[]
1225
1236
  denyNetwork?: readonly string[]
1226
1237
  systemInfo?: readonly string[]
@@ -1318,6 +1329,31 @@ function assertWriteStaysHome(grant: string, real: string, projectDir: string):
1318
1329
  )
1319
1330
  }
1320
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
+
1321
1357
  /** Where `p` lands through every link on it, a dangling last one included. */
1322
1358
  function throughLinks(p: string, hops = 0): string {
1323
1359
  if (hops > 40) return p
@@ -1352,12 +1388,15 @@ export function resolveSandboxConfig(
1352
1388
  }
1353
1389
  const a = cfg.allow ?? {}
1354
1390
  const pending: string[] = []
1391
+ const linked: Array<{ link: string; target: string }> = []
1355
1392
  const r: ResolvedSandboxConfig = {
1356
1393
  allowRead: expandGrants((a.read ?? []).map(resolve), walls),
1357
1394
  allowWrite: expandGrants(
1358
1395
  (a.write ?? []).map((p) => {
1359
1396
  const real = resolve(p)
1360
1397
  assertWriteStaysHome(p, real, projectDir)
1398
+ const link = linkSpelling(p, real, projectDir)
1399
+ if (link !== undefined) linked.push(link)
1361
1400
  // A directory a write glob matches is writable whole, as Linux's
1362
1401
  // bind of the hit makes it: `<glob>/**` collapses below into the
1363
1402
  // glob and its subtree. SRT compiles a glob as an exact regex, so
@@ -1374,6 +1413,7 @@ export function resolveSandboxConfig(
1374
1413
  ),
1375
1414
  }
1376
1415
  if (pending.length > 0) r.pendingWrites = pending
1416
+ if (linked.length > 0) r.linkedWrites = linked
1377
1417
  if (process.platform === 'darwin') {
1378
1418
  const read = wallsGlobsReach(r.allowRead, walls)
1379
1419
  const write = wallsGlobsReach(r.allowWrite, walls)
@@ -1446,6 +1486,11 @@ export interface SandboxViolation {
1446
1486
  * leaves it hidden.
1447
1487
  */
1448
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'
1449
1494
  ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
1450
1495
  }
1451
1496
 
@@ -1613,11 +1658,13 @@ async function wrapIn(
1613
1658
  ? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
1614
1659
  : { command: taggedCommand, forwards: false, traced: false }
1615
1660
  // The tag leads: SRT keys a record by the wrap's first 100 characters,
1616
- // 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.
1617
1662
  const inner = [
1618
1663
  process.platform === 'linux' ? `: 'vx-${tag}';` : '',
1619
1664
  ports.length > 0 ? portBridgeInner(ports, tag) : '',
1620
- process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
1665
+ process.platform === 'linux' && SandboxManager.getLinuxHttpSocketPath() !== undefined
1666
+ ? PROXY_BRIDGE_WAIT
1667
+ : '',
1621
1668
  grouped.command,
1622
1669
  ]
1623
1670
  .filter((part) => part !== '')
@@ -1688,7 +1735,7 @@ async function wrapIn(
1688
1735
  )
1689
1736
  wrapped =
1690
1737
  (gitGranted ? 'GIT_DISCOVERY_ACROSS_FILESYSTEM=${GIT_DISCOVERY_ACROSS_FILESYSTEM-1} ' : '') +
1691
- `exec ${readOnlyMasks(wrapped, scratch)}`
1738
+ `exec ${readOnlyMasks(withLinkedWrites(wrapped, linkedWriteBinds(args.config.linkedWrites)), scratch)}`
1692
1739
  }
1693
1740
  if (process.platform === 'linux' && !hostHasIpv6())
1694
1741
  wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
@@ -1815,16 +1862,23 @@ function ownGroupCommand(
1815
1862
  }
1816
1863
 
1817
1864
  /**
1818
- * What strace stops on: the reads, the execs and access probes (a refused
1819
- * `./gen.sh || fallback` passed unreported), and what moves or makes a
1820
- * process's cwd. Not the stat family: a stop per stat cost oxlint 19% and
1821
- * `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
1822
1876
  * vx's own no-op run 69% (pr-bench, 495 → 835 ms, 2026-10-23). So a tool
1823
1877
  * that stats before it opens, as Ubuntu 26's cat (uutils) does, refused
1824
1878
  * nothing the trace sees.
1825
1879
  */
1826
1880
  const TRACED_CALLS =
1827
- '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'
1828
1882
 
1829
1883
  /** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
1830
1884
  const TRACE_FD = 5
@@ -1971,6 +2025,13 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
1971
2025
  * command, until both listen in the task's network namespace, read off
1972
2026
  * /proc/net (IPv4 or IPv6, state 0A). Bounded at ~5 s: a bridge that never
1973
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.
1974
2035
  */
1975
2036
  const PROXY_BRIDGE_WAIT =
1976
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 );"
@@ -2159,8 +2220,8 @@ async function hostBridgesListen(ports: readonly number[], tag: string): Promise
2159
2220
  if (!procfsIsOwn()) return
2160
2221
  const want = ports.map((p) => `0100007F:${p.toString(16).toUpperCase().padStart(4, '0')}`)
2161
2222
  const procs = hostBridges.get(tag)?.procs ?? []
2162
- const until = Date.now() + 5_000
2163
- 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)) {
2164
2225
  let table: string
2165
2226
  try {
2166
2227
  table = readFileSync('/proc/net/tcp', 'utf8')
@@ -2267,9 +2328,13 @@ const MOUNT_RACE_RETRY_LINE =
2267
2328
  * bwrap's own refusal of a mount gone between its read of the mount table
2268
2329
  * and its remount: a container runtime's transient mount under /tmp
2269
2330
  * (`/newroot/tmp/containerd-mount…`, a `docker build` beside the task on
2270
- * CI). The command never started.
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).
2271
2335
  */
2272
- const BWRAP_MOUNT_GONE = /^bwrap: Can't .*: No such file or directory$/
2336
+ const BWRAP_MOUNT_GONE =
2337
+ /^bwrap: Can't (?:.*: No such file or directory|mkdir .*: Read-only file system)$/
2273
2338
 
2274
2339
  /**
2275
2340
  * Run a single task wrapped in the sandbox. Caller must have called
@@ -2420,7 +2485,8 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
2420
2485
  async function runSandboxedOnce(
2421
2486
  args: SandboxedRunArgs,
2422
2487
  ): Promise<SandboxedRunResult & { tracerFailed: boolean; mountGone: boolean; ranMs: number }> {
2423
- const start = Date.now()
2488
+ // Elapsed time, not wall time: see runCommand.
2489
+ const start = performance.now()
2424
2490
  const { SandboxManager } = await loadSrt()
2425
2491
  // Linux: SRT's store sees only writes (below), so read denials need
2426
2492
  // the command traced and the trace parsed for denied syscalls. The trace
@@ -2428,7 +2494,7 @@ async function runSandboxedOnce(
2428
2494
  // share a stream. Skipped when strace isn't on PATH — bwrap still
2429
2495
  // enforces structurally; we just lose the structured violation list.
2430
2496
  //
2431
- // We trace `openat`, the read itself, plus `execve` and the access
2497
+ // We trace the opens, the read itself, plus `execve` and the access
2432
2498
  // probes (`TRACED_CALLS`). Not `statx` / `newfstatat`: a stop per stat
2433
2499
  // is a tax on every stat-heavy task. A probe's ENOENT counts only where
2434
2500
  // the host has the path (`parseStraceViolations`), so a PATH walk past
@@ -2446,12 +2512,12 @@ async function runSandboxedOnce(
2446
2512
  // one gets the slow form rather than no detection.
2447
2513
  const useStrace = await wantsStraceDetection()
2448
2514
  // Before the spawn: what the task creates under a widened grant is its own.
2449
- const widened = useStrace ? widenedEntries(args.config.allowWrite) : undefined
2515
+ const widened = widenedEntries(args.config.allowWrite)
2450
2516
  const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
2451
2517
  await wrapSandboxedCommand({
2452
2518
  ...args,
2453
2519
  ...(useStrace ? { trace: useStrace } : {}),
2454
- ...(widened !== undefined && widened.size > 0 ? { tracePaths: true } : {}),
2520
+ ...(useStrace && widened.size > 0 ? { tracePaths: true } : {}),
2455
2521
  })
2456
2522
  const takeRecords = collectRecords(
2457
2523
  SandboxManager.getSandboxViolationStore(),
@@ -2467,7 +2533,7 @@ async function runSandboxedOnce(
2467
2533
  const signal = stopSignal(args.signal.reason)
2468
2534
  return {
2469
2535
  exitCode: signalExitCode(signal),
2470
- durationMs: Date.now() - start,
2536
+ durationMs: Math.round(performance.now() - start),
2471
2537
  stdout: '',
2472
2538
  stderr: '',
2473
2539
  signal,
@@ -2515,14 +2581,14 @@ async function runSandboxedOnce(
2515
2581
  rmSync(straceLog, { force: true })
2516
2582
  liveTempFiles.delete(straceLog)
2517
2583
  }
2518
- const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
2584
+ const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped, args.env)
2519
2585
  args.onStderr?.(stderr)
2520
2586
  releaseBridges(tag)
2521
2587
  void takeRecords(false)
2522
2588
  afterCommand(SandboxManager)
2523
2589
  return {
2524
2590
  exitCode: 127,
2525
- durationMs: Date.now() - start,
2591
+ durationMs: Math.round(performance.now() - start),
2526
2592
  stdout: '',
2527
2593
  stderr,
2528
2594
  spawnFailed: true,
@@ -2541,7 +2607,7 @@ async function runSandboxedOnce(
2541
2607
  args.onSpawn?.(proc.pid)
2542
2608
  const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
2543
2609
  const timeout = armTimeout(proc, args.timeoutMs)
2544
- const armedAt = Date.now()
2610
+ const armedAt = performance.now()
2545
2611
  const ac = new AbortController()
2546
2612
  // The unfinished last line of stderr, and whether a line was strace's.
2547
2613
  let partial = ''
@@ -2570,7 +2636,7 @@ async function runSandboxedOnce(
2570
2636
  // OR a clean exit that backgrounds a process) can't hang the run — timeout
2571
2637
  // aborts at once, otherwise drainOrAbort bounds the post-exit drain.
2572
2638
  await proc.exited
2573
- const ranMs = Date.now() - armedAt
2639
+ const ranMs = Math.round(performance.now() - armedAt)
2574
2640
  const looked = watch?.stop()
2575
2641
  await timeout.settle()
2576
2642
  let cut = false
@@ -2581,6 +2647,9 @@ async function runSandboxedOnce(
2581
2647
  const cutLine = ends.own(POST_EXIT_CUT_LINE)
2582
2648
  if (cut) args.onStderr?.(cutLine)
2583
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)
2584
2653
  args.liveChildren?.delete(proc)
2585
2654
  releaseGroup(proc)
2586
2655
  closeSignalChannel(proc)
@@ -2659,6 +2728,7 @@ async function runSandboxedOnce(
2659
2728
  bindableWrites(args.config.allowWrite),
2660
2729
  scratch,
2661
2730
  [...baselines.allowRead, ...bindableReads(args.config.allowRead)],
2731
+ { entries: widened, granted: args.config.allowWrite },
2662
2732
  ),
2663
2733
  ...refusedConnections(records.map((v) => v.line)),
2664
2734
  ]
@@ -2734,20 +2804,22 @@ async function runSandboxedOnce(
2734
2804
  config: args.config,
2735
2805
  skip: [taskTmpRoot()],
2736
2806
  })
2737
- if (hidden.length > 0) violations.push(hiddenReadsHint(hidden, args.reportWithin))
2807
+ if (hidden.length > 0)
2808
+ violations.push(hiddenReadsHint(hidden, baselines.denyRead, args.reportWithin))
2738
2809
  }
2739
2810
 
2740
2811
  afterCommand(SandboxManager)
2741
2812
 
2742
2813
  return {
2743
2814
  exitCode,
2744
- durationMs: Date.now() - start,
2815
+ durationMs: Math.round(performance.now() - start),
2745
2816
  stdout,
2746
2817
  stderr,
2747
2818
  violations,
2748
2819
  ...(typeof proc.signalCode === 'string' ? { signal: proc.signalCode } : {}),
2749
2820
  ...(timeout.timedOut() ? { timedOut: true } : {}),
2750
- ...(cut ? { leftRunning: true } : {}),
2821
+ ...(timeout.sigkilled() ? { killedAfterGrace: true } : {}),
2822
+ ...(left ? { leftRunning: true } : {}),
2751
2823
  // Linux: the task's CPU and peak never reach this wait. bwrap runs it
2752
2824
  // in a pid namespace (`--unshare-pid`), and what the namespace's
2753
2825
  // processes used is not folded into bwrap's usage: a 500 ms busy loop
@@ -2792,15 +2864,7 @@ function outsideWritesHint(
2792
2864
  ): SandboxViolation {
2793
2865
  const shown = paths.slice(0, 5).join(', ')
2794
2866
  const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
2795
- const home = toRealPath(os.homedir())
2796
- const dir = path.dirname(paths[0]!)
2797
- // In the workspace, from the project, as a committed config spells it:
2798
- // the absolute path held only on the machine that printed it.
2799
- const spelled = walled.some((w) => atOrUnder(dir, toRealPath(w)))
2800
- ? path.relative(toRealPath(within), dir) || '.'
2801
- : atOrUnder(dir, home)
2802
- ? `~${dir.slice(home.length)}`
2803
- : dir
2867
+ const spelled = grantSpelling(path.dirname(paths[0]!), walled, within)
2804
2868
  const refused =
2805
2869
  `vx: the sandbox refused writes outside the project, which are not reported as ` +
2806
2870
  `violations: ${shown}${more}.`
@@ -2866,19 +2930,52 @@ function hostTempRoots(): string[] {
2866
2930
  return [...new Set(['/tmp', '/var/tmp', os.tmpdir()].map(toRealPath))]
2867
2931
  }
2868
2932
 
2869
- 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 {
2870
2946
  const shown = paths.slice(0, 5).join(', ')
2871
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}.`
2872
2954
  return {
2873
2955
  timestamp: new Date(),
2874
2956
  hint: true,
2875
2957
  line:
2876
- `vx: the sandbox hid paths outside the project that exist on this machine, which are ` +
2877
- `not reported as violations: ${shown}${more}. If the task reads one, grant it, e.g. ` +
2878
- `\`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))}] }\`.`,
2879
2962
  }
2880
2963
  }
2881
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
+
2882
2979
  /**
2883
2980
  * Is `dir` inside a path the sandbox granted for reading? bwrap binds and
2884
2981
  * seatbelt rules are both prefix-based, so a grant covers the subtree.