diff --git a/docs/superpowers/plans/2026-05-02-bitwarden-cache.md b/docs/superpowers/plans/2026-05-02-bitwarden-cache.md new file mode 100644 index 0000000..e352fed --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-bitwarden-cache.md @@ -0,0 +1,1356 @@ +# 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.