agent-block 0.33.0

Lua-first Agent Runtime built on AgentMesh
//! End-to-end tests for sandbox mode (`--sandbox`).
//!
//! Landlock is a Linux-only LSM, so the whole file is compiled out elsewhere.
//!
//! | Scenario                       | Assertion                                  |
//! |--------------------------------|--------------------------------------------|
//! | `--sandbox`, write in project  | succeeds (default workflow must not break) |
//! | `--sandbox`, write outside     | `sh.exec` redirection fails, file absent   |
//! | no flag, write outside         | succeeds — control for the case above      |
//!
//! Both the "outside" target *and the project root* deliberately live under
//! `$HOME` rather than in a plain `tempdir()`: `/tmp` is part of the sandbox
//! write allowlist, so a temp path would prove nothing — an in-`/tmp` project
//! would keep passing even if the project-root grant itself regressed. Both are
//! created as `TempDir`s so they disappear with the test either way. When the
//! running kernel has no usable Landlock support the binary refuses to start
//! (fail-closed) and these tests skip.

#![cfg(target_os = "linux")]

mod common;

use std::path::{Path, PathBuf};
use std::process::Output;
use tempfile::TempDir;

/// Substring of the startup error emitted when Landlock enforces nothing.
/// Keep in sync with `agent_block_core::sandbox::SandboxError::NotEnforced`.
const KERNEL_SKIP_MARKER: &str = "Landlock is not enforced";

/// A scratch directory under `$HOME` — outside the built-in write allowlist
/// (unlike `/tmp`). Returns `None` when `$HOME` is unset or not writable, in
/// which case the caller skips.
fn home_scratch(prefix: &str) -> Option<TempDir> {
    let home = PathBuf::from(std::env::var_os("HOME")?);
    tempfile::Builder::new()
        .prefix(prefix)
        .tempdir_in(home)
        .ok()
}

/// Write the probe script into `project` and return its path.
///
/// The script writes one file inside the project root and one outside of it via
/// `sh.exec`, reporting the outcome of the latter on stdout. Both paths are
/// interpolated so the script needs nothing from the environment.
fn write_probe_script(project: &Path, denied: &Path) -> PathBuf {
    let script_path = project.join("sandbox_probe.lua");
    let source = format!(
        r#"-- generated by tests/e2e_sandbox.rs
std.fs.write("{inside}", "inside-ok")
print("INSIDE_WRITE_OK")

local r = sh.exec("printf 'x' > '{denied}'")
if r.ok and r.code == 0 then
  print("OUTSIDE_WRITE_OK")
else
  print("OUTSIDE_WRITE_DENIED")
end
"#,
        inside = project.join("inside.txt").display(),
        denied = denied.display(),
    );
    std::fs::write(&script_path, source).expect("write probe script");
    script_path
}

/// Run the probe script, optionally with `--sandbox`. Returns the raw output
/// plus the `AGENT_BLOCK_HOME` guard so it outlives the process.
fn run_probe(project: &Path, script: &Path, sandbox: bool) -> (Output, TempDir) {
    let home = tempfile::tempdir().expect("tempdir for AGENT_BLOCK_HOME");
    let mut cmd = common::agent_block_cmd();
    // Ambient sandbox knobs in a developer/CI shell must not leak into the
    // probes: the control run has to be genuinely unsandboxed, and the
    // sandboxed run has to test exactly the flags this file passes.
    cmd.env_remove("AGENT_BLOCK_SANDBOX")
        .env_remove("AGENT_BLOCK_SANDBOX_FS_RW")
        .env_remove("AGENT_BLOCK_SANDBOX_TCP");
    cmd.env("AGENT_BLOCK_HOME", home.path()).args([
        "-s",
        script.to_str().expect("utf-8 script path"),
        "-p",
        project.to_str().expect("utf-8 project path"),
    ]);
    if sandbox {
        cmd.arg("--sandbox");
    }
    let output = cmd.output().expect("spawn agent-block");
    (output, home)
}

