Skip to content

Commit 2402a64

Browse files
authored
Merge pull request #4 from magaransoft/hotfix-sbt-sandbox-exec-policy
hotfix(sbt): both sbt modes require dangerouslyDisableSandbox on macOS + /health childrenAlive
2 parents 23874ab + 3da34d3 commit 2402a64

5 files changed

Lines changed: 69 additions & 21 deletions

File tree

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,21 @@ Why each sandbox write is needed:
267267
extracts JNI native libraries here on first jdtls start. One-time
268268
write, then read-only. Standard Eclipse-tooling path; same dir
269269
VSCode-Java, IntelliJ Eclipse plugin, etc. write to.
270-
- `/private/var/folders/**/.sbt/**`, `~/.sbt/**`, `~/.ivy2/**`,
271-
`~/.coursier/**` — only for `sbt-direct`. sbt's BootServerSocket is
272-
created under the macOS per-user tmpdir regardless of `$TMPDIR` env,
273-
and Ivy/Coursier cache dependency jars here on first resolve. Native
274-
sbt behavior; same dirs any Scala toolchain writes to.
270+
- `/private/var/folders/**/T/.sbt*/**`, `/private/var/folders/**/.sbt/**`,
271+
`~/.sbt/**`, `~/.ivy2/**`, `~/.coursier/**` — for `sbt-direct`
272+
dependency-cache writes (Ivy/Coursier resolve on first build) and
273+
per-invocation sbt tmpdirs (`.sbt<digits>/`). **Neither oneshot nor
274+
bsp mode runs under Claude's Bash sandbox on macOS**: both dlopen a
275+
native dylib at runtime (bsp → `libsbtipcsocket*.dylib`, oneshot →
276+
`libswoval-files*.dylib` via sbt's `FileTreeRepository`), and macOS
277+
governs dylib load via `process-exec*` policy — orthogonal to
278+
`file-write*` allowWrite globs. The realpath-twin prefix
279+
(`/var/folders/**/...` alongside `/private/var/folders/**/...`) was
280+
tested 2026-04-22 and also RED. For either mode, call `sbt-direct`
281+
with `dangerouslyDisableSandbox: true`. Verified GREEN 2026-04-22
282+
with Claude Bash `/sandbox disabled`: oneshot boots cleanly, no
283+
"Operation not permitted", no swoval dylib denial; sbt resolves
284+
project + runner versions as expected. See `docs/per-language/sbt.md`.
275285
- `/private/var/folders/**/.scala-build/**` — only for Scala CLI /
276286
scala-cli users; safe no-op if you don't use it.
277287

bin/sbt-direct

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ cmd_start() {
8383
i=$((i+1))
8484
[ "$i" -ge "$timeout" ] && { kill "$pid" 2>/dev/null; die "coordinator did not bind port $port within ${timeout}s — check $dir/log"; }
8585
done
86+
# /health reports childrenAlive — catches coordinator binding its HTTP port
87+
# before the child sbt JVM died (bsp mode). oneshot has no persistent child
88+
# so childrenAlive is trivially true on empty list.
89+
local health
90+
health="$(curl -fsS -m 2 "http://127.0.0.1:$port/health" 2>/dev/null || true)"
91+
if [ -n "$health" ] && [ "$(echo "$health" | jq -r '.childrenAlive // true')" = "false" ]; then
92+
kill "$pid" 2>/dev/null
93+
die "coordinator bound port $port but child exited — check $dir/log (health: $health)"
94+
fi
8695
echo "started: workspace=$ws port=$port pid=$pid state=$dir"
8796
}
8897

bin/tool-harness.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,15 @@ function freePort() {
5454
// serveHttp — loopback HTTP server exposing GET /health and POST
5555
// /call (canonical) + POST /lsp (back-compat alias for existing
5656
// wrappers). The caller supplies onCall({method, params}) → Promise<any>.
57-
function serveHttp(port, { onCall, meta }) {
57+
function serveHttp(port, { onCall, meta, statusFn }) {
5858
const server = http.createServer((req, res) => {
5959
if (req.method === 'GET' && req.url === '/health') {
60+
let extra = {};
61+
if (typeof statusFn === 'function') {
62+
try { extra = statusFn() || {}; } catch (e) { extra = { statusError: e.message }; }
63+
}
6064
res.writeHead(200, { 'Content-Type': 'application/json' });
61-
res.end(JSON.stringify({ ok: true, ...(meta || {}) }));
65+
res.end(JSON.stringify({ ok: true, ...(meta || {}), ...extra }));
6266
return;
6367
}
6468
if (req.method === 'POST' && (req.url === '/call' || req.url === '/lsp')) {

bin/tool-server-proxy.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,18 @@ async function createProxy({ adapter, workspace, port, toolName }) {
163163
let invalidationFiredOnLastCall = false;
164164
const server = serveHttp(port, {
165165
meta: { workspace, toolName, adopted },
166+
statusFn: () => {
167+
const list = Object.entries(children).map(([id, c]) => ({
168+
id,
169+
pid: c.proc.pid,
170+
alive: c.proc.exitCode === null && !c.proc.killed,
171+
exitCode: c.proc.exitCode,
172+
}));
173+
return {
174+
children: list,
175+
childrenAlive: list.every(c => c.alive),
176+
};
177+
},
166178
async onCall({ method, params }) {
167179
const t0 = Date.now();
168180
invalidationFiredOnLastCall = false;

docs/per-language/sbt.md

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -143,30 +143,43 @@ The JVM uses the macOS per-user tmp dir (`/private/var/folders/.../T/`)
143143
for sbt's BootServerSocket regardless of shell `$TMPDIR`, and for
144144
dependency-cache writes during Ivy / Coursier resolution. Claude
145145
Bash default sandbox denies writes there. `scripts/install.sh` pre-
146-
allows the minimum set automatically:
146+
allows the dependency-cache paths automatically:
147147

148148
```json
149149
"sandbox": { "filesystem": { "allowWrite": [
150+
"/private/var/folders/**/T/.sbt*/**",
150151
"/private/var/folders/**/.sbt/**",
151152
"~/.sbt/**",
152153
"~/.ivy2/**",
153154
"~/.coursier/**"
154155
]}}
155156
```
156157

157-
With `install.sh` run, sbt-direct works under Claude Bash without any
158-
per-call bypass flag. Verified against a real multi-module Play 3 /
159-
Scala 3 project: `sbt-direct call version` reads the project's
160-
`build.sbt` correctly; `sbt-direct call task
161-
{"task":"scalafmtCheckAll"}` runs the sbt-scalafmt plugin end-to-end
162-
and surfaces per-file formatting diffs.
163-
164-
Users who don't run `install.sh` (stand-alone deployment, bespoke
165-
sandbox config) can either merge the same entries into their
166-
`~/.claude/settings.json` manually, or call sbt-direct with
167-
`dangerouslyDisableSandbox: true`. The coordinator, bash wrapper, and
168-
adapter are all sandbox-neutral — the socket write is the only
169-
block, and it's strictly in sbt's own boot code.
158+
`allowWrite` is **not** sufficient for either mode on macOS. Both
159+
`bsp` and `oneshot` load a native dylib at runtime:
160+
161+
- `bsp` loads `libsbtipcsocket*.dylib` (ipcsocket for persistent server).
162+
- `oneshot` loads `libswoval-files*.dylib` (swoval file-watcher used by
163+
sbt's `FileTreeRepository` during project load).
164+
165+
Both extractions call `Files.createTempFile` under the per-user tmpdir
166+
(`/var/folders/.../T/.sbt<digits>/swoval/...` or `.../sbtipcsocket/...`)
167+
and then `System.load`. macOS sandbox-exec governs `dlopen` via
168+
`process-exec*` policy, orthogonal to `file-write*`. Adding
169+
`allowWrite` globs for the tmp path — including the realpath twin
170+
(`/var/folders/**/T/.sbt*/**` alongside the `/private/var/folders/...`
171+
prefix) — does NOT rescue the load; denial happens at dylib-exec time,
172+
not at file-create time. Verified 2026-04-22 — both bsp (prior session) and
173+
oneshot (this session with twin-prefix glob active) RED at same error
174+
signature class.
175+
176+
Call sbt-direct with `dangerouslyDisableSandbox: true` for either
177+
mode. There is no sandbox-compatible sbt path on macOS.
178+
179+
Verified GREEN 2026-04-22 with Claude Bash `/sandbox disabled`:
180+
`SBT_DIRECT_MODE=oneshot sbt-direct call version '{}' <ws>` against a
181+
real Play/Scala workspace boots sbt cleanly, resolves project +
182+
runner versions, no dylib "Operation not permitted" in stderr.
170183

171184
## State directory
172185

0 commit comments

Comments
 (0)