# Two-Key Privilege Separation Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Enforce the agent/admin trust boundary with two environment keys — `EMCLI_ADMIN_KEY` (all commands) and `EMCLI_KEY` (agent commands only) — so a forced agent holding only `EMCLI_KEY` cannot run admin commands. **Architecture:** Envelope encryption. `init` generates a random data-encryption key (DEK) that seals all account secrets exactly as today. The DEK is stored in `settings`, sealed twice: under the admin KEK (`dek_wrap_admin`) and under the agent KEK (`dek_wrap_agent`). Admin commands unwrap the DEK from the admin slot **only** (no agent fallback); agent commands use the agent slot (falling back to the admin slot when only the admin key is present). The store's existing `s.key` field simply becomes the DEK, so `account.go` / mail crypto is untouched. **Tech Stack:** Go, `modernc.org/sqlite`, AES-256-GCM (existing `crypto.Seal`/`crypto.Open`), standard `flag` CLI. ## Global Constraints - Module path: `git.dcglab.co.uk/steve/emcli`. Packages under `internal/`. - Keys are base64-encoded **exactly 32 bytes** (AES-256). Reject anything else. - Single static CGO-free binary; `go vet ./...` must stay clean; tests pass under `-race`. - Secrets (keys, passwords, DEK) never appear on stdout, in the JSON envelope, or the audit log. - Agent commands emit exactly one JSON object on stdout; admin commands print human-readable text (never JSON). - DEK never written to disk in cleartext; wrap slots stored as base64 text in the `settings` table (`value TEXT NOT NULL`). - No migration / no schema-version gate — `init` writes wrap slots into a fresh DB (decided in spec). **Command → role mapping (single source of truth, implemented in Task 3):** | Command | Role | |---|---| | `list`, `get`, `search`, `ack`, `send`, `doctor` | agent | | `account`, `whitelist`, `config`, `audit` | admin | | `init` | bootstrap (needs **both** keys) | | `help` / no args | none (no DB access) | --- ## Task 1: crypto — named-var key loaders + DEK generation **Files:** - Modify: `internal/crypto/crypto.go` - Test: `internal/crypto/crypto_test.go` **Interfaces:** - Consumes: existing `Seal(key, plaintext []byte) ([]byte, error)`, `Open(key, blob []byte) ([]byte, error)` (unchanged — they double as DEK wrap/unwrap). - Produces: - `AgentKeyFromEnv() ([]byte, error)` — reads `EMCLI_KEY`. - `AdminKeyFromEnv() ([]byte, error)` — reads `EMCLI_ADMIN_KEY`. - `NewDEK() ([]byte, error)` — fresh random 32-byte key. - Removes: `KeyFromEnv`, `ErrNoKey`, `ErrBadKey` (only `crypto_test.go` and `internal/cli/run.go:30` reference them; the latter is rewritten in Task 3). - [ ] **Step 1: Replace the env/error section of `crypto.go`** Replace the current `var ( ErrNoKey … ); func KeyFromEnv()` block (lines ~14–30) with: ```go // keyFromEnv reads and validates a base64-encoded 32-byte AES key from the // named environment variable. Errors name the variable so callers get a // role-appropriate message. func keyFromEnv(varName string) ([]byte, error) { raw := os.Getenv(varName) if raw == "" { return nil, fmt.Errorf("%s is not set", varName) } key, err := base64.StdEncoding.DecodeString(raw) if err != nil || len(key) != 32 { return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName) } return key, nil } // AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only). func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") } // AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands). func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") } // NewDEK returns a fresh random 32-byte data-encryption key. func NewDEK() ([]byte, error) { dek := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, dek); err != nil { return nil, err } return dek, nil } ``` Update the import block to add `"fmt"` and drop `"errors"` if now unused (it is — no other `errors.` use remains after removing the sentinels; `Open` uses `errors.New` so KEEP `"errors"`). Net: add `"fmt"`, keep everything else. - [ ] **Step 2: Rewrite `TestKeyFromEnv` in `crypto_test.go`** Replace `TestKeyFromEnv` (lines ~53–69) with: ```go func TestAgentAndAdminKeyFromEnv(t *testing.T) { good := base64.StdEncoding.EncodeToString(testKey()) t.Setenv("EMCLI_KEY", good) if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 { t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err) } t.Setenv("EMCLI_ADMIN_KEY", good) if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 { t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err) } t.Setenv("EMCLI_ADMIN_KEY", "") if _, err := AdminKeyFromEnv(); err == nil || !strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") { t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err) } t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort"))) if _, err := AgentKeyFromEnv(); err == nil || !strings.Contains(err.Error(), "32 bytes") { t.Fatalf("short key: want length error, got %v", err) } } func TestNewDEKIsRandom32(t *testing.T) { a, err := NewDEK() if err != nil || len(a) != 32 { t.Fatalf("NewDEK: len=%d err=%v", len(a), err) } b, _ := NewDEK() if bytes.Equal(a, b) { t.Fatal("two DEKs must differ") } } ``` Add `"strings"` to the test imports (`bytes` and `encoding/base64` are already imported). - [ ] **Step 3: Run crypto tests, expect FAIL to PASS transition** Run: `go test ./internal/crypto/...` Expected: PASS. (If it fails to compile because `"errors"` became unused, that means `Open` no longer references it — it does, so this should not happen; if `"fmt"` is reported unused, you forgot to add a loader. Fix and re-run.) - [ ] **Step 4: Commit** ```bash git add internal/crypto/crypto.go internal/crypto/crypto_test.go git commit -m "feat(crypto): named-var key loaders (admin/agent) + NewDEK Replace KeyFromEnv with AgentKeyFromEnv/AdminKeyFromEnv reading EMCLI_KEY and EMCLI_ADMIN_KEY; add NewDEK for envelope encryption. Seal/Open double as DEK wrap/unwrap. Co-Authored-By: Claude Opus 4.8 (1M context) " ``` > Note: the repo will not fully build until Task 3 (cli still references the removed `crypto.KeyFromEnv`). The crypto package and its tests are self-contained and pass. --- ## Task 2: store — unlock/init split + DEK wrap slots **Files:** - Modify: `internal/store/store.go` - Create: `internal/store/keys.go` - Test: `internal/store/keys_test.go` **Interfaces:** - Consumes: `crypto.NewDEK`, `crypto.Seal`, `crypto.Open` (Task 1); existing `(*Store).GetSetting`, `(*Store).SetSetting`. - Produces: - `type Role int` with `RoleAgent Role = iota` and `RoleAdmin`. - `store.Open(path string) (*Store, error)` — **signature change**: drops the `key` param; store opens locked. - `(*Store).InitKeys(adminKey, agentKey []byte) error`. - `(*Store).Unlock(role Role, adminKey, agentKey []byte) error`. - `var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")`. - Note for later tasks: after `Open`, `s.key` is nil; a command MUST call `Unlock` (or `InitKeys`) before any account read/write, or `crypto.Open(s.key, …)` in `account.go` will fail. - [ ] **Step 1: Change `store.Open` to open locked (no key param)** In `internal/store/store.go`, change the signature and the struct construction: ```go // Open opens (creating if needed) the DB at path and applies the schema. // The store opens LOCKED: call InitKeys (first run) or Unlock before any // secret read/write. func Open(path string) (*Store, error) { ``` and replace `s := &Store{db: db, key: key}` with: ```go s := &Store{db: db} ``` Leave the rest of `Open` (dir creation, pragma, schema, schema_version setting) unchanged. The `key []byte` field on `Store` stays as-is (now populated by `Unlock`/`InitKeys`). - [ ] **Step 2: Write the failing test for InitKeys + Unlock** Create `internal/store/keys_test.go`: ```go package store import ( "bytes" "path/filepath" "testing" "git.dcglab.co.uk/steve/emcli/internal/crypto" ) func k(b byte) []byte { key := make([]byte, 32) for i := range key { key[i] = b } return key } func tempStore(t *testing.T) *Store { t.Helper() st, err := Open(filepath.Join(t.TempDir(), "emcli.db")) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { st.Close() }) return st } func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) { admin, agent := k(0xAA), k(0xBB) st := tempStore(t) if err := st.InitKeys(admin, agent); err != nil { t.Fatalf("InitKeys: %v", err) } // Seal a password under the DEK that InitKeys set. if _, err := st.AddAccount(Account{ Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw", }); err != nil { t.Fatalf("AddAccount: %v", err) } // Re-open and unlock via the AGENT slot. path := st.dbPath() st.Close() st2, _ := Open(path) if err := st2.Unlock(RoleAgent, nil, agent); err != nil { t.Fatalf("Unlock(agent): %v", err) } got, err := st2.GetAccount("a") if err != nil || got.Password != "pw" { t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err) } st2.Close() // Unlock via the ADMIN slot recovers the same DEK. st3, _ := Open(path) if err := st3.Unlock(RoleAdmin, admin, nil); err != nil { t.Fatalf("Unlock(admin): %v", err) } got3, err := st3.GetAccount("a") if err != nil || got3.Password != "pw" { t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err) } st3.Close() } func TestUnlockWrongKeyFails(t *testing.T) { st := tempStore(t) if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil { t.Fatal(err) } path := st.dbPath() st.Close() st2, _ := Open(path) if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil { t.Fatal("Unlock with wrong admin key must fail") } st2.Close() } func TestAdminSlotNotOpenableByAgentKey(t *testing.T) { st := tempStore(t) admin, agent := k(0xAA), k(0xBB) if err := st.InitKeys(admin, agent); err != nil { t.Fatal(err) } // RoleAdmin must use the admin slot; passing the agent key as the admin // key must fail — there is no fallback to the agent slot. if err := st.Unlock(RoleAdmin, agent, agent); err == nil { t.Fatal("agent key must not unlock the admin slot") } } func TestInitKeysIdempotentKeepsDEK(t *testing.T) { st := tempStore(t) admin, agent := k(0xAA), k(0xBB) if err := st.InitKeys(admin, agent); err != nil { t.Fatal(err) } st.AddAccount(Account{ Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw", }) // Second InitKeys must NOT regenerate the DEK (would orphan the password). if err := st.InitKeys(admin, agent); err != nil { t.Fatalf("re-InitKeys: %v", err) } got, err := st.GetAccount("a") if err != nil || got.Password != "pw" { t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err) } _ = bytes.Equal // keep import if unused elsewhere } ``` - [ ] **Step 3: Run the test to verify it fails (compile error)** Run: `go test ./internal/store/ -run TestInitKeys -v` Expected: FAIL — compile errors (`InitKeys`, `Unlock`, `RoleAgent`, `RoleAdmin`, `dbPath` undefined). - [ ] **Step 4: Implement `internal/store/keys.go`** ```go package store import ( "database/sql" "encoding/base64" "errors" "fmt" "git.dcglab.co.uk/steve/emcli/internal/crypto" ) // Role selects which DEK wrap slot a command may unlock. type Role int const ( RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback) RoleAdmin // all commands; uses dek_wrap_admin ONLY ) const ( settingDEKWrapAdmin = "dek_wrap_admin" settingDEKWrapAgent = "dek_wrap_agent" ) // ErrLocked means the DB has no DEK wrap slots yet (never initialized). var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`") // dbPath returns the file path SQLite opened (used by tests to re-open). func (s *Store) dbPath() string { var p string _ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p) return p } // InitKeys generates a DEK (only if absent), seals it under both KEKs, writes // both wrap slots, and unlocks the store. If the slots already exist it does // NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init). func (s *Store) InitKeys(adminKey, agentKey []byte) error { if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil { return s.Unlock(RoleAdmin, adminKey, nil) } dek, err := crypto.NewDEK() if err != nil { return err } wrapAdmin, err := crypto.Seal(adminKey, dek) if err != nil { return err } wrapAgent, err := crypto.Seal(agentKey, dek) if err != nil { return err } if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil { return err } if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil { return err } s.key = dek return nil } // Unlock loads the DEK into the store by decrypting the wrap slot for role. // RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and // falls back to the admin slot only when no agent key is supplied. func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error { switch role { case RoleAdmin: return s.unlockSlot(settingDEKWrapAdmin, adminKey) case RoleAgent: if len(agentKey) > 0 { return s.unlockSlot(settingDEKWrapAgent, agentKey) } return s.unlockSlot(settingDEKWrapAdmin, adminKey) default: return fmt.Errorf("unknown role %d", role) } } func (s *Store) unlockSlot(settingKey string, kek []byte) error { if len(kek) == 0 { return ErrLocked } enc, err := s.GetSetting(settingKey) if errors.Is(err, sql.ErrNoRows) { return ErrLocked } if err != nil { return err } blob, err := base64.StdEncoding.DecodeString(enc) if err != nil { return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err) } dek, err := crypto.Open(kek, blob) if err != nil { return errors.New("wrong key for this DB") } s.key = dek return nil } ``` - [ ] **Step 5: Run the store tests, expect PASS** Run: `go test ./internal/store/... -v` Expected: PASS, including the existing store tests. (Existing `store_test.go` may call `Open(path, key)` with two args — if so, that is fixed in Step 6.) - [ ] **Step 6: Fix any existing `store` callers of the old `Open(path, key)` signature** Run: `git grep -n "store.Open(\|Open(path," internal/store` For each in-package call to `Open` with two args (e.g. in `store_test.go`), change `Open(path, someKey)` to `Open(path)` followed by `st.InitKeys(k(0xAA), k(0xBB))` (or `st.Unlock(...)` if the test re-opens an initialized DB). Re-run `go test ./internal/store/...` until green. - [ ] **Step 7: Commit** ```bash git add internal/store/store.go internal/store/keys.go internal/store/keys_test.go internal/store/store_test.go git commit -m "feat(store): envelope DEK with admin/agent wrap slots Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs; Unlock loads it from the role's slot (admin slot has no agent fallback). s.key becomes the DEK, so account/mail crypto is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 3: cli — role routing, openStore(role), init bootstrap **Files:** - Modify: `internal/cli/run.go` (openStore, command routing, agent call sites) - Modify: `internal/cli/admin.go` (4 `openStore()` call sites) - Modify: `internal/cli/interactive.go` (`runInit` bootstrap) - Modify: `internal/cli/admin_test.go` (`adminEnv` helper) - Modify: `internal/cli/run_test.go` (b64 helpers, gating test) **Interfaces:** - Consumes: `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv` (Task 1); `store.Open`, `store.Role`, `store.RoleAgent`, `store.RoleAdmin`, `(*Store).Unlock`, `(*Store).InitKeys` (Task 2). - Produces: `commandRole(cmd string) store.Role`; `openStore(role store.Role) (*store.Store, error)`. - [ ] **Step 1: Rewrite `openStore` and add `commandRole` in `run.go`** Replace the current `openStore` (lines ~28–39) with: ```go // commandRole maps a command to the privilege it requires. Admin commands // mutate configuration or expose oversight data; everything else is agent. func commandRole(cmd string) store.Role { switch cmd { case "account", "whitelist", "config", "audit": return store.RoleAdmin default: // list, get, search, ack, send, doctor return store.RoleAgent } } // openStore resolves the keys for the role, opens the DB, and unlocks the DEK. // Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent // commands use EMCLI_KEY (falling back to the admin key if that is all there is). func openStore(role store.Role) (*store.Store, error) { adminKey, adminErr := crypto.AdminKeyFromEnv() agentKey, agentErr := crypto.AgentKeyFromEnv() switch role { case store.RoleAdmin: if adminErr != nil { return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)") } case store.RoleAgent: if agentErr != nil && adminErr != nil { return nil, agentErr // "EMCLI_KEY is not set" } } path, err := store.DefaultDBPath() if err != nil { return nil, err } st, err := store.Open(path) if err != nil { return nil, err } if err := st.Unlock(role, adminKey, agentKey); err != nil { st.Close() return nil, err } return st, nil } ``` Remove the now-unused `crypto` import only if it becomes unused — it does NOT (openStore still uses it). Ensure `run.go` imports include `"fmt"` (already present) and `crypto`/`store` (already present). - [ ] **Step 2: Update the three agent call sites in `run.go`** - `runDoctor` (line ~82): `st, err := openStore(store.RoleAgent)` - `runAgent` (line ~162): `st, err := openStore(store.RoleAgent)` - `runSend` (line ~252): `st, err := openStore(store.RoleAgent)` - [ ] **Step 3: Update the four admin call sites in `admin.go`** In `runAccount` (~24), `runConfig` (~207), `runAudit` (~265), `runWhitelist` (~304): change each `st, err := openStore()` to `st, err := openStore(store.RoleAdmin)`. Confirm `admin.go` imports `git.dcglab.co.uk/steve/emcli/internal/store` (it returns `store` types already; if not imported, add it). - [ ] **Step 4: Rewrite `runInit` bootstrap in `interactive.go`** Replace the body of `runInit` (lines ~75–97) with: ```go func runInit(args []string, out, errOut io.Writer) int { if len(args) > 0 && helpRequested(args[0]) { printCmdUsage(out, "init") return 0 } adminKey, err := crypto.AdminKeyFromEnv() if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } agentKey, err := crypto.AgentKeyFromEnv() if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } path, err := store.DefaultDBPath() if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } st, err := store.Open(path) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } defer st.Close() if err := st.InitKeys(adminKey, agentKey); err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } if _, err := st.GetSetting("audit_retention_days"); err != nil { _ = st.SetSetting("audit_retention_days", "90") } accs, _ := st.ListAccounts() if len(accs) > 0 { fmt.Fprintf(out, "emcli is already initialized (%d account(s)); adding another.\n", len(accs)) } else { fmt.Fprintln(out, "Initializing emcli — add your first account.") } return addInteractive(st, tui.Fields{}, out, errOut) } ``` Add imports to `interactive.go` if missing: `git.dcglab.co.uk/steve/emcli/internal/crypto` and `git.dcglab.co.uk/steve/emcli/internal/store` (it already uses `store` for `addInteractive`; `tui` and `fmt` are already imported). - [ ] **Step 5: Update test helpers in `admin_test.go`** Replace `adminEnv` (lines ~14–20) with a version that sets both keys AND seeds the wrap slots: ```go // adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB. func adminEnv(t *testing.T) string { t.Helper() db := filepath.Join(t.TempDir(), "emcli.db") t.Setenv("EMCLI_ADMIN_KEY", b64Key()) t.Setenv("EMCLI_KEY", b64AgentKey()) t.Setenv("EMCLI_DB", db) st, err := store.Open(db) if err != nil { t.Fatalf("Open: %v", err) } adminKey, _ := crypto.AdminKeyFromEnv() agentKey, _ := crypto.AgentKeyFromEnv() if err := st.InitKeys(adminKey, agentKey); err != nil { t.Fatalf("InitKeys: %v", err) } st.Close() return db } ``` Add `"git.dcglab.co.uk/steve/emcli/internal/crypto"` to `admin_test.go` imports (`store` and `filepath` are already imported). - [ ] **Step 6: Add `b64AgentKey` and fix the gating test in `run_test.go`** Add next to `b64Key` (line ~53): ```go func b64AgentKey() string { // 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface. return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" } ``` In `TestRunVersionIsJSONForAgentButTextHere` (lines ~21–33), make the admin-key absence explicit so the test is deterministic regardless of the developer's shell: ```go t.Setenv("EMCLI_KEY", "") t.Setenv("EMCLI_ADMIN_KEY", "") code := Run([]string{"account", "list"}, &out, &errOut) ``` The assertion `strings.Contains(out+err, "EMCLI_KEY")` still holds — the admin error text contains `EMCLI_ADMIN_KEY`. - [ ] **Step 7: Build and run the full cli suite** Run: `go build ./... && go test ./internal/cli/... -race` Expected: PASS. (This is the first point the whole repo builds again.) If `crypto` shows as an unused import anywhere you touched, remove it; if a `store` import is missing in `admin.go`/`interactive.go`, add it. - [ ] **Step 8: Add routing tests in a new `internal/cli/role_test.go`** ```go package cli import ( "path/filepath" "testing" "git.dcglab.co.uk/steve/emcli/internal/crypto" "git.dcglab.co.uk/steve/emcli/internal/store" ) func TestCommandRole(t *testing.T) { admin := []string{"account", "whitelist", "config", "audit"} agent := []string{"list", "get", "search", "ack", "send", "doctor"} for _, c := range admin { if commandRole(c) != store.RoleAdmin { t.Errorf("%s should be admin", c) } } for _, c := range agent { if commandRole(c) != store.RoleAgent { t.Errorf("%s should be agent", c) } } } func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) { // A human holding only the admin key can still run agent commands // (admin is a superset → agent-role unlock falls back to the admin slot). db := filepath.Join(t.TempDir(), "emcli.db") t.Setenv("EMCLI_ADMIN_KEY", b64Key()) t.Setenv("EMCLI_KEY", b64AgentKey()) t.Setenv("EMCLI_DB", db) st, _ := store.Open(db) ak, _ := crypto.AdminKeyFromEnv() gk, _ := crypto.AgentKeyFromEnv() st.InitKeys(ak, gk) st.Close() // Only the admin key now; agent command must still open the store. t.Setenv("EMCLI_KEY", "") s2, err := openStore(store.RoleAgent) if err != nil { t.Fatalf("agent role with only admin key should open: %v", err) } s2.Close() } ``` Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentCommandWorksWithOnlyAdminKey' -v` Expected: PASS. - [ ] **Step 9: Commit** ```bash git add internal/cli/run.go internal/cli/admin.go internal/cli/interactive.go \ internal/cli/admin_test.go internal/cli/run_test.go internal/cli/role_test.go git commit -m "feat(cli): two-key role routing + init bootstrap openStore(role) selects the DEK wrap slot; admin commands require EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both slots from both keys. Test helpers seed the wrap slots. Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 4: headline security-invariant test **Files:** - Create: `internal/cli/security_invariant_test.go` **Interfaces:** - Consumes: `store.Open`, `(*Store).InitKeys`, `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv`, `run`, `b64Key`, `b64AgentKey` (Tasks 2–3). - [ ] **Step 1: Write the invariant test** ```go package cli import ( "bytes" "os" "path/filepath" "testing" "git.dcglab.co.uk/steve/emcli/internal/crypto" "git.dcglab.co.uk/steve/emcli/internal/store" ) func dbBytes(t *testing.T, path string) []byte { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatalf("read db: %v", err) } return b } // A forced agent holding ONLY EMCLI_KEY must not be able to run any admin // command, and the DB must be unchanged after it tries. func TestAgentKeyCannotRunAdminCommands(t *testing.T) { db := filepath.Join(t.TempDir(), "emcli.db") t.Setenv("EMCLI_ADMIN_KEY", b64Key()) t.Setenv("EMCLI_KEY", b64AgentKey()) t.Setenv("EMCLI_DB", db) st, _ := store.Open(db) ak, _ := crypto.AdminKeyFromEnv() gk, _ := crypto.AgentKeyFromEnv() if err := st.InitKeys(ak, gk); err != nil { t.Fatalf("InitKeys: %v", err) } st.Close() // Simulate the agent's environment: admin key absent. t.Setenv("EMCLI_ADMIN_KEY", "") before := dbBytes(t, db) adminAttempts := [][]string{ {"account", "list"}, {"config", "set", "audit_retention_days", "30"}, {"audit"}, } for _, args := range adminAttempts { code, out, errOut := run(t, args...) if code == 0 { t.Fatalf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut) } } if !bytes.Equal(before, dbBytes(t, db)) { t.Fatal("DB changed despite all admin commands being refused") } } ``` - [ ] **Step 2: Run it** Run: `go test ./internal/cli/ -run TestAgentKeyCannotRunAdminCommands -v -race` Expected: PASS. (If any admin command exits 0, the role gate is broken — fix Task 3 before continuing.) - [ ] **Step 3: Full suite + vet** Run: `go vet ./... && go test ./... -race` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add internal/cli/security_invariant_test.go git commit -m "test(cli): prove agent key cannot run admin commands Initialize a DB, drop EMCLI_ADMIN_KEY, attempt every admin command with only EMCLI_KEY: each is refused and the DB is byte-for-byte unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 5: documentation **Files:** - Modify: `README.md` - Modify: `USER-MANUAL.md` - Modify: `specifications/SPEC.md` - Modify: `skills/emcli/SKILL.md` and `skills/emcli/AGENTIC-MANUAL.md` (whichever document key setup) **Interfaces:** none (docs only). No code; no test cycle — verification is `grep` + a manual read. - [ ] **Step 1: README "Getting started" — two keys** Replace the single-key export block with: ```bash export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this emcli init # writes both wrap slots emcli doctor # confirm connect/auth (agent key is enough) ``` Add one sentence: *"`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it."* - [ ] **Step 2: USER-MANUAL — key model + role table** Add a "Privilege model" section documenting: the two env vars; the DEK/envelope design in one paragraph (DEK sealed under both keys; admin slot has no agent fallback); the command→role table (copy from Global Constraints above); and the agent-launcher guidance (set only `EMCLI_KEY`). Update any existing single-`EMCLI_KEY` setup and `init` instructions to the two-key flow. - [ ] **Step 3: SPEC §4/§5 — enforced trust boundary** In §4 "Trust boundary": change the wording from *the agent invokes only the agent commands* (convention) to the enforced model — agent commands accept `EMCLI_KEY`; admin commands require `EMCLI_ADMIN_KEY` and unlock the admin DEK slot only. In §5 "Configuration & secrets": document `EMCLI_ADMIN_KEY`, `EMCLI_KEY`, the DEK, and the two `settings` wrap rows (`dek_wrap_admin`, `dek_wrap_agent`). - [ ] **Step 4: skill docs — agent gets only EMCLI_KEY** In `skills/emcli/SKILL.md` / `AGENTIC-MANUAL.md`, state that the agent is provided only `EMCLI_KEY` and therefore can run `list`/`get`/`search`/`ack`/`send`/`doctor`; admin commands are unavailable to it by design. Remove any text implying the agent can configure accounts/whitelists. - [ ] **Step 5: Verify and commit** Run: `git grep -n "EMCLI_KEY" README.md USER-MANUAL.md specifications/SPEC.md skills/` Confirm every setup/init reference reflects the two-key model and no doc tells the agent to run admin commands. Then: ```bash git add README.md USER-MANUAL.md specifications/SPEC.md skills/ git commit -m "docs: document two-key privilege model Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Self-Review (completed by plan author) **Spec coverage:** - Two env vars / role model → Tasks 1, 3. ✓ - Envelope DEK + two wrap slots → Task 2. ✓ - `s.key` becomes DEK, account crypto untouched → Task 2 (no change to `account.go`). ✓ - Command classification (doctor=agent, audit=admin) → Task 3 `commandRole`. ✓ - Admin slot has no agent fallback (enforcement linchpin) → Task 2 `Unlock`/`unlockSlot`, proved in Task 2 `TestAdminSlotNotOpenableByAgentKey` and Task 4. ✓ - Agent→admin superset fallback → Task 2 `Unlock`, Task 3 `TestAgentCommandWorksWithOnlyAdminKey`. ✓ - `init` requires both keys; idempotent (no DEK regen) → Task 3 `runInit`, Task 2 `TestInitKeysIdempotentKeepsDEK`. ✓ - No migration / no version gate → no such code added. ✓ - Error messages (admin privilege required / EMCLI_KEY not set / wrong key) → Task 3 `openStore`, Task 2 `unlockSlot`. ✓ - Existing-test fallout (helpers) → Task 3 Steps 5–6. ✓ - Headline invariant test → Task 4. ✓ - Docs → Task 5. ✓ **Placeholder scan:** No TBD/TODO; all code blocks complete; the only `bytes.Equal` no-op is annotated. ✓ **Type consistency:** `store.Role`/`RoleAgent`/`RoleAdmin`, `Open(path)`, `InitKeys(adminKey, agentKey)`, `Unlock(role, adminKey, agentKey)`, `commandRole`, `openStore(role)`, `AgentKeyFromEnv`/`AdminKeyFromEnv`/`NewDEK`, `b64Key`/`b64AgentKey` are used identically across tasks. ✓