Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift
T
abkslmandNucleic 16f704cc2c Read App Intents Documentation
Nucleic-Session: 56C5F781-4444-466B-91CE-A7734A8E117F
Co-authored-by: Nucleic <[email protected]>
2026-07-06 14:45:56 -07:00

139 lines
5.3 KiB
Swift

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)")
}
}