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
3 changes: 3 additions & 0 deletions apple/TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)

RecentConnectionsRecorder.shared.start()
if #available(macOS 14.0, *) {
ShortcutsParameterRefresher.shared.start()
}

LaunchTracer.shared.mark(.didFinishLaunchingEnded)
AppLaunchCoordinator.shared.didFinishLaunching()
Expand Down
27 changes: 3 additions & 24 deletions apple/TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -737,34 +737,13 @@ final class DataChangeManager: ObservableObject, ChangeManaging {
insertedRowIDs: Set<RowID> = [],
factory: RowChangeStatementFactory? = nil
) throws -> [DataWriteStep] {
switch try (factory ?? statementFactory()).rowWriteStatements(
let statements = try (factory ?? statementFactory()).rowWriteStatements(
for: changes,
insertedRowData: insertedRowData,
deletedRowIDs: deletedRowIDs,
insertedRowIDs: insertedRowIDs
) {
case .counted(let attributed):
return attributed.map {
DataWriteStep(
kind: .rowWrite,
statement: $0.statement,
expectedRowCount: $0.rowCount,
tableName: tableName,
matchesRowsWithoutKey: primaryKeyColumns.isEmpty && $0.kind != .insert,
refusesSharedKey: $0.refusesSharedKey
)
}
case .driverWritten(let statements):
return statements.map {
DataWriteStep(
kind: .rowWrite,
statement: $0.statement,
expectedRowCount: $0.keylessRowCount,
tableName: tableName,
matchesRowsWithoutKey: $0.keylessRowCount != nil
)
}
}
)
return DataWriteStep.rowWrites(statements, tableName: tableName, primaryKeyColumns: primaryKeyColumns)
}

func statementFactory() throws -> RowChangeStatementFactory {
Expand Down
32 changes: 32 additions & 0 deletions apple/TablePro/Core/DataWrite/DataWritePlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,38 @@ struct DataWriteStep: Sendable {
}
}

extension DataWriteStep {
static func rowWrites(
_ statements: RowWriteStatements,
tableName: String,
primaryKeyColumns: [String]
) -> [DataWriteStep] {
switch statements {
case .counted(let attributed):
return attributed.map {
DataWriteStep(
kind: .rowWrite,
statement: $0.statement,
expectedRowCount: $0.rowCount,
tableName: tableName,
matchesRowsWithoutKey: primaryKeyColumns.isEmpty && $0.kind != .insert,
refusesSharedKey: $0.refusesSharedKey
)
}
case .driverWritten(let statements):
return statements.map {
DataWriteStep(
kind: .rowWrite,
statement: $0.statement,
expectedRowCount: $0.keylessRowCount,
tableName: tableName,
matchesRowsWithoutKey: $0.keylessRowCount != nil
)
}
}
}
}

