diff --git a/docs/superpowers/specs/2026-06-23-agent-account-list-design.md b/docs/superpowers/specs/2026-06-23-agent-account-list-design.md new file mode 100644 index 0000000..2de6c17 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-agent-account-list-design.md @@ -0,0 +1,141 @@ +# Agent-readable `account list` — design + +**Date:** 2026-06-23 +**Status:** Approved (brainstorm), ready for implementation plan +**Author:** Steve + Claude + +## Problem + +An agent process is launched with only `EMCLI_KEY` (the two-key model — see +`2026-06-22-two-key-privilege-design.md`). Every agent command takes +`--account NAME`, but the agent has no way to *discover* which accounts exist: +`account list` is classified admin-only and is refused under the agent key. So +account names must be supplied to the agent out of band, which is brittle and +defeats the point of a self-directed agent. + +The two-key spec deliberately gated the whole `account` command to admin because +`account add/edit/remove` mutate configuration. But `account list` is read-only +and exposes no secrets — `store.ListAccounts` never decrypts the password +(`enc_password` is scanned and discarded). Gating *discovery* behind admin is +stricter than the threat model requires. + +## Goal + +Let an agent holding only `EMCLI_KEY` run `account list` to discover the +accounts it may operate on, while: + +- keeping `account add/edit/remove` admin-only (mutation stays gated); +- exposing to the agent only what it needs — **account name, the send-as From + address, and whether the account can send** — and *not* the IMAP host/port or + login username; +- preserving the admin's existing full-detail view unchanged. + +## Constraints / decisions + +Settled during brainstorming: + +1. **Scope is exactly `account list`.** `whitelist list`, `config get`, and + `audit list` stay admin-only. `audit` in particular is oversight data and + must remain invisible to the agent. +2. **Privilege-dependent rendering.** The admin keeps the current full table + (`NAME MODE IMAP USER`, human-readable). The agent gets a *reduced* view + containing only name, From, and send-capability. +3. **Agent output is JSON.** The agent is a machine consumer, so its + `account list` emits the standard agent envelope (like `list`/`get`/`search`), + not a text table. The admin path stays human-readable text. +4. **No secret exposure, no schema change.** `ListAccounts` already avoids + decrypting passwords; nothing about the data model changes. + +## Approach + +Reclassify `account list` to the agent role, and branch rendering on the +caller's actual privilege (presence of the admin key). + +### Routing (`internal/cli/run.go`) + +`commandRole` becomes subcommand-aware for `account`: + +- `account list` → `RoleAgent` +- `account add | edit | remove` (and bare `account`) → `RoleAdmin` +- all other commands unchanged. + +`commandRole` currently takes `cmd string`; it changes to take the full +`args []string` so it can peek at the `account` subcommand. `Run` passes +`args` through. This keeps `commandRole` the single source of truth for the +classification table. + +Authorization mechanics are unchanged: `openStore(RoleAgent)` requires +`EMCLI_KEY` (falling back to the admin key for a human who holds only that and +runs `account list`). `account add/edit/remove` still hard-require +`EMCLI_ADMIN_KEY` with no fallback. + +### Rendering (`internal/cli/admin.go`, the `list` branch) + +Determine privilege from the environment: `_, err := crypto.AdminKeyFromEnv()`; +`isAdmin := err == nil`. (Holding the admin key *is* being the admin in this +trust model. A human with only the admin key still gets the admin view; an agent +with only `EMCLI_KEY` gets the reduced view.) + +- **Admin** → existing full table, unchanged: + ``` + NAME MODE IMAP USER + work RW imap.example.com:993 me@example.com + ``` +- **Agent** → JSON envelope to stdout: + ```json + {"error":false,"error_detail":{},"data":{"accounts":[ + {"name":"work","from":"me@example.com","can_send":true}, + {"name":"alerts","from":"alerts@example.com","can_send":false} + ]}} + ``` + where `from = Account.SendFrom()` (the configured From address, falling back + to the username) and `can_send = (Mode == "RW")` (RW accounts have SMTP + configured; RO cannot send). The IMAP host/port and the raw login username are + **not** emitted (when From falls back to the username it may coincide with it, + which is acceptable — the user asked for From specifically). + +The reduced view reuses the existing `Success(...)` envelope and `Envelope.Write` +helper; no new output machinery. + +## Error handling + +- Agent key on `account add/edit/remove` → unchanged: + `emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`, non-zero. +- Agent `account list` with neither key set → the agent-command config error + (`EMCLI_KEY is not set`), surfaced the same way other agent commands surface a + missing key. Because the agent path now emits JSON, a missing-key failure on + `account list` is a JSON `Failure(CodeConfig, …)` envelope, consistent with the + other agent commands. +- DB/list errors on the agent path → `Failure(CodeDB, …)` envelope; on the admin + path → existing `list: ` text to stderr. + +## Testing + +- **Routing:** extend the `commandRole` table test — `account list` → agent; + `account add` / `account edit` / `account remove` → admin. +- **Agent view:** with only `EMCLI_KEY`, `account list` exits 0, emits a valid + envelope, and the `data.accounts` entries carry `name`/`from`/`can_send` — and + the output does **not** contain the IMAP host or the login username. +- **Admin view:** with `EMCLI_ADMIN_KEY`, `account list` still prints the full + `NAME MODE IMAP USER` table (regression guard). +- **`can_send`:** an RW account yields `can_send:true`, an RO account + `can_send:false`; `from` reflects `SendFrom()` (explicit From, else username). +- **Security invariant (`security_invariant_test.go`):** remove + `{"account","list"}` from the refused-commands set (it is now allowed) and + replace it with a *mutating* `account add …` attempt, so the "forced agent + cannot run admin commands and the DB is byte-for-byte unchanged" invariant + still covers the `account` family. + +## Documentation updates + +- **USER-MANUAL:** role/command table — `account list` is agent-readable + (reduced JSON view); `account add/edit/remove` remain admin. +- **`skills/emcli` (SKILL.md / AGENTIC-MANUAL.md):** document that the agent + discovers accounts via `account list`, including the JSON shape + (`name`, `from`, `can_send`). + +## Out of scope + +- Agent access to `whitelist list`, `config get`, or `audit list`. +- Any change to the admin `account list` columns or to the data model. +- JSON output for the admin `account list` path (stays human-readable text).