fix(macos): verify Full Disk Access and re-arm notices (#11716)

* fix(macos): verify full disk access status

* perf(macos): keep full disk probe off main thread
This commit is contained in:
Brennan Benson 2026-07-31 00:51:39 -07:00 committed by GitHub
parent 4f00b21186
commit 48e52540d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 188 additions and 27 deletions

View File

@ -6,6 +6,7 @@ const {
askForMediaAccessMock,
getMediaAccessStatusMock,
isTrustedAccessibilityClientMock,
getMacosFullDiskAccessStatusMock,
execFileMock,
createSocketMock,
socketMock,
@ -28,6 +29,7 @@ const {
askForMediaAccessMock: vi.fn(),
getMediaAccessStatusMock: vi.fn(),
isTrustedAccessibilityClientMock: vi.fn(),
getMacosFullDiskAccessStatusMock: vi.fn(),
execFileMock: vi.fn(),
createSocketMock: vi.fn(() => socketMock),
socketMock,
@ -59,6 +61,10 @@ vi.mock('node:child_process', () => ({
execFile: execFileMock
}))
vi.mock('../macos-full-disk-access-status', () => ({
getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock
}))
import { registerDeveloperPermissionHandlers } from './developer-permissions'
describe('registerDeveloperPermissionHandlers', () => {
@ -72,6 +78,8 @@ describe('registerDeveloperPermissionHandlers', () => {
askForMediaAccessMock.mockReset()
getMediaAccessStatusMock.mockReset()
isTrustedAccessibilityClientMock.mockReset()
getMacosFullDiskAccessStatusMock.mockReset()
getMacosFullDiskAccessStatusMock.mockResolvedValue('denied')
execFileMock.mockReset()
execFileMock.mockImplementation((...args: unknown[]) => {
const callback = args.at(-1)
@ -119,6 +127,21 @@ describe('registerDeveloperPermissionHandlers', () => {
return call[1] as (_event: unknown, args: { id: string }) => Promise<unknown>
}
it('returns the Full Disk Access read-probe status', async () => {
getMacosFullDiskAccessStatusMock.mockResolvedValue('granted')
registerDeveloperPermissionHandlers()
const call = handleMock.mock.calls.find(
(registration: unknown[]) => registration[0] === 'developerPermissions:getStatus'
)
const handler = call?.[1] as (() => Promise<unknown>) | undefined
await expect(handler?.()).resolves.toContainEqual({
id: 'full-disk-access',
status: 'granted'
})
})
it('clears the local-network prompt fallback timer when UDP send settles first', async () => {
registerDeveloperPermissionHandlers()

View File

@ -1,9 +1,7 @@
import { execFile } from 'node:child_process'
import dgram from 'node:dgram'
import { access } from 'node:fs/promises'
import { homedir } from 'node:os'
import path from 'node:path'
import { ipcMain, shell, systemPreferences } from 'electron'
import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status'
import type {
DeveloperPermissionId,
DeveloperPermissionRequestResult,
@ -50,21 +48,6 @@ function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): Develope
}
}
async function getFullDiskAccessStatus(): Promise<DeveloperPermissionStatus> {
const unsupported = unsupportedOffMac()
if (unsupported) {
return unsupported
}
try {
// Why: Safari bookmarks are TCC-protected, so read access is a practical
// Full Disk Access signal without touching user project contents.
await access(path.join(homedir(), 'Library', 'Safari', 'Bookmarks.plist'))
return 'granted'
} catch {
return 'unknown'
}
}
function getAccessibilityStatus(): DeveloperPermissionStatus {
const unsupported = unsupportedOffMac()
if (unsupported) {
@ -165,7 +148,7 @@ async function getPermissionState(id: DeveloperPermissionId): Promise<DeveloperP
case 'accessibility':
return { id, status: getAccessibilityStatus() }
case 'full-disk-access':
return { id, status: await getFullDiskAccessStatus() }
return { id, status: await getMacosFullDiskAccessStatus() }
case 'automation':
case 'local-network':
return { id, status: unsupportedOffMac() ?? 'unknown' }

View File

@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import {
getMacosFullDiskAccessStatus,
probeMacosFullDiskAccess
} from './macos-full-disk-access-status'
const originalPlatform = process.platform
const homeDirectory = join('Users', 'tester')
const databasePath = join(
homeDirectory,
'Library',
'Application Support',
'com.apple.TCC',
'TCC.db'
)
function fileSystemError(code: string): NodeJS.ErrnoException {
return Object.assign(new Error(code), { code })
}
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
describe('probeMacosFullDiskAccess', () => {
it('reports granted only when the TCC database opens for reading', async () => {
const readProbe = vi.fn().mockResolvedValue(undefined)
await expect(probeMacosFullDiskAccess({ homeDirectory, readProbe })).resolves.toBe('granted')
expect(readProbe).toHaveBeenCalledWith(databasePath)
})
it.each(['EACCES', 'EPERM'])('reports denied for %s', async (code) => {
await expect(
probeMacosFullDiskAccess({
homeDirectory,
readProbe: async () => {
throw fileSystemError(code)
}
})
).resolves.toBe('denied')
})
it.each(['ENOENT', 'ENOTDIR', 'EBUSY'])('keeps %s failures unknown', async (code) => {
await expect(
probeMacosFullDiskAccess({
homeDirectory,
readProbe: async () => {
throw fileSystemError(code)
}
})
).resolves.toBe('unknown')
})
})
describe('getMacosFullDiskAccessStatus', () => {
it('is unsupported off macOS', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
await expect(getMacosFullDiskAccessStatus()).resolves.toBe('unsupported')
})
})

View File

@ -0,0 +1,44 @@
import { open } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { DeveloperPermissionStatus } from '../shared/developer-permissions-types'
type ReadProbe = (filePath: string) => Promise<void>
async function openForRead(filePath: string): Promise<void> {
const handle = await open(filePath, 'r')
await handle.close()
}
function errorCode(error: unknown): string | undefined {
return error instanceof Error && 'code' in error
? (error as NodeJS.ErrnoException).code
: undefined
}
export async function probeMacosFullDiskAccess({
homeDirectory = homedir(),
readProbe = openForRead
}: {
homeDirectory?: string
readProbe?: ReadProbe
} = {}): Promise<DeveloperPermissionStatus> {
const databasePath = join(
homeDirectory,
'Library',
'Application Support',
'com.apple.TCC',
'TCC.db'
)
try {
await readProbe(databasePath)
return 'granted'
} catch (error) {
const code = errorCode(error)
return code === 'EACCES' || code === 'EPERM' ? 'denied' : 'unknown'
}
}
export async function getMacosFullDiskAccessStatus(): Promise<DeveloperPermissionStatus> {
return process.platform === 'darwin' ? probeMacosFullDiskAccess() : 'unsupported'
}

View File

@ -30,6 +30,7 @@ vi.mock('node:fs', async (importOriginal) => ({
const {
TCC_PROMPT_NOTICE_THRESHOLD,
TCC_PROMPT_NOTICE_VERSION,
TCC_PROMPT_WATCH_START_FALLBACK_MS,
acknowledgePendingTccPromptNotice,
consumePendingTccPromptNotice,
@ -67,7 +68,11 @@ describe('tcc prompt notice threshold', () => {
handleTccPromptForTests()
expect(writeFileAtomically).toHaveBeenCalledTimes(1)
const [, contents] = writeFileAtomically.mock.calls[0] as [string, string]
expect(JSON.parse(contents)).toMatchObject({ promptCount: 1, notified: false })
expect(JSON.parse(contents)).toMatchObject({
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: 1,
notified: false
})
})
it('never fires again once dismissed, even past the threshold', () => {
@ -303,9 +308,9 @@ describe('tcc prompt notice threshold', () => {
})
it.each([
{ promptCount: 2, notified: false },
{ promptCount: 1, notified: true }
])('requires a fresh detection for a legacy tally: $promptCount/$notified', (persisted) => {
{ promptCount: 2, notified: false, acknowledgedAfterClose: false },
{ promptCount: 1, notified: true, acknowledgedAfterClose: true }
])('re-arms a legacy tally after a fresh detection: $promptCount/$notified', (persisted) => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(JSON.stringify({ ...persisted, dismissed: false }))
@ -329,11 +334,35 @@ describe('tcc prompt notice threshold', () => {
}
})
it('preserves a legacy permanent opt-out', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(
JSON.stringify({
promptCount: 1,
notified: true,
dismissed: true,
acknowledgedAfterClose: true
})
)
try {
const mainWindow = createWindowStub()
initTccPromptNotice(mainWindow as never)
expect(watchStart).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(consumePendingTccPromptNotice(1)).toBeNull()
} finally {
Object.defineProperty(process, 'platform', platform!)
}
})
it('replays an unclosed notice from the new delivery contract', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(
JSON.stringify({
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: 1,
notified: false,
dismissed: false,
@ -359,6 +388,7 @@ describe('tcc prompt notice threshold', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(
JSON.stringify({
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: 1,
notified: true,
dismissed: false,

View File

@ -14,6 +14,9 @@ import { MacosTccPromptWatch } from './macos-tcc-prompt-watch'
/** Why: the first detected dialog identifies an affected user; the notice remains one-time. */
export const TCC_PROMPT_NOTICE_THRESHOLD = 1
/** Why: re-arm users who may have trusted the former false-positive Full Disk Access badge. */
export const TCC_PROMPT_NOTICE_VERSION = 2
/** Why: preserve detection when Electron never emits `ready-to-show` without competing with startup. */
export const TCC_PROMPT_WATCH_START_FALLBACK_MS = 10_000
@ -28,6 +31,7 @@ export type TccPromptNoticeClaim = TccPromptNoticePayload & {
}
type TccPromptTally = {
noticeVersion: number
promptCount: number
notified: boolean
dismissed: boolean
@ -35,6 +39,7 @@ type TccPromptTally = {
}
const EMPTY_TALLY: TccPromptTally = {
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: 0,
notified: false,
dismissed: false,
@ -57,15 +62,28 @@ function loadTally(): TccPromptTally {
try {
const parsed = JSON.parse(readFileSync(tallyPath(), 'utf-8')) as Partial<TccPromptTally>
const dismissed = parsed.dismissed === true
if (!dismissed && typeof parsed.acknowledgedAfterClose !== 'boolean') {
// Why: a legacy tally only proves a past prompt, not that Full Disk Access is still missing.
if (dismissed) {
return {
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0,
notified: true,
dismissed: true,
acknowledgedAfterClose: true
}
}
if (parsed.noticeVersion !== TCC_PROMPT_NOTICE_VERSION) {
return { ...EMPTY_TALLY }
}
if (typeof parsed.acknowledgedAfterClose !== 'boolean') {
// Why: an incomplete tally proves only a past prompt, not that access is still missing.
return { ...EMPTY_TALLY }
}
const acknowledgedAfterClose = parsed.acknowledgedAfterClose === true
return {
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0,
notified: parsed.notified === true && (dismissed || acknowledgedAfterClose),
dismissed,
notified: parsed.notified === true && acknowledgedAfterClose,
dismissed: false,
acknowledgedAfterClose
}
} catch {