diff --git a/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift b/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift new file mode 100644 index 0000000..e1beebf --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift @@ -0,0 +1,138 @@ +import AppIntents +import NucleicProtocol + +// MARK: - Decision option (§6 enums) + +/// The decision an approval intent can carry, mapped from the wire `Decision` (`Approval.swift`). +/// The `allowAlways` cases are the safe subset (session / this-tool); pattern scopes and any allow +/// on a destructive request are handled by the risk gate, not offered here. +enum ApprovalDecisionOption: String, AppEnum { + case allow + case deny + case allowAlwaysSession + case allowAlwaysTool + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Decision") + static let caseDisplayRepresentations: [ApprovalDecisionOption: DisplayRepresentation] = [ + .allow: "Allow", + .deny: "Deny", + .allowAlwaysSession: "Always Allow (this session)", + .allowAlwaysTool: "Always Allow (this tool)", + ] + + /// Whether this decision grants the request (everything but `deny`) — the half the risk gate + /// forbids inline on a high-risk approval. + var isAllow: Bool { self != .deny } + + /// The wire `Decision` this option resolves to. + func decision() -> Decision { + switch self { + case .allow: .allow(updatedInput: nil) + case .deny: .deny(reason: nil) + case .allowAlwaysSession: .allowAlways(.session) + case .allowAlwaysTool: .allowAlways(.toolName) + } + } +} + +// MARK: - Approval entity (§4.6) + +/// A pending approval exposed to Shortcuts/Siri. Backed by `RemoteStore.openApprovals`, which the +/// phone holds in full (id, risk, title) only for the *subscribed* session — so today this surfaces +/// the approvals of the session you're looking at. (A global pending-approvals feed would need the +/// host to carry the top approval's id/risk on the wire summary; see §4.1 / the Live Activity note.) +struct ApprovalEntity: AppEntity, Identifiable { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Approval") + static let defaultQuery = ApprovalEntityQuery() + + /// `ApprovalID.rawValue`. + var id: String + /// `SessionID.rawValue` of the owning session — routes the decision to the right Mac. + var sessionID: String + var toolName: String + var requestTitle: String + /// `Risk.rawValue`, kept for display; the gate uses `isHighRisk`. + var riskLabel: String + /// Destructive / network / host-exec — never allowed inline (§3.3). + var isHighRisk: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(toolName): \(requestTitle)", + subtitle: "\(riskLabel)") + } +} + +extension ApprovalEntity { + init(_ r: ApprovalRequest) { + self.init( + id: r.id.rawValue, + sessionID: r.sessionID.rawValue, + toolName: r.toolName, + requestTitle: r.title, + riskLabel: r.risk.label, + isHighRisk: r.risk.isHigh) + } +} + +struct ApprovalEntityQuery: EntityQuery { + @MainActor + func entities(for identifiers: [ApprovalEntity.ID]) async throws -> [ApprovalEntity] { + let wanted = Set(identifiers) + return RemoteStore.shared.openApprovals + .filter { wanted.contains($0.id.rawValue) } + .map(ApprovalEntity.init) + } + + @MainActor + func suggestedEntities() async throws -> [ApprovalEntity] { + RemoteStore.shared.openApprovals.map(ApprovalEntity.init) + } +} + +// MARK: - Answer an approval (§4.1, the flagship) + +/// Allow or deny what an agent is asking to do. The risk gate (§3.3) is enforced here so an intent +/// can never be a softer approval path than the UI: a high-risk request (destructive / network / +/// host-exec) is never granted inline — it routes to the app's guarded card. Deny always goes +/// straight through. "Already resolved elsewhere" is a friendly no-op (§3.4), not an error, because +/// the host de-dupes a lost first-responder race. +struct AnswerApprovalIntent: AppIntent { + static let title: LocalizedStringResource = "Answer Approval" + static let description = IntentDescription( + "Allow or deny what a Nucleic agent is asking to do.") + + @Parameter(title: "Approval") + var approval: ApprovalEntity + + @Parameter(title: "Decision", default: .allow) + var decision: ApprovalDecisionOption + + init() {} + init(approval: ApprovalEntity, decision: ApprovalDecisionOption) { + self.approval = approval + self.decision = decision + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + let store = RemoteStore.shared + guard store.isPaired else { throw IntentError.notPaired } + let approvalID = ApprovalID(rawValue: approval.id) + let sessionID = SessionID(rawValue: approval.sessionID) + + // §3.3 — high-risk can't be granted inline; route to the app's biometric-gated card. + if approval.isHighRisk, decision.isAllow { + store.route(to: sessionID) + throw IntentError.needsAppConfirmation + } + + guard await store.awaitLiveConnection() else { throw IntentError.macUnreachable } + store.respondToApproval(id: approvalID, sessionID: sessionID, decision: decision.decision()) + return .result(dialog: decision.isAllow ? "Allowed." : "Denied.") + } + + static var parameterSummary: some ParameterSummary { + Summary("\(\.$decision) \(\.$approval)") + } +}