a3s-sandbox 0.1.0

Cross-platform native command sandbox for A3S
Documentation
use super::*;
use std::collections::HashMap;

const TEST_COMMAND_TIMEOUT_MS: u64 = 5_000;
#[cfg(windows)]
const WINDOWS_PROBE_MARKER: &str = "a3s-sandbox-probe";

fn create_test_sandbox(workspace: &std::path::Path) -> NativeSandbox {
    NativeSandbox::new(workspace).unwrap()
}

async fn execute_test_command(
    sandbox: &NativeSandbox,
    command: impl Into<String>,
) -> anyhow::Result<CommandOutput> {
    let command = command.into();
    sandbox
        .execute(CommandRequest {
            command,
            timeout_ms: TEST_COMMAND_TIMEOUT_MS,
            output_observer: None,
            env: None,
        })
        .await
}

#[tokio::test]
async fn native_backend_starts_and_writes_only_ordinary_workspace_content() {
    let workspace = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(workspace.path().join(".git")).unwrap();
    std::fs::create_dir_all(workspace.path().join(".a3s")).unwrap();
    std::fs::write(workspace.path().join(".git/config"), "original-git").unwrap();
    std::fs::write(workspace.path().join(".a3s/policy.acl"), "original-policy").unwrap();
    std::fs::write(workspace.path().join(".env"), "workspace-secret").unwrap();
    let sandbox = create_test_sandbox(workspace.path());

    sandbox.probe().await.unwrap();
    #[cfg(not(windows))]
    let ordinary_command = "printf changed > ordinary.txt";
    #[cfg(windows)]
    let ordinary_command = r#"
$ErrorActionPreference = 'Stop'
$secretDenied = $false
try {
    $null = [IO.File]::ReadAllText((Join-Path (Get-Location) '.env'))
} catch {
    $secretDenied = $true
}
if (-not $secretDenied) {
    throw 'workspace credential read unexpectedly succeeded'
}
if ([IO.File]::ReadAllText((Join-Path (Get-Location) '.git/config')) -ne 'original-git') {
    throw 'read-only control metadata is unavailable'
}
[IO.File]::WriteAllText((Join-Path (Get-Location) 'ordinary.txt'), 'changed')
"#;
    let ordinary = execute_test_command(&sandbox, ordinary_command)
        .await
        .unwrap();
    assert_eq!(ordinary.exit_code, 0, "{}", ordinary.stderr);
    assert_eq!(
        std::fs::read_to_string(workspace.path().join("ordinary.txt")).unwrap(),
        "changed"
    );
    assert_eq!(
        std::fs::read_to_string(workspace.path().join(".env")).unwrap(),
        "workspace-secret",
        "sandbox cleanup did not restore host credential access"
    );

    #[cfg(not(windows))]
    let protected_writes = [
        (
            "printf changed > .git/config",
            ".git/config",
            "original-git",
        ),
        (
            "printf changed > .a3s/policy.acl",
            ".a3s/policy.acl",
            "original-policy",
        ),
    ];
    #[cfg(windows)]
    let protected_writes = [
        (
            "[IO.File]::WriteAllText((Join-Path (Get-Location) '.git/config'), 'changed')",
            ".git/config",
            "original-git",
        ),
        (
            "[IO.File]::WriteAllText((Join-Path (Get-Location) '.a3s/policy.acl'), 'changed')",
            ".a3s/policy.acl",
            "original-policy",
        ),
    ];
    for (command, path, expected) in protected_writes {
        let output = execute_test_command(&sandbox, command).await.unwrap();
        assert_ne!(
            output.exit_code, 0,
            "write unexpectedly succeeded: {command}"
        );
        assert_eq!(
            std::fs::read_to_string(workspace.path().join(path)).unwrap(),
            expected
        );
    }

    #[cfg(not(windows))]
    let create_command = "mkdir .codex";
    #[cfg(windows)]
    let create_command = "New-Item -ItemType Directory -Path '.codex' -ErrorAction Stop";
    let create = execute_test_command(&sandbox, create_command)
        .await
        .unwrap();
    assert_ne!(
        create.exit_code, 0,
        "protected directory creation succeeded"
    );
    assert!(!workspace.path().join(".codex").exists());
}

