@vzn/vx 0.0.215 → 0.0.217
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.217",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.217",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.217",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.217",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.217"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/inputs.ts
CHANGED
|
@@ -79,6 +79,10 @@ const ALWAYS_IGNORE = [
|
|
|
79
79
|
// file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
|
|
80
80
|
// exclude it (vx is Bun-native; compiling standalone binaries is a common task).
|
|
81
81
|
'**/*.bun-build',
|
|
82
|
+
// …and, cross-compiling for a target its cache lacks, the directory it
|
|
83
|
+
// extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
|
|
84
|
+
// 60 MB mid-write under the same race (2026-09-29).
|
|
85
|
+
'**/.????????????????-????????.tmp/**',
|
|
82
86
|
]
|
|
83
87
|
|
|
84
88
|
const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import { lstatSync, readdirSync, statSync } from 'node:fs'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
atOrUnder,
|
|
10
|
+
isMountableLiteral,
|
|
11
|
+
localBindingOn,
|
|
12
|
+
MOUNT_WILDCARDS,
|
|
13
|
+
unique,
|
|
14
|
+
} from './sandbox-paths.js'
|
|
9
15
|
import type { SandboxedRunArgs } from './sandbox-runtime.js'
|
|
10
16
|
|
|
11
17
|
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
@@ -42,6 +48,37 @@ export function bindableWrites(paths: readonly string[]): string[] {
|
|
|
42
48
|
)
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The write globs that matched nothing when the task started, split by
|
|
53
|
+
* where the task's writes under them go. A glob no mount holds is not
|
|
54
|
+
* lost: a path in the workspace that no read or write bind covers is the
|
|
55
|
+
* deny anchor's scratch, and a task may create, write and remove there —
|
|
56
|
+
* nothing it leaves outlives the sandbox. That is a tool's temp directory:
|
|
57
|
+
* `bun build --compile` extracts a cross-compile runtime into
|
|
58
|
+
* `<cwd>/.<hash>-00000000.tmp/` and renames it into its cache, and no
|
|
59
|
+
* bind could name that directory before it existed (2026-09-29). Where a
|
|
60
|
+
* read grant mounts the glob's directory read-only, or it lies outside the
|
|
61
|
+
* workspace, a write under it fails (`mountless`, warned before the task).
|
|
62
|
+
*/
|
|
63
|
+
export function scratchWrites(
|
|
64
|
+
pending: readonly string[],
|
|
65
|
+
fs: {
|
|
66
|
+
readonly allowRead?: readonly string[] | undefined
|
|
67
|
+
readonly allowWrite?: readonly string[] | undefined
|
|
68
|
+
},
|
|
69
|
+
anchors: readonly string[],
|
|
70
|
+
): { scratch: string[]; mountless: string[] } {
|
|
71
|
+
const binds = [...(fs.allowRead ?? []), ...(fs.allowWrite ?? [])]
|
|
72
|
+
const scratch: string[] = []
|
|
73
|
+
const mountless: string[] = []
|
|
74
|
+
for (const glob of pending) {
|
|
75
|
+
const dir = path.dirname(glob.slice(0, glob.search(MOUNT_WILDCARDS) + 1))
|
|
76
|
+
const free = anchors.some((a) => atOrUnder(dir, a)) && !binds.some((b) => atOrUnder(dir, b))
|
|
77
|
+
;(free ? scratch : mountless).push(glob)
|
|
78
|
+
}
|
|
79
|
+
return { scratch, mountless }
|
|
80
|
+
}
|
|
81
|
+
|
|
45
82
|
/**
|
|
46
83
|
* A read grant with the WALLS inside it cut out: the directories of the
|
|
47
84
|
* projects nested in this one, the repository and vx's own directory. A
|
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
UserError,
|
|
63
63
|
xxh3hex,
|
|
64
64
|
} from '../util/index.js'
|
|
65
|
-
import { bindableWrites, buildCustomConfig } from './sandbox-binds.js'
|
|
65
|
+
import { bindableWrites, buildCustomConfig, scratchWrites } from './sandbox-binds.js'
|
|
66
66
|
import {
|
|
67
67
|
atOrUnder,
|
|
68
68
|
isMountableLiteral,
|
|
@@ -76,7 +76,12 @@ import {
|
|
|
76
76
|
scopedMandatoryDenies,
|
|
77
77
|
srtDefaultWritePaths,
|
|
78
78
|
} from './sandbox-deny-scan.js'
|
|
79
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
parseStraceViolations,
|
|
81
|
+
refusedWrites,
|
|
82
|
+
refusedWritesOutside,
|
|
83
|
+
reportableViolations,
|
|
84
|
+
} from './sandbox-violations.js'
|
|
80
85
|
import {
|
|
81
86
|
closeSignalChannel,
|
|
82
87
|
killTree,
|
|
@@ -679,6 +684,11 @@ export interface ResolvedSandboxConfig {
|
|
|
679
684
|
allowRead: readonly string[]
|
|
680
685
|
/** Writable path prefixes, absolute. */
|
|
681
686
|
allowWrite: readonly string[]
|
|
687
|
+
/**
|
|
688
|
+
* Linux: the write globs that matched nothing when the task started, so
|
|
689
|
+
* no mount holds them. `scratchWrites` decides which still cover a write.
|
|
690
|
+
*/
|
|
691
|
+
pendingWrites?: readonly string[]
|
|
682
692
|
network?: true | readonly string[]
|
|
683
693
|
denyNetwork?: readonly string[]
|
|
684
694
|
systemInfo?: readonly string[]
|
|
@@ -784,18 +794,20 @@ export function resolveSandboxConfig(
|
|
|
784
794
|
return toRealPath(path.resolve(projectDir, p))
|
|
785
795
|
}
|
|
786
796
|
const a = cfg.allow ?? {}
|
|
797
|
+
const pending: string[] = []
|
|
787
798
|
const r: ResolvedSandboxConfig = {
|
|
788
|
-
allowRead: expandGrants((a.read ?? []).map(resolve),
|
|
799
|
+
allowRead: expandGrants((a.read ?? []).map(resolve), walls),
|
|
789
800
|
allowWrite: expandGrants(
|
|
790
801
|
(a.write ?? []).map((p) => {
|
|
791
802
|
const real = resolve(p)
|
|
792
803
|
assertWriteStaysHome(p, real, projectDir)
|
|
793
804
|
return real
|
|
794
805
|
}),
|
|
795
|
-
'write',
|
|
796
806
|
walls,
|
|
807
|
+
pending,
|
|
797
808
|
),
|
|
798
809
|
}
|
|
810
|
+
if (pending.length > 0) r.pendingWrites = pending
|
|
799
811
|
if (process.platform === 'darwin') {
|
|
800
812
|
const read = wallsGlobsReach(r.allowRead, walls)
|
|
801
813
|
const write = wallsGlobsReach(r.allowWrite, walls)
|
|
@@ -920,6 +932,8 @@ export async function wrapSandboxedCommand(
|
|
|
920
932
|
/** What SRT wrapped, and so what its store keys a record by (the group wrapper included on Linux). */
|
|
921
933
|
srtCommand: string
|
|
922
934
|
baselines: CanonicalBaselines
|
|
935
|
+
/** The pending write globs a write may still land under (`scratchWrites`). */
|
|
936
|
+
scratch: string[]
|
|
923
937
|
/** The command reads its polite signals off fd 3: spawn it with one and `signalThrough` it. */
|
|
924
938
|
forwardsSignals: boolean
|
|
925
939
|
/** The command writes its trace to fd TRACE_FD: spawn it with the log there. */
|
|
@@ -942,6 +956,7 @@ export async function wrapSandboxedCommand(
|
|
|
942
956
|
|
|
943
957
|
const baselines = canonicalBaselines(args)
|
|
944
958
|
const customConfig = buildCustomConfig(args, baselines)
|
|
959
|
+
const scratch = pendingWriteGrants(args.config, customConfig!.filesystem!, baselines.denyRead)
|
|
945
960
|
customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
|
|
946
961
|
customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
|
|
947
962
|
if (scopedDenyScan) {
|
|
@@ -998,6 +1013,7 @@ export async function wrapSandboxedCommand(
|
|
|
998
1013
|
taggedCommand,
|
|
999
1014
|
srtCommand: inner,
|
|
1000
1015
|
baselines,
|
|
1016
|
+
scratch,
|
|
1001
1017
|
forwardsSignals: grouped.forwards,
|
|
1002
1018
|
traced: grouped.traced,
|
|
1003
1019
|
}
|
|
@@ -1353,7 +1369,7 @@ async function runSandboxedOnce(
|
|
|
1353
1369
|
// kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
|
|
1354
1370
|
// one gets the slow form rather than no detection.
|
|
1355
1371
|
const useStrace = await wantsStraceDetection()
|
|
1356
|
-
const { wrapped, tag, srtCommand, baselines, forwardsSignals, traced } =
|
|
1372
|
+
const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
|
|
1357
1373
|
await wrapSandboxedCommand({ ...args, ...(useStrace ? { trace: useStrace } : {}) })
|
|
1358
1374
|
const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
|
|
1359
1375
|
// Beside the task directories, which every sandbox replaces with its own:
|
|
@@ -1511,6 +1527,7 @@ async function runSandboxedOnce(
|
|
|
1511
1527
|
// included, not by the tagged command macOS is keyed by.
|
|
1512
1528
|
records.map((v) => v.line),
|
|
1513
1529
|
bindableWrites(args.config.allowWrite),
|
|
1530
|
+
scratch,
|
|
1514
1531
|
),
|
|
1515
1532
|
]
|
|
1516
1533
|
: []
|
|
@@ -1525,11 +1542,24 @@ async function runSandboxedOnce(
|
|
|
1525
1542
|
// ignoreViolations through to the log monitor — that filter is set
|
|
1526
1543
|
// once globally at initSandbox time. So per-task user overrides have
|
|
1527
1544
|
// to be applied here, after read-back.
|
|
1528
|
-
const
|
|
1545
|
+
const recorded = [...macViolations, ...linuxViolations]
|
|
1546
|
+
const violations = reportableViolations(recorded, {
|
|
1529
1547
|
within: args.reportWithin,
|
|
1530
1548
|
linked: args.reportLinked,
|
|
1531
1549
|
config: args.config,
|
|
1532
1550
|
})
|
|
1551
|
+
// A write refused past the wall is not a violation (nothing a key reads),
|
|
1552
|
+
// but it may be why the task failed: name it, never counted, and only on
|
|
1553
|
+
// a failure, so it can never redden a pass.
|
|
1554
|
+
if (exitCode !== 0) {
|
|
1555
|
+
const outside = refusedWritesOutside(recorded, {
|
|
1556
|
+
within: args.reportWithin,
|
|
1557
|
+
linked: args.reportLinked,
|
|
1558
|
+
config: args.config,
|
|
1559
|
+
skip: [taskTmpRoot(), ...srtDefaultWritePaths()],
|
|
1560
|
+
})
|
|
1561
|
+
if (outside.length > 0) violations.push(outsideWritesHint(outside))
|
|
1562
|
+
}
|
|
1533
1563
|
|
|
1534
1564
|
// The one denial macOS never logs. MEASURED 2026-09-05, same machine, two
|
|
1535
1565
|
// runs differing only in the grant: with the cwd granted a failing task
|
|
@@ -1585,6 +1615,23 @@ async function runSandboxedOnce(
|
|
|
1585
1615
|
}
|
|
1586
1616
|
}
|
|
1587
1617
|
|
|
1618
|
+
/** The hint for writes refused outside the project, a few paths named. */
|
|
1619
|
+
function outsideWritesHint(paths: readonly string[]): SandboxViolation {
|
|
1620
|
+
const shown = paths.slice(0, 5).join(', ')
|
|
1621
|
+
const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
|
|
1622
|
+
const home = toRealPath(os.homedir())
|
|
1623
|
+
const dir = path.dirname(paths[0]!)
|
|
1624
|
+
const spelled = atOrUnder(dir, home) ? `~${dir.slice(home.length)}` : dir
|
|
1625
|
+
return {
|
|
1626
|
+
timestamp: new Date(),
|
|
1627
|
+
hint: true,
|
|
1628
|
+
line:
|
|
1629
|
+
`vx: the sandbox refused writes outside the project, which are not reported as ` +
|
|
1630
|
+
`violations: ${shown}${more}. If the task needs one, grant its directory, e.g. ` +
|
|
1631
|
+
`\`allow: { write: ['${spelled}/'] }\`.`,
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1588
1635
|
/**
|
|
1589
1636
|
* Is `dir` inside a path the sandbox granted for reading? bwrap binds and
|
|
1590
1637
|
* seatbelt rules are both prefix-based, so a grant covers the subtree.
|
|
@@ -1822,17 +1869,28 @@ export function darwinWallRules(
|
|
|
1822
1869
|
*/
|
|
1823
1870
|
function expandGrants(
|
|
1824
1871
|
paths: readonly string[],
|
|
1825
|
-
kind: 'read' | 'write',
|
|
1826
1872
|
walls: readonly string[],
|
|
1873
|
+
/** Write globs only: where a pattern that matched nothing is put. */
|
|
1874
|
+
unmatched?: string[],
|
|
1827
1875
|
): string[] {
|
|
1828
1876
|
// A pattern covering a directory WHOLE is that directory. `<d>/**/*` and
|
|
1829
1877
|
// `<d>/**` match everything UNDER `<d>` and never `<d>` itself, so a task
|
|
1830
1878
|
// granted `read: ['**/*']` still could not list its own cwd — the exact
|
|
1831
1879
|
// shape `bun test` and `oxlint` need. Collapsing is not a widening: the
|
|
1832
1880
|
// pattern already covered every file there; it adds the directory entry.
|
|
1833
|
-
|
|
1881
|
+
//
|
|
1882
|
+
// On macOS a `<d>` that is itself a glob keeps its subtree as `<d>/**/*`:
|
|
1883
|
+
// SRT matches a glob as an exact regex and a literal as a subpath, so the
|
|
1884
|
+
// collapsed `.*.tmp` alone covered the directory and nothing in it, and
|
|
1885
|
+
// `bun build --compile` could create its extraction directory but not
|
|
1886
|
+
// the runtime inside it (2026-09-29). Not `<d>/**`: SRT strips a trailing
|
|
1887
|
+
// `/**` before it compiles the regex (`removeTrailingGlobSuffix`), which
|
|
1888
|
+
// CI's `cp: .a1.tmp/f: Operation not permitted` showed.
|
|
1889
|
+
const collapsed = paths.flatMap((p) => {
|
|
1834
1890
|
const m = /^(.*?)\/\*\*(?:\/\*)?$/.exec(p)
|
|
1835
|
-
|
|
1891
|
+
if (m === null) return [p]
|
|
1892
|
+
const dir = m[1]!
|
|
1893
|
+
return process.platform !== 'linux' && !isMountableLiteral(dir) ? [dir, `${dir}/**/*`] : [dir]
|
|
1836
1894
|
})
|
|
1837
1895
|
if (process.platform !== 'linux') return collapsed
|
|
1838
1896
|
const out: string[] = []
|
|
@@ -1866,7 +1924,7 @@ function expandGrants(
|
|
|
1866
1924
|
out.push(abs)
|
|
1867
1925
|
hits++
|
|
1868
1926
|
}
|
|
1869
|
-
if (hits === 0
|
|
1927
|
+
if (hits === 0) unmatched?.push(p)
|
|
1870
1928
|
}
|
|
1871
1929
|
return out
|
|
1872
1930
|
}
|
|
@@ -1889,6 +1947,26 @@ function scanOrNothing(pattern: string, base: string): Iterable<string> {
|
|
|
1889
1947
|
}
|
|
1890
1948
|
}
|
|
1891
1949
|
|
|
1950
|
+
/**
|
|
1951
|
+
* The write globs that matched nothing at the start (Linux): the ones a
|
|
1952
|
+
* write may still land under, in the sandbox's scratch, returned; the ones
|
|
1953
|
+
* a read-only mount or the host's root holds, reported once each
|
|
1954
|
+
* (`writeGrantMatchedNothing`). `anchors` are the deny anchors the scratch
|
|
1955
|
+
* is made of.
|
|
1956
|
+
*/
|
|
1957
|
+
export function pendingWriteGrants(
|
|
1958
|
+
config: Pick<ResolvedSandboxConfig, 'pendingWrites'>,
|
|
1959
|
+
fs: {
|
|
1960
|
+
readonly allowRead?: readonly string[] | undefined
|
|
1961
|
+
readonly allowWrite?: readonly string[] | undefined
|
|
1962
|
+
},
|
|
1963
|
+
anchors: readonly string[],
|
|
1964
|
+
): string[] {
|
|
1965
|
+
const { scratch, mountless } = scratchWrites(config.pendingWrites ?? [], fs, anchors)
|
|
1966
|
+
for (const grant of mountless) writeGrantMatchedNothing(grant)
|
|
1967
|
+
return scratch
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1892
1970
|
/** Grants already reported — once per process, not per spawn. */
|
|
1893
1971
|
const warnedEmptyWriteGrant = new Set<string>()
|
|
1894
1972
|
|
|
@@ -1905,8 +1983,9 @@ function writeGrantMatchedNothing(grant: string): void {
|
|
|
1905
1983
|
if (warnedEmptyWriteGrant.has(grant)) return
|
|
1906
1984
|
warnedEmptyWriteGrant.add(grant)
|
|
1907
1985
|
process.stderr.write(
|
|
1908
|
-
`[vx] sandbox: the write grant ${grant} matches nothing yet,
|
|
1909
|
-
`a file the task creates under it will fail with "Read-only file
|
|
1986
|
+
`[vx] sandbox: the write grant ${grant} matches nothing yet, and a read grant mounts its ` +
|
|
1987
|
+
`directory read-only, so a file the task creates under it will fail with "Read-only file ` +
|
|
1988
|
+
`system". A bind mount ` +
|
|
1910
1989
|
`covers what exists when the task starts — grant the directory instead: ` +
|
|
1911
1990
|
`${grantPrefix(grant)}/**\n`,
|
|
1912
1991
|
)
|
|
@@ -200,15 +200,59 @@ export function reportableViolations(
|
|
|
200
200
|
// A record the producer did not describe is a seatbelt one, straight
|
|
201
201
|
// from SRT's store — parse it here so the filters below never see a
|
|
202
202
|
// platform's line format.
|
|
203
|
-
const described = violations.map((v) =>
|
|
204
|
-
v.target === undefined ? { ...v, ...describeMacViolation(v.line) } : v,
|
|
205
|
-
)
|
|
206
203
|
return filterIgnored(
|
|
207
|
-
loopbackNoise(
|
|
204
|
+
loopbackNoise(
|
|
205
|
+
withinReported(describe(violations), opts.within, opts.linked ?? []),
|
|
206
|
+
opts.config,
|
|
207
|
+
),
|
|
208
208
|
opts.config.ignore,
|
|
209
209
|
)
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The writes refused OUTSIDE the project, as distinct paths: the denials
|
|
214
|
+
* `reportableViolations` drops at the wall. A read there is the walk every
|
|
215
|
+
* process makes from `/`, but a refused write is the task failing to put
|
|
216
|
+
* something it needed: `bun build --compile` extracting a cross-compile
|
|
217
|
+
* runtime into `~/.bun/install/cache` said only "Failed to extract
|
|
218
|
+
* executable" and the report named nothing (2026-09-29). Never a
|
|
219
|
+
* violation — a failed task's hint (`runSandboxed`). The kernel's
|
|
220
|
+
* pseudo-files, a descriptor's non-path, and what `skip` names (the task's
|
|
221
|
+
* own temp root, SRT's default write paths) are left out: Linux's observer
|
|
222
|
+
* records every write ATTEMPT, and a write to `/dev/null` or the task's
|
|
223
|
+
* `TMPDIR` landed.
|
|
224
|
+
*/
|
|
225
|
+
export function refusedWritesOutside(
|
|
226
|
+
violations: readonly SandboxViolation[],
|
|
227
|
+
opts: {
|
|
228
|
+
within: string
|
|
229
|
+
linked?: readonly string[]
|
|
230
|
+
config: ResolvedSandboxConfig
|
|
231
|
+
skip: readonly string[]
|
|
232
|
+
},
|
|
233
|
+
): string[] {
|
|
234
|
+
const reported = reportedWithin(opts.within, opts.linked ?? [])
|
|
235
|
+
const skip = ['/dev', '/proc', '/sys', ...opts.skip.map(toRealPath)]
|
|
236
|
+
const paths = new Set<string>()
|
|
237
|
+
for (const v of filterIgnored(describe(violations), opts.config.ignore)) {
|
|
238
|
+
// Write-only: a strace record is ignorable as a read OR a write, and
|
|
239
|
+
// names a READ (a sibling's file at the wall is the sandbox working).
|
|
240
|
+
const write = v.ignorable?.length === 1 && v.ignorable[0] === 'write'
|
|
241
|
+
// Absolute only: a descriptor the runtime writes through (`/proc/self/fd/N`)
|
|
242
|
+
// canonicalizes to `anon_inode:[eventfd]`, which names no place.
|
|
243
|
+
if (v.path === undefined || !path.isAbsolute(v.path) || !write || reported(v)) continue
|
|
244
|
+
if (!skip.some((s) => atOrUnder(v.path!, s))) paths.add(v.path)
|
|
245
|
+
}
|
|
246
|
+
return [...paths]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Every record described: a seatbelt one parsed, a Linux one as produced. */
|
|
250
|
+
function describe(violations: readonly SandboxViolation[]): SandboxViolation[] {
|
|
251
|
+
return violations.map((v) =>
|
|
252
|
+
v.target === undefined ? { ...v, ...describeMacViolation(v.line) } : v,
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
212
256
|
/**
|
|
213
257
|
* Drop the loopback denial no grant can avoid.
|
|
214
258
|
*
|
|
@@ -261,15 +305,20 @@ function withinReported(
|
|
|
261
305
|
within: string,
|
|
262
306
|
linked: readonly string[],
|
|
263
307
|
): SandboxViolation[] {
|
|
308
|
+
return violations.filter(reportedWithin(within, linked))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function reportedWithin(
|
|
312
|
+
within: string,
|
|
313
|
+
linked: readonly string[],
|
|
314
|
+
): (v: SandboxViolation) => boolean {
|
|
264
315
|
const roots = [toRealPath(within), ...linked]
|
|
265
316
|
// `root === '/'` would otherwise compare against `'//'` and drop
|
|
266
317
|
// everything — the one prefix that needs no separator appended.
|
|
267
318
|
const prefixes = roots.map((root) => (root.endsWith(path.sep) ? root : root + path.sep))
|
|
268
|
-
return
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
roots.some((root, i) => v.path === root || v.path!.startsWith(prefixes[i]!)),
|
|
272
|
-
)
|
|
319
|
+
return (v) =>
|
|
320
|
+
v.path === undefined ||
|
|
321
|
+
roots.some((root, i) => v.path === root || v.path!.startsWith(prefixes[i]!))
|
|
273
322
|
}
|
|
274
323
|
|
|
275
324
|
/**
|
|
@@ -300,8 +349,11 @@ function filterIgnored(
|
|
|
300
349
|
export function refusedWrites(
|
|
301
350
|
records: readonly string[],
|
|
302
351
|
writable: readonly string[],
|
|
352
|
+
/** Write globs whose writes land in the sandbox's scratch (`scratchWrites`): granted. */
|
|
353
|
+
scratch: readonly string[] = [],
|
|
303
354
|
): SandboxViolation[] {
|
|
304
355
|
const binds = new Set(writable.map((w) => toRealPath(absolutize(w))))
|
|
356
|
+
const globs = scratch.map((g) => new Bun.Glob(g))
|
|
305
357
|
const seen = new Set<string>()
|
|
306
358
|
const out: SandboxViolation[] = []
|
|
307
359
|
for (const record of records) {
|
|
@@ -309,7 +361,7 @@ export function refusedWrites(
|
|
|
309
361
|
if (m === null) continue
|
|
310
362
|
const [, syscall, raw] = m as unknown as [string, string, string]
|
|
311
363
|
const abs = toRealPath(raw)
|
|
312
|
-
if (isUnderAny(abs, binds)) continue
|
|
364
|
+
if (isUnderAny(abs, binds) || underGlob(abs, globs)) continue
|
|
313
365
|
const key = `${syscall}|${abs}`
|
|
314
366
|
if (seen.has(key)) continue
|
|
315
367
|
seen.add(key)
|
|
@@ -323,3 +375,12 @@ export function refusedWrites(
|
|
|
323
375
|
}
|
|
324
376
|
return out
|
|
325
377
|
}
|
|
378
|
+
|
|
379
|
+
/** `p`, or a directory holding it, matches one of `globs`. */
|
|
380
|
+
function underGlob(p: string, globs: readonly InstanceType<typeof Bun.Glob>[]): boolean {
|
|
381
|
+
if (globs.length === 0) return false
|
|
382
|
+
for (let at = p; ; at = path.dirname(at)) {
|
|
383
|
+
if (globs.some((g) => g.match(at))) return true
|
|
384
|
+
if (path.dirname(at) === at) return false
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -415,15 +415,16 @@ async function prepareOutputsForBind(
|
|
|
415
415
|
const hasWildcard = !isMountableLiteral(g)
|
|
416
416
|
// A grant OUTSIDE the project is the user's own path — never joined
|
|
417
417
|
// onto the project dir, which would create a literal `~` there. Its
|
|
418
|
-
// directory is still created when the grant
|
|
419
|
-
//
|
|
420
|
-
// bind
|
|
421
|
-
// that has never populated it silently granted nothing, and the
|
|
422
|
-
// failed with the tool's own confusing message (`bun build
|
|
423
|
-
// reported a network error for an unwritable cache;
|
|
418
|
+
// directory is still created when the grant names one — a glob's
|
|
419
|
+
// static prefix, or a literal spelled `dir/` — because bwrap cannot
|
|
420
|
+
// bind a directory that does not exist: `~/.bun/install/cache/**` on a
|
|
421
|
+
// runner that has never populated it silently granted nothing, and the
|
|
422
|
+
// task failed with the tool's own confusing message (`bun build
|
|
423
|
+
// --compile` reported a network error for an unwritable cache;
|
|
424
|
+
// 2026-09-05). The `dir/` spelling was skipped until 2026-09-29.
|
|
424
425
|
if (path.isAbsolute(g) || g.startsWith('~')) {
|
|
425
|
-
if (!hasWildcard) continue
|
|
426
|
-
const abs = expandHome(grantPrefix(g))
|
|
426
|
+
if (!hasWildcard && !g.endsWith('/')) continue
|
|
427
|
+
const abs = expandHome(hasWildcard ? grantPrefix(g) : g)
|
|
427
428
|
await mkdir(abs, { recursive: true }).catch(() => undefined)
|
|
428
429
|
continue
|
|
429
430
|
}
|