Skip to content

fix(security): strip BWS token and *_PASSWORD from child-process envs - #77027

Open
andrexibiza wants to merge 7 commits into
NousResearch:mainfrom
andrexibiza:fix/security-scrub-child-process-env
Open

fix(security): strip BWS token and *_PASSWORD from child-process envs#77027
andrexibiza wants to merge 7 commits into
NousResearch:mainfrom
andrexibiza:fix/security-scrub-child-process-env

Conversation

@andrexibiza

@andrexibiza andrexibiza commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What changed and why

Closes the child-process credential-inheritance bug class where trusted Hermes credentials and other sensitive parent-environment values reached untrusted or model-authored subprocesses.

Confirmed production chain (pinned head de0e88d8): SingularityEnvironment._run_bash_popen_bash with no sanitized envsubprocess.Popen inherited the trusted Hermes process environment. Docker, SSH, and future backends converge on the same shared boundary, so safety cannot depend on every caller remembering to pass env.

Changes

  • tools/environments/base.py_popen_bash now builds a sanitized child env by default (build_subprocess_env(base=...)) and applies the same policy to caller-supplied env maps, so omitting env can never re-open ambient inheritance.
  • tools/environments/local.py — case-insensitive provider/security matching (_is_blocked_provider_env) for Windows environment semantics; effective destination keys behind APPTAINERENV_/SINGULARITYENV_ wrappers are checked (_credential_target_env_name); BWS_ACCESS_TOKEN promoted to Tier-1 _ALWAYS_STRIP_KEYS so inherit_credentials=True paths cannot export the vault bootstrap token.
  • tools/environments/singularity.py — preflight, instance start, image build, and cleanup subprocesses all use the sanitized builder; image construction re-adds ONLY the six explicit Apptainer/Singularity Docker registry-auth variables instead of the full parent environment.
  • tools/environments/docker.py — explicit docker_forward_env entries may not export Hermes-internal secrets (AUXILIARY_*/GATEWAY_RELAY_*/BWS_ACCESS_TOKEN) — the forwarding list is a capability boundary, not an unconditional bypass.
  • tools/env_passthrough.py — uses the case-insensitive blocklist predicate.
  • tests/tools/test_backend_subprocess_env_boundary.py — 12-test regression matrix covering Docker/SSH/Singularity exec, caller-supplied and empty base envs, mixed-case credentials, nested children, Singularity lifecycle, narrow image-build auth, and Docker explicit forwarding.

Why this matters to you as a user

A spawned child process (terminal command, Docker/SSH/Singularity backend, browser worker, ACP executor, model-driving CLI) could previously read your provider API keys, Bitwarden vault token, GitHub token, gateway secrets, and database/service passwords straight from its environment. After this change, those values don't cross the process boundary unless a command explicitly needs them — and on the terminal path, a command that legitimately needs a value (registered via env_passthrough) still gets it.

Reproduction steps (current behavior on main)

  1. export BWS_ACCESS_TOKEN=0.abc123.def456:xyz789 and export DB_PASSWORD=db-pass-9f2c1a.
  2. From a Hermes checkout, spawn a child with the default factory env:
    from tools.environments.local import build_subprocess_env
    env = build_subprocess_env()  # scrub on (default)
    print("BWS_ACCESS_TOKEN" in env, "DB_PASSWORD" in env)  # (True, True) on main
  3. Same for hermes_subprocess_env().

Current: both keys present in the child env.
Expected: both absent (BWS token unconditionally; *_PASSWORD unless explicitly registered for passthrough).

How to test

  • scripts/run_tests.sh tests/tools/test_backend_subprocess_env_boundary.py12 passed (Docker/SSH/Singularity exec boundary, caller-supplied and empty base envs, mixed-case credentials, nested children, Singularity lifecycle, narrow image-build auth, Docker explicit forwarding).
  • scripts/run_tests.sh tests/tools/test_build_subprocess_env.py → 8 passed (2 new E2E tests spawn a real child via sys.executable and assert the BWS token and DB_PASSWORD are absent; 1 new unit test asserts hermes_subprocess_env strips both).
  • scripts/run_tests.sh tests/tools/test_env_passthrough.py → 23 passed — confirms a passthrough-registered command still receives its value.

Evidence

Part of #83565 — the boundary fix: sanitize-by-default at the shared _popen_bash (Docker/SSH/Singularity + every future backend). Focused matrix 12/12 GREEN, CI green, mergeable clean.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/tools Tool registry, model_tools, toolsets backend/local Local shell execution area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 2, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tracing the child-process environment boundary; current main does allow unlisted *_PASSWORD values through both factories (tools/environments/local.py:469-495, tools/environments/local.py:606-625).

Problems

  • tools/environments/local.py:401 treats every *_ACCESS_TOKEN as Hermes-internal. The terminal path checks that predicate before passthrough (tools/environments/local.py:497), and passthrough registration rejects it (tools/env_passthrough.py:88). This blocks legitimate third-party access tokens despite the documented passthrough contract (tools/env_passthrough.py:61-63).
  • The suffix check does not implement arbitrary Bitwarden remaps: BitwardenSource.fetch() reads the exact configured access_token_env name (agent/secret_sources/bitwarden.py:916-917), including names not ending in _ACCESS_TOKEN.

