@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.
- package/README.md +46 -15
- package/package.json +5 -5
- package/src/bin.ts +9 -1
- package/src/cache/inputs.ts +5 -3
- package/src/cli/help.ts +2 -1
- package/src/cli/index.ts +17 -0
- package/src/cli/last.ts +47 -6
- package/src/cli/run-id.ts +23 -2
- package/src/cli/show.ts +46 -5
- package/src/cli/watch.ts +14 -1
- package/src/cli/why.ts +19 -3
- package/src/exec/kill-tree.ts +2 -0
- package/src/exec/runner.ts +24 -25
- package/src/exec/sandbox-binds.ts +6 -6
- package/src/exec/sandbox-runtime.ts +149 -6
- package/src/orchestrator/events.ts +3 -1
- package/src/orchestrator/execute-task.ts +38 -11
- package/src/orchestrator/framed-output.ts +7 -2
- package/src/orchestrator/hit-restore.ts +7 -2
- package/src/orchestrator/sandbox-request.ts +10 -2
- package/src/orchestrator/telemetry.ts +3 -2
- package/src/util/errors.ts +15 -0
- package/src/util/index.ts +4 -1
- package/src/util/secret-mask.ts +145 -0
- package/src/util/which.ts +12 -0
- package/src/workspace/affected.ts +10 -2
- package/src/workspace/config-cache.ts +3 -1
- package/src/workspace/config-imports.ts +184 -15
|
@@ -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, {
|
|
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' &&
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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(() =>
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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)
|
|
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
|
}
|
package/src/util/errors.ts
CHANGED
|
@@ -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'
|