name: adom-desktop-permissions description: > How the Adom Bridge human-approval permission gate works for an AI caller. Read this when a verb refuses with errorCode needs_approval or reason_required, when you need shell approval / bypass permissions on a machine, when you must call request_shell_approval to surface the human approval dialog, when a call demands a reason arg, or before an unattended run that touches shell_execute, run_script, write_file, or any other gated verb. Covers the human approval gate, the Approve 1 hr / 24 hr / Bypass Permissions buttons, why grants are human-only, and the macOS TCC permissions (Screen Recording, Accessibility) that sit underneath Bridge's own gate.

Adom Bridge permissions: the human approval gate for AI callers

The trust model in one paragraph

Bridge binds 127.0.0.1 only, and installing Bridge (or a bridge plugin) is the user's consent to be driven, so every LOCAL caller (the CLI on the same box, Adom Hydrogen, and bridges on the loopback direct API) is trusted by transport and never gated. The approval gate applies to exactly one origin: the remote relay / cloud-AI path (via_relay), a thread driving the machine from off-box that the user may not be watching. If you are calling over the relay, the gated verbs below require a human grant. Read-only verbs (screenshots, status, window and UI reads, discovery) are never gated.

Which verbs gate

The gated class is "runs code / writes / deletes / kills processes / synthesizes input":

  • Arbitrary code execution: shell_execute, run_script (bash / zsh / sh), launch_process, wiki_exec
  • Filesystem: write_file, delete_file
  • Process control: process_kill
  • Synthetic input (drive the machine as the user): desktop_click / desktop_type / desktop_press_key and the Accessibility (AX) action verbs; these ride the SAME grant window as shell

One human grant covers the whole class. A bridge's own exec verbs (browser_evaluate, nbrowser_eval, blender_run_python, fusion_run_modeling_script, ...) gate per the bridge's declared risk field, independently of this class; check that bridge's describe output, not this doc.

The reason requirement: every gated call must explain itself

Every permission-class verb sent over the relay MUST carry a reason arg: one plain sentence saying WHY you need this. The human sees it verbatim in the approval prompt, attributed as "The AI says: ..." (omitting it shows "(no reason given)", which a cautious user should decline). It also lands in Bridge's activity log.

adom-desktop shell_execute '{"command":"brew install kicad",
  "reason":"Installing KiCad so I can export your gerbers"}'

Write the reason for the human, not for the log: say what you are trying to accomplish, not what the command does. Good: "Installing the KiCad CLI so I can export your gerbers". Weak: "running npm install" (that is just the command again). The reason is capped at 300 chars and never replaces the command preview; the human always sees both.

A permission-class call with NO reason is refused before any approval path:

{
  "success": false,
  "errorCode": "reason_required",
  "error": "'write_file' needs human permission, so it REQUIRES a `reason`. Retry the SAME call with reason:\"...\".",
  "retryWith": { "reason": "<why you need this, in one plain sentence>" },
  "appliesTo": ["shell_execute", "run_script", "launch_process", "wiki_exec", "write_file", "delete_file", "process_kill"]
}

The rule is class-based and flat: the verbs in appliesTo ALWAYS carry a reason (whether or not a grant is currently active); verbs not in that list never need one. Learn it once: do not pay the round-trip again, and do not add reason to ungated verbs.

What a refusal looks like

When no grant is active, a gated relay call refuses with errorCode: "needs_approval" plus a structured approval block; you never need to read ~/.adom/config.json:

{
  "success": false,
  "errorCode": "needs_approval",
  "error": "'write_file' needs shell approval - it runs code / writes / deletes / kills a process (a Tier-2 verb, as dangerous as shell_execute).",
  "approval": {
    "required": true,
    "granted": false,
    "class": "shell",
    "verb": "write_file",
    "grantedBy": "human-only",
    "grantVerb": "request_shell_approval",
    "howToGrant": ["Ask the user to click Approve (1 hr / 24 hr) or Bypass Permissions ...",
                   "Call request_shell_approval to re-surface the prompt ...",
                   "The user can tick Settings > Shell Commands > Bypass Permissions ..."]
  }
}

The refusal itself already surfaced a human-facing prompt on the machine: the Bridge window SUMMONS ITSELF to show the approval dialog (Bridge is normally a menu-bar agent with no window; an approval flips it from Accessory to Regular, shows the window, and activates it), and a Notification Center notification fires as well. If the human actively clicks Deny (embedded mode), you get errorCode: "denied_by_user" instead; do NOT auto-retry that one, ask the user what they want.

