Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion crates/loopal-backend/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("@rules_rust//rust:defs.bzl", "rust_library")
load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")

rust_library(
name = "loopal-backend",
Expand All @@ -21,3 +21,19 @@ rust_library(
],
proc_macro_deps = ["@crates//:async-trait"],
)

rust_test(
name = "loopal-backend_test",
srcs = glob(["tests/**/*.rs"]),
crate_root = "tests/suite.rs",
edition = "2024",
deps = [
":loopal-backend",
"//crates/loopal-config",
"//crates/loopal-error",
"//crates/loopal-tool-api",
"@crates//:tempfile",
"@crates//:tokio",
],
proc_macro_deps = ["@crates//:async-trait"],
)
41 changes: 41 additions & 0 deletions crates/loopal-backend/src/approved.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Session-scoped set of sandbox-approved paths.
//!
//! Once a path is approved (user confirmation or Bypass mode), subsequent
//! operations on it skip the `RequiresApproval` sandbox check within the
//! same session.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use parking_lot::RwLock;

/// Thread-safe approved-paths set with interior mutability.
///
/// Wrapped in `RwLock` so it can live inside `Arc<LocalBackend>` without
/// requiring `&mut self`. The contention profile (rare writes after first
/// approval, frequent reads) is ideal for reader-writer locks.
pub struct ApprovedPaths {
inner: RwLock<HashSet<PathBuf>>,
}

impl Default for ApprovedPaths {
fn default() -> Self {
Self::new()
}
}

impl ApprovedPaths {
pub fn new() -> Self {
Self {
inner: RwLock::new(HashSet::new()),
}
}

pub fn insert(&self, path: PathBuf) {
self.inner.write().insert(path);
}

pub fn contains(&self, path: &Path) -> bool {
self.inner.read().contains(path)
}
}
2 changes: 2 additions & 0 deletions crates/loopal-backend/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
pub mod approved;
pub mod fs;
pub mod limits;
pub mod local;
pub mod net;
pub mod path;
pub mod platform;
pub mod search;
pub mod shell;
pub mod shell_stream;
Expand Down
100 changes: 47 additions & 53 deletions crates/loopal-backend/src/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,62 @@ use loopal_config::ResolvedPolicy;
use loopal_error::{ProcessHandle, ToolIoError};
use loopal_tool_api::backend_types::{
EditResult, ExecResult, FetchResult, FileInfo, GlobOptions, GlobSearchResult, GrepOptions,
GrepSearchResult, LsEntry, LsResult, ReadResult, WriteResult,
GrepSearchResult, LsResult, ReadResult, WriteResult,
};
use loopal_tool_api::{Backend, ExecOutcome};

use crate::approved::ApprovedPaths;
use crate::limits::ResourceLimits;
use crate::{fs, net, path, search, shell, shell_stream};
use crate::{fs, net, path, platform, search, shell, shell_stream};

/// Production backend: local disk I/O with path checking, size limits,
/// atomic writes, OS-level sandbox wrapping, and resource budgets.
pub struct LocalBackend {
cwd: PathBuf,
policy: Option<ResolvedPolicy>,
limits: ResourceLimits,
approved: ApprovedPaths,
}

impl LocalBackend {
pub fn new(cwd: PathBuf, policy: Option<ResolvedPolicy>, limits: ResourceLimits) -> Arc<Self> {
// Canonicalize cwd to resolve symlinks (e.g. macOS /tmp → /private/tmp).
// On Windows, strip \\?\ prefix that canonicalize() adds.
let cwd = path::strip_win_prefix(cwd.canonicalize().unwrap_or(cwd));
Arc::new(Self {
cwd,
policy,
limits,
approved: ApprovedPaths::new(),
})
}

/// Resolve with sandbox check; falls back to approved-paths on `RequiresApproval`.
fn resolve_checked(&self, raw: &str, is_write: bool) -> Result<PathBuf, ToolIoError> {
match path::resolve(&self.cwd, raw, is_write, self.policy.as_ref()) {
Ok(p) => Ok(p),
Err(ToolIoError::RequiresApproval(reason)) => {
let abs = path::to_absolute(&self.cwd, raw);
if self.approved.contains(&abs) {
// Canonicalize for consistency with the Allow path
// (path::resolve returns canonical form).
Ok(abs.canonicalize().unwrap_or(abs))
} else {
Err(ToolIoError::RequiresApproval(reason))
}
}
Err(e) => Err(e),
}
}
}

