CVE-2026-75915: CodeWhale: js_execution leaks parent environment to model context via missing env scrub
CodeWhale's `js_execution` tool starts Node without scrubbing the parent environment. Model-written JavaScript can read `process.env`, and the values come back as tool output into the model's context and on to the LLM provider. API keys and cloud tokens are exposed. Fixed in CodeWhale 0.8.64.
Published Sep 4, 2026 · Analysis Oct 24, 2026
What an attacker can do
crates/tui/src/tools/js_execution.rs builds its Node command with tokio::process::Command::new and never calls env_clear() or child_env::apply_to_tokio_command. The shell tool, the Python REPL and the MCP launcher all use that scrubber, which keeps only keys such as PATH, HOME and proxy variables. The JS tool was added four days after the scrubber landed and missed it.
So a snippet like console.log(JSON.stringify(process.env)) returns every exported variable in stdout. That output joins the next model request. Exposed values include DEEPSEEK_API_KEY, OPENAI_API_KEY, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, NPM_TOKEN and DATABASE_URL-style secrets. They travel to the configured LLM provider's logs.
The tool prompts for approval, but its label reads "local Node.js execution sandbox", which understates what it can do. Node has full filesystem and network access and only a 120-second timeout. In YOLO mode (auto_approve=true) there is no prompt at all, so a prompt injection in a README, fetched page or MCP output can drain the environment in one turn. The attacker needs content the agent will read.
Who is affected
codewhale(npm) andcodewhale-tui(Rust): 0.8.41 up to but not including 0.8.64.deepseek-tui(npm): 0.8.32 up to but not including 0.8.41.deepseek-tui(Rust crate): 0.8.32 through 0.8.41, no patched version listed.
npm ls -g codewhale deepseek-tuiThe tool needs Node on the path. Exposure is worst when you run in YOLO mode, or export long-lived credentials in the shell that starts the agent.
deepseek-tui
- Ecosystem
- Rust (crates.io)
- Affected
- >= 0.8.32, <= 0.8.41
- Fixed in
- No patched version yet
- Check
cargo tree -i deepseek-tui
deepseek-tui
- Ecosystem
- npm
- Affected
- >= 0.8.32, < 0.8.41
- Fixed in
- 0.8.41
- Check
npm ls deepseek-tui
codewhale-tui
- Ecosystem
- Rust (crates.io)
- Affected
- >= 0.8.41, < 0.8.64
- Fixed in
- 0.8.64
- Check
cargo tree -i codewhale-tui
codewhale
- Ecosystem
- npm
- Affected
- >= 0.8.41, < 0.8.64
- Fixed in
- 0.8.64
- Check
npm ls codewhale
How to fix it
Upgrade codewhale or codewhale-tui to 0.8.64 or later (commit 26de44a).
npm install -g codewhale@latestThe fix applies the existing scrubber to the Node child with no overrides, so secret-bearing variables stay in the parent. The deepseek-tui npm package is fixed in 0.8.41. The Rust crate has no patched version, so move to codewhale-tui.
If you may have run an affected version in a shell with real credentials and let the agent use js_execution, rotate those keys. Start the agent from a shell that holds only the variables it needs.
How to tell if you were hit
Search session transcripts for js_execution calls whose code mentions process.env, and for tool output that contains key names or values such as AWS_SECRET_ACCESS_KEY. If such output reached the model, treat those secrets as exposed to the LLM provider and rotate them. Check cloud and source-host audit logs for use of those credentials from unexpected addresses.
Beam records agent shell commands and MCP tool calls, and its rules flag risky ones, which gives you a trail of what ran during a session.
Original advisory
From the GitHub Advisory Database (CC BY 4.0).
Maintainer resolution
The CodeWhale maintainers validated this report. The affected package ranges are recorded in the advisory metadata. Version 0.8.64 contains the fix in commit 26de44a8bd5051f8f944ea60b2c37ae1d2b7d25e. Users should upgrade to 0.8.64 or later. The original reporter analysis is preserved below.
Summary
js_execution exposes parent process environment to model-provided JavaScript
The js_execution tool spawns Node with tokio::process::Command::new without calling the child_env scrubber that exec_shell, the Python REPL, and the MCP launcher all use. Model-provided JavaScript reads process.env and the values flow back to the parent transcript as the tool's stdout, exposing API keys, cloud credentials, and forge tokens to the next model turn.
Details
In crates/tui/src/tools/js_execution.rs (v0.8.37, lines 91-105):
let temp_dir = tempfile::tempdir()
.map_err(|e| ToolError::execution_failed(format!("tempdir failed: {e}")))?;
let script_path = temp_dir.path().join("js_execution.js");
tokio::fs::write(&script_path, code)
.await
.map_err(|e| ToolError::execution_failed(format!("tempfile write failed: {e}")))?;
let mut cmd = tokio::process::Command::new(&node);
cmd.arg(&script_path);
cmd.current_dir(workspace);
let output = tokio::time::timeout(Duration::from_secs(120), cmd.output())
.await
.map_err(|_| ToolError::Timeout { seconds: 120 })
.and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?;The Command is built without cmd.env_clear() and without the project's crate::child_env::apply_to_tokio_command helper. Every variable in the parent process environment is inherited by the spawned node.
For comparison, exec_shell (crates/tui/src/tools/shell.rs:790-792) and the Python REPL (crates/tui/src/repl/runtime.rs:238) both apply the scrubber:
child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));apply_to_tokio_command calls cmd.env_clear() and then re-installs only the keys that pass is_allowed_parent_env_key (PATH, HOME, USER, LANG/LC_*, TMPDIR, proxy variables, Windows toolchain context, terminal settings). Secret-bearing variables (DEEPSEEK_API_KEY, OPENAI_API_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, etc.) are not on the allowlist and are dropped before the child starts. The js_execution path bypasses both the env_clear and the allowlist.
Commit history makes the gap explicit. Commit e6d4eae fix(security): scrub child process environments (2026-05-08) introduced child_env.rs and rewrote exec_shell, the Python REPL, the MCP launcher, and main.rs to use it. Commit 2566f3c feat(tools): add js_execution tool (2026-05-12) added this file four days later and never picked up the helper.
The tool is described to the model and surfaced in the approval pane as "Run model-provided JavaScript code in local Node.js execution sandbox" (crates/tui/src/core/engine/turn_loop.rs:1174-1176). No sandbox is applied beyond a 120-second timeout; Node has full filesystem and network access in addition to the inherited environment. The wording understates the trust boundary that the user is being asked to cross.
In YOLO mode (auto_approve=true) the JS body runs without any prompt at all, so a single adversarial prompt-injection from a README, fetched web page, or MCP server output drains the parent environment to the next model turn.
PoC
A standalone Cargo test reproduces the unscrubbed-env behavior. Save as crates/tui/tests/js_execution_env_leak.rs and run with cargo test -p deepseek-tui --test js_execution_env_leak -- --nocapture:
use deepseek_tui::tools::js_execution::execute_js_execution_tool;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn js_execution_inherits_parent_secrets() {
if deepseek_tui::dependencies::resolve_node().is_none() {
eprintln!("node not on PATH; skipping");
return;
}
unsafe {
std::env::set_var("AWS_SECRET_ACCESS_KEY", "leak-marker-AKIA-EXAMPLE");
std::env::set_var("DEEPSEEK_API_KEY", "leak-marker-sk-EXAMPLE");
}
let tmp = tempdir().unwrap();
let result = execute_js_execution_tool(
&json!({"code": "console.log(process.env.AWS_SECRET_ACCESS_KEY + '|' + process.env.DEEPSEEK_API_KEY)"}),
tmp.path(),
).await.expect("execute");
let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
let stdout = payload["stdout"].as_str().unwrap_or("");
assert!(stdout.contains("leak-marker-AKIA-EXAMPLE"), "AWS leaked: {stdout}");
assert!(stdout.contains("leak-marker-sk-EXAMPLE"), "DEEPSEEK leaked: {stdout}");
}Equivalent reproducer against the binary:
export AWS_SECRET_ACCESS_KEY="leak-marker-AKIA-EXAMPLE"
export DEEPSEEK_API_KEY="leak-marker-sk-EXAMPLE"
deepseek
# Ask the model to run:
# js_execution({"code":"console.log(JSON.stringify(process.env))"})
# Approve once. The returned stdout contains every parent env value verbatim,
# including the markers above, and is now part of the model's context for the next request.The fix is one line added next to the existing cmd.current_dir(workspace) call:
let mut cmd = tokio::process::Command::new(&node);
cmd.arg(&script_path);
cmd.current_dir(workspace);
crate::child_env::apply_to_tokio_command(&mut cmd, std::iter::empty::<(&str, &str)>());This calls the existing helper with no overrides, mirroring how repl/runtime.rs spawns the Python REPL. The behavior the description string already promises (sandbox) is then partially honored: secret-bearing parent variables stay in the parent.
Impact
The tool returns parent-environment secrets to the model on a single approval, or with no approval in YOLO mode. Any variable the user has exported becomes part of the next model request and travels to the configured LLM provider's logs. Common variables that the codebase's own provider clients read from process env, and therefore the values most likely to be present, include DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY, AZURE_OPENAI_API_KEY, XAI_API_KEY, GROQ_API_KEY, and TOGETHER_API_KEY. Cloud and source-control credentials commonly exported in developer shells include AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, GOOGLE_APPLICATION_CREDENTIALS, GITHUB_TOKEN, GH_TOKEN, GITLAB_TOKEN, NPM_TOKEN, CARGO_REGISTRY_TOKEN, PYPI_API_TOKEN, and DATABASE_URL-style secrets. The local-sandbox wording shown at approval time understates the trust boundary, so users approving what they read as a sandboxed snippet do not anticipate that every shell-exported credential is reachable from the snippet. The remediation matches the pattern already adopted across exec_shell, the Python REPL, and the MCP launcher, so the gap is a missed call site rather than a design tradeoff.
Credited to fg0x0.
Questions
- Is a prompt required before js_execution runs?
Yes, normally. The approval pane shows it. In YOLO mode (
auto_approve=true) it runs with no prompt.- Where do the leaked values go?
They return as the tool's stdout and join the next model request, so they reach the configured LLM provider and its logs.
- Why did other tools not leak the environment?
exec_shell, the Python REPL and the MCP launcher callchild_env, which clears the environment and re-adds an allowlist.js_executiondid not.- Which version fixes CVE-2026-75915?
0.8.64 for
codewhaleandcodewhale-tui, and 0.8.41 for thedeepseek-tuinpm package.
References
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology
More Other MCP servers and tools vulnerabilities
All Other MCP servers and tools entriesCVE-2026-77361
obsidian-tc : Symlink read-ACL scope bypass via lexical path checks
medium4.7Oct 23, 2026CVE-2026-114126
Storybook dev server vulnerable to code injection from the local network
high8.7Oct 22, 2026CVE-2026-113731
djust: Arbitrary absolute-path file read in the find_handlers_for_template MCP tool
low3.3Oct 21, 2026CVE-2026-114125
Storybook: Missing origin validation in Storybook preview allows code injection from malicious websites
critical9.4Oct 21, 2026CVE-2026-111770
MCP TypeScript server SDK: Shared transport or server instances delivered one HTTP client's messages to another
high7.7Oct 20, 2026GHSA-953h-535j-jg2v
agent-router: MCP session resume does not verify the embedded anti-hijacking subject; default Helm seed makes session IDs forgeable
medium6.8Oct 19, 2026
