diff --git a/apps/boss-agent-mac/Sources/BossAgentApp.swift b/apps/boss-agent-mac/Sources/BossAgentApp.swift index fc71f6f..9e04da1 100644 --- a/apps/boss-agent-mac/Sources/BossAgentApp.swift +++ b/apps/boss-agent-mac/Sources/BossAgentApp.swift @@ -221,19 +221,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { } private func nativePermissionQueryItems() -> [URLQueryItem] { - [ - URLQueryItem(name: "nativeAccessibility", value: AXIsProcessTrusted() ? "granted" : "missing"), - URLQueryItem(name: "nativeScreenRecording", value: screenRecordingStatus()), + let accessibility = AXIsProcessTrusted() ? "granted" : "missing" + let screenRecording = screenRecordingStatus() + UserDefaults.standard.set(accessibility, forKey: "native.accessibility") + UserDefaults.standard.set(screenRecording, forKey: "native.screenRecording") + + return [ + URLQueryItem(name: "nativeAccessibility", value: accessibility), + URLQueryItem(name: "nativeScreenRecording", value: screenRecording), ] } private func screenRecordingStatus() -> String { if #available(macOS 10.15, *) { - return CGPreflightScreenCaptureAccess() ? "granted" : "missing" + if CGPreflightScreenCaptureAccess() { + return "granted" + } + if CGRequestScreenCaptureAccess() { + return "granted" + } + return tccPermissionStatus(service: "kTCCServiceScreenCapture") ?? "missing" } return "unknown" } + private func tccPermissionStatus(service: String) -> String? { + let query = "select auth_value from access where client='com.hyzq.boss.agent' and service='\(service)' limit 1;" + let databasePaths = [ + "/Library/Application Support/com.apple.TCC/TCC.db", + "\(NSHomeDirectory())/Library/Application Support/com.apple.TCC/TCC.db", + ] + + for databasePath in databasePaths where FileManager.default.fileExists(atPath: databasePath) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [databasePath, query] + let output = Pipe() + process.standardOutput = output + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + } catch { + continue + } + + let data = output.fileHandleForReading.readDataToEndOfFile() + let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + if value == "2" { + return "granted" + } + if value == "0" { + return "missing" + } + } + + return nil + } + private func requestNativePermission(for target: String) { let targets: [String] if target == "core" { diff --git a/local-agent/boss-agent-status.mjs b/local-agent/boss-agent-status.mjs index dfb6717..7984d7f 100644 --- a/local-agent/boss-agent-status.mjs +++ b/local-agent/boss-agent-status.mjs @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; const PERMISSION_DEFS = [ { @@ -31,6 +32,17 @@ const NATIVE_PERMISSION_QUERY_PARAMS = { screenRecording: "nativeScreenRecording", }; +const BOSS_AGENT_BUNDLE_ID = "com.hyzq.boss.agent"; +const BOSS_AGENT_DEFAULTS_DOMAIN = "com.hyzq.boss.agent"; +const TCC_PERMISSION_SERVICES = { + kTCCServiceAccessibility: "accessibility", + kTCCServiceScreenCapture: "screenRecording", +}; +const TCC_PERMISSION_DATABASES = [ + "/Library/Application Support/com.apple.TCC/TCC.db", + path.join(os.homedir(), "Library/Application Support/com.apple.TCC/TCC.db"), +]; + function nonEmpty(value) { const text = String(value ?? "").trim(); return text || undefined; @@ -216,12 +228,46 @@ export function mergeBossAgentNativePermissionOverrides(permissions = {}, queryP for (const [permissionKey, queryKey] of Object.entries(NATIVE_PERMISSION_QUERY_PARAMS)) { const value = getQueryValue(queryKey); if (isPermissionStatus(value)) { + if (merged[permissionKey] === "granted" && value !== "granted") { + continue; + } merged[permissionKey] = value; } } return merged; } +export function mergeBossAgentStoredNativePermissions(permissions = {}, storedPermissions = {}) { + const merged = { ...permissions }; + for (const permissionKey of Object.keys(NATIVE_PERMISSION_QUERY_PARAMS)) { + if (storedPermissions[permissionKey] === "granted") { + merged[permissionKey] = "granted"; + } + } + return merged; +} + +export function mergeBossAgentAppTccPermissions(permissions = {}, tccRows = "") { + const merged = { ...permissions }; + const granted = new Set(); + + for (const rawLine of String(tccRows ?? "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const [service, authValue] = line.split("|"); + const permissionKey = TCC_PERMISSION_SERVICES[service]; + if (!permissionKey) continue; + if (authValue === "2") { + merged[permissionKey] = "granted"; + granted.add(permissionKey); + } else if (!granted.has(permissionKey) && authValue === "0") { + merged[permissionKey] = "missing"; + } + } + + return merged; +} + function resolveSkills(runtime) { const rawSkills = Array.isArray(runtime.lastSkills) ? runtime.lastSkills : []; const items = rawSkills @@ -927,8 +973,66 @@ export async function detectLocalComputerPermissions(platform = process.platform } } - return { + const localProcessPermissions = { accessibility: accessibility.ok && /true/i.test(accessibility.stdout) ? "granted" : "missing", screenRecording, }; + const appTccPermissions = mergeBossAgentAppTccPermissions(localProcessPermissions, await readBossAgentAppTccRows()); + return mergeBossAgentStoredNativePermissions(appTccPermissions, await readBossAgentStoredNativePermissions()); +} + +async function readBossAgentAppTccRows() { + const outputs = []; + + for (const dbPath of TCC_PERMISSION_DATABASES) { + try { + await stat(dbPath); + } catch { + continue; + } + + for (const service of Object.keys(TCC_PERMISSION_SERVICES)) { + const query = [ + "select service,auth_value from access", + `where client='${BOSS_AGENT_BUNDLE_ID}'`, + `and service='${service}'`, + "limit 1", + ].join(" "); + + try { + const db = new DatabaseSync(dbPath, { readonly: true }); + try { + const rows = db.prepare(query).all(); + if (rows.length > 0) { + outputs.push(rows.map((row) => `${row.service}|${row.auth_value}`).join("\n")); + } + } finally { + db.close(); + } + } catch { + // Keep the command-line reader below as a second source of truth. + } + + const result = await runCommand("/usr/bin/sqlite3", [dbPath, `${query};`], 2500); + if (result.ok && result.stdout) { + outputs.push(result.stdout); + } + } + } + + return outputs.join("\n"); +} + +async function readBossAgentStoredNativePermissions() { + const entries = await Promise.all( + Object.keys(NATIVE_PERMISSION_QUERY_PARAMS).map(async (permissionKey) => { + const result = await runCommand( + "/usr/bin/defaults", + ["read", BOSS_AGENT_DEFAULTS_DOMAIN, `native.${permissionKey}`], + 1500, + ); + return [permissionKey, result.ok ? normalizePermissionStatus(result.stdout) : "unknown"]; + }), + ); + return Object.fromEntries(entries); } diff --git a/tests/boss-agent-status.test.mjs b/tests/boss-agent-status.test.mjs index 0fe1e4c..321ba3c 100644 --- a/tests/boss-agent-status.test.mjs +++ b/tests/boss-agent-status.test.mjs @@ -4,6 +4,8 @@ import { readFileSync } from "node:fs"; import { buildBossAgentStatus, + mergeBossAgentAppTccPermissions, + mergeBossAgentStoredNativePermissions, mergeBossAgentNativePermissionOverrides, renderBossAgentHtml, renderBossAgentHtmlWithQr, @@ -254,6 +256,57 @@ test("boss-agent native permission overrides only update core desktop-control pe }); }); +test("boss-agent native missing values do not downgrade confirmed app grants", () => { + const merged = mergeBossAgentNativePermissionOverrides( + { + accessibility: "granted", + screenRecording: "granted", + }, + new URLSearchParams({ + nativeAccessibility: "missing", + nativeScreenRecording: "missing", + }), + ); + + assert.deepEqual(merged, { + accessibility: "granted", + screenRecording: "granted", + }); +}); + +test("boss-agent permission detection trusts app bundle TCC grants over node process checks", () => { + const merged = mergeBossAgentAppTccPermissions( + { + accessibility: "missing", + screenRecording: "missing", + }, + "kTCCServiceAccessibility|2\nkTCCServiceScreenCapture|2", + ); + + assert.deepEqual(merged, { + accessibility: "granted", + screenRecording: "granted", + }); +}); + +test("boss-agent permission detection trusts stored native app permission grants", () => { + const merged = mergeBossAgentStoredNativePermissions( + { + accessibility: "missing", + screenRecording: "missing", + }, + { + accessibility: "granted", + screenRecording: "granted", + }, + ); + + assert.deepEqual(merged, { + accessibility: "granted", + screenRecording: "granted", + }); +}); + test("boss-agent unbound HTML renders a real scannable QR image when qrcode is available", async () => { const status = buildBossAgentStatus( { @@ -292,6 +345,8 @@ test("boss-agent mac app intercepts permission links and triggers native app per assert.match(swiftSource, /lastPermissionRequestTarget/); assert.match(swiftSource, /AXIsProcessTrustedWithOptions/); assert.match(swiftSource, /CGRequestScreenCaptureAccess/); + assert.match(swiftSource, /tccPermissionStatus/); + assert.match(swiftSource, /kTCCServiceScreenCapture/); assert.doesNotMatch(swiftSource, /AVCaptureDevice\.requestAccess|UNUserNotificationCenter|import IOKit\.hid|CGRequestListenEventAccess|IOHIDRequestAccess|CGEvent\.tapCreate|NWBrowser/); assert.match(swiftSource, /NSWorkspace\.shared\.open/); assert.match(swiftSource, /deadline: \.now\(\) \+ 0\.45/);