From 567f16891013c950e311bdad3e579488ce1233b4 Mon Sep 17 00:00:00 2001 From: euforic <395386+euforic@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:39:52 -0700 Subject: [PATCH] Read the Claude active-organization cookie in process The lastActiveOrg lookup was the last provider-database sqlite3 launch on the refresh path. It now uses SQLiteReadOnly.query, which gains an `under:` root forwarded to databaseArgument so the profile-root containment check is kept. The query returns host_key, the plain value, and the encrypted value as typed text and blob columns instead of a hex-and-pipe string; decryption, host-hash and UUID validation are unchanged, as are the 2 s timeout and 4 KiB budget, now with a one-row limit. Any query failure still yields no organization. Co-Authored-By: Claude Opus 5.5 --- Sources/AgentDockCore/ClaudeUsageClient.swift | 57 ++----- Sources/AgentDockCore/SQLiteReadOnly.swift | 4 +- .../ClaudeCredentialReaderTests.swift | 150 ++++++++++++++++++ .../SQLiteReadOnlyTests.swift | 42 +++++ docs/resource-usage-readers.md | 6 + docs/security.md | 3 +- 6 files changed, 218 insertions(+), 44 deletions(-) diff --git a/Sources/AgentDockCore/ClaudeUsageClient.swift b/Sources/AgentDockCore/ClaudeUsageClient.swift index 418b0a6..02ce65a 100644 --- a/Sources/AgentDockCore/ClaudeUsageClient.swift +++ b/Sources/AgentDockCore/ClaudeUsageClient.swift @@ -572,45 +572,34 @@ struct ClaudeCredentialReader { cache.keys.compactMap { desktopCacheMetadata($0)?.organization } } - private func activeOrganization(userDataURL: URL, key: Data) -> String? { + func activeOrganization(userDataURL: URL, key: Data) -> String? { let cookieURLs = [ userDataURL.appendingPathComponent("Cookies"), userDataURL.appendingPathComponent("Network/Cookies") ] + // A non-empty plain value wins; otherwise the encrypted value is used. let query = """ - SELECT host_key || '|' || CASE - WHEN length(value) > 0 THEN 'plain:' || hex(CAST(value AS BLOB)) - ELSE 'encrypted:' || hex(encrypted_value) - END + SELECT host_key, CAST(value AS TEXT), CAST(encrypted_value AS BLOB) FROM cookies WHERE name = 'lastActiveOrg' AND host_key IN ('.claude.ai', 'claude.ai') ORDER BY last_update_utc DESC LIMIT 1; """ for cookieURL in cookieURLs where FileManager.default.fileExists(atPath: cookieURL.path) { - guard let databaseArgument = try? SQLiteReadOnly.databaseArgument( - for: cookieURL, - under: userDataURL - ), - let result = try? BoundedSubprocess.run( - executableURL: URL(fileURLWithPath: "/usr/bin/sqlite3"), - arguments: ["-nofollow", "-readonly", databaseArgument, query], + guard let row = (try? SQLiteReadOnly.query( + database: cookieURL, + under: userDataURL, + sql: query, timeout: 2, - maximumOutputBytes: 4_096 - ), result.terminationStatus == 0, !result.exceededOutputLimit, - let line = String(data: result.output, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - let hostSeparator = line.firstIndex(of: "|") + maximumOutputBytes: 4_096, + maximumRows: 1 + ))?.first, + row.count == 3, + case let .text(host) = row[0] else { continue } - let host = String(line[.. [[SQLiteValue]] { guard !Task.isCancelled else { throw SQLiteReadOnlyError.cancelled } - let filename = try databaseArgument(for: database) + let filename = try databaseArgument(for: database, under: root) let interruption = SQLiteInterruption(timeout: timeout) var openedConnection: OpaquePointer? diff --git a/Tests/AgentDockCoreTests/ClaudeCredentialReaderTests.swift b/Tests/AgentDockCoreTests/ClaudeCredentialReaderTests.swift index dba6251..fb06709 100644 --- a/Tests/AgentDockCoreTests/ClaudeCredentialReaderTests.swift +++ b/Tests/AgentDockCoreTests/ClaudeCredentialReaderTests.swift @@ -1,5 +1,8 @@ +import CommonCrypto +import CryptoKit import Foundation import Security +import SQLite3 import XCTest @testable import AgentDockCore @@ -77,4 +80,151 @@ final class ClaudeCredentialReaderTests: XCTestCase { userDataURL: managed, allowKeychainInteraction: false )) } + + func testActiveOrganizationReadsNewestPlainCookie() throws { + let profile = try makeDirectory() + try makeCookies(at: profile.appendingPathComponent("Cookies"), rows: [ + ("claude.ai", "lastActiveOrg", "44444444-4444-4444-8444-444444444444", Data(), 1), + (".claude.ai", "lastActiveOrg", Self.organization.uppercased(), Data(), 2), + ("claude.ai.example", "lastActiveOrg", "55555555-5555-4555-8555-555555555555", Data(), 3), + (".claude.ai", "lastActiveProject", "66666666-6666-4666-8666-666666666666", Data(), 4) + ]) + + XCTAssertEqual( + ClaudeCredentialReader().activeOrganization(userDataURL: profile, key: Self.cookieKey), + Self.organization + ) + } + + func testActiveOrganizationDecryptsHostBoundCookie() throws { + let profile = try makeDirectory() + let network = profile.appendingPathComponent("Network", isDirectory: true) + try FileManager.default.createDirectory(at: network, withIntermediateDirectories: true) + try makeCookies(at: network.appendingPathComponent("Cookies"), rows: [ + (".claude.ai", "lastActiveOrg", "", encryptCookie(Self.organization, host: ".claude.ai"), 1) + ]) + XCTAssertEqual( + ClaudeCredentialReader().activeOrganization(userDataURL: profile, key: Self.cookieKey), + Self.organization + ) + + // A value bound to a different host fails validation. + let otherProfile = try makeDirectory() + try makeCookies(at: otherProfile.appendingPathComponent("Cookies"), rows: [ + (".claude.ai", "lastActiveOrg", "", encryptCookie(Self.organization, host: "claude.ai"), 1) + ]) + XCTAssertNil(ClaudeCredentialReader().activeOrganization( + userDataURL: otherProfile, key: Self.cookieKey + )) + } + + func testActiveOrganizationRejectsCookiesLinkedFromAnotherProfile() throws { + let profile = try makeDirectory() + let otherProfile = try makeDirectory() + try makeCookies(at: otherProfile.appendingPathComponent("Cookies"), rows: [ + (".claude.ai", "lastActiveOrg", Self.organization, Data(), 1) + ]) + let network = profile.appendingPathComponent("Network", isDirectory: true) + try FileManager.default.createSymbolicLink(at: network, withDestinationURL: otherProfile) + XCTAssertTrue(FileManager.default.fileExists(atPath: network.appendingPathComponent("Cookies").path)) + + XCTAssertNil(ClaudeCredentialReader().activeOrganization(userDataURL: profile, key: Self.cookieKey)) + XCTAssertEqual( + ClaudeCredentialReader().activeOrganization(userDataURL: otherProfile, key: Self.cookieKey), + Self.organization + ) + } + + func testActiveOrganizationSkipsDatabaseWithoutCookiesTable() throws { + let profile = try makeDirectory() + try makeDatabase(at: profile.appendingPathComponent("Cookies"), sql: """ + create table meta (key text, value text); + insert into meta values ('version', '24'); + """) + XCTAssertNil(ClaudeCredentialReader().activeOrganization(userDataURL: profile, key: Self.cookieKey)) + + // The failure is limited to that database; the next location is still read. + let network = profile.appendingPathComponent("Network", isDirectory: true) + try FileManager.default.createDirectory(at: network, withIntermediateDirectories: true) + try makeCookies(at: network.appendingPathComponent("Cookies"), rows: [ + (".claude.ai", "lastActiveOrg", Self.organization, Data(), 1) + ]) + XCTAssertEqual( + ClaudeCredentialReader().activeOrganization(userDataURL: profile, key: Self.cookieKey), + Self.organization + ) + } + + private static let organization = "33333333-3333-4333-8333-333333333333" + // A synthetic AES-128 key passed directly; Keychain derivation is covered above. + private static let cookieKey = Data(repeating: 0x5A, count: kCCKeySizeAES128) + + private func makeDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + return directory + } + + private func makeCookies( + at database: URL, + rows: [(host: String, name: String, value: String, encrypted: Data, updated: Int)] + ) throws { + let values = rows.map { row in + let encrypted = row.encrypted.map { String(format: "%02x", $0) }.joined() + return "(0, '\(row.host)', '\(row.name)', '\(row.value)', x'\(encrypted)', \(row.updated))" + } + try makeDatabase(at: database, sql: """ + create table cookies ( + creation_utc integer not null, host_key text not null, name text not null, + value text not null, encrypted_value blob not null, last_update_utc integer not null + ); + insert into cookies values \(values.joined(separator: ", ")); + """) + } + + private func makeDatabase(at database: URL, sql: String) throws { + var connection: OpaquePointer? + XCTAssertEqual( + sqlite3_open_v2(database.path, &connection, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil), + SQLITE_OK + ) + defer { sqlite3_close_v2(connection) } + XCTAssertEqual(sqlite3_exec(connection, sql, nil, nil, nil), SQLITE_OK) + } + + // Electron v10 cookie value: AES-128-CBC, space IV, SHA-256 host-key prefix. + private func encryptCookie(_ value: String, host: String) -> Data { + let plaintext = Data(SHA256.hash(data: Data(host.utf8))) + Data(value.utf8) + let key = Self.cookieKey + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var output = Data(count: plaintext.count + kCCBlockSizeAES128) + var outputLength = 0 + let capacity = output.count + let status = output.withUnsafeMutableBytes { outputBytes in + plaintext.withUnsafeBytes { plaintextBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + plaintextBytes.baseAddress, + plaintext.count, + outputBytes.baseAddress, + capacity, + &outputLength + ) + } + } + } + } + XCTAssertEqual(status, CCCryptorStatus(kCCSuccess)) + output.count = outputLength + return Data("v10".utf8) + output + } } diff --git a/Tests/AgentDockCoreTests/SQLiteReadOnlyTests.swift b/Tests/AgentDockCoreTests/SQLiteReadOnlyTests.swift index 5b559c2..18745f3 100644 --- a/Tests/AgentDockCoreTests/SQLiteReadOnlyTests.swift +++ b/Tests/AgentDockCoreTests/SQLiteReadOnlyTests.swift @@ -214,6 +214,48 @@ final class SQLiteReadOnlyTests: XCTestCase { } } + func testScopedQueryRejectsSymlinkedParentFromAnotherProfile() throws { + let profile = root.appendingPathComponent("Profile", isDirectory: true) + let otherProfile = root.appendingPathComponent("OtherProfile", isDirectory: true) + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: otherProfile, withIntermediateDirectories: true) + let database = try makeDatabase(""" + create table items (id integer); + insert into items values (3); + """) + let otherDatabase = otherProfile.appendingPathComponent("Cookies") + try FileManager.default.moveItem(at: database, to: otherDatabase) + let network = profile.appendingPathComponent("Network", isDirectory: true) + try FileManager.default.createSymbolicLink(at: network, withDestinationURL: otherProfile) + let linked = network.appendingPathComponent("Cookies") + let sql = "select id from items;" + + // Only the parent is resolved, so an unscoped query follows the link. + XCTAssertEqual( + try SQLiteReadOnly.query(database: linked, sql: sql, timeout: 2, maximumOutputBytes: 1_024), + [[.integer(3)]] + ) + XCTAssertThrowsError(try SQLiteReadOnly.query( + database: linked, + under: profile, + sql: sql, + timeout: 2, + maximumOutputBytes: 1_024 + )) { error in + XCTAssertEqual(error as? SQLiteReadOnlyError, .unsafeDatabase(linked.path)) + } + XCTAssertEqual( + try SQLiteReadOnly.query( + database: otherDatabase, + under: otherProfile, + sql: sql, + timeout: 2, + maximumOutputBytes: 1_024 + ), + [[.integer(3)]] + ) + } + func testQueryReadsCommittedWALContentWhileWriterIsOpen() throws { let database = root.appendingPathComponent("live.sqlite") var writer: OpaquePointer? diff --git a/docs/resource-usage-readers.md b/docs/resource-usage-readers.md index f0fb59a..b9bed36 100644 --- a/docs/resource-usage-readers.md +++ b/docs/resource-usage-readers.md @@ -50,6 +50,12 @@ keep their 8 MiB budget, and activity keeps its configured byte budget plus a row limit equal to its `LIMIT`. Text that is not UTF-8, or a missing or mistyped required activity column, fails that source instead of being repaired. +Claude's active-organization lookup reads the `lastActiveOrg` cookie from a +profile's `Cookies` or `Network/Cookies` database through the same reader. It +passes the profile's user-data root, so a database whose resolved path leaves +that root is refused, and keeps a 2-second timeout, a 4 KiB output budget, and +one row. Any query failure yields no organization for that database. + Subprocess waits are event driven. `BoundedSubprocess` watches root exit, pipe output, and its deadline with dispatch sources on a private queue, and its async overload suspends the calling task until they finish; the synchronous overload diff --git a/docs/security.md b/docs/security.md index f582ced..4890be1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -138,7 +138,8 @@ replace the displayed account. Desktop usage requires a valid account and organization identity, and responses are verified against that identity before display. An explicit identity mismatch clears the corresponding cached quota instead of showing stale data. Active-organization cookie database -reads enforce the same root containment and symlink checks as other databases. +reads run in process through the read-only database reader and enforce the same +root containment and symlink checks as other databases. ## Repository and Release Hygiene