@vzn/vx 0.0.368 → 0.0.370

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.368",
3
+ "version": "0.0.370",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.368",
25
- "@vzn/vx-linux-arm64": "0.0.368",
26
- "@vzn/vx-darwin-x64": "0.0.368",
27
- "@vzn/vx-darwin-arm64": "0.0.368"
24
+ "@vzn/vx-linux-x64": "0.0.370",
25
+ "@vzn/vx-linux-arm64": "0.0.370",
26
+ "@vzn/vx-darwin-x64": "0.0.370",
27
+ "@vzn/vx-darwin-arm64": "0.0.370"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -88,6 +88,7 @@ import {
88
88
  srtDefaultWritePaths,
89
89
  } from './sandbox-deny-scan.js'
90
90
  import {
91
+ hiddenReadsOutside,
91
92
  parseStraceViolations,
92
93
  refusedConnections,
93
94
  refusedWrites,
@@ -1820,6 +1821,16 @@ async function runSandboxedOnce(
1820
1821
  })
1821
1822
  }
1822
1823
 
1824
+ if (exitCode !== 0) {
1825
+ const hidden = hiddenReadsOutside(recorded, {
1826
+ within: args.reportWithin,
1827
+ linked: args.reportLinked,
1828
+ config: args.config,
1829
+ skip: [taskTmpRoot()],
1830
+ })
1831
+ if (hidden.length > 0) violations.push(hiddenReadsHint(hidden, args.reportWithin))
1832
+ }
1833
+
1823
1834
  try {
1824
1835
  SandboxManager.cleanupAfterCommand()
1825
1836
  } catch {
@@ -1867,6 +1878,19 @@ function outsideWritesHint(paths: readonly string[]): SandboxViolation {
1867
1878
  }
1868
1879
  }
1869
1880
 
1881
+ function hiddenReadsHint(paths: readonly string[], within: string): SandboxViolation {
1882
+ const shown = paths.slice(0, 5).join(', ')
1883
+ const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
1884
+ return {
1885
+ timestamp: new Date(),
1886
+ hint: true,
1887
+ line:
1888
+ `vx: the sandbox hid paths outside the project that exist on this machine, which are ` +
1889
+ `not reported as violations: ${shown}${more}. If the task reads one, grant it, e.g. ` +
1890
+ `\`allow: { read: ['${path.relative(toRealPath(within), paths[0]!)}'] }\`.`,
1891
+ }
1892
+ }
1893
+
1870
1894
  /**
1871
1895
  * Is `dir` inside a path the sandbox granted for reading? bwrap binds and
1872
1896
  * seatbelt rules are both prefix-based, so a grant covers the subtree.
@@ -4,6 +4,7 @@
4
4
  // task's owner can act on — inside the project, minus the loopback denial
5
5
  // no grant can avoid, minus what the task chose to ignore.
6
6
 
7
+ import { statSync } from 'node:fs'
7
8
  import path from 'node:path'
8
9
  import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
9
10
  import { bindableReads } from './sandbox-binds.js'
@@ -500,6 +501,44 @@ export function refusedWritesOutside(
500
501
  return [...paths]
501
502
  }
502
503
 
504
+ /**
505
+ * The paths outside the project a read was denied that exist on this
506
+ * machine, files first: the reads `reportableViolations` drops at the
507
+ * wall. A task that failed on one said only what its tool says for a
508
+ * missing file — `tsc` extending a root `tsconfig.base.json` read "File
509
+ * not found" — and nothing named the sandbox (2026-10-02). Never a
510
+ * violation — a failed task's hint (`runSandboxed`). What a process walks
511
+ * through to reach its cwd (the project's ancestors) and what `skip` names
512
+ * are left out, as is a path that does not exist on the host: that miss
513
+ * is the tool's own.
514
+ */
515
+ export function hiddenReadsOutside(
516
+ violations: readonly SandboxViolation[],
517
+ opts: {
518
+ within: string
519
+ linked?: readonly string[]
520
+ config: ResolvedSandboxConfig
521
+ skip: readonly string[]
522
+ },
523
+ ): string[] {
524
+ const reported = reportedWithin(opts.within, opts.linked ?? [])
525
+ const home = toRealPath(opts.within)
526
+ const skip = opts.skip.map(toRealPath)
527
+ const found = new Map<string, boolean>()
528
+ for (const v of filterIgnored(describe(violations), opts.config.ignore)) {
529
+ const p = v.path
530
+ if (p === undefined || v.hint === true || !v.ignorable?.includes('read') || reported(v))
531
+ continue
532
+ if (found.has(p) || atOrUnder(home, p) || skip.some((s) => atOrUnder(p, s))) continue
533
+ try {
534
+ found.set(p, statSync(p).isDirectory())
535
+ } catch {
536
+ // Not on the host either: the tool's own miss.
537
+ }
538
+ }
539
+ return [...found].sort((a, b) => Number(a[1]) - Number(b[1])).map(([p]) => p)
540
+ }
541
+
503
542
  /** Every record described: a seatbelt one parsed, a Linux one as produced. */
