use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{PoisonError, RwLock};
use crate::agent::guardrail::{GuardAction, Guardrail};
use crate::error::Error;
use crate::llm::types::ToolCall;
use crate::workspace::normalize_path;
const MUTATING_TOOLS: &[&str] = &["edit", "write", "patch"];
fn extract_path(input: &serde_json::Value) -> Option<PathBuf> {
input
.get("file_path")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub struct ScopeGuard {
allowed: RwLock<Vec<PathBuf>>,
workspace: RwLock<Option<PathBuf>>,
}
impl ScopeGuard {
pub fn new(allowed: Vec<PathBuf>) -> Self {
let normalized = allowed.iter().map(|p| normalize_path(p)).collect();
Self {
allowed: RwLock::new(normalized),
workspace: RwLock::new(None),
}
}
pub fn set_workspace(&self, root: impl AsRef<Path>) {
let normalized = normalize_path(root.as_ref());
*self
.workspace
.write()
.unwrap_or_else(PoisonError::into_inner) = Some(normalized);
}
pub fn allow(&self, path: impl AsRef<Path>) {
let normalized = normalize_path(path.as_ref());
let mut roots = self.allowed.write().unwrap_or_else(PoisonError::into_inner);
if !roots.contains(&normalized) {
roots.push(normalized);
}
}
pub fn set(&self, paths: Vec<PathBuf>) {
let normalized: Vec<PathBuf> = paths.iter().map(|p| normalize_path(p)).collect();
*self.allowed.write().unwrap_or_else(PoisonError::into_inner) = normalized;
}
pub fn roots(&self) -> Vec<PathBuf> {
self.allowed
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn decide(&self, call: &ToolCall) -> GuardAction {
if !MUTATING_TOOLS.contains(&call.name.as_str()) {
return GuardAction::Allow;
}
let Some(path) = extract_path(&call.input) else {
return GuardAction::Allow;
};
let workspace = self
.workspace
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone();
let anchor = |p: &Path| -> PathBuf {
match (&workspace, p.is_relative()) {
(Some(ws), true) => normalize_path(&ws.join(p)),
_ => normalize_path(p),
}
};
let target = anchor(&path);
let roots = self.allowed.read().unwrap_or_else(PoisonError::into_inner);
if roots.is_empty() {
return GuardAction::Allow;
}
let in_scope = roots.iter().any(|root| target.starts_with(anchor(root)));
if in_scope {
return GuardAction::Allow;
}
let roots_display = roots
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
GuardAction::deny(format!(
"scope guard: '{}' is outside the current task scope ([{}]). \
If this change is genuinely needed, ask the user to expand the scope \
(question tool) or add it to the plan first.",
target.display(),
roots_display,
))
}
}
impl Guardrail for ScopeGuard {
fn name(&self) -> &str {
"scope_guard"
}
fn pre_tool(
&self,
call: &ToolCall,
) -> Pin<Box<dyn Future<Output = Result<GuardAction, Error>> + Send + '_>> {
let action = self.decide(call);
Box::pin(async move { Ok(action) })
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn mutating_call(tool: &str, file_path: &str) -> ToolCall {
ToolCall {
id: "c1".into(),
name: tool.into(),
input: json!({ "file_path": file_path }),
}
}
#[tokio::test]
async fn out_of_scope_mutation_is_denied() {
let guard = ScopeGuard::new(vec![PathBuf::from("/tmp/x/src/a.rs")]);
let call = mutating_call("edit", "/tmp/x/src/b.rs");
let action = guard.pre_tool(&call).await.unwrap();
match action {
GuardAction::Deny { reason } => {
assert!(reason.contains("/tmp/x/src/b.rs"), "path named: {reason}");
assert!(reason.contains("scope guard"), "actionable: {reason}");
}
other => panic!("expected Deny, got {other:?}"),
}
}
#[tokio::test]
async fn in_scope_mutation_allowed() {
let guard = ScopeGuard::new(vec![PathBuf::from("/tmp/x/src/a.rs")]);
let call = mutating_call("edit", "/tmp/x/src/a.rs");
let action = guard.pre_tool(&call).await.unwrap();
assert!(matches!(action, GuardAction::Allow));
}
#[tokio::test]
async fn directory_root_allows_descendants() {
let guard = ScopeGuard::new(vec![PathBuf::from("/tmp/x/src")]);
let call = mutating_call("write", "/tmp/x/src/new.rs");
let action = guard.pre_tool(&call).await.unwrap();
assert!(matches!(action, GuardAction::Allow));
}
#[tokio::test]
async fn sibling_prefix_is_not_in_scope() {
let guard = ScopeGuard::new(vec![PathBuf::from("/tmp/x/src")]);
let call = mutating_call("write", "/tmp/x/src-other/b.rs");
let action = guard.pre_tool(&call).await.unwrap();
match action {
GuardAction::Deny { reason } => {
assert!(reason.contains("/tmp/x/src-other/b.rs"), "{reason}");
}
other => panic!("expected Deny for sibling-prefix path, got {other:?}"),
}
}
#[tokio::test]
async fn non_mutating_tools_pass() {
let guard = ScopeGuard::new(vec![PathBuf::from("/tmp/x/src/a.rs")]);
for tool in ["read", "grep", "bash"] {
let call = ToolCall {
id: "c1".into(),
name: tool.into(),
input: json!({ "file_path": "/etc/passwd", "path": "/anywhere" }),
};
let action = guard.pre_tool(&call).await.unwrap();
assert!(
matches!(action, GuardAction::Allow),
"non-mutating tool {tool} should pass"
);
}
}
#[tokio::test]
async fn patch_has_no_parseable_path_allowed() {
let guard = ScopeGuard::new(vec![PathBuf::from("/tmp/x/src")]);
let call = ToolCall {
id: "c1".into(),
name: "patch".into(),
input: json!({ "patch_text": "--- a/etc/passwd\n+++ b/etc/passwd\n" }),
};
let action = guard.pre_tool(&call).await.unwrap();
assert!(matches!(action, GuardAction::Allow));
}
#[tokio::test]
async fn empty_allowlist_allows_all() {
let guard = ScopeGuard::new(vec![]);
let call = mutating_call("edit", "/anywhere/at/all.rs");
let action = guard.pre_tool(&call).await.unwrap();
assert!(matches!(action, GuardAction::Allow));
}
#[tokio::test]
async fn allow_extends_at_runtime() {
let guard = ScopeGuard::new(vec![PathBuf::from("/tmp/x/src/a.rs")]);
let call = mutating_call("edit", "/tmp/x/other/c.rs");
assert!(guard.pre_tool(&call).await.unwrap().is_denied());
guard.allow("/tmp/x/other");
let action = guard.pre_tool(&call).await.unwrap();
assert!(matches!(action, GuardAction::Allow));
}
#[tokio::test]
async fn dotdot_in_path_is_normalized_before_check() {
let guard = ScopeGuard::new(vec![PathBuf::from("/tmp/x/src")]);
let call = mutating_call("edit", "/tmp/x/src/../b.rs");
let action = guard.pre_tool(&call).await.unwrap();
match action {
GuardAction::Deny { reason } => {
assert!(reason.contains("/tmp/x/b.rs"), "normalized path: {reason}");
}
other => panic!("expected Deny after normalization, got {other:?}"),
}
}
#[test]
fn guard_name() {
let guard = ScopeGuard::new(vec![]);
assert_eq!(guard.name(), "scope_guard");
}
#[tokio::test]
async fn relative_target_anchored_to_workspace_is_in_scope() {
let guard = ScopeGuard::new(vec![PathBuf::from("/repo/scratch-x")]);
guard.set_workspace("/repo");
let call = mutating_call("write", "scratch-x/new.rs");
let action = guard.pre_tool(&call).await.unwrap();
assert!(
matches!(action, GuardAction::Allow),
"a relative in-scope write must be allowed once anchored: {action:?}"
);
}
#[tokio::test]
async fn relative_target_outside_root_still_denied_when_anchored() {
let guard = ScopeGuard::new(vec![PathBuf::from("/repo/scratch-x")]);
guard.set_workspace("/repo");
let call = mutating_call("write", "src/main.rs");
let action = guard.pre_tool(&call).await.unwrap();
assert!(
action.is_denied(),
"an out-of-scope relative write must stay denied: {action:?}"
);
}
#[tokio::test]
async fn no_workspace_keeps_lexical_relative_matching() {
let guard = ScopeGuard::new(vec![PathBuf::from("scratch-x")]);
let call = mutating_call("write", "scratch-x/new.rs");
let action = guard.pre_tool(&call).await.unwrap();
assert!(matches!(action, GuardAction::Allow), "{action:?}");
}
}