Suggested changes

  • Protect the exact configured Bitwarden token-variable name rather than the global _ACCESS_TOKEN suffix.
  • Add tests for an explicitly passed-through DB_PASSWORD, inherit_credentials=True on hermes_subprocess_env, and a non-suffix Bitwarden token-variable remap.

Automated hermes-sweeper review.

Comment thread tools/environments/local.py Outdated
@@ -391,9 +398,27 @@ def _is_hermes_internal_secret(key: str) -> bool:
upper.endswith("_SECRET") or upper.endswith("_KEY") or upper.endswith("_TOKEN")
):
return True
if upper.endswith("_ACCESS_TOKEN"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This makes every third-party *_ACCESS_TOKEN non-passthroughable: _sanitize_subprocess_env() checks _is_hermes_internal_secret() before env_passthrough, and tools/env_passthrough.py rejects internal secrets during registration. Please narrow this to the exact configured Bitwarden access_token_env; the current suffix check also misses custom names such as MY_BWS_TOKEN.

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.

Fixed in 67dc4e8cb5. _is_hermes_internal_secret no longer matches the *_ACCESS_TOKEN suffix — it matches only the exact configured secrets.bitwarden.access_token_env name (per-Hermes-home cached), so third-party tokens like STRIPE_ACCESS_TOKEN are env_passthrough-registerable again, while non-suffix Bitwarden remaps like MY_BWS_TOKEN are stripped exactly. Regression tests: test_bws_token_env_remap_non_suffix_stripped plus the passthrough-survival cases; the per-profile cache sequence (A → B → A, no global resets) is covered by test_bws_token_env_cache_is_scoped_per_profile. Verified at head b7dfe883e8: 38/38 touched tests pass.

monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.abc123.def456:xyz789")
monkeypatch.setenv("DB_PASSWORD", "db-pass-9f2c1a")

env = hermes_subprocess_env() # inherit_credentials=False (default)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This only tests the default inherit_credentials=False path. Add coverage for inherit_credentials=True, since the implementation and PR description require *_PASSWORD values to be stripped unconditionally on this factory.

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.

Fixed in 67dc4e8cb5. test_hermes_subprocess_env_strips_password_with_inherit_credentials covers the non-terminal factory stripping *_PASSWORD unconditionally even when the caller opts into inherit_credentials=True. Verified at head b7dfe883e8: 38/38 touched tests pass (tests/tools/test_build_subprocess_env.py 15 + tests/tools/test_env_passthrough.py 23).

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Aug 2, 2026
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Fixed in 67dc4e8cb5.

Both sweeper problems addressed:

  1. Over-broad *_ACCESS_TOKEN suffix match removed_is_hermes_internal_secret no longer treats every third-party access token as Hermes-internal. Only the exact configured secrets.bitwarden.access_token_env name is protected (default BWS_ACCESS_TOKEN, resolved via the new _get_configured_bws_token_env() and cached per process). Third-party *_ACCESS_TOKEN vars (e.g. STRIPE_ACCESS_TOKEN) are again env_passthrough-registerable and pass through unchanged on the terminal path.
  2. Non-suffix Bitwarden remaps now caughtBitwardenSource.fetch reads the exact configured name (agent/secret_sources/bitwarden.py:916-917), so a remap like access_token_env: MY_BWS_TOKEN is now matched exactly instead of being missed by a suffix check.

Tests added (tests/tools/test_build_subprocess_env.py):

  • test_bws_token_env_remap_non_suffix_strippedMY_BWS_TOKEN remap stripped exactly; STRIPE_ACCESS_TOKEN survives and is passthrough-registerable
  • test_terminal_path_keeps_passthrough_db_password — explicitly passed-through DB_PASSWORD reaches the terminal child (strip is passthrough-aware)
  • test_hermes_subprocess_env_strips_password_with_inherit_credentials*_PASSWORD stripped unconditionally on hermes_subprocess_env even with inherit_credentials=True

Validation: scripts/run_tests.sh tests/tools/test_build_subprocess_env.py tests/tools/test_env_passthrough.py → 34 passed, 0 failed; tests/tools/test_local_env_blocklist.py + tests/agent/test_subprocess_env_guard.py + tests/gateway/test_delegation_session_id_leak.py → 39 passed (4 pre-existing Windows PATH-assertion failures, reproduced on the base commit); Ruff and git diff --check pass; scripts/check-windows-footguns.py → no footguns.

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Both suggested changes are in, fixed in 67dc4e8cb5.

1. Exact configured Bitwarden token-variable name, not the _ACCESS_TOKEN suffix.

_is_hermes_internal_secret no longer matches upper.endswith("_ACCESS_TOKEN"). It now matches upper == _get_configured_bws_token_env().upper() — the exact name configured via secrets.bitwarden.access_token_env (default BWS_ACCESS_TOKEN), resolved from config and cached per process. This:

  • keeps legitimate third-party *_ACCESS_TOKEN vars (e.g. STRIPE_ACCESS_TOKEN) out of the internal-secret set, so they remain env_passthrough-registerable (the passthrough gate in tools/env_passthrough.py:88 checks _is_hermes_internal_secret);
  • catches non-suffix remaps like MY_BWS_TOKEN, which the suffix check missed.

2. Coverage for the three cases.

Added to tests/tools/test_build_subprocess_env.py:

  • test_hermes_subprocess_env_strips_password_with_inherit_credentials*_PASSWORD stripped on hermes_subprocess_env even with inherit_credentials=True (unconditional, as the implementation promises).
  • test_terminal_path_keeps_passthrough_db_password — an explicitly registered DB_PASSWORD passthrough still reaches the terminal child (the strip is passthrough-aware on the terminal path).
  • test_bws_token_env_remap_non_suffix_strippedMY_BWS_TOKEN (non-suffix remap) is stripped exactly, STRIPE_ACCESS_TOKEN survives and registers as passthrough.

Verification: scripts/run_tests.sh tests/tools/test_build_subprocess_env.py → 11 passed; tests/tools/test_env_passthrough.py → 23 passed (passthrough semantics intact). git diff --check and check-windows-footguns.py clean.

andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 3, 2026
…cription, skill test

Addresses teknium1's review on NousResearch#77097:

1. 'Not true on main' — the security contract is now explicitly scoped
   as implemented by the secrets-exfiltration hardening series
   (NousResearch#77008/NousResearch#77012/NousResearch#77020/NousResearch#77027/NousResearch#77031/NousResearch#77039). The docs state current
   main behavior plainly (plaintext bws_cache.json read/written when
   encryption disabled, default false) and keep the rotation instruction
   mandatory today, since that exposure already exists on main. The
   posture framing stays — this eliminates an entire vulnerability
   class — but the claim is now sequenced truthfully.
2. Skill description shortened to 53 chars, one sentence, ends with a
   period (AGENTS.md hardline).
3. tests/skills/test_bitwarden_secrets_skill.py added: validates
   frontmatter, description length, required sections, user-only
   rotation + clipboard discipline, honest series scoping (no claim the
   gate test is on main), and docs-page metadata consistency.
4. Clipboard discipline added to rotation instructions (docs + skill):
   create token, copy to clipboard, paste into terminal, save nowhere
   in between.
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Review receipts — both comments from 2026-08-02:

*1) Over-broad _ACCESS_TOKEN match (tools/environments/local.py):
Addressed in 67dc4e8cb5 — _is_hermes_internal_secret now matches only the exact configured secrets.bitwarden.access_token_env (cached per process via _get_configured_bws_token_env()), so third-party *_ACCESS_TOKEN vars (STRIPE_ACCESS_TOKEN etc.) are registerable and pass through again, while non-suffix Bitwarden remaps like MY_BWS_TOKEN are stripped exactly as BitwardenSource.fetch reads them. Tests: test_bws_token_env_remap_non_suffix_stripped + passthrough survival cases.

2) inherit_credentials=True coverage (tests/tools/test_build_subprocess_env.py):
Addressed in the same commit — test_hermes_subprocess_env_strips_password_with_inherit_credentials covers the non-terminal factory stripping *_PASSWORD unconditionally even when the caller opts into credential inheritance.

