Die ersten echten Werkzeuge: Dateisystem und Shell

Die ersten echten Werkzeuge: Dateisystem und Shell

Artikel 5 · Serie: Ein lokaler Coding-Agent mit apfel

In Artikel 4 stand der Round-Trip, aber das einzige Werkzeug war get_time — harmlos, ohne Argumente, ohne Seiteneffekte. Jetzt bekommt der Agent Werkzeuge, mit denen er etwas außerhalb seiner selbst tut: read_file, list_dir, write_file und run_shell. Genau hier wird Sicherheit zum Thema. Ein Modell, das Dateien schreiben und Shell-Kommandos ausführen darf, braucht Grenzen — und zwar bevor es etwas anrichtet. Wir bauen deshalb zuerst den Sicherheitskern: eine Pfad-Sandbox, ein Bestätigungs-Gate, einen Diff vor jedem Schreiben und für die Shell eine gestaffelte Absicherung nach dem Vorbild etablierter Coding-Agenten. Die Werkzeuge selbst sind dann nur weitere Tool-Implementierungen in der Registry aus Artikel 4. Der Stand ist eingefroren als Tag v0.5: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.5

Die Pfad-Sandbox: Kanonisierung statt Prefix-Check

Das erste, was die lesenden Werkzeuge brauchen, ist eine Grenze: Der Agent soll nur im Arbeitsverzeichnis lesen, nicht im gesamten Dateisystem. Die naheliegende Lösung — prüfen, ob der angefragte Pfad mit dem Wurzelverzeichnis beginnt — ist unsicher. Sie lässt ..-Ketten, absolute Pfade und Symlinks durch, die aus dem Sandkasten herausführen.

Die sichere Variante löst jeden Pfad erst kanonisch auf und vergleicht dann:

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

Drei Details tragen die Sicherheit. Erstens wird der Root und das Ziel mit demselben Verfahren aufgelöst (resolvingSymlinksInPath folgt Symlinks, standardizedFileURL kollabiert .. und .). Das ist nicht kosmetisch: Auf macOS ist /tmp ein Symlink auf /private/tmp. Würden wir den Root als rohen String behalten und das Ziel auflösen, stimmten die Pfade nie überein. Zweitens wird der absolute Fall (/etc/passwd) explizit behandelt, damit er garantiert in dieselbe Prüfkette läuft und abgelehnt wird, statt durchzurutschen. Drittens ist der Vergleich pfadkomponenten-sauber: Das angehängte / in hasPrefix(rootPath + "/") verhindert, dass ein Geschwister-Verzeichnis wie …-evil fälschlich als „innerhalb" gilt.

Der wichtigste Test ist ein Angriff, der scheitern muss — ein echter Symlink, der aus dem Root zeigt:

@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 löst escape real auf /etc auf, escape/passwd wird zu /etc/passwd — außerhalb des Roots, abgelehnt. Dass dieses Verhalten auf macOS 26.3 verlässlich greift, war vorab nicht aus der Doku sicher; der grüne Test ist die Verifikation.

Lesen in Grenzen

read_file und list_dir brauchen nur die Sandbox. Sie sind weitere Tool-Implementierungen, dekodieren ihr Pfad-Argument selbst und geben Fehler als Ergebnis zurück, nicht als Absturz:

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)"
        }
    }
}

Ein Ausbruchsversuch des Modells (read_file mit ../../etc/passwd) endet nicht im Crash, sondern als Fehlertext, den das Modell im nächsten Schritt sieht. Das ist dasselbe Prinzip wie bei den Tool-Fehlern aus Artikel 4: Der Agent läuft weiter.

Warum Schreiben und Ausführen ein Gate brauchen

Lesen in einer Sandbox ist verkraftbar — im schlimmsten Fall liest der Agent eine Datei, die er nicht sollte, und das fällt im Diff oder in der Antwort auf. Schreiben und Ausführen sind eine andere Kategorie: Sie verändern den Zustand, teils unwiderruflich. Hier reicht eine Sandbox nicht. Wir ziehen eine zweite Linie ein — ein Bestätigungs-Gate, das ein Mensch passiert, bevor etwas geschrieben oder ausgeführt wird.

Das Gate hat bewusst drei Ausgänge, nicht zwei:

public enum Decision: Sendable, Equatable {
    case allowOnce          // dieses eine Mal
    case allowForSession    // und für den Rest der Sitzung merken
    case deny
}

