@vzn/vx 0.0.594 → 0.0.596

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 (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. package/src/workspace/workspace.ts +98 -20
package/src/config.ts CHANGED
@@ -292,10 +292,10 @@ export interface SandboxConfig {
292
292
 
293
293
  /** The denials `sandbox.ignore` can leave out of the report: patterns per class. */
294
294
  export interface SandboxIgnore {
295
- read?: string[]
296
- write?: string[]
297
- systemInfo?: string[]
298
- network?: string[]
295
+ read?: readonly string[]
296
+ write?: readonly string[]
297
+ systemInfo?: readonly string[]
298
+ network?: readonly string[]
299
299
  }
300
300
 
301
301
  export interface SandboxGrants {
@@ -304,29 +304,29 @@ export interface SandboxGrants {
304
304
  * `node_modules`, NOT `cache.inputs.files`. A task that reads its own
305
305
  * source in the sandbox says so here.
306
306
  */
307
- read?: string[]
307
+ read?: readonly string[]
308
308
  /**
309
309
  * Writable paths or globs. The task can write NOTHING without these:
310
310
  * declaring `cache.outputs.files` grants no write, and a task whose
311
311
  * only declaration is its outputs writes into a tmpfs that evaporates
312
312
  * (vx then warns that `cache.outputs` matched no files).
313
313
  */
314
- write?: string[]
314
+ write?: readonly string[]
315
315
  /**
316
316
  * Reachable domains, `['*.example.com']` a pattern. The proxy is the
317
317
  * run's: it filters every sandboxed task against the union of the lists
318
318
  * the run's tasks declare, so `true` adds no domain and reaches only
319
319
  * those (none in a run with no list). Omitted adds none either.
320
320
  */
321
- network?: true | string[]
321
+ network?: true | readonly string[]
322
322
  /**
323
323
  * Kernel information the task may query, by info type — the name that
324
324
  * appears in a denial, e.g. `vfs.disk-space` (every Bun and git process
325
325
  * probes free space at startup).
326
326
  */
327
- systemInfo?: string[]
327
+ systemInfo?: readonly string[]
328
328
  /** Unix socket paths the task may connect to. `true` allows any. */
329
- unixSockets?: true | string[]
329
+ unixSockets?: true | readonly string[]
330
330
  /**
331
331
  * Bind and reach localhost ports — a test that boots a server for
332
332
  * itself. A port LIST additionally makes those ports reachable from
@@ -335,9 +335,9 @@ export interface SandboxGrants {
335
335
  * namespace, each listed port is bridged out to the host's loopback;
336
336
  * on macOS the host already sees them and the list means `true`.
337
337
  */
338
- localBinding?: boolean | number[]
338
+ localBinding?: boolean | readonly number[]
339
339
  /** macOS: Mach service names the task may look up. */
340
- machLookup?: string[]
340
+ machLookup?: readonly string[]
341
341
  /** Acquire a TTY. Rare outside interactive tools. */
342
342
  pty?: boolean
343
343
  /** Write to `.git/config`. Most build tools should not. */
@@ -346,7 +346,7 @@ export interface SandboxGrants {
346
346
 
347
347
  export interface SandboxDenials {
348
348
  /** Domains blocked even if `allow.network` would admit them. */
349
- network?: string[]
349
+ network?: readonly string[]
350
350
  }
351
351
 
352
352
  export interface ExecConfig {
@@ -458,7 +458,7 @@ export interface ExecEnv {
458
458
  * folded into the cache key — for things like CI flags, regional
459
459
  * vars, secrets that change between machines without affecting output.
460
460
  */
461
- passThrough?: string[]
461
+ passThrough?: readonly string[]
462
462
  /**
463
463
  * Explicit `name: value` pairs. The values are set on the child AND
464
464
  * folded into the cache key (they are literal in your config, captured
@@ -471,7 +471,7 @@ export interface ExecEnv {
471
471
  * hold `TOKEN`, `SECRET`, `KEY`, `PASSWORD`, `PASSWD` or `CREDENTIAL`
472
472
  * are masked without it; this reaches `GH_PAT`, `NPM_AUTH`.
473
473
  */
474
- secret?: string[]
474
+ secret?: readonly string[]
475
475
  }
476
476
 
477
477
  export interface CacheConfig {
@@ -488,7 +488,7 @@ export interface CacheOutputs {
488
488
  * Outputs are not filtered through gitignore — typical artifact dirs
489
489
  * like `dist/` and `coverage/` are captured normally even when ignored.
490
490
  */
491
- files: string[]
491
+ files: readonly string[]
492
492
  /**
493
493
  * Files produced OUTSIDE the project dir, as workspace-root-relative
494
494
  * globs (the Turbo `$TURBO_ROOT$` / Nx `{workspaceRoot}` escape
@@ -500,7 +500,7 @@ export interface CacheOutputs {
500
500
  * like overlapping `files` (vx cleans declared outputs before a run
501
501
  * and before a restore, so the second would delete the first's).
502
502
  */
503
- workspaceFiles?: string[]
503
+ workspaceFiles?: readonly string[]
504
504
  }
505
505
 
506
506
  export interface CacheInputs {
@@ -510,11 +510,11 @@ export interface CacheInputs {
510
510
  * project files" (gitignore-aware), or narrow as needed. An empty
511
511
  * array means no file inputs at all.
512
512
  *
513
- * Declared outputs and any nested-project files are excluded
514
- * automatically — a task cannot invalidate itself, and cannot read
515
- * across project boundaries.
513
+ * Declared outputs (both `outputs` fields) and any nested-project
514
+ * files are excluded automatically — a task cannot invalidate itself,
515
+ * and cannot read across project boundaries.
516
516
  */
517
- files: string[]
517
+ files: readonly string[]
518
518
  /**
519
519
  * Workspace-root-relative globs (the Turbo `$TURBO_ROOT$` / Nx
520
520
  * `{workspaceRoot}` equivalent), for inputs that live outside the
@@ -525,10 +525,10 @@ export interface CacheInputs {
525
525
  * NO project-boundary rule applies: these globs may match files
526
526
  * inside other projects' directories. Deliberate escape hatch —
527
527
  * prefer project-relative `files` declarations where possible.
528
- * Declared `outputs.workspaceFiles` are excluded automatically (a
529
- * task cannot invalidate itself).
528
+ * Declared outputs (both `outputs` fields) are excluded
529
+ * automatically (a task cannot invalidate itself).
530
530
  */
531
- workspaceFiles?: string[]
531
+ workspaceFiles?: readonly string[]
532
532
  /**
533
533
  * Env var names whose runtime values from parent `process.env` are
534
534
  * folded into the cache key. **Independent of `exec.env`** — declaring
@@ -536,7 +536,7 @@ export interface CacheInputs {
536
536
  * invalidation. To both forward AND track, list the name in both
537
537
  * `exec.env.passThrough` and here.
538
538
  */
539
- env?: string[]
539
+ env?: readonly string[]
540
540
  /**
541
541
  * Which upstream tasks' cache keys participate in this task's key.
542
542
  * Uses the same Turbo/Nx micro-syntax as `dependsOn`, plus
@@ -565,7 +565,7 @@ export interface CacheInputs {
565
565
  * lock, the OUTPUT is resolved live every run — correct under
566
566
  * `--frozen`. Use for project-specific runtime probes.
567
567
  */
568
- runtime?: string[]
568
+ runtime?: readonly string[]
569
569
  /**
570
570
  * Like `runtime`, but commands run at the WORKSPACE ROOT and are
571
571
  * deduped GLOBALLY per command across the whole run — a `node -v`
@@ -573,7 +573,7 @@ export interface CacheInputs {
573
573
  * analog of `workspaceFiles`: per-task, root-anchored. Use for global
574
574
  * tool versions, OS info, etc.
575
575
  */
576
- workspaceRuntime?: string[]
576
+ workspaceRuntime?: readonly string[]
577
577
  }
578
578
 
579
579
  /**
package/src/exec/env.ts CHANGED
@@ -33,6 +33,11 @@ export const ESSENTIAL_ENV: readonly string[] = [
33
33
  'NO_COLOR',
34
34
  'CI',
35
35
  'NODE_OPTIONS',
36
+ // Where corepack and pnpm keep their own binaries, as HOME is: a machine
37
+ // path, never task output. Without them a corepack shim re-downloads its
38
+ // manager (offline or sandboxed: fails) and pnpm loses its global dir.
39
+ 'COREPACK_HOME',
40
+ 'PNPM_HOME',
36
41
  ]
37
42
 
38
43
  /**
package/src/exec/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  exitSignal,
19
19
  type CaptureConfig,
20
20
  execWord,
21
+ maskCaptured,
21
22
  } from './runner.js'
22
23
  export {
23
24
  initSandbox,
@@ -26,7 +26,7 @@ interface Proc {
26
26
 
27
27
  /**
28
28
  * Linux's `/proc/<pid>/stat` counts CPU in USER_HZ ticks, fixed at 100 by
29
- * the ABI on every architecture; `tests/proc-sample.test.ts` pins it by
29
+ * the ABI on every architecture; `tests/proc-sample.unsafe.test.ts` pins it by
30
30
  * burning a known span and reading it back.
31
31
  */
32
32
  const TICK_MS = 10
@@ -134,12 +134,16 @@ async function psTable(): Promise<Map<number, Proc> | undefined> {
134
134
  return table
135
135
  }
136
136
 
137
- /** `ps`'s `time`: `[[dd-]hh:]mm:ss[.cc]`, macOS printing hundredths. */
137
+ /**
138
+ * `ps`'s `time`: `[[dd-]hh:]mm:ss[.cc]`, macOS printing hundredths after
139
+ * the locale's decimal point — `0:01,50` under a German or French locale.
140
+ */
138
141
  export function psTimeMs(s: string): number | undefined {
139
142
  const dash = s.indexOf('-')
140
143
  const days = dash === -1 ? 0 : Number(s.slice(0, dash))
141
144
  const parts = s
142
145
  .slice(dash + 1)
146
+ .replace(',', '.')
143
147
  .split(':')
144
148
  .map(Number)
145
149
  if (parts.length < 2 || parts.some((n) => !Number.isFinite(n)) || !Number.isFinite(days)) {
@@ -12,6 +12,7 @@ import {
12
12
  isOutOfFds,
13
13
  killGraceMs,
14
14
  OUT_OF_FDS_HINT,
15
+ type SecretMask,
15
16
  } from '../util/index.js'
16
17
  import {
17
18
  closeSignalChannel,
@@ -112,30 +113,49 @@ export function shellQuote(arg: string): string {
112
113
  }
113
114
 
114
115
  /**
115
- * Where a shell comment still open at the end of `command` starts, or -1.
116
- * A `#` opens one only unquoted and at the start of a word (`a#b`, `$#` and
117
- * `${#x}` are words), and a newline closes it.
116
+ * One pass over `command` as sh reads it. `comment`: where a comment still
117
+ * open at the end starts, or -1 — a `#` opens one only unquoted and at the
118
+ * start of a word (`a#b`, `$#` and `${#x}` are words), and a newline closes
119
+ * it. `body`: per line, is it a heredoc's body or terminator (`<<X` /
120
+ * `<<-'X'` / `<<\X` … `X`). One scan, because each needs the other: a
121
+ * `<<X` in a comment or quotes opens no heredoc, and a quote in a body
122
+ * opens nothing (X-110).
118
123
  */
119
- function trailingCommentStart(command: string): number {
124
+ function shellShape(command: string): { comment: number; body: boolean[] } {
125
+ const lines = command.split('\n')
126
+ const body = lines.map(() => false)
127
+ const pending: Array<{ delim: string; tabs: boolean }> = []
120
128
  let quote = ''
121
129
  let comment = -1
122
130
  let atWordStart = true
131
+ let line = 0
123
132
  for (let i = 0; i < command.length; i++) {
124
133
  const c = command[i]!
125
- if (comment >= 0) {
126
- if (c === '\n') {
127
- comment = -1
128
- atWordStart = true
134
+ if (c === '\n') {
135
+ line++
136
+ comment = -1
137
+ if (quote !== '') continue
138
+ atWordStart = true
139
+ // A heredoc's body starts on the line after its `<<`.
140
+ while (pending.length > 0 && line < lines.length) {
141
+ const text = lines[line]!
142
+ body[line] = true
143
+ const open = pending[0]!
144
+ if ((open.tabs ? text.replace(/^\t+/, '') : text) === open.delim) pending.shift()
145
+ i += text.length + 1
146
+ line++
129
147
  }
130
148
  continue
131
149
  }
150
+ if (comment >= 0) continue
132
151
  if (quote === "'") {
133
152
  if (c === "'") quote = ''
134
153
  continue
135
154
  }
136
155
  if (c === '\\') {
156
+ if (command[i + 1] === '\n') line++
157
+ else atWordStart = false
137
158
  i++
138
- atWordStart = false
139
159
  continue
140
160
  }
141
161
  if (quote === '"') {
@@ -151,25 +171,18 @@ function trailingCommentStart(command: string): number {
151
171
  comment = i
152
172
  continue
153
173
  }
174
+ if (c === '<' && command[i - 1] !== '<') {
175
+ const m = /^<<(-?)[ \t]*\\?(['"]?)([A-Za-z_]\w*)\2/.exec(command.slice(i, i + 256))
176
+ if (m !== null) {
177
+ pending.push({ delim: m[3]!, tabs: m[1] === '-' })
178
+ i += m[0].length - 1
179
+ atWordStart = false
180
+ continue
181
+ }
182
+ }
154
183
  atWordStart = /[\s;&|()<>]/.test(c)
155
184
  }
156
- return comment
157
- }
158
-
159
- /** Per line: is it a heredoc's body or terminator (`<<X` / `<<-'X'` … `X`)? */
160
- function heredocLines(lines: readonly string[]): boolean[] {
161
- const pending: Array<{ delim: string; tabs: boolean }> = []
162
- return lines.map((line) => {
163
- const open = pending[0]
164
- if (open !== undefined) {
165
- if ((open.tabs ? line.replace(/^\t+/, '') : line) === open.delim) pending.shift()
166
- return true
167
- }
168
- for (const m of line.matchAll(/(?<!<)<<(?!<)(-?)\s*(['"]?)([A-Za-z_]\w*)\2/g)) {
169
- pending.push({ delim: m[3]!, tabs: m[1] === '-' })
170
- }
171
- return false
172
- })
185
+ return { comment, body }
173
186
  }
174
187
 
175
188
  /**
@@ -183,27 +196,34 @@ export function withForwardArgs(command: string, args: readonly string[] | undef
183
196
  // `X FWD` closed nothing and the args never reached the command (X-12).
184
197
  // They go on the last line that is a command, where `cat <<X FWD` is
185
198
  // still `cat FWD` reading the heredoc.
186
- const lines = command.trimEnd().split('\n')
187
- const body = heredocLines(lines)
188
- if (body[lines.length - 1] === true) {
189
- const last = body.lastIndexOf(false)
199
+ const trimmed = command.trimEnd()
200
+ const lines = trimmed.split('\n')
201
+ const shape = shellShape(trimmed)
202
+ if (shape.body[lines.length - 1] === true) {
203
+ const last = shape.body.lastIndexOf(false)
190
204
  const tail = lines.slice(last + 1).join('\n')
191
205
  return `${withForwardArgs(lines.slice(0, last + 1).join('\n'), args)}\n${tail}`
192
206
  }
193
207
  const quoted = args.map(shellQuote).join(' ')
194
- let comment = trailingCommentStart(command)
208
+ // Of the trimmed command: the newline closing a template literal's last
209
+ // line closed its comment too, and `echo args: # c\n` took the args into
210
+ // the comment (X-110).
211
+ let comment = shape.comment
195
212
  if (comment < 0) {
196
213
  // A command that ends in a newline (a template literal's closing line)
197
214
  // ran the args as a command of their own: `--watch: not found`. Kept
198
215
  // when an odd run of backslashes ends it: that escapes what follows.
199
- const body = command.trimEnd()
200
- const escaped = (/\\+$/.exec(body)?.[0].length ?? 0) % 2 === 1
201
- return `${escaped ? command : body} ${quoted}`
216
+ const escaped = (/\\+$/.exec(trimmed)?.[0].length ?? 0) % 2 === 1
217
+ return `${escaped ? command : trimmed} ${quoted}`
202
218
  }
203
219
  // A comment-only last line after a commented line: the args go before
204
220
  // the earliest comment, or they land inside it (`echo one # c\n# two`).
205
- for (let c = comment; c >= 0; c = trailingCommentStart(command.slice(0, c).trimEnd())) comment = c
206
- return `${command.slice(0, comment).trimEnd()} ${quoted} ${command.slice(comment)}`
221
+ for (let c = comment; c >= 0; c = shellShape(trimmed.slice(0, c).trimEnd()).comment) comment = c
222
+ // What precedes the comment may end in a heredoc: the args go where they
223
+ // would with no comment, never on its terminator.
224
+ const head = trimmed.slice(0, comment).trimEnd()
225
+ const gap = trimmed.slice(head.length, comment) || ' '
226
+ return `${withForwardArgs(head, args)}${gap}${trimmed.slice(comment)}`
207
227
  }
208
228
 
209
229
  // Any shell control/expansion character means the wrapping `sh` has real
@@ -348,6 +368,18 @@ const SIGNAL_ALIASES = new Set(['SIGIOT', 'SIGPOLL', 'SIGCLD'])
348
368
  */
349
369
  const TIMEOUT_SIGKILL_GRACE_MS = 2000
350
370
 
371
+ /**
372
+ * Run `fn` one loop turn later, once the child exits already pending are
373
+ * read. A deadline due in the same turn as a child's exit fires BEFORE Bun
374
+ * reaps it: a task that exited 0 inside its timeout, seen late by a busy vx,
375
+ * was SIGTERMed as a zombie and failed as timed out, and a server that died
376
+ * before ready read as a readiness timeout (2026-10-08). A turn later the
377
+ * exit has settled the caller, which clears the returned timer.
378
+ */
379
+ function afterPendingExits(fn: () => void): ReturnType<typeof setTimeout> {
380
+ return setTimeout(fn, 0)
381
+ }
382
+
351
383
  /**
352
384
  * Arm a SIGTERM timeout on a spawned child. Returns a handle whose
353
385
  * `timedOut()` reports whether the timer fired — so the caller can
@@ -368,7 +400,10 @@ export function armTimeout(
368
400
  let firedAt: number | undefined
369
401
  let killTimer: ReturnType<typeof setTimeout> | undefined
370
402
  const graceMs = killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)
371
- const timer = setTimeout(() => {
403
+ let timer = setTimeout(() => {
404
+ timer = afterPendingExits(fire)
405
+ }, timeoutMs)
406
+ function fire(): void {
372
407
  firedAt = Date.now()
373
408
  killTree(proc, 'SIGTERM')
374
409
  // Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
@@ -379,7 +414,7 @@ export function armTimeout(
379
414
  // the CLI alive.
380
415
  killTimer = setTimeout(() => killTree(proc, 'SIGKILL'), graceMs)
381
416
  killTimer.unref?.()
382
- }, timeoutMs)
417
+ }
383
418
  return {
384
419
  timedOut: () => firedAt !== undefined,
385
420
  settle: async () => {
@@ -680,7 +715,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
680
715
  // a healthy server is never killed by a stale timer.
681
716
  let readyTimer: ReturnType<typeof setTimeout> | undefined
682
717
  if (readyRe && opts.timeoutMs !== undefined) {
683
- readyTimer = setTimeout(() => {
718
+ const giveUp = (): void => {
684
719
  if (readyAt === undefined) {
685
720
  gaveUpAt = Date.now()
686
721
  rejectReady(
@@ -705,6 +740,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
705
740
  }, killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
706
741
  killTimer.unref?.()
707
742
  }
743
+ }
744
+ readyTimer = setTimeout(() => {
745
+ readyTimer = afterPendingExits(giveUp)
708
746
  }, opts.timeoutMs)
709
747
  }
710
748
 
@@ -883,6 +921,23 @@ export function droppedOutputLine(dropped: number): string {
883
921
  )
884
922
  }
885
923
 
924
+ /**
925
+ * A captured `text` with `secrets` masked. A value the dropped middle cut
926
+ * left a piece at the head's end and one at the tail's start, which the
927
+ * whole-value mask missed, and the cache entry stored and replayed both.
928
+ */
929
+ export function maskCaptured(text: string, secrets: SecretMask): string {
930
+ const at = text.indexOf('\n[vx] ', CAPTURE_HEAD_CHARS - 1)
931
+ const end = at === -1 ? -1 : text.indexOf('\n', at + 1) + 1
932
+ if (at === -1 || at > CAPTURE_HEAD_CHARS || !DROPPED_LINE.test(text.slice(at, end))) {
933
+ return secrets.mask(text)
934
+ }
935
+ const [head, tail] = secrets.maskCut(text.slice(0, at), text.slice(end))
936
+ return head + text.slice(at, end) + tail
937
+ }
938
+
939
+ const DROPPED_LINE = /^\n\[vx\] [\d.]+ MiB of output not kept — /
940
+
886
941
  const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
887
942
 
888
943
  /**
@@ -897,7 +952,10 @@ class BoundedCapture {
897
952
  private dropped = 0
898
953
 
899
954
  push(chunk: string): void {
900
- if (this.head.length < CAPTURE_HEAD_CHARS) {
955
+ // Once the tail has begun the head is closed, even a unit short of its
956
+ // bound (the surrogate case below): a later short chunk fit there and
957
+ // was retained ahead of the chunk before it.
958
+ if (this.tailLen === 0 && this.head.length < CAPTURE_HEAD_CHARS) {
901
959
  let room = CAPTURE_HEAD_CHARS - this.head.length
902
960
  if (chunk.length <= room) {
903
961
  this.head += chunk
@@ -78,14 +78,16 @@ export function localBindingOn(c: { localBinding?: boolean | readonly number[] }
78
78
  }
79
79
 
80
80
  /**
81
- * Whether a sandboxed task may read `file`: it lies under a read grant,
82
- * a write grant or a baseline read, by its canonical path. A path no
83
- * grant holds is not there inside the sandbox, which the shell reports
84
- * as "not found" with no denial a trace can see (an `execve`).
81
+ * Whether a sandboxed task may read `file`: it lies under no denial (the
82
+ * rest of the host is mounted read-only), or under a read grant, a write
83
+ * grant or a baseline read, by its canonical path. A path no grant holds
84
+ * under a denial is not there inside the sandbox, which the shell reports
85
+ * as "not found": a PATH search is a stat, which the trace does not stop on.
85
86
  */
86
87
  export function sandboxReads(
87
88
  sandbox: {
88
89
  readonly baseAllowRead: readonly string[]
90
+ readonly baseDenyRead: readonly string[]
89
91
  readonly config: {
90
92
  readonly allowRead: readonly string[]
91
93
  readonly allowWrite: readonly string[]
@@ -94,7 +96,11 @@ export function sandboxReads(
94
96
  file: string,
95
97
  ): boolean {
96
98
  const real = toRealPath(file)
97
- return [...sandbox.baseAllowRead, ...sandbox.config.allowRead, ...sandbox.config.allowWrite].some(
98
- (g) => atOrUnder(real, toRealPath(g)),
99
+ const under = (g: string): boolean => atOrUnder(real, toRealPath(g))
100
+ return (
101
+ !sandbox.baseDenyRead.some(under) ||
102
+ [...sandbox.baseAllowRead, ...sandbox.config.allowRead, ...sandbox.config.allowWrite].some(
103
+ under,
104
+ )
99
105
  )
100
106
  }