#[test]
fn sandbox_allows_project_writes_and_denies_writes_outside_the_allowlist() {
    let Some(scratch) = home_scratch(".agent-block-sandbox-test-") else {
        eprintln!("skipping: HOME is unset or not writable");
        return;
    };
    let Some(project) = home_scratch(".agent-block-sandbox-project-") else {
        eprintln!("skipping: HOME is unset or not writable");
        return;
    };
    let denied = scratch.path().join("probe.txt");
    let script = write_probe_script(project.path(), &denied);

    let (output, _home) = run_probe(project.path(), &script, true);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    if stderr.contains(KERNEL_SKIP_MARKER) {
        eprintln!("skipping: this kernel provides no usable Landlock support");
        return;
    }

    assert!(
        output.status.success(),
        "sandboxed run failed\nstdout: {stdout}\nstderr: {stderr}"
    );

    // (a) writes inside the project root still work.
    assert!(
        stdout.contains("INSIDE_WRITE_OK"),
        "expected the in-project write to succeed\nstdout: {stdout}\nstderr: {stderr}"
    );
    assert!(
        project.path().join("inside.txt").exists(),
        "in-project file was not created"
    );

    // (b) writes outside the allowlist are refused, and nothing lands on disk.
    assert!(
        stdout.contains("OUTSIDE_WRITE_DENIED"),
        "expected sh.exec to fail writing outside the allowlist\nstdout: {stdout}\nstderr: {stderr}"
    );
    assert!(
        !denied.exists(),
        "sandbox did not prevent the write to {}",
        denied.display()
    );
}

#[test]
fn without_sandbox_writes_outside_the_project_succeed() {
    // Control for the test above: the denial must come from the sandbox, not
    // from the probe script being wrong.
    let Some(scratch) = home_scratch(".agent-block-sandbox-test-") else {
        eprintln!("skipping: HOME is unset or not writable");
        return;
    };
    let Some(project) = home_scratch(".agent-block-sandbox-project-") else {
        eprintln!("skipping: HOME is unset or not writable");
        return;
    };
    let denied = scratch.path().join("probe.txt");
    let script = write_probe_script(project.path(), &denied);

    let (output, _home) = run_probe(project.path(), &script, false);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "unsandboxed run failed\nstdout: {stdout}\nstderr: {stderr}"
    );
    assert!(
        stdout.contains("OUTSIDE_WRITE_OK"),
        "expected the unsandboxed write to succeed\nstdout: {stdout}\nstderr: {stderr}"
    );
    assert!(
        denied.exists(),
        "unsandboxed write did not create {}",
        denied.display()
    );
    // `scratch` (a TempDir) removes the file when it drops.
}

#[test]
fn sandbox_with_unresolvable_project_root_fails_at_startup() {
    // The project root is the primary write grant; a typo'd `--project` must
    // abort before anything runs, not surface later as a distant EACCES.
    let home = tempfile::tempdir().expect("tempdir for AGENT_BLOCK_HOME");
    let mut cmd = common::agent_block_cmd();
    cmd.env_remove("AGENT_BLOCK_SANDBOX")
        .env_remove("AGENT_BLOCK_SANDBOX_FS_RW")
        .env_remove("AGENT_BLOCK_SANDBOX_TCP");
    cmd.env("AGENT_BLOCK_HOME", home.path()).args([
        "-s",
        "/nonexistent/agent-block-sandbox-test/script.lua",
        "-p",
        "/nonexistent/agent-block-sandbox-test",
        "--sandbox",
    ]);
    let output = cmd.output().expect("spawn agent-block");
    let stderr = String::from_utf8_lossy(&output.stderr);

    if stderr.contains(KERNEL_SKIP_MARKER) {
        eprintln!("skipping: this kernel provides no usable Landlock support");
        return;
    }
    assert!(
        !output.status.success(),
        "expected startup to fail\nstderr: {stderr}"
    );
    assert!(
        stderr.contains("project root"),
        "expected the project-root resolution error\nstderr: {stderr}"
    );
}