@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.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. 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 { realpathSync } from 'node:fs'
29
- import { unlink } from 'node:fs/promises'
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
- let availabilityCache: SandboxAvailability | undefined
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 binary
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 a minimal bwrap invocation on
65
- * Linux to catch the "bwrap installed but unprivileged user namespaces
66
- * blocked by AppArmor / sysctl" case that's the default on stock
67
- * Ubuntu 24.04. Without this real-execution probe, the unavailability
68
- * surfaces only at the first task spawn, deep inside the orchestrator.
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(): Promise<SandboxAvailability> {
71
- if (availabilityCache) return availabilityCache
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
- availabilityCache = { available: false, reason: `platform ${process.platform} not supported` }
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
- const ok = await tryBwrapOnce()
87
- if (!ok.available) {
88
- availabilityCache = ok
89
- return availabilityCache
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
- availabilityCache = { available: true, reason: '' }
93
- return availabilityCache
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 tryBwrapOnce(): Promise<SandboxAvailability> {
159
+ async function trySandboxedTrue(
160
+ SandboxManager: SrtModule['SandboxManager'],
161
+ weakerNested: boolean,
162
+ ): Promise<SandboxAvailability> {
97
163
  try {
98
- const proc = Bun.spawn(
99
- ['bwrap', '--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '/bin/true'],
100
- { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' },
164
+ const wrapped = await SandboxManager.wrapWithSandbox(
165
+ 'true',
166
+ undefined,
167
+ weakerNested ? { enableWeakerNestedSandbox: true } : undefined,
101
168
  )
102
- const stderr = await new Response(proc.stderr).text()
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 !== 0) {
105
- return {
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: `bwrap probe threw: ${(err as Error).message}` }
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.ignoreViolations`;
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
- export async function initSandbox(): Promise<void> {
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
- await SandboxManager.initialize(
148
- {
149
- network: { allowedDomains: [], deniedDomains: [] },
150
- filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
151
- ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
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 = undefined
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
- allowGitConfig?: boolean
214
- network?: boolean | SandboxNetworkConfig
215
- allowPty?: boolean
216
- enableWeakerNestedSandbox?: boolean
217
- enableWeakerNetworkIsolation?: boolean
218
- ignoreViolations?: Record<string, string[]>
219
- }
220
-
221
- /**
222
- * Canonicalize a path with realpath, tolerating paths that don't exist
223
- * yet: resolve the longest existing ancestor and re-append the rest.
224
- *
225
- * Why: the sandbox policy matches on canonical paths (macOS seatbelt
226
- * evaluates real vnode paths), and SRT's own normalization refuses to
227
- * canonicalize bare symlinked roots like `/tmp` → `/private/tmp` (its
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
- function canonicalBaselines(args: SandboxedRunArgs): {
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: (cfg.allowRead ?? []).map(resolve),
284
- allowWrite: (cfg.allowWrite ?? []).map(resolve),
401
+ allowRead: expandGrants((a.read ?? []).map(resolve)),
402
+ allowWrite: expandGrants((a.write ?? []).map(resolve)),
285
403
  }
286
- if (cfg.allowGitConfig !== undefined) r.allowGitConfig = cfg.allowGitConfig
287
- if (cfg.network !== undefined) r.network = cfg.network
288
- if (cfg.allowPty !== undefined) r.allowPty = cfg.allowPty
289
- if (cfg.enableWeakerNestedSandbox !== undefined) {
290
- r.enableWeakerNestedSandbox = cfg.enableWeakerNestedSandbox
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.enableWeakerNetworkIsolation !== undefined) {
293
- r.enableWeakerNetworkIsolation = cfg.enableWeakerNetworkIsolation
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
- export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
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
- const wrapped = await SandboxManager.wrapWithSandbox(taggedCommand, undefined, customConfig)
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
- ? ['strace', '-f', '-e', 'trace=openat', '-o', straceLog, '--', 'sh', '-c', wrapped]
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
- // Ancestor-directory traversal is NOT an input (owner call, 2026-08-24,
409
- // closing the recorded false-`undeclared-inputs` item): to reach its cwd
410
- // at all, a process stats/opens every directory on the path — macOS
411
- // reports those as `deny(1) file-read-data <ancestor-dir>` for dirs the
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
- .getViolationsForCommand(taggedCommand)
432
- .filter((v) => !isAncestorTraversal(v.line))
433
- .map((v) => ({
434
- line: v.line,
435
- timestamp: v.timestamp,
436
- }))
437
- let macViolations = readMacViolations()
438
- // The fail-exit gate keeps the warm path free — EXCEPT when the caller
439
- // says a clean exit + empty store will be read as PROOF (verify=inputs):
440
- // a leaky task that swallows its own read error exits 0, so without the
441
- // settle window a late unified-log record becomes a FALSE PASS of the
442
- // verify (measured locally 2026-08-24: 1/30 at idle, the same lossy
443
- // channel as the fail-exit case).
444
- const emptyStoreIsProof = args.settleOnCleanExit === true
445
- if (
446
- process.platform === 'darwin' &&
447
- (exitCode !== 0 || emptyStoreIsProof) &&
448
- macViolations.length === 0
449
- ) {
450
- for (let i = 0; i < 10 && macViolations.length === 0; i++) {
451
- await Bun.sleep(100)
452
- macViolations = readMacViolations()
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 userIgnore = args.config.ignoreViolations
471
- const violations: SandboxViolation[] = filterIgnored(
472
- [...macViolations, ...linuxViolations],
473
- userIgnore,
474
- userCommand,
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
- /** Memoized check: is `strace` on PATH on a Linux host? */
496
- let straceAvailableCache: boolean | undefined
497
- async function wantsStraceDetection(): Promise<boolean> {
498
- if (process.platform !== 'linux') return false
499
- if (straceAvailableCache !== undefined) return straceAvailableCache
500
- try {
501
- const p = Bun.spawn(['strace', '--version'], { stdout: 'ignore', stderr: 'ignore' })
502
- await p.exited
503
- straceAvailableCache = p.exitCode === 0
504
- } catch {
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
- * Parse a strace log for denied filesystem syscalls and convert each
512
- * one inside the workspace deny anchor (and not in allowRead) into a
513
- * SandboxViolation. Dedups by (syscall, abs-path) so a tool that
514
- * statx's the same missing path 10 times in a row produces one line.
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
- const SYSCALLS = 'openat|access|statx|newfstatat'
536
- const STRACE_DONE_RE = new RegExp(
537
- `^(\\d+)\\s+(${SYSCALLS})\\([^"]*"([^"]+)"[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
538
- )
539
- const STRACE_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+(${SYSCALLS})\\([^"]*"([^"]+)"[^)]*<unfinished`)
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
- * Walk the trace, pairing `<unfinished ...>` with its `<... resumed>` line.
798
+ * The capabilities that reach the macOS profile as rules rather than
799
+ * through SRT's config.
555
800
  *
556
- * Exported for testing: this is the security-relevant half of the Linux
557
- * detector, and a synthetic trace pins the split-line shapes deterministically
558
- * where an end-to-end run only produces them when strace happens to interleave.
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
- export function deniedCalls(text: string): DeniedCall[] {
561
- const pending = new Map<string, { syscall: string; rawPath: string }>()
562
- const out: DeniedCall[] = []
563
- for (const line of text.split('\n')) {
564
- const done = STRACE_DONE_RE.exec(line)
565
- if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
566
- out.push({ syscall: done[2], rawPath: done[3], errno: done[4] })
567
- continue
568
- }
569
- const unfinished = STRACE_UNFINISHED_RE.exec(line)
570
- if (
571
- unfinished?.[1] !== undefined &&
572
- unfinished[2] !== undefined &&
573
- unfinished[3] !== undefined
574
- ) {
575
- pending.set(unfinished[1], { syscall: unfinished[2], rawPath: unfinished[3] })
576
- continue
577
- }
578
- const resumedOk = STRACE_RESUMED_OK_RE.exec(line)
579
- if (resumedOk?.[1] === undefined) continue
580
- const held = pending.get(resumedOk[1])
581
- pending.delete(resumedOk[1])
582
- const resumed = STRACE_RESUMED_RE.exec(line)
583
- // Only a resume that carries a DENIAL is a violation; a successful
584
- // resume just retires the pending entry.
585
- if (held !== undefined && resumed?.[3] !== undefined) {
586
- out.push({ syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
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
- return out
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 out
841
+ return rules
629
842
  }
630
843
 
631
- function absolutize(p: string, cwd?: string): string {
632
- if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1))
633
- if (path.isAbsolute(p)) return p
634
- return path.resolve(cwd ?? process.cwd(), p)
635
- }
636
-
637
- function isUnderAny(abs: string, allow: Set<string>): boolean {
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 false
852
+ return value
643
853
  }
644
854
 
645
855
  /**
646
- * Apply the task's user-provided `sandbox.ignoreViolations` map on top
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
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
- * The defaults installed in `initSandbox` already filter on the macOS
655
- * side; this pass catches per-task additions + Linux strace results.
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 filterIgnored(
658
- violations: SandboxViolation[],
659
- ignore: Record<string, string[]> | undefined,
660
- userCommand: string,
661
- ): SandboxViolation[] {
662
- if (!ignore) return violations
663
- const wildcard = ignore['*'] ?? []
664
- const cmdEntries = Object.entries(ignore).filter(([k]) => k !== '*')
665
- return violations.filter((v) => {
666
- if (wildcard.some((s) => v.line.includes(s))) return false
667
- for (const [pattern, needles] of cmdEntries) {
668
- if (userCommand.includes(pattern) && needles.some((s) => v.line.includes(s))) {
669
- return false
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
- return true
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
- * Merge the orchestrator-provided baseline (declared inputs / outputs /
678
- * workspace-root anchor) with the user's resolved sandbox block to
679
- * produce the SRT customConfig. Path arrays are unioned and deduped.
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
- * Network coercion:
682
- * - missing / undefined / false → block all (allowedDomains: [])
683
- * - true → allow all (allowedDomains: ['*'])
684
- * - object → use as-is (no merge with shortcuts)
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 buildCustomConfig(
687
- args: SandboxedRunArgs,
688
- baselines: {
689
- allowRead: readonly string[]
690
- allowWrite: readonly string[]
691
- denyRead: readonly string[]
692
- },
693
- ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
694
- const c = args.config
695
- const allowRead = unique([...baselines.allowRead, ...c.allowRead])
696
- const denyRead = unique([...baselines.denyRead])
697
- const allowWrite = unique([...baselines.allowWrite, ...c.allowWrite])
698
-
699
- const custom: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] = {
700
- filesystem: {
701
- denyRead,
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
- : {}),
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
- if (c.ignoreViolations !== undefined) custom.ignoreViolations = c.ignoreViolations
743
- return custom
934
+ return out
744
935
  }
745
936
 
746
- function unique(arr: readonly string[]): string[] {
747
- return [...new Set(arr)]
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
  }