@vzn/vx 0.0.651 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -26,6 +26,7 @@ import type {
26
26
  * <pid> access("<path>", <mode>) = -1 EACCES (...)
27
27
  * <pid> statx(AT_FDCWD, "<path>", <flags>, <mask>, ...) = -1 ENOENT (...)
28
28
  * <pid> execve("<path>", ["<argv0>", ...], <envp>) = -1 ENOENT (...)
29
+ * <pid> readlinkat(AT_FDCWD, "<path>", 0x..., <size>) = -1 ENOENT (...)
29
30
  *
30
31
  * …but ONLY when the syscall completes without another traced process
31
32
  * interleaving. Under `-f` strace splits an interrupted call across two
@@ -42,12 +43,14 @@ import type {
42
43
  * (`cStringPath` decodes it). Paths that are relative resolve against the
43
44
  * task's cwd (set by Bun.spawn).
44
45
  */
45
- const SYSCALLS = 'openat|execve|faccessat2|faccessat|access|statx|newfstatat'
46
+ const SYSCALLS =
47
+ 'openat|open|execve|faccessat2|faccessat|access|statx|newfstatat|readlinkat|readlink'
46
48
  const QUOTED = '"((?:[^"\\\\]|\\\\.)+)"'
47
49
  // The directory descriptor a call names, `-y`'s path included: taken up to
48
50
  // the `, "` that opens the path argument, since a directory's name may hold
49
- // a quote (`4</ws/q"d>`). Captured: a relative path is in that directory.
50
- const DIRFD = '(?:AT_FDCWD|\\d+)(?:<(.*?)>)?, '
51
+ // a quote (`4</ws/q"d>`). Captured, and the descriptor too: a relative
52
+ // path is in that directory.
53
+ const DIRFD = '(AT_FDCWD|\\d+)(?:<(.*?)>)?, '
51
54
  // The rest of the call: an `execve`'s argv strings may hold a `)`
52
55
  // (`sh -c "f()"`), so it reads to the result at the line's end.
53
56
  const ARGS = '.*'
@@ -74,22 +77,44 @@ const PROBES = new Set([
74
77
  'statx',
75
78
  'newfstatat',
76
79
  'chdir',
80
+ 'readlinkat',
81
+ 'readlink',
77
82
  ])
83
+ /**
84
+ * The calls that read a link itself, never its target: git hashes a link
85
+ * by its target string, so `readlink cfg` read an input no open showed.
86
+ */
87
+ const READLINKS = new Set(['readlinkat', 'readlink'])
88
+ const READLINK_RE = new RegExp(
89
+ `^(\\d+)\\s+(readlinkat|readlink)\\((?:${DIRFD})?${QUOTED}, .*\\)\\s*=\\s*\\d+$`,
90
+ )
91
+ const READLINK_UNFINISHED_RE = /^\d+\s+readlink(?:at)?\(.*<unfinished/
78
92
  /** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
79
93
  const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
80
94
  /**
81
- * An `openat` that succeeded, and not for writing alone: a read. Placed by
95
+ * An open that succeeded, and not for writing alone: a read. Placed by
82
96
  * the path `-y` prints for the descriptor it returned; without one, only
83
- * from the cwd (`AT_FDCWD`) or by an absolute path.
97
+ * from the cwd (`AT_FDCWD`, or `open`, which names no directory) or by an
98
+ * absolute path.
84
99
  */
85
100
  const READ_FLAGS = '(?![A-Z_|]*O_WRONLY)[A-Z_|]+'
86
- const OPEN_READ_RE = new RegExp(
87
- `^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<.*?>)?, ${QUOTED}, ${READ_FLAGS}[^)]*\\)\\s*=\\s*\\d+(?:<(.*)>)?$`,
101
+ const OPEN_CALL = `(openat|open)\\((?:(AT_FDCWD|\\d+)(?:<.*?>)?, )?${QUOTED}, ${READ_FLAGS}`
102
+ const OPEN_READ_RE = new RegExp(`^(\\d+)\\s+${OPEN_CALL}[^)]*\\)\\s*=\\s*\\d+(?:<(.*)>)?$`)
103
+ const OPEN_READ_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+${OPEN_CALL}.*<unfinished`)
104
+ const RESUMED_FD_RE = /resumed>.*\)\s*=\s*(\d+)(?:<(.*)>)?$/
105
+ /** Any open that returned a descriptor: where a later `openat(N, …)` is. */
106
+ const OPENED_RE = new RegExp(
107
+ `^(\\d+)\\s+(?:openat|open)\\((?:(AT_FDCWD|\\d+)(?:<.*?>)?, )?${QUOTED}, [^)]*\\)\\s*=\\s*(\\d+)`,
88
108
  )
89
- const OPEN_READ_UNFINISHED_RE = new RegExp(
90
- `^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<.*?>)?, ${QUOTED}, ${READ_FLAGS}.*<unfinished`,
91
- )
92
- const RESUMED_FD_RE = /resumed>.*\)\s*=\s*\d+(?:<(.*)>)?$/
109
+ /** An open's flags, after its path. */
110
+ const OPEN_FLAGS_RE = new RegExp(`^\\d+\\s+(?:openat|open)\\((?:${DIRFD})?${QUOTED}, ([A-Z0-9_|]+)`)
111
+ const WRITE_FLAGS_RE = /O_WRONLY|O_RDWR|O_CREAT|O_TRUNC/
112
+
113
+ /** Whether an open line asks to write or create, as its flags say. */
114
+ function writeFlags(line: string): { writes?: true } {
115
+ const flags = OPEN_FLAGS_RE.exec(line)?.[4]
116
+ return flags !== undefined && WRITE_FLAGS_RE.test(flags) ? { writes: true } : {}
117
+ }
93
118
 
94
119
  /** Where a successful read opened: `-y`'s path, else the call's own when placeable. */
95
120
  function readPath(dirfd: string, rawPath: string, opened: string | undefined): string | undefined {
@@ -137,6 +162,8 @@ export interface DeniedCall {
137
162
  errno: string
138
163
  /** A read that SUCCEEDED (`errno` empty): asked for by `deniedCalls`' `reads`. */
139
164
  read?: true
165
+ /** An `openat` whose flags ask to write or create. */
166
+ writes?: true
140
167
  /**
141
168
  * The directory a relative `rawPath` was opened from, when the trace
142
169
  * shows the process (or one it was forked from) changed into one; absent
@@ -164,7 +191,11 @@ const FORK_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*<unfinished`)
164
191
  const FORK_RESUMED_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. ${FORK} resumed>.*?=\\s*(\\d+)$`)
165
192
 
166
193
  /** What a process did, in order, as far as its cwd and its denials go. */
167
- type Op = { chdir: string } | { lost: true } | { call: number }
194
+ type Op =
195
+ | { chdir: string }
196
+ | { lost: true }
197
+ | { call: number; fd?: string }
198
+ | { opened: string; path: string; fd: string }
168
199
 
169
200
  /**
170
201
  * Walk the trace, pairing `<unfinished ...>` with its `<... resumed>` line.
@@ -174,7 +205,11 @@ type Op = { chdir: string } | { lost: true } | { call: number }
174
205
  * and no `ignore` pattern for the real one matched. Resolved after the
175
206
  * whole pass: a `vfork` child's lines come before its parent's
176
207
  * `resumed` line names it. (`-y` names the directory on every line, but
177
- * cost 40% on 2,000 opens; this costs a stop per process.)
208
+ * cost 40% on 2,000 opens; this costs a stop per process.) Without `-y`,
209
+ * a call through a directory descriptor (`openat(3, "f")`) is placed by
210
+ * the traced `openat` that last returned that descriptor in its process
211
+ * or the one it was forked from: in the cwd, Python's
212
+ * `os.open('f', dir_fd=d)` inside its grant was a refusal of `./f`.
178
213
  *
179
214
  * Exported for testing: this is the security-relevant half of the Linux
180
215
  * detector, and a synthetic trace pins the split-line shapes deterministically
@@ -183,7 +218,14 @@ type Op = { chdir: string } | { lost: true } | { call: number }
183
218
  export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCall[] {
184
219
  const pending = new Map<
185
220
  string,
186
- { syscall: string; rawPath: string; read?: true; dirfd?: string; dir?: string }
221
+ {
222
+ syscall: string
223
+ rawPath: string
224
+ read?: true
225
+ writes?: true
226
+ dirfd?: string
227
+ dir?: string
228
+ }
187
229
  >()
188
230
  const out: DeniedCall[] = []
189
231
  const ops = new Map<string, Op[]>()
@@ -204,21 +246,56 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
204
246
  else parent.set(child, { pid: owner(from), at })
205
247
  }
206
248
  const chdirring = new Map<string, string>()
207
- const denied = (pid: string, call: DeniedCall): void => {
208
- opsOf(pid).push({ call: out.length })
249
+ // No `-y` and an `openat(3, …)`: each open's descriptor is followed.
250
+ const numbered = cwd !== undefined && /openat\(\d+, /.test(text)
251
+ const denied = (pid: string, call: DeniedCall, dirfd?: string): void => {
252
+ const unplaced =
253
+ numbered && dirfd !== undefined && dirfd !== 'AT_FDCWD' && call.dir === undefined
254
+ opsOf(pid).push(
255
+ unplaced && !call.rawPath.startsWith('/')
256
+ ? { call: out.length, fd: dirfd }
257
+ : { call: out.length },
258
+ )
209
259
  out.push(call)
210
260
  }
261
+ // Per process and descriptor, the op indexes of the opens that returned it.
262
+ const opens = new Map<string, Map<string, number[]>>()
263
+ const opened = (pid: string, dirfd: string, rawPath: string, fd: string): void => {
264
+ const list = opsOf(pid)
265
+ let byFd = opens.get(owner(pid))
266
+ if (byFd === undefined) opens.set(owner(pid), (byFd = new Map()))
267
+ const at = byFd.get(fd)
268
+ if (at === undefined) byFd.set(fd, [list.length])
269
+ else at.push(list.length)
270
+ list.push({ opened: dirfd, path: rawPath, fd })
271
+ }
211
272
  for (const line of text.split('\n')) {
212
273
  // Most lines are opens that succeeded: two substring tests skip them.
213
274
  const opener =
214
275
  line.includes('= -1') || line.includes('<unfinished') || line.includes('resumed>')
215
276
  if (!opener) {
216
- if (reads && line.includes('openat(')) {
277
+ if (numbered && line.includes('open')) {
278
+ const m = OPENED_RE.exec(line)
279
+ if (m !== null) opened(m[1]!, m[2] ?? 'AT_FDCWD', cStringPath(m[3]!), m[4]!)
280
+ }
281
+ if (reads && line.includes('readlink')) {
282
+ const m = READLINK_RE.exec(line)
283
+ if (m !== null) {
284
+ const rawPath = cStringPath(m[5]!)
285
+ denied(
286
+ m[1]!,
287
+ { syscall: m[2]!, rawPath, errno: '', read: true, ...within(m[4], rawPath) },
288
+ m[3],
289
+ )
290
+ continue
291
+ }
292
+ }
293
+ if (reads && line.includes('open')) {
217
294
  const m = OPEN_READ_RE.exec(line)
218
295
  if (m !== null) {
219
- const at = readPath(m[2]!, cStringPath(m[3]!), m[4])
296
+ const at = readPath(m[3] ?? 'AT_FDCWD', cStringPath(m[4]!), m[5])
220
297
  if (at !== undefined)
221
- denied(m[1]!, { syscall: 'openat', rawPath: at, errno: '', read: true })
298
+ denied(m[1]!, { syscall: m[2]!, rawPath: at, errno: '', read: true })
222
299
  continue
223
300
  }
224
301
  }
@@ -230,24 +307,37 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
230
307
  continue
231
308
  }
232
309
  const done = STRACE_DONE_RE.exec(line)
233
- if (done?.[2] !== undefined && done[4] !== undefined && done[5] !== undefined) {
234
- const rawPath = cStringPath(done[4])
235
- denied(done[1]!, { syscall: done[2], rawPath, errno: done[5], ...within(done[3], rawPath) })
310
+ if (done?.[2] !== undefined && done[5] !== undefined && done[6] !== undefined) {
311
+ const rawPath = cStringPath(done[5])
312
+ denied(
313
+ done[1]!,
314
+ {
315
+ syscall: done[2],
316
+ rawPath,
317
+ errno: done[6],
318
+ ...within(done[4], rawPath),
319
+ ...writeFlags(line),
320
+ },
321
+ done[3],
322
+ )
236
323
  continue
237
324
  }
238
325
  const unfinished = STRACE_UNFINISHED_RE.exec(line)
239
326
  if (
240
327
  unfinished?.[1] !== undefined &&
241
328
  unfinished[2] !== undefined &&
242
- unfinished[4] !== undefined
329
+ unfinished[5] !== undefined
243
330
  ) {
244
- const rawPath = cStringPath(unfinished[4])
331
+ const rawPath = cStringPath(unfinished[5])
245
332
  pending.set(unfinished[1], {
246
333
  syscall: unfinished[2],
247
334
  rawPath,
248
- ...within(unfinished[3], rawPath),
249
- ...(reads && OPEN_READ_UNFINISHED_RE.test(line) ? { read: true as const } : {}),
250
- ...(reads ? { dirfd: OPEN_READ_UNFINISHED_RE.exec(line)?.[2] ?? 'AT_FDCWD' } : {}),
335
+ ...within(unfinished[4], rawPath),
336
+ ...writeFlags(line),
337
+ ...(reads && (OPEN_READ_UNFINISHED_RE.test(line) || READLINK_UNFINISHED_RE.test(line))
338
+ ? { read: true as const }
339
+ : {}),
340
+ ...(unfinished[3] !== undefined ? { dirfd: unfinished[3] } : {}),
251
341
  })
252
342
  continue
253
343
  }
@@ -258,16 +348,37 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
258
348
  const resumed = STRACE_RESUMED_RE.exec(line)
259
349
  // Only a resume that carries a DENIAL is a violation; a successful
260
350
  // resume just retires the pending entry.
261
- const fd = held?.read === true ? RESUMED_FD_RE.exec(line) : null
351
+ const fd =
352
+ held?.read === true ||
353
+ (numbered && (held?.syscall === 'openat' || held?.syscall === 'open'))
354
+ ? RESUMED_FD_RE.exec(line)
355
+ : null
262
356
  if (held !== undefined && resumed?.[3] !== undefined) {
263
- denied(resumedOk[1], {
264
- syscall: held.syscall,
265
- rawPath: held.rawPath,
266
- errno: resumed[3],
267
- ...(held.dir !== undefined ? { dir: held.dir } : {}),
268
- })
269
- } else if (held !== undefined && fd !== null) {
270
- const at = readPath(held.dirfd ?? 'AT_FDCWD', held.rawPath, fd[1])
357
+ denied(
358
+ resumedOk[1],
359
+ {
360
+ syscall: held.syscall,
361
+ rawPath: held.rawPath,
362
+ errno: resumed[3],
363
+ ...(held.dir !== undefined ? { dir: held.dir } : {}),
364
+ ...(held.writes === true ? { writes: true as const } : {}),
365
+ },
366
+ held.dirfd,
367
+ )
368
+ continue
369
+ }
370
+ if (numbered && (held?.syscall === 'openat' || held?.syscall === 'open') && fd !== null) {
371
+ opened(resumedOk[1], held.dirfd ?? 'AT_FDCWD', held.rawPath, fd[1]!)
372
+ }
373
+ if (held?.read === true && fd !== null && READLINKS.has(held.syscall)) {
374
+ const { syscall, rawPath, dir, dirfd } = held
375
+ denied(
376
+ resumedOk[1],
377
+ { syscall, rawPath, errno: '', read: true, ...(dir !== undefined ? { dir } : {}) },
378
+ dirfd,
379
+ )
380
+ } else if (held?.read === true && fd !== null) {
381
+ const at = readPath(held.dirfd ?? 'AT_FDCWD', held.rawPath, fd[2])
271
382
  if (at !== undefined) {
272
383
  denied(resumedOk[1], { syscall: held.syscall, rawPath: at, errno: '', read: true })
273
384
  }
@@ -335,10 +446,29 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
335
446
  dirs.set(pid, list)
336
447
  return list
337
448
  }
449
+ // Where descriptor `fd` pointed before op `i` of `pid`: the last open
450
+ // that returned it, else the one it inherited at the fork. Not traced: a
451
+ // `close`, a `dup`, an exec's O_CLOEXEC; a number reused by one of those
452
+ // is placed by the open before it, or, never opened here, in the cwd.
453
+ const fdAt = (pid: string, i: number, fd: string, depth = 0): string | undefined => {
454
+ if (depth > 64) return undefined
455
+ const at = opens.get(pid)?.get(fd) ?? []
456
+ let k = at.length - 1
457
+ while (k >= 0 && at[k]! >= i) k--
458
+ if (k >= 0) {
459
+ const j = at[k]!
460
+ const op = ops.get(pid)![j] as Extract<Op, { opened: string }>
461
+ if (path.isAbsolute(op.path)) return op.path
462
+ const base = op.opened === 'AT_FDCWD' ? dirsOf(pid)[j] : fdAt(pid, j, op.opened, depth + 1)
463
+ return base && path.resolve(base, op.path)
464
+ }
465
+ const from = parent.get(pid)
466
+ return from === undefined ? undefined : fdAt(from.pid, from.at, fd, depth + 1)
467
+ }
338
468
  for (const [pid, list] of ops) {
339
469
  list.forEach((op, i) => {
340
470
  if (!('call' in op) || out[op.call]!.dir !== undefined) return
341
- const dir = dirsOf(pid)[i]
471
+ const dir = (op.fd !== undefined ? fdAt(pid, i, op.fd) : undefined) ?? dirsOf(pid)[i]
342
472
  if (dir !== undefined && dir !== cwd) out[op.call]!.dir = dir
343
473
  })
344
474
  }
@@ -405,7 +535,7 @@ export async function parseStraceViolations(
405
535
  const seen = new Set<string>()
406
536
  const judged = new Set<string>()
407
537
  const out: SandboxViolation[] = []
408
- for (const { syscall, rawPath, errno, dir, read } of deniedCalls(
538
+ for (const { syscall, rawPath, errno, dir, read, writes } of deniedCalls(
409
539
  text,
410
540
  baselines.cwd,
411
541
  widened.size > 0,
@@ -414,12 +544,14 @@ export async function parseStraceViolations(
414
544
  // stats: a test shard's 44,788 calls held the event loop 2.9 s, and a
415
545
  // DTE worker that quiet for two heartbeats was lost (main, 2026-10-25).
416
546
  // Each call is judged once, and the loop yields between batches.
417
- const call = `${syscall}\0${rawPath}\0${dir}\0${errno}\0${read}`
547
+ const call = `${syscall}\0${rawPath}\0${dir}\0${errno}\0${read}\0${writes}`
418
548
  if (judged.has(call)) continue
419
549
  judged.add(call)
420
550
  if (judged.size % JUDGED_PER_TURN === 0) await new Promise((r) => setImmediate(r))
421
551
  const lexical = absolutize(rawPath, dir ?? baselines.cwd)
422
- const abs = toRealPath(lexical)
552
+ const abs = READLINKS.has(syscall)
553
+ ? path.join(toRealPath(path.dirname(lexical)), path.basename(lexical))
554
+ : toRealPath(lexical)
423
555
  // Only report paths under the workspace-root deny anchor — system
424
556
  // libs / /proc / /sys / etc. probes are not interesting violations.
425
557
  if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
@@ -433,7 +565,7 @@ export async function parseStraceViolations(
433
565
  // the host has the file.
434
566
  if (errno === 'ENOENT' && PROBES.has(syscall) && !lstatSync(abs, { throwIfNoEntry: false }))
435
567
  continue
436
- const key = `${syscall}|${abs}`
568
+ const key = `${syscall}|${abs}|${writes}`
437
569
  if (seen.has(key)) continue
438
570
  seen.add(key)
439
571
  // The line is printed verbatim, and the trace's `\033` is decoded: a
@@ -453,10 +585,11 @@ export async function parseStraceViolations(
453
585
  timestamp: new Date(),
454
586
  target: abs,
455
587
  path: abs,
456
- // An openat is a read or a write depending on flags the parse
588
+ // An open is a read or a write depending on flags the parse
457
589
  // does not keep, and `access` may ask either — so either list
458
- // can silence it.
459
- ignorable: ['read', 'write'],
590
+ // can silence it. A readlink only reads.
591
+ ignorable: READLINKS.has(syscall) ? ['read'] : ['read', 'write'],
592
+ ...(syscall === 'openat' ? { opened: writes === true ? 'write' : 'read' } : {}),
460
593
  ...(via === undefined ? {} : { via }),
461
594
  },
462
595
  )
@@ -780,6 +913,15 @@ export function refusedConnections(records: readonly string[]): SandboxViolation
780
913
 
781
914
  /** Write syscalls that act on a path's last component, not what a link there names. */
782
915
  const NAME_SYSCALL = /^(?:symlink|unlink|rename|rmdir|mknod|link)/
916
+ /** The calls the observer records twice, source and destination, in that order. */
917
+ const MOVES = /^(?:rename|link)/
918
+ /**
919
+ * The observer's opens that may be reads: an `openat2`'s flags lie in a
920
+ * struct its seccomp filter cannot read, so it takes every one, and its
921
+ * record names no flags. strace never sees one (USER_NOTIF wins over
922
+ * TRACE), so this record is the only trace of an `openat2` read.
923
+ */
924
+ const OPEN_EITHER = /^openat2$/
783
925
 
784
926
  /**
785
927
  * The writes SRT's Linux observer saw that this task's binds do not cover.
@@ -789,6 +931,19 @@ const NAME_SYSCALL = /^(?:symlink|unlink|rename|rmdir|mknod|link)/
789
931
  * that landed, and anything else was refused or fell into the deny
790
932
  * anchor's scratch and vanished with the sandbox. Deduplicated by syscall
791
933
  * and canonical path; only a `write` ignore list can silence one.
934
+ *
935
+ * Under a directory a file grant was widened to (`widened`), the bind
936
+ * lets every write land, so there each is judged against the grants
937
+ * themselves: `echo x > other.txt` beside `write: ['o.txt']` wrote the
938
+ * host unreported, and `ln -f README.md o.txt` (the observer records a
939
+ * link's or a move's source too) cached README's bytes with no read the
940
+ * trace could see, replayed after README changed. An entry that was there
941
+ * when the task started is reported; one the task made is its own unless
942
+ * it outlives the task, since an atomic writer stages beside its target
943
+ * (the reason for the widening) and renames. A made directory holds
944
+ * nothing of its own: only a new one left behind is reported. An
945
+ * `openat2` may be a read (`OPEN_EITHER`): where the task may read its
946
+ * path it is nothing, elsewhere an open named for a read.
792
947
  */
793
948
  export function refusedWrites(
794
949
  records: readonly string[],
@@ -797,9 +952,34 @@ export function refusedWrites(
797
952
  scratch: readonly string[] = [],
798
953
  /** What the sandbox mounts readable, canonical: a mkdir of what is there is no write. */
799
954
  readable: readonly string[] = [],
955
+ /** `widenedEntries` at the task's start, and the write grants as declared. */
956
+ widened?: {
957
+ entries: ReadonlyMap<string, ReadonlySet<string>>
958
+ granted: readonly string[]
959
+ },
800
960
  ): SandboxViolation[] {
801
961
  const binds = new Set(writable.map((w) => toRealPath(absolutize(w))))
962
+ const granted = new Set((widened?.granted ?? []).map((w) => toRealPath(absolutize(w))))
963
+ // A brace is literal to the mount (`MOUNT_WILDCARDS`) and widened like
964
+ // any file grant, but `write: ['g/{a,b}.txt']` means `g/a.txt` too.
965
+ const braced = [...granted].filter((w) => w.includes('{')).map((w) => new Bun.Glob(w))
802
966
  const globs = scratch.map((g) => new Bun.Glob(g))
967
+ // Whether a write `binds` covers lands under a widened directory where
968
+ // no grant covers it, and so is reported (see above).
969
+ const undeclared = (syscall: string, abs: string): boolean => {
970
+ for (const [dir, entries] of widened?.entries ?? []) {
971
+ if (abs === dir || !atOrUnder(abs, dir)) continue
972
+ if (isUnderAny(abs, granted) || underGlob(abs, braced)) return false
973
+ const top = path.join(dir, path.relative(dir, abs).split(path.sep)[0]!)
974
+ if (!entries.has(top)) return exists(abs) && !(syscall.startsWith('mkdir') && abs !== top)
975
+ if (syscall.startsWith('mkdir')) return false
976
+ // A granted read as a link's source, or as an `openat2` (read or
977
+ // write, `OPEN_EITHER`): the read the grant allows.
978
+ const reads = syscall.startsWith('link') || OPEN_EITHER.test(syscall)
979
+ return !(reads && readable.some((r) => atOrUnder(abs, r)))
980
+ }
981
+ return false
982
+ }
803
983
  const seen = new Set<string>()
804
984
  const out: SandboxViolation[] = []
805
985
  for (const record of records) {
@@ -817,14 +997,22 @@ export function refusedWrites(
817
997
  const abs = NAME_SYSCALL.test(syscall)
818
998
  ? path.join(toRealPath(path.dirname(raw)), path.basename(raw))
819
999
  : toRealPath(raw)
820
- if (isUnderAny(abs, binds) || underGlob(abs, globs)) continue
1000
+ if (underGlob(abs, globs)) continue
1001
+ const bound = isUnderAny(abs, binds)
1002
+ if (bound && !undeclared(syscall, abs)) continue
1003
+ const moved = bound && MOVES.test(syscall)
821
1004
  // A directory a write bind lies in exists, since bwrap made it to mount
822
1005
  // the bind: `mkdir -p node_modules/.cache/tool` under a grant of
823
1006
  // `node_modules/.cache/` met EEXIST on `node_modules`, wrote nothing,
824
1007
  // and the attempt failed a clean task (2026-10-03).
825
1008
  // So does one a read grant shows, or holds: `mkdir -p src` under
826
1009
  // `read: ['src']` failed a clean task the same way (X-82).
827
- if (syscall.startsWith('mkdir') && inSandbox(abs, binds, readable)) continue
1010
+ if (!bound && syscall.startsWith('mkdir') && inSandbox(abs, binds, readable)) continue
1011
+ // A write where the sandbox can read met the read-only bind and
1012
+ // landed nothing; a read there was granted. Either way, nothing to say:
1013
+ // judged a write, a granted `openat2(O_RDONLY)` failed a clean task.
1014
+ const either = OPEN_EITHER.test(syscall)
1015
+ if (!bound && either && inSandbox(abs, binds, readable)) continue
828
1016
  const key = `${syscall}|${abs}`
829
1017
  if (seen.has(key)) continue
830
1018
  seen.add(key)
@@ -834,6 +1022,22 @@ export function refusedWrites(
834
1022
  target: abs,
835
1023
  path: abs,
836
1024
  ignorable: ['write'],
1025
+ // A link's source is read, and named for a read while it is there
1026
+ // (`sandboxFix`): its bytes reach the output with no open.
1027
+ ...(moved
1028
+ ? {
1029
+ line: `${syscall}(${raw}) = a link or move no grant covers [${abs}]`,
1030
+ ignorable: ['read', 'write'] as const,
1031
+ }
1032
+ : {}),
1033
+ // Named for a read, the cheaper grant: a write grant reads too.
1034
+ ...(either
1035
+ ? {
1036
+ line: `${syscall}(${raw}) = an open no grant covers [${abs}]`,
1037
+ ignorable: ['read', 'write'] as const,
1038
+ opened: 'read' as const,
1039
+ }
1040
+ : {}),
837
1041
  })
838
1042
  }
839
1043
  return out
@@ -29,7 +29,7 @@ export interface OffSpawn {
29
29
  /** Each a descriptor number or `'ignore'`: a stream cannot cross threads. */
30
30
  stdio: Array<number | 'ignore'>
31
31
  detached?: boolean
32
- /** False: the child keeps no run alive until `hold()` (a spare shell waiting for its task). */
32
+ /** False: once spawned, the child keeps no run alive until `hold()` (a spare shell waiting for its task). */
33
33
  held?: boolean
34
34
  }
35
35
 
@@ -86,10 +86,12 @@ function thread(): Worker {
86
86
  pending.delete(m.id)
87
87
  const err = new Error(m.error.message) as NodeJS.ErrnoException
88
88
  if (m.error.code !== undefined) err.code = m.error.code
89
- if (p.held()) settleLive(-1)
89
+ settleLive(-1)
90
90
  p.failed(err)
91
- } else if (m.pid !== undefined) p.spawned(m.pid)
92
- else {
91
+ } else if (m.pid !== undefined) {
92
+ if (!p.held()) settleLive(-1)
93
+ p.spawned(m.pid)
94
+ } else {
93
95
  pending.delete(m.id)
94
96
  if (p.held()) settleLive(-1)
95
97
  p.exit(m)
@@ -113,7 +115,10 @@ export function spawnOff(opts: OffSpawn): Promise<OffChild> {
113
115
  const w = thread()
114
116
  const id = nextId++
115
117
  let held = opts.held !== false
116
- if (held) settleLive(1)
118
+ // Every spawn holds the run until its reply: a caller awaiting a spare's
119
+ // spawn (`runOnSpare`) had nothing else holding the loop, and the run
120
+ // drained mid-graph with every slot waiting on one (2026-11-06).
121
+ settleLive(1)
117
122
  const { held: _, ...spawn } = opts
118
123
  return new Promise((resolve, reject) => {
119
124
  let exitCode: number | null = null
@@ -0,0 +1,93 @@
1
+ // One task's own proxy: the runtime's proxy is the run's and judges every
2
+ // connection by the union of all tasks' network lists, so a task reached a
3
+ // host only another task of the run declared. Each networked task gets this
4
+ // front on its own socket, which judges by the task's lists and hands what
5
+ // it allows to the run's proxy as its parent (that proxy still applies the
6
+ // union and its address guard). A task names itself to a proxy by a
7
+ // username it writes, so only the socket it was given says which task it is.
8
+
9
+ import { BlockList } from 'node:net'
10
+
11
+ /** The deep modules of the runtime the front is built from: its own proxy code, not a copy. */
12
+ async function loadProxyParts() {
13
+ // Literal specifiers: `bun build --compile` embeds only what it can see.
14
+ const [http, socks, mux, pattern, parent] = await Promise.all([
15
+ import('@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.js'),
16
+ import('@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.js'),
17
+ import('@anthropic-ai/sandbox-runtime/dist/sandbox/mux-proxy.js'),
18
+ import('@anthropic-ai/sandbox-runtime/dist/sandbox/domain-pattern.js'),
19
+ import('@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.js'),
20
+ ])
21
+ return { http, socks, mux, pattern, parent }
22
+ }
23
+
24
+ export interface TaskProxy {
25
+ close(): Promise<void>
26
+ }
27
+
28
+ /**
29
+ * Listen at `endpoint` (a unix socket path, or 0 for a localhost port) for
30
+ * one task. Returns the bound port when `endpoint` is 0.
31
+ */
32
+ export async function startTaskProxy(opts: {
33
+ endpoint: string | 0
34
+ allowed: readonly string[]
35
+ denied: readonly string[]
36
+ /** The run's proxy: its port and the session token both legs carry. */
37
+ runProxyPort: number
38
+ token: string
39
+ /** The task's attribution key (`srt.<encodedCommand>`'s suffix). */
40
+ encodedCommand: string
41
+ /** A refusal this front made, in the runtime's own record form. */
42
+ refused(line: string): void
43
+ }): Promise<TaskProxy & { port: number | undefined }> {
44
+ const { http, socks, mux, pattern, parent } = await loadProxyParts()
45
+ const filter = (port: number, host: string): boolean => {
46
+ const deny = (reason: string) => {
47
+ opts.refused(`deny network-outbound ${host}:${port} (${reason})`)
48
+ return false
49
+ }
50
+ if (!parent.isValidHost(host)) return deny('malformed host')
51
+ const canonical: string = parent.canonicalizeHost(host) ?? host
52
+ if (opts.denied.some((d) => pattern.matchesDomainPatternWithPort(canonical, port, d)))
53
+ return deny('host is on the deny list')
54
+ if (opts.allowed.some((a) => pattern.matchesDomainPatternWithPort(canonical, port, a)))
55
+ return true
56
+ return deny('host is not on the allow list')
57
+ }
58
+ const user = encodeURIComponent(`srt.${opts.encodedCommand}`)
59
+ const up = new URL(
60
+ `http://${user}:${encodeURIComponent(opts.token)}@127.0.0.1:${opts.runProxyPort}`,
61
+ )
62
+ // Every allowed connection goes on to the run's proxy, which applies the
63
+ // run's lists and address guard again; only a loopback host, which the
64
+ // runtime never sends to a parent, is dialed from here, and only one the
65
+ // task named.
66
+ const parentProxy = {
67
+ httpUrl: up,
68
+ httpsUrl: up,
69
+ noProxy: { all: false, suffixes: [], cidr: new BlockList() },
70
+ }
71
+ const httpServer = http.createHttpProxyServer({
72
+ filter: (port: number, host: string) => filter(port, host),
73
+ parentProxy,
74
+ proxyAuthToken: opts.token,
75
+ })
76
+ const socksServer = socks.createSocksProxyServer({
77
+ filter: (port: number, host: string) => filter(port, host),
78
+ parentProxy,
79
+ proxyAuthToken: opts.token,
80
+ })
81
+ const front = mux.createMuxProxyServer({
82
+ httpServer,
83
+ handleSocksConnection: (s) => socksServer.handleConnection(s),
84
+ })
85
+ await front.listenHttpBackend()
86
+ await new Promise<void>((resolve, reject) => {
87
+ front.server.once('error', reject)
88
+ if (opts.endpoint === 0) front.server.listen(0, '127.0.0.1', () => resolve())
89
+ else front.server.listen(opts.endpoint, () => resolve())
90
+ })
91
+ front.unref()
92
+ return { port: front.getPort(), close: () => front.close() }
93
+ }
@@ -93,10 +93,32 @@ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
93
93
  const quote = (v: string): string => `'${v.replaceAll("'", "'\\''")}'`
