Use Landlock to restrict bash calls. (#5)

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

View file

@ -1,10 +1,10 @@
//! `list_directory` tool: lists entries in a directory within the working directory.
use std::path::Path;
use crate::sandbox::Sandbox;
use async_trait::async_trait;
use super::{RiskLevel, Tool, ToolError, ToolOutput, validate_path};
use super::{RiskLevel, Tool, ToolError, ToolOutput};
/// Lists directory contents. Auto-approved (read-only).
pub struct ListDirectory;
@ -39,26 +39,13 @@ impl Tool for ListDirectory {
async fn execute(
&self,
input: &serde_json::Value,
working_dir: &Path,
sandbox: &Sandbox,
) -> Result<ToolOutput, ToolError> {
let path_str = input["path"]
.as_str()
.ok_or_else(|| ToolError::InvalidInput("missing 'path' string".to_string()))?;
let canonical = validate_path(working_dir, path_str)?;
let mut entries: Vec<String> = Vec::new();
let mut dir = tokio::fs::read_dir(&canonical).await?;
while let Some(entry) = dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
let suffix = if entry.file_type().await?.is_dir() {
"/"
} else {
""
};
entries.push(format!("{name}{suffix}"));
}
entries.sort();
let entries = sandbox.list_directory(path_str).await?;
Ok(ToolOutput {
content: entries.join("\n"),
@ -70,17 +57,18 @@ impl Tool for ListDirectory {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use crate::sandbox::test_sandbox;
use tempfile::TempDir;
#[tokio::test]
async fn list_directory_contents() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("a.txt"), "").unwrap();
fs::create_dir(dir.path().join("subdir")).unwrap();
std::fs::write(dir.path().join("a.txt"), "").unwrap();
std::fs::create_dir(dir.path().join("subdir")).unwrap();
let tool = ListDirectory;
let sandbox = test_sandbox(dir.path());
let input = serde_json::json!({"path": "."});
let out = tool.execute(&input, dir.path()).await.unwrap();
let out = tool.execute(&input, &sandbox).await.unwrap();
assert!(out.content.contains("a.txt"));
assert!(out.content.contains("subdir/"));
}

View file

@ -3,17 +3,19 @@
//! All tools implement the [`Tool`] trait. The [`ToolRegistry`] collects them
//! and provides lookup by name plus generation of [`ToolDefinition`]s for the
//! model provider.
//!
//! Tools execute through [`crate::sandbox::Sandbox`] -- they must never use
//! `std::fs` or `std::process::Command` directly.
mod list_directory;
mod read_file;
mod shell_exec;
mod write_file;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use crate::core::types::ToolDefinition;
use crate::sandbox::Sandbox;
/// The output of a tool execution.
#[derive(Debug)]
@ -35,6 +37,10 @@ pub enum RiskLevel {
/// A tool that the model can invoke.
///
/// All file I/O and process spawning must go through the [`Sandbox`] passed
/// to [`Tool::execute`]. Tools must never use `std::fs` or
/// `std::process::Command` directly.
///
/// The `execute` method is async so that tool implementations can use
/// `tokio::fs` and `tokio::process` without blocking a Tokio worker thread.
/// `#[async_trait]` desugars the async fn to a boxed future, which is required
@ -49,71 +55,26 @@ pub trait Tool: Send + Sync {
fn input_schema(&self) -> serde_json::Value;
/// The risk level of this tool.
fn risk_level(&self) -> RiskLevel;
/// Execute the tool with the given input, confined to `working_dir`.
/// Execute the tool with the given input, confined by `sandbox`.
async fn execute(
&self,
input: &serde_json::Value,
working_dir: &Path,
sandbox: &Sandbox,
) -> Result<ToolOutput, ToolError>;
}
/// Errors from tool execution.
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
/// The requested path escapes the working directory.
#[error("path escapes working directory: {0}")]
PathEscape(PathBuf),
/// An I/O error during tool execution.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// A required input field is missing or has the wrong type.
#[error("invalid input: {0}")]
InvalidInput(String),
}
/// Validate that `requested` resolves to a path inside `working_dir`.
///
/// Joins `working_dir` with `requested`, canonicalizes the result (resolving
/// symlinks and `..` components), and checks that the canonical path starts
/// with the canonical working directory.
///
/// Returns the canonical path on success, or [`ToolError::PathEscape`] if the
/// path would escape the working directory.
pub fn validate_path(working_dir: &Path, requested: &str) -> Result<PathBuf, ToolError> {
let candidate = if Path::new(requested).is_absolute() {
PathBuf::from(requested)
} else {
working_dir.join(requested)
};
// For paths that don't exist yet (e.g. write_file creating a new file),
// canonicalize the parent directory and append the filename.
let canonical = if candidate.exists() {
candidate
.canonicalize()
.map_err(|_| ToolError::PathEscape(candidate.clone()))?
} else {
let parent = candidate
.parent()
.ok_or_else(|| ToolError::PathEscape(candidate.clone()))?;
let file_name = candidate
.file_name()
.ok_or_else(|| ToolError::PathEscape(candidate.clone()))?;
let canonical_parent = parent
.canonicalize()
.map_err(|_| ToolError::PathEscape(candidate.clone()))?;
canonical_parent.join(file_name)
};
let canonical_root = working_dir
.canonicalize()
.map_err(|_| ToolError::PathEscape(candidate.clone()))?;
if canonical.starts_with(&canonical_root) {
Ok(canonical)
} else {
Err(ToolError::PathEscape(candidate))
}
/// A sandbox error during tool execution.
#[error("sandbox error: {0}")]
Sandbox(#[from] crate::sandbox::SandboxError),
}
/// Collection of available tools with name-based lookup.
@ -161,15 +122,16 @@ impl ToolRegistry {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use crate::sandbox::test_sandbox;
use tempfile::TempDir;
#[test]
fn validate_path_allows_subpath() {
let dir = TempDir::new().unwrap();
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
let result = validate_path(dir.path(), "sub");
std::fs::create_dir(&sub).unwrap();
let sandbox = test_sandbox(dir.path());
let result = sandbox.validate_path("sub");
assert!(result.is_ok());
assert!(
result
@ -181,22 +143,24 @@ mod tests {
#[test]
fn validate_path_rejects_traversal() {
let dir = TempDir::new().unwrap();
let result = validate_path(dir.path(), "../../../etc/passwd");
let sandbox = test_sandbox(dir.path());
let result = sandbox.validate_path("../../../etc/passwd");
assert!(result.is_err());
assert!(matches!(result, Err(ToolError::PathEscape(_))));
}
#[test]
fn validate_path_rejects_absolute_outside() {
let dir = TempDir::new().unwrap();
let result = validate_path(dir.path(), "/etc/passwd");
let sandbox = test_sandbox(dir.path());
let result = sandbox.validate_path("/etc/passwd");
assert!(result.is_err());
}
#[test]
fn validate_path_allows_new_file_in_working_dir() {
let dir = TempDir::new().unwrap();
let result = validate_path(dir.path(), "new_file.txt");
let sandbox = test_sandbox(dir.path());
let result = sandbox.validate_path("new_file.txt");
assert!(result.is_ok());
}

View file

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

View file

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

View file

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