Skip to content

Adopt ShellKit: route IO / FS / network / identity / exit through Shell.current#2

Merged
odrobnik merged 3 commits intomainfrom
feat/adopt-shellkit
May 8, 2026
Merged

Adopt ShellKit: route IO / FS / network / identity / exit through Shell.current#2
odrobnik merged 3 commits intomainfrom
feat/adopt-shellkit

Conversation

@odrobnik
Copy link
Copy Markdown
Collaborator

@odrobnik odrobnik commented May 8, 2026

SwiftScript now reads its host-touching surface from ShellKit.Shell.current instead of Foundation directly. Same script body runs equally well standalone (Shell.processDefault wraps real fd 0/1/2 + FileManager.default + ProcessInfo.processInfo) or under an embedder that has bound a custom Shell with capturing sinks, a Sandbox URL gate, a NetworkConfig allow-list, and a synthetic HostInfo.

Why

SwiftBash wants to register swift-script as a script-shebang interpreter (Cocoanetics/SwiftBash#9). Doing that without this refactor leaks every print, file open, URL fetch, and identity read past the bash sandbox. ShellKit is the contract bash already uses for those surfaces (Cocoanetics/SwiftBash#11) — SwiftScript joins the lane.

What changed

  • Output / error / stdin route through Shell.current.{stdout,stderr,stdin}. Default closures read the bound shell, so passthrough mode behaves exactly as before. print(_:terminator:) with a non-\n terminator no longer bypasses output and leaks to the host's real fd 1.
  • exit(_:) and abort() throw ScriptExit carrying an ExitStatus. New Interpreter.evalScript(_:fileName:) catches and returns it; legacy eval lets it propagate.
  • CommandLine.arguments is bound automatically at eval time from interpreter.scriptArguments (when set) or Shell.current.scriptName + positionalParameters.
  • New HostHooks.swift exposes top-level authorizePath(_:for:), authorizeURL(_:method:), hostUserName(), hostNameOverride(), hostProcessIdentifier(), hostProcessName(), hostEnvironment(), hostProcessArguments() — all reading Shell.current.
  • BridgeGeneratorTool learns three policies:
    • gates(...): scan signatures for String/URL args on FileManager / URLSession / String+Data file-IO inits and emit try await authorizePath / authorizeURL calls. Denials wrap as UserThrowSignal so script-side try/catch can see them.
    • redirectedPropertyCall(...): redirect ProcessInfo.{userName,fullUserName,hostName,processIdentifier,processName,environment,arguments} and FileManager.currentDirectoryPath to the bound shell's identity / environment / scriptName.
    • comparableUnavailableAtFloor: maintains a small list of types whose Comparable conformance lands on a newer OS than the deployment floor (today: UUID); the comparator emit falls back to Equatable-only ordering.
    • Deployment floor lowered to macOS 13 / iOS 16 / tvOS 16 / watchOS 9 to match SwiftBash. The generator's isDeprecated check now scans iOS / tvOS / watchOS domains alongside macOS, so symbols introduced in iOS 16.1 (e.g. URLRequest.requiresDNSSECValidation) get filtered for an iOS-16 build.
  • Hand-rolled bridges updated (Interpreter+FileIO.swift, URLSessionModule.swift) to call the same hooks before touching disk / network.
  • IdentityModule hand-rolls ProcessInfo.{userName,fullUserName,processIdentifier,arguments,environment} — types the generator can't bridge (Int32, [String], [String:String]).
  • swift-script CLI routes stdio through Shell.current; CLI no longer needs to manually register CommandLine.arguments; file scripts use evalScript(_:) so exit(N) propagates as the host's exit code.
  • Bridges regenerated via Tools/regen-foundation-bridge.sh (BRIDGE_TARGET defaults to arm64-apple-macos13.0).

Tests

Tests/SwiftScriptInterpreterTests/ShellKitIntegrationTests.swift exercises stdout / stderr / stdin / CommandLine.arguments / exit / sandbox-allow / sandbox-deny / ProcessInfo identity end-to-end against a TestShell that wires ShellKit.Shell up with capturing sinks. Suite: 412 → 426 tests.

Cross-platform

  • ✅ macOS native (Swift 6.2.4)
  • ✅ iOS — via xcodebuild -destination 'generic/platform=iOS' build
  • ✅ Linux — via swift:6.2-jammy Docker (aarch64), full swift build
  • CI runs the full matrix (macOS / iOS / Linux / Windows / Android).

🤖 Generated with Claude Code

odrobnik and others added 2 commits May 8, 2026 08:11
…ll.current

SwiftScript now reads its host-touching surface from
`ShellKit.Shell.current` instead of Foundation directly. The same
script body runs equally well standalone (Shell.processDefault wraps
real fd 0/1/2 + FileManager.default + ProcessInfo.processInfo) or
under an embedder that has bound a custom Shell with capturing
sinks, a Sandbox URL gate, a NetworkConfig allow-list, and a
synthetic HostInfo.

Why: SwiftBash wants to register `swift-script` as a script
interpreter (issue Cocoanetics/SwiftBash#9). Doing that without this
refactor leaks every `print`, file open, URL fetch, and identity
read past the bash sandbox. ShellKit is the contract bash already
uses for those surfaces (Cocoanetics/SwiftBash#11), so SwiftScript
joins the lane.

What changed:

* `Interpreter.output: (String) -> Void` defaults to
  `Shell.current.stdout($0)`. New `Interpreter.error: (String) -> Void`
  defaults to `Shell.current.stderr`. Tests that override these no
  longer need to add a trailing newline; the closure receives
  verbatim bytes (incl. terminator).
* `Interpreter+Calls.swift::tryPrintCall` no longer falls through
  to `Swift.print` when `terminator: ""` — the call now routes
  through `output(body + terminator)`. Earlier code leaked
  non-`\n`-terminated output to the host's real fd 1, bypassing the
  bound shell.
* `readLine()` reads from `Shell.current.stdin.readLine()`, so
  embedded pipelines feed input via an `InputSource`.
* `ScriptExit: Error` carries an `ExitStatus`; `exit(_:)` and
  `abort()` throw it. New `Interpreter.evalScript(_:fileName:)`
  catches and returns it as `ExitStatus`. The legacy `eval` path
  rethrows so existing callers can still observe it.
* `CommandLine.arguments` is bound automatically at `eval` time
  from `interpreter.scriptArguments` (when set) or
  `Shell.current.scriptName` + `positionalParameters`. The CLI no
  longer registers it manually.
* New `HostHooks.swift` exposes top-level
  `authorizePath(_:for:)`, `authorizeURL(_:method:)`,
  `hostUserName()`, `hostNameOverride()`,
  `hostProcessIdentifier()`, `hostProcessName()`,
  `hostEnvironment()`, `hostProcessArguments()`. These read
  `Shell.current.{sandbox,networkConfig,hostInfo,environment,
  scriptName,positionalParameters}`.
* `BridgeGeneratorTool` learns three policies:
  - `gates(...)`: scan signatures for `String`/`URL` args on
    FileManager (intent picked from method name), URLSession,
    String/Data file-IO inits, and emit `try await authorizePath`
    / `authorizeURL` calls in the generated body. Denials wrap as
    `UserThrowSignal` so they surface through script-side
    `try/catch` like any other Foundation error.
  - `redirectedPropertyCall(...)`: redirect `ProcessInfo.{userName,
    fullUserName,hostName,processIdentifier,processName,environment,
    arguments}` and `FileManager.currentDirectoryPath` to the bound
    shell's `HostInfo` / `Environment` / `scriptName` instead of
    Foundation defaults.
  - `comparableUnavailableAtFloor`: maintains a small list of
    types whose `Comparable` conformance lands on a newer OS than
    the deployment floor (today: `UUID`); the comparator emit
    falls back to Equatable-only ordering.
  - Deployment floor lowered to macOS 13 to match SwiftBash. Any
    Foundation symbol whose `@available` requires a newer OS is
    skipped via the existing `isDeprecated` check.
* `IdentityModule`: hand-rolled bridges for ProcessInfo's
  `userName` / `fullUserName` / `processIdentifier` / `arguments`
  / `environment` — the generator can't bridge Int32 / [String] /
  [String:String] but the helpers in HostHooks return the right
  shapes.
* `Interpreter+FileIO.swift` (the hand-rolled FileManager.default /
  String(contentsOfFile:) / String.write(toFile:) fast-path) now
  calls `authorizePath` before each disk touch. Without this, a
  script that uses `FileManager.default.fileExists(...)` would
  bypass the bridge dict entirely and reach the host's real disk
  even with a sandbox bound.
* `URLSessionModule`: hand-rolled `URLSession.bytes(from:)` calls
  `authorizeURL` before opening a connection.
* Foundation bridge files now `import ShellKit` (added by the
  generator's header emit).
* `Package.swift` lifts the floor from macOS 26 to macOS 13 / iOS
  16; depends on `Cocoanetics/ShellKit` `main`.
* `swift-script` CLI: stdio routes through `Shell.current` (so
  passthrough mode behaves identically to before); CommandLine.
  arguments bridge no longer needs manual registration; file-script
  path uses `evalScript(_:fileName:)` to honor `exit(N)`.
* Bridges regenerated via `Tools/regen-foundation-bridge.sh` (the
  script's `BRIDGE_TARGET` now defaults to `arm64-apple-macos13.0`).
* `ShellKitIntegrationTests` exercises stdout / stderr / stdin /
  CommandLine.arguments / exit / sandbox-allow / sandbox-deny /
  ProcessInfo identity end-to-end. Suite: 412 → 426 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`isDeprecated` previously checked only `domain == "macOS"`, so a
Foundation symbol available on macOS 13.0 but introduced in iOS 16.1
(URLRequest.requiresDNSSECValidation, etc.) sailed through the
filter and broke the iOS build of every consumer. Add iOS / tvOS /
watchOS deployment-floor constants (matching the SwiftBash floor:
iOS 16 / tvOS 16 / watchOS 9) and a domain → floor lookup so
`introduced` and `deprecated` checks apply to all four Apple
platforms uniformly.

The deprecation branch also got slightly more correct: previously
ANY non-macOS deprecation triggered a skip; now it only skips when
the deprecation version is at-or-below the platform's floor (so a
symbol deprecated in iOS 17 is still bridged for an iOS-16 build,
matching swiftc's own behaviour). Net regen: 2680 → 2695 Foundation
bridges, 187 → 189 stdlib bridges.

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: cc24e2acaf

ℹ️ 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/BridgeGeneratorTool/main.swift Outdated
…ndex 1+

The previous `gates(...)` only authorized URLSession calls when the
first arg was a `URL` and skipped index-1+ args entirely. That left
two real holes the codex reviewer flagged on the SwiftBash#9 PR:

1. `URLSession.data(for:)`, `download(for:)`, `upload(for:from:)`,
   `upload(for:fromFile:)`, `bytes(for:)` — every URLRequest-shaped
   overload — sailed past the network policy. A script could set
   custom headers / method via URLRequest and reach any URL outside
   the allow-list. Now the generator emits a `URLRequest` arg-bind
   followed by `authorizeURL(req.url ?? URL(fileURLWithPath: ""),
   method: req.httpMethod ?? "GET")` so the URL allow-list and
   method gate fire identically to the bare-URL overloads.

2. `URLSession.upload(for:fromFile:)`'s second arg is a local-file
   `URL` the session reads to upload — previously unguarded. Now
   gated as `.fsRead` so a sandboxed embedder denies the file
   access before the upload starts.

`renderGates` learns a `.networkRequest` GateKind that knows how
to extract `.url` and `.httpMethod` from a `URLRequest` to build
the authorize call. Auto-genned bridges now look like:

    "func URLSession.upload()": .method { receiver, args in
        ...
        let arg0 = try unboxOpaque(args[0], as: URLRequest.self, ...)
        do {
            try await authorizeURL(
                arg0.url ?? URL(fileURLWithPath: ""),
                method: arg0.httpMethod ?? "GET")
        } catch {
            throw UserThrowSignal(value: ...)
        }
        let arg1 = try unboxOpaque(args[1], as: URL.self, ...)
        do {
            try await authorizePath(arg1, for: .read)
        } catch {
            throw UserThrowSignal(value: ...)
        }
        ...
    }

A request constructed from a `nil`-URL component falls back to
the `URL(fileURLWithPath: "")` sentinel — the network policy
denies it explicitly rather than silently skipping the gate.

Every other Foundation IO surface still tracked in the broader
inventory follow-up (Cocoanetics/SwiftBash#13).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@odrobnik odrobnik merged commit a905828 into main May 8, 2026
5 checks passed
@odrobnik odrobnik deleted the feat/adopt-shellkit branch May 8, 2026 08:15
odrobnik added a commit to Cocoanetics/SwiftBash that referenced this pull request May 8, 2026
Closes #9.

A path-invoked simple command (`./hello.swift`, `/abs/path/script.foo`)
that the registry doesn't resolve falls through to a shebang-dispatch
path: read the file, parse the `#!`-line, look up a registered
`ScriptInterpreter`, run it inside a fresh `Shell.copy()` so
positional parameters / `$0` / errexit toggling stay scoped. The
SwiftScript binding is a thin bridge because SwiftScript adopted
ShellKit (Cocoanetics/SwiftScript#2) — it routes IO / FS / network
/ identity / exit through `Shell.current`, the same TaskLocal
SwiftBash binds for every dispatch. Output, stdin, sandbox
confinement, network allow-list, and synthetic identity all work
without per-call wiring.

What's in the diff:

* `BashInterpreter`:
  - `ScriptInterpreter` protocol + `ScriptInterpreterContext` +
    `ClosureScriptInterpreter` — language-agnostic registry; any
    embedder can add Python, Lua, etc. the same way.
  - `Shell+ScriptInterpreters.swift`: registration helpers,
    `parseShebangLine(_:)` (handles plain shebangs,
    `#!/usr/bin/env <name>`, `#!/usr/bin/env -S <name> --flag`,
    walks past KEY=value), `stripShebang(_:)` that drops the
    shebang content but preserves the trailing newline so
    interpreter diagnostics line up with the original file
    (`swift` itself counts the shebang as line 1).
  - `Shell+ExternalScript.swift`: dispatcher that reads the
    candidate file via `Shell.fileSystem`, applies bash-style
    diagnostics (missing → 127, directory → 126, no execute bit →
    126 / Permission denied, FS error → 126 / message), and
    invokes the registered interpreter inside a `Shell.copy()`
    subshell.
  - `Shell.scriptInterpreters: [String: ScriptInterpreter]` field,
    propagated via `copy()`.
  - `Shell+Run.swift::executeSimpleCommand` falls through to the
    dispatcher before `command not found`.
  - `FileSystemError.shellMessage()` helper for bash-style short
    diagnostics (`Permission denied`, `Is a directory`, …).

* `BashSwiftScript` (new library + product):
  - `SwiftScriptShellInterpreter` — `ScriptInterpreter` adapter:
    constructs a fresh `SwiftScriptInterpreter.Interpreter` per
    script, calls `evalScript(_:fileName:)`, renders parse +
    runtime errors via the interpreter's own renderer (which
    routes through `Shell.current.stderr`), translates
    cancellation to bash's 128+SIGTERM = 143.
  - `Shell+SwiftScript.swift::registerSwiftScript(names:)`
    registers under `["swift-script", "swift"]` by default.

* `swift-bash exec`:
  - Calls `registerSwiftScript()` automatically.
  - `--sandbox HOST_DIR` now ALSO binds
    `Shell.sandbox = ShellKit.Sandbox.rooted(at: HOST_DIR)` — the
    URL gate the SwiftScript bridges consult. Earlier the flag
    only bound the legacy `Shell.fileSystem` overlay (used by
    bash builtins) and SwiftScript bypassed confinement. Full
    unification of the two confinement mechanisms is tracked in
    issue #10. Documented in `Docs/SwiftScript.md`.
  - Detects non-bash shebangs in the source: when `parseShebangLine`
    returns an interpreter that's registered as a
    `ScriptInterpreter`, the CLI synthesises a quoted bash command
    line (`'<resolved-path>' '<arg1>' …`) and runs that, so the
    dispatcher's shebang-fallthrough fires. Stdin / `/dev/fd/N`
    paths still run the source as bash because the stream is
    already consumed.

* Tests:
  - `Tests/BashInterpreterTests/ScriptInterpreterTests.swift` — 25
    tests covering shebang parsing (env-prefix, env-S, KEY=value),
    strip semantics, dispatch positive / not-found / is-directory
    / non-executable / unreadable / unregistered / inheritance
    into subshells.
  - `Tests/BashSwiftScriptTests/BashSwiftScriptTests.swift` — 11
    end-to-end tests through the bash dispatcher: hello-world,
    bare `swift` shebang, `CommandLine.arguments` plumbing, exit
    code propagation through `$?`, parse + runtime diagnostics,
    line-number preservation, subshell positional isolation,
    stdin feed via `echo input | ./script.swift`, sandbox-rooted
    disk denial, synthetic-identity ProcessInfo readout.

* Docs: new `Docs/SwiftScript.md` (with the
  `--sandbox` known-limitations note and the issue #13 / #10
  follow-ups). Updates to `Docs/BashInterpreter.md`, `README.md`,
  `AGENTS.md`.

Cross-platform: macOS native (1760 tests pass), iOS via
`xcodebuild -destination 'generic/platform=iOS' build`, Linux via
`swift:6.2-jammy` Docker. CI handles the full matrix.

Suite: 1726 → 1760 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
odrobnik added a commit to Cocoanetics/SwiftBash that referenced this pull request May 8, 2026
Closes #9.

A path-invoked simple command (`./hello.swift`, `/abs/path/script.foo`)
that the registry doesn't resolve falls through to a shebang-dispatch
path: read the file, parse the `#!`-line, look up a registered
`ScriptInterpreter`, run it inside a fresh `Shell.copy()` so
positional parameters / `$0` / errexit toggling stay scoped. The
SwiftScript binding is a thin bridge because SwiftScript adopted
ShellKit (Cocoanetics/SwiftScript#2) — it routes IO / FS / network
/ identity / exit through `Shell.current`, the same TaskLocal
SwiftBash binds for every dispatch. Output, stdin, sandbox
confinement, network allow-list, and synthetic identity all work
without per-call wiring.

What's in the diff:

* `BashInterpreter`:
  - `ScriptInterpreter` protocol + `ScriptInterpreterContext` +
    `ClosureScriptInterpreter` — language-agnostic registry; any
    embedder can add Python, Lua, etc. the same way.
  - `Shell+ScriptInterpreters.swift`: registration helpers,
    `parseShebangLine(_:)` (handles plain shebangs,
    `#!/usr/bin/env <name>`, `#!/usr/bin/env -S <name> --flag`,
    walks past KEY=value), `stripShebang(_:)` that drops the
    shebang content but preserves the trailing newline so
    interpreter diagnostics line up with the original file
    (`swift` itself counts the shebang as line 1).
  - `Shell+ExternalScript.swift`: dispatcher that reads the
    candidate file via `Shell.fileSystem`, applies bash-style
    diagnostics (missing → 127, directory → 126, no execute bit →
    126 / Permission denied, FS error → 126 / message), and
    invokes the registered interpreter inside a `Shell.copy()`
    subshell.
  - `Shell.scriptInterpreters: [String: ScriptInterpreter]` field,
    propagated via `copy()`.
  - `Shell+Run.swift::executeSimpleCommand` falls through to the
    dispatcher before `command not found`.
  - `FileSystemError.shellMessage()` helper for bash-style short
    diagnostics (`Permission denied`, `Is a directory`, …).

* `BashSwiftScript` (new library + product):
  - `SwiftScriptShellInterpreter` — `ScriptInterpreter` adapter:
    constructs a fresh `SwiftScriptInterpreter.Interpreter` per
    script, calls `evalScript(_:fileName:)`, renders parse +
    runtime errors via the interpreter's own renderer (which
    routes through `Shell.current.stderr`), translates
    cancellation to bash's 128+SIGTERM = 143.
  - `Shell+SwiftScript.swift::registerSwiftScript(names:)`
    registers under `["swift-script", "swift"]` by default.

* `swift-bash exec`:
  - Calls `registerSwiftScript()` automatically.
  - `--sandbox HOST_DIR` now ALSO binds
    `Shell.sandbox = ShellKit.Sandbox.rooted(at: HOST_DIR)` — the
    URL gate the SwiftScript bridges consult. Earlier the flag
    only bound the legacy `Shell.fileSystem` overlay (used by
    bash builtins) and SwiftScript bypassed confinement. Full
    unification of the two confinement mechanisms is tracked in
    issue #10. Documented in `Docs/SwiftScript.md`.
  - Detects non-bash shebangs in the source: when `parseShebangLine`
    returns an interpreter that's registered as a
    `ScriptInterpreter`, the CLI synthesises a quoted bash command
    line (`'<resolved-path>' '<arg1>' …`) and runs that, so the
    dispatcher's shebang-fallthrough fires. Stdin / `/dev/fd/N`
    paths still run the source as bash because the stream is
    already consumed.

* Tests:
  - `Tests/BashInterpreterTests/ScriptInterpreterTests.swift` — 25
    tests covering shebang parsing (env-prefix, env-S, KEY=value),
    strip semantics, dispatch positive / not-found / is-directory
    / non-executable / unreadable / unregistered / inheritance
    into subshells.
  - `Tests/BashSwiftScriptTests/BashSwiftScriptTests.swift` — 11
    end-to-end tests through the bash dispatcher: hello-world,
    bare `swift` shebang, `CommandLine.arguments` plumbing, exit
    code propagation through `$?`, parse + runtime diagnostics,
    line-number preservation, subshell positional isolation,
    stdin feed via `echo input | ./script.swift`, sandbox-rooted
    disk denial, synthetic-identity ProcessInfo readout.

* Docs: new `Docs/SwiftScript.md` (with the
  `--sandbox` known-limitations note and the issue #13 / #10
  follow-ups). Updates to `Docs/BashInterpreter.md`, `README.md`,
  `AGENTS.md`.

Cross-platform: macOS native (1760 tests pass), iOS via
`xcodebuild -destination 'generic/platform=iOS' build`, Linux via
`swift:6.2-jammy` Docker. CI handles the full matrix.

Suite: 1726 → 1760 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
odrobnik added a commit to Cocoanetics/SwiftBash that referenced this pull request May 8, 2026
Closes #9.

A path-invoked simple command (`./hello.swift`, `/abs/path/script.foo`)
that the registry doesn't resolve falls through to a shebang-dispatch
path: read the file, parse the `#!`-line, look up a registered
`ScriptInterpreter`, run it inside a fresh `Shell.copy()` so
positional parameters / `$0` / errexit toggling stay scoped. The
SwiftScript binding is a thin bridge because SwiftScript adopted
ShellKit (Cocoanetics/SwiftScript#2) — it routes IO / FS / network
/ identity / exit through `Shell.current`, the same TaskLocal
SwiftBash binds for every dispatch. Output, stdin, sandbox
confinement, network allow-list, and synthetic identity all work
without per-call wiring.

What's in the diff:

* `BashInterpreter`:
  - `ScriptInterpreter` protocol + `ScriptInterpreterContext` +
    `ClosureScriptInterpreter` — language-agnostic registry; any
    embedder can add Python, Lua, etc. the same way.
  - `Shell+ScriptInterpreters.swift`: registration helpers,
    `parseShebangLine(_:)` (handles plain shebangs,
    `#!/usr/bin/env <name>`, `#!/usr/bin/env -S <name> --flag`,
    walks past KEY=value), `stripShebang(_:)` that drops the
    shebang content but preserves the trailing newline so
    interpreter diagnostics line up with the original file
    (`swift` itself counts the shebang as line 1).
  - `Shell+ExternalScript.swift`: dispatcher that reads the
    candidate file via `Shell.fileSystem`, applies bash-style
    diagnostics (missing → 127, directory → 126, no execute bit →
    126 / Permission denied, FS error → 126 / message), and
    invokes the registered interpreter inside a `Shell.copy()`
    subshell.
  - `Shell.scriptInterpreters: [String: ScriptInterpreter]` field,
    propagated via `copy()`.
  - `Shell+Run.swift::executeSimpleCommand` falls through to the
    dispatcher before `command not found`.
  - `FileSystemError.shellMessage()` helper for bash-style short
    diagnostics (`Permission denied`, `Is a directory`, …).

* `BashSwiftScript` (new library + product):
  - `SwiftScriptShellInterpreter` — `ScriptInterpreter` adapter:
    constructs a fresh `SwiftScriptInterpreter.Interpreter` per
    script, calls `evalScript(_:fileName:)`, renders parse +
    runtime errors via the interpreter's own renderer (which
    routes through `Shell.current.stderr`), translates
    cancellation to bash's 128+SIGTERM = 143.
  - `Shell+SwiftScript.swift::registerSwiftScript(names:)`
    registers under `["swift-script", "swift"]` by default.

* `swift-bash exec`:
  - Calls `registerSwiftScript()` automatically.
  - `--sandbox HOST_DIR` now ALSO binds
    `Shell.sandbox = ShellKit.Sandbox.rooted(at: HOST_DIR)` — the
    URL gate the SwiftScript bridges consult. Earlier the flag
    only bound the legacy `Shell.fileSystem` overlay (used by
    bash builtins) and SwiftScript bypassed confinement. Full
    unification of the two confinement mechanisms is tracked in
    issue #10. Documented in `Docs/SwiftScript.md`.
  - Detects non-bash shebangs in the source: when `parseShebangLine`
    returns an interpreter that's registered as a
    `ScriptInterpreter`, the CLI synthesises a quoted bash command
    line (`'<resolved-path>' '<arg1>' …`) and runs that, so the
    dispatcher's shebang-fallthrough fires. Stdin / `/dev/fd/N`
    paths still run the source as bash because the stream is
    already consumed.

* Tests:
  - `Tests/BashInterpreterTests/ScriptInterpreterTests.swift` — 25
    tests covering shebang parsing (env-prefix, env-S, KEY=value),
    strip semantics, dispatch positive / not-found / is-directory
    / non-executable / unreadable / unregistered / inheritance
    into subshells.
  - `Tests/BashSwiftScriptTests/BashSwiftScriptTests.swift` — 11
    end-to-end tests through the bash dispatcher: hello-world,
    bare `swift` shebang, `CommandLine.arguments` plumbing, exit
    code propagation through `$?`, parse + runtime diagnostics,
    line-number preservation, subshell positional isolation,
    stdin feed via `echo input | ./script.swift`, sandbox-rooted
    disk denial, synthetic-identity ProcessInfo readout.

* Docs: new `Docs/SwiftScript.md` (with the
  `--sandbox` known-limitations note and the issue #13 / #10
  follow-ups). Updates to `Docs/BashInterpreter.md`, `README.md`,
  `AGENTS.md`.

* CI: SwiftBash's `build-windows` job inherited the `vcpkg install
  zlib` step from upstream but missed the alias hop the SwiftPorts
  workflow uses. vcpkg's `x64-windows-static-md` triplet ships zlib
  as `zs.lib`, but `CZlib`'s modulemap declares `link "z"` (mirroring
  libarchive's `linkedLibrary("z")`), so lld-link asks for `z.lib`.
  Port the alias step (`Copy-Item zs.lib z.lib`) into
  `.github/workflows/swift.yml` so the link resolves, then drop the
  unnecessary `bashSwiftScriptPlatforms` gate + `#if canImport(
  SwiftScriptInterpreter)` guards that were a defensive guess based
  on the (truncated) original lld-link error.

Cross-platform: macOS native (1760 tests pass), iOS via
`xcodebuild -destination 'generic/platform=iOS' build`, Linux via
`swift:6.2-jammy` Docker. CI handles the full matrix.

Suite: 1726 → 1760 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
odrobnik added a commit to Cocoanetics/SwiftBash that referenced this pull request May 8, 2026
Closes #9.

A path-invoked simple command (`./hello.swift`, `/abs/path/script.foo`)
that the registry doesn't resolve falls through to a shebang-dispatch
path: read the file, parse the `#!`-line, look up a registered
`ScriptInterpreter`, run it inside a fresh `Shell.copy()` so
positional parameters / `$0` / errexit toggling stay scoped. The
SwiftScript binding is a thin bridge because SwiftScript adopted
ShellKit (Cocoanetics/SwiftScript#2) — it routes IO / FS / network
/ identity / exit through `Shell.current`, the same TaskLocal
SwiftBash binds for every dispatch. Output, stdin, sandbox
confinement, network allow-list, and synthetic identity all work
without per-call wiring.

What's in the diff:

* `BashInterpreter`:
  - `ScriptInterpreter` protocol + `ScriptInterpreterContext` +
    `ClosureScriptInterpreter` — language-agnostic registry; any
    embedder can add Python, Lua, etc. the same way.
  - `Shell+ScriptInterpreters.swift`: registration helpers,
    `parseShebangLine(_:)` (handles plain shebangs,
    `#!/usr/bin/env <name>`, `#!/usr/bin/env -S <name> --flag`,
    walks past KEY=value), `stripShebang(_:)` that drops the
    shebang content but preserves the trailing newline so
    interpreter diagnostics line up with the original file
    (`swift` itself counts the shebang as line 1).
  - `Shell+ExternalScript.swift`: dispatcher that reads the
    candidate file via `Shell.fileSystem`, applies bash-style
    diagnostics (missing → 127, directory → 126, no execute bit →
    126 / Permission denied, FS error → 126 / message), and
    invokes the registered interpreter inside a `Shell.copy()`
    subshell.
  - `Shell.scriptInterpreters: [String: ScriptInterpreter]` field,
    propagated via `copy()`.
  - `Shell+Run.swift::executeSimpleCommand` falls through to the
    dispatcher before `command not found`.
  - `FileSystemError.shellMessage()` helper for bash-style short
    diagnostics (`Permission denied`, `Is a directory`, …).

* `BashSwiftScript` (new library + product):
  - `SwiftScriptShellInterpreter` — `ScriptInterpreter` adapter:
    constructs a fresh `SwiftScriptInterpreter.Interpreter` per
    script, calls `evalScript(_:fileName:)`, renders parse +
    runtime errors via the interpreter's own renderer (which
    routes through `Shell.current.stderr`), translates
    cancellation to bash's 128+SIGTERM = 143.
  - `Shell+SwiftScript.swift::registerSwiftScript(names:)`
    registers under `["swift-script", "swift"]` by default.

* `swift-bash exec`:
  - Calls `registerSwiftScript()` automatically.
  - `--sandbox HOST_DIR` now ALSO binds
    `Shell.sandbox = ShellKit.Sandbox.rooted(at: HOST_DIR)` — the
    URL gate the SwiftScript bridges consult. Earlier the flag
    only bound the legacy `Shell.fileSystem` overlay (used by
    bash builtins) and SwiftScript bypassed confinement. Full
    unification of the two confinement mechanisms is tracked in
    issue #10. Documented in `Docs/SwiftScript.md`.
  - Detects non-bash shebangs in the source: when `parseShebangLine`
    returns an interpreter that's registered as a
    `ScriptInterpreter`, the CLI synthesises a quoted bash command
    line (`'<resolved-path>' '<arg1>' …`) and runs that, so the
    dispatcher's shebang-fallthrough fires. Stdin / `/dev/fd/N`
    paths still run the source as bash because the stream is
    already consumed.

* Tests:
  - `Tests/BashInterpreterTests/ScriptInterpreterTests.swift` — 25
    tests covering shebang parsing (env-prefix, env-S, KEY=value),
    strip semantics, dispatch positive / not-found / is-directory
    / non-executable / unreadable / unregistered / inheritance
    into subshells.
  - `Tests/BashSwiftScriptTests/BashSwiftScriptTests.swift` — 11
    end-to-end tests through the bash dispatcher: hello-world,
    bare `swift` shebang, `CommandLine.arguments` plumbing, exit
    code propagation through `$?`, parse + runtime diagnostics,
    line-number preservation, subshell positional isolation,
    stdin feed via `echo input | ./script.swift`, sandbox-rooted
    disk denial, synthetic-identity ProcessInfo readout.

* Docs: new `Docs/SwiftScript.md` (with the
  `--sandbox` known-limitations note and the issue #13 / #10
  follow-ups). Updates to `Docs/BashInterpreter.md`, `README.md`,
  `AGENTS.md`.

* CI: SwiftBash's `build-windows` job inherited the `vcpkg install
  zlib` step from upstream but missed the alias hop the SwiftPorts
  workflow uses. vcpkg's `x64-windows-static-md` triplet ships zlib
  as `zs.lib`, but `CZlib`'s modulemap declares `link "z"` (mirroring
  libarchive's `linkedLibrary("z")`), so lld-link asks for `z.lib`.
  Port the alias step (`Copy-Item zs.lib z.lib`) into
  `.github/workflows/swift.yml` so the link resolves, then drop the
  unnecessary `bashSwiftScriptPlatforms` gate + `#if canImport(
  SwiftScriptInterpreter)` guards that were a defensive guess based
  on the (truncated) original lld-link error.

Cross-platform: macOS native (1760 tests pass), iOS via
`xcodebuild -destination 'generic/platform=iOS' build`, Linux via
`swift:6.2-jammy` Docker. CI handles the full matrix.

Suite: 1726 → 1760 tests.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
odrobnik added a commit that referenced this pull request May 9, 2026
* Gate every Foundation IO door behind the host sandbox

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>

* Gate FileWrapper.matchesContents(of:) URL before disk access

`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>

* Gate Process bridge for non-iOS platforms

`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>

* Skip FileWrapper test on non-Darwin platforms

`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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant