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

30 KiB
Raw Blame History

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:

// 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:

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
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:

// 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:

	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:

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
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
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:

// 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:

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:

// 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):

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:

	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
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
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

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
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:

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:

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. ✓