Skip to content

fix(security): deny project-local .env/.envrc in media delivery - #59045

Open
cryptoyasenka wants to merge 1 commit into
NousResearch:mainfrom
cryptoyasenka:fix/media-delivery-project-env-guard
Open

fix(security): deny project-local .env/.envrc in media delivery#59045
cryptoyasenka wants to merge 1 commit into
NousResearch:mainfrom
cryptoyasenka:fix/media-delivery-project-env-guard

Conversation

@cryptoyasenka

@cryptoyasenka cryptoyasenka commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

The read guard agent/file_safety.get_read_block_error blocks the agent from reading .env / .envrc anywhere on disk by basename (_BLOCKED_PROJECT_ENV_BASENAMES). The media-delivery denylist that gates auto-attachment does not mirror that rule: _media_delivery_denied_paths only denies <hermes-root>/.env, so a user's own project .env (e.g. /home/user/app/.env) passes validate_media_delivery_path and is delivered as a native attachment.

_media_delivery_denied_paths states the invariant in its own comment: a credential the agent is forbidden to read must also never be auto-attached to a chat reply. In default (non-strict) single-user mode, a prompt-injected MEDIA:/path/.env tag exfiltrates API keys and database passwords into the chat transport. .env / .envrc reach the delivery gate because they are extensionless (Path(".env").suffix == ""), so extract_media takes the extensionless MEDIA: branch that calls validate_media_delivery_path.

This adds a shared is_project_env_basename() predicate in agent/file_safety (single source of truth for the basename set) and rejects those basenames in validate_media_delivery_path, before the cache allowlist, so a secret env file is never deliverable regardless of location or mode. The delivery side now mirrors the read guard.

Related Issue

Fixes #59044

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • agent/file_safety.py: add is_project_env_basename(name), a case-insensitive predicate over _BLOCKED_PROJECT_ENV_BASENAMES, shared by the read guard and the media-delivery denylist so the two cannot drift.
  • gateway/platforms/base.py: in validate_media_delivery_path, after the is_file() check and before the cache allowlist, reject any path whose basename is a project env file. Unconditional in both strict and non-strict mode, so the delivery side mirrors the read guard. The predicate is bound at module import, next to the existing first-party import, so the check cannot be skipped (see Review follow-up).
  • tests/gateway/test_media_delivery_project_env_guard.py (new, 6 cases): the pathlib extensionless fact and its scope caveat (.env.production is not extensionless); the delivery gate mirrors the read guard for .env/.envrc; e2e that a prompt-injected MEDIA: tag does not surface the project env file as a deliverable attachment; and that the gate fails closed when the predicate is unavailable.

Review follow-up

Review flagged the first cut of this gate as fail-open, and it was right. validate_media_delivery_path imported the predicate lazily behind try/except Exception: is_project_env_basename = None and then called it only if ... is not None, so any import failure skipped the check outright and silently reopened the exfil path the gate exists to close. A guard whose failure path allows the action is not a guard.

There was no import cycle to defer around: agent/file_safety imports only stdlib and never imports gateway, and agent/__init__ only preloads jiter. The predicate is now bound at module import and called unconditionally.

test_project_env_gate_fails_closed_on_import_failure pins the property rather than the mechanism: it poisons sys.modules["agent.file_safety"], asserts that a call-time import really raises ImportError, then asserts the project .env is still not deliverable. It discriminates: against the previous fail-open commit it fails, with validate_media_delivery_path returning the .env path instead of None, while the other five cases pass.

How to Test

# new regression test (PR base 52a5fc0048)
pytest tests/gateway/test_media_delivery_project_env_guard.py -q
  -> 6 passed          (on the base without the fix: 5 failed, 1 passed)

# existing platform-base suite, A/B against the fix
pytest tests/gateway/test_platform_base.py -q
  -> 6 failed, 170 passed, 1 skipped   (identical with and without this change)

# lint
ruff check agent/file_safety.py gateway/platforms/base.py tests/gateway/test_media_delivery_project_env_guard.py
  -> All checks passed!

The 5 failures on the base are the gate-mirror and e2e-delivery assertions for .env / .envrc plus the fail-closed regression; the fix turns them green. The 6 failures in test_platform_base.py are pre-existing and identical with and without this change (environment-specific on Windows: recency / symlink / null-path / cache-roots), so this change adds zero regressions. The runs above are local (Windows 11, Python 3.13, cherry-picked onto current main). Full pytest tests/ in my environment has pre-existing optional-dep collection errors unrelated to this change; the touched path is covered above and by CI.

Green ubuntu run for this head: https://github.com/cryptoyasenka/hermes-agent/actions/runs/31056695603

Related work (not duplicates)

