// // ShellTool.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The agent's `bash` tool: runs a shell command inside the task workspace. // Every call is (1) cleared by the PolicyEngine — never bypassed, in any // mode — (2) executed by ExecutionService with live line streaming and a // timeout, and (3) appended to the AuditLog with its exit code and a // truncated output excerpt. // import Foundation struct ShellTool: Tool { private let executor: ExecutionService init(executor: ExecutionService) { self.executor = executor } let name = "bash" let description = """ Run a shell command with /bin/bash inside the task workspace (the \ working directory). Use it for anything a terminal can do: creating \ files and folders, running scripts, installing project dependencies, \ inspecting the system. stdout and stderr stream live to the user and \ are returned with the exit code. Commands may be blocked or held for \ user approval by the safety policy — if a command is denied, explain \ the situation to the user or find a safer approach instead of \ retrying the same command. Prefer the dedicated file tools \ (read_file, write_file, edit_file, list_dir, search_files) for file \ content work; prefer relative paths, which resolve inside the \ workspace. Long or interactive commands will hit the timeout — pass \ timeout_seconds for legitimately slow commands. """ var parametersSchema: JSONValue { .object([ "type": .string("object"), "properties": .object([ "command": .object([ "type": .string("string"), "description": .string("The exact bash command to execute."), ]), "timeout_seconds": .object([ "type": .string("integer"), "description": .string("Optional timeout in seconds for this command (default 120)."), ]), "explanation": .object([ "type": .string("string"), "description": .string("One short sentence explaining what this command does and why — shown to the user on approval cards."), ]), ]), "required": .array([.string("command")]), ]) } func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { guard case .object(let object) = arguments, case .string(let command)? = object["command"], !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .failure("bash: missing required parameter `command`.") } 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: .shellCommand, payload: command, cwd: context.workspaceURL, explanation: explanation )) } catch let denial as PolicyDenied { await context.audit.append(AuditEntry( actionKind: name, payload: command, cwd: context.workspaceURL.path, ruling: PolicyDecisionRecord.Ruling.denied.rawValue, exitCode: nil, outputExcerpt: denial.reason )) return .failure("Command not run — denied by the safety policy: \(denial.reason)") } // ---- Execute ------------------------------------------------------ let result: ExecutionResult do { result = try await executor.runBash( command: cleared.payload, cwd: context.workspaceURL, timeout: timeout, onOutput: context.onOutput ) } catch let launchError as ExecutionLaunchError { await context.audit.append(AuditEntry( actionKind: name, payload: cleared.payload, cwd: context.workspaceURL.path, ruling: cleared.decision.ruling.rawValue, exitCode: nil, outputExcerpt: launchError.reason )) return .failure("bash failed to launch: \(launchError.reason)") } // ---- Audit --------------------------------------------------------- await context.audit.append(AuditEntry( actionKind: name, payload: cleared.payload, 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 — process was killed]" } if result.isCancelled { content += "\n[cancelled by the user]" } content += "\n[exit code: \(result.exitCode.map(String.init) ?? "none")]" let failed = result.isTimeout || result.isCancelled || (result.exitCode ?? 1) != 0 return ToolExecutionResult(content: content, isError: failed) } }