mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 10:51:26 -04:00
fix(code): block dotenv git config injection keys (#5723)
A project `.env` can no longer inject git configuration or git exec-helper environment variables into `dcode`'s process environment. Previously, a cloned repository's `.env` could set `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` (or `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_SYSTEM`/`GIT_CONFIG_GLOBAL`) to inject config values such as `core.fsmonitor`/`core.pager`/`core.sshCommand`, which git executes — and `dcode` runs `git` during startup local-context detection, before any approval prompt. It could also set `GIT_DIR`, `GIT_WORK_TREE`, `GIT_EDITOR`, `GIT_PAGER`, `GIT_SSH`/`GIT_SSH_COMMAND`, and related keys to redirect git or hook its helper programs. These keys are now silently skipped during dotenv loading, matching the existing handling of shell startup hooks (`BASH_ENV`, `ENV`) and dynamic-linker preload variables. --- Same trust boundary as #4288 (T12 in `libs/code/THREAT_MODEL.md`): a project `.env` is untrusted input that travels with a cloned repo, so it must not be able to turn loading configuration into code execution in subprocesses `dcode` spawns. The shell-hook denylist covered bash, the dynamic linker, and interpreter startup paths, but not git — and git is a live consumer because `local_context.build_detect_script` runs `git rev-parse`/`git status`/`git for-each-ref` at startup. Two shapes of git key are denied: - Exact keys added to `_DOTENV_DENIED_ENV_KEYS`: `GIT_DIR`, `GIT_WORK_TREE`, `GIT_OBJECT_DIRECTORY`, `GIT_EXEC_PATH`, `GIT_EDITOR`, `GIT_PAGER`, `GIT_SSH`, `GIT_SSH_COMMAND` (exec helpers / object-store redirects). `GIT_ASKPASS` was already covered. - A new `_DOTENV_DENIED_ENV_KEY_PREFIXES` family checked by prefix in `_is_dotenv_denied_env_key`, because `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` are numbered pairs that cannot be enumerated in a static set. Also covers `GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_GLOBAL`. Matching stays case-sensitive; benign keys like `GIT_AUTHOR_NAME` and near-misses like `GIT_CONFIGKEY_0` are unaffected. Both dotenv application paths (the mutating `_load_dotenv` and the dry-run `_preview_dotenv_environ`) route through the same predicate, so the preview cannot report a value a real reload would reject. This is defense-in-depth on a best-effort denylist: it closes the reported git vector without claiming the boundary is closed, and does not change the documented residual that project files in the working directory can still influence execution.
This commit is contained in:
@@ -121,6 +121,14 @@ _DOTENV_DENIED_ENV_KEYS = frozenset(
|
||||
"DYLD_LIBRARY_PATH",
|
||||
"ENV",
|
||||
"GIT_ASKPASS",
|
||||
"GIT_DIR",
|
||||
"GIT_EDITOR",
|
||||
"GIT_EXEC_PATH",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_PAGER",
|
||||
"GIT_SSH",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_WORK_TREE",
|
||||
"GLOBIGNORE",
|
||||
"LD_AUDIT",
|
||||
"LD_LIBRARY_PATH",
|
||||
@@ -159,17 +167,67 @@ checking which category it belongs to:
|
||||
`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.
|
||||
- Git config/exec injection (`GIT_DIR`, `GIT_WORK_TREE`, `GIT_OBJECT_DIRECTORY`,
|
||||
`GIT_EXEC_PATH`, `GIT_EDITOR`, `GIT_PAGER`, `GIT_SSH`, `GIT_SSH_COMMAND`):
|
||||
`dcode` runs `git rev-parse`/`git status`/`git for-each-ref` during startup
|
||||
local-context detection (`local_context.build_detect_script`), before any
|
||||
HITL approval. These keys redirect git's object store/exec path or hook its
|
||||
editor/pager/transport helpers into attacker-controlled binaries. The
|
||||
numbered `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` family
|
||||
and the inline `GIT_CONFIG_PARAMETERS` blob are worse: they inject arbitrary
|
||||
config values such as `core.fsmonitor`/`core.pager`/`core.sshCommand`, which
|
||||
git executes. The numbered keys cannot be enumerated in a static set, so
|
||||
they are matched by prefix in `_is_dotenv_denied_env_key`.
|
||||
|
||||
`_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.
|
||||
|
||||
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.
|
||||
Matching is case-sensitive on POSIX because the protected consumers (the
|
||||
dynamic linker, bash, CPython, git) read these names only in their canonical
|
||||
case, so a lowercase `bash_env` injected into the environment is inert there.
|
||||
On Windows, however, environment variable names are case-insensitive and
|
||||
`os.environ` normalizes assigned keys to uppercase, so a lowercase
|
||||
`git_config_key_0` in a `.env` would become an active `GIT_CONFIG_KEY_0` for a
|
||||
spawned `git`. `_is_dotenv_denied_env_key` therefore compares the uppercased
|
||||
key, which is a superset on POSIX (denied names are already uppercase, so the
|
||||
extra denials like `git_config_count` are of otherwise-inert spellings).
|
||||
"""
|
||||
|
||||
_DOTENV_DENIED_ENV_KEY_PREFIXES = (
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_",
|
||||
"GIT_CONFIG_VALUE_",
|
||||
"GIT_CONFIG_PARAMETERS",
|
||||
"GIT_CONFIG_SYSTEM",
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
)
|
||||
"""Prefixes of env keys that must not be injected from a `.env` file.
|
||||
|
||||
`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` are numbered pairs and cannot be listed
|
||||
exhaustively; the rest are single keys whose `GIT_CONFIG_` prefix makes them
|
||||
unambiguous config-injection vectors. `GIT_CONFIG_SYSTEM`/`GIT_CONFIG_GLOBAL`
|
||||
point git at attacker-controlled config files, which can carry the same
|
||||
executable values (`core.fsmonitor`, `core.pager`, ...).
|
||||
"""
|
||||
|
||||
|
||||
def _is_dotenv_denied_env_key(key: str) -> bool:
|
||||
"""Return whether a dotenv key is denied from any `.env` file.
|
||||
|
||||
Combines the exact-match `_DOTENV_DENIED_ENV_KEYS` set with the
|
||||
`_DOTENV_DENIED_ENV_KEY_PREFIXES` family so numbered git config keys
|
||||
(`GIT_CONFIG_KEY_0`, ...) are denied without enumeration. The key is
|
||||
uppercased before both checks so a lowercase or mixed-case spelling cannot
|
||||
slip past on Windows, where `os.environ` assignment would normalize it back
|
||||
to the active uppercase form.
|
||||
"""
|
||||
normalized = key.upper()
|
||||
return normalized in _DOTENV_DENIED_ENV_KEYS or normalized.startswith(
|
||||
_DOTENV_DENIED_ENV_KEY_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
_PROJECT_DOTENV_DENIED_ENV_KEYS = frozenset(
|
||||
{
|
||||
DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS,
|
||||
@@ -282,7 +340,7 @@ def _preview_dotenv_environ(*, start_path: Path | None = None) -> dict[str, str]
|
||||
for key, value in values.items():
|
||||
if value is None or key in env:
|
||||
continue
|
||||
if key in _DOTENV_DENIED_ENV_KEYS:
|
||||
if _is_dotenv_denied_env_key(key):
|
||||
# Log the key only — the value is attacker-controlled.
|
||||
logger.debug("Ignoring denied env key %r from %s", key, dotenv_path)
|
||||
continue
|
||||
@@ -391,7 +449,7 @@ def _load_dotenv(
|
||||
for key, value in values.items():
|
||||
if value is None or key in os.environ:
|
||||
continue
|
||||
if key in _DOTENV_DENIED_ENV_KEYS:
|
||||
if _is_dotenv_denied_env_key(key):
|
||||
# Log the key only — the value is attacker-controlled.
|
||||
logger.debug("Ignoring denied env key %r from %s", key, dotenv_path)
|
||||
continue
|
||||
|
||||
@@ -424,6 +424,16 @@ class TestReloadFromEnvironment:
|
||||
"CDPATH=/tmp\n"
|
||||
"COMSPEC=C:\\repo\\cmd.exe\n"
|
||||
"ENV=/tmp/evil.sh\n"
|
||||
"GIT_CONFIG_COUNT=1\n"
|
||||
"GIT_CONFIG_KEY_0=core.fsmonitor\n"
|
||||
"GIT_CONFIG_VALUE_0=/tmp/evil.sh\n"
|
||||
"GIT_CONFIG_PARAMETERS='core.pager=/tmp/evil.sh'\n"
|
||||
"GIT_CONFIG_GLOBAL=/tmp/evil.gitconfig\n"
|
||||
"GIT_CONFIG_SYSTEM=/tmp/evil.gitconfig\n"
|
||||
"GIT_DIR=/tmp/evil.git\n"
|
||||
"GIT_EDITOR=/tmp/evil.sh\n"
|
||||
"GIT_SSH_COMMAND=/tmp/evil.sh\n"
|
||||
"GIT_WORK_TREE=/tmp/evil\n"
|
||||
"GLOBIGNORE=*\n"
|
||||
"LD_PRELOAD=/tmp/evil.so\n"
|
||||
"PYTHONPATH=/tmp/evil\n"
|
||||
@@ -441,6 +451,16 @@ class TestReloadFromEnvironment:
|
||||
"CDPATH",
|
||||
"COMSPEC",
|
||||
"ENV",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_0",
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
"GIT_CONFIG_PARAMETERS",
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
"GIT_CONFIG_SYSTEM",
|
||||
"GIT_DIR",
|
||||
"GIT_EDITOR",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_WORK_TREE",
|
||||
"GLOBIGNORE",
|
||||
"LD_PRELOAD",
|
||||
"PYTHONPATH",
|
||||
@@ -460,6 +480,16 @@ class TestReloadFromEnvironment:
|
||||
assert "CDPATH" not in os.environ
|
||||
assert "COMSPEC" not in os.environ
|
||||
assert "ENV" not in os.environ
|
||||
assert "GIT_CONFIG_COUNT" not in os.environ
|
||||
assert "GIT_CONFIG_KEY_0" not in os.environ
|
||||
assert "GIT_CONFIG_VALUE_0" not in os.environ
|
||||
assert "GIT_CONFIG_PARAMETERS" not in os.environ
|
||||
assert "GIT_CONFIG_GLOBAL" not in os.environ
|
||||
assert "GIT_CONFIG_SYSTEM" not in os.environ
|
||||
assert "GIT_DIR" not in os.environ
|
||||
assert "GIT_EDITOR" not in os.environ
|
||||
assert "GIT_SSH_COMMAND" not in os.environ
|
||||
assert "GIT_WORK_TREE" not in os.environ
|
||||
assert "GLOBIGNORE" not in os.environ
|
||||
assert "LD_PRELOAD" not in os.environ
|
||||
assert "PYTHONPATH" not in os.environ
|
||||
@@ -472,6 +502,58 @@ class TestReloadFromEnvironment:
|
||||
assert "DEEPAGENTS_INHERITED_PYTHONPATH" not in os.environ
|
||||
assert os.environ["OPENAI_API_KEY"] == "sk-ok"
|
||||
|
||||
def test_project_dotenv_denies_lowercase_git_config_keys(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Denied keys are matched case-insensitively for Windows env semantics.
|
||||
|
||||
On Windows `os.environ` keys are case-insensitive and Python normalizes
|
||||
assigned keys to uppercase, so a lowercase `git_config_key_0` in a
|
||||
committed `.env` would otherwise pass a case-sensitive check and become
|
||||
an active `GIT_CONFIG_KEY_0` for the `git` commands dcode runs during
|
||||
startup detection. On POSIX the lowercase spelling is inert (git reads
|
||||
only the canonical case), so denying it there is harmless.
|
||||
"""
|
||||
from deepagents_code.config import _load_dotenv
|
||||
|
||||
project_env = tmp_path / ".env"
|
||||
project_env.write_text(
|
||||
"git_config_count=1\n"
|
||||
"git_config_key_0=core.fsmonitor\n"
|
||||
"Git_Config_Value_0=/tmp/evil.sh\n"
|
||||
"git_dir=/tmp/evil.git\n"
|
||||
"OPENAI_API_KEY=sk-ok\n"
|
||||
)
|
||||
# On POSIX the lowercase names are distinct env vars; ensure neither the
|
||||
# lowercase spelling nor its uppercase normalization is already set.
|
||||
for key in (
|
||||
"git_config_count",
|
||||
"git_config_key_0",
|
||||
"Git_Config_Value_0",
|
||||
"git_dir",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_0",
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
"GIT_DIR",
|
||||
"OPENAI_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
_load_dotenv(start_path=tmp_path)
|
||||
|
||||
for key in (
|
||||
"git_config_count",
|
||||
"git_config_key_0",
|
||||
"Git_Config_Value_0",
|
||||
"git_dir",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_0",
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
"GIT_DIR",
|
||||
):
|
||||
assert key not in os.environ
|
||||
assert os.environ["OPENAI_API_KEY"] == "sk-ok"
|
||||
|
||||
def test_project_dotenv_cannot_set_mcp_trust_lists(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
@@ -751,6 +833,13 @@ class TestReloadFromEnvironment:
|
||||
"CDPATH",
|
||||
"COMSPEC",
|
||||
"ENV",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_0",
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
"GIT_CONFIG_PARAMETERS",
|
||||
"GIT_DIR",
|
||||
"GIT_EDITOR",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GLOBIGNORE",
|
||||
"SHELLOPTS",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user