SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
8.6 KB · 215 lines swift
Raw Blame History
1//2//  SafetySettingsTab.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Settings › Safety — default safety mode, the editable allow/deny rule9//  lists, the read-only built-in destructive-pattern list, the AppleScript10//  approval toggle and the workspace-escape policy.11//12//  The rule editor reads/writes the same policy-rules.json the PolicyEngine13//  uses (through PersistenceService). Each run constructs a fresh14//  PolicyEngine at task start, which loads the rules then — edits here apply15//  to the NEXT run, not to a run already in flight.16//1718import SwiftUI1920struct SafetySettingsTab: View {21    @EnvironmentObject private var settings: AgentSettingsStore2223    var body: some View {24        Form {25            Section("Defaults") {26                Picker("Default safety mode for new tasks", selection: $settings.settings.defaultSafetyMode) {27                    ForEach(SafetyMode.allCases) { mode in28                        Text(mode.displayName).tag(mode)29                    }30                }31                .pickerStyle(.segmented)32                Text(modeDescription)33                    .font(ZyquoFont.caption)34                    .foregroundStyle(ZyquoColor.textTertiary)35            }3637            Section("AppleScript & workspace boundaries") {38                Toggle("Require approval for AppleScript", isOn: $settings.settings.requireApprovalForAppleScript)39                Text("Manual and Guarded modes always ask before AppleScript runs. This setting is stored for the Autonomous-mode engine hook (not yet enforced there — Autonomous currently auto-runs only scripts with no risky patterns).")40                    .font(ZyquoFont.caption)41                    .foregroundStyle(ZyquoColor.textTertiary)42                Picker("File access outside the workspace", selection: $settings.settings.workspaceEscapePolicy) {43                    ForEach(WorkspaceEscapePolicy.allCases) { policy in44                        Text(policy.displayName).tag(policy)45                    }46                }47                Text("The engine currently always asks on any read or write outside the task workspace, in every mode. “Deny without asking” is stored for a future engine hook.")48                    .font(ZyquoFont.caption)49                    .foregroundStyle(ZyquoColor.textTertiary)50            }5152            PolicyRulesSection()5354            Section("Built-in destructive patterns (always ask — every mode)") {55                DisclosureGroup("Never run (hard deny)") {56                    ForEach(ShellCommandAnalyzer.hardDenyDescriptions, id: \.self) { item in57                        patternRow(item, color: ZyquoColor.danger)58                    }59                }60                DisclosureGroup("Always require approval") {61                    ForEach(ShellCommandAnalyzer.alwaysAskDescriptions, id: \.self) { item in62                        patternRow(item, color: ZyquoColor.warning)63                    }64                }65                Text("These circuit breakers are built in and cannot be disabled — not even by Autonomous mode or a remembered allow rule.")66                    .font(ZyquoFont.caption)67                    .foregroundStyle(ZyquoColor.textTertiary)68            }69        }70        .formStyle(.grouped)71    }7273    private var modeDescription: String {74        switch settings.settings.defaultSafetyMode {75        case .manual: return "Manual — approve every action."76        case .guarded: return "Guarded — safe/read-only actions run automatically, anything mutating asks."77        case .autonomous: return "Autonomous — runs freely within budget; destructive and elevated actions still ask."78        }79    }8081    private func patternRow(_ text: String, color: Color) -> some View {82        HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xxs) {83            Circle()84                .fill(color)85                .frame(width: 5, height: 5)86                .padding(.top, 4)87            Text(text)88                .font(ZyquoFont.body(size: 12))89                .foregroundStyle(ZyquoColor.textSecondary)90                .fixedSize(horizontal: false, vertical: true)91        }92    }93}9495// MARK: - Allow/deny rule editor9697/// Edits the PolicyEngine's stored rules through the same policy-rules.json98/// document (PersistenceService). Live engines load rules at task start.99private struct PolicyRulesSection: View {100    @State private var rules = StoredPolicyRules()101    @State private var newPattern = ""102    @State private var newKind = "bash"103    @State private var newList: RuleList = .allow104105    private enum RuleList: String, CaseIterable, Identifiable {106        case allow = "Allow"107        case deny = "Deny"108        var id: String { rawValue }109    }110111    private static let rulesFileName = "policy-rules.json"112113    var body: some View {114        Section("Allow / deny rules") {115            Text("Token-prefix rules evaluated per subcommand: “brew list” matches `brew list --versions` but not `brew install`. Deny rules always win; allow rules can never cover a built-in destructive pattern. Changes apply to the next run.")116                .font(ZyquoFont.caption)117                .foregroundStyle(ZyquoColor.textTertiary)118119            if rules.allow.isEmpty && rules.deny.isEmpty {120                Text("No rules yet — “Approve & remember” on an approval card adds allow rules here.")121                    .font(ZyquoFont.body(size: 12))122                    .foregroundStyle(ZyquoColor.textTertiary)123            }124            ForEach(rules.deny, id: \.self) { rule in125                ruleRow(rule, isDeny: true)126            }127            ForEach(rules.allow, id: \.self) { rule in128                ruleRow(rule, isDeny: false)129            }130131            HStack(spacing: ZyquoSpacing.xs) {132                Picker("", selection: $newList) {133                    ForEach(RuleList.allCases) { list in Text(list.rawValue).tag(list) }134                }135                .labelsHidden()136                .frame(width: 90)137                Picker("", selection: $newKind) {138                    Text("bash").tag("bash")139                    Text("osascript").tag("osascript")140                }141                .labelsHidden()142                .frame(width: 110)143                TextField("Pattern (e.g. brew list)", text: $newPattern)144                    .textFieldStyle(.roundedBorder)145                    .font(ZyquoFont.code(size: 11))146                    .onSubmit(addRule)147                Button("Add") { addRule() }148                    .controlSize(.small)149                    .disabled(newPattern.trimmingCharacters(in: .whitespaces).isEmpty)150            }151        }152        .onAppear(perform: load)153    }154155    private func ruleRow(_ rule: StoredPolicyRule, isDeny: Bool) -> some View {156        HStack(spacing: ZyquoSpacing.xs) {157            ZyquoBadge(158                text: isDeny ? "deny" : "allow",159                color: isDeny ? ZyquoColor.danger : ZyquoColor.success160            )161            Text(rule.kind)162                .font(ZyquoFont.code(size: 11))163                .foregroundStyle(ZyquoColor.textTertiary)164                .frame(width: 70, alignment: .leading)165            Text(rule.pattern)166                .font(ZyquoFont.code(size: 11.5))167                .foregroundStyle(ZyquoColor.textPrimary)168            Spacer()169            Button {170                remove(rule, fromDeny: isDeny)171            } label: {172                Image(systemName: "trash")173                    .font(.system(size: 10))174                    .foregroundStyle(ZyquoColor.danger)175            }176            .buttonStyle(.plain)177            .help("Remove rule")178        }179    }180181    // MARK: Persistence (same document the PolicyEngine loads per task start)182183    private func load() {184        rules = PersistenceService.shared.load(StoredPolicyRules.self, from: Self.rulesFileName)185            ?? StoredPolicyRules()186    }187188    private func save() {189        PersistenceService.shared.save(rules, to: Self.rulesFileName)190    }191192    private func addRule() {193        let pattern = newPattern.trimmingCharacters(in: .whitespaces)194        guard !pattern.isEmpty else { return }195        let rule = StoredPolicyRule(kind: newKind, pattern: pattern)196        switch newList {197        case .allow:198            if !rules.allow.contains(rule) { rules.allow.append(rule) }199        case .deny:200            if !rules.deny.contains(rule) { rules.deny.append(rule) }201        }202        newPattern = ""203        save()204    }205206    private func remove(_ rule: StoredPolicyRule, fromDeny: Bool) {207        if fromDeny {208            rules.deny.removeAll { $0 == rule }209        } else {210            rules.allow.removeAll { $0 == rule }211        }212        save()213    }214}215