@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
writeFileSync,
|
|
45
45
|
} from 'node:fs'
|
|
46
46
|
import { mkdir, unlink } from 'node:fs/promises'
|
|
47
|
+
import { createConnection } from 'node:net'
|
|
47
48
|
import type { SandboxConfig } from '../config.js'
|
|
48
49
|
import {
|
|
49
50
|
armTimeout,
|
|
@@ -62,12 +63,14 @@ import {
|
|
|
62
63
|
type RunResult,
|
|
63
64
|
} from './runner.js'
|
|
64
65
|
import { TaskPipes } from './pipes.js'
|
|
66
|
+
import { startTaskProxy, type TaskProxy } from './task-proxy.js'
|
|
65
67
|
import { watchTree } from './proc-sample.js'
|
|
66
68
|
import {
|
|
67
69
|
BUN_GLOB_WILDCARDS,
|
|
68
70
|
ESSENTIAL_ENV,
|
|
69
71
|
executablePath,
|
|
70
72
|
helperEnv,
|
|
73
|
+
holdSecret,
|
|
71
74
|
taskShell,
|
|
72
75
|
grantPrefix,
|
|
73
76
|
isTmpdirRefusal,
|
|
@@ -81,9 +84,11 @@ import {
|
|
|
81
84
|
bindableReads,
|
|
82
85
|
bindableWrites,
|
|
83
86
|
buildCustomConfig,
|
|
87
|
+
linkedWriteBinds,
|
|
84
88
|
readOnlyMasks,
|
|
85
89
|
scratchWrites,
|
|
86
90
|
widenedEntries,
|
|
91
|
+
withLinkedWrites,
|
|
87
92
|
} from './sandbox-binds.js'
|
|
88
93
|
import {
|
|
89
94
|
atOrUnder,
|
|
@@ -109,6 +114,7 @@ import {
|
|
|
109
114
|
} from './sandbox-violations.js'
|
|
110
115
|
import {
|
|
111
116
|
closeSignalChannel,
|
|
117
|
+
groupAlive,
|
|
112
118
|
killTree,
|
|
113
119
|
releaseGroup,
|
|
114
120
|
signalThrough,
|
|
@@ -664,6 +670,124 @@ function hostHelpers(): { dirs: string[]; files: string[] } {
|
|
|
664
670
|
return { dirs: unique(dirs), files: files.map(toRealPath) }
|
|
665
671
|
}
|
|
666
672
|
|
|
673
|
+
/**
|
|
674
|
+
* What a hermetic task reads outside the workspace without naming it: the
|
|
675
|
+
* programs vx and the runtime run around its command (the runtime's bash,
|
|
676
|
+
* the task shell, setsid, strace, env, socat, the seccomp helper, and the
|
|
677
|
+
* grep and sleep its network wait runs) and what the
|
|
678
|
+
* loader maps for them. Anything else outside the workspace is the task's
|
|
679
|
+
* to grant. Learned once per PATH: the files a host's shell links are
|
|
680
|
+
* fixed for the process.
|
|
681
|
+
*/
|
|
682
|
+
interface HermeticReads {
|
|
683
|
+
/** What is denied: `/`, spelled as the runtime can mount it. */
|
|
684
|
+
deny: string[]
|
|
685
|
+
allow: string[]
|
|
686
|
+
}
|
|
687
|
+
let hermeticMemo: { path: string; reads: Promise<HermeticReads> } | undefined
|
|
688
|
+
function hermeticReads(): Promise<HermeticReads> {
|
|
689
|
+
const PATH = process.env['PATH'] ?? ''
|
|
690
|
+
if (hermeticMemo?.path !== PATH) hermeticMemo = { path: PATH, reads: learnHermeticReads() }
|
|
691
|
+
return hermeticMemo.reads
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function learnHermeticReads(): Promise<HermeticReads> {
|
|
695
|
+
return { deny: rootDenies(), allow: await hermeticAllows() }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* `/` as the runtime can deny it. Given `/`, it lays a tmpfs over every
|
|
700
|
+
* entry of the root, and bwrap cannot mount one on a link: a merged-/usr
|
|
701
|
+
* host's `/sbin -> usr/sbin` failed every hermetic task. A link at the
|
|
702
|
+
* root leads into a directory denied here, so it is left as it is. `/proc`,
|
|
703
|
+
* `/dev` and `/sys` are the runtime's own mounts; macOS denies by path, so
|
|
704
|
+
* `/` stands.
|
|
705
|
+
*/
|
|
706
|
+
function rootDenies(): string[] {
|
|
707
|
+
if (process.platform !== 'linux') return ['/']
|
|
708
|
+
const skip = new Set(['proc', 'dev', 'sys'])
|
|
709
|
+
return readdirSync('/', { withFileTypes: true })
|
|
710
|
+
.filter((e) => !skip.has(e.name) && !e.isSymbolicLink())
|
|
711
|
+
.map((e) => `/${e.name}`)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function hermeticAllows(): Promise<string[]> {
|
|
715
|
+
const programs: string[] = []
|
|
716
|
+
for (const name of ['bash', 'setsid', 'strace', 'env', 'socat', 'grep', 'sleep']) {
|
|
717
|
+
try {
|
|
718
|
+
programs.push(executablePath(name))
|
|
719
|
+
} catch {
|
|
720
|
+
// The runtime's dependency check names a missing one.
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
programs.push(taskShell())
|
|
724
|
+
if (process.platform === 'darwin') {
|
|
725
|
+
// dyld and the shared cache every macOS binary maps.
|
|
726
|
+
return unique([...programs.flatMap(bothSpellings), '/usr/lib', '/System'])
|
|
727
|
+
}
|
|
728
|
+
const { getApplySeccompBinaryPath } =
|
|
729
|
+
await import('@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.js')
|
|
730
|
+
const seccomp = getApplySeccompBinaryPath(undefined)
|
|
731
|
+
if (seccomp) programs.push(seccomp)
|
|
732
|
+
const files = [...programs]
|
|
733
|
+
// The loader and the libraries each program links, by `ldd`'s answer.
|
|
734
|
+
let ldd: string
|
|
735
|
+
try {
|
|
736
|
+
ldd = executablePath('ldd')
|
|
737
|
+
} catch {
|
|
738
|
+
throw new UserError(
|
|
739
|
+
'exec.sandbox.hermetic: no `ldd` on PATH, so vx cannot tell which libraries the ' +
|
|
740
|
+
"task's shell needs — install it (glibc's libc-bin), or drop hermetic",
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
const r = Bun.spawnSync({ cmd: [ldd, ...programs], stdout: 'pipe', stderr: 'ignore' })
|
|
744
|
+
for (const m of r.stdout.toString().matchAll(/(\/\S+)\s+\(0x/g)) files.push(m[1]!)
|
|
745
|
+
return unique(files.flatMap(bothSpellings))
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Every spelling the loader walks from `p` to its file: a link (the
|
|
750
|
+
* loader's `/lib64/ld-linux-x86-64.so.2` is one, then `/usr/lib64`'s is
|
|
751
|
+
* another) is bound as the file it reaches, so each hop needs its own
|
|
752
|
+
* grant, named under its directory's real path, where the mount lands.
|
|
753
|
+
*/
|
|
754
|
+
function bothSpellings(p: string): string[] {
|
|
755
|
+
const hops: string[] = []
|
|
756
|
+
for (let at = p; hops.length < 40;) {
|
|
757
|
+
const here = path.join(toRealPath(path.dirname(at)), path.basename(at))
|
|
758
|
+
hops.push(here)
|
|
759
|
+
let next: string
|
|
760
|
+
try {
|
|
761
|
+
if (!lstatSync(here).isSymbolicLink()) break
|
|
762
|
+
next = path.resolve(path.dirname(here), readlinkSync(here))
|
|
763
|
+
} catch {
|
|
764
|
+
break
|
|
765
|
+
}
|
|
766
|
+
at = next
|
|
767
|
+
}
|
|
768
|
+
return hops
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* The runtime's own sockets a hermetic task's command reaches: the write
|
|
773
|
+
* observer's and the network proxy's, under the host's temp directory.
|
|
774
|
+
* The runtime binds each before the read denies, so the deny's tmpfs over
|
|
775
|
+
* that directory hid them (`connect …/srt-obs-…: No such file`), and no
|
|
776
|
+
* call names the observer's. They are read off the wrap, once per run
|
|
777
|
+
* each: the observer listens after the runtime starts, so a later wrap may
|
|
778
|
+
* name one the first did not.
|
|
779
|
+
*/
|
|
780
|
+
const runtimeOwn = new Set<string>()
|
|
781
|
+
function runtimeBinds(wrapped: string): string[] {
|
|
782
|
+
const tmp = toRealPath(os.tmpdir()) + path.sep
|
|
783
|
+
const out: string[] = []
|
|
784
|
+
for (const m of wrapped.matchAll(/--bind (?:'([^']*)'|(\S+)) (?:'([^']*)'|(\S+))/g)) {
|
|
785
|
+
const from = m[1] ?? m[2]!
|
|
786
|
+
if (from === (m[3] ?? m[4]) && from.startsWith(tmp)) out.push(from)
|
|
787
|
+
}
|
|
788
|
+
return out
|
|
789
|
+
}
|
|
790
|
+
|
|
667
791
|
function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
|
|
668
792
|
if (process.platform !== 'linux') return {}
|
|
669
793
|
const paths: { bwrapPath?: string; socatPath?: string } = {}
|
|
@@ -815,16 +939,14 @@ function assertDomains(
|
|
|
815
939
|
* @param opts.allowedDomains every domain any sandboxed task in this run
|
|
816
940
|
* declared. SRT's filtering proxy is per-RUN and reads its allowlist from
|
|
817
941
|
* this call, never from the per-task config, so the union is the only
|
|
818
|
-
* place a domain list can take effect
|
|
819
|
-
*
|
|
942
|
+
* place a domain list can take effect; each task's own front
|
|
943
|
+
* (`ownProxy`) narrows it to the task's list.
|
|
820
944
|
*/
|
|
821
945
|
export async function initSandbox(opts?: {
|
|
822
946
|
allowedDomains?: readonly string[]
|
|
823
947
|
/**
|
|
824
|
-
* Every domain any sandboxed task of the run denies
|
|
825
|
-
*
|
|
826
|
-
* union's; before B-21 the list was always empty and a `deny.network`
|
|
827
|
-
* refused nothing.
|
|
948
|
+
* Every domain any sandboxed task of the run denies, checked here and
|
|
949
|
+
* never handed to the run's proxy: each task's front refuses its own.
|
|
828
950
|
*/
|
|
829
951
|
deniedDomains?: readonly string[]
|
|
830
952
|
/**
|
|
@@ -853,7 +975,9 @@ export async function initSandbox(opts?: {
|
|
|
853
975
|
const config: Parameters<typeof SandboxManager.initialize>[0] = {
|
|
854
976
|
network: {
|
|
855
977
|
allowedDomains: [...(opts?.allowedDomains ?? [])],
|
|
856
|
-
|
|
978
|
+
// Each task's own front refuses its own denies; the run's proxy
|
|
979
|
+
// refusing the union denied one task's host to every task.
|
|
980
|
+
deniedDomains: [],
|
|
857
981
|
...(opts?.allowAllUnixSockets === true ? { allowAllUnixSockets: true } : {}),
|
|
858
982
|
},
|
|
859
983
|
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
|
|
@@ -862,6 +986,8 @@ export async function initSandbox(opts?: {
|
|
|
862
986
|
...(scopedDenyScan ? scopedScanConfig() : {}),
|
|
863
987
|
...bundledJavaAgent(),
|
|
864
988
|
}
|
|
989
|
+
runAllowed = config.network.allowedDomains
|
|
990
|
+
runAllowsHosts = runAllowed.length > 0
|
|
865
991
|
const wasUp = srtUp
|
|
866
992
|
if (!wasUp) await unlinkStaleMuxSockets()
|
|
867
993
|
const listening = srtCleanupAdopted ? undefined : cleanupListeners()
|
|
@@ -886,6 +1012,11 @@ export async function initSandbox(opts?: {
|
|
|
886
1012
|
// reload of exactly these fields; the proxy and the wrapper read them
|
|
887
1013
|
// through getters, so the run's config is what every task sees.
|
|
888
1014
|
SandboxManager.updateConfig(config)
|
|
1015
|
+
// The proxy's session token rides into a networked task's env
|
|
1016
|
+
// (CLOUDSDK_PROXY_PASSWORD, the proxy URLs' password): a task printing
|
|
1017
|
+
// its env put it in the terminal, the cached log and `vx last --log`.
|
|
1018
|
+
const token = SandboxManager.getProxyAuthToken()
|
|
1019
|
+
if (token !== undefined) holdSecret(token)
|
|
889
1020
|
}
|
|
890
1021
|
|
|
891
1022
|
/**
|
|
@@ -920,6 +1051,7 @@ export async function resetSandbox(): Promise<void> {
|
|
|
920
1051
|
|
|
921
1052
|
function srtDown(): void {
|
|
922
1053
|
srtUp = false
|
|
1054
|
+
runtimeOwn.clear()
|
|
923
1055
|
perTaskRun = undefined
|
|
924
1056
|
availabilityCache.clear()
|
|
925
1057
|
straceAvailableCache = undefined
|
|
@@ -1098,6 +1230,8 @@ export interface ResolvedSandboxConfig {
|
|
|
1098
1230
|
* no mount holds them. `scratchWrites` decides which still cover a write.
|
|
1099
1231
|
*/
|
|
1100
1232
|
pendingWrites?: readonly string[]
|
|
1233
|
+
/** Linux: write grants spelled through a link in the project, bound there too (`linkedWriteBinds`). */
|
|
1234
|
+
linkedWrites?: readonly { readonly link: string; readonly target: string }[]
|
|
1101
1235
|
network?: true | readonly string[]
|
|
1102
1236
|
denyNetwork?: readonly string[]
|
|
1103
1237
|
systemInfo?: readonly string[]
|
|
@@ -1108,6 +1242,8 @@ export interface ResolvedSandboxConfig {
|
|
|
1108
1242
|
gitConfig?: boolean
|
|
1109
1243
|
weakerWhenNested?: boolean
|
|
1110
1244
|
weakerNetworkIsolation?: boolean
|
|
1245
|
+
/** Reads outside the workspace are refused too (`hermeticReads`). */
|
|
1246
|
+
hermetic?: boolean
|
|
1111
1247
|
/** macOS: the walls a glob grant reaches, by kind (`darwinWallRules`). */
|
|
1112
1248
|
wallsReached?: { read: readonly string[]; write: readonly string[] }
|
|
1113
1249
|
ignore?: {
|
|
@@ -1193,6 +1329,31 @@ function assertWriteStaysHome(grant: string, real: string, projectDir: string):
|
|
|
1193
1329
|
)
|
|
1194
1330
|
}
|
|
1195
1331
|
|
|
1332
|
+
/**
|
|
1333
|
+
* Linux: a literal write grant spelled through a link in the project
|
|
1334
|
+
* (`gen/` over `gen -> real`), as the task names it: the project's real
|
|
1335
|
+
* directory with the grant's own components, links unresolved. The grant
|
|
1336
|
+
* binds at its real path and bwrap mounts no link, so without a second
|
|
1337
|
+
* bind there the task met "Directory nonexistent", or, under a read
|
|
1338
|
+
* grant of the project, the link bound read-only over the same directory
|
|
1339
|
+
* ("Read-only file system"). Seatbelt judges the resolved path, so macOS
|
|
1340
|
+
* needs none.
|
|
1341
|
+
*/
|
|
1342
|
+
function linkSpelling(
|
|
1343
|
+
grant: string,
|
|
1344
|
+
real: string,
|
|
1345
|
+
projectDir: string,
|
|
1346
|
+
): { link: string; target: string } | undefined {
|
|
1347
|
+
if (process.platform !== 'linux' || grant.startsWith('~') || path.isAbsolute(grant)) return
|
|
1348
|
+
// `gen/**` is the directory whole (`expandGrants`).
|
|
1349
|
+
const whole = /\/\*\*(?:\/\*)?$/
|
|
1350
|
+
const lexical = path.resolve(projectDir, grant.replace(whole, ''))
|
|
1351
|
+
const target = real.replace(whole, '')
|
|
1352
|
+
if (!atOrUnder(lexical, projectDir) || !isMountableLiteral(target)) return
|
|
1353
|
+
const link = path.join(toRealPath(projectDir), path.relative(projectDir, lexical))
|
|
1354
|
+
return link === target ? undefined : { link, target }
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1196
1357
|
/** Where `p` lands through every link on it, a dangling last one included. */
|
|
1197
1358
|
function throughLinks(p: string, hops = 0): string {
|
|
1198
1359
|
if (hops > 40) return p
|
|
@@ -1227,12 +1388,15 @@ export function resolveSandboxConfig(
|
|
|
1227
1388
|
}
|
|
1228
1389
|
const a = cfg.allow ?? {}
|
|
1229
1390
|
const pending: string[] = []
|
|
1391
|
+
const linked: Array<{ link: string; target: string }> = []
|
|
1230
1392
|
const r: ResolvedSandboxConfig = {
|
|
1231
1393
|
allowRead: expandGrants((a.read ?? []).map(resolve), walls),
|
|
1232
1394
|
allowWrite: expandGrants(
|
|
1233
1395
|
(a.write ?? []).map((p) => {
|
|
1234
1396
|
const real = resolve(p)
|
|
1235
1397
|
assertWriteStaysHome(p, real, projectDir)
|
|
1398
|
+
const link = linkSpelling(p, real, projectDir)
|
|
1399
|
+
if (link !== undefined) linked.push(link)
|
|
1236
1400
|
// A directory a write glob matches is writable whole, as Linux's
|
|
1237
1401
|
// bind of the hit makes it: `<glob>/**` collapses below into the
|
|
1238
1402
|
// glob and its subtree. SRT compiles a glob as an exact regex, so
|
|
@@ -1249,6 +1413,7 @@ export function resolveSandboxConfig(
|
|
|
1249
1413
|
),
|
|
1250
1414
|
}
|
|
1251
1415
|
if (pending.length > 0) r.pendingWrites = pending
|
|
1416
|
+
if (linked.length > 0) r.linkedWrites = linked
|
|
1252
1417
|
if (process.platform === 'darwin') {
|
|
1253
1418
|
const read = wallsGlobsReach(r.allowRead, walls)
|
|
1254
1419
|
const write = wallsGlobsReach(r.allowWrite, walls)
|
|
@@ -1266,6 +1431,7 @@ export function resolveSandboxConfig(
|
|
|
1266
1431
|
if (cfg.weakerNetworkIsolation !== undefined) {
|
|
1267
1432
|
r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
|
|
1268
1433
|
}
|
|
1434
|
+
if (cfg.hermetic === true) r.hermetic = true
|
|
1269
1435
|
if (cfg.ignore !== undefined) {
|
|
1270
1436
|
// Relative patterns anchor at the project dir, `~` ones at the home
|
|
1271
1437
|
// directory, as a grant's are (a `~` pattern kept as written matched no
|
|
@@ -1320,6 +1486,11 @@ export interface SandboxViolation {
|
|
|
1320
1486
|
* leaves it hidden.
|
|
1321
1487
|
*/
|
|
1322
1488
|
via?: string
|
|
1489
|
+
/**
|
|
1490
|
+
* Linux: what a refused `openat`'s flags asked for; for an `openat2`,
|
|
1491
|
+
* whose flags no record names, a read.
|
|
1492
|
+
*/
|
|
1493
|
+
opened?: 'read' | 'write'
|
|
1323
1494
|
ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
|
|
1324
1495
|
}
|
|
1325
1496
|
|
|
@@ -1434,6 +1605,11 @@ async function wrapIn(
|
|
|
1434
1605
|
baselines.cwd,
|
|
1435
1606
|
)
|
|
1436
1607
|
customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
|
|
1608
|
+
if (args.config.hermetic === true) {
|
|
1609
|
+
const reads = await hermeticReads()
|
|
1610
|
+
customConfig!.filesystem!.denyRead!.push(...reads.deny)
|
|
1611
|
+
customConfig!.filesystem!.allowRead!.push(...reads.allow, ...runtimeOwn)
|
|
1612
|
+
}
|
|
1437
1613
|
const helpers = hostHelpers()
|
|
1438
1614
|
customConfig!.filesystem!.denyRead!.push(...helpers.dirs)
|
|
1439
1615
|
customConfig!.filesystem!.allowRead!.push(...helpers.files)
|
|
@@ -1482,11 +1658,13 @@ async function wrapIn(
|
|
|
1482
1658
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
1483
1659
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
1484
1660
|
// 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
|
|
1661
|
+
// and the proxy wait below is the same 190 for every task.
|
|
1486
1662
|
const inner = [
|
|
1487
1663
|
process.platform === 'linux' ? `: 'vx-${tag}';` : '',
|
|
1488
1664
|
ports.length > 0 ? portBridgeInner(ports, tag) : '',
|
|
1489
|
-
process.platform === 'linux' &&
|
|
1665
|
+
process.platform === 'linux' && SandboxManager.getLinuxHttpSocketPath() !== undefined
|
|
1666
|
+
? PROXY_BRIDGE_WAIT
|
|
1667
|
+
: '',
|
|
1490
1668
|
grouped.command,
|
|
1491
1669
|
]
|
|
1492
1670
|
.filter((part) => part !== '')
|
|
@@ -1502,6 +1680,29 @@ async function wrapIn(
|
|
|
1502
1680
|
ports.length > 0 || asksUnixSockets(args.config),
|
|
1503
1681
|
args.config.gitConfig === true,
|
|
1504
1682
|
)
|
|
1683
|
+
if (process.platform === 'linux' && args.config.hermetic === true) {
|
|
1684
|
+
const own = runtimeBinds(wrapped).filter((p) => !runtimeOwn.has(p))
|
|
1685
|
+
if (own.length > 0) {
|
|
1686
|
+
for (const p of own) runtimeOwn.add(p)
|
|
1687
|
+
customConfig!.filesystem!.allowRead!.push(...own)
|
|
1688
|
+
wrapped = await wrapForTask(
|
|
1689
|
+
SandboxManager,
|
|
1690
|
+
inner,
|
|
1691
|
+
literalReadPaths(customConfig),
|
|
1692
|
+
ports.length > 0 || asksUnixSockets(args.config),
|
|
1693
|
+
args.config.gitConfig === true,
|
|
1694
|
+
)
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if (runAllowsHosts) {
|
|
1698
|
+
try {
|
|
1699
|
+
wrapped = await ownProxy(SandboxManager, wrapped, inner, tag, args.config)
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
releaseBridges(tag)
|
|
1702
|
+
afterCommand(SandboxManager)
|
|
1703
|
+
throw err
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1505
1706
|
if (process.platform === 'darwin') {
|
|
1506
1707
|
const rules = [
|
|
1507
1708
|
...macProfileRules(args.config),
|
|
@@ -1534,7 +1735,7 @@ async function wrapIn(
|
|
|
1534
1735
|
)
|
|
1535
1736
|
wrapped =
|
|
1536
1737
|
(gitGranted ? 'GIT_DISCOVERY_ACROSS_FILESYSTEM=${GIT_DISCOVERY_ACROSS_FILESYSTEM-1} ' : '') +
|
|
1537
|
-
`exec ${readOnlyMasks(wrapped, scratch)}`
|
|
1738
|
+
`exec ${readOnlyMasks(withLinkedWrites(wrapped, linkedWriteBinds(args.config.linkedWrites)), scratch)}`
|
|
1538
1739
|
}
|
|
1539
1740
|
if (process.platform === 'linux' && !hostHasIpv6())
|
|
1540
1741
|
wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
|
|
@@ -1661,16 +1862,23 @@ function ownGroupCommand(
|
|
|
1661
1862
|
}
|
|
1662
1863
|
|
|
1663
1864
|
/**
|
|
1664
|
-
* What strace stops on: the reads
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
1667
|
-
*
|
|
1865
|
+
* What strace stops on: the reads (`openat`, and `open`, musl's open():
|
|
1866
|
+
* traced as openat alone, a sibling under a widened write grant was read
|
|
1867
|
+
* unreported and cached; arm64 has no `open`; an `openat2` never reaches
|
|
1868
|
+
* strace, SRT's write observer takes each one first: `refusedWrites`),
|
|
1869
|
+
* the execs and access probes (a refused
|
|
1870
|
+
* `./gen.sh || fallback` passed unreported), `readlink` (git hashes a link
|
|
1871
|
+
* by its target string: `readlink cfg > out.txt` under a widened grant
|
|
1872
|
+
* cached `v1` and replayed it after `cfg` pointed at `v2`; a stop costs
|
|
1873
|
+
* ~9 µs, 37 ms on 2,000 links, below the A/A noise on oxlint and vx), and
|
|
1874
|
+
* what moves or makes a process's cwd. Not the stat family: a stop per
|
|
1875
|
+
* stat cost oxlint 19% and `git status` 30%, where the execs and probes cost nothing measurable, and
|
|
1668
1876
|
* vx's own no-op run 69% (pr-bench, 495 → 835 ms, 2026-10-23). So a tool
|
|
1669
1877
|
* that stats before it opens, as Ubuntu 26's cat (uutils) does, refused
|
|
1670
1878
|
* nothing the trace sees.
|
|
1671
1879
|
*/
|
|
1672
1880
|
const TRACED_CALLS =
|
|
1673
|
-
'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
1881
|
+
'trace=openat,?open,execve,?access,faccessat,?faccessat2,?readlink,?readlinkat,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
1674
1882
|
|
|
1675
1883
|
/** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
|
|
1676
1884
|
const TRACE_FD = 5
|
|
@@ -1817,10 +2025,88 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
|
|
|
1817
2025
|
* command, until both listen in the task's network namespace, read off
|
|
1818
2026
|
* /proc/net (IPv4 or IPv6, state 0A). Bounded at ~5 s: a bridge that never
|
|
1819
2027
|
* listens leaves the command to meet the refusal it met before.
|
|
2028
|
+
*
|
|
2029
|
+
* Every task the run's bridge reaches waits, not only one that lists a
|
|
2030
|
+
* network: one that lists none dials the same proxy, which refuses and
|
|
2031
|
+
* names the host, and that refusal fails it. Waited for only by networked
|
|
2032
|
+
* tasks, it met the closed port instead under load, swallowed the error
|
|
2033
|
+
* and passed with nothing reported (2026-11-06). The wait costs ~4 ms, the
|
|
2034
|
+
* socat's own start.
|
|
1820
2035
|
*/
|
|
1821
2036
|
const PROXY_BRIDGE_WAIT =
|
|
1822
2037
|
"( i=0; until grep -qsE ':0C38 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6 && grep -qsE ':0438 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6; do [ $i -ge 500 ] && break; i=$((i+1)); sleep 0.01; done );"
|
|
1823
2038
|
|
|
2039
|
+
/** Each networked task's own proxy front, by tag, closed when its bridges go. */
|
|
2040
|
+
const taskProxies = new Map<string, TaskProxy>()
|
|
2041
|
+
/** Whether the run's proxy allows any host: one that allows none refuses every task alike, front or not. */
|
|
2042
|
+
let runAllowsHosts = false
|
|
2043
|
+
/** The run's union: what a `network: true` task's front allows. */
|
|
2044
|
+
let runAllowed: readonly string[] = []
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* A networked task's own proxy front (task-proxy.ts). The runtime's proxy
|
|
2048
|
+
* is the run's and filters by the union of every task's lists, so a task
|
|
2049
|
+
* reached a host only another task of the run declared. The front judges
|
|
2050
|
+
* by this task's own lists and passes what it allows on to the run's
|
|
2051
|
+
* proxy. Linux: it listens on a socket in the task's tmpdir, bound over
|
|
2052
|
+
* the runtime's bridge socket, so the task's side dials the same path.
|
|
2053
|
+
* macOS: it listens on a loopback port of its own, and the task's proxy
|
|
2054
|
+
* variables and profile name that port in place of the run's, so the
|
|
2055
|
+
* profile refuses the run's. A task that lists no network gets a front
|
|
2056
|
+
* that refuses every host; `network: true` gets one that allows the
|
|
2057
|
+
* run's union. Each front refuses only its own task's denies. Fails closed: a task the front cannot take does not run.
|
|
2058
|
+
*/
|
|
2059
|
+
async function ownProxy(
|
|
2060
|
+
SandboxManager: SrtModule['SandboxManager'],
|
|
2061
|
+
wrapped: string,
|
|
2062
|
+
inner: string,
|
|
2063
|
+
tag: string,
|
|
2064
|
+
config: ResolvedSandboxConfig,
|
|
2065
|
+
): Promise<string> {
|
|
2066
|
+
const port = SandboxManager.getProxyPort()
|
|
2067
|
+
const token = SandboxManager.getProxyAuthToken()
|
|
2068
|
+
const linux = process.platform === 'linux'
|
|
2069
|
+
const bridge = linux ? SandboxManager.getLinuxHttpSocketPath() : undefined
|
|
2070
|
+
const bind = bridge === undefined ? undefined : `--bind ${bridge} ${bridge}`
|
|
2071
|
+
const runPort = new RegExp(`localhost:${port}(?!\\d)`, 'g')
|
|
2072
|
+
const handed =
|
|
2073
|
+
port !== undefined &&
|
|
2074
|
+
token !== undefined &&
|
|
2075
|
+
(linux ? bind !== undefined && wrapped.includes(bind) : runPort.test(wrapped))
|
|
2076
|
+
// A task that lists no network is handed the run's proxy too when another
|
|
2077
|
+
// task of the run lists some; it gets a front that refuses every host.
|
|
2078
|
+
if (!handed) {
|
|
2079
|
+
if (config.network === undefined) return wrapped
|
|
2080
|
+
throw new Error('sandbox: the runtime gave this networked task no proxy to replace')
|
|
2081
|
+
}
|
|
2082
|
+
const socket = path.join(taskTmpdir(tag), 'vx-net.sock')
|
|
2083
|
+
const key = Buffer.from(inner.slice(0, 100)).toString('base64')
|
|
2084
|
+
const front = await startTaskProxy({
|
|
2085
|
+
endpoint: linux ? socket : 0,
|
|
2086
|
+
allowed:
|
|
2087
|
+
config.network === true
|
|
2088
|
+
? runAllowed
|
|
2089
|
+
: config.network === undefined
|
|
2090
|
+
? []
|
|
2091
|
+
: (config.network as string[]),
|
|
2092
|
+
denied: config.denyNetwork ?? [],
|
|
2093
|
+
runProxyPort: port!,
|
|
2094
|
+
token: token!,
|
|
2095
|
+
encodedCommand: key,
|
|
2096
|
+
refused: (line) => collecting.get(key)?.push({ line, timestamp: new Date() }),
|
|
2097
|
+
})
|
|
2098
|
+
taskProxies.set(tag, front)
|
|
2099
|
+
if (!linux) return wrapped.replace(runPort, `localhost:${front.port}`)
|
|
2100
|
+
// After the root's read-only bind, which would mask a bind made before it
|
|
2101
|
+
// with the host's own file: the runtime's socket, reachable as itself.
|
|
2102
|
+
const own = `--bind ${socket} ${bridge}`
|
|
2103
|
+
const root = ' --ro-bind / / '
|
|
2104
|
+
const at = wrapped.indexOf(root)
|
|
2105
|
+
return at === -1
|
|
2106
|
+
? wrapped.replace(bind!, own)
|
|
2107
|
+
: `${wrapped.slice(0, at + root.length)}${own} ${wrapped.slice(at + root.length)}`
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1824
2110
|
/** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
|
|
1825
2111
|
export function portBridgeSocket(tag: string, port: number): string {
|
|
1826
2112
|
return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
|
|
@@ -1934,8 +2220,8 @@ async function hostBridgesListen(ports: readonly number[], tag: string): Promise
|
|
|
1934
2220
|
if (!procfsIsOwn()) return
|
|
1935
2221
|
const want = ports.map((p) => `0100007F:${p.toString(16).toUpperCase().padStart(4, '0')}`)
|
|
1936
2222
|
const procs = hostBridges.get(tag)?.procs ?? []
|
|
1937
|
-
const until =
|
|
1938
|
-
while (
|
|
2223
|
+
const until = performance.now() + 5_000
|
|
2224
|
+
while (performance.now() < until && procs.every((p) => p.exitCode === null)) {
|
|
1939
2225
|
let table: string
|
|
1940
2226
|
try {
|
|
1941
2227
|
table = readFileSync('/proc/net/tcp', 'utf8')
|
|
@@ -2004,6 +2290,12 @@ export function releaseBridges(tag: string): void {
|
|
|
2004
2290
|
afterCommand(srtLoaded!.SandboxManager)
|
|
2005
2291
|
if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
|
|
2006
2292
|
}
|
|
2293
|
+
const front = taskProxies.get(tag)
|
|
2294
|
+
if (front !== undefined) {
|
|
2295
|
+
taskProxies.delete(tag)
|
|
2296
|
+
// quiet: a front that will not close holds only its own socket, gone with the tmpdir
|
|
2297
|
+
void front.close().catch(() => undefined)
|
|
2298
|
+
}
|
|
2007
2299
|
const bridges = hostBridges.get(tag)
|
|
2008
2300
|
if (bridges === undefined) return
|
|
2009
2301
|
hostBridges.delete(tag)
|
|
@@ -2028,6 +2320,22 @@ export function releaseBridges(tag: string): void {
|
|
|
2028
2320
|
const TRACER_RETRY_LINE =
|
|
2029
2321
|
"[vx] the sandbox's tracer (strace) failed on its own; running the task again\n"
|
|
2030
2322
|
|
|
2323
|
+
/** What a task's output says when a mount went away as bwrap set the sandbox up. */
|
|
2324
|
+
const MOUNT_RACE_RETRY_LINE =
|
|
2325
|
+
'[vx] a mount went away as the sandbox was set up; running the task again\n'
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* bwrap's own refusal of a mount gone between its read of the mount table
|
|
2329
|
+
* and its remount: a container runtime's transient mount under /tmp
|
|
2330
|
+
* (`/newroot/tmp/containerd-mount…`, a `docker build` beside the task on
|
|
2331
|
+
* CI). The command never started. Or a path the wrap walls, gone before
|
|
2332
|
+
* bwrap mounts on it: bwrap makes the mount point anew under a read-only
|
|
2333
|
+
* parent (`Can't mkdir /tmp/vx-noscan-0: Read-only file system`, the no-scan
|
|
2334
|
+
* directory removed by another process).
|
|
2335
|
+
*/
|
|
2336
|
+
const BWRAP_MOUNT_GONE =
|
|
2337
|
+
/^bwrap: Can't (?:.*: No such file or directory|mkdir .*: Read-only file system)$/
|
|
2338
|
+
|
|
2031
2339
|
/**
|
|
2032
2340
|
* Run a single task wrapped in the sandbox. Caller must have called
|
|
2033
2341
|
* `initSandbox()` first.
|
|
@@ -2053,20 +2361,21 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
2053
2361
|
if (chunk.length > 0) midLine = !chunk.endsWith('\n')
|
|
2054
2362
|
on?.(chunk)
|
|
2055
2363
|
}
|
|
2056
|
-
const { tracerFailed, ranMs, ...first } = await runSandboxedOnce({
|
|
2364
|
+
const { tracerFailed, mountGone, ranMs, ...first } = await runSandboxedOnce({
|
|
2057
2365
|
...args,
|
|
2058
2366
|
onStdout: seen(args.onStdout),
|
|
2059
2367
|
onStderr: seen(args.onStderr),
|
|
2060
2368
|
})
|
|
2061
2369
|
// A stopping run has killed the children it holds; a retry would be one
|
|
2062
2370
|
// spawned after that kill.
|
|
2063
|
-
if (!tracerFailed || args.signal?.aborted === true) return first
|
|
2064
|
-
const notice = (midLine ? '\n' : '') + TRACER_RETRY_LINE
|
|
2371
|
+
if (!(tracerFailed || mountGone) || args.signal?.aborted === true) return first
|
|
2372
|
+
const notice = (midLine ? '\n' : '') + (mountGone ? MOUNT_RACE_RETRY_LINE : TRACER_RETRY_LINE)
|
|
2065
2373
|
args.onStderr?.(notice)
|
|
2066
2374
|
// `timeout` bounds the task's whole run (schema.md), so the retry gets
|
|
2067
2375
|
// what the first attempt left of it, not a fresh window.
|
|
2068
2376
|
const {
|
|
2069
2377
|
tracerFailed: _again,
|
|
2378
|
+
mountGone: _gone,
|
|
2070
2379
|
ranMs: _ran,
|
|
2071
2380
|
...second
|
|
2072
2381
|
} = await runSandboxedOnce(
|
|
@@ -2088,18 +2397,21 @@ let collectorOn = false
|
|
|
2088
2397
|
|
|
2089
2398
|
/**
|
|
2090
2399
|
* Start collecting SRT's store records for `command`; the returned function
|
|
2091
|
-
* stops and hands them over
|
|
2092
|
-
*
|
|
2400
|
+
* stops and hands them over, with `settle` once every record the task's
|
|
2401
|
+
* Linux observer (`observer`, its socket) sent is read. The store is a
|
|
2402
|
+
* 100-record ring the whole run shares, and on Linux its write observer
|
|
2403
|
+
* reports every write any task
|
|
2093
2404
|
* makes, so a refused write followed by 150 declared ones was gone before
|
|
2094
2405
|
* the task's exit read it (B-7). One subscription takes each record as it
|
|
2095
2406
|
* arrives and keeps it only for a command still running. SRT keys a record
|
|
2096
2407
|
* by the base64 of the command's first 100 characters
|
|
2097
2408
|
* (`encodeSandboxedCommand`, not exported).
|
|
2098
2409
|
*/
|
|
2099
|
-
function collectRecords(
|
|
2410
|
+
export function collectRecords(
|
|
2100
2411
|
store: ReturnType<SrtModule['SandboxManager']['getSandboxViolationStore']>,
|
|
2101
2412
|
command: string,
|
|
2102
|
-
|
|
2413
|
+
observer: string | undefined,
|
|
2414
|
+
): (settle: boolean) => Promise<SandboxViolation[]> {
|
|
2103
2415
|
if (!collectorOn) {
|
|
2104
2416
|
collectorOn = true
|
|
2105
2417
|
let seen = store.getTotalCount()
|
|
@@ -2115,12 +2427,44 @@ function collectRecords(
|
|
|
2115
2427
|
const key = Buffer.from(command.slice(0, 100)).toString('base64')
|
|
2116
2428
|
const list: SandboxViolation[] = []
|
|
2117
2429
|
collecting.set(key, list)
|
|
2118
|
-
return () => {
|
|
2430
|
+
return async (settle) => {
|
|
2431
|
+
if (settle && observer !== undefined) await observerCaughtUp(observer)
|
|
2119
2432
|
collecting.delete(key)
|
|
2120
2433
|
return list
|
|
2121
2434
|
}
|
|
2122
2435
|
}
|
|
2123
2436
|
|
|
2437
|
+
/**
|
|
2438
|
+
* The Unix socket SRT's Linux write observer listens on, from the command
|
|
2439
|
+
* it wrapped (`--setenv SRT_OBSERVE_SOCK <path>`); none when the listener
|
|
2440
|
+
* failed or on macOS.
|
|
2441
|
+
*/
|
|
2442
|
+
function observerSocket(wrapped: string): string | undefined {
|
|
2443
|
+
return /--setenv SRT_OBSERVE_SOCK (?:'([^']*)'|(\S+))/.exec(wrapped)?.slice(1).find(Boolean)
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/**
|
|
2447
|
+
* Resolves once SRT has read every record sent to `socket` before the call.
|
|
2448
|
+
* A task's observer sends its records before the task exits, but they are
|
|
2449
|
+
* read when this process gets to that connection: a fixed count of event
|
|
2450
|
+
* loop turns after the exit (2) lost 4 of 24 refusals on a loaded CI
|
|
2451
|
+
* worker. So a connection of ours goes in after them: the kernel queues
|
|
2452
|
+
* connections in order, so SRT accepts it no earlier than the task's, and
|
|
2453
|
+
* the task's bytes were already there when it did. SRT's server is not
|
|
2454
|
+
* half-open, so it ends ours once it reads our end, and that end coming
|
|
2455
|
+
* back means the task's bytes were read first. A socket that is gone
|
|
2456
|
+
* answers at once with an error.
|
|
2457
|
+
*/
|
|
2458
|
+
function observerCaughtUp(socket: string): Promise<void> {
|
|
2459
|
+
return new Promise((resolve) => {
|
|
2460
|
+
const conn = createConnection(socket)
|
|
2461
|
+
conn.on('connect', () => conn.end())
|
|
2462
|
+
conn.on('error', () => resolve())
|
|
2463
|
+
conn.on('close', () => resolve())
|
|
2464
|
+
conn.resume()
|
|
2465
|
+
})
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2124
2468
|
/**
|
|
2125
2469
|
* strace's own message, a line of a traced task's stderr. strace names
|
|
2126
2470
|
* itself by its argv[0], the absolute path vx runs it by
|
|
@@ -2140,8 +2484,9 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
|
|
|
2140
2484
|
*/
|
|
2141
2485
|
async function runSandboxedOnce(
|
|
2142
2486
|
args: SandboxedRunArgs,
|
|
2143
|
-
): Promise<SandboxedRunResult & { tracerFailed: boolean; ranMs: number }> {
|
|
2144
|
-
|
|
2487
|
+
): Promise<SandboxedRunResult & { tracerFailed: boolean; mountGone: boolean; ranMs: number }> {
|
|
2488
|
+
// Elapsed time, not wall time: see runCommand.
|
|
2489
|
+
const start = performance.now()
|
|
2145
2490
|
const { SandboxManager } = await loadSrt()
|
|
2146
2491
|
// Linux: SRT's store sees only writes (below), so read denials need
|
|
2147
2492
|
// the command traced and the trace parsed for denied syscalls. The trace
|
|
@@ -2149,7 +2494,7 @@ async function runSandboxedOnce(
|
|
|
2149
2494
|
// share a stream. Skipped when strace isn't on PATH — bwrap still
|
|
2150
2495
|
// enforces structurally; we just lose the structured violation list.
|
|
2151
2496
|
//
|
|
2152
|
-
// We trace
|
|
2497
|
+
// We trace the opens, the read itself, plus `execve` and the access
|
|
2153
2498
|
// probes (`TRACED_CALLS`). Not `statx` / `newfstatat`: a stop per stat
|
|
2154
2499
|
// is a tax on every stat-heavy task. A probe's ENOENT counts only where
|
|
2155
2500
|
// the host has the path (`parseStraceViolations`), so a PATH walk past
|
|
@@ -2167,29 +2512,34 @@ async function runSandboxedOnce(
|
|
|
2167
2512
|
// one gets the slow form rather than no detection.
|
|
2168
2513
|
const useStrace = await wantsStraceDetection()
|
|
2169
2514
|
// Before the spawn: what the task creates under a widened grant is its own.
|
|
2170
|
-
const widened =
|
|
2515
|
+
const widened = widenedEntries(args.config.allowWrite)
|
|
2171
2516
|
const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
|
|
2172
2517
|
await wrapSandboxedCommand({
|
|
2173
2518
|
...args,
|
|
2174
2519
|
...(useStrace ? { trace: useStrace } : {}),
|
|
2175
|
-
...(
|
|
2520
|
+
...(useStrace && widened.size > 0 ? { tracePaths: true } : {}),
|
|
2176
2521
|
})
|
|
2177
|
-
const takeRecords = collectRecords(
|
|
2522
|
+
const takeRecords = collectRecords(
|
|
2523
|
+
SandboxManager.getSandboxViolationStore(),
|
|
2524
|
+
srtCommand,
|
|
2525
|
+
observerSocket(wrapped),
|
|
2526
|
+
)
|
|
2178
2527
|
// A stop that landed during the awaits above leaves nothing to kill yet:
|
|
2179
2528
|
// spawned now, the task ran after the teardown swept the run's children.
|
|
2180
2529
|
if (args.signal?.aborted === true) {
|
|
2181
2530
|
releaseBridges(tag)
|
|
2182
|
-
takeRecords()
|
|
2531
|
+
void takeRecords(false)
|
|
2183
2532
|
afterCommand(SandboxManager)
|
|
2184
2533
|
const signal = stopSignal(args.signal.reason)
|
|
2185
2534
|
return {
|
|
2186
2535
|
exitCode: signalExitCode(signal),
|
|
2187
|
-
durationMs:
|
|
2536
|
+
durationMs: Math.round(performance.now() - start),
|
|
2188
2537
|
stdout: '',
|
|
2189
2538
|
stderr: '',
|
|
2190
2539
|
signal,
|
|
2191
2540
|
violations: [],
|
|
2192
2541
|
tracerFailed: false,
|
|
2542
|
+
mountGone: false,
|
|
2193
2543
|
ranMs: 0,
|
|
2194
2544
|
}
|
|
2195
2545
|
}
|
|
@@ -2231,19 +2581,20 @@ async function runSandboxedOnce(
|
|
|
2231
2581
|
rmSync(straceLog, { force: true })
|
|
2232
2582
|
liveTempFiles.delete(straceLog)
|
|
2233
2583
|
}
|
|
2234
|
-
const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
|
|
2584
|
+
const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped, args.env)
|
|
2235
2585
|
args.onStderr?.(stderr)
|
|
2236
2586
|
releaseBridges(tag)
|
|
2237
|
-
takeRecords()
|
|
2587
|
+
void takeRecords(false)
|
|
2238
2588
|
afterCommand(SandboxManager)
|
|
2239
2589
|
return {
|
|
2240
2590
|
exitCode: 127,
|
|
2241
|
-
durationMs:
|
|
2591
|
+
durationMs: Math.round(performance.now() - start),
|
|
2242
2592
|
stdout: '',
|
|
2243
2593
|
stderr,
|
|
2244
2594
|
spawnFailed: true,
|
|
2245
2595
|
violations: [],
|
|
2246
2596
|
tracerFailed: false,
|
|
2597
|
+
mountGone: false,
|
|
2247
2598
|
ranMs: 0,
|
|
2248
2599
|
}
|
|
2249
2600
|
} finally {
|
|
@@ -2256,11 +2607,12 @@ async function runSandboxedOnce(
|
|
|
2256
2607
|
args.onSpawn?.(proc.pid)
|
|
2257
2608
|
const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
|
|
2258
2609
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
2259
|
-
const armedAt =
|
|
2610
|
+
const armedAt = performance.now()
|
|
2260
2611
|
const ac = new AbortController()
|
|
2261
2612
|
// The unfinished last line of stderr, and whether a line was strace's.
|
|
2262
2613
|
let partial = ''
|
|
2263
2614
|
let straceSpoke = false
|
|
2615
|
+
let mountGone = false
|
|
2264
2616
|
const [out, err] = pipes.streams(proc)
|
|
2265
2617
|
const ends = lineEnds(args.onStdout, args.onStderr)
|
|
2266
2618
|
const streams = Promise.all([
|
|
@@ -2273,6 +2625,7 @@ async function runSandboxedOnce(
|
|
|
2273
2625
|
const lines = (partial + chunk).split('\n')
|
|
2274
2626
|
partial = (lines.pop() ?? '').slice(0, 512)
|
|
2275
2627
|
if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
|
|
2628
|
+
if (lines.some((l) => BWRAP_MOUNT_GONE.test(l))) mountGone = true
|
|
2276
2629
|
ends.onStderr?.(chunk)
|
|
2277
2630
|
},
|
|
2278
2631
|
ac.signal,
|
|
@@ -2283,7 +2636,7 @@ async function runSandboxedOnce(
|
|
|
2283
2636
|
// OR a clean exit that backgrounds a process) can't hang the run — timeout
|
|
2284
2637
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
|
2285
2638
|
await proc.exited
|
|
2286
|
-
const ranMs =
|
|
2639
|
+
const ranMs = Math.round(performance.now() - armedAt)
|
|
2287
2640
|
const looked = watch?.stop()
|
|
2288
2641
|
await timeout.settle()
|
|
2289
2642
|
let cut = false
|
|
@@ -2294,6 +2647,9 @@ async function runSandboxedOnce(
|
|
|
2294
2647
|
const cutLine = ends.own(POST_EXIT_CUT_LINE)
|
|
2295
2648
|
if (cut) args.onStderr?.(cutLine)
|
|
2296
2649
|
const stderr = cut ? streamed + cutLine : streamed
|
|
2650
|
+
// As runCommand: a leftover holding no pipe is still in the group. Under
|
|
2651
|
+
// Linux's pid namespace it died with the command; macOS keeps it.
|
|
2652
|
+
const left = cut || groupAlive(proc)
|
|
2297
2653
|
args.liveChildren?.delete(proc)
|
|
2298
2654
|
releaseGroup(proc)
|
|
2299
2655
|
closeSignalChannel(proc)
|
|
@@ -2324,15 +2680,9 @@ async function runSandboxedOnce(
|
|
|
2324
2680
|
// (2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
2325
2681
|
// deciding on the user's behalf that a record was "just traversal" is how
|
|
2326
2682
|
// a failure ends up with an empty violations section and no explanation.
|
|
2327
|
-
// Linux: the write observer's records
|
|
2328
|
-
//
|
|
2329
|
-
|
|
2330
|
-
// in the turn its connection is accepted, before it is read: a turn to
|
|
2331
|
-
// accept and a turn to read take them in (40 fast refused mkdirs at
|
|
2332
|
-
// once: 38 of 200 unreported, 33 with one turn, none with two).
|
|
2333
|
-
if (process.platform === 'linux')
|
|
2334
|
-
for (let turn = 0; turn < 2; turn++) await new Promise((r) => setImmediate(r))
|
|
2335
|
-
const records = takeRecords()
|
|
2683
|
+
// Linux: the write observer's records arrive over a socket, not with the
|
|
2684
|
+
// exit; `takeRecords(true)` waits until all it sent are read.
|
|
2685
|
+
const records = await takeRecords(true)
|
|
2336
2686
|
// Read as is on darwin only. Since SRT 0.0.75 the store is fed on Linux
|
|
2337
2687
|
// too, by the seccomp helper's write observer — but SRT judges those
|
|
2338
2688
|
// reports against the GLOBAL `filesystem.allowWrite` from `initialize`
|
|
@@ -2378,6 +2728,7 @@ async function runSandboxedOnce(
|
|
|
2378
2728
|
bindableWrites(args.config.allowWrite),
|
|
2379
2729
|
scratch,
|
|
2380
2730
|
[...baselines.allowRead, ...bindableReads(args.config.allowRead)],
|
|
2731
|
+
{ entries: widened, granted: args.config.allowWrite },
|
|
2381
2732
|
),
|
|
2382
2733
|
...refusedConnections(records.map((v) => v.line)),
|
|
2383
2734
|
]
|
|
@@ -2453,20 +2804,22 @@ async function runSandboxedOnce(
|
|
|
2453
2804
|
config: args.config,
|
|
2454
2805
|
skip: [taskTmpRoot()],
|
|
2455
2806
|
})
|
|
2456
|
-
if (hidden.length > 0)
|
|
2807
|
+
if (hidden.length > 0)
|
|
2808
|
+
violations.push(hiddenReadsHint(hidden, baselines.denyRead, args.reportWithin))
|
|
2457
2809
|
}
|
|
2458
2810
|
|
|
2459
2811
|
afterCommand(SandboxManager)
|
|
2460
2812
|
|
|
2461
2813
|
return {
|
|
2462
2814
|
exitCode,
|
|
2463
|
-
durationMs:
|
|
2815
|
+
durationMs: Math.round(performance.now() - start),
|
|
2464
2816
|
stdout,
|
|
2465
2817
|
stderr,
|
|
2466
2818
|
violations,
|
|
2467
2819
|
...(typeof proc.signalCode === 'string' ? { signal: proc.signalCode } : {}),
|
|
2468
2820
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
2469
|
-
...(
|
|
2821
|
+
...(timeout.sigkilled() ? { killedAfterGrace: true } : {}),
|
|
2822
|
+
...(left ? { leftRunning: true } : {}),
|
|
2470
2823
|
// Linux: the task's CPU and peak never reach this wait. bwrap runs it
|
|
2471
2824
|
// in a pid namespace (`--unshare-pid`), and what the namespace's
|
|
2472
2825
|
// processes used is not folded into bwrap's usage: a 500 ms busy loop
|
|
@@ -2481,6 +2834,7 @@ async function runSandboxedOnce(
|
|
|
2481
2834
|
? {}
|
|
2482
2835
|
: { cpuMs: looked.cpuMs, peakRssBytes: looked.rssBytes }),
|
|
2483
2836
|
ranMs,
|
|
2837
|
+
mountGone: exitCode !== 0 && (mountGone || BWRAP_MOUNT_GONE.test(partial)),
|
|
2484
2838
|
tracerFailed:
|
|
2485
2839
|
straceLog !== undefined &&
|
|
2486
2840
|
!timeout.timedOut() &&
|
|
@@ -2510,15 +2864,7 @@ function outsideWritesHint(
|
|
|
2510
2864
|
): SandboxViolation {
|
|
2511
2865
|
const shown = paths.slice(0, 5).join(', ')
|
|
2512
2866
|
const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
|
|
2513
|
-
const
|
|
2514
|
-
const dir = path.dirname(paths[0]!)
|
|
2515
|
-
// In the workspace, from the project, as a committed config spells it:
|
|
2516
|
-
// the absolute path held only on the machine that printed it.
|
|
2517
|
-
const spelled = walled.some((w) => atOrUnder(dir, toRealPath(w)))
|
|
2518
|
-
? path.relative(toRealPath(within), dir) || '.'
|
|
2519
|
-
: atOrUnder(dir, home)
|
|
2520
|
-
? `~${dir.slice(home.length)}`
|
|
2521
|
-
: dir
|
|
2867
|
+
const spelled = grantSpelling(path.dirname(paths[0]!), walled, within)
|
|
2522
2868
|
const refused =
|
|
2523
2869
|
`vx: the sandbox refused writes outside the project, which are not reported as ` +
|
|
2524
2870
|
`violations: ${shown}${more}.`
|
|
@@ -2584,19 +2930,52 @@ function hostTempRoots(): string[] {
|
|
|
2584
2930
|
return [...new Set(['/tmp', '/var/tmp', os.tmpdir()].map(toRealPath))]
|
|
2585
2931
|
}
|
|
2586
2932
|
|
|
2587
|
-
|
|
2933
|
+
/**
|
|
2934
|
+
* The hint for reads the wall hid outside the project. A path under a
|
|
2935
|
+
* deny entry other than the ones holding the project (a credential store
|
|
2936
|
+
* or its link's target, vx's own store, the repository's git data outside
|
|
2937
|
+
* the workspace) is walled on purpose and never the suggested grant: an
|
|
2938
|
+
* agent applies a suggestion as given, and `cat ~/.npmrc` was told to
|
|
2939
|
+
* grant `../../home/.npmrc`.
|
|
2940
|
+
*/
|
|
2941
|
+
function hiddenReadsHint(
|
|
2942
|
+
paths: readonly string[],
|
|
2943
|
+
walled: readonly string[],
|
|
2944
|
+
within: string,
|
|
2945
|
+
): SandboxViolation {
|
|
2588
2946
|
const shown = paths.slice(0, 5).join(', ')
|
|
2589
2947
|
const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
|
|
2948
|
+
const project = toRealPath(within)
|
|
2949
|
+
const deny = walled.map(toRealPath)
|
|
2950
|
+
const grantable = paths.find((p) => !deny.some((w) => atOrUnder(p, w) && !atOrUnder(project, w)))
|
|
2951
|
+
const said =
|
|
2952
|
+
`vx: the sandbox hid paths outside the project that exist on this machine, which are ` +
|
|
2953
|
+
`not reported as violations: ${shown}${more}.`
|
|
2590
2954
|
return {
|
|
2591
2955
|
timestamp: new Date(),
|
|
2592
2956
|
hint: true,
|
|
2593
2957
|
line:
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2958
|
+
grantable === undefined
|
|
2959
|
+
? `${said} They are walled on purpose, so vx suggests no grant for them.`
|
|
2960
|
+
: `${said} If the task reads one, grant it, e.g. ` +
|
|
2961
|
+
`\`allow: { read: [${jsString(grantSpelling(grantable, walled, within))}] }\`.`,
|
|
2597
2962
|
}
|
|
2598
2963
|
}
|
|
2599
2964
|
|
|
2965
|
+
/**
|
|
2966
|
+
* A path as a committed config spells its grant: from the project inside
|
|
2967
|
+
* the walls (the absolute path holds only on the machine that printed
|
|
2968
|
+
* it), `~/…` under home, else absolute.
|
|
2969
|
+
*/
|
|
2970
|
+
function grantSpelling(p: string, walled: readonly string[], within: string): string {
|
|
2971
|
+
const home = toRealPath(os.homedir())
|
|
2972
|
+
return walled.some((w) => atOrUnder(p, toRealPath(w)))
|
|
2973
|
+
? path.relative(toRealPath(within), p) || '.'
|
|
2974
|
+
: atOrUnder(p, home)
|
|
2975
|
+
? `~${p.slice(home.length)}`
|
|
2976
|
+
: p
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2600
2979
|
/**
|
|
2601
2980
|
* Is `dir` inside a path the sandbox granted for reading? bwrap binds and
|
|
2602
2981
|
* seatbelt rules are both prefix-based, so a grant covers the subtree.
|
|
@@ -2634,7 +3013,7 @@ function sbplToken(value: string, field: string): string {
|
|
|
2634
3013
|
*
|
|
2635
3014
|
* `systemInfo` has no SRT field at any level. The rest DO have fields,
|
|
2636
3015
|
* but SRT reads `network.allowLocalBinding` / `allowUnixSockets` /
|
|
2637
|
-
* `allowMachLookup` off the config given to `initialize()` and never off
|
|
3016
|
+
* `allowMachLookup` / `enableWeakerNetworkIsolation` off the config given to `initialize()` and never off
|
|
2638
3017
|
* the per-call one (`sandbox-manager.js` getters, 0.0.75 and 0.0.76) — so a per-task
|
|
2639
3018
|
* grant passed there is silently dropped. vx is per-task by definition,
|
|
2640
3019
|
* so it emits them itself. The rule text mirrors SRT's own.
|
|
@@ -2657,6 +3036,9 @@ export function macProfileRules(c: ResolvedSandboxConfig): string[] {
|
|
|
2657
3036
|
rules.push('(allow network-inbound (local ip "*:*"))')
|
|
2658
3037
|
rules.push('(allow network-outbound (remote ip "localhost:*"))')
|
|
2659
3038
|
}
|
|
3039
|
+
if (c.weakerNetworkIsolation === true) {
|
|
3040
|
+
rules.push('(allow mach-lookup (global-name "com.apple.trustd.agent"))')
|
|
3041
|
+
}
|
|
2660
3042
|
if (c.unixSockets === true) {
|
|
2661
3043
|
rules.push('(allow system-socket (socket-domain AF_UNIX))')
|
|
2662
3044
|
rules.push('(allow network-bind (local unix-socket (path-regex #"^/")))')
|