@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.
- package/README.md +60 -29
- package/package.json +5 -5
- package/src/bin.ts +9 -1
- package/src/cache/inputs.ts +5 -3
- package/src/cli/cache.ts +2 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +17 -0
- package/src/cli/init.ts +50 -3
- package/src/cli/last.ts +20 -4
- package/src/cli/plugin-templates.ts +51 -0
- package/src/cli/run-id.ts +23 -2
- package/src/cli/run.ts +22 -7
- package/src/cli/show.ts +48 -6
- package/src/cli/watch-set.ts +182 -0
- package/src/cli/watch.ts +16 -181
- package/src/cli/why.ts +21 -4
- 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 +131 -6
- package/src/orchestrator/admission.ts +4 -3
- 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 +151 -25
package/src/exec/runner.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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:
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
-
//
|
|
189
|
-
//
|
|
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, {
|
|
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' &&
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
68
|
-
// answer
|
|
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)
|
|
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
|
}
|