Replacing Bash with Swift in an AI Harness
A few weeks ago I read An Interpreter for Swift, from Cocoanetics, and I had that nice feeling of somebody else having already articulated the thing that was vaguely floating in your head.
Let me just say it outright: I get very sad every time somebody insists that TypeScript is the future of agentic coding. So I kept sending my wish to the universe that Swift should be also in the race for that. Now – finally – I was able to manifest the missing piece: an interpreter for the language I love. - Cocoanetics
That resounded inside me as I’ve always thought that Swift was the ideal language for this. And if I am already building a tiny Swift AI harness to learn how these systems work, then this felt like the perfect place to test the idea. So I immediately knew I had to try using this Swift interpreter in the harness. Even if the project is still tiny and mostly for learning, I thought it could still be useful.
The old Bash
In the previous post I had already replaced the little file toolbox with bash, mostly to see what happened if I stopped pretending those file tools were anything other than wrappers around shell commands.
So we were already in a good position to perform the next part of the experiment and answer, what if the generic tool was not bash, but Swift?
Instead of letting the model write shell, let it write whatever top-level Swift it needs to accomplish the task. That sounds much nicer to me already.
Want the finished project?
The whole point of this post is that you build it yourself, and all the important ideas are already here. But if you want to support my writing, or you just want to save time, I packaged the project for you to download.
The first fork in the road
The most obvious version of the idea is very simple. Keep the harness the same. Just replace bash with a swift tool. Let the model write top-level Swift, and call out to the compiler to execute it.
That would definitely work, but running real Swift brings the whole toolchain with it. Module caches. Compiler behavior. We all know that running Swift means going through a compilation step, and that is not the fastest thing in the world, especially if we need to start a new compiler process every time. And given LLMs might roundtrip many times to accomplish a task, any gains we get here are worth it. It is the usual reality of “this is a language toolchain”, not “this is a tiny embedded runtime”.
It also means we have the security issues we had with Bash, we can still use Seatbelt of course, but maybe we can take this chance to do better.
And finally, I just wanted to use the Swift Interpreter. Despite not being an official solution, it looks very promising and useful, and like something that should have been taken more seriously over the years. I still think Swift is the best language even for scripting, but performance is not there. So in my head there have always been two options: increase the performance of the toolchain and be as fast as Jai, or accept the limits and make a faster interpreter. So what we have here now, despite not coming from the core team, lets us prove the second idea.
Embedding Swift
That is where SwiftScript becomes interesting.
SwiftScript is a tree-walking Swift interpreter that you can embed as a library. No swiftc. No shelling out to swift -e. Just parse Swift and interpret it.
Now the swift tool is not another subprocess. It can just be part of the harness itself. So we just make a new ToolDefinition that looks very similar to the bash one.
static func swift() -> Self {
ToolDefinition(
name: "swift",
description: "Run top-level Swift code directly inside the harness process, using SwiftScript as the execution engine and ShellKit for workspace confinement.",
arguments: ["code"],
run: { arguments, workspaceRoot in
let code = arguments["code"] ?? ""
let result = await SwiftScriptToolRunner.executeInProcess(
code: code,
at: workspaceRoot
)
return if result.exitCode == "ExitStatus(0)",
let stdout = result.stdout.nonEmpty {
stdout
} else {
executionResultJSON(
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode
)
}
}
)
}
Where does the interpreter run?
This was the next question I had to understand. I have experience writing interpreters and things are easy when you just need to exercise pure code. Interpreting a syntax tree that wants a mathematical computation is trivial, but things get trickier when you start talking about I/O and other features that need to interact with an “environment”.
So we have an interpreter that we can embed and it knows how to run Swift, but what is the environment around it? Where does print go? What is the current directory? How do file APIs know what to allow?
This is where ShellKit, another library from the same author, enters the picture.
SwiftScript’s interpreter defaults to using Shell.current for output and runtime context, and ShellKit lets an embedder install a custom Shell for the current async task with withCurrent { ... }.
So the actual execution path ends up looking like this:
let shell = Shell(
stdout: stdoutSink,
stderr: stderrSink,
environment: .synthetic(workingDirectory: workspaceRoot.path),
scriptName: "<swift-tool>",
sandbox: try? makeSandbox(...)
)
let interpreter = Interpreter()
let status = try await shell.withCurrent {
try await interpreter.evalScript(code, fileName: "<swift-tool>")
}
That withCurrent call is the key. It binds our synthetic shell as Shell.current for the duration of the script.
So when interpreted Swift does things that route through the ShellKit bridge, it sees:
- our stdout and stderr sinks
- our working directory
- our sandbox policy
That was one of those nice moments where the implementation becomes less magical after you read it. The interpreter is not doing anything mysterious. It is just running inside a runtime context we provide.
More Runtime, Less Terminal
This change is already feeling better than just running bash. Not because Swift is “safer” in some abstract sense, but because the execution model is narrower and more controlled.
With bash, the natural shape of the tool is “here is a shell, good luck, have fun”. With the embedded SwiftScript, the natural shape is “here is an interpreter we host, and here is the runtime context we chose to expose”. That is a very different starting point.
And of course this could be accomplished too by using some bash shell virtualizer, but that’s not why we are here today ^^.
If the one-tool experiment is supposed to give the model a general-purpose language, Swift feels like a great candidate.
Does that mean we no longer need sandboxing?
The good part is that the interpreter is not just floating there in the void. It runs inside the virtualized shell we control, and ShellKit gives that shell a sandbox too.
So when we build the shell for the swift tool, we can attach a policy like this:
return Sandbox(
documentsDirectory: workspaceRoot,
downloadsDirectory: workspaceRoot,
libraryDirectory: workspaceRoot.appendingPathComponent(".swift-script-library", isDirectory: true),
temporaryDirectory: temporaryDirectory,
homeDirectory: workspaceRoot,
authorize: { url in
guard url.isFileURL else {
guard let host = url.host, host.isEmpty == false else {
throw Sandbox.Denial(url: url, reason: "non-file URL has no host to authorize")
}
return
}
let resolved = url.standardizedFileURL.resolvingSymlinksInPath()
let path = resolved.path
if path == gitDirectory.path || path.hasPrefix(gitDirectory.path + "/") {
throw Sandbox.Denial(url: url, reason: "the Swift tool cannot access .git")
}
if isAllowed(path: path, under: workspaceRoot.path)
|| isAllowed(path: path, under: globalSkillsRoot.path)
|| isAllowed(path: path, under: temporaryDirectory.path) {
return
}
throw Sandbox.Denial(url: url, reason: "file URL is outside the Swift tool sandbox")
}
)
That is already a much nicer situation than bash.
The interpreter gets a view of the world that we shape. It can read the workspace, the user-level skills directory, a temp directory, and it can also make network requests through the bridged APIs. It cannot touch .git. It cannot just wander off into the rest of the machine through the bridged file APIs.
So due to this, my first instinct was that SwiftScript plus ShellKit might already be enough. Once the execution model moves from bash to an embedded interpreter, the whole thing already feels much more controlled. There is no arbitrary shell pipeline by default. No compiler subprocess. No “just spawn more tools and see what happens” as the natural path.
But that still does not make the interpreter a hard security boundary.
The important distinction is that ShellKit.Sandbox is an in-process policy layer. It is very useful, but is different from an OS-enforced sandbox. It is also still part of the same process and the same runtime we are controlling so it also exposes our harness and the quality of our code to security attacks.
Another approach I took, but discarded to keep things simple, was to use the same embedded interpreter and shell sandbox, but spawning it in a separate process just like we did with bash. If we do that, then we can wrap that separate process in the same Seatbelt system and get yet an extra, OS-enforced layer, of protection.
sandbox-exec -p '<seatbelt profile>' swiftagentharness swift-script-helper 'print(40 + 2)'
This reminds me of when browsers started going multi-process for isolation. Even though for this learning harness keeping it simple seems enough thanks to ShellKit and the interpreter, it’s still interesting to see how an out-of-process system would play in practice.
So what does this look like in practice?
At that point, the only interesting question left was the practical one. If I now run the harness with this swift tool, does it actually behave the way I want?
The answer was yes, at least for the kind of things that matter in this series.
The LLM can call the swift tool and just execute Swift:
<Step 1>
tool-call: TOOL_CALL {"name":"swift","arguments":{"code":"print(40 + 2)"}}
tool-success: swift -> 42
<Step 2>
Assistant: The Swift tool worked. It printed 42 from top-level Swift.
It can also use Foundation, inspect the workspace, and read files:
<Step 1>
tool-call: TOOL_CALL {"name":"swift","arguments":{"code":"import Foundation ..."}}
tool-success: swift -> cwd=/Users/me/code/swiftagentharness
top_level_count=11
top_level_sample=.agents,.build,.git,.gitignore,AGENTS.md,Package.resolved
package_lines=34
agents_lines=6
<Step 2>
Assistant: The Swift tool worked and returned the requested result.
So from the LLM point of view, this already behaves much more like a real general-purpose language tool than a terminal wrapper.
And the sandbox does kick in when the script tries to escape:
<Step 1>
tool-call: TOOL_CALL {"name":"swift","arguments":{"code":"import Foundation ... try \"hello\".write(toFile: \"../escape.txt\", atomically: true, encoding: .utf8)"}}
tool-success: swift -> {"stdout":"","stderr":"file URL is outside the Swift tool sandbox\n","exit_code":"interpreter-error"}
Which is exactly the kind of behavior I wanted. The model can read and write files, but only inside the world we decided to expose.
Of course, because this is still an interpreter library built by a single author and not some giant official runtime, there are parts of the surface that are not fully complete yet. That is fine. It still proves the point very well. The important thing is not that it supports every possible Swift API today. The important thing is that the one-tool experiment already feels real with Swift.
Where I land on it
Replacing bash with embedded Swift is not just a gimmick. It genuinely changes the shape of the harness, and makes the one-tool experiment feel much more deliberate.
And I also like that the final answer is not perfectly clean.
The experiment did not end with “Swift replaces everything and all concerns disappear.” It ended somewhere more honest. Swift is a much nicer one-tool language than bash, embedding the interpreter is the interesting version of the idea, ShellKit gives that interpreter a controlled runtime context, and the question of hard sandboxing becomes smaller, but not completely gone.
Huge kudos to @cocoanetics, because this is already an impressive foundation for exploring ideas like this in practice. Like him, I wish the Swift project had an officially supported embeddable interpreter. It’s been many WWDCs now that I expected a “SwiftCoreKit” as an alternative to JavaScriptCore.
I’m very pleased with this exploration, not because I found the final perfect tool, but because the model around tool execution, shells and interpreters is clearer in my head now. I also had fun exploring Swift interpretation and out-of-process execution. For this series, that is the whole point.