504
543
  function describe(violations: readonly SandboxViolation[]): SandboxViolation[] {
505
544
  return violations.map((v) =>
@@ -109,6 +109,7 @@ self.onmessage = async (e) => {
109
109
  const own = (o) => new Map(Reflect.ownKeys(o).map((k) => [k, Object.getOwnPropertyDescriptor(o, k)]))
110
110
  const before = watched?.map(([, o]) => own(o))
111
111
  const envBefore = watched ? { ...live } : null
112
+ const cwdBefore = watched ? globalThis.process.cwd() : null
112
113
  const same = (a, b) =>
113
114
  a !== undefined && b !== undefined && a.value === b.value && a.get === b.get && a.set === b.set &&
114
115
  a.writable === b.writable && a.enumerable === b.enumerable && a.configurable === b.configurable
@@ -122,6 +123,7 @@ self.onmessage = async (e) => {
122
123
  })
123
124
  for (const k of new Set([...Object.keys(envBefore), ...Object.keys(live)]))
124
125
  if (envBefore[k] !== live[k]) out.push('process.env.' + k)
126
+ if (globalThis.process.cwd() !== cwdBefore) out.push('process.cwd (a chdir)')
125
127
  return out
126
128
  }
127
129
  try {
@@ -738,6 +738,24 @@ export function migrateScripts(
738
738
  return !LIFECYCLE.test(n) && (base === undefined || !(base in scripts))
739
739
  })
740
740
  }
741
+ // Why a root mapped nothing: `eslint .` beside a member's `lint` runs
742
+ // nothing of the workspace, and the note said it did.
743
+ const rootLeftOut = (): string => {
744
+ if (rootMeta === undefined) return 'run the workspace'
745
+ const scripts = scriptsOf(rootMeta)
746
+ const named = Object.keys(scripts).filter(
747
+ (n) => typeof scripts[n] === 'string' && scripts[n] !== '' && !LIFECYCLE.test(n),
748
+ )
749
+ const runs = runningMembers(
750
+ scripts,
751
+ rootMeta.dir,
752
+ metas.filter((m) => m !== root).map((m) => m.dir),
753
+ )
754
+ const shared = named.filter((n) => memberTasks.has(n) && !runs.has(n))
755
+ if (shared.length === 0) return 'run the workspace'
756
+ const list = `${shared.slice(0, 8).join(', ')}${shared.length > 8 ? ', …' : ''}`
757
+ return `${shared.length < named.length ? 'run the workspace or ' : ''}share a member's task name (${list})`
758
+ }
741
759
  const rootName =
742
760
  root?.name ??
