Skip to content

Fix shell heredoc injection via prompt/candidate content#1

Open
iFuzzle wants to merge 1 commit into
cdlewis:mainfrom
iFuzzle:fix/heredoc-prompt-injection
Open

Fix shell heredoc injection via prompt/candidate content#1
iFuzzle wants to merge 1 commit into
cdlewis:mainfrom
iFuzzle:fix/heredoc-prompt-injection

Conversation

@iFuzzle

@iFuzzle iFuzzle commented Jul 11, 2026

Copy link
Copy Markdown

Summary

ClaudeBackend.BuildCommand and CodexBackend.BuildCommand interpolate the (possibly untrusted) prompt directly into a bash -c string using a heredoc with a static delimiter:

const delimiter = "__NIGEL_PROMPT_EOF__"
return fmt.Sprintf("%s %s -p <<'%s'\n%s\n%s", baseCmd, jsonFlags, delimiter, prompt, delimiter)

The prompt is built via $INPUT interpolation from candidate data (InterpolatePrompt in executor.go), and candidates come from whatever a task's candidate_source command prints - e.g. repository-derived data like function/file names. If a candidate value contains a line that happens to equal __NIGEL_PROMPT_EOF__, the heredoc closes early and everything after it in the assembled string is parsed and executed as a normal shell command by bash -c, with the privileges of whoever is running nigel. InterpolateCommand already shell-quotes $CANDIDATE for this exact reason - that protection just never extended to the AI-invocation heredoc.

Fix

Stop embedding the prompt in the shell string entirely. Both claude -p and codex exec --json - already support reading the prompt from stdin (that's exactly why the heredoc was there in the first place, per the removed comment). BuildCommand now only returns the command/flags, and RunAICommand sets cmd.Stdin to the prompt directly - so the prompt is never parsed by the shell, no matter what it contains.

Testing

  • go build ./src succeeds
  • go test ./src/... - all existing tests pass with updated BuildCommand signature/expectations
  • Added TestRunAICommandPromptNeverParsedByShell: a regression test that feeds a prompt containing an embedded fake heredoc terminator plus a touch payload through RunAICommand, and asserts the payload is never executed and the prompt is delivered byte-for-byte via stdin instead
  • Added TestCodexBuildCommandExcludesPromptFromShellString

(Generated by Claude)

BuildCommand interpolated the prompt directly into a bash -c heredoc
with a static delimiter (__NIGEL_PROMPT_EOF__). Since the prompt is
built from candidate_source/$INPUT data that can originate from
untrusted repository content, a candidate containing a line matching
the delimiter would close the heredoc early and cause the remaining
text to be parsed and executed as an arbitrary shell command.

Both claude -p and codex exec --json - already support reading the
prompt from stdin, so BuildCommand no longer embeds the prompt at
all; RunAICommand now sets cmd.Stdin directly instead, so the prompt
is never parsed by the shell regardless of its contents.

(Generated by Claude)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant