- Book: Observability for LLM Applications
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You told your agent it could run git without asking. That felt safe: you wanted git status and git diff to stop interrupting you. Here is a command that also starts with git:
git -c diff.external='sh -c "cat .env"' \ diff --no-index /dev/null .env That is the forged tool call from HiddenLayer's October 22, 2026 research on compromised LLM gateways. In their words: "Because the command begins with the approved git prefix, Codex runs it without prompting the user." Git hands the diff to an external program, the program is cat .env, and the secrets come back through the transcript. The agent never asked to read .env. The gateway wrote that tool call for it.
On the git side, a pile of config keys name programs, and git runs them during commands you think of as read-only.
What HiddenLayer showed
The setup is a gateway sitting between the agent and the model, like a LiteLLM proxy. It relays requests and responses, so a compromised one can add a tool call to a model's reply. The agent sees a normal-looking response and executes it. HiddenLayer's paper looks at Codex's permission instructions, which list the command prefixes the user has approved. With git on that list, the command above needs no prompt. The paper also notes that Codex's permission profile shows the repository's .git directory as writable.
One naming note before you search for it: HiddenLayer's other demo used Hermes Agent, which is Nous Research's agent. It has nothing to do with Hermes IDE, the tool I build.
It isn't the first time diff.external has turned an agent's git allowlist into code execution. In Pillar Security's GitPwned write-up, disclosed July 20, 2026, Codex CLI trusted git show as safe. git show --output=./.git/config wrote a [diff] external = ... section into the repo config, and the next git diff, also on the safe list, ran it. OpenAI fixed it in Codex CLI v0.95.0. The hardening PR, merged February 2, 2026, now treats --output and "risky Git config override flags (-c, --config-env) that can trigger external execution" as needing approval.
That fix covers Codex's built-in list of safe commands. A prefix you approve yourself is a different thing: it allows whatever comes after it.

