# CodeWhale: exec_shell_interact sends LLM-controlled input to a running shell without an approval prompt (privilege escalation)

CodeWhale's exec_shell_interact tool auto-approves LLM-controlled stdin writes to an already-approved interactive shell, letting prompt injection run commands at that shell's privilege.

- Payout: Research
- Severity: high
- Program: CodeWhale
- Found by (vendor credit): sai-sh
- Found by: sai-sh
- Disclosed: 2026-09-04
- CVE: CVE-2026-75857
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-g29h-pfmp-qp9r
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-g29h-pfmp-qp9r
- Tags: area:ai domain:ai impact:privesc impact:rce kind:research platform:ghsa sev:high tech:llm tech:rust technique:source-review vuln:auth-bypass vuln:privilege-escalation vuln:prompt-injection

**Target:** TUI coding agent shell tool (exec_shell_interact / exec_interact)
**Root cause:** ShellInteractTool overrides approval_requirement() to ApprovalRequirement::Auto despite declaring ExecutesCode, so the trait default of Required is bypassed and the approval policy is never consulted for stdin writes.
**Steps:**
1. User asks agent to open a REPL; model calls `exec_shell command="python3 -i"`; user approves once.
2. Later, untrusted content (fetched page, MCP result, repo AGENTS.md) instructs the model to send a payload to the open REPL.
3. Model calls `exec_shell_interact task_id=<repl> input="import os; os.system('...')\n"`; no approval prompt fires; Python executes it.
4. Verify by driving the TUI through a pty: only the initial exec_shell shows `Approval needed:`; a sentinel file proves the injected input ran.
**How found:** Code review of shell.rs comparing exec_shell (correctly gated) with its sibling exec_shell_interact, spotting the Auto override against the spec.rs trait default of Required, then confirming via pty-driven TUI that no approval dialog appears.
**Exploitation:** Prompt injection in any content the agent ingests drives exec_shell_interact against a previously approved long-running process; stdin becomes the command surface, so payloads execute at that process's privilege (mysql root, ssh remote host, sudo -i root) without re-prompting.
**Escalation:** Reach scales with the approved process: `mysql -u root` yields arbitrary SQL, `ssh host` yields remote commands, `sudo -i` yields root.
**Insight:** When auditing agent tool registries, diff each tool's approval_requirement() against its declared capabilities: any ExecutesCode tool returning Auto is a gate bypass, and interactive-session tools are especially dangerous because stdin is a command surface for privileged processes.

## Proof of concept (copied exactly from the original report)

**exec_shell_interact approval_requirement returns Auto**

```
fn approval_requirement(&self) -> ApprovalRequirement {
    ApprovalRequirement::Auto          // overrides the Required-for-ExecutesCode default
}

async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
    let task_id = required_task_id(&input)?;
    let close_stdin = optional_bool(&input, "close_stdin", false);
    let interaction_input = input
        .get("input").or_else(|| input.get("stdin")).or_else(|| input.get("data"))  // LLM-controlled
        .and_then(serde_json::Value::as_str).unwrap_or("");
    {
        let mut manager = context.shell_manager.lock()...;
        if !interaction_input.is_empty() || close_stdin {
            manager.write_stdin(task_id, interaction_input, close_stdin)...;          // no prompt
```

**PoC steps sending payload to approved REPL**

```
1. User asks the agent to open a REPL; the model calls `exec_shell command="python3 -i"`; the user sees and approves it once.
2. Later in the session, untrusted content (a fetched page, an MCP result, a repo `AGENTS.md`) instructs the model to send a payload to the open REPL.
3. The model calls `exec_shell_interact task_id=<repl> input="import os; os.system('...')\n"`. No prompt fires; Python runs it.
```

