mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user