@vzn/vx 0.0.215 → 0.0.217

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.215",
3
+ "version": "0.0.217",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.215",
21
- "@vzn/vx-linux-arm64": "0.0.215",
22
- "@vzn/vx-darwin-x64": "0.0.215",
23
- "@vzn/vx-darwin-arm64": "0.0.215"
20
+ "@vzn/vx-linux-x64": "0.0.217",
21
+ "@vzn/vx-linux-arm64": "0.0.217",
22
+ "@vzn/vx-darwin-x64": "0.0.217",
23
+ "@vzn/vx-darwin-arm64": "0.0.217"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -79,6 +79,10 @@ const ALWAYS_IGNORE = [
79
79
  // file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
80
80
  // exclude it (vx is Bun-native; compiling standalone binaries is a common task).
81
81
  '**/*.bun-build',
82
+ // …and, cross-compiling for a target its cache lacks, the directory it
83
+ // extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
84
+ // 60 MB mid-write under the same race (2026-09-29).
85
+ '**/.????????????????-????????.tmp/**',
82
86
  ]
83
87
 
84
88
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
@@ -5,7 +5,13 @@
5
5
 
6
6
  import { lstatSync, readdirSync, statSync } from 'node:fs'
7
7
  import path from 'node:path'
8
- import { isMountableLiteral, localBindingOn, unique } from './sandbox-paths.js'
8
+ import {
9
+ atOrUnder,
10
+ isMountableLiteral,
11
+ localBindingOn,
12
+ MOUNT_WILDCARDS,
13
+ unique,
14
+ } from './sandbox-paths.js'
9
15
  import type { SandboxedRunArgs } from './sandbox-runtime.js'
10
16
 
11
17
  type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
@@ -42,6 +48,37 @@ export function bindableWrites(paths: readonly string[]): string[] {
42
48
  )
43
49
  }
44
50
 
51
+ /**
52
+ * The write globs that matched nothing when the task started, split by
53
+ * where the task's writes under them go. A glob no mount holds is not
54
+ * lost: a path in the workspace that no read or write bind covers is the
55
+ * deny anchor's scratch, and a task may create, write and remove there —
56
+ * nothing it leaves outlives the sandbox. That is a tool's temp directory:
57
+ * `bun build --compile` extracts a cross-compile runtime into
58
+ * `<cwd>/.<hash>-00000000.tmp/` and renames it into its cache, and no
59
+ * bind could name that directory before it existed (2026-09-29). Where a
60
+ * read grant mounts the glob's directory read-only, or it lies outside the
61
+ * workspace, a write under it fails (`mountless`, warned before the task).
62
+ */
63
+ export function scratchWrites(
64
+ pending: readonly string[],
65
+ fs: {
66
+ readonly allowRead?: readonly string[] | undefined
67
+ readonly allowWrite?: readonly string[] | undefined
68
+ },
69
+ anchors: readonly string[],
70
+ ): { scratch: string[]; mountless: string[] } {
71
+ const binds = [...(fs.allowRead ?? []), ...(fs.allowWrite ?? [])]
72
+ const scratch: string[] = []
73
+ const mountless: string[] = []
74
+ for (const glob of pending) {
75
+ const dir = path.dirname(glob.slice(0, glob.search(MOUNT_WILDCARDS) + 1))
76
+ const free = anchors.some((a) => atOrUnder(dir, a)) && !binds.some((b) => atOrUnder(dir, b))
77
+ ;(free ? scratch : mountless).push(glob)
78
+ }
79
+ return { scratch, mountless }
80
+ }
81
+
45
82
  /**
46
83
  * A read grant with the WALLS inside it cut out: the directories of the
47
84
  * projects nested in this one, the repository and vx's own directory. A
@@ -62,7 +62,7 @@ import {
62
62
  UserError,
63
63
  xxh3hex,
64
64
  } from '../util/index.js'
65
- import { bindableWrites, buildCustomConfig } from './sandbox-binds.js'
65
+ import { bindableWrites, buildCustomConfig, scratchWrites } from './sandbox-binds.js'
66
66
  import {
67
67
  atOrUnder,
68
68
  isMountableLiteral,
@@ -76,7 +76,12 @@ import {
76
76
  scopedMandatoryDenies,
77
77
  srtDefaultWritePaths,
78
78
  } from './sandbox-deny-scan.js'
79
- import { parseStraceViolations, refusedWrites, reportableViolations } from './sandbox-violations.js'
79
+ import {
80
+ parseStraceViolations,
81
+ refusedWrites,
82
+ refusedWritesOutside,
83
+ reportableViolations,
84
+ } from './sandbox-violations.js'
80
85
  import {
81
86
  closeSignalChannel,
82
87
  killTree,
@@ -679,6 +684,11 @@ export interface ResolvedSandboxConfig {
679
684
  allowRead: readonly string[]
680
685
  /** Writable path prefixes, absolute. */