11/11 test_build_subprocess_env.py tests pass.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The patch does not close the claimed child-process credential boundary. The LocalEnvironment terminal spawn path still propagates arbitrary variables ending in _PASSWORD, and the new Bitwarden token-name cache can reuse one profile's configuration while serving another. Filter password variables in the actual terminal spawn factory and scope Bitwarden token-name resolution to the active Hermes home.

  • [P1] Filter password variables in the LocalEnvironment spawn path (tools/environments/local.py:1343)
    LocalEnvironment._run_bash constructs every model-authored terminal child's environment with _make_run_env. That function merges os.environ with the backend environment and filters Hermes internal and provider credentials, but it never calls the new _is_credential_shaped_password predicate. The new checks protect build_subprocess_env and hermes_subprocess_env callers only, so a DB_PASSWORD or REDIS_PASSWORD remains visible to ordinary terminal commands unless it happens to be in an existing exact-name blocklist. The added tests exercise the helper factories, not LocalEnvironment._run_bash, and therefore miss the live sink.
    Remediation: Apply the same passthrough-aware _PASSWORD filter inside _make_run_env before values are placed in run_env, and add an end-to-end LocalEnvironment execution test proving the default denial and explicit passthrough case.

  • [P1] Scope the Bitwarden token-name cache per Hermes profile (tools/environments/local.py:417)
    The gateway can serve multiple profiles in one process by switching a context-local Hermes home for each turn. _get_configured_bws_token_env reads profile-aware config but stores the first result behind one process-global loaded flag. If a later profile uses a different secrets.bitwarden.access_token_env name, _is_hermes_internal_secret continues matching the first profile's name and the later profile's vault bootstrap token can pass into terminal, Docker passthrough, and non-terminal child environments. The test avoids this production sequence by manually resetting both cache globals.
    Remediation: Key the cache by the resolved active Hermes home, or resolve the small config value per call with a profile-aware cache. Add a sequential two-profile test that changes the home override without resetting module globals and verifies both configured token names are denied in their respective scopes.

