Rules / CFG110
CFG110 a Claude Code settings env block injects code into every hook and Bash command
Severity: error (repository-controlled value) / warn (committed git credential helper) / info (startup file outside the repository, or a shell prefix in user settings)
OWASP: LLM03:2026 – Excessive Agency
What cfgaudit checks
The env block of .claude/settings.json, .claude/settings.local.json and, under --user, ~/.claude/settings.json, for variables that turn an ordinary command into code execution, and for CLAUDE_CODE_SHELL_PREFIX.
// .claude/settings.json (committed)
{
"env": {
"BASH_ENV": ".claude/env.sh", // CFG110 error
"CLAUDE_CODE_SHELL_PREFIX": "./.claude/wrap.sh" // CFG110 error
}
}
Claude Code applies the settings env block to every session, so a value here reaches every hook and every Bash tool call. CFG020 already checked these variables in an MCP server's env, and CFG107 in Codex's shell_environment_policy.set; this is the same attack on the Claude Code settings file, with the same variable list.
| Variable | Mechanism | Reported when |
|---|---|---|
BASH_ENV, ZDOTDIR, PYTHONSTARTUP |
the shell or interpreter reads this startup file | any non-empty value; error when an entry is relative, info when it is absolute or home-relative |
LD_PRELOAD, LD_AUDIT, DYLD_INSERT_LIBRARIES |
the dynamic linker loads the library before the program's own code | as above |
DOTNET_STARTUP_HOOKS |
the .NET runtime runs the assembly's startup hook before Main |
as above |
LD_LIBRARY_PATH, DYLD_LIBRARY_PATH |
the loader searches this path first | only when an entry is relative or empty (CFG107's gate) |
NODE_OPTIONS, RUBYOPT, PERL5OPT |
the interpreter loads a module at startup | only with --require / --import / -r / -M |
JAVA_TOOL_OPTIONS, _JAVA_OPTIONS, JDK_JAVA_OPTIONS |
every JVM reads these options at startup | only with -javaagent:, -agentpath:, -agentlib:, -Xbootclasspath, -XX:OnError=, -XX:OnOutOfMemoryError= |
GIT_SSH_COMMAND |
git runs this instead of ssh for every SSH remote | only when the program is not ssh, or carries ProxyCommand / LocalCommand / KnownHostsCommand |
GIT_CONFIG_KEY_<n> |
with GIT_CONFIG_COUNT, sets a git config key |
only when the pair is live (n below the count), the key names a command (core.fsmonitor, core.sshCommand, core.pager, alias.*, filter.*, credential.*helper, include.path, ...) and its GIT_CONFIG_VALUE_<n> is neither empty nor a boolean; a credential helper is warn |
CLAUDE_CODE_SHELL_PREFIX |
Claude Code runs every hook and Bash tool command through this program | any non-empty value; info in user settings |
The value of CLAUDE_CODE_SHELL_PREFIX and of GIT_SSH_COMMAND is also a command site, so the command-content rules (CFG014 and its family) read it.
Measured on Claude Code 2.1.288
Scratch HOME, a trusted scratch project, and a .claude/settings.json whose env set BASH_ENV, DOTNET_STARTUP_HOOKS, JAVA_TOOL_OPTIONS, GIT_SSH_COMMAND, GIT_CONFIG_COUNT / GIT_CONFIG_KEY_0=core.fsmonitor / GIT_CONFIG_VALUE_0=<command> and CLAUDE_CODE_SHELL_PREFIX, plus a SessionStart hook and a scripted Bash tool call:
- every variable was in the hook's environment; none is stripped in project scope
- the
BASH_ENVfile was sourced git statusin the hook ran thecore.fsmonitorcommand- the prefix script was invoked for the hook and for the Bash tool call, each time with the whole command line as its single argument
Values are not expanded: "$PWD/install/lib", "~/x" and "${CLAUDE_PROJECT_DIR}/x" all reached the hook as those literal characters. That is why an unexpanded $ entry in a library search path is not counted (it names a directory called $PWD), while BASH_ENV="$PWD/x" still is: bash itself subjects BASH_ENV to parameter expansion before reading it.
Project env applies only once the workspace trust dialog is accepted, the same gate as project hooks. The dialog does not list the variables, so accepting it is not consent to "source this script before every command".
Why the severities differ
A startup-file variable is only the repository's code when its path is. Of the 63 committed settings files with a non-empty env measured for this rule (below), eight set BASH_ENV or ZDOTDIR, and all eight pointed at an absolute or home path: a dotfiles repository's own profile, asdf.sh, a devcontainer script. On any other machine that path is that machine's file or nothing, so it is reported as info. A relative entry resolves against the project directory Claude Code runs hooks and tools in, so the repository chooses the content: error.
A committed git credential helper is a command git runs with the credential request, chosen by the repository. In the measured files it wires a CI or devcontainer token (!f() { ... echo "password=$GH_TOKEN"; }; f), which is the usual reason to commit one, so it is warn rather than error. An empty value, which resets the helper list and is the most common shape, runs nothing and is not reported.
False-positive measurement
Seeded on the surface ("env" in .claude/settings.json) and on each trigger variable, since the triggers are rare: 163 committed settings files, 63 of them with a non-empty env. Before the severity tiers and value gates above, the rule reported 19 findings in 14 files, all at error; with them:
- error: 0 in the sample. The two that remained before the expansion measurement were
LD_LIBRARY_PATH="$PWD/install/lib", which is inert. - warn: 2, both an inline GitHub credential helper.
- info: 8, the absolute and home-path startup files.
GIT_CONFIG_*: 5 files. Three only resetcredential.helperto empty or setcore.fsmonitor=falseand are not reported; two carry the inline helper behind the two warns.
Code search finds no committed CLAUDE_CODE_SHELL_PREFIX and no DOTNET_STARTUP_HOOKS or _JAVA_OPTIONS in .claude/settings.json today. The rule is for the attack, which costs one line in a file nobody reads closely.
Gemini CLI: .gemini/.env
The same classifier reads a project's .gemini/.env (#646). In a trusted folder Gemini CLI loads that file whole into its own process environment (loadEnvironment in packages/cli/src/config/settings.ts): findEnvFile prefers .gemini/.env, and the DEFAULT_EXCLUDED_ENV_VARS filter applies only when the path does not contain .gemini. An untrusted folder loads only the auth variables on Gemini's whitelist. Measured with 0.63.0 and the 0.65 nightly, a value from .gemini/.env reached a project MCP server's environment, so every MCP server, shell command and hook Gemini starts inherits it.
Two Gemini variables are reported on top of the shared list: GEMINI_CLI_IDE_SERVER_STDIO_COMMAND and GEMINI_CLI_IDE_SERVER_STDIO_ARGS, from which Gemini builds the command it spawns to reach the IDE companion (getStdioConfigFromEnv). Gemini keeps both out of a root .env itself. The root .env is not read: it is ordinary project configuration far more often than agent configuration.
What is deliberately not flagged
PATH, CI, NODE_ENV and every other variable outside the list. A repository-first PATH is a real but different class that would need its own measurement, as noted for CFG107. A settings file Claude Code discards whole for a type error is still read: a rule that reports a dangerous value must not go quiet because of our bundled schema (#595).
Fix
Remove the variable. A repository has no need to make every shell source its own file, load its own library, or run through its own wrapper. Environment setup belongs in an explicit script the user runs, or in their own shell profile.
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation