Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 8 additions & 17 deletions Sources/ClavisCore/Protocol/AgentSocketRole.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,14 @@ public enum AgentSocketRole: String, Sendable {
case personal
case agent

public var allowedPurposes: Set<KeyPurpose> {
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<KeyPurpose> { Set(KeyPurpose.allCases) }

public var listedPurposes: Set<KeyPurpose> {
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 {
Expand All @@ -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 }
}
}
}
4 changes: 2 additions & 2 deletions Sources/ClavisCore/Protocol/SSHAgentServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
21 changes: 15 additions & 6 deletions Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))))
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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).")
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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,
Expand Down
200 changes: 200 additions & 0 deletions Tests/ClavisCoreTests/UnifiedEndpointTests.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading