@vzn/vx 0.0.0 → 0.0.12

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,578 @@
1
+ // Thin wrapper around `@anthropic-ai/sandbox-runtime` (SRT) for enforcing
2
+ // per-task filesystem + network isolation.
3
+ //
4
+ // Design contract:
5
+ // The caller (executeCachedTask) computes the exact allowRead /
6
+ // allowWrite paths from the task's declared inputs + outputs + sandbox
7
+ // block. This module adds nothing implicit — no /tmp, no node_modules,
8
+ // no project dir. If a task needs them, the user declares them in
9
+ // their sandbox config. That gives users a complete view of what each
10
+ // task can touch from a single vx.config.ts file.
11
+ //
12
+ // Network is opt-in per task. By default the sandbox blocks all outbound
13
+ // traffic; tasks that need it set `sandbox.network: true`.
14
+ //
15
+ // Platform reality:
16
+ // macOS — `SandboxViolationStore` is populated from the system log
17
+ // monitor in real time; violations carry the offending command +
18
+ // syscall line, so we get structured detection.
19
+ // Linux — bwrap denies the read at the kernel boundary; the child
20
+ // sees ENOENT (or EPERM for some operations). SRT doesn't surface
21
+ // structured events for Linux, so detection is enforcement-only.
22
+
23
+ import path from 'node:path'
24
+ import os from 'node:os'
25
+ import { realpathSync } from 'node:fs'
26
+ import { unlink } from 'node:fs/promises'
27
+ import type { SandboxConfig, SandboxNetworkConfig } from '../config.js'
28
+ import {
29
+ armTimeout,
30
+ shellQuote,
31
+ signalExitCode,
32
+ streamToString,
33
+ resourceUsageToCpuRss,
34
+ type RunResult,
35
+ } from './runner.js'
36
+ import { xxh3hex } from '../util/index.js'
37
+
38
+ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
39
+ let srtPromise: Promise<SrtModule> | undefined
40
+
41
+ async function loadSrt(): Promise<SrtModule> {
42
+ if (!srtPromise) srtPromise = import('@anthropic-ai/sandbox-runtime')
43
+ return srtPromise
44
+ }
45
+
46
+ export interface SandboxAvailability {
47
+ available: boolean
48
+ /** Reason it isn't available (platform / missing deps). Empty when available. */
49
+ reason: string
50
+ }
51
+
52
+ let availabilityCache: SandboxAvailability | undefined
53
+
54
+ /**
55
+ * Probe whether SRT can sandbox on this host. Memoized — the binary
56
+ * presence + platform check doesn't change within a process.
57
+ *
58
+ * In addition to SRT's own `checkDependencies` (which only verifies
59
+ * binary presence on PATH), this runs a minimal bwrap invocation on
60
+ * Linux to catch the "bwrap installed but unprivileged user namespaces
61
+ * blocked by AppArmor / sysctl" case that's the default on stock
62
+ * Ubuntu 24.04. Without this real-execution probe, the unavailability
63
+ * surfaces only at the first task spawn, deep inside the orchestrator.
64
+ */
65
+ export async function probeSandbox(): Promise<SandboxAvailability> {
66
+ if (availabilityCache) return availabilityCache
67
+ const { SandboxManager } = await loadSrt()
68
+ if (!SandboxManager.isSupportedPlatform()) {
69
+ availabilityCache = { available: false, reason: `platform ${process.platform} not supported` }
70
+ return availabilityCache
71
+ }
72
+ const deps = SandboxManager.checkDependencies()
73
+ if (deps.errors.length > 0) {
74
+ availabilityCache = { available: false, reason: deps.errors.join('; ') }
75
+ return availabilityCache
76
+ }
77
+ // Linux only: real-execution probe. Run `bwrap` with the minimal
78
+ // user-namespace invocation it'd attempt for a sandboxed task; if
79
+ // the kernel rejects (AppArmor / sysctl), surface that here.
80
+ if (process.platform === 'linux') {
81
+ const ok = await tryBwrapOnce()
82
+ if (!ok.available) {
83
+ availabilityCache = ok
84
+ return availabilityCache
85
+ }
86
+ }
87
+ availabilityCache = { available: true, reason: '' }
88
+ return availabilityCache
89
+ }
90
+
91
+ async function tryBwrapOnce(): Promise<SandboxAvailability> {
92
+ try {
93
+ const proc = Bun.spawn(
94
+ ['bwrap', '--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '/bin/true'],
95
+ { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' },
96
+ )
97
+ const stderr = await new Response(proc.stderr).text()
98
+ await proc.exited
99
+ if (proc.exitCode !== 0) {
100
+ return {
101
+ available: false,
102
+ reason: `bwrap probe failed (exit ${proc.exitCode}): ${stderr.trim().slice(0, 200)}`,
103
+ }
104
+ }
105
+ return { available: true, reason: '' }
106
+ } catch (err) {
107
+ return { available: false, reason: `bwrap probe threw: ${(err as Error).message}` }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Default `ignoreViolations` we install in the SRT global config.
113
+ * These are well-known macOS shell-startup probes that SRT's own
114
+ * sysctl allowlist doesn't cover — every binary launched by sh
115
+ * (bash, sleep, mkdir, touch, etc.) sysctl-reads them at init, so
116
+ * without this filter every task floods with noise that isn't
117
+ * actionable security signal.
118
+ *
119
+ * Format mirrors SRT's: `'*'` is a wildcard pattern; entries in the
120
+ * array are substring-matched against the violation details line.
121
+ * Users can ADD to this via per-task `sandbox.ignoreViolations`;
122
+ * those are applied at violation read-back time, on top of the
123
+ * defaults installed here.
124
+ */
125
+ const DEFAULT_IGNORE_VIOLATIONS: Record<string, string[]> = {
126
+ '*': [
127
+ 'kern.iossupportversion', // newer macOS sysctl SRT's allowlist misses
128
+ ],
129
+ }
130
+
131
+ /**
132
+ * One-time SRT initialization per orchestrator run. Starts the proxy
133
+ * servers + (on macOS) the violation log monitor. Safe to call repeatedly
134
+ * — SRT itself returns early on the second call.
135
+ *
136
+ * The base config sets network to "block everything" (empty allowedDomains).
137
+ * Per-task wrapping passes a customConfig that re-enables network for
138
+ * tasks with `sandbox.network: true`.
139
+ */
140
+ export async function initSandbox(): Promise<void> {
141
+ const { SandboxManager } = await loadSrt()
142
+ await SandboxManager.initialize(
143
+ {
144
+ network: { allowedDomains: [], deniedDomains: [] },
145
+ filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
146
+ ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
147
+ },
148
+ undefined,
149
+ // enableLogMonitor — macOS-only; populates the SandboxViolationStore.
150
+ true,
151
+ )
152
+ }
153
+
154
+ export async function resetSandbox(): Promise<void> {
155
+ const { SandboxManager } = await loadSrt()
156
+ await SandboxManager.reset()
157
+ availabilityCache = undefined
158
+ }
159
+
160
+ export interface SandboxedRunArgs {
161
+ command: string
162
+ cwd: string
163
+ env: NodeJS.ProcessEnv
164
+ forwardArgs?: readonly string[] | undefined
165
+ onStdout?: (chunk: string) => void
166
+ onStderr?: (chunk: string) => void
167
+ /** See `RunOptions.liveChildren` — same contract for sandboxed spawns. */
168
+ liveChildren?: Set<ReturnType<typeof Bun.spawn>>
169
+ /** See `RunOptions.timeoutMs` — SIGTERM the child after this many ms. */
170
+ timeoutMs?: number
171
+ /**
172
+ * Baseline reads — paths the sandbox unconditionally allows. The
173
+ * caller builds this from resolved `cache.inputs.files`.
174
+ */
175
+ baseAllowRead: readonly string[]
176
+ /**
177
+ * Baseline writes — paths the sandbox unconditionally allows for
178
+ * writes. Built from the static prefix of `cache.outputs.files`.
179
+ */
180
+ baseAllowWrite: readonly string[]
181
+ /**
182
+ * Read-deny anchor. Combined with allowRead it produces the effective
183
+ * deny set: anything under one of these paths that isn't in allowRead
184
+ * is forbidden. Pass `[workspaceRoot]` to enforce project boundaries.
185
+ */
186
+ baseDenyRead: readonly string[]
187
+ /**
188
+ * User-declared sandbox block (after path-resolution). Path lists are
189
+ * unioned with the baselines; bool/object fields fall through to SRT.
190
+ */
191
+ config: ResolvedSandboxConfig
192
+ }
193
+
194
+ /**
195
+ * Sandbox config with all path fields resolved to absolute paths.
196
+ * Produced by `resolveSandboxConfig`. The shape mirrors `SandboxConfig`
197
+ * but every string in a path list is guaranteed absolute.
198
+ */
199
+ export interface ResolvedSandboxConfig {
200
+ allowRead: readonly string[]
201
+ allowWrite: readonly string[]
202
+ allowGitConfig?: boolean
203
+ network?: boolean | SandboxNetworkConfig
204
+ allowPty?: boolean
205
+ enableWeakerNestedSandbox?: boolean
206
+ enableWeakerNetworkIsolation?: boolean
207
+ ignoreViolations?: Record<string, string[]>
208
+ }
209
+
210
+ /**
211
+ * Canonicalize a path with realpath, tolerating paths that don't exist
212
+ * yet: resolve the longest existing ancestor and re-append the rest.
213
+ *
214
+ * Why: the sandbox policy matches on canonical paths (macOS seatbelt
215
+ * evaluates real vnode paths), and SRT's own normalization refuses to
216
+ * canonicalize bare symlinked roots like `/tmp` → `/private/tmp` (its
217
+ * boundary check only whitelists `/tmp/<child>` forms). Without this,
218
+ * `allowWrite: ['/tmp']` silently never matches on macOS.
219
+ */
220
+ function toRealPath(p: string): string {
221
+ try {
222
+ return realpathSync(p)
223
+ } catch {
224
+ const parent = path.dirname(p)
225
+ if (parent === p) return p
226
+ return path.join(toRealPath(parent), path.basename(p))
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Convert a user-facing `SandboxConfig` (paths may be relative / tilde)
232
+ * into a `ResolvedSandboxConfig` (all paths absolute + canonical) for a
233
+ * given project. Relative paths resolve against `projectDir`; tilde
234
+ * paths expand against the user's home; symlinks resolve to real paths.
235
+ */
236
+ export function resolveSandboxConfig(
237
+ cfg: SandboxConfig,
238
+ projectDir: string,
239
+ ): ResolvedSandboxConfig {
240
+ const resolve = (p: string): string => {
241
+ if (p.startsWith('~')) return toRealPath(path.join(os.homedir(), p.slice(1)))
242
+ if (path.isAbsolute(p)) return toRealPath(p)
243
+ return toRealPath(path.resolve(projectDir, p))
244
+ }
245
+ const r: ResolvedSandboxConfig = {
246
+ allowRead: (cfg.allowRead ?? []).map(resolve),
247
+ allowWrite: (cfg.allowWrite ?? []).map(resolve),
248
+ }
249
+ if (cfg.allowGitConfig !== undefined) r.allowGitConfig = cfg.allowGitConfig
250
+ if (cfg.network !== undefined) r.network = cfg.network
251
+ if (cfg.allowPty !== undefined) r.allowPty = cfg.allowPty
252
+ if (cfg.enableWeakerNestedSandbox !== undefined) {
253
+ r.enableWeakerNestedSandbox = cfg.enableWeakerNestedSandbox
254
+ }
255
+ if (cfg.enableWeakerNetworkIsolation !== undefined) {
256
+ r.enableWeakerNetworkIsolation = cfg.enableWeakerNetworkIsolation
257
+ }
258
+ if (cfg.ignoreViolations !== undefined) r.ignoreViolations = cfg.ignoreViolations
259
+ return r
260
+ }
261
+
262
+ export interface SandboxViolation {
263
+ /** Raw log line from SRT. Format differs between macOS / Linux. */
264
+ line: string
265
+ timestamp: Date
266
+ }
267
+
268
+ export interface SandboxedRunResult extends RunResult {
269
+ /** Violations captured during this task. Empty when nothing tripped. */
270
+ violations: SandboxViolation[]
271
+ }
272
+
273
+ /**
274
+ * Run a single task wrapped in the sandbox. Caller must have called
275
+ * `initSandbox()` first.
276
+ *
277
+ * Violations are matched by a unique per-task command prefix — SRT's
278
+ * `getViolationsForCommand` keys by base64 of the first 100 chars, so
279
+ * two tasks running the same underlying command (e.g. parallel `tsc`
280
+ * across packages) would otherwise collide. We prepend `: '<tag>';`
281
+ * (shell no-op) to make every command's first 100 chars unique.
282
+ */
283
+ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
284
+ const { SandboxManager } = await loadSrt()
285
+ const start = Date.now()
286
+ const userCommand =
287
+ args.forwardArgs && args.forwardArgs.length > 0
288
+ ? args.command + ' ' + args.forwardArgs.map(shellQuote).join(' ')
289
+ : args.command
290
+
291
+ const tag = xxh3hex(`${args.cwd}|${userCommand}|${process.hrtime.bigint()}`).slice(0, 16)
292
+ const taggedCommand = `: 'vx-${tag}'; ${userCommand}`
293
+
294
+ const customConfig = buildCustomConfig(args)
295
+ const wrapped = await SandboxManager.wrapWithSandbox(taggedCommand, undefined, customConfig)
296
+
297
+ // Linux: SRT's SandboxViolationStore is macOS-only, so structured
298
+ // detection on Linux requires us to wrap the spawn with strace and
299
+ // parse the trace for denied syscalls. The trace is per-task (unique
300
+ // log path keyed by the command tag) so parallel tasks don't share
301
+ // a stream. Skipped when strace isn't on PATH — bwrap still enforces
302
+ // structurally; we just lose the structured violation list.
303
+ const useStrace = await wantsStraceDetection()
304
+ const straceLog = useStrace ? path.join(os.tmpdir(), `vx-strace-${tag}.log`) : undefined
305
+ // We trace only `openat` — it's the actual file-read attempt, the
306
+ // signal the user cares about. `statx` / `newfstatat` / `access`
307
+ // are mostly shell PATH-walking and stat probes that aren't
308
+ // actionable (we'd report every node_modules/.bin entry the shell
309
+ // checks before resolving a command).
310
+ const spawnArgv = straceLog
311
+ ? ['strace', '-f', '-e', 'trace=openat', '-o', straceLog, '--', 'sh', '-c', wrapped]
312
+ : ['sh', '-c', wrapped]
313
+
314
+ let proc: ReturnType<typeof Bun.spawn>
315
+ try {
316
+ proc = Bun.spawn(spawnArgv, {
317
+ cwd: args.cwd,
318
+ env: args.env as Record<string, string>,
319
+ stdin: 'ignore',
320
+ stdout: 'pipe',
321
+ stderr: 'pipe',
322
+ })
323
+ } catch (err) {
324
+ const message = err instanceof Error ? err.message : String(err)
325
+ return {
326
+ exitCode: 127,
327
+ durationMs: Date.now() - start,
328
+ stdout: '',
329
+ stderr: `\n[vx] failed to spawn sandboxed task: ${message}\n`,
330
+ violations: [],
331
+ }
332
+ }
333
+
334
+ args.liveChildren?.add(proc)
335
+ const timeout = armTimeout(proc, args.timeoutMs)
336
+ const ac = new AbortController()
337
+ const streams = Promise.all([
338
+ streamToString(proc.stdout, args.onStdout, ac.signal),
339
+ streamToString(proc.stderr, args.onStderr, ac.signal),
340
+ ])
341
+ // See runCommand: gate on child exit, abort the readers on timeout so
342
+ // a lingering grandchild pipe can't hang the run.
343
+ await proc.exited
344
+ timeout.clear()
345
+ if (timeout.timedOut()) ac.abort()
346
+ const [stdout, stderr] = await streams
347
+ args.liveChildren?.delete(proc)
348
+ const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
349
+
350
+ // macOS: read the violation store keyed by our tagged command.
351
+ const store = SandboxManager.getSandboxViolationStore()
352
+ const macViolations = store.getViolationsForCommand(taggedCommand).map((v) => ({
353
+ line: v.line,
354
+ timestamp: v.timestamp,
355
+ }))
356
+
357
+ // Linux: parse the strace log and emit one violation per denied
358
+ // syscall on a path inside denyRead that wasn't unconditionally
359
+ // allowed. Best-effort — if parsing fails we surface no Linux
360
+ // violations rather than fail the whole task.
361
+ const linuxViolations: SandboxViolation[] = straceLog
362
+ ? await parseStraceViolations(straceLog, args).catch(() => [])
363
+ : []
364
+ if (straceLog) await unlink(straceLog).catch(() => undefined)
365
+
366
+ // Apply the task's own ignoreViolations on top of the global defaults.
367
+ // SRT's wrapCommandWithSandboxMacOS doesn't actually thread customConfig.
368
+ // ignoreViolations through to the log monitor — that filter is set
369
+ // once globally at initSandbox time. So per-task user overrides have
370
+ // to be applied here, after read-back.
371
+ const userIgnore = args.config.ignoreViolations
372
+ const violations: SandboxViolation[] = filterIgnored(
373
+ [...macViolations, ...linuxViolations],
374
+ userIgnore,
375
+ userCommand,
376
+ )
377
+
378
+ try {
379
+ SandboxManager.cleanupAfterCommand()
380
+ } catch {
381
+ // ignore; bwrap mount-point cleanup is best-effort
382
+ }
383
+
384
+ return {
385
+ exitCode,
386
+ durationMs: Date.now() - start,
387
+ stdout,
388
+ stderr,
389
+ violations,
390
+ ...(proc.signalCode ? { signal: proc.signalCode } : {}),
391
+ ...(timeout.timedOut() ? { timedOut: true } : {}),
392
+ ...resourceUsageToCpuRss(proc.resourceUsage()),
393
+ }
394
+ }
395
+
396
+ /** Memoized check: is `strace` on PATH on a Linux host? */
397
+ let straceAvailableCache: boolean | undefined
398
+ async function wantsStraceDetection(): Promise<boolean> {
399
+ if (process.platform !== 'linux') return false
400
+ if (straceAvailableCache !== undefined) return straceAvailableCache
401
+ try {
402
+ const p = Bun.spawn(['strace', '--version'], { stdout: 'ignore', stderr: 'ignore' })
403
+ await p.exited
404
+ straceAvailableCache = p.exitCode === 0
405
+ } catch {
406
+ straceAvailableCache = false
407
+ }
408
+ return straceAvailableCache
409
+ }
410
+
411
+ /**
412
+ * Parse a strace log for denied filesystem syscalls and convert each
413
+ * one inside the workspace deny anchor (and not in allowRead) into a
414
+ * SandboxViolation. Dedups by (syscall, abs-path) so a tool that
415
+ * statx's the same missing path 10 times in a row produces one line.
416
+ *
417
+ * strace line shape (with -f):
418
+ * <pid> openat(AT_FDCWD, "<path>", <flags>) = -1 ENOENT (...)
419
+ * <pid> access("<path>", <mode>) = -1 EACCES (...)
420
+ * <pid> statx(AT_FDCWD, "<path>", <flags>, <mask>, ...) = -1 ENOENT (...)
421
+ *
422
+ * We capture the first quoted-string argument as the path. paths that
423
+ * are relative resolve against the task's cwd (set by Bun.spawn).
424
+ */
425
+ const STRACE_RE =
426
+ /^\d+\s+(openat|access|statx|newfstatat)\([^"]*"([^"]+)"[^)]*\)\s*=\s*-1\s+(ENOENT|EACCES|EPERM)/gm
427
+
428
+ async function parseStraceViolations(
429
+ logPath: string,
430
+ args: SandboxedRunArgs,
431
+ ): Promise<SandboxViolation[]> {
432
+ const text = await Bun.file(logPath).text()
433
+ if (text.length === 0) return []
434
+
435
+ // Treat every baseAllow + sandbox.allowRead path as "this was
436
+ // explicitly permitted; any -ENOENT here is the user's own missing
437
+ // file, not a sandbox-induced denial". Same for absolute denyRead
438
+ // checks below.
439
+ const allowAbs = new Set<string>(
440
+ [...args.baseAllowRead, ...args.config.allowRead].map((p) => absolutize(p)),
441
+ )
442
+ const denyAnchors = args.baseDenyRead.map((p) => absolutize(p))
443
+
444
+ const seen = new Set<string>()
445
+ const out: SandboxViolation[] = []
446
+ for (const m of text.matchAll(STRACE_RE)) {
447
+ const [, syscall, rawPath, errno] = m
448
+ if (!rawPath) continue
449
+ const abs = absolutize(rawPath, args.cwd)
450
+ // Only report paths under the workspace-root deny anchor — system
451
+ // libs / /proc / /sys / etc. probes are not interesting violations.
452
+ if (!denyAnchors.some((root) => abs === root || abs.startsWith(root + path.sep))) continue
453
+ // Skip paths the user explicitly allowed (and their descendants).
454
+ if (isUnderAny(abs, allowAbs)) continue
455
+ const key = `${syscall}|${abs}`
456
+ if (seen.has(key)) continue
457
+ seen.add(key)
458
+ out.push({
459
+ line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
460
+ timestamp: new Date(),
461
+ })
462
+ }
463
+ return out
464
+ }
465
+
466
+ function absolutize(p: string, cwd?: string): string {
467
+ if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1))
468
+ if (path.isAbsolute(p)) return p
469
+ return path.resolve(cwd ?? process.cwd(), p)
470
+ }
471
+
472
+ function isUnderAny(abs: string, allow: Set<string>): boolean {
473
+ if (allow.has(abs)) return true
474
+ for (const a of allow) {
475
+ if (abs === a || abs.startsWith(a + path.sep)) return true
476
+ }
477
+ return false
478
+ }
479
+
480
+ /**
481
+ * Apply the task's user-provided `sandbox.ignoreViolations` map on top
482
+ * of whatever the macOS log monitor + Linux strace pass produced.
483
+ * Mirrors SRT's own substring-match semantics:
484
+ * - `'*'` entries match every command
485
+ * - other keys match commands whose userCommand string CONTAINS the key
486
+ * - the array of strings under each key is substring-matched against
487
+ * the violation line
488
+ *
489
+ * The defaults installed in `initSandbox` already filter on the macOS
490
+ * side; this pass catches per-task additions + Linux strace results.
491
+ */
492
+ function filterIgnored(
493
+ violations: SandboxViolation[],
494
+ ignore: Record<string, string[]> | undefined,
495
+ userCommand: string,
496
+ ): SandboxViolation[] {
497
+ if (!ignore) return violations
498
+ const wildcard = ignore['*'] ?? []
499
+ const cmdEntries = Object.entries(ignore).filter(([k]) => k !== '*')
500
+ return violations.filter((v) => {
501
+ if (wildcard.some((s) => v.line.includes(s))) return false
502
+ for (const [pattern, needles] of cmdEntries) {
503
+ if (userCommand.includes(pattern) && needles.some((s) => v.line.includes(s))) {
504
+ return false
505
+ }
506
+ }
507
+ return true
508
+ })
509
+ }
510
+
511
+ /**
512
+ * Merge the orchestrator-provided baseline (declared inputs / outputs /
513
+ * workspace-root anchor) with the user's resolved sandbox block to
514
+ * produce the SRT customConfig. Path arrays are unioned and deduped.
515
+ *
516
+ * Network coercion:
517
+ * - missing / undefined / false → block all (allowedDomains: [])
518
+ * - true → allow all (allowedDomains: ['*'])
519
+ * - object → use as-is (no merge with shortcuts)
520
+ */
521
+ function buildCustomConfig(
522
+ args: SandboxedRunArgs,
523
+ ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
524
+ const c = args.config
525
+ const allowRead = unique([...args.baseAllowRead, ...c.allowRead])
526
+ const denyRead = unique([...args.baseDenyRead])
527
+ const allowWrite = unique([...args.baseAllowWrite, ...c.allowWrite])
528
+
529
+ const custom: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] = {
530
+ filesystem: {
531
+ denyRead,
532
+ allowRead,
533
+ allowWrite,
534
+ denyWrite: [],
535
+ ...(c.allowGitConfig !== undefined ? { allowGitConfig: c.allowGitConfig } : {}),
536
+ },
537
+ }
538
+
539
+ // Network coercion. SRT requires allowedDomains + deniedDomains to be
540
+ // present on any network config; we always supply both.
541
+ if (c.network === true) {
542
+ custom.network = { allowedDomains: ['*'], deniedDomains: [] }
543
+ } else if (c.network && typeof c.network === 'object') {
544
+ custom.network = {
545
+ allowedDomains: c.network.allowedDomains ?? [],
546
+ deniedDomains: c.network.deniedDomains ?? [],
547
+ ...(c.network.allowUnixSockets !== undefined
548
+ ? { allowUnixSockets: c.network.allowUnixSockets }
549
+ : {}),
550
+ ...(c.network.allowAllUnixSockets !== undefined
551
+ ? { allowAllUnixSockets: c.network.allowAllUnixSockets }
552
+ : {}),
553
+ ...(c.network.allowLocalBinding !== undefined
554
+ ? { allowLocalBinding: c.network.allowLocalBinding }
555
+ : {}),
556
+ ...(c.network.allowMachLookup !== undefined
557
+ ? { allowMachLookup: c.network.allowMachLookup }
558
+ : {}),
559
+ }
560
+ } else {
561
+ // false / undefined → block all
562
+ custom.network = { allowedDomains: [], deniedDomains: [] }
563
+ }
564
+
565
+ if (c.allowPty !== undefined) custom.allowPty = c.allowPty
566
+ if (c.enableWeakerNestedSandbox !== undefined) {
567
+ custom.enableWeakerNestedSandbox = c.enableWeakerNestedSandbox
568
+ }
569
+ if (c.enableWeakerNetworkIsolation !== undefined) {
570
+ custom.enableWeakerNetworkIsolation = c.enableWeakerNetworkIsolation
571
+ }
572
+ if (c.ignoreViolations !== undefined) custom.ignoreViolations = c.ignoreViolations
573
+ return custom
574
+ }
575
+
576
+ function unique(arr: readonly string[]): string[] {
577
+ return [...new Set(arr)]
578
+ }
@@ -0,0 +1,69 @@
1
+ // Parser for the Turbo/Nx-style task-edge micro-syntax used in
2
+ // `dependsOn` and `cache.inputs.tasks`. Returns a small discriminated
3
+ // union the graph builder + cache filter both consume.
4
+
5
+ export type DependencySpec =
6
+ | { kind: 'self'; task: string; negated: boolean }
7
+ | { kind: 'deps'; task: string; negated: boolean }
8
+ | { kind: 'cross'; project: string; task: string; negated: boolean }
9
+ | { kind: 'wildcardSelf'; negated: boolean }
10
+ | { kind: 'wildcardDeps'; negated: boolean }
11
+
12
+ export class DependencySpecError extends Error {
13
+ constructor(
14
+ public readonly raw: string,
15
+ message: string,
16
+ ) {
17
+ super(`Invalid dependency spec "${raw}": ${message}`)
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Parse one `dependsOn` / `cache.inputs.tasks` entry.
23
+ *
24
+ * `name` → self.name
25
+ * `^name` → every dep workspace's `name` task
26
+ * `pkg#name` → specific package's `name` task (cross-edge)
27
+ * `*` → all same-project upstream (filter-only)
28
+ * `^*` → all dep upstream (filter-only)
29
+ * `!<form>` → negation of any of the above (filter-only)
30
+ *
31
+ * Wildcards and negation are accepted everywhere — callers (the graph
32
+ * builder vs the filter) decide which ones make semantic sense and
33
+ * surface their own validation errors. This keeps the parser pure.
34
+ */
35
+ export function parseDependencySpec(raw: string): DependencySpec {
36
+ if (raw.length === 0) throw new DependencySpecError(raw, 'empty spec')
37
+
38
+ let negated = false
39
+ let s = raw
40
+ if (s.startsWith('!')) {
41
+ negated = true
42
+ s = s.slice(1)
43
+ if (s.length === 0) throw new DependencySpecError(raw, 'negation with no body')
44
+ }
45
+
46
+ if (s.startsWith('^')) {
47
+ const rest = s.slice(1)
48
+ if (rest.length === 0) throw new DependencySpecError(raw, '"^" with no task name')
49
+ if (rest === '*') return { kind: 'wildcardDeps', negated }
50
+ if (rest.includes('#')) {
51
+ throw new DependencySpecError(raw, '"^" cannot combine with "pkg#task" — pick one')
52
+ }
53
+ return { kind: 'deps', task: rest, negated }
54
+ }
55
+
56
+ if (s === '*') return { kind: 'wildcardSelf', negated }
57
+
58
+ const hashIdx = s.indexOf('#')
59
+ if (hashIdx >= 0) {
60
+ const project = s.slice(0, hashIdx)
61
+ const task = s.slice(hashIdx + 1)
62
+ if (!project || !task) {
63
+ throw new DependencySpecError(raw, 'pkg#task requires a non-empty project AND task')
64
+ }
65
+ return { kind: 'cross', project, task, negated }
66
+ }
67
+
68
+ return { kind: 'self', task: s, negated }
69
+ }
@@ -0,0 +1,18 @@
1
+ // Module contract. Cross-module imports must come through here; see
2
+ // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
+
4
+ export { DependencySpecError, parseDependencySpec, type DependencySpec } from './dependency-spec.js'
5
+ export {
6
+ type ContinueMode,
7
+ runGraph,
8
+ type TaskOutcome,
9
+ type TaskStatus,
10
+ type VerifyVerdict,
11
+ } from './scheduler.js'
12
+ export {
13
+ buildTaskGraph,
14
+ expandRequested,
15
+ isGroupTask,
16
+ markSurfacedDeps,
17
+ type TaskNode,
18
+ } from './task-graph.js'