743
761
  (outsideRuns
@@ -768,7 +786,7 @@ export function migrateScripts(
768
786
  // A nameless root's vx.config is skipped (vx names projects by their
769
787
  // manifest's name), so the hand-written one needs a name first (vuejs/core).
770
788
  notes.push(
771
- `${rootName} (the workspace root) not mapped: its scripts run the workspace; declare its own tasks in its vx.config by hand` +
789
+ `${rootName} (the workspace root) not mapped: its scripts ${rootLeftOut()}; declare its own tasks in its vx.config by hand` +
772
790
  (rootName === 'package.json' ? ', after giving its package.json a "name"' : ''),
773
791
  )
774
792
  }
@@ -441,6 +441,7 @@ export async function loadProjectConfigs(
441
441
  // `node:stream`), ~7 ms of a two-config warm run where every load hit.
442
442
  let builtins: BuiltinSnapshot = []
443
443
  let env: Readonly<Record<string, string | undefined>> = {}
444
+ let cwd = ''
444
445
  // More than one evaluation in flight: a change seen after one load may
445
446
  // be another's.
446
447
  let overlapping = false
@@ -484,7 +485,9 @@ export async function loadProjectConfigs(
484
485
  : await loadDefaultExport(configPath, 'Project', bytes!)
485
486
  // Before anything reads through them: a replaced `Array.prototype.includes`
486
487
  // turned the JSON-data walk's own check into "a cyclic reference".
487
- const changed = repeat ? [] : [...restoreBuiltins(builtins), ...restoreEnv(env)]
488
+ const changed = repeat
489
+ ? []
490
+ : [...restoreBuiltins(builtins), ...restoreEnv(env), ...restoreCwd(cwd)]
488
491
  // Loads overlap, so another config's change can surface after this
489
492
  // one: named here, the refusal blamed the wrong file (D-119). The round
490
493
  // finds the one that made it.
@@ -536,6 +539,7 @@ export async function loadProjectConfigs(
536
539
  if (misses.length > 0) {
537
540
  builtins = builtinSnapshot()
538
541
  env = { ...process.env }
542
+ cwd = process.cwd()
539
543
  }
540
544
  overlapping = misses.length > 1
541
545
  let next = 0
@@ -554,7 +558,10 @@ export async function loadProjectConfigs(
554
558
  if (r.closure !== undefined) learnedClosures.push(r.closure)
555
559
  }
556
560
  }
557
- const changed = misses.length > 0 ? [...restoreBuiltins(builtins), ...restoreEnv(env)] : []
561
+ const changed =
562
+ misses.length > 0
563
+ ? [...restoreBuiltins(builtins), ...restoreEnv(env), ...restoreCwd(cwd)]
564
+ : []
558
565
  if (first?.failed instanceof ChangedInRound) {
559
566
  for (const i of misses) {
560
567
  const configPath = prepared[i]!.configPath
@@ -644,9 +651,13 @@ class ChangedInRound {
644
651
  /** Without `configPath`, no config alone was found to make the change. */
645
652
  function builtinsChanged(changed: readonly string[], configPath?: string): UserError {
646
653
  const who = configPath === undefined ? 'a project config' : configPath
647
- const env = changed.some((c) => c.startsWith('process.env.'))
648
- ? '; a task gets an env var through `exec.env.define` or `passThrough`'
649
- : ''
654
+ const env =
655
+ (changed.some((c) => c.startsWith('process.env.'))
656
+ ? '; a task gets an env var through `exec.env.define` or `passThrough`'
657
+ : '') +
658
+ (changed.some((c) => c.startsWith('process.cwd'))
659
+ ? '; a task runs in its project directory, and `cd <dir> && …` in `exec.command` moves it'
660
+ : '')
650
661
  return new UserError(
651
662
  `${who} changed ${changed.join(', ')} while it was evaluated — a config must not change the built-ins vx runs on: other configs are read through them and cache keys are made with them${env}`,
652
663
  )
@@ -702,6 +713,20 @@ function restoreEnv(before: Readonly<Record<string, string | undefined>>): strin
702
713
  return changed
703
714
  }
704
715
 
716
+ /**
717
+ * A config's `process.chdir()` moved the whole process: every relative path
718
+ * vx resolves after it, its own and a plugin's, read from the config's
719
+ * choice (D-120). Put back, and named as the change.
720
+ */
721
+ function restoreCwd(before: string): string[] {
722
+ // Through globalThis: the playground bundles this module, and its
723
+ // browser shim carries no free process global.
724
+ const proc = globalThis.process
725
+ if (before === '' || proc.cwd() === before) return []
726
+ proc.chdir(before)
727
+ return ['process.cwd (a chdir)']
728
+ }
729
+
705
730
  function sameDescriptor(a: PropertyDescriptor, b: PropertyDescriptor): boolean {
706
731
  return (
707
732
  a.value === b.value &&