Security evidence:

  • trust boundary: Sources are process and profile-scoped environment variables plus profile config. The sinks are model-authored local terminal children, Docker-forwarded variables, browser and ACP children, dependency installers, and model-driving CLI subprocesses. The main process is trusted to hold credentials; spawned children are not trusted to inherit credentials unless a narrow explicit passthrough contract applies.
  • source/sink/invariant: The claimed invariant is that the exact configured Bitwarden bootstrap-token variable never crosses any inherited child-process boundary, and variables ending in _PASSWORD are denied by default, with explicit skill passthrough supported only on the terminal surface. The patch enforces this on sanitized helper paths but not on _make_run_env and does not preserve the Bitwarden rule across profile changes.
  • current-main reproduction: Current main's LocalEnvironment._run_bash delegates to _make_run_env, whose loop copies non-blocklisted environment entries without a password-suffix decision. Direct child-environment probes also show the baseline helper paths preserve the configured Bitwarden token and arbitrary password variables.
  • PR-head or patch-replay validation: The exact PR head reproduces both helper/sink results. Cherry-picking the two PR commits onto current main completed without conflicts and reproduced the same terminal leak and two-profile cache result.
  • positive/negative cases: Positive source cases cover default BWS_ACCESS_TOKEN denial, a configured non-suffix Bitwarden name, password denial in helper-generated environments, third-party access-token preservation, and explicit terminal password passthrough. Negative review cases identify an ordinary LocalEnvironment terminal child with DB_PASSWORD and two sequential profile scopes with distinct Bitwarden token-variable names; neither case is covered by the added tests.
  • residual bypass search: Tracing all uses of _is_hermes_internal_secret, _is_credential_shaped_password, build_subprocess_env, hermes_subprocess_env, and LocalEnvironment found the unfiltered _make_run_env sibling path. Tracing profile runtime scopes and profile-aware config reads found that the new module-global Bitwarden cache crosses the context-local profile boundary.
  • reviewer validation: Independent source tracing confirms that _run_bash passes _make_run_env output directly to subprocess.Popen and that the new password predicate has no call in that function. It also confirms that the gateway multiplex path changes Hermes home through a context-local override while the new Bitwarden loaded flag is process-global.

Not checked:

  • Focused pytest execution
  • Full regression suite
  • Windows runtime behavior
  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Both P1s addressed — fixed in be5bd41878, with a follow-up guard in 63bee69ff0.

P1 #1_make_run_env (local.py:1343): the terminal spawn factory now applies the passthrough-aware _is_credential_shaped_password filter before values land in run_env, mirroring _sanitize_subprocess_env ordering (blocklist → password predicate → passthrough resolution). Live probe at head: a real LocalEnvironment._run_bash bash child with DB_PASSWORD/REDIS_PASSWORD/POSTGRES_PASSWORD planted sees none of them, while an explicitly env_passthrough-registered DB_PASSWORD still reaches the child. New tests: test_make_run_env_strips_password_by_default, test_make_run_env_keeps_passthrough_db_password, and test_local_environment_e2e_password_denial_and_passthrough (E2E through the real execution path, not the helper factories).

P1 #2 — process-global BWS cache (local.py:413): _get_configured_bws_token_env is now keyed by the active Hermes home (get_hermes_home(), context-override aware), so a gateway multiplexing profiles in one process resolves each profile's own access_token_env per turn. Sequential two-profile probe through the real contextvar seam with no module-global resets: profile A (TOKEN_ALPHA) → profile B (TOKEN_BETA) → B's name is matched and stripped in _sanitize_subprocess_env, _make_run_env, and hermes_subprocess_env, and excluded from the Docker passthrough filter. New test test_bws_token_env_cache_is_scoped_per_profile covers A → B → A.

63bee69ff0 additionally keeps the default BWS_ACCESS_TOKEN name internal in remapped profiles: the shared os.environ can carry a default profile's token into a remapped profile's turn, and it must not cross that child boundary either. Third-party *_ACCESS_TOKEN variables remain passthrough-registerable in every profile.

Validation: 38/38 tests pass across tests/tools/test_build_subprocess_env.py (15) and tests/tools/test_env_passthrough.py (23); git diff --check clean; check-windows-footguns.py clean on both changed files.

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Follow-up in b7dfe883e8: broadened the password-class scrub to match the sandbox — PGPASSWORD, MYSQL_PWD and bare PASSWORD are now stripped on every spawn path too (code_execution_tool.py already scrubbed these by substring; the terminal path is now at least as protective). PWD (the shell cwd var) is explicitly excluded. Tests extended; 38/38 touched tests pass, git diff --check and check-windows-footguns.py clean.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The post-review commits close the two earlier findings: the local terminal and non-terminal factories now strip the tested password class, explicit terminal passthrough still works, and the Bitwarden token-name cache follows A→B→A profile switches. The same child-process invariant is still bypassed by the Singularity/Apptainer terminal backend, though. SingularityEnvironment._run_bash calls the shared _popen_bash helper without an env, so subprocess.Popen inherits the complete Hermes process environment. With synthetic BWS_ACCESS_TOKEN, DB_PASSWORD, PGPASSWORD, MYSQL_PWD, and PASSWORD values planted, the apptainer exec child received all five on both current main and the current-main replay. Please build this backend's start/exec environments through the same sanitized terminal factory and add a production-path regression proving these values are absent while required non-secret controls remain available.

