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
39 changes: 22 additions & 17 deletions packages/core/src/remote/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -44,8 +43,8 @@ export interface HealthDeps {
exec(argv: string[]): Promise<Probe>;
computer(): Promise<ComputerStatus>;
requestPermissions(): Promise<ComputerStatus>;
/** 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;
Expand All @@ -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(),
Expand Down Expand Up @@ -192,10 +183,17 @@ async function ghCheck(): Promise<RunnerCheck> {
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 {
Expand Down Expand Up @@ -264,7 +262,14 @@ async function inspect(refresh: boolean): Promise<RunnerHealth> {
? [
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(),
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/services/fullDiskAccess.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 3 additions & 13 deletions packages/core/src/services/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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<PermissionStatus[]> {
if (process.platform !== "darwin") return [];
const out: PermissionStatus[] = [];
Expand Down Expand Up @@ -293,7 +283,7 @@ async function privacyChecks(): Promise<PermissionStatus[]> {
});
}
// 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({
Expand Down
54 changes: 53 additions & 1 deletion packages/core/test/remote-health.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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";
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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading