CASE · Minor · monitoring-gap
Unverified reportCollected from public reportsSelf-built security guards were bypassed in three places (unverified report)
According to the author, in a setup where AI agents handle work from implementation to merge, the hook meant to stop .env reads was bypassed with node -e, the hook meant to stop force pushes to main was bypassed by a second line after a newline, and the reviewing AI turned out to be steerable through configuration files in a PR. These were found in the author's own audit, and the article does not describe actual harm.
5 min read
- OBSERVED
- CATEGORY
- monitoring-gap
- BLAST RADIUS
- none
- DETECTED BY
- self-audit
- TAGS
- #claude-code #codex #hooks #env-file #force-push #shlex #poisoned-pipeline-execution #ai-code-review #guardrails
CH 1
Cause
Per the author, the .env hook listed the names of reading tools, so tools not on the list got through. The force-push hook missed that Python's shlex treats a newline as whitespace. The reviewer CLIs read settings from the directory they start in, and the head of a PR can contain whatever files its submitter likes.
CH 2
Consequence
The author confirmed that node -e could actually read the contents of .env.local. A git push -f origin main split across lines was not blocked in the test either. A command that gets the branch name depended on the caller's cwd, so calling it from outside the repository silently dropped the check. For the reviewer issue, the author says it was not actually exploited. The article reports no real-world damage.
CH 3
Fix
The author added a rule that blocks on .env-family file names instead of tool names, made newlines count as command separators, and removed the cwd dependency from the branch check. To avoid false positives, inspection was limited to strings that are actually executed, and cases that must be blocked and cases that must pass were placed in one test table run in CI. Six layers of measures were added around the reviewer, including a working copy stripped of the PR's config files.
What happened
The author uses Claude Code, Codex and Cursor Agent, and lets the AI carry work through branch, PR and CI up to the merge. Reading real .env values, and force-pushing to or deleting main, were set up to be blocked mechanically. According to the author, the blocking side had three holes. All three were found by the author auditing their own guards, and the article does not mention actual harm.
The chaos on the ground
On the morning of September 20, while preparing a production release, the author tried to have the AI register the values in .env.local with Secret Manager, and the hook stopped it. That was as designed. Trying a different tool just to check, the author found that a node -e command reading the file went through and the contents were readable.
The second hole turned up when the author tried to write tests for that hook and put them into CI. A one-line git status; git push -f origin main was blocked, but the same content split over two lines was not. The author says AI agents often join several commands with newlines in a single Bash call. In addition, the command that looks up the branch name depended on the caller's cwd. When the cwd was outside the repository the branch name came back empty and the check was dropped. No error appeared; the command was simply let through.
The third came on the morning of October 7, when the author ran Cloudflare's published security-audit skill against the shared-rules repository. It was the quick profile, which only reads source and runs no code. It found zero confirmed vulnerabilities, but the first item under "needs verification" was the reviewer issue. The author closed it the same morning.
Root cause
As the author explains, the .env hook worked by listing the names of reading tools such as cat, grep, sed and xxd. There are many other ways to read a file, such as node -e, python3 -c, base64 and cp, and when one tool is blocked the AI just looks for another route to finish the task, without any malice, the author says. Read(.env) under permissions.deny blocks Claude Code's own file reads, but does not cover arbitrary programs opening the file inside Bash.
The force-push hook split commands into tokens with shlex, whose default treats a newline as whitespace. It saw ; and && as separators, but a newline counted as a space, so two lines were joined into one command and only the leading git status was evaluated.
For the reviewer, both Claude Code and Codex read settings from the directory they are started in (hooks in .claude/settings.json, MCP servers in .mcp.json, rules in .codex/). Since the head of a PR can hold any file its submitter likes, adding a single .mcp.json could make an arbitrary command run when the reviewer starts. The author notes that PRs written by the AI are influenced by the issues, web pages and dependency READMEs the AI reads, so a config file could be slipped in through prompt injection, and says this has the same shape as OWASP's CICD-SEC-4 (Poisoned Pipeline Execution).
The fix
For the .env hook, the author added a check that blocks on .env-family file names appearing in the command, whatever the tool (only .env.example is allowed), without removing any existing rule. On 15 synthetic commands, the 8 that should be blocked and the 7 that should pass came out as expected. Even so, a script on disk that reads .env internally does not show the name in the command string and cannot be caught. The author wrote this limit into CLAUDE.md, and says values that must never be read should not live on the development machine, with production values kept in Secret Manager.
For the force-push hook, newlines now count as command separators instead of whitespace. That fix produced a false positive on a commit message, so inspection was narrowed to strings that are actually executed: bash -c, eval, ssh, and heredocs passed to a shell. A hook test table that lists cases that must be blocked together with cases that must pass was added to CI, and all 40 cases pass (23 blocked, 17 allowed). The branch check now derives the project root from the script's own location and no longer depends on the cwd.
Six layers were stacked around the reviewer: check out the PR head outside the Projects directory; remove .claude/, .codex/, .cursor/ and .mcp.json from the working copy before invoking the reviewer; start it with options that skip project settings and MCP; use the base branch's version of the review rules; pass review output through a redaction filter; and compare the PR head again just before applying qa-pass. For notifications, the author now sends only one line: repository name, PR number and title, and done, failed or waiting.
The lesson is that a guard cannot be known to work until it has been tried and broken. A faulty hook shows up not by blocking too much and making noise, but by passing things silently.
Sources
Pass this record on to your team