From 51cda71bf0d474b3dc3b7e106bee63f73d1fb56d Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 6 Oct 2026 08:20:39 +0200 Subject: [PATCH] Runner health: share one Full Disk Access probe and report unknown when nothing protected exists The runner check opened only the per-user TCC.db and called any error a denial, so a Mac without that file warned "Not allowed" even with Full Disk Access granted. Both the runner health and the permissions view now use one probe that tries the user and system TCC.db, then ~/Library/Safari, Mail and Messages. --- packages/core/src/remote/health.ts | 39 ++++++++------ packages/core/src/services/fullDiskAccess.ts | 39 ++++++++++++++ packages/core/src/services/permissions.ts | 16 ++---- packages/core/test/remote-health.test.ts | 54 +++++++++++++++++++- 4 files changed, 117 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/services/fullDiskAccess.ts diff --git a/packages/core/src/remote/health.ts b/packages/core/src/remote/health.ts index b9122a1d..83471223 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 00000000..554da3c3 --- /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 c3c30f35..dd0631bf 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 38f8e9df..767af63d 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();