Skip to content

fix(mainnet): deny local file secrets store, source from env vars (#386)#459

Open
eanzhao wants to merge 3 commits intodevfrom
fix/2026-04-27_mainnet-deny-local-file-secrets-store
Open

fix(mainnet): deny local file secrets store, source from env vars (#386)#459
eanzhao wants to merge 3 commits intodevfrom
fix/2026-04-27_mainnet-deny-local-file-secrets-store

Conversation

@eanzhao
Copy link
Copy Markdown
Contributor

@eanzhao eanzhao commented Apr 27, 2026

Closes #386.

问题

AevatarSecretsStore 在所有宿主中默认注册(经 AddAevatarConfig()),把 secrets 落地到 ~/.aevatar/secrets.json。Mainnet 项目继承这一行为,生产环境因此存在把 API key 等敏感信息写到本地文件的风险。

方案

不依赖运行时配置:只要入口是 mainnet,就在框架层切断本地文件 secrets store 的注册路径。其余宿主(localnet、CLI、demos、tests)保持默认行为,无破坏性变化。

改动概览

  • Aevatar.Configuration/EnvironmentSecretsStore.cs(新):只读 IAevatarSecretsStore 实现,仅从 IConfiguration(含 AEVATAR_ 前缀环境变量)读取;Set/Remove 直接抛 InvalidOperationException,misconfig 在调用点炸出而非静默落盘。
  • AevatarConfigLoader.AddAevatarConfig(IConfigurationBuilder, bool allowLocalFileStore = true):当 false 时跳过 secrets.json 加载(config.json/mcp.json/connectors.json/env 不变)。
  • Aevatar.Configuration.ServiceCollectionExtensions.AddAevatarConfig(IServiceCollection, bool allowLocalFileStore = true):当 false 时注册 EnvironmentSecretsStore 替代 AevatarSecretsStore
  • AevatarDefaultHostOptions.AllowLocalFileSecretsStore(新):宿主层开关,AddAevatarDefaultHost 透传到 AddAevatarBootstrapAddAevatarConfigAddAevatarBootstrap 也增加同名 overload。
  • Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs:在 AddAevatarMainnetHost 中显式置为 false
  • src/Aevatar.Configuration/README.md:补充新类型与 store 选择规则。

接口 IAevatarSecretsStore 未改动;现有架构守卫 secret_store_di_hits(禁止宿主直接 AddSingleton<IAevatarSecretsStore>)继续生效。

影响路径

  • mainnet:通过 AEVATAR_LLMProviders__Providers__deepseek__ApiKey 等环境变量注入;任何 Set/Remove 调用立即抛错。
  • 其他宿主(localnet、tools、demos、test):默认 true,行为与 dev 完全一致。

验证

  • dotnet build src/Aevatar.Configuration/...src/Aevatar.Bootstrap/...src/Aevatar.Mainnet.Host.Api/... — 0 errors。
  • dotnet test test/Aevatar.Foundation.Abstractions.Tests/Aevatar.Foundation.Abstractions.Tests.csproj — 50/50 通过(含 6 个新增 EnvironmentSecretsStore 用例 + 2 个 AddAevatarConfig 注册门禁用例)。
  • dotnet test test/Aevatar.Bootstrap.Tests/... — 51 通过(含 2 个新增端到端用例:AddAevatarBootstrapAddAevatarDefaultHost 的 disable 路径)。
  • dotnet test test/Aevatar.Integration.Tests/... --filter SecretsStore|ConfigurationCoverage — 18/18 通过。
  • dotnet test test/Aevatar.GAgents.ChannelRuntime.Tests/... — 465/465 通过。
  • 架构守卫 secret_store_di_hits 扫描宿主与 agent 扩展:no hits(pre-existing playground asset drift 与 MainnetHostCompositionTests 已确认在 dev 上同样失败,与本 PR 无关)。

Test plan

  • CI 通过 architecture/build/test 矩阵。
  • Aevatar.Mainnet.Host.Api 启动时若调用方误用 _secretsStore.Set(...),应抛 InvalidOperationException
  • 通过 AEVATAR_* 环境变量在 mainnet 中读取 LLMProviders:Providers:{name}:ApiKey / LLMProviders:Default 等仍然生效。
  • ~/.aevatar/secrets.json 存在时 mainnet 不再加载(验证 IConfiguration 中无对应 key)。

…only (#386)

Mainnet Host.Api now opts out of the read/write `AevatarSecretsStore` (which
backs `~/.aevatar/secrets.json`) and instead receives a read-only
`EnvironmentSecretsStore` sourced from `IConfiguration` + `AEVATAR_`-prefixed
environment variables. `Set` / `Remove` throw `InvalidOperationException`
so misconfigured callers fail at the call site rather than silently
landing secrets on disk.

The toggle is exposed at the host level via
`AevatarDefaultHostOptions.AllowLocalFileSecretsStore` and threaded through
`AddAevatarConfig` (config + DI) and `AddAevatarBootstrap`. Localnet, CLI,
demos, and tests keep the default behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09a613dd13

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 48 to 49
options.AllowLocalFileSecretsStore = false;
configureHost?.Invoke(options);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce mainnet secret-store lock after host overrides

This hardening can be bypassed because configureHost?.Invoke(options) runs after options.AllowLocalFileSecretsStore = false; any caller passing a customization callback can flip the flag back to true and re-enable ~/.aevatar/secrets.json loading/writing. In mainnet deployments that use AddAevatarMainnetHost(..., configureHost), this reintroduces the exact local secret persistence risk this commit is meant to eliminate.

Useful? React with 👍 / 👎.

Comment on lines +44 to +45
services.TryAddSingleton<IAevatarSecretsStore, EnvironmentSecretsStore>();
services.TryAddSingleton<EnvironmentSecretsStore>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register IConfiguration for environment-only secrets mode

When allowLocalFileStore is false, DI now registers EnvironmentSecretsStore, which requires IConfiguration in its constructor, but this extension does not ensure an IConfiguration service exists. In plain ServiceCollection usage (outside WebApplicationBuilder), resolving IAevatarSecretsStore will fail at runtime unless callers manually add configuration, which is a regression introduced by the new environment-only path.

Useful? React with 👍 / 👎.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 27, 2026

Codecov Report

❌ Patch coverage is 96.20253% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.91%. Comparing base (114ab79) to head (aeee185).
⚠️ Report is 59 commits behind head on dev.

Files with missing lines Patch % Lines
...c/Aevatar.Configuration/EnvironmentSecretsStore.cs 93.18% 0 Missing and 3 partials ⚠️
@@            Coverage Diff             @@
##              dev     #459      +/-   ##
==========================================
+ Coverage   70.50%   70.91%   +0.41%     
==========================================
  Files        1198     1210      +12     
  Lines       86188    87113     +925     
  Branches    11297    11426     +129     
==========================================
+ Hits        60764    61775    +1011     
+ Misses      21037    20884     -153     
- Partials     4387     4454      +67     
Flag Coverage Δ
ci 70.91% <96.20%> (+0.41%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...strap.Extensions.AI/ServiceCollectionExtensions.cs 86.38% <100.00%> (-0.20%) ⬇️
...otstrap/Hosting/WebApplicationBuilderExtensions.cs 90.24% <100.00%> (+0.24%) ⬆️
...c/Aevatar.Bootstrap/ServiceCollectionExtensions.cs 100.00% <100.00%> (ø)
src/Aevatar.Configuration/AevatarConfigLoader.cs 100.00% <100.00%> (ø)
...vatar.Configuration/ServiceCollectionExtensions.cs 100.00% <100.00%> (ø)
...t.Host.Api/Hosting/MainnetHostBuilderExtensions.cs 94.11% <100.00%> (+0.46%) ⬆️
...c/Aevatar.Configuration/EnvironmentSecretsStore.cs 93.18% <93.18%> (ø)

... and 32 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor Author

@eanzhao eanzhao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one issue worth addressing:

configureHost?.Invoke(options) runs AFTER AllowLocalFileSecretsStore = false

In src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs:48-49:

options.AllowLocalFileSecretsStore = false;   // line 48
configureHost?.Invoke(options);               // line 49

The user callback at line 49 can freely set AllowLocalFileSecretsStore = true, silently re-enabling the local file secrets store on mainnet. This contradicts the stated goal: "只要入口是 mainnet,就在框架层切断". The two lines should be swapped so false is enforced after the caller is done configuring:

configureHost?.Invoke(options);               // let caller configure
options.AllowLocalFileSecretsStore = false;    // then enforce mainnet invariant

Otherwise the rest looks clean — the EnvironmentSecretsStore fail-fast design is good, the bool parameter threading is consistent, and the tests cover the key paths.

Copy link
Copy Markdown
Contributor Author

@eanzhao eanzhao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: mainnet deny local file secrets store

Overall well-scoped and clearly reasoned. A few issues:


1. GetAll() dumps entire IConfiguration — not just secrets

EnvironmentSecretsStore.GetAll() calls _configuration.AsEnumerable() and returns every config key-value pair. In mainnet this means connection strings, feature flags, binding URLs — all exposed through the secrets store surface. AevatarSecretsStore.GetAll() only returns entries from secrets.json.

This is a security concern (secrets API becomes a config exfiltration vector) and a semantic mismatch between the two implementations.

Suggestion: filter to AEVATAR_-prefixed keys only, or document that GetAll() is not secrets-scoped.


2. AddAevatarConfig(IServiceCollection, bool)EnvironmentSecretsStore requires IConfiguration in DI

The TryAddSingleton<EnvironmentSecretsStore>() registration relies on IConfiguration being in the DI container. This works through WebApplicationBuilder / host builder, but if anyone calls AddAevatarConfig(allowLocalFileStore: false) on a bare ServiceCollection without IConfiguration registered, it throws at resolution time. Consider a doc comment noting the prerequisite.


3. AevatarPaths.EnsureDirectories() still runs when allowLocalFileStore: false

On a mainnet host that wants zero local file footprint, AddAevatarConfig() still creates ~/.aevatar/ directory tree. Slightly undermines the "no local file" guarantee. Consider guarding this behind the same flag.


Summary

Main actionable issue is #1. The rest are minor/defensive. The PR correctly solves the stated problem with minimal surface area change.

// Secrets are injected via AEVATAR_-prefixed environment variables
// managed by the deploy platform; Set/Remove on the secrets store
// will throw at the call site instead of silently writing to disk.
options.AllowLocalFileSecretsStore = false;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Mainnet AI bootstrap still reads the local secrets file

This switch only affects the config builder and the DI IAevatarSecretsStore registration. The same mainnet startup continues into builder.AddAevatarPlatform(...), which registers AI features; that path defaults AevatarAIFeatureOptions.SecretsStore to null, and CreateSecretsStoreAccessor falls back to new AevatarSecretsStore(). As a result, mainnet can still read ~/.aevatar/secrets.json when building the LLM provider factory, bypassing the environment-only store configured here. Please thread the registered/configured EnvironmentSecretsStore into AddAevatarAIFeatures for mainnet, or remove the direct file-store fallback from production composition.

- MainnetHostBuilderExtensions: enforce `AllowLocalFileSecretsStore = false`
  AFTER `configureHost?.Invoke(options)`, so user callbacks cannot silently
  re-enable the local file secrets store on mainnet (codex P1 + reviewer).
- Aevatar.Bootstrap.Extensions.AI: `CreateSecretsStoreAccessor` now resolves
  the DI-registered `IAevatarSecretsStore` before falling back to a fresh
  `AevatarSecretsStore()`. Prevents the LLM provider factory from re-opening
  `~/.aevatar/secrets.json` on a host that opted into `EnvironmentSecretsStore`
  (reviewer P1).
- EnvironmentSecretsStore.GetAll(): restrict to secret-shaped keys
  (`LLMProviders:` prefix or `*_API_KEY` suffix) instead of dumping the entire
  IConfiguration view. Prevents arbitrary connection strings, feature flags
  and binding URLs from leaking through the secrets API surface
  (reviewer security concern).
- AevatarConfigLoader / Configuration ServiceCollectionExtensions: skip
  `AevatarPaths.EnsureDirectories()` when `allowLocalFileStore=false`, so
  mainnet keeps a zero local-file footprint (reviewer minor).
- Configuration ServiceCollectionExtensions: doc-note the `IConfiguration`
  prerequisite for the env-only path (codex P2 + reviewer minor).
- Tests: assert mainnet ignores caller override, AI bootstrap honors the
  DI store, GetAll filters to secret-shaped keys, and `false` does not
  create the `~/.aevatar/` tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@eanzhao
Copy link
Copy Markdown
Contributor Author

eanzhao commented Apr 27, 2026

Addressed all five review points in 27741f5.

# Concern Fix
codex P1 / reviewer configureHost runs after the false set → caller can flip back MainnetHostBuilderExtensions: enforce AllowLocalFileSecretsStore = false after configureHost?.Invoke(options) so the invariant wins. Test: MainnetSecretsStoreInvariantTests
reviewer P1 AddAevatarAIFeaturesCreateSecretsStoreAccessor falls back to new AevatarSecretsStore(), re-opening secrets.json on mainnet Aevatar.Bootstrap.Extensions.AI: thread IServiceProvider through registration; CreateSecretsStoreAccessor now prefers the DI-registered IAevatarSecretsStore before the file-store fallback. Test: AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore
reviewer security GetAll() dumped the entire IConfiguration view (connection strings, feature flags, etc.) EnvironmentSecretsStore.GetAll() now only returns keys under LLMProviders: or ending in _API_KEY — i.e. only what GetApiKey/GetDefaultProvider already understand. Test: GetAll_ShouldOnlyReturnSecretShapedKeys_NotArbitraryConfiguration
reviewer minor ~/.aevatar/ directory tree still created when allowLocalFileStore=false AevatarPaths.EnsureDirectories() is now skipped on the env-only path (both IConfigurationBuilder and IServiceCollection overloads). Test: AddAevatarConfig_WhenLocalFileStoreDisabled_ShouldNotCreateLocalDirectoryTree
codex P2 / reviewer minor env-only path needs IConfiguration in DI; doc this XML doc note added on AddAevatarConfig(IServiceCollection, bool) calling out the prerequisite (frameworks register IConfiguration automatically; bare ServiceCollection callers must add it themselves)

Verification:

  • dotnet test Foundation.Abstractions.Tests: 51/51
  • dotnet test Bootstrap.Tests: 52 passed / 1 skipped
  • dotnet test Hosting.Tests MainnetSecretsStoreInvariantTests: 1/1
  • dotnet test Integration.Tests (SecretsStore + ConfigurationCoverage): 18/18
  • secrets-store DI architecture guard: no hits

Copy link
Copy Markdown
Contributor Author

@eanzhao eanzhao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix looks solid. All issues from the first review are addressed with thoughtful guardrails:

Resolved:

  1. OrderingconfigureHost?.Invoke now runs before AllowLocalFileSecretsStore = false in MainnetHostBuilderExtensions, enforcing the invariant regardless of caller input.
  2. GetAll() leakage — narrowed to secret-shaped keys only (LLMProviders:* / *_API_KEY), no longer dumping arbitrary config.
  3. Directory creation on mainnetEnsureDirectories() is now gated behind allowLocalFileStore in both AevatarConfigLoader and ServiceCollectionExtensions.
  4. Bonus fixCreateSecretsStoreAccessor in Aevatar.Bootstrap.Extensions.AI now resolves the DI-registered store via IServiceProvider instead of falling back to new AevatarSecretsStore(), closing a path where AI feature bootstrap could re-open secrets.json on mainnet.
  5. TestMainnetSecretsStoreInvariantTests explicitly verifies that a caller override is ignored, plus a new test for the directory-creation guard.

LGTM.

bool allowLocalFileSecretsStore = true)
{
services.AddAevatarConfig();
services.AddAevatarConfig(allowLocalFileSecretsStore);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: AddAevatarBootstrap(..., false) still leaves EnvironmentSecretsStore unresolvable

The env-only path still depends on callers pre-registering IConfiguration even when they use this bootstrap overload, which already receives the configuration instance. In plain ServiceCollection usage, services.AddAevatarBootstrap(configuration, allowLocalFileSecretsStore: false) registers EnvironmentSecretsStore, but resolving IAevatarSecretsStore later fails because DI has no IConfiguration. The new test masks this by calling services.AddSingleton(configuration) before the bootstrap call; production callers of this overload should not need that extra undocumented step. Please register the passed configuration here, e.g. services.TryAddSingleton<IConfiguration>(configuration), before AddAevatarConfig(false).

…-only path resolves without caller workaround (#386)

`AddAevatarBootstrap(IServiceCollection, IConfiguration, bool)` already
receives the configuration instance. Forward it to DI via TryAddSingleton
before AddAevatarConfig so EnvironmentSecretsStore (registered when
allowLocalFileSecretsStore=false) can resolve in plain ServiceCollection
usage too. TryAdd preserves any existing host-builder registration.

Also drops the manual services.AddSingleton(configuration) workaround
from BootstrapServiceCollectionExtensionsTests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@eanzhao
Copy link
Copy Markdown
Contributor Author

eanzhao commented Apr 27, 2026

Addressed the new P2 in aeee185.

Aevatar.Bootstrap.ServiceCollectionExtensions.AddAevatarBootstrap now services.TryAddSingleton(configuration) before AddAevatarConfig, so plain ServiceCollection callers get a working EnvironmentSecretsStore without the undocumented services.AddSingleton(configuration) step. TryAdd preserves any existing host-builder registration. Also removed the workaround from AddAevatarBootstrap_WhenLocalFileSecretsStoreDisabled_ShouldRegisterEnvironmentSecretsStore so the test now exercises the production path verbatim — 52 passed, 1 skipped.

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.

Deny local file secrets store when booting from mainnet project

1 participant