cfgaudit

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:

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:

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