1use std::future::Future;
8use std::path::PathBuf;
9use std::pin::Pin;
10use std::sync::Arc;
11
12use serde_json::json;
13
14use crate::agent::guardrails::ScopeGuard;
15use crate::error::Error;
16use crate::llm::types::ToolDefinition;
17use crate::tool::{Tool, ToolOutput};
18
19pub struct SetScopeTool {
21 guard: Arc<ScopeGuard>,
22 workspace: Option<std::path::PathBuf>,
30 outside_warned: std::sync::atomic::AtomicBool,
33}
34
35impl SetScopeTool {
36 pub fn new(guard: Arc<ScopeGuard>) -> Self {
38 Self {
39 guard,
40 workspace: None,
41 outside_warned: std::sync::atomic::AtomicBool::new(false),
42 }
43 }
44
45 pub fn with_workspace(mut self, root: impl Into<std::path::PathBuf>) -> Self {
51 let root = root.into();
52 self.guard.set_workspace(&root);
53 self.workspace = Some(root);
54 self
55 }
56}
57
58impl Tool for SetScopeTool {
59 fn definition(&self) -> ToolDefinition {
60 ToolDefinition {
61 name: "set_scope".into(),
62 description: "Declare the files/directories your current task is allowed to \
63 MODIFY (edit/write outside them will be denied). Call it after \
64 planning substantive work; call it again to widen the scope — \
65 widening is deliberate and visible, drift is not. An empty list \
66 removes the restriction."
67 .into(),
68 input_schema: json!({
69 "type": "object",
70 "properties": {
71 "paths": {
72 "type": "array",
73 "items": {"type": "string"},
74 "description": "In-scope roots (files or directories)."
75 },
76 "reason": {
77 "type": "string",
78 "description": "One line on why this is the scope (or why it widened)."
79 }
80 },
81 "required": ["paths"]
82 }),
83 }
84 }
85
86 fn execute(
87 &self,
88 _ctx: &crate::ExecutionContext,
89 input: serde_json::Value,
90 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
91 Box::pin(async move {
92 let Some(paths) = input.get("paths").and_then(|v| v.as_array()) else {
93 return Ok(ToolOutput::error("paths is required (an array of strings)"));
94 };
95 let roots: Vec<PathBuf> = paths
96 .iter()
97 .filter_map(|v| v.as_str())
98 .filter(|s| !s.trim().is_empty())
99 .map(|s| {
100 let p = PathBuf::from(s);
106 match (&self.workspace, p.is_relative()) {
107 (Some(ws), true) => ws.join(p),
108 _ => p,
109 }
110 })
111 .collect();
112 let n = roots.len();
113 if let Some(ws) = &self.workspace {
123 let outside: Vec<&PathBuf> = roots.iter().filter(|r| !r.starts_with(ws)).collect();
124 if !outside.is_empty()
125 && !self
126 .outside_warned
127 .swap(true, std::sync::atomic::Ordering::Relaxed)
128 {
129 return Ok(ToolOutput::error(format!(
130 "scope root(s) {} are OUTSIDE this workspace ({}) — the file tools can \
131 only write INSIDE it, so an outside scope cannot be acted on. For \
132 temporary or scratch work, scope a SUBDIRECTORY inside the workspace \
133 instead (e.g. ./scratch-<name>), kept gitignored so it stays \
134 disposable. Re-issue unchanged to override (e.g. read-only or \
135 bash-only use).",
136 outside
137 .iter()
138 .map(|p| p.display().to_string())
139 .collect::<Vec<_>>()
140 .join(", "),
141 ws.display()
142 )));
143 }
144 }
145 self.guard.set(roots);
146 if n == 0 {
147 return Ok(ToolOutput::success(
148 "scope cleared — mutations are unrestricted",
149 ));
150 }
151 let listing = self
152 .guard
153 .roots()
154 .iter()
155 .map(|p| format!("- {}", p.display()))
156 .collect::<Vec<_>>()
157 .join("\n");
158 Ok(ToolOutput::success(format!(
159 "scope set ({n} roots) — edit/write outside these will be denied:\n{listing}"
160 )))
161 })
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::agent::guardrail::{GuardAction, Guardrail};
169 use crate::llm::types::ToolCall;
170
171 fn call(name: &str, path: &str) -> ToolCall {
172 ToolCall {
173 id: "c1".into(),
174 name: name.into(),
175 input: json!({"file_path": path}),
176 }
177 }
178
179 #[tokio::test]
180 async fn set_scope_seeds_the_shared_guard() {
181 let guard = Arc::new(ScopeGuard::new(vec![]));
182 let tool = SetScopeTool::new(guard.clone());
183 let out = tool
184 .execute(
185 &crate::ExecutionContext::default(),
186 json!({"paths": ["/tmp/x/src"], "reason": "feature work"}),
187 )
188 .await
189 .unwrap();
190 assert!(!out.is_error);
191 assert!(out.content.contains("/tmp/x/src"));
192 let denied = guard
194 .pre_tool(&call("edit", "/tmp/elsewhere/a.rs"))
195 .await
196 .unwrap();
197 assert!(matches!(denied, GuardAction::Deny { .. }));
198 let allowed = guard
200 .pre_tool(&call("write", "/tmp/x/src/new.rs"))
201 .await
202 .unwrap();
203 assert!(matches!(allowed, GuardAction::Allow));
204 }
205
206 #[tokio::test]
211 async fn with_workspace_anchors_relative_writes_against_absolute_scope() {
212 let guard = Arc::new(ScopeGuard::new(vec![]));
213 let tool = SetScopeTool::new(guard.clone()).with_workspace("/repo");
214 tool.execute(
217 &crate::ExecutionContext::default(),
218 json!({"paths": ["scratch-x"], "reason": "scratch work"}),
219 )
220 .await
221 .unwrap();
222 let allowed = guard
224 .pre_tool(&call("write", "scratch-x/new.rs"))
225 .await
226 .unwrap();
227 assert!(
228 matches!(allowed, GuardAction::Allow),
229 "in-scope relative write must be allowed: {allowed:?}"
230 );
231 let denied = guard.pre_tool(&call("edit", "src/main.rs")).await.unwrap();
233 assert!(
234 matches!(denied, GuardAction::Deny { .. }),
235 "out-of-scope relative write must be denied: {denied:?}"
236 );
237 }
238
239 #[tokio::test]
240 async fn set_scope_replaces_not_extends() {
241 let guard = Arc::new(ScopeGuard::new(vec![PathBuf::from("/tmp/old")]));
242 let tool = SetScopeTool::new(guard.clone());
243 tool.execute(
244 &crate::ExecutionContext::default(),
245 json!({"paths": ["/tmp/new"]}),
246 )
247 .await
248 .unwrap();
249 let denied = guard
250 .pre_tool(&call("edit", "/tmp/old/a.rs"))
251 .await
252 .unwrap();
253 assert!(
254 matches!(denied, GuardAction::Deny { .. }),
255 "the old root must be gone (replace semantics)"
256 );
257 }
258
259 #[tokio::test]
260 async fn empty_paths_clears_the_restriction() {
261 let guard = Arc::new(ScopeGuard::new(vec![PathBuf::from("/tmp/x")]));
262 let tool = SetScopeTool::new(guard.clone());
263 tool.execute(&crate::ExecutionContext::default(), json!({"paths": []}))
264 .await
265 .unwrap();
266 let action = guard
267 .pre_tool(&call("edit", "/anywhere/a.rs"))
268 .await
269 .unwrap();
270 assert!(matches!(action, GuardAction::Allow));
271 }
272
273 #[tokio::test]
274 async fn missing_paths_is_an_error() {
275 let guard = Arc::new(ScopeGuard::new(vec![]));
276 let tool = SetScopeTool::new(guard);
277 let out = tool
278 .execute(&crate::ExecutionContext::default(), json!({}))
279 .await
280 .unwrap();
281 assert!(out.is_error);
282 }
283
284 #[tokio::test]
285 async fn relative_scope_is_resolved_against_the_workspace() {
286 let guard = Arc::new(ScopeGuard::new(vec![]));
291 let tool = SetScopeTool::new(guard.clone()).with_workspace("/repo");
292 let out = tool
293 .execute(
294 &crate::ExecutionContext::default(),
295 json!({"paths": ["./scratch-crm"]}),
296 )
297 .await
298 .unwrap();
299 assert!(!out.is_error);
300 let roots = guard.roots();
301 assert_eq!(roots, vec![PathBuf::from("/repo/scratch-crm")]);
302 tool.execute(
304 &crate::ExecutionContext::default(),
305 json!({"paths": ["/repo/elsewhere"]}),
306 )
307 .await
308 .unwrap();
309 assert_eq!(guard.roots(), vec![PathBuf::from("/repo/elsewhere")]);
310 }
311
312 #[test]
313 fn definition_name() {
314 let tool = SetScopeTool::new(Arc::new(ScopeGuard::new(vec![])));
315 assert_eq!(tool.definition().name, "set_scope");
316 }
317
318 #[tokio::test]
319 async fn outside_scope_is_refused_once_with_scratch_redirect() {
320 let guard = Arc::new(ScopeGuard::new(vec![]));
328 let tool = SetScopeTool::new(guard.clone()).with_workspace("/repo");
329 let out = tool
331 .execute(
332 &crate::ExecutionContext::default(),
333 json!({"paths": ["/tmp/proj"]}),
334 )
335 .await
336 .unwrap();
337 assert!(out.is_error, "an outside scope must be refused");
338 assert!(out.content.contains("OUTSIDE this workspace"));
339 assert!(
340 out.content.contains("scratch"),
341 "must redirect to an in-workspace scratch subdir: {}",
342 out.content
343 );
344 assert!(
345 guard.roots().is_empty(),
346 "the futile outside scope must NOT be applied"
347 );
348 let inside = tool
350 .execute(
351 &crate::ExecutionContext::default(),
352 json!({"paths": ["/repo/scratch-crm"]}),
353 )
354 .await
355 .unwrap();
356 assert!(!inside.is_error, "an in-workspace scope is always allowed");
357 assert_eq!(guard.roots(), vec![PathBuf::from("/repo/scratch-crm")]);
358 let retry = tool
361 .execute(
362 &crate::ExecutionContext::default(),
363 json!({"paths": ["/tmp/proj"]}),
364 )
365 .await
366 .unwrap();
367 assert!(!retry.is_error, "the explicit retry overrides the redirect");
368 }
369
370 #[tokio::test]
371 async fn inside_workspace_rescope_is_normal() {
372 let guard = Arc::new(ScopeGuard::new(vec![]));
375 let tool = SetScopeTool::new(guard).with_workspace("/repo");
376 tool.execute(
377 &crate::ExecutionContext::default(),
378 json!({"paths": ["/repo/src"]}),
379 )
380 .await
381 .unwrap();
382 let out = tool
383 .execute(
384 &crate::ExecutionContext::default(),
385 json!({"paths": ["/repo/src", "/repo/tests"]}),
386 )
387 .await
388 .unwrap();
389 assert!(!out.is_error);
390 }
391}