@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
readFileSync,
|
|
38
38
|
readlinkSync,
|
|
39
39
|
realpathSync,
|
|
40
|
+
renameSync,
|
|
40
41
|
rmSync,
|
|
41
42
|
statSync,
|
|
42
43
|
unlinkSync,
|
|
@@ -47,11 +48,13 @@ import type { SandboxConfig } from '../config.js'
|
|
|
47
48
|
import {
|
|
48
49
|
armTimeout,
|
|
49
50
|
drainOrAbort,
|
|
51
|
+
lineEnds,
|
|
50
52
|
POST_EXIT_CUT_LINE,
|
|
51
53
|
shellQuote,
|
|
52
54
|
withForwardArgs,
|
|
53
55
|
signalExitCode,
|
|
54
56
|
stopSignal,
|
|
57
|
+
SANDBOXED_TASK,
|
|
55
58
|
spawnFailureText,
|
|
56
59
|
streamToString,
|
|
57
60
|
resourceUsageToCpuRss,
|
|
@@ -59,8 +62,10 @@ import {
|
|
|
59
62
|
type CaptureConfig,
|
|
60
63
|
type RunResult,
|
|
61
64
|
} from './runner.js'
|
|
65
|
+
import { watchTree } from './proc-sample.js'
|
|
62
66
|
import {
|
|
63
67
|
BUN_GLOB_WILDCARDS,
|
|
68
|
+
ESSENTIAL_ENV,
|
|
64
69
|
executablePath,
|
|
65
70
|
helperEnv,
|
|
66
71
|
taskShell,
|
|
@@ -68,6 +73,7 @@ import {
|
|
|
68
73
|
isTmpdirRefusal,
|
|
69
74
|
procfsIsOwn,
|
|
70
75
|
TMPDIR_HINT,
|
|
76
|
+
ui,
|
|
71
77
|
UserError,
|
|
72
78
|
xxh3hex,
|
|
73
79
|
} from '../util/index.js'
|
|
@@ -205,6 +211,7 @@ async function probeUncached(weakerNested: boolean): Promise<SandboxAvailability
|
|
|
205
211
|
function applyPolicyHere(): SandboxAvailability {
|
|
206
212
|
const proc = Bun.spawnSync({
|
|
207
213
|
cmd: ['sandbox-exec', '-p', '(version 1)(allow default)', '/usr/bin/true'],
|
|
214
|
+
env: helperEnv(),
|
|
208
215
|
stdout: 'ignore',
|
|
209
216
|
stderr: 'pipe',
|
|
210
217
|
})
|
|
@@ -230,12 +237,25 @@ function applyPolicyHere(): SandboxAvailability {
|
|
|
230
237
|
* the loader).
|
|
231
238
|
*/
|
|
232
239
|
export function unavailableReason(exitCode: number | null, stderr: string): string {
|
|
233
|
-
const hint = stderr.includes('
|
|
234
|
-
?
|
|
235
|
-
|
|
240
|
+
const hint = stderr.includes('capability-restricted')
|
|
241
|
+
? // Ubuntu 25.04+ confines bwrap with this AppArmor profile (2026-10-23).
|
|
242
|
+
" — AppArmor's bwrap-userns-restrict profile strips the capabilities of the seccomp helper's nested user namespace: replace it with a profile for /usr/bin/bwrap that grants `userns,` (https://discourse.ubuntu.com/t/58007)"
|
|
243
|
+
: stderr.includes('uid_map')
|
|
244
|
+
? " — the runtime's seccomp helper cannot create its nested user namespace here (root inside a container, or a kernel that forbids nested user namespaces): run as a non-root user, or set `sandbox.weakerWhenNested: true` on every sandboxed task"
|
|
245
|
+
: ''
|
|
236
246
|
return `a sandboxed \`true\` failed (exit ${exitCode}): ${stderr.slice(0, 200)}${hint}`
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
/** vx's values of the variables every task gets (`ESSENTIAL_ENV`). */
|
|
250
|
+
function essentialEnv(): Record<string, string> {
|
|
251
|
+
const env: Record<string, string> = {}
|
|
252
|
+
for (const name of ESSENTIAL_ENV) {
|
|
253
|
+
const value = process.env[name]
|
|
254
|
+
if (value !== undefined) env[name] = value
|
|
255
|
+
}
|
|
256
|
+
return env
|
|
257
|
+
}
|
|
258
|
+
|
|
239
259
|
async function trySandboxedTrue(
|
|
240
260
|
SandboxManager: SrtModule['SandboxManager'],
|
|
241
261
|
weakerNested: boolean,
|
|
@@ -248,6 +268,8 @@ async function trySandboxedTrue(
|
|
|
248
268
|
)
|
|
249
269
|
const proc = Bun.spawn([taskShell(), '-c', wrapped], {
|
|
250
270
|
argv0: 'sh',
|
|
271
|
+
// What a task gets of vx's env: the probe is a sandboxed `true`.
|
|
272
|
+
env: essentialEnv(),
|
|
251
273
|
stdout: 'ignore',
|
|
252
274
|
stderr: 'pipe',
|
|
253
275
|
stdin: 'ignore',
|
|
@@ -445,10 +467,41 @@ function trackTaskTmpdir(dir: string): void {
|
|
|
445
467
|
if (tmpdirExitHooked) return
|
|
446
468
|
tmpdirExitHooked = true
|
|
447
469
|
process.on('exit', () => {
|
|
448
|
-
for (const d of liveTaskTmpdirs)
|
|
470
|
+
for (const d of liveTaskTmpdirs) removeTaskTmpdir(d)
|
|
449
471
|
})
|
|
450
472
|
}
|
|
451
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Remove a task's temp directory; never throws. The task may leave it
|
|
476
|
+
* unremovable: a read-only directory in it (EACCES), or a straggler still
|
|
477
|
+
* writing (ENOTEMPTY). Thrown, either failed a task that had passed, as a
|
|
478
|
+
* worker fault under dte. A read-only one is made writable and the remove
|
|
479
|
+
* retried; what still stands is left to the exit hook's last try.
|
|
480
|
+
*/
|
|
481
|
+
function removeTaskTmpdir(dir: string): boolean {
|
|
482
|
+
for (let attempt = 0; ; attempt++) {
|
|
483
|
+
try {
|
|
484
|
+
rmSync(dir, { recursive: true, force: true })
|
|
485
|
+
return true
|
|
486
|
+
} catch {
|
|
487
|
+
if (attempt === 2) return false
|
|
488
|
+
ownerWritable(dir)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Give the owner write and search on every directory under `dir`, which a recursive remove needs. */
|
|
494
|
+
function ownerWritable(dir: string): void {
|
|
495
|
+
try {
|
|
496
|
+
const st = lstatSync(dir)
|
|
497
|
+
if (!st.isDirectory()) return
|
|
498
|
+
if ((st.mode & 0o700) !== 0o700) chmodSync(dir, (st.mode & 0o7777) | 0o700)
|
|
499
|
+
for (const name of readdirSync(dir)) ownerWritable(path.join(dir, name))
|
|
500
|
+
} catch {
|
|
501
|
+
// gone, or not this user's: the retry says
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
452
505
|
/** Whether SRT is up in this process — set by `initSandbox`, cleared by `resetSandbox`. */
|
|
453
506
|
let srtUp = false
|
|
454
507
|
/**
|
|
@@ -595,6 +648,22 @@ function noScanCommand(): string {
|
|
|
595
648
|
return file
|
|
596
649
|
}
|
|
597
650
|
|
|
651
|
+
/**
|
|
652
|
+
* vx's executables the runtime spawns OUTSIDE the sandbox (the no-op scan,
|
|
653
|
+
* the socat wrapper), as directories every task is walled from, and the
|
|
654
|
+
* files a task still runs (the wrapper is its side of the proxy bridge
|
|
655
|
+
* too), granted back read-only. They live in the OS temp dir, and where
|
|
656
|
+
* that is one every task may write (SRT binds `/tmp/claude` writable into
|
|
657
|
+
* each), a task rewrote the no-op and the next wrap ran it unsandboxed.
|
|
658
|
+
*/
|
|
659
|
+
function hostHelpers(): { dirs: string[]; files: string[] } {
|
|
660
|
+
const files = [...socatWrappers].filter(([bare, w]) => w !== bare).map(([, w]) => w)
|
|
661
|
+
const dirs = [...(noScan === undefined ? [] : [noScan]), ...files].map((f) =>
|
|
662
|
+
toRealPath(path.dirname(f)),
|
|
663
|
+
)
|
|
664
|
+
return { dirs: unique(dirs), files: files.map(toRealPath) }
|
|
665
|
+
}
|
|
666
|
+
|
|
598
667
|
function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
|
|
599
668
|
if (process.platform !== 'linux') return {}
|
|
600
669
|
const paths: { bwrapPath?: string; socatPath?: string } = {}
|
|
@@ -604,13 +673,93 @@ function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
|
|
|
604
673
|
// SRT's dependency check says so
|
|
605
674
|
}
|
|
606
675
|
try {
|
|
607
|
-
paths.socatPath = executablePath('socat')
|
|
676
|
+
paths.socatPath = envCutSocat(executablePath('socat'))
|
|
608
677
|
} catch {
|
|
609
678
|
// SRT's dependency check says so
|
|
610
679
|
}
|
|
611
680
|
return paths
|
|
612
681
|
}
|
|
613
682
|
|
|
683
|
+
/**
|
|
684
|
+
* socat behind a wrapper that `exec`s it with the essential env only.
|
|
685
|
+
* SRT spawns its host bridge (`socat UNIX-LISTEN:… TCP:localhost:…`) with
|
|
686
|
+
* no `env`, so it held vx's whole environment for the run, readable from
|
|
687
|
+
* its `/proc/<pid>/environ` by any unsandboxed task beside it, the hole
|
|
688
|
+
* `hideEnvFromTasks` closes for vx itself. SRT takes no env for it, and
|
|
689
|
+
* narrowing `process.env` around `initialize` would hand a task spawned
|
|
690
|
+
* meanwhile the narrowed one. The same path runs socat inside the
|
|
691
|
+
* sandbox, so the wrapper keeps `SOCAT_DEFAULT_LISTEN_IP` (`hostHasIpv6`).
|
|
692
|
+
* It lives in this user's own 0700 directory, named by its content, and
|
|
693
|
+
* no exit hook removes it: SRT's dependency check reads the path its live
|
|
694
|
+
* config holds, and a per-process one an emitted `exit` reaped left the
|
|
695
|
+
* next probe "socat not executable" while the process went on (B-93's
|
|
696
|
+
* shape). A host where it cannot run (`/tmp` mounted noexec) keeps the
|
|
697
|
+
* bare socat.
|
|
698
|
+
*/
|
|
699
|
+
const socatWrappers = new Map<string, string>()
|
|
700
|
+
function envCutSocat(socat: string): string {
|
|
701
|
+
const known = socatWrappers.get(socat)
|
|
702
|
+
// One stat per init: a wrapper someone removed is made again.
|
|
703
|
+
if (known !== undefined && (known === socat || existsSync(known))) return known
|
|
704
|
+
let wrapper = socat
|
|
705
|
+
try {
|
|
706
|
+
const keep = [...ESSENTIAL_ENV, 'SOCAT_DEFAULT_LISTEN_IP']
|
|
707
|
+
.map((n) => `\${${n}+"${n}=$${n}"}`)
|
|
708
|
+
.join(' ')
|
|
709
|
+
const body = `#!/bin/sh\nexec ${shellQuote(executablePath('env'))} -i ${keep} ${shellQuote(socat)} "$@"\n`
|
|
710
|
+
const file = ownWrapper(body)
|
|
711
|
+
if (
|
|
712
|
+
Bun.spawnSync([file, '-V'], { env: helperEnv(), stdout: 'ignore', stderr: 'ignore' })
|
|
713
|
+
.exitCode === 0
|
|
714
|
+
) {
|
|
715
|
+
wrapper = file
|
|
716
|
+
}
|
|
717
|
+
} catch {
|
|
718
|
+
// no private directory or no `env`: the bare socat, as before
|
|
719
|
+
}
|
|
720
|
+
socatWrappers.set(socat, wrapper)
|
|
721
|
+
return wrapper
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* `body` as an executable in `vx-socat-<uid>/` under the OS temp dir,
|
|
726
|
+
* written once (to a temp, then renamed, so a process beside this one
|
|
727
|
+
* never runs half of it) and checked byte for byte before it is used.
|
|
728
|
+
* Anything else at the names (another owner, group or other bits, a
|
|
729
|
+
* link, other bytes) is refused.
|
|
730
|
+
*/
|
|
731
|
+
function ownWrapper(body: string): string {
|
|
732
|
+
const uid = process.getuid!()
|
|
733
|
+
const dir = path.join(os.tmpdir(), `vx-socat-${uid}`)
|
|
734
|
+
try {
|
|
735
|
+
mkdirSync(dir, { mode: 0o700 })
|
|
736
|
+
} catch (err) {
|
|
737
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
738
|
+
}
|
|
739
|
+
const d = lstatSync(dir)
|
|
740
|
+
if (!d.isDirectory() || d.uid !== uid || (d.mode & 0o077) !== 0) {
|
|
741
|
+
throw new Error(`${dir} is not this user's own directory`)
|
|
742
|
+
}
|
|
743
|
+
const file = path.join(dir, `socat-${xxh3hex(body).slice(0, 16)}`)
|
|
744
|
+
const ok = (): boolean => {
|
|
745
|
+
const f = lstatSync(file, { throwIfNoEntry: false })
|
|
746
|
+
return (
|
|
747
|
+
f !== undefined &&
|
|
748
|
+
f.isFile() &&
|
|
749
|
+
f.uid === uid &&
|
|
750
|
+
(f.mode & 0o777) === 0o500 &&
|
|
751
|
+
readFileSync(file, 'utf8') === body
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
if (ok()) return file
|
|
755
|
+
const tmp = `${file}.${process.pid}`
|
|
756
|
+
rmSync(tmp, { force: true })
|
|
757
|
+
writeFileSync(tmp, body, { mode: 0o500, flag: 'wx' })
|
|
758
|
+
renameSync(tmp, file)
|
|
759
|
+
if (!ok()) throw new Error(`${file} is not this user's wrapper`)
|
|
760
|
+
return file
|
|
761
|
+
}
|
|
762
|
+
|
|
614
763
|
/**
|
|
615
764
|
* Whether this Linux host speaks IPv6. SRT's in-sandbox network bridge is
|
|
616
765
|
* `socat TCP-LISTEN:3128`, and socat 1.8 opens that as an IPv6 socket: on
|
|
@@ -1165,6 +1314,12 @@ export interface SandboxViolation {
|
|
|
1165
1314
|
hint?: true
|
|
1166
1315
|
/** Absolute, when `target` is a path at all. */
|
|
1167
1316
|
path?: string
|
|
1317
|
+
/**
|
|
1318
|
+
* Linux: the directory holding a link on the path the task opened, not
|
|
1319
|
+
* in the sandbox, so the link was not there: a grant of `path` alone
|
|
1320
|
+
* leaves it hidden.
|
|
1321
|
+
*/
|
|
1322
|
+
via?: string
|
|
1168
1323
|
ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
|
|
1169
1324
|
}
|
|
1170
1325
|
|
|
@@ -1279,6 +1434,9 @@ async function wrapIn(
|
|
|
1279
1434
|
baselines.cwd,
|
|
1280
1435
|
)
|
|
1281
1436
|
customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
|
|
1437
|
+
const helpers = hostHelpers()
|
|
1438
|
+
customConfig!.filesystem!.denyRead!.push(...helpers.dirs)
|
|
1439
|
+
customConfig!.filesystem!.allowRead!.push(...helpers.files)
|
|
1282
1440
|
if (process.platform === 'linux' && !cwdMounted(baselines.cwd, customConfig!.filesystem!)) {
|
|
1283
1441
|
customConfig!.filesystem!.denyRead!.push(baselines.cwd)
|
|
1284
1442
|
}
|
|
@@ -1323,7 +1481,10 @@ async function wrapIn(
|
|
|
1323
1481
|
process.platform === 'linux'
|
|
1324
1482
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
1325
1483
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
1484
|
+
// The tag leads: SRT keys a record by the wrap's first 100 characters,
|
|
1485
|
+
// and the proxy wait below is the same 190 for every networked task.
|
|
1326
1486
|
const inner = [
|
|
1487
|
+
process.platform === 'linux' ? `: 'vx-${tag}';` : '',
|
|
1327
1488
|
ports.length > 0 ? portBridgeInner(ports, tag) : '',
|
|
1328
1489
|
process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
|
|
1329
1490
|
grouped.command,
|
|
@@ -1503,7 +1664,10 @@ function ownGroupCommand(
|
|
|
1503
1664
|
* What strace stops on: the reads, the execs and access probes (a refused
|
|
1504
1665
|
* `./gen.sh || fallback` passed unreported), and what moves or makes a
|
|
1505
1666
|
* process's cwd. Not the stat family: a stop per stat cost oxlint 19% and
|
|
1506
|
-
* `git status` 30%, where the execs and probes cost nothing measurable
|
|
1667
|
+
* `git status` 30%, where the execs and probes cost nothing measurable, and
|
|
1668
|
+
* vx's own no-op run 69% (pr-bench, 495 → 835 ms, 2026-10-23). So a tool
|
|
1669
|
+
* that stats before it opens, as Ubuntu 26's cat (uutils) does, refused
|
|
1670
|
+
* nothing the trace sees.
|
|
1507
1671
|
*/
|
|
1508
1672
|
const TRACED_CALLS =
|
|
1509
1673
|
'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
@@ -1603,7 +1767,8 @@ function seatbeltSpells(grant: string, kind: 'read' | 'write', name: boolean): b
|
|
|
1603
1767
|
const at = name ? m.index : m.index + m[0].length - 2
|
|
1604
1768
|
if (!warnedUnspellable.has(`${kind} ${grant}`)) {
|
|
1605
1769
|
warnedUnspellable.add(`${kind} ${grant}`)
|
|
1606
|
-
|
|
1770
|
+
ui.say(
|
|
1771
|
+
'warn',
|
|
1607
1772
|
`[vx] sandbox: the ${kind} grant ${grant} names a literal ${grant[at + (name ? 0 : 1)]}, ` +
|
|
1608
1773
|
`which the macOS sandbox reads as a pattern and cannot spell as a name, so it is not ` +
|
|
1609
1774
|
`granted. Rename it, or grant the directory above it: ` +
|
|
@@ -1831,7 +1996,8 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1831
1996
|
*/
|
|
1832
1997
|
export function releaseBridges(tag: string): void {
|
|
1833
1998
|
const tmp = taskTmpdir(tag)
|
|
1834
|
-
|
|
1999
|
+
// One that will not go stays tracked, for the exit hook.
|
|
2000
|
+
if (liveTaskTmpdirs.has(tmp) && removeTaskTmpdir(tmp)) liveTaskTmpdirs.delete(tmp)
|
|
1835
2001
|
if (liveServers.delete(tag)) {
|
|
1836
2002
|
// A server's wrap stays counted until it stops: one stopped server
|
|
1837
2003
|
// kept every later task's stubs in the workspace until the reset.
|
|
@@ -1878,11 +2044,25 @@ const TRACER_RETRY_LINE =
|
|
|
1878
2044
|
* declared, so a second run redoes, not doubles, it.
|
|
1879
2045
|
*/
|
|
1880
2046
|
export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
|
|
1881
|
-
|
|
2047
|
+
// Whether the first attempt's output, both streams, ended mid-line: the
|
|
2048
|
+
// notice starts its own line (it read `…/strace: bare[vx] the sandbox's…`).
|
|
2049
|
+
let midLine = false
|
|
2050
|
+
const seen =
|
|
2051
|
+
(on: ((chunk: string) => void) | undefined) =>
|
|
2052
|
+
(chunk: string): void => {
|
|
2053
|
+
if (chunk.length > 0) midLine = !chunk.endsWith('\n')
|
|
2054
|
+
on?.(chunk)
|
|
2055
|
+
}
|
|
2056
|
+
const { tracerFailed, ranMs, ...first } = await runSandboxedOnce({
|
|
2057
|
+
...args,
|
|
2058
|
+
onStdout: seen(args.onStdout),
|
|
2059
|
+
onStderr: seen(args.onStderr),
|
|
2060
|
+
})
|
|
1882
2061
|
// A stopping run has killed the children it holds; a retry would be one
|
|
1883
2062
|
// spawned after that kill.
|
|
1884
2063
|
if (!tracerFailed || args.signal?.aborted === true) return first
|
|
1885
|
-
|
|
2064
|
+
const notice = (midLine ? '\n' : '') + TRACER_RETRY_LINE
|
|
2065
|
+
args.onStderr?.(notice)
|
|
1886
2066
|
// `timeout` bounds the task's whole run (schema.md), so the retry gets
|
|
1887
2067
|
// what the first attempt left of it, not a fresh window.
|
|
1888
2068
|
const {
|
|
@@ -1898,7 +2078,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
1898
2078
|
...second,
|
|
1899
2079
|
durationMs: first.durationMs + second.durationMs,
|
|
1900
2080
|
stdout: first.stdout + second.stdout,
|
|
1901
|
-
stderr: first.stderr +
|
|
2081
|
+
stderr: first.stderr + notice + second.stderr,
|
|
1902
2082
|
}
|
|
1903
2083
|
}
|
|
1904
2084
|
|
|
@@ -2051,7 +2231,7 @@ async function runSandboxedOnce(
|
|
|
2051
2231
|
rmSync(straceLog, { force: true })
|
|
2052
2232
|
liveTempFiles.delete(straceLog)
|
|
2053
2233
|
}
|
|
2054
|
-
const stderr = spawnFailureText(err, args.cwd,
|
|
2234
|
+
const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
|
|
2055
2235
|
args.onStderr?.(stderr)
|
|
2056
2236
|
releaseBridges(tag)
|
|
2057
2237
|
takeRecords()
|
|
@@ -2074,6 +2254,7 @@ async function runSandboxedOnce(
|
|
|
2074
2254
|
|
|
2075
2255
|
args.liveChildren?.add(proc)
|
|
2076
2256
|
args.onSpawn?.(proc.pid)
|
|
2257
|
+
const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
|
|
2077
2258
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
2078
2259
|
const armedAt = Date.now()
|
|
2079
2260
|
const ac = new AbortController()
|
|
@@ -2081,8 +2262,9 @@ async function runSandboxedOnce(
|
|
|
2081
2262
|
let partial = ''
|
|
2082
2263
|
let straceSpoke = false
|
|
2083
2264
|
const [out, err] = pipes.streams(proc)
|
|
2265
|
+
const ends = lineEnds(args.onStdout, args.onStderr)
|
|
2084
2266
|
const streams = Promise.all([
|
|
2085
|
-
streamToString(out,
|
|
2267
|
+
streamToString(out, ends.onStdout, ac.signal, args.capture?.stdout ?? true),
|
|
2086
2268
|
streamToString(
|
|
2087
2269
|
err,
|
|
2088
2270
|
(chunk) => {
|
|
@@ -2091,7 +2273,7 @@ async function runSandboxedOnce(
|
|
|
2091
2273
|
const lines = (partial + chunk).split('\n')
|
|
2092
2274
|
partial = (lines.pop() ?? '').slice(0, 512)
|
|
2093
2275
|
if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
|
|
2094
|
-
|
|
2276
|
+
ends.onStderr?.(chunk)
|
|
2095
2277
|
},
|
|
2096
2278
|
ac.signal,
|
|
2097
2279
|
args.capture?.stderr ?? true,
|
|
@@ -2102,18 +2284,23 @@ async function runSandboxedOnce(
|
|
|
2102
2284
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
|
2103
2285
|
await proc.exited
|
|
2104
2286
|
const ranMs = Date.now() - armedAt
|
|
2287
|
+
const looked = watch?.stop()
|
|
2105
2288
|
await timeout.settle()
|
|
2106
2289
|
let cut = false
|
|
2107
2290
|
if (timeout.timedOut()) ac.abort()
|
|
2108
2291
|
else cut = await drainOrAbort(streams, ac)
|
|
2109
2292
|
const [stdout, streamed] = await streams
|
|
2110
|
-
|
|
2111
|
-
|
|
2293
|
+
const cutLine = ends.own(POST_EXIT_CUT_LINE)
|
|
2294
|
+
if (cut) args.onStderr?.(cutLine)
|
|
2295
|
+
const stderr = cut ? streamed + cutLine : streamed
|
|
2112
2296
|
args.liveChildren?.delete(proc)
|
|
2113
2297
|
releaseGroup(proc)
|
|
2114
2298
|
closeSignalChannel(proc)
|
|
2115
2299
|
releaseBridges(tag)
|
|
2116
|
-
const
|
|
2300
|
+
const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
2301
|
+
// A timeout is a failure, whatever the child said: one that traps the
|
|
2302
|
+
// TERM and exits 0 reported success to every executor built on this.
|
|
2303
|
+
const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
|
|
2117
2304
|
|
|
2118
2305
|
// macOS: read the violation store keyed by our tagged command.
|
|
2119
2306
|
//
|
|
@@ -2132,8 +2319,8 @@ async function runSandboxedOnce(
|
|
|
2132
2319
|
// DROPPED by the unified log under pressure, not delayed. Reporting on
|
|
2133
2320
|
// macOS is therefore lossy-by-OS under load; ENFORCEMENT is unaffected
|
|
2134
2321
|
// (every observed loss still denied the read and failed the child).
|
|
2135
|
-
// EVERY record SRT's store saw for this command, unfiltered
|
|
2136
|
-
// 2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
2322
|
+
// EVERY record SRT's store saw for this command, unfiltered
|
|
2323
|
+
// (2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
2137
2324
|
// deciding on the user's behalf that a record was "just traversal" is how
|
|
2138
2325
|
// a failure ends up with an empty violations section and no explanation.
|
|
2139
2326
|
const records = takeRecords()
|
|
@@ -2148,7 +2335,7 @@ async function runSandboxedOnce(
|
|
|
2148
2335
|
// below (`refusedWrites`).
|
|
2149
2336
|
const macViolations = process.platform === 'darwin' ? records : []
|
|
2150
2337
|
// No settle window: the store is read once, right after the child exits
|
|
2151
|
-
// (
|
|
2338
|
+
// (2026-09-05). It cost 300ms on EVERY clean sandboxed task — the
|
|
2152
2339
|
// full budget, since a task with nothing to report can only prove that by
|
|
2153
2340
|
// waiting — against 26ms for one that reports something and breaks out
|
|
2154
2341
|
// early. Measured: clean `echo` 331ms sandboxed vs 16ms plain.
|
|
@@ -2268,7 +2455,7 @@ async function runSandboxedOnce(
|
|
|
2268
2455
|
stdout,
|
|
2269
2456
|
stderr,
|
|
2270
2457
|
violations,
|
|
2271
|
-
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
2458
|
+
...(typeof proc.signalCode === 'string' ? { signal: proc.signalCode } : {}),
|
|
2272
2459
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
2273
2460
|
...(cut ? { leftRunning: true } : {}),
|
|
2274
2461
|
// Linux: the task's CPU and peak never reach this wait. bwrap runs it
|
|
@@ -2276,10 +2463,14 @@ async function runSandboxedOnce(
|
|
|
2276
2463
|
// processes used is not folded into bwrap's usage: a 500 ms busy loop
|
|
2277
2464
|
// read 2 ms under it and 510 without the flag, and the peak read was
|
|
2278
2465
|
// vx's own high-water mark, inherited at exec (a sandboxed `true` from a
|
|
2279
|
-
// 300 MB vx: 353 MB; B-3).
|
|
2280
|
-
//
|
|
2281
|
-
// its usage is the task's.
|
|
2282
|
-
...(process.platform
|
|
2466
|
+
// 300 MB vx: 353 MB; B-3). So the tree is looked at while it runs
|
|
2467
|
+
// (`watchTree`); a task over before the first look reports nothing.
|
|
2468
|
+
// macOS's `sandbox-exec` execs the command, so its usage is the task's.
|
|
2469
|
+
...(process.platform !== 'linux'
|
|
2470
|
+
? resourceUsageToCpuRss(proc.resourceUsage())
|
|
2471
|
+
: looked === undefined
|
|
2472
|
+
? {}
|
|
2473
|
+
: { cpuMs: looked.cpuMs, peakRssBytes: looked.rssBytes }),
|
|
2283
2474
|
ranMs,
|
|
2284
2475
|
tracerFailed:
|
|
2285
2476
|
straceLog !== undefined &&
|
|
@@ -2847,7 +3038,8 @@ function writeGrantMatchedNothing(grant: string, within: string): void {
|
|
|
2847
3038
|
// As a committed config spells them (B-97): the absolute path held only
|
|
2848
3039
|
// on the machine that printed it, as in `outsideWritesHint`.
|
|
2849
3040
|
const dir = grantSpelled(grantPrefix(grant), within)
|
|
2850
|
-
|
|
3041
|
+
ui.say(
|
|
3042
|
+
'warn',
|
|
2851
3043
|
`[vx] sandbox: the write grant ${jsString(grantSpelled(grant, within))} matches nothing ` +
|
|
2852
3044
|
`yet, and a read grant mounts its directory read-only, so a file the task creates under ` +
|
|
2853
3045
|
`it will fail with "Read-only file system". A bind mount covers what exists when the ` +
|
|
@@ -2883,6 +3075,7 @@ function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why: string
|
|
|
2883
3075
|
async function probeStrace(): Promise<{ form: false | 'plain' | 'seccomp'; why: string }> {
|
|
2884
3076
|
try {
|
|
2885
3077
|
const p = Bun.spawn([executablePath('strace'), '--version'], {
|
|
3078
|
+
env: helperEnv(),
|
|
2886
3079
|
stdout: 'pipe',
|
|
2887
3080
|
stderr: 'ignore',
|
|
2888
3081
|
})
|
|
@@ -2956,7 +3149,7 @@ async function traceRefusal(
|
|
|
2956
3149
|
'--',
|
|
2957
3150
|
executablePath('true'),
|
|
2958
3151
|
],
|
|
2959
|
-
{ stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
|
|
3152
|
+
{ stdin: 'ignore', stdout: 'ignore', stderr: 'pipe', env: helperEnv() },
|
|
2960
3153
|
)
|
|
2961
3154
|
const err = await new Response(p.stderr).text()
|
|
2962
3155
|
const line = err.split('\n').find((l) => STRACE_OWN_ERROR.test(l))
|
|
@@ -2969,7 +3162,8 @@ let warnedNoTrace = false
|
|
|
2969
3162
|
function warnUntraced(why: string): void {
|
|
2970
3163
|
if (warnedNoTrace) return
|
|
2971
3164
|
warnedNoTrace = true
|
|
2972
|
-
|
|
3165
|
+
ui.say(
|
|
3166
|
+
'warn',
|
|
2973
3167
|
`[vx] sandbox: ${why}, so sandboxed tasks run without the report of the reads the sandbox denied; ` +
|
|
2974
3168
|
`the sandbox still enforces\n`,
|
|
2975
3169
|
)
|
|
@@ -8,6 +8,7 @@ import { existsSync, lstatSync, statSync } from 'node:fs'
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
|
|
10
10
|
import { bindableReads, bindableWrites } from './sandbox-binds.js'
|
|
11
|
+
import { printable } from '../util/index.js'
|
|
11
12
|
import type {
|
|
12
13
|
ResolvedSandboxConfig,
|
|
13
14
|
SandboxedRunArgs,
|
|
@@ -50,17 +51,30 @@ const DIRFD = '(?:AT_FDCWD|\\d+)(?:<(.*?)>)?, '
|
|
|
50
51
|
// The rest of the call: an `execve`'s argv strings may hold a `)`
|
|
51
52
|
// (`sh -c "f()"`), so it reads to the result at the line's end.
|
|
52
53
|
const ARGS = '.*'
|
|
54
|
+
// The result, at the line's end: an argv string may hold `) = -1 ENOENT`
|
|
55
|
+
// itself, and a call that succeeded read as denied.
|
|
56
|
+
const FAILED = '\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)(?: \\([^)]*\\))?\\s*$'
|
|
53
57
|
const STRACE_DONE_RE = new RegExp(
|
|
54
|
-
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}\\)
|
|
58
|
+
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}\\)${FAILED}`,
|
|
55
59
|
)
|
|
56
60
|
const STRACE_UNFINISHED_RE = new RegExp(
|
|
57
61
|
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}<unfinished`,
|
|
58
62
|
)
|
|
59
|
-
const STRACE_RESUMED_RE = new RegExp(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const STRACE_RESUMED_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?${FAILED}`)
|
|
64
|
+
/**
|
|
65
|
+
* The calls that ask whether a path is there rather than read it. A
|
|
66
|
+
* `chdir` creates nothing: one into a directory the host lacks (`mkdir -p`
|
|
67
|
+
* after its refused mkdir) was judged a write, and the fix named a file.
|
|
68
|
+
*/
|
|
69
|
+
const PROBES = new Set([
|
|
70
|
+
'execve',
|
|
71
|
+
'faccessat2',
|
|
72
|
+
'faccessat',
|
|
73
|
+
'access',
|
|
74
|
+
'statx',
|
|
75
|
+
'newfstatat',
|
|
76
|
+
'chdir',
|
|
77
|
+
])
|
|
64
78
|
/** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
|
|
65
79
|
const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
|
|
66
80
|
/**
|
|
@@ -331,6 +345,9 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
|
|
|
331
345
|
return out
|
|
332
346
|
}
|
|
333
347
|
|
|
348
|
+
/** Distinct calls `parseStraceViolations` judges between yields to the event loop. */
|
|
349
|
+
const JUDGED_PER_TURN = 500
|
|
350
|
+
|
|
334
351
|
export async function parseStraceViolations(
|
|
335
352
|
logPath: string,
|
|
336
353
|
args: SandboxedRunArgs,
|
|
@@ -386,19 +403,29 @@ export async function parseStraceViolations(
|
|
|
386
403
|
}
|
|
387
404
|
|
|
388
405
|
const seen = new Set<string>()
|
|
406
|
+
const judged = new Set<string>()
|
|
389
407
|
const out: SandboxViolation[] = []
|
|
390
408
|
for (const { syscall, rawPath, errno, dir, read } of deniedCalls(
|
|
391
409
|
text,
|
|
392
410
|
baselines.cwd,
|
|
393
411
|
widened.size > 0,
|
|
394
412
|
)) {
|
|
413
|
+
// A suite's log repeats a call by the thousand, each judged by sync
|
|
414
|
+
// stats: a test shard's 44,788 calls held the event loop 2.9 s, and a
|
|
415
|
+
// DTE worker that quiet for two heartbeats was lost (main, 2026-10-25).
|
|
416
|
+
// Each call is judged once, and the loop yields between batches.
|
|
417
|
+
const call = `${syscall}\0${rawPath}\0${dir}\0${errno}\0${read}`
|
|
418
|
+
if (judged.has(call)) continue
|
|
419
|
+
judged.add(call)
|
|
420
|
+
if (judged.size % JUDGED_PER_TURN === 0) await new Promise((r) => setImmediate(r))
|
|
395
421
|
const lexical = absolutize(rawPath, dir ?? baselines.cwd)
|
|
396
422
|
const abs = toRealPath(lexical)
|
|
397
423
|
// Only report paths under the workspace-root deny anchor — system
|
|
398
424
|
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
399
425
|
if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
|
|
400
426
|
// Skip paths the user explicitly allowed (and their descendants).
|
|
401
|
-
|
|
427
|
+
const via = lexical === abs ? undefined : hiddenLink(lexical, abs, mounted, denyAnchors)
|
|
428
|
+
if (isUnderAny(abs, allowAbs) && via === undefined) continue
|
|
402
429
|
if (underGlob(abs, pendingWrites)) continue
|
|
403
430
|
if (read === true && !undeclared(abs)) continue
|
|
404
431
|
// A hidden path answers ENOENT, as a missing one does: a probe
|
|
@@ -409,17 +436,20 @@ export async function parseStraceViolations(
|
|
|
409
436
|
const key = `${syscall}|${abs}`
|
|
410
437
|
if (seen.has(key)) continue
|
|
411
438
|
seen.add(key)
|
|
439
|
+
// The line is printed verbatim, and the trace's `\033` is decoded: a
|
|
440
|
+
// denied file named `ESC ]0;…` retitled the terminal.
|
|
441
|
+
const [said, real] = [printable(rawPath), printable(abs)]
|
|
412
442
|
out.push(
|
|
413
443
|
read === true
|
|
414
444
|
? {
|
|
415
|
-
line: `${syscall}(${
|
|
445
|
+
line: `${syscall}(${said}) read under a write grant's directory, granted no read [${real}]`,
|
|
416
446
|
timestamp: new Date(),
|
|
417
447
|
target: abs,
|
|
418
448
|
path: abs,
|
|
419
449
|
ignorable: ['read'],
|
|
420
450
|
}
|
|
421
451
|
: {
|
|
422
|
-
line: `${syscall}(${
|
|
452
|
+
line: `${syscall}(${said}) = -1 ${errno} [${real}]`,
|
|
423
453
|
timestamp: new Date(),
|
|
424
454
|
target: abs,
|
|
425
455
|
path: abs,
|
|
@@ -427,6 +457,7 @@ export async function parseStraceViolations(
|
|
|
427
457
|
// does not keep, and `access` may ask either — so either list
|
|
428
458
|
// can silence it.
|
|
429
459
|
ignorable: ['read', 'write'],
|
|
460
|
+
...(via === undefined ? {} : { via }),
|
|
430
461
|
},
|
|
431
462
|
)
|
|
432
463
|
}
|
|
@@ -434,8 +465,10 @@ export async function parseStraceViolations(
|
|
|
434
465
|
}
|
|
435
466
|
|
|
436
467
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
468
|
+
* The directory holding a link on the path the task opened, when that
|
|
469
|
+
* directory is not in the sandbox, so the link is not there either: what
|
|
470
|
+
* a fix must grant beside the target. A granted file the task could not
|
|
471
|
+
* open this way was dropped as granted. A grant is mounted at its real path and
|
|
439
472
|
* bwrap mounts no link, so `read: ['config.json']` over `config.json ->
|
|
440
473
|
* conf/real.json` bound the target and left the name the task opens out;
|
|
441
474
|
* judged by its real path, the ENOENT was granted and dropped, and a tool
|
|
@@ -444,26 +477,25 @@ export async function parseStraceViolations(
|
|
|
444
477
|
* is missing outside the sandbox too, and outside the deny anchor the
|
|
445
478
|
* host is mounted, links and all.
|
|
446
479
|
*/
|
|
447
|
-
function
|
|
480
|
+
function hiddenLink(
|
|
448
481
|
lexical: string,
|
|
449
482
|
real: string,
|
|
450
483
|
mounted: Set<string>,
|
|
451
484
|
anchors: readonly string[],
|
|
452
|
-
):
|
|
453
|
-
if (
|
|
485
|
+
): string | undefined {
|
|
486
|
+
if (!existsSync(real)) return undefined
|
|
454
487
|
let at = lexical
|
|
455
|
-
const links: string[] = []
|
|
456
488
|
for (let up = path.dirname(at); up !== at; at = up, up = path.dirname(at)) {
|
|
457
489
|
try {
|
|
458
|
-
if (lstatSync(at).isSymbolicLink())
|
|
490
|
+
if (!lstatSync(at).isSymbolicLink()) continue
|
|
459
491
|
} catch {
|
|
460
492
|
// Not on the host by this spelling: no link to judge here.
|
|
493
|
+
continue
|
|
461
494
|
}
|
|
495
|
+
const holder = toRealPath(up)
|
|
496
|
+
if (anchors.some((a) => atOrUnder(holder, a)) && !isUnderAny(holder, mounted)) return holder
|
|
462
497
|
}
|
|
463
|
-
return
|
|
464
|
-
const holder = toRealPath(path.dirname(l))
|
|
465
|
-
return anchors.some((a) => atOrUnder(holder, a)) && !isUnderAny(holder, mounted)
|
|
466
|
-
})
|
|
498
|
+
return undefined
|
|
467
499
|
}
|
|
468
500
|
|
|
469
501
|
/**
|