2026-01-18 01:33:52 +00:00
import Foundation
import Observation
import SwiftUI
struct SystemRunSettingsView : View {
2026-01-18 04:27:33 +00:00
@ State private var model = ExecApprovalsSettingsModel ( )
@ State private var tab : ExecApprovalsSettingsTab = . policy
2026-01-18 01:33:52 +00:00
@ State private var newPattern : String = " "
var body : some View {
VStack ( alignment : . leading , spacing : 8 ) {
HStack ( alignment : . center , spacing : 12 ) {
2026-01-18 04:27:33 +00:00
Text ( " Exec approvals " )
2026-01-18 01:33:52 +00:00
. font ( . body )
Spacer ( minLength : 0 )
2026-01-18 08:54:34 +00:00
Picker ( " Agent " , selection : Binding (
get : { self . model . selectedAgentId } ,
set : { self . model . selectAgent ( $0 ) } ) )
{
ForEach ( self . model . agentPickerIds , id : \ . self ) { id in
Text ( self . model . label ( for : id ) ) . tag ( id )
2026-01-18 01:33:52 +00:00
}
}
2026-01-18 08:54:34 +00:00
. pickerStyle ( . menu )
. frame ( width : 180 , alignment : . trailing )
2026-01-18 01:33:52 +00:00
}
Picker ( " " , selection : self . $ tab ) {
2026-01-18 04:27:33 +00:00
ForEach ( ExecApprovalsSettingsTab . allCases ) { tab in
2026-01-18 01:33:52 +00:00
Text ( tab . title ) . tag ( tab )
}
}
. pickerStyle ( . segmented )
2026-01-18 04:27:33 +00:00
. frame ( width : 320 )
2026-01-18 01:33:52 +00:00
if self . tab = = . policy {
self . policyView
} else {
self . allowlistView
}
}
. task { await self . model . refresh ( ) }
. onChange ( of : self . tab ) { _ , _ in
Task { await self . model . refreshSkillBins ( ) }
}
}
private var policyView : some View {
2026-01-18 04:27:33 +00:00
VStack ( alignment : . leading , spacing : 8 ) {
Picker ( " " , selection : Binding (
get : { self . model . security } ,
set : { self . model . setSecurity ( $0 ) } ) )
{
ForEach ( ExecSecurity . allCases ) { security in
Text ( security . title ) . tag ( security )
}
}
. labelsHidden ( )
. pickerStyle ( . menu )
2026-01-18 01:33:52 +00:00
Picker ( " " , selection : Binding (
2026-01-18 04:27:33 +00:00
get : { self . model . ask } ,
set : { self . model . setAsk ( $0 ) } ) )
2026-01-18 01:33:52 +00:00
{
2026-01-18 04:27:33 +00:00
ForEach ( ExecAsk . allCases ) { ask in
Text ( ask . title ) . tag ( ask )
2026-01-18 01:33:52 +00:00
}
}
. labelsHidden ( )
. pickerStyle ( . menu )
2026-01-18 04:27:33 +00:00
Picker ( " " , selection : Binding (
get : { self . model . askFallback } ,
set : { self . model . setAskFallback ( $0 ) } ) )
{
ForEach ( ExecSecurity . allCases ) { mode in
Text ( " Fallback: \( mode . title ) " ) . tag ( mode )
}
}
. labelsHidden ( )
. pickerStyle ( . menu )
2026-01-18 08:54:34 +00:00
Text ( self . model . isDefaultsScope
? " Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable. "
: " Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable. " )
2026-01-18 01:33:52 +00:00
. font ( . footnote )
. foregroundStyle ( . tertiary )
. fixedSize ( horizontal : false , vertical : true )
}
}
private var allowlistView : some View {
VStack ( alignment : . leading , spacing : 10 ) {
Toggle ( " Auto-allow skill CLIs " , isOn : Binding (
get : { self . model . autoAllowSkills } ,
set : { self . model . setAutoAllowSkills ( $0 ) } ) )
if self . model . autoAllowSkills , ! self . model . skillBins . isEmpty {
Text ( " Skill CLIs: \( self . model . skillBins . joined ( separator : " , " ) ) " )
. font ( . footnote )
. foregroundStyle ( . secondary )
}
2026-01-18 08:54:34 +00:00
if self . model . isDefaultsScope {
Text ( " Allowlists are per-agent. Select an agent to edit its allowlist. " )
2026-01-18 01:33:52 +00:00
. font ( . footnote )
. foregroundStyle ( . secondary )
} else {
2026-01-18 08:54:34 +00:00
HStack ( spacing : 8 ) {
TextField ( " Add allowlist pattern (case-insensitive globs) " , text : self . $ newPattern )
. textFieldStyle ( . roundedBorder )
Button ( " Add " ) {
let pattern = self . newPattern . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! pattern . isEmpty else { return }
self . model . addEntry ( pattern )
self . newPattern = " "
}
. buttonStyle ( . bordered )
. disabled ( self . newPattern . trimmingCharacters ( in : . whitespacesAndNewlines ) . isEmpty )
}
if self . model . entries . isEmpty {
Text ( " No allowlisted commands yet. " )
. font ( . footnote )
. foregroundStyle ( . secondary )
} else {
VStack ( alignment : . leading , spacing : 8 ) {
ForEach ( Array ( self . model . entries . enumerated ( ) ) , id : \ . offset ) { index , _ in
ExecAllowlistRow (
entry : Binding (
get : { self . model . entries [ index ] } ,
set : { self . model . updateEntry ( $0 , at : index ) } ) ,
onRemove : { self . model . removeEntry ( at : index ) } )
}
2026-01-18 01:33:52 +00:00
}
}
}
}
}
}
2026-01-18 04:27:33 +00:00
private enum ExecApprovalsSettingsTab : String , CaseIterable , Identifiable {
2026-01-18 01:33:52 +00:00
case policy
case allowlist
var id : String { self . rawValue }
var title : String {
switch self {
2026-01-18 04:27:33 +00:00
case . policy : " Access "
2026-01-18 01:33:52 +00:00
case . allowlist : " Allowlist "
}
}
}
2026-01-18 04:27:33 +00:00
struct ExecAllowlistRow : View {
@ Binding var entry : ExecAllowlistEntry
let onRemove : ( ) -> Void
2026-01-18 01:33:52 +00:00
@ State private var draftPattern : String = " "
private static let relativeFormatter : RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter ( )
formatter . unitsStyle = . short
return formatter
} ( )
var body : some View {
VStack ( alignment : . leading , spacing : 4 ) {
HStack ( spacing : 8 ) {
TextField ( " Pattern " , text : self . patternBinding )
. textFieldStyle ( . roundedBorder )
Button ( role : . destructive ) {
2026-01-18 04:27:33 +00:00
self . onRemove ( )
2026-01-18 01:33:52 +00:00
} label : {
Image ( systemName : " trash " )
}
. buttonStyle ( . borderless )
}
if let lastUsedAt = self . entry . lastUsedAt {
2026-01-18 04:27:33 +00:00
let date = Date ( timeIntervalSince1970 : lastUsedAt / 1000.0 )
Text ( " Last used \( Self . relativeFormatter . localizedString ( for : date , relativeTo : Date ( ) ) ) " )
2026-01-18 01:33:52 +00:00
. font ( . caption )
. foregroundStyle ( . secondary )
2026-01-18 08:54:34 +00:00
}
if let lastUsedCommand = self . entry . lastUsedCommand , ! lastUsedCommand . isEmpty {
Text ( " Last command: \( lastUsedCommand ) " )
. font ( . caption )
. foregroundStyle ( . secondary )
}
if let lastResolvedPath = self . entry . lastResolvedPath , ! lastResolvedPath . isEmpty {
Text ( " Resolved path: \( lastResolvedPath ) " )
2026-01-18 01:33:52 +00:00
. font ( . caption )
. foregroundStyle ( . secondary )
}
}
. onAppear {
self . draftPattern = self . entry . pattern
}
}
private var patternBinding : Binding < String > {
Binding (
get : { self . draftPattern . isEmpty ? self . entry . pattern : self . draftPattern } ,
set : { newValue in
self . draftPattern = newValue
self . entry . pattern = newValue
} )
}
}
@ MainActor
@ Observable
2026-01-18 04:27:33 +00:00
final class ExecApprovalsSettingsModel {
2026-01-18 08:54:34 +00:00
private static let defaultsScopeId = " __defaults__ "
2026-01-18 01:33:52 +00:00
var agentIds : [ String ] = [ ]
var selectedAgentId : String = " main "
var defaultAgentId : String = " main "
2026-01-18 04:27:33 +00:00
var security : ExecSecurity = . deny
var ask : ExecAsk = . onMiss
var askFallback : ExecSecurity = . deny
2026-01-18 01:33:52 +00:00
var autoAllowSkills = false
2026-01-18 04:27:33 +00:00
var entries : [ ExecAllowlistEntry ] = [ ]
2026-01-18 01:33:52 +00:00
var skillBins : [ String ] = [ ]
2026-01-18 08:54:34 +00:00
var agentPickerIds : [ String ] {
[ Self . defaultsScopeId ] + self . agentIds
}
var isDefaultsScope : Bool {
self . selectedAgentId = = Self . defaultsScopeId
}
func label ( for id : String ) -> String {
if id = = Self . defaultsScopeId { return " Defaults " }
return id
}
2026-01-18 01:33:52 +00:00
func refresh ( ) async {
await self . refreshAgents ( )
self . loadSettings ( for : self . selectedAgentId )
await self . refreshSkillBins ( )
}
func refreshAgents ( ) async {
let root = await ConfigStore . load ( )
let agents = root [ " agents " ] as ? [ String : Any ]
let list = agents ? [ " list " ] as ? [ [ String : Any ] ] ? ? [ ]
var ids : [ String ] = [ ]
var seen = Set < String > ( )
var defaultId : String ?
for entry in list {
guard let raw = entry [ " id " ] as ? String else { continue }
let trimmed = raw . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! trimmed . isEmpty else { continue }
if ! seen . insert ( trimmed ) . inserted { continue }
ids . append ( trimmed )
if ( entry [ " default " ] as ? Bool ) = = true , defaultId = = nil {
defaultId = trimmed
}
}
if ids . isEmpty {
ids = [ " main " ]
defaultId = " main "
} else if defaultId = = nil {
defaultId = ids . first
}
self . agentIds = ids
self . defaultAgentId = defaultId ? ? " main "
2026-01-18 08:54:34 +00:00
if self . selectedAgentId = = Self . defaultsScopeId {
return
}
2026-01-18 01:33:52 +00:00
if ! self . agentIds . contains ( self . selectedAgentId ) {
self . selectedAgentId = self . defaultAgentId
}
}
func selectAgent ( _ id : String ) {
self . selectedAgentId = id
self . loadSettings ( for : id )
Task { await self . refreshSkillBins ( ) }
}
func loadSettings ( for agentId : String ) {
2026-01-18 08:54:34 +00:00
if agentId = = Self . defaultsScopeId {
let defaults = ExecApprovalsStore . resolveDefaults ( )
self . security = defaults . security
self . ask = defaults . ask
self . askFallback = defaults . askFallback
self . autoAllowSkills = defaults . autoAllowSkills
self . entries = [ ]
return
}
2026-01-18 04:27:33 +00:00
let resolved = ExecApprovalsStore . resolve ( agentId : agentId )
self . security = resolved . agent . security
self . ask = resolved . agent . ask
self . askFallback = resolved . agent . askFallback
self . autoAllowSkills = resolved . agent . autoAllowSkills
self . entries = resolved . allowlist
2026-01-18 01:33:52 +00:00
. sorted { $0 . pattern . localizedCaseInsensitiveCompare ( $1 . pattern ) = = . orderedAscending }
}
2026-01-18 04:27:33 +00:00
func setSecurity ( _ security : ExecSecurity ) {
self . security = security
2026-01-18 08:54:34 +00:00
if self . isDefaultsScope {
ExecApprovalsStore . updateDefaults { defaults in
defaults . security = security
}
} else {
ExecApprovalsStore . updateAgentSettings ( agentId : self . selectedAgentId ) { entry in
entry . security = security
}
2026-01-18 04:27:33 +00:00
}
self . syncQuickMode ( )
}
func setAsk ( _ ask : ExecAsk ) {
self . ask = ask
2026-01-18 08:54:34 +00:00
if self . isDefaultsScope {
ExecApprovalsStore . updateDefaults { defaults in
defaults . ask = ask
}
} else {
ExecApprovalsStore . updateAgentSettings ( agentId : self . selectedAgentId ) { entry in
entry . ask = ask
}
2026-01-18 04:27:33 +00:00
}
self . syncQuickMode ( )
}
func setAskFallback ( _ mode : ExecSecurity ) {
self . askFallback = mode
2026-01-18 08:54:34 +00:00
if self . isDefaultsScope {
ExecApprovalsStore . updateDefaults { defaults in
defaults . askFallback = mode
}
} else {
ExecApprovalsStore . updateAgentSettings ( agentId : self . selectedAgentId ) { entry in
entry . askFallback = mode
}
2026-01-18 01:33:52 +00:00
}
}
func setAutoAllowSkills ( _ enabled : Bool ) {
self . autoAllowSkills = enabled
2026-01-18 08:54:34 +00:00
if self . isDefaultsScope {
ExecApprovalsStore . updateDefaults { defaults in
defaults . autoAllowSkills = enabled
}
} else {
ExecApprovalsStore . updateAgentSettings ( agentId : self . selectedAgentId ) { entry in
entry . autoAllowSkills = enabled
}
2026-01-18 04:27:33 +00:00
}
2026-01-18 01:33:52 +00:00
Task { await self . refreshSkillBins ( force : enabled ) }
}
func addEntry ( _ pattern : String ) {
2026-01-18 08:54:34 +00:00
guard ! self . isDefaultsScope else { return }
2026-01-18 01:33:52 +00:00
let trimmed = pattern . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! trimmed . isEmpty else { return }
2026-01-18 04:27:33 +00:00
self . entries . append ( ExecAllowlistEntry ( pattern : trimmed , lastUsedAt : nil ) )
ExecApprovalsStore . updateAllowlist ( agentId : self . selectedAgentId , allowlist : self . entries )
2026-01-18 01:33:52 +00:00
}
2026-01-18 04:27:33 +00:00
func updateEntry ( _ entry : ExecAllowlistEntry , at index : Int ) {
2026-01-18 08:54:34 +00:00
guard ! self . isDefaultsScope else { return }
2026-01-18 04:27:33 +00:00
guard self . entries . indices . contains ( index ) else { return }
2026-01-18 01:33:52 +00:00
self . entries [ index ] = entry
2026-01-18 04:27:33 +00:00
ExecApprovalsStore . updateAllowlist ( agentId : self . selectedAgentId , allowlist : self . entries )
2026-01-18 01:33:52 +00:00
}
2026-01-18 04:27:33 +00:00
func removeEntry ( at index : Int ) {
2026-01-18 08:54:34 +00:00
guard ! self . isDefaultsScope else { return }
2026-01-18 04:27:33 +00:00
guard self . entries . indices . contains ( index ) else { return }
self . entries . remove ( at : index )
ExecApprovalsStore . updateAllowlist ( agentId : self . selectedAgentId , allowlist : self . entries )
2026-01-18 01:33:52 +00:00
}
func refreshSkillBins ( force : Bool = false ) async {
guard self . autoAllowSkills else {
self . skillBins = [ ]
return
}
let bins = await SkillBinsCache . shared . currentBins ( force : force )
self . skillBins = bins . sorted ( )
}
2026-01-18 04:27:33 +00:00
private func syncQuickMode ( ) {
2026-01-18 08:54:34 +00:00
if self . isDefaultsScope {
AppStateStore . shared . execApprovalMode = ExecApprovalQuickMode . from ( security : self . security , ask : self . ask )
return
}
2026-01-18 04:27:33 +00:00
if self . selectedAgentId = = self . defaultAgentId || self . agentIds . count <= 1 {
AppStateStore . shared . execApprovalMode = ExecApprovalQuickMode . from ( security : self . security , ask : self . ask )
}
}
2026-01-18 01:33:52 +00:00
}