@vzn/vx 0.0.101 → 0.0.103

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.
@@ -26,12 +26,16 @@
26
26
  import path from 'node:path'
27
27
  import os from 'node:os'
28
28
  import {
29
+ chmodSync,
29
30
  closeSync,
31
+ lstatSync,
32
+ existsSync,
30
33
  mkdirSync,
31
34
  openSync,
32
35
  readlinkSync,
33
36
  realpathSync,
34
37
  rmSync,
38
+ statSync,
35
39
  unlinkSync,
36
40
  } from 'node:fs'
37
41
  import { mkdir, unlink } from 'node:fs/promises'
@@ -354,6 +358,39 @@ function taskTmpRoot(): string {
354
358
  return path.join(sandboxTmpdir(), 'vx-tasks')
355
359
  }
356
360
 
361
+ /**
362
+ * `taskTmpRoot()`, made or checked as this user's own before a task's
363
+ * directory goes in it. It sits in a shared temp dir: a user who made it
364
+ * first owned the parent of every task's TMPDIR and bridge socket, and
365
+ * renamed a running task's directory to plant their own under its name
366
+ * (probed, L-13) — the path the host bridge dials and the write grant is
367
+ * resolved from. A symlink, another owner, or a parent another user may
368
+ * rewrite is refused; one of ours left open to others is closed.
369
+ */
370
+ function ownTaskTmpRoot(): string {
371
+ const root = taskTmpRoot()
372
+ mkdirSync(root, { recursive: true, mode: 0o700 })
373
+ const uid = process.getuid?.()
374
+ if (uid === undefined) return root
375
+ const st = lstatSync(root)
376
+ const parent = statSync(path.dirname(root))
377
+ // Group write is a umask-002 box's default; others' write is the hole,
378
+ // unless the sticky bit keeps them to their own entries (`/tmp`).
379
+ const parentShared = (parent.mode & 0o002) !== 0 && (parent.mode & 0o1000) === 0
380
+ if (
381
+ !st.isDirectory() ||
382
+ st.uid !== uid ||
383
+ (parent.uid !== uid && parent.uid !== 0) ||
384
+ parentShared
385
+ ) {
386
+ throw new UserError(
387
+ `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`,
388
+ )
389
+ }
390
+ if ((st.mode & 0o077) !== 0) chmodSync(root, 0o700)
391
+ return root
392
+ }
393
+
357
394
  const liveTaskTmpdirs = new Set<string>()
358
395
  let tmpdirExitHooked = false
359
396
  function trackTaskTmpdir(dir: string): void {
@@ -400,6 +437,46 @@ async function unlinkStaleMuxSockets(): Promise<void> {
400
437
  }
401
438
  }
402
439
 
440
+ /**
441
+ * The sandbox's own tools, by the paths vx resolves on its own PATH. SRT
442
+ * writes a bare `bwrap` (and, in the network bridge, a bare `socat`) into
443
+ * the command the task's shell runs, with the TASK's environment: a
444
+ * dependency's `node_modules/.bin/bwrap`, first on that PATH, ran in
445
+ * bwrap's place and the task ran unsandboxed with exit 0 (J-33's lead,
446
+ * B-19). A tool vx cannot find is left to SRT's own dependency check,
447
+ * whose refusal names it.
448
+ */
449
+ function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
450
+ if (process.platform !== 'linux') return {}
451
+ const paths: { bwrapPath?: string; socatPath?: string } = {}
452
+ try {
453
+ paths.bwrapPath = executablePath('bwrap')
454
+ } catch {
455
+ // SRT's dependency check says so
456
+ }
457
+ try {
458
+ paths.socatPath = executablePath('socat')
459
+ } catch {
460
+ // SRT's dependency check says so
461
+ }
462
+ return paths
463
+ }
464
+
465
+ /**
466
+ * Whether this Linux host speaks IPv6. SRT's in-sandbox network bridge is
467
+ * `socat TCP-LISTEN:3128`, and socat 1.8 opens that as an IPv6 socket: on
468
+ * a host without IPv6 it failed ("Address family not supported by
469
+ * protocol"), its error went to /dev/null, and every networked task met
470
+ * only "connection refused" on the proxy (J-33's lead, B-22). Without
471
+ * IPv6 the wrapped command sets `SOCAT_DEFAULT_LISTEN_IP=4`, socat's own
472
+ * switch for the listen family; with it nothing changes. Asked once.
473
+ */
474
+ let hasIpv6: boolean | undefined
475
+ function hostHasIpv6(): boolean {
476
+ hasIpv6 ??= existsSync('/proc/net/if_inet6')
477
+ return hasIpv6
478
+ }
479
+
403
480
  /**
404
481
  * One-time SRT initialization per orchestrator run. Starts the proxy
405
482
  * servers + (on macOS) the violation log monitor. Safe to call repeatedly
@@ -417,6 +494,13 @@ async function unlinkStaleMuxSockets(): Promise<void> {
417
494
  */
