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%
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