mcp-framework/docs/superpowers/plans/2026-05-02-bitwarden-cache.md

35 KiB

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:

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:

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:

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:

type SecretStore struct {
	BackendPolicy  string `toml:"backend_policy"`
	BitwardenCache *bool  `toml:"bitwarden_cache"`
}

Keep normalize unchanged except for preserving the pointer:

func (s *SecretStore) normalize() {
	s.BackendPolicy = strings.TrimSpace(s.BackendPolicy)
}
  • Step 4: Run manifest tests

Run:

go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset'

Expected: PASS.

  • Step 5: Commit

Run:

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:

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:

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:

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:

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:

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:

bitwardenCache := true
if file.SecretStore.BitwardenCache != nil {
	bitwardenCache = *file.SecretStore.BitwardenCache
}

Pass it in OpenFromManifest:

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:

func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool {
	return runtimeDisabled || !manifestEnabled
}

Call it as:

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:

DisableBitwardenCache: options.DisableBitwardenCache,
  • Step 5: Run plumbing test

Run:

go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable

Expected: PASS.

  • Step 6: Commit compile-safe plumbing

Run the broader compile target first:

go test ./secretstore ./manifest

Expected: PASS.

Then run:

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:

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:

go test ./secretstore -run 'TestBitwardenCache'

Expected: FAIL because cache types/functions do not exist.

  • Step 3: Implement cache core

Create secretstore/bitwarden_cache.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:

go test ./secretstore -run 'TestBitwardenCache'

Expected: PASS.

  • Step 6: Commit

Run:

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:

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:

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:

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:

type bitwardenStore struct {
	command     string
	serviceName string
	debug       bool
	cache       *bitwardenCache
}

In newBitwardenStore, after EnsureBitwardenSessionEnv, read the effective session:

session, _ := os.LookupEnv(bitwardenSessionEnvName)
cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()

When constructing the store:

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:

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:

if s.cache != nil {
	s.cache.invalidate(name, secretName)
	s.cache.store(name, secretName, secret)
}

After successful delete or not-found in DeleteSecret, call:

if s.cache != nil {
	s.cache.invalidate(name, secretName)
}
  • Step 6: Run integration tests

Run:

go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable'

Expected: PASS.

  • Step 7: Run all secretstore tests

Run:

go test ./secretstore

Expected: PASS.

  • Step 8: Commit

Run:

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:

"DisableBitwardenCache bool",
"DisableBitwardenCache: options.DisableBitwardenCache,",

If there is no focused assertion list, add a test:

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:

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:

DisableBitwardenCache bool

Pass to OpenFromManifestOptions and DescribeRuntimeOptions:

DisableBitwardenCache: options.DisableBitwardenCache,
  • Step 4: Run generator test

Run:

go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption

Expected: PASS.

  • Step 5: Commit

Run:

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:

[secret_store]
backend_policy = "auto"
# Optionnel: mettre false pour désactiver le cache Bitwarden.
bitwarden_cache = true

Add field description:

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

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

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:

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:

go test ./manifest ./secretstore ./generate

Expected: PASS.

  • Step 2: Run full test suite

Run:

go test ./...

Expected: PASS.

  • Step 3: Inspect git status

Run:

git status --short

Expected: clean working tree.

  • Step 4: If validation changed files, commit fixes

If any fixes were needed:

git add <changed-files>
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.