Use Landlock to restrict bash calls. #5
19 changed files with 1315 additions and 238 deletions
32
Cargo.lock
generated
32
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
153
PLAN.md
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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")?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
41
src/main.rs
41
src/main.rs
|
|
@ -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() {
|
||||||
|
"--project-dir" => {
|
||||||
let value = args
|
let value = args
|
||||||
.next()
|
.next()
|
||||||
.context("--project-dir requires a path argument")?;
|
.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
158
src/sandbox/landlock.rs
Normal 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
649
src/sandbox/mod.rs
Normal 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
101
src/sandbox/policy.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue