SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%

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>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent 5f12dd6

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