Let an agent holding only EMCLI_KEY discover accounts via `account list`, exposing name/from/can_send (not host/username); admin keeps the full text table. account add/edit/remove stay admin-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6.4 KiB
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/removeadmin-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:
- Scope is exactly
account list.whitelist list,config get, andaudit liststay admin-only.auditin particular is oversight data and must remain invisible to the agent. - 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. - Agent output is JSON. The agent is a machine consumer, so its
account listemits the standard agent envelope (likelist/get/search), not a text table. The admin path stays human-readable text. - No secret exposure, no schema change.
ListAccountsalready 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→RoleAgentaccount add | edit | remove(and bareaccount) →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:
where
{"error":false,"error_detail":{},"data":{"accounts":[ {"name":"work","from":"me@example.com","can_send":true}, {"name":"alerts","from":"alerts@example.com","can_send":false} ]}}from = Account.SendFrom()(the configured From address, falling back to the username) andcan_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 listwith 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 onaccount listis a JSONFailure(CodeConfig, …)envelope, consistent with the other agent commands. - DB/list errors on the agent path →
Failure(CodeDB, …)envelope; on the admin path → existinglist: <err>text to stderr.
Testing
- Routing: extend the
commandRoletable test —account list→ agent;account add/account edit/account remove→ admin. - Agent view: with only
EMCLI_KEY,account listexits 0, emits a valid envelope, and thedata.accountsentries carryname/from/can_send— and the output does not contain the IMAP host or the login username. - Admin view: with
EMCLI_ADMIN_KEY,account liststill prints the fullNAME MODE IMAP USERtable (regression guard). can_send: an RW account yieldscan_send:true, an RO accountcan_send:false;fromreflectsSendFrom()(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 mutatingaccount add …attempt, so the "forced agent cannot run admin commands and the DB is byte-for-byte unchanged" invariant still covers theaccountfamily.
Documentation updates
- USER-MANUAL: role/command table —
account listis agent-readable (reduced JSON view);account add/edit/removeremain admin. skills/emcli(SKILL.md / AGENTIC-MANUAL.md): document that the agent discovers accounts viaaccount list, including the JSON shape (name,from,can_send).
Out of scope
- Agent access to
whitelist list,config get, oraudit list. - Any change to the admin
account listcolumns or to the data model. - JSON output for the admin
account listpath (stays human-readable text).