// // PolicyEngine.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The safety gate every action passes through — no shell command, AppleScript, // or out-of-workspace file access runs without a ruling from here. // // Design (docs/AGENT-RESEARCH.md §6.3–6.4, Claude-Code-style): // // Precedence, first match wins, evaluated PER SUBCOMMAND: // 1. hard deny (never runs, no approval can help) // 2. user deny rules (user-managed denylist) // 3. always-ask class (destructive/elevated circuit breakers — // ask in EVERY mode, including Autonomous; // remembered allow rules can NOT override these) // 4. user allow rules ("Approve & remember" narrow per-subcommand rules) // 5. curated read-only allowset // 6. default: mutating (mode decides) // // Shell payloads are PARSED, never regex'd raw: split on `&&`, `||`, `;`, // `|` and newlines into subcommands (quote-aware), command substitutions // `$(…)`/backticks are extracted and classified too, and common wrappers // (`env VAR=x`, `nohup`, `time`, `xargs`, `nice`, `command`) are stripped // before classification. The overall ruling is the MOST SEVERE across all // subcommands — `ls && rm -rf ~/x` asks because the second half asks. // // Mode behavior: // manual → every gated action asks. (Deliberate exception documented // in FileTools: pure in-workspace FileTools READS never // reach the gate at all — asking to read the agent's own // scratch files would make Manual mode unusable.) // guarded → read-only allowset auto-runs; in-workspace file writes // (.fileWrite) auto-run; everything else asks. // autonomous → everything auto-runs EXCEPT hard-denies and the // always-ask class, which still ask. // // AppleScript: manual & guarded always ask. Autonomous asks only when the // script matches risky patterns (administrator privileges, System Events // keystrokes, delete, do shell script, quit/restart/shutdown). // // Pattern-matching is UX, not a security boundary (the Cursor lesson): // it is paired with workspace scoping, approvals, and the append-only // AuditLog — nothing the agent does is invisible. // import Foundation /// User-selectable safety posture, per task. enum SafetyMode: String, Codable, CaseIterable, Identifiable, Sendable { /// Approve every action. case manual /// Auto-run read-only/safe actions, ask for anything mutating or risky. case guarded /// Run freely within budget — clearly labeled, off by default, still /// audited, and destructive patterns STILL require approval. case autonomous var id: String { rawValue } var displayName: String { switch self { case .manual: return "Manual" case .guarded: return "Guarded" case .autonomous: return "Autonomous" } } } /// An action submitted to the gate for classification. struct ActionRequest: Sendable { enum Kind: String, Codable, Sendable { case shellCommand case appleScript /// File write/edit whose resolved path is INSIDE the workspace. case fileWrite /// File write/edit whose resolved path escapes the workspace — /// always asks, in every mode. case fileWriteOutsideWorkspace /// File read whose resolved path escapes the workspace — always asks. case fileReadOutsideWorkspace } var kind: Kind /// The exact command / script / path the user will see verbatim. var payload: String var cwd: URL /// Model-provided explanation of intent, shown on the approval card. var explanation: String? } /// The gate's ruling for one action. enum PolicyRuling: Sendable { /// Safe under the active mode — run without asking. case allow(reason: String) /// Hold for user approval (approval card), with a risk label. case ask(risk: RiskAssessment) /// Never run (hard denylist). case deny(reason: String) } /// Risk classification attached to approval requests. struct RiskAssessment: Sendable { enum Level: String, Codable, Sendable { case safe, mutating, destructive, elevated } var level: Level /// Human-readable reason ("deletes files recursively", "requires sudo"…). var reason: String } /// How the user (or mode) resolved an approval request. enum ApprovalResolution: Sendable { case approve /// Approve and remember an allow rule for this safe class. case approveAndRemember /// User edited the payload, then approved; carries the edited payload. case approveEdited(String) case deny } /// Asynchronous bridge to whoever answers approval requests (UI card or CLI /// prompt). The loop blocks on this until resolved. protocol ApprovalPresenting: Sendable { func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution } // MARK: - Stored rules ("Approve & remember" + user-managed lists) /// One persisted rule: `kind` scopes it to a tool family ("bash" or /// "osascript"), `pattern` is a normalized token-prefix — `"brew list"` /// matches `brew list`, `brew list --versions`, … but NOT `brew install`. /// Matching is per-SUBCOMMAND (after wrapper stripping), never against the /// raw compound string, so an allow rule cannot smuggle a `&& rm -rf` along. struct StoredPolicyRule: Codable, Hashable, Sendable { var kind: String var pattern: String } /// The on-disk rule document (`policy-rules.json` in the app data folder). struct StoredPolicyRules: Codable, Sendable { var allow: [StoredPolicyRule] = [] var deny: [StoredPolicyRule] = [] } // MARK: - PolicyEngine /// The gate. Actor: rulings and remembered rules mutate shared state. actor PolicyEngine { private(set) var mode: SafetyMode private let approvals: ApprovalPresenting private let persistence: PersistenceService private var storedRules: StoredPolicyRules private static let rulesFileName = "policy-rules.json" /// `persistence` decides where remembered rules live; tests pass a /// temp-rooted PersistenceService so they never touch the real rule file. init(mode: SafetyMode, approvals: ApprovalPresenting, persistence: PersistenceService = .shared) { self.mode = mode self.approvals = approvals self.persistence = persistence self.storedRules = persistence.load(StoredPolicyRules.self, from: Self.rulesFileName) ?? StoredPolicyRules() } func setMode(_ newMode: SafetyMode) { mode = newMode } // MARK: Rule management (Settings › Safety) var rules: StoredPolicyRules { storedRules } func addAllowRule(_ rule: StoredPolicyRule) { guard !storedRules.allow.contains(rule) else { return } storedRules.allow.append(rule) persistence.save(storedRules, to: Self.rulesFileName) } func addDenyRule(_ rule: StoredPolicyRule) { guard !storedRules.deny.contains(rule) else { return } storedRules.deny.append(rule) persistence.save(storedRules, to: Self.rulesFileName) } func removeAllowRule(_ rule: StoredPolicyRule) { storedRules.allow.removeAll { $0 == rule } persistence.save(storedRules, to: Self.rulesFileName) } func removeDenyRule(_ rule: StoredPolicyRule) { storedRules.deny.removeAll { $0 == rule } persistence.save(storedRules, to: Self.rulesFileName) } // MARK: The gate /// Classifies the action, asks the user when required, and returns what /// may actually run (payload may have been edited). Throws `PolicyDenied` /// when the action must not run. func clear(_ action: ActionRequest) async throws -> ClearedAction { switch evaluate(action) { case .deny(let reason): throw PolicyDenied(reason: reason) case .allow(let reason): return ClearedAction( payload: action.payload, decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: reason) ) case .ask(let risk): let resolution = await approvals.requestApproval(for: action, risk: risk) switch resolution { case .approve: return ClearedAction( payload: action.payload, decision: PolicyDecisionRecord(ruling: .approvedByUser, riskLabel: risk.level.rawValue, rationale: risk.reason) ) case .approveAndRemember: rememberAllowRules(for: action) return ClearedAction( payload: action.payload, decision: PolicyDecisionRecord(ruling: .approvedByUser, riskLabel: risk.level.rawValue, rationale: risk.reason) ) case .approveEdited(let edited): // The edited payload is re-classified: an edit can never // sneak past the hard denylist, but an explicit user edit + // approval covers ask-class results. var editedAction = action editedAction.payload = edited if case .deny(let reason) = evaluate(editedAction) { throw PolicyDenied(reason: "Edited command is on the hard denylist: \(reason)") } return ClearedAction( payload: edited, decision: PolicyDecisionRecord(ruling: .editedAndApproved, riskLabel: risk.level.rawValue, rationale: risk.reason) ) case .deny: throw PolicyDenied(reason: "User denied the action.") } } } /// Pure classification — no user interaction. Exposed for the approval /// UI (pre-labeling), tests, and the `--verify-policy` self-check. func evaluate(_ action: ActionRequest) -> PolicyRuling { switch action.kind { case .shellCommand: return evaluateShell(action) case .appleScript: return evaluateAppleScript(action) case .fileWrite: // Path already resolved INSIDE the workspace by FileTools. switch mode { case .manual: return .ask(risk: RiskAssessment(level: .mutating, reason: "Writes a file inside the task workspace.")) case .guarded, .autonomous: return .allow(reason: "File write scoped to the task workspace.") } case .fileWriteOutsideWorkspace: // Circuit breaker: escaping the workspace always asks. return .ask(risk: RiskAssessment(level: .destructive, reason: "Writes to a file OUTSIDE the task workspace: \(action.payload)")) case .fileReadOutsideWorkspace: // Reads can exfiltrate (dotfiles, keys) — always ask when escaping. return .ask(risk: RiskAssessment(level: .safe, reason: "Reads a file outside the task workspace: \(action.payload)")) } } // MARK: Shell classification private func evaluateShell(_ action: ActionRequest) -> PolicyRuling { let analysis = ShellCommandAnalyzer.analyze( command: action.payload, workspace: action.cwd, allowRules: storedRules.allow, denyRules: storedRules.deny ) if let denyReason = analysis.hardDenyReason { return .deny(reason: denyReason) } if let breaker = analysis.alwaysAsk { return .ask(risk: breaker) } switch mode { case .manual: return .ask(risk: analysis.risk) case .guarded: if analysis.allRunnableUnprompted { return .allow(reason: analysis.allowReason) } return .ask(risk: analysis.risk) case .autonomous: return .allow(reason: analysis.allRunnableUnprompted ? analysis.allowReason : "Autonomous mode — \(analysis.risk.reason)") } } // MARK: AppleScript classification /// AppleScript can do anything the user can, so it is treated like a /// mutating shell command: manual & guarded always ask; autonomous asks /// only for risky patterns found by scanning the script text. private func evaluateAppleScript(_ action: ActionRequest) -> PolicyRuling { let risk = Self.classifyAppleScript(action.payload) // Administrator privileges are elevated — ask in EVERY mode. if risk.level == .elevated { return .ask(risk: risk) } switch mode { case .manual, .guarded: return .ask(risk: risk) case .autonomous: if risk.level == .safe || risk.level == .mutating { return risk.level == .safe ? .allow(reason: "AppleScript with no risky patterns (Autonomous mode).") : .allow(reason: "Autonomous mode — \(risk.reason)") } return .ask(risk: risk) } } /// Text-scan risk classifier for AppleScript payloads. static func classifyAppleScript(_ script: String) -> RiskAssessment { let lowered = script.lowercased() if lowered.contains("with administrator privileges") { return RiskAssessment(level: .elevated, reason: "AppleScript requests administrator privileges.") } if lowered.contains("system events"), lowered.contains("keystroke") || lowered.contains("key code") { return RiskAssessment(level: .destructive, reason: "Sends synthetic keystrokes via System Events — can drive any app.") } if lowered.contains("delete ") || lowered.contains("move to trash") || lowered.contains("empty trash") { return RiskAssessment(level: .destructive, reason: "AppleScript deletes or trashes items.") } if lowered.contains("do shell script") { return RiskAssessment(level: .destructive, reason: "AppleScript runs a shell command (`do shell script`).") } if lowered.contains("shut down") || lowered.contains("restart") || lowered.contains("log out") { return RiskAssessment(level: .destructive, reason: "AppleScript shuts down, restarts, or logs out the Mac.") } return RiskAssessment(level: .mutating, reason: "Automates a macOS application via AppleScript.") } // MARK: Approve & remember /// Persists the NARROWEST allow rules covering the approved action: one /// per non-read-only subcommand (command + first argument), never for /// always-ask/destructive subcommands — circuit breakers cannot be /// remembered away. private func rememberAllowRules(for action: ActionRequest) { switch action.kind { case .shellCommand: let analysis = ShellCommandAnalyzer.analyze( command: action.payload, workspace: action.cwd, allowRules: storedRules.allow, denyRules: storedRules.deny ) for pattern in analysis.rememberablePatterns.prefix(5) { addAllowRule(StoredPolicyRule(kind: "bash", pattern: pattern)) } case .appleScript, .fileWrite, .fileWriteOutsideWorkspace, .fileReadOutsideWorkspace: // No stable, narrow pattern exists for scripts or arbitrary // paths — remembering them would be broader than the approval. break } } } /// An action that passed the gate, ready to execute. struct ClearedAction: Sendable { /// What actually runs (user may have edited it). var payload: String var decision: PolicyDecisionRecord } /// Thrown when the gate refuses an action; surfaces to the model as an /// error tool result so it can adapt. struct PolicyDenied: Error, Sendable { var reason: String } // MARK: - ShellCommandAnalyzer /// Stateless shell-payload analyzer: quote-aware splitting into subcommands, /// wrapper stripping, per-subcommand classification, and aggregation to the /// most severe finding. Pure functions — trivially testable. enum ShellCommandAnalyzer { /// Aggregated findings for one full shell payload. struct Analysis { /// Non-nil when any subcommand hit the hard denylist. var hardDenyReason: String? /// Non-nil when any subcommand is in the always-ask class. var alwaysAsk: RiskAssessment? /// True when EVERY subcommand is read-only, matched a stored allow /// rule, or is a workspace-scoped write — i.e. safe to auto-run in /// Guarded mode. var allRunnableUnprompted: Bool /// Most severe risk across subcommands (drives the approval card). var risk: RiskAssessment /// Reason shown when auto-allowed. var allowReason: String /// Narrow per-subcommand patterns eligible for "Approve & remember". var rememberablePatterns: [String] } /// Per-subcommand classification, ordered by severity. private enum Classification { case hardDeny(String) case alwaysAsk(RiskAssessment) case mutating(String) case workspaceWrite(String) case allowedByRule(String) case readOnly } // MARK: Curated read-only allowset (§6.3: auto-allow ONLY curated reads) /// Commands that never mutate anything regardless of arguments (barring /// output redirection, which is detected separately). private static let readOnlyCommands: Set = [ "ls", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "rg", "pwd", "echo", "printf", "which", "file", "stat", "du", "df", "date", "whoami", "uname", "sw_vers", "hostname", "id", "uptime", "printenv", "basename", "dirname", "realpath", "readlink", "type", "true", "false", "test", "[", "sleep", "md5", "shasum", "cksum", "diff", "cmp", "tree", "sort", "uniq", "cut", "tr", "column", "strings", "nl", "od", "xxd", "man", "wc", "locale", "arch", "getconf", "sysctl", "nproc" ] /// git subcommands that are read-only. private static let readOnlyGitSubcommands: Set = [ "status", "log", "diff", "show", "shortlog", "rev-parse", "ls-files", "ls-remote", "blame", "describe", "reflog", "remote", "config" ] /// Interpreters whose bare `--version`-style invocations are read-only. private static let versionOnlyFlags: Set = ["--version", "-v", "-V", "--help", "-h"] /// Wrappers stripped (with their own flags/assignments) before /// classifying — `env FOO=1 nohup time ls` classifies as `ls`. private static let strippableWrappers: Set = [ "env", "nohup", "time", "nice", "command", "builtin", "xargs", "caffeinate", "stdbuf" ] /// System path prefixes: mutating operations targeting these always ask. private static let protectedSystemPrefixes = ["/Library/", "/usr/", "/etc/", "/bin/", "/sbin/", "/var/", "/private/etc/"] /// Commands that write to their path arguments (used to judge writes to /// system paths and outside-workspace targets). private static let pathWritingCommands: Set = [ "rm", "mv", "cp", "tee", "mkdir", "touch", "ln", "rmdir", "install", "chmod", "chown", "chflags", "truncate", "dd", "rsync", "unzip", "tar" ] // MARK: Built-in pattern descriptions (Settings › Safety, read-only) /// Human-readable descriptions of the built-in hard-deny patterns — /// actions that never run, in any mode. Display-only mirror of the /// classification logic above; the logic itself is the source of truth. static let hardDenyDescriptions: [String] = [ "Fork bombs (`:(){ :|:& };:` and renamed variants)", "Filesystem creation — `mkfs*`, `newfs_apfs`, `newfs_hfs` (destroys the target volume)", "`diskutil erase* / partitionDisk / zeroDisk / reformat / apfs delete…`", "`dd` writing directly to a device node (`of=/dev/…`)", "`rm` targeting `/`, `/*`, or the entire home directory", "Any write into `/System` (protected by System Integrity Protection)", "Commands matching one of your deny rules", ] /// Human-readable descriptions of the always-ask circuit breakers — /// actions that require approval in EVERY mode, including Autonomous; /// remembered allow rules can never override them. static let alwaysAskDescriptions: [String] = [ "`sudo` / `doas` — elevated privileges are never run silently", "`shutdown`, `reboot`, `halt`", "`kill`, `pkill`, `killall` — terminating processes", "`launchctl` — modifying launchd services", "`systemsetup`, `csrutil`, `nvram`, `spctl` — system-level configuration", "`security` — keychain access", "`defaults write` / `defaults delete` — preference changes", "`installer`, `softwareupdate --install` — system-wide installs", "`git push --force` — rewriting remote history", "AppleScript requesting administrator privileges", "Recursive `chmod`/`chown`/`chflags` outside the workspace", "`rm -rf` (or any deletion) on paths outside the task workspace", "`mv`/`cp` whose destination escapes the workspace or targets a system path", "Output redirection (`>`/`>>`) to files outside the workspace or with unresolvable variables", "Network downloads piped into an interpreter (`curl … | sh`)", "`find -exec rm/mv/chmod/chown/shred` — mass file modification", "Any file read or write outside the task workspace (FileTools)", ] // MARK: Entry point static func analyze( command: String, workspace: URL, allowRules: [StoredPolicyRule], denyRules: [StoredPolicyRule] ) -> Analysis { // Fork bombs must be caught on the WHOLE payload — the `|`/`;`/`&` // splitting below would shred the pattern into unrecognizable bits. if isForkBomb(command) { return Analysis( hardDenyReason: "Fork bomb.", alwaysAsk: nil, allRunnableUnprompted: false, risk: RiskAssessment(level: .destructive, reason: "Fork bomb."), allowReason: "", rememberablePatterns: [] ) } let pipelines = splitIntoPipelines(command) var hardDeny: String? var alwaysAsk: RiskAssessment? var mutatingReasons: [String] = [] var allUnprompted = true var rememberable: [String] = [] for pipeline in pipelines { // Circuit breaker checked at PIPELINE level (needs the `|` shape): // network download piped into an interpreter. if let pipeRisk = classifyDownloadPipe(pipeline) { alwaysAsk = mostSevere(alwaysAsk, pipeRisk) allUnprompted = false } for rawSegment in pipeline.segments { // Command substitutions inside the segment are classified as // subcommands of their own (Claude Code's `$(…)` breaker). let embedded = extractCommandSubstitutions(rawSegment) for sub in [rawSegment] + embedded { let tokens = stripWrappers(tokenize(sub)) guard !tokens.isEmpty else { continue } let classification = classify( tokens: tokens, rawSubcommand: sub, workspace: workspace, allowRules: allowRules, denyRules: denyRules ) switch classification { case .hardDeny(let reason): hardDeny = hardDeny ?? reason allUnprompted = false case .alwaysAsk(let risk): alwaysAsk = mostSevere(alwaysAsk, risk) allUnprompted = false case .mutating(let reason): mutatingReasons.append(reason) allUnprompted = false rememberable.append(rememberPattern(for: tokens)) case .workspaceWrite: // Workspace-scoped writes auto-run in guarded mode, // matching FileTools' .fileWrite behavior. continue case .allowedByRule, .readOnly: continue } } } } let risk: RiskAssessment if let alwaysAsk { risk = alwaysAsk } else if let first = mutatingReasons.first { risk = RiskAssessment(level: .mutating, reason: first) } else { risk = RiskAssessment(level: .safe, reason: "Read-only command.") } return Analysis( hardDenyReason: hardDeny, alwaysAsk: alwaysAsk, allRunnableUnprompted: allUnprompted, risk: risk, allowReason: allUnprompted ? "All subcommands are read-only, workspace-scoped, or covered by remembered allow rules." : "", rememberablePatterns: rememberable ) } // MARK: Per-subcommand classification (deny → ask → allow order) private static func classify( tokens: [String], rawSubcommand: String, workspace: URL, allowRules: [StoredPolicyRule], denyRules: [StoredPolicyRule] ) -> Classification { let head = tokens[0] let args = Array(tokens.dropFirst()) let home = FileManager.default.homeDirectoryForCurrentUser.path // ---- 1a. User deny rules (deny comes first, always) -------------- if let denied = matchRule(tokens: tokens, rules: denyRules, kind: "bash") { return .hardDeny("Matches your deny rule “\(denied.pattern)”.") } // ---- 1b. Hard denylist ------------------------------------------- // Fork bomb fragments that survived splitting. if isForkBomb(rawSubcommand) { return .hardDeny("Fork bomb.") } // Filesystem/disk destruction. if head == "mkfs" || head.hasPrefix("mkfs.") || head == "newfs_apfs" || head == "newfs_hfs" { return .hardDeny("Creates a filesystem — destroys the target volume.") } if head == "diskutil" { let sub = args.first?.lowercased() ?? "" if sub.hasPrefix("erase") || sub == "partitiondisk" || sub == "zerodisk" || sub == "reformat" || (sub == "apfs" && (args.dropFirst().first?.lowercased().contains("delete") ?? false)) { return .hardDeny("diskutil \(sub) erases or repartitions a disk.") } return .alwaysAsk(RiskAssessment(level: .destructive, reason: "diskutil modifies disk state.")) } if head == "dd", args.contains(where: { $0.hasPrefix("of=/dev/") }) { return .hardDeny("dd writing directly to a device node.") } // rm -rf aimed at root or the entire home directory. if head == "rm" { let rm = analyzeRM(args: args, workspace: workspace, home: home) if rm.wholesale { return .hardDeny("rm targeting the filesystem root or the entire home directory.") } if rm.recursive || rm.force { if rm.anyOutsideWorkspace { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "rm \(rm.recursive ? "-r " : "")\(rm.force ? "-f " : "")on paths outside the task workspace.")) } return .mutating("Deletes files recursively inside the workspace.") } if rm.anyOutsideWorkspace { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Deletes files outside the task workspace.")) } return .mutating("Deletes files inside the workspace.") } // Writes to /System are futile (SIP) and never legitimate. if pathWritingCommands.contains(head), args.contains(where: { resolvePath($0, cwd: workspace, home: home).hasPrefix("/System/") }) { return .hardDeny("Writes to /System (protected by System Integrity Protection).") } // ---- 2. Always-ask circuit breakers (every mode) ---------------- if head == "sudo" || head == "doas" { return .alwaysAsk(RiskAssessment(level: .elevated, reason: "Requires elevated privileges (\(head)). Zyquo Agent never runs sudo silently.")) } if head == "shutdown" || head == "reboot" || head == "halt" { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Shuts down or restarts the Mac.")) } if head == "kill" || head == "pkill" || head == "killall" { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Terminates running processes (\(head)).")) } if head == "launchctl" { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Modifies launchd services.")) } if head == "systemsetup" || head == "csrutil" || head == "nvram" || head == "spctl" { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Changes system-level configuration (\(head)).")) } if head == "security" { return .alwaysAsk(RiskAssessment(level: .elevated, reason: "Accesses the keychain via security(1).")) } if head == "defaults", args.first == "write" || args.first == "delete" { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Modifies application/system preferences (defaults \(args.first ?? "")).")) } if head == "installer" || (head == "softwareupdate" && args.contains(where: { $0 == "-i" || $0 == "--install" })) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Installs software system-wide (\(head)).")) } if head == "git", args.contains("push"), args.contains(where: { $0 == "--force" || $0 == "-f" || $0.hasPrefix("--force-with-lease") }) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "git push --force rewrites remote history.")) } if head == "osascript", rawSubcommand.lowercased().contains("with administrator privileges") { return .alwaysAsk(RiskAssessment(level: .elevated, reason: "AppleScript requesting administrator privileges.")) } if (head == "chmod" || head == "chown" || head == "chflags"), args.contains(where: { $0 == "-R" || $0 == "-r" }) { let paths = args.filter { !$0.hasPrefix("-") }.dropFirst() // drop mode/owner operand if paths.contains(where: { !isInsideWorkspace(resolvePath($0, cwd: workspace, home: home), workspace: workspace) }) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Recursive \(head) on paths outside the task workspace.")) } } // mv/cp whose DESTINATION escapes the workspace (can overwrite user files). if head == "mv" || head == "cp" { let operands = args.filter { !$0.hasPrefix("-") } if let destination = operands.last, operands.count >= 2 { if isUnresolvable(destination) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) destination is variable-based (\(destination)) — cannot verify it stays inside the workspace.")) } let resolved = resolvePath(destination, cwd: workspace, home: home) if !isInsideWorkspace(resolved, workspace: workspace) { if protectedSystemPrefixes.contains(where: { resolved.hasPrefix($0) }) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) writing into a system path (\(resolved)).")) } return .alwaysAsk(RiskAssessment(level: .destructive, reason: "\(head) writing outside the task workspace (\(resolved)) — may overwrite existing files.")) } } return .mutating("Moves/copies files inside the workspace.") } // Mutating commands targeting protected system paths. Workspace- // internal paths are exempt: a temp workspace can itself live under // /var/folders/…, and writes inside it are workspace-scoped. if pathWritingCommands.contains(head), args.contains(where: { let resolved = resolvePath($0, cwd: workspace, home: home) return !isInsideWorkspace(resolved, workspace: workspace) && protectedSystemPrefixes.contains(where: resolved.hasPrefix) }) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Writes to a protected system path (/Library, /usr, /etc, …).")) } // Output redirection: judged by target path (workspace first — the // workspace itself may live under a protected-looking prefix). if let redirect = redirectionTarget(rawSubcommand) { if isUnresolvable(redirect) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output to a variable-based path (\(redirect)) — cannot verify it stays inside the workspace.")) } let resolved = resolvePath(redirect, cwd: workspace, home: home) if isInsideWorkspace(resolved, workspace: workspace) { return .workspaceWrite("Writes a file inside the workspace via redirection.") } if resolved.hasPrefix("/System/") { return .hardDeny("Redirects output into /System.") } if resolved.hasPrefix("/dev/") { // /dev/null, /dev/stdout … — harmless sinks. } else if protectedSystemPrefixes.contains(where: { resolved.hasPrefix($0) }) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output into a protected system path (\(resolved)).")) } else { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "Redirects output to a file outside the task workspace (\(resolved)).")) } } // ---- 4. User allow rules (never reached for the classes above) -- if let allowed = matchRule(tokens: tokens, rules: allowRules, kind: "bash") { return .allowedByRule("Matches your remembered allow rule “\(allowed.pattern)”.") } // ---- 5. Curated read-only allowset ------------------------------ if head == "git", let sub = args.first(where: { !$0.hasPrefix("-") }) { if readOnlyGitSubcommands.contains(sub) { // `git remote add`, `git config --global x y` DO mutate. if sub == "remote", args.count > 1, args.contains(where: { ["add", "remove", "rm", "set-url", "rename"].contains($0) }) { return .mutating("git remote modification.") } if sub == "config", args.contains(where: { !$0.hasPrefix("-") && $0 != "config" }) && !args.contains("--list") && !args.contains("--get") { return .mutating("git config write.") } return .readOnly } if sub == "branch", args.allSatisfy({ $0 == "branch" || $0.hasPrefix("-") }) || args == ["branch"] { return .readOnly } return .mutating("git \(sub) mutates the repository.") } if head == "find" { if args.contains("-delete") { return .mutating("find -delete removes files.") } if let execIndex = args.firstIndex(where: { $0 == "-exec" || $0 == "-execdir" || $0 == "-ok" }) { let executed = args.dropFirst(execIndex + 1).first ?? "" // find -exec is exec-capable — classify by what it runs; rm et al. ask. if ["rm", "mv", "chmod", "chown", "shred"].contains(executed) { return .alwaysAsk(RiskAssessment(level: .destructive, reason: "find -exec \(executed) modifies files en masse.")) } return .mutating("find -exec runs \(executed.isEmpty ? "a command" : executed) per match.") } return .readOnly } if readOnlyCommands.contains(head) { return .readOnly } // `defaults read`, `softwareupdate --list`, bare `env`… read-only tails. if head == "defaults", args.first == "read" { return .readOnly } if head == "softwareupdate", args.contains(where: { $0 == "-l" || $0 == "--list" }) { return .readOnly } // Any command invoked purely for its version/help. if args.count == 1, let only = args.first, versionOnlyFlags.contains(only) { return .readOnly } // ---- 6. Default: mutating (mode decides) ------------------------ return .mutating("`\(head)` may modify state — not in the read-only allowset.") } // MARK: rm analysis private struct RMAnalysis { var recursive = false var force = false /// True when a target resolves to `/`, `/*`, or the home directory itself. var wholesale = false var anyOutsideWorkspace = false } private static func analyzeRM(args: [String], workspace: URL, home: String) -> RMAnalysis { var analysis = RMAnalysis() var targets: [String] = [] for arg in args { if arg.hasPrefix("--") { if arg == "--recursive" { analysis.recursive = true } if arg == "--force" { analysis.force = true } continue } if arg.hasPrefix("-"), arg.count > 1 { let flags = arg.dropFirst().lowercased() if flags.contains("r") { analysis.recursive = true } if flags.contains("f") { analysis.force = true } continue } targets.append(arg) } for target in targets { // Paths built from variables or substitutions can point anywhere — // classify them conservatively as outside the workspace. if isUnresolvable(target) { analysis.anyOutsideWorkspace = true continue } let resolved = resolvePath(target, cwd: workspace, home: home) let normalized = resolved.hasSuffix("/") && resolved.count > 1 ? String(resolved.dropLast()) : resolved if normalized == "/" || normalized == "/*" || normalized == home || normalized == home + "/*" || target == "/" || target == "/*" || target == "~" || target == "~/" || target == "$HOME" { analysis.wholesale = true } if !isInsideWorkspace(resolved, workspace: workspace) { analysis.anyOutsideWorkspace = true } } // `rm -rf` with no explicit target is odd but not wholesale. return analysis } // MARK: Pipeline-level breaker: download piped into an interpreter private static func classifyDownloadPipe(_ pipeline: Pipeline) -> RiskAssessment? { guard pipeline.segments.count >= 2 else { return nil } let downloaders: Set = ["curl", "wget", "fetch"] let interpreters: Set = ["sh", "bash", "zsh", "ksh", "dash", "python", "python3", "ruby", "perl", "node", "osascript"] var sawDownloader = false for segment in pipeline.segments { let tokens = stripWrappers(tokenize(segment)) guard let head = tokens.first else { continue } let bare = (head as NSString).lastPathComponent if downloaders.contains(bare) { sawDownloader = true; continue } if sawDownloader && interpreters.contains(bare) { return RiskAssessment(level: .destructive, reason: "Pipes a network download directly into \(bare) — executes remote code.") } } return nil } // MARK: Rule matching /// Token-prefix match: rule "brew list" matches subcommand tokens /// beginning ["brew", "list"]. First match wins. private static func matchRule(tokens: [String], rules: [StoredPolicyRule], kind: String) -> StoredPolicyRule? { for rule in rules where rule.kind == kind { let ruleTokens = rule.pattern.split(separator: " ").map(String.init) guard !ruleTokens.isEmpty, ruleTokens.count <= tokens.count else { continue } if Array(tokens.prefix(ruleTokens.count)) == ruleTokens { return rule } } return nil } /// Narrowest pattern to remember for a subcommand: command + first /// non-flag argument when present ("brew list"), else just the command. private static func rememberPattern(for tokens: [String]) -> String { guard let head = tokens.first else { return "" } if tokens.count > 1, !tokens[1].hasPrefix("-") { return "\(head) \(tokens[1])" } return head } // MARK: Path helpers /// Detects the classic bash fork bomb (`:(){ :|:& };:`) and its /// renamed-function variants: a function definition whose body pipes the /// function into itself and backgrounds it. static func isForkBomb(_ text: String) -> Bool { let compact = text.replacingOccurrences(of: " ", with: "").replacingOccurrences(of: "\n", with: "") if compact.contains(":(){:|:&};:") || compact.contains("(){:|:&};") { return true } // Generic shape: name(){name|name&};name if let regex = try? NSRegularExpression(pattern: #"(\w+)\(\)\{\1\|\1&\};?"#), regex.firstMatch(in: compact, range: NSRange(compact.startIndex..., in: compact)) != nil { return true } return false } /// True when a path contains shell variables or substitutions we cannot /// statically resolve (other than a leading `$HOME`). Callers treat such /// paths conservatively (as escaping the workspace). static func isUnresolvable(_ raw: String) -> Bool { if raw == "$HOME" || raw.hasPrefix("$HOME/") { return false } return raw.contains("$") || raw.contains("`") } static func resolvePath(_ raw: String, cwd: URL, home: String) -> String { var path = raw if path.hasPrefix("~/") { path = home + String(path.dropFirst(1)) } else if path == "~" { path = home } else if path == "$HOME" || path.hasPrefix("$HOME/") { path = home + String(path.dropFirst("$HOME".count)) } if !path.hasPrefix("/") { path = cwd.appendingPathComponent(path).path } return (path as NSString).standardizingPath } static func isInsideWorkspace(_ resolvedPath: String, workspace: URL) -> Bool { let root = (workspace.path as NSString).standardizingPath return resolvedPath == root || resolvedPath.hasPrefix(root + "/") } // MARK: Shell parsing (quote-aware; parsing is UX, not a security boundary) /// One pipeline: the segments between `|` operators. struct Pipeline { var segments: [String] } /// Splits a payload on `&&`, `||`, `;`, newlines (into command lists) and /// then on `|` (into pipeline segments), respecting single/double quotes /// and backslash escapes. `2>&1`-style fd duplications are not treated as /// pipes. static func splitIntoPipelines(_ command: String) -> [Pipeline] { var pipelines: [Pipeline] = [] var currentSegment = "" var currentSegments: [String] = [] func endSegment() { let trimmed = currentSegment.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { currentSegments.append(trimmed) } currentSegment = "" } func endPipeline() { endSegment() if !currentSegments.isEmpty { pipelines.append(Pipeline(segments: currentSegments)) currentSegments = [] } } var iterator = command.makeIterator() var pending: Character? = nil var inSingle = false var inDouble = false var previous: Character? = nil func next() -> Character? { if let p = pending { pending = nil; return p } return iterator.next() } while let ch = next() { defer { previous = ch } if inSingle { currentSegment.append(ch) if ch == "'" { inSingle = false } continue } if inDouble { currentSegment.append(ch) if ch == "\\" { if let escaped = next() { currentSegment.append(escaped) } } else if ch == "\"" { inDouble = false } continue } switch ch { case "'": inSingle = true currentSegment.append(ch) case "\"": inDouble = true currentSegment.append(ch) case "\\": currentSegment.append(ch) if let escaped = next() { currentSegment.append(escaped) } case "&": if previous == ">" || previous == "<" { // fd duplication (`2>&1`, `>&2`, `<&0`) — not a control operator. currentSegment.append(ch) } else if let lookahead = next() { if lookahead == "&" { endPipeline() // `&&` } else { // Background `&`: terminates the command like `;`. endPipeline() pending = lookahead } } else { endPipeline() } case "|": if let lookahead = next() { if lookahead == "|" { endPipeline() // `||` } else if lookahead == "&" { endSegment() // `|&` pipes stdout+stderr } else { endSegment() // plain pipe pending = lookahead } } else { endSegment() } case ";", "\n": endPipeline() default: currentSegment.append(ch) } } endPipeline() return pipelines } /// Extracts the bodies of `$(…)` and `` `…` `` command substitutions so /// they are classified as subcommands in their own right — a breaker /// hidden inside a substitution must still trip. static func extractCommandSubstitutions(_ segment: String) -> [String] { var results: [String] = [] let characters = Array(segment) var i = 0 while i < characters.count { if characters[i] == "$", i + 1 < characters.count, characters[i + 1] == "(" { var depth = 1 var j = i + 2 var body = "" while j < characters.count, depth > 0 { if characters[j] == "(" { depth += 1 } if characters[j] == ")" { depth -= 1; if depth == 0 { break } } body.append(characters[j]) j += 1 } let trimmed = body.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty { results.append(trimmed) } i = j } else if characters[i] == "`" { var j = i + 1 var body = "" while j < characters.count, characters[j] != "`" { body.append(characters[j]) j += 1 } let trimmed = body.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty { results.append(trimmed) } i = j } i += 1 } return results } /// Splits one subcommand into tokens, respecting quotes (quotes removed). static func tokenize(_ subcommand: String) -> [String] { var tokens: [String] = [] var current = "" var inSingle = false var inDouble = false var hasContent = false for ch in subcommand { if inSingle { if ch == "'" { inSingle = false } else { current.append(ch) } continue } if inDouble { if ch == "\"" { inDouble = false } else { current.append(ch) } continue } switch ch { case "'": inSingle = true; hasContent = true case "\"": inDouble = true; hasContent = true case " ", "\t": if hasContent || !current.isEmpty { tokens.append(current) current = "" hasContent = false } default: current.append(ch) } } if hasContent || !current.isEmpty { tokens.append(current) } return tokens } /// Strips leading wrappers and env assignments: `env FOO=1 nohup time ls` /// → `["ls"]`. `xargs rm` classifies as `rm`. `sudo` is NOT strippable — /// it must classify as itself. static func stripWrappers(_ tokens: [String]) -> [String] { var tokens = tokens while let head = tokens.first { // VAR=value assignment prefixes. if head.contains("="), !head.hasPrefix("-"), !head.hasPrefix("="), head.firstIndex(of: "=").map({ head[head.startIndex..<$0].allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" } }) == true { tokens.removeFirst() continue } if strippableWrappers.contains(head) { tokens.removeFirst() // Drop the wrapper's own flags (e.g. `xargs -n1`, `env -i`). while let next = tokens.first, next.hasPrefix("-") { tokens.removeFirst() } continue } break } return tokens } /// Finds the target of the first `>` / `>>` output redirection outside /// quotes (nil when there is none). `2>&1`, `>&2` fd-duplications and /// heredocs are ignored. static func redirectionTarget(_ subcommand: String) -> String? { let characters = Array(subcommand) var inSingle = false var inDouble = false var i = 0 while i < characters.count { let ch = characters[i] if ch == "'" && !inDouble { inSingle.toggle() } else if ch == "\"" && !inSingle { inDouble.toggle() } else if ch == ">" && !inSingle && !inDouble { var j = i + 1 if j < characters.count, characters[j] == ">" { j += 1 } // `>>` if j < characters.count, characters[j] == "&" { i = j + 1; continue } // `>&1` // Skip whitespace, then read the target word. while j < characters.count, characters[j] == " " || characters[j] == "\t" { j += 1 } var target = "" while j < characters.count, characters[j] != " " && characters[j] != "\t" && characters[j] != ";" && characters[j] != "|" && characters[j] != "&" { target.append(characters[j]) j += 1 } let unquoted = target.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) return unquoted.isEmpty ? nil : unquoted } i += 1 } return nil } // MARK: Severity ordering private static func mostSevere(_ a: RiskAssessment?, _ b: RiskAssessment) -> RiskAssessment { guard let a else { return b } return rank(a.level) >= rank(b.level) ? a : b } private static func rank(_ level: RiskAssessment.Level) -> Int { switch level { case .safe: return 0 case .mutating: return 1 case .destructive: return 2 case .elevated: return 3 } } }