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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Every database operation TablePro authorizes is written to a local execution log, including the ones the AI assistant and MCP clients ask for, with the statement stored as a digest rather than as text. Records are hash chained, so an edited, reordered or removed entry can be detected. The log stays on the Mac and is not synced.
- An administrator can set a minimum Safe Mode level for every connection through a macOS configuration profile, so a managed Mac cannot be dropped below it. A connection set stricter keeps its own level, since the policy is a floor rather than a ceiling. The control shows as managed instead of editable.
- Plugins signed by other developers can be installed. TablePro used to refuse any plugin bundle it had not signed itself, so the only way to publish a driver was through the TablePro repository. A bundle signed with a Developer ID and notarized by Apple now installs after you agree to trust that developer by name, and the prompt says plainly that a database plugin runs as part of TablePro and can read the credentials of every connection you open. Trust is recorded per developer rather than per plugin, so their updates install without asking again, and you can withdraw it. Unsigned and ad-hoc signed bundles are still refused.
- `Cmd+F` on a table tab opens a find bar over the results. Type a term and the matching cell is highlighted and scrolled to; `Return` and `Cmd+G` step forward, `Cmd+Shift+G` steps back, `Escape` clears the term and then closes the bar. Matching ignores case and accents and runs over the text as displayed, skipping binary and spatial columns. The counter always says what it searched, reading "3 of 12 on this page" while rows remain unfetched and "3 of 12" once everything is loaded, so a result is never mistaken for an answer about the whole table. When nothing matches on the page and more rows exist, Search All Rows turns the term into a server-side filter.
Expand Down
13 changes: 12 additions & 1 deletion TablePro/Core/Services/Execution/DefaultExecutionGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,31 @@ internal actor DefaultExecutionGate: ExecutionGate {
private let authenticating: OperationAuthenticating
private let safeModeLevelResolver: @Sendable (UUID) async -> SafeModeLevel
private let forcesWriteResolver: @Sendable (DatabaseType) async -> Bool
private let auditLog: any ExecutionAuditLogging

init(
confirming: OperationConfirming,
authenticating: OperationAuthenticating,
safeModeLevelResolver: @escaping @Sendable (UUID) async -> SafeModeLevel,
forcesWriteResolver: @escaping @Sendable (DatabaseType) async -> Bool
forcesWriteResolver: @escaping @Sendable (DatabaseType) async -> Bool,
auditLog: any ExecutionAuditLogging = ExecutionAuditLog.shared
) {
self.confirming = confirming
self.authenticating = authenticating
self.safeModeLevelResolver = safeModeLevelResolver
self.forcesWriteResolver = forcesWriteResolver
self.auditLog = auditLog
}

/// A thin wrapper so every outcome is recorded once. `decide` has seven return points, and a
/// log call at each is one `return` away from a gap the next change opens silently.
func authorize(_ request: OperationRequest) async -> OperationDecision {
let decision = await decide(request)
await auditLog.record(request: request, decision: decision)
return decision
}

private func decide(_ request: OperationRequest) async -> OperationDecision {
let level = await safeModeLevelResolver(request.connectionId)
let caps = request.capabilities

Expand Down
126 changes: 126 additions & 0 deletions TablePro/Core/Services/Execution/ExecutionAuditLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//
// ExecutionAuditLog.swift
// TablePro
//

import Foundation
import os

internal protocol ExecutionAuditLogging: Sendable {
func record(request: OperationRequest, decision: OperationDecision) async
func entries() async -> [ExecutionAuditRecord]
func verify() async -> ExecutionAuditVerification
}

internal enum ExecutionAuditVerification: Equatable, Sendable {
case intact(count: Int)
case broken(atSequence: Int)
}

/// Append-only log of every authorization decision, written from inside `ExecutionGate`.
///
/// An actor rather than a lock: the gate is called from many tasks, and the sequence number and the
/// previous hash have to be read and advanced together or two concurrent decisions produce two
/// records claiming the same position.
internal actor ExecutionAuditLog: ExecutionAuditLogging {
internal static let shared = ExecutionAuditLog()

private static let logger = Logger(subsystem: "com.TablePro", category: "ExecutionAudit")

private let fileURL: URL?
private var records: [ExecutionAuditRecord] = []
private var loaded = false

internal init(fileURL: URL? = ExecutionAuditLog.defaultFileURL()) {
self.fileURL = fileURL
}

internal static func defaultFileURL() -> URL? {
guard let support = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
let directory = support.appendingPathComponent("TablePro", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent("ExecutionAudit.json")
}

internal func record(request: OperationRequest, decision: OperationDecision) async {
load()

let outcome: ExecutionAuditRecord.Outcome
let effectiveWrite: Bool
switch decision {
case .authorized(let receipt):
outcome = .authorized
effectiveWrite = receipt.effectiveWrite
case .denied:
outcome = .denied
effectiveWrite = false
}

let record = ExecutionAuditRecord(
sequence: records.count,
recordedAt: Date(),
connectionId: request.connectionId,
kind: String(describing: request.kind),
caller: Self.describe(request.caller),
outcome: outcome,
effectiveWrite: effectiveWrite,
statementDigest: request.sql.map(ExecutionAuditRecord.sha256),
previousHash: records.last?.hash ?? ExecutionAuditRecord.genesisHash
)
records.append(record)
persist()
}

/// A label, not the payload. An MCP client's label and an AI session id are caller-supplied and
/// can carry anything, so only the channel is recorded.
private static func describe(_ caller: OperationCaller) -> String {
switch caller {
case .userInterface: "userInterface"
case .mcpClient: "mcpClient"
case .aiAssistant: "aiAssistant"
case .importPipeline: "importPipeline"
case .backgroundMaintenance: "backgroundMaintenance"
}
}

internal func entries() -> [ExecutionAuditRecord] {
load()
return records
}

internal func verify() -> ExecutionAuditVerification {
load()
if let broken = ExecutionAuditRecord.firstBrokenSequence(in: records) {
return .broken(atSequence: broken)
}
return .intact(count: records.count)
}

private func load() {
guard !loaded else { return }
loaded = true
guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return }
do {
records = try JSONDecoder().decode([ExecutionAuditRecord].self, from: data)
} catch {
// A log that cannot be read is itself a finding, so it is reported rather than
// silently replaced. Starting a fresh chain over it would destroy the evidence.
Self.logger.error("Execution audit log could not be decoded: \(error.localizedDescription)")
}
}

private func persist() {
guard let fileURL else { return }
do {
let data = try JSONEncoder().encode(records)
try data.write(to: fileURL, options: .atomic)
} catch {
Self.logger.error("Execution audit log could not be written: \(error.localizedDescription)")
}
}
}
132 changes: 132 additions & 0 deletions TablePro/Core/Services/Execution/ExecutionAuditRecord.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// ExecutionAuditRecord.swift
// TablePro
//

import CryptoKit
import Foundation

/// One authorization decision, chained to the one before it.
///
/// Every caller that touches a database goes through `ExecutionGate.authorize`, including the AI
/// assistant and the MCP tools, so a record per decision is a complete account of what the app was
/// asked to do and what it allowed.
///
/// `previousHash` makes the sequence self-describing: recomputing the chain shows whether any record
/// was edited, reordered or removed. That is tamper *evident*, not tamper proof. Anyone who can
/// write the file can also rewrite every hash after the record they changed. It raises silent edits
/// into visible ones; it does not make them impossible. Only an append-only store the user does not
/// control would do that, and TablePro runs no server.
internal struct ExecutionAuditRecord: Codable, Equatable, Sendable {
internal let sequence: Int
internal let recordedAt: Date
internal let connectionId: UUID
internal let kind: String
/// Which subsystem asked. The distinction between a person clicking Save, an MCP client and the
/// AI assistant is the first thing anyone reading an audit trail wants, and it is the reason
/// this log is worth keeping at all.
internal let caller: String
internal let outcome: Outcome
internal let effectiveWrite: Bool
/// A digest, never the statement. A query holds customer data, and an audit trail that stores it
/// turns a compliance feature into a second copy of the database.
internal let statementDigest: String?
internal let previousHash: String
internal let hash: String

internal enum Outcome: String, Codable, Sendable {
case authorized
case denied
}

internal static let genesisHash = String(repeating: "0", count: 64)

internal init(
sequence: Int,
recordedAt: Date,
connectionId: UUID,
kind: String,
caller: String,
outcome: Outcome,
effectiveWrite: Bool,
statementDigest: String?,
previousHash: String
) {
self.sequence = sequence
self.recordedAt = recordedAt
self.connectionId = connectionId
self.kind = kind
self.caller = caller
self.outcome = outcome
self.effectiveWrite = effectiveWrite
self.statementDigest = statementDigest
self.previousHash = previousHash
hash = Self.digest(
sequence: sequence,
recordedAt: recordedAt,
connectionId: connectionId,
kind: kind,
caller: caller,
outcome: outcome,
effectiveWrite: effectiveWrite,
statementDigest: statementDigest,
previousHash: previousHash
)
}

/// Every field except `hash` itself feeds the digest, joined by a separator that cannot appear
/// in any of them. Concatenating without one lets two different records collide.
internal static func digest(
sequence: Int,
recordedAt: Date,
connectionId: UUID,
kind: String,
caller: String,
outcome: Outcome,
effectiveWrite: Bool,
statementDigest: String?,
previousHash: String
) -> String {
let fields = [
String(sequence),
String(recordedAt.timeIntervalSince1970),
connectionId.uuidString,
kind,
caller,
outcome.rawValue,
String(effectiveWrite),
statementDigest ?? "",
previousHash,
]
return sha256(fields.joined(separator: "\u{1F}"))
}

internal static func sha256(_ value: String) -> String {
SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined()
}

/// Recomputes the chain and reports the first record that does not agree with it.
internal static func firstBrokenSequence(in records: [ExecutionAuditRecord]) -> Int? {
var expectedPrevious = genesisHash
for (index, record) in records.enumerated() {
let expectedHash = digest(
sequence: record.sequence,
recordedAt: record.recordedAt,
connectionId: record.connectionId,
kind: record.kind,
caller: record.caller,
outcome: record.outcome,
effectiveWrite: record.effectiveWrite,
statementDigest: record.statementDigest,
previousHash: record.previousHash
)
if record.previousHash != expectedPrevious
|| record.hash != expectedHash
|| record.sequence != index {
return record.sequence
}
expectedPrevious = record.hash
}
return nil
}
}
Loading
Loading