Skip to content

Gate every Foundation IO door behind the host sandbox#4

Merged
odrobnik merged 4 commits intomainfrom
feat/foundation-io-doors
May 9, 2026
Merged

Gate every Foundation IO door behind the host sandbox#4
odrobnik merged 4 commits intomainfrom
feat/foundation-io-doors

Conversation

@odrobnik
Copy link
Copy Markdown
Collaborator

@odrobnik odrobnik commented May 8, 2026

Closes #3.

PR #2 routed IO/FS/network/identity/exit through ShellKit.Shell.current but the generator's gates(...) policy only covered FileManager path-arg methods, URLSession URL-arg methods, and the String/Data file-IO entry points. Every other Foundation IO door — FileHandle(forReadingAtPath:), Bundle(path:), InputStream(url:), FileWrapper(url:options:), Process() — still reached the host's real disk / process table even under a rooted Sandbox. This PR closes the gaps listed in #3's "Known gaps" table.

Summary

  • 6 new bridge receiversFileHandle, Bundle, InputStream, OutputStream, FileWrapper each get path/URL gating in gates(...) (so a script can't open a file outside the sandbox via a class init). Process is denied entirely whenever a sandbox is bound, via a new ProcessSandboxDenied error from denyProcessIfSandboxed()Foundation.Process spawns a real OS subprocess that escapes every host gate, and ShellKit's closure-based ProcessTable doesn't take a path-to-exec, so there's nothing to redirect to.
  • Generator: per-position label scan — new pathStringLabelsRead/Write and urlLabelsRead/Write sets describe which String/URL arg labels carry filesystem paths; scanByLabel walks every parameter position, not just index 0. This closes existing holes around two-path FileManager methods that PR Adopt ShellKit: route IO / FS / network / identity / exit through Shell.current #2 missed: setUbiquitous(_:itemAt:destinationURL:) (URL args at indices 1+2), createSymbolicLink(at:withDestinationURL:) (both URLs are writes), contentsEqual(atPath:andPath:) (the second path was ungated). The receiver rule supplies a default intent; the label scan upgrades it (e.g. to:.fsWrite even when the receiver default is read).
  • Host: reject unknown HTTP verbs in NetworkConfig.checkAllowed. Previously an unrecognised method silently fell back to .GET, evaluating the request against GET permissions while sending the actual verb — a policy-bypass.
  • Cross-references for the new typesURLRequest.httpBodyStream: InputStream?, URLResource.bundle: Bundle.

Tests

ShellKitIntegrationTests adds deny + allow paths for each new receiver:

  • FileHandle(forReadingAtPath:), (forWritingAtPath:)
  • Bundle(path:), (url:)
  • InputStream(fileAtPath:), (url:)
  • OutputStream(toFileAtPath:), (url:append:)
  • FileWrapper(url:options:)
  • Process() deny when sandbox is active

Notes

Bridges regenerated via Tools/regen-foundation-bridge.sh. The bulky reorder noise in FoundationBridges.swift and StdlibBridges.swift is generator-output non-determinism (dict iteration order) — the only truly-new lines in FoundationBridges.swift are the gate-injected bodies for the new receivers (~54 lines). Sorting the generator's dict output deterministically is a follow-up.

Test plan

  • CI green on macOS / iOS / tvOS / watchOS / Linux / Windows / Android
  • swift test --no-parallel covers each new gated receiver (added in ShellKitIntegrationTests)

🤖 Generated with Claude Code

Closes #3.

PR #2 routed IO/FS/network/identity/exit through `ShellKit.Shell.current`
but the generator's `gates(...)` policy only inserted authorize-path /
authorize-URL calls for FileManager path-arg methods, URLSession URL-arg
methods, and the `String`/`Data` file-IO entry points. Every other
Foundation IO door — `FileHandle(forReadingAtPath:)`, `Bundle(path:)`,
`InputStream(url:)`, `FileWrapper(url:options:)`, `Process()` — still
reached the host's real disk / process table even under a rooted
Sandbox. This change closes the gaps listed in #3's "Known gaps" table.

What changed:

* New bridge receivers: `FileHandle`, `Bundle`, `InputStream`,
  `OutputStream`, `FileWrapper`, `Process`. The first five each get
  path/URL gating in `gates(...)` so a script can't open a file outside
  the sandbox via a class init. `Process` is denied entirely under any
  sandbox via a new `ProcessSandboxDenied` error from
  `denyProcessIfSandboxed()` — `Foundation.Process` spawns a real OS
  subprocess that escapes every host gate, and ShellKit's
  closure-based `ProcessTable` doesn't take a path-to-exec, so there's
  nothing to redirect to.

* Generator: extend per-receiver rules with a generalised
  parameter-label scan. New `pathStringLabelsRead/Write` and
  `urlLabelsRead/Write` sets describe which `String`/`URL` argument
  labels carry filesystem paths; `scanByLabel` walks every parameter
  position and gates the matching ones (not just position 0). This
  closes the holes around two-path FileManager methods —
  `setUbiquitous(_:itemAt:destinationURL:)` (URL args at indices 1+2),
  `createSymbolicLink(at:withDestinationURL:)` (both URL args are
  writes), `contentsEqual(atPath:andPath:)` (the second path slipped
  past the gate). The receiver rule supplies a default intent; the
  label scan upgrades it (e.g. `to:` -> `.fsWrite` even when the
  receiver default is read).

* Cross-references for the new types: `URLRequest.httpBodyStream:
  InputStream?`, `URLResource.bundle: Bundle`.

* Host: reject unknown HTTP verbs in `NetworkConfig.checkAllowed`.
  Previously an unrecognised method silently fell back to `.GET`,
  which evaluated the request against GET permissions while sending
  the actual verb — a policy-bypass.

* Tests: deny + allow paths in `ShellKitIntegrationTests` for each new
  receiver. Covers `FileHandle(forReadingAtPath:)`, `(forWritingAtPath:)`,
  `Bundle(path:)`, `(url:)`, `InputStream(fileAtPath:)`, `(url:)`,
  `OutputStream(toFileAtPath:)`, `(url:append:)`,
  `FileWrapper(url:options:)`, and `Process()` deny.

Bridges regenerated via `Tools/regen-foundation-bridge.sh`. The
mostly-reorder noise in `FoundationBridges.swift` and
`StdlibBridges.swift` is generator-output non-determinism (dict
iteration order); the only truly-new lines in `FoundationBridges.swift`
are the gate-injected bodies for the new receivers (54 lines). Sorting
the generator's dict output deterministically is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

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

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: 6a6cb98958

ℹ️ 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".

odrobnik and others added 3 commits May 9, 2026 08:12
`matchesContents(of:)` was bridged without an `authorizePath` check,
letting a script call it with an out-of-root URL and still inspect
host filesystem state — an FS sandbox bypass through the
newly-added FileWrapper surface.

The `of:` label isn't in `urlLabelsRead` (too generic to add
globally — appears all over Foundation on non-URL args), so the
prior `scanByLabel`-only branch missed it. Add a positional
gate at index 0 of the FileWrapper receiver branch, mirroring
how FileManager / URLSession treat their first arg.