/// The statements a change set produces, beside the record of the rows behind them.
struct RowWriteBuild: Sendable {
let steps: [DataWriteStep]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal final class ScriptRunQueryCommand: ScriptCommand {
schema: optionalString(ScriptingKeys.Parameter.schema),
rowLimit: optionalInt(ScriptingKeys.Parameter.rowLimit),
timeoutSeconds: optionalInt(ScriptingKeys.Parameter.timeout),
client: sendingApplication
channel: .appleScript(client: sendingApplication)
)

return ScriptResultEncoder.encode(try await ScriptQueryRunner.run(request, bridge: bridge))
Expand Down
89 changes: 66 additions & 23 deletions apple/TablePro/Core/Scripting/ScriptQueryRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,8 @@

import Foundation

/// Runs one statement for a script, or on SQL Server a whole script, with every gate a script has to clear.
///
/// A script is an external caller with no token, so the gates it clears are the connection's own:
/// **External Clients** decides whether it may write at all, and Safe Mode decides whether a person
/// has to see the statement first. Both are applied by `ExternalStatementGate`, which is the same
/// code the MCP tools run, so the two surfaces cannot drift apart on what a connection allows.
///
/// The confirmation names the app that sent the Apple event, taken from the sender pid the kernel
/// stamps on it. A person approving a `DELETE` deserves to know whether it came from a Shortcut they
/// just ran or from something they forgot was open.
// One statement, or on SQL Server a whole script, from AppleScript or a Shortcuts action. Both clear the
// connection's own gates through `ExternalStatementGate`, the code MCP runs: External Clients, then Safe Mode.
internal enum ScriptQueryRunner {
/// Deliberately not the MCP settings. That row cap and that timeout belong to the MCP server's
/// own configuration, and reading them here would make a script's behaviour change when the user
Expand All @@ -24,14 +16,53 @@ internal enum ScriptQueryRunner {
internal static let defaultTimeoutSeconds = 30
internal static let maximumTimeoutSeconds = 600

internal enum Channel: Sendable, Equatable {
case appleScript(client: String?)
case shortcuts

internal var caller: OperationCaller {
switch self {
case .appleScript(let client): .appleScript(client: client)
case .shortcuts: .shortcuts
}
}

internal var historySource: QueryHistorySource {
switch self {
case .appleScript: .script
case .shortcuts: .shortcuts
}
}

// AppleScript cannot address a Blocked connection, so it hears that none exists. A shortcut picked the
// connection from a list before it was blocked, so it hears why it stopped.
internal func refusal(for access: ExternalAccessLevel?) -> ScriptingError {
switch (self, access) {
case (.shortcuts, .some):
return .refused(String(localized: "External access is disabled for this connection"))
case (.shortcuts, nil):
return .noSuchObject(String(localized: "That connection no longer exists in TablePro."))
case (.appleScript, _):
return .noSuchObject(String(localized: "No saved connection has that id."))
}
}

fileprivate var clientName: String? {
switch self {
case .appleScript(let client): client
case .shortcuts: String(localized: "Shortcuts")
}
}
}

internal struct Request: Sendable {
internal let sql: String
internal let connectionId: UUID
internal let database: String?
internal let schema: String?
internal let rowLimit: Int?
internal let timeoutSeconds: Int?
internal let client: String?
internal let channel: Channel
}

/// What a script may ask of the execution gate: to write, and to drop once a person confirms it. It is never
Expand All @@ -44,17 +75,11 @@ internal enum ScriptQueryRunner {
internal static func run(
_ request: Request,
bridge: DatabaseAccessBridge,
history: QueryHistoryRecording = QueryHistoryManager.shared
history: QueryHistoryRecording = QueryHistoryManager.shared,
gate: any ExecutionGate = ExecutionGateProvider.shared
) async throws -> DatabaseAccessBridge.ScriptOutcome {
let snapshot = try await MainActor.run { () throws -> ExternalConnectionPolicySnapshot in
/// Asked here as well as at the object model, so the rule holds even if some later
/// command hands this a connection id it did not resolve through `connections()`.
guard ScriptingSnapshot.isVisibleToScripts(connectionId: request.connectionId) else {
throw ScriptingError.noSuchObject(
String(localized: "No saved connection has that id.")
)
}
return try ExternalConnectionPolicySnapshot.resolve(connectionId: request.connectionId)
try visibleSnapshot(connectionId: request.connectionId, channel: request.channel)
}

let capabilities = Self.capabilities(on: snapshot.databaseType)
Expand Down Expand Up @@ -86,9 +111,13 @@ internal enum ScriptQueryRunner {
sql: request.sql,
connectionId: request.connectionId,
databaseType: snapshot.databaseType,
caller: .appleScript(client: request.client),
caller: request.channel.caller,
capabilities: capabilities,
operationDescription: confirmationTitle(client: request.client, connection: snapshot.connectionName)
operationDescription: confirmationTitle(
client: request.channel.clientName,
connection: snapshot.connectionName
),
gate: gate
)

let rowLimit = (request.rowLimit ?? defaultRowLimit).clamped(to: 1...maximumRowLimit)
Expand Down Expand Up @@ -146,6 +175,20 @@ internal enum ScriptQueryRunner {
}
}

// Asked here as well as at each caller's own listing, so the rule holds for a connection id that did not come
// from one.
@MainActor
internal static func visibleSnapshot(
connectionId: UUID,
channel: Channel
) throws -> ExternalConnectionPolicySnapshot {
guard ScriptingSnapshot.isVisibleToScripts(connectionId: connectionId) else {
let stored = ConnectionStorage.shared.loadConnections().first { $0.id == connectionId }
throw channel.refusal(for: stored?.externalAccess)
}
return try ExternalConnectionPolicySnapshot.resolve(connectionId: connectionId)
}

private static func confirmationTitle(client: String?, connection: String) -> String {
guard let client, !client.isEmpty else {
return String(format: String(localized: "A script wants to run a query on \"%@\""), connection)
Expand Down Expand Up @@ -175,7 +218,7 @@ internal enum ScriptQueryRunner {
databaseName: scope.database,
databaseType: databaseType,
schemaName: scope.schema,
source: .script,
source: request.channel.historySource,
executionTime: elapsed,
rowCount: rowCount,
wasSuccessful: error == nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ internal actor ExecutionAuditLog: ExecutionAuditLogging {
case .mcpClient: "mcpClient"
case .aiAssistant: "aiAssistant"
case .appleScript: "appleScript"
case .shortcuts: "shortcuts"
case .importPipeline: "importPipeline"
case .backgroundMaintenance: "backgroundMaintenance"
}
Expand Down
18 changes: 13 additions & 5 deletions apple/TablePro/Core/Services/Execution/ExternalStatementGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,8 @@ internal enum ExternalStatementGate {
)
}

if classification.tier != .safe, statement.externalAccess != .readWrite {
throw ExternalStatementGateError.denied(
String(localized: "This connection is read only for external clients.")
)
if classification.tier != .safe {
try refuseWrite(externalAccess: statement.externalAccess)
}

if classification.tier == .destructive, !statement.allowsDestructive {
Expand All @@ -110,6 +108,15 @@ internal enum ExternalStatementGate {
return classification
}

// For a caller that writes by construction, such as adding rows, whose statements a classifier may not read as
// writes on every engine.
internal static func refuseWrite(externalAccess: ExternalAccessLevel) throws {
guard externalAccess != .readWrite else { return }
throw ExternalStatementGateError.denied(
String(localized: "This connection is read only for external clients.")
)
}

/// Whether a caller that takes scripts may send several statements in one call to this engine.
///
/// Only where a script is cut into batches at `GO` lines, as on SQL Server. There the server runs whatever one
Expand Down Expand Up @@ -156,14 +163,15 @@ internal enum ExternalStatementGate {
caller: OperationCaller,
capabilities: CallerCapabilities,
operationDescription: String,
kind: OperationKind? = nil,
gate: any ExecutionGate = ExecutionGateProvider.shared
) async throws {
let decision = await gate.authorize(
OperationRequest(
connectionId: connectionId,
databaseType: databaseType,
sql: sql,
kind: OperationKind.from(QueryClassifier.classifyTier(sql, databaseType: databaseType)),
kind: kind ?? OperationKind.from(QueryClassifier.classifyTier(sql, databaseType: databaseType)),
caller: caller,
capabilities: capabilities,
operationDescription: operationDescription
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal enum OperationCaller: Sendable, Equatable {
/// An Apple event from another app. The name is the sending application's, taken from the pid
/// the kernel stamps on the event rather than from anything the script says about itself.
case appleScript(client: String?)
case shortcuts
case importPipeline
case backgroundMaintenance
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ internal enum OperationConfirmationPrompt {
return String(localized: "The AI assistant")
case .appleScript(let client):
return client ?? String(localized: "Another app")
case .shortcuts:
return String(localized: "Shortcuts")
}
}
}
Expand Down
Loading
Loading