diff --git a/packages/core/src/remote/health.ts b/packages/core/src/remote/health.ts index b9122a1..8347122 100644 --- a/packages/core/src/remote/health.ts +++ b/packages/core/src/remote/health.ts @@ -4,15 +4,14 @@ * * Every look at the machine goes through `deps`, so tests decide what it finds. */ -import { closeSync, openSync, statfsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { statfsSync } from "node:fs"; import type { ComputerStatus, DependencyId, DependencyStatus, DoctorReport, RunnerCheck, RunnerCheckGroup, RunnerCheckStatus, RunnerHealth } from "@godmode/shared"; import { computerStatus, requestComputerPermissions } from "../computer/service"; import { config } from "../config"; import { getMeta } from "../db"; import { logger } from "../log"; import { installDependency, resolveGh, runCommand, runDoctor, toolPath } from "../services/doctor"; +import { probeFullDiskAccess } from "../services/fullDiskAccess"; import { childEnv } from "../util"; import * as vault from "../vault/vault"; import { keepAwakeStatus, restartKeepAwake, type KeepAwakeStatus } from "./keepAwake"; @@ -44,8 +43,8 @@ export interface HealthDeps { exec(argv: string[]): Promise; computer(): Promise; requestPermissions(): Promise; - /** Can this process read files macOS keeps behind Full Disk Access? */ - fullDiskAccess(): boolean; + /** Can this process read files macOS keeps behind Full Disk Access? null = nothing protected to try here. */ + fullDiskAccess(): boolean | null; vault(): { initialized: boolean; unlocked: boolean }; /** Digest of the setup last copied here; null = nothing was copied yet. */ configDigest(): string | null; @@ -65,15 +64,7 @@ const defaults: HealthDeps = { exec: (argv) => runCommand(argv, { timeoutMs: PROBE_TIMEOUT_MS, env: childEnv({ PATH: toolPath() }) }), computer: computerStatus, requestPermissions: requestComputerPermissions, - fullDiskAccess: () => { - // The privacy database itself is the one file that is only readable with Full Disk Access. - try { - closeSync(openSync(join(homedir(), "Library", "Application Support", "com.apple.TCC", "TCC.db"), "r")); - return true; - } catch { - return false; - } - }, + fullDiskAccess: () => probeFullDiskAccess(), vault: () => vault.status(), configDigest: () => getMeta("link.config_digest"), service: () => serviceStatus(), @@ -192,10 +183,17 @@ async function ghCheck(): Promise { return make("gh", "software", name, "ok", account ? `Signed in to ${account[1]} as ${account[2]}` : "Signed in to GitHub", false); } -function permissionCheck(id: string, name: string, granted: boolean | null | undefined, required: boolean, missing: string): RunnerCheck { +function permissionCheck( + id: string, + name: string, + granted: boolean | null | undefined, + required: boolean, + missing: string, + unknown = "Couldn't check — the screen helper isn't available", +): RunnerCheck { if (granted === true) return make(id, "permissions", name, "ok", "Allowed", required); if (granted === false) return make(id, "permissions", name, failing(required), missing, required); - return make(id, "permissions", name, "unknown", "Couldn't check — the screen helper isn't available", required); + return make(id, "permissions", name, "unknown", unknown, required); } function vaultCheck(): RunnerCheck { @@ -264,7 +262,14 @@ async function inspect(refresh: boolean): Promise { ? [ permissionCheck("accessibility", "Accessibility", computer?.permissions.accessibility, screen, "Not allowed — agents can't click or type"), permissionCheck("screen-recording", "Screen Recording", computer?.permissions.screenRecording, screen, "Not allowed — agents can't see the screen"), - permissionCheck("full-disk-access", "Full Disk Access", deps.fullDiskAccess(), false, "Not allowed — some folders stay closed to agents"), + permissionCheck( + "full-disk-access", + "Full Disk Access", + deps.fullDiskAccess(), + false, + "Not allowed — some folders stay closed to agents", + "Couldn't check — none of the protected folders exist here", + ), ] : []), vaultCheck(), diff --git a/packages/core/src/services/fullDiskAccess.ts b/packages/core/src/services/fullDiskAccess.ts new file mode 100644 index 0000000..554da3c --- /dev/null +++ b/packages/core/src/services/fullDiskAccess.ts @@ -0,0 +1,39 @@ +/** + * Full Disk Access can't be queried, only tried: open something macOS keeps behind it. Not every Mac has every such + * place (a fresh account has no per-user privacy database, no Mail, no Safari data), so several are tried. + */ +import { closeSync, openSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface ProtectedPath { + path: string; + dir: boolean; +} + +export function protectedPaths(home = homedir()): ProtectedPath[] { + const lib = join(home, "Library"); + return [ + { path: join(lib, "Application Support", "com.apple.TCC", "TCC.db"), dir: false }, + { path: "/Library/Application Support/com.apple.TCC/TCC.db", dir: false }, + { path: join(lib, "Safari"), dir: true }, + { path: join(lib, "Mail"), dir: true }, + { path: join(lib, "Messages"), dir: true }, + ]; +} + +/** true = one of them opened; false = every one that exists was refused; null = none of them exist (or something else went wrong). */ +export function probeFullDiskAccess(candidates = protectedPaths()): boolean | null { + let denied = false; + for (const { path, dir } of candidates) { + try { + if (dir) readdirSync(path); + else closeSync(openSync(path, "r")); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES") denied = true; + } + } + return denied ? false : null; +} diff --git a/packages/core/src/services/permissions.ts b/packages/core/src/services/permissions.ts index c3c30f3..dd0631b 100644 --- a/packages/core/src/services/permissions.ts +++ b/packages/core/src/services/permissions.ts @@ -5,7 +5,7 @@ * Every problem says how it gets solved: Godmode repairs it itself ("auto": its own files and folders), the human * allows it in a system dialog ("request": macOS privacy), or only the human can ("manual": files of another user). */ -import { accessSync, chmodSync, closeSync, constants, lstatSync, openSync, readdirSync, realpathSync, statSync, type Stats } from "node:fs"; +import { accessSync, chmodSync, constants, lstatSync, readdirSync, realpathSync, statSync, type Stats } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, sep } from "node:path"; import type { FixResult, PermissionId, PermissionReport, PermissionStatus } from "@godmode/shared"; @@ -18,6 +18,7 @@ import { now } from "../util"; import { managedTartPath } from "../vm/tart"; import { claudeConfigDir } from "./claudeUpdate"; import { isWin, resetDoctorCache, resolveClaudeBinary, resolveUvx, runCommand } from "./doctor"; +import { probeFullDiskAccess } from "./fullDiskAccess"; import { getSettings } from "./settings"; const log = logger("permissions"); @@ -250,17 +251,6 @@ type PrivacyId = keyof typeof PRIVACY_PANES; const isPrivacyId = (id: PermissionId): id is PrivacyId => id in PRIVACY_PANES; -/** Full Disk Access can't be queried; macOS's own privacy database is only readable with it. */ -function fullDiskAccess(): boolean | null { - try { - closeSync(openSync(join(homedir(), "Library", "Application Support", "com.apple.TCC", "TCC.db"), "r")); - return true; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - return code === "EPERM" || code === "EACCES" ? false : null; - } -} - async function privacyChecks(): Promise { if (process.platform !== "darwin") return []; const out: PermissionStatus[] = []; @@ -293,7 +283,7 @@ async function privacyChecks(): Promise { }); } // Only worth a row once it matters: it is on, or macOS has kept Godmode out of a browser's data folder. - const fda = fullDiskAccess(); + const fda = probeFullDiskAccess(); const blocked = blockedProfileRoots(); if (fda === true || (fda === false && blocked.length)) { out.push({ diff --git a/packages/core/test/remote-health.test.ts b/packages/core/test/remote-health.test.ts index 38f8e9d..767af63 100644 --- a/packages/core/test/remote-health.test.ts +++ b/packages/core/test/remote-health.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ComputerStatus, DependencyId, DependencyStatus, DoctorReport, Run } from "@godmode/shared"; @@ -7,6 +7,7 @@ import { defaultRunnerDataDir, loadConfig } from "../src/config"; import { closeDb, openDb, setMeta } from "../src/db"; import { bus } from "../src/events/bus"; import { setLogLevel } from "../src/log"; +import { probeFullDiskAccess, protectedPaths } from "../src/services/fullDiskAccess"; import { bootstrapDependencies, fixCheck, runnerHealth, setHealthDeps, type HealthDeps } from "../src/remote/health"; import { keepAwakeStatus, restartKeepAwake, setKeepAwakeDeps, setWorking, startKeepAwake, stopKeepAwake } from "../src/remote/keepAwake"; import { @@ -515,6 +516,15 @@ describe("runner health", () => { for (const id of MAC_CHECKS) expect(await fixCheck(id)).toEqual({ ok: false, output: `Unknown check: ${id}` }); }); + test("Full Disk Access that can't be checked is unknown, not a warning", async () => { + machine({ fullDiskAccess: () => null }); + const health = await runnerHealth(true); + expect(health.checks.find((c) => c.id === "full-disk-access")).toMatchObject({ + status: "unknown", + detail: "Couldn't check — none of the protected folders exist here", + }); + }); + test("a report is reused for a moment unless a fresh one is asked for", async () => { const m = machine(); const first = await runnerHealth(); @@ -546,6 +556,48 @@ describe("runner health", () => { }); }); +describe("the Full Disk Access probe", () => { + const place = (name: string) => mkdtempSync(join(tmp, `${name}-`)); + + test("nothing protected exists: unknown instead of not allowed", () => { + const home = place("empty-home"); + expect(probeFullDiskAccess([{ path: join(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"), dir: false }, { path: join(home, "Library", "Mail"), dir: true }])).toBeNull(); + }); + + test("one readable place is enough, even when the privacy database is missing", () => { + const home = place("mail-home"); + const mail = join(home, "Library", "Mail"); + mkdirSync(mail, { recursive: true }); + expect(probeFullDiskAccess([{ path: join(home, "TCC.db"), dir: false }, { path: mail, dir: true }])).toBe(true); + }); + + test.if(process.getuid?.() !== 0)("refused everywhere it exists: not allowed", () => { + const home = place("locked-home"); + const db = join(home, "TCC.db"); + const mail = join(home, "Mail"); + writeFileSync(db, ""); + mkdirSync(mail); + chmodSync(db, 0o000); + chmodSync(mail, 0o000); + try { + expect(probeFullDiskAccess([{ path: db, dir: false }, { path: join(home, "Safari"), dir: true }, { path: mail, dir: true }])).toBe(false); + } finally { + chmodSync(db, 0o600); + chmodSync(mail, 0o700); + } + }); + + test("checks the per-user and the system privacy database before the folders", () => { + expect(protectedPaths("/Users/alex").map((p) => p.path)).toEqual([ + "/Users/alex/Library/Application Support/com.apple.TCC/TCC.db", + "/Library/Application Support/com.apple.TCC/TCC.db", + "/Users/alex/Library/Safari", + "/Users/alex/Library/Mail", + "/Users/alex/Library/Messages", + ]); + }); +}); + describe("fixing a check", () => { test("names every object has are unknown checks, not fixes", async () => { machine();