#[cfg(unix)]
#[tokio::test]
async fn native_backend_blocks_symlink_hardlink_and_credential_escape() {
    use std::os::unix::fs::symlink;

    let workspace = tempfile::tempdir().unwrap();
    let outside = tempfile::tempdir().unwrap();
    std::fs::write(outside.path().join("secret"), "outside-secret").unwrap();
    std::fs::hard_link(
        outside.path().join("secret"),
        workspace.path().join("outside-hardlink"),
    )
    .unwrap();
    symlink(outside.path(), workspace.path().join("outside-link")).unwrap();
    std::fs::write(workspace.path().join(".env"), "workspace-secret").unwrap();
    let sandbox = create_test_sandbox(workspace.path());

    for command in [
        "cat .env",
        "cat outside-hardlink",
        "printf escaped > outside-hardlink",
        "printf escaped > outside-link/symlink-escape",
    ] {
        let output = execute_test_command(&sandbox, command).await.unwrap();
        assert_ne!(
            output.exit_code, 0,
            "escape unexpectedly succeeded: {command}"
        );
        assert!(!output.stdout.contains("secret"));
    }
    assert_eq!(
        std::fs::read_to_string(outside.path().join("secret")).unwrap(),
        "outside-secret"
    );
    assert!(!outside.path().join("symlink-escape").exists());

    std::fs::write(workspace.path().join("link-source"), "ordinary").unwrap();
    let output = execute_test_command(&sandbox, "ln link-source new-hardlink")
        .await
        .unwrap();
    assert_ne!(output.exit_code, 0, "runtime hard-link creation succeeded");
    assert!(!workspace.path().join("new-hardlink").exists());
}

#[cfg(windows)]
#[tokio::test]
async fn windows_backend_blocks_preexisting_hardlink_escape() {
    let workspace = tempfile::tempdir().unwrap();
    let outside = tempfile::tempdir().unwrap();
    let outside_secret = outside.path().join("secret");
    std::fs::write(&outside_secret, "outside-secret").unwrap();
    std::fs::hard_link(&outside_secret, workspace.path().join("outside-hardlink")).unwrap();
    let sandbox = create_test_sandbox(workspace.path());

    let output = execute_test_command(
        &sandbox,
        "[Console]::Out.Write('a3s-sandbox-probe'); $ErrorActionPreference = 'Stop'; [IO.File]::WriteAllText((Join-Path (Get-Location) 'outside-hardlink'), 'escaped')",
    )
    .await
    .unwrap();
    assert_eq!(output.stdout, WINDOWS_PROBE_MARKER, "{}", output.stderr);
    assert_ne!(output.exit_code, 0, "hard-link write escape succeeded");
    assert_eq!(
        std::fs::read_to_string(outside_secret).unwrap(),
        "outside-secret"
    );
}

#[cfg(windows)]
#[tokio::test]
async fn windows_backend_suppresses_powershell_startup_progress() {
    let workspace = tempfile::tempdir().unwrap();
    let sandbox = create_test_sandbox(workspace.path());

    let output = execute_test_command(&sandbox, "[Console]::Out.Write('a3s-sandbox-probe')")
        .await
        .unwrap();

    assert_eq!(output.exit_code, 0, "{}", output.stderr);
    assert_eq!(output.stdout, WINDOWS_PROBE_MARKER);
    assert_eq!(
        output.stderr, "",
        "PowerShell startup diagnostics leaked into command output"
    );
}