This continues the "delivery side cannot trail the read/write guard" line (#51055, #56163) but covers a different file class:

None of them block a user's project-local .env/.envrc (an arbitrary working-directory file the read guard already blocks by basename), which is the only path this PR changes.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(security): ...)
  • I searched for existing PRs to make sure this isn't a duplicate (see Related work).
  • My PR contains only changes related to this fix
  • I've run the affected suites locally (see How to Test). Full pytest tests/ has pre-existing optional-dep collection errors in my environment; the touched path is covered above and by CI.
  • I've added tests for my changes (required for bug fixes)
  • I've tested on my platform: Windows 11

Documentation & Housekeeping

  • I've updated relevant documentation (N/A: internal delivery guard, no doc surface)
  • I've updated cli-config.yaml.example if I added/changed config keys (N/A: no config keys)
  • I've updated CONTRIBUTING.md or AGENTS.md (N/A)
  • I've considered cross-platform impact (basename logic, no OS-specific calls; the shared predicate is case-insensitive)
  • I've updated tool descriptions/schemas (N/A)

@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/gateway Gateway runner, session dispatch, delivery tool/file File tools (read, write, patch, search) area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P2 Medium — degraded but workaround exists labels Jul 5, 2026
@cryptoyasenka

Copy link
Copy Markdown
Contributor Author

Bumping this one for a look. It's in the same credential-exposure area as the redirect-header fix that just went in with #62488, so figured it's worth surfacing here too.

The gist: we already block the agent from reading .env / .envrc by basename, but the media-delivery denylist only covers <hermes-root>/.env. A user's own project .env (say /home/user/app/.env) still passes validate_media_delivery_path and goes out as a native attachment. In default single-user mode that means a prompt-injected MEDIA:/path/.env tag can quietly ship API keys and DB passwords into the chat transport. They slip past because .env and .envrc are extensionless.

The fix just mirrors the read-block basenames into _media_delivery_denied_paths, so "if the agent can't read it, it can't auto-attach it either" holds on both paths. Small change, 100 lines and no deletions, with a regression test in tests/gateway/test_media_delivery_project_env_guard.py.

If you'd rather scope it differently (strict-mode only, a config flag, whatever), glad to adjust. Mainly wanted to get eyes on the exfil path.

@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 this through the extensionless MEDIA: path. The premise is confirmed on current main: agent/file_safety.py:315 blocks these basenames, while gateway/platforms/base.py:1335-1338 returns a non-denied existing file in default mode; .env/.envrc reach that validator through extract_media() at gateway/platforms/base.py:3697-3704.

Problems

  • The new import guard in gateway/platforms/base.py:1296 catches every exception and then continues without the basename rejection. For a credential-delivery guard, that is fail-open: an import-time regression restores approval of the sensitive path.

Suggested changes

  • Import is_project_env_basename directly and apply its rejection unconditionally. agent/file_safety.py:1-16 has no gateway dependency, so the inspected import graph does not justify bypassing the guard on errors.
  • Salvage will need a minor conflict resolution in agent/file_safety.py because current main changed the preceding write-denial helper in 55d826cce; the validator hunk otherwise remains at the correct centralized gate.

Automated hermes-sweeper review.

Comment thread gateway/platforms/base.py Outdated
# env file is never deliverable regardless of location or delivery mode.
try:
from agent.file_safety import is_project_env_basename
except Exception:

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.

Do not fail open here. Catching every import exception and assigning None means the project-env denial is silently skipped if this import regresses. agent.file_safety has no gateway dependency; import the predicate directly (or reject on import failure) so credential delivery cannot be re-enabled by an unrelated import error.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
@cryptoyasenka
cryptoyasenka force-pushed the fix/media-delivery-project-env-guard branch 3 times, most recently from 4522475 to 0b16904 Compare August 5, 2026 22:10
The read guard (agent/file_safety.get_read_block_error) blocks reading
.env / .envrc anywhere on disk by basename, but the media-delivery
denylist only enumerated <hermes-root>/.env. A user's own project .env
(e.g. /home/user/app/.env) was therefore deliverable as a native
attachment via a prompt-injected MEDIA: tag in default (non-strict)
single-user mode, leaking API keys and database passwords.

Add a shared is_project_env_basename() predicate in file_safety and
reject those basenames in validate_media_delivery_path before the cache
allowlist, so the delivery side mirrors the read guard in every mode.

The predicate is bound at module import next to the existing
first-party import and called unconditionally, so the gate fails
closed: an import failure can no longer silently reopen the exfil path
the gate exists to close. There is no cycle to defer around:
agent/file_safety imports only stdlib and never imports gateway, and
agent/__init__ only preloads jiter.

Add regressions covering the guard in every delivery mode and one that
poisons sys.modules["agent.file_safety"] and asserts a project .env
stays undeliverable, so a call-time import can no longer decide whether
the check runs.
@cryptoyasenka
cryptoyasenka force-pushed the fix/media-delivery-project-env-guard branch from 0b16904 to d8be06c Compare August 5, 2026 23:32
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 comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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 tool/file File tools (read, write, patch, search) type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Project-local .env/.envrc deliverable as native media (media denylist trails the read guard)

3 participants