@vzn/vx 0.0.192 → 0.0.193

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.192",
3
+ "version": "0.0.193",
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.192",
21
- "@vzn/vx-linux-arm64": "0.0.192",
22
- "@vzn/vx-darwin-x64": "0.0.192",
23
- "@vzn/vx-darwin-arm64": "0.0.192"
20
+ "@vzn/vx-linux-x64": "0.0.193",
21
+ "@vzn/vx-linux-arm64": "0.0.193",
22
+ "@vzn/vx-darwin-x64": "0.0.193",
23
+ "@vzn/vx-darwin-arm64": "0.0.193"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -28,7 +28,7 @@ export {
28
28
  type SchemaReset,
29
29
  SCHEMA_VERSION,
30
30
  } from './cache.js'
31
- export { CACHE_VERSION } from './key-fold.js'
31
+ export { ABSENT_INPUT, absentOr, CACHE_VERSION } from './key-fold.js'
32
32
  export {
33
33
  cleanOutputs,
34
34
  cleanWorkspaceOutputs,
@@ -89,6 +89,22 @@ import type { CacheKeyInput } from './layer.js'
89
89
  // after the sibling changes (probed: the old output replayed).
90
90
  export const CACHE_VERSION = 'vx-cache-v36'
91
91
 
92
+ /**
93
+ * The digest a key folds for an input gone between its enumeration and its
94
+ * hash: an upstream task that deletes a file its dependant's globs matched
95
+ * (a `clean` ahead of a `build`) failed the dependant as an internal error on
96
+ * every run (A-55). Not an identity any file has, so a run that finds the
97
+ * file keys apart from one that does not.
98
+ */
99
+ export const ABSENT_INPUT = 'absent'
100
+
101
+ /** `ABSENT_INPUT` for a file that is not there to read; any other error rethrown. */
102
+ export function absentOr(err: unknown): string {
103
+ const code = (err as NodeJS.ErrnoException | null)?.code
104
+ if (code === 'ENOENT' || code === 'ENOTDIR') return ABSENT_INPUT
105
+ throw err
106
+ }
107
+
92
108
  /**
93
109
  * Fold one task's key inputs into its 16-hex cache key. `hashFile` answers
94
110
  * for an input file the caller's `fileHashes` map does not cover (the git
@@ -226,7 +242,9 @@ export async function foldKey(
226
242
  }
227
243
  if (out.length === sortedInputs.length) fileHashes = out
228
244
  }
229
- fileHashes ??= await Promise.all(sortedInputs.map((f) => provided?.get(f) ?? hashFile(f)))
245
+ fileHashes ??= await Promise.all(
246
+ sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
247
+ )
230
248
  for (let i = 0; i < sortedInputs.length; i++) {
231
249
  const file = sortedInputs[i]!
232
250
  const rel = relOf(file)
@@ -0,0 +1,152 @@
1
+ // The sandbox runtime's mandatory-deny scan, scoped to what a task can
2
+ // write (B-40). SRT walks `process.cwd()` (the workspace root) with
3
+ // `rg --max-depth 3` on every wrap and keeps a hit only when it lies in
4
+ // an allowed write path: anything else is already read-only under
5
+ // `--ro-bind / /`. A deny is the hit or one of its ancestors, so every
6
+ // hit that counts lies under a write path, and walking the write paths
7
+ // finds them all. On 1,090 packages the whole-root walk was half of a
8
+ // sandboxed run's wall time (25.5 s → 14.0 s without it). SRT keeps its
9
+ // own scan at depth 1 for the root's entries; this supplies the rest,
10
+ // per task and never from a memo (a refusal is decided by it).
11
+ //
12
+ // It is a superset of rg's hits, never a subset: it does not read
13
+ // `.gitignore`, walks into `node_modules`, and counts a symlink by its
14
+ // name. Each can only add a deny where rg would have skipped the path.
15
+
16
+ import { lstatSync, readdirSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import path from 'node:path'
19
+ import { UserError } from '../util/index.js'
20
+ import { atOrUnder, MOUNT_WILDCARDS } from './sandbox-paths.js'
21
+
22
+ /** SRT's `rg --max-depth` for the scan (`DEFAULT_MANDATORY_DENY_SEARCH_DEPTH`). */
23
+ const SEARCH_DEPTH = 3
24
+
25
+ /** SRT's `DANGEROUS_FILES`: matched by name, case-insensitively, at any depth. */
26
+ const DANGEROUS_FILES = new Set([
27
+ '.gitconfig',
28
+ '.gitmodules',
29
+ '.bashrc',
30
+ '.bash_profile',
31
+ '.zshrc',
32
+ '.zprofile',
33
+ '.profile',
34
+ '.ripgreprc',
35
+ '.mcp.json',
36
+ ])
37
+
38
+ /** SRT's `getDangerousDirectories()`: a hit is any file below one. */
39
+ const DANGEROUS_DIRS = ['.vscode', '.idea', '.claude/commands', '.claude/agents']
40
+
41
+ /** The write paths SRT adds to every wrap (`getDefaultWritePaths`); `/dev` never holds a hit. */
42
+ export function srtDefaultWritePaths(): string[] {
43
+ const home = homedir()
44
+ return [
45
+ '/tmp/claude',
46
+ '/private/tmp/claude',
47
+ path.join(home, '.npm/_logs'),
48
+ path.join(home, '.claude/debug'),
49
+ ]
50
+ }
51
+
52
+ const lower = (s: string): string => s.toLowerCase()
53
+
54
+ /** Whether `rel` (from the scan root, `/`-separated) is one of rg's hits for SRT's patterns. */
55
+ function isHit(rel: string): boolean {
56
+ const segs = rel.split('/').map(lower)
57
+ if (DANGEROUS_FILES.has(segs[segs.length - 1]!)) return true
58
+ const inside = (dir: string): boolean => {
59
+ const d = dir.split('/')
60
+ for (let i = 0; i + d.length < segs.length; i++) {
61
+ if (d.every((s, j) => segs[i + j] === s)) return true
62
+ }
63
+ return false
64
+ }
65
+ if (DANGEROUS_DIRS.some(inside) || inside('.git/hooks')) return true
66
+ // `**/.git/config`, and only while `allowGitConfig` is off: vx never
67
+ // turns it on in the run's config, which is the one SRT's scan reads.
68
+ return segs.length >= 2 && segs[segs.length - 2] === '.git' && segs[segs.length - 1] === 'config'
69
+ }
70
+
71
+ /** The deny path SRT derives from a hit (`linuxGetMandatoryDenyPaths`), or none. */
72
+ function denyOf(cwd: string, rel: string): string | undefined {
73
+ const absolute = path.resolve(cwd, rel)
74
+ const segments = absolute.split(path.sep)
75
+ // Two-segment names never equal one segment: their hits fall through to
76
+ // the file itself, as they do in SRT.
77
+ for (const dir of [...DANGEROUS_DIRS, '.git']) {
78
+ const i = segments.findIndex((s) => lower(s) === lower(dir))
79
+ if (i === -1) continue
80
+ if (dir !== '.git') return segments.slice(0, i + 1).join(path.sep)
81
+ const gitDir = segments.slice(0, i + 1).join(path.sep)
82
+ if (rel.includes('.git/hooks')) return path.join(gitDir, 'hooks')
83
+ if (rel.includes('.git/config')) return path.join(gitDir, 'config')
84
+ return undefined
85
+ }
86
+ return absolute
87
+ }
88
+
89
+ /**
90
+ * The mandatory write denies for a task whose write grants are
91
+ * `writePaths` (absolute), as SRT's whole-root scan of `cwd` would find
92
+ * them within those grants. Throws when a deny could not reach bwrap: a
93
+ * path with a glob character is dropped from `denyWrite` on Linux, and
94
+ * the write it guards must not be left open.
95
+ */
96
+ export function scopedMandatoryDenies(cwd: string, writePaths: readonly string[]): string[] {
97
+ const roots = new Set<string>()
98
+ for (const raw of writePaths) {
99
+ const w = raw.replace(/\/+$/, '') || '/'
100
+ if (atOrUnder(cwd, w)) roots.add(cwd)
101
+ else if (atOrUnder(w, cwd)) roots.add(w)
102
+ }
103
+ const denies = new Set<string>()
104
+ const visit = (abs: string, rel: string, depth: number): void => {
105
+ let entries
106
+ try {
107
+ entries = readdirSync(abs, { withFileTypes: true })
108
+ } catch {
109
+ return
110
+ }
111
+ for (const e of entries) {
112
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`
113
+ if (e.isDirectory()) {
114
+ if (depth + 1 < SEARCH_DEPTH) visit(path.join(abs, e.name), childRel, depth + 1)
115
+ } else if (isHit(childRel)) {
116
+ const d = denyOf(cwd, childRel)
117
+ if (d !== undefined) denies.add(d)
118
+ }
119
+ }
120
+ }
121
+ for (const root of roots) {
122
+ const rel = path.relative(cwd, root).split(path.sep).join('/')
123
+ const depth = rel === '' ? 0 : rel.split('/').length
124
+ if (depth > SEARCH_DEPTH) continue
125
+ let dir: boolean
126
+ try {
127
+ dir = lstatSync(root).isDirectory()
128
+ } catch {
129
+ continue
130
+ }
131
+ if (dir) {
132
+ if (depth < SEARCH_DEPTH) visit(root, rel, depth)
133
+ } else if (depth > 0 && isHit(rel)) {
134
+ const d = denyOf(cwd, rel)
135
+ if (d !== undefined) denies.add(d)
136
+ }
137
+ }
138
+ for (const d of denies) {
139
+ // SRT's `containsGlobChars` is this set: such a `denyWrite` entry is dropped on Linux.
140
+ if (MOUNT_WILDCARDS.test(d)) {
141
+ throw new UserError(
142
+ `the sandbox must keep ${d} read-only, and a path with a glob character (* ? [ ]) cannot reach it — rename the directory`,
143
+ )
144
+ }
145
+ }
146
+ return [...denies]
147
+ }
148
+
149
+ /** Whether the scan can be scoped here: a root with a glob character loses every supplied deny. */
150
+ export function canScopeDenyScan(cwd: string): boolean {
151
+ return !MOUNT_WILDCARDS.test(cwd)
152
+ }
@@ -71,6 +71,11 @@ import {
71
71
  toRealPath,
72
72
  unique,
73
73
  } from './sandbox-paths.js'
74
+ import {
75
+ canScopeDenyScan,
76
+ scopedMandatoryDenies,
77
+ srtDefaultWritePaths,
78
+ } from './sandbox-deny-scan.js'
74
79
  import { parseStraceViolations, refusedWrites, reportableViolations } from './sandbox-violations.js'
75
80
  import {
76
81
  closeSignalChannel,
@@ -469,6 +474,9 @@ function bundledJavaAgent(): { javaAgentJarPath?: string } {
469
474
  return javaAgent
470
475
  }
471
476
 
477
+ /** Whether this run's SRT scans at depth 1 and vx supplies the task-scoped denies (B-40). */
478
+ let scopedDenyScan = false
479
+
472
480
  function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
473
481
  if (process.platform !== 'linux') return {}
474
482
  const paths: { bwrapPath?: string; socatPath?: string } = {}
@@ -540,6 +548,7 @@ export async function initSandbox(opts?: {
540
548
  // Before SRT starts, so the very first task already has one.
541
549
  await mkdir(sandboxTmpdir(), { recursive: true })
542
550
  const { SandboxManager } = await loadSrt()
551
+ scopedDenyScan = process.platform === 'linux' && canScopeDenyScan(process.cwd())
543
552
  const config: Parameters<typeof SandboxManager.initialize>[0] = {
544
553
  network: {
545
554
  allowedDomains: [...(opts?.allowedDomains ?? [])],
@@ -549,6 +558,9 @@ export async function initSandbox(opts?: {
549
558
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
550
559
  ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
551
560
  ...linuxToolPaths(),
561
+ // SRT's own scan walks only the root's entries; `wrapSandboxedCommand`
562
+ // walks each task's write grants for the rest (B-40).
563
+ ...(scopedDenyScan ? { mandatoryDenySearchDepth: 1 } : {}),
552
564
  ...bundledJavaAgent(),
553
565
  }
554
566
  if (!srtUp) await unlinkStaleMuxSockets()
@@ -928,6 +940,14 @@ export async function wrapSandboxedCommand(
928
940
  const customConfig = buildCustomConfig(args, baselines)
929
941
  customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
930
942
  customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
943
+ if (scopedDenyScan) {
944
+ customConfig!.filesystem!.denyWrite!.push(
945
+ ...scopedMandatoryDenies(process.cwd(), [
946
+ ...srtDefaultWritePaths(),
947
+ ...customConfig!.filesystem!.allowWrite!,
948
+ ]),
949
+ )
950
+ }
931
951
  // Seatbelt re-allows a read inside a denied region only by name; on Linux
932
952
  // a read grant over a write path would remount it read-only.
933
953
  if (process.platform === 'darwin') customConfig!.filesystem!.allowRead!.push(toRealPath(tmp))
@@ -3,6 +3,8 @@ import path from 'node:path'
3
3
  import type { TaskConfig, CacheConfig } from '../config.js'
4
4
  import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
5
5
  import {
6
+ ABSENT_INPUT,
7
+ absentOr,
6
8
  type CacheKeyInput,
7
9
  type CacheLayer,
8
10
  FILE_HASH_RACY_MS,
@@ -138,12 +140,14 @@ export async function describeTaskInputs(
138
140
  const hash = await args.cache.key(input)
139
141
  const sorted = [...input.inputFiles].sort()
140
142
  const digests = await Promise.all(
141
- sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f)),
143
+ sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f).catch(absentOr)),
144
+ )
145
+ // A file gone since the enumeration is no input: the task will not read it.
146
+ const files: InputFile[] = sorted.flatMap((f, i) =>
147
+ digests[i] === ABSENT_INPUT
148
+ ? []
149
+ : [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
142
150
  )
143
- const files: InputFile[] = sorted.map((f, i) => ({
144
- path: relPosix(input.workspaceRoot, f),
145
- digest: digests[i]!,
146
- }))
147
151
  // The package.json digest is a per-run memo that may predate this
148
152
  // describe, so it is dated from the enumeration, the earliest a run
149
153
  // learns anything.
@@ -221,8 +225,12 @@ export async function movedInput(
221
225
  try {
222
226
  ctimeMs = lstatSync(f.path).ctimeMs
223
227
  } catch {
228
+ // Gone, as the key folded it (`ABSENT_INPUT`): unmoved.
229
+ if (f.digest === ABSENT_INPUT) continue
224
230
  return f.path
225
231
  }
232
+ // Back since the key folded it gone.
233
+ if (f.digest === ABSENT_INPUT) return f.path
226
234
  // A whole-second stamp may stand for any write in its second (two for
227
235
  // an even one, FAT's tick), one during the command included
228
236
  // (`racyWindowMs`).
@@ -386,6 +386,7 @@ export async function loadProjectConfigs(
386
386
  evaluated?: readonly [string, string]
387
387
  closure?: readonly [string, readonly string[]]
388
388
  }
389
+ const builtins = builtinSnapshot()
389
390
  const loadOne = async (entry: (typeof prepared)[number]): Promise<Loaded> => {
390
391
  const { configPath, cacheKey } = entry
391
392
  // A fast key that missed: the closure is stale or the file changed.
@@ -424,6 +425,10 @@ export async function loadProjectConfigs(
424
425
  throw configLoadError(err, configPath, 'Project') ?? err
425
426
  })
426
427
  : await loadDefaultExport(configPath, 'Project', bytes!)
428
+ // Before anything reads through them: a replaced `Array.prototype.includes`
429
+ // turned the JSON-data walk's own check into "a cyclic reference".
430
+ const changed = repeat ? [] : restoreBuiltins(builtins)
431
+ if (changed.length > 0) throw builtinsChanged(changed, configPath)
427
432
  assertDefaultObject(mod, 'Project', configPath)
428
433
  // Validation runs HERE, on whichever object we ended up with, so a
429
434
  // malformed config reports the identical UserError whether it was
@@ -480,7 +485,9 @@ export async function loadProjectConfigs(
480
485
  if (r.closure !== undefined) learnedClosures.push(r.closure)
481
486
  }
482
487
  }
488
+ const changed = restoreBuiltins(builtins)
483
489
  if (first !== undefined) throw first.failed
490
+ if (changed.length > 0) throw builtinsChanged(changed)
484
491
  return results.map((r) => (r as Loaded).config)
485
492
  } finally {
486
493
  endRound()
@@ -497,6 +504,70 @@ export async function loadProjectConfigs(
497
504
  }
498
505
  }
499
506
 
507
+ /**
508
+ * The built-in prototypes a config's object is read through. A config that
509
+ * set `Object.prototype.exec` gave every other project's task that
510
+ * command, and the key, which folds each config's own JSON, never saw it:
511
+ * a hit replayed under a key that did not name what ran (D-74). A first
512
+ * load runs in this process, so the round compares them before and after,
513
+ * puts back what changed (a failed load too) and refuses.
514
+ */
515
+ const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = [
516
+ ['Object.prototype', Object.prototype],
517
+ ['Array.prototype', Array.prototype],
518
+ ]
519
+
520
+ type BuiltinSnapshot = ReadonlyArray<ReadonlyMap<PropertyKey, PropertyDescriptor>>
521
+
522
+ function builtinSnapshot(): BuiltinSnapshot {
523
+ return WATCHED_BUILTINS.map(
524
+ ([, proto]) =>
525
+ new Map(Reflect.ownKeys(proto).map((k) => [k, Object.getOwnPropertyDescriptor(proto, k)!])),
526
+ )
527
+ }
528
+
529
+ /** Loads run together, so the config named is the one whose load saw the change. */
530
+ function builtinsChanged(changed: readonly string[], configPath?: string): UserError {
531
+ const who = configPath === undefined ? 'a project config' : configPath
532
+ return new UserError(
533
+ `${who} changed ${changed.join(', ')} while it was evaluated — a config must not change the built-ins: every other config is read through them, and the cache key does not see what they add`,
534
+ )
535
+ }
536
+
537
+ /** Puts back what changed since `before`, naming each property it put back. */
538
+ function restoreBuiltins(before: BuiltinSnapshot): string[] {
539
+ const changed: string[] = []
540
+ WATCHED_BUILTINS.forEach(([name, proto], i) => {
541
+ const was = before[i]!
542
+ for (const key of Reflect.ownKeys(proto)) {
543
+ const prior = was.get(key)
544
+ const now = Object.getOwnPropertyDescriptor(proto, key)!
545
+ if (prior !== undefined && sameDescriptor(prior, now)) continue
546
+ changed.push(`${name}.${String(key)}`)
547
+ if (prior === undefined) Reflect.deleteProperty(proto, key)
548
+ else Object.defineProperty(proto, key, prior)
549
+ }
550
+ for (const [key, prior] of was) {
551
+ if (!Object.hasOwn(proto, key)) {
552
+ changed.push(`${name}.${String(key)}`)
553
+ Object.defineProperty(proto, key, prior)
554
+ }
555
+ }
556
+ })
557
+ return changed
558
+ }
559
+
560
+ function sameDescriptor(a: PropertyDescriptor, b: PropertyDescriptor): boolean {
561
+ return (
562
+ a.value === b.value &&
563
+ a.get === b.get &&
564
+ a.set === b.set &&
565
+ a.writable === b.writable &&
566
+ a.enumerable === b.enumerable &&
567
+ a.configurable === b.configurable
568
+ )
569
+ }
570
+
500
571
  export async function loadProjectConfig(
501
572
  configPath: string,
502
573
  opts?: LoadProjectConfigOptions,