@vzn/vx 0.0.18 → 0.0.21
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 +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- 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-turbo.ts +0 -401
- 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/schedule-history/index.ts +0 -109
|
@@ -25,9 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
import os from 'node:os'
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import type { SandboxConfig, SandboxNetworkConfig } from '../config.js'
|
|
28
|
+
import { mkdir, unlink } from 'node:fs/promises'
|
|
29
|
+
import type { SandboxConfig } from '../config.js'
|
|
31
30
|
import {
|
|
32
31
|
armTimeout,
|
|
33
32
|
drainOrAbort,
|
|
@@ -38,7 +37,10 @@ import {
|
|
|
38
37
|
type CaptureConfig,
|
|
39
38
|
type RunResult,
|
|
40
39
|
} from './runner.js'
|
|
41
|
-
import { xxh3hex } from '../util/index.js'
|
|
40
|
+
import { UserError, xxh3hex } from '../util/index.js'
|
|
41
|
+
import { buildCustomConfig } from './sandbox-binds.js'
|
|
42
|
+
import { localBindingOn, toRealPath, unique } from './sandbox-paths.js'
|
|
43
|
+
import { parseStraceViolations, reportableViolations } from './sandbox-violations.js'
|
|
42
44
|
|
|
43
45
|
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
44
46
|
let srtPromise: Promise<SrtModule> | undefined
|
|
@@ -54,62 +56,127 @@ export interface SandboxAvailability {
|
|
|
54
56
|
reason: string
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
/** One verdict per mode: the wrapper the probe runs differs by mode. */
|
|
60
|
+
const availabilityCache = new Map<'secure' | 'weaker', SandboxAvailability>()
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
|
-
* Probe whether SRT can sandbox on this host. Memoized — the
|
|
61
|
-
* presence + platform check doesn't change within a process.
|
|
63
|
+
* Probe whether SRT can sandbox on this host. Memoized per mode — the
|
|
64
|
+
* binary presence + platform check doesn't change within a process.
|
|
62
65
|
*
|
|
63
66
|
* In addition to SRT's own `checkDependencies` (which only verifies
|
|
64
|
-
* binary presence on PATH), this runs
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
67
|
+
* binary presence on PATH), this runs ONE sandboxed `true` on Linux
|
|
68
|
+
* through SRT's own wrapper: bwrap with the runtime's namespace flags
|
|
69
|
+
* plus its vendored seccomp helper, which creates a NESTED user
|
|
70
|
+
* namespace. A bare `bwrap … /bin/true` (the probe until 2026-09-04)
|
|
71
|
+
* passed on hosts where every task then failed — as root inside a
|
|
72
|
+
* container, the helper's `write /proc/self/uid_map` is EPERM under
|
|
73
|
+
* `--cap-drop ALL`. Without this real-execution probe the
|
|
74
|
+
* unavailability surfaces only at the first task spawn, deep inside the
|
|
75
|
+
* orchestrator, as exit 1 with the helper's line in the task's stderr.
|
|
76
|
+
*
|
|
77
|
+
* `weakerNested` probes with `enableWeakerNestedSandbox`; the caller
|
|
78
|
+
* passes it only when EVERY sandboxed task opts in, since the secure
|
|
79
|
+
* wrapper is what any other task will run under.
|
|
80
|
+
*
|
|
81
|
+
* On Linux the probe INITIALIZES SRT (idempotent — `run()` initializes
|
|
82
|
+
* right after anyway), and SRT's proxy sockets then keep the event loop
|
|
83
|
+
* alive: a standalone caller must `resetSandbox()` or exit. CI's
|
|
84
|
+
* diagnostic `bun -e` step sat on this for its whole 10-minute timeout
|
|
85
|
+
* (2026-09-04).
|
|
69
86
|
*/
|
|
70
|
-
export async function probeSandbox(
|
|
71
|
-
|
|
87
|
+
export async function probeSandbox(opts?: {
|
|
88
|
+
weakerNested?: boolean
|
|
89
|
+
}): Promise<SandboxAvailability> {
|
|
90
|
+
const mode = opts?.weakerNested === true ? 'weaker' : 'secure'
|
|
91
|
+
const cached = availabilityCache.get(mode)
|
|
92
|
+
if (cached) return cached
|
|
93
|
+
const verdict = await probeUncached(mode === 'weaker')
|
|
94
|
+
availabilityCache.set(mode, verdict)
|
|
95
|
+
return verdict
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function probeUncached(weakerNested: boolean): Promise<SandboxAvailability> {
|
|
72
99
|
const { SandboxManager } = await loadSrt()
|
|
73
100
|
if (!SandboxManager.isSupportedPlatform()) {
|
|
74
|
-
|
|
75
|
-
return availabilityCache
|
|
101
|
+
return { available: false, reason: `platform ${process.platform} not supported` }
|
|
76
102
|
}
|
|
77
103
|
const deps = SandboxManager.checkDependencies()
|
|
78
|
-
if (deps.errors.length > 0) {
|
|
79
|
-
availabilityCache = { available: false, reason: deps.errors.join('; ') }
|
|
80
|
-
return availabilityCache
|
|
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.
|
|
104
|
+
if (deps.errors.length > 0) return { available: false, reason: deps.errors.join('; ') }
|
|
85
105
|
if (process.platform === 'linux') {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
await initSandbox()
|
|
107
|
+
return trySandboxedTrue(SandboxManager, weakerNested)
|
|
108
|
+
}
|
|
109
|
+
return applyPolicyHere()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Can a seatbelt policy be applied in THIS process at all?
|
|
114
|
+
*
|
|
115
|
+
* Not if one already is: macOS refuses `sandbox_apply` inside a sandbox,
|
|
116
|
+
* at any permission level — an inner profile of `(allow default)` still
|
|
117
|
+
* dies `sandbox_apply: Operation not permitted` (exit 71, measured
|
|
118
|
+
* 2026-09-05). No grant fixes it, so the honest verdict is "unavailable",
|
|
119
|
+
* which is what stops a sandboxed task from spawning tasks that report
|
|
120
|
+
* a sandbox they never got. Answering `available: true` here is how a
|
|
121
|
+
* suite that exercises the sandbox came to fail sixteen ways at once.
|
|
122
|
+
*
|
|
123
|
+
* The probe costs one `/usr/bin/true` and is memoized with the rest.
|
|
124
|
+
*/
|
|
125
|
+
function applyPolicyHere(): SandboxAvailability {
|
|
126
|
+
const proc = Bun.spawnSync({
|
|
127
|
+
cmd: ['sandbox-exec', '-p', '(version 1)(allow default)', '/usr/bin/true'],
|
|
128
|
+
stdout: 'ignore',
|
|
129
|
+
stderr: 'pipe',
|
|
130
|
+
})
|
|
131
|
+
if (proc.exitCode === 0) return { available: true, reason: '' }
|
|
132
|
+
const stderr = proc.stderr.toString().trim()
|
|
133
|
+
if (stderr.includes('sandbox_apply')) {
|
|
134
|
+
return {
|
|
135
|
+
available: false,
|
|
136
|
+
reason:
|
|
137
|
+
'this process is already sandboxed and macOS cannot nest one — a task that ' +
|
|
138
|
+
'itself sandboxes has to run without `exec.sandbox` of its own',
|
|
90
139
|
}
|
|
91
140
|
}
|
|
92
|
-
|
|
93
|
-
|
|
141
|
+
return { available: false, reason: `sandbox-exec failed: ${stderr || `exit ${proc.exitCode}`}` }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Why the probe's sandboxed `true` failed, in the user's terms. The hint
|
|
146
|
+
* names the CONFIG field (`exec.sandbox.weakerWhenNested`), not the
|
|
147
|
+
* runtime option it maps to — a user who followed the runtime's name into
|
|
148
|
+
* their config met the loader's unknown-field refusal instead of a fix
|
|
149
|
+
* (`tests/sandbox-hint.test.ts` pins every field the hint names against
|
|
150
|
+
* the loader).
|
|
151
|
+
*/
|
|
152
|
+
export function unavailableReason(exitCode: number | null, stderr: string): string {
|
|
153
|
+
const hint = stderr.includes('uid_map')
|
|
154
|
+
? " — 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"
|
|
155
|
+
: ''
|
|
156
|
+
return `a sandboxed \`true\` failed (exit ${exitCode}): ${stderr.slice(0, 200)}${hint}`
|
|
94
157
|
}
|
|
95
158
|
|
|
96
|
-
async function
|
|
159
|
+
async function trySandboxedTrue(
|
|
160
|
+
SandboxManager: SrtModule['SandboxManager'],
|
|
161
|
+
weakerNested: boolean,
|
|
162
|
+
): Promise<SandboxAvailability> {
|
|
97
163
|
try {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
164
|
+
const wrapped = await SandboxManager.wrapWithSandbox(
|
|
165
|
+
'true',
|
|
166
|
+
undefined,
|
|
167
|
+
weakerNested ? { enableWeakerNestedSandbox: true } : undefined,
|
|
101
168
|
)
|
|
102
|
-
const
|
|
169
|
+
const proc = Bun.spawn(['sh', '-c', wrapped], {
|
|
170
|
+
stdout: 'ignore',
|
|
171
|
+
stderr: 'pipe',
|
|
172
|
+
stdin: 'ignore',
|
|
173
|
+
})
|
|
174
|
+
const stderr = (await new Response(proc.stderr).text()).trim()
|
|
103
175
|
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: '' }
|
|
176
|
+
if (proc.exitCode === 0) return { available: true, reason: '' }
|
|
177
|
+
return { available: false, reason: unavailableReason(proc.exitCode, stderr) }
|
|
111
178
|
} catch (err) {
|
|
112
|
-
return { available: false, reason: `
|
|
179
|
+
return { available: false, reason: `sandbox probe threw: ${(err as Error).message}` }
|
|
113
180
|
}
|
|
114
181
|
}
|
|
115
182
|
|
|
@@ -123,7 +190,7 @@ async function tryBwrapOnce(): Promise<SandboxAvailability> {
|
|
|
123
190
|
*
|
|
124
191
|
* Format mirrors SRT's: `'*'` is a wildcard pattern; entries in the
|
|
125
192
|
* array are substring-matched against the violation details line.
|
|
126
|
-
* Users can ADD to this via per-task `sandbox.
|
|
193
|
+
* Users can ADD to this via per-task `sandbox.ignore`;
|
|
127
194
|
* those are applied at violation read-back time, on top of the
|
|
128
195
|
* defaults installed here.
|
|
129
196
|
*/
|
|
@@ -142,24 +209,75 @@ const DEFAULT_IGNORE_VIOLATIONS: Record<string, string[]> = {
|
|
|
142
209
|
* Per-task wrapping passes a customConfig that re-enables network for
|
|
143
210
|
* tasks with `sandbox.network: true`.
|
|
144
211
|
*/
|
|
145
|
-
|
|
212
|
+
/**
|
|
213
|
+
* The temp directory SRT hands every sandboxed task. It overrides `TMPDIR`
|
|
214
|
+
* so temp-file writers land somewhere its filesystem policy already allows,
|
|
215
|
+
* and it deliberately does NOT create the directory — its own comment says
|
|
216
|
+
* "/tmp/claude may not exist". Nobody else does either, so on any machine
|
|
217
|
+
* that is not Claude Code's own, every sandboxed task that writes a temp
|
|
218
|
+
* file died with ENOENT: this repo's `bun build --compile` under
|
|
219
|
+
* a sandboxed `bun build --compile` reported only `error: An unknown error occurred
|
|
220
|
+
* (Unexpected)` (2026-09-04). Resolution mirrors SRT's exactly.
|
|
221
|
+
*/
|
|
222
|
+
function sandboxTmpdir(): string {
|
|
223
|
+
// `process.env`, not `Bun.env`: a caller that REPLACES the env object
|
|
224
|
+
// (two tests here do, and an embedder may) leaves `Bun.env` pointing at
|
|
225
|
+
// the original, so a late assignment would be invisible.
|
|
226
|
+
const named = process.env['CLAUDE_CODE_TMPDIR'] ?? process.env['CLAUDE_TMPDIR']
|
|
227
|
+
return named !== undefined && named !== '' ? named : '/tmp/claude'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param opts.allowedDomains every domain any sandboxed task in this run
|
|
232
|
+
* declared. SRT's filtering proxy is per-RUN and reads its allowlist from
|
|
233
|
+
* this call, never from the per-task config, so the union is the only
|
|
234
|
+
* place a domain list can take effect. Per-task precision survives where
|
|
235
|
+
* it matters: a task that declared none is never handed the proxy port.
|
|
236
|
+
*/
|
|
237
|
+
export async function initSandbox(opts?: {
|
|
238
|
+
allowedDomains?: readonly string[]
|
|
239
|
+
/**
|
|
240
|
+
* Lift SRT's seccomp block on `socket(AF_UNIX)` for every sandboxed
|
|
241
|
+
* task of the run (Linux; per-run like the proxy allowlist, since SRT
|
|
242
|
+
* reads it at `initialize()` only). Armed when any task declares
|
|
243
|
+
* `unixSockets` or a `localBinding` port list — the port bridge is a
|
|
244
|
+
* unix socket the task's side has to create.
|
|
245
|
+
*/
|
|
246
|
+
allowAllUnixSockets?: boolean
|
|
247
|
+
}): Promise<void> {
|
|
248
|
+
// Before SRT starts, so the very first task already has one.
|
|
249
|
+
await mkdir(sandboxTmpdir(), { recursive: true })
|
|
146
250
|
const { SandboxManager } = await loadSrt()
|
|
147
|
-
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
251
|
+
const config: Parameters<typeof SandboxManager.initialize>[0] = {
|
|
252
|
+
network: {
|
|
253
|
+
allowedDomains: [...(opts?.allowedDomains ?? [])],
|
|
254
|
+
deniedDomains: [],
|
|
255
|
+
...(opts?.allowAllUnixSockets === true ? { allowAllUnixSockets: true } : {}),
|
|
152
256
|
},
|
|
257
|
+
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
|
|
258
|
+
ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
|
|
259
|
+
}
|
|
260
|
+
await SandboxManager.initialize(
|
|
261
|
+
config,
|
|
153
262
|
undefined,
|
|
154
263
|
// enableLogMonitor — macOS-only; populates the SandboxViolationStore.
|
|
155
264
|
true,
|
|
156
265
|
)
|
|
266
|
+
// `initialize()` returns early once SRT is up, and on Linux the
|
|
267
|
+
// availability probe brought it up with an EMPTY config before the run's
|
|
268
|
+
// own call — so the run's allowlist and unix-socket allowance never
|
|
269
|
+
// reached it (found 2026-09-10 by the port bridge: the task's side died
|
|
270
|
+
// on `socket(AF_UNIX)` with the flag set). `updateConfig` is SRT's hot
|
|
271
|
+
// reload of exactly these fields; the proxy and the wrapper read them
|
|
272
|
+
// through getters, so the run's config is what every task sees.
|
|
273
|
+
SandboxManager.updateConfig(config)
|
|
157
274
|
}
|
|
158
275
|
|
|
159
276
|
export async function resetSandbox(): Promise<void> {
|
|
277
|
+
for (const tag of [...hostBridges.keys()]) releaseBridges(tag)
|
|
160
278
|
const { SandboxManager } = await loadSrt()
|
|
161
279
|
await SandboxManager.reset()
|
|
162
|
-
availabilityCache
|
|
280
|
+
availabilityCache.clear()
|
|
163
281
|
straceAvailableCache = undefined
|
|
164
282
|
}
|
|
165
283
|
|
|
@@ -176,9 +294,6 @@ export interface SandboxedRunArgs {
|
|
|
176
294
|
timeoutMs?: number
|
|
177
295
|
/** See `CaptureConfig` — which streams are retained on the result. */
|
|
178
296
|
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
297
|
/**
|
|
183
298
|
* Baseline reads — paths the sandbox unconditionally allows. The
|
|
184
299
|
* caller builds this from resolved `cache.inputs.files`.
|
|
@@ -195,6 +310,12 @@ export interface SandboxedRunArgs {
|
|
|
195
310
|
* is forbidden. Pass `[workspaceRoot]` to enforce project boundaries.
|
|
196
311
|
*/
|
|
197
312
|
baseDenyRead: readonly string[]
|
|
313
|
+
/**
|
|
314
|
+
* Only denials on a path under this directory are reported. A denial
|
|
315
|
+
* outside it is still ENFORCED — the task cannot leave its project — but
|
|
316
|
+
* that is the wall doing its job, not a finding to fail a run over.
|
|
317
|
+
*/
|
|
318
|
+
readonly reportWithin: string
|
|
198
319
|
/**
|
|
199
320
|
* User-declared sandbox block (after path-resolution). Path lists are
|
|
200
321
|
* unioned with the baselines; bool/object fields fall through to SRT.
|
|
@@ -208,33 +329,25 @@ export interface SandboxedRunArgs {
|
|
|
208
329
|
* but every string in a path list is guaranteed absolute.
|
|
209
330
|
*/
|
|
210
331
|
export interface ResolvedSandboxConfig {
|
|
332
|
+
/** Readable path prefixes, absolute. */
|
|
211
333
|
allowRead: readonly string[]
|
|
334
|
+
/** Writable path prefixes, absolute. */
|
|
212
335
|
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))
|
|
336
|
+
network?: true | readonly string[]
|
|
337
|
+
denyNetwork?: readonly string[]
|
|
338
|
+
systemInfo?: readonly string[]
|
|
339
|
+
unixSockets?: true | readonly string[]
|
|
340
|
+
localBinding?: boolean | readonly number[]
|
|
341
|
+
machLookup?: readonly string[]
|
|
342
|
+
pty?: boolean
|
|
343
|
+
gitConfig?: boolean
|
|
344
|
+
weakerWhenNested?: boolean
|
|
345
|
+
weakerNetworkIsolation?: boolean
|
|
346
|
+
ignore?: {
|
|
347
|
+
read?: readonly string[]
|
|
348
|
+
write?: readonly string[]
|
|
349
|
+
systemInfo?: readonly string[]
|
|
350
|
+
network?: readonly string[]
|
|
238
351
|
}
|
|
239
352
|
}
|
|
240
353
|
|
|
@@ -250,12 +363,16 @@ function toRealPath(p: string): string {
|
|
|
250
363
|
* whatever the config, with an error naming an internal path the user has no
|
|
251
364
|
* way to act on. Canonicalizing here is what makes the two halves agree.
|
|
252
365
|
*/
|
|
253
|
-
|
|
366
|
+
interface CanonicalBaselines {
|
|
254
367
|
allowRead: string[]
|
|
255
368
|
allowWrite: string[]
|
|
256
369
|
denyRead: string[]
|
|
257
370
|
cwd: string
|
|
258
|
-
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function canonicalBaselines(
|
|
374
|
+
args: Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseAllowWrite' | 'baseDenyRead' | 'cwd'>,
|
|
375
|
+
): CanonicalBaselines {
|
|
259
376
|
return {
|
|
260
377
|
allowRead: args.baseAllowRead.map(toRealPath),
|
|
261
378
|
allowWrite: args.baseAllowWrite.map(toRealPath),
|
|
@@ -279,20 +396,35 @@ export function resolveSandboxConfig(
|
|
|
279
396
|
if (path.isAbsolute(p)) return toRealPath(p)
|
|
280
397
|
return toRealPath(path.resolve(projectDir, p))
|
|
281
398
|
}
|
|
399
|
+
const a = cfg.allow ?? {}
|
|
282
400
|
const r: ResolvedSandboxConfig = {
|
|
283
|
-
allowRead: (
|
|
284
|
-
allowWrite: (
|
|
401
|
+
allowRead: expandGrants((a.read ?? []).map(resolve)),
|
|
402
|
+
allowWrite: expandGrants((a.write ?? []).map(resolve)),
|
|
285
403
|
}
|
|
286
|
-
if (
|
|
287
|
-
if (cfg.network !== undefined) r.
|
|
288
|
-
if (
|
|
289
|
-
if (
|
|
290
|
-
|
|
404
|
+
if (a.network !== undefined) r.network = a.network
|
|
405
|
+
if (cfg.deny?.network !== undefined) r.denyNetwork = cfg.deny.network
|
|
406
|
+
if (a.systemInfo !== undefined) r.systemInfo = a.systemInfo
|
|
407
|
+
if (a.unixSockets !== undefined) r.unixSockets = a.unixSockets
|
|
408
|
+
if (a.localBinding !== undefined) r.localBinding = a.localBinding
|
|
409
|
+
if (a.machLookup !== undefined) r.machLookup = a.machLookup
|
|
410
|
+
if (a.pty !== undefined) r.pty = a.pty
|
|
411
|
+
if (a.gitConfig !== undefined) r.gitConfig = a.gitConfig
|
|
412
|
+
if (cfg.weakerWhenNested !== undefined) r.weakerWhenNested = cfg.weakerWhenNested
|
|
413
|
+
if (cfg.weakerNetworkIsolation !== undefined) {
|
|
414
|
+
r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
|
|
291
415
|
}
|
|
292
|
-
if (cfg.
|
|
293
|
-
|
|
416
|
+
if (cfg.ignore !== undefined) {
|
|
417
|
+
// Relative patterns anchor at the project dir; absolute and `~` ones
|
|
418
|
+
// are taken as written. NOT realpath'd — a pattern is not a path.
|
|
419
|
+
const anchor = (pat: string): string =>
|
|
420
|
+
pat.startsWith('~') || path.isAbsolute(pat) ? pat : path.join(projectDir, pat)
|
|
421
|
+
r.ignore = {
|
|
422
|
+
...(cfg.ignore.read ? { read: cfg.ignore.read.map(anchor) } : {}),
|
|
423
|
+
...(cfg.ignore.write ? { write: cfg.ignore.write.map(anchor) } : {}),
|
|
424
|
+
...(cfg.ignore.systemInfo ? { systemInfo: [...cfg.ignore.systemInfo] } : {}),
|
|
425
|
+
...(cfg.ignore.network ? { network: [...(cfg.ignore.network as string[])] } : {}),
|
|
426
|
+
}
|
|
294
427
|
}
|
|
295
|
-
if (cfg.ignoreViolations !== undefined) r.ignoreViolations = cfg.ignoreViolations
|
|
296
428
|
return r
|
|
297
429
|
}
|
|
298
430
|
|
|
@@ -300,6 +432,17 @@ export interface SandboxViolation {
|
|
|
300
432
|
/** Raw log line from SRT. Format differs between macOS / Linux. */
|
|
301
433
|
line: string
|
|
302
434
|
timestamp: Date
|
|
435
|
+
/**
|
|
436
|
+
* What the denial named, and which `ignore` lists could silence it —
|
|
437
|
+
* filled in by whichever platform produced the record. The filters read
|
|
438
|
+
* these instead of re-parsing a line whose shape depends on the OS: a
|
|
439
|
+
* seatbelt `deny(1) file-read-data /x` and a strace
|
|
440
|
+
* `openat(../x) = -1 ENOENT [/x]` say the same thing.
|
|
441
|
+
*/
|
|
442
|
+
target?: string
|
|
443
|
+
/** Absolute, when `target` is a path at all. */
|
|
444
|
+
path?: string
|
|
445
|
+
ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
|
|
303
446
|
}
|
|
304
447
|
|
|
305
448
|
export interface SandboxedRunResult extends RunResult {
|
|
@@ -317,9 +460,24 @@ export interface SandboxedRunResult extends RunResult {
|
|
|
317
460
|
* across packages) would otherwise collide. We prepend `: '<tag>';`
|
|
318
461
|
* (shell no-op) to make every command's first 100 chars unique.
|
|
319
462
|
*/
|
|
320
|
-
|
|
463
|
+
/**
|
|
464
|
+
* The sandboxed form of a command: SRT's wrapper over the tagged command,
|
|
465
|
+
* with vx's own seatbelt rules appended on macOS. This is the ENFORCEMENT
|
|
466
|
+
* half of `runSandboxed`, shared with persistent tasks — a dev server is
|
|
467
|
+
* spawned through it and never exits while the run watches, so it gets
|
|
468
|
+
* the same walls and no violation report (reporting reads the trace after
|
|
469
|
+
* exit). Returns the wrapped command and the tag the store keys by.
|
|
470
|
+
*/
|
|
471
|
+
export async function wrapSandboxedCommand(
|
|
472
|
+
args: Pick<SandboxedRunArgs, 'command' | 'cwd' | 'forwardArgs' | 'config'> &
|
|
473
|
+
Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseAllowWrite' | 'baseDenyRead'>,
|
|
474
|
+
): Promise<{
|
|
475
|
+
wrapped: string
|
|
476
|
+
tag: string
|
|
477
|
+
taggedCommand: string
|
|
478
|
+
baselines: CanonicalBaselines
|
|
479
|
+
}> {
|
|
321
480
|
const { SandboxManager } = await loadSrt()
|
|
322
|
-
const start = Date.now()
|
|
323
481
|
const userCommand =
|
|
324
482
|
args.forwardArgs && args.forwardArgs.length > 0
|
|
325
483
|
? args.command + ' ' + args.forwardArgs.map(shellQuote).join(' ')
|
|
@@ -330,7 +488,100 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
330
488
|
|
|
331
489
|
const baselines = canonicalBaselines(args)
|
|
332
490
|
const customConfig = buildCustomConfig(args, baselines)
|
|
333
|
-
|
|
491
|
+
// Linux: the ports a list grants are bridged out of the task's network
|
|
492
|
+
// namespace. The task's side of each bridge is a socat in front of the
|
|
493
|
+
// user command, so it goes INTO the sandboxed command; the host side is
|
|
494
|
+
// spawned here and released when the task's process ends.
|
|
495
|
+
const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
|
|
496
|
+
const inner = ports.length > 0 ? `${portBridgeInner(ports, tag)} ${taggedCommand}` : taggedCommand
|
|
497
|
+
let wrapped = await SandboxManager.wrapWithSandbox(inner, undefined, customConfig)
|
|
498
|
+
if (process.platform === 'darwin') {
|
|
499
|
+
const rules = macProfileRules(args.config)
|
|
500
|
+
if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
|
|
501
|
+
}
|
|
502
|
+
if (ports.length > 0) spawnHostBridges(ports, tag)
|
|
503
|
+
return { wrapped, tag, taggedCommand, baselines }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** The ports a list grants, deduped; `true` bridges nothing (the host sees no port on Linux). */
|
|
507
|
+
export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): number[] {
|
|
508
|
+
return Array.isArray(c.localBinding) ? [...new Set(c.localBinding)] : []
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
|
|
512
|
+
export function portBridgeSocket(tag: string, port: number): string {
|
|
513
|
+
return path.join(sandboxTmpdir(), `vx-port-${tag}-${port}.sock`)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The task's side of the bridge, in front of the user command inside the
|
|
518
|
+
* sandbox: one socat per port, listening on the unix socket and relaying
|
|
519
|
+
* into the namespace's loopback. Backgrounded and reaped with the shell,
|
|
520
|
+
* exactly as SRT starts its own proxy bridges. `unlink-early` clears a
|
|
521
|
+
* socket a killed task left behind; `>/dev/null` keeps its chatter out
|
|
522
|
+
* of the task's frame.
|
|
523
|
+
*/
|
|
524
|
+
export function portBridgeInner(ports: readonly number[], tag: string): string {
|
|
525
|
+
const cmds = ports.map(
|
|
526
|
+
(p) =>
|
|
527
|
+
`socat UNIX-LISTEN:${shellQuote(portBridgeSocket(tag, p))},fork,unlink-early TCP:127.0.0.1:${p} >/dev/null 2>&1 &`,
|
|
528
|
+
)
|
|
529
|
+
return `${cmds.join(' ')} trap 'kill $(jobs -p) 2>/dev/null' EXIT;`
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The host's side: one socat per port, listening on the host's loopback
|
|
534
|
+
* and connecting into the unix socket per client — with a retry, since a
|
|
535
|
+
* client can arrive before the task's side has bound the socket.
|
|
536
|
+
*/
|
|
537
|
+
export function portBridgeHostArgv(tag: string, port: number): string[] {
|
|
538
|
+
return [
|
|
539
|
+
'socat',
|
|
540
|
+
`TCP-LISTEN:${port},bind=127.0.0.1,fork,reuseaddr`,
|
|
541
|
+
`UNIX-CONNECT:${portBridgeSocket(tag, port)},retry=40,interval=0.25`,
|
|
542
|
+
]
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const hostBridges = new Map<string, Array<ReturnType<typeof Bun.spawn>>>()
|
|
546
|
+
|
|
547
|
+
function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
548
|
+
const procs: Array<ReturnType<typeof Bun.spawn>> = []
|
|
549
|
+
for (const p of ports) {
|
|
550
|
+
// A spawn failure (no socat on the host) is the task's to report:
|
|
551
|
+
// its own side dies the same way, in its frame.
|
|
552
|
+
try {
|
|
553
|
+
procs.push(
|
|
554
|
+
Bun.spawn(portBridgeHostArgv(tag, p), {
|
|
555
|
+
stdin: 'ignore',
|
|
556
|
+
stdout: 'ignore',
|
|
557
|
+
stderr: 'ignore',
|
|
558
|
+
}),
|
|
559
|
+
)
|
|
560
|
+
} catch {
|
|
561
|
+
// see above
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (procs.length > 0) hostBridges.set(tag, procs)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Stop the host side of a task's port bridges; idempotent. */
|
|
568
|
+
export function releaseBridges(tag: string): void {
|
|
569
|
+
const procs = hostBridges.get(tag)
|
|
570
|
+
if (procs === undefined) return
|
|
571
|
+
hostBridges.delete(tag)
|
|
572
|
+
for (const p of procs) {
|
|
573
|
+
try {
|
|
574
|
+
p.kill('SIGTERM')
|
|
575
|
+
} catch {
|
|
576
|
+
// already gone
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
|
|
582
|
+
const start = Date.now()
|
|
583
|
+
const { SandboxManager } = await loadSrt()
|
|
584
|
+
const { wrapped, tag, taggedCommand, baselines } = await wrapSandboxedCommand(args)
|
|
334
585
|
|
|
335
586
|
// Linux: SRT's SandboxViolationStore is macOS-only, so structured
|
|
336
587
|
// detection on Linux requires us to wrap the spawn with strace and
|
|
@@ -345,8 +596,31 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
345
596
|
// are mostly shell PATH-walking and stat probes that aren't
|
|
346
597
|
// actionable (we'd report every node_modules/.bin entry the shell
|
|
347
598
|
// checks before resolving a command).
|
|
599
|
+
//
|
|
600
|
+
// `--seccomp-bpf` is what makes that filter cheap: without it strace
|
|
601
|
+
// ptrace-stops the tracee on EVERY syscall and discards the untraced
|
|
602
|
+
// ones in userspace, so a stat-heavy task ran many times slower under
|
|
603
|
+
// the sandbox than outside it. That is what failed the cache perf
|
|
604
|
+
// baselines on the Linux job (reproduced 2026-09-09: plain `bun test`
|
|
605
|
+
// 24/24; under `strace -f -e trace=openat` the same four fail with
|
|
606
|
+
// medians 2.5–7× over budget; with `--seccomp-bpf` 24/24 again), and
|
|
607
|
+
// it taxed every other sandboxed task the same way. With the flag the
|
|
608
|
+
// kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
|
|
609
|
+
// one gets the slow form rather than no detection.
|
|
348
610
|
const spawnArgv = straceLog
|
|
349
|
-
? [
|
|
611
|
+
? [
|
|
612
|
+
'strace',
|
|
613
|
+
'-f',
|
|
614
|
+
...(useStrace === 'seccomp' ? ['--seccomp-bpf'] : []),
|
|
615
|
+
'-e',
|
|
616
|
+
'trace=openat',
|
|
617
|
+
'-o',
|
|
618
|
+
straceLog,
|
|
619
|
+
'--',
|
|
620
|
+
'sh',
|
|
621
|
+
'-c',
|
|
622
|
+
wrapped,
|
|
623
|
+
]
|
|
350
624
|
: ['sh', '-c', wrapped]
|
|
351
625
|
|
|
352
626
|
let proc: ReturnType<typeof Bun.spawn>
|
|
@@ -385,6 +659,7 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
385
659
|
else await drainOrAbort(streams, ac)
|
|
386
660
|
const [stdout, stderr] = await streams
|
|
387
661
|
args.liveChildren?.delete(proc)
|
|
662
|
+
releaseBridges(tag)
|
|
388
663
|
const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
389
664
|
|
|
390
665
|
// macOS: read the violation store keyed by our tagged command.
|
|
@@ -405,54 +680,34 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
405
680
|
// macOS is therefore lossy-by-OS under load; ENFORCEMENT is unaffected
|
|
406
681
|
// (every observed loss still denied the read and failed the child).
|
|
407
682
|
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
|
-
}
|
|
683
|
+
// EVERY record the store holds for this command, unfiltered (owner,
|
|
684
|
+
// 2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
685
|
+
// deciding on the user's behalf that a record was "just traversal" is how
|
|
686
|
+
// a failure ends up with an empty violations section and no explanation.
|
|
429
687
|
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
|
-
|
|
688
|
+
store.getViolationsForCommand(taggedCommand).map((v) => ({
|
|
689
|
+
line: v.line,
|
|
690
|
+
timestamp: v.timestamp,
|
|
691
|
+
}))
|
|
692
|
+
// Darwin only. Since SRT 0.0.75 the store is fed on Linux too, by the
|
|
693
|
+
// seccomp helper's write observer — but SRT judges those reports against
|
|
694
|
+
// the GLOBAL `filesystem.allowWrite` from `initialize` (empty here; the
|
|
695
|
+
// per-task list travels in `customConfig`, which the monitor never sees),
|
|
696
|
+
// so every write a task makes to its own declared output arrived as
|
|
697
|
+
// `deny openat <output>` and failed the task (reproduced 2026-09-04 in a
|
|
698
|
+
// Linux container: exit 1, empty stderr). Linux detection is the strace
|
|
699
|
+
// pass below, judged against the task's own baselines.
|
|
700
|
+
let macViolations = process.platform === 'darwin' ? readMacViolations() : []
|
|
701
|
+
// No settle window: the store is read once, right after the child exits
|
|
702
|
+
// (owner, 2026-09-05). It cost 300ms on EVERY clean sandboxed task — the
|
|
703
|
+
// full budget, since a task with nothing to report can only prove that by
|
|
704
|
+
// waiting — against 26ms for one that reports something and breaks out
|
|
705
|
+
// early. Measured: clean `echo` 331ms sandboxed vs 16ms plain.
|
|
706
|
+
//
|
|
707
|
+
// The price is that macOS feeds this store asynchronously, so a record
|
|
708
|
+
// that has not arrived yet is not reported. Enforcement is unaffected —
|
|
709
|
+
// the OS denied the operation either way, and a command that could not
|
|
710
|
+
// proceed still fails on its own exit code.
|
|
456
711
|
// Linux: parse the strace log and emit one violation per denied
|
|
457
712
|
// syscall on a path inside denyRead that wasn't unconditionally
|
|
458
713
|
// allowed. Best-effort — if parsing fails we surface no Linux
|
|
@@ -467,12 +722,33 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
467
722
|
// ignoreViolations through to the log monitor — that filter is set
|
|
468
723
|
// once globally at initSandbox time. So per-task user overrides have
|
|
469
724
|
// to be applied here, after read-back.
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
725
|
+
const violations = reportableViolations([...macViolations, ...linuxViolations], {
|
|
726
|
+
within: args.reportWithin,
|
|
727
|
+
config: args.config,
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
// The one denial macOS never logs. MEASURED 2026-09-05, same machine, two
|
|
731
|
+
// runs differing only in the grant: with the cwd granted a failing task
|
|
732
|
+
// reports its denials normally; WITHOUT it the store stays empty across
|
|
733
|
+
// the whole settle window (11 reads, 0 records) and the child reports
|
|
734
|
+
// whatever it likes — `bun test` says only `error: An unknown error
|
|
735
|
+
// occurred (Unexpected)`. The process dies before macOS logs anything, so
|
|
736
|
+
// no amount of un-filtering can surface it.
|
|
737
|
+
//
|
|
738
|
+
// Not a guess about the cause: the cwd lying outside every allowRead
|
|
739
|
+
// prefix is a fact of the resolved baselines. Added only when the task
|
|
740
|
+
// ALREADY failed with nothing to show, so it can never redden a pass.
|
|
741
|
+
const grantedRead = [...baselines.allowRead, ...args.config.allowRead]
|
|
742
|
+
if (exitCode !== 0 && violations.length === 0 && !readableUnder(args.cwd, grantedRead)) {
|
|
743
|
+
violations.push({
|
|
744
|
+
timestamp: new Date(),
|
|
745
|
+
line:
|
|
746
|
+
`vx: this sandbox grants no read access to the task's own working directory ` +
|
|
747
|
+
`(${args.cwd}), so a command that reads or lists it fails with whatever error it ` +
|
|
748
|
+
`reports for that — macOS logs no violation record. Add ` +
|
|
749
|
+
"`sandbox: { allow: { read: ['.'] } }`.",
|
|
750
|
+
})
|
|
751
|
+
}
|
|
476
752
|
|
|
477
753
|
try {
|
|
478
754
|
SandboxManager.cleanupAfterCommand()
|
|
@@ -492,257 +768,193 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
492
768
|
}
|
|
493
769
|
}
|
|
494
770
|
|
|
495
|
-
/**
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
straceAvailableCache = false
|
|
506
|
-
}
|
|
507
|
-
return straceAvailableCache
|
|
771
|
+
/**
|
|
772
|
+
* Is `dir` inside a path the sandbox granted for reading? bwrap binds and
|
|
773
|
+
* seatbelt rules are both prefix-based, so a grant covers the subtree.
|
|
774
|
+
*/
|
|
775
|
+
function readableUnder(dir: string, granted: readonly string[]): boolean {
|
|
776
|
+
const target = toRealPath(dir)
|
|
777
|
+
return granted.some((p) => {
|
|
778
|
+
const g = toRealPath(p)
|
|
779
|
+
return target === g || target.startsWith(g.endsWith(path.sep) ? g : g + path.sep)
|
|
780
|
+
})
|
|
508
781
|
}
|
|
509
782
|
|
|
510
783
|
/**
|
|
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).
|
|
784
|
+
* A token safe to interpolate into a seatbelt profile. The profile travels
|
|
785
|
+
* inside a shell-quoted `sandbox-exec -p '…'` argument, so a value carrying
|
|
786
|
+
* a quote, paren or backslash could rewrite the policy or escape the
|
|
787
|
+
* argument. Refuse rather than escape — every real info type is a plain
|
|
788
|
+
* dotted name.
|
|
534
789
|
*/
|
|
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
|
|
790
|
+
function sbplToken(value: string, field: string): string {
|
|
791
|
+
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
792
|
+
throw new UserError(`${field}: '${value}' is not a valid name`)
|
|
793
|
+
}
|
|
794
|
+
return value
|
|
551
795
|
}
|
|
552
796
|
|
|
553
797
|
/**
|
|
554
|
-
*
|
|
798
|
+
* The capabilities that reach the macOS profile as rules rather than
|
|
799
|
+
* through SRT's config.
|
|
555
800
|
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
801
|
+
* `systemInfo` has no SRT field at any level. The rest DO have fields,
|
|
802
|
+
* but SRT reads `network.allowLocalBinding` / `allowUnixSockets` /
|
|
803
|
+
* `allowMachLookup` off the config given to `initialize()` and never off
|
|
804
|
+
* the per-call one (`sandbox-manager.js` getters, 0.0.75) — so a per-task
|
|
805
|
+
* grant passed there is silently dropped. vx is per-task by definition,
|
|
806
|
+
* so it emits them itself. The rule text mirrors SRT's own.
|
|
559
807
|
*/
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
808
|
+
function macProfileRules(c: ResolvedSandboxConfig): string[] {
|
|
809
|
+
const rules: string[] = []
|
|
810
|
+
for (const t of c.systemInfo ?? []) {
|
|
811
|
+
rules.push(`(allow system-info (info-type "${sbplToken(t, 'allow.systemInfo')}"))`)
|
|
812
|
+
}
|
|
813
|
+
if (localBindingOn(c)) {
|
|
814
|
+
// `*:*`, not `localhost:*`: a dual-stack socket bound to 127.0.0.1 is
|
|
815
|
+
// ::ffff:127.0.0.1 in the kernel, which seatbelt's `localhost` does
|
|
816
|
+
// not match. bind and inbound carry no remote endpoint, so this is
|
|
817
|
+
// not egress. Egress stays pinned to loopback.
|
|
818
|
+
rules.push('(allow network-bind (local ip "*:*"))')
|
|
819
|
+
rules.push('(allow network-inbound (local ip "*:*"))')
|
|
820
|
+
rules.push('(allow network-outbound (remote ip "localhost:*"))')
|
|
821
|
+
}
|
|
822
|
+
if (c.unixSockets === true) {
|
|
823
|
+
rules.push('(allow system-socket (socket-domain AF_UNIX))')
|
|
824
|
+
rules.push('(allow network-bind (local unix-socket (path-regex #"^/")))')
|
|
825
|
+
rules.push('(allow network-outbound (remote unix-socket (path-regex #"^/")))')
|
|
826
|
+
} else if (c.unixSockets !== undefined && c.unixSockets.length > 0) {
|
|
827
|
+
rules.push('(allow system-socket (socket-domain AF_UNIX))')
|
|
828
|
+
for (const sock of c.unixSockets) {
|
|
829
|
+
// Both the declared path and what it resolves to: seatbelt matches the
|
|
830
|
+
// path the kernel sees, and on macOS `/tmp` is a symlink to
|
|
831
|
+
// `/private/tmp` — a grant on the former alone never matches.
|
|
832
|
+
for (const p of unique([sbplPath(sock, 'allow.unixSockets'), toRealPath(sock)])) {
|
|
833
|
+
rules.push(`(allow network-bind (local unix-socket (subpath "${p}")))`)
|
|
834
|
+
rules.push(`(allow network-outbound (remote unix-socket (subpath "${p}")))`)
|
|
835
|
+
}
|
|
587
836
|
}
|
|
588
837
|
}
|
|
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
|
-
})
|
|
838
|
+
for (const name of c.machLookup ?? []) {
|
|
839
|
+
rules.push(`(allow mach-lookup (global-name "${sbplToken(name, 'allow.machLookup')}"))`)
|
|
627
840
|
}
|
|
628
|
-
return
|
|
841
|
+
return rules
|
|
629
842
|
}
|
|
630
843
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
if (allow.has(abs)) return true
|
|
639
|
-
for (const a of allow) {
|
|
640
|
-
if (abs === a || abs.startsWith(a + path.sep)) return true
|
|
844
|
+
/**
|
|
845
|
+
* A path safe to interpolate into a seatbelt profile. Same reasoning as
|
|
846
|
+
* {@link sbplToken}, with the separators a path needs.
|
|
847
|
+
*/
|
|
848
|
+
function sbplPath(value: string, field: string): string {
|
|
849
|
+
if (!/^[A-Za-z0-9._\-/@+]+$/.test(value) || value.includes('..')) {
|
|
850
|
+
throw new UserError(`${field}: '${value}' is not a valid path`)
|
|
641
851
|
}
|
|
642
|
-
return
|
|
852
|
+
return value
|
|
643
853
|
}
|
|
644
854
|
|
|
645
855
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
856
|
+
* Add rules to the END of the seatbelt profile SRT generated.
|
|
857
|
+
*
|
|
858
|
+
* SBPL is last-match-wins, so the tail is the only place a rule of ours
|
|
859
|
+
* outranks one of SRT's. Measured 2026-09-05: the same rules injected
|
|
860
|
+
* after the `(deny default …)` header were inert in both directions —
|
|
861
|
+
* SRT's own later clauses won. Filesystem capabilities still go through
|
|
862
|
+
* SRT's config, where they also work on Linux; what arrives here is what
|
|
863
|
+
* SRT's per-call config CANNOT carry.
|
|
653
864
|
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
865
|
+
* A profile that does not have the expected shape THROWS. Silently
|
|
866
|
+
* running a task without a capability it asked for is worse than not
|
|
867
|
+
* offering the capability.
|
|
656
868
|
*/
|
|
657
|
-
function
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
869
|
+
function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
870
|
+
const anchor = /sandbox-exec -p '\(version 1\)\n\(deny default[^\n]*\n/.exec(wrapped)
|
|
871
|
+
if (anchor === null) {
|
|
872
|
+
throw new UserError(
|
|
873
|
+
'sandbox: the OS sandbox policy did not have the expected shape, so the ' +
|
|
874
|
+
'capabilities this task declared could not be applied',
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
// The profile is a single-quoted shell argument; SRT escapes a literal
|
|
878
|
+
// quote inside it as `'"'"'` (5 chars). The first bare `'` after the
|
|
879
|
+
// header therefore closes the profile.
|
|
880
|
+
let i = anchor.index + anchor[0].length
|
|
881
|
+
for (;;) {
|
|
882
|
+
const q = wrapped.indexOf("'", i)
|
|
883
|
+
if (q === -1) {
|
|
884
|
+
throw new UserError(
|
|
885
|
+
'sandbox: the OS sandbox policy was not quoted as expected, so the ' +
|
|
886
|
+
'capabilities this task declared could not be applied',
|
|
887
|
+
)
|
|
671
888
|
}
|
|
672
|
-
|
|
673
|
-
|
|
889
|
+
if (wrapped.startsWith(`'"'"'`, q)) {
|
|
890
|
+
i = q + 5
|
|
891
|
+
continue
|
|
892
|
+
}
|
|
893
|
+
return `${wrapped.slice(0, q)}\n${rules.join('\n')}\n${wrapped.slice(q)}`
|
|
894
|
+
}
|
|
674
895
|
}
|
|
675
896
|
|
|
676
897
|
/**
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
898
|
+
* Grant paths, with globs handled per platform.
|
|
899
|
+
*
|
|
900
|
+
* macOS: SRT's own `pathFilter` turns a glob into `(regex …)` and a literal
|
|
901
|
+
* into `(subpath …)`, so a pattern is passed through and seatbelt matches
|
|
902
|
+
* it — including files created DURING the run.
|
|
680
903
|
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
904
|
+
* Linux: a grant is a bwrap bind mount, and you cannot mount a pattern.
|
|
905
|
+
* The glob is expanded against the filesystem here, which means it covers
|
|
906
|
+
* what exists when the task STARTS. A pattern matching a file the task
|
|
907
|
+
* creates later grants nothing there — declare its directory instead.
|
|
685
908
|
*/
|
|
686
|
-
function
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
: {}),
|
|
909
|
+
function expandGrants(paths: readonly string[]): string[] {
|
|
910
|
+
// A pattern covering a directory WHOLE is that directory. `<d>/**/*` and
|
|
911
|
+
// `<d>/**` match everything UNDER `<d>` and never `<d>` itself, so a task
|
|
912
|
+
// granted `read: ['**/*']` still could not list its own cwd — the exact
|
|
913
|
+
// shape `bun test` and `oxlint` need. Collapsing is not a widening: the
|
|
914
|
+
// pattern already covered every file there; it adds the directory entry.
|
|
915
|
+
const collapsed = paths.map((p) => {
|
|
916
|
+
const m = /^(.*?)\/\*\*(?:\/\*)?$/.exec(p)
|
|
917
|
+
return m === null ? p : m[1]!
|
|
918
|
+
})
|
|
919
|
+
if (process.platform !== 'linux') return collapsed
|
|
920
|
+
const out: string[] = []
|
|
921
|
+
for (const p of collapsed) {
|
|
922
|
+
if (!/[*?[\]]/.test(p)) {
|
|
923
|
+
out.push(p)
|
|
924
|
+
continue
|
|
925
|
+
}
|
|
926
|
+
// Anchor the scan at the longest literal prefix so a pattern does not
|
|
927
|
+
// walk the whole filesystem to find its matches.
|
|
928
|
+
const base = path.dirname(p.slice(0, p.search(/[*?[\]]/)))
|
|
929
|
+
const pattern = path.relative(base, p)
|
|
930
|
+
for (const hit of new Bun.Glob(pattern).scanSync({ cwd: base, onlyFiles: false, dot: true })) {
|
|
931
|
+
out.push(path.join(base, hit))
|
|
729
932
|
}
|
|
730
|
-
} else {
|
|
731
|
-
// false / undefined → block all
|
|
732
|
-
custom.network = { allowedDomains: [], deniedDomains: [] }
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
if (c.allowPty !== undefined) custom.allowPty = c.allowPty
|
|
736
|
-
if (c.enableWeakerNestedSandbox !== undefined) {
|
|
737
|
-
custom.enableWeakerNestedSandbox = c.enableWeakerNestedSandbox
|
|
738
|
-
}
|
|
739
|
-
if (c.enableWeakerNetworkIsolation !== undefined) {
|
|
740
|
-
custom.enableWeakerNetworkIsolation = c.enableWeakerNetworkIsolation
|
|
741
933
|
}
|
|
742
|
-
|
|
743
|
-
return custom
|
|
934
|
+
return out
|
|
744
935
|
}
|
|
745
936
|
|
|
746
|
-
|
|
747
|
-
|
|
937
|
+
/**
|
|
938
|
+
* Memoized check: is `strace` on PATH on a Linux host, and does it know
|
|
939
|
+
* `--seccomp-bpf` (5.3+)? `'seccomp'` is the fast form; `'plain'` traces
|
|
940
|
+
* every syscall through ptrace and is kept only for an old strace.
|
|
941
|
+
*/
|
|
942
|
+
let straceAvailableCache: false | 'plain' | 'seccomp' | undefined
|
|
943
|
+
async function wantsStraceDetection(): Promise<false | 'plain' | 'seccomp'> {
|
|
944
|
+
if (process.platform !== 'linux') return false
|
|
945
|
+
if (straceAvailableCache !== undefined) return straceAvailableCache
|
|
946
|
+
try {
|
|
947
|
+
const p = Bun.spawn(['strace', '--version'], { stdout: 'pipe', stderr: 'ignore' })
|
|
948
|
+
const out = await new Response(p.stdout).text()
|
|
949
|
+
await p.exited
|
|
950
|
+
if (p.exitCode !== 0) straceAvailableCache = false
|
|
951
|
+
else {
|
|
952
|
+
const m = /version (\d+)\.(\d+)/.exec(out)
|
|
953
|
+
const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
|
|
954
|
+
straceAvailableCache = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
|
|
955
|
+
}
|
|
956
|
+
} catch {
|
|
957
|
+
straceAvailableCache = false
|
|
958
|
+
}
|
|
959
|
+
return straceAvailableCache
|
|
748
960
|
}
|