diff --git a/.gitignore b/.gitignore index eb7ea17..2622094 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ clavis-macos-*.dmg clavis-macos-*.dmg.sha256 *.md !README.md +!docs/personal-bot-routing.md !SECURITY.md !BENCHMARK_REPORT.md .claude/settings.local.json diff --git a/Sources/ClavisCore/Protocol/AgentSocketRole.swift b/Sources/ClavisCore/Protocol/AgentSocketRole.swift index a5f854e..a9e8dff 100644 --- a/Sources/ClavisCore/Protocol/AgentSocketRole.swift +++ b/Sources/ClavisCore/Protocol/AgentSocketRole.swift @@ -4,23 +4,14 @@ public enum AgentSocketRole: String, Sendable { case personal case agent - public var allowedPurposes: Set { - switch self { - case .personal: - return [.general, .gitSigningOnly, .agent] - case .agent: - return [.agent] - } - } + /// Legacy purpose is retained in records for migration restrictions. Socket + /// eligibility is based on the independent profile, never on routing alone. + public var allowedPurposes: Set { Set(KeyPurpose.allCases) } - public var listedPurposes: Set { - switch self { - case .personal: - return [.general, .agent] - case .agent: - return [.agent] - } + public func permits(_ key: SSHKeyInfo) -> Bool { + self == .personal || key.effectiveAccessProfile.type == .bot } + } public enum KeyKindFilter: String, CaseIterable, Identifiable, Sendable { @@ -35,9 +26,9 @@ public enum KeyKindFilter: String, CaseIterable, Identifiable, Sendable { case .all: return keys case .personal: - return keys.filter { $0.purpose.isPersonal } + return keys.filter { $0.effectiveAccessProfile.type == .personal } case .agent: - return keys.filter { $0.purpose == .agent } + return keys.filter { $0.effectiveAccessProfile.type == .bot } } } } diff --git a/Sources/ClavisCore/Protocol/SSHAgentServer.swift b/Sources/ClavisCore/Protocol/SSHAgentServer.swift index 9f2cf4f..a592d12 100644 --- a/Sources/ClavisCore/Protocol/SSHAgentServer.swift +++ b/Sources/ClavisCore/Protocol/SSHAgentServer.swift @@ -366,9 +366,9 @@ public class SSHAgentServer { clientDesc = "local process" } ClavisLogger.log("SSH_AGENT_IDENTITIES", "Listing active SSH identities for \(clientDesc)...") - // Filter keys according to socket role (personal lists general; agent lists agent) + // Both endpoints apply verified caller policy; compatibility is additionally Bot-only. let keys = ((try? keyManager.listKeys()) ?? []).filter { key in - guard key.effectiveAccessProfile.capabilities.ssh, + guard role.permits(key), key.effectiveAccessProfile.capabilities.ssh, let pid = clientPid, let path = clientExecutablePath ?? ProcessInspector.getProcessPath(pid: pid), let start = ProcessInspector.processStartTime(pid: pid), diff --git a/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift b/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift index a0f008f..198111e 100644 --- a/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift +++ b/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift @@ -131,6 +131,10 @@ public final class SigningPolicyEvaluator { return .denied } } + guard role.permits(key) else { + recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path) + return .denied + } let lease: ApplicationRequestLease do { lease = try applicationAccess.authorize(key: key, operation: operation, role: role, pid: clientPid, path: path, start: start) @@ -188,7 +192,7 @@ public final class SigningPolicyEvaluator { private func recordApplicationDecision(key: SSHKeyInfo, result: AuditResult, reason: AuditReason, pid: pid_t, path: String) { auditRecorder.record(AuditEvent(type: .signature, result: result, reason: reason, - keyFingerprint: key.fingerprint, keyKind: key.purpose.auditKind, count: 1, + keyFingerprint: key.fingerprint, keyKind: key.effectiveAccessProfile.type == .bot ? .agent : .personal, count: 1, sensitive: .init(keyLabel: key.label, processChain: AuditProcessChain.build(pid: pid, executablePath: path)))) } @@ -225,7 +229,7 @@ public final class SigningPolicyEvaluator { audit.keyFingerprint = matchingKey.fingerprint audit.keyLabel = matchingKey.label - audit.keyKind = matchingKey.purpose.auditKind + audit.keyKind = matchingKey.effectiveAccessProfile.type == .bot ? .agent : .personal let pid = clientPid guard let processPath = clientExecutablePath ?? ProcessInspector.getProcessPath(pid: pid) else { @@ -239,7 +243,7 @@ public final class SigningPolicyEvaluator { let clientDesc = "\(Self.safeProcessPath(processPath)) (PID \(pid))" - guard role.allowedPurposes.contains(matchingKey.purpose) else { + guard role.permits(matchingKey) else { audit.result = .denied audit.reason = .wrongKeyKind ClavisLogger.log("SECURITY_ALERT", "Key '\(matchingKey.label)' with purpose '\(matchingKey.purpose.rawValue)' is not allowed on \(role.rawValue) socket. Refusing request from \(clientDesc).") @@ -256,7 +260,12 @@ public final class SigningPolicyEvaluator { return .denied } - if role == .agent { + if role == .agent && (connection?.hasForwarding == true || connection?.hasFailedBind == true) { + audit.result = .denied + audit.reason = .forwardingRefused + return .denied + } + if role == .agent && matchingKey.purpose == .agent { if connection?.hasForwarding == true || connection?.hasFailedBind == true { audit.result = .denied audit.reason = .forwardingRefused @@ -435,7 +444,7 @@ public final class SigningPolicyEvaluator { let gitAnchor = ProcessInspector.grantAnchor(peerPid: pid, peerPath: processPath) let anchorPath = gitAnchor?.path let anchorPid = gitAnchor?.pid - let promptDesc = Self.requesterDescription( + let promptDesc = callerName ?? Self.requesterDescription( processPath: anchorPath ?? processPath, pid: anchorPid ?? pid ) @@ -457,7 +466,7 @@ public final class SigningPolicyEvaluator { return .denied } ClavisLogger.log("GIT_GRACE", "User approved 5-minute Git signing session for \(promptDesc). Authorizing via Touch ID...") - let authPrompt = Self.gitSigningSessionReason(keyLabel: matchingKey.label) + let authPrompt = Self.gitSigningSessionReason(keyLabel: matchingKey.label) + " Requested by \(requester)." let grant = try promptGate.run(requester: requesterIdentity, clientSocket: clientSocket) { try keyManager.authorizeGitSigningGrant( key: matchingKey, diff --git a/Tests/ClavisCoreTests/UnifiedEndpointTests.swift b/Tests/ClavisCoreTests/UnifiedEndpointTests.swift new file mode 100644 index 0000000..3a1383e --- /dev/null +++ b/Tests/ClavisCoreTests/UnifiedEndpointTests.swift @@ -0,0 +1,200 @@ +import Foundation +import LocalAuthentication +import CryptoKit +import Testing +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite struct UnifiedEndpointTests { + private func access(_ classification: ApplicationClassification?, paused: Bool = false) throws -> ApplicationRequestAuthorization { + let identity = try ApplicationIdentity(kind: .designatedRequirement, value: "verified-caller") + let app = RegisteredApplication(identity: identity, name: "Verified Bot", path: "/verified", classification: classification ?? .block) + let store = EndpointRegistry(.init(applications: classification == nil ? [] : [app], requestsPaused: paused)) + return ApplicationRequestAuthorization(store: store, resolver: ApplicationCallerResolver( + inspect: { pid in .init(pid: pid, parent: 1, start: ProcessInspector.processStartTime(pid: pid) ?? 7, path: "/verified") }, + verify: { _, _ in true }, transparentHelper: { _ in false })) + } + + @Test(arguments: ApplicationClassification.allCases, KeyType.allCases) + func bothOperationsRespectMatrixOnBothEndpoints(classification: ApplicationClassification, type: KeyType) async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let auth = CountingAuthenticator() + let manager = fixture.makeKeyManager(authenticator: auth) + let key = try manager.generateKey(label: "matrix", accessProfile: .init(type: type, capabilities: .init(ssh: true, signature: true))) + let gate = try access(classification) + for role in [AgentSocketRole.personal, .agent] { + let grants = GitSigningGraceManager(observeSystemEvents: false) + let evaluator = SigningPolicyEvaluator(applicationAccess: gate, keyManager: manager, + gitSigningGraceManager: grants, auditRecorder: InMemoryAuditRecorder(), authenticator: auth, + peerProcessValidator: { _, _, _ in true }) + for operation in KeyOperation.allCases { + let payload = operation == .ssh ? Data("ssh".utf8) : 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: role, clientPid: getpid(), clientExecutablePath: "/verified", clientStartTime: ProcessInspector.processStartTime(pid: getpid())) + let permitted = classification != .block && (classification == .personal || type == .bot) && (role == .personal || type == .bot) + if permitted { + guard case .approved = result else { Issue.record("Expected approval for \(classification), \(type), \(role), \(operation)"); continue } + } else { #expect(result == .denied) } + } + } + } + } + + @Test(arguments: [AgentSocketRole.personal, .agent]) + func disabledOperationDeniesBeforeAuthentication(role: AgentSocketRole) async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let auth = CountingAuthenticator() + let manager = fixture.makeKeyManager(authenticator: auth) + let key = try manager.generateKey(label: "disabled", accessProfile: .init(type: .bot, capabilities: .init(ssh: false, signature: false))) + let evaluator = SigningPolicyEvaluator(applicationAccess: try access(.personal), keyManager: manager, auditRecorder: InMemoryAuditRecorder(), authenticator: auth) + for payload in [Data("ssh".utf8), Data("SSHSIGanything".utf8)] { + #expect(try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: payload, flags: 0, role: role, clientPid: getpid(), clientExecutablePath: "/verified", clientStartTime: 7) == .denied) + } + #expect(auth.authenticationCount == 0) + } + } + + @Test func compatibilityDoesNotRequireWrapperForNewBotProfile() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let manager = fixture.makeKeyManager() + let key = try manager.generateKey(label: "new-bot", accessProfile: .init(type: .bot, capabilities: .init(ssh: true, signature: true))) + let evaluator = SigningPolicyEvaluator(applicationAccess: try access(.bot), keyManager: manager, auditRecorder: InMemoryAuditRecorder(), peerProcessValidator: { _, _, _ in true }) + let result = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), flags: 0, role: .agent, clientPid: getpid(), clientExecutablePath: "/verified", clientStartTime: ProcessInspector.processStartTime(pid: getpid())) + guard case .approved = result else { Issue.record("New Bot profiles must work without a legacy registered session"); return } + } + } + + @Test(arguments: [AgentSocketRole.personal, .agent]) + func pauseDeniesEnumerationAndDirectRequests(role: AgentSocketRole) async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let auth = CountingAuthenticator() + let manager = fixture.makeKeyManager(authenticator: auth) + let key = try manager.generateKey(label: "paused", accessProfile: .init(type: .bot, capabilities: .init(ssh: true, signature: true))) + let gate = try access(.personal, paused: true) + let server = SSHAgentServer(applicationAccess: gate, role: role, keyManager: manager) + let response = server.handleRequestIdentities(clientPid: getpid(), clientExecutablePath: "/verified") + var reader = DataReader(data: Data(response.dropFirst())) + #expect(reader.readUInt32() == 0) + let evaluator = SigningPolicyEvaluator(applicationAccess: gate, keyManager: manager, auditRecorder: InMemoryAuditRecorder(), authenticator: auth) + #expect(try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), flags: 0, role: role, clientPid: getpid(), clientExecutablePath: "/verified", clientStartTime: 7) == .denied) + #expect(auth.authenticationCount == 0) + } + } + + @Test func keyFiltersUseTypeRatherThanLegacyPurpose() { + let bot = SSHKeyInfo(label: "bot", publicKeyOpenSSH: "", publicKeyBlob: Data(), fingerprint: "bot", accessProfile: .init(type: .bot, capabilities: .init(ssh: true, signature: true))) + #expect(KeyKindFilter.apply(.agent, to: [bot]) == [bot]) + #expect(KeyKindFilter.apply(.personal, to: [bot]).isEmpty) + #expect(AgentSocketRole.agent.permits(bot)) + } + @Test(arguments: [AgentSocketRole.personal, .agent]) + func uncertainCallerIsAlwaysOneShotOnBotKeys(role: AgentSocketRole) 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 key = try manager.generateKey(label: "exact-requested-key", accessProfile: .init(type: .bot, capabilities: .init(ssh: true, signature: true))) + let evaluator = SigningPolicyEvaluator(applicationAccess: try access(nil), keyManager: manager, + gitSigningGraceManager: grants, auditRecorder: InMemoryAuditRecorder(), authenticator: auth, peerProcessValidator: { _, _, _ in true }) + for _ in 0..<2 { + let result = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, + dataToSign: Data("ssh".utf8), flags: 0, role: role, clientPid: getpid(), + clientExecutablePath: "/verified", clientStartTime: ProcessInspector.processStartTime(pid: getpid())) + guard case .approved = result else { Issue.record("Expected authenticated once-only approval"); return } + } + #expect(auth.authenticationCount == 2) + #expect(auth.lastReason?.contains(key.label) == true) + #expect(auth.lastReason?.contains("uncertain") == true) + #expect(grants.allActiveGrants().isEmpty) + } + } + + @Test func verifiedPromptNamesCallerAndRequestedKey() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let auth = CountingAuthenticator() + let recorder = InMemoryAuditRecorder() + let manager = fixture.makeKeyManager(authenticator: auth) + let key = try manager.generateKey(label: "bot-signature", accessProfile: .init(type: .bot, capabilities: .init(ssh: true, signature: true))) + let evaluator = SigningPolicyEvaluator(applicationAccess: try access(.bot), keyManager: manager, + auditRecorder: recorder, authenticator: auth, peerProcessValidator: { _, _, _ in true }) + _ = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), flags: 0, + role: .personal, clientPid: getpid(), clientExecutablePath: "/verified", clientStartTime: ProcessInspector.processStartTime(pid: getpid())) + #expect(auth.lastReason?.contains("Verified Bot") == true) + #expect(auth.lastReason?.contains(key.label) == true) + #expect(recorder.events.last?.keyKind == .agent) + } + } + + @Test func compatibilityRejectsForwardingForIndependentProfiles() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let auth = CountingAuthenticator() + let manager = fixture.makeKeyManager(authenticator: auth) + let key = try manager.generateKey(label: "forwarded", accessProfile: .init(type: .bot, capabilities: .init(ssh: true, signature: true))) + let evaluator = SigningPolicyEvaluator(applicationAccess: try access(.bot), keyManager: manager, auditRecorder: InMemoryAuditRecorder(), authenticator: auth) + let connection = ClientConnectionContext() + let host = Curve25519.Signing.PrivateKey() + let sessionID = Data("forwarded-session".utf8) + var hostBlob = Data() + hostBlob.appendWireString("ssh-ed25519") + hostBlob.appendWireData(host.publicKey.rawRepresentation) + var signature = Data() + signature.appendWireString("ssh-ed25519") + signature.appendWireData(try host.signature(for: sessionID)) + #expect(connection.bind(.init(hostKeyBlob: hostBlob, sessionID: sessionID, isForwarding: true), signature: signature)) + #expect(try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), flags: 0, + role: .agent, clientPid: getpid(), connection: connection, clientExecutablePath: "/verified", clientStartTime: 7) == .denied) + #expect(auth.authenticationCount == 0) + } + } + + @Test func compatibilityEnumerationUsesIndependentType() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let manager = fixture.makeKeyManager() + let bot = try manager.generateKey(label: "new-bot", accessProfile: .init(type: .bot, capabilities: .init(ssh: true, signature: true))) + _ = try manager.generateKey(label: "personal") + _ = try manager.generateKey(label: "signature-only", accessProfile: .init(type: .bot, capabilities: .init(ssh: false, signature: true))) + let server = SSHAgentServer(applicationAccess: try access(.personal), role: .agent, keyManager: manager) + let response = server.handleRequestIdentities(clientPid: getpid(), clientExecutablePath: "/verified") + var reader = DataReader(data: Data(response.dropFirst())) + #expect(reader.readUInt32() == 1) + #expect(reader.readWireData() == bot.publicKeyBlob) + #expect(reader.readWireString() == bot.label) + #expect(reader.isEOF) + } + } + + @Test func compatibilityRejectsEveryControlOpcodeEvenFromTrustedPeer() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let server = SSHAgentServer(applicationAccess: try access(.personal), role: .agent, keyManager: fixture.makeKeyManager()) + for opcode in UInt8.min...UInt8.max where AgentControlDispatcher.isControlOpcode(opcode) { + #expect(server.processAgentRequest(payload: Data([opcode]), clientPid: getpid(), + clientExecutablePath: "/verified", clientStartTime: 7, isTrustedControlPeer: { true }) == Data([5])) + } + } + } + +} + +private final class EndpointRegistry: ApplicationRegistryStoring, @unchecked Sendable { + let snapshot: ApplicationRegistrySnapshot + init(_ snapshot: ApplicationRegistrySnapshot) { self.snapshot = snapshot } + func load() throws -> ApplicationRegistrySnapshot { snapshot } + func save(_ snapshot: ApplicationRegistrySnapshot, replacing revision: UUID) throws { throw ApplicationRegistryError.staleRevision } +} diff --git a/docs/personal-bot-routing.md b/docs/personal-bot-routing.md new file mode 100644 index 0000000..bcda279 --- /dev/null +++ b/docs/personal-bot-routing.md @@ -0,0 +1,72 @@ +# Personal/Bot routing and migration + +Clavis uses `~/.ssh/clavis.sock` as its primary endpoint. +It handles Personal and Bot keys through the same protocol. +The socket chooses where a request goes; it does not identify the application. + +## Keys and applications + +Key type and operations are independent: + +| Application classification | Personal key | Bot key | +| --- | --- | --- | +| Personal | Eligible | Eligible | +| Bot | Denied | Eligible | +| Block | Denied | Denied | +| Uncertain identity | Authenticated once | Authenticated once | + +Eligibility also requires the requested operation to be enabled. +SSH and Signature switches do not change key type. +Changing key type preserves disabled operations. +Changing classification or key policy requires fresh authentication. +Existing approvals are invalidated when policy changes. + +Clavis does not replace a key requested by the client with another key. +Configure the requested SSH identity or Git signing key explicitly. +An unknown identity is never silently treated as Personal because it has a terminal ancestor. +Uncertain callers cannot create remembered classification or cached approvals. +They must authenticate each request. + +## SSH configuration without a launcher + +Use the primary socket directly in the appropriate SSH config: + +```sshconfig +Host github.com + IdentityAgent ~/.ssh/clavis.sock + IdentitiesOnly yes + IdentityFile ~/.ssh/clavis/project.pub +``` + +The identity file contains the public key, not an exported private key. +Its public key must match the intended Clavis key. +A separate bot configuration can use the same endpoint: + +```sh +ssh -F ~/.ssh/config_bot git@github.com +``` + +Using another config file does not itself prove that a request came from a bot. +Clavis still evaluates the verified caller and key policy. +For Git, select SSH signing and the intended public signing key in repository configuration. +A Nix shell or direnv can select configuration, but its environment is routing information. +It is not a source of Personal authority. + +## Compatibility endpoint + +`~/.ssh/clavis-agent.sock` remains a restricted compatibility endpoint. +It rejects Personal keys even for Personal-classified applications. +New Bot profiles can use enabled SSH or Signature operations there. +Legacy agent records retain their existing session and operation restrictions. +The compatibility endpoint rejects control requests, forwarding, and failed session bindings. +New configurations should use `clavis.sock`. + +## Pause and resume + +The kill switch revokes approvals and persistently pauses new requests. +Both endpoints reject enumeration and signing while paused. +Resuming requires fresh authentication and never restores revoked approvals. +Revocation cannot close an SSH connection that has already authenticated. + +These policies do not provide strong isolation from hostile processes running as the same user. +Application labels, environment variables, and socket paths are not a sandbox boundary.