fix(code): block dotenv shell startup hooks (#4288)

Project `.env` files are useful for app configuration, but they should
not be able to inject shell interpreter startup hooks into `dcode`'s
process environment. `dcode` runs its own local-context detection
through Bash during startup, so variables such as `BASH_ENV` and `ENV`
can cause project-provided scripts to run before any model-requested
tool call or approval prompt.

This blocks shell startup and environment-hijack keys from
project/global dotenv loading while preserving normal app/API
configuration values. The preview path now uses the same denylist as the
real reload path so dry-run config changes cannot report values that a
real reload would reject.

Regression coverage verifies that denied keys are omitted from dotenv
loading, preview loading mirrors that behavior, and local-context
startup detection does not execute a `BASH_ENV` payload from a project
`.env`.
This commit is contained in:
Mason Daugherty
2026-06-25 16:51:01 -04:00
committed by GitHub
parent 820b331552
commit 686d6f3a1d
4 changed files with 161 additions and 14 deletions
+45 -4
View File
@@ -87,9 +87,14 @@ re-applied only to the approval-gated shell backend's `execute` subprocesses by
_DOTENV_DENIED_ENV_KEYS = frozenset(
{
"BASH_ENV",
"BASHOPTS",
"CDPATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"ENV",
"GIT_ASKPASS",
"GLOBIGNORE",
"LD_AUDIT",
"LD_LIBRARY_PATH",
"LD_PRELOAD",
@@ -99,15 +104,42 @@ _DOTENV_DENIED_ENV_KEYS = frozenset(
"PYTHONHOME",
"PYTHONPATH",
"PYTHONSTARTUP",
"SHELLOPTS",
"SSH_ASKPASS",
_INHERITED_PYTHONPATH_ENV,
}
)
"""Environment keys that project `.env` files must not inject.
A project `.env` is untrusted (it travels with a cloned repo), so it must not be
able to set variables that turn loading the `.env` into code execution in the
subprocesses Deep Agents Code spawns. The set spans four threat categories;
every entry is here for one of these reasons, so do not remove one without
checking which category it belongs to:
- Dynamic-linker preload/audit (`DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`,
`LD_AUDIT`, `LD_LIBRARY_PATH`, `LD_PRELOAD`): force a loader to map an
attacker-supplied shared object into every spawned binary.
- Interpreter startup/path (`NODE_OPTIONS`, `PATH`, `PYTHONEXECUTABLE`,
`PYTHONHOME`, `PYTHONPATH`, `PYTHONSTARTUP`, `_INHERITED_PYTHONPATH_ENV`):
hijack which interpreter/binary runs or what it imports at startup.
- Shell startup hooks (`BASH_ENV`, `ENV`, `BASHOPTS`, `SHELLOPTS`, `CDPATH`,
`GLOBIGNORE`): `BASH_ENV`/`ENV` source a file on every non-interactive shell;
`SHELLOPTS`/`BASHOPTS` can force `xtrace`/alias expansion; `CDPATH`/
`GLOBIGNORE` alter path/glob resolution. The agent runs detection and
`execute` commands through non-interactive shells, so these are live vectors.
- Askpass hijack (`GIT_ASKPASS`, `SSH_ASKPASS`): point credential prompts at an
attacker-controlled binary.
`_INHERITED_PYTHONPATH_ENV` is denied so a project `.env` cannot smuggle a
`PYTHONPATH` into agent `execute` commands through the carrier var; the carrier
is only meant to relay a value the user set in their launch environment."""
is only meant to relay a value the user set in their launch environment.
Matching is exact and case-sensitive: the protected consumers (the dynamic
linker, bash, CPython) read these names only in their canonical case, so a
lowercase `bash_env` injected into the environment is inert. Any future entry
that some consumer reads case-insensitively would need a different check.
"""
def _find_dotenv_from_start_path(start_path: Path) -> Path | None:
@@ -169,8 +201,13 @@ def _preview_dotenv_environ(*, start_path: Path | None = None) -> dict[str, str]
)
return
for key, value in values.items():
if value is not None and key not in env:
env[key] = value
if value is None or key in env:
continue
if key in _DOTENV_DENIED_ENV_KEYS:
# Log the key only — the value is attacker-controlled.
logger.debug("Ignoring denied env key %r from %s", key, dotenv_path)
continue
env[key] = value
project_dotenv: Path | None = None
try:
@@ -265,7 +302,11 @@ def _load_dotenv(
values = dotenv.dotenv_values(dotenv_path=dotenv_path)
applied = False
for key, value in values.items():
if value is None or key in os.environ or key in _DOTENV_DENIED_ENV_KEYS:
if value is None or key in os.environ:
continue
if key in _DOTENV_DENIED_ENV_KEYS:
# Log the key only — the value is attacker-controlled.
logger.debug("Ignoring denied env key %r from %s", key, dotenv_path)
continue
os.environ[key] = value
_dotenv_loaded_values[key] = value
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import subprocess
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, Mock
@@ -10,6 +11,7 @@ if TYPE_CHECKING:
from pathlib import Path
import pytest
from deepagents.backends import LocalShellBackend
from deepagents.backends.protocol import ExecuteResponse
from deepagents.middleware._state import private_state_field_names
@@ -158,6 +160,43 @@ class TestLocalContextMiddleware:
assert "Current Directory" in result["_local_context"]
backend._mock.assert_called_once()
def test_before_agent_does_not_run_dotenv_bash_env(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A project `.env` cannot add `BASH_ENV` to startup detection."""
import deepagents_code.config as config_mod
payload = tmp_path / "payload.sh"
marker = tmp_path / "marker"
payload.write_text(f"echo sourced > {marker}\n")
(tmp_path / ".env").write_text(f"BASH_ENV={payload}\nOPENAI_API_KEY=sk-ok\n")
monkeypatch.delenv("BASH_ENV", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(
config_mod,
"_GLOBAL_DOTENV_PATH",
tmp_path / "missing-global.env",
)
config_mod._dotenv_loaded_values.clear()
try:
config_mod._load_dotenv(start_path=tmp_path)
backend = LocalShellBackend(
root_dir=tmp_path,
virtual_mode=False,
inherit_env=False,
env=os.environ.copy(),
)
middleware = LocalContextMiddleware(backend=backend)
result = middleware.before_agent({"messages": []}, Mock())
assert result is not None
assert os.environ["OPENAI_API_KEY"] == "sk-ok"
assert "BASH_ENV" not in os.environ
assert not marker.exists()
finally:
config_mod._dotenv_loaded_values.clear()
def test_before_agent_skips_when_already_set(self) -> None:
"""Test before_agent returns None when _local_context already exists."""
backend = _make_backend(output=SAMPLE_CONTEXT)
+64
View File
@@ -392,17 +392,29 @@ class TestReloadFromEnvironment:
project_env = tmp_path / ".env"
project_env.write_text(
"BASH_ENV=/tmp/evil.sh\n"
"BASHOPTS=expand_aliases\n"
"CDPATH=/tmp\n"
"ENV=/tmp/evil.sh\n"
"GLOBIGNORE=*\n"
"LD_PRELOAD=/tmp/evil.so\n"
"PYTHONPATH=/tmp/evil\n"
"PATH=/tmp/evil\n"
"NODE_OPTIONS=--require /tmp/evil.js\n"
"SHELLOPTS=xtrace\n"
"DEEPAGENTS_INHERITED_PYTHONPATH=/tmp/evil\n"
"OPENAI_API_KEY=sk-ok\n"
)
for key in (
"BASH_ENV",
"BASHOPTS",
"CDPATH",
"ENV",
"GLOBIGNORE",
"LD_PRELOAD",
"PYTHONPATH",
"NODE_OPTIONS",
"SHELLOPTS",
"DEEPAGENTS_INHERITED_PYTHONPATH",
"OPENAI_API_KEY",
):
@@ -410,9 +422,15 @@ class TestReloadFromEnvironment:
_load_dotenv(start_path=tmp_path)
assert "BASH_ENV" not in os.environ
assert "BASHOPTS" not in os.environ
assert "CDPATH" not in os.environ
assert "ENV" not in os.environ
assert "GLOBIGNORE" not in os.environ
assert "LD_PRELOAD" not in os.environ
assert "PYTHONPATH" not in os.environ
assert "NODE_OPTIONS" not in os.environ
assert "SHELLOPTS" not in os.environ
# The carrier var must not be injectable from `.env`, or a project could
# smuggle a PYTHONPATH into agent `execute` commands through it.
assert "DEEPAGENTS_INHERITED_PYTHONPATH" not in os.environ
@@ -491,6 +509,52 @@ class TestReloadFromEnvironment:
assert env["TEST_PREVIEW_KEY2"] == "project-value"
def test_preview_dotenv_denies_environment_hijack_keys(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Preview env mirrors `_load_dotenv`: denied keys are omitted.
Exercises the full shell-startup-hook set so the preview path stays
visibly parallel to the mutating path, and asserts the debug breadcrumb
names the denied key (and only the key, never its value).
"""
from deepagents_code.config import _preview_dotenv_environ
denied_keys = (
"BASH_ENV",
"BASHOPTS",
"CDPATH",
"ENV",
"GLOBIGNORE",
"SHELLOPTS",
)
evil_value = "/tmp/evil.sh" # test fixture value, never read
monkeypatch.setattr(
"deepagents_code.config._GLOBAL_DOTENV_PATH",
tmp_path / "nonexistent" / ".env",
)
dotenv_lines = [f"{key}={evil_value}\n" for key in denied_keys]
dotenv_lines.append("OPENAI_API_KEY=sk-ok\n")
(tmp_path / ".env").write_text("".join(dotenv_lines))
for key in (*denied_keys, "OPENAI_API_KEY"):
monkeypatch.delenv(key, raising=False)
with caplog.at_level(logging.DEBUG, logger="deepagents_code.config"):
env = _preview_dotenv_environ(start_path=tmp_path)
for key in denied_keys:
assert key not in env
assert env["OPENAI_API_KEY"] == "sk-ok"
# The breadcrumb names each denied key but never leaks the value.
for key in denied_keys:
assert any(key in record.getMessage() for record in caplog.records)
assert evil_value not in caplog.text
def test_preview_reports_api_key_masked_without_mutating(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@@ -102,17 +102,20 @@ class TestBuildServerEnv:
env = _build_server_env()
assert env[_INHERITED_PYTHONPATH_ENV] == ""
def test_pythonpath_blocked_from_both_server_and_dotenv(self) -> None:
"""`PYTHONPATH` (and its carrier) must stay blocked on both ingress paths.
The server interpreter must never inherit `PYTHONPATH`, and a project
`.env` must not inject either `PYTHONPATH` or the carrier var used to
relay it to `execute`. Guards against a future re-merge or denylist edit
silently re-opening the startup-shadowing vector.
"""
def test_startup_hijack_keys_blocked_from_dotenv(self) -> None:
"""Project `.env` files must not inject interpreter startup hooks."""
assert "PYTHONPATH" in _SERVER_ENV_DENYLIST
assert "PYTHONPATH" in _DOTENV_DENIED_ENV_KEYS
assert _INHERITED_PYTHONPATH_ENV in _DOTENV_DENIED_ENV_KEYS
for key in (
"BASH_ENV",
"BASHOPTS",
"CDPATH",
"ENV",
"GLOBIGNORE",
"PYTHONPATH",
"SHELLOPTS",
_INHERITED_PYTHONPATH_ENV,
):
assert key in _DOTENV_DENIED_ENV_KEYS
class TestPythonpathRelayRoundTrip: