Files
emcli/docs/superpowers/plans/2026-06-22-two-key-privilege.md
2026-06-22 22:43:16 +01:00

856 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ~1430) 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 ~5369) 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) <noreply@anthropic.com>"
```
> 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) <noreply@anthropic.com>"
```
---
## 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 ~2839) 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 ~7597) 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 ~1420) 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 ~2133), 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) <noreply@anthropic.com>"
```
---
## 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 23).
- [ ] **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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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 56. ✓
- 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. ✓