phase3.C: ExecutionService actor, full PolicyEngine rule engine, bash/osascript/file tools, ToolRegistry, WorkspaceManager, --verify-policy self-check (38/38)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 13 changed files with +3,303 and −29
modified
Sources/ZyquoAgent/App/AgentCLI.swift
+8 −3
@@ -5,15 +5,20 @@ | ||
| 5 | 5 | // Author: Simon-Pierre Boucher |
| 6 | 6 | // Mail: contact@spboucher.ai |
| 7 | 7 | // |
| 8 | −// Headless command-line modes: `--run "<task>"` (agent loop POC, Phase 3) | |
| 9 | −// and `--verify` (provider tool-calling harness, Phase 7). Fleshed out in | |
| 10 | −// their respective phases; Phase 1 ships the scaffold so `Main` compiles. | |
| 8 | +// Headless command-line modes: `--run "<task>"` (agent loop POC, Phase 3), | |
| 9 | +// `--verify` (provider tool-calling harness, Phase 7), and | |
| 10 | +// `--verify-policy` (PolicyEngine safety self-check, Phase 3.C — asserts | |
| 11 | +// the circuit-breaker guarantees and prints PASS/FAIL per case). | |
| 11 | 12 | // |
| 12 | 13 | |
| 13 | 14 | import Foundation |
| 14 | 15 | |
| 15 | 16 | enum AgentCLI { |
| 16 | 17 | static func run(arguments: [String]) async -> Int32 { |
| 18 | + if arguments.contains("--verify-policy") { | |
| 19 | + let allPassed = await PolicyEngineSelfCheck.run() | |
| 20 | + return allPassed ? 0 : 1 | |
| 21 | + } | |
| 17 | 22 | FileHandle.standardError.write(Data("Zyquo Agent CLI: engine not built yet (arrives in Phase 3/7). Arguments: \(arguments.dropFirst().joined(separator: " "))\n".utf8)) |
| 18 | 23 | return 64 |
| 19 | 24 | } |
modified
Sources/ZyquoAgent/App/Main.swift
+2 −1
@@ -7,6 +7,7 @@ | ||
| 7 | 7 | // |
| 8 | 8 | // Entry point. `--run "<task>"` executes a headless agent task (Phase 3 CLI |
| 9 | 9 | // POC), `--verify` runs the provider tool-calling harness (Phase 7), |
| 10 | +// `--verify-policy` runs the PolicyEngine safety self-check (Phase 3.C), | |
| 10 | 11 | // `--load-vault` seeds the encrypted vault from environment keys; otherwise |
| 11 | 12 | // the SwiftUI app launches. |
| 12 | 13 | // |
@@ -21,7 +22,7 @@ import Foundation | ||
| 21 | 22 | enum Main { |
| 22 | 23 | static func main() { |
| 23 | 24 | let arguments = CommandLine.arguments |
| 24 | − if arguments.contains("--run") || arguments.contains("--verify") { | |
| 25 | + if arguments.contains("--run") || arguments.contains("--verify") || arguments.contains("--verify-policy") { | |
| 25 | 26 | Task.detached { |
| 26 | 27 | let status = await AgentCLI.run(arguments: arguments) |
| 27 | 28 | exit(status) |
added
Sources/ZyquoAgent/Execution/ExecutionService.swift
+445 −0
@@ -0,0 +1,445 @@ | ||
| 1 | +// | |
| 2 | +// ExecutionService.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The single place Zyquo Agent spawns processes. Runs `/bin/bash -lc` and | |
| 9 | +// `/usr/bin/osascript` with the task workspace as cwd, streams stdout/stderr | |
| 10 | +// line by line to the UI as they arrive, enforces per-command timeouts | |
| 11 | +// (SIGTERM, then SIGKILL after a grace period), honors Swift Task | |
| 12 | +// cancellation, and caps captured output while the live stream still sees | |
| 13 | +// everything. Every caller reaches this actor only *after* the PolicyEngine | |
| 14 | +// has cleared the action — ExecutionService itself performs no policy checks | |
| 15 | +// and must never be invoked with an uncleared payload. | |
| 16 | +// | |
| 17 | + | |
| 18 | +import Foundation | |
| 19 | + | |
| 20 | +/// Tunables for process execution; a task can override the timeout per call. | |
| 21 | +struct ExecutionConfiguration: Sendable { | |
| 22 | + /// Default per-command timeout when the caller does not specify one. | |
| 23 | + var defaultTimeout: TimeInterval = 120 | |
| 24 | + /// Seconds between SIGTERM and SIGKILL when a process must die. | |
| 25 | + var killGracePeriod: TimeInterval = 3 | |
| 26 | + /// Maximum characters kept in the captured stdout/stderr/combined strings. | |
| 27 | + /// The streaming callback is NOT capped — the UI terminal sees everything. | |
| 28 | + var capturedOutputLimit: Int = 200_000 | |
| 29 | + | |
| 30 | + static let `default` = ExecutionConfiguration() | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Why a process stopped. Timeout and cancellation are reported distinctly | |
| 34 | +/// from a normal exit so tools can mark results accurately. | |
| 35 | +enum ExecutionTermination: Sendable, Equatable { | |
| 36 | + /// The process exited (or was signalled) on its own; carries the exit code. | |
| 37 | + case exited(Int32) | |
| 38 | + /// The per-command timeout expired and the service killed the process. | |
| 39 | + case timedOut | |
| 40 | + /// The surrounding Swift Task was cancelled and the service killed the process. | |
| 41 | + case cancelled | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// Everything a caller learns from one process run. | |
| 45 | +struct ExecutionResult: Sendable { | |
| 46 | + var termination: ExecutionTermination | |
| 47 | + /// Exit code when available (also populated for killed processes when the | |
| 48 | + /// OS reports one; nil only if the process never launched). | |
| 49 | + var exitCode: Int32? | |
| 50 | + /// Captured stdout, capped at `capturedOutputLimit`. | |
| 51 | + var stdout: String | |
| 52 | + /// Captured stderr, capped at `capturedOutputLimit`. | |
| 53 | + var stderr: String | |
| 54 | + /// stdout+stderr interleaved in arrival order, capped, with a truncation | |
| 55 | + /// notice appended when the cap was hit. | |
| 56 | + var combinedOutput: String | |
| 57 | + /// True when any captured string was truncated at the cap. | |
| 58 | + var wasTruncated: Bool | |
| 59 | + /// Wall-clock seconds from launch to termination. | |
| 60 | + var duration: TimeInterval | |
| 61 | + | |
| 62 | + var isTimeout: Bool { termination == .timedOut } | |
| 63 | + var isCancelled: Bool { termination == .cancelled } | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Thrown when the process cannot even be launched (bad executable, spawn | |
| 67 | +/// failure) — distinct from a process that ran and failed. | |
| 68 | +struct ExecutionLaunchError: Error, Sendable { | |
| 69 | + var reason: String | |
| 70 | +} | |
| 71 | + | |
| 72 | +/// Actor that owns all child-process execution. Serialization through the | |
| 73 | +/// actor is intentional for bookkeeping, but the actual waiting is fully | |
| 74 | +/// async — long commands do not block other actor work because the heavy | |
| 75 | +/// lifting happens off-actor in handlers and continuations. | |
| 76 | +actor ExecutionService { | |
| 77 | + private let configuration: ExecutionConfiguration | |
| 78 | + | |
| 79 | + init(configuration: ExecutionConfiguration = .default) { | |
| 80 | + self.configuration = configuration | |
| 81 | + } | |
| 82 | + | |
| 83 | + // MARK: - Public entry points | |
| 84 | + | |
| 85 | + /// Runs a shell command via `/bin/bash -lc "<command>"` with `cwd` as the | |
| 86 | + /// working directory. `onOutput` receives every stdout/stderr line live. | |
| 87 | + func runBash( | |
| 88 | + command: String, | |
| 89 | + cwd: URL, | |
| 90 | + timeout: TimeInterval? = nil, | |
| 91 | + onOutput: @escaping @Sendable (ToolOutputChunk) -> Void | |
| 92 | + ) async throws -> ExecutionResult { | |
| 93 | + try await runProcess( | |
| 94 | + executablePath: "/bin/bash", | |
| 95 | + arguments: ["-lc", command], | |
| 96 | + cwd: cwd, | |
| 97 | + timeout: timeout, | |
| 98 | + onOutput: onOutput | |
| 99 | + ) | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// Runs an inline AppleScript via `/usr/bin/osascript -e <line> [-e <line>…]`. | |
| 103 | + func runOSAScript( | |
| 104 | + lines: [String], | |
| 105 | + cwd: URL, | |
| 106 | + timeout: TimeInterval? = nil, | |
| 107 | + onOutput: @escaping @Sendable (ToolOutputChunk) -> Void | |
| 108 | + ) async throws -> ExecutionResult { | |
| 109 | + var arguments: [String] = [] | |
| 110 | + for line in lines { | |
| 111 | + arguments.append("-e") | |
| 112 | + arguments.append(line) | |
| 113 | + } | |
| 114 | + return try await runProcess( | |
| 115 | + executablePath: "/usr/bin/osascript", | |
| 116 | + arguments: arguments, | |
| 117 | + cwd: cwd, | |
| 118 | + timeout: timeout, | |
| 119 | + onOutput: onOutput | |
| 120 | + ) | |
| 121 | + } | |
| 122 | + | |
| 123 | + /// Runs an AppleScript file via `/usr/bin/osascript <file>` (the path used | |
| 124 | + /// for multi-line scripts, written under the workspace's `.zyquo/` dir). | |
| 125 | + func runOSAScriptFile( | |
| 126 | + scriptFile: URL, | |
| 127 | + cwd: URL, | |
| 128 | + timeout: TimeInterval? = nil, | |
| 129 | + onOutput: @escaping @Sendable (ToolOutputChunk) -> Void | |
| 130 | + ) async throws -> ExecutionResult { | |
| 131 | + try await runProcess( | |
| 132 | + executablePath: "/usr/bin/osascript", | |
| 133 | + arguments: [scriptFile.path], | |
| 134 | + cwd: cwd, | |
| 135 | + timeout: timeout, | |
| 136 | + onOutput: onOutput | |
| 137 | + ) | |
| 138 | + } | |
| 139 | + | |
| 140 | + /// General process runner used by the specialized entry points above. | |
| 141 | + /// Environment: inherits the user's environment, sets `PWD` to the cwd and | |
| 142 | + /// adds `ZYQUO_AGENT=1` so scripts can detect they run under the agent. | |
| 143 | + func runProcess( | |
| 144 | + executablePath: String, | |
| 145 | + arguments: [String], | |
| 146 | + cwd: URL, | |
| 147 | + timeout: TimeInterval? = nil, | |
| 148 | + onOutput: @escaping @Sendable (ToolOutputChunk) -> Void | |
| 149 | + ) async throws -> ExecutionResult { | |
| 150 | + // A cancelled task must not spawn anything. | |
| 151 | + if Task.isCancelled { | |
| 152 | + return ExecutionResult( | |
| 153 | + termination: .cancelled, exitCode: nil, | |
| 154 | + stdout: "", stderr: "", combinedOutput: "", | |
| 155 | + wasTruncated: false, duration: 0 | |
| 156 | + ) | |
| 157 | + } | |
| 158 | + | |
| 159 | + let supervisor = ProcessSupervisor( | |
| 160 | + executablePath: executablePath, | |
| 161 | + arguments: arguments, | |
| 162 | + cwd: cwd, | |
| 163 | + outputLimit: configuration.capturedOutputLimit, | |
| 164 | + killGracePeriod: configuration.killGracePeriod, | |
| 165 | + onOutput: onOutput | |
| 166 | + ) | |
| 167 | + let effectiveTimeout = timeout ?? configuration.defaultTimeout | |
| 168 | + return try await supervisor.run(timeout: effectiveTimeout) | |
| 169 | + } | |
| 170 | +} | |
| 171 | + | |
| 172 | +// MARK: - ProcessSupervisor | |
| 173 | + | |
| 174 | +/// Owns exactly one child process for its lifetime: launch, line-streaming, | |
| 175 | +/// timeout escalation (SIGTERM → SIGKILL), cancellation, and reaping. All | |
| 176 | +/// mutable state is behind `lock`, so the class is safe to touch from the | |
| 177 | +/// pipe-reader queues, the termination handler, the timeout task, and the | |
| 178 | +/// cancellation handler concurrently. | |
| 179 | +private final class ProcessSupervisor: @unchecked Sendable { | |
| 180 | + private let process = Process() | |
| 181 | + private let stdoutPipe = Pipe() | |
| 182 | + private let stderrPipe = Pipe() | |
| 183 | + private let onOutput: @Sendable (ToolOutputChunk) -> Void | |
| 184 | + private let outputLimit: Int | |
| 185 | + private let killGracePeriod: TimeInterval | |
| 186 | + | |
| 187 | + /// Serial queue that funnels both pipes' data so combined output keeps the | |
| 188 | + /// best-possible interleave order and capture buffers mutate safely. | |
| 189 | + private let outputQueue = DispatchQueue(label: "com.zyquo.agent.execution.output") | |
| 190 | + /// Signalled (once per stream) when a pipe reaches EOF. | |
| 191 | + private let drainGroup = DispatchGroup() | |
| 192 | + | |
| 193 | + private let lock = NSLock() | |
| 194 | + // Guarded by `lock`: | |
| 195 | + private var overrideTermination: ExecutionTermination? | |
| 196 | + private var continuation: CheckedContinuation<Void, Never>? | |
| 197 | + private var terminated = false | |
| 198 | + | |
| 199 | + // Guarded by `outputQueue`: | |
| 200 | + private var stdoutCapture = "" | |
| 201 | + private var stderrCapture = "" | |
| 202 | + private var combinedCapture = "" | |
| 203 | + private var truncated = false | |
| 204 | + private var stdoutLineBuffer = "" | |
| 205 | + private var stderrLineBuffer = "" | |
| 206 | + | |
| 207 | + init( | |
| 208 | + executablePath: String, | |
| 209 | + arguments: [String], | |
| 210 | + cwd: URL, | |
| 211 | + outputLimit: Int, | |
| 212 | + killGracePeriod: TimeInterval, | |
| 213 | + onOutput: @escaping @Sendable (ToolOutputChunk) -> Void | |
| 214 | + ) { | |
| 215 | + self.onOutput = onOutput | |
| 216 | + self.outputLimit = outputLimit | |
| 217 | + self.killGracePeriod = killGracePeriod | |
| 218 | + | |
| 219 | + process.executableURL = URL(fileURLWithPath: executablePath) | |
| 220 | + process.arguments = arguments | |
| 221 | + process.currentDirectoryURL = cwd | |
| 222 | + | |
| 223 | + var environment = ProcessInfo.processInfo.environment | |
| 224 | + environment["PWD"] = cwd.path | |
| 225 | + environment["ZYQUO_AGENT"] = "1" | |
| 226 | + process.environment = environment | |
| 227 | + | |
| 228 | + process.standardOutput = stdoutPipe | |
| 229 | + process.standardError = stderrPipe | |
| 230 | + process.standardInput = FileHandle.nullDevice | |
| 231 | + } | |
| 232 | + | |
| 233 | + /// Launches the process and suspends until it terminates, times out, or | |
| 234 | + /// the surrounding Task is cancelled. Never throws after a successful | |
| 235 | + /// launch — abnormal endings are reported in `ExecutionResult.termination`. | |
| 236 | + func run(timeout: TimeInterval) async throws -> ExecutionResult { | |
| 237 | + installReader(for: stdoutPipe.fileHandleForReading, isStdout: true) | |
| 238 | + installReader(for: stderrPipe.fileHandleForReading, isStdout: false) | |
| 239 | + | |
| 240 | + let start = Date() | |
| 241 | + process.terminationHandler = { [weak self] _ in | |
| 242 | + self?.finishWaiting() | |
| 243 | + } | |
| 244 | + | |
| 245 | + do { | |
| 246 | + try process.run() | |
| 247 | + } catch { | |
| 248 | + // Detach the readers we installed; the pipes never produced data. | |
| 249 | + stdoutPipe.fileHandleForReading.readabilityHandler = nil | |
| 250 | + stderrPipe.fileHandleForReading.readabilityHandler = nil | |
| 251 | + drainGroup.leave() | |
| 252 | + drainGroup.leave() | |
| 253 | + throw ExecutionLaunchError(reason: "Could not launch \(process.executableURL?.path ?? "?"): \(error.localizedDescription)") | |
| 254 | + } | |
| 255 | + | |
| 256 | + // Timeout watchdog: mark the run as timed out, then escalate | |
| 257 | + // SIGTERM → (grace) → SIGKILL. The termination handler is the single | |
| 258 | + // point that resumes the continuation, so there is no double-resume. | |
| 259 | + let watchdog = Task { [weak self] in | |
| 260 | + try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) | |
| 261 | + guard let self, !Task.isCancelled else { return } | |
| 262 | + self.kill(as: .timedOut) | |
| 263 | + } | |
| 264 | + | |
| 265 | + await withTaskCancellationHandler { | |
| 266 | + await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 267 | + let alreadyDone: Bool | |
| 268 | + lock.lock() | |
| 269 | + if terminated { | |
| 270 | + alreadyDone = true | |
| 271 | + } else { | |
| 272 | + continuation = cont | |
| 273 | + alreadyDone = false | |
| 274 | + } | |
| 275 | + lock.unlock() | |
| 276 | + if alreadyDone { cont.resume() } | |
| 277 | + } | |
| 278 | + } onCancel: { | |
| 279 | + self.kill(as: .cancelled) | |
| 280 | + } | |
| 281 | + watchdog.cancel() | |
| 282 | + | |
| 283 | + // Wait (bounded) for the pipes to drain so trailing output is not | |
| 284 | + // lost. The bound matters: a backgrounded grandchild can keep the | |
| 285 | + // write end open forever even after bash itself has died. The wait | |
| 286 | + // happens on a Dispatch thread — never blocks the cooperative pool. | |
| 287 | + await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 288 | + DispatchQueue.global(qos: .utility).async { [drainGroup] in | |
| 289 | + _ = drainGroup.wait(timeout: .now() + 2.0) | |
| 290 | + cont.resume() | |
| 291 | + } | |
| 292 | + } | |
| 293 | + stdoutPipe.fileHandleForReading.readabilityHandler = nil | |
| 294 | + stderrPipe.fileHandleForReading.readabilityHandler = nil | |
| 295 | + | |
| 296 | + let duration = Date().timeIntervalSince(start) | |
| 297 | + | |
| 298 | + let override = recordedOverride() | |
| 299 | + | |
| 300 | + let exitCode = process.processIdentifier > 0 ? process.terminationStatus : nil | |
| 301 | + let termination = override ?? .exited(exitCode ?? -1) | |
| 302 | + | |
| 303 | + // Flush partial trailing lines and read the final buffers on the | |
| 304 | + // output queue so we see everything the readers appended. | |
| 305 | + return outputQueue.sync { | |
| 306 | + flushLineBuffersLocked() | |
| 307 | + var combined = combinedCapture | |
| 308 | + if truncated { | |
| 309 | + combined += "\n… [output truncated at \(outputLimit / 1000)KB — full stream was shown live]" | |
| 310 | + } | |
| 311 | + return ExecutionResult( | |
| 312 | + termination: termination, | |
| 313 | + exitCode: exitCode, | |
| 314 | + stdout: stdoutCapture, | |
| 315 | + stderr: stderrCapture, | |
| 316 | + combinedOutput: combined, | |
| 317 | + wasTruncated: truncated, | |
| 318 | + duration: duration | |
| 319 | + ) | |
| 320 | + } | |
| 321 | + } | |
| 322 | + | |
| 323 | + /// Lock-guarded read of the recorded termination override (sync helper — | |
| 324 | + /// NSLock must not be taken directly inside an async function). | |
| 325 | + private func recordedOverride() -> ExecutionTermination? { | |
| 326 | + lock.lock() | |
| 327 | + defer { lock.unlock() } | |
| 328 | + return overrideTermination | |
| 329 | + } | |
| 330 | + | |
| 331 | + // MARK: Killing | |
| 332 | + | |
| 333 | + /// Records why the process is being killed, sends SIGTERM, and schedules a | |
| 334 | + /// SIGKILL after the grace period. Safe to call from any thread; the first | |
| 335 | + /// recorded reason wins. | |
| 336 | + private func kill(as reason: ExecutionTermination) { | |
| 337 | + lock.lock() | |
| 338 | + if terminated { | |
| 339 | + lock.unlock() | |
| 340 | + return | |
| 341 | + } | |
| 342 | + if overrideTermination == nil { overrideTermination = reason } | |
| 343 | + lock.unlock() | |
| 344 | + | |
| 345 | + let pid = process.processIdentifier | |
| 346 | + guard pid > 0 else { return } | |
| 347 | + Foundation.kill(pid, SIGTERM) | |
| 348 | + | |
| 349 | + let grace = killGracePeriod | |
| 350 | + DispatchQueue.global().asyncAfter(deadline: .now() + grace) { [weak self] in | |
| 351 | + guard let self else { return } | |
| 352 | + self.lock.lock() | |
| 353 | + let stillRunning = !self.terminated | |
| 354 | + self.lock.unlock() | |
| 355 | + if stillRunning { | |
| 356 | + Foundation.kill(pid, SIGKILL) | |
| 357 | + } | |
| 358 | + } | |
| 359 | + } | |
| 360 | + | |
| 361 | + /// Termination handler target — marks the process reaped and resumes the | |
| 362 | + /// suspended `run(timeout:)`. | |
| 363 | + private func finishWaiting() { | |
| 364 | + lock.lock() | |
| 365 | + terminated = true | |
| 366 | + let cont = continuation | |
| 367 | + continuation = nil | |
| 368 | + lock.unlock() | |
| 369 | + cont?.resume() | |
| 370 | + } | |
| 371 | + | |
| 372 | + // MARK: Output plumbing | |
| 373 | + | |
| 374 | + /// Attaches a readability handler that forwards raw data to the serial | |
| 375 | + /// output queue where it is captured (capped) and split into lines for | |
| 376 | + /// the live stream. An empty read means EOF: detach and signal the drain. | |
| 377 | + private func installReader(for handle: FileHandle, isStdout: Bool) { | |
| 378 | + drainGroup.enter() | |
| 379 | + handle.readabilityHandler = { [weak self] fileHandle in | |
| 380 | + let data = fileHandle.availableData | |
| 381 | + guard let self else { return } | |
| 382 | + if data.isEmpty { | |
| 383 | + fileHandle.readabilityHandler = nil | |
| 384 | + self.outputQueue.async { self.drainGroup.leave() } | |
| 385 | + return | |
| 386 | + } | |
| 387 | + self.outputQueue.async { | |
| 388 | + self.consume(data: data, isStdout: isStdout) | |
| 389 | + } | |
| 390 | + } | |
| 391 | + } | |
| 392 | + | |
| 393 | + /// Runs on `outputQueue` only. Captures (with cap) and emits whole lines. | |
| 394 | + private func consume(data: Data, isStdout: Bool) { | |
| 395 | + let text = String(decoding: data, as: UTF8.self) | |
| 396 | + | |
| 397 | + // Capture with cap. The live stream below is never capped. | |
| 398 | + appendCapped(text, to: isStdout ? \.stdoutCapture : \.stderrCapture) | |
| 399 | + appendCapped(text, to: \.combinedCapture) | |
| 400 | + | |
| 401 | + // Line-split for streaming; keep the unterminated tail buffered. | |
| 402 | + if isStdout { | |
| 403 | + stdoutLineBuffer += text | |
| 404 | + emitCompleteLines(from: &stdoutLineBuffer, isStdout: true) | |
| 405 | + } else { | |
| 406 | + stderrLineBuffer += text | |
| 407 | + emitCompleteLines(from: &stderrLineBuffer, isStdout: false) | |
| 408 | + } | |
| 409 | + } | |
| 410 | + | |
| 411 | + private func appendCapped(_ text: String, to keyPath: ReferenceWritableKeyPath<ProcessSupervisor, String>) { | |
| 412 | + let current = self[keyPath: keyPath] | |
| 413 | + guard current.count < outputLimit else { | |
| 414 | + truncated = true | |
| 415 | + return | |
| 416 | + } | |
| 417 | + let remaining = outputLimit - current.count | |
| 418 | + if text.count <= remaining { | |
| 419 | + self[keyPath: keyPath] = current + text | |
| 420 | + } else { | |
| 421 | + self[keyPath: keyPath] = current + String(text.prefix(remaining)) | |
| 422 | + truncated = true | |
| 423 | + } | |
| 424 | + } | |
| 425 | + | |
| 426 | + private func emitCompleteLines(from buffer: inout String, isStdout: Bool) { | |
| 427 | + while let newlineRange = buffer.range(of: "\n") { | |
| 428 | + let line = String(buffer[buffer.startIndex..<newlineRange.lowerBound]) | |
| 429 | + buffer.removeSubrange(buffer.startIndex..<newlineRange.upperBound) | |
| 430 | + onOutput(isStdout ? .stdout(line) : .stderr(line)) | |
| 431 | + } | |
| 432 | + } | |
| 433 | + | |
| 434 | + /// Runs on `outputQueue` only. Emits any unterminated trailing output. | |
| 435 | + private func flushLineBuffersLocked() { | |
| 436 | + if !stdoutLineBuffer.isEmpty { | |
| 437 | + onOutput(.stdout(stdoutLineBuffer)) | |
| 438 | + stdoutLineBuffer = "" | |
| 439 | + } | |
| 440 | + if !stderrLineBuffer.isEmpty { | |
| 441 | + onOutput(.stderr(stderrLineBuffer)) | |
| 442 | + stderrLineBuffer = "" | |
| 443 | + } | |
| 444 | + } | |
| 445 | +} | |
modified
Sources/ZyquoAgent/Execution/PolicyEngine.swift
+1032 −22
@@ -6,13 +6,44 @@ | ||
| 6 | 6 | // Mail: contact@spboucher.ai |
| 7 | 7 | // |
| 8 | 8 | // The safety gate every action passes through — no shell command, AppleScript, |
| 9 | −// or out-of-workspace file write runs without a ruling from here. Precedence | |
| 10 | −// is deny → ask → allow (Claude-Code style): destructive patterns always ask | |
| 11 | −// regardless of mode, curated read-only commands may auto-run in Guarded and | |
| 12 | −// Autonomous modes, everything else follows the active SafetyMode. | |
| 9 | +// or out-of-workspace file access runs without a ruling from here. | |
| 13 | 10 | // |
| 14 | −// Phase 2 skeleton: types + gate plumbing. The full rule engine (compound | |
| 15 | −// command parsing, risk classifier, remembered rules) lands in Phase 3.C. | |
| 11 | +// Design (docs/AGENT-RESEARCH.md §6.3–6.4, Claude-Code-style): | |
| 12 | +// | |
| 13 | +// Precedence, first match wins, evaluated PER SUBCOMMAND: | |
| 14 | +// 1. hard deny (never runs, no approval can help) | |
| 15 | +// 2. user deny rules (user-managed denylist) | |
| 16 | +// 3. always-ask class (destructive/elevated circuit breakers — | |
| 17 | +// ask in EVERY mode, including Autonomous; | |
| 18 | +// remembered allow rules can NOT override these) | |
| 19 | +// 4. user allow rules ("Approve & remember" narrow per-subcommand rules) | |
| 20 | +// 5. curated read-only allowset | |
| 21 | +// 6. default: mutating (mode decides) | |
| 22 | +// | |
| 23 | +// Shell payloads are PARSED, never regex'd raw: split on `&&`, `||`, `;`, | |
| 24 | +// `|` and newlines into subcommands (quote-aware), command substitutions | |
| 25 | +// `$(…)`/backticks are extracted and classified too, and common wrappers | |
| 26 | +// (`env VAR=x`, `nohup`, `time`, `xargs`, `nice`, `command`) are stripped | |
| 27 | +// before classification. The overall ruling is the MOST SEVERE across all | |
| 28 | +// subcommands — `ls && rm -rf ~/x` asks because the second half asks. | |
| 29 | +// | |
| 30 | +// Mode behavior: | |
| 31 | +// manual → every gated action asks. (Deliberate exception documented | |
| 32 | +// in FileTools: pure in-workspace FileTools READS never | |
| 33 | +// reach the gate at all — asking to read the agent's own | |
| 34 | +// scratch files would make Manual mode unusable.) | |
| 35 | +// guarded → read-only allowset auto-runs; in-workspace file writes | |
| 36 | +// (.fileWrite) auto-run; everything else asks. | |
| 37 | +// autonomous → everything auto-runs EXCEPT hard-denies and the | |
| 38 | +// always-ask class, which still ask. | |
| 39 | +// | |
| 40 | +// AppleScript: manual & guarded always ask. Autonomous asks only when the | |
| 41 | +// script matches risky patterns (administrator privileges, System Events | |
| 42 | +// keystrokes, delete, do shell script, quit/restart/shutdown). | |
| 43 | +// | |
| 44 | +// Pattern-matching is UX, not a security boundary (the Cursor lesson): | |
| 45 | +// it is paired with workspace scoping, approvals, and the append-only | |
| 46 | +// AuditLog — nothing the agent does is invisible. | |
| 16 | 47 | // |
| 17 | 48 | |
| 18 | 49 | import Foundation |
@@ -43,8 +74,13 @@ struct ActionRequest: Sendable { | ||
| 43 | 74 | enum Kind: String, Codable, Sendable { |
| 44 | 75 | case shellCommand |
| 45 | 76 | case appleScript |
| 77 | + /// File write/edit whose resolved path is INSIDE the workspace. | |
| 46 | 78 | case fileWrite |
| 79 | + /// File write/edit whose resolved path escapes the workspace — | |
| 80 | + /// always asks, in every mode. | |
| 47 | 81 | case fileWriteOutsideWorkspace |
| 82 | + /// File read whose resolved path escapes the workspace — always asks. | |
| 83 | + case fileReadOutsideWorkspace | |
| 48 | 84 | } |
| 49 | 85 | var kind: Kind |
| 50 | 86 | /// The exact command / script / path the user will see verbatim. |
@@ -90,36 +126,253 @@ protocol ApprovalPresenting: Sendable { | ||
| 90 | 126 | func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution |
| 91 | 127 | } |
| 92 | 128 | |
| 129 | +// MARK: - Stored rules ("Approve & remember" + user-managed lists) | |
| 130 | + | |
| 131 | +/// One persisted rule: `kind` scopes it to a tool family ("bash" or | |
| 132 | +/// "osascript"), `pattern` is a normalized token-prefix — `"brew list"` | |
| 133 | +/// matches `brew list`, `brew list --versions`, … but NOT `brew install`. | |
| 134 | +/// Matching is per-SUBCOMMAND (after wrapper stripping), never against the | |
| 135 | +/// raw compound string, so an allow rule cannot smuggle a `&& rm -rf` along. | |
| 136 | +struct StoredPolicyRule: Codable, Hashable, Sendable { | |
| 137 | + var kind: String | |
| 138 | + var pattern: String | |
| 139 | +} | |
| 140 | + | |
| 141 | +/// The on-disk rule document (`policy-rules.json` in the app data folder). | |
| 142 | +struct StoredPolicyRules: Codable, Sendable { | |
| 143 | + var allow: [StoredPolicyRule] = [] | |
| 144 | + var deny: [StoredPolicyRule] = [] | |
| 145 | +} | |
| 146 | + | |
| 147 | +// MARK: - PolicyEngine | |
| 148 | + | |
| 93 | 149 | /// The gate. Actor: rulings and remembered rules mutate shared state. |
| 94 | 150 | actor PolicyEngine { |
| 95 | 151 | private(set) var mode: SafetyMode |
| 96 | 152 | private let approvals: ApprovalPresenting |
| 153 | + private let persistence: PersistenceService | |
| 154 | + private var storedRules: StoredPolicyRules | |
| 155 | + | |
| 156 | + private static let rulesFileName = "policy-rules.json" | |
| 97 | 157 | |
| 98 | − init(mode: SafetyMode, approvals: ApprovalPresenting) { | |
| 158 | + /// `persistence` decides where remembered rules live; tests pass a | |
| 159 | + /// temp-rooted PersistenceService so they never touch the real rule file. | |
| 160 | + init(mode: SafetyMode, approvals: ApprovalPresenting, persistence: PersistenceService = .shared) { | |
| 99 | 161 | self.mode = mode |
| 100 | 162 | self.approvals = approvals |
| 163 | + self.persistence = persistence | |
| 164 | + self.storedRules = persistence.load(StoredPolicyRules.self, from: Self.rulesFileName) ?? StoredPolicyRules() | |
| 101 | 165 | } |
| 102 | 166 | |
| 103 | 167 | func setMode(_ newMode: SafetyMode) { |
| 104 | 168 | mode = newMode |
| 105 | 169 | } |
| 106 | 170 | |
| 107 | − /// Classifies the action, asks the user when required, and returns what may | |
| 108 | − /// actually run (payload may have been edited). Throws `PolicyDenied` when | |
| 109 | − /// the action must not run. Full classifier lands in Phase 3.C — until | |
| 110 | − /// then, everything asks (fail-closed). | |
| 171 | + // MARK: Rule management (Settings › Safety) | |
| 172 | + | |
| 173 | + var rules: StoredPolicyRules { storedRules } | |
| 174 | + | |
| 175 | + func addAllowRule(_ rule: StoredPolicyRule) { | |
| 176 | + guard !storedRules.allow.contains(rule) else { return } | |
| 177 | + storedRules.allow.append(rule) | |
| 178 | + persistence.save(storedRules, to: Self.rulesFileName) | |
| 179 | + } | |
| 180 | + | |
| 181 | + func addDenyRule(_ rule: StoredPolicyRule) { | |
| 182 | + guard !storedRules.deny.contains(rule) else { return } | |
| 183 | + storedRules.deny.append(rule) | |
| 184 | + persistence.save(storedRules, to: Self.rulesFileName) | |
| 185 | + } | |
| 186 | + | |
| 187 | + func removeAllowRule(_ rule: StoredPolicyRule) { | |
| 188 | + storedRules.allow.removeAll { $0 == rule } | |
| 189 | + persistence.save(storedRules, to: Self.rulesFileName) | |
| 190 | + } | |
| 191 | + | |
| 192 | + func removeDenyRule(_ rule: StoredPolicyRule) { | |
| 193 | + storedRules.deny.removeAll { $0 == rule } | |
| 194 | + persistence.save(storedRules, to: Self.rulesFileName) | |
| 195 | + } | |
| 196 | + | |
| 197 | + // MARK: The gate | |
| 198 | + | |
| 199 | + /// Classifies the action, asks the user when required, and returns what | |
| 200 | + /// may actually run (payload may have been edited). Throws `PolicyDenied` | |
| 201 | + /// when the action must not run. | |
| 111 | 202 | func clear(_ action: ActionRequest) async throws -> ClearedAction { |
| 112 | − let risk = RiskAssessment(level: .mutating, reason: "Risk classifier not built yet (Phase 3.C) — asking for everything.") | |
| 113 | − let resolution = await approvals.requestApproval(for: action, risk: risk) | |
| 114 | − switch resolution { | |
| 115 | − case .approve: | |
| 116 | − return ClearedAction(payload: action.payload, decision: .init(ruling: .approvedByUser, riskLabel: risk.level.rawValue, rationale: risk.reason)) | |
| 117 | − case .approveAndRemember: | |
| 118 | − return ClearedAction(payload: action.payload, decision: .init(ruling: .approvedByUser, riskLabel: risk.level.rawValue, rationale: risk.reason)) | |
| 119 | − case .approveEdited(let edited): | |
| 120 | − return ClearedAction(payload: edited, decision: .init(ruling: .editedAndApproved, riskLabel: risk.level.rawValue, rationale: risk.reason)) | |
| 121 | − case .deny: | |
| 122 | − throw PolicyDenied(reason: "User denied the action.") | |
| 203 | + switch evaluate(action) { | |
| 204 | + case .deny(let reason): | |
| 205 | + throw PolicyDenied(reason: reason) | |
| 206 | + | |
| 207 | + case .allow(let reason): | |
| 208 | + return ClearedAction( | |
| 209 | + payload: action.payload, | |
| 210 | + decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: reason) | |
| 211 | + ) | |
| 212 | + | |
| 213 | + case .ask(let risk): | |
| 214 | + let resolution = await approvals.requestApproval(for: action, risk: risk) | |
| 215 | + switch resolution { | |
| 216 | + case .approve: | |
| 217 | + return ClearedAction( | |
| 218 | + payload: action.payload, | |
| 219 | + decision: PolicyDecisionRecord(ruling: .approvedByUser, riskLabel: risk.level.rawValue, rationale: risk.reason) | |
| 220 | + ) | |
| 221 | + | |
| 222 | + case .approveAndRemember: | |
| 223 | + rememberAllowRules(for: action) | |
| 224 | + return ClearedAction( | |
| 225 | + payload: action.payload, | |
| 226 | + decision: PolicyDecisionRecord(ruling: .approvedByUser, riskLabel: risk.level.rawValue, rationale: risk.reason) | |
| 227 | + ) | |
| 228 | + | |
| 229 | + case .approveEdited(let edited): | |
| 230 | + // The edited payload is re-classified: an edit can never | |
| 231 | + // sneak past the hard denylist, but an explicit user edit + | |
| 232 | + // approval covers ask-class results. | |
| 233 | + var editedAction = action | |
| 234 | + editedAction.payload = edited | |
| 235 | + if case .deny(let reason) = evaluate(editedAction) { | |
| 236 | + throw PolicyDenied(reason: "Edited command is on the hard denylist: \(reason)") | |
| 237 | + } | |
| 238 | + return ClearedAction( | |
| 239 | + payload: edited, | |
| 240 | + decision: PolicyDecisionRecord(ruling: .editedAndApproved, riskLabel: risk.level.rawValue, rationale: risk.reason) | |
| 241 | + ) | |
| 242 | + | |
| 243 | + case .deny: | |
| 244 | + throw PolicyDenied(reason: "User denied the action.") | |
| 245 | + } | |
| 246 | + } | |
| 247 | + } | |
| 248 | + | |
| 249 | + /// Pure classification — no user interaction. Exposed for the approval | |
| 250 | + /// UI (pre-labeling), tests, and the `--verify-policy` self-check. | |
| 251 | + func evaluate(_ action: ActionRequest) -> PolicyRuling { | |
| 252 | + switch action.kind { | |
| 253 | + case .shellCommand: | |
| 254 | + return evaluateShell(action) | |
| 255 | + case .appleScript: | |
| 256 | + return evaluateAppleScript(action) | |
| 257 | + case .fileWrite: | |
| 258 | + // Path already resolved INSIDE the workspace by FileTools. | |
| 259 | + switch mode { | |
| 260 | + case .manual: | |
| 261 | + return .ask(risk: RiskAssessment(level: .mutating, reason: "Writes a file inside the task workspace.")) | |
| 262 | + case .guarded, .autonomous: | |
| 263 | + return .allow(reason: "File write scoped to the task workspace.") | |
| 264 | + } | |
| 265 | + case .fileWriteOutsideWorkspace: | |
| 266 | + // Circuit breaker: escaping the workspace always asks. | |
| 267 | + return .ask(risk: RiskAssessment(level: .destructive, reason: "Writes to a file OUTSIDE the task workspace: \(action.payload)")) | |
| 268 | + case .fileReadOutsideWorkspace: | |
| 269 | + // Reads can exfiltrate (dotfiles, keys) — always ask when escaping. | |
| 270 | + return .ask(risk: RiskAssessment(level: .safe, reason: "Reads a file outside the task workspace: \(action.payload)")) | |
| 271 | + } | |
| 272 | + } | |
| 273 | + | |
| 274 | + // MARK: Shell classification | |
| 275 | + | |
| 276 | + private func evaluateShell(_ action: ActionRequest) -> PolicyRuling { | |
| 277 | + let analysis = ShellCommandAnalyzer.analyze( | |
| 278 | + command: action.payload, | |
| 279 | + workspace: action.cwd, | |
| 280 | + allowRules: storedRules.allow, | |
| 281 | + denyRules: storedRules.deny | |
| 282 | + ) | |
| 283 | + | |
| 284 | + if let denyReason = analysis.hardDenyReason { | |
| 285 | + return .deny(reason: denyReason) | |
| 286 | + } | |
| 287 | + if let breaker = analysis.alwaysAsk { | |
| 288 | + return .ask(risk: breaker) | |
| 289 | + } | |
| 290 | + | |
| 291 | + switch mode { | |
| 292 | + case .manual: | |
| 293 | + return .ask(risk: analysis.risk) | |
| 294 | + case .guarded: | |
| 295 | + if analysis.allRunnableUnprompted { | |
| 296 | + return .allow(reason: analysis.allowReason) | |
| 297 | + } | |
| 298 | + return .ask(risk: analysis.risk) | |
| 299 | + case .autonomous: | |
| 300 | + return .allow(reason: analysis.allRunnableUnprompted | |
| 301 | + ? analysis.allowReason | |
| 302 | + : "Autonomous mode — \(analysis.risk.reason)") | |
| 303 | + } | |
| 304 | + } | |
| 305 | + | |
| 306 | + // MARK: AppleScript classification | |
| 307 | + | |
| 308 | + /// AppleScript can do anything the user can, so it is treated like a | |
| 309 | + /// mutating shell command: manual & guarded always ask; autonomous asks | |
| 310 | + /// only for risky patterns found by scanning the script text. | |
| 311 | + private func evaluateAppleScript(_ action: ActionRequest) -> PolicyRuling { | |
| 312 | + let risk = Self.classifyAppleScript(action.payload) | |
| 313 | + | |
| 314 | + // Administrator privileges are elevated — ask in EVERY mode. | |
| 315 | + if risk.level == .elevated { | |
| 316 | + return .ask(risk: risk) | |
| 317 | + } | |
| 318 | + | |
| 319 | + switch mode { | |
| 320 | + case .manual, .guarded: | |
| 321 | + return .ask(risk: risk) | |
| 322 | + case .autonomous: | |
| 323 | + if risk.level == .safe || risk.level == .mutating { | |
| 324 | + return risk.level == .safe | |
| 325 | + ? .allow(reason: "AppleScript with no risky patterns (Autonomous mode).") | |
| 326 | + : .allow(reason: "Autonomous mode — \(risk.reason)") | |
| 327 | + } | |
| 328 | + return .ask(risk: risk) | |
| 329 | + } | |
| 330 | + } | |
| 331 | + | |
| 332 | + /// Text-scan risk classifier for AppleScript payloads. | |
| 333 | + static func classifyAppleScript(_ script: String) -> RiskAssessment { | |
| 334 | + let lowered = script.lowercased() | |
| 335 | + if lowered.contains("with administrator privileges") { | |
| 336 | + return RiskAssessment(level: .elevated, reason: "AppleScript requests administrator privileges.") | |
| 337 | + } | |
| 338 | + if lowered.contains("system events"), | |
| 339 | + lowered.contains("keystroke") || lowered.contains("key code") { | |
| 340 | + return RiskAssessment(level: .destructive, reason: "Sends synthetic keystrokes via System Events — can drive any app.") | |
| 341 | + } | |
| 342 | + if lowered.contains("delete ") || lowered.contains("move to trash") || lowered.contains("empty trash") { | |
| 343 | + return RiskAssessment(level: .destructive, reason: "AppleScript deletes or trashes items.") | |
| 344 | + } | |
| 345 | + if lowered.contains("do shell script") { | |
| 346 | + return RiskAssessment(level: .destructive, reason: "AppleScript runs a shell command (`do shell script`).") | |
| 347 | + } | |
| 348 | + if lowered.contains("shut down") || lowered.contains("restart") || lowered.contains("log out") { | |
| 349 | + return RiskAssessment(level: .destructive, reason: "AppleScript shuts down, restarts, or logs out the Mac.") | |
| 350 | + } | |
| 351 | + return RiskAssessment(level: .mutating, reason: "Automates a macOS application via AppleScript.") | |
| 352 | + } | |
| 353 | + | |
| 354 | + // MARK: Approve & remember | |
| 355 | + | |
| 356 | + /// Persists the NARROWEST allow rules covering the approved action: one | |
| 357 | + /// per non-read-only subcommand (command + first argument), never for | |
| 358 | + /// always-ask/destructive subcommands — circuit breakers cannot be | |
| 359 | + /// remembered away. | |
| 360 | + private func rememberAllowRules(for action: ActionRequest) { | |
| 361 | + switch action.kind { | |
| 362 | + case .shellCommand: | |
| 363 | + let analysis = ShellCommandAnalyzer.analyze( | |
| 364 | + command: action.payload, | |
| 365 | + workspace: action.cwd, | |
| 366 | + allowRules: storedRules.allow, | |
| 367 | + denyRules: storedRules.deny | |
| 368 | + ) | |
| 369 | + for pattern in analysis.rememberablePatterns.prefix(5) { | |
| 370 | + addAllowRule(StoredPolicyRule(kind: "bash", pattern: pattern)) | |
| 371 | + } | |
| 372 | + case .appleScript, .fileWrite, .fileWriteOutsideWorkspace, .fileReadOutsideWorkspace: | |
| 373 | + // No stable, narrow pattern exists for scripts or arbitrary | |
| 374 | + // paths — remembering them would be broader than the approval. | |
| 375 | + break | |
| 123 | 376 | } |
| 124 | 377 | } |
| 125 | 378 | } |
@@ -136,3 +389,760 @@ struct ClearedAction: Sendable { | ||
| 136 | 389 | struct PolicyDenied: Error, Sendable { |
| 137 | 390 | var reason: String |
| 138 | 391 | } |
| 392 | + | |
| 393 | +// MARK: - ShellCommandAnalyzer | |
| 394 | + | |
| 395 | +/// Stateless shell-payload analyzer: quote-aware splitting into subcommands, | |
| 396 | +/// wrapper stripping, per-subcommand classification, and aggregation to the | |
| 397 | +/// most severe finding. Pure functions — trivially testable. | |
| 398 | +enum ShellCommandAnalyzer { | |
| 399 | + | |
| 400 | + /// Aggregated findings for one full shell payload. | |
| 401 | + struct Analysis { | |
| 402 | + /// Non-nil when any subcommand hit the hard denylist. | |
| 403 | + var hardDenyReason: String? | |
| 404 | + /// Non-nil when any subcommand is in the always-ask class. | |
| 405 | + var alwaysAsk: RiskAssessment? | |
| 406 | + /// True when EVERY subcommand is read-only, matched a stored allow | |
| 407 | + /// rule, or is a workspace-scoped write — i.e. safe to auto-run in | |
| 408 | + /// Guarded mode. | |
| 409 | + var allRunnableUnprompted: Bool | |
| 410 | + /// Most severe risk across subcommands (drives the approval card). | |
| 411 | + var risk: RiskAssessment | |
| 412 | + /// Reason shown when auto-allowed. | |
| 413 | + var allowReason: String | |
| 414 | + /// Narrow per-subcommand patterns eligible for "Approve & remember". | |
| 415 | + var rememberablePatterns: [String] | |
| 416 | + } | |
| 417 | + | |
| 418 | + /// Per-subcommand classification, ordered by severity. | |
| 419 | + private enum Classification { | |
| 420 | + case hardDeny(String) | |
| 421 | + case alwaysAsk(RiskAssessment) | |
| 422 | + case mutating(String) | |
| 423 | + case workspaceWrite(String) | |
| 424 | + case allowedByRule(String) | |
| 425 | + case readOnly | |
| 426 | + } | |
| 427 | + | |
| 428 | + // MARK: Curated read-only allowset (§6.3: auto-allow ONLY curated reads) | |
| 429 | + | |
| 430 | + /// Commands that never mutate anything regardless of arguments (barring | |
| 431 | + /// output redirection, which is detected separately). | |
| 432 | + private static let readOnlyCommands: Set<String> = [ | |
| 433 | + "ls", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "rg", | |
| 434 | + "pwd", "echo", "printf", "which", "file", "stat", "du", "df", "date", | |
| 435 | + "whoami", "uname", "sw_vers", "hostname", "id", "uptime", "printenv", | |
| 436 | + "basename", "dirname", "realpath", "readlink", "type", "true", "false", | |
| 437 | + "test", "[", "sleep", "md5", "shasum", "cksum", "diff", "cmp", "tree", | |
| 438 | + "sort", "uniq", "cut", "tr", "column", "strings", "nl", "od", "xxd", | |
| 439 | + "man", "wc", "locale", "arch", "getconf", "sysctl", "nproc" | |
| 440 | + ] | |
| 441 | + | |
| 442 | + /// git subcommands that are read-only. | |
| 443 | + private static let readOnlyGitSubcommands: Set<String> = [ | |
| 444 | + "status", "log", "diff", "show", "shortlog", "rev-parse", "ls-files", | |
| 445 | + "ls-remote", "blame", "describe", "reflog", "remote", "config" | |
| 446 | + ] | |
| 447 | + | |
| 448 | + /// Interpreters whose bare `--version`-style invocations are read-only. | |
| 449 | + private static let versionOnlyFlags: Set<String> = ["--version", "-v", "-V", "--help", "-h"] | |
| 450 | + | |
| 451 | + /// Wrappers stripped (with their own flags/assignments) before | |
| 452 | + /// classifying — `env FOO=1 nohup time ls` classifies as `ls`. | |
| 453 | + private static let strippableWrappers: Set<String> = [ | |
| 454 | + "env", "nohup", "time", "nice", "command", "builtin", "xargs", "caffeinate", "stdbuf" | |
| 455 | + ] | |
| 456 | + | |
| 457 | + /// System path prefixes: mutating operations targeting these always ask. | |
| 458 | + private static let protectedSystemPrefixes = ["/Library/", "/usr/", "/etc/", "/bin/", "/sbin/", "/var/", "/private/etc/"] | |
| 459 | + | |
| 460 | + /// Commands that write to their path arguments (used to judge writes to | |
| 461 | + /// system paths and outside-workspace targets). | |
| 462 | + private static let pathWritingCommands: Set<String> = [ | |
| 463 | + "rm", "mv", "cp", "tee", "mkdir", "touch", "ln", "rmdir", "install", | |
| 464 | + "chmod", "chown", "chflags", "truncate", "dd", "rsync", "unzip", "tar" | |
| 465 | + ] | |
| 466 | + | |
| 467 | + // MARK: Entry point | |
| 468 | + | |
| 469 | + static func analyze( | |
| 470 | + command: String, | |
| 471 | + workspace: URL, | |
| 472 | + allowRules: [StoredPolicyRule], | |
| 473 | + denyRules: [StoredPolicyRule] | |
| 474 | + ) -> Analysis { | |
| 475 | + // Fork bombs must be caught on the WHOLE payload — the `|`/`;`/`&` | |
| 476 | + // splitting below would shred the pattern into unrecognizable bits. | |
| 477 | + if isForkBomb(command) { | |
| 478 | + return Analysis( | |
| 479 | + hardDenyReason: "Fork bomb.", | |
| 480 | + alwaysAsk: nil, | |
| 481 | + allRunnableUnprompted: false, | |
| 482 | + risk: RiskAssessment(level: .destructive, reason: "Fork bomb."), | |
| 483 | + allowReason: "", | |
| 484 | + rememberablePatterns: [] | |
| 485 | + ) | |
| 486 | + } | |
| 487 | + | |
| 488 | + let pipelines = splitIntoPipelines(command) | |
| 489 | + | |
| 490 | + var hardDeny: String? | |
| 491 | + var alwaysAsk: RiskAssessment? | |
| 492 | + var mutatingReasons: [String] = [] | |
| 493 | + var allUnprompted = true | |
| 494 | + var rememberable: [String] = [] | |
| 495 | + | |
| 496 | + for pipeline in pipelines { | |
| 497 | + // Circuit breaker checked at PIPELINE level (needs the `|` shape): | |
| 498 | + // network download piped into an interpreter. | |
| 499 | + if let pipeRisk = classifyDownloadPipe(pipeline) { | |
| 500 | + alwaysAsk = mostSevere(alwaysAsk, pipeRisk) | |
| 501 | + allUnprompted = false | |
| 502 | + } | |
| 503 | + | |
| 504 | + for rawSegment in pipeline.segments { | |
| 505 | + // Command substitutions inside the segment are classified as | |
| 506 | + // subcommands of their own (Claude Code's `$(…)` breaker). | |
| 507 | + let embedded = extractCommandSubstitutions(rawSegment) | |
| 508 | + for sub in [rawSegment] + embedded { | |
| 509 | + let tokens = stripWrappers(tokenize(sub)) | |
| 510 | + guard !tokens.isEmpty else { continue } | |
| 511 | + let classification = classify( | |
| 512 | + tokens: tokens, | |
| 513 | + rawSubcommand: sub, | |
| 514 | + workspace: workspace, | |
| 515 | + allowRules: allowRules, | |
| 516 | + denyRules: denyRules | |
| 517 | + ) | |
| 518 | + switch classification { | |
| 519 | + case .hardDeny(let reason): | |
| 520 | + hardDeny = hardDeny ?? reason | |
| 521 | + allUnprompted = false | |
| 522 | + case .alwaysAsk(let risk): | |
| 523 | + alwaysAsk = mostSevere(alwaysAsk, risk) | |
| 524 | + allUnprompted = false | |
| 525 | + case .mutating(let reason): | |
| 526 | + mutatingReasons.append(reason) | |
| 527 | + allUnprompted = false | |
| 528 | + rememberable.append(rememberPattern(for: tokens)) | |
| 529 | + case .workspaceWrite: | |
| 530 | + // Workspace-scoped writes auto-run in guarded mode, | |
| 531 | + // matching FileTools' .fileWrite behavior. | |
| 532 | + continue | |
| 533 | + case .allowedByRule, .readOnly: | |
| 534 | + continue | |
| 535 | + } | |
| 536 | + } | |
| 537 | + } | |
| 538 | + } | |
| 539 | + | |
| 540 | + let risk: RiskAssessment | |
| 541 | + if let alwaysAsk { | |
| 542 | + risk = alwaysAsk | |
| 543 | + } else if let first = mutatingReasons.first { | |
| 544 | + risk = RiskAssessment(level: .mutating, reason: first) | |
| 545 | + } else { | |
| 546 | + risk = RiskAssessment(level: .safe, reason: "Read-only command.") | |
| 547 | + } | |
| 548 | + | |
| 549 | + return Analysis( | |
| 550 | + hardDenyReason: hardDeny, | |
| 551 | + alwaysAsk: alwaysAsk, | |
| 552 | + allRunnableUnprompted: allUnprompted, | |
| 553 | + risk: risk, | |
| 554 | + allowReason: allUnprompted | |
| 555 | + ? "All subcommands are read-only, workspace-scoped, or covered by remembered allow rules." | |
| 556 | + : "", | |
| 557 | + rememberablePatterns: rememberable | |
| 558 | + ) | |
| 559 | + } | |
| 560 | + | |
| 561 | + // MARK: Per-subcommand classification (deny → ask → allow order) | |
| 562 | + | |
| 563 | + private static func classify( | |
| 564 | + tokens: [String], | |
| 565 | + rawSubcommand: String, | |
| 566 | + workspace: URL, | |
| 567 | + allowRules: [StoredPolicyRule], | |
| 568 | + denyRules: [StoredPolicyRule] | |
| 569 | + ) -> Classification { | |
| 570 | + let head = tokens[0] | |
| 571 | + let args = Array(tokens.dropFirst()) | |
| 572 | + let home = FileManager.default.homeDirectoryForCurrentUser.path | |
| 573 | + | |
| 574 | + // ---- 1a. User deny rules (deny comes first, always) -------------- | |
| 575 | + | |
| 576 | + if let denied = matchRule(tokens: tokens, rules: denyRules, kind: "bash") { | |
| 577 | + return .hardDeny("Matches your deny rule “\(denied.pattern)”.") | |
| 578 | + } | |
| 579 | + | |
| 580 | + // ---- 1b. Hard denylist ------------------------------------------- | |
| 581 | + | |
| 582 | + // Fork bomb fragments that survived splitting. | |
| 583 | + if isForkBomb(rawSubcommand) { | |
| 584 | + return .hardDeny("Fork bomb.") | |
| 585 | + } | |
| 586 | + // Filesystem/disk destruction. | |
| 587 | + if head == "mkfs" || head.hasPrefix("mkfs.") || head == "newfs_apfs" || head == "newfs_hfs" { | |
| 588 | + return .hardDeny("Creates a filesystem — destroys the target volume.") | |
| 589 | + } | |
| 590 | + if head == "diskutil" { | |
| 591 | + let sub = args.first?.lowercased() ?? "" | |
| 592 | + if sub.hasPrefix("erase") || sub == "partitiondisk" || sub == "zerodisk" || sub == "reformat" | |
| 593 | + || (sub == "apfs" && (args.dropFirst().first?.lowercased().contains("delete") ?? false)) { | |
| 594 | + return .hardDeny("diskutil \(sub) erases or repartitions a disk.") | |
| 595 | + } | |
| 596 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "diskutil modifies disk state.")) | |
| 597 | + } | |
| 598 | + if head == "dd", args.contains(where: { $0.hasPrefix("of=/dev/") }) { | |
| 599 | + return .hardDeny("dd writing directly to a device node.") | |
| 600 | + } | |
| 601 | + // rm -rf aimed at root or the entire home directory. | |
| 602 | + if head == "rm" { | |
| 603 | + let rm = analyzeRM(args: args, workspace: workspace, home: home) | |
| 604 | + if rm.wholesale { | |
| 605 | + return .hardDeny("rm targeting the filesystem root or the entire home directory.") | |
| 606 | + } | |
| 607 | + if rm.recursive || rm.force { | |
| 608 | + if rm.anyOutsideWorkspace { | |
| 609 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "rm \(rm.recursive ? "-r " : "")\(rm.force ? "-f " : "")on paths outside the task workspace.")) | |
| 610 | + } | |
| 611 | + return .mutating("Deletes files recursively inside the workspace.") | |
| 612 | + } | |
| 613 | + if rm.anyOutsideWorkspace { | |
| 614 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Deletes files outside the task workspace.")) | |
| 615 | + } | |
| 616 | + return .mutating("Deletes files inside the workspace.") | |
| 617 | + } | |
| 618 | + // Writes to /System are futile (SIP) and never legitimate. | |
| 619 | + if pathWritingCommands.contains(head), args.contains(where: { resolvePath($0, cwd: workspace, home: home).hasPrefix("/System/") }) { | |
| 620 | + return .hardDeny("Writes to /System (protected by System Integrity Protection).") | |
| 621 | + } | |
| 622 | + | |
| 623 | + // ---- 2. Always-ask circuit breakers (every mode) ---------------- | |
| 624 | + | |
| 625 | + if head == "sudo" || head == "doas" { | |
| 626 | + return .alwaysAsk(RiskAssessment(level: .elevated, reason: "Requires elevated privileges (\(head)). Zyquo Agent never runs sudo silently.")) | |
| 627 | + } | |
| 628 | + if head == "shutdown" || head == "reboot" || head == "halt" { | |
| 629 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Shuts down or restarts the Mac.")) | |
| 630 | + } | |
| 631 | + if head == "kill" || head == "pkill" || head == "killall" { | |
| 632 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Terminates running processes (\(head)).")) | |
| 633 | + } | |
| 634 | + if head == "launchctl" { | |
| 635 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Modifies launchd services.")) | |
| 636 | + } | |
| 637 | + if head == "systemsetup" || head == "csrutil" || head == "nvram" || head == "spctl" { | |
| 638 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Changes system-level configuration (\(head)).")) | |
| 639 | + } | |
| 640 | + if head == "security" { | |
| 641 | + return .alwaysAsk(RiskAssessment(level: .elevated, reason: "Accesses the keychain via security(1).")) | |
| 642 | + } | |
| 643 | + if head == "defaults", args.first == "write" || args.first == "delete" { | |
| 644 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Modifies application/system preferences (defaults \(args.first ?? "")).")) | |
| 645 | + } | |
| 646 | + if head == "installer" || (head == "softwareupdate" && args.contains(where: { $0 == "-i" || $0 == "--install" })) { | |
| 647 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Installs software system-wide (\(head)).")) | |
| 648 | + } | |
| 649 | + if head == "git", args.contains("push"), args.contains(where: { $0 == "--force" || $0 == "-f" || $0.hasPrefix("--force-with-lease") }) { | |
| 650 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "git push --force rewrites remote history.")) | |
| 651 | + } | |
| 652 | + if head == "osascript", rawSubcommand.lowercased().contains("with administrator privileges") { | |
| 653 | + return .alwaysAsk(RiskAssessment(level: .elevated, reason: "AppleScript requesting administrator privileges.")) | |
| 654 | + } | |
| 655 | + if (head == "chmod" || head == "chown" || head == "chflags"), | |
| 656 | + args.contains(where: { $0 == "-R" || $0 == "-r" }) { | |
| 657 | + let paths = args.filter { !$0.hasPrefix("-") }.dropFirst() // drop mode/owner operand | |
| 658 | + if paths.contains(where: { !isInsideWorkspace(resolvePath($0, cwd: workspace, home: home), workspace: workspace) }) { | |
| 659 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Recursive \(head) on paths outside the task workspace.")) | |
| 660 | + } | |
| 661 | + } | |
| 662 | + // mv/cp whose DESTINATION escapes the workspace (can overwrite user files). | |
| 663 | + if head == "mv" || head == "cp" { | |
| 664 | + let operands = args.filter { !$0.hasPrefix("-") } | |
| 665 | + if let destination = operands.last, operands.count >= 2 { | |
| 666 | + if isUnresolvable(destination) { | |
| 667 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) destination is variable-based (\(destination)) — cannot verify it stays inside the workspace.")) | |
| 668 | + } | |
| 669 | + let resolved = resolvePath(destination, cwd: workspace, home: home) | |
| 670 | + if !isInsideWorkspace(resolved, workspace: workspace) { | |
| 671 | + if protectedSystemPrefixes.contains(where: { resolved.hasPrefix($0) }) { | |
| 672 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) writing into a system path (\(resolved)).")) | |
| 673 | + } | |
| 674 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) writing outside the task workspace (\(resolved)) — may overwrite existing files.")) | |
| 675 | + } | |
| 676 | + } | |
| 677 | + return .mutating("Moves/copies files inside the workspace.") | |
| 678 | + } | |
| 679 | + // Mutating commands targeting protected system paths. Workspace- | |
| 680 | + // internal paths are exempt: a temp workspace can itself live under | |
| 681 | + // /var/folders/…, and writes inside it are workspace-scoped. | |
| 682 | + if pathWritingCommands.contains(head), | |
| 683 | + args.contains(where: { | |
| 684 | + let resolved = resolvePath($0, cwd: workspace, home: home) | |
| 685 | + return !isInsideWorkspace(resolved, workspace: workspace) | |
| 686 | + && protectedSystemPrefixes.contains(where: resolved.hasPrefix) | |
| 687 | + }) { | |
| 688 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Writes to a protected system path (/Library, /usr, /etc, …).")) | |
| 689 | + } | |
| 690 | + // Output redirection: judged by target path (workspace first — the | |
| 691 | + // workspace itself may live under a protected-looking prefix). | |
| 692 | + if let redirect = redirectionTarget(rawSubcommand) { | |
| 693 | + if isUnresolvable(redirect) { | |
| 694 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output to a variable-based path (\(redirect)) — cannot verify it stays inside the workspace.")) | |
| 695 | + } | |
| 696 | + let resolved = resolvePath(redirect, cwd: workspace, home: home) | |
| 697 | + if isInsideWorkspace(resolved, workspace: workspace) { | |
| 698 | + return .workspaceWrite("Writes a file inside the workspace via redirection.") | |
| 699 | + } | |
| 700 | + if resolved.hasPrefix("/System/") { | |
| 701 | + return .hardDeny("Redirects output into /System.") | |
| 702 | + } | |
| 703 | + if resolved.hasPrefix("/dev/") { | |
| 704 | + // /dev/null, /dev/stdout … — harmless sinks. | |
| 705 | + } else if protectedSystemPrefixes.contains(where: { resolved.hasPrefix($0) }) { | |
| 706 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output into a protected system path (\(resolved)).")) | |
| 707 | + } else { | |
| 708 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output to a file outside the task workspace (\(resolved)).")) | |
| 709 | + } | |
| 710 | + } | |
| 711 | + | |
| 712 | + // ---- 4. User allow rules (never reached for the classes above) -- | |
| 713 | + | |
| 714 | + if let allowed = matchRule(tokens: tokens, rules: allowRules, kind: "bash") { | |
| 715 | + return .allowedByRule("Matches your remembered allow rule “\(allowed.pattern)”.") | |
| 716 | + } | |
| 717 | + | |
| 718 | + // ---- 5. Curated read-only allowset ------------------------------ | |
| 719 | + | |
| 720 | + if head == "git", let sub = args.first(where: { !$0.hasPrefix("-") }) { | |
| 721 | + if readOnlyGitSubcommands.contains(sub) { | |
| 722 | + // `git remote add`, `git config --global x y` DO mutate. | |
| 723 | + if sub == "remote", args.count > 1, args.contains(where: { ["add", "remove", "rm", "set-url", "rename"].contains($0) }) { | |
| 724 | + return .mutating("git remote modification.") | |
| 725 | + } | |
| 726 | + if sub == "config", args.contains(where: { !$0.hasPrefix("-") && $0 != "config" }) && !args.contains("--list") && !args.contains("--get") { | |
| 727 | + return .mutating("git config write.") | |
| 728 | + } | |
| 729 | + return .readOnly | |
| 730 | + } | |
| 731 | + if sub == "branch", args.allSatisfy({ $0 == "branch" || $0.hasPrefix("-") }) || args == ["branch"] { | |
| 732 | + return .readOnly | |
| 733 | + } | |
| 734 | + return .mutating("git \(sub) mutates the repository.") | |
| 735 | + } | |
| 736 | + if head == "find" { | |
| 737 | + if args.contains("-delete") { | |
| 738 | + return .mutating("find -delete removes files.") | |
| 739 | + } | |
| 740 | + if let execIndex = args.firstIndex(where: { $0 == "-exec" || $0 == "-execdir" || $0 == "-ok" }) { | |
| 741 | + let executed = args.dropFirst(execIndex + 1).first ?? "" | |
| 742 | + // find -exec is exec-capable — classify by what it runs; rm et al. ask. | |
| 743 | + if ["rm", "mv", "chmod", "chown", "shred"].contains(executed) { | |
| 744 | + return .alwaysAsk(RiskAssessment(level: .destructive, reason: "find -exec \(executed) modifies files en masse.")) | |
| 745 | + } | |
| 746 | + return .mutating("find -exec runs \(executed.isEmpty ? "a command" : executed) per match.") | |
| 747 | + } | |
| 748 | + return .readOnly | |
| 749 | + } | |
| 750 | + if readOnlyCommands.contains(head) { | |
| 751 | + return .readOnly | |
| 752 | + } | |
| 753 | + // `defaults read`, `softwareupdate --list`, bare `env`… read-only tails. | |
| 754 | + if head == "defaults", args.first == "read" { return .readOnly } | |
| 755 | + if head == "softwareupdate", args.contains(where: { $0 == "-l" || $0 == "--list" }) { return .readOnly } | |
| 756 | + // Any command invoked purely for its version/help. | |
| 757 | + if args.count == 1, let only = args.first, versionOnlyFlags.contains(only) { | |
| 758 | + return .readOnly | |
| 759 | + } | |
| 760 | + | |
| 761 | + // ---- 6. Default: mutating (mode decides) ------------------------ | |
| 762 | + | |
| 763 | + return .mutating("`\(head)` may modify state — not in the read-only allowset.") | |
| 764 | + } | |
| 765 | + | |
| 766 | + // MARK: rm analysis | |
| 767 | + | |
| 768 | + private struct RMAnalysis { | |
| 769 | + var recursive = false | |
| 770 | + var force = false | |
| 771 | + /// True when a target resolves to `/`, `/*`, or the home directory itself. | |
| 772 | + var wholesale = false | |
| 773 | + var anyOutsideWorkspace = false | |
| 774 | + } | |
| 775 | + | |
| 776 | + private static func analyzeRM(args: [String], workspace: URL, home: String) -> RMAnalysis { | |
| 777 | + var analysis = RMAnalysis() | |
| 778 | + var targets: [String] = [] | |
| 779 | + for arg in args { | |
| 780 | + if arg.hasPrefix("--") { | |
| 781 | + if arg == "--recursive" { analysis.recursive = true } | |
| 782 | + if arg == "--force" { analysis.force = true } | |
| 783 | + continue | |
| 784 | + } | |
| 785 | + if arg.hasPrefix("-"), arg.count > 1 { | |
| 786 | + let flags = arg.dropFirst().lowercased() | |
| 787 | + if flags.contains("r") { analysis.recursive = true } | |
| 788 | + if flags.contains("f") { analysis.force = true } | |
| 789 | + continue | |
| 790 | + } | |
| 791 | + targets.append(arg) | |
| 792 | + } | |
| 793 | + for target in targets { | |
| 794 | + // Paths built from variables or substitutions can point anywhere — | |
| 795 | + // classify them conservatively as outside the workspace. | |
| 796 | + if isUnresolvable(target) { | |
| 797 | + analysis.anyOutsideWorkspace = true | |
| 798 | + continue | |
| 799 | + } | |
| 800 | + let resolved = resolvePath(target, cwd: workspace, home: home) | |
| 801 | + let normalized = resolved.hasSuffix("/") && resolved.count > 1 ? String(resolved.dropLast()) : resolved | |
| 802 | + if normalized == "/" || normalized == "/*" || normalized == home || normalized == home + "/*" | |
| 803 | + || target == "/" || target == "/*" || target == "~" || target == "~/" || target == "$HOME" { | |
| 804 | + analysis.wholesale = true | |
| 805 | + } | |
| 806 | + if !isInsideWorkspace(resolved, workspace: workspace) { | |
| 807 | + analysis.anyOutsideWorkspace = true | |
| 808 | + } | |
| 809 | + } | |
| 810 | + // `rm -rf` with no explicit target is odd but not wholesale. | |
| 811 | + return analysis | |
| 812 | + } | |
| 813 | + | |
| 814 | + // MARK: Pipeline-level breaker: download piped into an interpreter | |
| 815 | + | |
| 816 | + private static func classifyDownloadPipe(_ pipeline: Pipeline) -> RiskAssessment? { | |
| 817 | + guard pipeline.segments.count >= 2 else { return nil } | |
| 818 | + let downloaders: Set<String> = ["curl", "wget", "fetch"] | |
| 819 | + let interpreters: Set<String> = ["sh", "bash", "zsh", "ksh", "dash", "python", "python3", "ruby", "perl", "node", "osascript"] | |
| 820 | + var sawDownloader = false | |
| 821 | + for segment in pipeline.segments { | |
| 822 | + let tokens = stripWrappers(tokenize(segment)) | |
| 823 | + guard let head = tokens.first else { continue } | |
| 824 | + let bare = (head as NSString).lastPathComponent | |
| 825 | + if downloaders.contains(bare) { sawDownloader = true; continue } | |
| 826 | + if sawDownloader && interpreters.contains(bare) { | |
| 827 | + return RiskAssessment(level: .destructive, reason: "Pipes a network download directly into \(bare) — executes remote code.") | |
| 828 | + } | |
| 829 | + } | |
| 830 | + return nil | |
| 831 | + } | |
| 832 | + | |
| 833 | + // MARK: Rule matching | |
| 834 | + | |
| 835 | + /// Token-prefix match: rule "brew list" matches subcommand tokens | |
| 836 | + /// beginning ["brew", "list"]. First match wins. | |
| 837 | + private static func matchRule(tokens: [String], rules: [StoredPolicyRule], kind: String) -> StoredPolicyRule? { | |
| 838 | + for rule in rules where rule.kind == kind { | |
| 839 | + let ruleTokens = rule.pattern.split(separator: " ").map(String.init) | |
| 840 | + guard !ruleTokens.isEmpty, ruleTokens.count <= tokens.count else { continue } | |
| 841 | + if Array(tokens.prefix(ruleTokens.count)) == ruleTokens { | |
| 842 | + return rule | |
| 843 | + } | |
| 844 | + } | |
| 845 | + return nil | |
| 846 | + } | |
| 847 | + | |
| 848 | + /// Narrowest pattern to remember for a subcommand: command + first | |
| 849 | + /// non-flag argument when present ("brew list"), else just the command. | |
| 850 | + private static func rememberPattern(for tokens: [String]) -> String { | |
| 851 | + guard let head = tokens.first else { return "" } | |
| 852 | + if tokens.count > 1, !tokens[1].hasPrefix("-") { | |
| 853 | + return "\(head) \(tokens[1])" | |
| 854 | + } | |
| 855 | + return head | |
| 856 | + } | |
| 857 | + | |
| 858 | + // MARK: Path helpers | |
| 859 | + | |
| 860 | + /// Detects the classic bash fork bomb (`:(){ :|:& };:`) and its | |
| 861 | + /// renamed-function variants: a function definition whose body pipes the | |
| 862 | + /// function into itself and backgrounds it. | |
| 863 | + static func isForkBomb(_ text: String) -> Bool { | |
| 864 | + let compact = text.replacingOccurrences(of: " ", with: "").replacingOccurrences(of: "\n", with: "") | |
| 865 | + if compact.contains(":(){:|:&};:") || compact.contains("(){:|:&};") { | |
| 866 | + return true | |
| 867 | + } | |
| 868 | + // Generic shape: name(){name|name&};name | |
| 869 | + if let regex = try? NSRegularExpression(pattern: #"(\w+)\(\)\{\1\|\1&\};?"#), | |
| 870 | + regex.firstMatch(in: compact, range: NSRange(compact.startIndex..., in: compact)) != nil { | |
| 871 | + return true | |
| 872 | + } | |
| 873 | + return false | |
| 874 | + } | |
| 875 | + | |
| 876 | + /// True when a path contains shell variables or substitutions we cannot | |
| 877 | + /// statically resolve (other than a leading `$HOME`). Callers treat such | |
| 878 | + /// paths conservatively (as escaping the workspace). | |
| 879 | + static func isUnresolvable(_ raw: String) -> Bool { | |
| 880 | + if raw == "$HOME" || raw.hasPrefix("$HOME/") { return false } | |
| 881 | + return raw.contains("$") || raw.contains("`") | |
| 882 | + } | |
| 883 | + | |
| 884 | + static func resolvePath(_ raw: String, cwd: URL, home: String) -> String { | |
| 885 | + var path = raw | |
| 886 | + if path.hasPrefix("~/") { | |
| 887 | + path = home + String(path.dropFirst(1)) | |
| 888 | + } else if path == "~" { | |
| 889 | + path = home | |
| 890 | + } else if path == "$HOME" || path.hasPrefix("$HOME/") { | |
| 891 | + path = home + String(path.dropFirst("$HOME".count)) | |
| 892 | + } | |
| 893 | + if !path.hasPrefix("/") { | |
| 894 | + path = cwd.appendingPathComponent(path).path | |
| 895 | + } | |
| 896 | + return (path as NSString).standardizingPath | |
| 897 | + } | |
| 898 | + | |
| 899 | + static func isInsideWorkspace(_ resolvedPath: String, workspace: URL) -> Bool { | |
| 900 | + let root = (workspace.path as NSString).standardizingPath | |
| 901 | + return resolvedPath == root || resolvedPath.hasPrefix(root + "/") | |
| 902 | + } | |
| 903 | + | |
| 904 | + // MARK: Shell parsing (quote-aware; parsing is UX, not a security boundary) | |
| 905 | + | |
| 906 | + /// One pipeline: the segments between `|` operators. | |
| 907 | + struct Pipeline { | |
| 908 | + var segments: [String] | |
| 909 | + } | |
| 910 | + | |
| 911 | + /// Splits a payload on `&&`, `||`, `;`, newlines (into command lists) and | |
| 912 | + /// then on `|` (into pipeline segments), respecting single/double quotes | |
| 913 | + /// and backslash escapes. `2>&1`-style fd duplications are not treated as | |
| 914 | + /// pipes. | |
| 915 | + static func splitIntoPipelines(_ command: String) -> [Pipeline] { | |
| 916 | + var pipelines: [Pipeline] = [] | |
| 917 | + var currentSegment = "" | |
| 918 | + var currentSegments: [String] = [] | |
| 919 | + | |
| 920 | + func endSegment() { | |
| 921 | + let trimmed = currentSegment.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 922 | + if !trimmed.isEmpty { currentSegments.append(trimmed) } | |
| 923 | + currentSegment = "" | |
| 924 | + } | |
| 925 | + func endPipeline() { | |
| 926 | + endSegment() | |
| 927 | + if !currentSegments.isEmpty { | |
| 928 | + pipelines.append(Pipeline(segments: currentSegments)) | |
| 929 | + currentSegments = [] | |
| 930 | + } | |
| 931 | + } | |
| 932 | + | |
| 933 | + var iterator = command.makeIterator() | |
| 934 | + var pending: Character? = nil | |
| 935 | + var inSingle = false | |
| 936 | + var inDouble = false | |
| 937 | + var previous: Character? = nil | |
| 938 | + | |
| 939 | + func next() -> Character? { | |
| 940 | + if let p = pending { pending = nil; return p } | |
| 941 | + return iterator.next() | |
| 942 | + } | |
| 943 | + | |
| 944 | + while let ch = next() { | |
| 945 | + defer { previous = ch } | |
| 946 | + if inSingle { | |
| 947 | + currentSegment.append(ch) | |
| 948 | + if ch == "'" { inSingle = false } | |
| 949 | + continue | |
| 950 | + } | |
| 951 | + if inDouble { | |
| 952 | + currentSegment.append(ch) | |
| 953 | + if ch == "\\" { if let escaped = next() { currentSegment.append(escaped) } } | |
| 954 | + else if ch == "\"" { inDouble = false } | |
| 955 | + continue | |
| 956 | + } | |
| 957 | + switch ch { | |
| 958 | + case "'": | |
| 959 | + inSingle = true | |
| 960 | + currentSegment.append(ch) | |
| 961 | + case "\"": | |
| 962 | + inDouble = true | |
| 963 | + currentSegment.append(ch) | |
| 964 | + case "\\": | |
| 965 | + currentSegment.append(ch) | |
| 966 | + if let escaped = next() { currentSegment.append(escaped) } | |
| 967 | + case "&": | |
| 968 | + if previous == ">" || previous == "<" { | |
| 969 | + // fd duplication (`2>&1`, `>&2`, `<&0`) — not a control operator. | |
| 970 | + currentSegment.append(ch) | |
| 971 | + } else if let lookahead = next() { | |
| 972 | + if lookahead == "&" { | |
| 973 | + endPipeline() // `&&` | |
| 974 | + } else { | |
| 975 | + // Background `&`: terminates the command like `;`. | |
| 976 | + endPipeline() | |
| 977 | + pending = lookahead | |
| 978 | + } | |
| 979 | + } else { | |
| 980 | + endPipeline() | |
| 981 | + } | |
| 982 | + case "|": | |
| 983 | + if let lookahead = next() { | |
| 984 | + if lookahead == "|" { | |
| 985 | + endPipeline() // `||` | |
| 986 | + } else if lookahead == "&" { | |
| 987 | + endSegment() // `|&` pipes stdout+stderr | |
| 988 | + } else { | |
| 989 | + endSegment() // plain pipe | |
| 990 | + pending = lookahead | |
| 991 | + } | |
| 992 | + } else { | |
| 993 | + endSegment() | |
| 994 | + } | |
| 995 | + case ";", "\n": | |
| 996 | + endPipeline() | |
| 997 | + default: | |
| 998 | + currentSegment.append(ch) | |
| 999 | + } | |
| 1000 | + } | |
| 1001 | + endPipeline() | |
| 1002 | + return pipelines | |
| 1003 | + } | |
| 1004 | + | |
| 1005 | + /// Extracts the bodies of `$(…)` and `` `…` `` command substitutions so | |
| 1006 | + /// they are classified as subcommands in their own right — a breaker | |
| 1007 | + /// hidden inside a substitution must still trip. | |
| 1008 | + static func extractCommandSubstitutions(_ segment: String) -> [String] { | |
| 1009 | + var results: [String] = [] | |
| 1010 | + let characters = Array(segment) | |
| 1011 | + var i = 0 | |
| 1012 | + while i < characters.count { | |
| 1013 | + if characters[i] == "$", i + 1 < characters.count, characters[i + 1] == "(" { | |
| 1014 | + var depth = 1 | |
| 1015 | + var j = i + 2 | |
| 1016 | + var body = "" | |
| 1017 | + while j < characters.count, depth > 0 { | |
| 1018 | + if characters[j] == "(" { depth += 1 } | |
| 1019 | + if characters[j] == ")" { depth -= 1; if depth == 0 { break } } | |
| 1020 | + body.append(characters[j]) | |
| 1021 | + j += 1 | |
| 1022 | + } | |
| 1023 | + let trimmed = body.trimmingCharacters(in: .whitespaces) | |
| 1024 | + if !trimmed.isEmpty { results.append(trimmed) } | |
| 1025 | + i = j | |
| 1026 | + } else if characters[i] == "`" { | |
| 1027 | + var j = i + 1 | |
| 1028 | + var body = "" | |
| 1029 | + while j < characters.count, characters[j] != "`" { | |
| 1030 | + body.append(characters[j]) | |
| 1031 | + j += 1 | |
| 1032 | + } | |
| 1033 | + let trimmed = body.trimmingCharacters(in: .whitespaces) | |
| 1034 | + if !trimmed.isEmpty { results.append(trimmed) } | |
| 1035 | + i = j | |
| 1036 | + } | |
| 1037 | + i += 1 | |
| 1038 | + } | |
| 1039 | + return results | |
| 1040 | + } | |
| 1041 | + | |
| 1042 | + /// Splits one subcommand into tokens, respecting quotes (quotes removed). | |
| 1043 | + static func tokenize(_ subcommand: String) -> [String] { | |
| 1044 | + var tokens: [String] = [] | |
| 1045 | + var current = "" | |
| 1046 | + var inSingle = false | |
| 1047 | + var inDouble = false | |
| 1048 | + var hasContent = false | |
| 1049 | + | |
| 1050 | + for ch in subcommand { | |
| 1051 | + if inSingle { | |
| 1052 | + if ch == "'" { inSingle = false } else { current.append(ch) } | |
| 1053 | + continue | |
| 1054 | + } | |
| 1055 | + if inDouble { | |
| 1056 | + if ch == "\"" { inDouble = false } else { current.append(ch) } | |
| 1057 | + continue | |
| 1058 | + } | |
| 1059 | + switch ch { | |
| 1060 | + case "'": inSingle = true; hasContent = true | |
| 1061 | + case "\"": inDouble = true; hasContent = true | |
| 1062 | + case " ", "\t": | |
| 1063 | + if hasContent || !current.isEmpty { | |
| 1064 | + tokens.append(current) | |
| 1065 | + current = "" | |
| 1066 | + hasContent = false | |
| 1067 | + } | |
| 1068 | + default: | |
| 1069 | + current.append(ch) | |
| 1070 | + } | |
| 1071 | + } | |
| 1072 | + if hasContent || !current.isEmpty { tokens.append(current) } | |
| 1073 | + return tokens | |
| 1074 | + } | |
| 1075 | + | |
| 1076 | + /// Strips leading wrappers and env assignments: `env FOO=1 nohup time ls` | |
| 1077 | + /// → `["ls"]`. `xargs rm` classifies as `rm`. `sudo` is NOT strippable — | |
| 1078 | + /// it must classify as itself. | |
| 1079 | + static func stripWrappers(_ tokens: [String]) -> [String] { | |
| 1080 | + var tokens = tokens | |
| 1081 | + while let head = tokens.first { | |
| 1082 | + // VAR=value assignment prefixes. | |
| 1083 | + if head.contains("="), !head.hasPrefix("-"), !head.hasPrefix("="), | |
| 1084 | + head.firstIndex(of: "=").map({ head[head.startIndex..<$0].allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" } }) == true { | |
| 1085 | + tokens.removeFirst() | |
| 1086 | + continue | |
| 1087 | + } | |
| 1088 | + if strippableWrappers.contains(head) { | |
| 1089 | + tokens.removeFirst() | |
| 1090 | + // Drop the wrapper's own flags (e.g. `xargs -n1`, `env -i`). | |
| 1091 | + while let next = tokens.first, next.hasPrefix("-") { | |
| 1092 | + tokens.removeFirst() | |
| 1093 | + } | |
| 1094 | + continue | |
| 1095 | + } | |
| 1096 | + break | |
| 1097 | + } | |
| 1098 | + return tokens | |
| 1099 | + } | |
| 1100 | + | |
| 1101 | + /// Finds the target of the first `>` / `>>` output redirection outside | |
| 1102 | + /// quotes (nil when there is none). `2>&1`, `>&2` fd-duplications and | |
| 1103 | + /// heredocs are ignored. | |
| 1104 | + static func redirectionTarget(_ subcommand: String) -> String? { | |
| 1105 | + let characters = Array(subcommand) | |
| 1106 | + var inSingle = false | |
| 1107 | + var inDouble = false | |
| 1108 | + var i = 0 | |
| 1109 | + while i < characters.count { | |
| 1110 | + let ch = characters[i] | |
| 1111 | + if ch == "'" && !inDouble { inSingle.toggle() } | |
| 1112 | + else if ch == "\"" && !inSingle { inDouble.toggle() } | |
| 1113 | + else if ch == ">" && !inSingle && !inDouble { | |
| 1114 | + var j = i + 1 | |
| 1115 | + if j < characters.count, characters[j] == ">" { j += 1 } // `>>` | |
| 1116 | + if j < characters.count, characters[j] == "&" { i = j + 1; continue } // `>&1` | |
| 1117 | + // Skip whitespace, then read the target word. | |
| 1118 | + while j < characters.count, characters[j] == " " || characters[j] == "\t" { j += 1 } | |
| 1119 | + var target = "" | |
| 1120 | + while j < characters.count, characters[j] != " " && characters[j] != "\t" | |
| 1121 | + && characters[j] != ";" && characters[j] != "|" && characters[j] != "&" { | |
| 1122 | + target.append(characters[j]) | |
| 1123 | + j += 1 | |
| 1124 | + } | |
| 1125 | + let unquoted = target.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) | |
| 1126 | + return unquoted.isEmpty ? nil : unquoted | |
| 1127 | + } | |
| 1128 | + i += 1 | |
| 1129 | + } | |
| 1130 | + return nil | |
| 1131 | + } | |
| 1132 | + | |
| 1133 | + // MARK: Severity ordering | |
| 1134 | + | |
| 1135 | + private static func mostSevere(_ a: RiskAssessment?, _ b: RiskAssessment) -> RiskAssessment { | |
| 1136 | + guard let a else { return b } | |
| 1137 | + return rank(a.level) >= rank(b.level) ? a : b | |
| 1138 | + } | |
| 1139 | + | |
| 1140 | + private static func rank(_ level: RiskAssessment.Level) -> Int { | |
| 1141 | + switch level { | |
| 1142 | + case .safe: return 0 | |
| 1143 | + case .mutating: return 1 | |
| 1144 | + case .destructive: return 2 | |
| 1145 | + case .elevated: return 3 | |
| 1146 | + } | |
| 1147 | + } | |
| 1148 | +} | |
added
Sources/ZyquoAgent/Execution/PolicyEngineSelfCheck.swift
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +// | |
| 2 | +// PolicyEngineSelfCheck.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Runtime assertions over the PolicyEngine — the same safety cases as | |
| 9 | +// Tests/ZyquoAgentTests/PolicyEngineTests.swift, executable without XCTest | |
| 10 | +// via `ZyquoAgent --verify-policy`. Prints one PASS/FAIL line per case and | |
| 11 | +// returns false when anything fails. These are the circuit-breaker | |
| 12 | +// guarantees Phase 7 re-verifies end-to-end; run this after ANY change to | |
| 13 | +// the policy engine. | |
| 14 | +// | |
| 15 | + | |
| 16 | +import Foundation | |
| 17 | + | |
| 18 | +enum PolicyEngineSelfCheck { | |
| 19 | + | |
| 20 | + /// Auto-denies every approval request — self-check rulings must be | |
| 21 | + /// decided by classification alone, never by a human in the loop. | |
| 22 | + private struct DenyAllApprovals: ApprovalPresenting { | |
| 23 | + func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution { | |
| 24 | + .deny | |
| 25 | + } | |
| 26 | + } | |
| 27 | + | |
| 28 | + static func run() async -> Bool { | |
| 29 | + // Isolated scratch environment: a temp workspace and a temp-rooted | |
| 30 | + // PersistenceService so remembered rules never touch real user data. | |
| 31 | + let scratchRoot = FileManager.default.temporaryDirectory | |
| 32 | + .appendingPathComponent("ZyquoAgent-policycheck-\(UUID().uuidString.prefix(8))") | |
| 33 | + let workspace = scratchRoot.appendingPathComponent("workspace") | |
| 34 | + try? FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) | |
| 35 | + defer { try? FileManager.default.removeItem(at: scratchRoot) } | |
| 36 | + let persistence = PersistenceService(rootDirectory: scratchRoot.appendingPathComponent("data")) | |
| 37 | + | |
| 38 | + func engine(_ mode: SafetyMode) -> PolicyEngine { | |
| 39 | + PolicyEngine(mode: mode, approvals: DenyAllApprovals(), persistence: persistence) | |
| 40 | + } | |
| 41 | + func shell(_ command: String) -> ActionRequest { | |
| 42 | + ActionRequest(kind: .shellCommand, payload: command, cwd: workspace, explanation: nil) | |
| 43 | + } | |
| 44 | + | |
| 45 | + var passed = 0 | |
| 46 | + var failed = 0 | |
| 47 | + | |
| 48 | + func check(_ label: String, _ condition: Bool) { | |
| 49 | + if condition { | |
| 50 | + passed += 1 | |
| 51 | + print("PASS \(label)") | |
| 52 | + } else { | |
| 53 | + failed += 1 | |
| 54 | + print("FAIL \(label)") | |
| 55 | + } | |
| 56 | + } | |
| 57 | + func isAsk(_ ruling: PolicyRuling) -> Bool { | |
| 58 | + if case .ask = ruling { return true } | |
| 59 | + return false | |
| 60 | + } | |
| 61 | + func isAllow(_ ruling: PolicyRuling) -> Bool { | |
| 62 | + if case .allow = ruling { return true } | |
| 63 | + return false | |
| 64 | + } | |
| 65 | + func isDeny(_ ruling: PolicyRuling) -> Bool { | |
| 66 | + if case .deny = ruling { return true } | |
| 67 | + return false | |
| 68 | + } | |
| 69 | + | |
| 70 | + print("Zyquo Agent — PolicyEngine self-check") | |
| 71 | + print("workspace: \(workspace.path)\n") | |
| 72 | + | |
| 73 | + let manual = engine(.manual) | |
| 74 | + let guarded = engine(.guarded) | |
| 75 | + let autonomous = engine(.autonomous) | |
| 76 | + | |
| 77 | + // --- The five mandated cases ----------------------------------- | |
| 78 | + check("sudo always asks, even in Autonomous", | |
| 79 | + await isAsk(autonomous.evaluate(shell("sudo whoami")))) | |
| 80 | + check("rm -rf outside the workspace asks in Autonomous", | |
| 81 | + await isAsk(autonomous.evaluate(shell("rm -rf ~/Documents/old-project")))) | |
| 82 | + check("ls auto-allows in Guarded", | |
| 83 | + await isAllow(guarded.evaluate(shell("ls -la")))) | |
| 84 | + check("compound `ls && rm -rf ~/x` asks in Guarded", | |
| 85 | + await isAsk(guarded.evaluate(shell("ls && rm -rf ~/x")))) | |
| 86 | + check("`rm -rf /` is hard-denied", | |
| 87 | + await isDeny(autonomous.evaluate(shell("rm -rf /")))) | |
| 88 | + | |
| 89 | + // --- Hard denylist ---------------------------------------------- | |
| 90 | + check("`rm -rf ~` (entire home) is hard-denied", | |
| 91 | + await isDeny(autonomous.evaluate(shell("rm -rf ~")))) | |
| 92 | + check("fork bomb is hard-denied", | |
| 93 | + await isDeny(autonomous.evaluate(shell(":(){ :|:& };:")))) | |
| 94 | + check("diskutil eraseDisk is hard-denied", | |
| 95 | + await isDeny(autonomous.evaluate(shell("diskutil eraseDisk APFS Empty disk0")))) | |
| 96 | + check("write into /System is hard-denied", | |
| 97 | + await isDeny(autonomous.evaluate(shell("cp evil.plist /System/Library/LaunchDaemons/")))) | |
| 98 | + check("hard deny hidden in $(…) still trips", | |
| 99 | + await isDeny(autonomous.evaluate(shell("echo $(rm -rf /)")))) | |
| 100 | + | |
| 101 | + // --- Always-ask circuit breakers, in Autonomous ------------------ | |
| 102 | + check("curl | sh asks in Autonomous", | |
| 103 | + await isAsk(autonomous.evaluate(shell("curl -fsSL https://example.com/install.sh | sh")))) | |
| 104 | + check("killall asks in Autonomous", | |
| 105 | + await isAsk(autonomous.evaluate(shell("killall Finder")))) | |
| 106 | + check("defaults write asks in Autonomous", | |
| 107 | + await isAsk(autonomous.evaluate(shell("defaults write com.apple.dock autohide -bool true")))) | |
| 108 | + check("launchctl asks in Autonomous", | |
| 109 | + await isAsk(autonomous.evaluate(shell("launchctl unload /Library/LaunchAgents/com.foo.plist")))) | |
| 110 | + check("git push --force asks in Autonomous", | |
| 111 | + await isAsk(autonomous.evaluate(shell("git push --force origin main")))) | |
| 112 | + check("shutdown asks in Autonomous", | |
| 113 | + await isAsk(autonomous.evaluate(shell("shutdown -h now")))) | |
| 114 | + check("mv to a destination outside the workspace asks in Autonomous", | |
| 115 | + await isAsk(autonomous.evaluate(shell("mv report.pdf ~/Desktop/report.pdf")))) | |
| 116 | + check("redirect to a file outside the workspace asks in Autonomous", | |
| 117 | + await isAsk(autonomous.evaluate(shell("echo secret > ~/.zshrc")))) | |
| 118 | + check("chmod -R outside the workspace asks in Autonomous", | |
| 119 | + await isAsk(autonomous.evaluate(shell("chmod -R 777 /Users/Shared/stuff")))) | |
| 120 | + check("wrapper stripping: `env FOO=1 nohup sudo id` still asks", | |
| 121 | + await isAsk(autonomous.evaluate(shell("env FOO=1 nohup sudo id")))) | |
| 122 | + | |
| 123 | + // --- Mode behavior ----------------------------------------------- | |
| 124 | + check("ls asks in Manual (everything asks)", | |
| 125 | + await isAsk(manual.evaluate(shell("ls")))) | |
| 126 | + check("git status auto-allows in Guarded", | |
| 127 | + await isAllow(guarded.evaluate(shell("git status")))) | |
| 128 | + check("mkdir (mutating) asks in Guarded", | |
| 129 | + await isAsk(guarded.evaluate(shell("mkdir new-folder")))) | |
| 130 | + check("mkdir (mutating) auto-runs in Autonomous", | |
| 131 | + await isAllow(autonomous.evaluate(shell("mkdir new-folder")))) | |
| 132 | + check("redirect INSIDE the workspace auto-runs in Guarded", | |
| 133 | + await isAllow(guarded.evaluate(shell("echo hello > notes.txt")))) | |
| 134 | + check("rm -rf inside the workspace asks in Guarded", | |
| 135 | + await isAsk(guarded.evaluate(shell("rm -rf build/")))) | |
| 136 | + | |
| 137 | + // --- File-tool kinds ---------------------------------------------- | |
| 138 | + let insideWrite = ActionRequest(kind: .fileWrite, payload: workspace.appendingPathComponent("a.txt").path, cwd: workspace, explanation: nil) | |
| 139 | + let outsideWrite = ActionRequest(kind: .fileWriteOutsideWorkspace, payload: "/Users/someone/Desktop/a.txt", cwd: workspace, explanation: nil) | |
| 140 | + let outsideRead = ActionRequest(kind: .fileReadOutsideWorkspace, payload: "/etc/hosts", cwd: workspace, explanation: nil) | |
| 141 | + check("workspace file write auto-allows in Guarded", | |
| 142 | + await isAllow(guarded.evaluate(insideWrite))) | |
| 143 | + check("workspace file write asks in Manual", | |
| 144 | + await isAsk(manual.evaluate(insideWrite))) | |
| 145 | + check("file write OUTSIDE the workspace asks even in Autonomous", | |
| 146 | + await isAsk(autonomous.evaluate(outsideWrite))) | |
| 147 | + check("file read OUTSIDE the workspace asks even in Autonomous", | |
| 148 | + await isAsk(autonomous.evaluate(outsideRead))) | |
| 149 | + | |
| 150 | + // --- AppleScript --------------------------------------------------- | |
| 151 | + func script(_ text: String) -> ActionRequest { | |
| 152 | + ActionRequest(kind: .appleScript, payload: text, cwd: workspace, explanation: nil) | |
| 153 | + } | |
| 154 | + check("AppleScript asks in Guarded", | |
| 155 | + await isAsk(guarded.evaluate(script("tell application \"Notes\" to make new note")))) | |
| 156 | + check("benign AppleScript auto-runs in Autonomous", | |
| 157 | + await isAllow(autonomous.evaluate(script("tell application \"Notes\" to make new note")))) | |
| 158 | + check("`with administrator privileges` asks even in Autonomous", | |
| 159 | + await isAsk(autonomous.evaluate(script("do shell script \"id\" with administrator privileges")))) | |
| 160 | + check("System Events keystrokes ask in Autonomous", | |
| 161 | + await isAsk(autonomous.evaluate(script("tell application \"System Events\" to keystroke \"hello\"")))) | |
| 162 | + | |
| 163 | + // --- Remembered rules ---------------------------------------------- | |
| 164 | + await guarded.addAllowRule(StoredPolicyRule(kind: "bash", pattern: "brew list")) | |
| 165 | + check("remembered rule `brew list` auto-allows in Guarded", | |
| 166 | + await isAllow(guarded.evaluate(shell("brew list --versions")))) | |
| 167 | + check("remembered rule does NOT cover `brew install`", | |
| 168 | + await isAsk(guarded.evaluate(shell("brew install wget")))) | |
| 169 | + await guarded.addDenyRule(StoredPolicyRule(kind: "bash", pattern: "npm publish")) | |
| 170 | + check("user deny rule blocks `npm publish` outright", | |
| 171 | + await isDeny(guarded.evaluate(shell("npm publish")))) | |
| 172 | + await autonomous.addAllowRule(StoredPolicyRule(kind: "bash", pattern: "sudo whoami")) | |
| 173 | + check("remembered rule can NOT override a circuit breaker", | |
| 174 | + await isAsk(autonomous.evaluate(shell("sudo whoami")))) | |
| 175 | + | |
| 176 | + print("\n\(passed) passed, \(failed) failed") | |
| 177 | + return failed == 0 | |
| 178 | + } | |
| 179 | +} | |
added
Sources/ZyquoAgent/Tools/AppleScriptTool.swift
+178 −0
@@ -0,0 +1,178 @@ | ||
| 1 | +// | |
| 2 | +// AppleScriptTool.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The agent's `osascript` tool: runs AppleScript to automate macOS apps | |
| 9 | +// (Finder, Notes, Mail, Calendar, System Events…). Single-line scripts run | |
| 10 | +// via `osascript -e`; multi-line scripts are written to a temp file under | |
| 11 | +// the workspace's `.zyquo/scripts/` dir and run as `osascript <file>`. | |
| 12 | +// Same policy → execute → audit path as the shell tool. When macOS TCC | |
| 13 | +// blocks Apple events (error -1743 / "Not authorized"), the result explains | |
| 14 | +// how to grant Automation access instead of leaving a cryptic error. | |
| 15 | +// | |
| 16 | + | |
| 17 | +import Foundation | |
| 18 | + | |
| 19 | +struct AppleScriptTool: Tool { | |
| 20 | + private let executor: ExecutionService | |
| 21 | + | |
| 22 | + init(executor: ExecutionService) { | |
| 23 | + self.executor = executor | |
| 24 | + } | |
| 25 | + | |
| 26 | + let name = "osascript" | |
| 27 | + | |
| 28 | + let description = """ | |
| 29 | + Run an AppleScript on this Mac via /usr/bin/osascript to automate \ | |
| 30 | + macOS applications: Finder, Notes, Reminders, Mail, Calendar, Safari, \ | |
| 31 | + Music, System Events, and any scriptable app. Use it for things the \ | |
| 32 | + shell cannot do cleanly — creating a Note or Reminder, reading \ | |
| 33 | + Calendar events, controlling app windows. AppleScript is powerful \ | |
| 34 | + and therefore gated: scripts are usually held for user approval, and \ | |
| 35 | + the first automation of each app triggers a one-time macOS \ | |
| 36 | + permission prompt the user must accept. If the result mentions \ | |
| 37 | + \"Not authorized\", the user needs to grant access in System \ | |
| 38 | + Settings > Privacy & Security > Automation — tell them so. Keep \ | |
| 39 | + scripts short and single-purpose; the script's `return` value is \ | |
| 40 | + returned to you as text. | |
| 41 | + """ | |
| 42 | + | |
| 43 | + var parametersSchema: JSONValue { | |
| 44 | + .object([ | |
| 45 | + "type": .string("object"), | |
| 46 | + "properties": .object([ | |
| 47 | + "script": .object([ | |
| 48 | + "type": .string("string"), | |
| 49 | + "description": .string("The complete AppleScript source to execute."), | |
| 50 | + ]), | |
| 51 | + "timeout_seconds": .object([ | |
| 52 | + "type": .string("integer"), | |
| 53 | + "description": .string("Optional timeout in seconds for this script (default 120)."), | |
| 54 | + ]), | |
| 55 | + "explanation": .object([ | |
| 56 | + "type": .string("string"), | |
| 57 | + "description": .string("One short sentence explaining what this script does and why — shown to the user on approval cards."), | |
| 58 | + ]), | |
| 59 | + ]), | |
| 60 | + "required": .array([.string("script")]), | |
| 61 | + ]) | |
| 62 | + } | |
| 63 | + | |
| 64 | + func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { | |
| 65 | + guard case .object(let object) = arguments, | |
| 66 | + case .string(let script)? = object["script"], | |
| 67 | + !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { | |
| 68 | + return .failure("osascript: missing required parameter `script`.") | |
| 69 | + } | |
| 70 | + var timeout: TimeInterval? | |
| 71 | + if case .number(let seconds)? = object["timeout_seconds"], seconds > 0 { | |
| 72 | + timeout = seconds | |
| 73 | + } | |
| 74 | + var explanation: String? | |
| 75 | + if case .string(let text)? = object["explanation"] { | |
| 76 | + explanation = text | |
| 77 | + } | |
| 78 | + | |
| 79 | + // ---- Safety gate (never skipped) -------------------------------- | |
| 80 | + let cleared: ClearedAction | |
| 81 | + do { | |
| 82 | + cleared = try await context.policy.clear(ActionRequest( | |
| 83 | + kind: .appleScript, | |
| 84 | + payload: script, | |
| 85 | + cwd: context.workspaceURL, | |
| 86 | + explanation: explanation | |
| 87 | + )) | |
| 88 | + } catch let denial as PolicyDenied { | |
| 89 | + await context.audit.append(AuditEntry( | |
| 90 | + actionKind: name, | |
| 91 | + payload: script, | |
| 92 | + cwd: context.workspaceURL.path, | |
| 93 | + ruling: PolicyDecisionRecord.Ruling.denied.rawValue, | |
| 94 | + exitCode: nil, | |
| 95 | + outputExcerpt: denial.reason | |
| 96 | + )) | |
| 97 | + return .failure("Script not run — denied by the safety policy: \(denial.reason)") | |
| 98 | + } | |
| 99 | + | |
| 100 | + // ---- Execute ----------------------------------------------------- | |
| 101 | + let effectiveScript = cleared.payload | |
| 102 | + let isMultiline = effectiveScript.contains("\n") | |
| 103 | + let result: ExecutionResult | |
| 104 | + var scriptFileURL: URL? | |
| 105 | + do { | |
| 106 | + if isMultiline { | |
| 107 | + // Multi-line: write to .zyquo/scripts/ and run the file. | |
| 108 | + let scriptsDir = context.workspaceURL | |
| 109 | + .appendingPathComponent(".zyquo") | |
| 110 | + .appendingPathComponent("scripts") | |
| 111 | + try FileManager.default.createDirectory(at: scriptsDir, withIntermediateDirectories: true) | |
| 112 | + let fileURL = scriptsDir.appendingPathComponent("osascript-\(UUID().uuidString.prefix(8)).applescript") | |
| 113 | + try effectiveScript.write(to: fileURL, atomically: true, encoding: .utf8) | |
| 114 | + scriptFileURL = fileURL | |
| 115 | + result = try await executor.runOSAScriptFile( | |
| 116 | + scriptFile: fileURL, | |
| 117 | + cwd: context.workspaceURL, | |
| 118 | + timeout: timeout, | |
| 119 | + onOutput: context.onOutput | |
| 120 | + ) | |
| 121 | + } else { | |
| 122 | + result = try await executor.runOSAScript( | |
| 123 | + lines: [effectiveScript], | |
| 124 | + cwd: context.workspaceURL, | |
| 125 | + timeout: timeout, | |
| 126 | + onOutput: context.onOutput | |
| 127 | + ) | |
| 128 | + } | |
| 129 | + } catch let launchError as ExecutionLaunchError { | |
| 130 | + await context.audit.append(AuditEntry( | |
| 131 | + actionKind: name, | |
| 132 | + payload: effectiveScript, | |
| 133 | + cwd: context.workspaceURL.path, | |
| 134 | + ruling: cleared.decision.ruling.rawValue, | |
| 135 | + exitCode: nil, | |
| 136 | + outputExcerpt: launchError.reason | |
| 137 | + )) | |
| 138 | + return .failure("osascript failed to launch: \(launchError.reason)") | |
| 139 | + } | |
| 140 | + // Keep the script file for the audit trail (it lives in .zyquo/, | |
| 141 | + // which is excluded from workspace file tracking). | |
| 142 | + _ = scriptFileURL | |
| 143 | + | |
| 144 | + // ---- Audit ------------------------------------------------------- | |
| 145 | + await context.audit.append(AuditEntry( | |
| 146 | + actionKind: name, | |
| 147 | + payload: effectiveScript, | |
| 148 | + cwd: context.workspaceURL.path, | |
| 149 | + ruling: cleared.decision.ruling.rawValue, | |
| 150 | + exitCode: result.exitCode, | |
| 151 | + outputExcerpt: result.combinedOutput | |
| 152 | + )) | |
| 153 | + | |
| 154 | + // ---- Result for the model ---------------------------------------- | |
| 155 | + var content = result.combinedOutput | |
| 156 | + if result.isTimeout { | |
| 157 | + content += "\n[timed out after \(Int(timeout ?? 120))s — osascript was killed]" | |
| 158 | + } | |
| 159 | + if result.isCancelled { | |
| 160 | + content += "\n[cancelled by the user]" | |
| 161 | + } | |
| 162 | + content += "\n[exit code: \(result.exitCode.map(String.init) ?? "none")]" | |
| 163 | + | |
| 164 | + // TCC / Automation permission guidance. | |
| 165 | + let lowered = result.stderr.lowercased() | |
| 166 | + if lowered.contains("not authorized") || lowered.contains("-1743") || result.stderr.contains("errAEEventNotPermitted") { | |
| 167 | + content += """ | |
| 168 | + \n[Automation permission needed] macOS blocked Zyquo Agent from \ | |
| 169 | + controlling that application. Ask the user to open System \ | |
| 170 | + Settings > Privacy & Security > Automation, find "Zyquo Agent", \ | |
| 171 | + and enable the target app — then run the script again. | |
| 172 | + """ | |
| 173 | + } | |
| 174 | + | |
| 175 | + let failed = result.isTimeout || result.isCancelled || (result.exitCode ?? 1) != 0 | |
| 176 | + return ToolExecutionResult(content: content, isError: failed) | |
| 177 | + } | |
| 178 | +} | |
added
Sources/ZyquoAgent/Tools/FileTools.swift
+656 −0
@@ -0,0 +1,656 @@ | ||
| 1 | +// | |
| 2 | +// FileTools.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The five workspace file tools: read_file, write_file, edit_file, | |
| 9 | +// list_dir, search_files. All paths resolve relative to the task workspace. | |
| 10 | +// Escaping the workspace (absolute paths or `..`) routes through the | |
| 11 | +// PolicyEngine: reads outside ask (`.fileReadOutsideWorkspace`), writes | |
| 12 | +// outside always ask (`.fileWriteOutsideWorkspace`); writes inside go | |
| 13 | +// through `.fileWrite` (auto-allowed in Guarded/Autonomous). In-workspace | |
| 14 | +// READS deliberately skip the gate — asking the user before the agent can | |
| 15 | +// look at its own scratch files would make Manual mode unusable (decision | |
| 16 | +// documented in PolicyEngine's header). Every operation is audited. | |
| 17 | +// | |
| 18 | + | |
| 19 | +import Foundation | |
| 20 | + | |
| 21 | +// MARK: - Shared path resolution | |
| 22 | + | |
| 23 | +/// Resolves tool-supplied paths against the workspace and detects escapes. | |
| 24 | +enum WorkspacePath { | |
| 25 | + struct Resolved { | |
| 26 | + /// Fully resolved file URL. | |
| 27 | + var url: URL | |
| 28 | + /// True when the path (symlinks resolved) stays inside the workspace. | |
| 29 | + var isInsideWorkspace: Bool | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// Relative paths resolve under the workspace; `~` and absolute paths are | |
| 33 | + /// honored but flagged when they escape. Symlinks are resolved on the | |
| 34 | + /// deepest existing ancestor so a link cannot smuggle a write outside. | |
| 35 | + static func resolve(_ raw: String, workspace: URL) -> Resolved { | |
| 36 | + var expanded = raw | |
| 37 | + if expanded == "~" || expanded.hasPrefix("~/") { | |
| 38 | + expanded = (expanded as NSString).expandingTildeInPath | |
| 39 | + } | |
| 40 | + let url: URL | |
| 41 | + if expanded.hasPrefix("/") { | |
| 42 | + url = URL(fileURLWithPath: (expanded as NSString).standardizingPath) | |
| 43 | + } else { | |
| 44 | + url = URL(fileURLWithPath: (workspace.appendingPathComponent(expanded).path as NSString).standardizingPath) | |
| 45 | + } | |
| 46 | + return Resolved(url: url, isInsideWorkspace: isInside(url, workspace: workspace)) | |
| 47 | + } | |
| 48 | + | |
| 49 | + private static func isInside(_ url: URL, workspace: URL) -> Bool { | |
| 50 | + let root = URL(fileURLWithPath: workspace.path).resolvingSymlinksInPath().path | |
| 51 | + // Resolve symlinks on the deepest existing ancestor of the target. | |
| 52 | + var probe = url | |
| 53 | + var suffix: [String] = [] | |
| 54 | + while !FileManager.default.fileExists(atPath: probe.path), probe.pathComponents.count > 1 { | |
| 55 | + suffix.append(probe.lastPathComponent) | |
| 56 | + probe = probe.deletingLastPathComponent() | |
| 57 | + } | |
| 58 | + var resolved = probe.resolvingSymlinksInPath() | |
| 59 | + for component in suffix.reversed() { | |
| 60 | + resolved.appendPathComponent(component) | |
| 61 | + } | |
| 62 | + let path = resolved.path | |
| 63 | + return path == root || path.hasPrefix(root + "/") | |
| 64 | + } | |
| 65 | + | |
| 66 | + /// Path shown to the model/user: workspace-relative when inside. | |
| 67 | + static func display(_ url: URL, workspace: URL) -> String { | |
| 68 | + let root = workspace.path | |
| 69 | + if url.path == root { return "." } | |
| 70 | + if url.path.hasPrefix(root + "/") { | |
| 71 | + return String(url.path.dropFirst(root.count + 1)) | |
| 72 | + } | |
| 73 | + return url.path | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +// MARK: - Shared gate/audit plumbing | |
| 78 | + | |
| 79 | +private enum FileToolSupport { | |
| 80 | + /// Clears a write with the policy engine (inside → `.fileWrite`, | |
| 81 | + /// outside → `.fileWriteOutsideWorkspace`). Returns the decision for the | |
| 82 | + /// audit line, or the denial message. | |
| 83 | + static func clearWrite( | |
| 84 | + of resolved: WorkspacePath.Resolved, | |
| 85 | + context: ToolExecutionContext, | |
| 86 | + explanation: String | |
| 87 | + ) async -> Result<PolicyDecisionRecord, PolicyDenied> { | |
| 88 | + do { | |
| 89 | + let cleared = try await context.policy.clear(ActionRequest( | |
| 90 | + kind: resolved.isInsideWorkspace ? .fileWrite : .fileWriteOutsideWorkspace, | |
| 91 | + payload: resolved.url.path, | |
| 92 | + cwd: context.workspaceURL, | |
| 93 | + explanation: explanation | |
| 94 | + )) | |
| 95 | + return .success(cleared.decision) | |
| 96 | + } catch let denial as PolicyDenied { | |
| 97 | + return .failure(denial) | |
| 98 | + } catch { | |
| 99 | + return .failure(PolicyDenied(reason: error.localizedDescription)) | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + /// Clears an out-of-workspace read (in-workspace reads skip the gate). | |
| 104 | + static func clearRead( | |
| 105 | + of resolved: WorkspacePath.Resolved, | |
| 106 | + context: ToolExecutionContext, | |
| 107 | + explanation: String | |
| 108 | + ) async -> Result<PolicyDecisionRecord, PolicyDenied> { | |
| 109 | + guard !resolved.isInsideWorkspace else { | |
| 110 | + return .success(PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: "Read inside the task workspace.")) | |
| 111 | + } | |
| 112 | + do { | |
| 113 | + let cleared = try await context.policy.clear(ActionRequest( | |
| 114 | + kind: .fileReadOutsideWorkspace, | |
| 115 | + payload: resolved.url.path, | |
| 116 | + cwd: context.workspaceURL, | |
| 117 | + explanation: explanation | |
| 118 | + )) | |
| 119 | + return .success(cleared.decision) | |
| 120 | + } catch let denial as PolicyDenied { | |
| 121 | + return .failure(denial) | |
| 122 | + } catch { | |
| 123 | + return .failure(PolicyDenied(reason: error.localizedDescription)) | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + static func audit( | |
| 128 | + _ context: ToolExecutionContext, | |
| 129 | + tool: String, | |
| 130 | + path: String, | |
| 131 | + decision: PolicyDecisionRecord?, | |
| 132 | + note: String | |
| 133 | + ) async { | |
| 134 | + await context.audit.append(AuditEntry( | |
| 135 | + actionKind: tool, | |
| 136 | + payload: path, | |
| 137 | + cwd: context.workspaceURL.path, | |
| 138 | + ruling: decision?.ruling.rawValue ?? PolicyDecisionRecord.Ruling.denied.rawValue, | |
| 139 | + exitCode: nil, | |
| 140 | + outputExcerpt: note | |
| 141 | + )) | |
| 142 | + } | |
| 143 | + | |
| 144 | + static func string(_ key: String, in object: [String: JSONValue]) -> String? { | |
| 145 | + if case .string(let value)? = object[key] { return value } | |
| 146 | + return nil | |
| 147 | + } | |
| 148 | + | |
| 149 | + static func integer(_ key: String, in object: [String: JSONValue]) -> Int? { | |
| 150 | + if case .number(let value)? = object[key] { return Int(value) } | |
| 151 | + return nil | |
| 152 | + } | |
| 153 | + | |
| 154 | + static func boolean(_ key: String, in object: [String: JSONValue]) -> Bool? { | |
| 155 | + if case .bool(let value)? = object[key] { return value } | |
| 156 | + return nil | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +// MARK: - read_file | |
| 161 | + | |
| 162 | +struct ReadFileTool: Tool { | |
| 163 | + /// Max bytes returned per call — page with offset/limit for bigger files. | |
| 164 | + static let byteLimit = 50_000 | |
| 165 | + | |
| 166 | + let name = "read_file" | |
| 167 | + | |
| 168 | + let description = """ | |
| 169 | + Read a text file. Paths are relative to the task workspace; reading \ | |
| 170 | + outside the workspace requires user approval. Returns numbered lines. \ | |
| 171 | + Output is capped at ~50KB per call — for larger files, page through \ | |
| 172 | + with `offset` (1-based first line) and `limit` (line count). Always \ | |
| 173 | + read a file before editing it with edit_file. | |
| 174 | + """ | |
| 175 | + | |
| 176 | + var parametersSchema: JSONValue { | |
| 177 | + .object([ | |
| 178 | + "type": .string("object"), | |
| 179 | + "properties": .object([ | |
| 180 | + "path": .object([ | |
| 181 | + "type": .string("string"), | |
| 182 | + "description": .string("File path, relative to the workspace (or absolute)."), | |
| 183 | + ]), | |
| 184 | + "offset": .object([ | |
| 185 | + "type": .string("integer"), | |
| 186 | + "description": .string("1-based line number to start reading from."), | |
| 187 | + ]), | |
| 188 | + "limit": .object([ | |
| 189 | + "type": .string("integer"), | |
| 190 | + "description": .string("Maximum number of lines to return."), | |
| 191 | + ]), | |
| 192 | + ]), | |
| 193 | + "required": .array([.string("path")]), | |
| 194 | + ]) | |
| 195 | + } | |
| 196 | + | |
| 197 | + func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { | |
| 198 | + guard case .object(let object) = arguments, | |
| 199 | + let path = FileToolSupport.string("path", in: object) else { | |
| 200 | + return .failure("read_file: missing required parameter `path`.") | |
| 201 | + } | |
| 202 | + let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) | |
| 203 | + | |
| 204 | + if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "Read \(resolved.url.path)") { | |
| 205 | + let reason = denial.reason | |
| 206 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)") | |
| 207 | + return .failure("Read not permitted: \(reason)") | |
| 208 | + } | |
| 209 | + | |
| 210 | + guard FileManager.default.fileExists(atPath: resolved.url.path) else { | |
| 211 | + return .failure("read_file: no file at \(WorkspacePath.display(resolved.url, workspace: context.workspaceURL)).") | |
| 212 | + } | |
| 213 | + guard let data = FileManager.default.contents(atPath: resolved.url.path) else { | |
| 214 | + return .failure("read_file: could not read \(resolved.url.path).") | |
| 215 | + } | |
| 216 | + guard let text = String(data: data, encoding: .utf8) else { | |
| 217 | + return .failure("read_file: \(resolved.url.lastPathComponent) is not UTF-8 text (\(data.count) bytes).") | |
| 218 | + } | |
| 219 | + | |
| 220 | + let allLines = text.components(separatedBy: "\n") | |
| 221 | + let offset = max(1, FileToolSupport.integer("offset", in: object) ?? 1) | |
| 222 | + let limit = FileToolSupport.integer("limit", in: object) ?? allLines.count | |
| 223 | + guard offset <= allLines.count else { | |
| 224 | + return .failure("read_file: offset \(offset) is past the end of the file (\(allLines.count) lines).") | |
| 225 | + } | |
| 226 | + | |
| 227 | + var out = "" | |
| 228 | + var emittedLines = 0 | |
| 229 | + var truncatedByBytes = false | |
| 230 | + var lineNumber = offset | |
| 231 | + for line in allLines.dropFirst(offset - 1) { | |
| 232 | + if emittedLines >= limit { break } | |
| 233 | + let numbered = "\(lineNumber)\t\(line)\n" | |
| 234 | + if out.utf8.count + numbered.utf8.count > Self.byteLimit { | |
| 235 | + truncatedByBytes = true | |
| 236 | + break | |
| 237 | + } | |
| 238 | + out += numbered | |
| 239 | + emittedLines += 1 | |
| 240 | + lineNumber += 1 | |
| 241 | + } | |
| 242 | + let linesRemaining = allLines.count - (lineNumber - 1) | |
| 243 | + if truncatedByBytes || (emittedLines >= limit && linesRemaining > 0) { | |
| 244 | + out += "… [truncated — file has \(allLines.count) lines; continue with offset=\(lineNumber)]\n" | |
| 245 | + } | |
| 246 | + | |
| 247 | + await FileToolSupport.audit( | |
| 248 | + context, tool: name, path: resolved.url.path, | |
| 249 | + decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil), | |
| 250 | + note: "read \(emittedLines) lines from line \(offset)" | |
| 251 | + ) | |
| 252 | + context.onOutput(.note("read \(WorkspacePath.display(resolved.url, workspace: context.workspaceURL)) (\(emittedLines) lines)")) | |
| 253 | + return .success(out.isEmpty ? "[empty file]" : out) | |
| 254 | + } | |
| 255 | +} | |
| 256 | + | |
| 257 | +// MARK: - write_file | |
| 258 | + | |
| 259 | +struct WriteFileTool: Tool { | |
| 260 | + let name = "write_file" | |
| 261 | + | |
| 262 | + let description = """ | |
| 263 | + Create or overwrite a text file with the given content. Paths are \ | |
| 264 | + relative to the task workspace; parent directories are created \ | |
| 265 | + automatically. Writing outside the workspace always requires user \ | |
| 266 | + approval. To change part of an existing file, prefer edit_file — it \ | |
| 267 | + is safer than rewriting the whole file. | |
| 268 | + """ | |
| 269 | + | |
| 270 | + var parametersSchema: JSONValue { | |
| 271 | + .object([ | |
| 272 | + "type": .string("object"), | |
| 273 | + "properties": .object([ | |
| 274 | + "path": .object([ | |
| 275 | + "type": .string("string"), | |
| 276 | + "description": .string("File path, relative to the workspace (or absolute)."), | |
| 277 | + ]), | |
| 278 | + "content": .object([ | |
| 279 | + "type": .string("string"), | |
| 280 | + "description": .string("The complete file content to write."), | |
| 281 | + ]), | |
| 282 | + ]), | |
| 283 | + "required": .array([.string("path"), .string("content")]), | |
| 284 | + ]) | |
| 285 | + } | |
| 286 | + | |
| 287 | + func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { | |
| 288 | + guard case .object(let object) = arguments, | |
| 289 | + let path = FileToolSupport.string("path", in: object), | |
| 290 | + let content = FileToolSupport.string("content", in: object) else { | |
| 291 | + return .failure("write_file: `path` and `content` are both required.") | |
| 292 | + } | |
| 293 | + let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) | |
| 294 | + let existed = FileManager.default.fileExists(atPath: resolved.url.path) | |
| 295 | + | |
| 296 | + let decision: PolicyDecisionRecord | |
| 297 | + switch await FileToolSupport.clearWrite(of: resolved, context: context, explanation: "\(existed ? "Overwrite" : "Create") \(resolved.url.path) (\(content.utf8.count) bytes)") { | |
| 298 | + case .failure(let denial): | |
| 299 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(denial.reason)") | |
| 300 | + return .failure("Write not permitted: \(denial.reason)") | |
| 301 | + case .success(let record): | |
| 302 | + decision = record | |
| 303 | + } | |
| 304 | + | |
| 305 | + do { | |
| 306 | + try FileManager.default.createDirectory(at: resolved.url.deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 307 | + try content.write(to: resolved.url, atomically: true, encoding: .utf8) | |
| 308 | + } catch { | |
| 309 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "failed: \(error.localizedDescription)") | |
| 310 | + return .failure("write_file failed: \(error.localizedDescription)") | |
| 311 | + } | |
| 312 | + | |
| 313 | + await context.workspaceManager?.noteFileTouched(resolved.url, existedBefore: existed) | |
| 314 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "\(existed ? "overwrote" : "created") \(content.utf8.count) bytes") | |
| 315 | + let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL) | |
| 316 | + context.onOutput(.note("\(existed ? "overwrote" : "created") \(display) (\(content.utf8.count) bytes)")) | |
| 317 | + return .success("\(existed ? "Overwrote" : "Created") \(display) (\(content.utf8.count) bytes).") | |
| 318 | + } | |
| 319 | +} | |
| 320 | + | |
| 321 | +// MARK: - edit_file | |
| 322 | + | |
| 323 | +struct EditFileTool: Tool { | |
| 324 | + let name = "edit_file" | |
| 325 | + | |
| 326 | + let description = """ | |
| 327 | + Replace text in an existing file. `old_string` must match the current \ | |
| 328 | + file content EXACTLY and UNIQUELY — if it matches zero or multiple \ | |
| 329 | + places the edit fails and reports the match count; include more \ | |
| 330 | + surrounding lines to make it unique, or set `replace_all` to change \ | |
| 331 | + every occurrence. Read the file first with read_file so you know its \ | |
| 332 | + exact current content. Paths resolve relative to the workspace; \ | |
| 333 | + editing outside the workspace always requires user approval. | |
| 334 | + """ | |
| 335 | + | |
| 336 | + var parametersSchema: JSONValue { | |
| 337 | + .object([ | |
| 338 | + "type": .string("object"), | |
| 339 | + "properties": .object([ | |
| 340 | + "path": .object([ | |
| 341 | + "type": .string("string"), | |
| 342 | + "description": .string("File path, relative to the workspace (or absolute)."), | |
| 343 | + ]), | |
| 344 | + "old_string": .object([ | |
| 345 | + "type": .string("string"), | |
| 346 | + "description": .string("Exact text to find (must be unique unless replace_all)."), | |
| 347 | + ]), | |
| 348 | + "new_string": .object([ | |
| 349 | + "type": .string("string"), | |
| 350 | + "description": .string("Text to replace it with."), | |
| 351 | + ]), | |
| 352 | + "replace_all": .object([ | |
| 353 | + "type": .string("boolean"), | |
| 354 | + "description": .string("Replace every occurrence instead of requiring a unique match (default false)."), | |
| 355 | + ]), | |
| 356 | + ]), | |
| 357 | + "required": .array([.string("path"), .string("old_string"), .string("new_string")]), | |
| 358 | + ]) | |
| 359 | + } | |
| 360 | + | |
| 361 | + func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { | |
| 362 | + guard case .object(let object) = arguments, | |
| 363 | + let path = FileToolSupport.string("path", in: object), | |
| 364 | + let oldString = FileToolSupport.string("old_string", in: object), | |
| 365 | + let newString = FileToolSupport.string("new_string", in: object) else { | |
| 366 | + return .failure("edit_file: `path`, `old_string`, and `new_string` are all required.") | |
| 367 | + } | |
| 368 | + guard !oldString.isEmpty else { | |
| 369 | + return .failure("edit_file: `old_string` must not be empty.") | |
| 370 | + } | |
| 371 | + guard oldString != newString else { | |
| 372 | + return .failure("edit_file: `old_string` and `new_string` are identical — nothing to change.") | |
| 373 | + } | |
| 374 | + let replaceAll = FileToolSupport.boolean("replace_all", in: object) ?? false | |
| 375 | + let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) | |
| 376 | + let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL) | |
| 377 | + | |
| 378 | + guard FileManager.default.fileExists(atPath: resolved.url.path) else { | |
| 379 | + return .failure("edit_file: no file at \(display). Use write_file to create new files.") | |
| 380 | + } | |
| 381 | + guard let data = FileManager.default.contents(atPath: resolved.url.path), | |
| 382 | + let text = String(data: data, encoding: .utf8) else { | |
| 383 | + return .failure("edit_file: could not read \(display) as UTF-8 text.") | |
| 384 | + } | |
| 385 | + | |
| 386 | + // Exact-unique match contract: fail loudly with the match count. | |
| 387 | + let matches = text.components(separatedBy: oldString).count - 1 | |
| 388 | + if matches == 0 { | |
| 389 | + return .failure("edit_file: `old_string` was not found in \(display) (0 matches). Read the file again — the content may differ in whitespace or have changed.") | |
| 390 | + } | |
| 391 | + if matches > 1 && !replaceAll { | |
| 392 | + return .failure("edit_file: `old_string` matches \(matches) places in \(display) — it must be unique. Include more surrounding context, or set replace_all=true to replace all \(matches).") | |
| 393 | + } | |
| 394 | + | |
| 395 | + let decision: PolicyDecisionRecord | |
| 396 | + switch await FileToolSupport.clearWrite(of: resolved, context: context, explanation: "Edit \(resolved.url.path) (\(matches) replacement\(matches == 1 ? "" : "s"))") { | |
| 397 | + case .failure(let denial): | |
| 398 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(denial.reason)") | |
| 399 | + return .failure("Edit not permitted: \(denial.reason)") | |
| 400 | + case .success(let record): | |
| 401 | + decision = record | |
| 402 | + } | |
| 403 | + | |
| 404 | + let updated = text.replacingOccurrences(of: oldString, with: newString) | |
| 405 | + do { | |
| 406 | + try updated.write(to: resolved.url, atomically: true, encoding: .utf8) | |
| 407 | + } catch { | |
| 408 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "failed: \(error.localizedDescription)") | |
| 409 | + return .failure("edit_file failed: \(error.localizedDescription)") | |
| 410 | + } | |
| 411 | + | |
| 412 | + await context.workspaceManager?.noteFileTouched(resolved.url, existedBefore: true) | |
| 413 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "replaced \(matches) occurrence\(matches == 1 ? "" : "s")") | |
| 414 | + context.onOutput(.note("edited \(display) (\(matches) replacement\(matches == 1 ? "" : "s"))")) | |
| 415 | + return .success("Edited \(display): replaced \(matches) occurrence\(matches == 1 ? "" : "s").") | |
| 416 | + } | |
| 417 | +} | |
| 418 | + | |
| 419 | +// MARK: - list_dir | |
| 420 | + | |
| 421 | +struct ListDirTool: Tool { | |
| 422 | + static let entryLimit = 500 | |
| 423 | + | |
| 424 | + let name = "list_dir" | |
| 425 | + | |
| 426 | + let description = """ | |
| 427 | + List files and folders. Defaults to the workspace root; pass `path` \ | |
| 428 | + for a subfolder and `depth` (default 2) to control recursion. \ | |
| 429 | + Directories end with '/'; files show their size. Listing outside the \ | |
| 430 | + workspace requires user approval. The internal `.zyquo/` folder and \ | |
| 431 | + `.git/` contents are omitted. | |
| 432 | + """ | |
| 433 | + | |
| 434 | + var parametersSchema: JSONValue { | |
| 435 | + .object([ | |
| 436 | + "type": .string("object"), | |
| 437 | + "properties": .object([ | |
| 438 | + "path": .object([ | |
| 439 | + "type": .string("string"), | |
| 440 | + "description": .string("Directory to list, relative to the workspace (default: workspace root)."), | |
| 441 | + ]), | |
| 442 | + "depth": .object([ | |
| 443 | + "type": .string("integer"), | |
| 444 | + "description": .string("How many directory levels to descend (default 2)."), | |
| 445 | + ]), | |
| 446 | + ]), | |
| 447 | + "required": .array([]), | |
| 448 | + ]) | |
| 449 | + } | |
| 450 | + | |
| 451 | + func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { | |
| 452 | + var path = "." | |
| 453 | + var depth = 2 | |
| 454 | + if case .object(let object) = arguments { | |
| 455 | + if let p = FileToolSupport.string("path", in: object) { path = p } | |
| 456 | + if let d = FileToolSupport.integer("depth", in: object) { depth = max(1, min(d, 8)) } | |
| 457 | + } | |
| 458 | + let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) | |
| 459 | + let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL) | |
| 460 | + | |
| 461 | + if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "List \(resolved.url.path)") { | |
| 462 | + let reason = denial.reason | |
| 463 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)") | |
| 464 | + return .failure("Listing not permitted: \(reason)") | |
| 465 | + } | |
| 466 | + | |
| 467 | + var isDirectory: ObjCBool = false | |
| 468 | + guard FileManager.default.fileExists(atPath: resolved.url.path, isDirectory: &isDirectory), isDirectory.boolValue else { | |
| 469 | + return .failure("list_dir: \(display) is not a directory.") | |
| 470 | + } | |
| 471 | + | |
| 472 | + var lines: [String] = ["\(display == "." ? "workspace root" : display)/"] | |
| 473 | + var count = 0 | |
| 474 | + var truncated = false | |
| 475 | + listRecursively(resolved.url, indent: " ", remainingDepth: depth, lines: &lines, count: &count, truncated: &truncated) | |
| 476 | + if truncated { | |
| 477 | + lines.append("… [listing truncated at \(Self.entryLimit) entries — list a subfolder or lower the depth]") | |
| 478 | + } | |
| 479 | + if count == 0 { | |
| 480 | + lines.append(" [empty]") | |
| 481 | + } | |
| 482 | + | |
| 483 | + await FileToolSupport.audit( | |
| 484 | + context, tool: name, path: resolved.url.path, | |
| 485 | + decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil), | |
| 486 | + note: "listed \(count) entries (depth \(depth))" | |
| 487 | + ) | |
| 488 | + return .success(lines.joined(separator: "\n")) | |
| 489 | + } | |
| 490 | + | |
| 491 | + private func listRecursively(_ directory: URL, indent: String, remainingDepth: Int, lines: inout [String], count: inout Int, truncated: inout Bool) { | |
| 492 | + guard remainingDepth > 0, !truncated else { return } | |
| 493 | + let contents = (try? FileManager.default.contentsOfDirectory( | |
| 494 | + at: directory, | |
| 495 | + includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], | |
| 496 | + options: [] | |
| 497 | + )) ?? [] | |
| 498 | + for entry in contents.sorted(by: { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }) { | |
| 499 | + if count >= Self.entryLimit { truncated = true; return } | |
| 500 | + let entryName = entry.lastPathComponent | |
| 501 | + if entryName == ".zyquo" || entryName == ".git" { continue } | |
| 502 | + let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) | |
| 503 | + if values?.isDirectory == true { | |
| 504 | + lines.append("\(indent)\(entryName)/") | |
| 505 | + count += 1 | |
| 506 | + listRecursively(entry, indent: indent + " ", remainingDepth: remainingDepth - 1, lines: &lines, count: &count, truncated: &truncated) | |
| 507 | + } else { | |
| 508 | + let size = values?.fileSize ?? 0 | |
| 509 | + lines.append("\(indent)\(entryName) (\(Self.format(bytes: size)))") | |
| 510 | + count += 1 | |
| 511 | + } | |
| 512 | + } | |
| 513 | + } | |
| 514 | + | |
| 515 | + private static func format(bytes: Int) -> String { | |
| 516 | + if bytes < 1000 { return "\(bytes) B" } | |
| 517 | + if bytes < 1_000_000 { return String(format: "%.1f KB", Double(bytes) / 1000) } | |
| 518 | + return String(format: "%.1f MB", Double(bytes) / 1_000_000) | |
| 519 | + } | |
| 520 | +} | |
| 521 | + | |
| 522 | +// MARK: - search_files | |
| 523 | + | |
| 524 | +struct SearchFilesTool: Tool { | |
| 525 | + static let matchLimit = 200 | |
| 526 | + static let scannedFileByteLimit = 2_000_000 | |
| 527 | + static let resultByteLimit = 50_000 | |
| 528 | + | |
| 529 | + let name = "search_files" | |
| 530 | + | |
| 531 | + let description = """ | |
| 532 | + Search file contents like grep. `pattern` is tried as a regular \ | |
| 533 | + expression first, then as a literal substring if the regex is \ | |
| 534 | + invalid. Searches the whole workspace by default; narrow with `path` \ | |
| 535 | + (subfolder) and `glob` (filename filter like *.swift). Returns \ | |
| 536 | + file:line: matches, capped at 200. Binary files, `.git/` and \ | |
| 537 | + `.zyquo/` are skipped. Searching outside the workspace requires user \ | |
| 538 | + approval. | |
| 539 | + """ | |
| 540 | + | |
| 541 | + var parametersSchema: JSONValue { | |
| 542 | + .object([ | |
| 543 | + "type": .string("object"), | |
| 544 | + "properties": .object([ | |
| 545 | + "pattern": .object([ | |
| 546 | + "type": .string("string"), | |
| 547 | + "description": .string("Regex (or literal substring) to search for."), | |
| 548 | + ]), | |
| 549 | + "path": .object([ | |
| 550 | + "type": .string("string"), | |
| 551 | + "description": .string("Directory to search, relative to the workspace (default: workspace root)."), | |
| 552 | + ]), | |
| 553 | + "glob": .object([ | |
| 554 | + "type": .string("string"), | |
| 555 | + "description": .string("Filename glob filter, e.g. *.swift or *.md."), | |
| 556 | + ]), | |
| 557 | + ]), | |
| 558 | + "required": .array([.string("pattern")]), | |
| 559 | + ]) | |
| 560 | + } | |
| 561 | + | |
| 562 | + func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { | |
| 563 | + guard case .object(let object) = arguments, | |
| 564 | + let pattern = FileToolSupport.string("pattern", in: object), | |
| 565 | + !pattern.isEmpty else { | |
| 566 | + return .failure("search_files: missing required parameter `pattern`.") | |
| 567 | + } | |
| 568 | + let path = FileToolSupport.string("path", in: object) ?? "." | |
| 569 | + let glob = FileToolSupport.string("glob", in: object) | |
| 570 | + let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) | |
| 571 | + | |
| 572 | + if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "Search \(resolved.url.path) for \(pattern)") { | |
| 573 | + let reason = denial.reason | |
| 574 | + await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)") | |
| 575 | + return .failure("Search not permitted: \(reason)") | |
| 576 | + } | |
| 577 | + | |
| 578 | + let regex = try? NSRegularExpression(pattern: pattern) | |
| 579 | + var matches: [String] = [] | |
| 580 | + var bytes = 0 | |
| 581 | + var filesScanned = 0 | |
| 582 | + var capped = false | |
| 583 | + | |
| 584 | + let enumerator = FileManager.default.enumerator( | |
| 585 | + at: resolved.url, | |
| 586 | + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], | |
| 587 | + options: [.skipsPackageDescendants] | |
| 588 | + ) | |
| 589 | + while let entry = enumerator?.nextObject() as? URL { | |
| 590 | + try Task.checkCancellation() | |
| 591 | + if capped { break } | |
| 592 | + let entryName = entry.lastPathComponent | |
| 593 | + if entryName == ".git" || entryName == ".zyquo" { | |
| 594 | + enumerator?.skipDescendants() | |
| 595 | + continue | |
| 596 | + } | |
| 597 | + let values = try? entry.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) | |
| 598 | + guard values?.isRegularFile == true else { continue } | |
| 599 | + if let glob, !Self.matchGlob(glob, name: entryName) { continue } | |
| 600 | + if (values?.fileSize ?? 0) > Self.scannedFileByteLimit { continue } | |
| 601 | + guard let data = FileManager.default.contents(atPath: entry.path), | |
| 602 | + !data.contains(0), | |
| 603 | + let text = String(data: data, encoding: .utf8) else { continue } | |
| 604 | + filesScanned += 1 | |
| 605 | + | |
| 606 | + let relative = WorkspacePath.display(entry, workspace: context.workspaceURL) | |
| 607 | + for (index, line) in text.components(separatedBy: "\n").enumerated() { | |
| 608 | + let hit: Bool | |
| 609 | + if let regex { | |
| 610 | + hit = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil | |
| 611 | + } else { | |
| 612 | + hit = line.contains(pattern) | |
| 613 | + } | |
| 614 | + guard hit else { continue } | |
| 615 | + let trimmed = line.count > 250 ? String(line.prefix(250)) + "…" : line | |
| 616 | + let record = "\(relative):\(index + 1): \(trimmed)" | |
| 617 | + matches.append(record) | |
| 618 | + bytes += record.utf8.count | |
| 619 | + if matches.count >= Self.matchLimit || bytes >= Self.resultByteLimit { | |
| 620 | + capped = true | |
| 621 | + break | |
| 622 | + } | |
| 623 | + } | |
| 624 | + } | |
| 625 | + | |
| 626 | + await FileToolSupport.audit( | |
| 627 | + context, tool: name, path: resolved.url.path, | |
| 628 | + decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil), | |
| 629 | + note: "pattern \(pattern): \(matches.count) matches in \(filesScanned) files" | |
| 630 | + ) | |
| 631 | + | |
| 632 | + if matches.isEmpty { | |
| 633 | + return .success("No matches for \(regex != nil ? "regex" : "substring") “\(pattern)” (\(filesScanned) files scanned).") | |
| 634 | + } | |
| 635 | + var output = matches.joined(separator: "\n") | |
| 636 | + if capped { | |
| 637 | + output += "\n… [capped at \(matches.count) matches — refine the pattern, path, or glob]" | |
| 638 | + } | |
| 639 | + return .success(output) | |
| 640 | + } | |
| 641 | + | |
| 642 | + /// Minimal glob: `*` and `?` on the filename (fnmatch-style, no slashes). | |
| 643 | + static func matchGlob(_ glob: String, name: String) -> Bool { | |
| 644 | + var regexPattern = "^" | |
| 645 | + for ch in glob { | |
| 646 | + switch ch { | |
| 647 | + case "*": regexPattern += ".*" | |
| 648 | + case "?": regexPattern += "." | |
| 649 | + default: regexPattern += NSRegularExpression.escapedPattern(for: String(ch)) | |
| 650 | + } | |
| 651 | + } | |
| 652 | + regexPattern += "$" | |
| 653 | + guard let regex = try? NSRegularExpression(pattern: regexPattern, options: [.caseInsensitive]) else { return false } | |
| 654 | + return regex.firstMatch(in: name, range: NSRange(name.startIndex..., in: name)) != nil | |
| 655 | + } | |
| 656 | +} | |
added
Sources/ZyquoAgent/Tools/ShellTool.swift
+142 −0
@@ -0,0 +1,142 @@ | ||
| 1 | +// | |
| 2 | +// ShellTool.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The agent's `bash` tool: runs a shell command inside the task workspace. | |
| 9 | +// Every call is (1) cleared by the PolicyEngine — never bypassed, in any | |
| 10 | +// mode — (2) executed by ExecutionService with live line streaming and a | |
| 11 | +// timeout, and (3) appended to the AuditLog with its exit code and a | |
| 12 | +// truncated output excerpt. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +struct ShellTool: Tool { | |
| 18 | + private let executor: ExecutionService | |
| 19 | + | |
| 20 | + init(executor: ExecutionService) { | |
| 21 | + self.executor = executor | |
| 22 | + } | |
| 23 | + | |
| 24 | + let name = "bash" | |
| 25 | + | |
| 26 | + let description = """ | |
| 27 | + Run a shell command with /bin/bash inside the task workspace (the \ | |
| 28 | + working directory). Use it for anything a terminal can do: creating \ | |
| 29 | + files and folders, running scripts, installing project dependencies, \ | |
| 30 | + inspecting the system. stdout and stderr stream live to the user and \ | |
| 31 | + are returned with the exit code. Commands may be blocked or held for \ | |
| 32 | + user approval by the safety policy — if a command is denied, explain \ | |
| 33 | + the situation to the user or find a safer approach instead of \ | |
| 34 | + retrying the same command. Prefer the dedicated file tools \ | |
| 35 | + (read_file, write_file, edit_file, list_dir, search_files) for file \ | |
| 36 | + content work; prefer relative paths, which resolve inside the \ | |
| 37 | + workspace. Long or interactive commands will hit the timeout — pass \ | |
| 38 | + timeout_seconds for legitimately slow commands. | |
| 39 | + """ | |
| 40 | + | |
| 41 | + var parametersSchema: JSONValue { | |
| 42 | + .object([ | |
| 43 | + "type": .string("object"), | |
| 44 | + "properties": .object([ | |
| 45 | + "command": .object([ | |
| 46 | + "type": .string("string"), | |
| 47 | + "description": .string("The exact bash command to execute."), | |
| 48 | + ]), | |
| 49 | + "timeout_seconds": .object([ | |
| 50 | + "type": .string("integer"), | |
| 51 | + "description": .string("Optional timeout in seconds for this command (default 120)."), | |
| 52 | + ]), | |
| 53 | + "explanation": .object([ | |
| 54 | + "type": .string("string"), | |
| 55 | + "description": .string("One short sentence explaining what this command does and why — shown to the user on approval cards."), | |
| 56 | + ]), | |
| 57 | + ]), | |
| 58 | + "required": .array([.string("command")]), | |
| 59 | + ]) | |
| 60 | + } | |
| 61 | + | |
| 62 | + func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { | |
| 63 | + guard case .object(let object) = arguments, | |
| 64 | + case .string(let command)? = object["command"], | |
| 65 | + !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { | |
| 66 | + return .failure("bash: missing required parameter `command`.") | |
| 67 | + } | |
| 68 | + var timeout: TimeInterval? | |
| 69 | + if case .number(let seconds)? = object["timeout_seconds"], seconds > 0 { | |
| 70 | + timeout = seconds | |
| 71 | + } | |
| 72 | + var explanation: String? | |
| 73 | + if case .string(let text)? = object["explanation"] { | |
| 74 | + explanation = text | |
| 75 | + } | |
| 76 | + | |
| 77 | + // ---- Safety gate (never skipped) -------------------------------- | |
| 78 | + let cleared: ClearedAction | |
| 79 | + do { | |
| 80 | + cleared = try await context.policy.clear(ActionRequest( | |
| 81 | + kind: .shellCommand, | |
| 82 | + payload: command, | |
| 83 | + cwd: context.workspaceURL, | |
| 84 | + explanation: explanation | |
| 85 | + )) | |
| 86 | + } catch let denial as PolicyDenied { | |
| 87 | + await context.audit.append(AuditEntry( | |
| 88 | + actionKind: name, | |
| 89 | + payload: command, | |
| 90 | + cwd: context.workspaceURL.path, | |
| 91 | + ruling: PolicyDecisionRecord.Ruling.denied.rawValue, | |
| 92 | + exitCode: nil, | |
| 93 | + outputExcerpt: denial.reason | |
| 94 | + )) | |
| 95 | + return .failure("Command not run — denied by the safety policy: \(denial.reason)") | |
| 96 | + } | |
| 97 | + | |
| 98 | + // ---- Execute ------------------------------------------------------ | |
| 99 | + let result: ExecutionResult | |
| 100 | + do { | |
| 101 | + result = try await executor.runBash( | |
| 102 | + command: cleared.payload, | |
| 103 | + cwd: context.workspaceURL, | |
| 104 | + timeout: timeout, | |
| 105 | + onOutput: context.onOutput | |
| 106 | + ) | |
| 107 | + } catch let launchError as ExecutionLaunchError { | |
| 108 | + await context.audit.append(AuditEntry( | |
| 109 | + actionKind: name, | |
| 110 | + payload: cleared.payload, | |
| 111 | + cwd: context.workspaceURL.path, | |
| 112 | + ruling: cleared.decision.ruling.rawValue, | |
| 113 | + exitCode: nil, | |
| 114 | + outputExcerpt: launchError.reason | |
| 115 | + )) | |
| 116 | + return .failure("bash failed to launch: \(launchError.reason)") | |
| 117 | + } | |
| 118 | + | |
| 119 | + // ---- Audit --------------------------------------------------------- | |
| 120 | + await context.audit.append(AuditEntry( | |
| 121 | + actionKind: name, | |
| 122 | + payload: cleared.payload, | |
| 123 | + cwd: context.workspaceURL.path, | |
| 124 | + ruling: cleared.decision.ruling.rawValue, | |
| 125 | + exitCode: result.exitCode, | |
| 126 | + outputExcerpt: result.combinedOutput | |
| 127 | + )) | |
| 128 | + | |
| 129 | + // ---- Result for the model ------------------------------------------- | |
| 130 | + var content = result.combinedOutput | |
| 131 | + if result.isTimeout { | |
| 132 | + content += "\n[timed out after \(Int(timeout ?? 120))s — process was killed]" | |
| 133 | + } | |
| 134 | + if result.isCancelled { | |
| 135 | + content += "\n[cancelled by the user]" | |
| 136 | + } | |
| 137 | + content += "\n[exit code: \(result.exitCode.map(String.init) ?? "none")]" | |
| 138 | + | |
| 139 | + let failed = result.isTimeout || result.isCancelled || (result.exitCode ?? 1) != 0 | |
| 140 | + return ToolExecutionResult(content: content, isError: failed) | |
| 141 | + } | |
| 142 | +} | |
modified
Sources/ZyquoAgent/Tools/Tool.swift
+3 −0
@@ -34,6 +34,9 @@ struct ToolExecutionContext: Sendable { | ||
| 34 | 34 | let audit: AuditLog |
| 35 | 35 | /// Streams live output chunks to the UI as they happen. |
| 36 | 36 | let onOutput: @Sendable (ToolOutputChunk) -> Void |
| 37 | + /// Tracks files the agent creates/modifies (badges, checkpoints). Optional | |
| 38 | + /// so contexts without a managed workspace (tests, scratch runs) still work. | |
| 39 | + var workspaceManager: WorkspaceManager? = nil | |
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | /// Outcome of one tool call, before it is threaded back to the model as a |
added
Sources/ZyquoAgent/Tools/ToolRegistry.swift
+182 −0
@@ -0,0 +1,182 @@ | ||
| 1 | +// | |
| 2 | +// ToolRegistry.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The catalog of tools the agent can use. Hands the model the ToolSpecs, | |
| 9 | +// resolves incoming ToolCalls by name, decodes and validates their JSON | |
| 10 | +// arguments against the tool's schema, executes, and normalizes every | |
| 11 | +// failure class (unknown tool, malformed JSON, missing parameters, policy | |
| 12 | +// denial, cancellation) into an error ToolResult the model can act on. | |
| 13 | +// Adding a tool = conform to `Tool` + append it here (or pass a custom list | |
| 14 | +// to `init`). | |
| 15 | +// | |
| 16 | + | |
| 17 | +import Foundation | |
| 18 | + | |
| 19 | +struct ToolRegistry: Sendable { | |
| 20 | + private let tools: [any Tool] | |
| 21 | + private let toolsByName: [String: any Tool] | |
| 22 | + | |
| 23 | + init(tools: [any Tool]) { | |
| 24 | + self.tools = tools | |
| 25 | + var index: [String: any Tool] = [:] | |
| 26 | + for tool in tools { | |
| 27 | + precondition(index[tool.name] == nil, "Duplicate tool name: \(tool.name)") | |
| 28 | + index[tool.name] = tool | |
| 29 | + } | |
| 30 | + self.toolsByName = index | |
| 31 | + } | |
| 32 | + | |
| 33 | + /// The standard Zyquo Agent tool set: bash, osascript, and the five | |
| 34 | + /// workspace file tools. | |
| 35 | + static func standard(executor: ExecutionService) -> ToolRegistry { | |
| 36 | + ToolRegistry(tools: [ | |
| 37 | + ShellTool(executor: executor), | |
| 38 | + AppleScriptTool(executor: executor), | |
| 39 | + ReadFileTool(), | |
| 40 | + WriteFileTool(), | |
| 41 | + EditFileTool(), | |
| 42 | + ListDirTool(), | |
| 43 | + SearchFilesTool(), | |
| 44 | + ]) | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// Provider-neutral specs handed to the model on every request. | |
| 48 | + var toolSpecs: [ToolSpec] { | |
| 49 | + tools.map(\.toolSpec) | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// All registered tool names, in registration order. | |
| 53 | + var toolNames: [String] { | |
| 54 | + tools.map(\.name) | |
| 55 | + } | |
| 56 | + | |
| 57 | + func tool(named name: String) -> (any Tool)? { | |
| 58 | + toolsByName[name] | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Executes one model-issued tool call end to end and returns the | |
| 62 | + /// ToolResult to thread back into the conversation. Never throws for | |
| 63 | + /// tool-level failures — the model must see them as error results so it | |
| 64 | + /// can self-correct; only Task cancellation escapes as a thrown error. | |
| 65 | + func execute(call: ToolCall, context: ToolExecutionContext) async throws -> ToolResult { | |
| 66 | + guard let tool = toolsByName[call.name] else { | |
| 67 | + return ToolResult( | |
| 68 | + toolCallID: call.id, | |
| 69 | + content: "Unknown tool “\(call.name)”. Available tools: \(toolNames.joined(separator: ", ")).", | |
| 70 | + isError: true | |
| 71 | + ) | |
| 72 | + } | |
| 73 | + | |
| 74 | + // Decode the raw argument JSON. An empty string counts as {} — | |
| 75 | + // some providers omit arguments for parameterless calls. | |
| 76 | + let rawJSON = call.argumentsJSON.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 77 | + let arguments: JSONValue | |
| 78 | + if rawJSON.isEmpty { | |
| 79 | + arguments = .object([:]) | |
| 80 | + } else if let parsed = JSONValue.parse(rawJSON) { | |
| 81 | + arguments = parsed | |
| 82 | + } else { | |
| 83 | + return ToolResult( | |
| 84 | + toolCallID: call.id, | |
| 85 | + content: "The arguments for \(call.name) were not valid JSON. Re-issue the call with a well-formed JSON object.", | |
| 86 | + isError: true | |
| 87 | + ) | |
| 88 | + } | |
| 89 | + guard case .object = arguments else { | |
| 90 | + return ToolResult( | |
| 91 | + toolCallID: call.id, | |
| 92 | + content: "The arguments for \(call.name) must be a JSON object, got: \(rawJSON.prefix(100)).", | |
| 93 | + isError: true | |
| 94 | + ) | |
| 95 | + } | |
| 96 | + | |
| 97 | + // Validate against the tool's schema (required keys + basic types). | |
| 98 | + if let problem = Self.validate(arguments: arguments, against: tool.parametersSchema, toolName: call.name) { | |
| 99 | + return ToolResult(toolCallID: call.id, content: problem, isError: true) | |
| 100 | + } | |
| 101 | + | |
| 102 | + do { | |
| 103 | + let outcome = try await tool.execute(arguments: arguments, context: context) | |
| 104 | + return ToolResult(toolCallID: call.id, content: outcome.content, isError: outcome.isError) | |
| 105 | + } catch is CancellationError { | |
| 106 | + throw CancellationError() | |
| 107 | + } catch let denial as PolicyDenied { | |
| 108 | + // Tools normally convert denials themselves; this is a backstop. | |
| 109 | + return ToolResult( | |
| 110 | + toolCallID: call.id, | |
| 111 | + content: "Action denied by the safety policy: \(denial.reason)", | |
| 112 | + isError: true | |
| 113 | + ) | |
| 114 | + } catch { | |
| 115 | + return ToolResult( | |
| 116 | + toolCallID: call.id, | |
| 117 | + content: "\(call.name) failed: \(error.localizedDescription)", | |
| 118 | + isError: true | |
| 119 | + ) | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + // MARK: Schema validation | |
| 124 | + | |
| 125 | + /// Checks `required` properties are present and that provided values | |
| 126 | + /// match the schema's declared primitive types. Returns a model-actionable | |
| 127 | + /// problem description, or nil when valid. (Full JSON-Schema validation | |
| 128 | + /// is intentionally out of scope — schemas stay in the flat common | |
| 129 | + /// subset per docs/AGENT-RESEARCH.md §2.1.) | |
| 130 | + static func validate(arguments: JSONValue, against schema: JSONValue, toolName: String) -> String? { | |
| 131 | + guard case .object(let args) = arguments, | |
| 132 | + case .object(let schemaObject) = schema else { return nil } | |
| 133 | + | |
| 134 | + if case .array(let required)? = schemaObject["required"] { | |
| 135 | + for entry in required { | |
| 136 | + if case .string(let key) = entry, args[key] == nil { | |
| 137 | + return "\(toolName): missing required parameter `\(key)`." | |
| 138 | + } | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 142 | + if case .object(let properties)? = schemaObject["properties"] { | |
| 143 | + for (key, value) in args { | |
| 144 | + guard case .object(let property)? = properties[key] else { | |
| 145 | + // Unknown extra keys are tolerated (models add them). | |
| 146 | + continue | |
| 147 | + } | |
| 148 | + guard case .string(let expected)? = property["type"] else { continue } | |
| 149 | + if let problem = typeMismatch(value: value, expected: expected, key: key, toolName: toolName) { | |
| 150 | + return problem | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | + return nil | |
| 155 | + } | |
| 156 | + | |
| 157 | + private static func typeMismatch(value: JSONValue, expected: String, key: String, toolName: String) -> String? { | |
| 158 | + let actual: String | |
| 159 | + switch value { | |
| 160 | + case .null: actual = "null" | |
| 161 | + case .bool: actual = "boolean" | |
| 162 | + case .number(let n): actual = n.truncatingRemainder(dividingBy: 1) == 0 ? "integer" : "number" | |
| 163 | + case .string: actual = "string" | |
| 164 | + case .array: actual = "array" | |
| 165 | + case .object: actual = "object" | |
| 166 | + } | |
| 167 | + let compatible: Bool | |
| 168 | + switch expected { | |
| 169 | + case "integer": compatible = actual == "integer" | |
| 170 | + case "number": compatible = actual == "integer" || actual == "number" | |
| 171 | + case "boolean": compatible = actual == "boolean" | |
| 172 | + case "string": compatible = actual == "string" | |
| 173 | + case "array": compatible = actual == "array" | |
| 174 | + case "object": compatible = actual == "object" | |
| 175 | + default: compatible = true | |
| 176 | + } | |
| 177 | + if !compatible { | |
| 178 | + return "\(toolName): parameter `\(key)` should be \(expected), got \(actual)." | |
| 179 | + } | |
| 180 | + return nil | |
| 181 | + } | |
| 182 | +} | |
added
Sources/ZyquoAgent/Workspace/WorkspaceManager.swift
+282 −0
@@ -0,0 +1,282 @@ | ||
| 1 | +// | |
| 2 | +// WorkspaceManager.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// One task = one working directory under | |
| 9 | +// ~/Library/Application Support/ZyquoAgent/Workspaces/<slug>-<shortid>/. | |
| 10 | +// The workspace is the shell tool's cwd and the file tools' root; escaping | |
| 11 | +// it requires explicit user approval (PolicyEngine). The manager tracks | |
| 12 | +// every file the agent creates or modifies — via explicit notes from the | |
| 13 | +// file tools plus a baseline-mtime refresh scan that also catches files | |
| 14 | +// shell commands touched — powers the Files tab's created/modified badges, | |
| 15 | +// and snapshots agent-touched files into .zyquo/checkpoints/ on demand. | |
| 16 | +// Internal bookkeeping (offloaded outputs, scripts, MEMORY.md later, | |
| 17 | +// checkpoints) lives in the workspace's `.zyquo/` folder, which is excluded | |
| 18 | +// from tracking and listings. | |
| 19 | +// | |
| 20 | + | |
| 21 | +import Foundation | |
| 22 | + | |
| 23 | +/// How a tracked file relates to the workspace baseline. | |
| 24 | +enum WorkspaceFileStatus: String, Codable, Sendable { | |
| 25 | + case created | |
| 26 | + case modified | |
| 27 | +} | |
| 28 | + | |
| 29 | +/// One agent-touched file, for the Files tab and checkpoints. | |
| 30 | +struct WorkspaceFileEntry: Codable, Identifiable, Sendable { | |
| 31 | + /// Workspace-relative path (also the stable identity). | |
| 32 | + var path: String | |
| 33 | + var status: WorkspaceFileStatus | |
| 34 | + var firstTouched: Date | |
| 35 | + var lastTouched: Date | |
| 36 | + | |
| 37 | + var id: String { path } | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// Errors surfaced with enough context to fix the problem. | |
| 41 | +struct WorkspaceError: Error, Sendable, CustomStringConvertible { | |
| 42 | + var description: String | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// Actor: tracking state mutates from tool calls and refresh scans. | |
| 46 | +actor WorkspaceManager { | |
| 47 | + /// The workspace directory (the agent's cwd / file-tool root). | |
| 48 | + nonisolated let root: URL | |
| 49 | + /// `.zyquo/` — internal files, never tracked or listed. | |
| 50 | + nonisolated var internalDirectory: URL { root.appendingPathComponent(".zyquo") } | |
| 51 | + nonisolated var checkpointsDirectory: URL { internalDirectory.appendingPathComponent("checkpoints") } | |
| 52 | + nonisolated var outputsDirectory: URL { internalDirectory.appendingPathComponent("outputs") } | |
| 53 | + | |
| 54 | + /// mtimes of everything present when the workspace was opened — | |
| 55 | + /// anything newer or absent from this map is agent-created/modified. | |
| 56 | + private var baseline: [String: Date] | |
| 57 | + private var tracked: [String: WorkspaceFileEntry] = [:] | |
| 58 | + | |
| 59 | + private static let stateFileName = "state.json" | |
| 60 | + | |
| 61 | + // MARK: Creation | |
| 62 | + | |
| 63 | + /// Creates a fresh workspace `<slug>-<shortid>/` for a new task. | |
| 64 | + init(taskTitle: String, persistence: PersistenceService = .shared) throws { | |
| 65 | + let slug = Self.slug(from: taskTitle) | |
| 66 | + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(6).lowercased() | |
| 67 | + let directory = persistence.workspacesDirectory.appendingPathComponent("\(slug)-\(shortID)") | |
| 68 | + try Self.prepare(directory: directory) | |
| 69 | + self.root = directory | |
| 70 | + self.baseline = [:] | |
| 71 | + Self.persist( | |
| 72 | + state: PersistedState(baseline: [:], tracked: []), | |
| 73 | + to: directory.appendingPathComponent(".zyquo").appendingPathComponent(Self.stateFileName) | |
| 74 | + ) | |
| 75 | + } | |
| 76 | + | |
| 77 | + /// Reattaches an existing workspace (reopening a past task keeps its | |
| 78 | + /// files, tracking state, and checkpoints intact). | |
| 79 | + init(existingAt directory: URL) throws { | |
| 80 | + guard FileManager.default.fileExists(atPath: directory.path) else { | |
| 81 | + throw WorkspaceError(description: "No workspace at \(directory.path).") | |
| 82 | + } | |
| 83 | + self.root = directory | |
| 84 | + let stateURL = directory.appendingPathComponent(".zyquo").appendingPathComponent(Self.stateFileName) | |
| 85 | + if let data = try? Data(contentsOf: stateURL), | |
| 86 | + let state = try? Self.decoder.decode(PersistedState.self, from: data) { | |
| 87 | + self.baseline = state.baseline | |
| 88 | + self.tracked = Dictionary(uniqueKeysWithValues: state.tracked.map { ($0.path, $0) }) | |
| 89 | + } else { | |
| 90 | + // No saved state: baseline = current content, nothing tracked yet. | |
| 91 | + self.baseline = Self.scanMTimes(under: directory) | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + /// A throwaway workspace under the system temp dir — for tests and the | |
| 96 | + /// Phase 7 evaluation harness (never pollutes the user's Workspaces/). | |
| 97 | + static func scratch(label: String = "scratch") throws -> WorkspaceManager { | |
| 98 | + let directory = FileManager.default.temporaryDirectory | |
| 99 | + .appendingPathComponent("ZyquoAgent-\(label)-\(UUID().uuidString.prefix(8))") | |
| 100 | + try prepare(directory: directory) | |
| 101 | + return try WorkspaceManager(existingAt: directory) | |
| 102 | + } | |
| 103 | + | |
| 104 | + private static func prepare(directory: URL) throws { | |
| 105 | + let fm = FileManager.default | |
| 106 | + try fm.createDirectory(at: directory, withIntermediateDirectories: true) | |
| 107 | + try fm.createDirectory(at: directory.appendingPathComponent(".zyquo"), withIntermediateDirectories: true) | |
| 108 | + } | |
| 109 | + | |
| 110 | + // MARK: File tracking | |
| 111 | + | |
| 112 | + /// Called by file tools right after a write. `existedBefore` disambiguates | |
| 113 | + /// created vs. modified for files the baseline has never seen. | |
| 114 | + func noteFileTouched(_ url: URL, existedBefore: Bool) { | |
| 115 | + guard let relative = relativePath(of: url) else { return } // outside or .zyquo — not tracked | |
| 116 | + let now = Date() | |
| 117 | + if var entry = tracked[relative] { | |
| 118 | + entry.lastTouched = now | |
| 119 | + tracked[relative] = entry | |
| 120 | + } else { | |
| 121 | + let status: WorkspaceFileStatus = (baseline[relative] != nil || existedBefore) ? .modified : .created | |
| 122 | + tracked[relative] = WorkspaceFileEntry(path: relative, status: status, firstTouched: now, lastTouched: now) | |
| 123 | + } | |
| 124 | + persistState() | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Rescans the workspace against the baseline, catching files created or | |
| 128 | + /// modified by shell commands (which cannot self-report like file tools). | |
| 129 | + /// Call after tool execution steps and before rendering the Files tab. | |
| 130 | + func refreshScan() { | |
| 131 | + let current = Self.scanMTimes(under: root) | |
| 132 | + let now = Date() | |
| 133 | + for (path, mtime) in current { | |
| 134 | + if let baselineMTime = baseline[path] { | |
| 135 | + if mtime > baselineMTime, tracked[path] == nil { | |
| 136 | + tracked[path] = WorkspaceFileEntry(path: path, status: .modified, firstTouched: now, lastTouched: mtime) | |
| 137 | + } else if mtime > baselineMTime, var entry = tracked[path], mtime > entry.lastTouched { | |
| 138 | + entry.lastTouched = mtime | |
| 139 | + tracked[path] = entry | |
| 140 | + } | |
| 141 | + } else if tracked[path] == nil { | |
| 142 | + tracked[path] = WorkspaceFileEntry(path: path, status: .created, firstTouched: now, lastTouched: mtime) | |
| 143 | + } | |
| 144 | + } | |
| 145 | + // Files that disappeared are dropped from tracking. | |
| 146 | + tracked = tracked.filter { current[$0.key] != nil } | |
| 147 | + persistState() | |
| 148 | + } | |
| 149 | + | |
| 150 | + /// Agent-touched files with their created/modified badges, for the UI. | |
| 151 | + func files() -> [WorkspaceFileEntry] { | |
| 152 | + tracked.values.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } | |
| 153 | + } | |
| 154 | + | |
| 155 | + // MARK: Checkpoints | |
| 156 | + | |
| 157 | + /// Copies every agent-touched file into `.zyquo/checkpoints/<n>-<label>/` | |
| 158 | + /// (preserving relative paths) and returns the checkpoint folder. | |
| 159 | + func checkpoint(label: String) throws -> URL { | |
| 160 | + refreshScan() | |
| 161 | + let fm = FileManager.default | |
| 162 | + try fm.createDirectory(at: checkpointsDirectory, withIntermediateDirectories: true) | |
| 163 | + let existing = (try? fm.contentsOfDirectory(atPath: checkpointsDirectory.path))?.count ?? 0 | |
| 164 | + let folder = checkpointsDirectory.appendingPathComponent("\(existing + 1)-\(Self.slug(from: label))") | |
| 165 | + try fm.createDirectory(at: folder, withIntermediateDirectories: true) | |
| 166 | + | |
| 167 | + for entry in tracked.values { | |
| 168 | + let source = root.appendingPathComponent(entry.path) | |
| 169 | + guard fm.fileExists(atPath: source.path) else { continue } | |
| 170 | + let destination = folder.appendingPathComponent(entry.path) | |
| 171 | + try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 172 | + if fm.fileExists(atPath: destination.path) { | |
| 173 | + try fm.removeItem(at: destination) | |
| 174 | + } | |
| 175 | + try fm.copyItem(at: source, to: destination) | |
| 176 | + } | |
| 177 | + return folder | |
| 178 | + } | |
| 179 | + | |
| 180 | + /// Existing checkpoint folders, oldest first. | |
| 181 | + func checkpoints() -> [URL] { | |
| 182 | + let contents = (try? FileManager.default.contentsOfDirectory( | |
| 183 | + at: checkpointsDirectory, | |
| 184 | + includingPropertiesForKeys: nil | |
| 185 | + )) ?? [] | |
| 186 | + return contents.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } | |
| 187 | + } | |
| 188 | + | |
| 189 | + // MARK: Helpers | |
| 190 | + | |
| 191 | + /// Workspace-relative path, or nil for anything outside or under .zyquo/. | |
| 192 | + private func relativePath(of url: URL) -> String? { | |
| 193 | + let path = (url.path as NSString).standardizingPath | |
| 194 | + let rootPath = (root.path as NSString).standardizingPath | |
| 195 | + guard path.hasPrefix(rootPath + "/") else { return nil } | |
| 196 | + let relative = String(path.dropFirst(rootPath.count + 1)) | |
| 197 | + guard !relative.hasPrefix(".zyquo") else { return nil } | |
| 198 | + return relative | |
| 199 | + } | |
| 200 | + | |
| 201 | + /// All regular files under the workspace (excluding .zyquo/ and .git/), | |
| 202 | + /// keyed by relative path, valued by modification date. | |
| 203 | + private static func scanMTimes(under root: URL) -> [String: Date] { | |
| 204 | + var result: [String: Date] = [:] | |
| 205 | + let fm = FileManager.default | |
| 206 | + guard let enumerator = fm.enumerator( | |
| 207 | + at: root, | |
| 208 | + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], | |
| 209 | + options: [] | |
| 210 | + ) else { return result } | |
| 211 | + let rootPath = (root.path as NSString).standardizingPath | |
| 212 | + while let entry = enumerator.nextObject() as? URL { | |
| 213 | + let entryName = entry.lastPathComponent | |
| 214 | + if entryName == ".zyquo" || entryName == ".git" { | |
| 215 | + enumerator.skipDescendants() | |
| 216 | + continue | |
| 217 | + } | |
| 218 | + guard let values = try? entry.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]), | |
| 219 | + values.isRegularFile == true else { continue } | |
| 220 | + let path = (entry.path as NSString).standardizingPath | |
| 221 | + guard path.hasPrefix(rootPath + "/") else { continue } | |
| 222 | + result[String(path.dropFirst(rootPath.count + 1))] = values.contentModificationDate ?? .distantPast | |
| 223 | + } | |
| 224 | + return result | |
| 225 | + } | |
| 226 | + | |
| 227 | + /// Filesystem-safe slug: lowercase alphanumerics and dashes, ≤ 40 chars. | |
| 228 | + static func slug(from title: String) -> String { | |
| 229 | + var slug = "" | |
| 230 | + var lastWasDash = true | |
| 231 | + for scalar in title.lowercased().unicodeScalars { | |
| 232 | + if CharacterSet.alphanumerics.contains(scalar), scalar.isASCII { | |
| 233 | + slug.append(Character(scalar)) | |
| 234 | + lastWasDash = false | |
| 235 | + } else if !lastWasDash { | |
| 236 | + slug.append("-") | |
| 237 | + lastWasDash = true | |
| 238 | + } | |
| 239 | + if slug.count >= 40 { break } | |
| 240 | + } | |
| 241 | + while slug.hasSuffix("-") { slug.removeLast() } | |
| 242 | + return slug.isEmpty ? "task" : slug | |
| 243 | + } | |
| 244 | + | |
| 245 | + // MARK: State persistence (.zyquo/state.json) | |
| 246 | + | |
| 247 | + private struct PersistedState: Codable { | |
| 248 | + var baseline: [String: Date] | |
| 249 | + var tracked: [WorkspaceFileEntry] | |
| 250 | + } | |
| 251 | + | |
| 252 | + private static let encoder: JSONEncoder = { | |
| 253 | + let encoder = JSONEncoder() | |
| 254 | + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 255 | + encoder.dateEncodingStrategy = .iso8601 | |
| 256 | + return encoder | |
| 257 | + }() | |
| 258 | + | |
| 259 | + private static let decoder: JSONDecoder = { | |
| 260 | + let decoder = JSONDecoder() | |
| 261 | + decoder.dateDecodingStrategy = .iso8601 | |
| 262 | + return decoder | |
| 263 | + }() | |
| 264 | + | |
| 265 | + private func persistState() { | |
| 266 | + Self.persist( | |
| 267 | + state: PersistedState(baseline: baseline, tracked: Array(tracked.values)), | |
| 268 | + to: internalDirectory.appendingPathComponent(Self.stateFileName) | |
| 269 | + ) | |
| 270 | + } | |
| 271 | + | |
| 272 | + /// Nonisolated worker shared by the isolated `persistState()` and the | |
| 273 | + /// initializers (which run outside actor isolation). | |
| 274 | + private static func persist(state: PersistedState, to url: URL) { | |
| 275 | + do { | |
| 276 | + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 277 | + try encoder.encode(state).write(to: url, options: .atomic) | |
| 278 | + } catch { | |
| 279 | + FileHandle.standardError.write(Data("WorkspaceManager state save failed: \(error)\n".utf8)) | |
| 280 | + } | |
| 281 | + } | |
| 282 | +} | |
added
Tests/ZyquoAgentTests/PolicyEngineTests.swift
+185 −0
@@ -0,0 +1,185 @@ | ||
| 1 | +// | |
| 2 | +// PolicyEngineTests.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Safety-property tests for the PolicyEngine (docs/AGENT-RESEARCH.md | |
| 9 | +// §6.3–6.4). These mirror the runtime self-check reachable via | |
| 10 | +// `ZyquoAgent --verify-policy` — the Command Line Tools toolchain used to | |
| 11 | +// build this repo ships no XCTest, so CI/Xcode users run these while local | |
| 12 | +// iteration uses the self-check. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import XCTest | |
| 16 | +@testable import ZyquoAgent | |
| 17 | + | |
| 18 | +final class PolicyEngineTests: XCTestCase { | |
| 19 | + | |
| 20 | + private struct DenyAllApprovals: ApprovalPresenting { | |
| 21 | + func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution { | |
| 22 | + .deny | |
| 23 | + } | |
| 24 | + } | |
| 25 | + | |
| 26 | + private var scratchRoot: URL! | |
| 27 | + private var workspace: URL! | |
| 28 | + private var persistence: PersistenceService! | |
| 29 | + | |
| 30 | + override func setUpWithError() throws { | |
| 31 | + scratchRoot = FileManager.default.temporaryDirectory | |
| 32 | + .appendingPathComponent("ZyquoAgent-policytests-\(UUID().uuidString.prefix(8))") | |
| 33 | + workspace = scratchRoot.appendingPathComponent("workspace") | |
| 34 | + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) | |
| 35 | + persistence = PersistenceService(rootDirectory: scratchRoot.appendingPathComponent("data")) | |
| 36 | + } | |
| 37 | + | |
| 38 | + override func tearDownWithError() throws { | |
| 39 | + try? FileManager.default.removeItem(at: scratchRoot) | |
| 40 | + } | |
| 41 | + | |
| 42 | + private func engine(_ mode: SafetyMode) -> PolicyEngine { | |
| 43 | + PolicyEngine(mode: mode, approvals: DenyAllApprovals(), persistence: persistence) | |
| 44 | + } | |
| 45 | + | |
| 46 | + private func shell(_ command: String) -> ActionRequest { | |
| 47 | + ActionRequest(kind: .shellCommand, payload: command, cwd: workspace, explanation: nil) | |
| 48 | + } | |
| 49 | + | |
| 50 | + private func assertAsks(_ ruling: PolicyRuling, _ message: String, file: StaticString = #filePath, line: UInt = #line) { | |
| 51 | + if case .ask = ruling { return } | |
| 52 | + XCTFail(message, file: file, line: line) | |
| 53 | + } | |
| 54 | + | |
| 55 | + private func assertAllows(_ ruling: PolicyRuling, _ message: String, file: StaticString = #filePath, line: UInt = #line) { | |
| 56 | + if case .allow = ruling { return } | |
| 57 | + XCTFail(message, file: file, line: line) | |
| 58 | + } | |
| 59 | + | |
| 60 | + private func assertDenies(_ ruling: PolicyRuling, _ message: String, file: StaticString = #filePath, line: UInt = #line) { | |
| 61 | + if case .deny = ruling { return } | |
| 62 | + XCTFail(message, file: file, line: line) | |
| 63 | + } | |
| 64 | + | |
| 65 | + // MARK: The mandated safety cases | |
| 66 | + | |
| 67 | + func testSudoAlwaysAsksInAutonomous() async { | |
| 68 | + let ruling = await engine(.autonomous).evaluate(shell("sudo whoami")) | |
| 69 | + assertAsks(ruling, "sudo must ask even in Autonomous mode") | |
| 70 | + } | |
| 71 | + | |
| 72 | + func testRMRFOutsideWorkspaceAsks() async { | |
| 73 | + let ruling = await engine(.autonomous).evaluate(shell("rm -rf ~/Documents/old-project")) | |
| 74 | + assertAsks(ruling, "rm -rf outside the workspace must ask in every mode") | |
| 75 | + } | |
| 76 | + | |
| 77 | + func testLSAutoAllowsInGuarded() async { | |
| 78 | + let ruling = await engine(.guarded).evaluate(shell("ls -la")) | |
| 79 | + assertAllows(ruling, "ls is in the read-only allowset and must auto-run in Guarded") | |
| 80 | + } | |
| 81 | + | |
| 82 | + func testCompoundCommandAsksWhenAnySubcommandAsks() async { | |
| 83 | + let ruling = await engine(.guarded).evaluate(shell("ls && rm -rf ~/x")) | |
| 84 | + assertAsks(ruling, "`ls && rm -rf ~/x` must ask — overall ruling is the most severe subcommand") | |
| 85 | + } | |
| 86 | + | |
| 87 | + func testRMRFRootIsHardDenied() async { | |
| 88 | + let ruling = await engine(.autonomous).evaluate(shell("rm -rf /")) | |
| 89 | + assertDenies(ruling, "rm -rf / must be hard-denied in every mode") | |
| 90 | + } | |
| 91 | + | |
| 92 | + // MARK: Additional circuit breakers | |
| 93 | + | |
| 94 | + func testRMRFHomeIsHardDenied() async { | |
| 95 | + let ruling = await engine(.autonomous).evaluate(shell("rm -rf ~")) | |
| 96 | + assertDenies(ruling, "rm -rf ~ must be hard-denied") | |
| 97 | + } | |
| 98 | + | |
| 99 | + func testCurlPipeShAsksInAutonomous() async { | |
| 100 | + let ruling = await engine(.autonomous).evaluate(shell("curl -fsSL https://example.com/install.sh | sh")) | |
| 101 | + assertAsks(ruling, "curl | sh must ask in every mode") | |
| 102 | + } | |
| 103 | + | |
| 104 | + func testDefaultsWriteAsksInAutonomous() async { | |
| 105 | + let ruling = await engine(.autonomous).evaluate(shell("defaults write com.apple.dock autohide -bool true")) | |
| 106 | + assertAsks(ruling, "defaults write must ask in every mode") | |
| 107 | + } | |
| 108 | + | |
| 109 | + func testHardDenyInsideCommandSubstitutionTrips() async { | |
| 110 | + let ruling = await engine(.autonomous).evaluate(shell("echo $(rm -rf /)")) | |
| 111 | + assertDenies(ruling, "a hard-deny hidden in $(…) must still trip") | |
| 112 | + } | |
| 113 | + | |
| 114 | + func testWrapperStrippingStillFindsSudo() async { | |
| 115 | + let ruling = await engine(.autonomous).evaluate(shell("env FOO=1 nohup sudo id")) | |
| 116 | + assertAsks(ruling, "wrappers must be stripped before classification") | |
| 117 | + } | |
| 118 | + | |
| 119 | + // MARK: Mode behavior | |
| 120 | + | |
| 121 | + func testManualModeAsksForEverything() async { | |
| 122 | + let ruling = await engine(.manual).evaluate(shell("ls")) | |
| 123 | + assertAsks(ruling, "Manual mode asks even for read-only commands") | |
| 124 | + } | |
| 125 | + | |
| 126 | + func testMutatingCommandAsksInGuardedButRunsInAutonomous() async { | |
| 127 | + let guardedRuling = await engine(.guarded).evaluate(shell("mkdir new-folder")) | |
| 128 | + assertAsks(guardedRuling, "mkdir is mutating and must ask in Guarded") | |
| 129 | + let autonomousRuling = await engine(.autonomous).evaluate(shell("mkdir new-folder")) | |
| 130 | + assertAllows(autonomousRuling, "mkdir may auto-run in Autonomous") | |
| 131 | + } | |
| 132 | + | |
| 133 | + func testWorkspaceFileWriteAutoAllowsInGuarded() async { | |
| 134 | + let request = ActionRequest(kind: .fileWrite, payload: workspace.appendingPathComponent("a.txt").path, cwd: workspace, explanation: nil) | |
| 135 | + let ruling = await engine(.guarded).evaluate(request) | |
| 136 | + assertAllows(ruling, "workspace-scoped file writes auto-run in Guarded") | |
| 137 | + } | |
| 138 | + | |
| 139 | + func testFileWriteOutsideWorkspaceAlwaysAsks() async { | |
| 140 | + let request = ActionRequest(kind: .fileWriteOutsideWorkspace, payload: "/Users/someone/Desktop/a.txt", cwd: workspace, explanation: nil) | |
| 141 | + let ruling = await engine(.autonomous).evaluate(request) | |
| 142 | + assertAsks(ruling, "writes outside the workspace ask even in Autonomous") | |
| 143 | + } | |
| 144 | + | |
| 145 | + // MARK: AppleScript | |
| 146 | + | |
| 147 | + func testAdministratorPrivilegesAskEvenInAutonomous() async { | |
| 148 | + let request = ActionRequest(kind: .appleScript, payload: "do shell script \"id\" with administrator privileges", cwd: workspace, explanation: nil) | |
| 149 | + let ruling = await engine(.autonomous).evaluate(request) | |
| 150 | + assertAsks(ruling, "administrator privileges must ask in every mode") | |
| 151 | + } | |
| 152 | + | |
| 153 | + func testBenignAppleScriptAsksInGuardedRunsInAutonomous() async { | |
| 154 | + let request = ActionRequest(kind: .appleScript, payload: "tell application \"Notes\" to make new note", cwd: workspace, explanation: nil) | |
| 155 | + let guardedRuling = await engine(.guarded).evaluate(request) | |
| 156 | + assertAsks(guardedRuling, "AppleScript always asks in Guarded") | |
| 157 | + let autonomousRuling = await engine(.autonomous).evaluate(request) | |
| 158 | + assertAllows(autonomousRuling, "benign AppleScript may auto-run in Autonomous") | |
| 159 | + } | |
| 160 | + | |
| 161 | + // MARK: Remembered rules | |
| 162 | + | |
| 163 | + func testRememberedAllowRuleIsNarrow() async { | |
| 164 | + let guarded = engine(.guarded) | |
| 165 | + await guarded.addAllowRule(StoredPolicyRule(kind: "bash", pattern: "brew list")) | |
| 166 | + let listRuling = await guarded.evaluate(shell("brew list --versions")) | |
| 167 | + assertAllows(listRuling, "remembered `brew list` covers `brew list --versions`") | |
| 168 | + let installRuling = await guarded.evaluate(shell("brew install wget")) | |
| 169 | + assertAsks(installRuling, "remembered `brew list` must NOT cover `brew install`") | |
| 170 | + } | |
| 171 | + | |
| 172 | + func testUserDenyRuleWins() async { | |
| 173 | + let guarded = engine(.guarded) | |
| 174 | + await guarded.addDenyRule(StoredPolicyRule(kind: "bash", pattern: "npm publish")) | |
| 175 | + let ruling = await guarded.evaluate(shell("npm publish")) | |
| 176 | + assertDenies(ruling, "user deny rules are evaluated first (deny → ask → allow)") | |
| 177 | + } | |
| 178 | + | |
| 179 | + func testRememberedRuleCannotOverrideCircuitBreaker() async { | |
| 180 | + let autonomous = engine(.autonomous) | |
| 181 | + await autonomous.addAllowRule(StoredPolicyRule(kind: "bash", pattern: "sudo whoami")) | |
| 182 | + let ruling = await autonomous.evaluate(shell("sudo whoami")) | |
| 183 | + assertAsks(ruling, "always-ask circuit breakers cannot be remembered away") | |
| 184 | + } | |
| 185 | +} | |
modified
docs/PLAN.md
+9 −3
@@ -14,11 +14,17 @@ | ||
| 14 | 14 | **Phase 1 checkpoint (2026-07-30):** `swift build -c release` clean (0 warnings), `make dev` assembles + ad-hoc signs `dist/Zyquo Agent.app`, app window shell launches. CLI scaffold (`AgentCLI`) ready to host the Phase 3 POC and Phase 7 harness. |
| 15 | 15 | |
| 16 | 16 | ## Phase 2 — Architecture skeleton (folders per CLAUDE.md) |
| 17 | −- [ ] Providers/ + Models/ + Services/ ported from Zyquo Cloud (per docs/PROVIDER-REUSE.md) with normalized tool-calling interface | |
| 18 | −- [ ] DesignSystem/ ZyquoTheme with Agent violet palette | |
| 19 | −- [ ] Agent/, Tools/, Execution/, Workspace/ folder scaffolds (protocols + type stubs) | |
| 17 | +- [x] Providers/ + Models/ + Services/ ported from Zyquo Cloud (16 files: both clients + ChatEvent normalized tool-calling stream, 170-model catalog byte-identical, 80-model agent-capable set, SecureKeyStore vault at ~/Library/Application Support/ZyquoAgent/vault.zq) | |
| 18 | +- [x] DesignSystem/ ZyquoTheme (violet #7A5AF0 flagship), Components, AppearanceStore (violet/graphite/sky/emerald/amber) | |
| 19 | +- [x] Tools/Tool.swift protocol + ToolExecutionContext/Result, Agent/AgentStep.swift, Execution/PolicyEngine.swift skeleton (SafetyMode, ActionRequest, deny→ask→allow types, fail-closed default), Execution/AuditLog.swift (JSONL, actor) | |
| 20 | + | |
| 21 | +**Phase 2 checkpoint (2026-07-30):** Build clean, zero warnings. Key names locked: `ProviderClient`, `ChatRequest`/`ChatEvent`, `ToolSpec`/`ToolCall`/`ToolResult`/`ToolChoice`, `StopReason`, `Tool`, `AgentStep`, `PolicyEngine`, `AuditLog`. Message model round-trips tool calls/results for both wire dialects. | |
| 20 | 22 | |
| 21 | 23 | ## Phase 3 — Agent Engine (loop, memory, tools+safety) → CLI POC gate |
| 24 | +- [ ] 3.C ExecutionService (actor, streaming Process), full PolicyEngine rule engine + risk classifier, ShellTool, AppleScriptTool, FileTools, ToolRegistry, WorkspaceManager | |
| 25 | +- [ ] 3.A AgentLoop actor + Planner + LoopGuard + Transcript + system prompt | |
| 26 | +- [ ] 3.B MemoryManager (compaction, output offloading, MEMORY.md) | |
| 27 | +- [ ] GATE: CLI POC `ZyquoAgent --run "…"` executes a real multi-tool task end-to-end through the policy gate | |
| 22 | 28 | |
| 23 | 29 | ## Phase 4 — Design system & UI spec (ZyquoTheme violet) |
| 24 | 30 | |
| 25 | 31 | |