From 03cfdf31a84e7c0363a28c12ba0400ae6414a871 Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Tue, 24 Feb 2026 20:33:51 -0800 Subject: [PATCH] Landlock --- Cargo.lock | 32 ++ Cargo.toml | 1 + PLAN.md | 153 +++++---- src/app/mod.rs | 32 +- src/core/orchestrator.rs | 85 ++++- src/core/types.rs | 4 + src/main.rs | 47 ++- src/sandbox/landlock.rs | 158 +++++++++ src/sandbox/mod.rs | 649 ++++++++++++++++++++++++++++++++++++ src/sandbox/policy.rs | 101 ++++++ src/tools/list_directory.rs | 30 +- src/tools/mod.rs | 82 ++--- src/tools/read_file.rs | 26 +- src/tools/shell_exec.rs | 36 +- src/tools/write_file.rs | 27 +- src/tui/events.rs | 3 + src/tui/input.rs | 18 + src/tui/mod.rs | 11 + src/tui/render.rs | 58 +++- 19 files changed, 1315 insertions(+), 238 deletions(-) create mode 100644 src/sandbox/landlock.rs create mode 100644 src/sandbox/mod.rs create mode 100644 src/sandbox/policy.rs diff --git a/Cargo.lock b/Cargo.lock index 7abfbd9..65bb80c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,6 +423,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1055,6 +1075,17 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" +[[package]] +name = "landlock" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2044,6 +2075,7 @@ dependencies = [ "async-trait", "crossterm", "futures", + "landlock", "ratatui", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index ff66a81..97f34f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } reqwest = { version = "0.13", features = ["stream", "json"] } futures = "0.3" async-trait = "0.1" +landlock = "0.4" [dev-dependencies] tempfile = "3.26.0" diff --git a/PLAN.md b/PLAN.md index 703e7a0..7ecb254 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,81 +1,96 @@ # Implementation Plan -## Phase 3: Tool Execution +## Phase 4: Sandboxing -### Step 3.1: Enrich the content model -- Replace `ConversationMessage { role, content: String }` with content-block model -- Define `ContentBlock` enum: `Text(String)`, `ToolUse { id, name, input: Value }`, `ToolResult { tool_use_id, content: String, is_error: bool }` -- Change `ConversationMessage.content` from `String` to `Vec` -- Add `ConversationMessage::text(role, s)` helper to keep existing call sites clean -- Update serialization, orchestrator, tests, TUI display -- **Files:** `src/core/types.rs`, `src/core/history.rs` -- **Done when:** `cargo test` passes with new model; all existing tests updated +### Step 4.1: Create sandbox module with policy types and tracing foundation +- `SandboxPolicy` struct: read-only paths, read-write paths, network allowed bool +- `Sandbox` struct holding policy + working dir +- Add `tracing` spans and events throughout from the start: + - `#[instrument]` on all public `Sandbox` methods + - `debug!` on policy construction with path lists + - `info!` on sandbox creation with full policy summary +- No enforcement yet, just the type skeleton and module wiring +- **Files:** new `src/sandbox/mod.rs`, `src/sandbox/policy.rs` +- **Done when:** compiles, unit tests for policy construction, `RUST_LOG=debug cargo test` shows sandbox trace output -### Step 3.2: Send tool definitions in API requests -- Add `ToolDefinition { name, description, input_schema: Value }` (provider-agnostic) -- Extend `ModelProvider::stream` to accept `&[ToolDefinition]` -- Include `"tools"` array in Claude provider request body -- **Files:** `src/provider/mod.rs`, `src/provider/claude.rs` -- **Done when:** API responses contain `tool_use` content blocks in raw SSE stream +### Step 4.2: Landlock policy builder with startup gate and tracing +- Translate `SandboxPolicy` into Landlock ruleset using `landlock` crate +- Kernel requirements: + - **ABI v4 (kernel 6.7+):** minimum required -- provides both filesystem and network sandboxing + - ABI 1-3 have filesystem only, no network restriction -- tools could exfiltrate data freely +- Startup behavior -- on launch, check Landlock ABI version: + - ABI >= 4: proceed normally (full filesystem + network sandboxing) + - ABI < 4 (including unsupported): **refuse to start** with clear error: "Landlock ABI v4+ required (kernel 6.7+). Use --yolo to run without sandboxing." + - `--yolo` flag: skip all Landlock enforcement, log `warn!` at startup, show "UNSANDBOXED" in status bar permanently +- Landlock applied per-child-process via `pre_exec`, NOT to the main process + - Main process needs unrestricted network (Claude API) and filesystem (provider) + - Each `exec_command` child gets the current policy at spawn time + - `:net on/off` takes effect on the next spawned command +- Tracing: + - `info!` on kernel ABI version detected + - `debug!` for each rule added to ruleset (path, access flags) + - `warn!` on `--yolo` mode ("running without kernel sandboxing") + - `error!` if ruleset creation fails unexpectedly +- **Files:** `src/sandbox/landlock.rs`, add `landlock` dep to `Cargo.toml`, update CLI args in `src/app/` +- **Done when:** unit test constructs ruleset without panic; `--yolo` flag works on unsupported kernel; startup refuses without flag on unsupported kernel -### Step 3.3: Parse tool-use blocks from SSE stream -- Add `StreamEvent::ToolUseStart { id, name }`, `ToolUseInputDelta(String)`, `ToolUseDone` -- Handle `content_block_start` (type "tool_use"), `content_block_delta` (type "input_json_delta"), `content_block_stop` for tool blocks -- Track current block type state in SSE parser -- **Files:** `src/provider/claude.rs`, `src/core/types.rs` -- **Done when:** Unit test with recorded tool-use SSE fixture asserts correct StreamEvent sequence +### Step 4.3: Sandbox file I/O API with operation tracing +- `Sandbox::read_file`, `Sandbox::write_file`, `Sandbox::list_directory` +- Move `validate_path` from `src/tools/mod.rs` into sandbox +- Tracing: + - `debug!` on every file operation: requested path, canonical path, allowed/denied + - `trace!` for path validation steps (join, canonicalize, starts_with check) + - `warn!` on path escape attempts (log the attempted path for debugging) + - `debug!` on successful operations with bytes read/written +- **Files:** `src/sandbox/mod.rs` +- **Done when:** unit tests in tempdir pass; path traversal rejected; `RUST_LOG=trace` shows full path resolution chain -### Step 3.4: Orchestrator accumulates tool-use blocks -- Accumulate `ToolUseInputDelta` fragments into JSON buffer per tool-use id -- On `ToolUseDone`, parse JSON into `ContentBlock::ToolUse` -- After `StreamEvent::Done`, if assistant message contains ToolUse blocks, enter tool-execution phase -- **Files:** `src/core/orchestrator.rs` -- **Done when:** Unit test with mock provider emitting tool-use events produces correct ContentBlocks +### Step 4.4: Sandbox command execution with process tracing +- `Sandbox::exec_command(cmd, args, working_dir)` spawns child process with Landlock applied +- Captures stdout/stderr, enforces timeout +- Tracing: + - `info!` on command spawn: command, args, working_dir, timeout + - `debug!` on command completion: exit code, stdout/stderr byte lengths, duration + - `warn!` on non-zero exit codes + - `error!` on timeout or spawn failure with full context + - `trace!` for Landlock application to child process thread +- **Files:** `src/sandbox/mod.rs` or `src/sandbox/exec.rs` +- **Done when:** unit test runs `echo hello` in tempdir; write outside sandbox fails (on supported kernels) -### Step 3.5: Tool trait, registry, and core tools -- `Tool` trait: `name()`, `description()`, `input_schema() -> Value`, `execute(input: Value, working_dir: &Path) -> Result` -- `ToolOutput { content: String, is_error: bool }` -- `ToolRegistry`: stores tools, provides `get(name)` and `definitions() -> Vec` -- Risk level: `AutoApprove` (reads), `RequiresApproval` (writes/shell) -- Implement: `read_file` (auto), `list_directory` (auto), `write_file` (approval), `shell_exec` (approval) -- Path validation: `canonicalize` + `starts_with` check, reject paths outside working dir (no Landlock yet) -- **Files:** New `src/tools/` module: `mod.rs`, `read_file.rs`, `write_file.rs`, `list_directory.rs`, `shell_exec.rs` -- **Done when:** Unit tests pass for each tool in temp dirs; path traversal rejected +### Step 4.5: Wire tools through Sandbox +- Change `Tool::execute` signature to accept `&Sandbox` instead of (or in addition to) `&Path` +- Update all 4 built-in tools to call `Sandbox` methods instead of `std::fs`/`std::process::Command` +- Remove direct `std::fs` usage from tool implementations +- Update `ToolRegistry` and orchestrator to pass `Sandbox` +- Tracing: tools now inherit sandbox spans automatically via `#[instrument]` +- **Files:** `src/tools/*.rs`, `src/tools/mod.rs`, `src/core/orchestrator.rs` +- **Done when:** all existing tool tests pass through Sandbox; no direct `std::fs` in tool files; `RUST_LOG=debug cargo run` shows sandbox operations during tool execution -### Step 3.6: Approval gate (TUI <-> core) -- New `UIEvent::ToolApprovalRequest { tool_use_id, tool_name, input_summary }` -- New `UserAction::ToolApprovalResponse { tool_use_id, approved: bool }` -- Orchestrator: check risk level -> auto-approve or send approval request and await response -- Denied tools return `ToolResult { is_error: true }` with denial message -- TUI: render approval prompt overlay with y/n keybindings -- **Files:** `src/core/types.rs`, `src/core/orchestrator.rs`, `src/tui/events.rs`, `src/tui/input.rs`, `src/tui/render.rs` -- **Done when:** Integration test: mock provider + mock TUI channel verifies approval flow +### Step 4.6: Network toggle +- `network_allowed: bool` in `SandboxPolicy` +- `:net on/off` TUI command parsed in input handler, sent as `UserAction::SetNetworkPolicy(bool)` +- Orchestrator updates `Sandbox` policy. Status bar shows network state. +- Only available when Landlock ABI >= 4 (kernel 6.7+); command hidden otherwise +- Status bar shows: network state when available, "UNSANDBOXED" in `--yolo` mode +- Tracing: `info!` on network policy change +- **Files:** `src/tui/input.rs`, `src/tui/render.rs`, `src/core/types.rs`, `src/core/orchestrator.rs`, `src/sandbox/mod.rs` +- **Done when:** toggling `:net` updates status bar; Landlock network restriction applied on ABI >= 4 -### Step 3.7: Tool results fed back to the model -- After executing tool calls: append assistant message (with ToolUse blocks) to history, append user message with ToolResult blocks, re-call provider -- Loop: model may respond with more tool calls or text -- Cap at max iterations (25) to prevent runaway -- **Files:** `src/core/orchestrator.rs` -- **Done when:** Integration test: mock provider returns tool-use then text; orchestrator makes two calls. Max-iteration cap tested. +### Step 4.7: Integration tests +- Tools + Sandbox in tempdir: write confinement, path traversal rejection, shell command confinement +- Skip Landlock-specific assertions on ABI < 4 +- Test `--yolo` mode: sandbox constructed but no kernel enforcement +- Test startup gate: verify error on ABI < 4 without `--yolo` +- Tests should assert tracing output where relevant (use `tracing-test` crate or `tracing_subscriber::fmt::TestWriter`) +- **Files:** `tests/sandbox.rs` +- **Done when:** `cargo test --test sandbox` passes -### Step 3.8: TUI display for tool activity -- New `UIEvent::ToolExecuting { tool_name, input_summary }`, `UIEvent::ToolResult { tool_name, output_summary, is_error }` -- Render tool calls as distinct visual blocks in conversation view -- Render tool results inline (truncated if long) -- **Files:** `src/tui/render.rs`, `src/tui/events.rs` -- **Done when:** Visual check with `cargo run`; TestBackend test for tool block rendering - -### Phase 3 verification (end-to-end) +### Phase 4 verification (end-to-end) 1. `cargo test` -- all tests pass 2. `cargo clippy -- -D warnings` -- zero warnings -3. `cargo run -- --project-dir .` -- ask Claude to read a file, approve, see contents -4. Ask Claude to write a file -- approve, verify written -5. Ask Claude to run a shell command -- approve, verify output -6. Deny an approval -- Claude gets denial and responds gracefully - -## Phase 4: Sandboxing -- Landlock: read-only system, read-write project dir, network blocked -- Tools execute through `Sandbox`, never directly -- `:net on/off` toggle, state in status bar -- Graceful degradation on older kernels -- **Done when:** Writes outside project dir fail; network toggle works +3. `RUST_LOG=debug cargo run -- --project-dir .` -- ask Claude to read a file, observe sandbox trace logs showing path validation and Landlock policy +4. Ask Claude to write a file outside project dir -- sandbox denies with `warn!` log +5. Ask Claude to run a shell command -- observe command spawn/completion trace +6. `:net off` then ask for network access -- verify blocked +7. Without `--yolo` on ABI < 4: verify startup refuses with clear error +8. With `--yolo`: verify startup succeeds, "UNSANDBOXED" in status bar, `warn!` in logs diff --git a/src/app/mod.rs b/src/app/mod.rs index d2c7ff0..89ee428 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -27,6 +27,8 @@ use tokio::sync::mpsc; use crate::core::orchestrator::Orchestrator; use crate::core::types::{UIEvent, UserAction}; use crate::provider::ClaudeProvider; +use crate::sandbox::policy::SandboxPolicy; +use crate::sandbox::{EnforcementMode, Sandbox}; use crate::tools::ToolRegistry; /// Model ID sent on every request. @@ -55,11 +57,27 @@ const CHANNEL_CAP: usize = 64; /// 4. Spawn the [`Orchestrator`] event loop on a tokio worker task. /// 5. Run the TUI event loop on the calling task (crossterm must not be used /// from multiple threads concurrently). -pub async fn run(project_dir: &Path) -> anyhow::Result<()> { +pub async fn run(project_dir: &Path, yolo: bool) -> anyhow::Result<()> { // -- Tracing ------------------------------------------------------------------ workspace::SkateDir::open(project_dir)?.init_tracing()?; - tracing::info!(project_dir = %project_dir.display(), "skate starting"); + tracing::info!(project_dir = %project_dir.display(), yolo = yolo, "skate starting"); + + // -- Sandbox ------------------------------------------------------------------ + let enforcement = if yolo { + tracing::warn!("--yolo: running without kernel sandboxing"); + EnforcementMode::Yolo + } else { + crate::sandbox::landlock::check_abi().context("kernel sandboxing not available")?; + EnforcementMode::Enforced + }; + + let sandbox_policy = SandboxPolicy::for_project(project_dir.to_path_buf()); + let sandbox = Sandbox::new(sandbox_policy, project_dir.to_path_buf(), enforcement); + + // Landlock is NOT applied to the main process -- it needs unrestricted + // network for the Claude API and broad filesystem reads. Instead, Landlock + // is applied per-child-process in Sandbox::exec_command via pre_exec. // -- Provider ----------------------------------------------------------------- let provider = ClaudeProvider::from_env(MODEL) @@ -71,20 +89,14 @@ pub async fn run(project_dir: &Path) -> anyhow::Result<()> { // -- Tools & Orchestrator (background task) ------------------------------------ let tool_registry = ToolRegistry::default_tools(); - let orch = Orchestrator::new( - provider, - tool_registry, - project_dir.to_path_buf(), - action_rx, - event_tx, - ); + let orch = Orchestrator::new(provider, tool_registry, sandbox, action_rx, event_tx); tokio::spawn(orch.run()); // -- TUI (foreground task) ---------------------------------------------------- // `action_tx` is moved into tui::run; when it returns (user quit), the // sender is dropped, which closes the channel and causes the orchestrator's // recv() loop to exit. - crate::tui::run(action_tx, event_rx) + crate::tui::run(action_tx, event_rx, yolo) .await .context("TUI error")?; diff --git a/src/core/orchestrator.rs b/src/core/orchestrator.rs index 472f4bd..146126d 100644 --- a/src/core/orchestrator.rs +++ b/src/core/orchestrator.rs @@ -7,6 +7,7 @@ use crate::core::types::{ ContentBlock, ConversationMessage, Role, StreamEvent, ToolDefinition, UIEvent, UserAction, }; use crate::provider::ModelProvider; +use crate::sandbox::Sandbox; use crate::tools::{RiskLevel, ToolOutput, ToolRegistry}; /// Accumulates data for a single tool-use block while it is being streamed. @@ -106,7 +107,7 @@ pub struct Orchestrator

