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
57 changes: 15 additions & 42 deletions Sources/AgentDockCore/ClaudeUsageClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<hostSeparator])
let encoded = String(line[line.index(after: hostSeparator)...])
guard let modeSeparator = encoded.firstIndex(of: ":"),
let stored = Data(hexString: String(encoded[encoded.index(after: modeSeparator)...]))
else { continue }
let mode = encoded[..<modeSeparator]
let value: Data
if mode == "plain" {
value = stored
} else if mode == "encrypted",
if case let .text(plain) = row[1], !plain.isEmpty {
value = Data(plain.utf8)
} else if case let .blob(stored) = row[2],
let decrypted = try? decrypt(stored, key: key)
{
let hostHash = Data(SHA256.hash(data: Data(host.utf8)))
Expand Down Expand Up @@ -760,22 +749,6 @@ private enum ClaudeUsageClientError: Error {
case requestFailed(Int)
}

private extension Data {
init?(hexString: String) {
guard hexString.count.isMultiple(of: 2) else { return nil }
var bytes: [UInt8] = []
bytes.reserveCapacity(hexString.count / 2)
var index = hexString.startIndex
while index < hexString.endIndex {
let next = hexString.index(index, offsetBy: 2)
guard let byte = UInt8(hexString[index..<next], radix: 16) else { return nil }
bytes.append(byte)
index = next
}
self.init(bytes)
}
}

// Claude uses the file-based login Keychain. LAContext only suppresses UI for
// Data Protection items; legacy Keychain access needs the process-level flag.
// Serialize the synchronous read and restore the prior flag before returning.
Expand Down
4 changes: 3 additions & 1 deletion Sources/AgentDockCore/SQLiteReadOnly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,17 @@ extension SQLiteReadOnly {
/// Runs each statement in `sql` in process and returns every result row in
/// order. The connection is read-only, refuses symlinked paths, and stops at
/// the deadline, on task cancellation, or when the output budget is spent.
/// A `root` additionally requires the resolved database to stay inside it.
static func query(
database: URL,
under root: URL? = nil,
sql: String,
timeout: TimeInterval,
maximumOutputBytes: Int,
maximumRows: Int = .max
) throws -> [[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?
Expand Down
150 changes: 150 additions & 0 deletions Tests/AgentDockCoreTests/ClaudeCredentialReaderTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import CommonCrypto
import CryptoKit
import Foundation
import Security
import SQLite3
import XCTest
@testable import AgentDockCore

Expand Down Expand Up @@ -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
}
}
42 changes: 42 additions & 0 deletions Tests/AgentDockCoreTests/SQLiteReadOnlyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
6 changes: 6 additions & 0 deletions docs/resource-usage-readers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading