Skip to content

Credential approvals

The headline feature: shed-host-agent delegates credential-approval decisions to shed-desktop over a local Unix-domain socket, and streams an all-namespace audit feed the app surfaces in Activity. The app holds no credentials — only request metadata crosses the socket, and the agent stays the sole credential holder.

Configuring for shed-desktop

In the host agent's ~/.config/shed/extensions.yaml, set the approval.policy of each extension you want the app to decide to shed-desktop:

ssh:
  approval:
    policy: shed-desktop      # SSH approvals decided in the app (interactive)
aws:
  approval:
    policy: shed-desktop      # optional — a live Allow/Deny toggle in the app
docker:
  approval:
    policy: shed-desktop      # optional — a live Allow/Deny toggle in the app

# Optional — how long the agent waits for the app to decide before failing
# closed (deny). Go duration, default 25s.
# approval_timeout: 25s

The agent always serves the approval socket at a fixed path, so there is nothing else to enable. (shed-host-agent v0.4.0 removed the old desktop.enabled / socket_path / timeout_ms keys; a config that still sets them loads with a deprecation warning.)

Restart the host agent, then launch shed-desktop. The Approvals pane header shows gate: shed-desktop once connected; if the agent is down it shows host agent not connected (also observable over IPC as ui.state.host_agent_connected).

It is default-off: an extension whose policy isn't shed-desktop is handled by the agent itself (deny-all, approve-all, or native Touch ID for SSH) and is audit-only to the app — its events still stream to Activity. The agent advertises which extensions it delegates in hello_ack.gate_namespaces; Preferences shows an approval section for exactly those.

Policy (per provider)

Each request is decided by the PolicyEngine, most-specific match first:

session grant  >  per-(server,shed) rule  >  per-provider rule

Configured in Preferences, per delegated provider:

  • SSH — a Method (Touch ID or password / Touch ID only / Prompt) plus a default decision (pre-fills the card) and Duration. An incoming SSH sign shows a card with one decision dropdown, ordered most → least permissive:
    • Always Allow — persistent auto-approve rule for the shed (survives restart).
    • Per Shed Allow — auto-approve the shed until the app restarts (so each shed prompts once, until you restart).
    • Time Based Allow — auto-approve for the duration (default 2h).
    • Always Ask — approve this request and prompt again next time.
    • Always Deny — persistent auto-deny rule.

Changing any SSH setting clears the live in-memory grants, so the new policy takes effect on the next request. The fingerprint icon appears only for the biometric methods (not "Prompt"). - AWS / Docker — a live Allow / Deny toggle (no prompt). Changing it takes effect immediately; no restart. - Per-shed rulesAlways allow / Always deny on a card persists a per-(server, shed) rule (managed under Preferences → Per-shed overrides). Identical shed names on different servers don't collide — rules are keyed by (server, shed). - Session grants — an in-memory grant for the chosen duration (not persisted).

When SSH prompts, an actionable Approve / Deny notification is posted so the decision is reachable without the dashboard.

Fail-closed

The agent denies a request when no app is connected, when the app doesn't answer within approval_timeout, or on a disconnect mid-request — the same outcome as an unanswered local prompt today. The app likewise auto-denies a queued request when its countdown expires.

Verifying it live

Everything above is exercised hermetically by the pytest harness against a Python fake agent (make e2e-ci). The one path a fake can't prove — the real Go agent ↔ real Swift app wire protocol — is covered by scripts/live-verify.sh:

# Non-disruptive: builds a real host-agent, starts it on a private socket with
# no plugin-bus servers, launches the real app pointed at it, and asserts the
# hello/hello_ack handshake (ui.state.host_agent_connected). Touches no brew
# service and no shed server.
./scripts/live-verify.sh --handshake

# Full SSH-sign drive: stops the brew host-agent (restored on exit), watches
# every server, and waits for you to trigger a real sign inside a shed
# (ssh in, then a git op). Approves it via shedctl, asserts the response, then
# checks fail-closed (quit the app → the next sign is denied).
./scripts/live-verify.sh --full

The script builds the agent from ../shed-extensions (override with SHED_EXTENSIONS_SRC). It is not part of CI — it needs a real VM in the loop.