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%
29.4 KB · 657 lines swift
Raw Blame History
1//2//  FileTools.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//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 the11//  PolicyEngine: reads outside ask (`.fileReadOutsideWorkspace`), writes12//  outside always ask (`.fileWriteOutsideWorkspace`); writes inside go13//  through `.fileWrite` (auto-allowed in Guarded/Autonomous). In-workspace14//  READS deliberately skip the gate — asking the user before the agent can15//  look at its own scratch files would make Manual mode unusable (decision16//  documented in PolicyEngine's header). Every operation is audited.17//1819import Foundation2021// MARK: - Shared path resolution2223/// Resolves tool-supplied paths against the workspace and detects escapes.24enum WorkspacePath {25    struct Resolved {26        /// Fully resolved file URL.27        var url: URL28        /// True when the path (symlinks resolved) stays inside the workspace.29        var isInsideWorkspace: Bool30    }3132    /// Relative paths resolve under the workspace; `~` and absolute paths are33    /// honored but flagged when they escape. Symlinks are resolved on the34    /// deepest existing ancestor so a link cannot smuggle a write outside.35    static func resolve(_ raw: String, workspace: URL) -> Resolved {36        var expanded = raw37        if expanded == "~" || expanded.hasPrefix("~/") {38            expanded = (expanded as NSString).expandingTildeInPath39        }40        let url: URL41        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    }4849    private static func isInside(_ url: URL, workspace: URL) -> Bool {50        let root = URL(fileURLWithPath: workspace.path).resolvingSymlinksInPath().path51        // Resolve symlinks on the deepest existing ancestor of the target.52        var probe = url53        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.path63        return path == root || path.hasPrefix(root + "/")64    }6566    /// Path shown to the model/user: workspace-relative when inside.67    static func display(_ url: URL, workspace: URL) -> String {68        let root = workspace.path69        if url.path == root { return "." }70        if url.path.hasPrefix(root + "/") {71            return String(url.path.dropFirst(root.count + 1))72        }73        return url.path74    }75}7677// MARK: - Shared gate/audit plumbing7879private enum FileToolSupport {80    /// Clears a write with the policy engine (inside → `.fileWrite`,81    /// outside → `.fileWriteOutsideWorkspace`). Returns the decision for the82    /// audit line, or the denial message.83    static func clearWrite(84        of resolved: WorkspacePath.Resolved,85        context: ToolExecutionContext,86        explanation: String87    ) 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: explanation94            ))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    }102103    /// 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: String108    ) 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: explanation118            ))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    }126127    static func audit(128        _ context: ToolExecutionContext,129        tool: String,130        path: String,131        decision: PolicyDecisionRecord?,132        note: String133    ) 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: note141        ))142    }143144    static func string(_ key: String, in object: [String: JSONValue]) -> String? {145        if case .string(let value)? = object[key] { return value }146        return nil147    }148149    static func integer(_ key: String, in object: [String: JSONValue]) -> Int? {150        if case .number(let value)? = object[key] { return Int(value) }151        return nil152    }153154    static func boolean(_ key: String, in object: [String: JSONValue]) -> Bool? {155        if case .bool(let value)? = object[key] { return value }156        return nil157    }158}159160// MARK: - read_file161162struct ReadFileTool: Tool {163    /// Max bytes returned per call — page with offset/limit for bigger files.164    static let byteLimit = 50_000165166    let name = "read_file"167168    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        """175176    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    }196197    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)203204        if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "Read \(resolved.url.path)") {205            let reason = denial.reason206            await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)")207            return .failure("Read not permitted: \(reason)")208        }209210        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        }219220        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.count223        guard offset <= allLines.count else {224            return .failure("read_file: offset \(offset) is past the end of the file (\(allLines.count) lines).")225        }226227        var out = ""228        var emittedLines = 0229        var truncatedByBytes = false230        var lineNumber = offset231        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 = true236                break237            }238            out += numbered239            emittedLines += 1240            lineNumber += 1241        }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        }246247        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}256257// MARK: - write_file258259struct WriteFileTool: Tool {260    let name = "write_file"261262    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        """269270    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    }286287    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)295296        let decision: PolicyDecisionRecord297        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 = record303        }304305        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        }312313        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}320321// MARK: - edit_file322323struct EditFileTool: Tool {324    let name = "edit_file"325326    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        """335336    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    }360361    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) ?? false375        let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL)376        let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL)377378        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        }385386        // Exact-unique match contract: fail loudly with the match count.387        let matches = text.components(separatedBy: oldString).count - 1388        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        }394395        let decision: PolicyDecisionRecord396        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 = record402        }403404        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        }411412        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}418419// MARK: - list_dir420421struct ListDirTool: Tool {422    static let entryLimit = 500423424    let name = "list_dir"425426    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        """433434    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    }450451    func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult {452        var path = "."453        var depth = 2454        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)460461        if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "List \(resolved.url.path)") {462            let reason = denial.reason463            await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)")464            return .failure("Listing not permitted: \(reason)")465        }466467        var isDirectory: ObjCBool = false468        guard FileManager.default.fileExists(atPath: resolved.url.path, isDirectory: &isDirectory), isDirectory.boolValue else {469            return .failure("list_dir: \(display) is not a directory.")470        }471472        var lines: [String] = ["\(display == "." ? "workspace root" : display)/"]473        var count = 0474        var truncated = false475        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        }482483        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    }490491    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.lastPathComponent501            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 += 1506                listRecursively(entry, indent: indent + "  ", remainingDepth: remainingDepth - 1, lines: &lines, count: &count, truncated: &truncated)507            } else {508                let size = values?.fileSize ?? 0509                lines.append("\(indent)\(entryName)  (\(Self.format(bytes: size)))")510                count += 1511            }512        }513    }514515    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}521522// MARK: - search_files523524struct SearchFilesTool: Tool {525    static let matchLimit = 200526    static let scannedFileByteLimit = 2_000_000527    static let resultByteLimit = 50_000528529    let name = "search_files"530531    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        """540541    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    }561562    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)571572        if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "Search \(resolved.url.path) for \(pattern)") {573            let reason = denial.reason574            await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)")575            return .failure("Search not permitted: \(reason)")576        }577578        let regex = try? NSRegularExpression(pattern: pattern)579        var matches: [String] = []580        var bytes = 0581        var filesScanned = 0582        var capped = false583584        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.lastPathComponent593            if entryName == ".git" || entryName == ".zyquo" {594                enumerator?.skipDescendants()595                continue596            }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 += 1605606            let relative = WorkspacePath.display(entry, workspace: context.workspaceURL)607            for (index, line) in text.components(separatedBy: "\n").enumerated() {608                let hit: Bool609                if let regex {610                    hit = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil611                } else {612                    hit = line.contains(pattern)613                }614                guard hit else { continue }615                let trimmed = line.count > 250 ? String(line.prefix(250)) + "…" : line616                let record = "\(relative):\(index + 1): \(trimmed)"617                matches.append(record)618                bytes += record.utf8.count619                if matches.count >= Self.matchLimit || bytes >= Self.resultByteLimit {620                    capped = true621                    break622                }623            }624        }625626        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        )631632        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    }641642    /// 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)) != nil655    }656}657