From d7b2c34faf70b99ae99c7269a806d62ab2649a11 Mon Sep 17 00:00:00 2001 From: Damir Garifullin Date: Sat, 24 Oct 2026 14:09:12 +0300 Subject: [PATCH] Add authoritative application policy controls and persistent request pause --- .../Models/ApplicationRegistry.swift | 20 +- .../Protocol/AgentControlDispatcher.swift | 13 +- .../Protocol/ApplicationPolicyControl.swift | 62 +++++++ .../ApplicationRequestAuthorization.swift | 5 +- .../ClavisCore/Protocol/SSHAgentServer.swift | 9 +- .../Services/AgentControlling.swift | 6 + .../Services/AgentLifecycleManager.swift | 17 ++ .../ApplicationPolicyControlTests.swift | 173 ++++++++++++++++++ 8 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 Sources/ClavisCore/Protocol/ApplicationPolicyControl.swift create mode 100644 Tests/ClavisCoreTests/ApplicationPolicyControlTests.swift diff --git a/Sources/ClavisCore/Models/ApplicationRegistry.swift b/Sources/ClavisCore/Models/ApplicationRegistry.swift index dd5b1f9..ec86436 100644 --- a/Sources/ClavisCore/Models/ApplicationRegistry.swift +++ b/Sources/ClavisCore/Models/ApplicationRegistry.swift @@ -74,11 +74,14 @@ public struct ApplicationRegistrySnapshot: Codable, Equatable, Sendable { public let version: Int public let revision: UUID public var applications: [RegisteredApplication] + public let requestsPaused: Bool? + public var isPaused: Bool { requestsPaused ?? false } - public init(revision: UUID = UUID(), applications: [RegisteredApplication] = []) { + public init(revision: UUID = UUID(), applications: [RegisteredApplication] = [], requestsPaused: Bool = false) { version = 1 self.revision = revision self.applications = applications + self.requestsPaused = requestsPaused } public func validate() throws { @@ -156,7 +159,7 @@ public final class AuthenticatedApplicationRegistry { private func commit(_ application: RegisteredApplication, replacing old: ApplicationRegistrySnapshot) throws { var entries = old.applications.filter { $0.identity != application.identity } entries.append(application) - let updated = ApplicationRegistrySnapshot(applications: entries) + let updated = ApplicationRegistrySnapshot(applications: entries, requestsPaused: old.isPaused) try updated.validate() let context = try authenticator.authenticate( reason: "Change access for \(application.name) to \(application.classification.title)", @@ -169,6 +172,19 @@ public final class AuthenticatedApplicationRegistry { try store.save(updated, replacing: old.revision) } + /// Pausing stops requests without waiting for authentication. Resuming is + /// authenticated and never recreates revoked approvals. + public func setPaused(_ paused: Bool) throws { + let old = try store.load() + if !paused { + let context = try authenticator.authenticate(reason: "Resume Clavis requests", policy: .deviceOwnerAuthentication) + context.invalidate() + } + try invalidateApprovals() + let updated = ApplicationRegistrySnapshot(applications: old.applications, requestsPaused: paused) + try store.save(updated, replacing: old.revision) + } + public func remove(identity: ApplicationIdentity) throws { let old = try store.load() guard var app = old.applications.first(where: { $0.identity == identity }) else { diff --git a/Sources/ClavisCore/Protocol/AgentControlDispatcher.swift b/Sources/ClavisCore/Protocol/AgentControlDispatcher.swift index 07bd4e7..03bc847 100644 --- a/Sources/ClavisCore/Protocol/AgentControlDispatcher.swift +++ b/Sources/ClavisCore/Protocol/AgentControlDispatcher.swift @@ -2,6 +2,7 @@ import Foundation import Darwin internal struct AgentControlDispatcher { + let applicationRegistry: ApplicationRegistryStoring let keyManager: KeychainManager let auditRecorder: AuditRecording let agentSessions: AgentSessionRegistry @@ -19,8 +20,10 @@ internal struct AgentControlDispatcher { authenticator: UserAuthenticating, promptGate: SigningPromptGate, peerProcessValidator: @escaping (pid_t, String, UInt64) -> Bool, - gitSigningGraceManager: GitSigningGraceManager = .shared + gitSigningGraceManager: GitSigningGraceManager = .shared, + applicationRegistry: ApplicationRegistryStoring = KeychainApplicationRegistry.shared ) { + self.applicationRegistry = applicationRegistry self.keyManager = keyManager self.auditRecorder = auditRecorder self.agentSessions = agentSessions @@ -32,7 +35,9 @@ internal struct AgentControlDispatcher { } static func isControlOpcode(_ msgType: UInt8) -> Bool { - msgType == SSHAgentServer.invalidateKeyRequest + msgType == SSHAgentServer.applicationRegistryRequest + || msgType == SSHAgentServer.applicationPolicyChangeRequest + || msgType == SSHAgentServer.invalidateKeyRequest || msgType == SSHAgentServer.queryGitGraceRequest || msgType == SSHAgentServer.lockAllRequest || msgType == SSHAgentServer.registerAgentSessionRequest @@ -53,6 +58,10 @@ internal struct AgentControlDispatcher { clientSocket: Int32? ) -> Data { switch msgType { + case SSHAgentServer.applicationRegistryRequest: + return handleApplicationRegistry(payload: payload) + case SSHAgentServer.applicationPolicyChangeRequest: + return handleApplicationPolicyChange(payload: Data(payload.dropFirst()), clientPid: clientPid, clientExecutablePath: clientExecutablePath, clientStartTime: clientStartTime, clientSocket: clientSocket) case SSHAgentServer.invalidateKeyRequest: return handleInvalidateKey(payload: Data(payload.dropFirst())) case SSHAgentServer.queryGitGraceRequest: diff --git a/Sources/ClavisCore/Protocol/ApplicationPolicyControl.swift b/Sources/ClavisCore/Protocol/ApplicationPolicyControl.swift new file mode 100644 index 0000000..a875e07 --- /dev/null +++ b/Sources/ClavisCore/Protocol/ApplicationPolicyControl.swift @@ -0,0 +1,62 @@ +import Foundation +import Darwin + +public enum ApplicationPolicyChange: Codable, Equatable, Sendable { + case set(RegisteredApplication) + case remove(ApplicationIdentity) + case pause + case resume +} + +extension AgentControlDispatcher { + func handleApplicationRegistry(payload: Data) -> Data { + guard payload.count == 1, + let snapshot = try? applicationRegistry.load(), + let data = try? JSONEncoder().encode(snapshot) else { return Data([5]) } + var response = Data([6]) + response.appendWireData(data) + return response + } + + func handleApplicationPolicyChange( + payload: Data, clientPid: pid_t?, clientExecutablePath: String?, + clientStartTime: UInt64?, clientSocket: Int32? + ) -> Data { + var reader = DataReader(data: payload) + guard let json = reader.readWireData(), json.count <= 65_536, reader.isEOF, + let change = try? JSONDecoder().decode(ApplicationPolicyChange.self, from: json), + let pid = clientPid, let path = clientExecutablePath, let start = clientStartTime, + peerProcessValidator(pid, path, start) else { return Data([5]) } + let registry = AuthenticatedApplicationRegistry( + store: applicationRegistry, authenticator: authenticator, + invalidateApprovals: { + guard self.peerProcessValidator(pid, path, start) else { throw ApplicationRegistryError.staleRevision } + self.gitSigningGraceManager.invalidateAll(broadcast: false) + self.agentSessions.endAll(reason: .keyChanged) + } + ) + do { + if change == .pause { + try registry.setPaused(true) + } else { + try promptGate.run(requester: .init(executablePath: path, pid: pid), clientSocket: clientSocket) { + switch change { + case .set(let app): try registry.set(app) + case .remove(let identity): try registry.remove(identity: identity) + case .pause: break + case .resume: try registry.setPaused(false) + } + } + } + auditRecorder.record(AuditEvent(type: .policyChange, result: .info, + sensitive: .init(processChain: AuditProcessChain.build(pid: pid, executablePath: path)))) + return Data([6]) + } catch { + auditRecorder.record(AuditEvent(type: .policyChange, + result: AuditEvent.isUserCancellation(error) ? .cancelled : .denied, + reason: .policyUnavailable, + sensitive: .init(processChain: AuditProcessChain.build(pid: pid, executablePath: path)))) + return Data([5]) + } + } +} diff --git a/Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift b/Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift index 7c07f10..0cd7887 100644 --- a/Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift +++ b/Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift @@ -27,6 +27,9 @@ public final class ApplicationRequestAuthorization: ApplicationRequestAuthorizin public func authorize(key: SSHKeyInfo, operation: KeyOperation, role: AgentSocketRole, pid: pid_t, path: String, start: UInt64) throws -> ApplicationRequestLease { let snapshot = try store.load() + guard !snapshot.isPaused else { + return .init(eligibility: .denied, revision: snapshot.revision, caller: nil) + } let caller = resolver.resolve(peerPID: pid, peerPath: path, peerStart: start, registry: snapshot) let eligibility = ApplicationEligibility.evaluate( profile: key.effectiveAccessProfile, operation: operation, @@ -37,7 +40,7 @@ public final class ApplicationRequestAuthorization: ApplicationRequestAuthorizin public func validate(_ lease: ApplicationRequestLease, pid: pid_t, path: String, start: UInt64) throws -> Bool { let snapshot = try store.load() - guard snapshot.revision == lease.revision else { return false } + guard !snapshot.isPaused, snapshot.revision == lease.revision else { return false } let caller = resolver.resolve(peerPID: pid, peerPath: path, peerStart: start, registry: snapshot) return caller == lease.caller } diff --git a/Sources/ClavisCore/Protocol/SSHAgentServer.swift b/Sources/ClavisCore/Protocol/SSHAgentServer.swift index 18c6110..9f2cf4f 100644 --- a/Sources/ClavisCore/Protocol/SSHAgentServer.swift +++ b/Sources/ClavisCore/Protocol/SSHAgentServer.swift @@ -58,6 +58,8 @@ public class SSHAgentServer { internal static let extendAgentSessionRequest: UInt8 = 247 internal static let setAgentPolicyRequest: UInt8 = 248 internal static let getAgentPolicyRequest: UInt8 = 249 + internal static let applicationRegistryRequest: UInt8 = 250 + internal static let applicationPolicyChangeRequest: UInt8 = 251 public static let defaultSocketPath = NSString(string: "~/.ssh/clavis.sock").expandingTildeInPath public static let defaultAgentSocketPath = NSString(string: "~/.ssh/clavis-agent.sock").expandingTildeInPath public static let shared = SSHAgentServer(promptGate: .shared) @@ -90,7 +92,8 @@ public class SSHAgentServer { internal var activeClientCount: Int { socketServer.activeClientCount } public init( - applicationAccess: ApplicationRequestAuthorizing = ApplicationRequestAuthorization(), + applicationAccess: ApplicationRequestAuthorizing? = nil, + applicationRegistry: ApplicationRegistryStoring = KeychainApplicationRegistry.shared, socketPath: String = SSHAgentServer.defaultSocketPath, role: AgentSocketRole = .personal, maxConcurrentClients: Int = 32, @@ -131,6 +134,7 @@ public class SSHAgentServer { let resolvedPeerValidator = peerProcessValidator ?? { SSHAgentServer.peerProcessUnchanged(pid: $0, path: $1, startTime: $2) } + let applicationAccess = applicationAccess ?? ApplicationRequestAuthorization(store: applicationRegistry) self.controlDispatcher = AgentControlDispatcher( keyManager: keyManager, auditRecorder: auditRecorder, @@ -139,7 +143,8 @@ public class SSHAgentServer { authenticator: authenticator, promptGate: promptGate, peerProcessValidator: resolvedPeerValidator, - gitSigningGraceManager: gitSigningGraceManager + gitSigningGraceManager: gitSigningGraceManager, + applicationRegistry: applicationRegistry ) self.applicationAccess = applicationAccess diff --git a/Sources/ClavisCore/Services/AgentControlling.swift b/Sources/ClavisCore/Services/AgentControlling.swift index adf4e3f..9109e8c 100644 --- a/Sources/ClavisCore/Services/AgentControlling.swift +++ b/Sources/ClavisCore/Services/AgentControlling.swift @@ -23,11 +23,17 @@ public protocol AgentControlling: Sendable { func revokeAllAgentSessions() -> Int func extendAgentSession(id: String, minutes: Int) -> Date? + func applicationRegistry() -> ApplicationRegistrySnapshot? + func changeApplicationPolicy(_ change: ApplicationPolicyChange) -> Bool + func getAgentPolicy(target: String) -> String? func setAgentPolicy(target: String, policyJson: String) -> Bool } public extension AgentControlling { + func applicationRegistry() -> ApplicationRegistrySnapshot? { nil } + func changeApplicationPolicy(_ change: ApplicationPolicyChange) -> Bool { false } + func queryAllAgentGitGraces() -> [(keyLabel: String, remainingSeconds: Int, remainingOperations: Int)] { if let single = queryAgentGitGrace() { return [single] diff --git a/Sources/ClavisCore/Services/AgentLifecycleManager.swift b/Sources/ClavisCore/Services/AgentLifecycleManager.swift index cf6c2c0..31f59c0 100644 --- a/Sources/ClavisCore/Services/AgentLifecycleManager.swift +++ b/Sources/ClavisCore/Services/AgentLifecycleManager.swift @@ -483,6 +483,23 @@ public final class AgentLifecycleManager: @unchecked Sendable { return Date(timeIntervalSince1970: TimeInterval(newExpirySeconds)) } + public func applicationRegistry() -> ApplicationRegistrySnapshot? { + guard let response = try? sendControlRequest(Data([SSHAgentServer.applicationRegistryRequest])), + response.first == 6 else { return nil } + var reader = DataReader(data: Data(response.dropFirst())) + guard let data = reader.readWireData(), reader.isEOF, + let snapshot = try? JSONDecoder().decode(ApplicationRegistrySnapshot.self, from: data), + (try? snapshot.validate()) != nil else { return nil } + return snapshot + } + + public func changeApplicationPolicy(_ change: ApplicationPolicyChange) -> Bool { + guard let json = try? JSONEncoder().encode(change) else { return false } + var payload = Data([SSHAgentServer.applicationPolicyChangeRequest]) + payload.appendWireData(json) + return (try? sendControlRequest(payload, timeout: 75))?.first == 6 + } + public func setAgentPolicy(target: String, policyJson: String) -> Bool { guard FileManager.default.fileExists(atPath: socketPath) else { return false } var payload = Data([SSHAgentServer.setAgentPolicyRequest]) diff --git a/Tests/ClavisCoreTests/ApplicationPolicyControlTests.swift b/Tests/ClavisCoreTests/ApplicationPolicyControlTests.swift new file mode 100644 index 0000000..8623fc1 --- /dev/null +++ b/Tests/ClavisCoreTests/ApplicationPolicyControlTests.swift @@ -0,0 +1,173 @@ +import Foundation +import LocalAuthentication +import Testing +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite struct ApplicationPolicyControlTests { + private func payload(_ change: ApplicationPolicyChange) throws -> Data { + var data = Data([SSHAgentServer.applicationPolicyChangeRequest]) + data.appendWireData(try JSONEncoder().encode(change)) + return data + } + private func send(_ change: ApplicationPolicyChange, server: SSHAgentServer, trusted: Bool = true) throws -> Data { + server.processAgentRequest(payload: try payload(change), clientPid: getpid(), clientExecutablePath: "/clavis", clientStartTime: 7, isTrustedControlPeer: { trusted }) + } + private func application() throws -> RegisteredApplication { + RegisteredApplication(identity: try .init(kind: .designatedRequirement, value: "test"), name: "Test App", path: "/Test.app", classification: .bot) + } + + @Test func classificationRequiresFreshAuthenticationAndCanBeReadBack() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let store = PolicyControlRegistry() + let auth = CountingAuthenticator() + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), applicationRegistry: store, keyManager: fixture.makeKeyManager(), peerProcessValidator: { _, _, _ in true }, authenticator: auth) + let app = try application() + #expect(try send(.set(app), server: server) == Data([6])) + #expect(try send(.set(app), server: server) == Data([6])) + #expect(auth.authenticationCount == 2) + #expect(auth.lastPolicy == .deviceOwnerAuthentication) + let data = server.processAgentRequest(payload: Data([SSHAgentServer.applicationRegistryRequest]), isTrustedControlPeer: { true }) + #expect(data.first == 6) + var reader = DataReader(data: Data(data.dropFirst())) + let raw = reader.readWireData() + let snapshot = try JSONDecoder().decode(ApplicationRegistrySnapshot.self, from: #require(raw)) + #expect(snapshot.applications == [app]) + #expect(reader.isEOF) + } + } + + @Test func untrustedAndCompatibilityControlRequestsCannotMutateRegistry() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let store = PolicyControlRegistry() + let auth = CountingAuthenticator() + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), applicationRegistry: store, keyManager: fixture.makeKeyManager(), peerProcessValidator: { _, _, _ in true }, authenticator: auth) + let app = try application() + #expect(try send(.set(app), server: server, trusted: false) == Data([5])) + let compatibility = SSHAgentServer(applicationAccess: TestApplicationAccess(), applicationRegistry: store, role: .agent, keyManager: fixture.makeKeyManager(), peerProcessValidator: { _, _, _ in true }, authenticator: auth) + #expect(try send(.pause, server: compatibility) == Data([5])) + #expect(auth.authenticationCount == 0) + #expect(try store.load().applications.isEmpty) + #expect(try !store.load().isPaused) + } + } + + @Test func pauseIsImmediateAndResumeDoesNotRestoreGrants() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let store = PolicyControlRegistry() + let auth = CountingAuthenticator() + let grants = GitSigningGraceManager(observeSystemEvents: false) + let pid = getpid() + let start = try #require(ProcessInspector.processStartTime(pid: pid)) + grants.recordGrant(keyLabel: "key", clientIdentity: "caller", duration: 300, maxOperations: 100, context: LAContext(), approvedProcess: .init(pid: pid, startTime: start)) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), applicationRegistry: store, keyManager: fixture.makeKeyManager(), gitSigningGraceManager: grants, peerProcessValidator: { _, _, _ in true }, authenticator: auth) + #expect(try send(.pause, server: server) == Data([6])) + #expect(auth.authenticationCount == 0) + #expect(try store.load().isPaused) + #expect(grants.allActiveGrants().isEmpty) + let key = SSHKeyInfo(label: "key", publicKeyOpenSSH: "", publicKeyBlob: Data(), fingerprint: "fp") + let access = ApplicationRequestAuthorization(store: store) + #expect(try access.authorize(key: key, operation: .ssh, role: .personal, pid: pid, path: "/clavis", start: start).eligibility == .denied) + #expect(try send(.resume, server: server) == Data([6])) + #expect(auth.authenticationCount == 1) + #expect(try !store.load().isPaused) + #expect(grants.allActiveGrants().isEmpty) + } + } + + @Test func policyChangesPreservePauseAndRemovalLeavesBlock() async throws { + let store = PolicyControlRegistry() + let service = AuthenticatedApplicationRegistry(store: store, authenticator: AllowingAuthenticator(), invalidateApprovals: {}) + try service.setPaused(true) + let app = try application() + try service.set(app) + #expect(try store.load().isPaused) + try service.remove(identity: app.identity) + let snapshot = try store.load() + #expect(snapshot.isPaused) + #expect(snapshot.classification(for: app.identity) == .block) + #expect(snapshot.applications.first?.removed == true) + } + + @Test func failedAuthenticationCannotResumePausedRequests() throws { + let store = PolicyControlRegistry() + let service = AuthenticatedApplicationRegistry(store: store, authenticator: RejectingPolicyAuthenticator(), invalidateApprovals: {}) + try service.setPaused(true) + let original = try store.load() + #expect(throws: (any Error).self) { try service.setPaused(false) } + #expect(try store.load() == original) + } + + @Test func pauseInvalidatesAnInFlightAuthorizationLease() throws { + let store = PolicyControlRegistry() + let access = ApplicationRequestAuthorization(store: store, resolver: ApplicationCallerResolver(inspect: { _ in nil })) + let key = SSHKeyInfo(label: "key", publicKeyOpenSSH: "", publicKeyBlob: Data(), fingerprint: "fp") + let lease = try access.authorize(key: key, operation: .ssh, role: .personal, pid: 100, path: "/caller", start: 7) + #expect(try access.validate(lease, pid: 100, path: "/caller", start: 7)) + let service = AuthenticatedApplicationRegistry(store: store, authenticator: AllowingAuthenticator(), invalidateApprovals: {}) + try service.setPaused(true) + #expect(try !access.validate(lease, pid: 100, path: "/caller", start: 7)) + try service.setPaused(false) + #expect(try !access.validate(lease, pid: 100, path: "/caller", start: 7)) + let newLease = try access.authorize(key: key, operation: .ssh, role: .personal, pid: 100, path: "/caller", start: 7) + #expect(newLease.revision != lease.revision) + } + + @Test func peerChangeDuringAuthenticationCannotCommitClassification() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let store = PolicyControlRegistry() + let original = try store.load() + var valid = true + let auth = PeerChangingPolicyAuthenticator { valid = false } + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), applicationRegistry: store, keyManager: fixture.makeKeyManager(), peerProcessValidator: { _, _, _ in valid }, authenticator: auth) + let app = try application() + #expect(try send(.set(app), server: server) == Data([5])) + #expect(try store.load() == original) + } + } + + @Test func oldSnapshotsDecodeAsUnpausedAndMalformedChangesAreRejected() async throws { + let snapshot = ApplicationRegistrySnapshot() + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any]) + json.removeValue(forKey: "requestsPaused") + #expect(try !JSONDecoder().decode(ApplicationRegistrySnapshot.self, from: JSONSerialization.data(withJSONObject: json)).isPaused) + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let store = PolicyControlRegistry() + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), applicationRegistry: store, keyManager: fixture.makeKeyManager()) + #expect(server.processAgentRequest(payload: Data([SSHAgentServer.applicationPolicyChangeRequest, 0]), isTrustedControlPeer: { true }) == Data([5])) + let loaded = try store.load() + #expect(loaded.applications.isEmpty) + } + } +} + +private final class PolicyControlRegistry: ApplicationRegistryStoring, @unchecked Sendable { + private let lock = NSLock() + private var snapshot = ApplicationRegistrySnapshot() + func load() throws -> ApplicationRegistrySnapshot { lock.lock(); defer { lock.unlock() }; return snapshot } + func save(_ snapshot: ApplicationRegistrySnapshot, replacing revision: UUID) throws { + lock.lock(); defer { lock.unlock() } + guard self.snapshot.revision == revision else { throw ApplicationRegistryError.staleRevision } + self.snapshot = snapshot + } +} +private struct RejectingPolicyAuthenticator: UserAuthenticating { + func authenticate(reason: String, policy: LAPolicy) throws -> LAContext { throw UserAuthenticationError.rejected(nil) } + func authenticate(reason: String, policy: LAPolicy) async throws -> LAContext { throw UserAuthenticationError.rejected(nil) } +} + +private struct PeerChangingPolicyAuthenticator: UserAuthenticating { + let action: () -> Void + func authenticate(reason: String, policy: LAPolicy) throws -> LAContext { action(); return LAContext() } + func authenticate(reason: String, policy: LAPolicy) async throws -> LAContext { action(); return LAContext() } +}