Security evidence:

  • trust boundary: the trusted Hermes process can hold vault and service credentials; the external Apptainer/Singularity terminal process and the model-authored command it launches must not inherit them by default.
  • source/sink/invariant: process environment variables flow through SingularityEnvironment._run_bash into _popen_bash; because no env is supplied, Python inherits the parent environment instead of applying the PR's password and Bitwarden filters.
  • current-main reproduction: an invocation-level probe showed the Singularity exec path supplied no env, and the effective child environment contained all five planted secret variables.
  • PR-head or patch-replay validation: the exact head merged cleanly onto current main; the replay fixed the local/non-terminal factories but reproduced the unchanged Singularity inheritance result.
  • positive/negative cases: local terminal denial, non-terminal denial, explicit DB_PASSWORD passthrough, PWD preservation, and A→B→A Bitwarden profile isolation all behaved as intended; only the Singularity sibling path retained the secrets.
  • residual bypass search: the local, background/PTY, non-terminal, Docker-passthrough, Bitwarden-name, and Singularity spawn paths were traced; the shared _popen_bash call without an environment is the uncovered sibling sink.
  • reviewer validation: the current-main/replay probe imported both decisive modules from the bound tree, exercised the production call shape, and a harmless real child confirmed that _popen_bash inherits the planted values when no environment is passed; compile and diff checks passed.

Not checked:

  • Focused pytest execution
  • Native Apptainer runtime
  • Full regression suite
  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

Audit of the two subprocess-env factories (build_subprocess_env for the
terminal surface, hermes_subprocess_env for the non-terminal surface)
found two credential classes that leaked into children by default:

1. BWS_ACCESS_TOKEN — Hermes's own Bitwarden Secrets Manager bootstrap
   token. The static provider blocklist doesn't know it (it's not an LLM
   provider key), so it flowed to every spawned child. The one child
   that legitimately needs it — the bws CLI — receives it explicitly via
   build_subprocess_env(scrub_secrets=False) in bitwarden.py, never by
   inheritance.
2. *_PASSWORD values (DB_PASSWORD, POSTGRES_PASSWORD, REDIS_PASSWORD).
   Only exact names like EMAIL_PASSWORD were blocklisted; the general
   credential shape fell through.

Fix: _is_hermes_internal_secret now matches *_ACCESS_TOKEN (the BWS
token and any access_token_env remap); a new _is_credential_shaped_password
predicate strips *_PASSWORD — passthrough-aware on the terminal path (a
skill-registered command that legitimately needs the value still gets it
via env_passthrough) and unconditional on the non-terminal surface.

Why this matters to users: a spawned child process (browser worker, ACP
executor, computer-use driver, a shell command) could previously read
your Bitwarden vault token and database/service passwords from its
environment. Those values no longer cross the process boundary unless a
command explicitly needs them.

Tests: 8/8 in test_build_subprocess_env.py (2 new E2E regression tests
spawn a real child and assert the BWS token + *_PASSWORD are absent);
23/23 env_passthrough tests pass, confirming passthrough semantics are
preserved. Pre-existing Windows HOME-semantics failures in
test_base_environment.py / test_subprocess_home_isolation.py are
unrelated and fail identically on main.
…CESS_TOKEN suffix

The over-broad suffix match treated every third-party *_ACCESS_TOKEN as
Hermes-internal, blocking legitimate passthrough registration via
tools/env_passthrough.py (GHSA-adjacent regression: STRIPE_ACCESS_TOKEN
etc. became non-passthroughable). It also missed non-suffix Bitwarden
remaps (e.g. access_token_env: MY_BWS_TOKEN), which BitwardenSource.fetch
reads by exact configured name.

Now _is_hermes_internal_secret matches only the exact configured
secrets.bitwarden.access_token_env name (default BWS_ACCESS_TOKEN),
cached per process via _get_configured_bws_token_env(). Third-party
*_ACCESS_TOKEN vars stay registerable and pass through unchanged.

Tests:
- non-suffix Bitwarden remap (MY_BWS_TOKEN) is stripped exactly while
  STRIPE_ACCESS_TOKEN survives and is passthrough-registerable
- DB_PASSWORD passthrough on the terminal path is honored
- *_PASSWORD stripped unconditionally on hermes_subprocess_env even with
  inherit_credentials=True
…rminal spawn

Addresses egilewski review P1s on NousResearch#77027:

1. _make_run_env (the LocalEnvironment terminal spawn factory) now
   applies the passthrough-aware _is_credential_shaped_password filter
   before values land in run_env, closing the live sink where a
   DB_PASSWORD / REDIS_PASSWORD stayed visible to ordinary terminal
   commands even though build_subprocess_env and hermes_subprocess_env
   already stripped them.

2. The Bitwarden access_token_env name cache is now keyed by the active
   Hermes home instead of a process-global single-entry flag. A gateway
   serving multiple profiles by switching the context-local home per
   turn no longer reuses the first profile's token-variable name, so a
   later profile's differently-named vault bootstrap token is matched
   and stripped instead of passing into terminal children.

