@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
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
toRealPath,
|
|
14
14
|
unique,
|
|
15
15
|
} from './sandbox-paths.js'
|
|
16
|
-
import
|
|
16
|
+
import { shellQuote } from './runner.js'
|
|
17
|
+
import type { ResolvedSandboxConfig, SandboxedRunArgs } from './sandbox-runtime.js'
|
|
17
18
|
import * as ui from '../ui/index.js'
|
|
18
19
|
|
|
19
20
|
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
@@ -40,6 +41,48 @@ export function bindableWrites(paths: readonly string[]): string[] {
|
|
|
40
41
|
return unique(paths.filter((p) => !holdsGlobChar(p)).map(bindOf))
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Linux: each write grant spelled through a link in the project, as the
|
|
46
|
+
* bind that puts it where the task names it: `[source, at]`, the grant's
|
|
47
|
+
* own bind and its twin under the link. A file grant binds its directory,
|
|
48
|
+
* so its twin is the link's; a link that is the file itself sits in the
|
|
49
|
+
* bound directory already and needs none. SRT skips a write path that is
|
|
50
|
+
* a sibling link (it reads one as leaving its tree), so vx lays these
|
|
51
|
+
* itself (`withLinkedWrites`).
|
|
52
|
+
*/
|
|
53
|
+
export function linkedWriteBinds(
|
|
54
|
+
linked: ResolvedSandboxConfig['linkedWrites'],
|
|
55
|
+
): Array<readonly [string, string]> {
|
|
56
|
+
if (process.platform !== 'linux' || linked === undefined) return []
|
|
57
|
+
const out: Array<readonly [string, string]> = []
|
|
58
|
+
for (const { link, target } of linked) {
|
|
59
|
+
if (holdsGlobChar(target) || holdsGlobChar(link)) continue
|
|
60
|
+
const source = bindOf(target)
|
|
61
|
+
const at = source === target ? link : path.dirname(link)
|
|
62
|
+
if (at !== source && !out.some(([, a]) => a === at)) out.push([source, at])
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Linux: SRT's bwrap line with each `linkedWriteBinds` pair bound last,
|
|
69
|
+
* before the `--` that ends bwrap's options: after the masks and the read
|
|
70
|
+
* grants, so none lands over it. The link's directory is then a mount of
|
|
71
|
+
* its target, writable as the grant is.
|
|
72
|
+
*/
|
|
73
|
+
export function withLinkedWrites(
|
|
74
|
+
wrapped: string,
|
|
75
|
+
binds: ReadonlyArray<readonly [string, string]>,
|
|
76
|
+
): string {
|
|
77
|
+
if (binds.length === 0) return wrapped
|
|
78
|
+
for (const m of wrapped.matchAll(/(?:'[^']*'|"[^"]*"|[^\s'"])+/g)) {
|
|
79
|
+
if (m[0] !== '--') continue
|
|
80
|
+
const words = binds.map(([s, a]) => `--bind ${shellQuote(s)} ${shellQuote(a)} `).join('')
|
|
81
|
+
return `${wrapped.slice(0, m.index)}${words}${wrapped.slice(m.index)}`
|
|
82
|
+
}
|
|
83
|
+
return wrapped
|
|
84
|
+
}
|
|
85
|
+
|
|
43
86
|
function bindOf(p: string): string {
|
|
44
87
|
if (!isMountableLiteral(p)) return p
|
|
45
88
|
try {
|
|
@@ -162,7 +205,12 @@ const warnedSymlinkPunch = new Set<string>()
|
|
|
162
205
|
* A grant with no write path under it is returned untouched — the common
|
|
163
206
|
* case costs nothing, not even a readdir.
|
|
164
207
|
*/
|
|
165
|
-
export function punchWritePaths(
|
|
208
|
+
export function punchWritePaths(
|
|
209
|
+
readPath: string,
|
|
210
|
+
writePaths: readonly string[],
|
|
211
|
+
/** Whether a link child's real path may be bound; one it may not is dropped. */
|
|
212
|
+
reachable: (real: string) => boolean = () => true,
|
|
213
|
+
): string[] {
|
|
166
214
|
// Linux only. The shadowing is a property of bwrap MOUNTS; macOS seatbelt
|
|
167
215
|
// evaluates rules by precedence, so a read grant on a directory and a
|
|
168
216
|
// write grant inside it coexist. Punching there costs the directory
|
|
@@ -193,25 +241,57 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
|
|
|
193
241
|
// SRT's config carries no `--symlink`, so the only fix is the grant:
|
|
194
242
|
// say which one, loudly.
|
|
195
243
|
try {
|
|
196
|
-
if (lstatSync(child).isSymbolicLink())
|
|
244
|
+
if (lstatSync(child).isSymbolicLink()) {
|
|
245
|
+
// Bound by its target, a link out to a denied place (`node_modules
|
|
246
|
+
// -> ..`, `shared -> ../b`) granted what the whole grant never did:
|
|
247
|
+
// bound unpunched, the link stays a link into the deny (item 1006's
|
|
248
|
+
// glob hit, through the punch).
|
|
249
|
+
if (!reachable(toRealPath(child))) continue
|
|
250
|
+
linked.push(child)
|
|
251
|
+
}
|
|
197
252
|
} catch {
|
|
198
253
|
// vanished between readdir and lstat: nothing to bind either way
|
|
199
254
|
}
|
|
200
|
-
out.push(...punchWritePaths(child, under))
|
|
255
|
+
out.push(...punchWritePaths(child, under, reachable))
|
|
201
256
|
}
|
|
202
257
|
if (linked.length > 0 && !warnedSymlinkPunch.has(readPath)) {
|
|
203
258
|
warnedSymlinkPunch.add(readPath)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
259
|
+
// A link INTO a write grant is that grant under a second name, mounted
|
|
260
|
+
// read-only: the remedy is to grant the name (`linkedWriteBinds` binds
|
|
261
|
+
// it writable), not to move the grant.
|
|
262
|
+
const plain: string[] = []
|
|
263
|
+
for (const l of linked) {
|
|
264
|
+
const w = writePaths.find((w) => atOrUnder(toRealPath(l), w))
|
|
265
|
+
if (w === undefined) {
|
|
266
|
+
plain.push(l)
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
let dir = false
|
|
270
|
+
try {
|
|
271
|
+
dir = statSync(l).isDirectory()
|
|
272
|
+
} catch {
|
|
273
|
+
// dangling: granted as a file
|
|
274
|
+
}
|
|
275
|
+
ui.say(
|
|
276
|
+
'warn',
|
|
277
|
+
`[vx] sandbox: ${l} is a symlink into the write grant ${w}, and the Linux sandbox ` +
|
|
278
|
+
`mounts it read-only, so a write through it is refused. Grant the link itself to ` +
|
|
279
|
+
`write through it: ${l}${dir ? '/' : ''}\n`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
if (plain.length > 0) {
|
|
283
|
+
ui.say(
|
|
284
|
+
'warn',
|
|
285
|
+
`[vx] sandbox: a write grant under ${readPath} makes its ${plain.length} symlinked ` +
|
|
286
|
+
`entr${plain.length === 1 ? 'y' : 'ies'} (${plain
|
|
287
|
+
.slice(0, 3)
|
|
288
|
+
.map((l) => path.basename(l))
|
|
289
|
+
.join(', ')}${plain.length > 3 ? ', …' : ''}) plain directories inside the sandbox — ` +
|
|
290
|
+
`a package resolved through one loses its siblings. Move the write grant (${under
|
|
291
|
+
.map((w) => path.relative(readPath, w))
|
|
292
|
+
.join(', ')}) out of it.\n`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
215
295
|
}
|
|
216
296
|
return out
|
|
217
297
|
}
|
|
@@ -300,9 +380,20 @@ export function buildCustomConfig(
|
|
|
300
380
|
const c = args.config
|
|
301
381
|
const denyRead = unique([...baselines.denyRead])
|
|
302
382
|
const allowWrite = bindableWrites(unique([...c.allowWrite]))
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
)
|
|
383
|
+
// A link a write grant is spelled through is bound by vx, not SRT: a
|
|
384
|
+
// read grant above it is punched around it all the same.
|
|
385
|
+
const punched = [...allowWrite, ...linkedWriteBinds(c.linkedWrites).map(([, at]) => at)]
|
|
386
|
+
const reads = [...baselines.allowRead, ...c.allowRead]
|
|
387
|
+
// Asked only of a link a punch meets: a target under the deny anchor is
|
|
388
|
+
// bound only where some read grant already reaches it.
|
|
389
|
+
let fence: { denied: string[]; granted: string[] } | undefined
|
|
390
|
+
const reachable = (real: string): boolean => {
|
|
391
|
+
fence ??= { denied: denyRead.map(toRealPath), granted: reads.map(toRealPath) }
|
|
392
|
+
return (
|
|
393
|
+
!fence.denied.some((d) => atOrUnder(real, d)) || fence.granted.some((g) => atOrUnder(real, g))
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
const allowRead = unique(reads.flatMap((r) => punchWritePaths(r, punched, reachable)))
|
|
306
397
|
|
|
307
398
|
const custom: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] = {
|
|
308
399
|
filesystem: {
|
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
ESSENTIAL_ENV,
|
|
71
71
|
executablePath,
|
|
72
72
|
helperEnv,
|
|
73
|
+
holdSecret,
|
|
73
74
|
taskShell,
|
|
74
75
|
grantPrefix,
|
|
75
76
|
isTmpdirRefusal,
|
|
@@ -83,9 +84,11 @@ import {
|
|
|
83
84
|
bindableReads,
|
|
84
85
|
bindableWrites,
|
|
85
86
|
buildCustomConfig,
|
|
87
|
+
linkedWriteBinds,
|
|
86
88
|
readOnlyMasks,
|
|
87
89
|
scratchWrites,
|
|
88
90
|
widenedEntries,
|
|
91
|
+
withLinkedWrites,
|
|
89
92
|
} from './sandbox-binds.js'
|
|
90
93
|
import {
|
|
91
94
|
atOrUnder,
|
|
@@ -111,6 +114,7 @@ import {
|
|
|
111
114
|
} from './sandbox-violations.js'
|
|
112
115
|
import {
|
|
113
116
|
closeSignalChannel,
|
|
117
|
+
groupAlive,
|
|
114
118
|
killTree,
|
|
115
119
|
releaseGroup,
|
|
116
120
|
signalThrough,
|
|
@@ -1008,6 +1012,11 @@ export async function initSandbox(opts?: {
|
|
|
1008
1012
|
// reload of exactly these fields; the proxy and the wrapper read them
|
|
1009
1013
|
// through getters, so the run's config is what every task sees.
|
|
1010
1014
|
SandboxManager.updateConfig(config)
|
|
1015
|
+
// The proxy's session token rides into a networked task's env
|
|
1016
|
+
// (CLOUDSDK_PROXY_PASSWORD, the proxy URLs' password): a task printing
|
|
1017
|
+
// its env put it in the terminal, the cached log and `vx last --log`.
|
|
1018
|
+
const token = SandboxManager.getProxyAuthToken()
|
|
1019
|
+
if (token !== undefined) holdSecret(token)
|
|
1011
1020
|
}
|
|
1012
1021
|
|
|
1013
1022
|
/**
|
|
@@ -1221,6 +1230,8 @@ export interface ResolvedSandboxConfig {
|
|
|
1221
1230
|
* no mount holds them. `scratchWrites` decides which still cover a write.
|
|
1222
1231
|
*/
|
|
1223
1232
|
pendingWrites?: readonly string[]
|
|
1233
|
+
/** Linux: write grants spelled through a link in the project, bound there too (`linkedWriteBinds`). */
|
|
1234
|
+
linkedWrites?: readonly { readonly link: string; readonly target: string }[]
|
|
1224
1235
|
network?: true | readonly string[]
|
|
1225
1236
|
denyNetwork?: readonly string[]
|
|
1226
1237
|
systemInfo?: readonly string[]
|
|
@@ -1318,6 +1329,31 @@ function assertWriteStaysHome(grant: string, real: string, projectDir: string):
|
|
|
1318
1329
|
)
|
|
1319
1330
|
}
|
|
1320
1331
|
|
|
1332
|
+
/**
|
|
1333
|
+
* Linux: a literal write grant spelled through a link in the project
|
|
1334
|
+
* (`gen/` over `gen -> real`), as the task names it: the project's real
|
|
1335
|
+
* directory with the grant's own components, links unresolved. The grant
|
|
1336
|
+
* binds at its real path and bwrap mounts no link, so without a second
|
|
1337
|
+
* bind there the task met "Directory nonexistent", or, under a read
|
|
1338
|
+
* grant of the project, the link bound read-only over the same directory
|
|
1339
|
+
* ("Read-only file system"). Seatbelt judges the resolved path, so macOS
|
|
1340
|
+
* needs none.
|
|
1341
|
+
*/
|
|
1342
|
+
function linkSpelling(
|
|
1343
|
+
grant: string,
|
|
1344
|
+
real: string,
|
|
1345
|
+
projectDir: string,
|
|
1346
|
+
): { link: string; target: string } | undefined {
|
|
1347
|
+
if (process.platform !== 'linux' || grant.startsWith('~') || path.isAbsolute(grant)) return
|
|
1348
|
+
// `gen/**` is the directory whole (`expandGrants`).
|
|
1349
|
+
const whole = /\/\*\*(?:\/\*)?$/
|
|
1350
|
+
const lexical = path.resolve(projectDir, grant.replace(whole, ''))
|
|
1351
|
+
const target = real.replace(whole, '')
|
|
1352
|
+
if (!atOrUnder(lexical, projectDir) || !isMountableLiteral(target)) return
|
|
1353
|
+
const link = path.join(toRealPath(projectDir), path.relative(projectDir, lexical))
|
|
1354
|
+
return link === target ? undefined : { link, target }
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1321
1357
|
/** Where `p` lands through every link on it, a dangling last one included. */
|
|
1322
1358
|
function throughLinks(p: string, hops = 0): string {
|
|
1323
1359
|
if (hops > 40) return p
|
|
@@ -1352,12 +1388,15 @@ export function resolveSandboxConfig(
|
|
|
1352
1388
|
}
|
|
1353
1389
|
const a = cfg.allow ?? {}
|
|
1354
1390
|
const pending: string[] = []
|
|
1391
|
+
const linked: Array<{ link: string; target: string }> = []
|
|
1355
1392
|
const r: ResolvedSandboxConfig = {
|
|
1356
1393
|
allowRead: expandGrants((a.read ?? []).map(resolve), walls),
|
|
1357
1394
|
allowWrite: expandGrants(
|
|
1358
1395
|
(a.write ?? []).map((p) => {
|
|
1359
1396
|
const real = resolve(p)
|
|
1360
1397
|
assertWriteStaysHome(p, real, projectDir)
|
|
1398
|
+
const link = linkSpelling(p, real, projectDir)
|
|
1399
|
+
if (link !== undefined) linked.push(link)
|
|
1361
1400
|
// A directory a write glob matches is writable whole, as Linux's
|
|
1362
1401
|
// bind of the hit makes it: `<glob>/**` collapses below into the
|
|
1363
1402
|
// glob and its subtree. SRT compiles a glob as an exact regex, so
|
|
@@ -1374,6 +1413,7 @@ export function resolveSandboxConfig(
|
|
|
1374
1413
|
),
|
|
1375
1414
|
}
|
|
1376
1415
|
if (pending.length > 0) r.pendingWrites = pending
|
|
1416
|
+
if (linked.length > 0) r.linkedWrites = linked
|
|
1377
1417
|
if (process.platform === 'darwin') {
|
|
1378
1418
|
const read = wallsGlobsReach(r.allowRead, walls)
|
|
1379
1419
|
const write = wallsGlobsReach(r.allowWrite, walls)
|
|
@@ -1446,6 +1486,11 @@ export interface SandboxViolation {
|
|
|
1446
1486
|
* leaves it hidden.
|
|
1447
1487
|
*/
|
|
1448
1488
|
via?: string
|
|
1489
|
+
/**
|
|
1490
|
+
* Linux: what a refused `openat`'s flags asked for; for an `openat2`,
|
|
1491
|
+
* whose flags no record names, a read.
|
|
1492
|
+
*/
|
|
1493
|
+
opened?: 'read' | 'write'
|
|
1449
1494
|
ignorable?: readonly ('read' | 'write' | 'network' | 'systemInfo')[]
|
|
1450
1495
|
}
|
|
1451
1496
|
|
|
@@ -1613,11 +1658,13 @@ async function wrapIn(
|
|
|
1613
1658
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
1614
1659
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
1615
1660
|
// The tag leads: SRT keys a record by the wrap's first 100 characters,
|
|
1616
|
-
// and the proxy wait below is the same 190 for every
|
|
1661
|
+
// and the proxy wait below is the same 190 for every task.
|
|
1617
1662
|
const inner = [
|
|
1618
1663
|
process.platform === 'linux' ? `: 'vx-${tag}';` : '',
|
|
1619
1664
|
ports.length > 0 ? portBridgeInner(ports, tag) : '',
|
|
1620
|
-
process.platform === 'linux' &&
|
|
1665
|
+
process.platform === 'linux' && SandboxManager.getLinuxHttpSocketPath() !== undefined
|
|
1666
|
+
? PROXY_BRIDGE_WAIT
|
|
1667
|
+
: '',
|
|
1621
1668
|
grouped.command,
|
|
1622
1669
|
]
|
|
1623
1670
|
.filter((part) => part !== '')
|
|
@@ -1688,7 +1735,7 @@ async function wrapIn(
|
|
|
1688
1735
|
)
|
|
1689
1736
|
wrapped =
|
|
1690
1737
|
(gitGranted ? 'GIT_DISCOVERY_ACROSS_FILESYSTEM=${GIT_DISCOVERY_ACROSS_FILESYSTEM-1} ' : '') +
|
|
1691
|
-
`exec ${readOnlyMasks(wrapped, scratch)}`
|
|
1738
|
+
`exec ${readOnlyMasks(withLinkedWrites(wrapped, linkedWriteBinds(args.config.linkedWrites)), scratch)}`
|
|
1692
1739
|
}
|
|
1693
1740
|
if (process.platform === 'linux' && !hostHasIpv6())
|
|
1694
1741
|
wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
|
|
@@ -1815,16 +1862,23 @@ function ownGroupCommand(
|
|
|
1815
1862
|
}
|
|
1816
1863
|
|
|
1817
1864
|
/**
|
|
1818
|
-
* What strace stops on: the reads
|
|
1819
|
-
*
|
|
1820
|
-
*
|
|
1821
|
-
*
|
|
1865
|
+
* What strace stops on: the reads (`openat`, and `open`, musl's open():
|
|
1866
|
+
* traced as openat alone, a sibling under a widened write grant was read
|
|
1867
|
+
* unreported and cached; arm64 has no `open`; an `openat2` never reaches
|
|
1868
|
+
* strace, SRT's write observer takes each one first: `refusedWrites`),
|
|
1869
|
+
* the execs and access probes (a refused
|
|
1870
|
+
* `./gen.sh || fallback` passed unreported), `readlink` (git hashes a link
|
|
1871
|
+
* by its target string: `readlink cfg > out.txt` under a widened grant
|
|
1872
|
+
* cached `v1` and replayed it after `cfg` pointed at `v2`; a stop costs
|
|
1873
|
+
* ~9 µs, 37 ms on 2,000 links, below the A/A noise on oxlint and vx), and
|
|
1874
|
+
* what moves or makes a process's cwd. Not the stat family: a stop per
|
|
1875
|
+
* stat cost oxlint 19% and `git status` 30%, where the execs and probes cost nothing measurable, and
|
|
1822
1876
|
* vx's own no-op run 69% (pr-bench, 495 → 835 ms, 2026-10-23). So a tool
|
|
1823
1877
|
* that stats before it opens, as Ubuntu 26's cat (uutils) does, refused
|
|
1824
1878
|
* nothing the trace sees.
|
|
1825
1879
|
*/
|
|
1826
1880
|
const TRACED_CALLS =
|
|
1827
|
-
'trace=openat,execve,?access,faccessat,?faccessat2,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
1881
|
+
'trace=openat,?open,execve,?access,faccessat,?faccessat2,?readlink,?readlinkat,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
1828
1882
|
|
|
1829
1883
|
/** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
|
|
1830
1884
|
const TRACE_FD = 5
|
|
@@ -1971,6 +2025,13 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
|
|
|
1971
2025
|
* command, until both listen in the task's network namespace, read off
|
|
1972
2026
|
* /proc/net (IPv4 or IPv6, state 0A). Bounded at ~5 s: a bridge that never
|
|
1973
2027
|
* listens leaves the command to meet the refusal it met before.
|
|
2028
|
+
*
|
|
2029
|
+
* Every task the run's bridge reaches waits, not only one that lists a
|
|
2030
|
+
* network: one that lists none dials the same proxy, which refuses and
|
|
2031
|
+
* names the host, and that refusal fails it. Waited for only by networked
|
|
2032
|
+
* tasks, it met the closed port instead under load, swallowed the error
|
|
2033
|
+
* and passed with nothing reported (2026-11-06). The wait costs ~4 ms, the
|
|
2034
|
+
* socat's own start.
|
|
1974
2035
|
*/
|
|
1975
2036
|
const PROXY_BRIDGE_WAIT =
|
|
1976
2037
|
"( i=0; until grep -qsE ':0C38 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6 && grep -qsE ':0438 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6; do [ $i -ge 500 ] && break; i=$((i+1)); sleep 0.01; done );"
|
|
@@ -2159,8 +2220,8 @@ async function hostBridgesListen(ports: readonly number[], tag: string): Promise
|
|
|
2159
2220
|
if (!procfsIsOwn()) return
|
|
2160
2221
|
const want = ports.map((p) => `0100007F:${p.toString(16).toUpperCase().padStart(4, '0')}`)
|
|
2161
2222
|
const procs = hostBridges.get(tag)?.procs ?? []
|
|
2162
|
-
const until =
|
|
2163
|
-
while (
|
|
2223
|
+
const until = performance.now() + 5_000
|
|
2224
|
+
while (performance.now() < until && procs.every((p) => p.exitCode === null)) {
|
|
2164
2225
|
let table: string
|
|
2165
2226
|
try {
|
|
2166
2227
|
table = readFileSync('/proc/net/tcp', 'utf8')
|
|
@@ -2267,9 +2328,13 @@ const MOUNT_RACE_RETRY_LINE =
|
|
|
2267
2328
|
* bwrap's own refusal of a mount gone between its read of the mount table
|
|
2268
2329
|
* and its remount: a container runtime's transient mount under /tmp
|
|
2269
2330
|
* (`/newroot/tmp/containerd-mount…`, a `docker build` beside the task on
|
|
2270
|
-
* CI). The command never started.
|
|
2331
|
+
* CI). The command never started. Or a path the wrap walls, gone before
|
|
2332
|
+
* bwrap mounts on it: bwrap makes the mount point anew under a read-only
|
|
2333
|
+
* parent (`Can't mkdir /tmp/vx-noscan-0: Read-only file system`, the no-scan
|
|
2334
|
+
* directory removed by another process).
|
|
2271
2335
|
*/
|
|
2272
|
-
const BWRAP_MOUNT_GONE =
|
|
2336
|
+
const BWRAP_MOUNT_GONE =
|
|
2337
|
+
/^bwrap: Can't (?:.*: No such file or directory|mkdir .*: Read-only file system)$/
|
|
2273
2338
|
|
|
2274
2339
|
/**
|
|
2275
2340
|
* Run a single task wrapped in the sandbox. Caller must have called
|
|
@@ -2420,7 +2485,8 @@ const STRACE_OWN_ERROR = /^(?:[^\s:]*\/)?strace: /
|
|
|
2420
2485
|
async function runSandboxedOnce(
|
|
2421
2486
|
args: SandboxedRunArgs,
|
|
2422
2487
|
): Promise<SandboxedRunResult & { tracerFailed: boolean; mountGone: boolean; ranMs: number }> {
|
|
2423
|
-
|
|
2488
|
+
// Elapsed time, not wall time: see runCommand.
|
|
2489
|
+
const start = performance.now()
|
|
2424
2490
|
const { SandboxManager } = await loadSrt()
|
|
2425
2491
|
// Linux: SRT's store sees only writes (below), so read denials need
|
|
2426
2492
|
// the command traced and the trace parsed for denied syscalls. The trace
|
|
@@ -2428,7 +2494,7 @@ async function runSandboxedOnce(
|
|
|
2428
2494
|
// share a stream. Skipped when strace isn't on PATH — bwrap still
|
|
2429
2495
|
// enforces structurally; we just lose the structured violation list.
|
|
2430
2496
|
//
|
|
2431
|
-
// We trace
|
|
2497
|
+
// We trace the opens, the read itself, plus `execve` and the access
|
|
2432
2498
|
// probes (`TRACED_CALLS`). Not `statx` / `newfstatat`: a stop per stat
|
|
2433
2499
|
// is a tax on every stat-heavy task. A probe's ENOENT counts only where
|
|
2434
2500
|
// the host has the path (`parseStraceViolations`), so a PATH walk past
|
|
@@ -2446,12 +2512,12 @@ async function runSandboxedOnce(
|
|
|
2446
2512
|
// one gets the slow form rather than no detection.
|
|
2447
2513
|
const useStrace = await wantsStraceDetection()
|
|
2448
2514
|
// Before the spawn: what the task creates under a widened grant is its own.
|
|
2449
|
-
const widened =
|
|
2515
|
+
const widened = widenedEntries(args.config.allowWrite)
|
|
2450
2516
|
const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
|
|
2451
2517
|
await wrapSandboxedCommand({
|
|
2452
2518
|
...args,
|
|
2453
2519
|
...(useStrace ? { trace: useStrace } : {}),
|
|
2454
|
-
...(
|
|
2520
|
+
...(useStrace && widened.size > 0 ? { tracePaths: true } : {}),
|
|
2455
2521
|
})
|
|
2456
2522
|
const takeRecords = collectRecords(
|
|
2457
2523
|
SandboxManager.getSandboxViolationStore(),
|
|
@@ -2467,7 +2533,7 @@ async function runSandboxedOnce(
|
|
|
2467
2533
|
const signal = stopSignal(args.signal.reason)
|
|
2468
2534
|
return {
|
|
2469
2535
|
exitCode: signalExitCode(signal),
|
|
2470
|
-
durationMs:
|
|
2536
|
+
durationMs: Math.round(performance.now() - start),
|
|
2471
2537
|
stdout: '',
|
|
2472
2538
|
stderr: '',
|
|
2473
2539
|
signal,
|
|
@@ -2515,14 +2581,14 @@ async function runSandboxedOnce(
|
|
|
2515
2581
|
rmSync(straceLog, { force: true })
|
|
2516
2582
|
liveTempFiles.delete(straceLog)
|
|
2517
2583
|
}
|
|
2518
|
-
const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped)
|
|
2584
|
+
const stderr = spawnFailureText(err, args.cwd, SANDBOXED_TASK, wrapped, args.env)
|
|
2519
2585
|
args.onStderr?.(stderr)
|
|
2520
2586
|
releaseBridges(tag)
|
|
2521
2587
|
void takeRecords(false)
|
|
2522
2588
|
afterCommand(SandboxManager)
|
|
2523
2589
|
return {
|
|
2524
2590
|
exitCode: 127,
|
|
2525
|
-
durationMs:
|
|
2591
|
+
durationMs: Math.round(performance.now() - start),
|
|
2526
2592
|
stdout: '',
|
|
2527
2593
|
stderr,
|
|
2528
2594
|
spawnFailed: true,
|
|
@@ -2541,7 +2607,7 @@ async function runSandboxedOnce(
|
|
|
2541
2607
|
args.onSpawn?.(proc.pid)
|
|
2542
2608
|
const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
|
|
2543
2609
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
2544
|
-
const armedAt =
|
|
2610
|
+
const armedAt = performance.now()
|
|
2545
2611
|
const ac = new AbortController()
|
|
2546
2612
|
// The unfinished last line of stderr, and whether a line was strace's.
|
|
2547
2613
|
let partial = ''
|
|
@@ -2570,7 +2636,7 @@ async function runSandboxedOnce(
|
|
|
2570
2636
|
// OR a clean exit that backgrounds a process) can't hang the run — timeout
|
|
2571
2637
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
|
2572
2638
|
await proc.exited
|
|
2573
|
-
const ranMs =
|
|
2639
|
+
const ranMs = Math.round(performance.now() - armedAt)
|
|
2574
2640
|
const looked = watch?.stop()
|
|
2575
2641
|
await timeout.settle()
|
|
2576
2642
|
let cut = false
|
|
@@ -2581,6 +2647,9 @@ async function runSandboxedOnce(
|
|
|
2581
2647
|
const cutLine = ends.own(POST_EXIT_CUT_LINE)
|
|
2582
2648
|
if (cut) args.onStderr?.(cutLine)
|
|
2583
2649
|
const stderr = cut ? streamed + cutLine : streamed
|
|
2650
|
+
// As runCommand: a leftover holding no pipe is still in the group. Under
|
|
2651
|
+
// Linux's pid namespace it died with the command; macOS keeps it.
|
|
2652
|
+
const left = cut || groupAlive(proc)
|
|
2584
2653
|
args.liveChildren?.delete(proc)
|
|
2585
2654
|
releaseGroup(proc)
|
|
2586
2655
|
closeSignalChannel(proc)
|
|
@@ -2659,6 +2728,7 @@ async function runSandboxedOnce(
|
|
|
2659
2728
|
bindableWrites(args.config.allowWrite),
|
|
2660
2729
|
scratch,
|
|
2661
2730
|
[...baselines.allowRead, ...bindableReads(args.config.allowRead)],
|
|
2731
|
+
{ entries: widened, granted: args.config.allowWrite },
|
|
2662
2732
|
),
|
|
2663
2733
|
...refusedConnections(records.map((v) => v.line)),
|
|
2664
2734
|
]
|
|
@@ -2734,20 +2804,22 @@ async function runSandboxedOnce(
|
|
|
2734
2804
|
config: args.config,
|
|
2735
2805
|
skip: [taskTmpRoot()],
|
|
2736
2806
|
})
|
|
2737
|
-
if (hidden.length > 0)
|
|
2807
|
+
if (hidden.length > 0)
|
|
2808
|
+
violations.push(hiddenReadsHint(hidden, baselines.denyRead, args.reportWithin))
|
|
2738
2809
|
}
|
|
2739
2810
|
|
|
2740
2811
|
afterCommand(SandboxManager)
|
|
2741
2812
|
|
|
2742
2813
|
return {
|
|
2743
2814
|
exitCode,
|
|
2744
|
-
durationMs:
|
|
2815
|
+
durationMs: Math.round(performance.now() - start),
|
|
2745
2816
|
stdout,
|
|
2746
2817
|
stderr,
|
|
2747
2818
|
violations,
|
|
2748
2819
|
...(typeof proc.signalCode === 'string' ? { signal: proc.signalCode } : {}),
|
|
2749
2820
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
2750
|
-
...(
|
|
2821
|
+
...(timeout.sigkilled() ? { killedAfterGrace: true } : {}),
|
|
2822
|
+
...(left ? { leftRunning: true } : {}),
|
|
2751
2823
|
// Linux: the task's CPU and peak never reach this wait. bwrap runs it
|
|
2752
2824
|
// in a pid namespace (`--unshare-pid`), and what the namespace's
|
|
2753
2825
|
// processes used is not folded into bwrap's usage: a 500 ms busy loop
|
|
@@ -2792,15 +2864,7 @@ function outsideWritesHint(
|
|
|
2792
2864
|
): SandboxViolation {
|
|
2793
2865
|
const shown = paths.slice(0, 5).join(', ')
|
|
2794
2866
|
const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
|
|
2795
|
-
const
|
|
2796
|
-
const dir = path.dirname(paths[0]!)
|
|
2797
|
-
// In the workspace, from the project, as a committed config spells it:
|
|
2798
|
-
// the absolute path held only on the machine that printed it.
|
|
2799
|
-
const spelled = walled.some((w) => atOrUnder(dir, toRealPath(w)))
|
|
2800
|
-
? path.relative(toRealPath(within), dir) || '.'
|
|
2801
|
-
: atOrUnder(dir, home)
|
|
2802
|
-
? `~${dir.slice(home.length)}`
|
|
2803
|
-
: dir
|
|
2867
|
+
const spelled = grantSpelling(path.dirname(paths[0]!), walled, within)
|
|
2804
2868
|
const refused =
|
|
2805
2869
|
`vx: the sandbox refused writes outside the project, which are not reported as ` +
|
|
2806
2870
|
`violations: ${shown}${more}.`
|
|
@@ -2866,19 +2930,52 @@ function hostTempRoots(): string[] {
|
|
|
2866
2930
|
return [...new Set(['/tmp', '/var/tmp', os.tmpdir()].map(toRealPath))]
|
|
2867
2931
|
}
|
|
2868
2932
|
|
|
2869
|
-
|
|
2933
|
+
/**
|
|
2934
|
+
* The hint for reads the wall hid outside the project. A path under a
|
|
2935
|
+
* deny entry other than the ones holding the project (a credential store
|
|
2936
|
+
* or its link's target, vx's own store, the repository's git data outside
|
|
2937
|
+
* the workspace) is walled on purpose and never the suggested grant: an
|
|
2938
|
+
* agent applies a suggestion as given, and `cat ~/.npmrc` was told to
|
|
2939
|
+
* grant `../../home/.npmrc`.
|
|
2940
|
+
*/
|
|
2941
|
+
function hiddenReadsHint(
|
|
2942
|
+
paths: readonly string[],
|
|
2943
|
+
walled: readonly string[],
|
|
2944
|
+
within: string,
|
|
2945
|
+
): SandboxViolation {
|
|
2870
2946
|
const shown = paths.slice(0, 5).join(', ')
|
|
2871
2947
|
const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
|
|
2948
|
+
const project = toRealPath(within)
|
|
2949
|
+
const deny = walled.map(toRealPath)
|
|
2950
|
+
const grantable = paths.find((p) => !deny.some((w) => atOrUnder(p, w) && !atOrUnder(project, w)))
|
|
2951
|
+
const said =
|
|
2952
|
+
`vx: the sandbox hid paths outside the project that exist on this machine, which are ` +
|
|
2953
|
+
`not reported as violations: ${shown}${more}.`
|
|
2872
2954
|
return {
|
|
2873
2955
|
timestamp: new Date(),
|
|
2874
2956
|
hint: true,
|
|
2875
2957
|
line:
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2958
|
+
grantable === undefined
|
|
2959
|
+
? `${said} They are walled on purpose, so vx suggests no grant for them.`
|
|
2960
|
+
: `${said} If the task reads one, grant it, e.g. ` +
|
|
2961
|
+
`\`allow: { read: [${jsString(grantSpelling(grantable, walled, within))}] }\`.`,
|
|
2879
2962
|
}
|
|
2880
2963
|
}
|
|
2881
2964
|
|
|
2965
|
+
/**
|
|
2966
|
+
* A path as a committed config spells its grant: from the project inside
|
|
2967
|
+
* the walls (the absolute path holds only on the machine that printed
|
|
2968
|
+
* it), `~/…` under home, else absolute.
|
|
2969
|
+
*/
|
|
2970
|
+
function grantSpelling(p: string, walled: readonly string[], within: string): string {
|
|
2971
|
+
const home = toRealPath(os.homedir())
|
|
2972
|
+
return walled.some((w) => atOrUnder(p, toRealPath(w)))
|
|
2973
|
+
? path.relative(toRealPath(within), p) || '.'
|
|
2974
|
+
: atOrUnder(p, home)
|
|
2975
|
+
? `~${p.slice(home.length)}`
|
|
2976
|
+
: p
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2882
2979
|
/**
|
|
2883
2980
|
* Is `dir` inside a path the sandbox granted for reading? bwrap binds and
|
|
2884
2981
|
* seatbelt rules are both prefix-based, so a grant covers the subtree.
|