418
495
  export async function initSandbox(opts?: {
419
496
  allowedDomains?: readonly string[]
497
+ /**
498
+ * Every domain any sandboxed task of the run denies. The proxy is the
499
+ * run's, so a deny refuses it for every task, as the allowlist is the
500
+ * union's; before B-21 the list was always empty and a `deny.network`
501
+ * refused nothing.
502
+ */
503
+ deniedDomains?: readonly string[]
420
504
  /**
421
505
  * Whether any task of the run lifts SRT's seccomp block on
422
506
  * `socket(AF_UNIX)`: one that declares `unixSockets` or a `localBinding`
@@ -436,11 +520,12 @@ export async function initSandbox(opts?: {
436
520
  const config: Parameters<typeof SandboxManager.initialize>[0] = {
437
521
  network: {
438
522
  allowedDomains: [...(opts?.allowedDomains ?? [])],
439
- deniedDomains: [],
523
+ deniedDomains: [...(opts?.deniedDomains ?? [])],
440
524
  ...(opts?.allowAllUnixSockets === true ? { allowAllUnixSockets: true } : {}),
441
525
  },
442
526
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
443
527
  ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
528
+ ...linuxToolPaths(),
444
529
  }
445
530
  if (!srtUp) await unlinkStaleMuxSockets()
446
531
  await SandboxManager.initialize(
@@ -728,6 +813,12 @@ export interface SandboxViolation {
728
813
  * `openat(../x) = -1 ENOENT [/x]` say the same thing.
729
814
  */
730
815
  target?: string
816
+ /**
817
+ * vx's own note beside a failure (the cwd it cannot read, a placeholder
818
+ * the task never wrote, a withheld link), not a denial: shown with the
819
+ * denials, never counted as one (B-20).
820
+ */
821
+ hint?: true
731
822
  /** Absolute, when `target` is a path at all. */
732
823
  path?: string
733
824
  ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
@@ -798,8 +889,9 @@ export async function wrapSandboxedCommand(
798
889
  const userCommand = withForwardArgs(args.command, args.forwardArgs)
799
890
 
800
891
  const tag = xxh3hex(`${args.cwd}|${userCommand}|${process.hrtime.bigint()}`).slice(0, 16)
892
+ ownTaskTmpRoot()
801
893
  const tmp = taskTmpdir(tag)
802
- mkdirSync(tmp, { recursive: true })
894
+ mkdirSync(tmp, { mode: 0o700 })
803
895
  trackTaskTmpdir(tmp)
804
896
  // After the tag: SRT keys violations by the command's first 100 chars.
805
897
  const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
@@ -845,7 +937,9 @@ export async function wrapSandboxedCommand(
845
937
  // sandboxed server and all it forked outlived vx (turborepo#9666). Now
846
938
  // the namespace goes with vx, a `setsid` daemon inside included, a
847
939
  // traced one-shot task too: its strace runs inside (B-11).
848
- if (process.platform === 'linux' && wrapped.startsWith('bwrap ')) wrapped = `exec ${wrapped}`
940
+ if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped)) wrapped = `exec ${wrapped}`
941
+ if (process.platform === 'linux' && !hostHasIpv6())
942
+ wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
849
943
  if (args.server === true) liveServers.add(tag)
850
944
  if (ports.length > 0) spawnHostBridges(ports, tag)
851
945
  return {
@@ -1394,6 +1488,7 @@ async function runSandboxedOnce(
1394
1488
  if (exitCode !== 0 && violations.length === 0 && !readableUnder(args.cwd, grantedRead)) {
1395
1489
  violations.push({
1396
1490
  timestamp: new Date(),
1491
+ hint: true,
1397
1492
  line:
1398
1493
  `vx: this sandbox grants no read access to the task's own working directory ` +
1399
1494
  `(${args.cwd}), so a command that reads or lists it fails with whatever error it ` +
@@ -1438,9 +1533,16 @@ async function runSandboxedOnce(
1438
1533
  */
1439
1534
  function readableUnder(dir: string, granted: readonly string[]): boolean {
1440
1535
  const target = toRealPath(dir)
1536
+ const under = (a: string, b: string): boolean =>
1537
+ a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep)
1441
1538
  return granted.some((p) => {
1442
1539
  const g = toRealPath(p)
1443
- return target === g || target.startsWith(g.endsWith(path.sep) ? g : g + path.sep)
1540
+ // On Linux a grant INSIDE the cwd makes the cwd listable too: bwrap
1541
+ // builds the path to the bind. A root's `read: ['.']` is bound as its
1542
+ // children around the walls (`wallOff`), so every failing root task
1543
+ // was told its cwd was unreadable while it listed it (B-20). Seatbelt
1544
+ // grants no parent, so on macOS the cwd itself must be covered.
1545
+ return under(target, g) || (process.platform === 'linux' && under(g, target))
1444
1546
  })
1445
1547
  }
1446
1548
 
@@ -1606,7 +1708,7 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
1606
1708
  * literal head. On Linux `expandGrants` drops such a hit before the bind
1607
1709
  * (B-1); seatbelt matches a glob as a path regex, with no hit to drop.
1608
1710
  */
1609
- function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
1711
+ export function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
1610
1712
  const heads = grants
1611
1713
  .filter((g) => !isMountableLiteral(g))
1612
1714
  .map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
@@ -1776,10 +1878,51 @@ async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {
1776
1878
  else {
1777
1879
  const m = /version (\d+)\.(\d+)/.exec(out)
1778
1880
  const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
1779
- straceAvailableCache = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
1881
+ const form = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
1882
+ straceAvailableCache = (await traceAttaches(form)) ? form : false
1780
1883
  }
1781
1884
  } catch {
1782
1885
  straceAvailableCache = false
1783
1886
  }
1784
1887
  return straceAvailableCache
1785
1888
  }
1889
+
1890
+ /**
1891
+ * Whether strace may attach here, asked once with the flags a task's trace
1892
+ * uses (`ownGroupCommand`). `--version` answers on a host that refuses
1893
+ * ptrace (Yama's `ptrace_scope` 2 or 3, a container's seccomp profile),
1894
+ * and there every sandboxed task failed twice, the retry included, on
1895
+ * `attach: ptrace(PTRACE_SEIZE…): Operation not permitted`. Refused, the
1896
+ * run goes untraced, as without strace: bwrap still enforces, and only the
1897
+ * read-violation report is lost, which is said once.
1898
+ */
1899
+ async function traceAttaches(form: 'plain' | 'seccomp'): Promise<boolean> {
1900
+ const p = Bun.spawn(
1901
+ [
1902
+ executablePath('strace'),
1903
+ '-DD',
1904
+ '-f',
1905
+ ...(form === 'seccomp' ? ['--seccomp-bpf'] : []),
1906
+ '-qq',
1907
+ '-e',
1908
+ 'trace=openat',
1909
+ '-o',
1910
+ '/dev/null',
1911
+ '--',
1912
+ executablePath('true'),
1913
+ ],
1914
+ { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
1915
+ )
1916
+ const err = await new Response(p.stderr).text()
1917
+ if ((await p.exited) === 0) return true
1918
+ if (!warnedNoTrace) {
1919
+ warnedNoTrace = true
1920
+ process.stderr.write(
1921
+ `[vx] sandbox: strace cannot trace here (${err.trim().split('\n')[0] ?? `exit ${p.exitCode}`}), ` +
1922
+ `so sandboxed tasks run without the report of the reads the sandbox denied; ` +
1923
+ `the sandbox still enforces\n`,
1924
+ )
1925
+ }
1926
+ return false
1927
+ }
1928
+ let warnedNoTrace = false
@@ -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
  }
@@ -49,6 +49,21 @@ export function isDiskFull(err: unknown): err is NodeJS.ErrnoException {
49
49
  return code === 'ENOSPC' || code === 'EDQUOT'
50
50
  }
51
51
 
52
+ /**
53
+ * `EMFILE` or `ENFILE`: the process or the system is out of file
54
+ * descriptors. macOS starts a shell at 256, which a large workspace's
55
+ * config load and input walk can reach; the stack it printed named a file
56
+ * that was fine.
57
+ */
58
+ export function isOutOfFds(err: unknown): err is NodeJS.ErrnoException {
59
+ if (!(err instanceof Error)) return false
60
+ const code = (err as NodeJS.ErrnoException).code
61
+ return code === 'EMFILE' || code === 'ENFILE'
62
+ }
63
+
64
+ export const OUT_OF_FDS_HINT =
65
+ 'the process is out of file descriptors; raise the limit (ulimit -n 4096) and re-run'
66
+
52
67
  /** A file system refusing a write for a reason no code path caused: permission or space. */
53
68
  export function isFsRefusal(err: unknown): err is NodeJS.ErrnoException {
54
69
  return isPermissionError(err) || isDiskFull(err)
package/src/util/index.ts CHANGED
@@ -14,6 +14,8 @@ export {
14
14
  UserError,
15
15
  gitSpawnRefusal,
16
16
  isExecutableMissing,
17
+ isOutOfFds,
18
+ OUT_OF_FDS_HINT,
17
19
  } from './errors.js'
18
20
  export { xxh3, xxh3hex } from './hash.js'
19
21
  export { mark, printTimings, span } from './timing.js'
@@ -47,5 +49,6 @@ export { splitTaskId } from './task-id.js'
47
49
  export { editDistance, nearMatches, nearest } from './edit-distance.js'
48
50
  export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
49
51
  export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
50
- export { executablePath } from './which.js'
52
+ export { executablePath, shellArgv } from './which.js'
51
53
  export { procfsIsOwn } from './procfs.js'
54
+ export { maskedCommand, maskedEmitter, MASKED, secretMask, type SecretMask } from './secret-mask.js'