From b229cfb4756df9a723dec959dd2e75e3fb9b4bf9 Mon Sep 17 00:00:00 2001 From: Damir Garifullin Date: Sat, 24 Oct 2026 18:05:10 +0300 Subject: [PATCH] Record final signing outcomes and sealed caller context in History --- .../Views/History/HistoryEventRow.swift | 7 +- .../Views/History/HistoryViewModel.swift | 9 ++ .../Views/History/Model/HistoryQuery.swift | 2 +- Sources/ClavisCore/Audit/AuditEvent.swift | 11 +- Sources/ClavisCore/Audit/AuditExporter.swift | 8 +- .../Audit/AuditRequestContext.swift | 15 ++ .../Crypto/GitSigningGraceManager.swift | 4 + .../Protocol/SigningPolicyEvaluator.swift | 43 +++-- .../HistoryRequestContextTests.swift | 47 ++++++ Tests/ClavisCoreTests/AuditEventTests.swift | 5 +- .../AuditRequestContextTests.swift | 148 ++++++++++++++++++ .../GitSigningGraceTests.swift | 10 ++ 12 files changed, 289 insertions(+), 20 deletions(-) create mode 100644 Sources/ClavisCore/Audit/AuditRequestContext.swift create mode 100644 Tests/ClavisAppTests/HistoryRequestContextTests.swift create mode 100644 Tests/ClavisCoreTests/AuditRequestContextTests.swift diff --git a/Sources/Clavis/Views/History/HistoryEventRow.swift b/Sources/Clavis/Views/History/HistoryEventRow.swift index 8f4770d..ccbcdbf 100644 --- a/Sources/Clavis/Views/History/HistoryEventRow.swift +++ b/Sources/Clavis/Views/History/HistoryEventRow.swift @@ -55,7 +55,9 @@ struct HistoryEventRow: View { } } - private var secondaryText: String { "\(operation) · \(result.title)" } + private var secondaryText: String { + [operation, HistoryViewModel.callerTitle(sens), result.title].compactMap { $0 }.joined(separator: " · ") + } private var primaryTitle: String { keyName ?? operation } private var dateText: String { @@ -160,6 +162,7 @@ struct HistoryEventRow: View { "Key: \(keyName ?? "—")", "Host: \(host ?? "—")", "Result: \(reasonLine)", + "Caller: \(HistoryViewModel.callerTitle(sens) ?? "Not recorded")", "Process: \(processLine)", "Launched by: \(parentsLine)", "Timestamp: \(Self.isoFormatter.string(from: event.time))", @@ -233,6 +236,8 @@ struct HistoryEventRow: View { if let fp = event.keyFingerprint { lines.append("Key Fingerprint: \(fp)") } if case .plaintext(let s) = unseal { if let label = s.keyLabel { lines.append("Key Label: \(label)") } + if let operation = s.requestOperation { lines.append("Operation: \(operation.rawValue)") } + if let caller = HistoryViewModel.callerTitle(s) { lines.append("Caller: \(caller)") } if let host = s.host { lines.append("Host: \(host)") } if !s.processChain.isEmpty { lines.append("Processes:") diff --git a/Sources/Clavis/Views/History/HistoryViewModel.swift b/Sources/Clavis/Views/History/HistoryViewModel.swift index 0973cbc..a9aad96 100644 --- a/Sources/Clavis/Views/History/HistoryViewModel.swift +++ b/Sources/Clavis/Views/History/HistoryViewModel.swift @@ -413,6 +413,12 @@ public final class HistoryViewModel: ObservableObject { return record.event.sensitive } + public nonisolated static func callerTitle(_ sensitive: AuditSensitive) -> String? { + if sensitive.callerUncertain == true { return "Caller identity uncertain" } + guard let name = sensitive.callerName, !name.isEmpty else { return nil } + return "Verified caller: \(name)" + } + public nonisolated static func operationTitle( for record: AuditRecord, sensitive: AuditSensitive? = nil @@ -420,6 +426,9 @@ public final class HistoryViewModel: ObservableObject { let sens = sensitive ?? record.event.sensitive switch record.event.type { case .signature: + if let operation = sens.requestOperation { + return operation == .ssh ? "SSH authentication" : "Signature" + } if record.event.keyKind == .agent || record.event.reason == .viaAgentSession { return "Agent signature" } diff --git a/Sources/Clavis/Views/History/Model/HistoryQuery.swift b/Sources/Clavis/Views/History/Model/HistoryQuery.swift index b07b028..2d35229 100644 --- a/Sources/Clavis/Views/History/Model/HistoryQuery.swift +++ b/Sources/Clavis/Views/History/Model/HistoryQuery.swift @@ -358,7 +358,7 @@ public struct HistoryFilter: Equatable, Sendable { || has(e.result.rawValue) || has(e.keyFingerprint) || has(e.sessionID) { return true } guard let s = sensitive else { return false } - if has(s.keyLabel) || has(s.host) { return true } + if has(s.keyLabel) || has(s.host) || has(s.callerName) { return true } return s.processChain.contains { has($0.executablePath) || has(($0.executablePath as NSString).lastPathComponent) || String($0.pid).contains(q) } diff --git a/Sources/ClavisCore/Audit/AuditEvent.swift b/Sources/ClavisCore/Audit/AuditEvent.swift index 68aeb9f..00aecbd 100644 --- a/Sources/ClavisCore/Audit/AuditEvent.swift +++ b/Sources/ClavisCore/Audit/AuditEvent.swift @@ -80,15 +80,24 @@ public struct AuditSensitive: Codable, Equatable, Sendable { public var keyLabel: String? public var processChain: [AuditProcess] // [0] = direct peer, then parents public var host: String? // filled by story 006 + public var requestOperation: KeyOperation? + public var callerName: String? + public var callerUncertain: Bool? public init( keyLabel: String? = nil, processChain: [AuditProcess] = [], - host: String? = nil + host: String? = nil, + requestOperation: KeyOperation? = nil, + callerName: String? = nil, + callerUncertain: Bool? = nil ) { self.keyLabel = keyLabel self.processChain = processChain self.host = host + self.requestOperation = requestOperation + self.callerName = callerName + self.callerUncertain = callerUncertain } } diff --git a/Sources/ClavisCore/Audit/AuditExporter.swift b/Sources/ClavisCore/Audit/AuditExporter.swift index 2489383..abdc545 100644 --- a/Sources/ClavisCore/Audit/AuditExporter.swift +++ b/Sources/ClavisCore/Audit/AuditExporter.swift @@ -38,6 +38,9 @@ public enum AuditExporter { let keyLabel: String? let processChain: [ExportProcess] let host: String? + let requestOperation: KeyOperation? + let callerName: String? + let callerUncertain: Bool? } /// Exports records in JSON Lines format, one object per record, newest first. @@ -94,7 +97,10 @@ public enum AuditExporter { count: event.count, keyLabel: redactedLabel, processChain: redactedChain, - host: resolvedSensitive?.host + host: resolvedSensitive?.host, + requestOperation: resolvedSensitive?.requestOperation, + callerName: resolvedSensitive?.callerName.map { hashString($0, salt: salt) }, + callerUncertain: resolvedSensitive?.callerUncertain ) if let lineData = try? encoder.encode(exportRec), diff --git a/Sources/ClavisCore/Audit/AuditRequestContext.swift b/Sources/ClavisCore/Audit/AuditRequestContext.swift new file mode 100644 index 0000000..478b24b --- /dev/null +++ b/Sources/ClavisCore/Audit/AuditRequestContext.swift @@ -0,0 +1,15 @@ +import Foundation + +public extension AuditEvent { + /// Request identity is sealed with other sensitive data. The original event + /// identifier and timestamp remain stable for linked History records. + func withRequestContext(operation: KeyOperation?, callerName: String?, uncertain: Bool?) -> AuditEvent { + var details = sensitive + details.requestOperation = operation + details.callerName = callerName + details.callerUncertain = uncertain + return AuditEvent(id: id, time: time, type: type, result: result, reason: reason, + keyFingerprint: keyFingerprint, keyKind: keyKind, sessionID: sessionID, + count: count, sensitive: details) + } +} diff --git a/Sources/ClavisCore/Crypto/GitSigningGraceManager.swift b/Sources/ClavisCore/Crypto/GitSigningGraceManager.swift index 848abcc..da7a086 100644 --- a/Sources/ClavisCore/Crypto/GitSigningGraceManager.swift +++ b/Sources/ClavisCore/Crypto/GitSigningGraceManager.swift @@ -252,6 +252,10 @@ public enum GitSigningPrompt { return .singleShot } + #if DEBUG + // Tests must inject a presenter instead of displaying an interactive alert. + guard !ClavisEnvironment.isTesting else { return .cancel } + #endif var responseFlags: CFOptionFlags = 0 let strings = GitSigningPromptStrings.current let header = strings.header as CFString diff --git a/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift b/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift index 198111e..3d7e454 100644 --- a/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift +++ b/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift @@ -115,37 +115,38 @@ public final class SigningPolicyEvaluator { guard flags == 0, let key = try keyManager.listKeys().first(where: { $0.publicKeyBlob == keyBlob }) else { return try evaluateAuthorizedSigningRequest(keyBlob: keyBlob, dataToSign: dataToSign, flags: flags, role: role, clientPid: clientPid, connection: connection, clientExecutablePath: clientExecutablePath, clientStartTime: clientStartTime, clientSocket: clientSocket) } + let operation: KeyOperation = dataToSign.starts(with: SSHSIGPayload.magic) ? .signature : .ssh guard let path = clientExecutablePath ?? ProcessInspector.getProcessPath(pid: clientPid), let start = clientStartTime ?? ProcessInspector.processStartTime(pid: clientPid) else { - recordApplicationDecision(key: key, result: .denied, reason: .noPeerAttribution, pid: clientPid, path: clientExecutablePath ?? "") + recordApplicationDecision(key: key, result: .denied, reason: .noPeerAttribution, pid: clientPid, path: clientExecutablePath ?? "", operation: operation) return .denied } - let operation: KeyOperation = dataToSign.starts(with: SSHSIGPayload.magic) ? .signature : .ssh if key.purpose == .gitSigningOnly { guard SSHSIGPayload.parse(from: dataToSign) != nil else { - recordApplicationDecision(key: key, result: .denied, reason: .gitOnlyKeyNonGitPayload, pid: clientPid, path: path) + recordApplicationDecision(key: key, result: .denied, reason: .gitOnlyKeyNonGitPayload, pid: clientPid, path: path, operation: operation) return .denied } guard ProcessInspector.isTrustedGitExecutable(path: path) || ProcessInspector.isSignerHelper(path: path) else { - recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path) + recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path, operation: operation) return .denied } } guard role.permits(key) else { - recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path) + recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path, operation: operation) return .denied } let lease: ApplicationRequestLease do { lease = try applicationAccess.authorize(key: key, operation: operation, role: role, pid: clientPid, path: path, start: start) } catch { - recordApplicationDecision(key: key, result: .denied, reason: .policyUnavailable, pid: clientPid, path: path) + recordApplicationDecision(key: key, result: .denied, reason: .policyUnavailable, pid: clientPid, path: path, operation: operation) return .denied } let decision: SigningDecision + var pendingAudit: AuditEvent? switch lease.eligibility { case .denied: - recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path) + recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path, operation: operation, lease: lease) return .denied case .authenticatedOnceOnly: // No cache lookup, history heuristic, or remembered classification. @@ -163,14 +164,15 @@ public final class SigningPolicyEvaluator { } decision = .approved(signature: signature) } catch { - recordApplicationDecision(key: key, result: AuditEvent.isUserCancellation(error) ? .cancelled : .denied, reason: .authenticationFailed, pid: clientPid, path: path) + recordApplicationDecision(key: key, result: AuditEvent.isUserCancellation(error) ? .cancelled : .denied, reason: .authenticationFailed, pid: clientPid, path: path, operation: operation, lease: lease) return .denied } case .eligible: decision = try evaluateAuthorizedSigningRequest( keyBlob: keyBlob, dataToSign: dataToSign, flags: flags, role: role, clientPid: clientPid, connection: connection, clientExecutablePath: path, clientStartTime: start, clientSocket: clientSocket, - approvalIdentity: lease.approvalIdentity, callerName: lease.caller?.application.name + approvalIdentity: lease.approvalIdentity, callerName: lease.caller?.application.name, + auditSink: { pendingAudit = $0 } ) } if case .approved = decision { @@ -180,20 +182,27 @@ public final class SigningPolicyEvaluator { (try? applicationAccess.validate(lease, pid: clientPid, path: path, start: start)) == true else { gitSigningGraceManager.invalidateAll(broadcast: false) agentSessions.endAll(reason: .keyChanged) - recordApplicationDecision(key: key, result: .denied, reason: .peerChanged, pid: clientPid, path: path) + recordApplicationDecision(key: key, result: .denied, reason: .peerChanged, pid: clientPid, path: path, operation: operation, lease: lease) return .dropConnection } if lease.eligibility == .authenticatedOnceOnly { - recordApplicationDecision(key: key, result: .allowed, reason: .viaPrompt, pid: clientPid, path: path) + recordApplicationDecision(key: key, result: .allowed, reason: .viaPrompt, pid: clientPid, path: path, operation: operation, lease: lease) } } + if let pendingAudit { + auditRecorder.record(pendingAudit.withRequestContext(operation: operation, + callerName: lease.caller?.application.name, uncertain: false)) + } return decision } - private func recordApplicationDecision(key: SSHKeyInfo, result: AuditResult, reason: AuditReason, pid: pid_t, path: String) { + private func recordApplicationDecision(key: SSHKeyInfo, result: AuditResult, reason: AuditReason, pid: pid_t, path: String, + operation: KeyOperation? = nil, lease: ApplicationRequestLease? = nil) { auditRecorder.record(AuditEvent(type: .signature, result: result, reason: reason, keyFingerprint: key.fingerprint, keyKind: key.effectiveAccessProfile.type == .bot ? .agent : .personal, count: 1, - sensitive: .init(keyLabel: key.label, processChain: AuditProcessChain.build(pid: pid, executablePath: path)))) + sensitive: .init(keyLabel: key.label, processChain: AuditProcessChain.build(pid: pid, executablePath: path))) + .withRequestContext(operation: operation, callerName: lease?.caller?.application.name, + uncertain: lease.map { $0.caller == nil })) } private func evaluateAuthorizedSigningRequest( @@ -207,10 +216,14 @@ public final class SigningPolicyEvaluator { clientStartTime: UInt64? = nil, clientSocket: Int32? = nil, approvalIdentity: String? = nil, - callerName: String? = nil + callerName: String? = nil, + auditSink: ((AuditEvent) -> Void)? = nil ) throws -> SigningDecision { var audit = PendingSignAudit() - defer { auditRecorder.record(audit.makeEvent()) } + defer { + if let auditSink { auditSink(audit.makeEvent()) } + else { auditRecorder.record(audit.makeEvent()) } + } guard flags == 0 else { audit.result = .denied diff --git a/Tests/ClavisAppTests/HistoryRequestContextTests.swift b/Tests/ClavisAppTests/HistoryRequestContextTests.swift new file mode 100644 index 0000000..26d2f3f --- /dev/null +++ b/Tests/ClavisAppTests/HistoryRequestContextTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import Clavis +@testable import ClavisCore + +@Suite struct HistoryRequestContextTests { + @Test func explicitOperationWinsOverProcessAndLegacyKeyKind() { + for operation in KeyOperation.allCases { + let details = AuditSensitive(processChain: [.init(executablePath: "/usr/bin/git", pid: 1)], requestOperation: operation) + let record = AuditRecord(seq: 1, event: .init(type: .signature, result: .allowed, keyKind: .agent, sensitive: details)) + #expect(HistoryViewModel.operationTitle(for: record) == (operation == .ssh ? "SSH authentication" : "Signature")) + } + } + + @Test func uncertaintyDoesNotShowAClaimedVerifiedName() { + #expect(HistoryViewModel.callerTitle(.init(callerName: "Claimed caller", callerUncertain: true)) == "Caller identity uncertain") + #expect(HistoryViewModel.callerTitle(.init(callerName: "Signed caller", callerUncertain: false)) == "Verified caller: Signed caller") + #expect(HistoryViewModel.callerTitle(.init()) == nil) + } + + @Test func existingTextSearchFindsSealedCallerAfterDecryption() { + let event = AuditEvent(type: .signature, result: .allowed) + let record = AuditRecord(seq: 1, event: event) + var filter = HistoryFilter() + filter.add(.text("Antigravity")) + #expect(filter.matches(record, sensitive: .init(callerName: "Antigravity"), calendar: .current)) + #expect(!filter.matches(record, sensitive: nil, calendar: .current)) + } + @Test func contextDoesNotRelabelNonSignatureEvents() { + let details = AuditSensitive(requestOperation: .ssh) + let record = AuditRecord(seq: 1, event: .init(type: .policyChange, result: .info, sensitive: details)) + #expect(HistoryViewModel.operationTitle(for: record) == "Signing policy updated") + } + + @Test func oldRowsKeepTheirExistingOperationInterpretation() { + let details = AuditSensitive(processChain: [.init(executablePath: "/usr/bin/git", pid: 1)]) + let record = AuditRecord(seq: 1, event: .init(type: .signature, result: .allowed, sensitive: details)) + #expect(HistoryViewModel.operationTitle(for: record) == "Git commit signature") + #expect(HistoryViewModel.callerTitle(details) == nil) + } + + @Test func missingCallerNameCannotBeDisplayedAsVerified() { + #expect(HistoryViewModel.callerTitle(.init(callerName: "", callerUncertain: false)) == nil) + #expect(HistoryViewModel.callerTitle(.init(callerUncertain: false)) == nil) + } + +} diff --git a/Tests/ClavisCoreTests/AuditEventTests.swift b/Tests/ClavisCoreTests/AuditEventTests.swift index f41e8d5..488e727 100644 --- a/Tests/ClavisCoreTests/AuditEventTests.swift +++ b/Tests/ClavisCoreTests/AuditEventTests.swift @@ -32,7 +32,10 @@ import LocalAuthentication let expectedSensitiveProperties: Set = [ "keyLabel", "processChain", - "host" + "host", + "requestOperation", + "callerName", + "callerUncertain" ] #expect(sensitiveProperties == expectedSensitiveProperties) } diff --git a/Tests/ClavisCoreTests/AuditRequestContextTests.swift b/Tests/ClavisCoreTests/AuditRequestContextTests.swift new file mode 100644 index 0000000..934c065 --- /dev/null +++ b/Tests/ClavisCoreTests/AuditRequestContextTests.swift @@ -0,0 +1,148 @@ +import Foundation +import CryptoKit +import LocalAuthentication +import Testing +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite struct AuditRequestContextTests { + @Test func contextPreservesEventIdentityAndOutcome() { + let event = AuditEvent(type: .signature, result: .denied, reason: .peerChanged, + keyFingerprint: "fp", keyKind: .agent, sessionID: "session", count: 2, + sensitive: .init(keyLabel: "key", processChain: [.init(executablePath: "/peer", pid: 7)], host: "host")) + let copy = event.withRequestContext(operation: .signature, callerName: "Caller", uncertain: false) + #expect(copy.id == event.id) + #expect(copy.time == event.time) + #expect(copy.reason == .peerChanged) + #expect(copy.result == .denied) + #expect(copy.sessionID == event.sessionID) + #expect(copy.count == event.count) + #expect(copy.sensitive.processChain == event.sensitive.processChain) + #expect(copy.sensitive.host == "host") + #expect(copy.sensitive.callerName == "Caller") + #expect(copy.sensitive.callerUncertain == false) + } + + @Test func oldSensitiveRowsDoNotInventVerifiedCaller() throws { + let json = Data(#"{"keyLabel":"legacy","processChain":[],"host":"github.com"}"#.utf8) + let sensitive = try JSONDecoder().decode(AuditSensitive.self, from: json) + #expect(sensitive.requestOperation == nil) + #expect(sensitive.callerName == nil) + #expect(sensitive.callerUncertain == nil) + #expect(sensitive.keyLabel == "legacy") + } + + @Test func requestContextRoundTripsInsideSealedPayload() throws { + let details = AuditSensitive(keyLabel: "private-key", requestOperation: .ssh, + callerName: "Sensitive App Name", callerUncertain: false) + let dek = AuditCrypto.newDEK() + let epoch = Data(repeating: 1, count: 16) + let aad = AuditCrypto.rowAAD(eventID: UUID(), time: Date(), type: .signature, fingerprint: "fp") + let blob = try AuditCrypto.sealRow(details, dek: dek, epochID: epoch, aad: aad) + #expect(!blob.contains(Data("Sensitive App Name".utf8))) + #expect(try AuditCrypto.openRow(blob, dek: dek, aad: aad) == details) + } + + @Test func exportedCallerNamesAreRedacted() throws { + let details = AuditSensitive(requestOperation: .signature, callerName: "Private Caller", callerUncertain: false) + let record = AuditRecord(seq: 1, event: AuditEvent(type: .signature, result: .allowed, sensitive: details)) + let output = AuditExporter.export([record], salt: Data(repeating: 7, count: 16)) + let text = try #require(String(data: output, encoding: .utf8)) + #expect(!text.contains("Private Caller")) + let json = try #require(JSONSerialization.jsonObject(with: output) as? [String: Any]) + #expect((json["callerName"] as? String)?.hasPrefix("h:") == true) + #expect(json["requestOperation"] as? String == "signature") + #expect(json["callerUncertain"] as? Bool == false) + } + + @Test func finalPolicyRejectionHasOneDeniedAuditAndNoAllowedAudit() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let recorder = InMemoryAuditRecorder() + let auth = CountingAuthenticator() + let manager = fixture.makeKeyManager(authenticator: auth) + let key = try manager.generateKey(label: "final-policy-check") + let evaluator = SigningPolicyEvaluator(applicationAccess: AuditAccess(uncertain: false, valid: false), + keyManager: manager, auditRecorder: recorder, authenticator: auth, + peerProcessValidator: { _, _, _ in true }) + #expect(try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), + flags: 0, role: .personal, clientPid: getpid(), clientExecutablePath: "/caller", clientStartTime: 7) == .dropConnection) + let signatures = recorder.events.filter { $0.type == .signature } + #expect(signatures.count == 1) + #expect(signatures.first?.result == .denied) + #expect(signatures.first?.reason == .peerChanged) + #expect(signatures.first?.sensitive.requestOperation == .ssh) + } + } + + @Test func successfulOnceOnlyAuditRecordsUncertaintyAndExactOperation() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let recorder = InMemoryAuditRecorder() + let manager = fixture.makeKeyManager() + let key = try manager.generateKey(label: "once-only") + let auth = CountingAuthenticator() + let evaluator = SigningPolicyEvaluator(applicationAccess: AuditAccess(uncertain: true, valid: true), + keyManager: manager, auditRecorder: recorder, authenticator: auth, + peerProcessValidator: { _, _, _ in true }) + let result = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("SSHSIGpayload".utf8), + flags: 0, role: .personal, clientPid: getpid(), clientExecutablePath: "/caller", clientStartTime: 7) + guard case .approved = result else { Issue.record("Expected authenticated one-shot request"); return } + let signatures = recorder.events.filter { $0.type == .signature } + #expect(signatures.count == 1) + #expect(signatures.first?.sensitive.callerUncertain == true) + #expect(signatures.first?.sensitive.requestOperation == .signature) + #expect(signatures.first?.sensitive.keyLabel == key.label) + #expect(signatures.first?.sensitive.callerName == nil) + } + } + @Test(arguments: [AuditUnsealResult.omitted, .unreadable]) + func exportDoesNotExposeCallerContextFromUnavailableRows(unseal: AuditUnsealResult) throws { + let details = AuditSensitive(requestOperation: .ssh, callerName: "Secret caller", callerUncertain: false) + let event = AuditEvent(type: .signature, result: .allowed, sensitive: details) + let record = AuditRecord(seq: 1, event: event, sensitiveFormat: 1, sealedSensitive: Data([1])) + let output = AuditExporter.export([record], unsealed: [1: unseal]) + let json = try #require(JSONSerialization.jsonObject(with: output) as? [String: Any]) + #expect(json["callerName"] == nil) + #expect(json["callerUncertain"] == nil) + #expect(json["requestOperation"] == nil) + } + + @Test func verifiedCallerProducesOneCanonicalAllowedRow() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let identity = try ApplicationIdentity(kind: .designatedRequirement, value: "verified") + let application = RegisteredApplication(identity: identity, name: "Verified App", path: "/caller", classification: .personal) + let caller = VerifiedApplicationCaller(identity: identity, application: application, pid: getpid(), startTime: 7, path: "/caller") + let recorder = InMemoryAuditRecorder() + let manager = fixture.makeKeyManager() + let key = try manager.generateKey(label: "canonical") + let evaluator = SigningPolicyEvaluator(applicationAccess: AuditAccess(uncertain: false, valid: true, caller: caller), + keyManager: manager, auditRecorder: recorder, peerProcessValidator: { _, _, _ in true }) + let result = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), flags: 0, + role: .personal, clientPid: getpid(), clientExecutablePath: "/caller", clientStartTime: 7) + guard case .approved = result else { Issue.record("Expected allowed request"); return } + let events = recorder.events.filter { $0.type == .signature } + #expect(events.count == 1) + #expect(events.first?.result == .allowed) + #expect(events.first?.sensitive.callerName == "Verified App") + #expect(events.first?.sensitive.callerUncertain == false) + #expect(events.first?.sensitive.requestOperation == .ssh) + } + } + +} + +private struct AuditAccess: ApplicationRequestAuthorizing { + let uncertain: Bool + let valid: Bool + var caller: VerifiedApplicationCaller? = nil + private let revision = UUID() + func authorize(key: SSHKeyInfo, operation: KeyOperation, role: AgentSocketRole, pid: pid_t, path: String, start: UInt64) throws -> ApplicationRequestLease { + .init(eligibility: uncertain ? .authenticatedOnceOnly : .eligible(.personal), revision: revision, caller: caller) + } + func validate(_ lease: ApplicationRequestLease, pid: pid_t, path: String, start: UInt64) throws -> Bool { valid } +} diff --git a/Tests/ClavisCoreTests/GitSigningGraceTests.swift b/Tests/ClavisCoreTests/GitSigningGraceTests.swift index 0d0d54a..ad5a8f1 100644 --- a/Tests/ClavisCoreTests/GitSigningGraceTests.swift +++ b/Tests/ClavisCoreTests/GitSigningGraceTests.swift @@ -743,6 +743,16 @@ final class GitSigningGraceTests { #expect(elapsed < 0.5) } + @Test func testDefaultModalPresenterCannotInterruptTestRuns() { + let previousProvider = GitSigningPrompt.sessionCheckProvider + defer { GitSigningPrompt.sessionCheckProvider = previousProvider } + GitSigningPrompt.sessionCheckProvider = { true } + let start = Date() + let result = GitSigningPrompt.displayModal(keyLabel: "test-only", clientDesc: "Test process") + #expect(result == .cancel) + #expect(Date().timeIntervalSince(start) < 1) + } + @Test func testModalDefaultButtonIsTheLeastPrivilegedChoice() { // Return / default button must never start an unattended signing session. #expect(