Skip to content

sandbox: one PathMapping core, two facades (fixes #83) - #88

Merged
odrobnik merged 6 commits into
mainfrom
claude/suspicious-wilbur-ea4bd9
Jun 11, 2026
Merged

sandbox: one PathMapping core, two facades (fixes #83)#88
odrobnik merged 6 commits into
mainfrom
claude/suspicious-wilbur-ea4bd9

Conversation

@odrobnik

Copy link
Copy Markdown
Contributor

What

Implements #83 on top of ShellKit's new PathMapping core (Cocoanetics/ShellKit#17merge that first, this repo pins ShellKit branch: main):

  • Facade AMountedFileSystem now wraps a PathMapping; its Mount is a typealias of PathMapping.Mount and the lexical mount routing is the shared implementation. Shell.normalizePath moved down to ShellKit.Shell (call sites unchanged — inherited static).
  • Facade BExecCommand installs Sandbox.confined(to:home:temporaryDirectory:) over the same mapping, so Shell.resolve translates virtual spellings to the mapped host dirs and authorize gates exactly that host space. With --sandbox <dir> --workspace /batch, fd/rg/jq/git/JS stop resolving a literal nonexistent /batch — workspace access works for every non-builtin caller.

/tmp follow-ups from #82's interim contract

  • $TMPDIR now carries the virtual /tmp spelling; Facade B callers translate it back to the per-instance dir through resolve(). The per-instance host path no longer leaks into the script's env.
  • The identity mount (tempHost at its own host path) is retired — the mount table is just workspace + /tmp; the Linux nesting subtlety is gone.
  • Sandbox.bashWorkspace (host-rooted gate + temp carve-out that matched only by string coincidence) is deleted; the general containment check covers FileManager-backed callers uniformly, symlink-escape rejection included. Embedders (iBash) migrate to Sandbox.confined(to: fs.mapping, home:, temporaryDirectory:) — a compile-time break, intentionally loud since the gate semantics changed.

JS runtime (SwiftJSCore)

fs.* / require / process.chdir resolve the script's virtual spelling to the mapped host path for both the gate and the I/O (translating for only one of them would authorize one file and touch another — under a sandbox, a literal /tmp/x would otherwise reach the host's shared temp dir). process.cwd(), os.tmpdir(), os.homedir(), and __filename stay in (or fold back to) the virtual spelling. The exception code-frame reader now translates and gates its source read — sourceURL is script-influenced, so the previously ungated read could echo host file contents into error frames.

Tests

  • ConfinedSandboxTests (replaces BashWorkspaceSandboxTests, which pinned the retired gate): both facades land on the same files for /tmp + workspace, mktemp answers round-trip through resolve, $TMPDIR translates, display paths fold back, and a planted symlink escape is rejected identically at both doors.
  • SandboxGatePathMappingTests: the JS bridge under a mapped sandbox (writes land in the instance temp dir, not host /tmp; virtual cwd preserved across chdir; os.* answers virtual; require loads through the mapping with virtual __filename).
  • ExecCommandFileSystemTests pins the identity-mount retirement.
  • Full suite: 1980 tests green (4 pre-existing timing-sensitive concurrency tests flake under full parallel load; pass in isolation).

Companions / follow-up

  • Add the virtual↔host PathMapping core with two facades over it ShellKit#17 — the core (merge first).
  • SwiftPorts: "Fold displayed absolute paths back to virtual under a path mapping" (display hygiene; merge after ShellKit).
  • SwiftScript is deferred to its own PR: it gates paths but never resolves them, so its adaptation needs authorizePath to return the translated path, a BridgeGeneratorTool change so generated bridges consume it, and a bridge regeneration (which currently drags ~2k lines of symbol-graph drift on a newer SDK — needs a pinned regen environment). Interim behavior is fail-closed: virtual spellings are denied, no escape. Half-adopting (translate-for-check-only) would be an escape, so it ships whole or not at all.

Fixes #83

🤖 Generated with Claude Code

The virtual<->host translation that lived only inside
MountedFileSystem moves down into ShellKit as PathMapping; SwiftBash
becomes a consumer. Every cooperative caller now resolves and is
confined through the same table:

- Facade A (bash builtins, FileSystem protocol): MountedFileSystem
  wraps a PathMapping; its Mount type is now a typealias of
  PathMapping.Mount and the lexical mount routing is the shared
  implementation. Shell.normalizePath moved down to ShellKit.Shell.
- Facade B (SwiftPorts CLIs, JS runtime, SwiftScript): ExecCommand
  installs Sandbox.confined(to:home:temporaryDirectory:) over the
  SAME mapping, so ShellKit's Shell.resolve translates virtual
  spellings to the mapped host dirs and authorize gates exactly that
  host space. Workspace access works for non-builtin callers; with
  --sandbox dir --workspace /batch, fd//rg/jq/git/JS no longer
  resolve to a literal nonexistent /batch.

/tmp follow-ups from #82's interim contract:
- $TMPDIR now carries the virtual /tmp spelling; Facade B callers
  translate it back to the per-instance dir through resolve().
- The identity mount (tempHost at its own host path) is retired; the
  mount table is just workspace + /tmp, and the Linux nesting
  subtlety is gone.
- Sandbox.bashWorkspace - the host-rooted gate with the temp
  carve-out that only matched by string coincidence - is deleted;
  the general containment check covers FileManager-backed callers
  uniformly, symlink-escape rejection included.

JS runtime (SwiftJSCore): fs.*/require/chdir resolve the script's
virtual spelling to the mapped host path for BOTH the gate and the
I/O (translating for only one of them would authorize one file and
touch another); process.cwd(), os.tmpdir(), os.homedir(), and
__filename stay in (or fold back to) the virtual spelling. The
exception code-frame reader now translates AND gates its source
read - sourceURL is script-influenced, so the ungated read could
echo host file contents into error frames.

Tests: BashWorkspaceSandboxTests (pinning the retired gate) is
replaced by ConfinedSandboxTests, which pins the two-doors contract:
both facades land on the same files for /tmp and the workspace,
mktemp answers round-trip through resolve, $TMPDIR translates,
display paths fold back to virtual, and a planted symlink escape is
rejected identically at both doors. SandboxGatePathMappingTests
covers the JS bridge; ExecCommandFileSystemTests pins the
identity-mount retirement.

Requires Cocoanetics/ShellKit's PathMapping core (merge that first;
this repo pins ShellKit branch:main).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28d5bf3581

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/SwiftJSCore/Modules.swift Outdated
odrobnik added 4 commits June 11, 2026 13:25
Companion to ShellKit's resolve sentinel (Codex P1 on ShellKit#17):
a script holding the host path of a mounted dir gets ENOENT from the
bash filesystem AND a voided, gate-denied resolution from Facade B.
Same Linux nuance as the ShellKit-side fix: the platform temp root
IS /tmp there, so the fixture's host dirs prefix-match the virtual
/tmp mount and translate instead of voiding. Probe through a local
/work + /scratch mapping so the no-mount-matches contract is what
runs on every platform.
Codex P2 on #88: loadFileModule probed `fileExists` on the translated
host path before authorizing it, so a workspace symlink escaping the
sandbox got stat'd through on the host before the gate ran. A script
could `require('/batch/link')` and read the error shape to tell an
existing outside target (EACCES after the probe) from a missing one
(module-not-found) — an existence oracle past the boundary.

Resolution now walks Node's candidate order (bare, then .js/.mjs/
.cjs/.json) authorizing each host translation BEFORE any fileExists
touch, and folds a denied candidate into the same not-found path as a
missing one. So a symlink escape — whether it points at an existing
file or nothing — is indistinguishable from a plain missing module:
uniform MODULE_NOT_FOUND, no stat before the gate, no EACCES that
would confirm the escape landed on something real. Cache hits still
short-circuit before any gate/disk work. Regression test plants both
escape flavors and asserts identical MODULE_NOT_FOUND.
@odrobnik
odrobnik merged commit 6a1f569 into main Jun 11, 2026
6 checks passed
@odrobnik
odrobnik deleted the claude/suspicious-wilbur-ea4bd9 branch June 11, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sandbox path enforcement: move the mapping into ShellKit and offer two facades over one core

1 participant