public protocol ConfirmationGate: Sendable {
    func confirm(_ action: PendingAction) async -> Decision
}

allowForSession ist der Unterschied zwischen einem benutzbaren und einem nervtötenden Agenten: Ohne diese Option fragt das Werkzeug bei jedem swift test erneut. Das Protokoll ist injizierbar — im CLI ein interaktiver Terminal-Prompt, in den Tests ein Test-Double mit fester Entscheidung.

write_file mit Diff vor dem Schreiben

Damit der Mensch am Gate eine informierte Entscheidung trifft, zeigt write_file vor dem Schreiben, was sich ändert. Den Diff bauen wir aus dem alten und neuen Inhalt — über CollectionDifference aus der Standardbibliothek:

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")
    }
}

Das Werkzeug selbst geht durch Sandbox und Gate. Es löst den Pfad auf, liest den bisherigen Inhalt (falls vorhanden), erzeugt den Diff und legt ihn dem Gate vor. Erst bei Zustimmung wird geschrieben:

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)."
}

Lehnt der Mensch ab, bleibt die Datei unberührt und das Werkzeug meldet das als Ergebnis. Ein Test hält beides fest: dass das Gate eine write-Aktion mit dem Diff sieht, und dass eine abgelehnte Aktion keine Datei entstehen lässt.

run_shell und die drei Schichten

run_shell ist die heikelste Operation. Eine Shell kann alles, was der Benutzer kann. Eine einzelne Ja/Nein-Frage reicht hier nicht — wir bauen Defense-in-Depth nach dem Vorbild etablierter Coding-Agenten wie Claude Code: eine Denylist, eine Session-Allowlist und das Gate, in dieser Reihenfolge.

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 — hart ablehnen, erreicht das Gate nie.
        if let rule = denylist.first(where: { $0.matches(command) }) {
            return .denied(reason: rule.reason)
        }
        // 2. Session-Allowlist — vorher erlaubt, ohne erneute Frage.
        if allowed.contains(command) {
            return .allowed
        }
        // 3. Gate — den Menschen fragen.
        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
        }
    }
}

Die Denylist fängt offensichtlich destruktive Muster ab, bevor überhaupt gefragt wird:

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, weitere Pipe-Varianten
]

Hier ist Ehrlichkeit wichtiger als ein Sicherheitsversprechen: Die Denylist ist kein Schutzwall. Sie ist ein Substring-Match, case-sensitive, und von jedem, der es darauf anlegt, trivial zu umgehen. Sie soll grobe Fehlgriffe abfangen, nicht einen Angreifer aufhalten. Ebenso hat run_shell keine echte Filesystem-Sandbox: Das Arbeitsverzeichnis ist nur der Startpunkt, eine Shell kann cd .. oder absolute Pfade nutzen. Die eigentliche Sicherung ist und bleibt der Mensch am Gate. Genau diese Grenze ist der Grund, warum die gestaffelte Absicherung überhaupt nötig ist.

Das Werkzeug führt das Kommando erst nach .allowed aus, im Arbeitsverzeichnis, und gibt stdout, stderr und Exit-Code als Ergebnis zurück:

let outData = stdout.fileHandleForReading.readDataToEndOfFile()
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()

Ein Detail, das leicht zum Aufhänger wird: Wir lesen stdout und stderr vor waitUntilExit(). Andersherum kann bei größerer Ausgabe der Pipe-Puffer volllaufen — der Prozess blockiert beim Schreiben, während wir auf sein Ende warten, und nichts geht mehr.

Fehler als Result statt Absturz

Durch alle vier Werkzeuge zieht sich dasselbe Prinzip: Erwartbare Fehler werden zu Ergebnistext, nicht zu Abstürzen. Sandbox-Verletzung, fehlende Datei, denylisted Kommando, abgelehnte Aktion, Exit-Code ungleich null — alles geht als Tool-Result zurück. Der Agent-Loop bleibt am Leben und das Modell bekommt die Information, statt dass die Sitzung abbricht. Nur ein wirklich unerwarteter Fall (etwa kaputtes Argument-JSON) darf werfen — und das fängt der ToolRoundTrip aus Artikel 4.

Die Werkzeuge in der Registry

Im CLI kommt alles zusammen. Ein PathSandbox und ein TerminalGate werden geteilt; daraus entstehen die Werkzeuge, die alle in derselben Registry aus Artikel 4 liegen:

