diff --git a/Sources/Clavis/Components/GlassComponents.swift b/Sources/Clavis/Components/GlassComponents.swift index d465615..186dd21 100644 --- a/Sources/Clavis/Components/GlassComponents.swift +++ b/Sources/Clavis/Components/GlassComponents.swift @@ -132,6 +132,7 @@ public struct LiquidGlassCapsuleControlModifier: ViewModifier { public var isFocused: Bool = false public var tint: Color? = nil @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var contrast @ViewBuilder public func body(content: Content) -> some View { @@ -141,21 +142,21 @@ public struct LiquidGlassCapsuleControlModifier: ViewModifier { .overlay( Capsule() .stroke( - isFocused ? DesignTokens.accentBlue.opacity(0.8) : Color.clear, - lineWidth: isFocused ? 1.5 : 1 + isFocused ? DesignTokens.accentBlue : contrast == .increased ? Color.primary : Color.clear, + lineWidth: isFocused || contrast == .increased ? 1.5 : 1 ) ) } else { content .background( Capsule() - .fill(tint ?? Color(nsColor: .controlBackgroundColor).opacity(0.65)) + .fill(tint ?? Color(nsColor: .controlBackgroundColor).opacity(reduceTransparency ? 1 : 0.65)) ) .overlay( Capsule() .stroke( - isFocused ? DesignTokens.accentBlue.opacity(0.75) : DesignTokens.cardBorder, - lineWidth: isFocused ? 1.5 : 1 + isFocused ? DesignTokens.accentBlue : contrast == .increased ? Color.primary : DesignTokens.cardBorder, + lineWidth: isFocused || contrast == .increased ? 1.5 : 1 ) ) } @@ -227,25 +228,30 @@ public struct LiquidGlassButtonStyleModifier: ViewModifier { } public struct AdaptiveGlassCapsuleModifier: ViewModifier { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var contrast public init() {} @ViewBuilder public func body(content: Content) -> some View { - if #available(macOS 26.0, *) { + if #available(macOS 26.0, *), !reduceTransparency { content .glassEffect(.regular.interactive(), in: .capsule) } else { content .background( ZStack { - VisualEffectView(material: .sidebar, blendingMode: .withinWindow) - Color(nsColor: NSColor.controlBackgroundColor).opacity(0.5) + if !reduceTransparency { + VisualEffectView(material: .sidebar, blendingMode: .withinWindow) + } + Color(nsColor: NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1 : 0.5) } .clipShape(Capsule()) ) .overlay( Capsule() - .stroke(DesignTokens.cardBorder, lineWidth: 0.8) + .stroke(contrast == .increased ? Color.primary : DesignTokens.cardBorder, + lineWidth: contrast == .increased ? 1.5 : 0.8) ) } } diff --git a/Sources/Clavis/Views/KeyDetailInspectorView.swift b/Sources/Clavis/Views/KeyDetailInspectorView.swift index 3b2bb93..c77eb83 100644 --- a/Sources/Clavis/Views/KeyDetailInspectorView.swift +++ b/Sources/Clavis/Views/KeyDetailInspectorView.swift @@ -482,10 +482,12 @@ public struct KeyDetailInspectorView: View { } private var activeSessionsSection: some View { - let sessions = appState.agentSessions.filter { $0.keyFingerprint == key.fingerprint } + let paused = appState.requestPolicy.isPaused + let available = appState.isSocketActive && appState.requestPolicy.state != .unknown + let sessions = paused ? [] : appState.agentSessions.filter { $0.keyFingerprint == key.fingerprint } return Section("Active Sessions") { VStack(spacing: 12) { - if let grace = activeGitGraceSession { + if !paused, let grace = activeGitGraceSession { HStack { Label("Git signing", systemImage: "signature") Spacer() @@ -504,8 +506,10 @@ public struct KeyDetailInspectorView: View { Button("Revoke") { appState.endAgentSession(id: session.id) } } } - if sessions.isEmpty && activeGitGraceSession == nil { - Text("No active approvals. Clavis will ask on the next permitted request.") + if paused || (sessions.isEmpty && activeGitGraceSession == nil) { + Text(paused ? "Requests are paused. Approvals have been revoked." + : available ? "No active approvals. Clavis will ask on the next permitted request." + : "Session status is unavailable. Check the agent connection.") .font(.callout).foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Sources/Clavis/Views/Sheets/KeyPurposePicker.swift b/Sources/Clavis/Views/Sheets/KeyPurposePicker.swift deleted file mode 100644 index dad6730..0000000 --- a/Sources/Clavis/Views/Sheets/KeyPurposePicker.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI -import ClavisCore - -/// Shared key-purpose selector (domain isolation) used by the create and import sheets. -struct KeyPurposePicker: View { - @Binding var selection: KeyPurpose - let accent: Color - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(ClavisUIStrings.CreateKey.purposeLabel) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(DesignTokens.textSecondary) - - HStack(spacing: 10) { - ForEach(KeyPurpose.allCases) { purpose in - Button(action: { - selection = purpose - }) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: icon(for: purpose)) - .foregroundColor(selection == purpose ? accent : .secondary) - Spacer() - if selection == purpose { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(accent) - .font(.caption) - } - } - Text(purpose.title) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - Text(purpose.subtitle) - .font(.system(size: 11)) - .foregroundColor(DesignTokens.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(10) - .frame(maxWidth: .infinity, minHeight: 74, alignment: .topLeading) - .liquidGlassControl(cornerRadius: 9, isSelected: selection == purpose) - } - .buttonStyle(.plain) - .accessibilityAddTraits(selection == purpose ? [.isSelected] : []) - } - } - } - } - - private func icon(for purpose: KeyPurpose) -> String { - switch purpose { - case .general: - return "network" - case .gitSigningOnly: - return "arrow.triangle.branch" - case .agent: - return "cpu" - } - } -} diff --git a/Sources/Clavis/Views/Sheets/SettingsNavigation.swift b/Sources/Clavis/Views/Sheets/SettingsNavigation.swift index 563ab3a..6f47324 100644 --- a/Sources/Clavis/Views/Sheets/SettingsNavigation.swift +++ b/Sources/Clavis/Views/Sheets/SettingsNavigation.swift @@ -12,6 +12,7 @@ struct SettingsNavigation: View { let general: General @AppStorage("clavis.settings.tab") private var selectedTab = SettingsTab.general.rawValue @StateObject private var applications: ApplicationSettingsModel + @FocusState private var focusedTab: SettingsTab? init(general: General, agent: any AgentControlling) { self.general = general @@ -36,6 +37,7 @@ struct SettingsNavigation: View { ForEach(SettingsTab.allCases, id: \.rawValue) { tab in Button { selectedTab = tab.rawValue + focusedTab = tab } label: { VStack(spacing: 3) { Image(systemName: tab.symbol).font(.system(size: 14)) @@ -51,10 +53,20 @@ struct SettingsNavigation: View { .contentShape(Capsule()) } .buttonStyle(.plain) + .focused($focusedTab, equals: tab) + .accessibilityIdentifier("settings-tab-\(tab.rawValue.lowercased())") .accessibilityLabel("\(tab.rawValue) settings") .accessibilityAddTraits(selectedTab == tab.rawValue ? [.isSelected] : []) } } + .onMoveCommand { direction in + guard focusedTab != nil else { return } + switch direction { + case .left: selectedTab = SettingsTab.general.rawValue; focusedTab = .general + case .right: selectedTab = SettingsTab.bots.rawValue; focusedTab = .bots + default: break + } + } .padding(4) .liquidGlassCapsuleControl() .padding(.vertical, 12) diff --git a/Sources/Clavis/Views/Sheets/SettingsSheet.swift b/Sources/Clavis/Views/Sheets/SettingsSheet.swift index 19bdfac..0c92a3f 100644 --- a/Sources/Clavis/Views/Sheets/SettingsSheet.swift +++ b/Sources/Clavis/Views/Sheets/SettingsSheet.swift @@ -3,26 +3,14 @@ import ClavisCore import Security import Darwin -public enum PinModeUI { - public static func showsWeakPINWarning(_ mode: AuditReadMode) -> Bool { - mode == .biometryOrPIN - } -} - public struct SettingsView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var appState: AppState @ObservedObject var lock: PanelLockController private let modeChanger: PinModeChanger? private let applicationAgent: any AgentControlling - @State private var globalMaxLease: Int = 1440 - @State private var lastSavedGlobalMaxLease: Int = 1440 - @State private var globalPolicyError: String? = nil - private let globalMaxLeaseOptions = [60, 240, 480, 1440, 2880, 10080] - - @State private var copiedEnv = false - @State private var activeSheet: ActiveSheet? - @State private var showingResetAlert = false + @State private var unlockMethodError: String? + @State private var isChangingUnlockMethod = false @State private var isSettingsAuthenticated: Bool @State private var isAuthenticating: Bool = false @@ -33,20 +21,6 @@ public struct SettingsView: View { @State private var vaultRepairSuccessMessage: String? = nil @State private var vaultRepairErrorMessage: String? = nil - private enum ActiveSheet: Identifiable { - case modeChange(AuditReadMode) - case changePIN - case resetPIN - - var id: String { - switch self { - case .modeChange(let m): return "mode-\(m.rawValue)" - case .changePIN: return "changePIN" - case .resetPIN: return "resetPIN" - } - } - } - public init( lock: PanelLockController? = nil, modeChanger: PinModeChanger? = nil, @@ -134,62 +108,9 @@ public struct SettingsView: View { settingsHeader } .frame(width: 580, height: 680) - .onExitCommand { - dismiss() - } - .onChange(of: lock.state) { _ in - isSettingsAuthenticated = !lock.isEffectivelyLocked - } - .alert(ClavisUIStrings.PinUnlock.forgotPIN, isPresented: $showingResetAlert) { - Button(ClavisUIStrings.PinUnlock.setPinTitle, role: .destructive) { - ClavisLogger.promptDebug("calvis-ui", "SettingsSheet: user confirmed reset PIN dialog") - activeSheet = .resetPIN - } - Button(ClavisUIStrings.Common.cancel, role: .cancel) { - ClavisLogger.promptDebug("calvis-ui", "SettingsSheet: user cancelled reset PIN dialog") - } - } message: { - Text(ClavisUIStrings.PinUnlock.resetWarning) - } - .sheet(item: $activeSheet) { sheet in - let title: String = { - switch sheet { - case .modeChange: return ClavisUIStrings.PinUnlock.setPinTitle - case .changePIN: return ClavisUIStrings.PinUnlock.changePIN - case .resetPIN: return ClavisUIStrings.PinUnlock.forgotPIN - } - }() - let subtitle: String? = { - if case .modeChange(let m) = sheet { return modeTitle(for: m) } - return nil - }() - let warning: String? = { - switch sheet { - case .modeChange(let m): return PinModeUI.showsWeakPINWarning(m) ? ClavisUIStrings.PinUnlock.warningOrMode : nil - case .changePIN: return nil - case .resetPIN: return ClavisUIStrings.PinUnlock.resetWarning - } - }() - PinModeChangeSheet( - title: title, - subtitle: subtitle, - warning: warning, - onSave: { pin, confirmPin in - switch sheet { - case .modeChange(let targetMode): - _ = try await modeChanger?.changeMode(to: targetMode, newPIN: pin, confirmPIN: confirmPin, oldContext: lock.unlockContext, skipPasswordAuth: true) - case .changePIN: - _ = try await modeChanger?.changeMode(to: lock.currentMode, newPIN: pin, confirmPIN: confirmPin, oldContext: lock.unlockContext, skipPasswordAuth: true) - case .resetPIN: - try await modeChanger?.resetPIN(newPIN: pin, confirmPIN: confirmPin, skipPasswordAuth: true) - } - await MainActor.run { lock.objectWillChange.send() } - }, - onDismiss: { activeSheet = nil } - ) - .dismissOnOutsideClick { - activeSheet = nil - } + .onExitCommand { dismiss() } + .onChange(of: lock.state) { _ in + isSettingsAuthenticated = !lock.isEffectivelyLocked } } @@ -247,11 +168,16 @@ public struct SettingsView: View { ? "Your existing unlock method remains active until you change it." : "Use Touch ID with your macOS login password as a fallback.") { if lock.currentMode.requiresPIN { - Button("Use This Method…") { selectMode(.passwordOrBiometry) } + Button("Use This Method…") { useSystemUnlockMethod() } + .disabled(isChangingUnlockMethod) } } } + if let unlockMethodError { + Text(unlockMethodError).font(.callout).foregroundStyle(.secondary) + } + // System Startup Section SettingsGroup(title: ClavisUIStrings.Settings.startupSection) { SettingsRow( @@ -435,26 +361,23 @@ public struct SettingsView: View { } } - private func selectMode(_ mode: AuditReadMode) { - if mode == .passwordOrBiometry { - Task { - do { - _ = try await modeChanger?.changeMode( - to: .passwordOrBiometry, - newPIN: nil, - confirmPIN: nil, - oldContext: lock.unlockContext, - skipPasswordAuth: true - ) - await MainActor.run { - lock.objectWillChange.send() - } - } catch { - // Password authentication cancelled or failed - } + private func useSystemUnlockMethod() { + guard !isChangingUnlockMethod else { return } + guard let modeChanger else { + unlockMethodError = "The unlock method could not be changed." + return + } + isChangingUnlockMethod = true + unlockMethodError = nil + Task { @MainActor in + defer { isChangingUnlockMethod = false } + do { + _ = try await modeChanger.changeMode(to: .passwordOrBiometry, newPIN: nil, + confirmPIN: nil, oldContext: lock.unlockContext, skipPasswordAuth: false) + lock.objectWillChange.send() + } catch { + unlockMethodError = "The unlock method was not changed. \(error.localizedDescription)" } - } else { - activeSheet = .modeChange(mode) } } @@ -516,116 +439,6 @@ public struct SettingsView: View { } } -struct PinModeChangeSheet: View { - let title: String - let subtitle: String? - let warning: String? - let onSave: (String, String) async throws -> Void - let onDismiss: () -> Void - - @State private var pin: String = "" - @State private var confirmPin: String = "" - @State private var errorMessage: String? - @State private var isProcessing: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.headline) - if let subtitle = subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - if let warning = warning { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - .font(.system(size: 14)) - Text(warning) - .font(.system(size: 11)) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(8) - .background(Color.orange.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - } - - VStack(alignment: .leading, spacing: 10) { - SecureField(ClavisUIStrings.PinUnlock.pinPlaceholder, text: $pin) - .textFieldStyle(.roundedBorder) - .disabled(isProcessing) - - SecureField(ClavisUIStrings.PinUnlock.confirmPinPlaceholder, text: $confirmPin) - .textFieldStyle(.roundedBorder) - .disabled(isProcessing) - } - - if pin.count > 0 && pin.count < 6 { - Text(ClavisUIStrings.PinUnlock.tooShort) - .font(.system(size: 11)) - .foregroundColor(.red) - } else if confirmPin.count > 0 && pin != confirmPin { - Text(ClavisUIStrings.PinUnlock.pinMismatch) - .font(.system(size: 11)) - .foregroundColor(.red) - } else if let errorMessage = errorMessage { - Text(errorMessage) - .font(.system(size: 11)) - .foregroundColor(.red) - } - - HStack { - Button(ClavisUIStrings.Common.cancel) { - onDismiss() - } - .liquidGlassButton() - .disabled(isProcessing) - - Spacer() - - Button(action: save) { - HStack(spacing: 4) { - if isProcessing { - ProgressView() - .controlSize(.small) - } - Text(ClavisUIStrings.PinUnlock.setPinTitle) - } - } - .liquidGlassButton(prominent: true) - .disabled(pin.count < 6 || pin != confirmPin || isProcessing) - } - } - .padding(20) - .frame(width: 380) - } - - private func save() { - guard pin.count >= 6, pin == confirmPin, !isProcessing else { return } - isProcessing = true - errorMessage = nil - Task { - do { - try await onSave(pin, confirmPin) - await MainActor.run { - isProcessing = false - onDismiss() - } - } catch { - await MainActor.run { - isProcessing = false - errorMessage = error.localizedDescription - } - } - } - } -} - // Backward-compatible wrapper for any sheet callers public struct SettingsSheet: View { @ObservedObject var appState: AppState diff --git a/Sources/ClavisCore/Crypto/KeychainManager.swift b/Sources/ClavisCore/Crypto/KeychainManager.swift index 9935188..3e0e110 100644 --- a/Sources/ClavisCore/Crypto/KeychainManager.swift +++ b/Sources/ClavisCore/Crypto/KeychainManager.swift @@ -707,8 +707,9 @@ public class KeychainManager { maxOperations: Int = 200, approvedProcess: GitApprovedProcess? = nil ) throws -> GitSigningGrant { - guard key.purpose != .agent else { - throw KeyPurposeError.notAllowedOnThisPath(.agent) + guard key.effectiveAccessProfile.capabilities.signature else { + if key.purpose == .agent { throw KeyPurposeError.notAllowedOnThisPath(.agent) } + throw KeyAccessError.operationDisabled(.signature) } return try withVerifiedPrivateKeyRecord( @@ -716,10 +717,7 @@ public class KeychainManager { prompt: prompt, auditOnAuthFailure: false ) { context, record in - guard record.purpose != .agent else { - context.invalidate() - throw KeyPurposeError.notAllowedOnThisPath(.agent) - } + try record.accessProfile.require(.signature) context.interactionNotAllowed = true @@ -818,12 +816,7 @@ public class KeychainManager { } let reason = prompt ?? "Touch ID to change key kind for '\(label)'" - try withVerifiedPrivateKeyRecord( - for: key, - prompt: reason, - auditOnAuthFailure: true, - auditKeyKind: newPurpose.auditKind - ) { _, record in + try withPolicyMutationRecord(for: key, prompt: reason, auditKeyKind: newPurpose.auditKind) { record in guard (record.purpose == .general && newPurpose == .agent) || (record.purpose == .agent && newPurpose == .general) else { throw KeyPurposeError.kindChangeNotAllowed } @@ -917,11 +910,8 @@ public class KeychainManager { let profile = KeyAccessProfile(type: type ?? key.effectiveAccessProfile.type, capabilities: capabilities ?? key.effectiveAccessProfile.capabilities) guard profile != key.effectiveAccessProfile else { return } - try withVerifiedPrivateKeyRecord(for: key, - prompt: "Change access policy for '\(label)'", - auditOnAuthFailure: true, auditKeyKind: profile.type == .bot ? .agent : .personal - ) { context, record in - defer { context.invalidate() } + try withPolicyMutationRecord(for: key, prompt: "Change access policy for '\(label)'", + auditKeyKind: profile.type == .bot ? .agent : .personal) { record in // Retain Git-only restrictions unless the user explicitly enables SSH. let purpose: KeyPurpose = record.purpose == .gitSigningOnly && !profile.capabilities.ssh ? .gitSigningOnly : .general @@ -932,6 +922,29 @@ public class KeychainManager { } } + private func withPolicyMutationRecord(for key: SSHKeyInfo, prompt: String, auditKeyKind: AuditKeyKind, + _ body: (StoredPrivateKeyRecord) throws -> T) throws -> T { + let context: LAContext + do { + // Metadata changes use system authentication with password fallback. + // This does not relax the Secure Enclave key's signing access control. + context = try authenticator.authenticate(reason: prompt, policy: .deviceOwnerAuthentication) + } catch { + auditRecorder.record(AuditEvent(type: .keyKindChange, + result: AuditEvent.isUserCancellation(error) ? .cancelled : .denied, + reason: .authenticationFailed, keyFingerprint: key.fingerprint, + keyKind: auditKeyKind, + sensitive: .init(keyLabel: key.label, processChain: [], host: nil))) + throw error + } + defer { context.invalidate() } + var record = try loadAuthenticatedRecord(label: key.label, context: context, prompt: prompt, expectedKeyInfo: key) + defer { record.wipe() } + try validateAuthenticatedRecord(record, against: key, context: context) + return try body(record) + } + + /// Rename an existing key label across the private key store, shadow vault, and public key store. @discardableResult public func renameKey(from oldLabel: String, to newLabel: String) throws -> SSHKeyInfo { diff --git a/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift b/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift index 3d7e454..c73fb07 100644 --- a/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift +++ b/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift @@ -278,7 +278,7 @@ public final class SigningPolicyEvaluator { audit.reason = .forwardingRefused return .denied } - if role == .agent && matchingKey.purpose == .agent { + if role == .agent && matchingKey.purpose == .agent && !dataToSign.starts(with: SSHSIGPayload.magic) { if connection?.hasForwarding == true || connection?.hasFailedBind == true { audit.result = .denied audit.reason = .forwardingRefused diff --git a/Tests/ClavisAppTests/PinModeUITests.swift b/Tests/ClavisAppTests/PinModeUITests.swift deleted file mode 100644 index ede60bb..0000000 --- a/Tests/ClavisAppTests/PinModeUITests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Testing -import ClavisCore -@testable import Clavis - -@Suite struct PinModeUITests { - @Test func test_004_AC6_orModeShowsWarning() { - #expect(PinModeUI.showsWeakPINWarning(.biometryOrPIN)) - #expect(!PinModeUI.showsWeakPINWarning(.biometryAndPIN)) - #expect(!PinModeUI.showsWeakPINWarning(.passwordOrBiometry)) - } -} diff --git a/Tests/ClavisAppTests/SettingsRenderingTests.swift b/Tests/ClavisAppTests/SettingsRenderingTests.swift index 577eb9c..63be284 100644 --- a/Tests/ClavisAppTests/SettingsRenderingTests.swift +++ b/Tests/ClavisAppTests/SettingsRenderingTests.swift @@ -6,8 +6,10 @@ import Testing @testable import ClavisTestSupport @Suite @MainActor struct SettingsRenderingTests { - @Test(arguments: ["General", "Bots"], [false, true]) - func modalRendersWithFloatingNavigation(tab: String, dark: Bool) async throws { + @Test(arguments: ["General", "Bots"], [0, 1, 2, 3]) + func modalRendersWithFloatingNavigation(tab: String, appearance: Int) async throws { + let dark = appearance & 1 != 0 + let accessibility = appearance & 2 != 0 let fixture = try ClavisTestFixture() defer { fixture.cleanup() } try await fixture.run { @@ -30,7 +32,9 @@ import Testing .environment(\.colorScheme, dark ? .dark : .light) let view = NSHostingView(rootView: root) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 580, height: 680), styleMask: [.borderless], backing: .buffered, defer: false) - window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.appearance = NSAppearance(named: accessibility + ? (dark ? .accessibilityHighContrastDarkAqua : .accessibilityHighContrastAqua) + : (dark ? .darkAqua : .aqua)) window.contentView = view window.orderFront(nil) defer { window.orderOut(nil) } @@ -45,7 +49,7 @@ import Testing if ProcessInfo.processInfo.environment["CLAVIS_RENDER_REVIEW"] == "1" { let directory = URL(fileURLWithPath: "/private/tmp/clavis-settings-review") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try png.write(to: directory.appendingPathComponent("\(tab.lowercased())-\(dark ? "dark" : "light").png")) + try png.write(to: directory.appendingPathComponent("\(tab.lowercased())-\(dark ? "dark" : "light")-\(accessibility ? "accessible" : "standard").png")) } } } diff --git a/Tests/ClavisCoreTests/KeyProfileMutationTests.swift b/Tests/ClavisCoreTests/KeyProfileMutationTests.swift index 843d8f6..c9e6894 100644 --- a/Tests/ClavisCoreTests/KeyProfileMutationTests.swift +++ b/Tests/ClavisCoreTests/KeyProfileMutationTests.swift @@ -140,4 +140,52 @@ import Testing } } + @Test(arguments: [false, true]) + func strictKeyPolicyChangesUseSystemFallbackWithoutRelaxingSigningPolicy(legacy: Bool) async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let store = InMemoryPrivateKeyStore() + let auth = CountingAuthenticator() + let manager = fixture.makeKeyManager(authenticator: auth, privateKeyStore: store) + let key = try manager.generateKey(label: "strict-policy", biometricPolicy: .biometryCurrentSet) + if legacy { try manager.changeKind(label: key.label, to: .agent) } + else { try manager.changeAccessProfile(label: key.label, type: .bot) } + #expect(auth.lastPolicy == .deviceOwnerAuthentication) + #expect(auth.authenticationCount == 1) + let changed = try #require(try manager.fetchKeyInfo(label: key.label)) + #expect(changed.effectiveBiometricPolicy == .biometryCurrentSet) + #expect(changed.effectiveAccessProfile.type == .bot) + let raw = try #require(try store.load(label: key.label, context: LAContext(), prompt: "")) + let record = try StoredPrivateKeyRecord.decode(from: raw) + #expect(record.biometricPolicy == .biometryCurrentSet) + #expect(record.accessProfile == changed.effectiveAccessProfile) + } + } + + @Test func explicitSignatureCapabilityOnLegacyBotDoesNotRequireOrReuseSSHSession() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let auth = CountingAuthenticator() + let grants = GitSigningGraceManager(observeSystemEvents: false) + let manager = fixture.makeKeyManager(authenticator: auth, gitSigningGraceManager: grants) + let original = try manager.generateKey(label: "legacy-bot-signature") + try manager.changeKind(label: original.label, to: .agent) + let key = try #require(try manager.fetchKeyInfo(label: original.label)) + #expect(key.effectiveAccessProfile.capabilities.signature) + let evaluator = SigningPolicyEvaluator(applicationAccess: TestApplicationAccess(), keyManager: manager, + gitSigningGraceManager: grants, auditRecorder: InMemoryAuditRecorder(), peerProcessValidator: { _, _, _ in true }) + let payload = SSHSIGPayload(namespace: "git", hashAlgorithm: "sha256", messageHash: Data(repeating: 1, count: 32)).serialize() + let result = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: payload, flags: 0, + role: .agent, clientPid: getpid(), clientExecutablePath: "/peer", clientStartTime: 7) + guard case .approved = result else { Issue.record("Signature must use its own approval instead of a legacy SSH session"); return } + #expect(auth.authenticationCount == 2) + let grant = try manager.authorizeGitSigningGrant(key: key, prompt: "Approve Signature", clientIdentity: "caller") + #expect(try !grant.sign(payload).isEmpty) + try manager.changeAccessProfile(label: key.label, capabilities: .init(ssh: true, signature: false)) + #expect(grants.allActiveGrants().isEmpty) + } + } + }