docs(macos): finalize the STA-7948 daemon folder-access plan on live evidence

Replace the helper-first recovery design with a two-layer plan: an in-daemon
opendir verdict plus a notice on the existing attribution poll (ship now), and
the helper probe for already-deployed old daemons (evidence-gated). Record the
2026-09-21 measurements: on Documents, access(2) passes while opendir gets
EPERM, so the current accessSync verdict is blind to the incident's folder
class; daemon-spawned login shells carry the daemon's grant; the login wrapper
is unconditional in production.
This commit is contained in:
Jinwoo-H
2026-09-21 00:59:44 -04:00
parent 56f816936e
commit f7e129ffe6
5 changed files with 364 additions and 324 deletions
+152 -324
View File
@@ -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.
+79
View File
@@ -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).
@@ -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}')
@@ -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)
@@ -0,0 +1,21 @@
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <sys/types.h>
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<argc;i++){
pid_t p=(pid_t)atoi(argv[i]);
pid_t r=f(p);
printf("%d -> %d\n", p, r);
}
return 0;
}