94
94
 
95
95
  /**
96
- * The script a spare runs as the task: its directory (127 when it is gone,
97
- * as a spawn into it fails; the OLDPWD `cd` sets is not the task's), its
98
- * env, then `command`. Undefined when an env
99
- * name is one sh cannot export: that task spawns fresh.
96
+ * Names sh sets itself at start, whatever its env holds: dash resets IFS
97
+ * and OPTIND, sets PPID and keeps PWD only when it names the directory;
98
+ * bash, a distro's sh, also counts SHLVL up and refuses to assign its
99
+ * read-only names. Exported into a shell already started, they kept the
100
+ * value given (`IFS=:` split the command's words) or ended the shell.
101
+ */
102
+ const SHELL_SET = new Set([
103
+ 'IFS',
104
+ 'OPTIND',
105
+ 'PPID',
106
+ 'PWD',
107
+ 'SHLVL',
108
+ 'SHELLOPTS',
109
+ 'BASHOPTS',
110
+ 'UID',
111
+ 'EUID',
112
+ ])
113
+
114
+ /**
115
+ * The script a spare runs as the task: its directory by its physical path,
116
+ * as a fresh shell's PWD names it (127 when it is gone, as a spawn into it
117
+ * fails; the OLDPWD `cd` sets is not the task's), its env, then `command`
118
+ * through `eval`, which numbers the command's lines from 1 as `sh -c` does
119
+ * (sourced after the script's own lines, sh's "not found" named line 3 +
120
+ * the env's size). Undefined when an env name is one sh cannot export or
121
+ * sets itself: that task spawns fresh.
100
122
  */
101
123
  export function spareScript(
102
124
  cwd: string,
@@ -105,15 +127,15 @@ export function spareScript(
105
127
  ): string | undefined {
106
128
  // A script past what one pipe write lands whole would hold vx's thread.
107
129
  if (command.length > SCRIPT_BYTES) return undefined
108
- let out = `exec 3<&-\ncd -- ${quote(cwd)} 2>/dev/null || exit 127\nunset OLDPWD\n`
130
+ let out = `exec 3<&-\ncd -P -- ${quote(cwd)} 2>/dev/null || exit 127\nunset OLDPWD\n`
109
131
  for (const k in env) {
110
132
  const v = env[k]
111
133
  // Undefined unsets the name: a spawn drops it.
112
134
  if (v === undefined) continue
113
- if (!NAME.test(k)) return undefined
135
+ if (!NAME.test(k) || SHELL_SET.has(k)) return undefined
114
136
  out += `export ${k}=${quote(v)}\n`
115
137
  }
116
- out += `${command}\n`
138
+ out += `eval ${quote(command)}\n`
117
139
  return Buffer.byteLength(out) > SCRIPT_BYTES ? undefined : out
118
140
  }
119
141