#[tokio::test]
async fn native_backend_blocks_ip_and_host_unix_socket_communication() {
    let workspace = tempfile::tempdir().unwrap();
    let sandbox = create_test_sandbox(workspace.path());

    #[cfg(windows)]
    {
        let ipv4_listener =
            std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap();
        let ipv6_listener =
            std::net::TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, 0)).unwrap();
        let unix_parent = tempfile::tempdir().unwrap();
        let unix_socket = unix_parent
            .path()
            .join("blocked.sock")
            .to_string_lossy()
            .replace('\'', "''");
        let probe = format!(
            r#"
[Console]::Out.Write('{WINDOWS_PROBE_MARKER}')
$ErrorActionPreference = 'Stop'

$client = [Net.Sockets.TcpClient]::new()
$ipv4Allowed = $false
try {{
    $task = $client.ConnectAsync([Net.IPAddress]::Loopback, {ipv4_port})
    if ($task.Wait(500)) {{
        $task.GetAwaiter().GetResult()
        $ipv4Allowed = $true
    }}
}} catch {{
}} finally {{
    $client.Dispose()
}}
if ($ipv4Allowed) {{ throw 'IPv4 loopback communication unexpectedly succeeded' }}

$client = [Net.Sockets.TcpClient]::new([Net.Sockets.AddressFamily]::InterNetworkV6)
$ipv6Allowed = $false
try {{
    $task = $client.ConnectAsync([Net.IPAddress]::IPv6Loopback, {ipv6_port})
    if ($task.Wait(500)) {{
        $task.GetAwaiter().GetResult()
        $ipv6Allowed = $true
    }}
}} catch {{
}} finally {{
    $client.Dispose()
}}
if ($ipv6Allowed) {{ throw 'IPv6 loopback communication unexpectedly succeeded' }}

$socket = [Net.Sockets.Socket]::new(
    [Net.Sockets.AddressFamily]::Unix,
    [Net.Sockets.SocketType]::Stream,
    [Net.Sockets.ProtocolType]::Unspecified
)
$unixAllowed = $false
try {{
    $socket.Bind([Net.Sockets.UnixDomainSocketEndPoint]::new('{unix_socket}'))
    $unixAllowed = $true
}} catch {{
}} finally {{
    $socket.Dispose()
}}
if ($unixAllowed) {{ throw 'host Unix socket creation unexpectedly succeeded' }}
"#,
            ipv4_port = ipv4_listener.local_addr().unwrap().port(),
            ipv6_port = ipv6_listener.local_addr().unwrap().port(),
        );
        let output = execute_test_command(&sandbox, probe).await.unwrap();
        assert_eq!(output.stdout, WINDOWS_PROBE_MARKER, "{}", output.stderr);
        assert_eq!(output.exit_code, 0, "{}", output.stderr);
        assert!(!unix_parent.path().join("blocked.sock").exists());
    }

    #[cfg(not(windows))]
    let probes = [
        "python3 -c 'import socket; s=socket.socket(); s.bind((\"127.0.0.1\", 0))'",
        "python3 -c 'import socket; s=socket.socket(socket.AF_UNIX); s.bind(\"blocked.sock\")'",
    ];

    #[cfg(not(windows))]
    for probe in probes {
        let output = execute_test_command(&sandbox, probe).await.unwrap();
        assert_ne!(
            output.exit_code, 0,
            "network or host IPC probe unexpectedly succeeded: {}{}",
            output.stdout, output.stderr
        );
    }
}

#[cfg(target_os = "linux")]
#[tokio::test]
async fn linux_backend_drops_all_process_capabilities_before_bash() {
    let workspace = tempfile::tempdir().unwrap();
    let sandbox = create_test_sandbox(workspace.path());
    let output = execute_test_command(
        &sandbox,
        "grep '^Cap\\(Inh\\|Prm\\|Eff\\|Bnd\\|Amb\\):' /proc/self/status",
    )
    .await
    .unwrap();
    assert_eq!(output.exit_code, 0, "{}", output.stderr);
    let capabilities = output.stdout.lines().collect::<Vec<_>>();
    assert_eq!(capabilities.len(), 5, "{}", output.stdout);
    assert!(
        capabilities
            .iter()
            .all(|line| line.ends_with("0000000000000000")),
        "sandboxed Bash retained Linux capabilities: {}",
        output.stdout
    );
}

#[tokio::test]
async fn native_backend_sanitizes_environment_and_kills_timed_out_descendants() {
    let workspace = tempfile::tempdir().unwrap();
    let sandbox = create_test_sandbox(workspace.path());
    let request = CommandRequest {
        #[cfg(not(windows))]
        command: "printf '%s|%s|%s' \"${SAFE_VALUE:-}\" \"${BASH_ENV:-}\" \"$HOME\"".to_string(),
        #[cfg(windows)]
        command: "[Console]::Out.Write(\"$env:SAFE_VALUE|$env:BASH_ENV|$env:HOME\")".to_string(),
        timeout_ms: 5_000,
        output_observer: None,
        env: Some(std::sync::Arc::new(HashMap::from([
            ("SAFE_VALUE".to_string(), "visible".to_string()),
            ("BASH_ENV".to_string(), "attack".to_string()),
        ]))),
    };
    let output = sandbox.execute(request).await.unwrap();
    assert_eq!(output.exit_code, 0, "{}", output.stderr);
    assert!(output.stdout.starts_with("visible||"), "{}", output.stdout);
    assert!(!output
        .stdout
        .contains(workspace.path().to_string_lossy().as_ref()));

    #[cfg(not(windows))]
    let command = "(sleep 0.30; touch timeout-leak) & wait";
    #[cfg(windows)]
    let command = "Start-Job { Start-Sleep -Milliseconds 300; New-Item timeout-leak } | Wait-Job";
    let output = sandbox
        .execute(CommandRequest {
            command: command.to_string(),
            timeout_ms: 50,
            output_observer: None,
            env: None,
        })
        .await
        .unwrap();
    assert!(output.timed_out);
    tokio::time::sleep(std::time::Duration::from_millis(450)).await;
    assert!(!workspace.path().join("timeout-leak").exists());
}