// // AppleScriptTool.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The agent's `osascript` tool: runs AppleScript to automate macOS apps // (Finder, Notes, Mail, Calendar, System Events…). Single-line scripts run // via `osascript -e`; multi-line scripts are written to a temp file under // the workspace's `.zyquo/scripts/` dir and run as `osascript `. // Same policy → execute → audit path as the shell tool. When macOS TCC // blocks Apple events (error -1743 / "Not authorized"), the result explains // how to grant Automation access instead of leaving a cryptic error. // import Foundation struct AppleScriptTool: Tool { private let executor: ExecutionService init(executor: ExecutionService) { self.executor = executor } let name = "osascript" let description = """ Run an AppleScript on this Mac via /usr/bin/osascript to automate \ macOS applications: Finder, Notes, Reminders, Mail, Calendar, Safari, \ Music, System Events, and any scriptable app. Use it for things the \ shell cannot do cleanly — creating a Note or Reminder, reading \ Calendar events, controlling app windows. AppleScript is powerful \ and therefore gated: scripts are usually held for user approval, and \ the first automation of each app triggers a one-time macOS \ permission prompt the user must accept. If the result mentions \ \"Not authorized\", the user needs to grant access in System \ Settings > Privacy & Security > Automation — tell them so. Keep \ scripts short and single-purpose; the script's `return` value is \ returned to you as text. """ var parametersSchema: JSONValue { .object([ "type": .string("object"), "properties": .object([ "script": .object([ "type": .string("string"), "description": .string("The complete AppleScript source to execute."), ]), "timeout_seconds": .object([ "type": .string("integer"), "description": .string("Optional timeout in seconds for this script (default 120)."), ]), "explanation": .object([ "type": .string("string"), "description": .string("One short sentence explaining what this script does and why — shown to the user on approval cards."), ]), ]), "required": .array([.string("script")]), ]) } func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { guard case .object(let object) = arguments, case .string(let script)? = object["script"], !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .failure("osascript: missing required parameter `script`.") } var timeout: TimeInterval? if case .number(let seconds)? = object["timeout_seconds"], seconds > 0 { timeout = seconds } var explanation: String? if case .string(let text)? = object["explanation"] { explanation = text } // ---- Safety gate (never skipped) -------------------------------- let cleared: ClearedAction do { cleared = try await context.policy.clear(ActionRequest( kind: .appleScript, payload: script, cwd: context.workspaceURL, explanation: explanation )) } catch let denial as PolicyDenied { await context.audit.append(AuditEntry( actionKind: name, payload: script, cwd: context.workspaceURL.path, ruling: PolicyDecisionRecord.Ruling.denied.rawValue, exitCode: nil, outputExcerpt: denial.reason )) return .failure("Script not run — denied by the safety policy: \(denial.reason)") } // ---- Execute ----------------------------------------------------- let effectiveScript = cleared.payload let isMultiline = effectiveScript.contains("\n") let result: ExecutionResult var scriptFileURL: URL? do { if isMultiline { // Multi-line: write to .zyquo/scripts/ and run the file. let scriptsDir = context.workspaceURL .appendingPathComponent(".zyquo") .appendingPathComponent("scripts") try FileManager.default.createDirectory(at: scriptsDir, withIntermediateDirectories: true) let fileURL = scriptsDir.appendingPathComponent("osascript-\(UUID().uuidString.prefix(8)).applescript") try effectiveScript.write(to: fileURL, atomically: true, encoding: .utf8) scriptFileURL = fileURL result = try await executor.runOSAScriptFile( scriptFile: fileURL, cwd: context.workspaceURL, timeout: timeout, onOutput: context.onOutput ) } else { result = try await executor.runOSAScript( lines: [effectiveScript], cwd: context.workspaceURL, timeout: timeout, onOutput: context.onOutput ) } } catch let launchError as ExecutionLaunchError { await context.audit.append(AuditEntry( actionKind: name, payload: effectiveScript, cwd: context.workspaceURL.path, ruling: cleared.decision.ruling.rawValue, exitCode: nil, outputExcerpt: launchError.reason )) return .failure("osascript failed to launch: \(launchError.reason)") } // Keep the script file for the audit trail (it lives in .zyquo/, // which is excluded from workspace file tracking). _ = scriptFileURL // ---- Audit ------------------------------------------------------- await context.audit.append(AuditEntry( actionKind: name, payload: effectiveScript, cwd: context.workspaceURL.path, ruling: cleared.decision.ruling.rawValue, exitCode: result.exitCode, outputExcerpt: result.combinedOutput )) // ---- Result for the model ---------------------------------------- var content = result.combinedOutput if result.isTimeout { content += "\n[timed out after \(Int(timeout ?? 120))s — osascript was killed]" } if result.isCancelled { content += "\n[cancelled by the user]" } content += "\n[exit code: \(result.exitCode.map(String.init) ?? "none")]" // TCC / Automation permission guidance. let lowered = result.stderr.lowercased() if lowered.contains("not authorized") || lowered.contains("-1743") || result.stderr.contains("errAEEventNotPermitted") { content += """ \n[Automation permission needed] macOS blocked Zyquo Agent from \ controlling that application. Ask the user to open System \ Settings > Privacy & Security > Automation, find "Zyquo Agent", \ and enable the target app — then run the script again. """ } let failed = result.isTimeout || result.isCancelled || (result.exitCode ?? 1) != 0 return ToolExecutionResult(content: content, isError: failed) } }