#[async_trait]
impl Backend for LocalBackend {
async fn read(&self, p: &str, offset: usize, limit: usize) -> Result<ReadResult, ToolIoError> {
let resolved = path::resolve(&self.cwd, p, false, self.policy.as_ref())?;
let resolved = self.resolve_checked(p, false)?;
fs::read_file(&resolved, offset, limit, &self.limits).await
}

async fn write(&self, p: &str, content: &str) -> Result<WriteResult, ToolIoError> {
let resolved = path::resolve(&self.cwd, p, true, self.policy.as_ref())?;
fs::write_file(&resolved, content).await
fs::write_file(&self.resolve_checked(p, true)?, content).await
}

async fn edit(
Expand All @@ -55,12 +73,11 @@ impl Backend for LocalBackend {
new: &str,
replace_all: bool,
) -> Result<EditResult, ToolIoError> {
let resolved = path::resolve(&self.cwd, p, true, self.policy.as_ref())?;
fs::edit_file(&resolved, old, new, replace_all).await
fs::edit_file(&self.resolve_checked(p, true)?, old, new, replace_all).await
}

async fn remove(&self, p: &str) -> Result<(), ToolIoError> {
let resolved = path::resolve(&self.cwd, p, true, self.policy.as_ref())?;
let resolved = self.resolve_checked(p, true)?;
let meta = tokio::fs::metadata(&resolved).await?;
if meta.is_dir() {
tokio::fs::remove_dir_all(&resolved).await?;
Expand All @@ -71,54 +88,30 @@ impl Backend for LocalBackend {
}

async fn create_dir_all(&self, p: &str) -> Result<(), ToolIoError> {
let resolved = path::resolve(&self.cwd, p, true, self.policy.as_ref())?;
tokio::fs::create_dir_all(&resolved).await?;
tokio::fs::create_dir_all(self.resolve_checked(p, true)?).await?;
Ok(())
}

async fn copy(&self, from: &str, to: &str) -> Result<(), ToolIoError> {
let src = path::resolve(&self.cwd, from, false, self.policy.as_ref())?;
let dst = path::resolve(&self.cwd, to, true, self.policy.as_ref())?;
let src = self.resolve_checked(from, false)?;
let dst = self.resolve_checked(to, true)?;
tokio::fs::copy(&src, &dst).await?;
Ok(())
}

async fn rename(&self, from: &str, to: &str) -> Result<(), ToolIoError> {
let src = path::resolve(&self.cwd, from, true, self.policy.as_ref())?;
let dst = path::resolve(&self.cwd, to, true, self.policy.as_ref())?;
let src = self.resolve_checked(from, true)?;
let dst = self.resolve_checked(to, true)?;
tokio::fs::rename(&src, &dst).await?;
Ok(())
}

async fn file_info(&self, p: &str) -> Result<FileInfo, ToolIoError> {
let resolved = path::resolve(&self.cwd, p, false, self.policy.as_ref())?;
fs::get_file_info(&resolved).await
fs::get_file_info(&self.resolve_checked(p, false)?).await
}

async fn ls(&self, p: &str) -> Result<LsResult, ToolIoError> {
let resolved = path::resolve(&self.cwd, p, false, self.policy.as_ref())?;
let mut rd = tokio::fs::read_dir(&resolved).await?;
let mut entries = Vec::new();
while let Some(entry) = rd.next_entry().await? {
let meta = entry.metadata().await?;
let ft = entry.file_type().await?;
let modified = meta.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
});
let permissions = extract_permissions(&meta);
entries.push(LsEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir: ft.is_dir(),
is_symlink: ft.is_symlink(),
size: meta.len(),
modified,
permissions,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(LsResult { entries })
platform::list_directory(&self.resolve_checked(p, false)?).await
}

async fn glob(&self, opts: &GlobOptions) -> Result<GlobSearchResult, ToolIoError> {
Expand All @@ -140,12 +133,11 @@ impl Backend for LocalBackend {
}

fn resolve_path(&self, raw: &str, is_write: bool) -> Result<PathBuf, ToolIoError> {
path::resolve(&self.cwd, raw, is_write, self.policy.as_ref())
self.resolve_checked(raw, is_write)
}

async fn read_raw(&self, p: &str) -> Result<String, ToolIoError> {
let resolved = path::resolve(&self.cwd, p, false, self.policy.as_ref())?;
fs::read_raw_file(&resolved, &self.limits).await
fs::read_raw_file(&self.resolve_checked(p, false)?, &self.limits).await
}

fn cwd(&self) -> &Path {
Expand Down Expand Up @@ -179,6 +171,7 @@ impl Backend for LocalBackend {
)
.await
}

async fn exec_background(&self, command: &str) -> Result<ProcessHandle, ToolIoError> {
let data = shell::exec_background(&self.cwd, self.policy.as_ref(), command).await?;
Ok(ProcessHandle(Box::new(data)))
Expand All @@ -187,15 +180,16 @@ impl Backend for LocalBackend {
async fn fetch(&self, url: &str) -> Result<FetchResult, ToolIoError> {
net::fetch_url(url, self.policy.as_ref(), &self.limits).await
}
}

#[cfg(unix)]
fn extract_permissions(meta: &std::fs::Metadata) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
Some(meta.permissions().mode())
}
fn approve_path(&self, p: &Path) {
self.approved.insert(p.to_path_buf());
}

#[cfg(not(unix))]
fn extract_permissions(_meta: &std::fs::Metadata) -> Option<u32> {
None
fn check_sandbox_path(&self, raw: &str, is_write: bool) -> Option<String> {
let abs = path::to_absolute(&self.cwd, raw);
if self.approved.contains(&abs) {
return None;
}
path::check_requires_approval(&self.cwd, raw, is_write, self.policy.as_ref())
}
}
23 changes: 21 additions & 2 deletions crates/loopal-backend/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,26 @@ fn check_with_policy(
) -> Result<PathBuf, ToolIoError> {
match loopal_sandbox::path_checker::check_path(policy, path, is_write) {
PathDecision::Allow => Ok(path.to_path_buf()),
PathDecision::DenyWrite(reason) => Err(ToolIoError::PermissionDenied(reason)),
PathDecision::DenyRead(reason) => Err(ToolIoError::PermissionDenied(reason)),
PathDecision::Deny(reason) => Err(ToolIoError::PermissionDenied(reason)),
PathDecision::RequiresApproval(reason) => Err(ToolIoError::RequiresApproval(reason)),
}
}

/// Check whether a path would require sandbox approval (without executing I/O).
///
/// Returns `Some(reason)` if approval is needed, `None` if allowed.
/// Used by the runtime's sandbox pre-check phase to route through the
/// permission system before tool execution.
pub fn check_requires_approval(
cwd: &Path,
raw: &str,
is_write: bool,
policy: Option<&ResolvedPolicy>,
) -> Option<String> {
let path = to_absolute(cwd, raw);
let pol = policy?;
match loopal_sandbox::path_checker::check_path(pol, &path, is_write) {
PathDecision::RequiresApproval(reason) => Some(reason),
_ => None,
}
}
43 changes: 43 additions & 0 deletions crates/loopal-backend/src/platform.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! Platform-specific helpers and directory listing.

use std::path::Path;

use loopal_error::ToolIoError;
use loopal_tool_api::backend_types::{LsEntry, LsResult};

/// Extract Unix permission bits from file metadata.
#[cfg(unix)]
pub fn extract_permissions(meta: &std::fs::Metadata) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
Some(meta.permissions().mode())
}

#[cfg(not(unix))]
pub fn extract_permissions(_meta: &std::fs::Metadata) -> Option<u32> {
None
}

/// List a directory's contents sorted by name.
pub async fn list_directory(resolved: &Path) -> Result<LsResult, ToolIoError> {
let mut rd = tokio::fs::read_dir(resolved).await?;
let mut entries = Vec::new();
while let Some(entry) = rd.next_entry().await? {
let meta = entry.metadata().await?;
let ft = entry.file_type().await?;
let modified = meta.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
});
entries.push(LsEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir: ft.is_dir(),
is_symlink: ft.is_symlink(),
size: meta.len(),
modified,
permissions: extract_permissions(&meta),
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(LsResult { entries })
}
7 changes: 7 additions & 0 deletions crates/loopal-backend/tests/suite.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Single test binary for loopal-backend
#[path = "suite/approved_paths_test.rs"]
mod approved_paths_test;
#[path = "suite/path_approval_test.rs"]
mod path_approval_test;
#[path = "suite/resolve_checked_test.rs"]
mod resolve_checked_test;
43 changes: 43 additions & 0 deletions crates/loopal-backend/tests/suite/approved_paths_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! Unit tests for ApprovedPaths (session-scoped approval set).

use std::path::PathBuf;

use loopal_backend::approved::ApprovedPaths;

#[test]
fn empty_set_contains_nothing() {
let ap = ApprovedPaths::new();
assert!(!ap.contains(&PathBuf::from("/etc/hosts")));
assert!(!ap.contains(&PathBuf::from("/tmp/test.txt")));
}

#[test]
fn insert_then_contains() {
let ap = ApprovedPaths::new();
let path = PathBuf::from("/etc/nginx/nginx.conf");
ap.insert(path.clone());
assert!(ap.contains(&path));
}

#[test]
fn distinct_paths_independent() {
let ap = ApprovedPaths::new();
ap.insert(PathBuf::from("/etc/hosts"));
assert!(ap.contains(&PathBuf::from("/etc/hosts")));
assert!(!ap.contains(&PathBuf::from("/etc/passwd")));
}

#[test]
fn duplicate_insert_is_idempotent() {
let ap = ApprovedPaths::new();
let path = PathBuf::from("/tmp/test.txt");
ap.insert(path.clone());
ap.insert(path.clone());
assert!(ap.contains(&path));
}

#[test]
fn default_is_empty() {
let ap = ApprovedPaths::default();
assert!(!ap.contains(&PathBuf::from("/any/path")));
}
Loading
Loading