Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions agent/file_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,17 @@ def get_write_denied_error(path: str, *, verb: str = "Write") -> Optional[str]:
}


def is_project_env_basename(name: str) -> bool:
"""True if ``name`` is a secret-bearing project env filename.

Shared by the read guard (:func:`get_read_block_error`, which blocks reading
these anywhere on disk) and the media-delivery denylist
(``gateway/platforms/base.validate_media_delivery_path``) so the delivery /
exfil side can't drift from the read side. Case-insensitive basename match.
"""
return name.lower() in _BLOCKED_PROJECT_ENV_BASENAMES


def get_read_block_error(path: str) -> Optional[str]:
"""Return an error message when a read targets a denied Hermes path.

Expand Down
11 changes: 11 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from abc import ABC, abstractmethod
from urllib.parse import urlsplit

from agent.file_safety import is_project_env_basename
from utils import normalize_proxy_url

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1494,6 +1495,16 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
if not resolved.is_file():
return None

# Project-local secret env files (.env, .envrc, ...) are read-blocked
# anywhere on disk by agent/file_safety.get_read_block_error. Mirror that on
# the delivery/exfil side so it can't trail the read guard (the denylist's
# own stated invariant, above): the per-Hermes-root ".env" entry in
# _media_delivery_denied_paths only covers <hermes-root>/.env, never a
# user's own project .env. Checked before the cache allowlist so a secret
# env file is never deliverable regardless of location or delivery mode.
if is_project_env_basename(resolved.name):
return None

# Cache / operator allowlist is always honored — these are unconditionally
# trusted regardless of mode.
for root in _media_delivery_allowed_roots():
Expand Down
103 changes: 103 additions & 0 deletions tests/gateway/test_media_delivery_project_env_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Regression: a project-local ``.env`` / ``.envrc`` must not be deliverable as
native media when the same path is read-blocked by ``agent/file_safety``.

``gateway/platforms/base._media_delivery_denied_paths`` documents the invariant
that "a credential the agent is forbidden to ... read must also never be
auto-attached to a chat reply". ``agent/file_safety.get_read_block_error``
read-blocks ``.env`` / ``.envrc`` anywhere on disk by basename, but the media
denylist only enumerates ``<hermes-root>/.env``, so a user's *project* ``.env``
(e.g. ``/home/user/app/.env``) was deliverable via a prompt-injected ``MEDIA:``
tag in default (non-strict) single-user mode -> credential exfiltration.

The gate is also only worth as much as its weakest path, so it must fail
closed: an unavailable predicate has to deny, never skip the check.
"""

import importlib
import sys
from pathlib import Path

import pytest

import gateway.platforms.base as base
from gateway.platforms.base import BasePlatformAdapter, validate_media_delivery_path
from agent import file_safety


def test_dotenv_basename_has_no_suffix():
# Reachability of the extensionless ``MEDIA:`` branch hinges on this pathlib
# fact: a leading-dot name has no suffix, so ``.env`` / ``.envrc`` are
# extensionless and take the branch that calls validate_media_delivery_path.
# Verified empirically, not from memory.
assert Path("/home/u/app/.env").suffix == ""
assert Path("/home/u/app/.envrc").suffix == ""
# A real extension is still detected, so ``.env.production`` is NOT
# extensionless and does not reach that branch (scope caveat).
assert Path("/home/u/app/.env.production").suffix == ".production"


@pytest.mark.parametrize("basename", [".env", ".envrc"])
def test_project_env_gate_mirrors_read_guard(tmp_path, monkeypatch, basename):
# Default single-user gateway = non-strict media delivery.
monkeypatch.setattr(base, "_media_delivery_strict_mode", lambda: False)

proj = tmp_path / "app"
proj.mkdir()
secret = proj / basename
secret.write_text("OPENAI_API_KEY=sk-live-SECRET\nDATABASE_URL=postgres://u:p@h/db\n")
p = str(secret.resolve())

# Precondition: the read guard blocks reading this project env file.
assert file_safety.get_read_block_error(p) is not None, (
"read guard should block the project env file (basename rule)"
)

# The delivery gate must mirror the read guard: a read-blocked credential
# must not be returned as a safe native attachment.
assert validate_media_delivery_path(p) is None, (
f"{basename} is read-blocked but validate_media_delivery_path approved "
"it for native attachment -> prompt-injected MEDIA: exfil"
)


@pytest.mark.parametrize("basename", [".env", ".envrc"])
def test_project_env_not_deliverable_via_media_tag(tmp_path, monkeypatch, basename):
# End-to-end: a prompt-injected MEDIA: tag pointing at a project env file
# must not surface it as a deliverable attachment.
monkeypatch.setattr(base, "_media_delivery_strict_mode", lambda: False)

proj = tmp_path / "app"
proj.mkdir()
secret = proj / basename
secret.write_text("STRIPE_SECRET_KEY=sk_live_SECRET\n")
p = str(secret.resolve())

reply = f"Sure, here is the file:\nMEDIA:{p}\n"
media, _cleaned = BasePlatformAdapter.extract_media(reply)
delivered_basenames = [Path(mp).name.lower() for mp, _voice in media]
assert basename not in delivered_basenames, (
f"project {basename} auto-attached for exfil via MEDIA: tag: {media}"
)


def test_project_env_gate_fails_closed_on_import_failure(tmp_path, monkeypatch):
# The gate has to fail *closed*: an unavailable predicate must deny, never
# skip the check. Binding it at module import is what guarantees that, so
# pin the property rather than the mechanism -- a call-time import must not
# be able to decide whether the .env check runs at all.
monkeypatch.setattr(base, "_media_delivery_strict_mode", lambda: False)
monkeypatch.setitem(sys.modules, "agent.file_safety", None)

# Precondition: with the entry poisoned, a call-time import really fails.
with pytest.raises(ImportError):
importlib.import_module("agent.file_safety")

proj = tmp_path / "app"
proj.mkdir()
secret = proj / ".env"
secret.write_text("AWS_SECRET_ACCESS_KEY=SECRET\n")

assert validate_media_delivery_path(str(secret.resolve())) is None, (
"delivery gate fell open while agent.file_safety was unimportable at "
"call time -> project .env deliverable again"
)