Use Landlock to restrict bash calls. (#5)

https://docs.kernel.org/userspace-api/landlock.html
Reviewed-on: #5
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
Drew 2026-03-02 03:51:46 +00:00 committed by Drew
parent 797d7564b7
commit 7efc6705d3
19 changed files with 1315 additions and 238 deletions

32
Cargo.lock generated
View file

@ -423,6 +423,26 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -1055,6 +1075,17 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" 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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -2044,6 +2075,7 @@ dependencies = [
"async-trait", "async-trait",
"crossterm", "crossterm",
"futures", "futures",
"landlock",
"ratatui", "ratatui",
"reqwest", "reqwest",
"serde", "serde",

View file

@ -16,6 +16,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.13", features = ["stream", "json"] } reqwest = { version = "0.13", features = ["stream", "json"] }
futures = "0.3" futures = "0.3"
async-trait = "0.1" async-trait = "0.1"
landlock = "0.4"
[dev-dependencies] [dev-dependencies]
tempfile = "3.26.0" tempfile = "3.26.0"

153
PLAN.md
View file

@ -1,81 +1,96 @@
# Implementation Plan # Implementation Plan
## Phase 3: Tool Execution ## Phase 4: Sandboxing
### Step 3.1: Enrich the content model ### Step 4.1: Create sandbox module with policy types and tracing foundation
- Replace `ConversationMessage { role, content: String }` with content-block model - `SandboxPolicy` struct: read-only paths, read-write paths, network allowed bool
- Define `ContentBlock` enum: `Text(String)`, `ToolUse { id, name, input: Value }`, `ToolResult { tool_use_id, content: String, is_error: bool }` - `Sandbox` struct holding policy + working dir
- Change `ConversationMessage.content` from `String` to `Vec<ContentBlock>` - Add `tracing` spans and events throughout from the start:
- Add `ConversationMessage::text(role, s)` helper to keep existing call sites clean - `#[instrument]` on all public `Sandbox` methods
- Update serialization, orchestrator, tests, TUI display - `debug!` on policy construction with path lists
- **Files:** `src/core/types.rs`, `src/core/history.rs` - `info!` on sandbox creation with full policy summary
- **Done when:** `cargo test` passes with new model; all existing tests updated - 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 ### Step 4.2: Landlock policy builder with startup gate and tracing
- Add `ToolDefinition { name, description, input_schema: Value }` (provider-agnostic) - Translate `SandboxPolicy` into Landlock ruleset using `landlock` crate
- Extend `ModelProvider::stream` to accept `&[ToolDefinition]` - Kernel requirements:
- Include `"tools"` array in Claude provider request body - **ABI v4 (kernel 6.7+):** minimum required -- provides both filesystem and network sandboxing
- **Files:** `src/provider/mod.rs`, `src/provider/claude.rs` - ABI 1-3 have filesystem only, no network restriction -- tools could exfiltrate data freely
- **Done when:** API responses contain `tool_use` content blocks in raw SSE stream - 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 ### Step 4.3: Sandbox file I/O API with operation tracing
- Add `StreamEvent::ToolUseStart { id, name }`, `ToolUseInputDelta(String)`, `ToolUseDone` - `Sandbox::read_file`, `Sandbox::write_file`, `Sandbox::list_directory`
- Handle `content_block_start` (type "tool_use"), `content_block_delta` (type "input_json_delta"), `content_block_stop` for tool blocks - Move `validate_path` from `src/tools/mod.rs` into sandbox
- Track current block type state in SSE parser - Tracing:
- **Files:** `src/provider/claude.rs`, `src/core/types.rs` - `debug!` on every file operation: requested path, canonical path, allowed/denied
- **Done when:** Unit test with recorded tool-use SSE fixture asserts correct StreamEvent sequence - `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 ### Step 4.4: Sandbox command execution with process tracing
- Accumulate `ToolUseInputDelta` fragments into JSON buffer per tool-use id - `Sandbox::exec_command(cmd, args, working_dir)` spawns child process with Landlock applied
- On `ToolUseDone`, parse JSON into `ContentBlock::ToolUse` - Captures stdout/stderr, enforces timeout
- After `StreamEvent::Done`, if assistant message contains ToolUse blocks, enter tool-execution phase - Tracing:
- **Files:** `src/core/orchestrator.rs` - `info!` on command spawn: command, args, working_dir, timeout
- **Done when:** Unit test with mock provider emitting tool-use events produces correct ContentBlocks - `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 ### Step 4.5: Wire tools through Sandbox
- `Tool` trait: `name()`, `description()`, `input_schema() -> Value`, `execute(input: Value, working_dir: &Path) -> Result<ToolOutput>` - Change `Tool::execute` signature to accept `&Sandbox` instead of (or in addition to) `&Path`
- `ToolOutput { content: String, is_error: bool }` - Update all 4 built-in tools to call `Sandbox` methods instead of `std::fs`/`std::process::Command`
- `ToolRegistry`: stores tools, provides `get(name)` and `definitions() -> Vec<ToolDefinition>` - Remove direct `std::fs` usage from tool implementations
- Risk level: `AutoApprove` (reads), `RequiresApproval` (writes/shell) - Update `ToolRegistry` and orchestrator to pass `Sandbox`
- Implement: `read_file` (auto), `list_directory` (auto), `write_file` (approval), `shell_exec` (approval) - Tracing: tools now inherit sandbox spans automatically via `#[instrument]`
- Path validation: `canonicalize` + `starts_with` check, reject paths outside working dir (no Landlock yet) - **Files:** `src/tools/*.rs`, `src/tools/mod.rs`, `src/core/orchestrator.rs`
- **Files:** New `src/tools/` module: `mod.rs`, `read_file.rs`, `write_file.rs`, `list_directory.rs`, `shell_exec.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
- **Done when:** Unit tests pass for each tool in temp dirs; path traversal rejected
### Step 3.6: Approval gate (TUI <-> core) ### Step 4.6: Network toggle
- New `UIEvent::ToolApprovalRequest { tool_use_id, tool_name, input_summary }` - `network_allowed: bool` in `SandboxPolicy`
- New `UserAction::ToolApprovalResponse { tool_use_id, approved: bool }` - `:net on/off` TUI command parsed in input handler, sent as `UserAction::SetNetworkPolicy(bool)`
- Orchestrator: check risk level -> auto-approve or send approval request and await response - Orchestrator updates `Sandbox` policy. Status bar shows network state.
- Denied tools return `ToolResult { is_error: true }` with denial message - Only available when Landlock ABI >= 4 (kernel 6.7+); command hidden otherwise
- TUI: render approval prompt overlay with y/n keybindings - Status bar shows: network state when available, "UNSANDBOXED" in `--yolo` mode
- **Files:** `src/core/types.rs`, `src/core/orchestrator.rs`, `src/tui/events.rs`, `src/tui/input.rs`, `src/tui/render.rs` - Tracing: `info!` on network policy change
- **Done when:** Integration test: mock provider + mock TUI channel verifies approval flow - **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 ### Step 4.7: Integration tests
- After executing tool calls: append assistant message (with ToolUse blocks) to history, append user message with ToolResult blocks, re-call provider - Tools + Sandbox in tempdir: write confinement, path traversal rejection, shell command confinement
- Loop: model may respond with more tool calls or text - Skip Landlock-specific assertions on ABI < 4
- Cap at max iterations (25) to prevent runaway - Test `--yolo` mode: sandbox constructed but no kernel enforcement
- **Files:** `src/core/orchestrator.rs` - Test startup gate: verify error on ABI < 4 without `--yolo`
- **Done when:** Integration test: mock provider returns tool-use then text; orchestrator makes two calls. Max-iteration cap tested. - 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 ### Phase 4 verification (end-to-end)
- 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)
1. `cargo test` -- all tests pass 1. `cargo test` -- all tests pass
2. `cargo clippy -- -D warnings` -- zero warnings 2. `cargo clippy -- -D warnings` -- zero warnings
3. `cargo run -- --project-dir .` -- ask Claude to read a file, approve, see contents 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 -- approve, verify written 4. Ask Claude to write a file outside project dir -- sandbox denies with `warn!` log
5. Ask Claude to run a shell command -- approve, verify output 5. Ask Claude to run a shell command -- observe command spawn/completion trace
6. Deny an approval -- Claude gets denial and responds gracefully 6. `:net off` then ask for network access -- verify blocked
7. Without `--yolo` on ABI < 4: verify startup refuses with clear error
## Phase 4: Sandboxing 8. With `--yolo`: verify startup succeeds, "UNSANDBOXED" in status bar, `warn!` in logs
- 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

View file

@ -27,6 +27,8 @@ use tokio::sync::mpsc;
use crate::core::orchestrator::Orchestrator; use crate::core::orchestrator::Orchestrator;
use crate::core::types::{UIEvent, UserAction}; use crate::core::types::{UIEvent, UserAction};
use crate::provider::ClaudeProvider; use crate::provider::ClaudeProvider;
use crate::sandbox::policy::SandboxPolicy;
use crate::sandbox::{EnforcementMode, Sandbox};
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
/// Model ID sent on every request. /// 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. /// 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 /// 5. Run the TUI event loop on the calling task (crossterm must not be used
/// from multiple threads concurrently). /// 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 ------------------------------------------------------------------ // -- Tracing ------------------------------------------------------------------
workspace::SkateDir::open(project_dir)?.init_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 ----------------------------------------------------------------- // -- Provider -----------------------------------------------------------------
let provider = ClaudeProvider::from_env(MODEL) let provider = ClaudeProvider::from_env(MODEL)
@ -71,20 +89,14 @@ pub async fn run(project_dir: &Path) -> anyhow::Result<()> {
// -- Tools & Orchestrator (background task) ------------------------------------ // -- Tools & Orchestrator (background task) ------------------------------------
let tool_registry = ToolRegistry::default_tools(); let tool_registry = ToolRegistry::default_tools();
let orch = Orchestrator::new( let orch = Orchestrator::new(provider, tool_registry, sandbox, action_rx, event_tx);
provider,
tool_registry,
project_dir.to_path_buf(),
action_rx,
event_tx,
);
tokio::spawn(orch.run()); tokio::spawn(orch.run());
// -- TUI (foreground task) ---------------------------------------------------- // -- TUI (foreground task) ----------------------------------------------------
// `action_tx` is moved into tui::run; when it returns (user quit), the // `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 // sender is dropped, which closes the channel and causes the orchestrator's
// recv() loop to exit. // recv() loop to exit.
crate::tui::run(action_tx, event_rx) crate::tui::run(action_tx, event_rx, yolo)
.await .await
.context("TUI error")?; .context("TUI error")?;

View file

@ -7,6 +7,7 @@ use crate::core::types::{
ContentBlock, ConversationMessage, Role, StreamEvent, ToolDefinition, UIEvent, UserAction, ContentBlock, ConversationMessage, Role, StreamEvent, ToolDefinition, UIEvent, UserAction,
}; };
use crate::provider::ModelProvider; use crate::provider::ModelProvider;
use crate::sandbox::Sandbox;
use crate::tools::{RiskLevel, ToolOutput, ToolRegistry}; use crate::tools::{RiskLevel, ToolOutput, ToolRegistry};
/// Accumulates data for a single tool-use block while it is being streamed. /// Accumulates data for a single tool-use block while it is being streamed.
@ -106,7 +107,7 @@ pub struct Orchestrator<P> {
history: ConversationHistory, history: ConversationHistory,
provider: P, provider: P,
tool_registry: ToolRegistry, tool_registry: ToolRegistry,
working_dir: std::path::PathBuf, sandbox: Sandbox,
action_rx: mpsc::Receiver<UserAction>, action_rx: mpsc::Receiver<UserAction>,
event_tx: mpsc::Sender<UIEvent>, event_tx: mpsc::Sender<UIEvent>,
/// Messages typed by the user while an approval prompt is open. They are /// 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( pub fn new(
provider: P, provider: P,
tool_registry: ToolRegistry, tool_registry: ToolRegistry,
working_dir: std::path::PathBuf, sandbox: Sandbox,
action_rx: mpsc::Receiver<UserAction>, action_rx: mpsc::Receiver<UserAction>,
event_tx: mpsc::Sender<UIEvent>, event_tx: mpsc::Sender<UIEvent>,
) -> Self { ) -> Self {
@ -127,7 +128,7 @@ impl<P: ModelProvider> Orchestrator<P> {
history: ConversationHistory::new(), history: ConversationHistory::new(),
provider, provider,
tool_registry, tool_registry,
working_dir, sandbox,
action_rx, action_rx,
event_tx, event_tx,
queued_messages: Vec::new(), queued_messages: Vec::new(),
@ -340,7 +341,7 @@ impl<P: ModelProvider> Orchestrator<P> {
// Re-fetch tool for execution (borrow was released above). // Re-fetch tool for execution (borrow was released above).
let tool = self.tool_registry.get(tool_name).unwrap(); 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) => { Ok(output) => {
let _ = self let _ = self
.event_tx .event_tx
@ -409,6 +410,14 @@ impl<P: ModelProvider> Orchestrator<P> {
// not in the main action loop. If one arrives here it's stale. // not in the main action loop. If one arrives here it's stale.
UserAction::ToolApprovalResponse { .. } => {} UserAction::ToolApprovalResponse { .. } => {}
UserAction::SetNetworkPolicy(allowed) => {
self.sandbox.set_network_allowed(allowed);
let _ = self
.event_tx
.send(UIEvent::NetworkPolicyChanged(allowed))
.await;
}
UserAction::SendMessage(text) => { UserAction::SendMessage(text) => {
self.history self.history
.push(ConversationMessage::text(Role::User, text)); .push(ConversationMessage::text(Role::User, text));
@ -467,10 +476,17 @@ mod tests {
action_rx: mpsc::Receiver<UserAction>, action_rx: mpsc::Receiver<UserAction>,
event_tx: mpsc::Sender<UIEvent>, event_tx: mpsc::Sender<UIEvent>,
) -> Orchestrator<P> { ) -> 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( Orchestrator::new(
provider, provider,
ToolRegistry::empty(), ToolRegistry::empty(),
std::path::PathBuf::from("/tmp"), sandbox,
action_rx, action_rx,
event_tx, event_tx,
) )
@ -717,12 +733,19 @@ mod tests {
let (event_tx, mut event_rx) = mpsc::channel::<UIEvent>(32); let (event_tx, mut event_rx) = mpsc::channel::<UIEvent>(32);
// Use a real ToolRegistry so read_file works. // Use a real ToolRegistry so read_file works.
use crate::sandbox::policy::SandboxPolicy;
use crate::sandbox::{EnforcementMode, Sandbox};
let dir = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").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( let orch = Orchestrator::new(
MultiCallMock { turns }, MultiCallMock { turns },
ToolRegistry::default_tools(), ToolRegistry::default_tools(),
dir.path().to_path_buf(), sandbox,
action_rx, action_rx,
event_tx, event_tx,
); );
@ -873,14 +896,7 @@ mod tests {
let (action_tx, action_rx) = mpsc::channel::<UserAction>(16); let (action_tx, action_rx) = mpsc::channel::<UserAction>(16);
let (event_tx, mut event_rx) = mpsc::channel::<UIEvent>(64); let (event_tx, mut event_rx) = mpsc::channel::<UIEvent>(64);
let dir = tempfile::TempDir::new().unwrap(); let orch = test_orchestrator(MultiCallMock { turns }, action_rx, event_tx);
let orch = Orchestrator::new(
MultiCallMock { turns },
ToolRegistry::default_tools(),
dir.path().to_path_buf(),
action_rx,
event_tx,
);
let handle = tokio::spawn(orch.run()); let handle = tokio::spawn(orch.run());
// Start turn 1 -- orchestrator will block on approval. // Start turn 1 -- orchestrator will block on approval.
@ -927,4 +943,45 @@ mod tests {
action_tx.send(UserAction::Quit).await.unwrap(); action_tx.send(UserAction::Quit).await.unwrap();
handle.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, Quit,
/// The user has requested to clear conversation history. /// The user has requested to clear conversation history.
ClearHistory, ClearHistory,
/// The user has toggled the network policy via `:net on/off`.
SetNetworkPolicy(bool),
} }
/// An event sent from the core orchestrator to the TUI. /// An event sent from the core orchestrator to the TUI.
@ -56,6 +58,8 @@ pub enum UIEvent {
output_summary: String, output_summary: String,
is_error: bool, is_error: bool,
}, },
/// The network policy has changed (sent after `:net on/off` is processed).
NetworkPolicyChanged(bool),
/// The current assistant turn has completed. /// The current assistant turn has completed.
TurnComplete, TurnComplete,
/// An error to display to the user. /// An error to display to the user.

View file

@ -1,6 +1,7 @@
mod app; mod app;
mod core; mod core;
mod provider; mod provider;
mod sandbox;
mod tools; mod tools;
mod tui; mod tui;
@ -11,30 +12,50 @@ use anyhow::Context;
/// Run skate against a project directory. /// Run skate against a project directory.
/// ///
/// ```text /// ```text
/// Usage: skate --project-dir <path> /// Usage: skate --project-dir <path> [--yolo]
/// ``` /// ```
/// ///
/// `ANTHROPIC_API_KEY` must be set in the environment. /// `ANTHROPIC_API_KEY` must be set in the environment.
/// `RUST_LOG` controls log verbosity (default: `info`); logs go to /// `RUST_LOG` controls log verbosity (default: `info`); logs go to
/// `<project-dir>/.skate/skate.log`. /// `<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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let project_dir = parse_project_dir()?; let cli = parse_cli()?;
app::run(&project_dir).await 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. /// Accepts `--project-dir <path>` (required) and `--yolo` (optional).
fn parse_project_dir() -> anyhow::Result<PathBuf> { fn parse_cli() -> anyhow::Result<Cli> {
let mut args = std::env::args().skip(1); // skip the binary name 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() { while let Some(arg) = args.next() {
if arg == "--project-dir" { match arg.as_str() {
let value = args "--project-dir" => {
.next() let value = args
.context("--project-dir requires a path argument")?; .next()
return Ok(PathBuf::from(value)); .context("--project-dir requires a path argument")?;
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. //! `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 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). /// Lists directory contents. Auto-approved (read-only).
pub struct ListDirectory; pub struct ListDirectory;
@ -39,26 +39,13 @@ impl Tool for ListDirectory {
async fn execute( async fn execute(
&self, &self,
input: &serde_json::Value, input: &serde_json::Value,
working_dir: &Path, sandbox: &Sandbox,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = input["path"] let path_str = input["path"]
.as_str() .as_str()
.ok_or_else(|| ToolError::InvalidInput("missing 'path' string".to_string()))?; .ok_or_else(|| ToolError::InvalidInput("missing 'path' string".to_string()))?;
let canonical = validate_path(working_dir, path_str)?; let entries = sandbox.list_directory(path_str).await?;
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();
Ok(ToolOutput { Ok(ToolOutput {
content: entries.join("\n"), content: entries.join("\n"),
@ -70,17 +57,18 @@ impl Tool for ListDirectory {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs; use crate::sandbox::test_sandbox;
use tempfile::TempDir; use tempfile::TempDir;
#[tokio::test] #[tokio::test]
async fn list_directory_contents() { async fn list_directory_contents() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
fs::write(dir.path().join("a.txt"), "").unwrap(); std::fs::write(dir.path().join("a.txt"), "").unwrap();
fs::create_dir(dir.path().join("subdir")).unwrap(); std::fs::create_dir(dir.path().join("subdir")).unwrap();
let tool = ListDirectory; let tool = ListDirectory;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"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("a.txt"));
assert!(out.content.contains("subdir/")); assert!(out.content.contains("subdir/"));
} }

View file

@ -3,17 +3,19 @@
//! All tools implement the [`Tool`] trait. The [`ToolRegistry`] collects them //! All tools implement the [`Tool`] trait. The [`ToolRegistry`] collects them
//! and provides lookup by name plus generation of [`ToolDefinition`]s for the //! and provides lookup by name plus generation of [`ToolDefinition`]s for the
//! model provider. //! model provider.
//!
//! Tools execute through [`crate::sandbox::Sandbox`] -- they must never use
//! `std::fs` or `std::process::Command` directly.
mod list_directory; mod list_directory;
mod read_file; mod read_file;
mod shell_exec; mod shell_exec;
mod write_file; mod write_file;
use std::path::{Path, PathBuf};
use async_trait::async_trait; use async_trait::async_trait;
use crate::core::types::ToolDefinition; use crate::core::types::ToolDefinition;
use crate::sandbox::Sandbox;
/// The output of a tool execution. /// The output of a tool execution.
#[derive(Debug)] #[derive(Debug)]
@ -35,6 +37,10 @@ pub enum RiskLevel {
/// A tool that the model can invoke. /// 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 /// The `execute` method is async so that tool implementations can use
/// `tokio::fs` and `tokio::process` without blocking a Tokio worker thread. /// `tokio::fs` and `tokio::process` without blocking a Tokio worker thread.
/// `#[async_trait]` desugars the async fn to a boxed future, which is required /// `#[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; fn input_schema(&self) -> serde_json::Value;
/// The risk level of this tool. /// The risk level of this tool.
fn risk_level(&self) -> RiskLevel; 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( async fn execute(
&self, &self,
input: &serde_json::Value, input: &serde_json::Value,
working_dir: &Path, sandbox: &Sandbox,
) -> Result<ToolOutput, ToolError>; ) -> Result<ToolOutput, ToolError>;
} }
/// Errors from tool execution. /// Errors from tool execution.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ToolError { 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. /// An I/O error during tool execution.
#[error("I/O error: {0}")] #[error("I/O error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
/// A required input field is missing or has the wrong type. /// A required input field is missing or has the wrong type.
#[error("invalid input: {0}")] #[error("invalid input: {0}")]
InvalidInput(String), InvalidInput(String),
} /// A sandbox error during tool execution.
#[error("sandbox error: {0}")]
/// Validate that `requested` resolves to a path inside `working_dir`. Sandbox(#[from] crate::sandbox::SandboxError),
///
/// 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))
}
} }
/// Collection of available tools with name-based lookup. /// Collection of available tools with name-based lookup.
@ -161,15 +122,16 @@ impl ToolRegistry {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs; use crate::sandbox::test_sandbox;
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
fn validate_path_allows_subpath() { fn validate_path_allows_subpath() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let sub = dir.path().join("sub"); let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap(); std::fs::create_dir(&sub).unwrap();
let result = validate_path(dir.path(), "sub"); let sandbox = test_sandbox(dir.path());
let result = sandbox.validate_path("sub");
assert!(result.is_ok()); assert!(result.is_ok());
assert!( assert!(
result result
@ -181,22 +143,24 @@ mod tests {
#[test] #[test]
fn validate_path_rejects_traversal() { fn validate_path_rejects_traversal() {
let dir = TempDir::new().unwrap(); 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!(result.is_err());
assert!(matches!(result, Err(ToolError::PathEscape(_))));
} }
#[test] #[test]
fn validate_path_rejects_absolute_outside() { fn validate_path_rejects_absolute_outside() {
let dir = TempDir::new().unwrap(); 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!(result.is_err());
} }
#[test] #[test]
fn validate_path_allows_new_file_in_working_dir() { fn validate_path_allows_new_file_in_working_dir() {
let dir = TempDir::new().unwrap(); 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()); assert!(result.is_ok());
} }

View file

@ -1,10 +1,10 @@
//! `read_file` tool: reads the contents of a file within the working directory. //! `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 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). /// Reads file contents. Auto-approved (read-only).
pub struct ReadFile; pub struct ReadFile;
@ -39,15 +39,12 @@ impl Tool for ReadFile {
async fn execute( async fn execute(
&self, &self,
input: &serde_json::Value, input: &serde_json::Value,
working_dir: &Path, sandbox: &Sandbox,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = input["path"] let path_str = input["path"]
.as_str() .as_str()
.ok_or_else(|| ToolError::InvalidInput("missing 'path' string".to_string()))?; .ok_or_else(|| ToolError::InvalidInput("missing 'path' string".to_string()))?;
let content = sandbox.read_file(path_str).await?;
let canonical = validate_path(working_dir, path_str)?;
let content = tokio::fs::read_to_string(&canonical).await?;
Ok(ToolOutput { Ok(ToolOutput {
content, content,
is_error: false, is_error: false,
@ -58,16 +55,17 @@ impl Tool for ReadFile {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs; use crate::sandbox::test_sandbox;
use tempfile::TempDir; use tempfile::TempDir;
#[tokio::test] #[tokio::test]
async fn read_existing_file() { async fn read_existing_file() {
let dir = TempDir::new().unwrap(); 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 tool = ReadFile;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"path": "hello.txt"}); 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_eq!(out.content, "world");
assert!(!out.is_error); assert!(!out.is_error);
} }
@ -76,8 +74,9 @@ mod tests {
async fn read_nonexistent_file_errors() { async fn read_nonexistent_file_errors() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let tool = ReadFile; let tool = ReadFile;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"path": "nope.txt"}); 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()); assert!(result.is_err());
} }
@ -85,8 +84,9 @@ mod tests {
async fn read_path_traversal_rejected() { async fn read_path_traversal_rejected() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let tool = ReadFile; let tool = ReadFile;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"path": "../../../etc/passwd"}); let input = serde_json::json!({"path": "../../../etc/passwd"});
let result = tool.execute(&input, dir.path()).await; let result = tool.execute(&input, &sandbox).await;
assert!(matches!(result, Err(ToolError::PathEscape(_)))); assert!(result.is_err());
} }
} }

View file

@ -1,6 +1,6 @@
//! `shell_exec` tool: runs a shell command within the working directory. //! `shell_exec` tool: runs a shell command within the working directory.
use std::path::Path; use crate::sandbox::Sandbox;
use async_trait::async_trait; use async_trait::async_trait;
@ -39,43 +39,32 @@ impl Tool for ShellExec {
async fn execute( async fn execute(
&self, &self,
input: &serde_json::Value, input: &serde_json::Value,
working_dir: &Path, sandbox: &Sandbox,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let command = input["command"] let command = input["command"]
.as_str() .as_str()
.ok_or_else(|| ToolError::InvalidInput("missing 'command' string".to_string()))?; .ok_or_else(|| ToolError::InvalidInput("missing 'command' string".to_string()))?;
let output = tokio::process::Command::new("sh") let output = sandbox.exec_command(command).await?;
.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 mut content = String::new(); let mut content = String::new();
if !stdout.is_empty() { if !output.stdout.is_empty() {
content.push_str(&stdout); content.push_str(&output.stdout);
} }
if !stderr.is_empty() { if !output.stderr.is_empty() {
if !content.is_empty() { if !content.is_empty() {
content.push('\n'); content.push('\n');
} }
content.push_str("[stderr]\n"); content.push_str("[stderr]\n");
content.push_str(&stderr); content.push_str(&output.stderr);
} }
if content.is_empty() { if content.is_empty() {
content.push_str("(no output)"); content.push_str("(no output)");
} }
let is_error = !output.status.success(); let is_error = !output.success;
if is_error { if is_error {
content.push_str(&format!( content.push_str(&format!("\n[exit code: {}]", output.exit_code));
"\n[exit code: {}]",
output.status.code().unwrap_or(-1)
));
} }
Ok(ToolOutput { content, is_error }) Ok(ToolOutput { content, is_error })
@ -85,14 +74,16 @@ impl Tool for ShellExec {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::sandbox::test_sandbox;
use tempfile::TempDir; use tempfile::TempDir;
#[tokio::test] #[tokio::test]
async fn shell_exec_echo() { async fn shell_exec_echo() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let tool = ShellExec; let tool = ShellExec;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"command": "echo hello"}); 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.content.contains("hello"));
assert!(!out.is_error); assert!(!out.is_error);
} }
@ -101,8 +92,9 @@ mod tests {
async fn shell_exec_failing_command() { async fn shell_exec_failing_command() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let tool = ShellExec; let tool = ShellExec;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"command": "false"}); 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); assert!(out.is_error);
} }
} }

View file

@ -1,10 +1,10 @@
//! `write_file` tool: writes content to a file within the working directory. //! `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 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. /// Writes content to a file. Requires user approval.
pub struct WriteFile; pub struct WriteFile;
@ -43,7 +43,7 @@ impl Tool for WriteFile {
async fn execute( async fn execute(
&self, &self,
input: &serde_json::Value, input: &serde_json::Value,
working_dir: &Path, sandbox: &Sandbox,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = input["path"] let path_str = input["path"]
.as_str() .as_str()
@ -52,14 +52,7 @@ impl Tool for WriteFile {
.as_str() .as_str()
.ok_or_else(|| ToolError::InvalidInput("missing 'content' string".to_string()))?; .ok_or_else(|| ToolError::InvalidInput("missing 'content' string".to_string()))?;
let canonical = validate_path(working_dir, path_str)?; sandbox.write_file(path_str, content).await?;
// Create parent directories if needed.
if let Some(parent) = canonical.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&canonical, content).await?;
Ok(ToolOutput { Ok(ToolOutput {
content: format!("Wrote {} bytes to {path_str}", content.len()), content: format!("Wrote {} bytes to {path_str}", content.len()),
@ -71,18 +64,19 @@ impl Tool for WriteFile {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs; use crate::sandbox::test_sandbox;
use tempfile::TempDir; use tempfile::TempDir;
#[tokio::test] #[tokio::test]
async fn write_creates_file() { async fn write_creates_file() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let tool = WriteFile; let tool = WriteFile;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"path": "out.txt", "content": "hello"}); 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!(!out.is_error);
assert_eq!( assert_eq!(
fs::read_to_string(dir.path().join("out.txt")).unwrap(), std::fs::read_to_string(dir.path().join("out.txt")).unwrap(),
"hello" "hello"
); );
} }
@ -91,8 +85,9 @@ mod tests {
async fn write_path_traversal_rejected() { async fn write_path_traversal_rejected() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let tool = WriteFile; let tool = WriteFile;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"path": "../../evil.txt", "content": "bad"}); let input = serde_json::json!({"path": "../../evil.txt", "content": "bad"});
let result = tool.execute(&input, dir.path()).await; let result = tool.execute(&input, &sandbox).await;
assert!(matches!(result, Err(ToolError::PathEscape(_)))); 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 => { UIEvent::TurnComplete => {
debug!("turn complete"); debug!("turn complete");
} }
UIEvent::NetworkPolicyChanged(allowed) => {
state.network_allowed = allowed;
}
UIEvent::Error(msg) => { UIEvent::Error(msg) => {
state state
.messages .messages

View file

@ -15,6 +15,8 @@ pub(super) enum LoopControl {
ClearHistory, ClearHistory,
/// The user responded to a tool approval prompt. /// The user responded to a tool approval prompt.
ToolApproval { tool_use_id: String, approved: bool }, 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. /// 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; state.scroll = 0;
Some(LoopControl::ClearHistory) Some(LoopControl::ClearHistory)
} }
"net on" => Some(LoopControl::SetNetworkPolicy(true)),
"net off" => Some(LoopControl::SetNetworkPolicy(false)),
other => { other => {
state.status_error = Some(format!("Unknown command: {other}")); state.status_error = Some(format!("Unknown command: {other}"));
None None
@ -514,6 +518,20 @@ mod tests {
assert_eq!(state.status_error.as_deref(), Some("Unknown command: foo")); 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] #[test]
fn status_error_cleared_on_next_keypress() { fn status_error_cleared_on_next_keypress() {
let mut state = AppState::new(); let mut state = AppState::new();

View file

@ -74,6 +74,10 @@ pub struct AppState {
pub status_error: Option<String>, pub status_error: Option<String>,
/// A tool approval request waiting for user input (y/n). /// A tool approval request waiting for user input (y/n).
pub pending_approval: Option<events::PendingApproval>, 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 { impl AppState {
@ -88,6 +92,8 @@ impl AppState {
viewport_height: 0, viewport_height: 0,
status_error: None, status_error: None,
pending_approval: None, pending_approval: None,
sandbox_yolo: false,
network_allowed: false,
} }
} }
} }
@ -145,10 +151,12 @@ pub fn install_panic_hook() {
pub async fn run( pub async fn run(
action_tx: mpsc::Sender<UserAction>, action_tx: mpsc::Sender<UserAction>,
mut event_rx: mpsc::Receiver<UIEvent>, mut event_rx: mpsc::Receiver<UIEvent>,
sandbox_yolo: bool,
) -> Result<(), TuiError> { ) -> Result<(), TuiError> {
install_panic_hook(); install_panic_hook();
let mut terminal = init_terminal()?; let mut terminal = init_terminal()?;
let mut state = AppState::new(); let mut state = AppState::new();
state.sandbox_yolo = sandbox_yolo;
let mut event_stream = EventStream::new(); let mut event_stream = EventStream::new();
loop { loop {
@ -199,6 +207,9 @@ pub async fn run(
}) })
.await; .await;
} }
Some(input::LoopControl::SetNetworkPolicy(allowed)) => {
let _ = action_tx.send(UserAction::SetNetworkPolicy(allowed)).await;
}
None => {} 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 { let right_text = if let Some(ref err) = state.status_error {
err.clone() err.clone()
} else { } else {
@ -210,12 +228,13 @@ pub(super) fn render(frame: &mut Frame, state: &AppState) {
}; };
let bar_width = chunks[2].width as usize; 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 right_len = right_text.len();
let pad = bar_width.saturating_sub(left_len + right_len); let pad = bar_width.saturating_sub(left_len + right_len);
let status_line = Line::from(vec![ let status_line = Line::from(vec![
Span::styled(mode_label, mode_style), Span::styled(mode_label, mode_style),
Span::styled(sandbox_label, sandbox_style),
Span::raw(" ".repeat(pad)), Span::raw(" ".repeat(pad)),
Span::styled(right_text, right_style), Span::styled(right_text, right_style),
]); ]);
@ -439,4 +458,41 @@ mod tests {
"expected tool name in overlay" "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}"
);
}
} }