This commit is contained in:
Drew 2026-02-24 20:33:51 -08:00
parent 797d7564b7
commit 03cfdf31a8
19 changed files with 1315 additions and 238 deletions

32
Cargo.lock generated
View file

@ -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",

View file

@ -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"

153
PLAN.md
View file

@ -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<ContentBlock>`
- 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>`
- `ToolOutput { content: String, is_error: bool }`
- `ToolRegistry`: stores tools, provides `get(name)` and `definitions() -> Vec<ToolDefinition>`
- 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

View file

@ -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")?;

View file

@ -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<P> {
history: ConversationHistory,
provider: P,
tool_registry: ToolRegistry,
working_dir: std::path::PathBuf,
sandbox: Sandbox,
action_rx: mpsc::Receiver<UserAction>,
event_tx: mpsc::Sender<UIEvent>,
/// Messages typed by the user while an approval prompt is open. They are
@ -119,7 +120,7 @@ impl<P: ModelProvider> Orchestrator<P> {
pub fn new(
provider: P,
tool_registry: ToolRegistry,
working_dir: std::path::PathBuf,
sandbox: Sandbox,
action_rx: mpsc::Receiver<UserAction>,
event_tx: mpsc::Sender<UIEvent>,
) -> Self {
@ -127,7 +128,7 @@ impl<P: ModelProvider> Orchestrator<P> {
history: ConversationHistory::new(),
provider,
tool_registry,
working_dir,
sandbox,
action_rx,
event_tx,
queued_messages: Vec::new(),
@ -340,7 +341,7 @@ impl<P: ModelProvider> Orchestrator<P> {
// 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<P: ModelProvider> Orchestrator<P> {
// 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<UserAction>,
event_tx: mpsc::Sender<UIEvent>,
) -> Orchestrator<P> {
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::<UIEvent>(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::<UserAction>(16);
let (event_tx, mut event_rx) = mpsc::channel::<UIEvent>(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<Item = StreamEvent> + Send + 'a {
futures::stream::empty()
}
}
let (action_tx, action_rx) = mpsc::channel::<UserAction>(8);
let (event_tx, mut event_rx) = mpsc::channel::<UIEvent>(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();
}
}

View file

@ -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.

View file

@ -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 <path>
/// Usage: skate --project-dir <path> [--yolo]
/// ```
///
/// `ANTHROPIC_API_KEY` must be set in the environment.
/// `RUST_LOG` controls log verbosity (default: `info`); logs go to
/// `<project-dir>/.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<PathBuf> {
let mut args = std::env::args().skip(1); // skip the binary name
/// Accepts `--project-dir <path>` (required) and `--yolo` (optional).
fn parse_cli() -> anyhow::Result<Cli> {
let mut project_dir: Option<PathBuf> = None;
let mut yolo = false;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--project-dir" {
match arg.as_str() {
"--project-dir" => {
let value = args
.next()
.context("--project-dir requires a path argument")?;
return Ok(PathBuf::from(value));
project_dir = Some(PathBuf::from(value));
}
"--yolo" => {
yolo = true;
}
_ => {}
}
}
anyhow::bail!("Usage: skate --project-dir <path>")
let project_dir = project_dir.context("Usage: skate --project-dir <path> [--yolo]")?;
Ok(Cli { project_dir, yolo })
}

158
src/sandbox/landlock.rs Normal file
View file

@ -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,
}

649
src/sandbox/mod.rs Normal file
View file

@ -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<PathBuf, SandboxError> {
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<String, SandboxError> {
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<Vec<String>, SandboxError> {
let canonical = self.validate_path(requested)?;
let entries = tokio::task::spawn_blocking(move || {
let mut entries: Vec<String> = 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::<Vec<String>, 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 <command>` 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<CommandOutput, SandboxError> {
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, std::io::Error>(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
);
}
}

101
src/sandbox/policy.rs Normal file
View file

@ -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<PathRule>,
/// 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);
}
}

View file

@ -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<ToolOutput, ToolError> {
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<String> = 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/"));
}

View file

@ -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<ToolOutput, ToolError>;
}
/// 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<PathBuf, ToolError> {
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());
}

View file

@ -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<ToolOutput, ToolError> {
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());
}
}

View file

@ -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<ToolOutput, ToolError> {
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);
}
}

View file

@ -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<ToolOutput, ToolError> {
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());
}
}

View file

@ -62,6 +62,9 @@ pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mu
UIEvent::TurnComplete => {
debug!("turn complete");
}
UIEvent::NetworkPolicyChanged(allowed) => {
state.network_allowed = allowed;
}
UIEvent::Error(msg) => {
state
.messages

View file

@ -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<LoopControl> {
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();

View file

@ -74,6 +74,10 @@ pub struct AppState {
pub status_error: Option<String>,
/// A tool approval request waiting for user input (y/n).
pub pending_approval: Option<events::PendingApproval>,
/// 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<UserAction>,
mut event_rx: mpsc::Receiver<UIEvent>,
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 => {}
}
}

View file

@ -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}"
);
}
}