681
686
  allowWrite: readonly string[]
687
+ /**
688
+ * Linux: the write globs that matched nothing when the task started, so
689
+ * no mount holds them. `scratchWrites` decides which still cover a write.
690
+ */
691
+ pendingWrites?: readonly string[]
682
692
  network?: true | readonly string[]
683
693
  denyNetwork?: readonly string[]
684
694
  systemInfo?: readonly string[]
@@ -784,18 +794,20 @@ export function resolveSandboxConfig(
784
794
  return toRealPath(path.resolve(projectDir, p))
785
795
  }
786
796
  const a = cfg.allow ?? {}
797
+ const pending: string[] = []
787
798
  const r: ResolvedSandboxConfig = {
788
- allowRead: expandGrants((a.read ?? []).map(resolve), 'read', walls),
799
+ allowRead: expandGrants((a.read ?? []).map(resolve), walls),
789
800
  allowWrite: expandGrants(
790
801
  (a.write ?? []).map((p) => {
791
802
  const real = resolve(p)
792
803
  assertWriteStaysHome(p, real, projectDir)
793
804
  return real
794
805
  }),
795
- 'write',
796
806
  walls,
807
+ pending,
797
808
  ),
798
809
  }
810
+ if (pending.length > 0) r.pendingWrites = pending
799
811
  if (process.platform === 'darwin') {
800
812
  const read = wallsGlobsReach(r.allowRead, walls)
801
813
  const write = wallsGlobsReach(r.allowWrite, walls)
@@ -920,6 +932,8 @@ export async function wrapSandboxedCommand(
920
932
  /** What SRT wrapped, and so what its store keys a record by (the group wrapper included on Linux). */
921
933
  srtCommand: string
922
934
  baselines: CanonicalBaselines
935
+ /** The pending write globs a write may still land under (`scratchWrites`). */
936
+ scratch: string[]
923
937
  /** The command reads its polite signals off fd 3: spawn it with one and `signalThrough` it. */
924
938
  forwardsSignals: boolean
925
939
  /** The command writes its trace to fd TRACE_FD: spawn it with the log there. */
@@ -942,6 +956,7 @@ export async function wrapSandboxedCommand(
942
956
 
943
957
  const baselines = canonicalBaselines(args)
944
958
  const customConfig = buildCustomConfig(args, baselines)
959
+ const scratch = pendingWriteGrants(args.config, customConfig!.filesystem!, baselines.denyRead)
945
960
  customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
946
961
  customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
947
962
  if (scopedDenyScan) {
@@ -998,6 +1013,7 @@ export async function wrapSandboxedCommand(
998
1013
  taggedCommand,
999
1014
  srtCommand: inner,
1000
1015
  baselines,
1016
+ scratch,
1001
1017
  forwardsSignals: grouped.forwards,
1002
1018
  traced: grouped.traced,
1003
1019
  }
@@ -1353,7 +1369,7 @@ async function runSandboxedOnce(
1353
1369
  // kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
1354
1370
  // one gets the slow form rather than no detection.
1355
1371
  const useStrace = await wantsStraceDetection()
1356
- const { wrapped, tag, srtCommand, baselines, forwardsSignals, traced } =
1372
+ const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
1357
1373
  await wrapSandboxedCommand({ ...args, ...(useStrace ? { trace: useStrace } : {}) })
1358
1374
  const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
1359
1375
  // Beside the task directories, which every sandbox replaces with its own:
@@ -1511,6 +1527,7 @@ async function runSandboxedOnce(
1511
1527
  // included, not by the tagged command macOS is keyed by.
1512
1528
  records.map((v) => v.line),
1513
1529
  bindableWrites(args.config.allowWrite),
1530
+ scratch,
1514
1531
  ),
1515
1532
  ]
1516
1533
  : []
@@ -1525,11 +1542,24 @@ async function runSandboxedOnce(
1525
1542
  // ignoreViolations through to the log monitor — that filter is set
1526
1543
  // once globally at initSandbox time. So per-task user overrides have
1527
1544
  // to be applied here, after read-back.
1528
- const violations = reportableViolations([...macViolations, ...linuxViolations], {
1545
+ const recorded = [...macViolations, ...linuxViolations]
1546
+ const violations = reportableViolations(recorded, {
1529
1547
  within: args.reportWithin,
1530
1548
  linked: args.reportLinked,
1531
1549
  config: args.config,
1532
1550
  })
1551
+ // A write refused past the wall is not a violation (nothing a key reads),
1552
+ // but it may be why the task failed: name it, never counted, and only on
1553
+ // a failure, so it can never redden a pass.
1554
+ if (exitCode !== 0) {
1555
+ const outside = refusedWritesOutside(recorded, {
1556
+ within: args.reportWithin,
1557
+ linked: args.reportLinked,
1558
+ config: args.config,
1559
+ skip: [taskTmpRoot(), ...srtDefaultWritePaths()],
1560
+ })
1561
+ if (outside.length > 0) violations.push(outsideWritesHint(outside))
1562
+ }
1533
1563
 
1534
1564
  // The one denial macOS never logs. MEASURED 2026-09-05, same machine, two
1535
1565
  // runs differing only in the grant: with the cwd granted a failing task
@@ -1585,6 +1615,23 @@ async function runSandboxedOnce(
1585
1615
  }
1586
1616
  }
1587
1617
 
1618
+ /** The hint for writes refused outside the project, a few paths named. */
1619
+ function outsideWritesHint(paths: readonly string[]): SandboxViolation {
1620
+ const shown = paths.slice(0, 5).join(', ')
1621
+ const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
1622
+ const home = toRealPath(os.homedir())
1623
+ const dir = path.dirname(paths[0]!)
1624
+ const spelled = atOrUnder(dir, home) ? `~${dir.slice(home.length)}` : dir
1625
+ return {
1626
+ timestamp: new Date(),
1627
+ hint: true,
1628
+ line:
1629
+ `vx: the sandbox refused writes outside the project, which are not reported as ` +
1630
+ `violations: ${shown}${more}. If the task needs one, grant its directory, e.g. ` +
1631
+ `\`allow: { write: ['${spelled}/'] }\`.`,
1632
+ }
1633
+ }
1634
+
1588
1635
  /**
1589
1636
  * Is `dir` inside a path the sandbox granted for reading? bwrap binds and
1590
1637
  * seatbelt rules are both prefix-based, so a grant covers the subtree.
@@ -1822,17 +1869,28 @@ export function darwinWallRules(
1822
1869
  */
1823
1870
  function expandGrants(
1824
1871
  paths: readonly string[],
1825
- kind: 'read' | 'write',
1826
1872
  walls: readonly string[],
1873
+ /** Write globs only: where a pattern that matched nothing is put. */
1874
+ unmatched?: string[],
1827
1875
  ): string[] {
1828
1876
  // A pattern covering a directory WHOLE is that directory. `<d>/**/*` and
1829
1877
  // `<d>/**` match everything UNDER `<d>` and never `<d>` itself, so a task
1830
1878
  // granted `read: ['**/*']` still could not list its own cwd — the exact
1831
1879
  // shape `bun test` and `oxlint` need. Collapsing is not a widening: the
1832
1880
  // pattern already covered every file there; it adds the directory entry.
1833
- const collapsed = paths.map((p) => {
1881
+ //
1882
+ // On macOS a `<d>` that is itself a glob keeps its subtree as `<d>/**/*`:
1883
+ // SRT matches a glob as an exact regex and a literal as a subpath, so the
1884
+ // collapsed `.*.tmp` alone covered the directory and nothing in it, and
1885
+ // `bun build --compile` could create its extraction directory but not
1886
+ // the runtime inside it (2026-09-29). Not `<d>/**`: SRT strips a trailing
1887
+ // `/**` before it compiles the regex (`removeTrailingGlobSuffix`), which
1888
+ // CI's `cp: .a1.tmp/f: Operation not permitted` showed.
1889
+ const collapsed = paths.flatMap((p) => {
1834
1890
  const m = /^(.*?)\/\*\*(?:\/\*)?$/.exec(p)
1835
- return m === null ? p : m[1]!
1891
+ if (m === null) return [p]
1892
+ const dir = m[1]!
1893
+ return process.platform !== 'linux' && !isMountableLiteral(dir) ? [dir, `${dir}/**/*`] : [dir]
1836
1894
  })
1837
1895
  if (process.platform !== 'linux') return collapsed
1838
1896
  const out: string[] = []
@@ -1866,7 +1924,7 @@ function expandGrants(
1866
1924
  out.push(abs)
1867
1925
  hits++
1868
1926
  }
1869
- if (hits === 0 && kind === 'write') writeGrantMatchedNothing(p)
1927
+ if (hits === 0) unmatched?.push(p)
1870
1928
  }
1871
1929
  return out
1872
1930
  }
@@ -1889,6 +1947,26 @@ function scanOrNothing(pattern: string, base: string): Iterable<string> {
1889
1947
  }
1890
1948
  }
1891
1949
 
1950
+ /**
1951
+ * The write globs that matched nothing at the start (Linux): the ones a
1952
+ * write may still land under, in the sandbox's scratch, returned; the ones
1953
+ * a read-only mount or the host's root holds, reported once each
1954
+ * (`writeGrantMatchedNothing`). `anchors` are the deny anchors the scratch
1955
+ * is made of.
1956
+ */
1957
+ export function pendingWriteGrants(
1958
+ config: Pick<ResolvedSandboxConfig, 'pendingWrites'>,
1959
+ fs: {
1960
+ readonly allowRead?: readonly string[] | undefined
1961
+ readonly allowWrite?: readonly string[] | undefined
1962
+ },
1963
+ anchors: readonly string[],
1964
+ ): string[] {
1965
+ const { scratch, mountless } = scratchWrites(config.pendingWrites ?? [], fs, anchors)
1966
+ for (const grant of mountless) writeGrantMatchedNothing(grant)
1967
+ return scratch
1968
+ }
1969
+
1892
1970
  /** Grants already reported — once per process, not per spawn. */
1893
1971
  const warnedEmptyWriteGrant = new Set<string>()
1894
1972
 
@@ -1905,8 +1983,9 @@ function writeGrantMatchedNothing(grant: string): void {
1905
1983
  if (warnedEmptyWriteGrant.has(grant)) return
1906
1984
  warnedEmptyWriteGrant.add(grant)
1907
1985
  process.stderr.write(
1908
- `[vx] sandbox: the write grant ${grant} matches nothing yet, so it mounts nothing and ` +
1909
- `a file the task creates under it will fail with "Read-only file system". A bind mount ` +
1986
+ `[vx] sandbox: the write grant ${grant} matches nothing yet, and a read grant mounts its ` +
1987
+ `directory read-only, so a file the task creates under it will fail with "Read-only file ` +
1988
+ `system". A bind mount ` +
1910
1989
  `covers what exists when the task starts — grant the directory instead: ` +
1911
1990
  `${grantPrefix(grant)}/**\n`,
1912
1991
  )
@@ -200,15 +200,59 @@ export function reportableViolations(
200
200
  // A record the producer did not describe is a seatbelt one, straight
201
201
  // from SRT's store — parse it here so the filters below never see a
202
202
  // platform's line format.
203
- const described = violations.map((v) =>
204
- v.target === undefined ? { ...v, ...describeMacViolation(v.line) } : v,
205
- )
206
203
  return filterIgnored(
207
- loopbackNoise(withinReported(described, opts.within, opts.linked ?? []), opts.config),
204
+ loopbackNoise(
205
+ withinReported(describe(violations), opts.within, opts.linked ?? []),
206
+ opts.config,
207
+ ),
208
208
  opts.config.ignore,
209
209
  )
210
210
  }
211
211
 
212
+ /**
213
+ * The writes refused OUTSIDE the project, as distinct paths: the denials
214
+ * `reportableViolations` drops at the wall. A read there is the walk every
215
+ * process makes from `/`, but a refused write is the task failing to put
216
+ * something it needed: `bun build --compile` extracting a cross-compile
217
+ * runtime into `~/.bun/install/cache` said only "Failed to extract
218
+ * executable" and the report named nothing (2026-09-29). Never a
219
+ * violation — a failed task's hint (`runSandboxed`). The kernel's
220
+ * pseudo-files, a descriptor's non-path, and what `skip` names (the task's
221
+ * own temp root, SRT's default write paths) are left out: Linux's observer
222
+ * records every write ATTEMPT, and a write to `/dev/null` or the task's
223
+ * `TMPDIR` landed.
224
+ */
225
+ export function refusedWritesOutside(
226
+ violations: readonly SandboxViolation[],
227
+ opts: {
228
+ within: string
229
+ linked?: readonly string[]
230
+ config: ResolvedSandboxConfig
231
+ skip: readonly string[]
232
+ },
233
+ ): string[] {
234
+ const reported = reportedWithin(opts.within, opts.linked ?? [])
235
+ const skip = ['/dev', '/proc', '/sys', ...opts.skip.map(toRealPath)]
236
+ const paths = new Set<string>()
237
+ for (const v of filterIgnored(describe(violations), opts.config.ignore)) {
238
+ // Write-only: a strace record is ignorable as a read OR a write, and
239
+ // names a READ (a sibling's file at the wall is the sandbox working).
240
+ const write = v.ignorable?.length === 1 && v.ignorable[0] === 'write'
241
+ // Absolute only: a descriptor the runtime writes through (`/proc/self/fd/N`)
242
+ // canonicalizes to `anon_inode:[eventfd]`, which names no place.
243
+ if (v.path === undefined || !path.isAbsolute(v.path) || !write || reported(v)) continue
244
+ if (!skip.some((s) => atOrUnder(v.path!, s))) paths.add(v.path)
245
+ }
246
+ return [...paths]
247
+ }
248
+
249
+ /** Every record described: a seatbelt one parsed, a Linux one as produced. */
250
+ function describe(violations: readonly SandboxViolation[]): SandboxViolation[] {
251
+ return violations.map((v) =>
252
+ v.target === undefined ? { ...v, ...describeMacViolation(v.line) } : v,
253
+ )
254
+ }
255
+
212
256
  /**
213
257
  * Drop the loopback denial no grant can avoid.
214
258
  *
@@ -261,15 +305,20 @@ function withinReported(
261
305
  within: string,
262
306
  linked: readonly string[],
263
307
  ): SandboxViolation[] {
308
+ return violations.filter(reportedWithin(within, linked))
309
+ }
310
+
311
+ function reportedWithin(
312
+ within: string,
313
+ linked: readonly string[],
314
+ ): (v: SandboxViolation) => boolean {
264
315
  const roots = [toRealPath(within), ...linked]
265
316
  // `root === '/'` would otherwise compare against `'//'` and drop
266
317
  // everything — the one prefix that needs no separator appended.
267
318
  const prefixes = roots.map((root) => (root.endsWith(path.sep) ? root : root + path.sep))
268
- return violations.filter(
269
- (v) =>
270
- v.path === undefined ||
271
- roots.some((root, i) => v.path === root || v.path!.startsWith(prefixes[i]!)),
272
- )
319
+ return (v) =>
320
+ v.path === undefined ||
321
+ roots.some((root, i) => v.path === root || v.path!.startsWith(prefixes[i]!))
273
322
  }
274
323
 
275
324
  /**
@@ -300,8 +349,11 @@ function filterIgnored(
300
349
  export function refusedWrites(
301
350
  records: readonly string[],
302
351
  writable: readonly string[],
352
+ /** Write globs whose writes land in the sandbox's scratch (`scratchWrites`): granted. */
353
+ scratch: readonly string[] = [],
303
354
  ): SandboxViolation[] {
304
355
  const binds = new Set(writable.map((w) => toRealPath(absolutize(w))))
356
+ const globs = scratch.map((g) => new Bun.Glob(g))
305
357
  const seen = new Set<string>()
306
358
  const out: SandboxViolation[] = []
307
359
  for (const record of records) {
@@ -309,7 +361,7 @@ export function refusedWrites(
309
361
  if (m === null) continue
310
362
  const [, syscall, raw] = m as unknown as [string, string, string]
311
363
  const abs = toRealPath(raw)
312
- if (isUnderAny(abs, binds)) continue
364
+ if (isUnderAny(abs, binds) || underGlob(abs, globs)) continue
313
365
  const key = `${syscall}|${abs}`
314
366
  if (seen.has(key)) continue
315
367
  seen.add(key)
@@ -323,3 +375,12 @@ export function refusedWrites(
323
375
  }
324
376
  return out
325
377
  }
378
+
379
+ /** `p`, or a directory holding it, matches one of `globs`. */
380
+ function underGlob(p: string, globs: readonly InstanceType<typeof Bun.Glob>[]): boolean {
381
+ if (globs.length === 0) return false
382
+ for (let at = p; ; at = path.dirname(at)) {
383
+ if (globs.some((g) => g.match(at))) return true
384
+ if (path.dirname(at) === at) return false
385
+ }
386
+ }
@@ -415,15 +415,16 @@ async function prepareOutputsForBind(
415
415
  const hasWildcard = !isMountableLiteral(g)
416
416
  // A grant OUTSIDE the project is the user's own path — never joined
417
417
  // onto the project dir, which would create a literal `~` there. Its
418
- // directory is still created when the grant is a glob, because then
419
- // the static prefix is unambiguously a directory and bwrap cannot
420
- // bind one that does not exist: `~/.bun/install/cache/**` on a runner
421
- // that has never populated it silently granted nothing, and the task
422
- // failed with the tool's own confusing message (`bun build --compile`
423
- // reported a network error for an unwritable cache; 2026-09-05).
418
+ // directory is still created when the grant names one — a glob's
419
+ // static prefix, or a literal spelled `dir/` — because bwrap cannot
420
+ // bind a directory that does not exist: `~/.bun/install/cache/**` on a
421
+ // runner that has never populated it silently granted nothing, and the
422
+ // task failed with the tool's own confusing message (`bun build
423
+ // --compile` reported a network error for an unwritable cache;
424
+ // 2026-09-05). The `dir/` spelling was skipped until 2026-09-29.
424
425
  if (path.isAbsolute(g) || g.startsWith('~')) {
425
- if (!hasWildcard) continue
426
- const abs = expandHome(grantPrefix(g))
426
+ if (!hasWildcard && !g.endsWith('/')) continue
427
+ const abs = expandHome(hasWildcard ? grantPrefix(g) : g)
427
428
  await mkdir(abs, { recursive: true }).catch(() => undefined)
428
429
  continue
429
430
  }