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 tobitwardenStore, initialize it innewBitwardenStore, and use it inGetSecret,SetSecret,DeleteSecret. - Modify
secretstore/store.go: add cache options and defaults toOptions. - Modify
secretstore/manifest_open.go: resolvemanifest.SecretStore.BitwardenCacheintoOpenFromManifestOptionsandOptions. - Modify
secretstore/runtime.go: pass cache option through runtime describe/preflight paths. - Modify
manifest/manifest.go: parse and normalize[secret_store].bitwarden_cachewith 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: exposeDisableBitwardenCachein generatedSecretStoreOptionsand pass it to framework options. - Modify
generate/generate_test.go: assert generated helper contains the new option. - Modify
docs/manifest.mdanddocs/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:
BitwardenCacheis the manifest field,DisableBitwardenCacheis the runtime/generated option,bitwardenCacheis the internal cache type, andbitwardenCacheEnvNameis the env constant.