security: restrict code-introducing settings to trusted scopes at run time#203
Open
sadlilas wants to merge 9 commits into
Open
security: restrict code-introducing settings to trusted scopes at run time#203sadlilas wants to merge 9 commits into
sadlilas wants to merge 9 commits into
Conversation
When Amplifier runs inside a cloned repository it merges the folder's .amplifier/settings.yaml and .amplifier/settings.local.yaml into the run config. Certain settings are code-introducing: they install/download modules and bundles from arbitrary git/path URIs, or redirect module names to attacker-controlled sources. A malicious repo could therefore achieve code execution simply by being opened with amplifier. ## Trust rule Four settings scopes exist: global = ~/.amplifier/settings.yaml -> TRUSTED (outside cwd) session = ~/.amplifier/projects/.../settings.yaml -> TRUSTED (outside cwd) project = ./.amplifier/settings.yaml -> UNTRUSTED (inside cwd) local = ./.amplifier/settings.local.yaml -> UNTRUSTED (inside cwd) Code-introducing settings are now only honored from global + session. ## Changes ### Part A — settings.py - Add TRUSTED_CODE_SCOPES constant (global, session). - Extract _merge_paths() helper from get_merged_settings() loop body. - Add _trusted_paths() and get_trusted_settings() (global + session only). - Add trusted_only: bool = False kwarg to get_app_bundles, get_added_bundles, get_module_sources, get_bundle_sources, get_module_overrides, get_source_overrides, get_provider_overrides. - get_config_overrides() always reads the full merge (not code-introducing). ### Part B — call site flips (behavior changes) - runtime/config.py: get_app_bundles, get_source_overrides, get_module_sources, get_bundle_sources, get_added_bundles all call trusted_only=True. provider_sources extracted for Bundle.prepare also calls get_provider_overrides(trusted_only=True). - bundle_loader/resolvers.py (2 sites): get_module_sources(trusted_only=True) with TypeError guard for older protocol implementations. - bundle_loader/discovery.py (_load_user_registry): get_added_bundles(trusted_only=True). - paths.py (CLISettingsProvider): get_module_sources forwards trusted_only, get_module_source() calls it with trusted_only=True. - provider_sources.py: get_module_sources(trusted_only=True). - Management call sites (commands/, user_registry.py) unchanged — they need full visibility for list/update UX. ### Part C — strip source from runtime provider config (defense in depth) Strip the source key from every provider dict before applying provider_overrides at runtime. source is a prepare-time directive and must not ride along into the running session's provider config. ### Part D — one-time stderr notice _warn_dropped_code_config() compares full-merge vs trusted-merge for all code-introducing settings. If any differ, emits a single line to stderr: 'Ignored code-introducing configuration from this folder's .amplifier/ settings for safety: <labels>. Move it to your global (~/.amplifier/settings.yaml) settings to apply it.' ### Part E — tests tests/test_folder_config_trust_boundary.py: 21 tests covering all seven requirements from the spec (trusted_settings merge semantics, per-getter trusted_only behavior, config_overrides regression guard, management-path sanity).
…nd fail loud The initial trust-boundary fix restricted sources.modules, sources.bundles, bundle.app, and added-bundle URIs to trusted scopes (global + session) on the run path. Review surfaced two remaining run-path reads of the FULL merge that re-introduced code-introducing config from an untrusted working directory: - CLISettingsProvider.get_module_sources extracted modules.<category>[].source registrations from the full merge, so a cloned folder's .amplifier/settings could redirect any module's code via a registered source URI. - get_effective_provider_sources extracted modules.providers[].source from the full merge, the same vector for provider module downloads. Both now read these code-introducing source URIs from the trusted merge only. Also removed the silent `except TypeError -> full merge` fallback in the module resolver: trusted_only is part of SettingsProviderProtocol, so a provider that fails to honor it must fail loud rather than silently serve untrusted sources. Adds regression tests covering the modules.providers[].source vector (project-scope excluded, global-scope honored, novel folder provider rejected). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
resume_sub_session refreshes provider credentials by re-reading provider overrides from settings and splicing them into the resumed session config. This read used the full settings merge, which includes the current working directory's .amplifier/settings.yaml, so a folder-origin provider entry (including one carrying a code-introducing `source:`) could be merged into a resumed session's provider config. Resume cannot assume a clean working directory. The credential refresh now reads provider overrides from trusted scopes only (global + session), matching the run/prepare/resolve defenses. Folder-scope provider config is never trusted at resume time. Adds regression tests pinning the trusted_only read at the resume call site and proving a folder-scope provider source never survives into the resumed config. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Extend the folder-config trust boundary to the active bundle selector (bundle.active). A bundle *name* resolves only against trusted-only bundle sources, so it can never introduce code and stays honored from any scope. A raw *URI* selector (git+/file:///http(s):///zip+) is loaded directly by the bundle loader, bypassing trusted discovery -- it is code introduction. get_active_bundle() now drops a raw-URI selector that originates only from the project/local scope (inside the possibly-cloned working directory), falling back to the trusted selector or None. All run-time bundle-loading callers (run, tool, update) route through this single gate, and config preparation warns when a folder-origin URI selector is silenced. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
The active-bundle selector gate honored a bare name unconditionally, on the
premise that a name can only resolve against trusted-only bundle sources. That
holds for the registry leg but not for bundle discovery's filesystem leg, which
searches Path.cwd()/.amplifier/bundles/ at highest precedence. A cloned repo
shipping
.amplifier/settings.yaml -> bundle: {active: evil}
.amplifier/bundles/evil/bundle.md
therefore selected an attacker-authored bundle by *name* from the untrusted
working directory, controlling the whole session composition (system prompt,
active tools, context) and -- via a module source: URI in that bundle.md --
reaching uv pip install (remote code execution). This is the same threat the
raw-URI selector gate already closes, reached by name instead of URI.
get_active_bundle() now drops a name that would resolve into the cwd's
.amplifier/bundles/ unless a trusted (global/session) scope selected it,
mirroring the existing URI handling and falling back to the trusted selector or
None. Names that resolve trusted-only (registry/well-known) are unaffected; an
explicit --bundle flag still bypasses as a per-invocation user trust decision.
The folder-config safety warning now also fires when a name selector is dropped.
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
The trust gate for a project-scope bundle.active *name* re-implemented bundle discovery's resolution and only checked the two directory forms (<name>/bundle.md, <name>/bundle.yaml). The loader's _find_in_path also resolves two single-FILE forms (<name>.yaml, <name>.md), so a cloned repo could ship .amplifier/bundles/evil.md and select it by name from project scope -- the gate returned False, the loader matched evil.md, and attacker-authored bundle code (system prompt, active tools, module source: URIs -> pip install -> RCE) loaded from the untrusted cwd. Root cause is the divergence itself: two copies of the resolution form list will drift. Extract _find_in_path's body into a module-level resolve_bundle_in_path() that both the loader and the gate call, so the guard can never resolve a name differently from the loader. The gate now drops every cwd-resolving form, including single-file bundles. Tests: add single-file md/yaml vectors to the bundle.active matrix. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
The test test_resume_call_site_uses_trusted_only was renamed to
test_get_provider_overrides_trusted_only_excludes_project_scope with an
updated docstring. The original test name claimed to guard the call site
at session_spawner.py:897, but the test only calls the method in isolation
and never invokes resume_sub_session(). This rename + docstring fix
addresses the misleading security assertion.
Closes: microsoft-amplifier/amplifier-support#272
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…opped_code_config N1: Added TestGetBundleSources class (4 tests) — verifies get_bundle_sources(trusted_only=True) excludes project-scope bundle sources, consistent with existing TestGetModuleSources pattern. The get_bundle_sources() function was introduced by this PR with no behavioral coverage. N3: Added TestWarnDroppedCodeConfig class (4 tests) — verifies _warn_dropped_code_config() fires a stderr notice when project-scope code-introducing settings are dropped, and stays silent when nothing is dropped. The _warn_dropped_code_config() function was introduced by this PR with zero test coverage. All 52 tests pass (uv run pytest tests/test_folder_config_trust_boundary.py tests/test_resume_credential_refresh.py). Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ted_only Added test_resume_sub_session_uses_trusted_only_for_credential_refresh() — an async integration test that invokes resume_sub_session() through a minimal mock harness and asserts that get_provider_overrides(trusted_only=True) is called at session_spawner.py:897. This test pins the actual call site behavior (not just method isolation). If the trusted_only argument is removed or changed, the test fails. Mutation verified: reverting the call site to trusted_only=False causes the test to fail as expected. The test shims two symbols (bridge_child_cost, RUNTIME_SKILL_OVERLAY_CAPABILITY) that are absent in older installed versions of amplifier_foundation, ensuring imports succeed in all environments. All 53 tests pass. Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
kenotron-ms
approved these changes
Jun 10, 2026
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.
Problem
Running
amplifierinside a directory applies that directory's.amplifier/settings.yamlwith no trust prompt. Project-scope settings are merged into the effective configuration on the run/prepare path, and several of those settings are code-introducing — they tell Amplifier where to fetch module or bundle code from. A cloned or downloaded folder can therefore silently redirect Amplifier to fetch and execute attacker-controlled code the moment a user opens a session in it. No prompt, no opt-in.The code-introducing settings are:
sources.modules/sources.bundles— per-identifier source overridesmodules.<category>[].source— module registrations carrying asource:URI (e.g.modules.providers[].source)bundle.appand added-bundle URIsbundle.active— the active-bundle selector, whether it is a raw URI (git+/file:///http(s):///zip+) loaded directly as code, or a bare name that resolves into the working directory's.amplifier/bundles/(see below)Fix
Split settings into two trust tiers and read code-introducing settings from the trusted merge only (global + session scopes) on the run/prepare path, while leaving non-code policy (model selection, config overrides, display prefs) on the full merge so a folder can still customize behavior that cannot introduce code.
AppSettingsgrowsget_trusted_settings()and atrusted_only=parameter on every code-introducing accessor (get_module_sources,get_bundle_sources,get_app_bundles, provider sources). Run/prepare callers passtrusted_only=True; management/display commands keep full visibility.CLISettingsProvider.get_module_sourcesandget_effective_provider_sourcesreadmodules.<category>[].sourceregistrations from the trusted merge only.The module resolver no longer silently falls back to the full merge when a settings provider is asked for trusted sources —
trusted_onlyis part ofSettingsProviderProtocol, so a non-conforming provider fails loud rather than serving untrusted sources.Sub-session resume refreshes provider credentials by re-reading provider overrides and splicing them into the resumed config; this read also uses the trusted merge only (
trusted_only=True). Resume does not assume a clean working directory, so a folder-scope provider entry — including one carrying a code-introducingsource:— cannot be merged into a resumed session's provider config.The active-bundle selector is gated at a single chokepoint that covers both ways it can introduce code from the working directory.
get_active_bundle()honors a selector only when it cannot load attacker code from the cwd:git+/file:///http(s):///zip+) is loaded directly by the bundle loader, bypassing trusted discovery — it is code introduction — so it is honored only when a trusted scope (global/session) set it. A URI appearing only in project/local scope is dropped.Path.cwd()/.amplifier/bundles/at the highest filesystem precedence. A name with no trusted registry entry therefore resolves into the possibly-cloned working directory and loads attacker-authored bundle code (system prompt, which tools are active, context, and modulesource:URIs that get pip-installed — RCE) — the same threat as a URI, reached by name. The gate asks the bundle loader's own resolver (resolve_bundle_in_path, shared with discovery's_find_in_path) whether the name resolves into the cwd, so it covers every form the loader would load \x97 directory bundles (<name>/bundle.md,<name>/bundle.yaml) AND single-file bundles (<name>.yaml,<name>.md) \x97 and cannot drift from the loader. A name is dropped when it would resolve into the cwd unless a trusted scope selected it.In both cases a dropped selector falls back to the trusted selector or
None. Every run-time bundle-loading caller (run,tool,update) routes through this one gate; management/display reads ofbundle.activeare scope-finders, not load paths, and are unaffected. An explicit--bundleflag bypasses this gate entirely as a per-invocation, user-initiated trust decision.Management commands (
module add,source add,bundle add,provider use,bundle use) are unchanged: explicitly running a command to register a source or select a bundle is intentional user action, distinct from a folder doing it silently on session open. To use a specific code-introducing bundle for a project, add it to the global list first, then select it by name — the name resolves trusted-only (registry), so the selection works without re-introducing the folder-trust hole.When a folder-origin code-introducing setting is silenced on the prepare path — including a dropped
bundle.activeselector, whether URI or cwd-resolving name — config preparation prints a one-line notice so the drop is visible rather than mysterious.Tests
tests/test_folder_config_trust_boundary.pycovers each code-introducing setting: project scope is excluded undertrusted_only=True, global/session scopes remain honored, and non-code policy still merges from the folder. Includes themodules.providers[].sourcevector (project excluded, global honored, novel folder-introduced provider rejected) and the fullbundle.activeselector matrix:.amplifier/bundles/dropped — directory forms (<name>/bundle.md,<name>/bundle.yaml) AND single-file forms (<name>.md,<name>.yaml); falls back to the trusted selector.tests/test_resume_credential_refresh.pycovers the resume path: a folder-scope providersource:is excluded from the trusted read the credential refresh performs and never survives into the resumed config, the trusted global credential still refreshes, and a guard pins thetrusted_only=Trueread at the resume call site against revert.