Skip to content

feat(chat): surface macOS TCC denials as in-card affordance#406

Merged
ljagiello merged 2 commits into
mainfrom
tcc-affordance-card
May 20, 2026
Merged

feat(chat): surface macOS TCC denials as in-card affordance#406
ljagiello merged 2 commits into
mainfrom
tcc-affordance-card

Conversation

@ljagiello
Copy link
Copy Markdown
Contributor

Summary

When the chat agent's run_code (or file-io) tool hits Operation not permitted against a TCC-protected user folder (~/Downloads, ~/Desktop, ~/Documents), the chat now renders a yellow macOS permission needed card with a one-click deeplink to System Settings → Privacy & Security → Files & Folders.

Before: agent saw the raw shell error, dumped it at the user, and asked them to debug it.

After: structured affordance the model can point at — and a clickable surface the user can act on directly.

Scope: macOS only. On Linux/Windows the same error usually means a real ACL issue and the raw error is the right signal — the detector is gated on process.platform === "darwin".

Edge case clarification: this card is the fallback for the already-denied state (user previously clicked Don't Allow, or revoked via System Settings → auth_value=0 in TCC.db). The first-encounter path is still handled by macOS's native consent dialog.

What's in the change

  • packages/system/agents/workspace-chat/tools/tcc-detect.ts — pure detector. Recognizes three error formats (find:, ls:, Python PermissionError), returns a structured TccDenial with deeplink + optional mv suggestion. 12 unit tests.
  • code-exec.ts — attaches tcc_denied?: TccDenial to RunCodeSuccess when the script exited non-zero and the stderr matches.
  • file-io.ts — same wiring via a shared toFileErr helper. Forward-defensive — the existing path-resolution gates keep these tools inside the scratch dir, so TCC isn't reachable today.
  • tools/agent-playground/.../tcc-denied-card.svelte — the actual card component, 4 SSR tests.
  • tool-call-card.svelte — typed readTccDenied extractor + render branch.
  • tool-call-utils.ts — extends needsUserAction to recognize tcc_denied, which is what auto-opens the tool-burst <details> so the card is actually visible.

QA

Real-TCC reproduction on a Tahoe VM (192.168.64.17) with the daemon's Downloads grant revoked via tccutil reset:

# Case Result
1 Unit prechecks (detector, file-io, card SSR) 18/18 PASS
2 Build + deploy to VM, launcher bounced, hashes verified PASS
3 ls ~/Downloads → card renders with eyebrow, guidance, path, Settings button PASS
4 ls ~/Downloads/qa-test → both buttons render; Move button writes correct mv '<src>' '<dst>' to clipboard PASS
5 Settings button payload = correct x-apple.systempreferences: deeplink (structural) PASS
6 ls /tmp (success) → no false card PASS
7 ls /nonexistent (ENOENT) → no false card PASS

QA found two bugs that are folded into this PR:

  1. UI card never renderedtool-burst wraps tool calls in a collapsed <details> and needsUserAction didn't recognize tcc_denied, so the card was in the DOM but hidden. Embarrassingly, the model could see tcc_denied in its tool result JSON and would describe "a shortcut button above" that wasn't actually on screen. Fix: extend needsUserAction.
  2. No-op mv suggestion when user attempted the protected root directly (mv '~/Downloads' '~/Downloads'). Fix: only push the Move action when attemptedPath !== root. New unit test locks this in.

Test plan

  • CI runs the 18 new unit tests (detector + card SSR + run_code wiring + file-io wiring)
  • Revoke Downloads access for Friday Studio (tccutil reset SystemPolicyDownloadsFolder ai.hellofriday.studio + click Don't Allow when prompted), then ask the chat run ls -la ~/Downloads — verify yellow card appears
  • Same with ~/Desktop and ~/Documents to cover the other two protected dirs
  • Confirm clean runs (ls /tmp) and ENOENT runs (ls /nonexistent) don't trigger the affordance

When run_code (or a future file-io variant) hits "Operation not permitted"
against a TCC-protected user folder (~/Downloads, ~/Desktop, ~/Documents),
the chat now renders a yellow "macOS permission needed" card with the
attempted path and a one-click deeplink to System Settings → Privacy &
Security → Files & Folders. For sub-paths under the protected root we also
offer a copy-to-clipboard mv command. The model used to be left dumping
the raw shell error at the user; now it has a structured affordance to
point at.

Scope is macOS only — on Linux "Operation not permitted" usually means
a real chmod/ACL issue and the raw error is the right signal. The
detector is gated on process.platform === "darwin".

The card is the fallback for the already-denied state (user previously
clicked Don't Allow, or revoked via System Settings — auth_value=0 in
TCC.db). The first-encounter path is still handled by macOS's native
consent dialog.

QA validation (real-TCC repro on a Tahoe VM with the daemon's Downloads
grant revoked via tccutil) found two bugs that are folded into this commit:

  1. UI card never rendered: tool-burst wraps tool calls in a collapsed
     <details>, and needsUserAction didn't recognize tcc_denied so the
     burst stayed closed. The card was in the DOM but invisible —
     embarrassingly, the model could see tcc_denied in its tool result
     JSON and would describe "a shortcut button above" that wasn't
     actually on screen.
  2. No-op mv suggestion: when the user attempted the protected root
     itself (ls ~/Downloads), the suggested action was
     mv '~/Downloads' '~/Downloads'. Now suppressed when attemptedPath
     === protectedRoot.
@ljagiello ljagiello requested a review from Vpr99 as a code owner May 20, 2026 05:01
…dexedAccess

CI flagged two real issues on PR #406:

1. test (vitest): tcc-denied-card.test.ts couldn't load because importing
   `Button, IconSmall from "@atlas/ui"` transitively pulls in the table
   barrel → `@tanstack/svelte-table` → an extensionless `createTable.svelte`
   re-export that Node ESM can't resolve under vitest. The SvelteKit build
   pipeline handles this fine; only the test runtime trips. Fix: ditch the
   barrel import and render plain HTML — the card already owns its styling
   so there's no design-system regression.

2. type-check (deno check): `result?.actions[N].payload` is `undefined`
   when the array index is out of range (noUncheckedIndexedAccess). Added
   the second `?.` chain on the four affected lines in tcc-detect.test.ts.

Both fixes are local to this PR's surface — no changes to the detector
itself or the runtime behavior. All 16 unit tests still pass.
@ljagiello ljagiello merged commit 8e72f0f into main May 20, 2026
8 of 9 checks passed
@ljagiello ljagiello deleted the tcc-affordance-card branch May 20, 2026 05:22
@ljagiello ljagiello mentioned this pull request May 20, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant