diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f958863..7774ae4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,6 +57,283 @@ jobs: cd playground timeout 60 hemlock test/test_examples.hml || echo "Tests completed" + secure-mode: + runs-on: ubuntu-latest + name: Secure Mode (Bubblewrap) + + steps: + - name: Checkout playground + uses: actions/checkout@v4 + with: + path: playground + + - name: Checkout hemlock + uses: actions/checkout@v4 + with: + repository: hemlang/hemlock + path: hemlock + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential libssl-dev libffi-dev zlib1g-dev bubblewrap + + - name: Verify bubblewrap installation + run: | + bwrap --version + echo "Bubblewrap installed successfully" + + - name: Enable unprivileged user namespaces + run: | + # Enable unprivileged user namespaces (required for bwrap without root) + echo "Enabling unprivileged user namespaces..." + + # Check current setting + if [ -f /proc/sys/kernel/unprivileged_userns_clone ]; then + echo "Current setting: $(cat /proc/sys/kernel/unprivileged_userns_clone)" + sudo sysctl -w kernel.unprivileged_userns_clone=1 + fi + + # Also check AppArmor restrictions on Ubuntu + if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + echo "AppArmor restriction: $(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + echo "User namespace configuration complete" + + - name: Check kernel namespace support + run: | + echo "Checking unprivileged user namespaces..." + if [ -f /proc/sys/kernel/unprivileged_userns_clone ]; then + echo "unprivileged_userns_clone: $(cat /proc/sys/kernel/unprivileged_userns_clone)" + else + echo "sysctl not present, namespaces likely enabled by default" + fi + + echo "Testing user namespace with bwrap..." + bwrap --unshare-user --uid 65534 --gid 65534 \ + --ro-bind /usr /usr \ + --ro-bind /lib /lib \ + --ro-bind-try /lib64 /lib64 \ + --ro-bind /bin /bin \ + /bin/true && echo "User namespaces work!" + + - name: Build hemlock + run: | + cd hemlock + make + echo "$PWD" >> $GITHUB_PATH + # Store hemlock directory for bwrap binds + echo "HEMLOCK_DIR=$PWD" >> $GITHUB_ENV + + - name: Verify hemlock installation + run: | + hemlock --version + echo "Hemlock binary at: $(which hemlock)" + echo "HEMLOCK_DIR: $HEMLOCK_DIR" + + - name: Create test temp directory + run: | + sudo mkdir -p /var/tmp/hemlock-test + sudo chmod 777 /var/tmp/hemlock-test + + - name: Test basic bwrap execution + run: | + cd playground + + # Write a simple test file + echo 'print("hello from sandbox");' > /var/tmp/hemlock-test/test.hml + + # Run with bubblewrap - bind hemlock directory + bwrap \ + --unshare-user \ + --unshare-pid \ + --unshare-net \ + --unshare-ipc \ + --unshare-uts \ + --uid 65534 \ + --gid 65534 \ + --die-with-parent \ + --new-session \ + --ro-bind /usr /usr \ + --ro-bind /lib /lib \ + --ro-bind-try /lib64 /lib64 \ + --ro-bind /bin /bin \ + --ro-bind-try /sbin /sbin \ + --ro-bind-try /usr/local /usr/local \ + --ro-bind "$HEMLOCK_DIR" "$HEMLOCK_DIR" \ + --dir /var \ + --dir /var/tmp \ + --ro-bind /var/tmp/hemlock-test /var/tmp/hemlock-test \ + --tmpfs /tmp \ + --setenv TMPDIR /tmp \ + --dev /dev \ + --proc /proc \ + --hostname sandbox \ + --clearenv \ + --setenv PATH "$HEMLOCK_DIR:/usr/local/bin:/usr/bin:/bin" \ + --setenv HOME /tmp \ + -- hemlock --sandbox /var/tmp/hemlock-test/test.hml + + echo "Basic bwrap execution successful!" + + - name: Test bwrap with arithmetic + run: | + echo 'print(2 + 2);' > /var/tmp/hemlock-test/arith.hml + + OUTPUT=$(bwrap \ + --unshare-user \ + --unshare-pid \ + --unshare-net \ + --uid 65534 \ + --gid 65534 \ + --ro-bind /usr /usr \ + --ro-bind /lib /lib \ + --ro-bind-try /lib64 /lib64 \ + --ro-bind /bin /bin \ + --ro-bind-try /usr/local /usr/local \ + --ro-bind "$HEMLOCK_DIR" "$HEMLOCK_DIR" \ + --dir /var/tmp \ + --ro-bind /var/tmp/hemlock-test /var/tmp/hemlock-test \ + --tmpfs /tmp \ + --dev /dev \ + --proc /proc \ + --clearenv \ + --setenv PATH "$HEMLOCK_DIR:/usr/local/bin:/usr/bin:/bin" \ + -- hemlock --sandbox /var/tmp/hemlock-test/arith.hml) + + echo "Output: $OUTPUT" + if echo "$OUTPUT" | grep -q "4"; then + echo "Arithmetic test passed!" + else + echo "Arithmetic test failed!" + exit 1 + fi + + - name: Test bwrap with JSON stdlib + run: | + cat > /var/tmp/hemlock-test/json.hml << 'EOF' + import { stringify } from "@stdlib/json"; + let obj = { name: "test", value: 42 }; + print(stringify(obj)); + EOF + + OUTPUT=$(bwrap \ + --unshare-user \ + --unshare-pid \ + --unshare-net \ + --uid 65534 \ + --gid 65534 \ + --ro-bind /usr /usr \ + --ro-bind /lib /lib \ + --ro-bind-try /lib64 /lib64 \ + --ro-bind /bin /bin \ + --ro-bind-try /usr/local /usr/local \ + --ro-bind "$HEMLOCK_DIR" "$HEMLOCK_DIR" \ + --dir /var/tmp \ + --ro-bind /var/tmp/hemlock-test /var/tmp/hemlock-test \ + --tmpfs /tmp \ + --dev /dev \ + --proc /proc \ + --clearenv \ + --setenv PATH "$HEMLOCK_DIR:/usr/local/bin:/usr/bin:/bin" \ + -- hemlock --sandbox /var/tmp/hemlock-test/json.hml) + + echo "Output: $OUTPUT" + if echo "$OUTPUT" | grep -q "name" && echo "$OUTPUT" | grep -q "42"; then + echo "JSON stdlib test passed!" + else + echo "JSON stdlib test failed!" + exit 1 + fi + + - name: Test network isolation + run: | + # Network should be isolated in the sandbox + # The sandbox uses --unshare-net, so this should work + echo 'print("network isolated");' > /var/tmp/hemlock-test/net.hml + + bwrap \ + --unshare-user \ + --unshare-pid \ + --unshare-net \ + --uid 65534 \ + --gid 65534 \ + --ro-bind /usr /usr \ + --ro-bind /lib /lib \ + --ro-bind-try /lib64 /lib64 \ + --ro-bind /bin /bin \ + --ro-bind-try /usr/local /usr/local \ + --ro-bind "$HEMLOCK_DIR" "$HEMLOCK_DIR" \ + --dir /var/tmp \ + --ro-bind /var/tmp/hemlock-test /var/tmp/hemlock-test \ + --tmpfs /tmp \ + --dev /dev \ + --proc /proc \ + --clearenv \ + --setenv PATH "$HEMLOCK_DIR:/usr/local/bin:/usr/bin:/bin" \ + -- hemlock --sandbox /var/tmp/hemlock-test/net.hml + + echo "Network isolation test passed!" + + - name: Test filesystem isolation (cannot write outside sandbox) + run: | + cat > /var/tmp/hemlock-test/fs_write.hml << 'EOF' + import { write_file } from "@stdlib/fs"; + try { + write_file("/etc/test.txt", "should fail"); + print("FAIL: wrote to /etc"); + } catch (e) { + print("OK: blocked"); + } + EOF + + OUTPUT=$(bwrap \ + --unshare-user \ + --unshare-pid \ + --unshare-net \ + --uid 65534 \ + --gid 65534 \ + --ro-bind /usr /usr \ + --ro-bind /lib /lib \ + --ro-bind-try /lib64 /lib64 \ + --ro-bind /bin /bin \ + --ro-bind-try /usr/local /usr/local \ + --ro-bind "$HEMLOCK_DIR" "$HEMLOCK_DIR" \ + --dir /var/tmp \ + --ro-bind /var/tmp/hemlock-test /var/tmp/hemlock-test \ + --tmpfs /tmp \ + --dev /dev \ + --proc /proc \ + --clearenv \ + --setenv PATH "$HEMLOCK_DIR:/usr/local/bin:/usr/bin:/bin" \ + -- hemlock --sandbox /var/tmp/hemlock-test/fs_write.hml 2>&1 || true) + + echo "Output: $OUTPUT" + if echo "$OUTPUT" | grep -q "FAIL"; then + echo "Filesystem isolation FAILED - code could write to /etc!" + exit 1 + else + echo "Filesystem isolation test passed!" + fi + + - name: Run secure mode test suite + run: | + cd playground + timeout 120 hemlock test/test_secure_mode.hml || echo "Secure mode tests completed" + + - name: Test Grove startup message (secure mode detection) + run: | + cd playground + + # Start Grove briefly and check it detects bwrap + timeout 2 hemlock grove.hml 2>&1 || true | head -20 + + # Also check with secure mode enabled + echo "Testing with GROVE_SECURE_MODE=1..." + timeout 2 env GROVE_SECURE_MODE=1 hemlock grove.hml 2>&1 || true | head -20 + lint: runs-on: ubuntu-latest @@ -72,6 +349,7 @@ jobs: test -f package.json || (echo "Missing package.json" && exit 1) test -f CLAUDE.md || (echo "Missing CLAUDE.md" && exit 1) test -d examples || (echo "Missing examples directory" && exit 1) + test -d test || (echo "Missing test directory" && exit 1) echo "All required files present!" - name: Validate package.json @@ -87,3 +365,17 @@ jobs: test -f "examples/${f}.hml" || (echo "Missing examples/${f}.hml" && exit 1) done echo "All example files present!" + + - name: Check test files exist + run: | + echo "Checking test files..." + test -f "test/test_examples.hml" || (echo "Missing test/test_examples.hml" && exit 1) + test -f "test/test_secure_mode.hml" || (echo "Missing test/test_secure_mode.hml" && exit 1) + echo "All test files present!" + + - name: Check setup script exists + run: | + echo "Checking setup script..." + test -f "setup-sandbox.sh" || (echo "Missing setup-sandbox.sh" && exit 1) + test -x "setup-sandbox.sh" || echo "Warning: setup-sandbox.sh is not executable" + echo "Setup script present!" diff --git a/test/test_secure_mode.hml b/test/test_secure_mode.hml new file mode 100644 index 0000000..e051623 --- /dev/null +++ b/test/test_secure_mode.hml @@ -0,0 +1,498 @@ +// Secure Mode Tests for Grove +// ============================ +// Tests for bubblewrap sandbox functionality +// +// Run with: GROVE_SECURE_MODE=1 hemlock test/test_secure_mode.hml +// Note: Requires bubblewrap (bwrap) to be installed and user namespaces enabled +// +// If user namespaces are not available, bwrap-specific tests will be skipped +// and only configuration validation tests will run. + +import { describe, test, expect, run } from "@stdlib/testing"; +import { exists, read_file, write_file, remove_file } from "@stdlib/fs"; +import { getenv } from "@stdlib/env"; +import { run_capture } from "@stdlib/shell"; +import { time_ms } from "@stdlib/time"; + +// ============================================================================= +// Test Configuration +// ============================================================================= + +let BASE_DIR = getenv("PWD"); +let HEMLOCK_PATH = getenv("HEMLOCK_PATH"); +let BWRAP_PATH = getenv("GROVE_BWRAP_PATH"); + +if (HEMLOCK_PATH == null) { + HEMLOCK_PATH = "hemlock"; +} +if (BWRAP_PATH == null) { + BWRAP_PATH = "bwrap"; +} + +// Get absolute path of hemlock and its directory for bwrap binds +fn get_hemlock_dir() { + let result = run_capture(["which", HEMLOCK_PATH]); + if (result["code"] == 0) { + let abs_path = result["stdout"].trim(); + // Get directory from path (remove /hemlock from end) + let parts = abs_path.split("/"); + parts.pop(); // Remove filename + return parts.join("/"); + } + return null; +} + +let HEMLOCK_DIR = get_hemlock_dir(); + +let TEMP_DIR = "/var/tmp/hemlock-test"; + +// ============================================================================= +// Check if bwrap with user namespaces is available +// ============================================================================= + +fn check_bwrap_available() { + // First check if bwrap is installed + let version_check = run_capture([BWRAP_PATH, "--version"]); + if (version_check["code"] != 0) { + print("NOTICE: bubblewrap (bwrap) is not installed - skipping sandbox execution tests"); + return false; + } + + // Try a simple bwrap command with user namespace + let test_cmd = `${BWRAP_PATH} --unshare-user --uid 65534 --gid 65534 --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind-try /lib64 /lib64 --ro-bind /bin /bin /bin/true 2>&1`; + let result = run_capture(["sh", "-c", test_cmd]); + + if (result["code"] != 0) { + print("NOTICE: User namespaces not available - skipping sandbox execution tests"); + print(` Reason: ${result["stdout"]}${result["stderr"]}`); + print(" To enable: sudo sysctl -w kernel.unprivileged_userns_clone=1"); + print(" Or on Ubuntu 24.04+: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0"); + return false; + } + + return true; +} + +let BWRAP_AVAILABLE = check_bwrap_available(); + +// ============================================================================= +// Helpers +// ============================================================================= + +fn ensure_temp_dir() { + if (!exists(TEMP_DIR)) { + run_capture(["mkdir", "-p", TEMP_DIR]); + } +} + +fn random_suffix() { + let ts = time_ms(); + return `${ts}`; +} + +fn write_temp_file(name, content) { + ensure_temp_dir(); + let path = `${TEMP_DIR}/${name}_${random_suffix()}.hml`; + write_file(path, content); + return path; +} + +fn cleanup_file(path) { + if (exists(path)) { + remove_file(path); + } +} + +// Build a bubblewrap command similar to Grove's implementation +fn build_bwrap_command(temp_file) { + let inner_cmd = `"${HEMLOCK_PATH}" --sandbox "${temp_file}"`; + + // Build path with hemlock directory if available + let sandbox_path = "/usr/local/bin:/usr/bin:/bin"; + let hemlock_bind = ""; + if (HEMLOCK_DIR != null) { + sandbox_path = `${HEMLOCK_DIR}:${sandbox_path}`; + hemlock_bind = `--ro-bind "${HEMLOCK_DIR}" "${HEMLOCK_DIR}" `; + } + + let bwrap_cmd = `"${BWRAP_PATH}" ` + + `--unshare-user ` + + `--unshare-pid ` + + `--unshare-net ` + + `--unshare-ipc ` + + `--unshare-uts ` + + `--unshare-cgroup ` + + `--uid 65534 ` + + `--gid 65534 ` + + `--die-with-parent ` + + `--new-session ` + + `--ro-bind /usr /usr ` + + `--ro-bind /lib /lib ` + + `--ro-bind-try /lib64 /lib64 ` + + `--ro-bind /bin /bin ` + + `--ro-bind-try /sbin /sbin ` + + `--ro-bind-try /usr/local /usr/local ` + + hemlock_bind + + `--dir /var ` + + `--dir /var/tmp ` + + `--ro-bind "${TEMP_DIR}" "${TEMP_DIR}" ` + + `--tmpfs /tmp ` + + `--setenv TMPDIR /tmp ` + + `--dev /dev ` + + `--proc /proc ` + + `--hostname sandbox ` + + `--clearenv ` + + `--setenv PATH ${sandbox_path} ` + + `--setenv HOME /tmp ` + + `-- sh -c "${inner_cmd}"`; + + return bwrap_cmd; +} + +// Run code in bubblewrap sandbox +fn run_in_sandbox(code) { + let temp_file = write_temp_file("sandbox_test", code); + let bwrap_cmd = build_bwrap_command(temp_file); + + // Use sh -c to execute the bwrap command + let result = run_capture(["sh", "-c", `timeout 30 ${bwrap_cmd} 2>&1`]); + + cleanup_file(temp_file); + return result; +} + +// Run code with hemlock --sandbox only (no bwrap) +fn run_basic_sandbox(code) { + let temp_file = write_temp_file("basic_test", code); + let result = run_capture([HEMLOCK_PATH, "--sandbox", temp_file]); + cleanup_file(temp_file); + return result; +} + +// ============================================================================= +// Bubblewrap Installation Check +// ============================================================================= + +describe("Bubblewrap Installation", fn() { + test("bwrap is installed", fn() { + let result = run_capture([BWRAP_PATH, "--version"]); + // Only expect success if bwrap should be available + if (BWRAP_AVAILABLE) { + expect(result["code"]).to_equal(0); + } else { + // Skip - just verify we can check + expect(true).to_be_true(); + } + }); + + test("bwrap version is available", fn() { + if (!BWRAP_AVAILABLE) { + // Skip this test + expect(true).to_be_true(); + return; + } + let result = run_capture([BWRAP_PATH, "--version"]); + expect(result["stdout"].contains("bubblewrap")).to_be_true(); + }); + + test("user namespaces are enabled", fn() { + if (!BWRAP_AVAILABLE) { + // Skip this test - user namespaces not available + expect(true).to_be_true(); + return; + } + // Try a simple bwrap command with user namespace + let result = run_capture(["sh", "-c", `${BWRAP_PATH} --unshare-user --uid 65534 --gid 65534 --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind-try /lib64 /lib64 --ro-bind /bin /bin /bin/true 2>&1`]); + expect(result["code"]).to_equal(0); + }); +}); + +// ============================================================================= +// Basic Sandbox Execution Tests +// ============================================================================= + +describe("Sandbox Basic Execution", fn() { + // Skip all tests in this section if bwrap is not available + if (!BWRAP_AVAILABLE) { + test("SKIPPED - bwrap not available", fn() { + print(" Skipping sandbox execution tests - user namespaces not available"); + expect(true).to_be_true(); + }); + return; + } + test("simple print works in sandbox", fn() { + let result = run_in_sandbox("print(\"hello sandbox\");"); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("hello sandbox")).to_be_true(); + }); + + test("arithmetic works in sandbox", fn() { + let result = run_in_sandbox("print(2 + 2);"); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("4")).to_be_true(); + }); + + test("functions work in sandbox", fn() { + let code = "fn double(x) { return x * 2; } print(double(21));"; + let result = run_in_sandbox(code); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("42")).to_be_true(); + }); + + test("loops work in sandbox", fn() { + let code = "for (let i = 0; i < 3; i = i + 1) { print(i); }"; + let result = run_in_sandbox(code); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("0")).to_be_true(); + expect(result["stdout"].contains("1")).to_be_true(); + expect(result["stdout"].contains("2")).to_be_true(); + }); + + test("JSON stdlib works in sandbox", fn() { + let code = "import { stringify } from \"@stdlib/json\"; print(stringify({a: 1}));"; + let result = run_in_sandbox(code); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("a")).to_be_true(); + }); + + test("template strings work in sandbox", fn() { + let code = "let x = 42; print(`Value: ${x}`);"; + let result = run_in_sandbox(code); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("Value: 42")).to_be_true(); + }); + + test("arrays work in sandbox", fn() { + let code = "let arr = [1, 2, 3]; arr.push(4); print(arr.length);"; + let result = run_in_sandbox(code); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("4")).to_be_true(); + }); + + test("objects work in sandbox", fn() { + let code = "let obj = { name: \"test\", value: 42 }; print(obj[\"value\"]);"; + let result = run_in_sandbox(code); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("42")).to_be_true(); + }); +}); + +// ============================================================================= +// Filesystem Isolation Tests +// ============================================================================= + +describe("Sandbox Filesystem Isolation", fn() { + if (!BWRAP_AVAILABLE) { + test("SKIPPED - bwrap not available", fn() { + expect(true).to_be_true(); + }); + return; + } + + test("cannot write to /etc", fn() { + // This should fail - /etc is not writable in sandbox + let code = "import { write_file } from \"@stdlib/fs\"; try { write_file(\"/etc/test.txt\", \"hack\"); print(\"FAIL: wrote to /etc\"); } catch (e) { print(\"OK: blocked\"); }"; + let result = run_in_sandbox(code); + // Either hemlock sandbox blocks it, or bwrap filesystem isolation does + expect(result["stdout"].contains("FAIL")).to_be_false(); + }); + + test("cannot write to /home", fn() { + let code = "import { write_file } from \"@stdlib/fs\"; try { write_file(\"/home/test.txt\", \"hack\"); print(\"FAIL: wrote to /home\"); } catch (e) { print(\"OK: blocked\"); }"; + let result = run_in_sandbox(code); + expect(result["stdout"].contains("FAIL")).to_be_false(); + }); + + test("cannot write to root", fn() { + let code = "import { write_file } from \"@stdlib/fs\"; try { write_file(\"/test.txt\", \"hack\"); print(\"FAIL: wrote to root\"); } catch (e) { print(\"OK: blocked\"); }"; + let result = run_in_sandbox(code); + expect(result["stdout"].contains("FAIL")).to_be_false(); + }); + + test("cannot read /etc/passwd in sandbox mode", fn() { + // hemlock --sandbox should restrict filesystem access + let code = "import { read_file } from \"@stdlib/fs\"; try { let content = read_file(\"/etc/passwd\"); print(\"read \" + content.length + \" bytes\"); } catch (e) { print(\"blocked\"); }"; + let result = run_in_sandbox(code); + // Sandbox should block this + expect(result["stdout"].contains("blocked")).to_be_true(); + }); + + test("can write to /tmp inside sandbox", fn() { + // /tmp is a tmpfs inside the sandbox, should be writable + let code = "import { write_file, read_file, exists } from \"@stdlib/fs\"; write_file(\"/tmp/test.txt\", \"hello\"); if (exists(\"/tmp/test.txt\")) { print(\"OK: wrote to /tmp\"); } else { print(\"FAIL\"); }"; + let result = run_in_sandbox(code); + // Note: this depends on hemlock --sandbox allowing /tmp writes + // The actual result may vary based on hemlock's sandbox implementation + expect(result["code"]).to_equal(0); + }); +}); + +// ============================================================================= +// Network Isolation Tests +// ============================================================================= + +describe("Sandbox Network Isolation", fn() { + if (!BWRAP_AVAILABLE) { + test("SKIPPED - bwrap not available", fn() { + expect(true).to_be_true(); + }); + return; + } + + test("network is isolated with --unshare-net", fn() { + // The sandbox uses --unshare-net, so network should be unavailable + // This test verifies the sandbox runs even with network isolation + let result = run_in_sandbox("print(\"network isolated\");"); + expect(result["code"]).to_equal(0); + expect(result["stdout"].contains("network isolated")).to_be_true(); + }); +}); + +// ============================================================================= +// Process Isolation Tests +// ============================================================================= + +describe("Sandbox Process Isolation", fn() { + if (!BWRAP_AVAILABLE) { + test("SKIPPED - bwrap not available", fn() { + expect(true).to_be_true(); + }); + return; + } + + test("process runs as nobody (uid 65534)", fn() { + // The sandbox runs as nobody + let result = run_in_sandbox("print(\"running\");"); + expect(result["code"]).to_equal(0); + }); + + test("hostname is sandbox", fn() { + // The sandbox sets hostname to "sandbox" + // We can't directly check this from hemlock, but the command succeeds + let result = run_in_sandbox("print(\"hostname test\");"); + expect(result["code"]).to_equal(0); + }); + + test("environment is cleared", fn() { + // Environment should be minimal + let code = "import { getenv } from \"@stdlib/env\"; let home = getenv(\"HOME\"); if (home == \"/tmp\" || home == null) { print(\"OK: clean env\"); } else { print(`HOME=${home}`); }"; + let result = run_in_sandbox(code); + expect(result["code"]).to_equal(0); + }); +}); + +// ============================================================================= +// Error Handling in Sandbox +// ============================================================================= + +describe("Sandbox Error Handling", fn() { + if (!BWRAP_AVAILABLE) { + test("SKIPPED - bwrap not available", fn() { + expect(true).to_be_true(); + }); + return; + } + + test("syntax errors are caught", fn() { + let result = run_in_sandbox("print(42"); // Missing semicolon and paren + expect(result["code"] != 0).to_be_true(); + }); + + test("runtime errors are caught", fn() { + let result = run_in_sandbox("print(undefined_var);"); + expect(result["code"] != 0).to_be_true(); + }); + + test("division by zero handled", fn() { + let result = run_in_sandbox("print(1 / 0);"); + // Should complete without crashing + expect(result != null).to_be_true(); + }); +}); + +// ============================================================================= +// Grove Integration Tests +// ============================================================================= + +describe("Grove Secure Mode Configuration", fn() { + test("grove.hml exists", fn() { + expect(exists(`${BASE_DIR}/grove.hml`)).to_be_true(); + }); + + test("grove.hml has secure_mode config", fn() { + let content = read_file(`${BASE_DIR}/grove.hml`); + expect(content.contains("secure_mode")).to_be_true(); + }); + + test("grove.hml has bwrap_path config", fn() { + let content = read_file(`${BASE_DIR}/grove.hml`); + expect(content.contains("bwrap_path")).to_be_true(); + }); + + test("grove.hml has GROVE_SECURE_MODE env check", fn() { + let content = read_file(`${BASE_DIR}/grove.hml`); + expect(content.contains("GROVE_SECURE_MODE")).to_be_true(); + }); + + test("grove.hml has build_bwrap_command function", fn() { + let content = read_file(`${BASE_DIR}/grove.hml`); + expect(content.contains("build_bwrap_command")).to_be_true(); + }); + + test("grove.hml has namespace isolation flags", fn() { + let content = read_file(`${BASE_DIR}/grove.hml`); + expect(content.contains("--unshare-user")).to_be_true(); + expect(content.contains("--unshare-net")).to_be_true(); + expect(content.contains("--unshare-pid")).to_be_true(); + }); + + test("grove.hml has memory limit config", fn() { + let content = read_file(`${BASE_DIR}/grove.hml`); + expect(content.contains("sandbox_memory_mb")).to_be_true(); + }); + + test("grove.hml has process limit config", fn() { + let content = read_file(`${BASE_DIR}/grove.hml`); + expect(content.contains("sandbox_pids_max")).to_be_true(); + }); +}); + +// ============================================================================= +// Comparison: Basic vs Secure Sandbox +// ============================================================================= + +describe("Basic vs Secure Sandbox Comparison", fn() { + if (!BWRAP_AVAILABLE) { + test("SKIPPED - bwrap not available", fn() { + expect(true).to_be_true(); + }); + return; + } + + test("basic sandbox produces same output as secure", fn() { + let code = "print(\"hello\");"; + let basic_result = run_basic_sandbox(code); + let secure_result = run_in_sandbox(code); + + expect(basic_result["code"]).to_equal(0); + expect(secure_result["code"]).to_equal(0); + expect(basic_result["stdout"].contains("hello")).to_be_true(); + expect(secure_result["stdout"].contains("hello")).to_be_true(); + }); + + test("both sandboxes handle errors", fn() { + let code = "print(undefined);"; + let basic_result = run_basic_sandbox(code); + let secure_result = run_in_sandbox(code); + + // Both should error on undefined variable + expect(basic_result["code"] != 0).to_be_true(); + expect(secure_result["code"] != 0).to_be_true(); + }); +}); + +// ============================================================================= +// Run Tests +// ============================================================================= + +run();