{ history: ConversationHistory, provider: P, tool_registry: ToolRegistry, - working_dir: std::path::PathBuf, + sandbox: Sandbox, action_rx: mpsc::Receiver, event_tx: mpsc::Sender, /// Messages typed by the user while an approval prompt is open. They are @@ -119,7 +120,7 @@ impl Orchestrator

{ pub fn new( provider: P, tool_registry: ToolRegistry, - working_dir: std::path::PathBuf, + sandbox: Sandbox, action_rx: mpsc::Receiver, event_tx: mpsc::Sender, ) -> Self { @@ -127,7 +128,7 @@ impl Orchestrator

{ history: ConversationHistory::new(), provider, tool_registry, - working_dir, + sandbox, action_rx, event_tx, queued_messages: Vec::new(), @@ -340,7 +341,7 @@ impl Orchestrator

{ // Re-fetch tool for execution (borrow was released above). let tool = self.tool_registry.get(tool_name).unwrap(); - match tool.execute(input, &self.working_dir).await { + match tool.execute(input, &self.sandbox).await { Ok(output) => { let _ = self .event_tx @@ -409,6 +410,14 @@ impl Orchestrator

{ // not in the main action loop. If one arrives here it's stale. UserAction::ToolApprovalResponse { .. } => {} + UserAction::SetNetworkPolicy(allowed) => { + self.sandbox.set_network_allowed(allowed); + let _ = self + .event_tx + .send(UIEvent::NetworkPolicyChanged(allowed)) + .await; + } + UserAction::SendMessage(text) => { self.history .push(ConversationMessage::text(Role::User, text)); @@ -467,10 +476,17 @@ mod tests { action_rx: mpsc::Receiver, event_tx: mpsc::Sender, ) -> Orchestrator

{ + use crate::sandbox::policy::SandboxPolicy; + use crate::sandbox::{EnforcementMode, Sandbox}; + let sandbox = Sandbox::new( + SandboxPolicy::for_project(std::path::PathBuf::from("/tmp")), + std::path::PathBuf::from("/tmp"), + EnforcementMode::Yolo, + ); Orchestrator::new( provider, ToolRegistry::empty(), - std::path::PathBuf::from("/tmp"), + sandbox, action_rx, event_tx, ) @@ -717,12 +733,19 @@ mod tests { let (event_tx, mut event_rx) = mpsc::channel::(32); // Use a real ToolRegistry so read_file works. + use crate::sandbox::policy::SandboxPolicy; + use crate::sandbox::{EnforcementMode, Sandbox}; let dir = tempfile::TempDir::new().unwrap(); std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + let sandbox = Sandbox::new( + SandboxPolicy::for_project(dir.path().to_path_buf()), + dir.path().to_path_buf(), + EnforcementMode::Yolo, + ); let orch = Orchestrator::new( MultiCallMock { turns }, ToolRegistry::default_tools(), - dir.path().to_path_buf(), + sandbox, action_rx, event_tx, ); @@ -873,14 +896,7 @@ mod tests { let (action_tx, action_rx) = mpsc::channel::(16); let (event_tx, mut event_rx) = mpsc::channel::(64); - let dir = tempfile::TempDir::new().unwrap(); - let orch = Orchestrator::new( - MultiCallMock { turns }, - ToolRegistry::default_tools(), - dir.path().to_path_buf(), - action_rx, - event_tx, - ); + let orch = test_orchestrator(MultiCallMock { turns }, action_rx, event_tx); let handle = tokio::spawn(orch.run()); // Start turn 1 -- orchestrator will block on approval. @@ -927,4 +943,45 @@ mod tests { action_tx.send(UserAction::Quit).await.unwrap(); handle.await.unwrap(); } + + // -- network policy toggle ------------------------------------------------ + + /// SetNetworkPolicy sends a NetworkPolicyChanged event back to the TUI. + #[tokio::test] + async fn set_network_policy_sends_event() { + // Provider that returns immediately to avoid blocking. + struct NeverCalledProvider; + impl ModelProvider for NeverCalledProvider { + fn stream<'a>( + &'a self, + _messages: &'a [ConversationMessage], + _tools: &'a [ToolDefinition], + ) -> impl Stream + Send + 'a { + futures::stream::empty() + } + } + + let (action_tx, action_rx) = mpsc::channel::(8); + let (event_tx, mut event_rx) = mpsc::channel::(8); + + let orch = test_orchestrator(NeverCalledProvider, action_rx, event_tx); + let handle = tokio::spawn(orch.run()); + + action_tx + .send(UserAction::SetNetworkPolicy(true)) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let mut found = false; + while let Ok(ev) = event_rx.try_recv() { + if matches!(ev, UIEvent::NetworkPolicyChanged(true)) { + found = true; + } + } + assert!(found, "expected NetworkPolicyChanged(true) event"); + + action_tx.send(UserAction::Quit).await.unwrap(); + handle.await.unwrap(); + } } diff --git a/src/core/types.rs b/src/core/types.rs index 65e3f43..c02d90a 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -32,6 +32,8 @@ pub enum UserAction { Quit, /// The user has requested to clear conversation history. ClearHistory, + /// The user has toggled the network policy via `:net on/off`. + SetNetworkPolicy(bool), } /// An event sent from the core orchestrator to the TUI. @@ -56,6 +58,8 @@ pub enum UIEvent { output_summary: String, is_error: bool, }, + /// The network policy has changed (sent after `:net on/off` is processed). + NetworkPolicyChanged(bool), /// The current assistant turn has completed. TurnComplete, /// An error to display to the user. diff --git a/src/main.rs b/src/main.rs index 237a581..02ae8c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod core; mod provider; +mod sandbox; mod tools; mod tui; @@ -11,30 +12,50 @@ use anyhow::Context; /// Run skate against a project directory. /// /// ```text -/// Usage: skate --project-dir +/// Usage: skate --project-dir [--yolo] /// ``` /// /// `ANTHROPIC_API_KEY` must be set in the environment. /// `RUST_LOG` controls log verbosity (default: `info`); logs go to /// `/.skate/skate.log`. +/// +/// Requires Landlock ABI v4 (kernel 6.7+) for sandboxing. Pass `--yolo` to +/// skip kernel sandboxing entirely (tools run without confinement). #[tokio::main] async fn main() -> anyhow::Result<()> { - let project_dir = parse_project_dir()?; - app::run(&project_dir).await + let cli = parse_cli()?; + app::run(&cli.project_dir, cli.yolo).await } -/// Extract the value of `--project-dir` from `argv`. +/// Parsed command-line arguments. +struct Cli { + project_dir: PathBuf, + yolo: bool, +} + +/// Parse `argv` into [`Cli`]. /// -/// Returns an error if the flag is absent or is not followed by a value. -fn parse_project_dir() -> anyhow::Result { - let mut args = std::env::args().skip(1); // skip the binary name +/// Accepts `--project-dir ` (required) and `--yolo` (optional). +fn parse_cli() -> anyhow::Result { + let mut project_dir: Option = None; + let mut yolo = false; + + let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { - if arg == "--project-dir" { - let value = args - .next() - .context("--project-dir requires a path argument")?; - return Ok(PathBuf::from(value)); + match arg.as_str() { + "--project-dir" => { + let value = args + .next() + .context("--project-dir requires a path argument")?; + project_dir = Some(PathBuf::from(value)); + } + "--yolo" => { + yolo = true; + } + _ => {} } } - anyhow::bail!("Usage: skate --project-dir ") + + let project_dir = project_dir.context("Usage: skate --project-dir [--yolo]")?; + Ok(Cli { project_dir, yolo }) } diff --git a/src/sandbox/landlock.rs b/src/sandbox/landlock.rs new file mode 100644 index 0000000..61de974 --- /dev/null +++ b/src/sandbox/landlock.rs @@ -0,0 +1,158 @@ +//! Landlock enforcement: translates [`SandboxPolicy`] into kernel-level rules. +//! +//! Requires Landlock ABI v4 (kernel 6.7+) for both filesystem and network +//! sandboxing. On older kernels, [`check_abi`] returns an error so the caller +//! can refuse to start (unless `--yolo` is passed). +//! +//! # Landlock ABI versions +//! +//! | ABI | Kernel | Feature | +//! |-----|--------|-----------------------------------| +//! | v1 | 5.13 | Basic filesystem access rights | +//! | v2 | 5.19 | LANDLOCK_ACCESS_FS_REFER | +//! | v3 | 6.2 | LANDLOCK_ACCESS_FS_TRUNCATE | +//! | v4 | 6.7 | Network restrictions (TCP) | +//! | v5 | 6.10 | IOCTL on devices | +//! +//! See the [Landlock kernel docs] and the [rust-landlock crate docs]. +//! +//! [Landlock kernel docs]: https://docs.kernel.org/userspace-api/landlock.html +//! [rust-landlock crate docs]: https://docs.rs/landlock/latest/landlock/ + +use landlock::{ + ABI, Access, AccessFs, AccessNet, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, + RulesetCreatedAttr, RulesetStatus, +}; + +use super::policy::{AccessLevel, SandboxPolicy}; + +/// The minimum Landlock ABI version we require. +const REQUIRED_ABI: ABI = ABI::V4; + +/// Check that the running kernel supports Landlock ABI v4+. +/// +/// Attempts to create a ruleset with filesystem and network access rights +/// using `HardRequirement` compatibility. If the kernel doesn't support +/// ABI v4, the ruleset creation fails and we return an error. +/// +/// Returns `Ok(())` if the kernel supports our minimum ABI. +pub fn check_abi() -> Result<(), LandlockSetupError> { + tracing::debug!("probing kernel for Landlock ABI v4+ support"); + + // Try to create a ruleset with both fs and net access, requiring v4. + let result = Ruleset::default() + .set_compatibility(landlock::CompatLevel::HardRequirement) + .handle_access(AccessFs::from_all(REQUIRED_ABI)) + .and_then(|r| r.handle_access(AccessNet::BindTcp)) + .and_then(|r| r.handle_access(AccessNet::ConnectTcp)); + + match result { + Ok(_) => { + tracing::info!("Landlock ABI v4+ detected"); + Ok(()) + } + Err(e) => { + tracing::warn!( + error = %e, + "Landlock ABI v4+ not available" + ); + Err(LandlockSetupError::UnsupportedKernel(e.to_string())) + } + } +} + +/// Apply Landlock rules to the current thread based on the given policy. +/// +/// Called in the child process via `pre_exec` (between fork and exec), +/// NOT on the main skate process. After this call, the current thread +/// (and any children) are restricted according to the policy's path +/// rules and network settings. +pub fn enforce(policy: &SandboxPolicy) -> Result<(), LandlockSetupError> { + tracing::info!( + path_rules = policy.path_rules.len(), + network_allowed = policy.network_allowed, + "applying Landlock ruleset" + ); + + let mut ruleset = Ruleset::default() + .handle_access(AccessFs::from_all(REQUIRED_ABI)) + .map_err(|e| LandlockSetupError::RulesetCreation(e.to_string()))?; + + // Only restrict network if network is NOT allowed. + if !policy.network_allowed { + ruleset = ruleset + .handle_access(AccessNet::BindTcp) + .map_err(|e| LandlockSetupError::RulesetCreation(e.to_string()))? + .handle_access(AccessNet::ConnectTcp) + .map_err(|e| LandlockSetupError::RulesetCreation(e.to_string()))?; + } + + let mut created = ruleset + .create() + .map_err(|e| LandlockSetupError::RulesetCreation(e.to_string()))?; + + // Add filesystem path rules. + for rule in &policy.path_rules { + let access = match rule.access { + AccessLevel::ReadOnly => AccessFs::from_read(REQUIRED_ABI), + AccessLevel::ReadWrite => AccessFs::from_all(REQUIRED_ABI), + }; + + tracing::debug!( + path = %rule.path.display(), + access = ?rule.access, + "adding Landlock filesystem rule" + ); + + let fd = PathFd::new(&rule.path).map_err(|e| { + LandlockSetupError::RulesetCreation(format!( + "failed to open path {:?}: {}", + rule.path, e + )) + })?; + let path_rule = PathBeneath::new(fd, access); + created = created + .add_rule(path_rule) + .map_err(|e| LandlockSetupError::RulesetCreation(e.to_string()))?; + } + + // If network is allowed, we don't add network rules (no restriction). + // If network is blocked, we handled_access above but add no allow rules, + // meaning all TCP bind/connect is denied. + + let status = created + .restrict_self() + .map_err(|e| LandlockSetupError::RulesetCreation(e.to_string()))?; + + match status.ruleset { + RulesetStatus::FullyEnforced => { + tracing::info!("Landlock ruleset fully enforced"); + } + RulesetStatus::PartiallyEnforced => { + tracing::error!("Landlock ruleset only partially enforced -- refusing to run child"); + return Err(LandlockSetupError::NotEnforced); + } + RulesetStatus::NotEnforced => { + tracing::error!("Landlock ruleset was not enforced"); + return Err(LandlockSetupError::NotEnforced); + } + } + + Ok(()) +} + +/// Errors from Landlock setup. +#[derive(Debug, thiserror::Error)] +pub enum LandlockSetupError { + /// The kernel does not support Landlock ABI v4+. + #[error( + "Landlock ABI v4+ required (kernel 6.7+). Use --yolo to run without sandboxing. Detail: {0}" + )] + UnsupportedKernel(String), + /// Failed to create or populate the Landlock ruleset. + #[error("Landlock ruleset error: {0}")] + RulesetCreation(String), + /// The ruleset was created but not enforced by the kernel. + #[error("Landlock ruleset was not enforced by the kernel")] + NotEnforced, +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs new file mode 100644 index 0000000..f166259 --- /dev/null +++ b/src/sandbox/mod.rs @@ -0,0 +1,649 @@ +//! Sandbox: kernel-enforced confinement for tool execution. +//! +//! All file I/O and process spawning in tools MUST go through [`Sandbox`] +//! rather than using `std::fs` or `std::process::Command` directly. The +//! sandbox enforces a [`SandboxPolicy`] via Linux Landlock (ABI v4+), +//! restricting filesystem access and network connectivity. +//! +//! # Architecture +//! +//! ```text +//! SandboxPolicy (declarative) +//! | +//! v +//! Sandbox (API surface: read_file, write_file, list_directory, exec_command) +//! | +//! v +//! Landlock enforcement (kernel-level, applied per-thread/per-process) +//! ``` +//! +//! # Startup gate +//! +//! Landlock ABI v4 (kernel 6.7+) is required for both filesystem and network +//! sandboxing. On older kernels, skate refuses to start unless `--yolo` is +//! passed, which disables all kernel enforcement. + +pub mod landlock; +pub mod policy; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use policy::SandboxPolicy; + +/// Timeout applied to each sandboxed command. Commands that run longer than +/// this are killed and [`SandboxError::Timeout`] is returned to the caller. +const COMMAND_TIMEOUT_SECS: u64 = 30; + +/// Whether Landlock enforcement is active or bypassed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnforcementMode { + /// Landlock rules are applied to tool operations. + Enforced, + /// Running in `--yolo` mode: no kernel sandboxing. + Yolo, +} + +/// Kernel-enforced sandbox for tool execution. +/// +/// Holds the policy and working directory. All tool file I/O and process +/// spawning goes through this struct's methods, which apply path validation +/// and (when enforced) Landlock rules. +/// +/// The canonical form of `working_dir` is computed once at construction and +/// cached in `canonical_root`, avoiding a redundant `canonicalize` syscall on +/// every [`validate_path`] call. +pub struct Sandbox { + /// The policy governing filesystem and network access. + #[allow(dead_code)] + policy: SandboxPolicy, + /// The project working directory (tools operate relative to this). + working_dir: PathBuf, + /// Canonical (symlink-resolved) form of `working_dir`, computed once at + /// construction so `validate_path` can compare against it cheaply. + canonical_root: PathBuf, + /// Whether Landlock enforcement is active. + #[allow(dead_code)] // Read by enforcement() accessor. + enforcement: EnforcementMode, +} + +impl Sandbox { + /// Create a new sandbox with the given policy and working directory. + /// + /// `working_dir` is canonicalized immediately and cached as + /// `canonical_root`. If canonicalization fails (e.g. the directory does + /// not exist yet in tests), `working_dir` itself is used as the root. + pub fn new(policy: SandboxPolicy, working_dir: PathBuf, enforcement: EnforcementMode) -> Self { + let canonical_root = working_dir + .canonicalize() + .unwrap_or_else(|_| working_dir.clone()); + + tracing::info!( + working_dir = %working_dir.display(), + enforcement = ?enforcement, + network_allowed = policy.network_allowed, + path_rules = policy.path_rules.len(), + "sandbox created" + ); + + Self { + policy, + working_dir, + canonical_root, + enforcement, + } + } + + /// The sandbox policy. + #[allow(dead_code)] + pub fn policy(&self) -> &SandboxPolicy { + &self.policy + } + + /// The project working directory. + #[allow(dead_code)] + pub fn working_dir(&self) -> &Path { + &self.working_dir + } + + /// The current enforcement mode. + #[allow(dead_code)] + pub fn enforcement(&self) -> EnforcementMode { + self.enforcement + } + + /// Whether network access is currently allowed by policy. + #[allow(dead_code)] + pub fn network_allowed(&self) -> bool { + self.policy.network_allowed + } + + /// Set the network access policy. + pub fn set_network_allowed(&mut self, allowed: bool) { + tracing::info!(network_allowed = allowed, "sandbox network policy updated"); + self.policy.network_allowed = allowed; + } + + /// Validate that `requested` resolves to a path inside `working_dir`. + /// + /// Joins `working_dir` with `requested`, canonicalizes the result (resolving + /// symlinks and `..` components), and checks that the canonical path starts + /// with `self.canonical_root` (computed once at construction). + /// + /// Returns the canonical path on success. + #[tracing::instrument(skip(self), fields(working_dir = %self.working_dir.display()))] + pub fn validate_path(&self, requested: &str) -> Result { + let candidate = if Path::new(requested).is_absolute() { + PathBuf::from(requested) + } else { + self.working_dir.join(requested) + }; + + tracing::trace!(candidate = %candidate.display(), "resolving path"); + + // For paths that don't exist yet (e.g. write_file creating a new file), + // canonicalize the parent directory and append the filename. + let canonical = if candidate.exists() { + candidate + .canonicalize() + .map_err(|_| SandboxError::PathEscape(candidate.clone()))? + } else { + let parent = candidate + .parent() + .ok_or_else(|| SandboxError::PathEscape(candidate.clone()))?; + let file_name = candidate + .file_name() + .ok_or_else(|| SandboxError::PathEscape(candidate.clone()))?; + let canonical_parent = parent + .canonicalize() + .map_err(|_| SandboxError::PathEscape(candidate.clone()))?; + canonical_parent.join(file_name) + }; + + tracing::trace!( + canonical = %canonical.display(), + root = %self.canonical_root.display(), + "path validation check" + ); + + if canonical.starts_with(&self.canonical_root) { + tracing::debug!(path = %canonical.display(), "path validated"); + Ok(canonical) + } else { + tracing::warn!( + requested = requested, + resolved = %canonical.display(), + "path escape attempt blocked" + ); + Err(SandboxError::PathEscape(candidate)) + } + } + + /// Read the contents of a file within the working directory. + /// + /// Path validation is synchronous; the actual `read_to_string` is + /// dispatched to a `spawn_blocking` thread so the tokio runtime is not + /// stalled during disk I/O. + #[tracing::instrument(skip(self))] + pub async fn read_file(&self, requested: &str) -> Result { + let canonical = self.validate_path(requested)?; + let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&canonical)) + .await + .map_err(|e| SandboxError::Io(std::io::Error::other(e)))??; + tracing::debug!(bytes = content.len(), "file read"); + Ok(content) + } + + /// Write content to a file within the working directory, creating parent + /// directories as needed. + /// + /// Path validation is synchronous; the actual write is dispatched to a + /// `spawn_blocking` thread so the tokio runtime is not stalled during + /// disk I/O. + #[tracing::instrument(skip(self, content), fields(content_len = content.len()))] + pub async fn write_file(&self, requested: &str, content: &str) -> Result<(), SandboxError> { + let canonical = self.validate_path(requested)?; + let content = content.to_string(); + tokio::task::spawn_blocking(move || { + if let Some(parent) = canonical.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&canonical, &content) + }) + .await + .map_err(|e| SandboxError::Io(std::io::Error::other(e)))??; + tracing::debug!("file written"); + Ok(()) + } + + /// List entries in a directory within the working directory. + /// + /// Path validation is synchronous; the `read_dir` walk is dispatched to a + /// `spawn_blocking` thread so the tokio runtime is not stalled. + #[tracing::instrument(skip(self))] + pub async fn list_directory(&self, requested: &str) -> Result, SandboxError> { + let canonical = self.validate_path(requested)?; + let entries = tokio::task::spawn_blocking(move || { + let mut entries: Vec = Vec::new(); + for entry in std::fs::read_dir(&canonical)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let suffix = if entry.file_type()?.is_dir() { "/" } else { "" }; + entries.push(format!("{name}{suffix}")); + } + entries.sort(); + Ok::, std::io::Error>(entries) + }) + .await + .map_err(|e| SandboxError::Io(std::io::Error::other(e)))??; + tracing::debug!(count = entries.len(), "directory listed"); + Ok(entries) + } + + /// Execute a shell command in the working directory. + /// + /// Spawns `sh -c ` with the working directory set to the project + /// root. When Landlock enforcement is active, the child process is + /// sandboxed via `pre_exec` before it execs into the shell -- this + /// applies the current [`SandboxPolicy`] (including network rules) to + /// the child only, leaving the main skate process unrestricted. + /// + /// The blocking `Command::output()` call is dispatched to a + /// `spawn_blocking` thread and wrapped in a [`COMMAND_TIMEOUT_SECS`]-second + /// `tokio::time::timeout`. Commands that exceed the timeout return + /// [`SandboxError::Timeout`]. + /// + /// # Safety + /// + /// Uses `CommandExt::pre_exec` which is `unsafe` because the closure + /// runs between `fork` and `exec`. The closure only calls Landlock + /// syscalls (prctl + landlock_*), which are async-signal-safe. + #[tracing::instrument(skip(self))] + pub async fn exec_command(&self, command: &str) -> Result { + use std::os::unix::process::CommandExt; + + let command_str = command.to_string(); + let working_dir = self.working_dir.clone(); + let enforcement = self.enforcement; + let policy = self.policy.clone(); + + tracing::info!( + command = command, + working_dir = %working_dir.display(), + "spawning command" + ); + + let blocking = tokio::task::spawn_blocking(move || { + let start = std::time::Instant::now(); + + let mut cmd = std::process::Command::new("sh"); + cmd.arg("-c").arg(&command_str).current_dir(&working_dir); + + // Apply Landlock to the child process before exec. + if enforcement == EnforcementMode::Enforced { + // SAFETY: The pre_exec closure runs between fork and exec in the + // child process. landlock::enforce only makes prctl(2) and + // landlock_*(2) syscalls, which are async-signal-safe. + unsafe { + cmd.pre_exec(move || { + landlock::enforce(&policy).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string()) + }) + }); + } + } + + let output = cmd.output()?; + + let duration = start.elapsed(); + let exit_code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + tracing::debug!( + exit_code = exit_code, + stdout_bytes = stdout.len(), + stderr_bytes = stderr.len(), + duration_ms = duration.as_millis() as u64, + "command completed" + ); + } else { + tracing::warn!( + exit_code = exit_code, + stdout_bytes = stdout.len(), + stderr_bytes = stderr.len(), + duration_ms = duration.as_millis() as u64, + "command failed" + ); + } + + Ok::(CommandOutput { + stdout, + stderr, + exit_code, + success: output.status.success(), + }) + }); + + tokio::time::timeout(Duration::from_secs(COMMAND_TIMEOUT_SECS), blocking) + .await + .map_err(|_| SandboxError::Timeout(COMMAND_TIMEOUT_SECS))? + .map_err(|e| SandboxError::Io(std::io::Error::other(e)))? + .map_err(SandboxError::Io) + } +} + +/// Output of a sandboxed command execution. +#[derive(Debug)] +pub struct CommandOutput { + /// Standard output. + pub stdout: String, + /// Standard error. + pub stderr: String, + /// Process exit code (-1 if terminated by signal). + pub exit_code: i32, + /// Whether the process exited successfully (code 0). + pub success: bool, +} + +/// Errors from sandbox operations. +#[derive(Debug, thiserror::Error)] +pub enum SandboxError { + /// The requested path escapes the working directory. + #[error("path escapes working directory: {0}")] + PathEscape(PathBuf), + /// An I/O error during a sandbox operation. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + /// A sandboxed command exceeded the timeout. + #[error("command timed out after {0} seconds")] + Timeout(u64), +} + +/// Build a yolo [`Sandbox`] rooted at `dir` for use in tests. +/// +/// Exported as `pub(crate)` so individual tool test modules can import it +/// with `use crate::sandbox::test_sandbox` rather than duplicating the +/// constructor call in each file. +#[cfg(test)] +pub(crate) fn test_sandbox(dir: &Path) -> Sandbox { + Sandbox::new( + SandboxPolicy::for_project(dir.to_path_buf()), + dir.to_path_buf(), + EnforcementMode::Yolo, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use policy::SandboxPolicy; + use tempfile::TempDir; + + // -- path validation -- + + #[test] + fn validate_path_allows_subpath() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join("sub")).unwrap(); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.validate_path("sub"); + assert!(result.is_ok()); + } + + #[test] + fn validate_path_rejects_traversal() { + let dir = TempDir::new().unwrap(); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.validate_path("../../../etc/passwd"); + assert!(matches!(result, Err(SandboxError::PathEscape(_)))); + } + + #[test] + fn validate_path_rejects_absolute_outside() { + let dir = TempDir::new().unwrap(); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.validate_path("/etc/passwd"); + assert!(matches!(result, Err(SandboxError::PathEscape(_)))); + } + + #[test] + fn validate_path_allows_new_file() { + let dir = TempDir::new().unwrap(); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.validate_path("new_file.txt"); + assert!(result.is_ok()); + } + + // -- file operations -- + + #[tokio::test] + async fn read_file_works() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("hello.txt"), "world").unwrap(); + let sandbox = test_sandbox(dir.path()); + let content = sandbox.read_file("hello.txt").await.unwrap(); + assert_eq!(content, "world"); + } + + #[tokio::test] + async fn read_file_rejects_escape() { + let dir = TempDir::new().unwrap(); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.read_file("../../../etc/passwd").await; + assert!(matches!(result, Err(SandboxError::PathEscape(_)))); + } + + #[tokio::test] + async fn write_file_works() { + let dir = TempDir::new().unwrap(); + let sandbox = test_sandbox(dir.path()); + sandbox.write_file("out.txt", "hello").await.unwrap(); + let content = std::fs::read_to_string(dir.path().join("out.txt")).unwrap(); + assert_eq!(content, "hello"); + } + + #[tokio::test] + async fn write_file_rejects_escape() { + let dir = TempDir::new().unwrap(); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.write_file("../../evil.txt", "bad").await; + assert!(matches!(result, Err(SandboxError::PathEscape(_)))); + } + + #[tokio::test] + async fn list_directory_works() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "").unwrap(); + std::fs::create_dir(dir.path().join("subdir")).unwrap(); + let sandbox = test_sandbox(dir.path()); + let entries = sandbox.list_directory(".").await.unwrap(); + assert!(entries.contains(&"a.txt".to_string())); + assert!(entries.contains(&"subdir/".to_string())); + } + + // -- command execution -- + + #[tokio::test] + async fn exec_command_echo() { + let dir = TempDir::new().unwrap(); + let sandbox = test_sandbox(dir.path()); + let output = sandbox.exec_command("echo hello").await.unwrap(); + assert!(output.success); + assert!(output.stdout.contains("hello")); + } + + #[tokio::test] + async fn exec_command_failure() { + let dir = TempDir::new().unwrap(); + let sandbox = test_sandbox(dir.path()); + let output = sandbox.exec_command("false").await.unwrap(); + assert!(!output.success); + } + + #[tokio::test] + async fn exec_command_timeout() { + let _dir = TempDir::new().unwrap(); + // Use a very short timeout to keep tests fast. We temporarily override + // COMMAND_TIMEOUT_SECS by calling exec_command directly but relying on + // the real constant -- instead, test the Timeout variant by running a + // command that sleeps longer than the constant. Since COMMAND_TIMEOUT_SECS + // is 30 s, we cannot feasibly do that in a unit test; instead, verify the + // Timeout error shape is constructible and round-trips correctly. + let err = SandboxError::Timeout(30); + assert_eq!(err.to_string(), "command timed out after 30 seconds"); + } + + // -- Landlock enforcement (require kernel ABI v4) -- + // + // These tests exercise EnforcementMode::Enforced. They are skipped + // automatically on kernels without Landlock ABI v4 support. + + fn landlock_available() -> bool { + super::landlock::check_abi().is_ok() + } + + fn enforced_sandbox(dir: &Path) -> Sandbox { + Sandbox::new( + SandboxPolicy::for_project(dir.to_path_buf()), + dir.to_path_buf(), + EnforcementMode::Enforced, + ) + } + + fn enforced_sandbox_with_network(dir: &Path) -> Sandbox { + let mut policy = SandboxPolicy::for_project(dir.to_path_buf()); + policy.network_allowed = true; + Sandbox::new(policy, dir.to_path_buf(), EnforcementMode::Enforced) + } + + #[tokio::test] + async fn landlock_child_can_read_inside_project() { + if !landlock_available() { + eprintln!("skipping: Landlock ABI v4 not available"); + return; + } + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello").unwrap(); + let sandbox = enforced_sandbox(dir.path()); + let output = sandbox.exec_command("cat test.txt").await.unwrap(); + assert!(output.success, "stderr: {}", output.stderr); + assert_eq!(output.stdout.trim(), "hello"); + } + + #[tokio::test] + async fn landlock_child_can_write_inside_project() { + if !landlock_available() { + eprintln!("skipping: Landlock ABI v4 not available"); + return; + } + let dir = TempDir::new().unwrap(); + let sandbox = enforced_sandbox(dir.path()); + let output = sandbox + .exec_command("echo content > out.txt") + .await + .unwrap(); + assert!(output.success, "stderr: {}", output.stderr); + let content = std::fs::read_to_string(dir.path().join("out.txt")).unwrap(); + assert_eq!(content.trim(), "content"); + } + + #[tokio::test] + async fn landlock_child_cannot_write_outside_project() { + if !landlock_available() { + eprintln!("skipping: Landlock ABI v4 not available"); + return; + } + let dir = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let target = outside.path().join("evil.txt"); + let sandbox = enforced_sandbox(dir.path()); + let output = sandbox + .exec_command(&format!("echo bad > {}", target.display())) + .await + .unwrap(); + assert!( + !output.success, + "child should not be able to write outside project" + ); + assert!(!target.exists()); + } + + #[tokio::test] + async fn landlock_child_network_blocked_by_default() { + if !landlock_available() { + eprintln!("skipping: Landlock ABI v4 not available"); + return; + } + let dir = TempDir::new().unwrap(); + let sandbox = enforced_sandbox(dir.path()); + // Try to connect to a TCP port. Use /dev/tcp bash trick or a simple + // command that attempts a network connection. `curl` may not be + // installed, so use bash built-in or timeout with cat. + let output = sandbox + .exec_command("bash -c 'echo > /dev/tcp/1.1.1.1/80' 2>&1 || exit 1") + .await + .unwrap(); + assert!( + !output.success, + "network should be blocked; stderr: {}", + output.stderr + ); + } + + #[tokio::test] + async fn landlock_child_network_allowed_when_policy_permits() { + if !landlock_available() { + eprintln!("skipping: Landlock ABI v4 not available"); + return; + } + let dir = TempDir::new().unwrap(); + let sandbox = enforced_sandbox_with_network(dir.path()); + // With network allowed, a TCP connect should succeed (or at least not + // be blocked by Landlock -- it may still time out, so we just check + // that the error is NOT a permission denial). + let output = sandbox + .exec_command("bash -c 'echo > /dev/tcp/1.1.1.1/80' 2>&1") + .await + .unwrap(); + // If Landlock blocked it, bash reports "Permission denied". We accept + // either success or a non-permission error (e.g. timeout). + assert!( + !output.stderr.contains("Permission denied") + && !output.stderr.contains("Operation not permitted"), + "network should be allowed; stderr: {}", + output.stderr + ); + } + + #[tokio::test] + async fn landlock_net_toggle_affects_next_command() { + if !landlock_available() { + eprintln!("skipping: Landlock ABI v4 not available"); + return; + } + let dir = TempDir::new().unwrap(); + let mut sandbox = enforced_sandbox(dir.path()); + + // Network blocked by default. + let output = sandbox + .exec_command("bash -c 'echo > /dev/tcp/1.1.1.1/80' 2>&1 || exit 1") + .await + .unwrap(); + assert!(!output.success, "network should be blocked initially"); + + // Enable network. + sandbox.set_network_allowed(true); + let output = sandbox + .exec_command("bash -c 'echo > /dev/tcp/1.1.1.1/80' 2>&1") + .await + .unwrap(); + assert!( + !output.stderr.contains("Permission denied") + && !output.stderr.contains("Operation not permitted"), + "network should be allowed after toggle; stderr: {}", + output.stderr + ); + } +} diff --git a/src/sandbox/policy.rs b/src/sandbox/policy.rs new file mode 100644 index 0000000..5afcac3 --- /dev/null +++ b/src/sandbox/policy.rs @@ -0,0 +1,101 @@ +//! Sandbox policy: the set of rules governing what a sandboxed process can do. +//! +//! A [`SandboxPolicy`] declares which filesystem paths are accessible (and at +//! what level) and whether network access is permitted. The sandbox module +//! translates this policy into kernel-level enforcement via Landlock. + +use std::path::PathBuf; + +/// Filesystem access level for a path in the sandbox policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccessLevel { + /// Read-only access (directory listing, file reads). + ReadOnly, + /// Read-write access (file creation, modification, deletion). + ReadWrite, +} + +/// A single filesystem access rule. +#[derive(Debug, Clone)] +pub struct PathRule { + /// The path this rule applies to (directory or file). + pub path: PathBuf, + /// The access level granted. + pub access: AccessLevel, +} + +/// Declarative policy for sandbox enforcement. +/// +/// Constructed once at startup from the project directory and runtime flags, +/// then handed to [`super::Sandbox`] which enforces it via Landlock. +/// +/// Default policy: +/// - System paths (`/`): read-only +/// - Project directory: read-write +/// - Network: blocked +#[derive(Debug, Clone)] +pub struct SandboxPolicy { + /// Ordered list of filesystem access rules. More specific paths should + /// appear after broader ones so that Landlock applies the most specific + /// match. + pub path_rules: Vec, + /// Whether outbound network access (TCP connect/bind) is allowed. + pub network_allowed: bool, +} + +impl SandboxPolicy { + /// Build the default policy for a project directory. + /// + /// Grants read-only access to the entire filesystem and read-write access + /// to the project directory. Network is blocked by default. + pub fn for_project(project_dir: PathBuf) -> Self { + tracing::debug!( + project_dir = %project_dir.display(), + "building sandbox policy for project" + ); + + let policy = Self { + path_rules: vec![ + PathRule { + path: PathBuf::from("/"), + access: AccessLevel::ReadOnly, + }, + PathRule { + path: project_dir, + access: AccessLevel::ReadWrite, + }, + ], + network_allowed: false, + }; + + tracing::info!( + rules = policy.path_rules.len(), + network_allowed = policy.network_allowed, + "sandbox policy constructed" + ); + + policy + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_policy_has_two_rules_and_no_network() { + let policy = SandboxPolicy::for_project(PathBuf::from("/tmp/project")); + assert_eq!(policy.path_rules.len(), 2); + assert_eq!(policy.path_rules[0].access, AccessLevel::ReadOnly); + assert_eq!(policy.path_rules[1].access, AccessLevel::ReadWrite); + assert!(!policy.network_allowed); + } + + #[test] + fn project_dir_is_read_write() { + let dir = PathBuf::from("/home/user/code"); + let policy = SandboxPolicy::for_project(dir.clone()); + assert_eq!(policy.path_rules[1].path, dir); + assert_eq!(policy.path_rules[1].access, AccessLevel::ReadWrite); + } +} diff --git a/src/tools/list_directory.rs b/src/tools/list_directory.rs index 5be154c..d36b0f0 100644 --- a/src/tools/list_directory.rs +++ b/src/tools/list_directory.rs @@ -1,10 +1,10 @@ //! `list_directory` tool: lists entries in a directory within the working directory. -use std::path::Path; +use crate::sandbox::Sandbox; use async_trait::async_trait; -use super::{RiskLevel, Tool, ToolError, ToolOutput, validate_path}; +use super::{RiskLevel, Tool, ToolError, ToolOutput}; /// Lists directory contents. Auto-approved (read-only). pub struct ListDirectory; @@ -39,26 +39,13 @@ impl Tool for ListDirectory { async fn execute( &self, input: &serde_json::Value, - working_dir: &Path, + sandbox: &Sandbox, ) -> Result { let path_str = input["path"] .as_str() .ok_or_else(|| ToolError::InvalidInput("missing 'path' string".to_string()))?; - let canonical = validate_path(working_dir, path_str)?; - - let mut entries: Vec = Vec::new(); - let mut dir = tokio::fs::read_dir(&canonical).await?; - while let Some(entry) = dir.next_entry().await? { - let name = entry.file_name().to_string_lossy().to_string(); - let suffix = if entry.file_type().await?.is_dir() { - "/" - } else { - "" - }; - entries.push(format!("{name}{suffix}")); - } - entries.sort(); + let entries = sandbox.list_directory(path_str).await?; Ok(ToolOutput { content: entries.join("\n"), @@ -70,17 +57,18 @@ impl Tool for ListDirectory { #[cfg(test)] mod tests { use super::*; - use std::fs; + use crate::sandbox::test_sandbox; use tempfile::TempDir; #[tokio::test] async fn list_directory_contents() { let dir = TempDir::new().unwrap(); - fs::write(dir.path().join("a.txt"), "").unwrap(); - fs::create_dir(dir.path().join("subdir")).unwrap(); + std::fs::write(dir.path().join("a.txt"), "").unwrap(); + std::fs::create_dir(dir.path().join("subdir")).unwrap(); let tool = ListDirectory; + let sandbox = test_sandbox(dir.path()); let input = serde_json::json!({"path": "."}); - let out = tool.execute(&input, dir.path()).await.unwrap(); + let out = tool.execute(&input, &sandbox).await.unwrap(); assert!(out.content.contains("a.txt")); assert!(out.content.contains("subdir/")); } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 656c935..e4b0cf7 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -3,17 +3,19 @@ //! All tools implement the [`Tool`] trait. The [`ToolRegistry`] collects them //! and provides lookup by name plus generation of [`ToolDefinition`]s for the //! model provider. +//! +//! Tools execute through [`crate::sandbox::Sandbox`] -- they must never use +//! `std::fs` or `std::process::Command` directly. mod list_directory; mod read_file; mod shell_exec; mod write_file; -use std::path::{Path, PathBuf}; - use async_trait::async_trait; use crate::core::types::ToolDefinition; +use crate::sandbox::Sandbox; /// The output of a tool execution. #[derive(Debug)] @@ -35,6 +37,10 @@ pub enum RiskLevel { /// A tool that the model can invoke. /// +/// All file I/O and process spawning must go through the [`Sandbox`] passed +/// to [`Tool::execute`]. Tools must never use `std::fs` or +/// `std::process::Command` directly. +/// /// The `execute` method is async so that tool implementations can use /// `tokio::fs` and `tokio::process` without blocking a Tokio worker thread. /// `#[async_trait]` desugars the async fn to a boxed future, which is required @@ -49,71 +55,26 @@ pub trait Tool: Send + Sync { fn input_schema(&self) -> serde_json::Value; /// The risk level of this tool. fn risk_level(&self) -> RiskLevel; - /// Execute the tool with the given input, confined to `working_dir`. + /// Execute the tool with the given input, confined by `sandbox`. async fn execute( &self, input: &serde_json::Value, - working_dir: &Path, + sandbox: &Sandbox, ) -> Result; } /// Errors from tool execution. #[derive(Debug, thiserror::Error)] pub enum ToolError { - /// The requested path escapes the working directory. - #[error("path escapes working directory: {0}")] - PathEscape(PathBuf), /// An I/O error during tool execution. #[error("I/O error: {0}")] Io(#[from] std::io::Error), /// A required input field is missing or has the wrong type. #[error("invalid input: {0}")] InvalidInput(String), -} - -/// Validate that `requested` resolves to a path inside `working_dir`. -/// -/// Joins `working_dir` with `requested`, canonicalizes the result (resolving -/// symlinks and `..` components), and checks that the canonical path starts -/// with the canonical working directory. -/// -/// Returns the canonical path on success, or [`ToolError::PathEscape`] if the -/// path would escape the working directory. -pub fn validate_path(working_dir: &Path, requested: &str) -> Result { - let candidate = if Path::new(requested).is_absolute() { - PathBuf::from(requested) - } else { - working_dir.join(requested) - }; - - // For paths that don't exist yet (e.g. write_file creating a new file), - // canonicalize the parent directory and append the filename. - let canonical = if candidate.exists() { - candidate - .canonicalize() - .map_err(|_| ToolError::PathEscape(candidate.clone()))? - } else { - let parent = candidate - .parent() - .ok_or_else(|| ToolError::PathEscape(candidate.clone()))?; - let file_name = candidate - .file_name() - .ok_or_else(|| ToolError::PathEscape(candidate.clone()))?; - let canonical_parent = parent - .canonicalize() - .map_err(|_| ToolError::PathEscape(candidate.clone()))?; - canonical_parent.join(file_name) - }; - - let canonical_root = working_dir - .canonicalize() - .map_err(|_| ToolError::PathEscape(candidate.clone()))?; - - if canonical.starts_with(&canonical_root) { - Ok(canonical) - } else { - Err(ToolError::PathEscape(candidate)) - } + /// A sandbox error during tool execution. + #[error("sandbox error: {0}")] + Sandbox(#[from] crate::sandbox::SandboxError), } /// Collection of available tools with name-based lookup. @@ -161,15 +122,16 @@ impl ToolRegistry { #[cfg(test)] mod tests { use super::*; - use std::fs; + use crate::sandbox::test_sandbox; use tempfile::TempDir; #[test] fn validate_path_allows_subpath() { let dir = TempDir::new().unwrap(); let sub = dir.path().join("sub"); - fs::create_dir(&sub).unwrap(); - let result = validate_path(dir.path(), "sub"); + std::fs::create_dir(&sub).unwrap(); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.validate_path("sub"); assert!(result.is_ok()); assert!( result @@ -181,22 +143,24 @@ mod tests { #[test] fn validate_path_rejects_traversal() { let dir = TempDir::new().unwrap(); - let result = validate_path(dir.path(), "../../../etc/passwd"); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.validate_path("../../../etc/passwd"); assert!(result.is_err()); - assert!(matches!(result, Err(ToolError::PathEscape(_)))); } #[test] fn validate_path_rejects_absolute_outside() { let dir = TempDir::new().unwrap(); - let result = validate_path(dir.path(), "/etc/passwd"); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.validate_path("/etc/passwd"); assert!(result.is_err()); } #[test] fn validate_path_allows_new_file_in_working_dir() { let dir = TempDir::new().unwrap(); - let result = validate_path(dir.path(), "new_file.txt"); + let sandbox = test_sandbox(dir.path()); + let result = sandbox.validate_path("new_file.txt"); assert!(result.is_ok()); } diff --git a/src/tools/read_file.rs b/src/tools/read_file.rs index da2bbf2..5c286d5 100644 --- a/src/tools/read_file.rs +++ b/src/tools/read_file.rs @@ -1,10 +1,10 @@ //! `read_file` tool: reads the contents of a file within the working directory. -use std::path::Path; +use crate::sandbox::Sandbox; use async_trait::async_trait; -use super::{RiskLevel, Tool, ToolError, ToolOutput, validate_path}; +use super::{RiskLevel, Tool, ToolError, ToolOutput}; /// Reads file contents. Auto-approved (read-only). pub struct ReadFile; @@ -39,15 +39,12 @@ impl Tool for ReadFile { async fn execute( &self, input: &serde_json::Value, - working_dir: &Path, + sandbox: &Sandbox, ) -> Result { let path_str = input["path"] .as_str() .ok_or_else(|| ToolError::InvalidInput("missing 'path' string".to_string()))?; - - let canonical = validate_path(working_dir, path_str)?; - let content = tokio::fs::read_to_string(&canonical).await?; - + let content = sandbox.read_file(path_str).await?; Ok(ToolOutput { content, is_error: false, @@ -58,16 +55,17 @@ impl Tool for ReadFile { #[cfg(test)] mod tests { use super::*; - use std::fs; + use crate::sandbox::test_sandbox; use tempfile::TempDir; #[tokio::test] async fn read_existing_file() { let dir = TempDir::new().unwrap(); - fs::write(dir.path().join("hello.txt"), "world").unwrap(); + std::fs::write(dir.path().join("hello.txt"), "world").unwrap(); let tool = ReadFile; + let sandbox = test_sandbox(dir.path()); let input = serde_json::json!({"path": "hello.txt"}); - let out = tool.execute(&input, dir.path()).await.unwrap(); + let out = tool.execute(&input, &sandbox).await.unwrap(); assert_eq!(out.content, "world"); assert!(!out.is_error); } @@ -76,8 +74,9 @@ mod tests { async fn read_nonexistent_file_errors() { let dir = TempDir::new().unwrap(); let tool = ReadFile; + let sandbox = test_sandbox(dir.path()); let input = serde_json::json!({"path": "nope.txt"}); - let result = tool.execute(&input, dir.path()).await; + let result = tool.execute(&input, &sandbox).await; assert!(result.is_err()); } @@ -85,8 +84,9 @@ mod tests { async fn read_path_traversal_rejected() { let dir = TempDir::new().unwrap(); let tool = ReadFile; + let sandbox = test_sandbox(dir.path()); let input = serde_json::json!({"path": "../../../etc/passwd"}); - let result = tool.execute(&input, dir.path()).await; - assert!(matches!(result, Err(ToolError::PathEscape(_)))); + let result = tool.execute(&input, &sandbox).await; + assert!(result.is_err()); } } diff --git a/src/tools/shell_exec.rs b/src/tools/shell_exec.rs index a8c78ce..19ef1a9 100644 --- a/src/tools/shell_exec.rs +++ b/src/tools/shell_exec.rs @@ -1,6 +1,6 @@ //! `shell_exec` tool: runs a shell command within the working directory. -use std::path::Path; +use crate::sandbox::Sandbox; use async_trait::async_trait; @@ -39,43 +39,32 @@ impl Tool for ShellExec { async fn execute( &self, input: &serde_json::Value, - working_dir: &Path, + sandbox: &Sandbox, ) -> Result { let command = input["command"] .as_str() .ok_or_else(|| ToolError::InvalidInput("missing 'command' string".to_string()))?; - let output = tokio::process::Command::new("sh") - .arg("-c") - .arg(command) - .current_dir(working_dir) - .output() - .await?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let output = sandbox.exec_command(command).await?; let mut content = String::new(); - if !stdout.is_empty() { - content.push_str(&stdout); + if !output.stdout.is_empty() { + content.push_str(&output.stdout); } - if !stderr.is_empty() { + if !output.stderr.is_empty() { if !content.is_empty() { content.push('\n'); } content.push_str("[stderr]\n"); - content.push_str(&stderr); + content.push_str(&output.stderr); } if content.is_empty() { content.push_str("(no output)"); } - let is_error = !output.status.success(); + let is_error = !output.success; if is_error { - content.push_str(&format!( - "\n[exit code: {}]", - output.status.code().unwrap_or(-1) - )); + content.push_str(&format!("\n[exit code: {}]", output.exit_code)); } Ok(ToolOutput { content, is_error }) @@ -85,14 +74,16 @@ impl Tool for ShellExec { #[cfg(test)] mod tests { use super::*; + use crate::sandbox::test_sandbox; use tempfile::TempDir; #[tokio::test] async fn shell_exec_echo() { let dir = TempDir::new().unwrap(); let tool = ShellExec; + let sandbox = test_sandbox(dir.path()); let input = serde_json::json!({"command": "echo hello"}); - let out = tool.execute(&input, dir.path()).await.unwrap(); + let out = tool.execute(&input, &sandbox).await.unwrap(); assert!(out.content.contains("hello")); assert!(!out.is_error); } @@ -101,8 +92,9 @@ mod tests { async fn shell_exec_failing_command() { let dir = TempDir::new().unwrap(); let tool = ShellExec; + let sandbox = test_sandbox(dir.path()); let input = serde_json::json!({"command": "false"}); - let out = tool.execute(&input, dir.path()).await.unwrap(); + let out = tool.execute(&input, &sandbox).await.unwrap(); assert!(out.is_error); } } diff --git a/src/tools/write_file.rs b/src/tools/write_file.rs index 315d477..e0276fe 100644 --- a/src/tools/write_file.rs +++ b/src/tools/write_file.rs @@ -1,10 +1,10 @@ //! `write_file` tool: writes content to a file within the working directory. -use std::path::Path; +use crate::sandbox::Sandbox; use async_trait::async_trait; -use super::{RiskLevel, Tool, ToolError, ToolOutput, validate_path}; +use super::{RiskLevel, Tool, ToolError, ToolOutput}; /// Writes content to a file. Requires user approval. pub struct WriteFile; @@ -43,7 +43,7 @@ impl Tool for WriteFile { async fn execute( &self, input: &serde_json::Value, - working_dir: &Path, + sandbox: &Sandbox, ) -> Result { let path_str = input["path"] .as_str() @@ -52,14 +52,7 @@ impl Tool for WriteFile { .as_str() .ok_or_else(|| ToolError::InvalidInput("missing 'content' string".to_string()))?; - let canonical = validate_path(working_dir, path_str)?; - - // Create parent directories if needed. - if let Some(parent) = canonical.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - tokio::fs::write(&canonical, content).await?; + sandbox.write_file(path_str, content).await?; Ok(ToolOutput { content: format!("Wrote {} bytes to {path_str}", content.len()), @@ -71,18 +64,19 @@ impl Tool for WriteFile { #[cfg(test)] mod tests { use super::*; - use std::fs; + use crate::sandbox::test_sandbox; use tempfile::TempDir; #[tokio::test] async fn write_creates_file() { let dir = TempDir::new().unwrap(); let tool = WriteFile; + let sandbox = test_sandbox(dir.path()); let input = serde_json::json!({"path": "out.txt", "content": "hello"}); - let out = tool.execute(&input, dir.path()).await.unwrap(); + let out = tool.execute(&input, &sandbox).await.unwrap(); assert!(!out.is_error); assert_eq!( - fs::read_to_string(dir.path().join("out.txt")).unwrap(), + std::fs::read_to_string(dir.path().join("out.txt")).unwrap(), "hello" ); } @@ -91,8 +85,9 @@ mod tests { async fn write_path_traversal_rejected() { let dir = TempDir::new().unwrap(); let tool = WriteFile; + let sandbox = test_sandbox(dir.path()); let input = serde_json::json!({"path": "../../evil.txt", "content": "bad"}); - let result = tool.execute(&input, dir.path()).await; - assert!(matches!(result, Err(ToolError::PathEscape(_)))); + let result = tool.execute(&input, &sandbox).await; + assert!(result.is_err()); } } diff --git a/src/tui/events.rs b/src/tui/events.rs index 8d6da6a..e39fa58 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -62,6 +62,9 @@ pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver, state: &mu UIEvent::TurnComplete => { debug!("turn complete"); } + UIEvent::NetworkPolicyChanged(allowed) => { + state.network_allowed = allowed; + } UIEvent::Error(msg) => { state .messages diff --git a/src/tui/input.rs b/src/tui/input.rs index 2b60794..fb4e316 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -15,6 +15,8 @@ pub(super) enum LoopControl { ClearHistory, /// The user responded to a tool approval prompt. ToolApproval { tool_use_id: String, approved: bool }, + /// The user ran `:net on` or `:net off`. + SetNetworkPolicy(bool), } /// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect. @@ -200,6 +202,8 @@ fn execute_command(buf: &str, state: &mut AppState) -> Option { state.scroll = 0; Some(LoopControl::ClearHistory) } + "net on" => Some(LoopControl::SetNetworkPolicy(true)), + "net off" => Some(LoopControl::SetNetworkPolicy(false)), other => { state.status_error = Some(format!("Unknown command: {other}")); None @@ -514,6 +518,20 @@ mod tests { assert_eq!(state.status_error.as_deref(), Some("Unknown command: foo")); } + #[test] + fn command_net_on_returns_set_network_policy() { + let mut state = AppState::new(); + let result = execute_command("net on", &mut state); + assert!(matches!(result, Some(LoopControl::SetNetworkPolicy(true)))); + } + + #[test] + fn command_net_off_returns_set_network_policy() { + let mut state = AppState::new(); + let result = execute_command("net off", &mut state); + assert!(matches!(result, Some(LoopControl::SetNetworkPolicy(false)))); + } + #[test] fn status_error_cleared_on_next_keypress() { let mut state = AppState::new(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 7a21385..dcb231c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -74,6 +74,10 @@ pub struct AppState { pub status_error: Option, /// A tool approval request waiting for user input (y/n). pub pending_approval: Option, + /// Whether the sandbox is in yolo (unsandboxed) mode. + pub sandbox_yolo: bool, + /// Whether network access is currently allowed. + pub network_allowed: bool, } impl AppState { @@ -88,6 +92,8 @@ impl AppState { viewport_height: 0, status_error: None, pending_approval: None, + sandbox_yolo: false, + network_allowed: false, } } } @@ -145,10 +151,12 @@ pub fn install_panic_hook() { pub async fn run( action_tx: mpsc::Sender, mut event_rx: mpsc::Receiver, + sandbox_yolo: bool, ) -> Result<(), TuiError> { install_panic_hook(); let mut terminal = init_terminal()?; let mut state = AppState::new(); + state.sandbox_yolo = sandbox_yolo; let mut event_stream = EventStream::new(); loop { @@ -199,6 +207,9 @@ pub async fn run( }) .await; } + Some(input::LoopControl::SetNetworkPolicy(allowed)) => { + let _ = action_tx.send(UserAction::SetNetworkPolicy(allowed)).await; + } None => {} } } diff --git a/src/tui/render.rs b/src/tui/render.rs index e12a636..ab221c7 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -197,6 +197,24 @@ pub(super) fn render(frame: &mut Frame, state: &AppState) { ), }; + // Sandbox indicator: leftmost after mode label. + let (sandbox_label, sandbox_style) = if state.sandbox_yolo { + ( + " UNSANDBOXED ", + Style::default().bg(Color::Red).fg(Color::White), + ) + } else { + let net_label = if state.network_allowed { + " NET:ON " + } else { + " NET:OFF " + }; + ( + net_label, + Style::default().bg(Color::DarkGray).fg(Color::White), + ) + }; + let right_text = if let Some(ref err) = state.status_error { err.clone() } else { @@ -210,12 +228,13 @@ pub(super) fn render(frame: &mut Frame, state: &AppState) { }; let bar_width = chunks[2].width as usize; - let left_len = mode_label.len(); + let left_len = mode_label.len() + sandbox_label.len(); let right_len = right_text.len(); let pad = bar_width.saturating_sub(left_len + right_len); let status_line = Line::from(vec![ Span::styled(mode_label, mode_style), + Span::styled(sandbox_label, sandbox_style), Span::raw(" ".repeat(pad)), Span::styled(right_text, right_style), ]); @@ -439,4 +458,41 @@ mod tests { "expected tool name in overlay" ); } + + #[test] + fn render_status_bar_shows_net_off() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let state = AppState::new(); // network_allowed defaults to false + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!( + all_text.contains("NET:OFF"), + "expected 'NET:OFF' in status bar, got: {all_text:.200}" + ); + } + + #[test] + fn render_status_bar_shows_unsandboxed() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + state.sandbox_yolo = true; + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!( + all_text.contains("UNSANDBOXED"), + "expected 'UNSANDBOXED' in status bar, got: {all_text:.200}" + ); + } }