The correct AI flow on a gate hit

  1. Surface the prompt (the gate already did once; re-surface on demand):

    adom-desktop request_shell_approval
    

    This is ungated; it only SURFACES, never grants. It re-shows the approval dialog and brings the Bridge window to the front, so "there is nothing to click" is never true.

  2. Ask the human (in your conversation) to click Approve 1 hr, Approve 24 hr, or Bypass Permissions in the Adom Bridge window.

  3. RETRY the original verb unchanged (with its reason). One grant covers the whole exec/write/kill/input class for the granted window.

Grants are HUMAN-ONLY: you cannot self-grant

Granting is permanently human-only. The only grant paths are a human clicking in Bridge: the approval dialog's Approve buttons and the Settings checkbox. A GRANT attempt via shell_auto_approve / set_shell_auto_approve from a relay (cloud-AI) caller is refused with errorCode: "human_only" and auto-surfaces the human prompt instead. Your only lever is request_shell_approval; it TRIGGERS the human decision, it does not perform it.

The one exception: a REVOKE is allowed for any caller (de-escalation is always safe):

adom-desktop shell_auto_approve '{"duration_secs":0}'   # clears the checkbox AND the timer

Do not look for a workaround. launch_process is not a bypass of shell_execute (it gates identically), and synthetic input (clicking the Approve button yourself) rides the same grant. If the AI could grant, the gate would be theater.

The durable Settings checkbox

Settings > Shell Commands > Bypass Permissions (config shell_auto_approve_permanent, default OFF) is the durable indefinite grant, for machines the user fully trusts. The timed buttons (1 hr / 24 hr) set an in-memory window instead. Unticking the checkbox, or shell_auto_approve {"duration_secs":0}, returns to per-command approval instantly.

Embedded mode: Adom Hydrogen owns the decision

When Bridge runs embedded under Adom Hydrogen (Hydrogen passed auto_approve in POST /embedded/enter), the human's app is Hydrogen and Bridge shows no UI of its own. Each gated relay command is FORWARDED to Hydrogen's /permission/request; Hydrogen renders the prompt (with your reason and a decoded command preview) and returns the decision:

  • allow_once: run this one command, no window
  • allow_1h / allow_24h: set a timed grant window and run
  • bypass_while_running: session-length grant (clears on restart)
  • deny: you get errorCode: "denied_by_user"; do not auto-retry

While forwarding is active, a standing grant (flag / checkbox / timer) does NOT suppress the forward; Hydrogen decides every time. If Hydrogen is unreachable, Bridge falls back to its own dialog. auto_approve: true means the user opted in to auto-approval.

macOS TCC permissions (underneath Bridge's gate)

Bridge's approval gate governs WHO may drive the machine; macOS's own TCC privacy system separately governs what the Bridge PROCESS may do, and no Bridge grant can override it. In System Settings > Privacy & Security the app appears as Adom Bridge:

  • Screen Recording: required for window and screen screenshots. Without it, captures come back blank or fail.
  • Accessibility: required for synthetic input (CGEvent mouse/keyboard) and the AX read/action verbs. Without it, gated input verbs fail with a clear error naming the missing grant (macOS silently ignores CGEvent posting otherwise, so Bridge checks AXIsProcessTrusted first and refuses loudly).

If a verb says the Accessibility or Screen Recording permission is missing, that is a one-time human toggle in System Settings, not something request_shell_approval can grant.

Check state up front with status

Before an unattended run, read status.permissions.shell so you know whether you will stall on a human gate:

adom-desktop status
"permissions": {
  "shell": {
    "autoApproved": false,
    "willPrompt": true,
    "source": "none",              // "permanent" | "timed" | "none"
    "expiresInSec": null,
    "grantedBy": "human-only",
    "grantVerb": "request_shell_approval",
    "gatedFor": "relay (cloud-AI) callers only",
    "embeddedPolicy": "standalone" // "forward-to-hd" | "auto-approve" | "standalone"
  },
  "bridgeExec": { "note": "bridge exec verbs gate per the bridge's declared risk field" }
}

If willPrompt is true and no human is at the machine, get the grant FIRST (request_shell_approval plus asking the user) before starting the run.

Quick reference

Situation What you do
errorCode: reason_required Resend the SAME call plus reason:"<one plain sentence>"
errorCode: needs_approval request_shell_approval, ask the human to click Approve, retry
errorCode: human_only You tried to self-grant. Ask the human instead
errorCode: denied_by_user The human said no. Do not auto-retry; ask them
A capture/input verb reports a missing macOS permission Ask the user to enable Adom Bridge under System Settings > Privacy & Security (Screen Recording or Accessibility)
Planning an unattended run Check status.permissions.shell.willPrompt first
Need to drop a grant shell_auto_approve {"duration_secs":0} (allowed for any caller)