The first real tools: file system and shell
Article 5 · Series: A Local Coding Agent with apfel
In Article 4 the round-trip was in place, but the only tool was get_time — harmless, no arguments, no side effects. Now the agent gets tools that do something outside itself: read_file, list_dir, write_file and run_shell. This is exactly where security becomes the topic. A model allowed to write files and run shell commands needs limits — before it does any damage. So we build the security core first: a path sandbox, a confirmation gate, a diff before every write, and for the shell a layered defense modeled on established coding agents. The tools themselves are then just more Tool implementations in the registry from Article 4. The state is frozen as tag v0.5: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.5
The path sandbox: canonicalization, not a prefix check
The first thing the reading tools need is a boundary: the agent should read inside the working directory only, not across the whole file system. The obvious solution — check whether the requested path starts with the root directory — is unsafe. It lets .. chains, absolute paths and symlinks escape the sandbox.
The safe variant resolves every path canonically first, then compares:
public struct PathSandbox: Sendable {
public let root: URL
public init(root: URL) throws {
self.root = root.resolvingSymlinksInPath().standardizedFileURL
}
public func resolve(_ path: String) throws -> URL {
let candidate = path.hasPrefix("/")
? URL(fileURLWithPath: path)
: root.appendingPathComponent(path)
let resolved = candidate.resolvingSymlinksInPath().standardizedFileURL
let rootPath = root.path
let resolvedPath = resolved.path
guard resolvedPath == rootPath || resolvedPath.hasPrefix(rootPath + "/") else {
throw SandboxError.escapesSandbox(path)
}
return resolved
}
}
Three details carry the security. First, the root and the target are resolved with the same procedure (resolvingSymlinksInPath follows symlinks, standardizedFileURL collapses .. and .). This is not cosmetic: on macOS /tmp is a symlink to /private/tmp. Keep the root as a raw string and resolve the target, and the paths never match. Second, the absolute case (/etc/passwd) is handled explicitly so it is guaranteed to run through the same check chain and be rejected, instead of slipping past. Third, the comparison is path-component clean: the trailing / in hasPrefix(rootPath + "/") keeps a sibling directory like …-evil from counting as “inside”.
The most important test is an attack that must fail — a real symlink pointing out of the root:
@Test("A symlink escaping the root is rejected")
func symlinkEscapeRejected() throws {
let root = try makeTempRoot()
let link = root.appendingPathComponent("escape")
try FileManager.default.createSymbolicLink(
at: link, withDestinationURL: URL(fileURLWithPath: "/etc")
)
let sandbox = try PathSandbox(root: root)
#expect(throws: SandboxError.self) {
try sandbox.resolve("escape/passwd")
}
}
resolvingSymlinksInPath resolves escape to /etc, so escape/passwd becomes /etc/passwd — outside the root, rejected. Whether this behaves reliably on macOS 26.3 was not certain from the docs; the green test is the verification.
Reading within bounds
read_file and list_dir need only the sandbox. They are more Tool implementations, decode their path argument themselves and return failures as results, not as crashes:
public struct ReadFileTool: Tool {
public let name = "read_file"
let sandbox: PathSandbox
public func call(_ arguments: Data) async throws -> String {
let path = try JSONDecoder().decode(PathArg.self, from: arguments).path
do {
let url = try sandbox.resolve(path)
return try String(contentsOf: url, encoding: .utf8)
} catch let error as SandboxError {
return "Error: \(error.message)"
} catch {
return "Error: could not read \(path): \(error.localizedDescription)"
}
}
}
An escape attempt by the model (read_file with ../../etc/passwd) does not end in a crash but as error text the model sees on the next step. Same principle as the tool failures in Article 4: the agent keeps running.
Why writing and executing need a gate
Reading inside a sandbox is survivable — at worst the agent reads a file it shouldn’t, and that shows up in the diff or the answer. Writing and executing are a different category: they change state, sometimes irreversibly. A sandbox alone is not enough here. We add a second line — a confirmation gate a human passes before anything is written or executed.
The gate deliberately has three exits, not two:
public enum Decision: Sendable, Equatable {
case allowOnce // this one time
case allowForSession // and remember for the rest of the session
case deny
}
public protocol ConfirmationGate: Sendable {
func confirm(_ action: PendingAction) async -> Decision
}
allowForSession is the difference between a usable and a maddening agent: without it the tool asks again on every swift test. The protocol is injectable — an interactive terminal prompt in the CLI, a test double with a fixed decision in the tests.
write_file with a diff before writing
So the human at the gate makes an informed decision, write_file shows what changes before writing. We build the diff from the old and new content — via CollectionDifference from the standard library:
public enum Diff {
public static func lines(old: String, new: String) -> String {
let oldLines = old.isEmpty ? [] : old.components(separatedBy: "\n")
let newLines = new.isEmpty ? [] : new.components(separatedBy: "\n")
var out: [String] = []
for change in newLines.difference(from: oldLines) {
switch change {
case .remove(_, let line, _): out.append("- \(line)")
case .insert(_, let line, _): out.append("+ \(line)")
}
}
return out.joined(separator: "\n")
}
}
The tool itself goes through sandbox and gate. It resolves the path, reads the existing content (if any), builds the diff and presents it to the gate. Only on approval does it write:
let existing = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
let diff = Diff.lines(old: existing, new: arg.content)
switch await gate.confirm(.init(kind: .write(path: arg.path, diff: diff))) {
case .deny:
return "Write to \(arg.path) declined."
case .allowOnce, .allowForSession:
try arg.content.write(to: url, atomically: true, encoding: .utf8)
return "Wrote \(arg.path) (\(arg.content.utf8.count) bytes)."
}
If the human declines, the file stays untouched and the tool reports it as a result. A test pins both down: that the gate sees a write action with the diff, and that a declined action creates no file.
run_shell and the three layers
run_shell is the most dangerous operation. A shell can do anything the user can. A single yes/no question is not enough here — we build defense-in-depth modeled on established coding agents like Claude Code: a denylist, a session allowlist, and the gate, in that order.
public actor CommandPolicy {
private let denylist: [DenyRule]
private let gate: any ConfirmationGate
private var allowed: Set<String> = []
public func authorize(_ command: String) async -> Authorization {
// 1. Denylist — hard reject, never reaches the gate.
if let rule = denylist.first(where: { $0.matches(command) }) {
return .denied(reason: rule.reason)
}
// 2. Session allowlist — approved earlier, no repeat question.
if allowed.contains(command) {
return .allowed
}
// 3. Gate — ask the human.
switch await gate.confirm(.init(kind: .shell(command: command))) {
case .deny: return .denied(reason: "declined by user")
case .allowOnce: return .allowed
case .allowForSession: allowed.insert(command); return .allowed
}
}
}
The denylist catches obviously destructive patterns before anything is asked:
public static let defaultDenylist: [DenyRule] = [
DenyRule(pattern: "rm -rf", reason: "recursive force delete"),
DenyRule(pattern: "sudo ", reason: "privilege escalation"),
DenyRule(pattern: ":(){", reason: "fork bomb"),
DenyRule(pattern: "| sh", reason: "pipe to shell"),
DenyRule(pattern: "> /dev/", reason: "write to device"),
// … dd, mkfs, more pipe variants
]
Honesty matters more here than a security promise: the denylist is not a wall. It is a substring match, case-sensitive, and trivially bypassed by anyone who tries. It is meant to catch gross mistakes, not to stop an attacker. Likewise run_shell has no real file-system sandbox: the working directory is only the starting point — a shell can cd .. or use absolute paths. The actual safeguard is and remains the human at the gate. That very limit is why the layered defense is needed at all.
The tool runs the command only after .allowed, in the working directory, and returns stdout, stderr and the exit code as the result:
let outData = stdout.fileHandleForReading.readDataToEndOfFile()
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
One detail that easily becomes the headline: we read stdout and stderr before waitUntilExit(). The other way around, a larger output can fill the pipe buffer — the process blocks on writing while we wait for it to finish, and everything stalls.
Errors as results, not crashes
The same principle runs through all four tools: expected failures become result text, not crashes. Sandbox violation, missing file, denylisted command, declined action, non-zero exit code — all go back as tool results. The agent loop stays alive and the model gets the information, instead of the session aborting. Only a truly unexpected case (malformed argument JSON, say) may throw — and that is caught by the ToolRoundTrip from Article 4.
The tools in the registry
In the CLI everything comes together. One PathSandbox and one TerminalGate are shared; from them the tools are built, all living in the same registry from Article 4:
let sandbox = try PathSandbox(root: root) // from --workdir or cwd
let gate = TerminalGate()
let policy = CommandPolicy(gate: gate)
let registry = ToolRegistry([
GetTimeTool(),
ReadFileTool(sandbox: sandbox),
ListDirTool(sandbox: sandbox),
WriteFileTool(sandbox: sandbox, gate: gate),
RunShellTool(workdir: sandbox.root, policy: policy),
])
This is the payoff of the abstraction from Article 4: the new tools are just more Tool implementations, the security is constructor wiring. The TerminalGate prints the diff or command to stderr and reads y/n/a — and EOF becomes deny, the safe default direction.
An actual run against the model, against a working directory with one file:
$ swift run apfel-agent --tools --workdir /tmp/work "List the files here. Use list_dir."
→ tool call: list_dir({"path": "."})
Here are the files in the current directory: example.txt
The model calls list_dir with the correct argument, gets the directory contents from the workdir and formulates the answer. The round-trip stands — this time with a tool that reads the real world.
Demo repo: apfel-coding-agent v0.5
The state of this article is frozen as tag v0.5: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.5
Setting up the demo repo apfel-coding-agent v0.5
Clone (if you haven’t already) and check out the tag:
git clone https://codeberg.org/rotecodefraktion/apfel-coding-agent.git
cd apfel-coding-agent
git checkout v0.5
New in v0.5 over v0.4:
Sources/AgentCore/Safety/—PathSandbox,ConfirmationGate,CommandPolicy,DiffSources/AgentCore/Tools/—FileTools(read/list/write),ShellTool(run_shell)Sources/apfel-agent/TerminalGate.swift— the interactive y/n/a gate,--workdirdocs/adr/003-sandbox-und-gates.md— sandbox, gates, defense-in-depth with limitsscripts/smoke-tools.sh— end-to-end test of the tools
Build, test, run:
swift build
swift test # offline, no apfel needed
swift run apfel-agent --tools --workdir /tmp/work "List the files. Use list_dir."
The unit tests run without apfel — the whole security core is verifiable offline. The end-to-end test needs a running apfel serve:
./scripts/smoke-tools.sh
The adversarial sandbox tests are written so that every escape route (.., absolute, symlink) must fail. If the last output reads SMOKE OK, everything works.
Pitfalls from the build
Canonicalization must happen on both sides. Leaving the root raw and resolving only the target does not work — /tmp vs. /private/tmp alone breaks the comparison. Both paths through resolvingSymlinksInPath().standardizedFileURL.
The prefix comparison needs the slash. hasPrefix(root) without a trailing / lets …-evil pass as “inside”. Small, but security-relevant.
Drain the pipes before waitUntilExit. Reading stdout/stderr only after the process ends can deadlock on larger output. Read first, then wait.
EOF at the gate is deny. If the TerminalGate reads no y/n/a (because stdin is closed, say), it decides to refuse — not to wave through silently. The safe direction as the default.
What comes next
The agent can now read, write and execute — under guard. What it actually achieves with that is another question. Article 6 is the series’ first critical interlude: a systematic eval that measures where a local 3-billion-parameter model holds up at coding and where it fails. Not a new feature but an evidenced assessment — with reproducible tasks instead of claims. The tools from this article are the prerequisite: only once the agent may change something can we measure whether it does so correctly.
Previous article: Understanding tool calling: from schema to round-trip. Next article: What a 3B model cannot do at coding (placeholder, link finalized when Article 6 is published). Repo tag: v0.5.