Adds unit + E2E LocalEnvironment tests (default denial, explicit
passthrough, per-profile remap isolation).
The per-home cache (be5bd41878) correctly makes each profile's configured
access_token_env authoritative, but the rule must hold in both directions:
the process-global os.environ carries the default profile's BWS_ACCESS_TOKEN
across turns (multiplex cron loads each profile's .env into the shared
namespace), so a remapped profile's terminal, Docker-passthrough and
non-terminal children must still strip the default name. Third-party
*_ACCESS_TOKEN vars stay registerable everywhere.

Verified by live probes: under profile B (remap MY_BWS_TOKEN), the stale-cache
probe now resolves B's own name (STALE=False), MY_BWS_TOKEN is denied in
_sanitize_subprocess_env/_make_run_env/hermes_subprocess_env and excluded
from the Docker passthrough filter, and BWS_ACCESS_TOKEN is stripped in B's
scope as well. 38/38 touched tests pass (15 build_subprocess_env + 23
env_passthrough).

Signed-off-by: Andrex Ibiza, MBA <84248988+andrexibiza@users.noreply.github.com>
…lass

_is_credential_shaped_password only matched the *_PASSWORD suffix, leaving
PGPASSWORD, MYSQL_PWD and bare PASSWORD visible to terminal children even
though the execute_code sandbox already scrubs the same class by substring
(code_execution_tool.py). The terminal path must be at least as protective
as the sandbox for the same secret class.

