From e212f6ca1dcab711125d2fec17950424889adf43 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 11:40:56 -0700 Subject: [PATCH 1/5] Open the attachments connection the way Madrid opened chat.db The second SQLite connection that reads attachments assumed that the default database path and folder grants always open chat.db with its write-ahead log. When Madrid falls back to immutable mode because the log is unreadable, that connection still opened with mode=ro, and macOS refused it, so messages_fetch with attachments failed with "authorization denied". Take the mode from the database's accessMode. --- App/Services/Messages.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 01260d5d..e4f06988 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -443,8 +443,8 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } /// A second, read-only SQLite connection on the same grant, for the tables the iMessage - /// package does not model (attachments). `.file` grants cannot reach the write-ahead log, - /// hence `immutable=1` there, as the package itself does. + /// package does not model (attachments). It uses `immutable=1` whenever the package does: + /// a `.file` grant, or a default path whose write-ahead log is unreadable. private final class RawDatabase { private var handle: OpaquePointer? private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) @@ -615,11 +615,17 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { /// An open connection and the security scope it reads through. private struct DatabaseAccess { let database: iMessage.Database - /// Where `database` was opened, and whether without its write-ahead log. + /// Where `database` was opened. let path: String - let immutable: Bool fileprivate let scopedURL: URL? + /// Whether `database` was opened without its write-ahead log. + /// Other connections on the same file must open it the same way, + /// or the sandbox and privacy checks that made Madrid fall back refuse them. + var immutable: Bool { + database.accessMode == .immutable + } + /// Ends the security scope. Call it after the last read on `database`. func stop() { scopedURL?.stopAccessingSecurityScopedResource() @@ -631,7 +637,6 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { return DatabaseAccess( database: try iMessage.Database(), path: messagesDatabasePath, - immutable: false, scopedURL: nil ) } @@ -644,20 +649,16 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { do { let database: iMessage.Database - let immutable: Bool switch grant { case .directory: database = try iMessage.Database(path: grant.databaseURL.path, mode: .live) - immutable = false case .file: // Warned about once, in activate(offeringUpgrade:). database = try iMessage.Database(path: grant.databaseURL.path, mode: .immutable) - immutable = true } return DatabaseAccess( database: database, path: grant.databaseURL.path, - immutable: immutable, scopedURL: grant.url ) } catch { From f809c18e224acd0fde2f1ecf0466a19dac9c2c90 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 12:00:31 -0700 Subject: [PATCH 2/5] Prefer the Messages folder grant over the default path When chat.db is readable at its default path, for example through a permission left over from an earlier grant on chat.db alone, openDatabase used that path without the folder grant's security scope, so reading an attachment failed with "Operation not permitted". Use a folder grant first when one is stored. The Messages toggle now also offers the folder when chat.db is readable but the Attachments folder is not. --- App/Services/Messages.swift | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index e4f06988..c870bce3 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -32,13 +32,17 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { private func activate(offeringUpgrade: Bool) async throws { log.debug("Starting message service activation") - if canAccessDatabaseAtDefaultPath { + // chat.db can be readable at its default path while the attachments next to it are not, + // for example through a permission left over from an earlier grant on chat.db alone. + // The toggle then offers the folder; tool calls keep working with what is readable. + let canReadDefaultPath = canAccessDatabaseAtDefaultPath + if canReadDefaultPath, !offeringUpgrade || canAccessAttachmentsAtDefaultPath { log.debug("Successfully activated using default database path") return } let grant = try? resolveBookmarkedGrant() - var upgrading = false + var upgrading = canReadDefaultPath if canAccessDatabaseUsingBookmark { switch grant { case .directory: @@ -522,6 +526,13 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { return FileManager.default.isReadableFile(atPath: messagesDatabasePath) } + /// Whether the attachments folder can be listed without a grant, as with Full Disk Access. + private var canAccessAttachmentsAtDefaultPath: Bool { + let path = messagesDirectoryPath + "/Attachments" + guard FileManager.default.fileExists(atPath: path) else { return true } + return (try? FileManager.default.contentsOfDirectory(atPath: path)) != nil + } + private enum DatabaseAccessError: LocalizedError { case noBookmarkFound case securityScopeAccessFailed @@ -633,7 +644,16 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } private func openDatabase() throws -> DatabaseAccess { - if canAccessDatabaseAtDefaultPath { + // A granted Messages folder comes first: only its security scope is sure to reach + // the attachments, even when chat.db is also readable at its default path. + let bookmarkedGrant = try? resolveBookmarkedGrant() + let hasFolderGrant: Bool + if case .directory = bookmarkedGrant { + hasFolderGrant = true + } else { + hasFolderGrant = false + } + if !hasFolderGrant, canAccessDatabaseAtDefaultPath { return DatabaseAccess( database: try iMessage.Database(), path: messagesDatabasePath, @@ -641,7 +661,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { ) } - let grant = try resolveBookmarkedGrant() + guard let grant = bookmarkedGrant else { throw DatabaseAccessError.noBookmarkFound } guard grant.url.startAccessingSecurityScopedResource() else { log.error("Failed to start accessing security-scoped resource") throw DatabaseAccessError.securityScopeAccessFailed From 962e76ec655b7e71c834594e55c5ab8586bff9ab Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 17:48:57 -0700 Subject: [PATCH 3/5] Report Messages as not set up when its attachments are unreadable The menu toggle only runs activate() for a service that reports itself as not activated. Messages reported activated whenever chat.db was readable at its default path, so the toggle never offered the folder when the Attachments folder was unreadable. --- App/Services/Messages.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index c870bce3..d0d77e1e 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -93,7 +93,8 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { get async { // A grant on chat.db alone still serves tool calls, but the service is only fully set // up once the Messages folder is granted; until then the toggle offers the upgrade. - var isActivated = canAccessDatabaseAtDefaultPath + // The same goes for chat.db readable at its default path without the attachments. + var isActivated = canAccessDatabaseAtDefaultPath && canAccessAttachmentsAtDefaultPath if case .directory = try? resolveBookmarkedGrant() { isActivated = isActivated || canAccessDatabaseUsingBookmark } From 294669ef0dd47b4d8b8ec7853c08457a1c0dbb01 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 19:52:03 -0700 Subject: [PATCH 4/5] Revert "Report Messages as not set up when its attachments are unreadable" This reverts commit 962e76ec655b7e71c834594e55c5ab8586bff9ab. --- App/Services/Messages.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index d0d77e1e..c870bce3 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -93,8 +93,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { get async { // A grant on chat.db alone still serves tool calls, but the service is only fully set // up once the Messages folder is granted; until then the toggle offers the upgrade. - // The same goes for chat.db readable at its default path without the attachments. - var isActivated = canAccessDatabaseAtDefaultPath && canAccessAttachmentsAtDefaultPath + var isActivated = canAccessDatabaseAtDefaultPath if case .directory = try? resolveBookmarkedGrant() { isActivated = isActivated || canAccessDatabaseUsingBookmark } From a711c3455dc63fc8b6cf2740d324409d857e4848 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 19:52:03 -0700 Subject: [PATCH 5/5] Revert "Prefer the Messages folder grant over the default path" This reverts commit f809c18e224acd0fde2f1ecf0466a19dac9c2c90. --- App/Services/Messages.swift | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index c870bce3..e4f06988 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -32,17 +32,13 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { private func activate(offeringUpgrade: Bool) async throws { log.debug("Starting message service activation") - // chat.db can be readable at its default path while the attachments next to it are not, - // for example through a permission left over from an earlier grant on chat.db alone. - // The toggle then offers the folder; tool calls keep working with what is readable. - let canReadDefaultPath = canAccessDatabaseAtDefaultPath - if canReadDefaultPath, !offeringUpgrade || canAccessAttachmentsAtDefaultPath { + if canAccessDatabaseAtDefaultPath { log.debug("Successfully activated using default database path") return } let grant = try? resolveBookmarkedGrant() - var upgrading = canReadDefaultPath + var upgrading = false if canAccessDatabaseUsingBookmark { switch grant { case .directory: @@ -526,13 +522,6 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { return FileManager.default.isReadableFile(atPath: messagesDatabasePath) } - /// Whether the attachments folder can be listed without a grant, as with Full Disk Access. - private var canAccessAttachmentsAtDefaultPath: Bool { - let path = messagesDirectoryPath + "/Attachments" - guard FileManager.default.fileExists(atPath: path) else { return true } - return (try? FileManager.default.contentsOfDirectory(atPath: path)) != nil - } - private enum DatabaseAccessError: LocalizedError { case noBookmarkFound case securityScopeAccessFailed @@ -644,16 +633,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } private func openDatabase() throws -> DatabaseAccess { - // A granted Messages folder comes first: only its security scope is sure to reach - // the attachments, even when chat.db is also readable at its default path. - let bookmarkedGrant = try? resolveBookmarkedGrant() - let hasFolderGrant: Bool - if case .directory = bookmarkedGrant { - hasFolderGrant = true - } else { - hasFolderGrant = false - } - if !hasFolderGrant, canAccessDatabaseAtDefaultPath { + if canAccessDatabaseAtDefaultPath { return DatabaseAccess( database: try iMessage.Database(), path: messagesDatabasePath, @@ -661,7 +641,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { ) } - guard let grant = bookmarkedGrant else { throw DatabaseAccessError.noBookmarkFound } + let grant = try resolveBookmarkedGrant() guard grant.url.startAccessingSecurityScopedResource() else { log.error("Failed to start accessing security-scoped resource") throw DatabaseAccessError.securityScopeAccessFailed