Test: `sandboxBlocksFileWrapperMatchesContentsOutsideRoot`
constructs a FileWrapper(regularFileWithContents:) (no disk
hop) under a rooted sandbox and asserts matchesContents(of:)
on a path outside the root throws Sandbox.Denial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Foundation.Process` is unavailable on the iOS family (iOS, tvOS,
watchOS, visionOS). The scl oracle classified it cross-platform
because Linux Foundation has it, so the generator emitted the
Process bridge dict and comparator without any guard — the iOS
build broke with "cannot find 'Process' in scope".

Generator: introduce a third Platform case `nonIOSOnly` for types
available on macOS / Linux / Windows / Android but not iOS-family.
`nonIOSOnlyTypes = ["Process"]` overrides both the scl-oracle
classification and the Apple-only fallback for class-typed
comparators. The per-type bridge file gets wrapped in
`#if !os(iOS) && !os(tvOS) && !os(watchOS) && !os(visionOS)`
with a `[:]` stub on the excluded platforms so the manifest's
reference still resolves; the runtime-body comparator uses the
same guard.

Tests: skip the two Process tests on the iOS family with a
matching `#if`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Foundation.FileWrapper` isn't available on swift-corelibs-foundation
(Linux / Windows / Android) — the bridge file is already
`#if canImport(Darwin)`-only, so on those platforms the script
fails to resolve `FileWrapper` and the test sees a "cannot find
'FileWrapper' in scope" error instead of `Sandbox.Denial`.

Match the bridge's platform gating: wrap the new test in
`#if canImport(Darwin)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@odrobnik odrobnik merged commit 9daab07 into main May 9, 2026
5 checks passed
@odrobnik odrobnik deleted the feat/foundation-io-doors branch May 9, 2026 06:49
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.

Inventory + gate every Foundation IO surface in SwiftScript bridges

1 participant