@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// per-task filesystem + network isolation.
|
|
3
3
|
//
|
|
4
4
|
// Design contract:
|
|
5
|
-
// The caller (
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// task
|
|
5
|
+
// The caller (sandbox-request.ts) hands over the task's own
|
|
6
|
+
// `sandbox.allow` block plus two baselines: the `node_modules` reads a
|
|
7
|
+
// task's dependencies need, and the workspace-root deny anchor. Writes
|
|
8
|
+
// are the task's `allow.write` alone — nothing is derived from `cache`.
|
|
9
|
+
// This module adds nothing implicit on top — no /tmp, no project dir —
|
|
10
|
+
// so what a task may touch is its vx.config.ts plus those baselines.
|
|
11
11
|
//
|
|
12
12
|
// Network is opt-in per task. By default the sandbox blocks all outbound
|
|
13
13
|
// traffic; tasks that need it set `sandbox.network: true`.
|
|
@@ -25,20 +25,45 @@
|
|
|
25
25
|
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
import os from 'node:os'
|
|
28
|
-
import { realpathSync } from 'node:fs'
|
|
29
|
-
import { unlink } from 'node:fs/promises'
|
|
30
|
-
import type { SandboxConfig
|
|
28
|
+
import { mkdirSync, readlinkSync, realpathSync, rmSync, unlinkSync } from 'node:fs'
|
|
29
|
+
import { mkdir, unlink } from 'node:fs/promises'
|
|
30
|
+
import type { SandboxConfig } from '../config.js'
|
|
31
31
|
import {
|
|
32
32
|
armTimeout,
|
|
33
33
|
drainOrAbort,
|
|
34
|
+
POST_EXIT_CUT_LINE,
|
|
34
35
|
shellQuote,
|
|
35
36
|
signalExitCode,
|
|
37
|
+
spawnFailureText,
|
|
36
38
|
streamToString,
|
|
37
39
|
resourceUsageToCpuRss,
|
|
38
40
|
type CaptureConfig,
|
|
39
41
|
type RunResult,
|
|
40
42
|
} from './runner.js'
|
|
41
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
executablePath,
|
|
45
|
+
grantPrefix,
|
|
46
|
+
isTmpdirRefusal,
|
|
47
|
+
TMPDIR_HINT,
|
|
48
|
+
UserError,
|
|
49
|
+
xxh3hex,
|
|
50
|
+
} from '../util/index.js'
|
|
51
|
+
import { buildCustomConfig } from './sandbox-binds.js'
|
|
52
|
+
import {
|
|
53
|
+
isMountableLiteral,
|
|
54
|
+
localBindingOn,
|
|
55
|
+
MOUNT_WILDCARDS,
|
|
56
|
+
toRealPath,
|
|
57
|
+
unique,
|
|
58
|
+
} from './sandbox-paths.js'
|
|
59
|
+
import { parseStraceViolations, reportableViolations } from './sandbox-violations.js'
|
|
60
|
+
import {
|
|
61
|
+
closeSignalChannel,
|
|
62
|
+
killTree,
|
|
63
|
+
releaseGroup,
|
|
64
|
+
signalThrough,
|
|
65
|
+
spawnGuarded,
|
|
66
|
+
} from './kill-tree.js'
|
|
42
67
|
|
|
43
68
|
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
44
69
|
let srtPromise: Promise<SrtModule> | undefined
|
|
@@ -54,65 +79,180 @@ export interface SandboxAvailability {
|
|
|
54
79
|
reason: string
|
|
55
80
|
}
|
|
56
81
|
|
|
57
|
-
|
|
82
|
+
/** One verdict per mode: the wrapper the probe runs differs by mode. */
|
|
83
|
+
const availabilityCache = new Map<'secure' | 'weaker', SandboxAvailability>()
|
|
58
84
|
|
|
59
85
|
/**
|
|
60
|
-
* Probe whether SRT can sandbox on this host. Memoized — the
|
|
61
|
-
* presence + platform check doesn't change within a process.
|
|
86
|
+
* Probe whether SRT can sandbox on this host. Memoized per mode — the
|
|
87
|
+
* binary presence + platform check doesn't change within a process.
|
|
62
88
|
*
|
|
63
89
|
* In addition to SRT's own `checkDependencies` (which only verifies
|
|
64
|
-
* binary presence on PATH), this runs
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
* binary presence on PATH), this runs ONE sandboxed `true` on Linux
|
|
91
|
+
* through SRT's own wrapper: bwrap with the runtime's namespace flags
|
|
92
|
+
* plus its vendored seccomp helper, which creates a NESTED user
|
|
93
|
+
* namespace. A bare `bwrap … /bin/true` (the probe until 2026-09-04)
|
|
94
|
+
* passed on hosts where every task then failed — as root inside a
|
|
95
|
+
* container, the helper's `write /proc/self/uid_map` is EPERM under
|
|
96
|
+
* `--cap-drop ALL`. Without this real-execution probe the
|
|
97
|
+
* unavailability surfaces only at the first task spawn, deep inside the
|
|
98
|
+
* orchestrator, as exit 1 with the helper's line in the task's stderr.
|
|
99
|
+
*
|
|
100
|
+
* `weakerNested` probes with `enableWeakerNestedSandbox`; the caller
|
|
101
|
+
* passes it only when EVERY sandboxed task opts in, since the secure
|
|
102
|
+
* wrapper is what any other task will run under.
|
|
103
|
+
*
|
|
104
|
+
* On Linux the probe INITIALIZES SRT (idempotent — `run()` initializes
|
|
105
|
+
* right after anyway), and SRT's proxy sockets then keep the event loop
|
|
106
|
+
* alive: a standalone caller must `resetSandbox()` or exit. CI's
|
|
107
|
+
* diagnostic `bun -e` step sat on this for its whole 10-minute timeout
|
|
108
|
+
* (2026-09-04).
|
|
109
|
+
*/
|
|
110
|
+
export async function probeSandbox(opts?: {
|
|
111
|
+
weakerNested?: boolean
|
|
112
|
+
}): Promise<SandboxAvailability> {
|
|
113
|
+
const mode = opts?.weakerNested === true ? 'weaker' : 'secure'
|
|
114
|
+
const cached = availabilityCache.get(mode)
|
|
115
|
+
if (cached) return cached
|
|
116
|
+
const verdict = await probeUncached(mode === 'weaker')
|
|
117
|
+
availabilityCache.set(mode, verdict)
|
|
118
|
+
return verdict
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function probeUncached(weakerNested: boolean): Promise<SandboxAvailability> {
|
|
72
122
|
const { SandboxManager } = await loadSrt()
|
|
73
123
|
if (!SandboxManager.isSupportedPlatform()) {
|
|
74
|
-
|
|
75
|
-
return availabilityCache
|
|
124
|
+
return { available: false, reason: `platform ${process.platform} not supported` }
|
|
76
125
|
}
|
|
77
126
|
const deps = SandboxManager.checkDependencies()
|
|
78
|
-
if (deps.errors.length > 0) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
// Linux only: real-execution probe. Run `bwrap` with the minimal
|
|
83
|
-
// user-namespace invocation it'd attempt for a sandboxed task; if
|
|
84
|
-
// the kernel rejects (AppArmor / sysctl), surface that here.
|
|
127
|
+
if (deps.errors.length > 0) return { available: false, reason: dependencyReason(deps.errors) }
|
|
128
|
+
const long = socketPathRefusal()
|
|
129
|
+
if (long !== undefined) return { available: false, reason: long }
|
|
85
130
|
if (process.platform === 'linux') {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
131
|
+
await initSandbox()
|
|
132
|
+
return trySandboxedTrue(SandboxManager, weakerNested)
|
|
133
|
+
}
|
|
134
|
+
return applyPolicyHere()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Can a seatbelt policy be applied in THIS process at all?
|
|
139
|
+
*
|
|
140
|
+
* Not if one already is: macOS refuses `sandbox_apply` inside a sandbox,
|
|
141
|
+
* at any permission level — an inner profile of `(allow default)` still
|
|
142
|
+
* dies `sandbox_apply: Operation not permitted` (exit 71, measured
|
|
143
|
+
* 2026-09-05). No grant fixes it, so the honest verdict is "unavailable",
|
|
144
|
+
* which is what stops a sandboxed task from spawning tasks that report
|
|
145
|
+
* a sandbox they never got. Answering `available: true` here is how a
|
|
146
|
+
* suite that exercises the sandbox came to fail sixteen ways at once.
|
|
147
|
+
*
|
|
148
|
+
* The probe costs one `/usr/bin/true` and is memoized with the rest.
|
|
149
|
+
*/
|
|
150
|
+
function applyPolicyHere(): SandboxAvailability {
|
|
151
|
+
const proc = Bun.spawnSync({
|
|
152
|
+
cmd: ['sandbox-exec', '-p', '(version 1)(allow default)', '/usr/bin/true'],
|
|
153
|
+
stdout: 'ignore',
|
|
154
|
+
stderr: 'pipe',
|
|
155
|
+
})
|
|
156
|
+
if (proc.exitCode === 0) return { available: true, reason: '' }
|
|
157
|
+
const stderr = proc.stderr.toString().trim()
|
|
158
|
+
if (stderr.includes('sandbox_apply')) {
|
|
159
|
+
return {
|
|
160
|
+
available: false,
|
|
161
|
+
reason:
|
|
162
|
+
'this process is already sandboxed and macOS cannot nest one — a task that ' +
|
|
163
|
+
'itself sandboxes has to run without `exec.sandbox` of its own',
|
|
90
164
|
}
|
|
91
165
|
}
|
|
92
|
-
|
|
93
|
-
return availabilityCache
|
|
166
|
+
return { available: false, reason: `sandbox-exec failed: ${stderr || `exit ${proc.exitCode}`}` }
|
|
94
167
|
}
|
|
95
168
|
|
|
96
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Why the probe's sandboxed `true` failed, in the user's terms. The hint
|
|
171
|
+
* names the CONFIG field (`exec.sandbox.weakerWhenNested`), not the
|
|
172
|
+
* runtime option it maps to — a user who followed the runtime's name into
|
|
173
|
+
* their config met the loader's unknown-field refusal instead of a fix
|
|
174
|
+
* (`tests/sandbox-hint.test.ts` pins every field the hint names against
|
|
175
|
+
* the loader).
|
|
176
|
+
*/
|
|
177
|
+
export function unavailableReason(exitCode: number | null, stderr: string): string {
|
|
178
|
+
const hint = stderr.includes('uid_map')
|
|
179
|
+
? " — 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"
|
|
180
|
+
: ''
|
|
181
|
+
return `a sandboxed \`true\` failed (exit ${exitCode}): ${stderr.slice(0, 200)}${hint}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function trySandboxedTrue(
|
|
185
|
+
SandboxManager: SrtModule['SandboxManager'],
|
|
186
|
+
weakerNested: boolean,
|
|
187
|
+
): Promise<SandboxAvailability> {
|
|
97
188
|
try {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
189
|
+
const wrapped = await SandboxManager.wrapWithSandbox(
|
|
190
|
+
'true',
|
|
191
|
+
undefined,
|
|
192
|
+
weakerNested ? { enableWeakerNestedSandbox: true } : undefined,
|
|
101
193
|
)
|
|
102
|
-
const
|
|
194
|
+
const proc = Bun.spawn([executablePath('sh'), '-c', wrapped], {
|
|
195
|
+
argv0: 'sh',
|
|
196
|
+
stdout: 'ignore',
|
|
197
|
+
stderr: 'pipe',
|
|
198
|
+
stdin: 'ignore',
|
|
199
|
+
})
|
|
200
|
+
const stderr = (await new Response(proc.stderr).text()).trim()
|
|
103
201
|
await proc.exited
|
|
104
|
-
if (proc.exitCode
|
|
105
|
-
|
|
106
|
-
available: false,
|
|
107
|
-
reason: `bwrap probe failed (exit ${proc.exitCode}): ${stderr.trim().slice(0, 200)}`,
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return { available: true, reason: '' }
|
|
202
|
+
if (proc.exitCode === 0) return { available: true, reason: '' }
|
|
203
|
+
return { available: false, reason: unavailableReason(proc.exitCode, stderr) }
|
|
111
204
|
} catch (err) {
|
|
112
|
-
return { available: false, reason:
|
|
205
|
+
return { available: false, reason: thrownReason(err, 'sandbox probe threw') }
|
|
113
206
|
}
|
|
114
207
|
}
|
|
115
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The runtime's own dependency check names what is missing ("ripgrep (rg)
|
|
211
|
+
* not found") and nothing else. On Linux it needs three binaries — bwrap
|
|
212
|
+
* for the namespaces, socat for the network bridge, ripgrep to expand its
|
|
213
|
+
* mandatory deny globs — and the docs named two of them until a minimal
|
|
214
|
+
* image with the two failed on the third (2026-09-16). Name the set and
|
|
215
|
+
* the install.
|
|
216
|
+
*/
|
|
217
|
+
export function dependencyReason(errors: readonly string[]): string {
|
|
218
|
+
const need =
|
|
219
|
+
process.platform === 'linux'
|
|
220
|
+
? 'the sandbox runtime needs bubblewrap (bwrap), socat and ripgrep (rg) on PATH'
|
|
221
|
+
: 'the sandbox runtime is missing a dependency'
|
|
222
|
+
return `${need}: ${errors.join('; ')} — install it (Linux: apt install bubblewrap socat ripgrep, or the same names in your package manager) and re-run`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The runtime listens on `<tmpdir>/srt-mux-<pid>-<seq>.sock`, and a unix
|
|
227
|
+
* socket path has a hard length (`sun_path`: 108 bytes on Linux, 104 on
|
|
228
|
+
* macOS, one of them the NUL). Past it the runtime says "ENAMETOOLONG …
|
|
229
|
+
* listen" on macOS and "Failed to create bridge sockets after 5 attempts"
|
|
230
|
+
* on Linux (its retry loop swallows the code), neither naming the
|
|
231
|
+
* directory (2026-09-16). Checked up front, with room for the sequence.
|
|
232
|
+
*/
|
|
233
|
+
export function socketPathRefusal(tmpdir = os.tmpdir()): string | undefined {
|
|
234
|
+
const sample = path.join(tmpdir, `srt-mux-${process.pid}-zzz.sock`)
|
|
235
|
+
const limit = process.platform === 'darwin' ? 103 : 107
|
|
236
|
+
const length = Buffer.byteLength(sample)
|
|
237
|
+
if (length <= limit) return undefined
|
|
238
|
+
return `the sandbox runtime listens on a unix socket under the temp directory, and ${sample} is ${length} bytes where the OS allows ${limit} — point TMPDIR at a shorter path`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A throw from the runtime itself, in the user's terms. Its own temp files
|
|
243
|
+
* (the observer directory, the bridge sockets, the strace log) live under
|
|
244
|
+
* `os.tmpdir()`, so a temp directory that is missing or not writable
|
|
245
|
+
* failed a sandboxed task with a path and no knob ("EACCES … mkdtemp
|
|
246
|
+
* '/tmp/probe-ro/srt-obs-…'", a minimal image, 2026-09-16).
|
|
247
|
+
*/
|
|
248
|
+
export function thrownReason(err: unknown, what = ''): string {
|
|
249
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
250
|
+
if (isTmpdirRefusal(err)) {
|
|
251
|
+
return `the sandbox runtime needs a writable temp directory and ${os.tmpdir()} is not one (${message}) — ${TMPDIR_HINT}`
|
|
252
|
+
}
|
|
253
|
+
return what === '' ? message : `${what}: ${message}`
|
|
254
|
+
}
|
|
255
|
+
|
|
116
256
|
/**
|
|
117
257
|
* Default `ignoreViolations` we install in the SRT global config.
|
|
118
258
|
* These are well-known macOS shell-startup probes that SRT's own
|
|
@@ -123,7 +263,7 @@ async function tryBwrapOnce(): Promise<SandboxAvailability> {
|
|
|
123
263
|
*
|
|
124
264
|
* Format mirrors SRT's: `'*'` is a wildcard pattern; entries in the
|
|
125
265
|
* array are substring-matched against the violation details line.
|
|
126
|
-
* Users can ADD to this via per-task `sandbox.
|
|
266
|
+
* Users can ADD to this via per-task `sandbox.ignore`;
|
|
127
267
|
* those are applied at violation read-back time, on top of the
|
|
128
268
|
* defaults installed here.
|
|
129
269
|
*/
|
|
@@ -133,6 +273,100 @@ const DEFAULT_IGNORE_VIOLATIONS: Record<string, string[]> = {
|
|
|
133
273
|
],
|
|
134
274
|
}
|
|
135
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The temp directory SRT hands every sandboxed task. It overrides `TMPDIR`
|
|
278
|
+
* so temp-file writers land somewhere its filesystem policy already allows,
|
|
279
|
+
* and it deliberately does NOT create the directory — its own comment says
|
|
280
|
+
* "/tmp/claude may not exist". Nobody else does either, so on any machine
|
|
281
|
+
* that is not Claude Code's own, every sandboxed task that writes a temp
|
|
282
|
+
* file died with ENOENT: this repo's `bun build --compile` under
|
|
283
|
+
* a sandboxed `bun build --compile` reported only `error: An unknown error occurred
|
|
284
|
+
* (Unexpected)` (2026-09-04). Resolution mirrors SRT's exactly.
|
|
285
|
+
*/
|
|
286
|
+
function sandboxTmpdir(): string {
|
|
287
|
+
// `process.env`, not `Bun.env`: a caller that REPLACES the env object
|
|
288
|
+
// (two tests here do, and an embedder may) leaves `Bun.env` pointing at
|
|
289
|
+
// the original, so a late assignment would be invisible.
|
|
290
|
+
const named = process.env['CLAUDE_CODE_TMPDIR'] ?? process.env['CLAUDE_TMPDIR']
|
|
291
|
+
return named !== undefined && named !== '' ? named : '/tmp/claude'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The temp files of tasks still running: strace logs and port-bridge
|
|
296
|
+
* sockets. A signal exit is `process.exit` (orchestrator/signals.ts),
|
|
297
|
+
* which never reaches a task's own unlink, so a Ctrl-C left one log per
|
|
298
|
+
* sandboxed task in the temp dir (item 848); the process's `exit` event
|
|
299
|
+
* removes what is still listed.
|
|
300
|
+
*/
|
|
301
|
+
const liveTempFiles = new Set<string>()
|
|
302
|
+
let tempExitHooked = false
|
|
303
|
+
function unlinkOnExit(file: string): void {
|
|
304
|
+
liveTempFiles.add(file)
|
|
305
|
+
if (tempExitHooked) return
|
|
306
|
+
tempExitHooked = true
|
|
307
|
+
process.on('exit', () => {
|
|
308
|
+
for (const f of liveTempFiles) {
|
|
309
|
+
try {
|
|
310
|
+
unlinkSync(f)
|
|
311
|
+
} catch {
|
|
312
|
+
// never written, or gone
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* A task's own temp directory, `TMPDIR` inside its sandbox. SRT points
|
|
320
|
+
* every sandboxed task at ONE host directory (`sandboxTmpdir`), bound
|
|
321
|
+
* read-write and kept across runs, so a file one task wrote there was
|
|
322
|
+
* another's undeclared input: a cached reader replayed the first value it
|
|
323
|
+
* saw after the writer changed it (item 965). Created for the task, removed
|
|
324
|
+
* with its bridges at its end (and at exit, as the logs are); a `kill -9`
|
|
325
|
+
* leaves it. No sweep by the owner's pid: a nested vx sees another pid
|
|
326
|
+
* namespace, where the outer vx's pid reads as dead, and a sweep there
|
|
327
|
+
* removed the outer task's own TMPDIR mid-run (the gate, item 965). The shared
|
|
328
|
+
* directory itself stays writable, since SRT's policy grants it: a command
|
|
329
|
+
* that names it outright still reaches it.
|
|
330
|
+
*/
|
|
331
|
+
function taskTmpdir(tag: string): string {
|
|
332
|
+
return path.join(sandboxTmpdir(), `vx-task-${process.pid}-${tag}`)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const liveTaskTmpdirs = new Set<string>()
|
|
336
|
+
let tmpdirExitHooked = false
|
|
337
|
+
function trackTaskTmpdir(dir: string): void {
|
|
338
|
+
liveTaskTmpdirs.add(dir)
|
|
339
|
+
if (tmpdirExitHooked) return
|
|
340
|
+
tmpdirExitHooked = true
|
|
341
|
+
process.on('exit', () => {
|
|
342
|
+
for (const d of liveTaskTmpdirs) rmSync(d, { recursive: true, force: true })
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Whether SRT is up in this process — set by `initSandbox`, cleared by `resetSandbox`. */
|
|
347
|
+
let srtUp = false
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* SRT listens on `<tmpdir>/srt-mux-<pid>-<seq>.sock`, seq from 0, and a
|
|
351
|
+
* killed run leaves those files behind; when the kernel hands the pid to a
|
|
352
|
+
* later vx, its first listen meets EADDRINUSE and every sandboxed task
|
|
353
|
+
* fails (255 of them after a stopped gate, 2026-09-16). A file carrying
|
|
354
|
+
* OUR pid before SRT is up can only be a dead process's, so unlink the
|
|
355
|
+
* contiguous run from seq 0 — a stat per file, no directory scan (a 7,000
|
|
356
|
+
* entry /tmp reads in 9 ms; this is microseconds).
|
|
357
|
+
*/
|
|
358
|
+
async function unlinkStaleMuxSockets(): Promise<void> {
|
|
359
|
+
for (let seq = 0; seq < 64; seq++) {
|
|
360
|
+
const stale = path.join(os.tmpdir(), `srt-mux-${process.pid}-${seq.toString(36)}.sock`)
|
|
361
|
+
try {
|
|
362
|
+
await unlink(stale)
|
|
363
|
+
} catch (err) {
|
|
364
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return
|
|
365
|
+
throw err
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
136
370
|
/**
|
|
137
371
|
* One-time SRT initialization per orchestrator run. Starts the proxy
|
|
138
372
|
* servers + (on macOS) the violation log monitor. Safe to call repeatedly
|
|
@@ -141,26 +375,95 @@ const DEFAULT_IGNORE_VIOLATIONS: Record<string, string[]> = {
|
|
|
141
375
|
* The base config sets network to "block everything" (empty allowedDomains).
|
|
142
376
|
* Per-task wrapping passes a customConfig that re-enables network for
|
|
143
377
|
* tasks with `sandbox.network: true`.
|
|
378
|
+
*
|
|
379
|
+
* @param opts.allowedDomains every domain any sandboxed task in this run
|
|
380
|
+
* declared. SRT's filtering proxy is per-RUN and reads its allowlist from
|
|
381
|
+
* this call, never from the per-task config, so the union is the only
|
|
382
|
+
* place a domain list can take effect. Per-task precision survives where
|
|
383
|
+
* it matters: a task that declared none is never handed the proxy port.
|
|
144
384
|
*/
|
|
145
|
-
export async function initSandbox(
|
|
385
|
+
export async function initSandbox(opts?: {
|
|
386
|
+
allowedDomains?: readonly string[]
|
|
387
|
+
/**
|
|
388
|
+
* Lift SRT's seccomp block on `socket(AF_UNIX)` for every sandboxed
|
|
389
|
+
* task of the run (Linux; per-run like the proxy allowlist, since SRT
|
|
390
|
+
* reads it at `initialize()` only). Armed when any task declares
|
|
391
|
+
* `unixSockets` or a `localBinding` port list — the port bridge is a
|
|
392
|
+
* unix socket the task's side has to create.
|
|
393
|
+
*/
|
|
394
|
+
allowAllUnixSockets?: boolean
|
|
395
|
+
}): Promise<void> {
|
|
396
|
+
// A reset a server's exit started unawaited: a watch cycle stops its
|
|
397
|
+
// server and starts its run at once, and an init under that reset
|
|
398
|
+
// found SRT up, hot-reloaded it, and had it torn down after (item 884).
|
|
399
|
+
await resetting
|
|
400
|
+
// Before SRT starts, so the very first task already has one.
|
|
401
|
+
await mkdir(sandboxTmpdir(), { recursive: true })
|
|
146
402
|
const { SandboxManager } = await loadSrt()
|
|
147
|
-
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
403
|
+
const config: Parameters<typeof SandboxManager.initialize>[0] = {
|
|
404
|
+
network: {
|
|
405
|
+
allowedDomains: [...(opts?.allowedDomains ?? [])],
|
|
406
|
+
deniedDomains: [],
|
|
407
|
+
...(opts?.allowAllUnixSockets === true ? { allowAllUnixSockets: true } : {}),
|
|
152
408
|
},
|
|
409
|
+
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
|
|
410
|
+
ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
|
|
411
|
+
}
|
|
412
|
+
if (!srtUp) await unlinkStaleMuxSockets()
|
|
413
|
+
await SandboxManager.initialize(
|
|
414
|
+
config,
|
|
153
415
|
undefined,
|
|
154
416
|
// enableLogMonitor — macOS-only; populates the SandboxViolationStore.
|
|
155
417
|
true,
|
|
156
418
|
)
|
|
419
|
+
srtUp = true
|
|
420
|
+
// `initialize()` returns early once SRT is up, and on Linux the
|
|
421
|
+
// availability probe brought it up with an EMPTY config before the run's
|
|
422
|
+
// own call — so the run's allowlist and unix-socket allowance never
|
|
423
|
+
// reached it (found 2026-09-10 by the port bridge: the task's side died
|
|
424
|
+
// on `socket(AF_UNIX)` with the flag set). `updateConfig` is SRT's hot
|
|
425
|
+
// reload of exactly these fields; the proxy and the wrapper read them
|
|
426
|
+
// through getters, so the run's config is what every task sees.
|
|
427
|
+
SandboxManager.updateConfig(config)
|
|
157
428
|
}
|
|
158
429
|
|
|
430
|
+
/**
|
|
431
|
+
* The tags of sandboxed servers still running (`wrapSandboxedCommand`'s
|
|
432
|
+
* `server`). A run resets the sandbox at its end, and a foreground
|
|
433
|
+
* `vx run dev` or a `vx watch` holds its servers past that: the reset
|
|
434
|
+
* released their host bridges and SRT's proxies, and a server's port went
|
|
435
|
+
* dark ~40 ms after the summary (item 882). While one runs, the reset
|
|
436
|
+
* releases only the bridges no server owns and waits for the last
|
|
437
|
+
* server's `releaseBridges`.
|
|
438
|
+
*/
|
|
439
|
+
const liveServers = new Set<string>()
|
|
440
|
+
let resetDeferred = false
|
|
441
|
+
/** The reset in flight, settled either way; `initSandbox` waits for it. */
|
|
442
|
+
let resetting: Promise<void> | undefined
|
|
443
|
+
|
|
159
444
|
export async function resetSandbox(): Promise<void> {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
445
|
+
for (const tag of [...hostBridges.keys()]) if (!liveServers.has(tag)) releaseBridges(tag)
|
|
446
|
+
if (liveServers.size > 0) {
|
|
447
|
+
resetDeferred = true
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
resetDeferred = false
|
|
451
|
+
const reset = (async () => {
|
|
452
|
+
const { SandboxManager } = await loadSrt()
|
|
453
|
+
await SandboxManager.reset()
|
|
454
|
+
srtUp = false
|
|
455
|
+
availabilityCache.clear()
|
|
456
|
+
straceAvailableCache = undefined
|
|
457
|
+
})()
|
|
458
|
+
const settled = reset.then(
|
|
459
|
+
() => {},
|
|
460
|
+
() => {},
|
|
461
|
+
)
|
|
462
|
+
resetting = settled
|
|
463
|
+
void settled.then(() => {
|
|
464
|
+
if (resetting === settled) resetting = undefined
|
|
465
|
+
})
|
|
466
|
+
await reset
|
|
164
467
|
}
|
|
165
468
|
|
|
166
469
|
export interface SandboxedRunArgs {
|
|
@@ -176,25 +479,27 @@ export interface SandboxedRunArgs {
|
|
|
176
479
|
timeoutMs?: number
|
|
177
480
|
/** See `CaptureConfig` — which streams are retained on the result. */
|
|
178
481
|
capture?: CaptureConfig
|
|
179
|
-
/** See `ExecuteSandbox.settleOnCleanExit` — pay the macOS settle window
|
|
180
|
-
* even on a 0-exit, because an empty store will be read as proof. */
|
|
181
|
-
settleOnCleanExit?: boolean
|
|
182
482
|
/**
|
|
183
|
-
* Baseline reads — paths the sandbox
|
|
184
|
-
*
|
|
483
|
+
* Baseline reads — paths the sandbox allows beside the task's own
|
|
484
|
+
* `allow.read`. Core passes the project's and the root's
|
|
485
|
+
* `node_modules` and the workspace dependencies linked from them
|
|
486
|
+
* (sandbox-request.ts); nothing is derived from `cache`.
|
|
185
487
|
*/
|
|
186
488
|
baseAllowRead: readonly string[]
|
|
187
|
-
/**
|
|
188
|
-
* Baseline writes — paths the sandbox unconditionally allows for
|
|
189
|
-
* writes. Built from the static prefix of `cache.outputs.files`.
|
|
190
|
-
*/
|
|
191
|
-
baseAllowWrite: readonly string[]
|
|
192
489
|
/**
|
|
193
490
|
* Read-deny anchor. Combined with allowRead it produces the effective
|
|
194
491
|
* deny set: anything under one of these paths that isn't in allowRead
|
|
195
492
|
* is forbidden. Pass `[workspaceRoot]` to enforce project boundaries.
|
|
196
493
|
*/
|
|
197
494
|
baseDenyRead: readonly string[]
|
|
495
|
+
/**
|
|
496
|
+
* Only denials on a path under this directory are reported. A denial
|
|
497
|
+
* outside it is still ENFORCED — the task cannot leave its project — but
|
|
498
|
+
* that is the wall doing its job, not a finding to fail a run over.
|
|
499
|
+
*/
|
|
500
|
+
readonly reportWithin: string
|
|
501
|
+
/** Canonical directories whose denials are reported too (`ExecuteSandbox.reportLinked`). */
|
|
502
|
+
readonly reportLinked: readonly string[]
|
|
198
503
|
/**
|
|
199
504
|
* User-declared sandbox block (after path-resolution). Path lists are
|
|
200
505
|
* unioned with the baselines; bool/object fields fall through to SRT.
|
|
@@ -208,33 +513,25 @@ export interface SandboxedRunArgs {
|
|
|
208
513
|
* but every string in a path list is guaranteed absolute.
|
|
209
514
|
*/
|
|
210
515
|
export interface ResolvedSandboxConfig {
|
|
516
|
+
/** Readable path prefixes, absolute. */
|
|
211
517
|
allowRead: readonly string[]
|
|
518
|
+
/** Writable path prefixes, absolute. */
|
|
212
519
|
allowWrite: readonly string[]
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
* boundary check only whitelists `/tmp/<child>` forms). Without this,
|
|
229
|
-
* `allowWrite: ['/tmp']` silently never matches on macOS.
|
|
230
|
-
*/
|
|
231
|
-
function toRealPath(p: string): string {
|
|
232
|
-
try {
|
|
233
|
-
return realpathSync(p)
|
|
234
|
-
} catch {
|
|
235
|
-
const parent = path.dirname(p)
|
|
236
|
-
if (parent === p) return p
|
|
237
|
-
return path.join(toRealPath(parent), path.basename(p))
|
|
520
|
+
network?: true | readonly string[]
|
|
521
|
+
denyNetwork?: readonly string[]
|
|
522
|
+
systemInfo?: readonly string[]
|
|
523
|
+
unixSockets?: true | readonly string[]
|
|
524
|
+
localBinding?: boolean | readonly number[]
|
|
525
|
+
machLookup?: readonly string[]
|
|
526
|
+
pty?: boolean
|
|
527
|
+
gitConfig?: boolean
|
|
528
|
+
weakerWhenNested?: boolean
|
|
529
|
+
weakerNetworkIsolation?: boolean
|
|
530
|
+
ignore?: {
|
|
531
|
+
read?: readonly string[]
|
|
532
|
+
write?: readonly string[]
|
|
533
|
+
systemInfo?: readonly string[]
|
|
534
|
+
network?: readonly string[]
|
|
238
535
|
}
|
|
239
536
|
}
|
|
240
537
|
|
|
@@ -242,7 +539,7 @@ function toRealPath(p: string): string {
|
|
|
242
539
|
* Canonical form of every path the sandbox policy is expressed in.
|
|
243
540
|
*
|
|
244
541
|
* `resolveSandboxConfig` already canonicalizes the USER's paths; the
|
|
245
|
-
* orchestrator-supplied baselines (
|
|
542
|
+
* orchestrator-supplied baselines (the `node_modules` reads and the
|
|
246
543
|
* workspace-root deny anchor) arrived raw, so a workspace reached through a
|
|
247
544
|
* symlink expressed HALF its policy in real paths and half in link paths.
|
|
248
545
|
* bwrap then died mounting the link path inside its new root
|
|
@@ -250,20 +547,63 @@ function toRealPath(p: string): string {
|
|
|
250
547
|
* whatever the config, with an error naming an internal path the user has no
|
|
251
548
|
* way to act on. Canonicalizing here is what makes the two halves agree.
|
|
252
549
|
*/
|
|
253
|
-
|
|
550
|
+
interface CanonicalBaselines {
|
|
254
551
|
allowRead: string[]
|
|
255
|
-
allowWrite: string[]
|
|
256
552
|
denyRead: string[]
|
|
257
553
|
cwd: string
|
|
258
|
-
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function canonicalBaselines(
|
|
557
|
+
args: Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseDenyRead' | 'cwd'>,
|
|
558
|
+
): CanonicalBaselines {
|
|
259
559
|
return {
|
|
260
560
|
allowRead: args.baseAllowRead.map(toRealPath),
|
|
261
|
-
allowWrite: args.baseAllowWrite.map(toRealPath),
|
|
262
561
|
denyRead: args.baseDenyRead.map(toRealPath),
|
|
263
562
|
cwd: toRealPath(args.cwd),
|
|
264
563
|
}
|
|
265
564
|
}
|
|
266
565
|
|
|
566
|
+
/**
|
|
567
|
+
* A write grant that names a path in the project must BIND one there. The
|
|
568
|
+
* grant was realpath'd, so `out.txt -> ../b/src/x` (committed, or planted
|
|
569
|
+
* by the task's own previous run) bound project b's directory writable,
|
|
570
|
+
* and the task wrote into a sibling with exit 0 (item 1003). A read grant
|
|
571
|
+
* may resolve out through a link (`node_modules` into the store) and is
|
|
572
|
+
* not judged here; a grant spelled outside the project is the user's own.
|
|
573
|
+
*/
|
|
574
|
+
function assertWriteStaysHome(grant: string, real: string, projectDir: string): void {
|
|
575
|
+
if (grant.startsWith('~') || path.isAbsolute(grant)) return
|
|
576
|
+
const lexical = path.resolve(projectDir, grant)
|
|
577
|
+
const inside = (p: string, dir: string): boolean =>
|
|
578
|
+
p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
|
|
579
|
+
if (!inside(lexical, projectDir)) return
|
|
580
|
+
const home = toRealPath(projectDir)
|
|
581
|
+
// `toRealPath` stops at a DANGLING link and keeps its own path, which is
|
|
582
|
+
// inside; the link's target is where a write through it would land.
|
|
583
|
+
const target = throughLinks(real)
|
|
584
|
+
if (inside(real, home) && inside(target, home)) return
|
|
585
|
+
throw new UserError(
|
|
586
|
+
`exec.sandbox.allow.write: "${grant}" resolves through a symlink to ${inside(real, home) ? target : real}, outside the ` +
|
|
587
|
+
`project — a write grant binds the path it names in the project, and vx does not follow a ` +
|
|
588
|
+
`link out of it. Remove the link, or grant the target by its own path.`,
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Where `p` lands through every link on it, a dangling last one included. */
|
|
593
|
+
function throughLinks(p: string, hops = 0): string {
|
|
594
|
+
if (hops > 40) return p
|
|
595
|
+
try {
|
|
596
|
+
return realpathSync(p)
|
|
597
|
+
} catch {
|
|
598
|
+
try {
|
|
599
|
+
return throughLinks(path.resolve(path.dirname(p), readlinkSync(p)), hops + 1)
|
|
600
|
+
} catch {
|
|
601
|
+
const parent = path.dirname(p)
|
|
602
|
+
return parent === p ? p : path.join(throughLinks(parent, hops + 1), path.basename(p))
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
267
607
|
/**
|
|
268
608
|
* Convert a user-facing `SandboxConfig` (paths may be relative / tilde)
|
|
269
609
|
* into a `ResolvedSandboxConfig` (all paths absolute + canonical) for a
|
|
@@ -279,20 +619,42 @@ export function resolveSandboxConfig(
|
|
|
279
619
|
if (path.isAbsolute(p)) return toRealPath(p)
|
|
280
620
|
return toRealPath(path.resolve(projectDir, p))
|
|
281
621
|
}
|
|
622
|
+
const a = cfg.allow ?? {}
|
|
282
623
|
const r: ResolvedSandboxConfig = {
|
|
283
|
-
allowRead: (
|
|
284
|
-
allowWrite: (
|
|
624
|
+
allowRead: expandGrants((a.read ?? []).map(resolve), 'read'),
|
|
625
|
+
allowWrite: expandGrants(
|
|
626
|
+
(a.write ?? []).map((p) => {
|
|
627
|
+
const real = resolve(p)
|
|
628
|
+
assertWriteStaysHome(p, real, projectDir)
|
|
629
|
+
return real
|
|
630
|
+
}),
|
|
631
|
+
'write',
|
|
632
|
+
),
|
|
285
633
|
}
|
|
286
|
-
if (
|
|
287
|
-
if (cfg.network !== undefined) r.
|
|
288
|
-
if (
|
|
289
|
-
if (
|
|
290
|
-
|
|
634
|
+
if (a.network !== undefined) r.network = a.network
|
|
635
|
+
if (cfg.deny?.network !== undefined) r.denyNetwork = cfg.deny.network
|
|
636
|
+
if (a.systemInfo !== undefined) r.systemInfo = a.systemInfo
|
|
637
|
+
if (a.unixSockets !== undefined) r.unixSockets = a.unixSockets
|
|
638
|
+
if (a.localBinding !== undefined) r.localBinding = a.localBinding
|
|
639
|
+
if (a.machLookup !== undefined) r.machLookup = a.machLookup
|
|
640
|
+
if (a.pty !== undefined) r.pty = a.pty
|
|
641
|
+
if (a.gitConfig !== undefined) r.gitConfig = a.gitConfig
|
|
642
|
+
if (cfg.weakerWhenNested !== undefined) r.weakerWhenNested = cfg.weakerWhenNested
|
|
643
|
+
if (cfg.weakerNetworkIsolation !== undefined) {
|
|
644
|
+
r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
|
|
291
645
|
}
|
|
292
|
-
if (cfg.
|
|
293
|
-
|
|
646
|
+
if (cfg.ignore !== undefined) {
|
|
647
|
+
// Relative patterns anchor at the project dir; absolute and `~` ones
|
|
648
|
+
// are taken as written. NOT realpath'd — a pattern is not a path.
|
|
649
|
+
const anchor = (pat: string): string =>
|
|
650
|
+
pat.startsWith('~') || path.isAbsolute(pat) ? pat : path.join(projectDir, pat)
|
|
651
|
+
r.ignore = {
|
|
652
|
+
...(cfg.ignore.read ? { read: cfg.ignore.read.map(anchor) } : {}),
|
|
653
|
+
...(cfg.ignore.write ? { write: cfg.ignore.write.map(anchor) } : {}),
|
|
654
|
+
...(cfg.ignore.systemInfo ? { systemInfo: [...cfg.ignore.systemInfo] } : {}),
|
|
655
|
+
...(cfg.ignore.network ? { network: [...(cfg.ignore.network as string[])] } : {}),
|
|
656
|
+
}
|
|
294
657
|
}
|
|
295
|
-
if (cfg.ignoreViolations !== undefined) r.ignoreViolations = cfg.ignoreViolations
|
|
296
658
|
return r
|
|
297
659
|
}
|
|
298
660
|
|
|
@@ -300,6 +662,17 @@ export interface SandboxViolation {
|
|
|
300
662
|
/** Raw log line from SRT. Format differs between macOS / Linux. */
|
|
301
663
|
line: string
|
|
302
664
|
timestamp: Date
|
|
665
|
+
/**
|
|
666
|
+
* What the denial named, and which `ignore` lists could silence it —
|
|
667
|
+
* filled in by whichever platform produced the record. The filters read
|
|
668
|
+
* these instead of re-parsing a line whose shape depends on the OS: a
|
|
669
|
+
* seatbelt `deny(1) file-read-data /x` and a strace
|
|
670
|
+
* `openat(../x) = -1 ENOENT [/x]` say the same thing.
|
|
671
|
+
*/
|
|
672
|
+
target?: string
|
|
673
|
+
/** Absolute, when `target` is a path at all. */
|
|
674
|
+
path?: string
|
|
675
|
+
ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
|
|
303
676
|
}
|
|
304
677
|
|
|
305
678
|
export interface SandboxedRunResult extends RunResult {
|
|
@@ -308,29 +681,306 @@ export interface SandboxedRunResult extends RunResult {
|
|
|
308
681
|
}
|
|
309
682
|
|
|
310
683
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
684
|
+
* The shell that gives a sandboxed task its OWN `JAVA_TOOL_OPTIONS`. When
|
|
685
|
+
* SRT restricts the network it sets the variable to its proxy agent's flag
|
|
686
|
+
* composed with VX'S value (`process.env`, read inside the wrap), over the
|
|
687
|
+
* one vx gave the task: a host value no layer passes reached the task, out
|
|
688
|
+
* of its key, and a changed host value replayed the old output; a task's
|
|
689
|
+
* own `define` never arrived (item 995). Where SRT left the variable alone
|
|
690
|
+
* it already holds the task's value and this is a no-op; elsewhere the
|
|
691
|
+
* host's value is cut out and the task's appended to what SRT added.
|
|
319
692
|
*/
|
|
320
|
-
|
|
693
|
+
function javaToolOptionsFix(host: string | undefined, task: string | undefined): string {
|
|
694
|
+
// The same value both sides (a task that passes the host's through, or
|
|
695
|
+
// neither has one): SRT's composition is already the task's own, and a
|
|
696
|
+
// prefix quoting the value twice would only lengthen every command
|
|
697
|
+
// (item 1007).
|
|
698
|
+
if ((host ?? '') === (task ?? '')) return ''
|
|
699
|
+
const cut = host
|
|
700
|
+
? `__vx_h=${shellQuote(host)}; case "$__vx_j" in *"$__vx_h"*) __vx_j="\${__vx_j%%"$__vx_h"*}\${__vx_j#*"$__vx_h"}";; esac; `
|
|
701
|
+
: ''
|
|
702
|
+
const add = task ? `__vx_j="$__vx_j $__vx_t"; ` : ''
|
|
703
|
+
return (
|
|
704
|
+
`__vx_t=${shellQuote(task ?? '')}; __vx_j=\${JAVA_TOOL_OPTIONS-}; ` +
|
|
705
|
+
`if [ "$__vx_j" != "$__vx_t" ]; then ${cut}${add}` +
|
|
706
|
+
`case "$__vx_j" in *[![:space:]]*) export JAVA_TOOL_OPTIONS="$__vx_j";; *) unset JAVA_TOOL_OPTIONS;; esac; fi; ` +
|
|
707
|
+
`unset __vx_j __vx_h __vx_t; `
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The sandboxed form of a command: SRT's wrapper over the tagged command,
|
|
713
|
+
* with vx's own seatbelt rules appended on macOS. This is the ENFORCEMENT
|
|
714
|
+
* half of `runSandboxed`, shared with persistent tasks — a dev server is
|
|
715
|
+
* spawned through it and never exits while the run watches, so it gets
|
|
716
|
+
* the same walls and no violation report (reporting reads the trace after
|
|
717
|
+
* exit). Returns the wrapped command and the tag the store keys by.
|
|
718
|
+
*/
|
|
719
|
+
export async function wrapSandboxedCommand(
|
|
720
|
+
args: Pick<SandboxedRunArgs, 'command' | 'cwd' | 'forwardArgs' | 'config' | 'env'> &
|
|
721
|
+
Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseDenyRead'> & {
|
|
722
|
+
/** A persistent server: the sandbox outlives a run's reset until `releaseBridges(tag)`. */
|
|
723
|
+
server?: boolean
|
|
724
|
+
},
|
|
725
|
+
): Promise<{
|
|
726
|
+
wrapped: string
|
|
727
|
+
tag: string
|
|
728
|
+
taggedCommand: string
|
|
729
|
+
baselines: CanonicalBaselines
|
|
730
|
+
/** The command reads its polite signals off fd 3: spawn it with one and `signalThrough` it. */
|
|
731
|
+
forwardsSignals: boolean
|
|
732
|
+
}> {
|
|
321
733
|
const { SandboxManager } = await loadSrt()
|
|
322
|
-
const start = Date.now()
|
|
323
734
|
const userCommand =
|
|
324
735
|
args.forwardArgs && args.forwardArgs.length > 0
|
|
325
736
|
? args.command + ' ' + args.forwardArgs.map(shellQuote).join(' ')
|
|
326
737
|
: args.command
|
|
327
738
|
|
|
328
739
|
const tag = xxh3hex(`${args.cwd}|${userCommand}|${process.hrtime.bigint()}`).slice(0, 16)
|
|
329
|
-
const
|
|
740
|
+
const tmp = taskTmpdir(tag)
|
|
741
|
+
mkdirSync(tmp, { recursive: true })
|
|
742
|
+
trackTaskTmpdir(tmp)
|
|
743
|
+
// After the tag: SRT keys violations by the command's first 100 chars.
|
|
744
|
+
const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
|
|
745
|
+
process.env['JAVA_TOOL_OPTIONS'],
|
|
746
|
+
args.env['JAVA_TOOL_OPTIONS'],
|
|
747
|
+
)}${userCommand}`
|
|
748
|
+
const taggedCommand = `: 'vx-${tag}'; ${inTmp}`
|
|
330
749
|
|
|
331
750
|
const baselines = canonicalBaselines(args)
|
|
332
751
|
const customConfig = buildCustomConfig(args, baselines)
|
|
333
|
-
|
|
752
|
+
// Linux: the ports a list grants are bridged out of the task's network
|
|
753
|
+
// namespace. The task's side of each bridge is a socat in front of the
|
|
754
|
+
// user command, so it goes INTO the sandboxed command; the host side is
|
|
755
|
+
// spawned here and released when the task's process ends.
|
|
756
|
+
const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
|
|
757
|
+
const grouped =
|
|
758
|
+
process.platform === 'linux'
|
|
759
|
+
? ownGroupCommand(tag, inTmp)
|
|
760
|
+
: { command: taggedCommand, forwards: false }
|
|
761
|
+
const inner =
|
|
762
|
+
ports.length > 0 ? `${portBridgeInner(ports, tag)} ${grouped.command}` : grouped.command
|
|
763
|
+
let wrapped = await SandboxManager.wrapWithSandbox(inner, undefined, customConfig)
|
|
764
|
+
if (process.platform === 'darwin') {
|
|
765
|
+
const rules = macProfileRules(args.config)
|
|
766
|
+
if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
|
|
767
|
+
}
|
|
768
|
+
// Linux: the shell execs bwrap, so bwrap is the spawn itself and its
|
|
769
|
+
// `--die-with-parent` is keyed to vx. Behind a shell that waited on it,
|
|
770
|
+
// a `kill -9` of vx left the shell alive, bwrap never heard, and a
|
|
771
|
+
// sandboxed server and all it forked outlived vx (turborepo#9666). Now
|
|
772
|
+
// the namespace goes with vx, a `setsid` daemon inside included. A
|
|
773
|
+
// strace-traced spawn keeps strace as bwrap's parent: that residual is
|
|
774
|
+
// a one-shot task's, and a persistent one is never traced.
|
|
775
|
+
if (process.platform === 'linux' && wrapped.startsWith('bwrap ')) wrapped = `exec ${wrapped}`
|
|
776
|
+
if (args.server === true) liveServers.add(tag)
|
|
777
|
+
if (ports.length > 0) spawnHostBridges(ports, tag)
|
|
778
|
+
return { wrapped, tag, taggedCommand, baselines, forwardsSignals: grouped.forwards }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Linux: the user command in a session, and so a process group, of its
|
|
783
|
+
* own. bwrap's `--new-session` puts the runtime's shells (the proxy
|
|
784
|
+
* bridges' script, the seccomp step's) in ONE group with the command, and
|
|
785
|
+
* `kill 0` reaches a group's members across the nested pid namespace: a
|
|
786
|
+
* command that signalled its own group ended the runtime's shell, bwrap
|
|
787
|
+
* exited 143 and the namespace's teardown SIGKILLed the rest mid-trap
|
|
788
|
+
* (item 751). The shell `exec`s `setsid`, which is no group leader here,
|
|
789
|
+
* so it calls setsid() and execs without a fork: the command keeps the
|
|
790
|
+
* shell's pid, and its status, a signal death included, is the one the
|
|
791
|
+
* runtime waits on.
|
|
792
|
+
*
|
|
793
|
+
* The same group is where a cancellation lands. vx's group signal reaches
|
|
794
|
+
* bwrap's monitor, which dies of it, and the namespace goes with SIGKILL,
|
|
795
|
+
* so vx sends a polite signal's name down fd 3 instead (`signalThrough`)
|
|
796
|
+
* and a watcher forked before the `exec` signals the group, `$$`, with it
|
|
797
|
+
* (item 752). The command runs in the foreground because an `&` command
|
|
798
|
+
* starts with SIGINT ignored, and a shell cannot trap what it inherited
|
|
799
|
+
* ignored. The command does not get fd 3; a caller that passed none
|
|
800
|
+
* forwards nothing, silently.
|
|
801
|
+
*
|
|
802
|
+
* Tools resolve on vx's own PATH; without `setsid` the command keeps the
|
|
803
|
+
* shared group, as before, and `forwards` says the channel is not there.
|
|
804
|
+
*/
|
|
805
|
+
function ownGroupCommand(tag: string, userCommand: string): { command: string; forwards: boolean } {
|
|
806
|
+
// `sh`, as an unsandboxed task runs (`runner.ts`): the command ran under
|
|
807
|
+
// bash here, so `[[ … ]]`, brace expansion and `echo 'a\tb'` read one
|
|
808
|
+
// way sandboxed and another unsandboxed or on a remote executor, where
|
|
809
|
+
// `/bin/sh` is dash (item 964).
|
|
810
|
+
let sh: string
|
|
811
|
+
try {
|
|
812
|
+
sh = executablePath('sh')
|
|
813
|
+
} catch {
|
|
814
|
+
return { command: `: 'vx-${tag}'; ${userCommand}`, forwards: false }
|
|
815
|
+
}
|
|
816
|
+
let setsid: string
|
|
817
|
+
try {
|
|
818
|
+
setsid = executablePath('setsid')
|
|
819
|
+
} catch {
|
|
820
|
+
return {
|
|
821
|
+
command: `: 'vx-${tag}'; exec ${shellQuote(sh)} -c ${shellQuote(userCommand)}`,
|
|
822
|
+
forwards: false,
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const watch = `{ IFS= read -r s && kill -s "$s" -- "-$$"; } 2>/dev/null <&3 3<&- &`
|
|
826
|
+
const run = `exec ${shellQuote(setsid)} ${shellQuote(sh)} -c ${shellQuote(userCommand)} 3<&-`
|
|
827
|
+
return { command: `: 'vx-${tag}'; ${watch} ${run}`, forwards: true }
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** The ports a list grants, deduped; `true` bridges nothing (the host sees no port on Linux). */
|
|
831
|
+
export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): number[] {
|
|
832
|
+
return Array.isArray(c.localBinding) ? [...new Set(c.localBinding)] : []
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
|
|
836
|
+
export function portBridgeSocket(tag: string, port: number): string {
|
|
837
|
+
return path.join(sandboxTmpdir(), `vx-port-${tag}-${port}.sock`)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* The task's side of the bridge, in front of the user command inside the
|
|
842
|
+
* sandbox: one socat per port, listening on the unix socket and relaying
|
|
843
|
+
* into the namespace's loopback. Backgrounded and reaped with the shell,
|
|
844
|
+
* exactly as SRT starts its own proxy bridges. `unlink-early` clears a
|
|
845
|
+
* socket a killed task left behind; `>/dev/null` keeps its chatter out
|
|
846
|
+
* of the task's frame.
|
|
847
|
+
*/
|
|
848
|
+
export function portBridgeInner(ports: readonly number[], tag: string): string {
|
|
849
|
+
const cmds = ports.map(
|
|
850
|
+
(p) =>
|
|
851
|
+
`socat UNIX-LISTEN:${shellQuote(portBridgeSocket(tag, p))},fork,unlink-early TCP:127.0.0.1:${p} >/dev/null 2>&1 &`,
|
|
852
|
+
)
|
|
853
|
+
return `${cmds.join(' ')} trap 'kill $(jobs -p) 2>/dev/null' EXIT;`
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* The host's side: one socat per port, listening on the host's loopback
|
|
858
|
+
* and connecting into the unix socket per client — with a retry, since a
|
|
859
|
+
* client can arrive before the task's side has bound the socket.
|
|
860
|
+
*/
|
|
861
|
+
export function portBridgeHostArgv(tag: string, port: number): string[] {
|
|
862
|
+
return [
|
|
863
|
+
'socat',
|
|
864
|
+
`TCP-LISTEN:${port},bind=127.0.0.1,fork,reuseaddr`,
|
|
865
|
+
`UNIX-CONNECT:${portBridgeSocket(tag, port)},retry=40,interval=0.25`,
|
|
866
|
+
]
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const hostBridges = new Map<
|
|
870
|
+
string,
|
|
871
|
+
{ ports: readonly number[]; procs: Array<ReturnType<typeof Bun.spawn>> }
|
|
872
|
+
>()
|
|
873
|
+
|
|
874
|
+
function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
875
|
+
const procs: Array<ReturnType<typeof Bun.spawn>> = []
|
|
876
|
+
for (const p of ports) {
|
|
877
|
+
// A spawn failure (no socat on the host) is the task's to report:
|
|
878
|
+
// its own side dies the same way, in its frame.
|
|
879
|
+
try {
|
|
880
|
+
// Guarded, in a group of its own (kill-tree.ts): a plain child of vx
|
|
881
|
+
// was in no group the guard lists, and a `kill -9` of vx left it
|
|
882
|
+
// listening on the port under init, where the next run's bridge
|
|
883
|
+
// could not bind it (item 873).
|
|
884
|
+
procs.push(
|
|
885
|
+
spawnGuarded(() =>
|
|
886
|
+
Bun.spawn(portBridgeHostArgv(tag, p), {
|
|
887
|
+
stdin: 'ignore',
|
|
888
|
+
stdout: 'ignore',
|
|
889
|
+
stderr: 'ignore',
|
|
890
|
+
detached: true,
|
|
891
|
+
}),
|
|
892
|
+
),
|
|
893
|
+
)
|
|
894
|
+
} catch {
|
|
895
|
+
// see above
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (procs.length > 0) {
|
|
899
|
+
hostBridges.set(tag, { ports, procs })
|
|
900
|
+
for (const p of ports) unlinkOnExit(portBridgeSocket(tag, p))
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Stop the host side of a task's port bridges and remove their sockets;
|
|
906
|
+
* idempotent. Called once the task's process has exited, so the socat
|
|
907
|
+
* that listened on each socket is gone with the namespace, and never
|
|
908
|
+
* unlinked it: one socket per bridged run stayed in the temp dir (item
|
|
909
|
+
* 877).
|
|
910
|
+
*/
|
|
911
|
+
export function releaseBridges(tag: string): void {
|
|
912
|
+
const tmp = taskTmpdir(tag)
|
|
913
|
+
if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
|
|
914
|
+
if (liveServers.delete(tag) && liveServers.size === 0 && resetDeferred) {
|
|
915
|
+
void resetSandbox().catch(() => {})
|
|
916
|
+
}
|
|
917
|
+
const bridges = hostBridges.get(tag)
|
|
918
|
+
if (bridges === undefined) return
|
|
919
|
+
hostBridges.delete(tag)
|
|
920
|
+
for (const port of bridges.ports) {
|
|
921
|
+
const sock = portBridgeSocket(tag, port)
|
|
922
|
+
try {
|
|
923
|
+
unlinkSync(sock)
|
|
924
|
+
} catch {
|
|
925
|
+
// never bound, or gone
|
|
926
|
+
}
|
|
927
|
+
liveTempFiles.delete(sock)
|
|
928
|
+
}
|
|
929
|
+
for (const p of bridges.procs) {
|
|
930
|
+
// The group: a socat forks one child per connection. Listed on the
|
|
931
|
+
// guard until it has gone (item 867's order).
|
|
932
|
+
killTree(p, 'SIGTERM')
|
|
933
|
+
void p.exited.then(() => releaseGroup(p))
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** What a task's output says when strace, not the task, ended its first attempt. */
|
|
938
|
+
const TRACER_RETRY_LINE =
|
|
939
|
+
"[vx] the sandbox's tracer (strace) failed on its own; running the task again\n"
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Run a single task wrapped in the sandbox. Caller must have called
|
|
943
|
+
* `initSandbox()` first.
|
|
944
|
+
*
|
|
945
|
+
* On Linux the task runs under strace, which only REPORTS what the sandbox
|
|
946
|
+
* denied, and a traced task's exit is strace's. strace failing on its own
|
|
947
|
+
* (`ptrace(PTRACE_LISTEN,…): Input/output error`, after a build that had
|
|
948
|
+
* finished) turned green work red on CI five times (STATUS Next 24). Such an
|
|
949
|
+
* attempt, its last word strace's own, is run once more: the sandbox kept
|
|
950
|
+
* its writes to what it declared, so a second run redoes, not doubles, it.
|
|
951
|
+
*/
|
|
952
|
+
export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
|
|
953
|
+
const { tracerFailed, ...first } = await runSandboxedOnce(args)
|
|
954
|
+
if (!tracerFailed) return first
|
|
955
|
+
args.onStderr?.(TRACER_RETRY_LINE)
|
|
956
|
+
const { tracerFailed: _again, ...second } = await runSandboxedOnce(args)
|
|
957
|
+
return {
|
|
958
|
+
...second,
|
|
959
|
+
durationMs: first.durationMs + second.durationMs,
|
|
960
|
+
stdout: first.stdout + second.stdout,
|
|
961
|
+
stderr: first.stderr + TRACER_RETRY_LINE + second.stderr,
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** strace's own message, as the last line a failed traced task printed. */
|
|
966
|
+
const STRACE_OWN_ERROR = /^strace: /
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* One attempt of `runSandboxed`.
|
|
970
|
+
*
|
|
971
|
+
* Violations are matched by a unique per-task command prefix — SRT's
|
|
972
|
+
* `getViolationsForCommand` keys by base64 of the first 100 chars, so
|
|
973
|
+
* two tasks running the same underlying command (e.g. parallel `tsc`
|
|
974
|
+
* across packages) would otherwise collide. We prepend `: '<tag>';`
|
|
975
|
+
* (shell no-op) to make every command's first 100 chars unique.
|
|
976
|
+
*/
|
|
977
|
+
async function runSandboxedOnce(
|
|
978
|
+
args: SandboxedRunArgs,
|
|
979
|
+
): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
|
|
980
|
+
const start = Date.now()
|
|
981
|
+
const { SandboxManager } = await loadSrt()
|
|
982
|
+
const { wrapped, tag, taggedCommand, baselines, forwardsSignals } =
|
|
983
|
+
await wrapSandboxedCommand(args)
|
|
334
984
|
|
|
335
985
|
// Linux: SRT's SandboxViolationStore is macOS-only, so structured
|
|
336
986
|
// detection on Linux requires us to wrap the spawn with strace and
|
|
@@ -340,51 +990,103 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
340
990
|
// structurally; we just lose the structured violation list.
|
|
341
991
|
const useStrace = await wantsStraceDetection()
|
|
342
992
|
const straceLog = useStrace ? path.join(os.tmpdir(), `vx-strace-${tag}.log`) : undefined
|
|
993
|
+
if (straceLog) unlinkOnExit(straceLog)
|
|
343
994
|
// We trace only `openat` — it's the actual file-read attempt, the
|
|
344
995
|
// signal the user cares about. `statx` / `newfstatat` / `access`
|
|
345
996
|
// are mostly shell PATH-walking and stat probes that aren't
|
|
346
997
|
// actionable (we'd report every node_modules/.bin entry the shell
|
|
347
998
|
// checks before resolving a command).
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
999
|
+
//
|
|
1000
|
+
// `--seccomp-bpf` is what makes that filter cheap: without it strace
|
|
1001
|
+
// ptrace-stops the tracee on EVERY syscall and discards the untraced
|
|
1002
|
+
// ones in userspace, so a stat-heavy task ran many times slower under
|
|
1003
|
+
// the sandbox than outside it. That is what failed the cache perf
|
|
1004
|
+
// baselines on the Linux job (reproduced 2026-09-09: plain `bun test`
|
|
1005
|
+
// 24/24; under `strace -f -e trace=openat` the same four fail with
|
|
1006
|
+
// medians 2.5–7× over budget; with `--seccomp-bpf` 24/24 again), and
|
|
1007
|
+
// it taxed every other sandboxed task the same way. With the flag the
|
|
1008
|
+
// kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
|
|
1009
|
+
// one gets the slow form rather than no detection.
|
|
352
1010
|
let proc: ReturnType<typeof Bun.spawn>
|
|
353
1011
|
try {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1012
|
+
// Both tools resolved on vx's own PATH (util/which.ts): strace would
|
|
1013
|
+
// otherwise walk the task's PATH for `sh`, where a project's
|
|
1014
|
+
// node_modules/.bin comes first.
|
|
1015
|
+
const sh = executablePath('sh')
|
|
1016
|
+
const spawnArgv = straceLog
|
|
1017
|
+
? [
|
|
1018
|
+
executablePath('strace'),
|
|
1019
|
+
'-f',
|
|
1020
|
+
...(useStrace === 'seccomp' ? ['--seccomp-bpf'] : []),
|
|
1021
|
+
'-e',
|
|
1022
|
+
'trace=openat',
|
|
1023
|
+
'-o',
|
|
1024
|
+
straceLog,
|
|
1025
|
+
'--',
|
|
1026
|
+
sh,
|
|
1027
|
+
'-c',
|
|
1028
|
+
wrapped,
|
|
1029
|
+
]
|
|
1030
|
+
: [sh, '-c', wrapped]
|
|
1031
|
+
proc = spawnGuarded(() =>
|
|
1032
|
+
Bun.spawn(spawnArgv, {
|
|
1033
|
+
argv0: straceLog ? 'strace' : 'sh',
|
|
1034
|
+
cwd: args.cwd,
|
|
1035
|
+
env: args.env as Record<string, string>,
|
|
1036
|
+
// fd 3 is the signal channel the in-sandbox watcher reads.
|
|
1037
|
+
stdio: forwardsSignals ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
1038
|
+
// As the unsandboxed spawn: its own process group (kill-tree.ts).
|
|
1039
|
+
detached: true,
|
|
1040
|
+
}),
|
|
1041
|
+
)
|
|
1042
|
+
if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
|
|
361
1043
|
} catch (err) {
|
|
362
|
-
const
|
|
1044
|
+
const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
|
|
1045
|
+
args.onStderr?.(stderr)
|
|
1046
|
+
releaseBridges(tag)
|
|
363
1047
|
return {
|
|
364
1048
|
exitCode: 127,
|
|
365
1049
|
durationMs: Date.now() - start,
|
|
366
1050
|
stdout: '',
|
|
367
|
-
stderr
|
|
1051
|
+
stderr,
|
|
368
1052
|
violations: [],
|
|
1053
|
+
tracerFailed: false,
|
|
369
1054
|
}
|
|
370
1055
|
}
|
|
371
1056
|
|
|
372
1057
|
args.liveChildren?.add(proc)
|
|
373
1058
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
374
1059
|
const ac = new AbortController()
|
|
1060
|
+
let stderrTail = ''
|
|
375
1061
|
const streams = Promise.all([
|
|
376
1062
|
streamToString(proc.stdout, args.onStdout, ac.signal, args.capture?.stdout ?? true),
|
|
377
|
-
streamToString(
|
|
1063
|
+
streamToString(
|
|
1064
|
+
proc.stderr,
|
|
1065
|
+
(chunk) => {
|
|
1066
|
+
// Kept whatever the capture setting: the last line says whether
|
|
1067
|
+
// strace, not the task, ended the attempt.
|
|
1068
|
+
stderrTail = (stderrTail + chunk).slice(-1024)
|
|
1069
|
+
args.onStderr?.(chunk)
|
|
1070
|
+
},
|
|
1071
|
+
ac.signal,
|
|
1072
|
+
args.capture?.stderr ?? true,
|
|
1073
|
+
),
|
|
378
1074
|
])
|
|
379
1075
|
// See runCommand: gate on child exit; a lingering grandchild pipe (timeout
|
|
380
1076
|
// OR a clean exit that backgrounds a process) can't hang the run — timeout
|
|
381
1077
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
|
382
1078
|
await proc.exited
|
|
383
|
-
timeout.
|
|
1079
|
+
await timeout.settle()
|
|
1080
|
+
let cut = false
|
|
384
1081
|
if (timeout.timedOut()) ac.abort()
|
|
385
|
-
else await drainOrAbort(streams, ac)
|
|
386
|
-
const [stdout,
|
|
1082
|
+
else cut = await drainOrAbort(streams, ac)
|
|
1083
|
+
const [stdout, streamed] = await streams
|
|
1084
|
+
if (cut) args.onStderr?.(POST_EXIT_CUT_LINE)
|
|
1085
|
+
const stderr = cut ? streamed + POST_EXIT_CUT_LINE : streamed
|
|
387
1086
|
args.liveChildren?.delete(proc)
|
|
1087
|
+
releaseGroup(proc)
|
|
1088
|
+
closeSignalChannel(proc)
|
|
1089
|
+
releaseBridges(tag)
|
|
388
1090
|
const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
389
1091
|
|
|
390
1092
|
// macOS: read the violation store keyed by our tagged command.
|
|
@@ -405,54 +1107,34 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
405
1107
|
// macOS is therefore lossy-by-OS under load; ENFORCEMENT is unaffected
|
|
406
1108
|
// (every observed loss still denied the read and failed the child).
|
|
407
1109
|
const store = SandboxManager.getSandboxViolationStore()
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
// baseline doesn't list, node survives them, and no file CONTENT flowed.
|
|
413
|
-
// Dropping them is scoped to exact ancestor-or-self DIRECTORY paths of
|
|
414
|
-
// the task's cwd — a denied read of any file INSIDE an ancestor still
|
|
415
|
-
// reports. (These records usually arrived too late to be seen at all;
|
|
416
|
-
// the settle window made the pre-existing class visible on every clean
|
|
417
|
-
// verify exit.)
|
|
418
|
-
const cwdReal = toRealPath(args.cwd)
|
|
419
|
-
const ancestorDirs = new Set<string>()
|
|
420
|
-
for (let d = cwdReal; ; d = path.dirname(d)) {
|
|
421
|
-
ancestorDirs.add(d)
|
|
422
|
-
if (d === path.dirname(d)) break
|
|
423
|
-
}
|
|
424
|
-
const isAncestorTraversal = (line: string): boolean => {
|
|
425
|
-
const m = /deny\(\d+\)\s+file-read-(?:data|metadata)\s+(\/\S+)\s*$/.exec(line)
|
|
426
|
-
if (m === null) return false
|
|
427
|
-
return ancestorDirs.has(toRealPath(m[1]!))
|
|
428
|
-
}
|
|
1110
|
+
// EVERY record the store holds for this command, unfiltered (owner,
|
|
1111
|
+
// 2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
1112
|
+
// deciding on the user's behalf that a record was "just traversal" is how
|
|
1113
|
+
// a failure ends up with an empty violations section and no explanation.
|
|
429
1114
|
const readMacViolations = (): SandboxViolation[] =>
|
|
430
|
-
store
|
|
431
|
-
.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
1115
|
+
store.getViolationsForCommand(taggedCommand).map((v) => ({
|
|
1116
|
+
line: v.line,
|
|
1117
|
+
timestamp: v.timestamp,
|
|
1118
|
+
}))
|
|
1119
|
+
// Darwin only. Since SRT 0.0.75 the store is fed on Linux too, by the
|
|
1120
|
+
// seccomp helper's write observer — but SRT judges those reports against
|
|
1121
|
+
// the GLOBAL `filesystem.allowWrite` from `initialize` (empty here; the
|
|
1122
|
+
// per-task list travels in `customConfig`, which the monitor never sees),
|
|
1123
|
+
// so every write a task makes to its own declared output arrived as
|
|
1124
|
+
// `deny openat <output>` and failed the task (reproduced 2026-09-04 in a
|
|
1125
|
+
// Linux container: exit 1, empty stderr). Linux detection is the strace
|
|
1126
|
+
// pass below, judged against the task's own baselines.
|
|
1127
|
+
let macViolations = process.platform === 'darwin' ? readMacViolations() : []
|
|
1128
|
+
// No settle window: the store is read once, right after the child exits
|
|
1129
|
+
// (owner, 2026-09-05). It cost 300ms on EVERY clean sandboxed task — the
|
|
1130
|
+
// full budget, since a task with nothing to report can only prove that by
|
|
1131
|
+
// waiting — against 26ms for one that reports something and breaks out
|
|
1132
|
+
// early. Measured: clean `echo` 331ms sandboxed vs 16ms plain.
|
|
1133
|
+
//
|
|
1134
|
+
// The price is that macOS feeds this store asynchronously, so a record
|
|
1135
|
+
// that has not arrived yet is not reported. Enforcement is unaffected —
|
|
1136
|
+
// the OS denied the operation either way, and a command that could not
|
|
1137
|
+
// proceed still fails on its own exit code.
|
|
456
1138
|
// Linux: parse the strace log and emit one violation per denied
|
|
457
1139
|
// syscall on a path inside denyRead that wasn't unconditionally
|
|
458
1140
|
// allowed. Best-effort — if parsing fails we surface no Linux
|
|
@@ -460,19 +1142,45 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
460
1142
|
const linuxViolations: SandboxViolation[] = straceLog
|
|
461
1143
|
? await parseStraceViolations(straceLog, args, baselines).catch(() => [])
|
|
462
1144
|
: []
|
|
463
|
-
if (straceLog)
|
|
1145
|
+
if (straceLog) {
|
|
1146
|
+
// Listed until the unlink lands, as the run lock's taking (item 868).
|
|
1147
|
+
await unlink(straceLog).catch(() => undefined)
|
|
1148
|
+
liveTempFiles.delete(straceLog)
|
|
1149
|
+
}
|
|
464
1150
|
|
|
465
1151
|
// Apply the task's own ignoreViolations on top of the global defaults.
|
|
466
1152
|
// SRT's wrapCommandWithSandboxMacOS doesn't actually thread customConfig.
|
|
467
1153
|
// ignoreViolations through to the log monitor — that filter is set
|
|
468
1154
|
// once globally at initSandbox time. So per-task user overrides have
|
|
469
1155
|
// to be applied here, after read-back.
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1156
|
+
const violations = reportableViolations([...macViolations, ...linuxViolations], {
|
|
1157
|
+
within: args.reportWithin,
|
|
1158
|
+
linked: args.reportLinked,
|
|
1159
|
+
config: args.config,
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
// The one denial macOS never logs. MEASURED 2026-09-05, same machine, two
|
|
1163
|
+
// runs differing only in the grant: with the cwd granted a failing task
|
|
1164
|
+
// reports its denials normally; WITHOUT it the store stays empty across
|
|
1165
|
+
// the whole settle window (11 reads, 0 records) and the child reports
|
|
1166
|
+
// whatever it likes — `bun test` says only `error: An unknown error
|
|
1167
|
+
// occurred (Unexpected)`. The process dies before macOS logs anything, so
|
|
1168
|
+
// no amount of un-filtering can surface it.
|
|
1169
|
+
//
|
|
1170
|
+
// Not a guess about the cause: the cwd lying outside every allowRead
|
|
1171
|
+
// prefix is a fact of the resolved baselines. Added only when the task
|
|
1172
|
+
// ALREADY failed with nothing to show, so it can never redden a pass.
|
|
1173
|
+
const grantedRead = [...baselines.allowRead, ...args.config.allowRead]
|
|
1174
|
+
if (exitCode !== 0 && violations.length === 0 && !readableUnder(args.cwd, grantedRead)) {
|
|
1175
|
+
violations.push({
|
|
1176
|
+
timestamp: new Date(),
|
|
1177
|
+
line:
|
|
1178
|
+
`vx: this sandbox grants no read access to the task's own working directory ` +
|
|
1179
|
+
`(${args.cwd}), so a command that reads or lists it fails with whatever error it ` +
|
|
1180
|
+
`reports for that — macOS logs no violation record. Add ` +
|
|
1181
|
+
"`sandbox: { allow: { read: ['.'] } }`.",
|
|
1182
|
+
})
|
|
1183
|
+
}
|
|
476
1184
|
|
|
477
1185
|
try {
|
|
478
1186
|
SandboxManager.cleanupAfterCommand()
|
|
@@ -480,6 +1188,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
480
1188
|
// ignore; bwrap mount-point cleanup is best-effort
|
|
481
1189
|
}
|
|
482
1190
|
|
|
1191
|
+
const lastLine = stderrTail.trimEnd().split('\n').pop() ?? ''
|
|
483
1192
|
return {
|
|
484
1193
|
exitCode,
|
|
485
1194
|
durationMs: Date.now() - start,
|
|
@@ -489,260 +1198,288 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
489
1198
|
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
490
1199
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
491
1200
|
...resourceUsageToCpuRss(proc.resourceUsage()),
|
|
1201
|
+
tracerFailed:
|
|
1202
|
+
straceLog !== undefined &&
|
|
1203
|
+
exitCode !== 0 &&
|
|
1204
|
+
!timeout.timedOut() &&
|
|
1205
|
+
STRACE_OWN_ERROR.test(lastLine),
|
|
492
1206
|
}
|
|
493
1207
|
}
|
|
494
1208
|
|
|
495
|
-
/**
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
straceAvailableCache = false
|
|
506
|
-
}
|
|
507
|
-
return straceAvailableCache
|
|
1209
|
+
/**
|
|
1210
|
+
* Is `dir` inside a path the sandbox granted for reading? bwrap binds and
|
|
1211
|
+
* seatbelt rules are both prefix-based, so a grant covers the subtree.
|
|
1212
|
+
*/
|
|
1213
|
+
function readableUnder(dir: string, granted: readonly string[]): boolean {
|
|
1214
|
+
const target = toRealPath(dir)
|
|
1215
|
+
return granted.some((p) => {
|
|
1216
|
+
const g = toRealPath(p)
|
|
1217
|
+
return target === g || target.startsWith(g.endsWith(path.sep) ? g : g + path.sep)
|
|
1218
|
+
})
|
|
508
1219
|
}
|
|
509
1220
|
|
|
510
1221
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
* strace line shape (with -f):
|
|
517
|
-
* <pid> openat(AT_FDCWD, "<path>", <flags>) = -1 ENOENT (...)
|
|
518
|
-
* <pid> access("<path>", <mode>) = -1 EACCES (...)
|
|
519
|
-
* <pid> statx(AT_FDCWD, "<path>", <flags>, <mask>, ...) = -1 ENOENT (...)
|
|
520
|
-
*
|
|
521
|
-
* …but ONLY when the syscall completes without another traced process
|
|
522
|
-
* interleaving. Under `-f` strace splits an interrupted call across two
|
|
523
|
-
* lines and the result never appears next to the path:
|
|
524
|
-
* <pid> openat(AT_FDCWD, "<path>", <flags> <unfinished ...>
|
|
525
|
-
* <pid> <... openat resumed>) = -1 ENOENT (...)
|
|
526
|
-
* A single-line regex silently drops every one of those, so a task that
|
|
527
|
-
* forks concurrent children reading undeclared files reported an
|
|
528
|
-
* INCOMPLETE violation list — which for `--verify=inputs` reads as
|
|
529
|
-
* `proven-complete`. We pair them by pid instead (a process has at most
|
|
530
|
-
* one syscall in flight, so the pid is a sufficient key).
|
|
531
|
-
*
|
|
532
|
-
* We capture the first quoted-string argument as the path. paths that
|
|
533
|
-
* are relative resolve against the task's cwd (set by Bun.spawn).
|
|
1222
|
+
* A token safe to interpolate into a seatbelt profile. The profile travels
|
|
1223
|
+
* inside a shell-quoted `sandbox-exec -p '…'` argument, so a value carrying
|
|
1224
|
+
* a quote, paren or backslash could rewrite the policy or escape the
|
|
1225
|
+
* argument. Refuse rather than escape — every real info type is a plain
|
|
1226
|
+
* dotted name.
|
|
534
1227
|
*/
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const STRACE_RESUMED_RE = new RegExp(
|
|
541
|
-
`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
|
|
542
|
-
)
|
|
543
|
-
/** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
|
|
544
|
-
const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
|
|
545
|
-
|
|
546
|
-
/** One denied syscall, however strace chose to lay it out. */
|
|
547
|
-
export interface DeniedCall {
|
|
548
|
-
syscall: string
|
|
549
|
-
rawPath: string
|
|
550
|
-
errno: string
|
|
1228
|
+
function sbplToken(value: string, field: string): string {
|
|
1229
|
+
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
1230
|
+
throw new UserError(`${field}: '${value}' is not a valid name`)
|
|
1231
|
+
}
|
|
1232
|
+
return value
|
|
551
1233
|
}
|
|
552
1234
|
|
|
553
1235
|
/**
|
|
554
|
-
*
|
|
1236
|
+
* The capabilities that reach the macOS profile as rules rather than
|
|
1237
|
+
* through SRT's config.
|
|
555
1238
|
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
)
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1239
|
+
* `systemInfo` has no SRT field at any level. The rest DO have fields,
|
|
1240
|
+
* but SRT reads `network.allowLocalBinding` / `allowUnixSockets` /
|
|
1241
|
+
* `allowMachLookup` off the config given to `initialize()` and never off
|
|
1242
|
+
* the per-call one (`sandbox-manager.js` getters, 0.0.75) — so a per-task
|
|
1243
|
+
* grant passed there is silently dropped. vx is per-task by definition,
|
|
1244
|
+
* so it emits them itself. The rule text mirrors SRT's own.
|
|
1245
|
+
*/
|
|
1246
|
+
export function macProfileRules(c: ResolvedSandboxConfig): string[] {
|
|
1247
|
+
const rules: string[] = []
|
|
1248
|
+
for (const t of c.systemInfo ?? []) {
|
|
1249
|
+
rules.push(`(allow system-info (info-type "${sbplToken(t, 'allow.systemInfo')}"))`)
|
|
1250
|
+
}
|
|
1251
|
+
if (localBindingOn(c)) {
|
|
1252
|
+
// `*:*`, not `localhost:*`: a dual-stack socket bound to 127.0.0.1 is
|
|
1253
|
+
// ::ffff:127.0.0.1 in the kernel, which seatbelt's `localhost` does
|
|
1254
|
+
// not match. bind and inbound carry no remote endpoint, so this is
|
|
1255
|
+
// not egress. Egress stays pinned to loopback.
|
|
1256
|
+
rules.push('(allow network-bind (local ip "*:*"))')
|
|
1257
|
+
rules.push('(allow network-inbound (local ip "*:*"))')
|
|
1258
|
+
rules.push('(allow network-outbound (remote ip "localhost:*"))')
|
|
1259
|
+
}
|
|
1260
|
+
if (c.unixSockets === true) {
|
|
1261
|
+
rules.push('(allow system-socket (socket-domain AF_UNIX))')
|
|
1262
|
+
rules.push('(allow network-bind (local unix-socket (path-regex #"^/")))')
|
|
1263
|
+
rules.push('(allow network-outbound (remote unix-socket (path-regex #"^/")))')
|
|
1264
|
+
} else if (c.unixSockets !== undefined && c.unixSockets.length > 0) {
|
|
1265
|
+
rules.push('(allow system-socket (socket-domain AF_UNIX))')
|
|
1266
|
+
for (const sock of c.unixSockets) {
|
|
1267
|
+
// Both the declared path and what it resolves to: seatbelt matches the
|
|
1268
|
+
// path the kernel sees, and on macOS `/tmp` is a symlink to
|
|
1269
|
+
// `/private/tmp` — a grant on the former alone never matches.
|
|
1270
|
+
for (const p of unique([
|
|
1271
|
+
sbplPath(sock, 'allow.unixSockets'),
|
|
1272
|
+
sbplResolvedPath(toRealPath(sock), 'allow.unixSockets'),
|
|
1273
|
+
])) {
|
|
1274
|
+
rules.push(`(allow network-bind (local unix-socket (subpath "${p}")))`)
|
|
1275
|
+
rules.push(`(allow network-outbound (remote unix-socket (subpath "${p}")))`)
|
|
1276
|
+
}
|
|
587
1277
|
}
|
|
588
1278
|
}
|
|
589
|
-
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
async function parseStraceViolations(
|
|
593
|
-
logPath: string,
|
|
594
|
-
args: SandboxedRunArgs,
|
|
595
|
-
baselines: { allowRead: readonly string[]; denyRead: readonly string[]; cwd: string },
|
|
596
|
-
): Promise<SandboxViolation[]> {
|
|
597
|
-
const text = await Bun.file(logPath).text()
|
|
598
|
-
if (text.length === 0) return []
|
|
599
|
-
|
|
600
|
-
// Treat every baseAllow + sandbox.allowRead path as "this was
|
|
601
|
-
// explicitly permitted; any -ENOENT here is the user's own missing
|
|
602
|
-
// file, not a sandbox-induced denial". Same for absolute denyRead
|
|
603
|
-
// checks below. Canonical on BOTH sides — the policy is expressed in
|
|
604
|
-
// real paths (see `canonicalBaselines`), so comparing a link-path here
|
|
605
|
-
// would report an explicitly-allowed read as a violation.
|
|
606
|
-
const allowAbs = new Set<string>(
|
|
607
|
-
[...baselines.allowRead, ...args.config.allowRead].map((p) => toRealPath(absolutize(p))),
|
|
608
|
-
)
|
|
609
|
-
const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
|
|
610
|
-
|
|
611
|
-
const seen = new Set<string>()
|
|
612
|
-
const out: SandboxViolation[] = []
|
|
613
|
-
for (const { syscall, rawPath, errno } of deniedCalls(text)) {
|
|
614
|
-
const abs = toRealPath(absolutize(rawPath, baselines.cwd))
|
|
615
|
-
// Only report paths under the workspace-root deny anchor — system
|
|
616
|
-
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
617
|
-
if (!denyAnchors.some((root) => abs === root || abs.startsWith(root + path.sep))) continue
|
|
618
|
-
// Skip paths the user explicitly allowed (and their descendants).
|
|
619
|
-
if (isUnderAny(abs, allowAbs)) continue
|
|
620
|
-
const key = `${syscall}|${abs}`
|
|
621
|
-
if (seen.has(key)) continue
|
|
622
|
-
seen.add(key)
|
|
623
|
-
out.push({
|
|
624
|
-
line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
|
|
625
|
-
timestamp: new Date(),
|
|
626
|
-
})
|
|
1279
|
+
for (const name of c.machLookup ?? []) {
|
|
1280
|
+
rules.push(`(allow mach-lookup (global-name "${sbplToken(name, 'allow.machLookup')}"))`)
|
|
627
1281
|
}
|
|
628
|
-
return
|
|
1282
|
+
return rules
|
|
629
1283
|
}
|
|
630
1284
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1285
|
+
/**
|
|
1286
|
+
* A path safe to interpolate into a seatbelt profile. Same reasoning as
|
|
1287
|
+
* {@link sbplToken}, with the separators a path needs.
|
|
1288
|
+
*/
|
|
1289
|
+
function sbplPath(value: string, field: string): string {
|
|
1290
|
+
if (!/^[A-Za-z0-9._\-/@+]+$/.test(value) || value.includes('..')) {
|
|
1291
|
+
throw new UserError(`${field}: '${value}' is not a valid path`)
|
|
1292
|
+
}
|
|
1293
|
+
return value
|
|
635
1294
|
}
|
|
636
1295
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1296
|
+
/**
|
|
1297
|
+
* The check for a path the FILESYSTEM handed back (`toRealPath` of a
|
|
1298
|
+
* declared socket), which the declared-value allowlist above is wrong for:
|
|
1299
|
+
* a real macOS home is `/Users/Jane Smith`, and refusing the space would
|
|
1300
|
+
* regress every such layout to close a hole. What can leave the quoted
|
|
1301
|
+
* SBPL string, or the single-quoted `sandbox-exec -p '…'` argument it
|
|
1302
|
+
* travels in, is exactly a double quote, a backslash, a single quote, or a
|
|
1303
|
+
* control character — so only those are refused. Item 478 recorded the
|
|
1304
|
+
* hole (a symlink whose TARGET carries a quote went into the profile
|
|
1305
|
+
* unchecked, because the check was on the string the user wrote and the
|
|
1306
|
+
* interpolation was of the string the kernel resolves); item 582 closed
|
|
1307
|
+
* it. Refuse, never escape, as the sibling checkers do.
|
|
1308
|
+
*/
|
|
1309
|
+
export function sbplResolvedPath(value: string, field: string): string {
|
|
1310
|
+
// eslint-disable-next-line no-control-regex -- the control range is the point
|
|
1311
|
+
if (/["'\\\x00-\x1f\x7f]/.test(value)) {
|
|
1312
|
+
throw new UserError(
|
|
1313
|
+
`${field}: '${value}' (resolved from a symlink) carries a quote, a backslash or a control ` +
|
|
1314
|
+
`character, which cannot go into a seatbelt profile; point the link at a plain path`,
|
|
1315
|
+
)
|
|
641
1316
|
}
|
|
642
|
-
return
|
|
1317
|
+
return value
|
|
643
1318
|
}
|
|
644
1319
|
|
|
645
1320
|
/**
|
|
646
|
-
*
|
|
647
|
-
* of whatever the macOS log monitor + Linux strace pass produced.
|
|
648
|
-
* Mirrors SRT's own substring-match semantics:
|
|
649
|
-
* - `'*'` entries match every command
|
|
650
|
-
* - other keys match commands whose userCommand string CONTAINS the key
|
|
651
|
-
* - the array of strings under each key is substring-matched against
|
|
652
|
-
* the violation line
|
|
1321
|
+
* Add rules to the END of the seatbelt profile SRT generated.
|
|
653
1322
|
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1323
|
+
* SBPL is last-match-wins, so the tail is the only place a rule of ours
|
|
1324
|
+
* outranks one of SRT's. Measured 2026-09-05: the same rules injected
|
|
1325
|
+
* after the `(deny default …)` header were inert in both directions —
|
|
1326
|
+
* SRT's own later clauses won. Filesystem capabilities still go through
|
|
1327
|
+
* SRT's config, where they also work on Linux; what arrives here is what
|
|
1328
|
+
* SRT's per-call config CANNOT carry.
|
|
1329
|
+
*
|
|
1330
|
+
* A profile that does not have the expected shape THROWS. Silently
|
|
1331
|
+
* running a task without a capability it asked for is worse than not
|
|
1332
|
+
* offering the capability.
|
|
1333
|
+
*/
|
|
1334
|
+
function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
1335
|
+
const anchor = /sandbox-exec -p '\(version 1\)\n\(deny default[^\n]*\n/.exec(wrapped)
|
|
1336
|
+
if (anchor === null) {
|
|
1337
|
+
throw new UserError(
|
|
1338
|
+
'sandbox: the OS sandbox policy did not have the expected shape, so the ' +
|
|
1339
|
+
'capabilities this task declared could not be applied',
|
|
1340
|
+
)
|
|
1341
|
+
}
|
|
1342
|
+
// The profile is a single-quoted shell argument; SRT escapes a literal
|
|
1343
|
+
// quote inside it as `'"'"'` (5 chars). The first bare `'` after the
|
|
1344
|
+
// header therefore closes the profile.
|
|
1345
|
+
let i = anchor.index + anchor[0].length
|
|
1346
|
+
for (;;) {
|
|
1347
|
+
const q = wrapped.indexOf("'", i)
|
|
1348
|
+
if (q === -1) {
|
|
1349
|
+
throw new UserError(
|
|
1350
|
+
'sandbox: the OS sandbox policy was not quoted as expected, so the ' +
|
|
1351
|
+
'capabilities this task declared could not be applied',
|
|
1352
|
+
)
|
|
671
1353
|
}
|
|
672
|
-
|
|
673
|
-
|
|
1354
|
+
if (wrapped.startsWith(`'"'"'`, q)) {
|
|
1355
|
+
i = q + 5
|
|
1356
|
+
continue
|
|
1357
|
+
}
|
|
1358
|
+
return `${wrapped.slice(0, q)}\n${rules.join('\n')}\n${wrapped.slice(q)}`
|
|
1359
|
+
}
|
|
674
1360
|
}
|
|
675
1361
|
|
|
676
1362
|
/**
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
1363
|
+
* Grant paths, with globs handled per platform.
|
|
1364
|
+
*
|
|
1365
|
+
* macOS: SRT's own `pathFilter` turns a glob into `(regex …)` and a literal
|
|
1366
|
+
* into `(subpath …)`, so a pattern is passed through and seatbelt matches
|
|
1367
|
+
* it — including files created DURING the run.
|
|
1368
|
+
*
|
|
1369
|
+
* Linux: a grant is a bwrap bind mount, and you cannot mount a pattern.
|
|
1370
|
+
* The glob is expanded against the filesystem here, which means it covers
|
|
1371
|
+
* what exists when the task STARTS. A pattern matching a file the task
|
|
1372
|
+
* creates later grants nothing there — declare its directory instead.
|
|
680
1373
|
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
1374
|
+
* That last sentence is the whole contract, and until item 496 a task
|
|
1375
|
+
* that broke it learned so from its OWN tool. Measured, one task per
|
|
1376
|
+
* spelling, each writing files it declares:
|
|
1377
|
+
*
|
|
1378
|
+
* write: ['g/**'] ok — collapsed to the directory
|
|
1379
|
+
* write: ['g/a.txt'] ok — a literal is widened to its directory
|
|
1380
|
+
* write: ['g/*'] FAILED: `bash: g/a.txt: Read-only file system`
|
|
1381
|
+
* write: ['g/*.txt'] FAILED, same
|
|
1382
|
+
* write: ['g/?.txt'] FAILED, same
|
|
1383
|
+
* write: ['g/[ab].txt'] FAILED, same
|
|
1384
|
+
*
|
|
1385
|
+
* The failure names neither vx nor the grant, so `writeGrantMatchedNothing`
|
|
1386
|
+
* below says what happened and what to write instead. A READ grant that
|
|
1387
|
+
* matches nothing is ordinary (an optional file, a cache not yet
|
|
1388
|
+
* populated), so only writes are reported.
|
|
1389
|
+
*
|
|
1390
|
+
* The classifier is `isMountableLiteral` (sandbox-paths.ts), deliberately
|
|
1391
|
+
* NOT `isLiteralPattern`: its docblock says why the brace is not a wildcard
|
|
1392
|
+
* to a grant.
|
|
685
1393
|
*/
|
|
686
|
-
function
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
allowRead,
|
|
703
|
-
allowWrite,
|
|
704
|
-
denyWrite: [],
|
|
705
|
-
...(c.allowGitConfig !== undefined ? { allowGitConfig: c.allowGitConfig } : {}),
|
|
706
|
-
},
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Network coercion. SRT requires allowedDomains + deniedDomains to be
|
|
710
|
-
// present on any network config; we always supply both.
|
|
711
|
-
if (c.network === true) {
|
|
712
|
-
custom.network = { allowedDomains: ['*'], deniedDomains: [] }
|
|
713
|
-
} else if (c.network && typeof c.network === 'object') {
|
|
714
|
-
custom.network = {
|
|
715
|
-
allowedDomains: c.network.allowedDomains ?? [],
|
|
716
|
-
deniedDomains: c.network.deniedDomains ?? [],
|
|
717
|
-
...(c.network.allowUnixSockets !== undefined
|
|
718
|
-
? { allowUnixSockets: c.network.allowUnixSockets }
|
|
719
|
-
: {}),
|
|
720
|
-
...(c.network.allowAllUnixSockets !== undefined
|
|
721
|
-
? { allowAllUnixSockets: c.network.allowAllUnixSockets }
|
|
722
|
-
: {}),
|
|
723
|
-
...(c.network.allowLocalBinding !== undefined
|
|
724
|
-
? { allowLocalBinding: c.network.allowLocalBinding }
|
|
725
|
-
: {}),
|
|
726
|
-
...(c.network.allowMachLookup !== undefined
|
|
727
|
-
? { allowMachLookup: c.network.allowMachLookup }
|
|
728
|
-
: {}),
|
|
1394
|
+
function expandGrants(paths: readonly string[], kind: 'read' | 'write'): string[] {
|
|
1395
|
+
// A pattern covering a directory WHOLE is that directory. `<d>/**/*` and
|
|
1396
|
+
// `<d>/**` match everything UNDER `<d>` and never `<d>` itself, so a task
|
|
1397
|
+
// granted `read: ['**/*']` still could not list its own cwd — the exact
|
|
1398
|
+
// shape `bun test` and `oxlint` need. Collapsing is not a widening: the
|
|
1399
|
+
// pattern already covered every file there; it adds the directory entry.
|
|
1400
|
+
const collapsed = paths.map((p) => {
|
|
1401
|
+
const m = /^(.*?)\/\*\*(?:\/\*)?$/.exec(p)
|
|
1402
|
+
return m === null ? p : m[1]!
|
|
1403
|
+
})
|
|
1404
|
+
if (process.platform !== 'linux') return collapsed
|
|
1405
|
+
const out: string[] = []
|
|
1406
|
+
for (const p of collapsed) {
|
|
1407
|
+
if (isMountableLiteral(p)) {
|
|
1408
|
+
out.push(p)
|
|
1409
|
+
continue
|
|
729
1410
|
}
|
|
730
|
-
|
|
731
|
-
//
|
|
732
|
-
|
|
1411
|
+
// Anchor the scan at the longest literal prefix so a pattern does not
|
|
1412
|
+
// walk the whole filesystem to find its matches.
|
|
1413
|
+
const base = path.dirname(p.slice(0, p.search(MOUNT_WILDCARDS)))
|
|
1414
|
+
const pattern = path.relative(base, p)
|
|
1415
|
+
// A hit that leaves the scan's base through a link is not what the
|
|
1416
|
+
// pattern named: bwrap binds by the link's target, so `read: ['*']`
|
|
1417
|
+
// over `shared -> ../b/src` bound project b readable, a read `'.'`
|
|
1418
|
+
// never gives, and a cached task replayed b's old bytes (item 1006).
|
|
1419
|
+
// The boundary is the directory holding the first wildcard component,
|
|
1420
|
+
// not the scan's anchor: that sits one component higher.
|
|
1421
|
+
const head = p.slice(0, p.search(MOUNT_WILDCARDS))
|
|
1422
|
+
const home = toRealPath(head.endsWith('/') ? head.slice(0, -1) : path.dirname(head))
|
|
1423
|
+
let hits = 0
|
|
1424
|
+
for (const hit of new Bun.Glob(pattern).scanSync({ cwd: base, onlyFiles: false, dot: true })) {
|
|
1425
|
+
const abs = path.join(base, hit)
|
|
1426
|
+
const real = toRealPath(abs)
|
|
1427
|
+
if (real !== home && !real.startsWith(home + path.sep)) continue
|
|
1428
|
+
out.push(abs)
|
|
1429
|
+
hits++
|
|
1430
|
+
}
|
|
1431
|
+
if (hits === 0 && kind === 'write') writeGrantMatchedNothing(p)
|
|
733
1432
|
}
|
|
1433
|
+
return out
|
|
1434
|
+
}
|
|
734
1435
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1436
|
+
/** Grants already reported — once per process, not per spawn. */
|
|
1437
|
+
const warnedEmptyWriteGrant = new Set<string>()
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* A write grant that mounted nothing, said once, before the task dies on
|
|
1441
|
+
* it. The remedy is the directory, which is what the grant would have been
|
|
1442
|
+
* widened to anyway had it named a file — and it is named with
|
|
1443
|
+
* `grantPrefix`, the shared wildcard-free head, NOT the scan's anchor
|
|
1444
|
+
* above: that anchor is one component higher (`dirname` of the head, so
|
|
1445
|
+
* the relative pattern keeps its wildcard component), and printing it
|
|
1446
|
+
* would tell the user to grant the PARENT of the directory they meant.
|
|
1447
|
+
*/
|
|
1448
|
+
function writeGrantMatchedNothing(grant: string): void {
|
|
1449
|
+
if (warnedEmptyWriteGrant.has(grant)) return
|
|
1450
|
+
warnedEmptyWriteGrant.add(grant)
|
|
1451
|
+
process.stderr.write(
|
|
1452
|
+
`[vx] sandbox: the write grant ${grant} matches nothing yet, so it mounts nothing and ` +
|
|
1453
|
+
`a file the task creates under it will fail with "Read-only file system". A bind mount ` +
|
|
1454
|
+
`covers what exists when the task starts — grant the directory instead: ` +
|
|
1455
|
+
`${grantPrefix(grant)}/**\n`,
|
|
1456
|
+
)
|
|
744
1457
|
}
|
|
745
1458
|
|
|
746
|
-
|
|
747
|
-
|
|
1459
|
+
/**
|
|
1460
|
+
* Memoized check: is `strace` on PATH on a Linux host, and does it know
|
|
1461
|
+
* `--seccomp-bpf` (5.3+)? `'seccomp'` is the fast form; `'plain'` traces
|
|
1462
|
+
* every syscall through ptrace and is kept only for an old strace.
|
|
1463
|
+
*/
|
|
1464
|
+
let straceAvailableCache: false | 'plain' | 'seccomp' | undefined
|
|
1465
|
+
async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {
|
|
1466
|
+
if (process.platform !== 'linux') return false
|
|
1467
|
+
if (straceAvailableCache !== undefined) return straceAvailableCache
|
|
1468
|
+
try {
|
|
1469
|
+
const p = Bun.spawn([executablePath('strace'), '--version'], {
|
|
1470
|
+
stdout: 'pipe',
|
|
1471
|
+
stderr: 'ignore',
|
|
1472
|
+
})
|
|
1473
|
+
const out = await new Response(p.stdout).text()
|
|
1474
|
+
await p.exited
|
|
1475
|
+
if (p.exitCode !== 0) straceAvailableCache = false
|
|
1476
|
+
else {
|
|
1477
|
+
const m = /version (\d+)\.(\d+)/.exec(out)
|
|
1478
|
+
const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
|
|
1479
|
+
straceAvailableCache = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
|
|
1480
|
+
}
|
|
1481
|
+
} catch {
|
|
1482
|
+
straceAvailableCache = false
|
|
1483
|
+
}
|
|
1484
|
+
return straceAvailableCache
|
|
748
1485
|
}
|