Skip to content

fix(secrets): skip unloadable encrypted-file providers (closes #52)#57

Merged
initializ-mk merged 2 commits into
mainfrom
bug/stale-global-secrets-chain
May 18, 2026
Merged

fix(secrets): skip unloadable encrypted-file providers (closes #52)#57
initializ-mk merged 2 commits into
mainfrom
bug/stale-global-secrets-chain

Conversation

@initializ-mk
Copy link
Copy Markdown
Contributor

Summary

Fixes #52 — a stale ~/.forge/secrets.enc with a different passphrase no longer poisons the agent-local secret chain. Pre-fix, this hid every non-builtin key the local file declared (the regression the agent-48 test surfaced live during PR #51 validation).

Root cause recap

OverlaySecretsToEnv and Runner.buildSecretProvider appended ~/.forge/secrets.enc to the provider chain unconditionally. When that file used a different passphrase than the project's local file, EncryptedFileProvider.ensureLoaded() returned decrypting secrets file: ... — a non-NotFound error. ChainProvider.List() and ChainProvider.Get() (for keys not in the local file) propagated it, blocking the rest of the chain.

ChainProvider's strict semantics are correct by design — the bug was admitting a provider we already knew would fail.

Approach

Add viableEncryptedFileProviders(workDir, passCb, warnFn) in forge-cli/runtime/runner.go. It builds the candidate list (agent-local, then global) and filters:

Candidate state Behavior Rationale
File missing (os.IsNotExist) Silently skipped Common case — operator never ran forge secret set --global
ensureLoaded fails (wrong passphrase, corruption) Warning via warnFn, dropped from chain Prevents chain poisoning, surfaces a debuggable log line
Decrypt succeeds Admitted List() populates the decrypted cache so the next read reuses the work — no second Argon2id

Both call sites use the helper. Runner.buildSecretProvider passes r.logger.Warn. OverlaySecretsToEnv runs before the structured logger exists and passes stderrWarn — a fmt.Fprintf(os.Stderr, ...) sink with the forge: ... prefix matching other early-startup messages.

ChainProvider semantics are untouched — it stays strict. The fix is at the chain-construction site.

Test plan

gofmt -l forge-cli/ forge-core/ forge-plugins/      # clean
golangci-lint run ./forge-cli/... ./forge-core/... ./forge-plugins/...   # 0 issues
cd forge-cli && go test ./...                       # all pass

New tests in forge-cli/runtime/runner_secrets_test.go:

  • TestOverlaySecretsToEnv_StaleGlobalFileDoesNotPoisonChain — hermetic end-to-end. Sets HOME to a temp dir, seeds a global file with a deliberately-different passphrase, seeds a local file with the project passphrase, asserts the local file's custom key reaches OS env. This is the regression-protection commit for Stale global ~/.forge/secrets.enc poisons the secrets provider chain #52.
  • TestViableEncryptedFileProviders_Categorization — covers all three outcomes (admit / drop+warn / silent skip) with assertions on both the returned provider list and the warning callback.
  • TestViableEncryptedFileProviders_MissingGlobalIsSilent — missing global must not produce warning noise.

Existing tests updated:

  • TestOverlaySecretsToEnv_CustomSkillKey (the Skill-declared env vars stored as encrypted secrets are not passed to downstream binaries #48 regression) — t.Setenv("HOME", t.TempDir()) removed. The test now exercises the real chain-build path and confirms the fix works against the developer's actual ~/.forge/secrets.enc state. Run output shows the warning firing on my own machine (real global file, wrong passphrase) while the local key still overlays:

    forge: skipping secrets provider that failed to load
      (label=global, path=/Users/.../.forge/secrets.enc,
       error=decrypting secrets file: ... wrong passphrase?)
    --- PASS: TestOverlaySecretsToEnv_CustomSkillKey
    

Acceptance criteria from #52

  • A user with a pre-existing global ~/.forge/secrets.enc (any passphrase) can run an agent in a project whose local secrets.enc uses a different passphrase, and all keys in the local file — builtin and custom — overlay correctly.
  • OverlaySecretsToEnv and the runner's secret overlay log a clear warning when a provider in the chain fails to load, rather than silently dropping custom keys.
  • A failing global file does not prevent the local file's keys from being enumerated by ChainProvider.List.
  • Existing tests pass; the t.Setenv("HOME", ...) isolation in TestOverlaySecretsToEnv_CustomSkillKey is removed.

Manual verification

  • On a machine with ~/.forge/secrets.enc from an unrelated project, run a fresh agent that uses forge secret --local set MY_KEY ... with a different passphrase. Confirm: MY_KEY is visible to the agent at runtime, and stderr shows a skipping secrets provider that failed to load warning naming the global file.
  • Delete the global file. Re-run the agent. Confirm no warning is emitted (silent skip for the missing-file case).
  • With both files using the same passphrase, confirm both are admitted and forge secret list (without --local) sees the union.

Cross-reference

Surfaced during the validation of PR #51 in the agent-48-skill-env-secrets/ test harness. The agent's README documented HOME=$(mktemp -d) as a workaround pending this fix — once #52 merges, that workaround note can be removed too.

OverlaySecretsToEnv and Runner.buildSecretProvider previously appended
$HOME/.forge/secrets.enc to the provider chain unconditionally. When
that global file used a different passphrase than the project's local
file, ChainProvider.Get and ChainProvider.List propagated the decrypt
error and poisoned the entire chain — silently hiding every non-builtin
key the local file declared (the failure mode #51 exposed).

Add viableEncryptedFileProviders(workDir, passCb, warnFn) which builds
the candidate list of agent-local and global encrypted-file providers
and filters out the unviable ones:

- Missing file: silently skipped (common case — user never ran
  `forge secret set --global`).
- Decryption fails (wrong passphrase, corruption): warning emitted via
  warnFn, provider dropped from the chain.
- Decryption succeeds: provider admitted. List() during validation
  populates EncryptedFileProvider.cache so the subsequent
  secretOverlayKeys + Get path reuses the work with no extra Argon2id.

ChainProvider semantics are unchanged — it stays strict on
non-NotFound errors. The fix is at the chain-construction site: we
don't admit providers we already know will fail.

Both OverlaySecretsToEnv (pre-runner, no structured logger available)
and Runner.buildSecretProvider (uses r.logger.Warn) now call the
shared helper. stderrWarn provides the pre-runner sink with a
"forge: ..." prefix matching other early-startup messages.

Tests:
- TestOverlaySecretsToEnv_StaleGlobalFileDoesNotPoisonChain: hermetic
  end-to-end with a temp HOME containing a deliberately-bad global
  file; asserts the local file's custom key still overlays.
- TestViableEncryptedFileProviders_Categorization: covers
  admit/drop+warn/silent-skip outcomes.
- TestViableEncryptedFileProviders_MissingGlobalIsSilent: missing
  global must not produce warning noise.
- TestOverlaySecretsToEnv_CustomSkillKey (the #48 regression) no
  longer needs t.Setenv("HOME", t.TempDir()) — the fix handles
  real-world stale globals automatically. Confirmed against the
  actual machine's ~/.forge/secrets.enc (mismatched passphrase) where
  the warning fires and the test still passes.
Add a Provider Chain Validation subsection to
docs/security/secret-management.md documenting the eager-load check
introduced by #52:

- Missing file: silently skipped (common case)
- Decryptable: admitted; cache populated for reuse
- Undecryptable: dropped with warning, naming the file path

Cross-reference from docs/core-concepts/runtime-engine.md so the
runtime engine's Secret Overlay section points at the chain-build
validation step.
@initializ-mk initializ-mk merged commit 16c2279 into main May 18, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale global ~/.forge/secrets.enc poisons the secrets provider chain

1 participant