@vzn/vx 0.0.100 → 0.0.102

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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import { constants as osConstants } from 'node:os'
9
- import { executablePath, isExecutableMissing, killGraceMs } from '../util/index.js'
9
+ import { shellArgv, isExecutableMissing, killGraceMs } from '../util/index.js'
10
10
  import {
11
11
  closeSignalChannel,
12
12
  holdGroups,
@@ -19,6 +19,10 @@ import {
19
19
  untilGroupsGone,
20
20
  } from './kill-tree.js'
21
21
 
22
+ const WIN32 = process.platform === 'win32'
23
+ /** `sh` names itself in its errors; `bun exec` on Windows keeps its own. */
24
+ const SH_ARGV0 = WIN32 ? {} : { argv0: 'sh' }
25
+
22
26
  export interface RunResult {
23
27
  exitCode: number
24
28
  durationMs: number
@@ -204,6 +208,8 @@ const SHELL_BUILTINS = new Set([
204
208
  * the shell.
205
209
  */
206
210
  export function execWrap(command: string): string {
211
+ // Bun's shell, the task shell on Windows, has no `exec`.
212
+ if (WIN32) return command
207
213
  const first = execWord(command)
208
214
  return first === undefined ? command : `exec ${command}`
209
215
  }
@@ -439,13 +445,11 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
439
445
  const signalFd = opts.signalChannel === true
440
446
  child = spawnGuarded((guard) =>
441
447
  Bun.spawn(
442
- [
443
- executablePath('sh'),
444
- '-c',
448
+ shellArgv(
445
449
  (guard === undefined ? '' : guardLine(signalFd ? 4 : 3)) + execWrap(opts.command),
446
- ],
450
+ ),
447
451
  {
448
- argv0: 'sh',
452
+ ...SH_ARGV0,
449
453
  cwd: opts.cwd,
450
454
  env: opts.env as Record<string, string>,
451
455
  // A pipe vx holds and never writes: stdin stays open while vx
@@ -464,8 +468,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
464
468
  ],
465
469
  // Its own session and process group, so a kill reaches what it
466
470
  // forked (kill-tree.ts). stdin is a pipe, so a background group
467
- // never stops on a terminal read.
468
- detached: true,
471
+ // never stops on a terminal read. Windows has no groups, and a
472
+ // detached child there has no console.
473
+ detached: !WIN32,
469
474
  },
470
475
  ),
471
476
  )
@@ -666,23 +671,17 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
666
671
  let proc: ReturnType<typeof Bun.spawn>
667
672
  try {
668
673
  proc = spawnGuarded((guard) =>
669
- Bun.spawn(
670
- [
671
- executablePath('sh'),
672
- '-c',
673
- (guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand),
674
- ],
675
- {
676
- argv0: 'sh',
677
- cwd: opts.cwd,
678
- env: opts.env as Record<string, string>,
679
- stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
680
- // Its own session and process group, so a kill reaches what it
681
- // forked (kill-tree.ts). stdin is ignored, so a background group
682
- // never stops on a terminal read.
683
- detached: true,
684
- },
685
- ),
674
+ Bun.spawn(shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand)), {
675
+ ...SH_ARGV0,
676
+ cwd: opts.cwd,
677
+ env: opts.env as Record<string, string>,
678
+ stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
679
+ // Its own session and process group, so a kill reaches what it
680
+ // forked (kill-tree.ts). stdin is ignored, so a background group
681
+ // never stops on a terminal read. Windows has no groups, and a
682
+ // detached child there has no console.
683
+ detached: !WIN32,
684
+ }),
686
685
  )
687
686
  } catch (err) {
688
687
  const stderr = spawnFailureText(err, opts.cwd)
@@ -157,9 +157,10 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
157
157
  * produce the SRT customConfig. Path arrays are unioned and deduped; every
158
158
  * read grant is punched around the write grants (`punchWritePaths`).
159
159
  *
160
- * Network: `allow.network` missing → block all (allowedDomains: []);
161
- * `true` → allow all (['*']); a domain list → exactly that list.
162
- * `deny.network` is always passed as deniedDomains.
160
+ * Network: the lists are passed as the task declared them, but SRT's
161
+ * proxy filters by the RUN's lists from `initialize()`, never these: a
162
+ * task reaches the run's allowlist union less its deny union
163
+ * (`sandboxRunUnion`, B-21), whatever it declared here.
163
164
  */
164
165
  export function buildCustomConfig(
165
166
  args: Pick<SandboxedRunArgs, 'config'>,
@@ -185,9 +186,8 @@ export function buildCustomConfig(
185
186
  },
186
187
  }
187
188
 
188
- // `allow.network` / `deny.network` become SRT's domain lists. SRT requires
189
- // both to be present on any network config, so we always supply both;
190
- // omitted means no network at all.
189
+ // SRT requires both lists on any network config, so both are supplied;
190
+ // its proxy filters by the run's lists, not these (see above).
191
191
  custom.network = {
192
192
  allowedDomains: c.network === true ? ['*'] : [...(c.network ?? [])],
193
193
  deniedDomains: [...(c.denyNetwork ?? [])],
@@ -26,12 +26,15 @@
26
26
  import path from 'node:path'
27
27
  import os from 'node:os'
28
28
  import {
29
+ chmodSync,
29
30
  closeSync,
31
+ lstatSync,
30
32
  mkdirSync,
31
33
  openSync,
32
34
  readlinkSync,
33
35
  realpathSync,
34
36
  rmSync,
37
+ statSync,
35
38
  unlinkSync,
36
39
  } from 'node:fs'
37
40
  import { mkdir, unlink } from 'node:fs/promises'
@@ -354,6 +357,39 @@ function taskTmpRoot(): string {
354
357
  return path.join(sandboxTmpdir(), 'vx-tasks')
355
358
  }
356
359
 
360
+ /**
361
+ * `taskTmpRoot()`, made or checked as this user's own before a task's
362
+ * directory goes in it. It sits in a shared temp dir: a user who made it
363
+ * first owned the parent of every task's TMPDIR and bridge socket, and
364
+ * renamed a running task's directory to plant their own under its name
365
+ * (probed, L-13) — the path the host bridge dials and the write grant is
366
+ * resolved from. A symlink, another owner, or a parent another user may
367
+ * rewrite is refused; one of ours left open to others is closed.
368
+ */
369
+ function ownTaskTmpRoot(): string {
370
+ const root = taskTmpRoot()
371
+ mkdirSync(root, { recursive: true, mode: 0o700 })
372
+ const uid = process.getuid?.()
373
+ if (uid === undefined) return root
374
+ const st = lstatSync(root)
375
+ const parent = statSync(path.dirname(root))
376
+ // Group write is a umask-002 box's default; others' write is the hole,
377
+ // unless the sticky bit keeps them to their own entries (`/tmp`).
378
+ const parentShared = (parent.mode & 0o002) !== 0 && (parent.mode & 0o1000) === 0
379
+ if (
380
+ !st.isDirectory() ||
381
+ st.uid !== uid ||
382
+ (parent.uid !== uid && parent.uid !== 0) ||
383
+ parentShared
384
+ ) {
385
+ throw new UserError(
386
+ `sandbox: ${root} is not this user's own directory (a link, another owner, or a parent others may write); remove it, or point CLAUDE_CODE_TMPDIR at a private directory`,
387
+ )
388
+ }
389
+ if ((st.mode & 0o077) !== 0) chmodSync(root, 0o700)
390
+ return root
391
+ }
392
+
357
393
  const liveTaskTmpdirs = new Set<string>()
358
394
  let tmpdirExitHooked = false
359
395
  function trackTaskTmpdir(dir: string): void {
@@ -400,6 +436,31 @@ async function unlinkStaleMuxSockets(): Promise<void> {
400
436
  }
401
437
  }
402
438
 
439
+ /**
440
+ * The sandbox's own tools, by the paths vx resolves on its own PATH. SRT
441
+ * writes a bare `bwrap` (and, in the network bridge, a bare `socat`) into
442
+ * the command the task's shell runs, with the TASK's environment: a
443
+ * dependency's `node_modules/.bin/bwrap`, first on that PATH, ran in
444
+ * bwrap's place and the task ran unsandboxed with exit 0 (J-33's lead,
445
+ * B-19). A tool vx cannot find is left to SRT's own dependency check,
446
+ * whose refusal names it.
447
+ */
448
+ function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
449
+ if (process.platform !== 'linux') return {}
450
+ const paths: { bwrapPath?: string; socatPath?: string } = {}
451
+ try {
452
+ paths.bwrapPath = executablePath('bwrap')
453
+ } catch {
454
+ // SRT's dependency check says so
455
+ }
456
+ try {
457
+ paths.socatPath = executablePath('socat')
458
+ } catch {
459
+ // SRT's dependency check says so
460
+ }
461
+ return paths
462
+ }
463
+
403
464
  /**
404
465
  * One-time SRT initialization per orchestrator run. Starts the proxy
405
466
  * servers + (on macOS) the violation log monitor. Safe to call repeatedly
@@ -417,6 +478,13 @@ async function unlinkStaleMuxSockets(): Promise<void> {
417
478
  */
418
479
  export async function initSandbox(opts?: {
419
480
  allowedDomains?: readonly string[]
481
+ /**
482
+ * Every domain any sandboxed task of the run denies. The proxy is the
483
+ * run's, so a deny refuses it for every task, as the allowlist is the
484
+ * union's; before B-21 the list was always empty and a `deny.network`
485
+ * refused nothing.
486
+ */
487
+ deniedDomains?: readonly string[]
420
488
  /**
421
489
  * Whether any task of the run lifts SRT's seccomp block on
422
490
  * `socket(AF_UNIX)`: one that declares `unixSockets` or a `localBinding`
@@ -436,11 +504,12 @@ export async function initSandbox(opts?: {
436
504
  const config: Parameters<typeof SandboxManager.initialize>[0] = {
437
505
  network: {
438
506
  allowedDomains: [...(opts?.allowedDomains ?? [])],
439
- deniedDomains: [],
507
+ deniedDomains: [...(opts?.deniedDomains ?? [])],
440
508
  ...(opts?.allowAllUnixSockets === true ? { allowAllUnixSockets: true } : {}),
441
509
  },
442
510
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
443
511
  ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
512
+ ...linuxToolPaths(),
444
513
  }
445
514
  if (!srtUp) await unlinkStaleMuxSockets()
446
515
  await SandboxManager.initialize(
@@ -728,6 +797,12 @@ export interface SandboxViolation {
728
797
  * `openat(../x) = -1 ENOENT [/x]` say the same thing.
729
798
  */
730
799
  target?: string
800
+ /**
801
+ * vx's own note beside a failure (the cwd it cannot read, a placeholder
802
+ * the task never wrote, a withheld link), not a denial: shown with the
803
+ * denials, never counted as one (B-20).
804
+ */
805
+ hint?: true
731
806
  /** Absolute, when `target` is a path at all. */
732
807
  path?: string
733
808
  ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
@@ -798,8 +873,9 @@ export async function wrapSandboxedCommand(
798
873
  const userCommand = withForwardArgs(args.command, args.forwardArgs)
799
874
 
800
875
  const tag = xxh3hex(`${args.cwd}|${userCommand}|${process.hrtime.bigint()}`).slice(0, 16)
876
+ ownTaskTmpRoot()
801
877
  const tmp = taskTmpdir(tag)
802
- mkdirSync(tmp, { recursive: true })
878
+ mkdirSync(tmp, { mode: 0o700 })
803
879
  trackTaskTmpdir(tmp)
804
880
  // After the tag: SRT keys violations by the command's first 100 chars.
805
881
  const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
@@ -845,7 +921,7 @@ export async function wrapSandboxedCommand(
845
921
  // sandboxed server and all it forked outlived vx (turborepo#9666). Now
846
922
  // the namespace goes with vx, a `setsid` daemon inside included, a
847
923
  // traced one-shot task too: its strace runs inside (B-11).
848
- if (process.platform === 'linux' && wrapped.startsWith('bwrap ')) wrapped = `exec ${wrapped}`
924
+ if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped)) wrapped = `exec ${wrapped}`
849
925
  if (args.server === true) liveServers.add(tag)
850
926
  if (ports.length > 0) spawnHostBridges(ports, tag)
851
927
  return {
@@ -1394,6 +1470,7 @@ async function runSandboxedOnce(
1394
1470
  if (exitCode !== 0 && violations.length === 0 && !readableUnder(args.cwd, grantedRead)) {
1395
1471
  violations.push({
1396
1472
  timestamp: new Date(),
1473
+ hint: true,
1397
1474
  line:
1398
1475
  `vx: this sandbox grants no read access to the task's own working directory ` +
1399
1476
  `(${args.cwd}), so a command that reads or lists it fails with whatever error it ` +
@@ -1438,9 +1515,16 @@ async function runSandboxedOnce(
1438
1515
  */
1439
1516
  function readableUnder(dir: string, granted: readonly string[]): boolean {
1440
1517
  const target = toRealPath(dir)
1518
+ const under = (a: string, b: string): boolean =>
1519
+ a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep)
1441
1520
  return granted.some((p) => {
1442
1521
  const g = toRealPath(p)
1443
- return target === g || target.startsWith(g.endsWith(path.sep) ? g : g + path.sep)
1522
+ // On Linux a grant INSIDE the cwd makes the cwd listable too: bwrap
1523
+ // builds the path to the bind. A root's `read: ['.']` is bound as its
1524
+ // children around the walls (`wallOff`), so every failing root task
1525
+ // was told its cwd was unreadable while it listed it (B-20). Seatbelt
1526
+ // grants no parent, so on macOS the cwd itself must be covered.
1527
+ return under(target, g) || (process.platform === 'linux' && under(g, target))
1444
1528
  })
1445
1529
  }
1446
1530
 
@@ -1606,7 +1690,7 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
1606
1690
  * literal head. On Linux `expandGrants` drops such a hit before the bind
1607
1691
  * (B-1); seatbelt matches a glob as a path regex, with no hit to drop.
1608
1692
  */
1609
- function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
1693
+ export function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
1610
1694
  const heads = grants
1611
1695
  .filter((g) => !isMountableLiteral(g))
1612
1696
  .map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
@@ -1776,10 +1860,51 @@ async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {
1776
1860
  else {
1777
1861
  const m = /version (\d+)\.(\d+)/.exec(out)
1778
1862
  const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
1779
- straceAvailableCache = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
1863
+ const form = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
1864
+ straceAvailableCache = (await traceAttaches(form)) ? form : false
1780
1865
  }
1781
1866
  } catch {
1782
1867
  straceAvailableCache = false
1783
1868
  }
1784
1869
  return straceAvailableCache
1785
1870
  }
1871
+
1872
+ /**
1873
+ * Whether strace may attach here, asked once with the flags a task's trace
1874
+ * uses (`ownGroupCommand`). `--version` answers on a host that refuses
1875
+ * ptrace (Yama's `ptrace_scope` 2 or 3, a container's seccomp profile),
1876
+ * and there every sandboxed task failed twice, the retry included, on
1877
+ * `attach: ptrace(PTRACE_SEIZE…): Operation not permitted`. Refused, the
1878
+ * run goes untraced, as without strace: bwrap still enforces, and only the
1879
+ * read-violation report is lost, which is said once.
1880
+ */
1881
+ async function traceAttaches(form: 'plain' | 'seccomp'): Promise<boolean> {
1882
+ const p = Bun.spawn(
1883
+ [
1884
+ executablePath('strace'),
1885
+ '-DD',
1886
+ '-f',
1887
+ ...(form === 'seccomp' ? ['--seccomp-bpf'] : []),
1888
+ '-qq',
1889
+ '-e',
1890
+ 'trace=openat',
1891
+ '-o',
1892
+ '/dev/null',
1893
+ '--',
1894
+ executablePath('true'),
1895
+ ],
1896
+ { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
1897
+ )
1898
+ const err = await new Response(p.stderr).text()
1899
+ if ((await p.exited) === 0) return true
1900
+ if (!warnedNoTrace) {
1901
+ warnedNoTrace = true
1902
+ process.stderr.write(
1903
+ `[vx] sandbox: strace cannot trace here (${err.trim().split('\n')[0] ?? `exit ${p.exitCode}`}), ` +
1904
+ `so sandboxed tasks run without the report of the reads the sandbox denied; ` +
1905
+ `the sandbox still enforces\n`,
1906
+ )
1907
+ }
1908
+ return false
1909
+ }
1910
+ let warnedNoTrace = false
@@ -64,8 +64,9 @@ export function taintTracker(
64
64
  u !== undefined && (failed(u) || taintOf(u.node.id))
65
65
  // Post-order over the settled deps with an explicit stack: a chain of
66
66
  // restore-tier hits is judged in one ask, and a recursion would take a
67
- // frame per hop. Memoized once every dep has settled; until then the
68
- // answer may still grow.
67
+ // frame per hop. Taint is final at once; a clean answer only once every
68
+ // dep has settled and its own answer is kept, since a dep below a settled
69
+ // one may still fail (C-23).
69
70
  const taintOf = (root: string): boolean => {
70
71
  const known = memo.get(root)
71
72
  if (known !== undefined) return known
@@ -95,7 +96,7 @@ export function taintTracker(
95
96
  return u !== undefined && (failed(u) || valueOf(d) === true)
96
97
  })
97
98
  local.set(id, taint)
98
- if (taint || deps.every((d) => outcomes.has(d))) memo.set(id, taint)
99
+ if (taint || deps.every((d) => outcomes.has(d) && memo.has(d))) memo.set(id, taint)
99
100
  }
100
101
  return valueOf(root)!
101
102
  }
@@ -9,6 +9,7 @@
9
9
  import { exitSignal } from '../exec/index.js'
10
10
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
11
11
  import { isGroupTask } from '../graph/index.js'
12
+ import { maskedCommand } from '../util/index.js'
12
13
  import type { Logger } from './logger.js'
13
14
  import type { RunContext } from './summary.js'
14
15
 
@@ -340,7 +341,8 @@ export function projectNode(node: TaskNode): TaskView {
340
341
  surfaced: node.surfaced === true,
341
342
  persistent: node.config.exec?.persistent !== undefined,
342
343
  }
343
- if (node.config.exec?.command !== undefined) view.command = node.config.exec.command
344
+ if (node.config.exec?.command !== undefined)
345
+ view.command = maskedCommand(node.config.exec.command, node.config.exec.env?.define)
344
346
  return view
345
347
  }
346
348
 
@@ -33,7 +33,7 @@ import {
33
33
  PersistentReadyError,
34
34
  } from '../exec/index.js'
35
35
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
36
- import { killGraceMs, relPosix, span } from '../util/index.js'
36
+ import { killGraceMs, maskedEmitter, relPosix, secretMask, span } from '../util/index.js'
37
37
  import { SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
38
38
  import { executorLabel } from './plugin-host.js'
39
39
  import {
@@ -323,12 +323,18 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
323
323
  bridgeTag = wrapped.tag
324
324
  signalChannel = wrapped.forwardsSignals
325
325
  }
326
+ // A server's output masked as a one-shot task's is (L-11); `readyWhen`
327
+ // is matched on the raw chunks, before this. Its held tail is flushed by
328
+ // the idle timer: a server never ends on the way to a flush.
329
+ const serverSecrets = secretMask(process.env, env, step.env?.define)
330
+ const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
331
+ const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
326
332
  const persistentOpts: Parameters<typeof runPersistent>[0] = {
327
333
  command,
328
334
  cwd: node.projectDir,
329
335
  env,
330
- onStdout: (chunk) => log.taskStdout(node, chunk),
331
- onStderr: (chunk) => log.taskStderr(node, chunk),
336
+ onStdout: serverOut ? (chunk) => serverOut.push(chunk) : (chunk) => log.taskStdout(node, chunk),
337
+ onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
332
338
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
333
339
  ...(signalChannel ? { signalChannel } : {}),
334
340
  }
@@ -779,8 +785,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
779
785
  await sweepPlaceholders(placeholders)
780
786
  throw err
781
787
  })
782
- .finally(() => clearTimeout(timeoutTimer))
788
+ .finally(() => {
789
+ clearTimeout(timeoutTimer)
790
+ flushMasked()
791
+ })
783
792
  endExec()
793
+ if (secrets !== null)
794
+ res = { ...res, stdout: secrets.mask(res.stdout), stderr: secrets.mask(res.stderr) }
784
795
  // An executor that stopped on the timeout's abort exits non-zero; say
785
796
  // why, so the frame, the retry line and `timedOut` read as a timeout.
786
797
  if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
@@ -791,7 +802,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
791
802
  // ENOENT; the line beside it names the package and how to key it. Only
792
803
  // with a denial there, so it never reddens a pass.
793
804
  for (const w of reachedWithheld(withheld, violations)) {
794
- violations.push({ timestamp: new Date(), line: withheldLinkLine(node.id, w) })
805
+ violations.push({ timestamp: new Date(), hint: true, line: withheldLinkLine(node.id, w) })
795
806
  }
796
807
  // A placeholder the task never wrote is not its output: take it back
797
808
  // before the outputs are collected. On a failure it is also the one
@@ -801,6 +812,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
801
812
  for (const p of untouched) {
802
813
  violations.push({
803
814
  timestamp: new Date(),
815
+ hint: true,
804
816
  line: untouchedPlaceholderLine(node.projectDir, p),
805
817
  })
806
818
  }
@@ -818,7 +830,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
818
830
  // the cache key never folded is not a safe thing to replay. A task that
819
831
  // survives the denial is the dangerous case, not the harmless one:
820
832
  // that is precisely the run that succeeds and caches a wrong result.
821
- if (userSandbox && violations.length > 0 && code === 0) code = 1
833
+ if (userSandbox && violations.some((v) => v.hint !== true) && code === 0) code = 1
822
834
  // A declared sandbox fails the task on any violation — that is its
823
835
  // whole contract. `userSandbox` is the only way a task is sandboxed,
824
836
  // so this reads as "sandboxed and it tripped".
@@ -878,6 +890,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
878
890
  }
879
891
  }
880
892
 
893
+ // The values of secret-named variables, masked in what the task prints
894
+ // and what the cache keeps of it (L-11); null when there are none.
895
+ const secrets = secretMask(process.env, env, step.env?.define)
896
+ let flushMasked = (): void => {}
897
+ // The entry's command is shown by `vx why` and sent with the entry to a
898
+ // remote cache: a value a config interpolated stays out of both.
899
+ const storedCommand = secrets?.mask(step.command) ?? step.command
900
+
881
901
  // Pass or fail, the command may have written where its project's
882
902
  // run-start facts describe; a remote executor wrote on its own disk.
883
903
  const writeReach =
@@ -935,6 +955,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
935
955
  }
936
956
 
937
957
  async function buildRequest(): Promise<ExecuteRequest> {
958
+ const out = secrets && maskedEmitter(secrets, (t) => log.taskStdout(node, t))
959
+ const err = secrets && maskedEmitter(secrets, (t) => log.taskStderr(node, t))
960
+ flushMasked = () => {
961
+ out?.end()
962
+ err?.end()
963
+ }
938
964
  const base: ExecuteRequest = {
939
965
  taskId: node.id,
940
966
  workspaceRoot: args.workspaceRoot,
@@ -944,8 +970,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
944
970
  env,
945
971
  envDefine: step.env?.define ?? {},
946
972
  capture,
947
- onStdout: (chunk) => log.taskStdout(node, chunk),
948
- onStderr: (chunk) => log.taskStderr(node, chunk),
973
+ onStdout: out ? (chunk) => out.push(chunk) : (chunk) => log.taskStdout(node, chunk),
974
+ onStderr: err ? (chunk) => err.push(chunk) : (chunk) => log.taskStderr(node, chunk),
949
975
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
950
976
  signal: requestSignal(),
951
977
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
@@ -987,7 +1013,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
987
1013
  hash,
988
1014
  entry: {
989
1015
  taskId: node.id,
990
- command: step.command,
1016
+ command: storedCommand,
991
1017
  durationMs: result.durationMs,
992
1018
  stdout: result.stdout,
993
1019
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
@@ -1012,7 +1038,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1012
1038
  outputs,
1013
1039
  wsOutputs,
1014
1040
  captured,
1015
- command: step.command,
1041
+ command: storedCommand,
1016
1042
  durationMs: result.durationMs,
1017
1043
  stdout: result.stdout,
1018
1044
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
@@ -1125,7 +1151,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1125
1151
  wallclockEndNs,
1126
1152
  ...(finalViolations.length > 0
1127
1153
  ? {
1128
- sandboxViolations: finalViolations.length,
1154
+ // vx's own notes ride with the lines and are no denial (B-20).
1155
+ sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
1129
1156
  sandboxViolationLines: finalViolations.map((v) => v.line),
1130
1157
  }
1131
1158
  : {}),
@@ -26,6 +26,7 @@
26
26
  // `--ui=stream` mode.
27
27
 
28
28
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
29
+ import { maskedCommand } from '../util/index.js'
29
30
  import { paint, type ColorSupport } from './colors.js'
30
31
  import { formatDuration } from './summary.js'
31
32
  import { outcomeLabel, skippedReason } from './events.js'
@@ -104,7 +105,11 @@ export function formatTaskBlock(
104
105
  if (forceCommand || outcome.status === 'success' || outcome.status === 'failed') {
105
106
  // No section label for the command (owner cut it) — the dim `$ `
106
107
  // line under the header reads as the command on its own.
107
- lines.push('', corner(`$ ${node.config.exec?.command ?? ''}`), '')
108
+ lines.push(
109
+ '',
110
+ corner(`$ ${maskedCommand(node.config.exec?.command ?? '', node.config.exec?.env?.define)}`),
111
+ '',
112
+ )
108
113
  }
109
114
 
110
115
  pushStreamSection(lines, stdout, 'STDOUT', SUCCESS, body.droppedStdout ?? 0, colors)
@@ -386,7 +391,7 @@ export function formatTaskSkippedLine(
386
391
  */
387
392
  export function formatFrameOpen(node: TaskNode, colors: ColorSupport = NO_COLOR): string {
388
393
  const corner = (t: string) => paint('', t, colors, { dim: true })
389
- const cmd = node.config.exec?.command ?? ''
394
+ const cmd = maskedCommand(node.config.exec?.command ?? '', node.config.exec?.env?.define)
390
395
  const mark = isPersistentNode(node) ? `${paint(ACCENT, '▸', colors)} ` : ''
391
396
  return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
392
397
  }
@@ -20,7 +20,7 @@ import {
20
20
  WORKSPACE_OUTPUT_PREFIX,
21
21
  } from '../cache/index.js'
22
22
  import type { TaskOutcome } from '../graph/index.js'
23
- import { asTrees, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
23
+ import { asTrees, secretMask, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
24
24
  import type { ExecuteArgs } from './execute-task.js'
25
25
  import { entryHolds } from './miss-save.js'
26
26
 
@@ -263,7 +263,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
263
263
  ])
264
264
  }
265
265
  }
266
- if (hit.stdout) log.taskStdout(node, hit.stdout)
266
+ if (hit.stdout) {
267
+ // An entry saved before masking, or by another machine, can hold a
268
+ // secret-named variable's value (L-11). Asked only of a hit that prints.
269
+ const secrets = secretMask(process.env, node.config.exec?.env?.define)
270
+ log.taskStdout(node, secrets === null ? hit.stdout : secrets.mask(hit.stdout))
271
+ }
267
272
  const status =
268
273
  hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
269
274
  // `restored` distinguishes "we just wrote files to disk" from
@@ -56,6 +56,11 @@ export interface SandboxArmer {
56
56
  export interface SandboxRunUnion {
57
57
  /** Domains the run's filtering proxy will accept, in declaration order. */
58
58
  domains: string[]
59
+ /**
60
+ * Domains the proxy refuses, whichever task asks: every task's
61
+ * `deny.network`. The proxy is the run's, so a deny is too (B-21).
62
+ */
63
+ deniedDomains: string[]
59
64
  /** Whether SRT's all-or-nothing `socket(AF_UNIX)` filter is lifted for the run. */
60
65
  unixSockets: boolean
61
66
  /** Whether EVERY sandboxed task accepts the weaker nested profile. */
@@ -67,6 +72,7 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
67
72
  if (sandboxed.length === 0) return null
68
73
  const weakerNested = sandboxed.every((n) => n.config.exec?.sandbox?.weakerWhenNested === true)
69
74
  const domains = new Set<string>()
75
+ const denied = new Set<string>()
70
76
  let unixSockets = false
71
77
  for (const n of sandboxed) {
72
78
  const allow = n.config.exec?.sandbox?.allow
@@ -75,12 +81,13 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
75
81
  // proxy rather than going through it, so folding it in as `*` would
76
82
  // widen the allowlist every OTHER task in the run is filtered against.
77
83
  if (Array.isArray(net)) for (const d of net) domains.add(d)
84
+ for (const d of n.config.exec?.sandbox?.deny?.network ?? []) denied.add(d)
78
85
  const sockets = allow?.unixSockets
79
86
  if (sockets === true || (Array.isArray(sockets) && sockets.length > 0)) unixSockets = true
80
87
  const lb = allow?.localBinding
81
88
  if (Array.isArray(lb) && lb.length > 0 && process.platform === 'linux') unixSockets = true
82
89
  }
83
- return { domains: [...domains], unixSockets, weakerNested }
90
+ return { domains: [...domains], deniedDomains: [...denied], unixSockets, weakerNested }
84
91
  }
85
92
 
86
93
  /**
@@ -107,7 +114,7 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
107
114
  export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
108
115
  const union = sandboxRunUnion(nodes)
109
116
  if (union === null) return null
110
- const { domains, unixSockets, weakerNested } = union
117
+ const { domains, deniedDomains, unixSockets, weakerNested } = union
111
118
  let pending: Promise<void> | undefined
112
119
  let armed = false
113
120
  return {
@@ -121,6 +128,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
121
128
  if (!avail.available) throw new UserError(`sandbox not available: ${avail.reason}`)
122
129
  await initSandbox({
123
130
  allowedDomains: domains,
131
+ deniedDomains,
124
132
  ...(unixSockets ? { allowAllUnixSockets: true } : {}),
125
133
  })
126
134
  } catch (err) {
@@ -17,7 +17,7 @@
17
17
  // core calls INTO; those are the behavior capabilities, kept separate.
18
18
 
19
19
  import type { TaskOutcome, TaskStatus } from '../graph/index.js'
20
- import { settleWithin, teardownTimeoutMs } from '../util/index.js'
20
+ import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
21
21
  import type { RunEvent, RunEventSubscriber } from './events.js'
22
22
 
23
23
  /** Bumped when the record shape changes. Readers MUST check `v`. */
@@ -438,7 +438,8 @@ export function createTelemetrySource(args: {
438
438
  task: node.taskName,
439
439
  ts,
440
440
  }
441
- if (node.config.exec.command !== undefined) rec.command = node.config.exec.command
441
+ if (node.config.exec.command !== undefined)
442
+ rec.command = maskedCommand(node.config.exec.command, node.config.exec.env?.define)
442
443
  deliver(rec)
443
444
  return
444
445
  }