@vzn/vx 0.0.652 → 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.
- package/README.md +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- 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 =
|
|
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
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 =
|
|
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
|
-
{
|
|
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
|
-
|
|
208
|
-
|
|
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 (
|
|
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[
|
|
296
|
+
const at = readPath(m[3] ?? 'AT_FDCWD', cStringPath(m[4]!), m[5])
|
|
220
297
|
if (at !== undefined)
|
|
221
|
-
denied(m[1]!, { syscall:
|
|
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[
|
|
234
|
-
const rawPath = cStringPath(done[
|
|
235
|
-
denied(
|
|
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[
|
|
329
|
+
unfinished[5] !== undefined
|
|
243
330
|
) {
|
|
244
|
-
const rawPath = cStringPath(unfinished[
|
|
331
|
+
const rawPath = cStringPath(unfinished[5])
|
|
245
332
|
pending.set(unfinished[1], {
|
|
246
333
|
syscall: unfinished[2],
|
|
247
334
|
rawPath,
|
|
248
|
-
...within(unfinished[
|
|
249
|
-
...(
|
|
250
|
-
...(reads
|
|
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 =
|
|
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(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
package/src/exec/spawn-off.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
+
settleLive(-1)
|
|
90
90
|
p.failed(err)
|
|
91
|
-
} else if (m.pid !== undefined)
|
|
92
|
-
|
|
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
|
-
|
|
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
|
package/src/exec/warm-shell.ts
CHANGED
|
@@ -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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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 +=
|
|
138
|
+
out += `eval ${quote(command)}\n`
|
|
117
139
|
return Buffer.byteLength(out) > SCRIPT_BYTES ? undefined : out
|
|
118
140
|
}
|
|
119
141
|
|