fix(security): resolve profile-varying env vars through the profile scope (cross-profile leak) - #104265
fix(security): resolve profile-varying env vars through the profile scope (cross-profile leak)#104265Bergmann89 wants to merge 5 commits into
Conversation
aec1faf to
f478315
Compare
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head f478315f3a42f2ae30c2fab2e6e0e9e54b822f3e (3 commits, 20 files) against the live repository and current main (7166071fcaadb36df26f6d753dda97da6b5d699e). I traced the read-side authority changes through secret_scope, terminal_scope, file_safety, the changed consumers, the new AST guard, the focused regressions, exact-head Actions, and the adjacent multiplexing work. The overall direction is right: moving policy reads to the existing ContextVar owners instead of adding another profile-routing mechanism is the correct shape, and the hook/SSRF defaults are correctly restrictive.
There is one security blocker in the current HERMES_WRITE_SAFE_ROOT layering, plus the landing/verification gate.
BLOCKER — an explicit empty profile value bypasses the claimed container-wide write floor
agent/file_safety.py::_safe_write_root_raw() says the Docker /opt/data value is a container-wide floor and explicitly calls empty resolution a fail-open / allow-all state. But the scope-hit branch currently does:
if scope is not None and "HERMES_WRITE_SAFE_ROOT" in scope:
return scope["HERMES_WRITE_SAFE_ROOT"] or ""An empty .env assignment is a real scoped value: agent.secret_scope.load_env_file() retains any KEY= entry in the returned mapping. Therefore a multiplexed profile containing:
HERMES_WRITE_SAFE_ROOT=hits this branch and returns "" instead of the ambient /opt/data floor. get_safe_write_roots() then returns an empty set, and _classify_write_denial() only enforces the safe-root restriction when that set is truthy. The result is exactly the state the new docstring says must never occur: paths outside /opt/data are no longer denied by the safe-root policy.
The new tests cover scope hit with a non-empty value, scope miss, unscoped multiplex, and the historical single-profile-unset case, but not multiplex + explicit empty scoped value + container floor present. Please make the empty/absent distinction explicit at the policy owner and add that regression. If /opt/data is truly a floor, an explicit empty profile override cannot erase it. If empty is intentionally allowed to opt out of the Docker floor, then the current security contract/documentation is false and needs a different design statement; given the PR's own fail-closed invariant, I do not think that is the intended answer.
Repository interlocks / ownership
This is the read-side complement to the earlier terminal bridge work in #103672: that PR fences which profile may project TERMINAL_* into process env; this one makes consumers stop treating ambient process env as per-profile authority. Those changes are complementary, not duplicates.
Two current PRs show the important other side of this class outside this patch's tools/ + agent/ guard boundary: #104279 owns the shared proxy resolver's profile-scoped *_PROXY read, while #104276 owns Slack's mention/reaction/DM/channel policy reads and deliberately leaves its separate allow_bots overlap to #100028. I would not fold those implementations into this PR or erase their authorship. I would, however, narrow the claim that check_profile_env_scope.py “closes the class”: the guard is a useful regression nudge for the keys and directories it enumerates, but it is not repository-wide multiplex-policy coverage. The adjacent PRs are concrete proof of that boundary. #39004 is also complementary rather than competing: it owns execution-write confinement; this PR owns correct per-profile resolution of the native write_file / patch safe-root policy.
The placeholder Fixes #<!-- ... --> should also be removed or replaced with the actual incident issue before landing; there is no valid closure relationship there today.
Exact-object verification / merge order
At this reviewed head, the executable jobs I inspected are green: Python tests, Python e2e, blocking Ruff, diff Ruff+ty, Windows-footgun + the new env-scope guard, OS-specific tests, JS/TS, docs, supply-chain/OSV, attribution, Docker, and Nix all pass. The main CI workflow is nevertheless red because the Review label gate fails on the missing ci-reviewed label, which in turn makes All required checks pass red; this is an administrative gate, not an executable-test failure.
The commit train is also not every-commit green: the first two surviving SHAs (e29e9399bc2613b96718b94ca7e7ee15bbb6ce43, 3aae3a6a01b4a5879eb1632358526e8889dda9d4) have no PR-triggered hosted workflow receipts. The branch is now materially behind live main (it forked from 089bb32886c8c18f7fa20182c7bf8826d6935ac5, while main is 7166071fcaadb36df26f6d753dda97da6b5d699e). After fixing the explicit-empty case, rebase and obtain fresh exact-head evidence; preserve the three-commit attribution/history unless there is a deliberate reason to rewrite it.
Once the empty scoped-value path is fail-closed and the rebased commit train has complete green receipts, I do not see another blocking correctness issue in the scoped hook/SSRF/terminal-read changes I traced. This is worthwhile security work and the read-time-owner direction is the right one.
f478315 to
f0b5fb6
Compare
|
Thanks for the trace — the empty-override blocker is real and now fixed. Blocker: explicit empty scoped value bypassed the container floor. Added PR body: removed the Rebase / evidence: rebased onto current Scope tests (21) green, CI guard green, ruff clean locally. |
817db16 to
1277793
Compare
28a84ac to
6f2306a
Compare
…ile leak) Under gateway multiplexing one process serves every profile off one shared os.environ, into which each profile's .env is loaded with override=True. The write-safety allowlist was read with a plain os.getenv, so whichever profile loaded last gated every profile's writes - proven live: profile felix's write_file was checked against HERMES_WRITE_SAFE_ROOT=/home/jonas. get_safe_write_roots now resolves through agent.secret_scope.get_secret, which returns the bound profile's value and never another profile's os.environ value. An unscoped read under active multiplex (UnscopedSecretError) is treated as unset - the permissive baseline, never another profile's value and never a crash of the write tool. Single-profile / unscoped-default runs still read os.environ, so their behavior is unchanged. Refs: cross-profile env-leak class (see follow-up commits for the terminal/policy readers and the CI guard).
Every plain os.getenv of a profile-varying key leaks under gateway multiplexing (one process, one shared os.environ, last profile's .env wins). Route them all through the existing fail-closed scopes instead of os.environ: TERMINAL_* (global prefix -> terminal_env()/scope_terminal_cwd()): skills_tool, credential_files, image_source, image_generation_tool, file_tools_paths (TERMINAL_ENV); process_registry (TERMINAL_TIMEOUT, TERMINAL_LOCAL_MEMORY_MAX_MB); environments/base (TERMINAL_SANDBOX_DIR); environments/singularity (TERMINAL_SCRATCH_DIR); tool_executor + delegate_tool_progress (TERMINAL_CWD). Policy vars (non-global -> get_secret(), unscoped-multiplex treated as unset, fail closed): shell_hooks HERMES_ACCEPT_HOOKS (auto-approve trust boundary), url_safety HERMES_ALLOW_PRIVATE_URLS (SSRF blocking). url_safety's per-turn cache bypass under a home override already handles multiplex. Terminal readers that intentionally read os.environ (terminal_tool, terminal_tool_config) are unchanged - the scope is projected into their env. Refs: cross-profile env-leak class.
Every raw os.getenv of a profile-varying key is its own fresh cross-profile leak under multiplexing - the same brittleness that let HERMES_WRITE_SAFE_ROOT leak. Close the class: an AST guard walks tools/ and agent/ and fails on a raw os.getenv / os.environ.get / os.environ[...] whose key is HERMES_WRITE_SAFE_ROOT, HERMES_ACCEPT_HOOKS, HERMES_ALLOW_PRIVATE_URLS, or any TERMINAL_* (a global-env prefix, so those must go via terminal_env, not get_secret). Legitimate raw readers (the ImportError fallbacks inside the new scope-aware wrappers) opt out with a trailing '# scope-exempt: <reason>'. Wired into lint.yml next to check_compat_pointers; self-tested in tests/scripts.
…er floor An explicit empty per-profile override (HERMES_WRITE_SAFE_ROOT= in the profile .env) is retained by load_env_file and reaches the secret scope, so the scope-hit branch returned "" - an empty write-root set, which _classify_write_denial treats as no safe-root restriction. A multiplexed profile could thus erase the container-wide /opt/data floor and fail OPEN (allow-all), the exact state the docstring says must never occur. Gate the scope-hit branch on a non-empty value: an empty override now falls through to the container floor from os.environ, same as a scope miss. Fail closed, never allow-all, never another profile value. Adds test_explicit_empty_scoped_value_keeps_container_floor (multiplex + explicit empty scoped value + floor present) - red before the guard, green after.
An argument-less load_hermes_dotenv() (lazy import mid-tick from mcp_tool_config.py / mcp_config.py / plugins.py) resolves home_path from the process HERMES_HOME, so it passes the override-immune equality guard in _reapply_terminal_config_bridge even while a routed-profile home override is active. apply_terminal_config_to_env() then reads config via the override-FOLLOWING get_hermes_home() and bridges the routed profile's terminal.* (e.g. docker backend + volumes) into the shared process os.environ, hijacking the launch profile's next unscoped turn. This is the write-side complement to the read-side scope fix: suppress the re-bridge whenever any context-local home override is active, so a routed context never writes terminal policy into the shared env regardless of which home_path it resolved. Regression test fails without the guard (bridges docker) and passes with it.
6f2306a to
b48ed47
Compare
|
Confirmation from a live multiplexed install — this reproduces exactly as you describe, and it's the profile-scoping half of the same bug class as #107327. Environment: one multiplexed gateway serving 8 profiles (default + 7) on a single host. Each profile's own Symptom: an agent turn hosted for a non-default profile is evaluated against the default profile's roots. That profile's own vault is refused while a different agent's vault is permitted. Controlled A/B — same cron job, same profile, same target path, only the entry point differs:
The root list inside that refusal is the default profile's, printed from a turn whose Two things that fall out of this, in case they're useful:
Rollout note: changing Conflict heads-up: #111168 also rewrites |
|
f33b519eb45cc83a — your PR's exact class, one more consumer to fold in: profile-scoped HERMES_WRITE_SAFE_ROOT is inert under the multiplexing gateway. |
What does this PR do?
Under gateway multiplexing, one OS process serves every profile off one shared
os.environ, into which each profile's.envis loaded withoverride=True(last writer wins). Security-relevant env vars were read with a plainos.getenv, so whichever profile loaded its.envlast silently gated the others: one profile'sHERMES_WRITE_SAFE_ROOTcould confine another profile'swrite_file, and one profile'sHERMES_ACCEPT_HOOKS/HERMES_ALLOW_PRIVATE_URLScould flip another profile's trust boundaries.The fix routes every profile-varying env read through the isolation seams that already exist in the codebase, rather than inventing a new mechanism:
TERMINAL_*(a global-env prefix insecret_scope, soget_secretwould not isolate them) ->tools.terminal_scope.terminal_env/agent.runtime_cwd.scope_terminal_cwd.HERMES_ACCEPT_HOOKS,HERMES_ALLOW_PRIVATE_URLS) ->agent.secret_scope.get_secretvia a newsecret_or()fail-closed helper: an unscoped-multiplex miss returns the restrictive default instead of raising or leaking.HERMES_WRITE_SAFE_ROOTis special — it is a container-wide floor (DockerfilesetsENV HERMES_WRITE_SAFE_ROOT=/opt/data) that a profile may tighten in its own.env, and its empty value is permissive (allow-all). So it resolves by layer: a scope hit with a non-empty value (profile set its own) wins and fixes the leak; an explicit empty override (HERMES_WRITE_SAFE_ROOT=) is treated as absent, not allow-all, and falls through to the floor; a scope miss falls back to the container-wide floor fromos.environ— never to empty (which would fail open) and never to another profile's value.A CI guard (
scripts/check_profile_env_scope.py, AST-based, wired intolint.yml) backstops the specific keys this PR routes: it fails on any rawos.getenv/os.environread of the enumerated profile-varying keys (HERMES_WRITE_SAFE_ROOT,HERMES_ACCEPT_HOOKS,HERMES_ALLOW_PRIVATE_URLS) and theTERMINAL_*prefix withintools/andagent/, with a# scope-exempt: <reason>opt-out for the legitimate fallbacks. It is a targeted regression nudge for those keys and directories, not repository-wide multiplex-policy coverage — the adjacent profile-scope PRs (#104279 proxy resolver, #104276 Slack policy reads) own other slices of the same class outside this guard's boundary.Why this approach: the codebase already made the design call that read-time secret scoping owns cross-profile isolation (documented in
tests/hermes_cli/test_env_loader.py— a process cannot distinguish a shell export from parent-process leakage, so the startup scrub set stays deliberately narrow). This PR routes the leaking reads through that existing layer instead of widening the scrub set, which would have re-introduced a bug that test already guards.Related Issue
None — internal security hardening, no tracking issue.
Type of Change
Changes Made
Read-time scope routing (the leak fix):
agent/file_safety.py—HERMES_WRITE_SAFE_ROOTresolves by layer (scope hit with a non-empty value -> own value; explicit empty override -> container floor; miss -> container floor); fail-closed, never allow-all.agent/shell_hooks.py—HERMES_ACCEPT_HOOKS(auto-approve trust boundary) viasecret_or.tools/url_safety.py—HERMES_ALLOW_PRIVATE_URLS(SSRF/private-IP blocking) viasecret_or.tools/skills_tool.py,tools/credential_files.py,tools/image_source.py,tools/image_generation_tool.py,tools/file_tools_paths.py—TERMINAL_ENVviaterminal_env.tools/process_registry.py—TERMINAL_TIMEOUT,TERMINAL_LOCAL_MEMORY_MAX_MBviaterminal_env(withTerminalPolicyUnavailablehandled).tools/environments/base.py,tools/environments/singularity.py—TERMINAL_SANDBOX_DIR,TERMINAL_SCRATCH_DIRviaterminal_env.agent/tool_executor.py,tools/delegate_tool_progress.py—TERMINAL_CWDviascope_terminal_cwd.Shared helper:
agent/secret_scope.py— newsecret_or(name, default): fail-closedget_secretwrapper for policy toggles whose default is the safe value.CI guard:
scripts/check_profile_env_scope.py— AST guard banning raw reads of the profile-varying keys/prefix intools/+agent/,# scope-exemptopt-out..github/workflows/lint.yml— runs the guard next tocheck_compat_pointers.Tests:
tests/agent/test_file_safety_write_root_scope.py— layered resolution: scope hit wins, explicit empty override keeps the container floor (fail-closed), miss keeps the container floor (asserts a path outside it is still denied), unscoped-multiplex does not crash or allow-all.tests/agent/test_policy_env_scope.py— the two trust boundaries: a leaked truthy from another profile cannot auto-approve hooks or disable SSRF blocking;secret_orsemantics.tests/scripts/test_check_profile_env_scope.py— guard self-test: red on planted violations, green on the tree,# scope-exempthonored.How to Test
.envset differentHERMES_WRITE_SAFE_ROOTvalues. Awrite_fileunder profile A into A's own root is denied, gated by profile B's value.pytest tests/agent/test_file_safety_write_root_scope.py tests/agent/test_policy_env_scope.py tests/scripts/test_check_profile_env_scope.py -q— all green.python scripts/check_profile_env_scope.pyexits 0 on the tree; plantingos.getenv("TERMINAL_ENV")intools/makes it exit 1.scripts/run_tests.sh.Platforms tested
Debian (aarch64), Python 3.13.
Screenshots / Logs
Leak evidence (pre-fix): a
write_fileunder one profile is rejected by another profile's allowlist