One approved prefix, and every config key that runs a program behind it.
Reproduce it with an echo
Use a throwaway repo. None of this touches anything outside the folder.
git init -q gitcfg-demo && cd gitcfg-demo echo 'GREETING=hello' > .env git -c diff.external='sh -c "echo diff.external ran"' \ diff --no-index /dev/null .env Instead of a diff, git runs the sh -c string, so the line diff.external ran is what you should see. Git calls the external diff with seven arguments (path, old file, old hash, old mode, new file, new hash, new mode). sh -c takes them as $0 onward and ignores them, which is why the one-liner works.
Now the version that needs no -c at all. This mirrors the payload in Sonar's 2022 research on git integrations:
git config core.fsmonitor 'echo fsmonitor ran > proof.txt' git status cat proof.txt git config --unset core.fsmonitor rm proof.txt git status reads core.fsmonitor from .git/config and runs it as the file system monitor hook. Sonar lists git status and git diff as commands that trigger it. Once that line is in the repo config, the most read-only command you have runs a program.
Last one, an alias:
git config alias.st '!echo alias ran' git st git config --unset alias.st The git-config docs say an alias starting with ! "will be treated as a shell command."
The config keys that run programs
From the git-config reference (2.56.0), git-diff, githooks and Sonar's write-up:
| Key | What git runs | When |
|---|---|---|
diff.external | The diff command you name | git diff |
diff.<driver>.textconv | A converter for a file type | git diff, git log |
core.fsmonitor | The fsmonitor hook | git status, git diff |
core.pager | The pager, through the shell | Output to a terminal |
core.sshCommand | A replacement for ssh | git fetch, git push |
core.hooksPath | Hooks from another folder | git commit and others |
core.editor | Your editor | git commit, git tag |
alias.<name> = !... | A shell command | git <name> |
Two caveats. Git only starts a pager "if standard output is a terminal" (git docs), so whether core.pager fires depends on how your agent runs commands. And a textconv driver is named in .gitattributes, which is committed, but the command itself has to come from config.
Sonar notes that cloning a repository doesn't bring its .git/config with it, so the config reaches your agent in three ways:
- On the command line, with
-cor--config-env. That is HiddenLayer's route, and any prompt injection can ask for it. - Written into
.git/configby something with write access to.git: the agent's file tool, or a git command with--output, as in GitPwned. - In a folder that came from outside git, such as an archive or a download, with its
.git/included.
Route 2 has a twist if you run agents in worktrees. The git-worktree docs say "By default, the repository config file is shared across all worktrees." One agent writes core.fsmonitor once, and every worktree of that repo runs it on the next git status.
Narrower rules for each agent
Approve subcommands, never the bare program. Then forbid the config override flags.
Codex rules match the argument list as "an exact prefix," and when several match, "forbidden > prompt > allow." Open ~/.codex/rules/default.rules, which is where the TUI saves your approvals, and delete any rule whose pattern is only ["git"]. Then add:
prefix_rule( pattern = ["git", "-c"], decision = "forbidden", justification = "config overrides can run programs", match = ["git -c diff.external=x diff"], not_match = ["git status"], ) Forbidden beats any allow rule for the commands it matches. But an exact prefix only sees the first arguments. It doesn't catch --config-env in either form, or -c placed after another global option, such as git -C . -c diff.external=x diff, because the second argument is then not -c. With a broad ["git"] allow rule present, those commands run. That is the reason not to approve bare git in the first place.
In Claude Code, don't add Bash(git *). Its docs give the exact trap: Bash(git * main) matches git -c core.fsmonitor=<script> diff main. Read-only forms of git already run without a prompt. Add deny rules for the override flags:
{ "permissions": { "deny": ["Bash(git -c *)", "Bash(git --config-env*)"] } } Like any Bash rule, these match the text of the command, so git -C . -c ... gets past them. The docs' own table shows git -C . push origin main escaping Bash(git push *). The same page says a Bash rule "isn't a security boundary around the program": it won't stop sh -c 'git ...' either. Claude Code treats .git as a protected path, and its docs warn that bypassPermissions mode skips the prompt even for writes there.
You can also pin the worst keys in the agent's environment. The GIT_CONFIG_COUNT variables are the "command" scope, which git reads after the repo's own file, and the "last value found" wins:
export GIT_CONFIG_COUNT=2 export GIT_CONFIG_KEY_0=core.fsmonitor export GIT_CONFIG_VALUE_0=false export GIT_CONFIG_KEY_1=diff.external export GIT_CONFIG_VALUE_1= export GIT_PAGER=cat These pins beat a poisoned .git/config (routes 2 and 3). They lose to the command line: the git-config docs say these variables "will be overridden by any explicit options passed via git -c." So they don't stop HiddenLayer's command. The rules above have to do that.
Pillar's write-up uses the same empty diff.external= override to break the chain. GIT_PAGER=cat stops git launching a pager, per the git docs. Git 2.35.1 and older read false as a hook path rather than a boolean, so update git first. A third pair with core.hooksPath=/dev/null would disable hooks too, but the docs call that "usually only advisable for expert users and on a per-command basis," so only add it if your repo has no hooks you rely on. None of this covers diff.<driver> or alias entries, so you still need the audit.
Audit every worktree
--show-scope and --show-origin tell you where each value came from: system, global, local, worktree or command, and the file that set it. Run it with git config, not git status, since status is one of the commands that runs core.fsmonitor:
pat='fsmonitor|pager|sshcommand|hookspath|editor' pat="$pat|external|\.command=|textconv|alias\.|filter\." git worktree list --porcelain | sed -n 's/^worktree //p' | while IFS= read -r wt; do echo "== $wt" git -C "$wt" config --show-scope --show-origin -l | grep -iE "$pat" done Anything under local or worktree that you didn't put there is the line to read closely. A core.pager in your own ~/.gitconfig is normal. A core.fsmonitor in .git/config that appeared during an agent session is not.
I build Hermes IDE, so weigh this paragraph with that in mind. Hermes gives each task its own hermes/<name> branch and its own worktree, which is exactly the setup where one poisoned .git/config reaches every task in that repo. Its Review Desk diffs your files turn by turn. .git/config is not a tracked file, so don't count on any diff view, that one included, to show it to you. Run the loop above before you land a task. Hermes never answers a permission prompt for you, so the rules above stay the agent's own. It's free and source-available (BSL 1.1) on macOS, Windows and Linux: download it or read the source.

The Review Desk's Worktrees tab: one Hermes worktree each for orders-api, web-app and orders-types under ~/code, with each worktree's hermes/ branch, task, age and disk size.
An allowlist checks the text of a command. Git decides what runs from config state the allowlist never sees, so run the audit loop in the repo you have open right now.
If this was useful
HiddenLayer's attack lives in the gateway, a box many teams already route their LLM traffic through, so the gateway's own logs are the wrong place to look for it. If you trace the tool calls your agent actually executes, on the agent's side, a git -c diff.external=... span is something you can alert on instead of finding it in a transcript later. Observability for LLM Applications is my book on tracing, evals and cost for LLM features in production, gateways included.