let sandbox = try PathSandbox(root: root)              // aus --workdir oder 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),
])

Das ist die Auszahlung der Abstraktion aus Artikel 4: Die neuen Werkzeuge sind nur weitere Tool-Implementierungen, die Sicherheit ist Konstruktor-Verdrahtung. Der TerminalGate druckt den Diff oder das Kommando auf stderr und liest y/n/a — und EOF wird zu deny, der sicheren Default-Richtung.

Ein echter Lauf gegen das Modell, gegen ein Arbeitsverzeichnis mit einer Datei:

$ 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

Das Modell ruft list_dir mit korrektem Argument, bekommt den Verzeichnisinhalt aus dem Workdir und formuliert die Antwort. Der Round-Trip steht — diesmal mit einem Werkzeug, das die echte Welt liest.

Demo-Repo: apfel-coding-agent v0.5

Der Stand dieses Artikels ist eingefroren als Tag v0.5: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.5

Demo-Repo apfel-coding-agent v0.5 einrichten

Klonen (falls noch nicht geschehen) und auf den Tag einsteigen:

git clone https://codeberg.org/rotecodefraktion/apfel-coding-agent.git
cd apfel-coding-agent
git checkout v0.5

Neu in v0.5 gegenüber v0.4:

  • Sources/AgentCore/Safety/PathSandbox, ConfirmationGate, CommandPolicy, Diff
  • Sources/AgentCore/Tools/FileTools (read/list/write), ShellTool (run_shell)
  • Sources/apfel-agent/TerminalGate.swift — das interaktive y/n/a-Gate, --workdir
  • docs/adr/003-sandbox-und-gates.md — Sandbox, Gates, Defense-in-Depth mit Grenzen
  • scripts/smoke-tools.sh — End-to-End-Test der Werkzeuge

Bauen, testen, laufen lassen:

swift build
swift test                        # offline, kein apfel nötig
swift run apfel-agent --tools --workdir /tmp/work "List the files. Use list_dir."

Die Unit-Tests laufen ohne apfel — der gesamte Sicherheitskern ist offline prüfbar. Der End-to-End-Test braucht einen laufenden apfel-Serve:

./scripts/smoke-tools.sh

Die adversarialen Sandbox-Tests sind so geschrieben, dass jeder Ausbruchsweg (.., absolut, Symlink) fehlschlagen muss. Zeigt der letzte Output SMOKE OK, läuft alles.

Stolpersteine aus dem Bau

Kanonisierung muss auf beiden Seiten passieren. Den Root roh lassen und nur das Ziel auflösen funktioniert nicht — /tmp vs. /private/tmp allein bricht den Vergleich. Beide Pfade durch resolvingSymlinksInPath().standardizedFileURL.

Der Prefix-Vergleich braucht den Schrägstrich. hasPrefix(root) ohne angehängtes / lässt …-evil als „innen" durchgehen. Klein, aber sicherheitsrelevant.

Pipes vor waitUntilExit leeren. stdout/stderr erst nach dem Prozessende zu lesen, kann bei größerer Ausgabe in einen Deadlock laufen. Erst lesen, dann warten.

EOF am Gate ist deny. Liest der TerminalGate kein y/n/a (etwa weil stdin geschlossen ist), entscheidet er auf Ablehnung — nicht auf stilles Durchwinken. Die sichere Richtung als Default.

Wie es weitergeht

Der Agent kann jetzt lesen, schreiben und ausführen — abgesichert. Was er damit tatsächlich zustande bringt, ist eine andere Frage. Artikel 6 ist das erste kritische Interludium der Reihe: ein systematisches Eval, das misst, wo ein lokales 3-Milliarden-Parameter-Modell beim Coding trägt und wo es scheitert. Kein neues Feature, sondern eine belegte Einordnung — mit reproduzierbaren Aufgaben statt Behauptungen. Die Werkzeuge aus diesem Artikel sind dafür die Voraussetzung: Erst wenn der Agent etwas verändern darf, lässt sich messen, ob er es richtig tut.


Vorheriger Artikel: Tool-Calling verstehen: vom Schema zum Round-Trip. Nächster Artikel: Was ein 3-B-Modell beim Coding nicht leistet (Platzhalter, Link wird mit Publish von Artikel 6 final). Repo-Tag: v0.5.