From a8771d0a402ff1295fe52494d49f6b20383a06f8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:14:18 -0700 Subject: [PATCH] fix(macos): disclaim TCC responsibility at PTY spawn instead of relying on login(1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 26 no longer breaks TCC responsibility inheritance at an intermediate login(1) session, so every child of a "verified-wrapped" pane is still attributed to Orca's bundle. Because Tahoe issues only session-scoped consent for kTCCServiceSystemPolicyAppData, nothing is ever persisted and each fresh child re-prompts — a dialog storm for agent-heavy workflows (STA-3631, STA-3297). Apply responsibility_spawnattrs_setdisclaim in node-pty's darwin posix_spawn path, resolved through dlsym so hosts without the SPI degrade instead of failing to build. The login(1) wrapper stays: disclaiming survives its setuid exec, so the two compose. The spawn site now reports the wrapper decision and the attribution verdict as separate facts, and only says "disclaimed" on a positive report from the native spawn. An unpatched node-pty reports nothing and reads as "unknown" rather than being mistaken for isolated. --- config/patches/node-pty@1.1.0.patch | 171 +++++++++++++++--- pnpm-lock.yaml | 6 +- src/main/daemon/daemon-entry.ts | 5 +- src/main/daemon/pty-subprocess.test.ts | 5 +- src/main/daemon/pty-subprocess.ts | 3 +- .../native-pty-spawn-tcc-attribution.test.ts | 84 +++++++++ .../daemon/pty-subprocess/native-pty-spawn.ts | 13 +- .../macos-tcc-spawn-attribution.test.ts | 57 ++++++ .../providers/macos-tcc-spawn-attribution.ts | 54 ++++++ 9 files changed, 360 insertions(+), 38 deletions(-) create mode 100644 src/main/daemon/pty-subprocess/native-pty-spawn-tcc-attribution.test.ts create mode 100644 src/main/providers/macos-tcc-spawn-attribution.test.ts create mode 100644 src/main/providers/macos-tcc-spawn-attribution.ts diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index f8ddaae9104..37e05b8dfbe 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -1,5 +1,5 @@ diff --git a/binding.gyp b/binding.gyp -index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644 +index 5f63978..837360b 100644 --- a/binding.gyp +++ b/binding.gyp @@ -1,13 +1,18 @@ @@ -8,7 +8,7 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc83 'dependencies': [ - " #include -@@ -47,6 +49,25 @@ +@@ -47,6 +49,68 @@ #include #endif @@ -211,11 +232,54 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d +__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION); +# endif +#endif ++ ++/* Orca (STA-3631): macOS 26 no longer breaks TCC responsibility inheritance at ++ * an intermediate login(1) session, so every terminal child is attributed to the ++ * app bundle. Disclaim responsibility on the spawn attributes instead — the same ++ * mechanism Terminal.app and iTerm2 rely on. Resolved at runtime because the ++ * symbol is SPI and absent from the public SDK headers. */ ++#if defined(__APPLE__) ++#include ++#include ++ ++/* Verdict codes surfaced to JS; keep in sync with lib/unixTerminal.js. */ ++#define ORCA_TCC_DISCLAIM_UNKNOWN 0 ++#define ORCA_TCC_DISCLAIM_APPLIED 1 ++#define ORCA_TCC_DISCLAIM_UNSUPPORTED 2 ++#define ORCA_TCC_DISCLAIM_FAILED 3 ++ ++typedef int (*orca_tcc_setdisclaim_fn)(posix_spawnattr_t*, int); ++ ++static orca_tcc_setdisclaim_fn ++orca_tcc_resolve_setdisclaim(void) { ++ static orca_tcc_setdisclaim_fn resolved = NULL; ++ static bool attempted = false; ++ if (!attempted) { ++ attempted = true; ++ resolved = (orca_tcc_setdisclaim_fn)dlsym( ++ RTLD_DEFAULT, "responsibility_spawnattrs_setdisclaim"); ++ } ++ return resolved; ++} ++ ++/* Returns one of the ORCA_TCC_DISCLAIM_* verdicts. Never reports APPLIED unless ++ * the SPI both resolved and returned success, so callers can say "unknown" ++ * rather than assume isolation that may not exist. */ ++static int ++orca_tcc_apply_disclaim(posix_spawnattr_t* attrs) { ++ orca_tcc_setdisclaim_fn setdisclaim = orca_tcc_resolve_setdisclaim(); ++ if (setdisclaim == NULL) { ++ return ORCA_TCC_DISCLAIM_UNSUPPORTED; ++ } ++ return setdisclaim(attrs, 1) == 0 ? ORCA_TCC_DISCLAIM_APPLIED ++ : ORCA_TCC_DISCLAIM_FAILED; ++} ++#endif + /* Some platforms name VWERASE and VDISCARD differently */ #if !defined(VWERASE) && defined(VWERSE) #define VWERASE VWERSE -@@ -237,13 +258,23 @@ pty_getproc(int, char *); +@@ -237,13 +301,24 @@ pty_getproc(int, char *); #endif #if defined(__APPLE__) || defined(__OpenBSD__) @@ -236,11 +300,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d int* master, pid_t* pid, - int* err); -+ pty_spawn_error* err); ++ pty_spawn_error* err, ++ int* tcc_disclaim); #endif struct DelBuf { -@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { +@@ -367,10 +442,13 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { argv[i + 3] = strdup(arg.c_str()); } @@ -249,14 +314,26 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d - if (err != 0) { - throw Napi::Error::New(napiEnv, "posix_spawnp failed."); + pty_spawn_error spawn_error = { NULL, 0, "", "" }; -+ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error); ++ int tcc_disclaim = ORCA_TCC_DISCLAIM_UNKNOWN; ++ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error, ++ &tcc_disclaim); + if (spawn_error.errnum != 0) { + std::string spawn_message = pty_format_spawn_error(spawn_error); + throw Napi::Error::New(napiEnv, spawn_message); } if (pty_nonblock(master) == -1) { throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); -@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) { +@@ -452,6 +530,9 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { + obj.Set("fd", Napi::Number::New(napiEnv, master)); + obj.Set("pid", Napi::Number::New(napiEnv, pid)); + obj.Set("pty", Napi::String::New(napiEnv, ptsname(master))); ++#if defined(__APPLE__) ++ obj.Set("tccDisclaim", Napi::Number::New(napiEnv, tcc_disclaim)); ++#endif + + // Set up process exit callback. + Napi::Function cb = info[10].As(); +@@ -684,15 +765,74 @@ pty_getproc(int fd, char *tty) { #endif #if defined(__APPLE__) @@ -320,7 +397,8 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d pid_t* pid, - int* err) { - int low_fds[3]; -+ pty_spawn_error* err) { ++ pty_spawn_error* err, ++ int* tcc_disclaim) { + int low_fds[3] = {-1, -1, -1}; size_t count = 0; + int res = -1; @@ -332,25 +410,25 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d for (; count < 3; count++) { low_fds[count] = posix_openpt(O_RDWR); -@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env, +@@ -706,80 +846,122 @@ pty_posix_spawn(char** argv, char** env, POSIX_SPAWN_SETSID; *master = posix_openpt(O_RDWR); if (*master == -1) { - return; + pty_set_spawn_error(err, "posix_openpt", errno); ++ goto done; ++ } ++ ++ res = grantpt(*master); ++ if (res == -1) { ++ pty_set_spawn_error(err, "grantpt", errno); + goto done; } - int res = grantpt(*master) || unlockpt(*master); -+ res = grantpt(*master); ++ res = unlockpt(*master); if (res == -1) { - return; -+ pty_set_spawn_error(err, "grantpt", errno); -+ goto done; -+ } -+ -+ res = unlockpt(*master); -+ if (res == -1) { + pty_set_spawn_error(err, "unlockpt", errno); + goto done; } @@ -411,14 +489,18 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d + res = posix_spawnattr_init(&attrs); + if (res != 0) { + pty_set_spawn_error(err, "posix_spawnattr_init", res); -+ goto done; -+ } + goto done; + } + attrs_initialized = true; + res = posix_spawnattr_setflags(&attrs, flags); + if (res != 0) { + pty_set_spawn_error(err, "posix_spawnattr_setflags", res); - goto done; - } ++ goto done; ++ } ++ ++ /* Orca (STA-3631): a failed disclaim is reported, never fatal — a shell with ++ * collapsed attribution still beats no shell. */ ++ *tcc_disclaim = orca_tcc_apply_disclaim(&attrs); sigset_t signal_set; /* Reset all signal the child to their default behavior */ @@ -475,8 +557,37 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d } } #endif +diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts +index 98733dc..6077b46 100644 +--- a/src/unixTerminal.ts ++++ b/src/unixTerminal.ts +@@ -26,6 +26,7 @@ const DESTROY_SOCKET_TIMEOUT_MS = 200; + export class UnixTerminal extends Terminal { + protected _fd: number; + protected _pty: string; ++ protected _tccDisclaim: number | undefined; + + protected _file: string; + protected _name: string; +@@ -147,6 +148,8 @@ export class UnixTerminal extends Terminal { + this._pid = term.pid; + this._fd = term.fd; + this._pty = term.pty; ++ // Orca (STA-3631): absent on unpatched builds, so readers treat undefined as "unknown". ++ this._tccDisclaim = term.tccDisclaim; + + this._file = file; + this._name = name; +@@ -172,6 +175,7 @@ export class UnixTerminal extends Terminal { + + /* Accessors */ + get fd(): number { return this._fd; } ++ get tccDisclaim(): number | undefined { return this._tccDisclaim; } + get ptsName(): string { return this._pty; } + + /** diff --git a/src/win/conpty.cc b/src/win/conpty.cc -index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a6a4082ce 100644 +index 7b286d3..ec6bf39 100644 --- a/src/win/conpty.cc +++ b/src/win/conpty.cc @@ -18,6 +18,7 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c425b79c2d..dd3a2e091c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ patchedDependencies: '@xterm/addon-webgl@0.20.0-beta.286': 2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91 '@xterm/xterm@6.1.0-beta.287': 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 - node-pty@1.1.0: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17 + node-pty@1.1.0: e09d7ecc093abdce946aab18c025faa1ae4ab4b329be1d6be4829090740cd4ba importers: @@ -156,7 +156,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17) + version: 1.1.0(patch_hash=e09d7ecc093abdce946aab18c025faa1ae4ab4b329be1d6be4829090740cd4ba) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -12152,7 +12152,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17): + node-pty@1.1.0(patch_hash=e09d7ecc093abdce946aab18c025faa1ae4ab4b329be1d6be4829090740cd4ba): dependencies: node-addon-api: 7.1.1 diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 72537f64cbf..a54e6dba142 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -298,7 +298,10 @@ async function main(): Promise { ...(process.platform === 'darwin' ? { onMacosTccSpawnStrategy: (strategy) => - daemonLog.log('macos-tcc-pty-spawn', { strategy }) + daemonLog.log('macos-tcc-pty-spawn', { + wrapper: strategy.wrapper, + attribution: strategy.attribution + }) } : {}) }), diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 100adaafaf5..c01ef8105c7 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -130,7 +130,10 @@ describe('createPtySubprocess', () => { name: 'xterm-256color' }) ) - expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith('direct') + expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith({ + wrapper: 'direct', + attribution: 'unknown' + }) }) it('does not spawn after cancellation wins during async cwd validation', async () => { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 8e8d2314783..8a0804eefdb 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -4,6 +4,7 @@ import { TerminalAttachCanceledError } from './daemon-errors' import { createDaemonPtyEnvironment } from './pty-subprocess/spawn-environment' import { createPtyShellLaunchPlan } from './pty-subprocess/shell-launch-plan' import { spawnNativeDaemonPty, type SpawnedDaemonPty } from './pty-subprocess/native-pty-spawn' +import type { MacosTccSpawnStrategy } from '../providers/macos-tcc-spawn-attribution' import { formatPtySpawnError, preflightPtySpawn, @@ -33,7 +34,7 @@ export type PtySubprocessOptions = { isCanceled?: () => boolean /** Aborts in-progress cwd validation; `isCanceled` is only polled between steps. */ cancelSignal?: AbortSignal - onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void + onMacosTccSpawnStrategy?: (strategy: MacosTccSpawnStrategy) => void } export async function checkPtySpawnHealth(): Promise { diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-tcc-attribution.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-tcc-attribution.test.ts new file mode 100644 index 00000000000..48a906d1d4e --- /dev/null +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-tcc-attribution.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const spawnMock = vi.fn() +vi.mock('node-pty', () => ({ spawn: (...a: unknown[]) => spawnMock(...a) })) +vi.mock('../../windows/windows-pty-job', () => ({ assignHostProcessToKillOnCloseJob: vi.fn() })) + +import { spawnNativeDaemonPty } from './native-pty-spawn' +import type { MacosTccSpawnStrategy } from '../../providers/macos-tcc-spawn-attribution' + +const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value, configurable: true }) +} + +function spawnedPty(extra: Record = {}) { + return { pid: 4242, onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), ...extra } +} + +function run(onMacosTccSpawnStrategy: (strategy: MacosTccSpawnStrategy) => void) { + spawnNativeDaemonPty({ + shellPath: '/bin/zsh', + shellArgs: ['-l'], + spawnCwd: '/tmp', + env: { SHELL: '/bin/zsh' }, + cols: 80, + rows: 24, + windowsFallbackAttempts: [], + onMacosTccSpawnStrategy + }) +} + +beforeEach(() => spawnMock.mockReset()) +afterEach(() => { + if (realPlatform) { + Object.defineProperty(process, 'platform', realPlatform) + } +}) + +describe('spawnNativeDaemonPty macOS TCC attribution reporting', () => { + // Why: the verdict must come off the process node-pty actually returned, not + // from the argv we asked for — `wrapped` never implied isolation (STA-3631). + it('reports the disclaim verdict carried by the spawned process', () => { + setPlatform('darwin') + spawnMock.mockReturnValue(spawnedPty({ tccDisclaim: 1 })) + const onMacosTccSpawnStrategy = vi.fn() + run(onMacosTccSpawnStrategy) + expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith( + expect.objectContaining({ attribution: 'disclaimed' }) + ) + }) + + it('reports unknown when the spawned process carries no verdict', () => { + setPlatform('darwin') + spawnMock.mockReturnValue(spawnedPty()) + const onMacosTccSpawnStrategy = vi.fn() + run(onMacosTccSpawnStrategy) + expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith( + expect.objectContaining({ attribution: 'unknown' }) + ) + }) + + it('reports not-disclaimed when the native spawn could not apply the attribute', () => { + setPlatform('darwin') + spawnMock.mockReturnValue(spawnedPty({ tccDisclaim: 2 })) + const onMacosTccSpawnStrategy = vi.fn() + run(onMacosTccSpawnStrategy) + expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith( + expect.objectContaining({ attribution: 'not-disclaimed' }) + ) + }) + + // Why: the wrapper decision and the disclaim verdict are independent facts. + it('keeps the wrapper verdict separate from the attribution verdict', () => { + setPlatform('darwin') + spawnMock.mockReturnValue(spawnedPty({ tccDisclaim: 1 })) + const onMacosTccSpawnStrategy = vi.fn() + run(onMacosTccSpawnStrategy) + expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith({ + wrapper: 'direct', + attribution: 'disclaimed' + }) + }) +}) diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts index e0332ba9921..7094645fe2d 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -3,6 +3,10 @@ import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution } from '../../providers/macos-tcc-login-shell' +import { + readMacosTccAttribution, + type MacosTccSpawnStrategy +} from '../../providers/macos-tcc-spawn-attribution' import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain' import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' @@ -24,7 +28,7 @@ export function spawnNativeDaemonPty(args: { cols: number rows: number windowsFallbackAttempts: WindowsShellSpawnAttempt[] - onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void + onMacosTccSpawnStrategy?: (strategy: MacosTccSpawnStrategy) => void }): SpawnedDaemonPty { let reportsChildExitStatus = true const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { @@ -43,7 +47,12 @@ export function spawnNativeDaemonPty(args: { ...(process.platform === 'win32' ? { useConptyDll: true } : {}) }) reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) - args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') + // Why: the wrapper and the disclaim are independent — login(1) stopped isolating + // attribution on macOS 26, so report what each one actually achieved (STA-3631). + args.onMacosTccSpawnStrategy?.({ + wrapper: wrapped.file === shellPath ? 'direct' : 'wrapped', + attribution: readMacosTccAttribution(proc) + }) return proc } diff --git a/src/main/providers/macos-tcc-spawn-attribution.test.ts b/src/main/providers/macos-tcc-spawn-attribution.test.ts new file mode 100644 index 00000000000..e3b7559a123 --- /dev/null +++ b/src/main/providers/macos-tcc-spawn-attribution.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { readMacosTccAttribution } from './macos-tcc-spawn-attribution' + +const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function withPlatform(value: NodeJS.Platform, run: () => T): T { + Object.defineProperty(process, 'platform', { value, configurable: true }) + return run() +} + +afterEach(() => { + if (realPlatform) { + Object.defineProperty(process, 'platform', realPlatform) + } +}) + +describe('readMacosTccAttribution', () => { + it('reports disclaimed only when the native spawn says the attribute applied', () => { + expect(withPlatform('darwin', () => readMacosTccAttribution({ tccDisclaim: 1 }))).toBe( + 'disclaimed' + ) + }) + + it('reports not-disclaimed when the SPI was missing or the call failed', () => { + expect(withPlatform('darwin', () => readMacosTccAttribution({ tccDisclaim: 2 }))).toBe( + 'not-disclaimed' + ) + expect(withPlatform('darwin', () => readMacosTccAttribution({ tccDisclaim: 3 }))).toBe( + 'not-disclaimed' + ) + }) + + // Why: an unpatched node-pty reports nothing; silence must never read as success (STA-3631). + it('reports unknown when node-pty reports no verdict at all', () => { + expect(withPlatform('darwin', () => readMacosTccAttribution({}))).toBe('unknown') + expect(withPlatform('darwin', () => readMacosTccAttribution(undefined))).toBe('unknown') + expect(withPlatform('darwin', () => readMacosTccAttribution(null))).toBe('unknown') + }) + + it('reports unknown for a non-numeric or unrecognized verdict', () => { + expect(withPlatform('darwin', () => readMacosTccAttribution({ tccDisclaim: '1' }))).toBe( + 'unknown' + ) + expect(withPlatform('darwin', () => readMacosTccAttribution({ tccDisclaim: 0 }))).toBe( + 'unknown' + ) + expect(withPlatform('darwin', () => readMacosTccAttribution({ tccDisclaim: 99 }))).toBe( + 'unknown' + ) + }) + + // Why: disclaiming is a darwin spawn attribute; other hosts have nothing to claim either way. + it('never claims disclaimed off macOS even if a verdict is present', () => { + expect(withPlatform('linux', () => readMacosTccAttribution({ tccDisclaim: 1 }))).toBe('unknown') + expect(withPlatform('win32', () => readMacosTccAttribution({ tccDisclaim: 1 }))).toBe('unknown') + }) +}) diff --git a/src/main/providers/macos-tcc-spawn-attribution.ts b/src/main/providers/macos-tcc-spawn-attribution.ts new file mode 100644 index 00000000000..35e74d6eb9d --- /dev/null +++ b/src/main/providers/macos-tcc-spawn-attribution.ts @@ -0,0 +1,54 @@ +/** + * Spawn-time TCC responsibility disclaiming (STA-3631). + * + * macOS 26 no longer breaks TCC responsibility inheritance at an intermediate + * `login(1)` session, so every child of a wrapped pane is still attributed to + * Orca's bundle. The patched node-pty darwin spawn path calls + * `responsibility_spawnattrs_setdisclaim` and reports whether it took effect; + * this module turns that report into a verdict the spawn site can log honestly. + */ + +/** Verdict codes emitted by the patched node-pty darwin spawn path. Keep in sync + * with ORCA_TCC_DISCLAIM_* in config/patches/node-pty@1.1.0.patch. */ +const DISCLAIM_APPLIED = 1 +const DISCLAIM_UNSUPPORTED = 2 +const DISCLAIM_FAILED = 3 + +/** + * Whether this spawn's children get their own TCC identity. + * + * `unknown` is the honest default: an unpatched node-pty reports nothing, and + * `wrapped` never implied isolation on macOS 26 — neither may this. + */ +export type MacosTccAttribution = 'disclaimed' | 'not-disclaimed' | 'unknown' + +/** How a pane shell's argv was built, paired with what that actually bought. */ +export type MacosTccSpawnStrategy = { + wrapper: 'wrapped' | 'direct' + attribution: MacosTccAttribution +} + +/** + * Read the disclaim verdict off a spawned node-pty process. + * + * Reports `disclaimed` only on a positive report from the native spawn; a + * missing, non-numeric, or unrecognized value stays `unknown` so a node-pty + * without the patch can never be mistaken for an isolated one. + */ +export function readMacosTccAttribution(ptyProcess: unknown): MacosTccAttribution { + if (process.platform !== 'darwin') { + return 'unknown' + } + // Why: every non-verdict — absent, wrong type, unrecognized code — funnels through + // the same default, so an unpatched node-pty can never read as isolated. + const reported = (ptyProcess as { tccDisclaim?: unknown } | null | undefined)?.tccDisclaim + switch (reported) { + case DISCLAIM_APPLIED: + return 'disclaimed' + case DISCLAIM_UNSUPPORTED: + case DISCLAIM_FAILED: + return 'not-disclaimed' + default: + return 'unknown' + } +}