fix(secrets): skip unloadable encrypted-file providers (closes #52)#57
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #52 — a stale
~/.forge/secrets.encwith 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
OverlaySecretsToEnvandRunner.buildSecretProviderappended~/.forge/secrets.encto the provider chain unconditionally. When that file used a different passphrase than the project's local file,EncryptedFileProvider.ensureLoaded()returneddecrypting secrets file: ...— a non-NotFound error.ChainProvider.List()andChainProvider.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)inforge-cli/runtime/runner.go. It builds the candidate list (agent-local, then global) and filters:os.IsNotExist)forge secret set --globalensureLoadedfails (wrong passphrase, corruption)warnFn, dropped from chainList()populates the decrypted cache so the next read reuses the work — no second Argon2idBoth call sites use the helper.
Runner.buildSecretProviderpassesr.logger.Warn.OverlaySecretsToEnvruns before the structured logger exists and passesstderrWarn— afmt.Fprintf(os.Stderr, ...)sink with theforge: ...prefix matching other early-startup messages.ChainProvidersemantics are untouched — it stays strict. The fix is at the chain-construction site.Test plan
New tests in
forge-cli/runtime/runner_secrets_test.go:TestOverlaySecretsToEnv_StaleGlobalFileDoesNotPoisonChain— hermetic end-to-end. SetsHOMEto 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.encstate. Run output shows the warning firing on my own machine (real global file, wrong passphrase) while the local key still overlays:Acceptance criteria from #52
~/.forge/secrets.enc(any passphrase) can run an agent in a project whose localsecrets.encuses a different passphrase, and all keys in the local file — builtin and custom — overlay correctly.OverlaySecretsToEnvand the runner's secret overlay log a clear warning when a provider in the chain fails to load, rather than silently dropping custom keys.ChainProvider.List.t.Setenv("HOME", ...)isolation inTestOverlaySecretsToEnv_CustomSkillKeyis removed.Manual verification
~/.forge/secrets.encfrom an unrelated project, run a fresh agent that usesforge secret --local set MY_KEY ...with a different passphrase. Confirm:MY_KEYis visible to the agent at runtime, and stderr shows askipping secrets provider that failed to loadwarning naming the global file.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 documentedHOME=$(mktemp -d)as a workaround pending this fix — once #52 merges, that workaround note can be removed too.