From 5476c0444357cbabf1ea2278cc64bfefb625e702 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sat, 27 Jun 2026 12:42:19 +0100 Subject: [PATCH] fix(cli): recognize account ls alias for agent role; align account show output; document edit password invariant Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/account_show.go | 2 +- internal/cli/admin.go | 4 ++++ internal/cli/run.go | 3 ++- internal/cli/run_test.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/cli/account_show.go b/internal/cli/account_show.go index c8b3918..38a0584 100644 --- a/internal/cli/account_show.go +++ b/internal/cli/account_show.go @@ -44,6 +44,6 @@ func accountShow(st *store.Store, rest []string, out, errOut io.Writer) int { fmt.Fprintf(out, "send-from: %s\n", a.SendFrom()) fmt.Fprintf(out, "subject filter: %s\n", subj) fmt.Fprintf(out, "inbound whitelist: %s\n", onOff(a.WhitelistInEnabled)) - fmt.Fprintf(out, "outbound whitelist:%s\n", onOff(a.WhitelistOutEnabled)) + fmt.Fprintf(out, "outbound whitelist: %s\n", onOff(a.WhitelistOutEnabled)) return 0 } diff --git a/internal/cli/admin.go b/internal/cli/admin.go index e5bd57c..95d0514 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -167,6 +167,10 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int { acc.SubjectRegex = *subj } }) + // GetAccount loaded the existing decrypted password into acc; fs.Visit + // overwrites acc.Password only when --password was passed; UpdateAccount + // re-seals whatever non-empty password is present, so omitting --password + // on edit preserves the existing password unchanged. if err := st.UpdateAccount(acc); err != nil { fmt.Fprintf(errOut, "edit: %v\n", err) return 1 diff --git a/internal/cli/run.go b/internal/cli/run.go index 9db8678..23b79c7 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -32,7 +32,8 @@ func commandRole(args []string) store.Role { case "account": // account list is a read-only discovery view available to agents; // add/edit/remove mutate config and require admin. - if len(args) >= 2 && args[1] == "list" { + // Normalize the verb so `account ls` routes the same as `account list`. + if len(args) >= 2 && normalizeVerb(args[1]) == "list" { return store.RoleAgent } return store.RoleAdmin diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index edc34a1..e9a04ec 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -91,3 +91,33 @@ func TestTopLevelLsAlias(t *testing.T) { t.Fatalf("ls should alias list (usage about --account): code=%d out=%q", code, out) } } + +// TestAccountLsAliasAgentRole verifies that `account ls` is treated as an agent +// command (not admin) so a caller with only EMCLI_KEY can use it and gets the +// same reduced-JSON envelope as `account list`. +func TestAccountLsAliasAgentRole(t *testing.T) { + adminEnv(t) // sets up both keys + initialized temp DB + run(t, "account", "add", "work", "--mode", "RW", + "--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com", + "--username", "login@example.com", "--from", "me@example.com") + + // Drop the admin key — caller is agent-only. + t.Setenv("EMCLI_ADMIN_KEY", "") + + // `account ls` must succeed with the reduced JSON view, same as `account list`. + code, out, errOut := run(t, "account", "ls") + if code != 0 { + t.Fatalf("agent account ls should succeed: code=%d out=%q err=%q", code, out, errOut) + } + var env map[string]any + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("account ls output is not JSON: %v\n%s", err, out) + } + if env["error"] == true { + t.Fatalf("account ls returned error envelope: %s", out) + } + // The agent view must not leak IMAP host or login username. + if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") { + t.Fatalf("account ls leaked host/username:\n%s", out) + } +}