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%
51.7 KB · 1,187 lines swift
Raw Blame History
1//2//  PolicyEngine.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The safety gate every action passes through — no shell command, AppleScript,9//  or out-of-workspace file access runs without a ruling from here.10//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 allowset21//      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 substitutions25//    `$(…)`/backticks are extracted and classified too, and common wrappers26//    (`env VAR=x`, `nohup`, `time`, `xargs`, `nice`, `command`) are stripped27//    before classification. The overall ruling is the MOST SEVERE across all28//    subcommands — `ls && rm -rf ~/x` asks because the second half asks.29//30//    Mode behavior:31//      manual     → every gated action asks. (Deliberate exception documented32//                   in FileTools: pure in-workspace FileTools READS never33//                   reach the gate at all — asking to read the agent's own34//                   scratch files would make Manual mode unusable.)35//      guarded    → read-only allowset auto-runs; in-workspace file writes36//                   (.fileWrite) auto-run; everything else asks.37//      autonomous → everything auto-runs EXCEPT hard-denies and the38//                   always-ask class, which still ask.39//40//    AppleScript: manual & guarded always ask. Autonomous asks only when the41//    script matches risky patterns (administrator privileges, System Events42//    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-only46//    AuditLog — nothing the agent does is invisible.47//4849import Foundation5051/// User-selectable safety posture, per task.52enum SafetyMode: String, Codable, CaseIterable, Identifiable, Sendable {53    /// Approve every action.54    case manual55    /// Auto-run read-only/safe actions, ask for anything mutating or risky.56    case guarded57    /// Run freely within budget — clearly labeled, off by default, still58    /// audited, and destructive patterns STILL require approval.59    case autonomous6061    var id: String { rawValue }6263    var displayName: String {64        switch self {65        case .manual: return "Manual"66        case .guarded: return "Guarded"67        case .autonomous: return "Autonomous"68        }69    }70}7172/// An action submitted to the gate for classification.73struct ActionRequest: Sendable {74    enum Kind: String, Codable, Sendable {75        case shellCommand76        case appleScript77        /// File write/edit whose resolved path is INSIDE the workspace.78        case fileWrite79        /// File write/edit whose resolved path escapes the workspace —80        /// always asks, in every mode.81        case fileWriteOutsideWorkspace82        /// File read whose resolved path escapes the workspace — always asks.83        case fileReadOutsideWorkspace84    }85    var kind: Kind86    /// The exact command / script / path the user will see verbatim.87    var payload: String88    var cwd: URL89    /// Model-provided explanation of intent, shown on the approval card.90    var explanation: String?91}9293/// The gate's ruling for one action.94enum PolicyRuling: Sendable {95    /// Safe under the active mode — run without asking.96    case allow(reason: String)97    /// Hold for user approval (approval card), with a risk label.98    case ask(risk: RiskAssessment)99    /// Never run (hard denylist).100    case deny(reason: String)101}102103/// Risk classification attached to approval requests.104struct RiskAssessment: Sendable {105    enum Level: String, Codable, Sendable {106        case safe, mutating, destructive, elevated107    }108    var level: Level109    /// Human-readable reason ("deletes files recursively", "requires sudo"…).110    var reason: String111}112113/// How the user (or mode) resolved an approval request.114enum ApprovalResolution: Sendable {115    case approve116    /// Approve and remember an allow rule for this safe class.117    case approveAndRemember118    /// User edited the payload, then approved; carries the edited payload.119    case approveEdited(String)120    case deny121}122123/// Asynchronous bridge to whoever answers approval requests (UI card or CLI124/// prompt). The loop blocks on this until resolved.125protocol ApprovalPresenting: Sendable {126    func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution127}128129// MARK: - Stored rules ("Approve & remember" + user-managed lists)130131/// One persisted rule: `kind` scopes it to a tool family ("bash" or132/// "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 the135/// raw compound string, so an allow rule cannot smuggle a `&& rm -rf` along.136struct StoredPolicyRule: Codable, Hashable, Sendable {137    var kind: String138    var pattern: String139}140141/// The on-disk rule document (`policy-rules.json` in the app data folder).142struct StoredPolicyRules: Codable, Sendable {143    var allow: [StoredPolicyRule] = []144    var deny: [StoredPolicyRule] = []145}146147// MARK: - PolicyEngine148149/// The gate. Actor: rulings and remembered rules mutate shared state.150actor PolicyEngine {151    private(set) var mode: SafetyMode152    private let approvals: ApprovalPresenting153    private let persistence: PersistenceService154    private var storedRules: StoredPolicyRules155156    private static let rulesFileName = "policy-rules.json"157158    /// `persistence` decides where remembered rules live; tests pass a159    /// temp-rooted PersistenceService so they never touch the real rule file.160    init(mode: SafetyMode, approvals: ApprovalPresenting, persistence: PersistenceService = .shared) {161        self.mode = mode162        self.approvals = approvals163        self.persistence = persistence164        self.storedRules = persistence.load(StoredPolicyRules.self, from: Self.rulesFileName) ?? StoredPolicyRules()165    }166167    func setMode(_ newMode: SafetyMode) {168        mode = newMode169    }170171    // MARK: Rule management (Settings › Safety)172173    var rules: StoredPolicyRules { storedRules }174175    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    }180181    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    }186187    func removeAllowRule(_ rule: StoredPolicyRule) {188        storedRules.allow.removeAll { $0 == rule }189        persistence.save(storedRules, to: Self.rulesFileName)190    }191192    func removeDenyRule(_ rule: StoredPolicyRule) {193        storedRules.deny.removeAll { $0 == rule }194        persistence.save(storedRules, to: Self.rulesFileName)195    }196197    // MARK: The gate198199    /// Classifies the action, asks the user when required, and returns what200    /// may actually run (payload may have been edited). Throws `PolicyDenied`201    /// when the action must not run.202    func clear(_ action: ActionRequest) async throws -> ClearedAction {203        switch evaluate(action) {204        case .deny(let reason):205            throw PolicyDenied(reason: reason)206207        case .allow(let reason):208            return ClearedAction(209                payload: action.payload,210                decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: reason)211            )212213        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                )221222            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                )228229            case .approveEdited(let edited):230                // The edited payload is re-classified: an edit can never231                // sneak past the hard denylist, but an explicit user edit +232                // approval covers ask-class results.233                var editedAction = action234                editedAction.payload = edited235                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                )242243            case .deny:244                throw PolicyDenied(reason: "User denied the action.")245            }246        }247    }248249    /// Pure classification — no user interaction. Exposed for the approval250    /// 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    }273274    // MARK: Shell classification275276    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.deny282        )283284        if let denyReason = analysis.hardDenyReason {285            return .deny(reason: denyReason)286        }287        if let breaker = analysis.alwaysAsk {288            return .ask(risk: breaker)289        }290291        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.allRunnableUnprompted301                ? analysis.allowReason302                : "Autonomous mode — \(analysis.risk.reason)")303        }304    }305306    // MARK: AppleScript classification307308    /// AppleScript can do anything the user can, so it is treated like a309    /// mutating shell command: manual & guarded always ask; autonomous asks310    /// only for risky patterns found by scanning the script text.311    private func evaluateAppleScript(_ action: ActionRequest) -> PolicyRuling {312        let risk = Self.classifyAppleScript(action.payload)313314        // Administrator privileges are elevated — ask in EVERY mode.315        if risk.level == .elevated {316            return .ask(risk: risk)317        }318319        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 == .safe325                    ? .allow(reason: "AppleScript with no risky patterns (Autonomous mode).")326                    : .allow(reason: "Autonomous mode — \(risk.reason)")327            }328            return .ask(risk: risk)329        }330    }331332    /// 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    }353354    // MARK: Approve & remember355356    /// Persists the NARROWEST allow rules covering the approved action: one357    /// per non-read-only subcommand (command + first argument), never for358    /// always-ask/destructive subcommands — circuit breakers cannot be359    /// 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.deny368            )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 arbitrary374            // paths — remembering them would be broader than the approval.375            break376        }377    }378}379380/// An action that passed the gate, ready to execute.381struct ClearedAction: Sendable {382    /// What actually runs (user may have edited it).383    var payload: String384    var decision: PolicyDecisionRecord385}386387/// Thrown when the gate refuses an action; surfaces to the model as an388/// error tool result so it can adapt.389struct PolicyDenied: Error, Sendable {390    var reason: String391}392393// MARK: - ShellCommandAnalyzer394395/// Stateless shell-payload analyzer: quote-aware splitting into subcommands,396/// wrapper stripping, per-subcommand classification, and aggregation to the397/// most severe finding. Pure functions — trivially testable.398enum ShellCommandAnalyzer {399400    /// 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 allow407        /// rule, or is a workspace-scoped write — i.e. safe to auto-run in408        /// Guarded mode.409        var allRunnableUnprompted: Bool410        /// Most severe risk across subcommands (drives the approval card).411        var risk: RiskAssessment412        /// Reason shown when auto-allowed.413        var allowReason: String414        /// Narrow per-subcommand patterns eligible for "Approve & remember".415        var rememberablePatterns: [String]416    }417418    /// 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 readOnly426    }427428    // MARK: Curated read-only allowset (§6.3: auto-allow ONLY curated reads)429430    /// Commands that never mutate anything regardless of arguments (barring431    /// 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    ]441442    /// 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    ]447448    /// Interpreters whose bare `--version`-style invocations are read-only.449    private static let versionOnlyFlags: Set<String> = ["--version", "-v", "-V", "--help", "-h"]450451    /// Wrappers stripped (with their own flags/assignments) before452    /// 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    ]456457    /// System path prefixes: mutating operations targeting these always ask.458    private static let protectedSystemPrefixes = ["/Library/", "/usr/", "/etc/", "/bin/", "/sbin/", "/var/", "/private/etc/"]459460    /// Commands that write to their path arguments (used to judge writes to461    /// 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    ]466467    // MARK: Built-in pattern descriptions (Settings › Safety, read-only)468469    /// Human-readable descriptions of the built-in hard-deny patterns —470    /// actions that never run, in any mode. Display-only mirror of the471    /// classification logic above; the logic itself is the source of truth.472    static let hardDenyDescriptions: [String] = [473        "Fork bombs (`:(){ :|:& };:` and renamed variants)",474        "Filesystem creation — `mkfs*`, `newfs_apfs`, `newfs_hfs` (destroys the target volume)",475        "`diskutil erase* / partitionDisk / zeroDisk / reformat / apfs delete…`",476        "`dd` writing directly to a device node (`of=/dev/…`)",477        "`rm` targeting `/`, `/*`, or the entire home directory",478        "Any write into `/System` (protected by System Integrity Protection)",479        "Commands matching one of your deny rules",480    ]481482    /// Human-readable descriptions of the always-ask circuit breakers —483    /// actions that require approval in EVERY mode, including Autonomous;484    /// remembered allow rules can never override them.485    static let alwaysAskDescriptions: [String] = [486        "`sudo` / `doas` — elevated privileges are never run silently",487        "`shutdown`, `reboot`, `halt`",488        "`kill`, `pkill`, `killall` — terminating processes",489        "`launchctl` — modifying launchd services",490        "`systemsetup`, `csrutil`, `nvram`, `spctl` — system-level configuration",491        "`security` — keychain access",492        "`defaults write` / `defaults delete` — preference changes",493        "`installer`, `softwareupdate --install` — system-wide installs",494        "`git push --force` — rewriting remote history",495        "AppleScript requesting administrator privileges",496        "Recursive `chmod`/`chown`/`chflags` outside the workspace",497        "`rm -rf` (or any deletion) on paths outside the task workspace",498        "`mv`/`cp` whose destination escapes the workspace or targets a system path",499        "Output redirection (`>`/`>>`) to files outside the workspace or with unresolvable variables",500        "Network downloads piped into an interpreter (`curl … | sh`)",501        "`find -exec rm/mv/chmod/chown/shred` — mass file modification",502        "Any file read or write outside the task workspace (FileTools)",503    ]504505    // MARK: Entry point506507    static func analyze(508        command: String,509        workspace: URL,510        allowRules: [StoredPolicyRule],511        denyRules: [StoredPolicyRule]512    ) -> Analysis {513        // Fork bombs must be caught on the WHOLE payload — the `|`/`;`/`&`514        // splitting below would shred the pattern into unrecognizable bits.515        if isForkBomb(command) {516            return Analysis(517                hardDenyReason: "Fork bomb.",518                alwaysAsk: nil,519                allRunnableUnprompted: false,520                risk: RiskAssessment(level: .destructive, reason: "Fork bomb."),521                allowReason: "",522                rememberablePatterns: []523            )524        }525526        let pipelines = splitIntoPipelines(command)527528        var hardDeny: String?529        var alwaysAsk: RiskAssessment?530        var mutatingReasons: [String] = []531        var allUnprompted = true532        var rememberable: [String] = []533534        for pipeline in pipelines {535            // Circuit breaker checked at PIPELINE level (needs the `|` shape):536            // network download piped into an interpreter.537            if let pipeRisk = classifyDownloadPipe(pipeline) {538                alwaysAsk = mostSevere(alwaysAsk, pipeRisk)539                allUnprompted = false540            }541542            for rawSegment in pipeline.segments {543                // Command substitutions inside the segment are classified as544                // subcommands of their own (Claude Code's `$(…)` breaker).545                let embedded = extractCommandSubstitutions(rawSegment)546                for sub in [rawSegment] + embedded {547                    let tokens = stripWrappers(tokenize(sub))548                    guard !tokens.isEmpty else { continue }549                    let classification = classify(550                        tokens: tokens,551                        rawSubcommand: sub,552                        workspace: workspace,553                        allowRules: allowRules,554                        denyRules: denyRules555                    )556                    switch classification {557                    case .hardDeny(let reason):558                        hardDeny = hardDeny ?? reason559                        allUnprompted = false560                    case .alwaysAsk(let risk):561                        alwaysAsk = mostSevere(alwaysAsk, risk)562                        allUnprompted = false563                    case .mutating(let reason):564                        mutatingReasons.append(reason)565                        allUnprompted = false566                        rememberable.append(rememberPattern(for: tokens))567                    case .workspaceWrite:568                        // Workspace-scoped writes auto-run in guarded mode,569                        // matching FileTools' .fileWrite behavior.570                        continue571                    case .allowedByRule, .readOnly:572                        continue573                    }574                }575            }576        }577578        let risk: RiskAssessment579        if let alwaysAsk {580            risk = alwaysAsk581        } else if let first = mutatingReasons.first {582            risk = RiskAssessment(level: .mutating, reason: first)583        } else {584            risk = RiskAssessment(level: .safe, reason: "Read-only command.")585        }586587        return Analysis(588            hardDenyReason: hardDeny,589            alwaysAsk: alwaysAsk,590            allRunnableUnprompted: allUnprompted,591            risk: risk,592            allowReason: allUnprompted593                ? "All subcommands are read-only, workspace-scoped, or covered by remembered allow rules."594                : "",595            rememberablePatterns: rememberable596        )597    }598599    // MARK: Per-subcommand classification (deny → ask → allow order)600601    private static func classify(602        tokens: [String],603        rawSubcommand: String,604        workspace: URL,605        allowRules: [StoredPolicyRule],606        denyRules: [StoredPolicyRule]607    ) -> Classification {608        let head = tokens[0]609        let args = Array(tokens.dropFirst())610        let home = FileManager.default.homeDirectoryForCurrentUser.path611612        // ---- 1a. User deny rules (deny comes first, always) --------------613614        if let denied = matchRule(tokens: tokens, rules: denyRules, kind: "bash") {615            return .hardDeny("Matches your deny rule “\(denied.pattern)”.")616        }617618        // ---- 1b. Hard denylist -------------------------------------------619620        // Fork bomb fragments that survived splitting.621        if isForkBomb(rawSubcommand) {622            return .hardDeny("Fork bomb.")623        }624        // Filesystem/disk destruction.625        if head == "mkfs" || head.hasPrefix("mkfs.") || head == "newfs_apfs" || head == "newfs_hfs" {626            return .hardDeny("Creates a filesystem — destroys the target volume.")627        }628        if head == "diskutil" {629            let sub = args.first?.lowercased() ?? ""630            if sub.hasPrefix("erase") || sub == "partitiondisk" || sub == "zerodisk" || sub == "reformat"631                || (sub == "apfs" && (args.dropFirst().first?.lowercased().contains("delete") ?? false)) {632                return .hardDeny("diskutil \(sub) erases or repartitions a disk.")633            }634            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "diskutil modifies disk state."))635        }636        if head == "dd", args.contains(where: { $0.hasPrefix("of=/dev/") }) {637            return .hardDeny("dd writing directly to a device node.")638        }639        // rm -rf aimed at root or the entire home directory.640        if head == "rm" {641            let rm = analyzeRM(args: args, workspace: workspace, home: home)642            if rm.wholesale {643                return .hardDeny("rm targeting the filesystem root or the entire home directory.")644            }645            if rm.recursive || rm.force {646                if rm.anyOutsideWorkspace {647                    return .alwaysAsk(RiskAssessment(level: .destructive, reason: "rm \(rm.recursive ? "-r " : "")\(rm.force ? "-f " : "")on paths outside the task workspace."))648                }649                return .mutating("Deletes files recursively inside the workspace.")650            }651            if rm.anyOutsideWorkspace {652                return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Deletes files outside the task workspace."))653            }654            return .mutating("Deletes files inside the workspace.")655        }656        // Writes to /System are futile (SIP) and never legitimate.657        if pathWritingCommands.contains(head), args.contains(where: { resolvePath($0, cwd: workspace, home: home).hasPrefix("/System/") }) {658            return .hardDeny("Writes to /System (protected by System Integrity Protection).")659        }660661        // ---- 2. Always-ask circuit breakers (every mode) ----------------662663        if head == "sudo" || head == "doas" {664            return .alwaysAsk(RiskAssessment(level: .elevated, reason: "Requires elevated privileges (\(head)). Zyquo Agent never runs sudo silently."))665        }666        if head == "shutdown" || head == "reboot" || head == "halt" {667            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Shuts down or restarts the Mac."))668        }669        if head == "kill" || head == "pkill" || head == "killall" {670            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Terminates running processes (\(head))."))671        }672        if head == "launchctl" {673            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Modifies launchd services."))674        }675        if head == "systemsetup" || head == "csrutil" || head == "nvram" || head == "spctl" {676            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Changes system-level configuration (\(head))."))677        }678        if head == "security" {679            return .alwaysAsk(RiskAssessment(level: .elevated, reason: "Accesses the keychain via security(1)."))680        }681        if head == "defaults", args.first == "write" || args.first == "delete" {682            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Modifies application/system preferences (defaults \(args.first ?? ""))."))683        }684        if head == "installer" || (head == "softwareupdate" && args.contains(where: { $0 == "-i" || $0 == "--install" })) {685            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Installs software system-wide (\(head))."))686        }687        if head == "git", args.contains("push"), args.contains(where: { $0 == "--force" || $0 == "-f" || $0.hasPrefix("--force-with-lease") }) {688            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "git push --force rewrites remote history."))689        }690        if head == "osascript", rawSubcommand.lowercased().contains("with administrator privileges") {691            return .alwaysAsk(RiskAssessment(level: .elevated, reason: "AppleScript requesting administrator privileges."))692        }693        if (head == "chmod" || head == "chown" || head == "chflags"),694           args.contains(where: { $0 == "-R" || $0 == "-r" }) {695            let paths = args.filter { !$0.hasPrefix("-") }.dropFirst() // drop mode/owner operand696            if paths.contains(where: { !isInsideWorkspace(resolvePath($0, cwd: workspace, home: home), workspace: workspace) }) {697                return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Recursive \(head) on paths outside the task workspace."))698            }699        }700        // mv/cp whose DESTINATION escapes the workspace (can overwrite user files).701        if head == "mv" || head == "cp" {702            let operands = args.filter { !$0.hasPrefix("-") }703            if let destination = operands.last, operands.count >= 2 {704                if isUnresolvable(destination) {705                    return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) destination is variable-based (\(destination)) — cannot verify it stays inside the workspace."))706                }707                let resolved = resolvePath(destination, cwd: workspace, home: home)708                if !isInsideWorkspace(resolved, workspace: workspace) {709                    if protectedSystemPrefixes.contains(where: { resolved.hasPrefix($0) }) {710                        return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) writing into a system path (\(resolved))."))711                    }712                    return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) writing outside the task workspace (\(resolved)) — may overwrite existing files."))713                }714            }715            return .mutating("Moves/copies files inside the workspace.")716        }717        // Mutating commands targeting protected system paths. Workspace-718        // internal paths are exempt: a temp workspace can itself live under719        // /var/folders/…, and writes inside it are workspace-scoped.720        if pathWritingCommands.contains(head),721           args.contains(where: {722               let resolved = resolvePath($0, cwd: workspace, home: home)723               return !isInsideWorkspace(resolved, workspace: workspace)724                   && protectedSystemPrefixes.contains(where: resolved.hasPrefix)725           }) {726            return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Writes to a protected system path (/Library, /usr, /etc, …)."))727        }728        // Output redirection: judged by target path (workspace first — the729        // workspace itself may live under a protected-looking prefix).730        if let redirect = redirectionTarget(rawSubcommand) {731            if isUnresolvable(redirect) {732                return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output to a variable-based path (\(redirect)) — cannot verify it stays inside the workspace."))733            }734            let resolved = resolvePath(redirect, cwd: workspace, home: home)735            if isInsideWorkspace(resolved, workspace: workspace) {736                return .workspaceWrite("Writes a file inside the workspace via redirection.")737            }738            if resolved.hasPrefix("/System/") {739                return .hardDeny("Redirects output into /System.")740            }741            if resolved.hasPrefix("/dev/") {742                // /dev/null, /dev/stdout … — harmless sinks.743            } else if protectedSystemPrefixes.contains(where: { resolved.hasPrefix($0) }) {744                return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output into a protected system path (\(resolved))."))745            } else {746                return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output to a file outside the task workspace (\(resolved))."))747            }748        }749750        // ---- 4. User allow rules (never reached for the classes above) --751752        if let allowed = matchRule(tokens: tokens, rules: allowRules, kind: "bash") {753            return .allowedByRule("Matches your remembered allow rule “\(allowed.pattern)”.")754        }755756        // ---- 5. Curated read-only allowset ------------------------------757758        if head == "git", let sub = args.first(where: { !$0.hasPrefix("-") }) {759            if readOnlyGitSubcommands.contains(sub) {760                // `git remote add`, `git config --global x y` DO mutate.761                if sub == "remote", args.count > 1, args.contains(where: { ["add", "remove", "rm", "set-url", "rename"].contains($0) }) {762                    return .mutating("git remote modification.")763                }764                if sub == "config", args.contains(where: { !$0.hasPrefix("-") && $0 != "config" }) && !args.contains("--list") && !args.contains("--get") {765                    return .mutating("git config write.")766                }767                return .readOnly768            }769            if sub == "branch", args.allSatisfy({ $0 == "branch" || $0.hasPrefix("-") }) || args == ["branch"] {770                return .readOnly771            }772            return .mutating("git \(sub) mutates the repository.")773        }774        if head == "find" {775            if args.contains("-delete") {776                return .mutating("find -delete removes files.")777            }778            if let execIndex = args.firstIndex(where: { $0 == "-exec" || $0 == "-execdir" || $0 == "-ok" }) {779                let executed = args.dropFirst(execIndex + 1).first ?? ""780                // find -exec is exec-capable — classify by what it runs; rm et al. ask.781                if ["rm", "mv", "chmod", "chown", "shred"].contains(executed) {782                    return .alwaysAsk(RiskAssessment(level: .destructive, reason: "find -exec \(executed) modifies files en masse."))783                }784                return .mutating("find -exec runs \(executed.isEmpty ? "a command" : executed) per match.")785            }786            return .readOnly787        }788        if readOnlyCommands.contains(head) {789            return .readOnly790        }791        // `defaults read`, `softwareupdate --list`, bare `env`… read-only tails.792        if head == "defaults", args.first == "read" { return .readOnly }793        if head == "softwareupdate", args.contains(where: { $0 == "-l" || $0 == "--list" }) { return .readOnly }794        // Any command invoked purely for its version/help.795        if args.count == 1, let only = args.first, versionOnlyFlags.contains(only) {796            return .readOnly797        }798799        // ---- 6. Default: mutating (mode decides) ------------------------800801        return .mutating("`\(head)` may modify state — not in the read-only allowset.")802    }803804    // MARK: rm analysis805806    private struct RMAnalysis {807        var recursive = false808        var force = false809        /// True when a target resolves to `/`, `/*`, or the home directory itself.810        var wholesale = false811        var anyOutsideWorkspace = false812    }813814    private static func analyzeRM(args: [String], workspace: URL, home: String) -> RMAnalysis {815        var analysis = RMAnalysis()816        var targets: [String] = []817        for arg in args {818            if arg.hasPrefix("--") {819                if arg == "--recursive" { analysis.recursive = true }820                if arg == "--force" { analysis.force = true }821                continue822            }823            if arg.hasPrefix("-"), arg.count > 1 {824                let flags = arg.dropFirst().lowercased()825                if flags.contains("r") { analysis.recursive = true }826                if flags.contains("f") { analysis.force = true }827                continue828            }829            targets.append(arg)830        }831        for target in targets {832            // Paths built from variables or substitutions can point anywhere —833            // classify them conservatively as outside the workspace.834            if isUnresolvable(target) {835                analysis.anyOutsideWorkspace = true836                continue837            }838            let resolved = resolvePath(target, cwd: workspace, home: home)839            let normalized = resolved.hasSuffix("/") && resolved.count > 1 ? String(resolved.dropLast()) : resolved840            if normalized == "/" || normalized == "/*" || normalized == home || normalized == home + "/*"841                || target == "/" || target == "/*" || target == "~" || target == "~/" || target == "$HOME" {842                analysis.wholesale = true843            }844            if !isInsideWorkspace(resolved, workspace: workspace) {845                analysis.anyOutsideWorkspace = true846            }847        }848        // `rm -rf` with no explicit target is odd but not wholesale.849        return analysis850    }851852    // MARK: Pipeline-level breaker: download piped into an interpreter853854    private static func classifyDownloadPipe(_ pipeline: Pipeline) -> RiskAssessment? {855        guard pipeline.segments.count >= 2 else { return nil }856        let downloaders: Set<String> = ["curl", "wget", "fetch"]857        let interpreters: Set<String> = ["sh", "bash", "zsh", "ksh", "dash", "python", "python3", "ruby", "perl", "node", "osascript"]858        var sawDownloader = false859        for segment in pipeline.segments {860            let tokens = stripWrappers(tokenize(segment))861            guard let head = tokens.first else { continue }862            let bare = (head as NSString).lastPathComponent863            if downloaders.contains(bare) { sawDownloader = true; continue }864            if sawDownloader && interpreters.contains(bare) {865                return RiskAssessment(level: .destructive, reason: "Pipes a network download directly into \(bare) — executes remote code.")866            }867        }868        return nil869    }870871    // MARK: Rule matching872873    /// Token-prefix match: rule "brew list" matches subcommand tokens874    /// beginning ["brew", "list"]. First match wins.875    private static func matchRule(tokens: [String], rules: [StoredPolicyRule], kind: String) -> StoredPolicyRule? {876        for rule in rules where rule.kind == kind {877            let ruleTokens = rule.pattern.split(separator: " ").map(String.init)878            guard !ruleTokens.isEmpty, ruleTokens.count <= tokens.count else { continue }879            if Array(tokens.prefix(ruleTokens.count)) == ruleTokens {880                return rule881            }882        }883        return nil884    }885886    /// Narrowest pattern to remember for a subcommand: command + first887    /// non-flag argument when present ("brew list"), else just the command.888    private static func rememberPattern(for tokens: [String]) -> String {889        guard let head = tokens.first else { return "" }890        if tokens.count > 1, !tokens[1].hasPrefix("-") {891            return "\(head) \(tokens[1])"892        }893        return head894    }895896    // MARK: Path helpers897898    /// Detects the classic bash fork bomb (`:(){ :|:& };:`) and its899    /// renamed-function variants: a function definition whose body pipes the900    /// function into itself and backgrounds it.901    static func isForkBomb(_ text: String) -> Bool {902        let compact = text.replacingOccurrences(of: " ", with: "").replacingOccurrences(of: "\n", with: "")903        if compact.contains(":(){:|:&};:") || compact.contains("(){:|:&};") {904            return true905        }906        // Generic shape: name(){name|name&};name907        if let regex = try? NSRegularExpression(pattern: #"(\w+)\(\)\{\1\|\1&\};?"#),908           regex.firstMatch(in: compact, range: NSRange(compact.startIndex..., in: compact)) != nil {909            return true910        }911        return false912    }913914    /// True when a path contains shell variables or substitutions we cannot915    /// statically resolve (other than a leading `$HOME`). Callers treat such916    /// paths conservatively (as escaping the workspace).917    static func isUnresolvable(_ raw: String) -> Bool {918        if raw == "$HOME" || raw.hasPrefix("$HOME/") { return false }919        return raw.contains("$") || raw.contains("`")920    }921922    static func resolvePath(_ raw: String, cwd: URL, home: String) -> String {923        var path = raw924        if path.hasPrefix("~/") {925            path = home + String(path.dropFirst(1))926        } else if path == "~" {927            path = home928        } else if path == "$HOME" || path.hasPrefix("$HOME/") {929            path = home + String(path.dropFirst("$HOME".count))930        }931        if !path.hasPrefix("/") {932            path = cwd.appendingPathComponent(path).path933        }934        return (path as NSString).standardizingPath935    }936937    static func isInsideWorkspace(_ resolvedPath: String, workspace: URL) -> Bool {938        let root = (workspace.path as NSString).standardizingPath939        return resolvedPath == root || resolvedPath.hasPrefix(root + "/")940    }941942    // MARK: Shell parsing (quote-aware; parsing is UX, not a security boundary)943944    /// One pipeline: the segments between `|` operators.945    struct Pipeline {946        var segments: [String]947    }948949    /// Splits a payload on `&&`, `||`, `;`, newlines (into command lists) and950    /// then on `|` (into pipeline segments), respecting single/double quotes951    /// and backslash escapes. `2>&1`-style fd duplications are not treated as952    /// pipes.953    static func splitIntoPipelines(_ command: String) -> [Pipeline] {954        var pipelines: [Pipeline] = []955        var currentSegment = ""956        var currentSegments: [String] = []957958        func endSegment() {959            let trimmed = currentSegment.trimmingCharacters(in: .whitespacesAndNewlines)960            if !trimmed.isEmpty { currentSegments.append(trimmed) }961            currentSegment = ""962        }963        func endPipeline() {964            endSegment()965            if !currentSegments.isEmpty {966                pipelines.append(Pipeline(segments: currentSegments))967                currentSegments = []968            }969        }970971        var iterator = command.makeIterator()972        var pending: Character? = nil973        var inSingle = false974        var inDouble = false975        var previous: Character? = nil976977        func next() -> Character? {978            if let p = pending { pending = nil; return p }979            return iterator.next()980        }981982        while let ch = next() {983            defer { previous = ch }984            if inSingle {985                currentSegment.append(ch)986                if ch == "'" { inSingle = false }987                continue988            }989            if inDouble {990                currentSegment.append(ch)991                if ch == "\\" { if let escaped = next() { currentSegment.append(escaped) } }992                else if ch == "\"" { inDouble = false }993                continue994            }995            switch ch {996            case "'":997                inSingle = true998                currentSegment.append(ch)999            case "\"":1000                inDouble = true1001                currentSegment.append(ch)1002            case "\\":1003                currentSegment.append(ch)1004                if let escaped = next() { currentSegment.append(escaped) }1005            case "&":1006                if previous == ">" || previous == "<" {1007                    // fd duplication (`2>&1`, `>&2`, `<&0`) — not a control operator.1008                    currentSegment.append(ch)1009                } else if let lookahead = next() {1010                    if lookahead == "&" {1011                        endPipeline() // `&&`1012                    } else {1013                        // Background `&`: terminates the command like `;`.1014                        endPipeline()1015                        pending = lookahead1016                    }1017                } else {1018                    endPipeline()1019                }1020            case "|":1021                if let lookahead = next() {1022                    if lookahead == "|" {1023                        endPipeline() // `||`1024                    } else if lookahead == "&" {1025                        endSegment() // `|&` pipes stdout+stderr1026                    } else {1027                        endSegment() // plain pipe1028                        pending = lookahead1029                    }1030                } else {1031                    endSegment()1032                }1033            case ";", "\n":1034                endPipeline()1035            default:1036                currentSegment.append(ch)1037            }1038        }1039        endPipeline()1040        return pipelines1041    }10421043    /// Extracts the bodies of `$(…)` and `` `…` `` command substitutions so1044    /// they are classified as subcommands in their own right — a breaker1045    /// hidden inside a substitution must still trip.1046    static func extractCommandSubstitutions(_ segment: String) -> [String] {1047        var results: [String] = []1048        let characters = Array(segment)1049        var i = 01050        while i < characters.count {1051            if characters[i] == "$", i + 1 < characters.count, characters[i + 1] == "(" {1052                var depth = 11053                var j = i + 21054                var body = ""1055                while j < characters.count, depth > 0 {1056                    if characters[j] == "(" { depth += 1 }1057                    if characters[j] == ")" { depth -= 1; if depth == 0 { break } }1058                    body.append(characters[j])1059                    j += 11060                }1061                let trimmed = body.trimmingCharacters(in: .whitespaces)1062                if !trimmed.isEmpty { results.append(trimmed) }1063                i = j1064            } else if characters[i] == "`" {1065                var j = i + 11066                var body = ""1067                while j < characters.count, characters[j] != "`" {1068                    body.append(characters[j])1069                    j += 11070                }1071                let trimmed = body.trimmingCharacters(in: .whitespaces)1072                if !trimmed.isEmpty { results.append(trimmed) }1073                i = j1074            }1075            i += 11076        }1077        return results1078    }10791080    /// Splits one subcommand into tokens, respecting quotes (quotes removed).1081    static func tokenize(_ subcommand: String) -> [String] {1082        var tokens: [String] = []1083        var current = ""1084        var inSingle = false1085        var inDouble = false1086        var hasContent = false10871088        for ch in subcommand {1089            if inSingle {1090                if ch == "'" { inSingle = false } else { current.append(ch) }1091                continue1092            }1093            if inDouble {1094                if ch == "\"" { inDouble = false } else { current.append(ch) }1095                continue1096            }1097            switch ch {1098            case "'": inSingle = true; hasContent = true1099            case "\"": inDouble = true; hasContent = true1100            case " ", "\t":1101                if hasContent || !current.isEmpty {1102                    tokens.append(current)1103                    current = ""1104                    hasContent = false1105                }1106            default:1107                current.append(ch)1108            }1109        }1110        if hasContent || !current.isEmpty { tokens.append(current) }1111        return tokens1112    }11131114    /// Strips leading wrappers and env assignments: `env FOO=1 nohup time ls`1115    /// → `["ls"]`. `xargs rm` classifies as `rm`. `sudo` is NOT strippable —1116    /// it must classify as itself.1117    static func stripWrappers(_ tokens: [String]) -> [String] {1118        var tokens = tokens1119        while let head = tokens.first {1120            // VAR=value assignment prefixes.1121            if head.contains("="), !head.hasPrefix("-"), !head.hasPrefix("="),1122               head.firstIndex(of: "=").map({ head[head.startIndex..<$0].allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" } }) == true {1123                tokens.removeFirst()1124                continue1125            }1126            if strippableWrappers.contains(head) {1127                tokens.removeFirst()1128                // Drop the wrapper's own flags (e.g. `xargs -n1`, `env -i`).1129                while let next = tokens.first, next.hasPrefix("-") {1130                    tokens.removeFirst()1131                }1132                continue1133            }1134            break1135        }1136        return tokens1137    }11381139    /// Finds the target of the first `>` / `>>` output redirection outside1140    /// quotes (nil when there is none). `2>&1`, `>&2` fd-duplications and1141    /// heredocs are ignored.1142    static func redirectionTarget(_ subcommand: String) -> String? {1143        let characters = Array(subcommand)1144        var inSingle = false1145        var inDouble = false1146        var i = 01147        while i < characters.count {1148            let ch = characters[i]1149            if ch == "'" && !inDouble { inSingle.toggle() }1150            else if ch == "\"" && !inSingle { inDouble.toggle() }1151            else if ch == ">" && !inSingle && !inDouble {1152                var j = i + 11153                if j < characters.count, characters[j] == ">" { j += 1 } // `>>`1154                if j < characters.count, characters[j] == "&" { i = j + 1; continue } // `>&1`1155                // Skip whitespace, then read the target word.1156                while j < characters.count, characters[j] == " " || characters[j] == "\t" { j += 1 }1157                var target = ""1158                while j < characters.count, characters[j] != " " && characters[j] != "\t"1159                        && characters[j] != ";" && characters[j] != "|" && characters[j] != "&" {1160                    target.append(characters[j])1161                    j += 11162                }1163                let unquoted = target.trimmingCharacters(in: CharacterSet(charactersIn: "'\""))1164                return unquoted.isEmpty ? nil : unquoted1165            }1166            i += 11167        }1168        return nil1169    }11701171    // MARK: Severity ordering11721173    private static func mostSevere(_ a: RiskAssessment?, _ b: RiskAssessment) -> RiskAssessment {1174        guard let a else { return b }1175        return rank(a.level) >= rank(b.level) ? a : b1176    }11771178    private static func rank(_ level: RiskAssessment.Level) -> Int {1179        switch level {1180        case .safe: return 01181        case .mutating: return 11182        case .destructive: return 21183        case .elevated: return 31184        }1185    }1186}1187