fix(security): stop exporting AWS secrets into the environment; frame bridged coding-agent tool output - #270
Merged
Conversation
…environment
`bedrock.rs` and `sagemaker_tgi.rs` both opened `from_env` with the same
closure: read `config.all_values()` and `config.all_secrets()`, keep every
`AWS_`-prefixed key, and `std::env::set_var` each one so the AWS SDK's
environment chain would find it. Two defects, and the second is the one that
matters.
`all_secrets()` is the keyring, so the closure exported the user's real
`AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN`. Every process spawned
afterwards inherits the environment — and one of the things the agent spawns is
its own shell, so a chat on a *public* model with `developer__shell` could print
the credential. Nothing in the privacy lattice stops that: the tier gates decide
which model may read a conversation, not what a shell may read out of its own
environment, and the general filesystem read-deny (DR-14) is deferred.
`std::env::set_var` is also unsound in a multi-threaded process, which is why
Rust 2024 made it `unsafe`; binding a provider is not a startup-only act here.
The settings now reach the SDK through the client builder, the alternative this
workspace already documented at `providers/auto_detect.rs`. New module
`providers/aws_stored_settings.rs` reads the stored `AWS_*` keys (config file
then secrets, so a secret still wins a clash, as the export's ordering did) and
applies them to `aws_config::ConfigLoader`: a complete access-key/secret pair as
an explicit `Credentials`, a stored bearer token as an explicit `token_provider`
plus `httpBearerAuth`, plus region, profile and the service's endpoint variable.
Two properties are preserved deliberately, because dropping either would be a
silent config regression riding inside a security fix:
* The store still beats the environment. `set_var` overwrote, so a key in
`config.yaml` or the keyring outranked the same variable already set; the
settings are therefore applied LAST, where an explicit loader value wins.
* Absent means absent. A store holding no credentials, endpoint or region sets
nothing and the SDK's own chain (env, SSO, profile files, IMDS) runs
untouched. Two existing rows depend on that.
The SigV4-vs-bearer preference is deliberately NOT pinned for the public cards.
Under the export the stored keys landed in the environment, where an environment
`AWS_BEARER_TOKEN_BEDROCK` still won the scheme, so pinning `sigv4` would change
which credential an existing install authenticates with. `versa_bedrock` pins it
because its endpoint and keys are institutional. Measured and pinned by a test.
Fail-before evidence. Reverting only the closure, with the tests kept,
`a_process_the_agent_spawns_never_sees_a_stored_aws_secret` fails reporting
`{"AWS_ACCESS_KEY_ID": "PUBLICTESTACCESSKEY", "AWS_REGION": "us-west-2",
"AWS_SECRET_ACCESS_KEY": "store-only-secret-must-never-be-exported"}` — read by
a process spawned after the bind, from a secret that existed only in
`secrets.yaml`. The row also asserts the credential STILL signs the request
(`signed_by() == Some(("PUBLICTESTACCESSKEY", "us-west-2"))`), because an
absence-only assertion would pass on a fix that broke every store-based install.
The re-exec harness in `bedrock_namespace_tests.rs` was extended rather than
duplicated (it exists for exactly this: the environment cannot be measured
in-process from a multi-threaded test binary). The spawned stand-in is a re-exec
of the test binary rather than a shell, so the row runs on Windows too.
Every tool result the parent model reads is wrapped in
`<tool-output untrusted="true" tool="…">` and scanned for injection markers and
PII/PHI by `guardrails::tool_output::guard_tool_result`. It had exactly one call
site — `Agent::integrate_tool_result` — and a bridged call never reaches it: the
vendor CLI calls `POST /tool_bridge/{nonce}`, the route answers from
`BridgeGrant::call_for_child`, and the provider later lifts the kept result
straight into the transcript via `mirror::stored_bridged_result`. None of that is
the agent's turn loop, so the bypass was structural rather than a forgotten line.
Measured, not inferred: the same `date` call stored framed text under
`versa_azure` and raw text under both `claude_code` and `codex`. Two readers were
wrong. The transcript, which disagreed with every other provider's — including
for the BR-31/66 detectors that read one back. And the child, which is itself a
whole agent consuming bytes a third party wrote, and can be talked into acting on
them; that is the reader the frame exists for.
The frame is now applied in `call_for_child`, ONCE and above the fork, so the
same bytes go to both destinations:
dispatch -> guard_tool_result -> record(child_call_id) -> the transcript
-> child_view(...) -> the child agent
Framing only the child's copy would have left the transcript disagreeing.
Framing only the stored copy would have left the injection surface open. And
framing exactly one of the two would have broken `mirror::recorded_if_received`,
which decides whether the child received a result by comparing
`child_view(recorded)` against the child's echo — both sides now derive from the
same framed result, so the texts still match.
The MCP result shape the vendor CLIs parse is untouched: `guard_tool_result`
rewrites `text` and nothing else, so `is_error`, `structured_content`, images,
embedded resources and every annotation pass through bit-for-bit, and the frame
is plain text inside a text block. The mode is sampled once when the grant is
built, like every other field on it, for the reason the agent samples it once per
turn.
`the_guardrail_has_exactly_one_call_site` becomes
`the_guardrail_has_one_call_site_in_each_of_its_two_funnels`: a table of
(file, funnel) rows, counted over each file's PRODUCTION half only — `bridge.rs`'s
own suite calls the guardrail to build the non-bridged reference frame, and a test
proving the funnel works must not read as a second funnel.
Fail-before evidence, with the guard call replaced by `(Ok(result), None)`:
* `a_bridged_result_is_stored_with_the_same_frame_every_other_provider_stores`
fails with left `"Thu Sep 11 12:00:00 PDT 2026"` against right
`"<tool-output untrusted=\"true\" tool=\"developer__shell\">\n…\n</tool-output>"`,
every other field identical.
* `bridged_tool_output_is_scanned_for_injection_before_the_child_reads_it` fails:
"the injection marker never reached the child agent".
* `the_child_is_answered_with_the_models_view_and_the_full_result_is_kept`
(the real router, over HTTP) fails the same way.
The non-bridged side of the agreement test is not a hand-written string: it is
the expression `integrate_tool_result` evaluates, so the row says "the two
providers agree" rather than pinning one spelling of the frame. The route test
derives its expectation the same way and from the same sampled mode, so it holds
whatever the developer's `BIOROUTER_TOOL_OUTPUT_GUARDRAIL` says, with an explicit
non-vacuity check when it is not `Off`.
`grant_cancelled_by` now pins the mode to `Off` for the bridge's existing suite:
`BridgeGrant::new` samples the user's own config, so a developer with the
guardrail switched off would otherwise run a different suite from CI on every row
that asserts on a result's text.
`mirror.rs` needed no change — the audience-dropping defect that made every
bridged result reach the child twice is fixed on main, in `bridge.rs::child_view`,
and `recorded_if_received` consumes it.
`call_for_child` is the only production caller — the tool-bridge route calls it and nothing else — but `call` is `pub` and two integration test binaries drive it directly to exercise the gate stack. The guardrail sits one level up, so a future caller answering a model from `call` would hand the child raw third-party bytes again with nothing failing. Say so where someone would reach for it.
…d-bridged-framing
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.
Two HIGH-severity items from the QA campaign that never became a job. One commit each, so either
can be reverted alone.
A1 —
bedrock.rs/sagemaker_tgi.rsexported the user's AWS secrets into the process environmenta4ecfa2eBoth providers opened
from_envwith the same closure: readconfig.all_values()andconfig.all_secrets(), keep everyAWS_-prefixed key,std::env::set_vareach one so the AWS SDK'senvironment chain would find it.
all_secrets()is the keyring. So the closure exported the user's realAWS_SECRET_ACCESS_KEYandAWS_SESSION_TOKEN— and every process spawned afterwards inherits the environment, including theagent's own shell. A chat on a public model with
developer__shellcould print the credential.The privacy lattice does not stop that: its gates decide which model may read a conversation, not
what a shell may read out of its own environment, and the general filesystem read-deny (DR-14) is
deferred.
std::env::set_varis also unsound in a multi-threaded process — Rust 2024 made itunsafefor exactly this — and binding a provider is not a startup-only act here (a user switchesmodels mid-session; a subagent binds its own).
Fail-before evidence
Reverting only the closure, with the new tests kept:
The secret existed only in
<root>/config/secrets.yaml. The child process started with everyAWS_*variable scrubbed, bound the public Bedrock provider exactly as production does, then spawneda grandchild and asked what it could see — and the grandchild read the credential. No shell involved:
the stand-in is a re-exec of the test binary, so the row also runs on Windows.
The existing re-exec harness in
bedrock_namespace_tests.rswas extended rather than duplicated — itexists for precisely this reason, as its own module doc says ("the public provider's
std::env::set_varis part of what is under test … Calling it in this multi-threaded binary isunsound"). It gained a
secrets_yamlseam and a generic report type.The explicit-credentials shape adopted
New shared module
crates/biorouter/src/providers/aws_stored_settings.rs(StoredAwsSettings), readfrom
config.yamlthen the keyring (so a secret still wins a clash, as the export's ordering did),and applied to
aws_config::ConfigLoader:AWS_PROFILEConfigLoader::profile_nameAWS_REGIONConfigLoader::region(Region::new(..))AWS_ENDPOINT_URL_<SERVICE>_RUNTIME, thenAWS_ENDPOINT_URLConfigLoader::endpoint_urlAWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY(+AWS_SESSION_TOKEN)credentials_provider(Credentials::new(..))AWS_BEARER_TOKEN_BEDROCK, and no key pairtoken_provider(Token::new(..))+auth_scheme_preference(["httpBearerAuth"])This is
versa_bedrock.rs's shape, which was already doing it correctly. A partial pair (a keywithout its secret, or a lone session token) is deliberately not credentials — it falls through to
the SDK's own chain rather than binding the provider to something that cannot sign.
sagemaker_tgi.rsalso moved fromaws_config::load_from_env()toaws_config::defaults(BehaviorVersion::latest())— the same chain, but with a builder to apply to. Asa side effect SageMaker now honours a stored region/profile/endpoint, which the export only gave it by
accident.
Two properties preserved deliberately
Either would have been a silent config regression riding inside a security fix:
set_varoverwrites, so a key held inconfig.yamlor the keyring outranked the same variable already in the environment. The settings are therefore
applied last, where an explicit loader value wins.
SDK's own chain (env, SSO, profile files, IMDS) runs untouched. Two existing rows depend on this —
one where the credentials live only in the environment, one where the endpoint does. Both still pass
unmodified.
The SigV4-vs-bearer trap: measured, and deliberately left alone
The SDK reads
AWS_BEARER_TOKEN_BEDROCKitself and prefers bearer auth over signing unless the schemewas chosen in code.
versa_bedrockpinssigv4for that reason. The public cards do not, andthat is a decision rather than an omission: under the export the stored keys landed in the
environment, where an environment bearer token still won the scheme — so pinning
sigv4here wouldchange which credential an existing install authenticates with, a behaviour change smuggled inside a
security fix.
a_stored_credential_does_not_change_which_auth_scheme_the_sdk_picksmeasures it: with the keys inthe store and a bearer token in the environment, the request still carries
Bearer <token>andsigned_by()isNone— i.e. moving the credentials offenvironchanged nothing aboutauthentication. That is the property a security fix should have.
The leak row also asserts the credential still reaches the SDK
(
signed_by() == Some(("PUBLICTESTACCESSKEY", "us-west-2"))), because an absence-only assertion wouldpass on a fix that broke every store-based install.
Two stale pointers in the brief, confirmed stale
bedrock.rs:92does not set a second variable. Lines ~86–102 are a prose comment recording thatthe
AWS_ENDPOINT_URL_BEDROCK→_BEDROCK_RUNTIMEpromotion was removed on 2026-09-11. Exactly oneset_varexisted in the provider setup path.versa_bedrock.rshas noset_varat all —:85is prose,:758/:778are inside test functions.PR fix(versa_bedrock): Versa Bedrock and the public Amazon Bedrock card stop steering each other #248 stopped Versa reading the
AWS_*namespace; it never exported anything.A2 — bridged coding-agent tool output skipped the untrusted-framing guardrail
d17dbd14Every tool result the parent model reads is wrapped in
<tool-output untrusted="true" tool="…">and scanned for injection markers and PII/PHI by
guardrails::tool_output::guard_tool_result. It hadexactly one call site:
Agent::integrate_tool_result.A bridged call never reaches it. The vendor CLI calls
POST /tool_bridge/{nonce}, the routeanswers from
BridgeGrant::call_for_child, and the provider later lifts the kept result straight intothe transcript via
mirror::stored_bridged_result. None of that is the agent's turn loop — so thebypass was structural, not a forgotten line. (It also predates #228, so #228 merging does not fix
it.)
Two readers were wrong:
detectors that read one back;
README, a database row) and can be talked into acting on them. That is the reader the frame exists
for, and it is the one this fix is actually about.
Fail-before evidence
With the guard call replaced by
(Ok(result), None):Exactly the
date-shaped call from the report. In the diff only the text differs —is_error,structured_contentand every annotation are identical — which is itself the evidence that framingdoes not disturb the MCP result shape.
Where the framing belongs, and why
In
call_for_child, once, above the fork, so the same bytes go to both destinations:provider's, which is the reported defect.
the actual injection surface.
mirror::recorded_if_receiveddecides whether the child really receiveda result by comparing
child_view(recorded)against the child's echo. Both sides now derive from thesame framed result, so the texts still match; framing exactly one of them would have made every
bridged call read as un-received and silently fall back to storing the echo. This is the constraint
that makes "above the fork" the only correct position rather than merely the tidy one.
The MCP result shape the vendor CLIs expect is untouched.
guard_tool_resultrewritestextandnothing else —
is_error,structured_content, images, embedded resources and every annotation passthrough bit-for-bit — and the frame is plain text inside a text block.
child_view's own contract(one model-facing block, no annotations, no
priorityfor codex-cli to choke on) is assertedalongside.
The mode is sampled once, when the grant is built, matching the struct's documented "snapshot when
the grant is issued" convention and the agent's once-per-turn sample: a mode that changed mid-turn
would frame some of a turn's results and not others.
Census interaction
the_guardrail_has_exactly_one_call_sitebecomesthe_guardrail_has_one_call_site_in_each_of_its_two_funnels— a table of (file, funnel) rows. Its oldclaim ("called from exactly one place") is now false, and a test whose doc lies is worse than no test.
⚠ It first went red at
calls == 2inbridge.rs: the new agreement test callsguard_tool_resultitself, to build the non-bridged reference frame — the "census counts test helpers" failure mode. Fixed
by counting each file's production half only (
split("\nmod tests {").next()); each file hasexactly one
mod tests {at column 0, verified by grep. Two self-matching greps in the A1 tests neededthe same treatment (
concat!("set_", "var")).grant_cancelled_bynow pins the bridge suite's mode toOff:BridgeGrant::newsamples the user'sown config, so a developer with the guardrail switched off would otherwise run a different suite from
CI on every row that asserts on a result's text.
mirror.rs: checked, no change neededThe brief flagged that
mirror.rsonce dropped audience annotations, making every bridged result reachboth coding agents twice. That is fixed on
origin/main, and the fix does not live inmirror.rs— it is
bridge.rs::child_view(:1016), which doesretain(audience::is_for_model)and then clearsannotations, with the QA-E F4 write-up above it.
mirror.rsconsumeschild_view(
recorded_if_received). Three tests pin it. Nothing touched.Verification
cargo test -p biorouter --lib(whole suite)... --lib -- providers::bedrock providers::sagemaker providers::versa_bedrock providers::coding_agent providers::aws_stored_settings guardrails... -p biorouter --test privacy_capability --test privacy_guard_wiring... -p biorouter-server --lib -- routes::tool_bridge... -p biorouter-server --test tool_bridge_routescargo fmt --all --checkAll with
BIOROUTER_DISABLE_KEYRING=true.No privacy enforcement point moved. Neither change adds or relocates a
CallCapabilitysamplesite, a
privacy::floorcall, a.call_tool(site,raise_privacyoraffiliation::refusing_mismatch.privacy_capability.rsandprivacy_guard_wiring.rsare greenunmodified, so no census row was touched and none needed extending.
Not covered by automation: the eight
tool_bridge_routesrows that drive the realclaudeandcodexCLIs are#[ignore]d (they spend the user's own plan quota). The framing change is on thepath those rows exercise, so a live run against both CLIs is worth doing before release — that is the
one thing this PR asserts only at the HTTP layer and not against a real vendor child.
Docs
docs/providers/coding-agents/tool-bridge.mdgains a "Tool output is framed as untrusted on thispath too" section (the two funnels, the fork diagram, the three reasons the position is what it is)
plus a row in the gate table.
README.md,child-agent-isolation.mdandhow-it-works.mdeach linkto it from the place they already discussed the relevant half — isolation in both directions, and the
mirror being why the frame was missing.
🤖 Generated with Claude Code