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
4 changes: 4 additions & 0 deletions Sources/AskKeyAppKit/App/AppDelegate+Approval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ extension AppDelegate {
requestID: $0.requestID, capability: $0.capability
)
},
organizationSummary: pending.flatMap {
try? Vault.shared.frozenAgentOrganizationSummary(operationID: $0.request.operationID,
requestID: $0.requestID, capability: $0.capability)
},
revealMaterial: pending.map { frozenPending in
{ [weak self] in
guard let self, Self.screenState() == .unlocked else {
Expand Down
1 change: 1 addition & 0 deletions Sources/AskKeyAppKit/App/AppDelegate+BrokerLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ extension AppDelegate {
throw BrokerProviderError.agentAccessPaused
}
},
catalogGroups: { try Vault.shared.brokerCredentialGroups(cancellation: $0) },
requestStatus: { requestID, capability in
if let completed = try Vault.shared.committedAgentFileWriteStatus(
requestID: requestID, capability: capability) {
Expand Down
1 change: 1 addition & 0 deletions Sources/AskKeyAppKit/App/ApprovalPromptContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct ApprovalPromptContent: Equatable {
case .create: title = appLocalizedFormat("“%@” wants to create “%@”", caller, credentialName)
case .modify: title = appLocalizedFormat("“%@” wants to modify “%@”", caller, credentialName)
case .delete: title = appLocalizedFormat("“%@” wants to delete “%@”", caller, credentialName)
case .organize: title = appLocalizedFormat("“%@” wants to organize credentials", caller)
}
let display = request.operation == .read ? request.display : nil
commandSummary = display?.commandSummary
Expand Down
18 changes: 15 additions & 3 deletions Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct FrozenAgentApprovalPrompt: View {
let timedAllowanceEnabled: Bool
var timedAllowanceMinutes: Int = 30
var writeSummary: BrokerCredentialWriteSummary? = nil
var organizationSummary: BrokerOrganizationSummary? = nil
var revealMaterial: (@MainActor () async throws -> FrozenApprovalMaterial)? = nil
/// The approval whose system authentication was cancelled. The prompt then
/// offers to authenticate again for that same decision, or to deny.
Expand Down Expand Up @@ -42,6 +43,7 @@ struct FrozenAgentApprovalPrompt: View {
.font(Theme.Fonts.body.bold())
.foregroundStyle(Theme.text)
.multilineTextAlignment(.center)
.lineLimit(request.operation == .organize ? 3 : nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Theme.Spacing.md)
if let command = content.commandSummary {
Expand All @@ -67,11 +69,18 @@ struct FrozenAgentApprovalPrompt: View {
detailsToggle(expanded: expanded)
.padding(.top, Theme.Spacing.md)
if expanded {
detailRows(details)
.padding(.top, Theme.Spacing.md)
if request.operation == .organize {
ScrollView { detailRows(details) }.frame(maxHeight: 90)
.padding(.top, Theme.Spacing.md)
} else {
detailRows(details).padding(.top, Theme.Spacing.md)
}
}
}
if request.operation != .read {
if request.operation == .organize {
FrozenOrganizationApprovalContent(summary: organizationSummary)
.padding(.top, Theme.Spacing.lg)
} else if request.operation != .read {
FrozenWriteApprovalContent(writeSummary: writeSummary, revealMaterial: revealMaterial)
.padding(.top, Theme.Spacing.lg)
}
Expand Down Expand Up @@ -140,9 +149,11 @@ struct FrozenAgentApprovalPrompt: View {
VStack(spacing: Theme.Spacing.sm) {
if let retry = cancelledAuthenticationDecision {
ApprovalPromptButton(title: content.retryTitle, primary: true) { finish(retry) }
.disabled(request.operation == .organize && organizationSummary == nil)
.accessibilityIdentifier("approval-retry-authentication")
} else {
ApprovalPromptButton(title: primaryTitle, primary: true) { finish(.once) }
.disabled(request.operation == .organize && organizationSummary == nil)
.accessibilityIdentifier("approval-allow-once")
if request.operation == .read, timedAllowanceEnabled {
ApprovalPromptButton(
Expand Down Expand Up @@ -179,6 +190,7 @@ struct FrozenAgentApprovalPrompt: View {
case .create: return appLocalized("Approve Creation")
case .modify: return appLocalized("Approve Change")
case .delete: return appLocalized("Approve Deletion")
case .organize: return appLocalized("Approve Organization")
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/AskKeyAppKit/App/FrozenApprovalActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum FrozenApprovalActions {
case .create: return [appLocalized("Approve Creation"), appLocalized("Deny")]
case .modify: return [appLocalized("Approve Change"), appLocalized("Deny")]
case .delete: return [appLocalized("Approve Deletion"), appLocalized("Deny")]
case .organize: return [appLocalized("Approve Organization"), appLocalized("Deny")]
}
}
}
79 changes: 79 additions & 0 deletions Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import SwiftUI
import AskKeyBroker

struct FrozenOrganizationSummaryContent {
struct Row: Equatable {
let title: String
let detail: String?
}

let rows: [Row]

init(summary: BrokerOrganizationSummary) {
rows = summary.operations.enumerated().map { index, operation in
let title: String
let detail: String?
switch operation {
case .move(let credential, let from, let to):
title = appLocalizedFormat("Move %@: %@ → %@", credential,
from ?? appLocalized("Ungrouped"), to ?? appLocalized("Ungrouped"))
detail = nil
case .createGroup(let name):
title = appLocalizedFormat("Create group “%@”", name)
detail = appLocalized("New group — created when approved")
case .renameGroup(let from, let to, let members, let nonvisible):
title = appLocalizedFormat("Rename group “%@” → “%@”", from, to)
detail = Self.memberCountDescription(members: members, nonvisible: nonvisible)
case .deleteGroup(let name, let members, let nonvisible):
title = appLocalizedFormat("Delete group “%@”", name)
detail = Self.memberCountDescription(members: members, nonvisible: nonvisible)
}
return .init(title: String(index + 1) + ". " + title, detail: detail)
}
}

private static func memberCountDescription(members: Int, nonvisible: Int) -> String {
if nonvisible > 0 {
return members == 1
? appLocalizedFormat("%lld credential, %lld not visible to agents", members, nonvisible)
: appLocalizedFormat("%lld credentials, %lld not visible to agents", members, nonvisible)
}
return members == 1
? appLocalizedFormat("%lld credential", members)
: appLocalizedFormat("%lld credentials", members)
}
}

/// All operations remain readable by scrolling; actions stay outside the cap.
struct FrozenOrganizationApprovalContent: View {
let summary: BrokerOrganizationSummary?

var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text(appLocalized("Proposed organization"))
.font(Theme.Fonts.caption.weight(.semibold))
if let summary {
let rows = FrozenOrganizationSummaryContent(summary: summary).rows
ScrollView {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(verbatim: row.title)
.foregroundStyle(Theme.text)
if let detail = row.detail {
Text(verbatim: detail).foregroundStyle(Theme.textSecondary)
}
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.font(Theme.Fonts.caption)
.textSelection(.enabled)
}
.frame(maxHeight: 210)
.accessibilityIdentifier("approval-organization-operations")
}
}
}
}
2 changes: 1 addition & 1 deletion Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ struct FrozenWriteSummaryContent {
switch operation {
case .create:
return after.isEmpty ? [] : [.init(label: nil, values: after)]
case .delete, .read:
case .delete, .read, .organize:
return before.isEmpty ? [] : [.init(label: nil, values: before)]
case .modify:
if unchanged {
Expand Down
97 changes: 97 additions & 0 deletions Sources/AskKeyAppKit/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,103 @@
{
"sourceLanguage": "en",
"strings": {
"“%@” wants to organize credentials": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "“%@” wants to organize credentials" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "“%@”想整理凭证" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%@” wants to organize credentials!!]" } }
}
},
"%@ wants to organize credentials": {
"extractionState": "manual",
"generatesSymbol": false,
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "%@ wants to organize credentials" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 想整理凭证" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ wants to organize credentials!!]" } }
}
},
"Approve Organization": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Approve Organization" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "批准整理" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Approve Organization!!]" } }
}
},
"Proposed organization": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Proposed organization" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "待执行的整理" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Proposed organization!!]" } }
}
},
"Move %@: %@ → %@": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Move %@: %@ → %@" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "移动 %@:%@ → %@" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Move %@: %@ → %@!!]" } }
}
},
"Create group “%@”": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Create group “%@”" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "创建分组“%@”" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Create group “%@”!!]" } }
}
},
"Rename group “%@” → “%@”": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Rename group “%@” → “%@”" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "重命名分组“%@” → “%@”" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Rename group “%@” → “%@”!!]" } }
}
},
"Delete group “%@”": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Delete group “%@”" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "删除分组“%@”" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delete group “%@”!!]" } }
}
},
"%lld credential": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "%lld credential" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credential!!]" } }
}
},
"%lld credentials": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "%lld credentials" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credentials!!]" } }
}
},
"%lld credential, %lld not visible to agents": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "%lld credential, %lld not visible to agents" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证,其中 %lld 个 Agent 看不到" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credential, %lld not visible to agents!!]" } }
}
},
"%lld credentials, %lld not visible to agents": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "%lld credentials, %lld not visible to agents" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证,其中 %lld 个 Agent 看不到" } },
"qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credentials, %lld not visible to agents!!]" } }
}
},
"Unchanged": {
"extractionState": "manual",
"localizations": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct PendingRequestPresentation: Equatable {
switch operation {
case .read: return appLocalized("%1$@ wants to use %2$@")
case .create: return appLocalized("%1$@ wants to create %2$@")
case .organize: return appLocalized("%@ wants to organize credentials")
case .modify: return appLocalized("%1$@ wants to change %2$@")
case .delete: return appLocalized("%1$@ wants to delete %2$@")
}
Expand Down
1 change: 1 addition & 0 deletions Sources/AskKeyBroker/AgentTextWriteAction.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation

public enum AgentTextWriteAction: Codable, Equatable, Sendable {
case organize([BrokerOrganizationOperation])
case createBundle(name: String, components: [BrokerCredentialComponentInput], usageInstructions: String? = nil, group: String? = nil)
case modifyBundle(name: String, changes: [BrokerCredentialComponentChange] = [], usageInstructions: String? = nil, group: BrokerCredentialGroupChange? = nil)
case create(name: String, value: String)
Expand Down
1 change: 1 addition & 0 deletions Sources/AskKeyBroker/BrokerApprovalOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public enum BrokerApprovalOperation: String, Codable, Equatable, Sendable {
case create
case modify
case delete
case organize
}
12 changes: 9 additions & 3 deletions Sources/AskKeyBroker/BrokerApprovalOperationRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public struct BrokerApprovalOperationRequest: Equatable, Sendable {
public let callerPurpose: String?
/// App-derived presentation only; never an authorization input.
public let display: Display?
/// App-side batch binding. Never included in a Broker response.
public let organizationCredentialIDs: [String]?

public init(
operationID: String,
Expand All @@ -23,7 +25,8 @@ public struct BrokerApprovalOperationRequest: Equatable, Sendable {
callerName: String? = nil,
callerPurpose: String? = nil,
retransmissionDigest: String? = nil,
display: Display? = nil
display: Display? = nil,
organizationCredentialIDs: [String]? = nil
) {
self.operationID = operationID
self.credentialID = credentialID
Expand All @@ -35,10 +38,11 @@ public struct BrokerApprovalOperationRequest: Equatable, Sendable {
self.callerName = callerName
self.callerPurpose = callerPurpose
self.display = display
self.organizationCredentialIDs = organizationCredentialIDs
}

// Preserve equality over exactly the pre-existing fields. Display changes
// must not affect submission, retransmission or consumption.
// Authorization fields, including the batch subject, bind retransmission
// and consumption. Display changes remain presentation-only.
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.operationID == rhs.operationID
&& lhs.credentialID == rhs.credentialID
Expand All @@ -49,6 +53,7 @@ public struct BrokerApprovalOperationRequest: Equatable, Sendable {
&& lhs.credentialName == rhs.credentialName
&& lhs.callerName == rhs.callerName
&& lhs.callerPurpose == rhs.callerPurpose
&& lhs.organizationCredentialIDs == rhs.organizationCredentialIDs
}

public struct Display: Equatable, Sendable {
Expand Down Expand Up @@ -89,6 +94,7 @@ extension BrokerApprovalOperationRequest {
&& credentialName == other.credentialName
&& callerName == other.callerName
&& callerPurpose == other.callerPurpose
&& organizationCredentialIDs == other.organizationCredentialIDs
&& retransmissionDigest == other.retransmissionDigest
&& constantTimeEqual(payloadDigest, other.payloadDigest)
}
Expand Down
17 changes: 15 additions & 2 deletions Sources/AskKeyBroker/BrokerApprovalStateMachine+RequestStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ extension BrokerApprovalStateMachine {
static func valid(_ request: BrokerApprovalOperationRequest) -> Bool {
let required = [
request.operationID,
request.credentialID,
request.targetID,
request.payloadDigest,
]
Expand All @@ -47,7 +46,7 @@ extension BrokerApprovalStateMachine {
} && [request.credentialName, request.callerName, request.callerPurpose]
.compactMap { $0 }
.allSatisfy { $0.utf8.count <= BrokerLimits.maximumFieldBytes }
&& (request.operation == .create || request.targetID == request.credentialID)
&& validSubject(request)
&& [request.retransmissionDigest].compactMap { $0 }.allSatisfy {
$0.utf8.count == 64 && $0.unicodeScalars.allSatisfy {
CharacterSet(charactersIn: "0123456789abcdefABCDEF").contains($0)
Expand All @@ -59,6 +58,20 @@ extension BrokerApprovalStateMachine {
}
}

private static func validSubject(_ request: BrokerApprovalOperationRequest) -> Bool {
if request.operation == .organize {
guard request.credentialID.isEmpty, request.targetID == "credential-library",
request.credentialName == nil, request.display == nil,
let ids = request.organizationCredentialIDs else { return false }
return ids == Array(Set(ids)).sorted() && ids.allSatisfy {
!$0.isEmpty && $0.utf8.count <= BrokerLimits.maximumFieldBytes
}
}
return request.organizationCredentialIDs == nil && !request.credentialID.isEmpty
&& request.credentialID.utf8.count <= BrokerLimits.maximumFieldBytes
&& (request.operation == .create || request.targetID == request.credentialID)
}

func makeRetentionRoom() throws {
guard entriesByOperationID.count >= BrokerLimits.maximumRetainedRequestStates else { return }
guard let index = operationOrder.firstIndex(where: {
Expand Down
Loading
Loading