# Bitwarden Cache 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:** Add a secure, portable Bitwarden secret cache with memory-first reads and encrypted disk persistence derived from `BW_SESSION`. **Architecture:** The cache is internal to `secretstore` and used only by the `bitwarden-cli` backend. `GetSecret` reads memory, encrypted disk, then Bitwarden CLI; successful CLI reads fill both caches. Manifest and generated helper options carry a default-enabled `bitwarden_cache` option, with an environment override for operational disablement. **Tech Stack:** Go 1.25 standard library (`crypto/aes`, `crypto/cipher`, `crypto/hkdf`, `crypto/hmac`, `crypto/rand`, `crypto/sha256`, `encoding/json`, `os`, `time`), existing `manifest`, `secretstore`, and `generate` packages. --- ## File Structure - Create `secretstore/bitwarden_cache.go`: cache implementation, key derivation, disk encryption, TTL, path resolution, env override parsing. - Create `secretstore/bitwarden_cache_test.go`: focused cache tests independent from the Bitwarden fake CLI when possible. - Modify `secretstore/bitwarden.go`: add cache field to `bitwardenStore`, initialize it in `newBitwardenStore`, and use it in `GetSecret`, `SetSecret`, `DeleteSecret`. - Modify `secretstore/store.go`: add cache options and defaults to `Options`. - Modify `secretstore/manifest_open.go`: resolve `manifest.SecretStore.BitwardenCache` into `OpenFromManifestOptions` and `Options`. - Modify `secretstore/runtime.go`: pass cache option through runtime describe/preflight paths. - Modify `manifest/manifest.go`: parse and normalize `[secret_store].bitwarden_cache` with tri-state semantics so omission means enabled. - Modify `manifest/manifest_test.go`: test parsing explicit false and omitted default behavior. - Modify `secretstore/manifest_open_test.go`: test manifest false disables Bitwarden cache. - Modify `generate/generate.go`: expose `DisableBitwardenCache` in generated `SecretStoreOptions` and pass it to framework options. - Modify `generate/generate_test.go`: assert generated helper contains the new option. - Modify `docs/manifest.md` and `docs/secrets.md`: document cache default, disable controls, TTL, and threat model. ## Manifest Option Shape Use a pointer bool in `manifest.SecretStore` so the code can distinguish omitted from explicit false: ```go type SecretStore struct { BackendPolicy string `toml:"backend_policy"` BitwardenCache *bool `toml:"bitwarden_cache"` } ``` Use a value bool named `DisableBitwardenCache` in runtime option structs. This preserves default-enabled zero-value behavior while still allowing callers to disable the cache programmatically. The manifest resolver applies `bitwarden_cache = false`, then the runtime disable flag and environment override can force disablement. --- ### Task 1: Manifest Parses `bitwarden_cache` **Files:** - Modify: `manifest/manifest.go` - Test: `manifest/manifest_test.go` - [ ] **Step 1: Write the failing manifest test** Add this to `manifest/manifest_test.go`: ```go func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, DefaultFile) const content = ` [secret_store] backend_policy = "bitwarden-cli" bitwarden_cache = false ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile manifest: %v", err) } file, err := Load(path) if err != nil { t.Fatalf("Load returned error: %v", err) } if file.SecretStore.BitwardenCache == nil { t.Fatal("bitwarden cache option is nil, want explicit false pointer") } if *file.SecretStore.BitwardenCache { t.Fatal("bitwarden cache option = true, want false") } } func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, DefaultFile) const content = ` [secret_store] backend_policy = "bitwarden-cli" ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile manifest: %v", err) } file, err := Load(path) if err != nil { t.Fatalf("Load returned error: %v", err) } if file.SecretStore.BitwardenCache != nil { t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset' ``` Expected: FAIL because `SecretStore.BitwardenCache` does not exist. - [ ] **Step 3: Implement manifest field** In `manifest/manifest.go`, change `SecretStore` to: ```go type SecretStore struct { BackendPolicy string `toml:"backend_policy"` BitwardenCache *bool `toml:"bitwarden_cache"` } ``` Keep `normalize` unchanged except for preserving the pointer: ```go func (s *SecretStore) normalize() { s.BackendPolicy = strings.TrimSpace(s.BackendPolicy) } ``` - [ ] **Step 4: Run manifest tests** Run: ```bash go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset' ``` Expected: PASS. - [ ] **Step 5: Commit** Run: ```bash git add manifest/manifest.go manifest/manifest_test.go git commit -m "feat: parse bitwarden cache manifest option" ``` --- ### Task 2: Add Runtime Cache Option Plumbing **Files:** - Modify: `secretstore/store.go` - Modify: `secretstore/manifest_open.go` - Modify: `secretstore/runtime.go` - Test: `secretstore/manifest_open_test.go` - [ ] **Step 1: Write failing manifest plumbing test** Add this to `secretstore/manifest_open_test.go`: ```go func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) { cacheDisabled := false resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ ServiceName: "email-mcp", ExecutableResolver: func() (string, error) { return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil }, ManifestLoader: func(startDir string) (manifest.File, string, error) { return manifest.File{ SecretStore: manifest.SecretStore{ BackendPolicy: string(BackendBitwardenCLI), BitwardenCache: &cacheDisabled, }, }, filepath.Join(startDir, manifest.DefaultFile), nil }, }) if err != nil { t.Fatalf("resolveManifestPolicy returned error: %v", err) } if resolution.BitwardenCache { t.Fatal("resolution BitwardenCache = true, want false") } if resolution.Policy != BackendBitwardenCLI { t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable ``` Expected: FAIL because cache option fields are not wired. - [ ] **Step 3: Add option fields** In `secretstore/store.go`, update `Options`: ```go type Options struct { ServiceName string BackendPolicy BackendPolicy LookupEnv func(string) (string, bool) KWalletAppID string KWalletFolder string BitwardenCommand string BitwardenDebug bool DisableBitwardenCache bool Shell string } ``` In `secretstore/manifest_open.go`, update `OpenFromManifestOptions`: ```go type OpenFromManifestOptions struct { ServiceName string LookupEnv func(string) (string, bool) KWalletAppID string KWalletFolder string BitwardenCommand string BitwardenDebug bool DisableBitwardenCache bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver } ``` Add a field to `manifestPolicyResolution`: ```go type manifestPolicyResolution struct { Policy BackendPolicy Source string BitwardenCache bool } ``` Where `resolveManifestPolicy` returns missing-manifest or omitted-field defaults, set `BitwardenCache: true`. When manifest has an explicit pointer, use it: ```go bitwardenCache := true if file.SecretStore.BitwardenCache != nil { bitwardenCache = *file.SecretStore.BitwardenCache } ``` Pass it in `OpenFromManifest`: ```go DisableBitwardenCache: options.DisableBitwardenCache || !manifestPolicy.BitwardenCache, ``` This keeps `OpenFromManifestOptions{}` default-enabled when the manifest omits the field, while still respecting both `bitwarden_cache = false` and a caller's explicit disable flag. If a helper is clearer, add: ```go func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool { return runtimeDisabled || !manifestEnabled } ``` Call it as: ```go DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache), ``` - [ ] **Step 4: Pass through runtime describe options** In `secretstore/runtime.go`, add `DisableBitwardenCache bool` to `DescribeRuntimeOptions`, and pass it to `Open`. Use: ```go DisableBitwardenCache: options.DisableBitwardenCache, ``` - [ ] **Step 5: Run plumbing test** Run: ```bash go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable ``` Expected: PASS. - [ ] **Step 6: Commit compile-safe plumbing** Run the broader compile target first: ```bash go test ./secretstore ./manifest ``` Expected: PASS. Then run: ```bash git add secretstore/store.go secretstore/manifest_open.go secretstore/runtime.go secretstore/manifest_open_test.go git commit -m "feat: wire bitwarden cache options" ``` --- ### Task 3: Implement Cache Core **Files:** - Create: `secretstore/bitwarden_cache.go` - Create: `secretstore/bitwarden_cache_test.go` - [ ] **Step 1: Write cache core tests** Create `secretstore/bitwarden_cache_test.go`: ```go package secretstore import ( "bytes" "os" "strings" "testing" "time" ) func TestBitwardenCacheMemoryHit(t *testing.T) { cache := newBitwardenCache(bitwardenCacheOptions{ ServiceName: "email-mcp", Session: "session-v1", TTL: 10 * time.Minute, Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) }, CacheDir: t.TempDir(), Enabled: true, }) cache.store("api-token", "email-mcp/api-token", "secret-v1") got, ok := cache.loadMemory("api-token", "email-mcp/api-token") if !ok { t.Fatal("memory cache miss, want hit") } if got != "secret-v1" { t.Fatalf("memory cache value = %q, want secret-v1", got) } } func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) { dir := t.TempDir() now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) cache := newBitwardenCache(bitwardenCacheOptions{ ServiceName: "email-mcp", Session: "session-v1", TTL: 10 * time.Minute, Now: func() time.Time { return now }, CacheDir: dir, Enabled: true, }) cache.store("api-token", "email-mcp/api-token", "secret-v1") reopened := newBitwardenCache(bitwardenCacheOptions{ ServiceName: "email-mcp", Session: "session-v1", TTL: 10 * time.Minute, Now: func() time.Time { return now.Add(time.Minute) }, CacheDir: dir, Enabled: true, }) got, ok := reopened.loadDisk("api-token", "email-mcp/api-token") if !ok { t.Fatal("disk cache miss, want hit") } if got != "secret-v1" { t.Fatalf("disk cache value = %q, want secret-v1", got) } entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir cache dir: %v", err) } if len(entries) != 1 { t.Fatalf("cache file count = %d, want 1", len(entries)) } data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) if err != nil { t.Fatalf("ReadFile cache file: %v", err) } if bytes.Contains(data, []byte("secret-v1")) { t.Fatalf("cache file contains plaintext secret: %s", data) } if strings.Contains(entries[0].Name(), "api-token") { t.Fatalf("cache file name exposes secret name: %s", entries[0].Name()) } } func TestBitwardenCacheRejectsChangedSession(t *testing.T) { dir := t.TempDir() now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) cache := newBitwardenCache(bitwardenCacheOptions{ ServiceName: "email-mcp", Session: "session-v1", TTL: 10 * time.Minute, Now: func() time.Time { return now }, CacheDir: dir, Enabled: true, }) cache.store("api-token", "email-mcp/api-token", "secret-v1") changed := newBitwardenCache(bitwardenCacheOptions{ ServiceName: "email-mcp", Session: "session-v2", TTL: 10 * time.Minute, Now: func() time.Time { return now.Add(time.Minute) }, CacheDir: dir, Enabled: true, }) if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok { t.Fatalf("disk cache hit with changed session = %q, want miss", got) } } func TestBitwardenCacheExpiresEntries(t *testing.T) { dir := t.TempDir() now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) cache := newBitwardenCache(bitwardenCacheOptions{ ServiceName: "email-mcp", Session: "session-v1", TTL: time.Minute, Now: func() time.Time { return now }, CacheDir: dir, Enabled: true, }) cache.store("api-token", "email-mcp/api-token", "secret-v1") expired := newBitwardenCache(bitwardenCacheOptions{ ServiceName: "email-mcp", Session: "session-v1", TTL: time.Minute, Now: func() time.Time { return now.Add(2 * time.Minute) }, CacheDir: dir, Enabled: true, }) if got, ok := expired.load("api-token", "email-mcp/api-token"); ok { t.Fatalf("expired cache hit = %q, want miss", got) } } ``` If `filepath` is missing, add it to the imports. - [ ] **Step 2: Run tests to verify they fail** Run: ```bash go test ./secretstore -run 'TestBitwardenCache' ``` Expected: FAIL because cache types/functions do not exist. - [ ] **Step 3: Implement cache core** Create `secretstore/bitwarden_cache.go`: ```go package secretstore import ( "crypto/aes" "crypto/cipher" "crypto/hkdf" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "time" ) const ( bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE" defaultBitwardenCacheTTL = 10 * time.Minute bitwardenCacheFormatVersion = 1 bitwardenCacheAlgorithm = "AES-256-GCM" bitwardenCacheDirName = "bitwarden-cache" bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1" bitwardenCacheInfo = "mcp-framework bitwarden cache v1" bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1" bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1" bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" ) type bitwardenCacheOptions struct { ServiceName string Session string TTL time.Duration Now func() time.Time CacheDir string Enabled bool } type bitwardenCache struct { mu sync.Mutex enabled bool serviceName string ttl time.Duration now func() time.Time cacheDir string encryptionKey []byte entryIDKey []byte memory map[string]bitwardenCacheMemoryEntry } type bitwardenCacheMemoryEntry struct { value string expiresAt time.Time } type bitwardenCachePlaintext struct { Version int `json:"version"` ServiceName string `json:"service_name"` SecretName string `json:"secret_name"` ScopedName string `json:"scoped_name"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` Value string `json:"value"` } type bitwardenCacheEnvelope struct { Version int `json:"version"` Algorithm string `json:"algorithm"` Nonce string `json:"nonce"` Ciphertext string `json:"ciphertext"` } func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache { now := options.Now if now == nil { now = time.Now } ttl := options.TTL if ttl <= 0 { ttl = defaultBitwardenCacheTTL } cache := &bitwardenCache{ enabled: options.Enabled, serviceName: strings.TrimSpace(options.ServiceName), ttl: ttl, now: now, cacheDir: strings.TrimSpace(options.CacheDir), memory: map[string]bitwardenCacheMemoryEntry{}, } if !cache.enabled { return cache } session := strings.TrimSpace(options.Session) if session == "" { return cache } masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32) if err != nil { return cache } cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32) if err != nil { cache.encryptionKey = nil return cache } cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32) if err != nil { cache.encryptionKey = nil cache.entryIDKey = nil return cache } return cache } func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) { if value, ok := c.loadMemory(secretName, scopedName); ok { return value, true } return c.loadDisk(secretName, scopedName) } func (c *bitwardenCache) store(secretName, scopedName, value string) { if c == nil || !c.enabled { return } c.storeMemory(secretName, scopedName, value) c.storeDisk(secretName, scopedName, value) } func (c *bitwardenCache) invalidate(secretName, scopedName string) { if c == nil { return } key := c.memoryKey(secretName, scopedName) c.mu.Lock() delete(c.memory, key) c.mu.Unlock() if path, ok := c.entryPath(secretName, scopedName); ok { _ = os.Remove(path) } } func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) { if c == nil || !c.enabled { return "", false } key := c.memoryKey(secretName, scopedName) now := c.now() c.mu.Lock() defer c.mu.Unlock() entry, ok := c.memory[key] if !ok { return "", false } if !entry.expiresAt.After(now) { delete(c.memory, key) return "", false } return entry.value, true } func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) { key := c.memoryKey(secretName, scopedName) c.mu.Lock() c.memory[key] = bitwardenCacheMemoryEntry{ value: value, expiresAt: c.now().Add(c.ttl), } c.mu.Unlock() } func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) { path, ok := c.entryPath(secretName, scopedName) if !ok { return "", false } data, err := os.ReadFile(path) if err != nil { return "", false } var envelope bitwardenCacheEnvelope if err := json.Unmarshal(data, &envelope); err != nil { _ = os.Remove(path) return "", false } plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope) if err != nil { _ = os.Remove(path) return "", false } if plaintext.Version != bitwardenCacheFormatVersion || plaintext.ServiceName != c.serviceName || plaintext.SecretName != strings.TrimSpace(secretName) || plaintext.ScopedName != strings.TrimSpace(scopedName) || !plaintext.ExpiresAt.After(c.now()) { _ = os.Remove(path) return "", false } c.storeMemory(secretName, scopedName, plaintext.Value) return plaintext.Value, true } func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) { if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 { return } path, ok := c.entryPath(secretName, scopedName) if !ok { return } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return } _ = os.Chmod(filepath.Dir(path), 0o700) now := c.now() plaintext := bitwardenCachePlaintext{ Version: bitwardenCacheFormatVersion, ServiceName: c.serviceName, SecretName: strings.TrimSpace(secretName), ScopedName: strings.TrimSpace(scopedName), CreatedAt: now, ExpiresAt: now.Add(c.ttl), Value: value, } envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext) if err != nil { return } data, err := json.Marshal(envelope) if err != nil { return } tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp") if err != nil { return } tmpPath := tmp.Name() cleanup := true defer func() { _ = tmp.Close() if cleanup { _ = os.Remove(tmpPath) } }() _ = tmp.Chmod(0o600) if _, err := tmp.Write(data); err != nil { return } if err := tmp.Close(); err != nil { return } if err := os.Rename(tmpPath, path); err != nil { return } _ = os.Chmod(path, 0o600) cleanup = false } func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) { raw, err := json.Marshal(plaintext) if err != nil { return bitwardenCacheEnvelope{}, err } block, err := aes.NewCipher(c.encryptionKey) if err != nil { return bitwardenCacheEnvelope{}, err } aead, err := cipher.NewGCM(block) if err != nil { return bitwardenCacheEnvelope{}, err } nonce := make([]byte, aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return bitwardenCacheEnvelope{}, err } ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName)) return bitwardenCacheEnvelope{ Version: bitwardenCacheFormatVersion, Algorithm: bitwardenCacheAlgorithm, Nonce: base64.StdEncoding.EncodeToString(nonce), Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), }, nil } func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) { if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm { return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope") } nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce) if err != nil { return bitwardenCachePlaintext{}, err } ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext) if err != nil { return bitwardenCachePlaintext{}, err } block, err := aes.NewCipher(c.encryptionKey) if err != nil { return bitwardenCachePlaintext{}, err } aead, err := cipher.NewGCM(block) if err != nil { return bitwardenCachePlaintext{}, err } raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName)) if err != nil { return bitwardenCachePlaintext{}, err } var plaintext bitwardenCachePlaintext if err := json.Unmarshal(raw, &plaintext); err != nil { return bitwardenCachePlaintext{}, err } return plaintext, nil } func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) { if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 { return "", false } mac := hmac.New(sha256.New, c.entryIDKey) _, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName))) return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true } func (c *bitwardenCache) memoryKey(secretName, scopedName string) string { return c.cacheContext(secretName, scopedName) } func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte { return []byte(fmt.Sprintf( "mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s", c.serviceName, strings.TrimSpace(secretName), strings.TrimSpace(scopedName), )) } func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { return fmt.Sprintf( "version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s", bitwardenCacheFormatVersion, c.serviceName, strings.TrimSpace(secretName), strings.TrimSpace(scopedName), bitwardenCacheContextScope, ) } func resolveBitwardenCacheDir(serviceName string) string { cacheRoot, err := os.UserCacheDir() if err != nil { return "" } return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName) } func bitwardenCacheDisabledByEnv() bool { raw, ok := os.LookupEnv(bitwardenCacheEnvName) if !ok { return false } switch strings.ToLower(strings.TrimSpace(raw)) { case "0", "false", "no", "off", "disabled": return true default: return false } } ``` - [ ] **Step 4: Fix test imports** Ensure `secretstore/bitwarden_cache_test.go` imports `path/filepath` because `TestBitwardenCacheDiskRoundTripIsEncrypted` uses it. - [ ] **Step 5: Run cache core tests** Run: ```bash go test ./secretstore -run 'TestBitwardenCache' ``` Expected: PASS. - [ ] **Step 6: Commit** Run: ```bash git add secretstore/bitwarden_cache.go secretstore/bitwarden_cache_test.go git commit -m "feat: add encrypted bitwarden cache core" ``` --- ### Task 4: Integrate Cache With Bitwarden Store **Files:** - Modify: `secretstore/bitwarden.go` - Modify: `secretstore/bitwarden_test.go` - Modify: `secretstore/manifest_open_test.go` - [ ] **Step 1: Add integration tests** Add these tests to `secretstore/bitwarden_test.go`: ```go func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ ID: "item-1", Name: "email-mcp/api-token", Secret: "secret-v1", MarkerService: "email-mcp", MarkerSecretName: "api-token", } withBitwardenRunner(t, fakeCLI.run) store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, }) if err != nil { t.Fatalf("Open returned error: %v", err) } for i := 0; i < 2; i++ { value, err := store.GetSecret("api-token") if err != nil { t.Fatalf("GetSecret #%d returned error: %v", i+1, err) } if value != "secret-v1" { t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) } } if fakeCLI.getItemCalls != 1 { t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls) } } func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { withBitwardenSession(t) t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") fakeCLI := newFakeBitwardenCLI("bw") fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ ID: "item-1", Name: "email-mcp/api-token", Secret: "secret-v1", MarkerService: "email-mcp", MarkerSecretName: "api-token", } withBitwardenRunner(t, fakeCLI.run) store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, }) if err != nil { t.Fatalf("Open returned error: %v", err) } for i := 0; i < 2; i++ { if _, err := store.GetSecret("api-token"); err != nil { t.Fatalf("GetSecret #%d returned error: %v", i+1, err) } } if fakeCLI.getItemCalls != 2 { t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls) } } ``` Add this test to `secretstore/manifest_open_test.go`: ```go func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) { withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ ID: "item-1", Name: "email-mcp/api-token", Secret: "secret-v1", MarkerService: "email-mcp", MarkerSecretName: "api-token", } withBitwardenRunner(t, fakeCLI.run) cacheDisabled := false store, err := OpenFromManifest(OpenFromManifestOptions{ ServiceName: "email-mcp", ExecutableResolver: func() (string, error) { return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil }, ManifestLoader: func(startDir string) (manifest.File, string, error) { return manifest.File{ SecretStore: manifest.SecretStore{ BackendPolicy: string(BackendBitwardenCLI), BitwardenCache: &cacheDisabled, }, }, filepath.Join(startDir, manifest.DefaultFile), nil }, }) if err != nil { t.Fatalf("OpenFromManifest returned error: %v", err) } for i := 0; i < 2; i++ { value, err := store.GetSecret("api-token") if err != nil { t.Fatalf("GetSecret #%d returned error: %v", i+1, err) } if value != "secret-v1" { t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) } } if fakeCLI.getItemCalls != 2 { t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls) } } ``` - [ ] **Step 2: Run integration tests to verify failure** Run: ```bash go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable' ``` Expected: FAIL until `bitwardenStore` uses the cache. - [ ] **Step 3: Add cache field and initialization** In `secretstore/bitwarden.go`, update `bitwardenStore`: ```go type bitwardenStore struct { command string serviceName string debug bool cache *bitwardenCache } ``` In `newBitwardenStore`, after `EnsureBitwardenSessionEnv`, read the effective session: ```go session, _ := os.LookupEnv(bitwardenSessionEnvName) cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv() ``` When constructing the store: ```go store := &bitwardenStore{ command: command, serviceName: serviceName, debug: debugEnabled, cache: newBitwardenCache(bitwardenCacheOptions{ ServiceName: serviceName, Session: session, TTL: defaultBitwardenCacheTTL, CacheDir: resolveBitwardenCacheDir(serviceName), Enabled: cacheEnabled, }), } ``` Make sure this happens after session restoration, not before. If needed, move the `store := &bitwardenStore{...}` block below the `EnsureBitwardenSessionEnv` call. - [ ] **Step 4: Use cache in `GetSecret`** Change `GetSecret`: ```go func (s *bitwardenStore) GetSecret(name string) (string, error) { secretName := s.scopedName(name) if s.cache != nil { if secret, ok := s.cache.load(name, secretName); ok { return secret, nil } } _, payload, err := s.findItem(secretName, name) if err != nil { return "", err } secret, ok := readBitwardenSecret(payload) if !ok { return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) } if s.cache != nil { s.cache.store(name, secretName, secret) } return secret, nil } ``` - [ ] **Step 5: Invalidate on writes and deletes** After successful create/edit in `SetSecret`, call: ```go if s.cache != nil { s.cache.invalidate(name, secretName) s.cache.store(name, secretName, secret) } ``` After successful delete or not-found in `DeleteSecret`, call: ```go if s.cache != nil { s.cache.invalidate(name, secretName) } ``` - [ ] **Step 6: Run integration tests** Run: ```bash go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable' ``` Expected: PASS. - [ ] **Step 7: Run all secretstore tests** Run: ```bash go test ./secretstore ``` Expected: PASS. - [ ] **Step 8: Commit** Run: ```bash git add secretstore/bitwarden.go secretstore/bitwarden_test.go secretstore/manifest_open_test.go git commit -m "feat: cache bitwarden secret reads" ``` --- ### Task 5: Generated Helper Support **Files:** - Modify: `generate/generate.go` - Modify: `generate/generate_test.go` - [ ] **Step 1: Write failing generated helper assertion** In `generate/generate_test.go`, add these expected snippets to the test that checks generated secret store helper content: ```go "DisableBitwardenCache bool", "DisableBitwardenCache: options.DisableBitwardenCache,", ``` If there is no focused assertion list, add a test: ```go func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) { projectDir := newProject(t, ` binary_name = "demo-mcp" [secret_store] backend_policy = "bitwarden-cli" `) if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { t.Fatalf("Generate returned error: %v", err) } content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) if err != nil { t.Fatalf("ReadFile generated secretstore: %v", err) } text := string(content) for _, snippet := range []string{ "DisableBitwardenCache bool", "DisableBitwardenCache: options.DisableBitwardenCache,", } { if !strings.Contains(text, snippet) { t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text) } } } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption ``` Expected: FAIL because generated code lacks `DisableBitwardenCache`. - [ ] **Step 3: Update generator template** In `generate/generate.go`, add to generated `SecretStoreOptions`: ```go DisableBitwardenCache bool ``` Pass to `OpenFromManifestOptions` and `DescribeRuntimeOptions`: ```go DisableBitwardenCache: options.DisableBitwardenCache, ``` - [ ] **Step 4: Run generator test** Run: ```bash go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption ``` Expected: PASS. - [ ] **Step 5: Commit** Run: ```bash git add generate/generate.go generate/generate_test.go git commit -m "feat: expose bitwarden cache in generated helpers" ``` --- ### Task 6: Documentation **Files:** - Modify: `docs/manifest.md` - Modify: `docs/secrets.md` - [ ] **Step 1: Update manifest docs** In `docs/manifest.md`, update the `[secret_store]` example: ```toml [secret_store] backend_policy = "auto" # Optionnel: mettre false pour désactiver le cache Bitwarden. bitwarden_cache = true ``` Add field description: ```markdown - `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire + disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver. ``` - [ ] **Step 2: Update secrets docs** In `docs/secrets.md`, after the Bitwarden backend section, add this Markdown: ````markdown ## Cache Bitwarden Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut. Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et AES-GCM. TTL par défaut : 10 minutes. Pour désactiver le cache dans `mcp.toml` : ```toml [secret_store] backend_policy = "bitwarden-cli" bitwarden_cache = false ``` Pour le désactiver sans modifier le manifeste : ```bash MCP_FRAMEWORK_BITWARDEN_CACHE=0 ``` Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut lire l'environnement ou la mémoire du process pendant l'exécution. ```` - [ ] **Step 3: Run docs grep** Run: ```bash rg -n "bitwarden_cache|MCP_FRAMEWORK_BITWARDEN_CACHE|Cache Bitwarden" docs ``` Expected: shows entries in `docs/manifest.md` and `docs/secrets.md`. - [ ] **Step 4: Commit** Run: ```bash git add docs/manifest.md docs/secrets.md git commit -m "docs: document bitwarden cache controls" ``` --- ### Task 7: Final Validation **Files:** - No planned code edits unless validation exposes a defect. - [ ] **Step 1: Run targeted package tests** Run: ```bash go test ./manifest ./secretstore ./generate ``` Expected: PASS. - [ ] **Step 2: Run full test suite** Run: ```bash go test ./... ``` Expected: PASS. - [ ] **Step 3: Inspect git status** Run: ```bash git status --short ``` Expected: clean working tree. - [ ] **Step 4: If validation changed files, commit fixes** If any fixes were needed: ```bash git add git commit -m "fix: stabilize bitwarden cache" ``` If no fixes were needed, do not create an empty commit. --- ## Self-Review Notes - Spec coverage: manifest default and disable path are covered by Tasks 1, 2, and 6; secure cache core is covered by Task 3; store integration and invalidation are covered by Task 4; generated helper support is covered by Task 5; docs are covered by Task 6; validation is covered by Task 7. - Red-flag scan: no incomplete markers or open-ended “add tests” steps remain. - Type consistency: `BitwardenCache` is the manifest field, `DisableBitwardenCache` is the runtime/generated option, `bitwardenCache` is the internal cache type, and `bitwardenCacheEnvName` is the env constant.