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:
Mason Daugherty
2026-08-21 11:59:15 -04:00
committed by GitHub
parent 017f0bed92
commit 328f9160e0
2 changed files with 153 additions and 6 deletions
+64 -6
View File
@@ -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
+89
View File
@@ -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",
)