New rule: PASSWORD substring OR *_PWD suffix — never PWD itself (the
shell's working-directory variable, which children still receive).
Passthrough semantics unchanged (registration is checked before the
predicate on the terminal path).

Tests extended in test_make_run_env_strips_password_by_default: PGPASSWORD,
MYSQL_PWD, PASSWORD denied; PWD and control vars survive. 38/38 touched
tests pass; diff-check and Windows footgun lint clean.

Signed-off-by: Andrex Ibiza, MBA <84248988+andrexibiza@users.noreply.github.com>
@andrexibiza
andrexibiza force-pushed the fix/security-scrub-child-process-env branch from 06a7837 to de0e88d Compare August 10, 2026 04:47
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Confirmed — this is a real remaining sibling bypass. SingularityEnvironment._run_bash reaches _popen_bash without an explicit env, so Apptainer/Singularity inherits the trusted Hermes process environment and bypasses the LocalEnvironment sanitization added by this PR.

The production-path evidence in this review is sufficient. I am treating the Singularity start/exec environment as part of the same child-process credential boundary. This PR is not ready to claim closure until that path uses the same passthrough-aware sanitizer and carries regression coverage for the listed Bitwarden/password variables plus required non-secret controls.

…NousResearch#77027)

Closes the child-process credential-inheritance class where trusted Hermes
credentials and other sensitive parent-environment values reached untrusted
or model-authored subprocesses.

Confirmed production chain (pinned head de0e88d):
SingularityEnvironment._run_bash -> _popen_bash with no sanitized env ->
subprocess.Popen inherited the trusted Hermes process environment. Docker,
SSH, and future backends converge on the same shared boundary.

Changes:
- tools/environments/base.py: _popen_bash now builds a sanitized child env by
  default (build_subprocess_env(base=...)) and applies the same policy to
  caller-supplied env maps, so omitting env can never re-open ambient
  inheritance.
- tools/environments/local.py: case-insensitive provider/security matching
  (_is_blocked_provider_env) for Windows env semantics; effective destination
  keys behind APPTAINERENV_/SINGULARITYENV_ wrappers are checked
  (_credential_target_env_name); BWS_ACCESS_TOKEN promoted to Tier-1
  _ALWAYS_STRIP_KEYS so inherit_credentials=True paths cannot export the
  vault bootstrap token.
- tools/environments/singularity.py: preflight, instance start, image build,
  and cleanup subprocesses all use the sanitized builder; image construction
  re-adds ONLY the six explicit Apptainer/Singularity Docker registry-auth
  variables instead of the full parent environment.
- tools/environments/docker.py: explicit docker_forward_env entries may not
  export Hermes-internal secrets (AUXILIARY_*/GATEWAY_RELAY_*/BWS) — the
  forwarding list is a capability boundary, not an unconditional bypass.
- tools/env_passthrough.py: uses the case-insensitive blocklist predicate.
- tests/tools/test_backend_subprocess_env_boundary.py: 12-test regression
  matrix covering Docker/SSH/Singularity exec, caller-supplied and empty base
  envs, mixed-case credentials, nested children, Singularity lifecycle,
  narrow image-build auth, and Docker explicit forwarding.

Evidence:
- Focused matrix: 12/12 passed (was 2/12 RED on pinned head).
- Adjacent suites: 118 passed / 3 failed / 4 skipped; backend suites:
  93 passed / 1 failed / 5 skipped — all 4 failures reproduced identically
  on a pristine pinned-head worktree (pre-existing Windows/MSYS host
  failures, zero regressions).
- Synthetic merge on fresh main (8edcdd1): cumulative diff applied cleanly,
  focused matrix 12/12 passed.
- git diff --check clean; ruff clean on all changed files.

Co-authored-by: Axl Ibiza, MBA <andrexibiza@gmail.com>
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Closing the child-process credential-inheritance class

This PR closes the bug class where trusted Hermes credentials and other sensitive parent-environment values reach untrusted or model-authored subprocesses. It is the terminal-backend half of a class that spans the whole repository; the sibling PRs below are the other halves, and this comment is the map that ties them together.

The confirmed production chain

SingularityEnvironment._run_bash_popen_bash with no sanitized envsubprocess.Popen inherited the trusted Hermes process environment. Docker, SSH, and every future backend converge on the same shared _popen_bash boundary, so a fix at the call site alone would have let the next backend re-open the hole by omitting env. The boundary itself had to be safe by default.

Why this is the right fix

  1. The boundary, not the call sites. _popen_bash now sanitizes by default and applies the same policy to caller-supplied env maps. Safety no longer depends on every caller remembering to pass env — the failure mode is structurally impossible, not just patched.
  2. Windows semantics honored. Environment keys are case-insensitive on Windows; the filter now matches case-insensitively (_is_blocked_provider_env), so bWs_AcCeSs_ToKeN cannot slip past BWS_ACCESS_TOKEN.
  3. Wrapper tunneling closed. Apptainer/Singularity rename host variables into the container via APPTAINERENV_* / SINGULARITYENV_*; the filter now evaluates the effective destination key (_credential_target_env_name), so APPTAINERENV_GH_TOKEN cannot tunnel a blocked credential past the sanitizer.
  4. Least privilege, not blanket denial. Benign runtime values still flow; the Singularity image-build path re-adds only the six explicit Apptainer/Singularity Docker registry-auth variables instead of inheriting the whole parent environment; env_passthrough-registered commands still receive their values.
  5. Explicit forwarding is a capability boundary, not a bypass. docker_forward_env can no longer export Hermes-internal secrets (AUXILIARY_*, GATEWAY_RELAY_*, BWS_ACCESS_TOKEN) — naming a bootstrap credential in a forwarding list does not authorize exporting it.
  6. The vault token is Tier-1. BWS_ACCESS_TOKEN is promoted to _ALWAYS_STRIP_KEYS, so even the inherit_credentials=True path (codex/copilot/TUI host) cannot export it. The one child that legitimately needs it — the bws CLI — receives it explicitly via scrub_secrets=False in agent/secret_sources/bitwarden.py, never by inheritance.

Evidence

  • Focused matrix: 12/12 GREEN (was 2/12 RED on the pinned vulnerable head de0e88d8).
  • No regressions: all broad-suite failures (3 PATH + 1 docker exec-126 + suite-2's 8) reproduced identically on a pristine worktree at the pinned head — pre-existing Windows/MSYS host failures, zero regressions.
  • Synthetic merge on fresh main: cumulative diff applied cleanly, focused matrix 12/12 passed.
  • Blind 5×2×3 wave: 5 analysts + 5 cross-set witnesses, packet-pinned at de0e88d8, all artifacts claim-verified. Every region's pair independently confirmed the baseline vulnerability set, and every VULNERABLE sink in the terminal-backend class maps 1:1 onto this fix.
  • GraphQL collision audit: 40 query families, 1,274 nodes, 277 hydrated candidates, 24-PR adjudication — the sibling map below is the result.
  • CI: SUCCESS (22 checks) on the final head.

The sibling map — the rest of the class

This PR is one half of a class that spans the repository. The other halves are open PRs and issues that fix the same inheritance pattern on their own surfaces. They are not duplicates of this PR — they are the same bug class on different boundaries — and they should be linked, reviewed, and merged as a coordinated class closure:

Surface PR / Issue Status
WhatsApp bridge subprocess env #38079 / #38080 OPEN
LSP server env #55256 OPEN
LSP installer env #55556 OPEN
Kanban worker env #55600 OPEN
Platform bridge subprocess env #56245 OPEN
Non-provider secrets in inherited child envs #56668 OPEN
Azure Entra credentials #56977 OPEN
Singularity/Apptainer exec env #57639 OPEN (closest sibling — same file, same leak)
Browser session isolation + spawn boundary #59840 OPEN (overlaps _popen_bash)
TUI shell.exec env #60423 / #78036 OPEN
Checkpoint git env #70332 OPEN
host_supervisor undoing env scrub #70351 OPEN
Desktop PTY / serve / updater / bootstrap children #70370 / #70372 / #70373 OPEN
Profile-scoped subprocess env #73051 OPEN
Credential leakage to subprocesses and sandbox children #73153 OPEN
Provenance-aware child-env scrub #77164 / #77193 OPEN (directly linked to this PR)
TUI compute host + LSP server env #77528 OPEN
Plugin sidecar children #78033 OPEN (directly linked to this PR)
Internal secret name-shape matching #78330 OPEN (directly linked to this PR)
Multiplex profile secret scoping #82936 / #83007 OPEN
Vault CLI child gets full os.environ #77467 OPEN (sibling issue)

Directly interlocked with this PR: #77164, #77193, #77463, #77528, #78033, #78330.

Closest siblings (same files, same leak class): #57639 (singularity.py), #59840 (base.py), #73051 / #83007 (local.py + env_passthrough.py).

Why this is the best solution

  • It kills the vulnerability at the shared boundary where Docker, SSH, Singularity, and every future backend converge — one fix, structurally enforced, not N call-site patches that drift.
  • It is case-insensitive and wrapper-aware, so the two classic evasion tricks (mixed-case keys on Windows, APPTAINERENV_/SINGULARITYENV_ tunneling) are closed by construction.
  • It is least-privilege, not blanket denial — benign runtime values flow, passthrough contracts hold, and the one legitimate registry-auth need is narrowed to six explicit variables.
  • It is proven, not asserted — RED on the vulnerable head, GREEN on the fix, no-regression baseline adjudication, synthetic merge on fresh main, a full blind 5×2×3 wave, and CI green.
  • It is the map, not just the fix — the sibling table above turns a one-PR patch into a coordinated class closure, so the maintainers can see exactly which other surfaces carry the same pattern and merge them as a set.

The class is not closed until the siblings land. This PR is the anchor — the boundary fix that makes the terminal backends safe by default, with the evidence and the map to finish the rest.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The PR substantially closes ambient credential inheritance for local, Docker, SSH, and Singularity terminal children and its focused regression suite passes. Two gaps remain: names containing PASSWD still reach model-authored terminal children, and the per-home Bitwarden token-name cache does not refresh after a same-home credential/config rotation.

  • [P1] PASSWD-suffixed credentials still cross the terminal child boundary (tools/environments/local.py:517)
    The new _is_credential_shaped_password predicate only matches the substring PASSWORD or a suffix of _PWD (while preserving PWD). It does not match the common PASSWD form, such as DB_PASSWD or ROOT_PASSWD. _make_run_env, _sanitize_subprocess_env, and hermes_subprocess_env all rely on this predicate, and the shared _popen_bash boundary now routes Docker, SSH, and Singularity through the same builders. A direct PR-head probe with those parent variables showed both values in the spawned terminal environment, allowing a model-authored command or child process to read them.
    Remediation: Extend the credential-shaped predicate to cover PASSWD (including wrapped target names) while retaining the deliberate PWD working-directory exception and explicit terminal passthrough semantics. Add regression cases for DB_PASSWD/ROOT_PASSWD through the local and shared non-local child paths.

  • [P2] Bitwarden token-name cache stays stale after same-home rotation (tools/environments/local.py:479)
    _get_configured_bws_token_env caches only by the active home path and returns the cached name before rereading config.yaml. A long-running process that changes secrets.bitwarden.access_token_env from ALPHA_BWS_TOKEN to BETA_BWS_TOKEN in the same profile keeps matching ALPHA_BWS_TOKEN, so the new BETA_BWS_TOKEN is not treated as Hermes-internal. The PR-head probe reproduced first=ALPHA, second=ALPHA, and BETA_BWS_TOKEN present in build_subprocess_env. Runtime config and credential writers can change the profile while the process remains alive, allowing a rotated vault bootstrap token to cross the terminal child boundary.
    Remediation: Key the cached value by a config-file signature (or invalidate it from config and environment credential writers) and resolve the current access_token_env before every child-boundary decision. Add a same-home remap/rotation regression test alongside the existing per-home isolation cases.

Security evidence:

  • trust boundary: Trusted Hermes parent environment and profile-scoped secret state flow into model-authored terminal commands through LocalEnvironment, the shared _popen_bash wrapper, and Docker/SSH/Singularity backends. The child environment is the sink that must not contain parent provider, vault, gateway, or password credentials.
  • source/sink/invariant: Every terminal child builder must remove Hermes-managed and credential-shaped values by default, preserve only deliberate terminal passthroughs, and pass the resulting mapping to the child. The PR enforces that invariant for ambient inheritance and most password spellings, but not PASSWD spellings, and its cached Bitwarden name can become stale after same-home rotation.
  • current-main reproduction: Read-only current-main source inspection shows _popen_bash inherited ambient environment and _make_run_env had no password-class filter. The PR fixes ambient inheritance and PASSWORD/PGPASSWORD/MYSQL_PWD, while the PASSWD residual remains reproducible at the reviewed head.
  • PR-head or patch-replay validation: The focused PR regression suite passed on the reviewed head. Separate direct probes observed DB_PASSWD/ROOT_PASSWD in a terminal child and a rotated BETA_BWS_TOKEN in the sanitized child mapping.
  • positive/negative cases: Positive cases remove DB_PASSWORD, PGPASSWORD, MYSQL_PWD, and bare PASSWORD, while explicit terminal passthrough remains available and PWD is retained as the shell working-directory variable. Negative cases DB_PASSWD and ROOT_PASSWD remain visible, and a same-home ALPHA-to-BETA Bitwarden remap leaves BETA visible.
  • residual bypass search: Reviewed the changed local sanitizer, env-passthrough registry, shared popen boundary, Docker forwarding, Singularity lifecycle and registry-auth exception, and SSH/process-registry call paths. No additional PR-specific bypass was found beyond the PASSWD gap, stale same-home Bitwarden cache, and documented explicit capability exceptions.
  • reviewer validation: Focused backend, subprocess-environment, and blocklist tests passed; the PR-only diff is whitespace-clean. The direct negative probes are the basis for the published findings.

Not checked:

  • Ruff lint
  • Full repository test suite
  • Current-main runtime replay

Signed: GPT-5.6-luna-max in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools backend/local Local shell execution comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants