import Foundation public nonisolated enum CommandRisk: Int, Comparable, Sendable, CaseIterable { case safe = 0 case caution = 1 case destructive = 2 public nonisolated static func < (lhs: CommandRisk, rhs: CommandRisk) -> Bool { lhs.rawValue < rhs.rawValue } public var label: String { switch self { case .safe: String(localized: "Read-only") case .caution: String(localized: "Modifies state") case .destructive: String(localized: "Destructive") } } public var symbol: String { switch self { case .safe: "eye" case .caution: "exclamationmark.triangle" case .destructive: "exclamationmark.octagon.fill" } } } public nonisolated struct RiskAssessment: Sendable, Hashable { public var risk: CommandRisk /// Human-readable justifications, shown under the command card so the user /// can see *why* something was flagged rather than trusting a bare badge. public var reasons: [String] /// Destructive commands always require a second confirmation. Caution /// commands require one click. Safe commands may be one-click-run if the /// user opted into that in Settings. public var requiresSecondConfirmation: Bool { risk == .destructive } public init(risk: CommandRisk, reasons: [String]) { self.risk = risk self.reasons = reasons } } /// Classifies a shell command by how much damage running it could do. /// /// This runs entirely on the client and deliberately does **not** ask the model /// to rate its own suggestion — a model that is wrong about a command is also /// likely to be wrong about how dangerous it is. It is advisory input to the UI, /// never an authorisation: the user still clicks. /// /// Design rule: **fail closed.** An unrecognised command is `.caution`, never /// `.safe`. Being wrong in the safe direction just costs an extra click; being /// wrong in the other direction costs a production database. public nonisolated enum CommandRiskClassifier { // MARK: - Public entry point public static func assess(_ command: String) -> RiskAssessment { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return RiskAssessment(risk: .safe, reasons: []) } var risk = CommandRisk.safe var reasons: [String] = [] func merge(_ assessment: RiskAssessment) { risk = max(risk, assessment.risk) for reason in assessment.reasons where !reasons.contains(reason) { reasons.append(reason) } } func raise(_ level: CommandRisk, _ reason: String) { merge(RiskAssessment(risk: level, reasons: [reason])) } if isForkBomb(trimmed) { return RiskAssessment( risk: .destructive, reasons: [String(localized: "Matches a fork-bomb pattern.")] ) } for statement in splitStatements(trimmed) { let stages = splitPipeline(statement) // A download piped into an interpreter executes arbitrary remote // code. Neither half looks dangerous alone, so this has to be // checked across the pipeline rather than per-command. if let reason = pipeToInterpreterReason(stages) { raise(.destructive, reason) } for stage in stages { merge(assessSingle(stage)) } if let assessment = redirectionAssessment(statement) { merge(assessment) } } return RiskAssessment(risk: risk, reasons: reasons) } // MARK: - Single command private static func assessSingle(_ stage: String) -> RiskAssessment { var tokens = tokenize(stage) // Strip leading `FOO=bar` environment assignments so `LANG=C rm -rf x` // is still read as an `rm`. while let first = tokens.first, isEnvironmentAssignment(first) { tokens.removeFirst() } guard var head = tokens.first else { return RiskAssessment(risk: .safe, reasons: []) } var reasons: [String] = [] var floor = CommandRisk.safe // `sudo` / `doas` — never safe, and reclassify what follows. if head == "sudo" || head == "doas" { floor = .caution reasons.append(String(localized: "Runs with elevated privileges.")) tokens.removeFirst() while let next = tokens.first, next.hasPrefix("-") { tokens.removeFirst() } guard let newHead = tokens.first else { return RiskAssessment(risk: floor, reasons: reasons) } head = newHead } // Normalise `/usr/bin/rm` and `./script.sh`. let name = String(head.split(separator: "/").last ?? Substring(head)) let args = Array(tokens.dropFirst()) if let specific = specificRule(name: name, args: args) { reasons.append(contentsOf: specific.reasons) return RiskAssessment(risk: max(floor, specific.risk), reasons: reasons) } if destructiveCommands.contains(name) { reasons.append(String(localized: "`\(name)` can irreversibly destroy data or state.")) return RiskAssessment(risk: .destructive, reasons: reasons) } if cautionCommands.contains(name) { reasons.append(String(localized: "`\(name)` changes system or file state.")) return RiskAssessment(risk: max(floor, .caution), reasons: reasons) } if readOnlyCommands.contains(name) { return RiskAssessment(risk: floor, reasons: reasons) } // Fail closed. reasons.append(String(localized: "`\(name)` is not a recognised read-only command.")) return RiskAssessment(risk: max(floor, .caution), reasons: reasons) } /// Rules where the verdict depends on the arguments, not just the command. private static func specificRule(name: String, args: [String]) -> RiskAssessment? { let flags = args.filter { $0.hasPrefix("-") } let joinedFlags = flags.joined() switch name { case "rm": let recursive = joinedFlags.contains("r") || joinedFlags.contains("R") let forced = joinedFlags.contains("f") if recursive || forced { let targetsRoot = args.contains { $0 == "/" || $0.hasPrefix("/*") } if targetsRoot { return RiskAssessment( risk: .destructive, reasons: [String(localized: "Recursive delete targeting the filesystem root.")] ) } return RiskAssessment( risk: .destructive, reasons: [String(localized: "Recursive or forced delete — no undo, no trash.")] ) } return RiskAssessment( risk: .destructive, reasons: [String(localized: "Deletes files permanently.")] ) case "find": if args.contains("-delete") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "`find -delete` removes every match.")] ) } if args.contains("-exec") || args.contains("-execdir") || args.contains("-ok") { return RiskAssessment( risk: .caution, reasons: [String(localized: "`find -exec` runs a command per match.")] ) } return RiskAssessment(risk: .safe, reasons: []) case "chmod", "chown", "chgrp": if joinedFlags.contains("R") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "Recursive ownership or permission change.")] ) } return RiskAssessment( risk: .caution, reasons: [String(localized: "Changes file ownership or permissions.")] ) case "sed": if joinedFlags.contains("i") { return RiskAssessment( risk: .caution, reasons: [String(localized: "`sed -i` edits files in place.")] ) } return RiskAssessment(risk: .safe, reasons: []) case "fdisk", "parted", "gdisk": if flags.contains("-l") || args.contains("--list") { return RiskAssessment(risk: .safe, reasons: []) } return RiskAssessment( risk: .destructive, reasons: [String(localized: "Interactive partition editor.")] ) case "git": guard let sub = args.first(where: { !$0.hasPrefix("-") }) else { return RiskAssessment(risk: .safe, reasons: []) } switch sub { case "status", "log", "diff", "show", "blame": return RiskAssessment(risk: .safe, reasons: []) case "branch", "fetch": return RiskAssessment( risk: .caution, reasons: [String(localized: "Updates or may update local Git references.")] ) case "remote": let operation = args.drop(while: { $0 != sub }).dropFirst() .first(where: { !$0.hasPrefix("-") }) if operation == nil || operation == "show" || operation == "get-url" { return RiskAssessment(risk: .safe, reasons: []) } return RiskAssessment(risk: .caution, reasons: []) case "stash": let operation = args.drop(while: { $0 != sub }).dropFirst() .first(where: { !$0.hasPrefix("-") }) if operation == "list" || operation == "show" { return RiskAssessment(risk: .safe, reasons: []) } return RiskAssessment( risk: .caution, reasons: [String(localized: "Changes the working tree or stash.")] ) case "push": if args.contains("--force") || args.contains("-f") || args.contains("--force-with-lease") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "Force push can overwrite remote history.")] ) } return RiskAssessment( risk: .caution, reasons: [String(localized: "Publishes commits to a remote.")] ) case "reset": if args.contains("--hard") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "`git reset --hard` discards uncommitted work.")] ) } return RiskAssessment(risk: .caution, reasons: []) case "clean": return RiskAssessment( risk: .destructive, reasons: [String(localized: "`git clean` deletes untracked files.")] ) default: return RiskAssessment(risk: .caution, reasons: []) } case "systemctl", "service": let sub = args.first { !$0.hasPrefix("-") } ?? "" // Every subcommand that only reports. The list was `status`, // `list-units`, `show` and `cat`, which left the ordinary ways of // asking "what is running, what is enabled, what failed" classified // as state changes — and therefore behind an approval prompt, or // refused outright while planning. if systemctlReadOnlySubcommands.contains(sub) { return RiskAssessment(risk: .safe, reasons: []) } if sub == "stop" || sub == "disable" || sub == "mask" { return RiskAssessment( risk: .destructive, reasons: [String(localized: "Stops or disables a running service.")] ) } return RiskAssessment( risk: .caution, reasons: [String(localized: "Changes service state.")] ) case "docker", "podman": let sub = args.first { !$0.hasPrefix("-") } ?? "" switch sub { case "ps", "images", "logs", "inspect", "stats", "version", "info", "top": return RiskAssessment(risk: .safe, reasons: []) case "rm", "rmi", "prune", "kill": return RiskAssessment( risk: .destructive, reasons: [String(localized: "Removes containers, images, or volumes.")] ) case "system": if args.contains("prune") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "`docker system prune` reclaims and deletes broadly.")] ) } return RiskAssessment(risk: .caution, reasons: []) default: return RiskAssessment(risk: .caution, reasons: []) } case "kubectl": let sub = args.first { !$0.hasPrefix("-") } ?? "" switch sub { case "get", "describe", "logs", "explain", "top", "version", "config": return RiskAssessment(risk: .safe, reasons: []) case "delete": return RiskAssessment( risk: .destructive, reasons: [String(localized: "Deletes cluster resources.")] ) default: return RiskAssessment(risk: .caution, reasons: []) } case "kill", "pkill", "killall": if joinedFlags.contains("9") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "SIGKILL gives the process no chance to clean up.")] ) } return RiskAssessment( risk: .caution, reasons: [String(localized: "Signals a running process.")] ) case "rsync": if args.contains("--delete") || args.contains("--delete-after") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "`rsync --delete` removes files missing from the source.")] ) } return RiskAssessment(risk: .caution, reasons: []) case "journalctl": if args.contains(where: { $0.hasPrefix("--vacuum") }) { return RiskAssessment( risk: .destructive, reasons: [String(localized: "Vacuuming permanently drops journal history.")] ) } return RiskAssessment(risk: .safe, reasons: []) case "tar": if joinedFlags.contains("x") { return RiskAssessment( risk: .caution, reasons: [String(localized: "Extraction writes files, possibly overwriting.")] ) } return RiskAssessment(risk: .safe, reasons: []) case "crontab": if joinedFlags.contains("r") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "`crontab -r` erases the crontab with no prompt.")] ) } if joinedFlags.contains("l") { return RiskAssessment(risk: .safe, reasons: []) } return RiskAssessment(risk: .caution, reasons: []) default: return nil } } // MARK: - Pipelines and redirection private static func pipeToInterpreterReason(_ stages: [String]) -> String? { guard stages.count > 1 else { return nil } let fetchers: Set = ["curl", "wget", "fetch"] let interpreters: Set = [ "sh", "bash", "zsh", "fish", "dash", "python", "python3", "perl", "ruby", "node", ] let heads = stages.map { stage -> String in let raw = tokenize(stage).first ?? "" return String(raw.split(separator: "/").last ?? Substring(raw)) } guard let firstHead = heads.first, fetchers.contains(firstHead) else { return nil } guard heads.dropFirst().contains(where: { interpreters.contains($0) }) else { return nil } return String( localized: "Pipes downloaded content straight into an interpreter — this executes untrusted remote code.") } private static func redirectionAssessment(_ statement: String) -> RiskAssessment? { guard let target = redirectTarget(statement) else { return nil } if target == "/dev/null" { return nil } if target.hasPrefix("/dev/sd") || target.hasPrefix("/dev/disk") || target.hasPrefix("/dev/nvme") { return RiskAssessment( risk: .destructive, reasons: [String(localized: "Writes directly to a block device.")] ) } return RiskAssessment( risk: .caution, reasons: [String(localized: "Overwrites `\(target)`.")] ) } /// Finds the target of a truncating `>` redirect, ignoring `>>` appends, /// `2>&1` duplications, and anything inside quotes. private static func redirectTarget(_ statement: String) -> String? { let chars = Array(statement) var i = 0 var quoteState = QuoteState() while i < chars.count { let c = chars[i] if quoteState.consume(c) { i += 1 continue } if c == ">" && !quoteState.isQuoted { if i + 1 < chars.count && chars[i + 1] == ">" { i += 2 continue } if i + 1 < chars.count && chars[i + 1] == "&" { i += 2 continue } var j = i + 1 while j < chars.count, chars[j] == " " { j += 1 } var target = "" while j < chars.count, chars[j] != " ", chars[j] != ";", chars[j] != "|" { target.append(chars[j]) j += 1 } return target.isEmpty ? nil : target } i += 1 } return nil } /// Catches `:(){ :|:& };:` and its renamed variants (`bomb(){ bomb|bomb& }`) /// by looking for a function that pipes itself into itself in the background. private static func isForkBomb(_ command: String) -> Bool { let squeezed = command.filter { !$0.isWhitespace } guard let parenRange = squeezed.range(of: "(){") else { return false } let name = String(squeezed[squeezed.startIndex.. [String] { splitRespectingQuotes(command, separators: [";", "&&", "||", "\n"]) } /// Splits a statement into pipeline stages on `|`, respecting quotes and /// leaving `||` alone (already handled by `splitStatements`). private static func splitPipeline(_ statement: String) -> [String] { splitRespectingQuotes(statement, separators: ["|"]) } private static func splitRespectingQuotes(_ input: String, separators: [String]) -> [String] { var results: [String] = [] var current = "" var quoteState = QuoteState() let chars = Array(input) let longestSeparatorsFirst = separators.sorted { $0.count > $1.count } var i = 0 while i < chars.count { let c = chars[i] if quoteState.consume(c) { current.append(c) i += 1 continue } if !quoteState.isQuoted { var matched: String? for separator in longestSeparatorsFirst { let end = i + separator.count if end <= chars.count, String(chars[i.. Bool { guard let equals = token.firstIndex(of: "=") else { return false } let name = token[token.startIndex.. [String] { var tokens: [String] = [] var current = "" var quoteState = QuoteState() for c in stage { if quoteState.consume(c) { continue } if c.isWhitespace && !quoteState.isQuoted { if !current.isEmpty { tokens.append(current) current = "" } continue } current.append(c) } if !current.isEmpty { tokens.append(current) } return tokens } private struct QuoteState { private var isInSingleQuotes = false private var isInDoubleQuotes = false public var isQuoted: Bool { isInSingleQuotes || isInDoubleQuotes } /// Updates the quote context and reports whether `character` was a quote /// delimiter. Callers decide whether the delimiter belongs in output. public mutating func consume(_ character: Character) -> Bool { if character == "'" && !isInDoubleQuotes { isInSingleQuotes.toggle() return true } if character == "\"" && !isInSingleQuotes { isInDoubleQuotes.toggle() return true } return false } public init(isInSingleQuotes: Bool = false, isInDoubleQuotes: Bool = false) { self.isInSingleQuotes = isInSingleQuotes self.isInDoubleQuotes = isInDoubleQuotes } } // MARK: - Tables /// Commands that only read. Anything not listed here is treated as at least /// `.caution`, so this list being incomplete is safe — it just means an /// extra click. private static let systemctlReadOnlySubcommands: Set = [ "status", "show", "cat", "help", "is-active", "is-enabled", "is-failed", "is-system-running", "get-default", "show-environment", "list-units", "list-unit-files", "list-timers", "list-sockets", "list-jobs", "list-dependencies", "list-machines", ] private static let readOnlyCommands: Set = [ "ls", "ll", "cat", "bat", "less", "more", "head", "tail", "df", "du", "ps", "top", "htop", "btop", "free", "uptime", "uname", "whoami", "id", "pwd", "date", "hostname", "which", "whereis", "type", "file", "stat", "wc", "sort", "uniq", "cut", "tr", "column", "jq", "yq", "awk", "grep", "egrep", "fgrep", "rg", "ag", "tree", "env", "printenv", "echo", "printf", "dmesg", "lsof", "netstat", "ss", "ip", "ifconfig", "ping", "traceroute", "dig", "nslookup", "host", "arp", "route", "md5sum", "sha1sum", "sha256sum", "cksum", "diff", "cmp", "basename", "dirname", "realpath", "readlink", "groups", "last", "w", "who", "lsblk", "blkid", "mount", "vmstat", "iostat", "sar", "nproc", "nvidia-smi", "sensors", "lscpu", "lsmem", "lspci", "lsusb", "true", "false", // Reading a server's state is most of what the assistant does before it // proposes anything, and this list is what decides whether that needs a // prompt — or, while planning, happens at all. // // Membership means *no invocation of this writes*. That is why `swapon`, // `rpm` and `ulimit` are deliberately absent despite reading in their // common form: `swapon /dev/sdb1` enables swap, `rpm -i` installs, and // `ulimit -n` sets a limit. A name whose verdict depends on its // arguments belongs in `specificRule`, not here, and until it has one // failing closed is the right answer. "pgrep", "pidof", "pstree", "mpstat", "pidstat", "findmnt", "getconf", "locale", "getent", "timedatectl", "hostnamectl", "localectl", "loginctl", "resolvectl", "sestatus", "getenforce", "lsattr", "lsns", "dpkg-query", "apt-cache", "zcat", "zgrep", "zless", "bzcat", "tac", "nl", "rev", "fold", "expand", "unexpand", "paste", "join", "comm", "seq", "numfmt", "base64", "strings", "xxd", "od", "hexdump", "ethtool", "arch", ] private static let cautionCommands: Set = [ "mv", "cp", "ln", "mkdir", "touch", "install", "tee", "truncate", "apt", "apt-get", "yum", "dnf", "pacman", "apk", "brew", "npm", "pnpm", "yarn", "pip", "pip3", "gem", "cargo", "go", "make", "cmake", "ninja", "ssh-keygen", "ssh-copy-id", "scp", "sftp", "unzip", "gunzip", "gzip", "zip", "xz", "bzip2", "curl", "wget", "nano", "vim", "vi", "emacs", "export", "source", "alias", "unalias", "usermod", "groupadd", "systemd-run", "nohup", "screen", "tmux", "at", "batch", "renice", "mysql", "psql", "redis-cli", "mongosh", "sqlite3", "terraform", "ansible", "ansible-playbook", "helm", "git-lfs", ] private static let destructiveCommands: Set = [ "dd", "mkfs", "mkfs.ext4", "mkfs.xfs", "mkfs.btrfs", "shred", "wipefs", "userdel", "groupdel", "deluser", "delgroup", "reboot", "shutdown", "halt", "poweroff", "init", "swapoff", "umount", "iptables", "nft", "ufw", "firewall-cmd", "setenforce", "chroot", "mkswap", "losetup", "vgremove", "lvremove", "pvremove", "zpool", "zfs", ] }