// // SafetySettingsTab.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Settings › Safety — default safety mode, the editable allow/deny rule // lists, the read-only built-in destructive-pattern list, the AppleScript // approval toggle and the workspace-escape policy. // // The rule editor reads/writes the same policy-rules.json the PolicyEngine // uses (through PersistenceService). Each run constructs a fresh // PolicyEngine at task start, which loads the rules then — edits here apply // to the NEXT run, not to a run already in flight. // import SwiftUI struct SafetySettingsTab: View { @EnvironmentObject private var settings: AgentSettingsStore var body: some View { Form { Section("Defaults") { Picker("Default safety mode for new tasks", selection: $settings.settings.defaultSafetyMode) { ForEach(SafetyMode.allCases) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) Text(modeDescription) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } Section("AppleScript & workspace boundaries") { Toggle("Require approval for AppleScript", isOn: $settings.settings.requireApprovalForAppleScript) 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).") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) Picker("File access outside the workspace", selection: $settings.settings.workspaceEscapePolicy) { ForEach(WorkspaceEscapePolicy.allCases) { policy in Text(policy.displayName).tag(policy) } } 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.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } PolicyRulesSection() Section("Built-in destructive patterns (always ask — every mode)") { DisclosureGroup("Never run (hard deny)") { ForEach(ShellCommandAnalyzer.hardDenyDescriptions, id: \.self) { item in patternRow(item, color: ZyquoColor.danger) } } DisclosureGroup("Always require approval") { ForEach(ShellCommandAnalyzer.alwaysAskDescriptions, id: \.self) { item in patternRow(item, color: ZyquoColor.warning) } } Text("These circuit breakers are built in and cannot be disabled — not even by Autonomous mode or a remembered allow rule.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } .formStyle(.grouped) } private var modeDescription: String { switch settings.settings.defaultSafetyMode { case .manual: return "Manual — approve every action." case .guarded: return "Guarded — safe/read-only actions run automatically, anything mutating asks." case .autonomous: return "Autonomous — runs freely within budget; destructive and elevated actions still ask." } } private func patternRow(_ text: String, color: Color) -> some View { HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xxs) { Circle() .fill(color) .frame(width: 5, height: 5) .padding(.top, 4) Text(text) .font(ZyquoFont.body(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) .fixedSize(horizontal: false, vertical: true) } } } // MARK: - Allow/deny rule editor /// Edits the PolicyEngine's stored rules through the same policy-rules.json /// document (PersistenceService). Live engines load rules at task start. private struct PolicyRulesSection: View { @State private var rules = StoredPolicyRules() @State private var newPattern = "" @State private var newKind = "bash" @State private var newList: RuleList = .allow private enum RuleList: String, CaseIterable, Identifiable { case allow = "Allow" case deny = "Deny" var id: String { rawValue } } private static let rulesFileName = "policy-rules.json" var body: some View { Section("Allow / deny rules") { 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.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) if rules.allow.isEmpty && rules.deny.isEmpty { Text("No rules yet — “Approve & remember” on an approval card adds allow rules here.") .font(ZyquoFont.body(size: 12)) .foregroundStyle(ZyquoColor.textTertiary) } ForEach(rules.deny, id: \.self) { rule in ruleRow(rule, isDeny: true) } ForEach(rules.allow, id: \.self) { rule in ruleRow(rule, isDeny: false) } HStack(spacing: ZyquoSpacing.xs) { Picker("", selection: $newList) { ForEach(RuleList.allCases) { list in Text(list.rawValue).tag(list) } } .labelsHidden() .frame(width: 90) Picker("", selection: $newKind) { Text("bash").tag("bash") Text("osascript").tag("osascript") } .labelsHidden() .frame(width: 110) TextField("Pattern (e.g. brew list)", text: $newPattern) .textFieldStyle(.roundedBorder) .font(ZyquoFont.code(size: 11)) .onSubmit(addRule) Button("Add") { addRule() } .controlSize(.small) .disabled(newPattern.trimmingCharacters(in: .whitespaces).isEmpty) } } .onAppear(perform: load) } private func ruleRow(_ rule: StoredPolicyRule, isDeny: Bool) -> some View { HStack(spacing: ZyquoSpacing.xs) { ZyquoBadge( text: isDeny ? "deny" : "allow", color: isDeny ? ZyquoColor.danger : ZyquoColor.success ) Text(rule.kind) .font(ZyquoFont.code(size: 11)) .foregroundStyle(ZyquoColor.textTertiary) .frame(width: 70, alignment: .leading) Text(rule.pattern) .font(ZyquoFont.code(size: 11.5)) .foregroundStyle(ZyquoColor.textPrimary) Spacer() Button { remove(rule, fromDeny: isDeny) } label: { Image(systemName: "trash") .font(.system(size: 10)) .foregroundStyle(ZyquoColor.danger) } .buttonStyle(.plain) .help("Remove rule") } } // MARK: Persistence (same document the PolicyEngine loads per task start) private func load() { rules = PersistenceService.shared.load(StoredPolicyRules.self, from: Self.rulesFileName) ?? StoredPolicyRules() } private func save() { PersistenceService.shared.save(rules, to: Self.rulesFileName) } private func addRule() { let pattern = newPattern.trimmingCharacters(in: .whitespaces) guard !pattern.isEmpty else { return } let rule = StoredPolicyRule(kind: newKind, pattern: pattern) switch newList { case .allow: if !rules.allow.contains(rule) { rules.allow.append(rule) } case .deny: if !rules.deny.contains(rule) { rules.deny.append(rule) } } newPattern = "" save() } private func remove(_ rule: StoredPolicyRule, fromDeny: Bool) { if fromDeny { rules.deny.removeAll { $0 == rule } } else { rules.allow.removeAll { $0 == rule } } save() } }