Hmbown/CodeWhale · critical

reviewed plugin executable bytes changed before spawn

Error message

reviewed plugin executable bytes changed before spawn

What it means

Thrown by the reviewed-plugin launch guard in bind_file (crates/tui/src/mcp.rs:912). Before spawning a trusted plugin's executable, Codewhale re-hashes the file (SHA-256 over the domain tag "codewhale-plugin-file-bytes-v1\0" plus the bytes) and compares it with the hash recorded in the byte inventory captured at /plugin trust time. A mismatch means the exact bytes that were reviewed are no longer on disk, so launch fails closed. This is a deliberate tamper/TOCTOU check, not an I/O malfunction.

Source

Thrown at crates/tui/src/mcp.rs:912

        let mut hasher = sha2::Sha256::new();
        hasher.update(b"codewhale-plugin-file-bytes-v1\0");
        let mut buffer = [0_u8; 64 * 1024];
        loop {
            let read = file
                .read(&mut buffer)
                .context("read reviewed launch file")?;
            if read == 0 {
                break;
            }
            hasher.update(&buffer[..read]);
        }
        let actual = hasher
            .finalize()
            .iter()
            .map(|byte| format!("{byte:02x}"))
            .collect::<String>();
        if &actual != expected {
            anyhow::bail!("reviewed plugin executable bytes changed before spawn");
        }
        file.seek(std::io::SeekFrom::Start(0))
            .context("rewind reviewed launch file after verification")?;

        #[cfg(unix)]
        let launch_path = {
            use std::os::fd::AsRawFd as _;
            let fd = file.as_raw_fd();
            // SAFETY: `fd` is owned by `file`; clearing only FD_CLOEXEC keeps
            // that same descriptor available across the imminent exec.
            let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
            if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0
            {
                anyhow::bail!("failed to inherit reviewed plugin executable descriptor");
            }
            #[cfg(target_os = "linux")]
            let prefix = "/proc/self/fd";
            #[cfg(not(target_os = "linux"))]

View on GitHub (pinned to 8880682c63)

Solutions

  1. Re-trust the plugin: run /plugin reload, inspect /plugin show <name>, repeat the displayed /plugin trust <name> <token> command, then /plugin enable <name> (the exact steps the adjacent validate_before_use message prescribes at mcp.rs:719-721).
  2. If the change was intentional, go through the update path (/plugin update) so the byte inventory is regenerated instead of bypassed.
  3. Find what rewrote the file: compare mtimes (stat on the bundle files), disable sync clients (Dropbox/OneDrive) over the plugins directory, stop build watchers that rewrite outputs.
  4. If nothing you did explains the change, treat it as tampering: verify the file's provenance out-of-band before re-trusting.

Example fix

// before: plugin binary rebuilt after trust -> spawn aborts:
//   reviewed plugin executable bytes changed before spawn
// after: refresh the trust receipt after every intentional byte change
//   /plugin reload
//   /plugin show my-plugin
//   /plugin trust my-plugin <token-shown-by-show>
//   /plugin enable my-plugin
Defensive patterns

Strategy: validation

Validate before calling

use sha2::{Digest, Sha256};
use std::{fs::File, io::Read, path::Path};

/// Mirrors the launcher's check: SHA-256 over the domain tag + file bytes.
fn launch_bytes_match(path: &Path, expected_hex: &str) -> anyhow::Result<bool> {
    let mut file = File::open(path)?;
    let mut hasher = Sha256::new();
    hasher.update(b"codewhale-plugin-file-bytes-v1\0");
    let mut buf = [0u8; 64 * 1024];
    loop {
        let n = file.read(&mut buf)?;
        if n == 0 { break; }
        hasher.update(&buf[..n]);
    }
    let actual: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect();
    Ok(actual == expected_hex)
}

Try / catch

match McpConnection::connect_with_policy(name, config, &timeouts, policy).await {
    Ok(conn) => conn,
    Err(err) if err.to_string().contains("reviewed plugin executable bytes changed") => {
        // Fail-closed by design: surface the re-trust steps, never retry unchanged.
        Err(err.context("plugin bytes drifted from review; re-run /plugin trust"))?
    }
    Err(err) => return Err(err),
}

Prevention

When it happens

Trigger: Spawning an MCP server from a plugin whose executable or bound bundle file was modified after /plugin trust recorded its hash: rebuilding the plugin, a package manager / sync client rewriting the file, an editor touching the script, or /plugin update landing without re-trusting.

Common situations: Plugin authors iterating on a bundle and re-running the TUI without re-trusting; build watchers or CI rewriting plugin outputs between trust and use; marketplace updates arriving mid-session; the rare case of actual tampering, which this guard exists to catch.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@8880682c63 (2026-08-16). Data as JSON: /api/errors/a6d02ead62d73cd0. Report an issue: GitHub.