// // FileTools.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The five workspace file tools: read_file, write_file, edit_file, // list_dir, search_files. All paths resolve relative to the task workspace. // Escaping the workspace (absolute paths or `..`) routes through the // PolicyEngine: reads outside ask (`.fileReadOutsideWorkspace`), writes // outside always ask (`.fileWriteOutsideWorkspace`); writes inside go // through `.fileWrite` (auto-allowed in Guarded/Autonomous). In-workspace // READS deliberately skip the gate — asking the user before the agent can // look at its own scratch files would make Manual mode unusable (decision // documented in PolicyEngine's header). Every operation is audited. // import Foundation // MARK: - Shared path resolution /// Resolves tool-supplied paths against the workspace and detects escapes. enum WorkspacePath { struct Resolved { /// Fully resolved file URL. var url: URL /// True when the path (symlinks resolved) stays inside the workspace. var isInsideWorkspace: Bool } /// Relative paths resolve under the workspace; `~` and absolute paths are /// honored but flagged when they escape. Symlinks are resolved on the /// deepest existing ancestor so a link cannot smuggle a write outside. static func resolve(_ raw: String, workspace: URL) -> Resolved { var expanded = raw if expanded == "~" || expanded.hasPrefix("~/") { expanded = (expanded as NSString).expandingTildeInPath } let url: URL if expanded.hasPrefix("/") { url = URL(fileURLWithPath: (expanded as NSString).standardizingPath) } else { url = URL(fileURLWithPath: (workspace.appendingPathComponent(expanded).path as NSString).standardizingPath) } return Resolved(url: url, isInsideWorkspace: isInside(url, workspace: workspace)) } private static func isInside(_ url: URL, workspace: URL) -> Bool { let root = URL(fileURLWithPath: workspace.path).resolvingSymlinksInPath().path // Resolve symlinks on the deepest existing ancestor of the target. var probe = url var suffix: [String] = [] while !FileManager.default.fileExists(atPath: probe.path), probe.pathComponents.count > 1 { suffix.append(probe.lastPathComponent) probe = probe.deletingLastPathComponent() } var resolved = probe.resolvingSymlinksInPath() for component in suffix.reversed() { resolved.appendPathComponent(component) } let path = resolved.path return path == root || path.hasPrefix(root + "/") } /// Path shown to the model/user: workspace-relative when inside. static func display(_ url: URL, workspace: URL) -> String { let root = workspace.path if url.path == root { return "." } if url.path.hasPrefix(root + "/") { return String(url.path.dropFirst(root.count + 1)) } return url.path } } // MARK: - Shared gate/audit plumbing private enum FileToolSupport { /// Clears a write with the policy engine (inside → `.fileWrite`, /// outside → `.fileWriteOutsideWorkspace`). Returns the decision for the /// audit line, or the denial message. static func clearWrite( of resolved: WorkspacePath.Resolved, context: ToolExecutionContext, explanation: String ) async -> Result { do { let cleared = try await context.policy.clear(ActionRequest( kind: resolved.isInsideWorkspace ? .fileWrite : .fileWriteOutsideWorkspace, payload: resolved.url.path, cwd: context.workspaceURL, explanation: explanation )) return .success(cleared.decision) } catch let denial as PolicyDenied { return .failure(denial) } catch { return .failure(PolicyDenied(reason: error.localizedDescription)) } } /// Clears an out-of-workspace read (in-workspace reads skip the gate). static func clearRead( of resolved: WorkspacePath.Resolved, context: ToolExecutionContext, explanation: String ) async -> Result { guard !resolved.isInsideWorkspace else { return .success(PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: "Read inside the task workspace.")) } do { let cleared = try await context.policy.clear(ActionRequest( kind: .fileReadOutsideWorkspace, payload: resolved.url.path, cwd: context.workspaceURL, explanation: explanation )) return .success(cleared.decision) } catch let denial as PolicyDenied { return .failure(denial) } catch { return .failure(PolicyDenied(reason: error.localizedDescription)) } } static func audit( _ context: ToolExecutionContext, tool: String, path: String, decision: PolicyDecisionRecord?, note: String ) async { await context.audit.append(AuditEntry( actionKind: tool, payload: path, cwd: context.workspaceURL.path, ruling: decision?.ruling.rawValue ?? PolicyDecisionRecord.Ruling.denied.rawValue, exitCode: nil, outputExcerpt: note )) } static func string(_ key: String, in object: [String: JSONValue]) -> String? { if case .string(let value)? = object[key] { return value } return nil } static func integer(_ key: String, in object: [String: JSONValue]) -> Int? { if case .number(let value)? = object[key] { return Int(value) } return nil } static func boolean(_ key: String, in object: [String: JSONValue]) -> Bool? { if case .bool(let value)? = object[key] { return value } return nil } } // MARK: - read_file struct ReadFileTool: Tool { /// Max bytes returned per call — page with offset/limit for bigger files. static let byteLimit = 50_000 let name = "read_file" let description = """ Read a text file. Paths are relative to the task workspace; reading \ outside the workspace requires user approval. Returns numbered lines. \ Output is capped at ~50KB per call — for larger files, page through \ with `offset` (1-based first line) and `limit` (line count). Always \ read a file before editing it with edit_file. """ var parametersSchema: JSONValue { .object([ "type": .string("object"), "properties": .object([ "path": .object([ "type": .string("string"), "description": .string("File path, relative to the workspace (or absolute)."), ]), "offset": .object([ "type": .string("integer"), "description": .string("1-based line number to start reading from."), ]), "limit": .object([ "type": .string("integer"), "description": .string("Maximum number of lines to return."), ]), ]), "required": .array([.string("path")]), ]) } func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { guard case .object(let object) = arguments, let path = FileToolSupport.string("path", in: object) else { return .failure("read_file: missing required parameter `path`.") } let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "Read \(resolved.url.path)") { let reason = denial.reason await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)") return .failure("Read not permitted: \(reason)") } guard FileManager.default.fileExists(atPath: resolved.url.path) else { return .failure("read_file: no file at \(WorkspacePath.display(resolved.url, workspace: context.workspaceURL)).") } guard let data = FileManager.default.contents(atPath: resolved.url.path) else { return .failure("read_file: could not read \(resolved.url.path).") } guard let text = String(data: data, encoding: .utf8) else { return .failure("read_file: \(resolved.url.lastPathComponent) is not UTF-8 text (\(data.count) bytes).") } let allLines = text.components(separatedBy: "\n") let offset = max(1, FileToolSupport.integer("offset", in: object) ?? 1) let limit = FileToolSupport.integer("limit", in: object) ?? allLines.count guard offset <= allLines.count else { return .failure("read_file: offset \(offset) is past the end of the file (\(allLines.count) lines).") } var out = "" var emittedLines = 0 var truncatedByBytes = false var lineNumber = offset for line in allLines.dropFirst(offset - 1) { if emittedLines >= limit { break } let numbered = "\(lineNumber)\t\(line)\n" if out.utf8.count + numbered.utf8.count > Self.byteLimit { truncatedByBytes = true break } out += numbered emittedLines += 1 lineNumber += 1 } let linesRemaining = allLines.count - (lineNumber - 1) if truncatedByBytes || (emittedLines >= limit && linesRemaining > 0) { out += "… [truncated — file has \(allLines.count) lines; continue with offset=\(lineNumber)]\n" } await FileToolSupport.audit( context, tool: name, path: resolved.url.path, decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil), note: "read \(emittedLines) lines from line \(offset)" ) context.onOutput(.note("read \(WorkspacePath.display(resolved.url, workspace: context.workspaceURL)) (\(emittedLines) lines)")) return .success(out.isEmpty ? "[empty file]" : out) } } // MARK: - write_file struct WriteFileTool: Tool { let name = "write_file" let description = """ Create or overwrite a text file with the given content. Paths are \ relative to the task workspace; parent directories are created \ automatically. Writing outside the workspace always requires user \ approval. To change part of an existing file, prefer edit_file — it \ is safer than rewriting the whole file. """ var parametersSchema: JSONValue { .object([ "type": .string("object"), "properties": .object([ "path": .object([ "type": .string("string"), "description": .string("File path, relative to the workspace (or absolute)."), ]), "content": .object([ "type": .string("string"), "description": .string("The complete file content to write."), ]), ]), "required": .array([.string("path"), .string("content")]), ]) } func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { guard case .object(let object) = arguments, let path = FileToolSupport.string("path", in: object), let content = FileToolSupport.string("content", in: object) else { return .failure("write_file: `path` and `content` are both required.") } let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) let existed = FileManager.default.fileExists(atPath: resolved.url.path) let decision: PolicyDecisionRecord switch await FileToolSupport.clearWrite(of: resolved, context: context, explanation: "\(existed ? "Overwrite" : "Create") \(resolved.url.path) (\(content.utf8.count) bytes)") { case .failure(let denial): await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(denial.reason)") return .failure("Write not permitted: \(denial.reason)") case .success(let record): decision = record } do { try FileManager.default.createDirectory(at: resolved.url.deletingLastPathComponent(), withIntermediateDirectories: true) try content.write(to: resolved.url, atomically: true, encoding: .utf8) } catch { await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "failed: \(error.localizedDescription)") return .failure("write_file failed: \(error.localizedDescription)") } await context.workspaceManager?.noteFileTouched(resolved.url, existedBefore: existed) await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "\(existed ? "overwrote" : "created") \(content.utf8.count) bytes") let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL) context.onOutput(.note("\(existed ? "overwrote" : "created") \(display) (\(content.utf8.count) bytes)")) return .success("\(existed ? "Overwrote" : "Created") \(display) (\(content.utf8.count) bytes).") } } // MARK: - edit_file struct EditFileTool: Tool { let name = "edit_file" let description = """ Replace text in an existing file. `old_string` must match the current \ file content EXACTLY and UNIQUELY — if it matches zero or multiple \ places the edit fails and reports the match count; include more \ surrounding lines to make it unique, or set `replace_all` to change \ every occurrence. Read the file first with read_file so you know its \ exact current content. Paths resolve relative to the workspace; \ editing outside the workspace always requires user approval. """ var parametersSchema: JSONValue { .object([ "type": .string("object"), "properties": .object([ "path": .object([ "type": .string("string"), "description": .string("File path, relative to the workspace (or absolute)."), ]), "old_string": .object([ "type": .string("string"), "description": .string("Exact text to find (must be unique unless replace_all)."), ]), "new_string": .object([ "type": .string("string"), "description": .string("Text to replace it with."), ]), "replace_all": .object([ "type": .string("boolean"), "description": .string("Replace every occurrence instead of requiring a unique match (default false)."), ]), ]), "required": .array([.string("path"), .string("old_string"), .string("new_string")]), ]) } func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { guard case .object(let object) = arguments, let path = FileToolSupport.string("path", in: object), let oldString = FileToolSupport.string("old_string", in: object), let newString = FileToolSupport.string("new_string", in: object) else { return .failure("edit_file: `path`, `old_string`, and `new_string` are all required.") } guard !oldString.isEmpty else { return .failure("edit_file: `old_string` must not be empty.") } guard oldString != newString else { return .failure("edit_file: `old_string` and `new_string` are identical — nothing to change.") } let replaceAll = FileToolSupport.boolean("replace_all", in: object) ?? false let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL) guard FileManager.default.fileExists(atPath: resolved.url.path) else { return .failure("edit_file: no file at \(display). Use write_file to create new files.") } guard let data = FileManager.default.contents(atPath: resolved.url.path), let text = String(data: data, encoding: .utf8) else { return .failure("edit_file: could not read \(display) as UTF-8 text.") } // Exact-unique match contract: fail loudly with the match count. let matches = text.components(separatedBy: oldString).count - 1 if matches == 0 { 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.") } if matches > 1 && !replaceAll { 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).") } let decision: PolicyDecisionRecord switch await FileToolSupport.clearWrite(of: resolved, context: context, explanation: "Edit \(resolved.url.path) (\(matches) replacement\(matches == 1 ? "" : "s"))") { case .failure(let denial): await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(denial.reason)") return .failure("Edit not permitted: \(denial.reason)") case .success(let record): decision = record } let updated = text.replacingOccurrences(of: oldString, with: newString) do { try updated.write(to: resolved.url, atomically: true, encoding: .utf8) } catch { await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "failed: \(error.localizedDescription)") return .failure("edit_file failed: \(error.localizedDescription)") } await context.workspaceManager?.noteFileTouched(resolved.url, existedBefore: true) await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "replaced \(matches) occurrence\(matches == 1 ? "" : "s")") context.onOutput(.note("edited \(display) (\(matches) replacement\(matches == 1 ? "" : "s"))")) return .success("Edited \(display): replaced \(matches) occurrence\(matches == 1 ? "" : "s").") } } // MARK: - list_dir struct ListDirTool: Tool { static let entryLimit = 500 let name = "list_dir" let description = """ List files and folders. Defaults to the workspace root; pass `path` \ for a subfolder and `depth` (default 2) to control recursion. \ Directories end with '/'; files show their size. Listing outside the \ workspace requires user approval. The internal `.zyquo/` folder and \ `.git/` contents are omitted. """ var parametersSchema: JSONValue { .object([ "type": .string("object"), "properties": .object([ "path": .object([ "type": .string("string"), "description": .string("Directory to list, relative to the workspace (default: workspace root)."), ]), "depth": .object([ "type": .string("integer"), "description": .string("How many directory levels to descend (default 2)."), ]), ]), "required": .array([]), ]) } func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { var path = "." var depth = 2 if case .object(let object) = arguments { if let p = FileToolSupport.string("path", in: object) { path = p } if let d = FileToolSupport.integer("depth", in: object) { depth = max(1, min(d, 8)) } } let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL) if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "List \(resolved.url.path)") { let reason = denial.reason await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)") return .failure("Listing not permitted: \(reason)") } var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: resolved.url.path, isDirectory: &isDirectory), isDirectory.boolValue else { return .failure("list_dir: \(display) is not a directory.") } var lines: [String] = ["\(display == "." ? "workspace root" : display)/"] var count = 0 var truncated = false listRecursively(resolved.url, indent: " ", remainingDepth: depth, lines: &lines, count: &count, truncated: &truncated) if truncated { lines.append("… [listing truncated at \(Self.entryLimit) entries — list a subfolder or lower the depth]") } if count == 0 { lines.append(" [empty]") } await FileToolSupport.audit( context, tool: name, path: resolved.url.path, decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil), note: "listed \(count) entries (depth \(depth))" ) return .success(lines.joined(separator: "\n")) } private func listRecursively(_ directory: URL, indent: String, remainingDepth: Int, lines: inout [String], count: inout Int, truncated: inout Bool) { guard remainingDepth > 0, !truncated else { return } let contents = (try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [] )) ?? [] for entry in contents.sorted(by: { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }) { if count >= Self.entryLimit { truncated = true; return } let entryName = entry.lastPathComponent if entryName == ".zyquo" || entryName == ".git" { continue } let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) if values?.isDirectory == true { lines.append("\(indent)\(entryName)/") count += 1 listRecursively(entry, indent: indent + " ", remainingDepth: remainingDepth - 1, lines: &lines, count: &count, truncated: &truncated) } else { let size = values?.fileSize ?? 0 lines.append("\(indent)\(entryName) (\(Self.format(bytes: size)))") count += 1 } } } private static func format(bytes: Int) -> String { if bytes < 1000 { return "\(bytes) B" } if bytes < 1_000_000 { return String(format: "%.1f KB", Double(bytes) / 1000) } return String(format: "%.1f MB", Double(bytes) / 1_000_000) } } // MARK: - search_files struct SearchFilesTool: Tool { static let matchLimit = 200 static let scannedFileByteLimit = 2_000_000 static let resultByteLimit = 50_000 let name = "search_files" let description = """ Search file contents like grep. `pattern` is tried as a regular \ expression first, then as a literal substring if the regex is \ invalid. Searches the whole workspace by default; narrow with `path` \ (subfolder) and `glob` (filename filter like *.swift). Returns \ file:line: matches, capped at 200. Binary files, `.git/` and \ `.zyquo/` are skipped. Searching outside the workspace requires user \ approval. """ var parametersSchema: JSONValue { .object([ "type": .string("object"), "properties": .object([ "pattern": .object([ "type": .string("string"), "description": .string("Regex (or literal substring) to search for."), ]), "path": .object([ "type": .string("string"), "description": .string("Directory to search, relative to the workspace (default: workspace root)."), ]), "glob": .object([ "type": .string("string"), "description": .string("Filename glob filter, e.g. *.swift or *.md."), ]), ]), "required": .array([.string("pattern")]), ]) } func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult { guard case .object(let object) = arguments, let pattern = FileToolSupport.string("pattern", in: object), !pattern.isEmpty else { return .failure("search_files: missing required parameter `pattern`.") } let path = FileToolSupport.string("path", in: object) ?? "." let glob = FileToolSupport.string("glob", in: object) let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL) if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "Search \(resolved.url.path) for \(pattern)") { let reason = denial.reason await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)") return .failure("Search not permitted: \(reason)") } let regex = try? NSRegularExpression(pattern: pattern) var matches: [String] = [] var bytes = 0 var filesScanned = 0 var capped = false let enumerator = FileManager.default.enumerator( at: resolved.url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsPackageDescendants] ) while let entry = enumerator?.nextObject() as? URL { try Task.checkCancellation() if capped { break } let entryName = entry.lastPathComponent if entryName == ".git" || entryName == ".zyquo" { enumerator?.skipDescendants() continue } let values = try? entry.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) guard values?.isRegularFile == true else { continue } if let glob, !Self.matchGlob(glob, name: entryName) { continue } if (values?.fileSize ?? 0) > Self.scannedFileByteLimit { continue } guard let data = FileManager.default.contents(atPath: entry.path), !data.contains(0), let text = String(data: data, encoding: .utf8) else { continue } filesScanned += 1 let relative = WorkspacePath.display(entry, workspace: context.workspaceURL) for (index, line) in text.components(separatedBy: "\n").enumerated() { let hit: Bool if let regex { hit = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil } else { hit = line.contains(pattern) } guard hit else { continue } let trimmed = line.count > 250 ? String(line.prefix(250)) + "…" : line let record = "\(relative):\(index + 1): \(trimmed)" matches.append(record) bytes += record.utf8.count if matches.count >= Self.matchLimit || bytes >= Self.resultByteLimit { capped = true break } } } await FileToolSupport.audit( context, tool: name, path: resolved.url.path, decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil), note: "pattern \(pattern): \(matches.count) matches in \(filesScanned) files" ) if matches.isEmpty { return .success("No matches for \(regex != nil ? "regex" : "substring") “\(pattern)” (\(filesScanned) files scanned).") } var output = matches.joined(separator: "\n") if capped { output += "\n… [capped at \(matches.count) matches — refine the pattern, path, or glob]" } return .success(output) } /// Minimal glob: `*` and `?` on the filename (fnmatch-style, no slashes). static func matchGlob(_ glob: String, name: String) -> Bool { var regexPattern = "^" for ch in glob { switch ch { case "*": regexPattern += ".*" case "?": regexPattern += "." default: regexPattern += NSRegularExpression.escapedPattern(for: String(ch)) } } regexPattern += "$" guard let regex = try? NSRegularExpression(pattern: regexPattern, options: [.caseInsensitive]) else { return false } return regex.firstMatch(in: name, range: NSRange(name.startIndex..., in: name)) != nil } }