diff --git a/docs/plans/macos-tcc-explicit-recovery-design.md b/docs/plans/macos-tcc-explicit-recovery-design.md index fe5b7f43ea2..1e55e425ac6 100644 --- a/docs/plans/macos-tcc-explicit-recovery-design.md +++ b/docs/plans/macos-tcc-explicit-recovery-design.md @@ -1,324 +1,152 @@ -# macOS terminal permission recovery: detection for surviving daemons - -Status: revised after independent review and local prototype validation, 2026-09-14. -No production implementation. Probe transport and cleanup are demonstrated; detection of -the reported TCC failure is not yet demonstrated. See [validation evidence](validation/README.md). -The durable prevention design is [macos-tcc-durable-relocation-design.md](macos-tcc-durable-relocation-design.md). - -## 1. Final design - -Detect the access failure, then point to the existing restart action: - -1. When the user attempts to open or restore a local macOS terminal, including failed attempts, - check the requested workspace directory through the adopted daemon. -2. Run a tiny directory-read executable in a disposable daemon-owned PTY. This works through - the existing protocol without running user shell startup files or touching an existing terminal. -3. If the check returns EPERM or EACCES, attempt the same read from Orca. Only when Orca succeeds - show the existing permission notice suggesting restart and warning that terminals and agents stop. -4. Link to **Manage Sessions → Restart daemon** and reuse the existing confirmation. -5. After the explicit restart, repeat the check before claiming that access recovered. - -No polling, folder scanning, terminal counts, new dialog, or automatic restart. Coalesce -concurrent checks; inconclusive results do not trigger a permission diagnosis. - -Performance contract: the probe is off the terminal-readiness path. Measure terminal-open -time-to-first-prompt with probing enabled and disabled; the implementation must show no measurable -readiness regression and must cap work at one active probe plus one queued request per daemon. -Record bounded helper-spawn, PTY setup, app-read, cleanup, and total durations. Restore bursts -and reconnect storms are required stress cases; process/PTY churn must remain bounded by the -coalescing and cooldown rules below. - -This supports investigating already-running daemons whose negotiated protocol already has the -existing create/cleanup fields; it does not require a new RPC. It adds no terminal pane, -inventory, counts, renamed controls, or recovery wizard. -A transient diagnostic session can still appear in service listings: old protocols have no -hidden-session flag. - -Prevention across updates remains the priority. This recovery is explicit; never restart -automatically or terminate healthy terminals to migrate the launch mechanism. - -The decisive release gate is TCC fidelity: the final signed helper must reproduce -the denial of an ordinary terminal on an actually affected old daemon. A prototype running -successfully is not sufficient evidence to ship a TCC detector. - -## 2. Why this detector - -The reported affected daemon can answer health probes and have an intact launcher path. -App version, executable-path existence, and code-signature metadata do not establish whether -a filesystem operation will be permitted. - -Current code provides optional `cwdReadableByDaemon` on new terminal creation, using -`accessSync(R_OK | X_OK)`. App-side divergence is emitted by -`trackDaemonPtyCwdDeniedIfDiverged` as telemetry. Older daemons omit the field, and reattaching -an existing terminal omits it too. Absence is unknown, never success. - -Use actual directory enumeration to test the relevant operation, rather than treating -`accessSync` as equivalent. Keep one compatibility detector for adopted daemons initially. -Direct reporting from newer daemons can replace this work later after equivalence is proven. - -Do not parse arbitrary terminal output for “EPERM” or “Operation not permitted”. It may be -quoted text or a command failing for unrelated reasons. A new daemon RPC cannot repair the -observability of a process that is already running old code. - -## 3. When to check and when to show the notice - -Trigger when a user attempts to open or restore a local terminal using an adopted macOS daemon, -once its connection and the requested directory are known. Include failed admission: permission -denial can prevent terminal creation itself. Use the requested workspace directory, whether a -folder workspace or a git worktree. Run asynchronously; the diagnostic must not delay ordinary -terminal readiness or enter automatic daemon-replacement/retry paths. An admission error alone -does not establish the access mismatch; still require the structured diagnostic result. - -Coalesce simultaneous requests for the same connection generation and directory. Initially -keep no lasting success cache: later create/reattach activity can discover a newly developed -failure. No periodic timer, protected-folder sweep, or probe on every keystroke. - -Allow one diagnostic at a time per daemon. Coalesce restore bursts; use a short bounded -cooldown after an inconclusive attempt, checked only on later terminal activity. A cooldown -is not a health verdict. Bound queued work and discard obsolete workspace/connection requests. - -Only after a permission-denied result, perform the equivalent directory read from Electron -main. The result must still belong to the active connection generation when consumed. - -| Diagnostic child | App read of the same directory | Behavior | -| --- | --- | --- | -| EPERM or EACCES | Success | Show the existing permission notice with restart guidance. | -| EPERM or EACCES | EPERM or EACCES | No service-specific diagnosis; use existing permission guidance. | -| EPERM or EACCES | Missing path, timeout, other error | Inconclusive; no restart diagnosis. | -| Success | Not needed | No access warning for this check. | -| Missing path, spawn failure, malformed/no result, timeout | Not needed | Inconclusive; no restart diagnosis. | - -Suggested notice: - -> A check through the terminal service could not access this folder, but Orca can. -> Restarting the service may help. This stops its terminals and running agents. - -Reuse **Open Manage Sessions** and the existing restart confirmation. Show once per affected -daemon connection scope per app session; dismissal prevents repeated notices for that scope. -Reconnect/replacement invalidates pending evidence. Use authenticated incarnation identity -when present; with old metadata, use the uninterrupted connection generation rather than a -PID guessed from the current file. - -Keep the existing severed-attribution reason separate. It may explain possible permission -trouble, but it does not establish an observed directory-read denial. Neither reason promises -that restart restores permissions. - -## 4. Probe transport - -Use a small standalone macOS executable as `shellOverride`, with `command` omitted. -Pass target and a random request nonce in dedicated environment variables. Start from an -accessible neutral runtime directory, not the directory being checked. - -There are three distinct processes in this flow: - -1. **Orca main** owns the diagnostic request, captures the daemon identity, parses the helper - result, and performs the comparison read under Orca's own filesystem identity. -2. **The existing terminal daemon** is the already-running daemon that owns the user's terminals. - Orca connects to this exact daemon and asks it to create one temporary diagnostic PTY; the - check must not restart, replace, or silently adopt another daemon. -3. **The diagnostic helper** is a packaged, one-shot executable launched by that daemon inside - the temporary PTY. It is not a daemon, does not own or persist a terminal, and exits after one - directory read. The PTY is only its launch container. - -The helper is intentional. Sending a shell command would run startup files, aliases, prompts, or -arbitrary user configuration and would produce output that is unsafe to interpret. The helper -receives only the target path and request nonce through dedicated environment variables, uses the -neutral cwd, performs `opendir`/one `readdir`/`closedir`, and returns no names or file contents. -If the exact packaged helper cannot be launched, the result is `unknown`; the diagnostic path -must never fall back to zsh, another shell, or a normal terminal. - -The lifecycle is: Orca main sends the existing create request → the captured daemon launches the -helper in the temporary PTY → the helper emits one bounded result and exits → Orca parses it and -performs its own equivalent read. Client deadlines cover connection, create, output, and cleanup; -the helper also has its own self-expiry. A timeout or lost connection makes cleanup uncertain, -not evidence that the daemon or child died. The exact diagnostic session is cleaned up through the -original daemon connection when possible. Older protocols may briefly expose this temporary -session in listings because they cannot mark it hidden; that bounded visibility is accepted. - -This is a deliberate change from the original “fixed command” proposal: - -- The existing Unix launch path ordinarily starts a login shell; a command sent through it - can execute user startup files and interact with shell readiness/history. -- The protocol provides `shellOverride` and environment but no arbitrary argument vector. - An unknown executable receives the default `-l` argument; the helper must tolerate it. -- Do not assume the app's fresh Electron executable is a suitable oracle. Its identity can - change attribution, and the normal PTY environment strips `ELECTRON_RUN_AS_NODE`. - -The helper performs `opendir`, one `readdir`, then `closedir`. It does not enumerate the -whole directory and never returns filenames or contents. Emit one bounded record containing -the request nonce and an allow-listed outcome/errno, then exit. Validate framing and nonce; -PTY transport may add CRLF, but arbitrary preambles, postambles, duplicate records, and -additional structured-looking output are invalid. - -Do not infer success from the PTY exit code: the macOS login wrapper can return zero even -when its child failed. Only the helper's valid result establishes the read outcome. - -Use the same operation in main for the comparison; do not launch another helper directly from -the app and assume its identity is equivalent to Electron main's. - -## 5. Lifecycle and compatibility - -Connect directly to the captured daemon endpoint for diagnostics, using its captured token and -negotiated protocol version; never use the current default protocol against an older endpoint. -Do not use a provider path that may silently respawn or adopt another daemon on error. Do not -inject input into any existing terminal. Assign a unique diagnostic session ID without pane or -agent ownership. - -This is a dedicated diagnostic path, not the normal provider spawn path. It may issue only the -raw existing create/attach and cleanup requests through the captured client or an exact-version -direct client. It must bypass provider spawn, daemon retry, replacement preflight, history restore, -fallback routing, and admission retry logic. The diagnostic create always uses a verified, -daemon-readable neutral runtime cwd; the target workspace is passed only in the bounded helper -environment and is never the PTY cwd. - -Start the helper's own deadline immediately, before reading the target. Also bound client -connection, create, output, and cleanup work. The prototype uses a five-second helper alarm; -that is demonstrated for an ordinary sleeping child, not an uninterruptible kernel operation. - -On timeout/cancellation, attempt cleanup of that exact diagnostic session over the original -connection. Never restart/kill the daemon to clean up the probe. A late legacy create can -outlive the client's request deadline; helper self-expiry is needed because old cancellation -semantics cannot be assumed. Loss of contact means cleanup is uncertain, not that the child died. - -No protocol-only hidden flag can make a session invisible to old readers. Accept brief listing -visibility with a recognizable diagnostic name; do not create a new cross-reader filtering -subsystem for this feature. Confirm historical exit/reaping behavior against supported old -binaries; current-daemon cleanup tests do not establish every old release's behavior. - -Source inspection of the parent of `a7fda48fe3` (before cwd-readability reporting) confirms -`shellOverride`, `env`, and the Unix launch behavior existed there. This is one compatibility -anchor, not an executed old release or a claim about all legacy protocols. Keep recovery scoped -to local daemon populations whose negotiated protocol has the required fields; SSH, relay, -legacy adapters without those fields, and other profiles remain outside the target. - -Package the helper using existing standalone macOS-helper build/signing conventions. Verify the -exact packaged path is executable immediately before issuing the request. Diagnostics must not -use the normal Unix shell fallback chain: if that exact helper cannot be resolved or launched, -return unknown and prove in tests that no fallback shell remains alive. -Verify both architectures, executable permissions, an unpacked runnable location, and the -final signing identity. Do not add Python/Perl/system-command fallback detectors. Failure to -launch the helper yields unknown. - -A directory read can trigger a macOS permission prompt. “Background” does not mean guaranteed -silent. Probe only a directory the user has chosen to use. - -## 6. Recovery - -Use the existing restart action and confirmation. It stops current-protocol local terminals, -including running agents/commands, and degraded local fallback terminals. Preserve SSH and -legacy-protocol sessions. Agents require manual resume. - -No new preview IPC, impact inventory, expiring confirmation tokens, or forced escalation on -graceful timeout is part of this design. Fix demonstrated correctness problems in the existing -restart path without introducing a parallel implementation. - -In particular, verify premature synthetic exits, partial shutdown, permission-error handling -in signal fallback, ownership changes, and failed replacement. An unsuccessful restart may -already have stopped work. Listener rebinding cannot restore cleared session state. Do not -spawn beside an owner whose survival is unresolved; preserve the endpoint ownership contract. - -After an explicit restart prompted by this detector, repeat the directory check against the -replacement, using the same request path. A successful check clears this access warning. -A failed/inconclusive check cannot be reported as recovered. Service readiness and folder -access are separate results; do not promise permissions were granted or agents resumed. - -## 7. Validation result and remaining release gates - -Completed locally with a second agent's independent protocol/lifecycle review: - -- A real isolated daemon process and native PTY launched the prototype helper using existing - RPC fields, without a shell command or a visible app window. -- Direct and actual login-wrapped launches returned the expected structured result for readable, - missing, and POSIX-permission-denied directories. -- A target containing spaces, a quote, dollar sign, backticks, and a newline was passed intact. -- Completed diagnostic sessions were absent from current-daemon session inventory. -- Explicit cleanup stopped a stalled child; the helper also self-terminated without client kill. -- Existing cwd-readability, divergence-telemetry, and attribution suites passed: 22 tests. - -Not validated: a poisoned TCC lineage, an executed historical daemon binary, final signed helper -attribution, app-versus-daemon divergence under TCC, or permission recovery after restart. -The comparison in the prototype uses a Node driver, not the installed Electron main process. - -Before release, reproduce on an affected old daemon: - -1. An ordinary terminal directory read fails with a permission denial. -2. The final signed diagnostic helper, through that same daemon's launch path, also fails. -3. Electron main's equivalent read succeeds on that directory. -4. The existing notice offers the existing restart, without counts or another confirmation. -5. After user-confirmed restart, both the diagnostic and ordinary terminal can read it. - -Exercise relevant direct and login-wrapped populations, historical create/cleanup behavior, -late create after client timeout, connection replacement, duplicate restores, dismissal, -and same-app-lifetime failure after a previous successful check. - -If the helper succeeds while the affected terminal fails, it is not a valid oracle for that -population. Do not weaken the trigger to app-version mismatch to conceal that failure; -revise the execution context based on the reproduction. - -## 8. Limits and implementation checks - -This detects access for checked workspace directories. A later arbitrary shell command can -access another directory without structured feedback to Orca. Keep manual restart available; -do not claim universal automatic detection. - -TCC poisoning is a hypothesis for the observed mismatch. POSIX permissions, path changes, and -other process-specific restrictions must not be described as proven TCC cache corruption. - -No production changes were made during validation. Sources and results are retained under -`docs/plans/validation/`; the plan and evidence are currently git-ignored. Relevant typechecks, -native packaging checks, focused regression tests, and hidden Electron UI validation remain -implementation requirements. Use the electron skill and Playwright CDP for rendered checks; -all tests/apps use `ORCA_BACKGROUND_LAUNCH=1`, with no focus or window activation. - -## 9. Implementation contract (release-blocking details) - -The implementation must preserve these invariants; they are part of the design rather than -optional tactics: - -- The probe request carries an immutable `{ connectionId, protocolVersion, connectionGeneration, - canonicalPath, requestNonce }`. The caller captures all five before opening the diagnostic - session, and the result is ignored unless all five still match. A path or connection selected - after the probe - starts is a race and must not be used for attribution. -- The path comes from the terminal admission/restore request (including failed admission), after - the existing absolute, lexical workspace-path normalization. Do not call `realpath` as a - prerequisite or change symlink spelling unless admission already does so. Do not fall back to - the current tab, cwd reported by a shell, or a path selected by a later restore. Folder - workspaces use their folder path; git worktrees use the requested worktree path. This exact - canonical string is used for the helper, app comparison, coalescing, and state keys. -- The daemon diagnostic API is an internal main-process operation. Renderer code may request a - probe and receive a typed result, but it cannot provide an executable path, environment keys, - nonce, or daemon endpoint. The main process owns helper-path allow-listing, nonce generation, - output-size limits, and result parsing. -- The helper result is a versioned, single-record protocol with an exact nonce match and an - allow-list of `ok | eperm | eacces | missing | other`; malformed, duplicate, over-budget, or - extra records are `unknown`. Never map arbitrary errno values to permission denial. -- Cleanup is idempotent and keyed by the diagnostic session ID plus connection generation. A - cleanup acknowledgement is not evidence that a late create did not happen; late creates are - tracked as an explicit compatibility test and surfaced only in diagnostics. -- Maintain per-path evidence/recovery state. The user-visible shown/dismissed latch is keyed by - authenticated daemon incarnation when available, otherwise by uninterrupted connection - generation, plus app-session ID. A successful post-restart probe clears only that canonical - path's evidence. Reconnect, replacement, logout, and app restart clear pending work; dismissal - suppresses only the current latch key. -- The app-side comparison uses the same `opendir`/one-`readdir`/`closedir` sequence and maps - `ENOENT`/`ENOTDIR` to `missing`. It must not use `accessSync`, a recursive scan, or a second - child process. The comparison runs only after the daemon returns `eperm` or `eacces`, has an - absolute deadline, and resolves timeout to `unknown` without blocking the renderer or main - event loop. The underlying kernel call may remain uninterruptible; release the diagnostic slot - and record bounded telemetry rather than waiting indefinitely. -- Revalidate `{connectionId, protocolVersion, connectionGeneration, canonicalPath, requestNonce}` - after the app read and immediately before committing notice state. Evidence that becomes stale - during the comparison is `unknown`. -- A probe is best-effort and never participates in terminal admission, retry, daemon replacement, - or renderer startup readiness. Every timeout, transport error, stale-generation result, and - helper launch failure resolves to `unknown` and is observable in bounded debug telemetry. - -The minimum automated coverage is: readable/missing/permission-denied directories; spaces, -quotes, shell metacharacters, and newlines in paths; helper output framing and nonce attacks; -login-wrapped and direct launch; duplicate restore coalescing; timeout followed by late create; -connection replacement; dismissal scoping; a second failure after an earlier success; and a -successful and unsuccessful explicit restart. Test the oldest supported daemon protocol that has -the required fields, plus one daemon with and one without the production login wrapper. Add one -end-to-end Electron test that verifies the existing notice and restart confirmation without -activating or showing a window. +# STA-7948: macOS daemon folder-access mismatch — finalized plan + +Status: finalized 2026-09-21 after live evidence on a production adopted daemon. Supersedes the +2026-09-14 helper-first design (kept below as Layer B, now evidence-gated). No production code yet. +Evidence: [validation/README.md](validation/README.md) (section "2026-09-21 live evidence"). + +## 1. What is settled + +1. **macOS TCC lets `access(2)` and `stat` succeed on Documents/Desktop/Downloads while + `opendir`/`readdir` fails with EPERM.** Measured in a grant-less launchd process on + `~/Documents`: `access(R_OK|X_OK)=True`, `stat=ok`, `scandir=EPERM`. Full Disk Access folders + behave differently (`access()` is denied too). Consequence: the daemon's existing + `cwdReadableByDaemon` verdict (`accessSync` in `terminal-host-session-create.ts`) is **blind to + the incident's folder class**, and the `daemon_pty_cwd_denied` telemetry has a structural zero + for it. The original report's `ls -lde .` success is consistent with this (it only stats). +2. **Daemon-spawned shells carry the daemon's effective grant.** On the production adopted daemon + (spawned by 1.4.207-adhoc, app now 1.4.206), the daemon's verdict, a login-wrapped shell's + `scandir`, and a control shell agreed on `~/Documents`, `/tmp`, and Full-Disk-Access-gated + `~/Library/Safari`. `responsibility_get_pid_responsible_for_pid` reports the daemon and each + shell as its own responsible pid, never Orca main, yet the shells read Safari, which only + `com.stablyai.orca` holds. `com.stablyai.orca.helper` has no TCC row of its own. +3. **The login wrapper is unconditional in production**: 1734 of 1734 recorded spawns were + `wrapped`. +4. **The incident itself has never been reproduced** (2026-09-01 matrix, 2026-09-21 run). Field + recovery for the one confirmed case reportedly required daemon restart **plus** + `tccutil reset SystemPolicyDocumentsFolder com.stablyai.orca` and re-allowing. + +## 2. Design + +Two layers. Layer A ships first and is small. Layer B is the previous helper design, built only +if evidence says the population it covers matters. + +### Layer A — enumeration verdict in the daemon, notice in the app (ship now) + +**Daemon.** Replace `isCwdReadableByThisProcess` in +`src/main/daemon/terminal-host-session-create.ts` with real enumeration: `fs.opendirSync(cwd)`, +one `dir.readSync()`, `dir.closeSync()`. `EPERM`/`EACCES` → `false`. `ENOENT`/`ENOTDIR`/anything +else → `true`, exactly as today, so a non-permission failure never masquerades as denial. Keep the +wire field name and type (`cwdReadableByDaemon?: boolean`); its meaning becomes "enumerable by +the daemon". Old clients read it unchanged. This runs where `accessSync` already runs (before the +fork, synchronous, local paths only, skipped for WSL). + +**Main: evidence.** In `src/main/daemon/daemon-pty-session-spawn.ts`, next to the existing +`trackDaemonPtyCwdDeniedIfDiverged` call, record proven divergence in a new +`src/main/daemon/daemon-folder-access-mismatch.ts`: `{ canonicalPath, daemonIdentity: { pid, +startedAtMs, launchNonce }, appSessionId, observedAtMs }`. The app-side comparison uses the same +`opendir`/one-`readdir`/`closedir` sequence (switch `trackDaemonPtyCwdDeniedIfDiverged` from +`accessSync` to it as well). Keep at most one entry per daemon identity; a later spawn that +enumerates successfully on that identity clears it. `canonicalPath` is the cwd actually sent to the +daemon; no `realpath`, no case folding. Local current-protocol adapter only; SSH, WSL, relay, +legacy adapters, and local fallback providers never write evidence. + +**Main: IPC.** Extend the existing `pty:management:macTccAttribution` handler in +`src/main/ipc/pty-management.ts` to return +`{ health, folderAccessMismatch: { daemonScope: string } | null }` where `daemonScope` is a +stable hash of the daemon identity. Mirror the type in `src/preload/api/pty-management-api.ts` +and return `null` from `src/renderer/src/web/preload-api/web-terminal-api.ts`. No new channel, +no push event. + +**Renderer.** In `src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts` add a second toast +(`mac-daemon-folder-access-mismatch`) driven by the same focus-time poll, latched per +`daemonScope` per app session, dismissable per scope. Copy (final wording depends on gate G1): + +> **Orca's terminal service can't read a folder Orca can.** +> The terminal service was denied access to a workspace folder that Orca itself can read. +> Restart the daemon from Manage Sessions. If macOS asks again, allow the folder. This closes all +> running Orca terminals and agents. + +Action: the existing **Open Manage Sessions** target and the existing restart confirmation in +`useDaemonActions`. Keep the severed-attribution toast separate. + +**Recovery and clearing.** Use `restartDaemon()` unchanged. Evidence is keyed by daemon identity +and a restart always replaces the identity, so the next poll returns `null` and the hook dismisses +the toast. If the replacement daemon is also denied, the next spawn re-records and re-toasts. No +post-restart probe: the next terminal spawn is the probe. + +**Telemetry.** `daemon_pty_cwd_denied` becomes meaningful for the Documents class. Add +`daemon_folder_access_notice` with `{ action: shown | dismissed | restart_clicked }` and the +existing `cwd_class` enum; no paths. + +**Tests.** Extend `terminal-host-cwd-readability.test.ts` for opendir mapping (EPERM, EACCES, +ENOENT, ENOTDIR, readable, empty directory). Unit-test the evidence store (records only on +divergence, one per identity, clears on success or identity change). IPC handler shape. Hook: +toast once per scope, dismissal latch, clears when the poll returns `null`, no toast when both +sides fail or the path is missing. No Electron perf gate is needed: nothing new runs on the +spawn path beyond one `opendir`. + +**Accepted gap.** Daemons already running pre-change code (every adopted daemon deployed today) +never produce the enumeration verdict. They are covered after their next restart or reboot, so +each user is blind for at most one update cycle. Layer B exists for that population. + +### Layer B — helper probe for already-deployed old daemons (evidence-gated, unchanged design) + +The 2026-09-14 design (signed helper launched through `createOrAttach` with `shellOverride`, +neutral cwd, nonce-framed single record, direct and login-wrapped strategies, lease and cleanup +contract, one active plus one queued per daemon, Electron A/B performance gate) is the only path +that observes an old daemon's view without new daemon code. Build it only if, after Layer A ships: + +- PostHog `daemon_adopted` shows the adopted-old-daemon population is large enough that a + one-cycle blind spot matters, or +- gate G1 shows the daemon's in-process verdict disagrees with its shells (the daemon enumerates + but the terminal is denied), which would mean Layer A's oracle is wrong for the broken state. + +If Layer B is built, do not route it through the plan's createOrAttach-plus-lease machinery for +new daemons; new daemons get a single capability-gated `directoryAccessProbe` request built on the +existing `ptySpawnHealth` pattern (`runPtySpawnHealthProbe` in +`src/main/daemon/pty-subprocess/spawn-preflight.ts`) with cleanup on `onClientDisconnected`. + +## 3. Open gates + +- **G1 — affected machine, remedy and oracle.** On a machine showing the failure, inside the + affected Orca terminal: + 1. `python3 -c "import os; list(os.scandir(os.path.expanduser('~/Documents')))"` → expect EPERM. + 2. Settings → Terminal → Manage Sessions → Restart daemon. Open a new terminal, rerun step 1. + 3. If still denied: `tccutil reset SystemPolicyDocumentsFolder com.stablyai.orca`, re-allow + when prompted, rerun step 1. + Decides whether the notice says "restart" or "restart, then re-allow", and whether the daemon + identity is what breaks (step 2 fixes it) or the app's grant (step 3 fixes it). +- **G2 — population.** PostHog: count of `daemon_adopted` per week, split by + `app_version_match`. Ignore `daemon_pty_cwd_denied` for now: it is structurally zero for the + Documents class until Layer A ships. +- **G3 — Documents prompt behaviour on a signed build.** Confirm the daemon's `opendir` on a + never-granted Documents folder does not raise a consent prompt attributed to the helper bundle. + On this machine the grant-less probe was denied silently; verify once on a signed build with a + fresh TCC profile. + +## 4. Action items + +1. Send G1 to the affected user (draft below). Owner: Jinwoo. +2. Run G2 in PostHog. Owner: whoever holds PostHog access. +3. Land this doc and the 2026-09-21 evidence on PR #21740; keep the durable-relocation design + separate and unblocked. +4. Implement Layer A in one PR against `main` (daemon verdict, evidence store, IPC field, toast, + tests). Do not wait on G1 for the code; G1 only changes the copy. +5. Decide Layer B after two release cycles of Layer A telemetry. + +Draft for G1: + +> Hi Jinjing — for the Documents-folder terminal issue, could you run three quick things inside +> the affected Orca terminal and paste the outputs? (1) +> `python3 -c "import os; list(os.scandir(os.path.expanduser('~/Documents')))"`. (2) In Orca: +> Settings → Terminal → Manage Sessions → Restart daemon, open a new terminal, run (1) again. +> (3) Only if (2) still fails: `tccutil reset SystemPolicyDocumentsFolder com.stablyai.orca`, +> allow the folder when macOS asks, run (1) once more. Which step made it work tells us where the +> permission is breaking. Thanks! + +## 5. Non-goals + +Automatic restart, continuous polling, parsing arbitrary terminal output, Python or shell-command +fallbacks, failed-admission and restore triggers (the verdict exists only on a completed spawn; +a rejected folder-workspace admission is an app-side failure, not a mismatch), and any claim of +proven TCC corruption. diff --git a/docs/plans/validation/README.md b/docs/plans/validation/README.md index ecc6ab15970..6cd11901c7d 100644 --- a/docs/plans/validation/README.md +++ b/docs/plans/validation/README.md @@ -89,3 +89,82 @@ since permission denial can prevent creation. The rewritten design includes that The shipping gate remains: affected ordinary terminal denied, final packaged helper denied, Electron main succeeds, then terminal/helper reads succeed after explicit restart. + +--- + +# 2026-09-21 live evidence (production adopted daemon) + +Scripts: [`live-2026-09-21/`](live-2026-09-21/). Full raw report (TCC rows, ps trees, logs) is +kept outside the repo at `~/orca-lanes/sta-7948-evidence-20260921/report.md` on the collecting +machine because it contains account paths. + +## A. `access(2)` versus enumeration under TCC (`access_vs_scandir.py`) + +Run as a launchd-owned `/usr/bin/python3` via `launchctl submit` (responsible pid = itself, no +TCC row). No `tccutil`, no window; the job was removed afterwards. + +| Target | `access(R_OK\|X_OK)` | `stat` | `scandir` | +| --- | --- | --- | --- | +| `~/Documents` | **True** | ok | **EPERM** | +| `~/Library/Safari` (Full Disk Access class) | False | ok | EPERM | +| `~/Library/Mail` (Full Disk Access class) | False | ok | EPERM | +| `/tmp` | True | ok | ok | + +The Documents run was executed twice with identical output. Conclusion: for the +SystemPolicyDocumentsFolder class, `accessSync(R_OK|X_OK)` passes while enumeration is denied, so +the daemon's current `cwdReadableByDaemon` verdict cannot observe the incident's failure. + +## B. Production daemon, healthy state (`probe-daemon.mjs`) + +Target: the live adopted daemon (pid 1232, protocol v36, spawned by app 1.4.207-adhoc while the +running app was 1.4.206; `spawnerExecPath` `/Applications/Orca.app/Contents/MacOS/Orca`). The +client speaks the real NDJSON protocol on the control and stream sockets with its own `clientId`, +creates one short-lived session per target with a `sta7948-evidence-*` session id, reads the +probe line, kills the session, and confirms it is absent from `listSessions`. + +| Target | daemon `cwdReadableByDaemon` (accessSync) | login-wrapped shell `scandir` | control shell | +| --- | --- | --- | --- | +| `~/Documents` | true | ok (5 entries) | ok | +| `/tmp` | true | ok | ok | +| `~/Library/Safari` | true | ok (36 entries) | ok | + +Five sessions were created; all exited 0; session count was 43 before and after. No process was +killed, signalled, or restarted; no window was shown. + +## C. TCC responsible-process identity (`resp.c`) + +`responsibility_get_pid_responsible_for_pid` (dlsym) for the process tree: + +``` +86248 -> 86248 Orca main +25437 -> 86248 Orca Helper (Renderer), child of Orca main (probe control: children resolve to main) +86324 -> 86248 /usr/bin/log forked by Orca main (probe control) +1232 -> 1232 production daemon (Orca Helper) +123 -> -1 /usr/bin/login (root; query not permitted) +162 -> 162 -/bin/zsh under that login +73362 -> 73362 -/bin/zsh under another login +73502 -> 73502 claude under that zsh +``` + +The daemon and every login-wrapped shell resolve to themselves, never to Orca main. Yet the +shells read `~/Library/Safari`, which only `com.stablyai.orca` holds Full Disk Access for +(`com.stablyai.orca.helper`, the daemon's own identifier, has no TCC row). The shells therefore +carry Orca's grant through the daemon, not through a pid-level link this API exposes. The +`ps` tree is `daemon -> /usr/bin/login -> -/bin/zsh -> agent CLIs`; the bash trampoline `exec`s +away and never appears. + +## D. Other facts + +- All 1734 `macos-tcc-pty-spawn` events in the local daemon log carry `strategy: "wrapped"`. +- `daemon_pty_cwd_denied` has never fired on this machine and no `cwdReadableByDaemon=false` + appears in `daemon.log` or `main.trace.ndjson`; per section A this is uninformative for the + Documents class. +- `/Applications/Orca.app` and `Orca Helper.app` are signed Developer ID, TeamIdentifier + `6CX3WHS9HZ`, hardened runtime. + +## What this still does not prove + +- The broken state was not observed; sections B and C describe the healthy state only. +- Whether the daemon's in-process `opendir` and its shells' reads diverge when the lineage is + broken (gate G1 in the design). +- Whether restart alone recovers, or restart plus `tccutil reset` and re-allow is required (G1). diff --git a/docs/plans/validation/live-2026-09-21/access_vs_scandir.py b/docs/plans/validation/live-2026-09-21/access_vs_scandir.py new file mode 100644 index 00000000000..474c980a2a0 --- /dev/null +++ b/docs/plans/validation/live-2026-09-21/access_vs_scandir.py @@ -0,0 +1,15 @@ +import os, sys, errno +targets = [os.path.expanduser('~/Documents'), '/tmp'] +print('pid', os.getpid(), 'ppid', os.getppid()) +for t in targets: + acc = os.access(t, os.R_OK | os.X_OK) + try: + n = sum(1 for _ in os.scandir(t)) + sc = f'ok {n}' + except OSError as e: + sc = f'err {e.errno} {errno.errorcode.get(e.errno)} {e.strerror}' + try: + os.stat(t); st = 'ok' + except OSError as e: + st = f'err {e.errno}' + print(f'{t}: access(R|X)={acc} stat={st} scandir={sc}') diff --git a/docs/plans/validation/live-2026-09-21/probe-daemon.mjs b/docs/plans/validation/live-2026-09-21/probe-daemon.mjs new file mode 100644 index 00000000000..e6f65ee54a6 --- /dev/null +++ b/docs/plans/validation/live-2026-09-21/probe-daemon.mjs @@ -0,0 +1,97 @@ +import net from 'node:net' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import crypto from 'node:crypto' + +const RUNTIME = path.join(os.homedir(), 'Library/Application Support/orca/daemon') +const SOCK = path.join(RUNTIME, 'daemon-v36.sock') +const TOKEN = fs.readFileSync(path.join(RUNTIME, 'daemon-v36.token'), 'utf8').trim() +const VERSION = 36 +const CLIENT_ID = 'sta7948-evidence-' + crypto.randomBytes(6).toString('hex') +const TARGET = process.argv[2] +const LABEL = process.argv[3] || 'probe' + +function connect(role) { + return new Promise((resolve, reject) => { + const s = net.createConnection(SOCK) + const lines = [] + let buf = '' + const handlers = [] + s.setEncoding('utf8') + s.on('data', (c) => { + buf += c + let i + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1) + if (!line.trim()) continue + let msg; try { msg = JSON.parse(line) } catch { continue } + lines.push(msg) + for (const h of handlers) h(msg) + } + }) + s.on('error', reject) + s.on('connect', () => { + s.write(JSON.stringify({ type: 'hello', version: VERSION, token: TOKEN, clientId: CLIENT_ID, role }) + '\n') + handlers.push(function onHello(m) { + if (m.type === 'hello') resolve({ socket: s, hello: m, lines, handlers }) + }) + }) + }) +} + +const rpcWaiters = new Map() +function rpc(ctl, type, payload) { + const id = 'req_' + crypto.randomBytes(6).toString('hex') + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('rpc timeout ' + type)), 20000) + rpcWaiters.set(id, (m) => { clearTimeout(t); resolve(m) }) + ctl.socket.write(JSON.stringify({ id, type, payload }) + '\n') + }) +} + +const ctl = await connect('control') +console.log('HELLO_CONTROL', JSON.stringify(ctl.hello)) +ctl.handlers.push((m) => { if (m.id && rpcWaiters.has(m.id)) { const f = rpcWaiters.get(m.id); rpcWaiters.delete(m.id); f(m) } }) + +const stream = await connect('stream') +console.log('HELLO_STREAM', JSON.stringify(stream.hello)) + +const sessionId = 'sta7948-evidence-' + crypto.randomBytes(8).toString('hex') +let out = '' +let exited = null +stream.handlers.push((m) => { + if (m.type !== 'event' || m.sessionId !== sessionId) return + if (m.event === 'data') out += m.payload.data + if (m.event === 'exit') exited = m.payload + if (m.event === 'terminalError') console.log('TERMINAL_ERROR', JSON.stringify(m.payload)) +}) + +const PY = `import os,sys\ntry:\n n=sum(1 for _ in os.scandir(${JSON.stringify(TARGET)}))\n print('ORCA_PROBE ok', n)\nexcept OSError as e:\n print('ORCA_PROBE err', e.errno, e.strerror)\n` +const command = `/usr/bin/python3 ${JSON.stringify(path.join(process.env.HOME, "orca-lanes/sta-7948-evidence-20260921/scandir_probe.py"))} ${JSON.stringify(TARGET)}; exit` + +const created = await rpc(ctl, 'createOrAttach', { + sessionId, cols: 80, rows: 24, cwd: TARGET, command, + startupCommandDelivery: 'shell-ready', shellReadySupported: true, shellReadyTimeoutMs: 8000, + cancelAfterMs: 20000 +}) +console.log('CREATE_RESULT', JSON.stringify(created)) + +const deadline = Date.now() + 25000 +while (Date.now() < deadline && !(out.includes('ORCA_MARKER_END') && exited)) { + await new Promise((r) => setTimeout(r, 250)) +} +console.log('EXIT_EVENT', JSON.stringify(exited)) +console.log('--- OUTPUT BEGIN ---') +console.log(JSON.stringify(out.slice(-4000))) +console.log('--- OUTPUT END ---') +const probeLines = out.split(/\r?\n/).filter((l) => l.includes('ORCA_PROBE')) +console.log('PROBE_LINES', JSON.stringify(probeLines)) + +try { console.log('KILL', JSON.stringify(await rpc(ctl, 'kill', { sessionId }))) } catch (e) { console.log('KILL_ERR', String(e)) } +const list = await rpc(ctl, 'listSessions', {}) +const mine = (list.payload?.sessions ?? []).filter((s) => s.sessionId === sessionId) +console.log('LIST_AFTER_KILL_MINE', JSON.stringify(mine)) +console.log('LIST_TOTAL', (list.payload?.sessions ?? []).length) +ctl.socket.destroy(); stream.socket.destroy() +process.exit(0) diff --git a/docs/plans/validation/live-2026-09-21/resp.c b/docs/plans/validation/live-2026-09-21/resp.c new file mode 100644 index 00000000000..3021dcf32b6 --- /dev/null +++ b/docs/plans/validation/live-2026-09-21/resp.c @@ -0,0 +1,21 @@ +#include +#include +#include +#include +typedef pid_t (*rfn)(pid_t); +int main(int argc, char **argv){ + rfn f = (rfn)dlsym(RTLD_DEFAULT, "responsibility_get_pid_responsible_for_pid"); + const char *src = "RTLD_DEFAULT"; + if(!f){ + void *h = dlopen("/usr/lib/system/libquarantine.dylib", RTLD_LAZY); + if(h){ f=(rfn)dlsym(h,"responsibility_get_pid_responsible_for_pid"); src="libquarantine"; } + } + if(!f){ fprintf(stderr,"SYMBOL_NOT_FOUND: %s\n", dlerror()?dlerror():"(null)"); return 2; } + fprintf(stderr,"symbol via %s\n", src); + for(int i=1;i %d\n", p, r); + } + return 0; +}