Skip to main content

heartbit_core/tool/
set_scope.rs

1//! `set_scope` — the entry agent declares its working scope after planning.
2//! Shares the [`ScopeGuard`] with the runner's guardrails: mutating tool calls
3//! (edit/write) outside the declared roots are then denied with actionable
4//! feedback. Re-declaring (or widening) the scope is itself a visible,
5//! auditable tool call — out-of-scope work can't drift in silently.
6
7use 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
19/// Declare/replace the working scope enforced by the shared [`ScopeGuard`].
20pub struct SetScopeTool {
21    guard: Arc<ScopeGuard>,
22    /// The host workspace root. A scope root OUTSIDE it is futile: the file
23    /// tools reject any write outside the workspace regardless of scope (live
24    /// finding 6a265efd — models scoped /tmp for "a temporary directory", every
25    /// write was rejected, then they thrashed into the repo via a symlink and
26    /// stray dirs). The first outside scope is refused with a redirect to an
27    /// in-workspace scratch subdir; relocating INTO the workspace is the correct
28    /// course-correction, never refused.
29    workspace: Option<std::path::PathBuf>,
30    /// One-shot: the first scope declaring an OUTSIDE root is refused with the
31    /// scratch redirect; an explicit retry passes (e.g. read-only / bash use).
32    outside_warned: std::sync::atomic::AtomicBool,
33}
34
35impl SetScopeTool {
36    /// `guard` must be the SAME guard registered in the runner's guardrails.
37    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    /// Set the host workspace root enabling the outside-scope redirect.
46    /// Also shared with the guard so it anchors relative file paths against
47    /// the same root (set_scope declares ABSOLUTE roots; the file tools pass
48    /// workspace-RELATIVE paths — without a common anchor the guard falsely
49    /// denied in-scope writes).
50    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                    // Resolve RELATIVE roots against the workspace so the scope
101                    // is ONE canonical absolute location. A relative `./scratch`
102                    // is ambiguous (the write tool reads it workspace-relative,
103                    // bash relative to its drifting cwd) — live finding
104                    // 6a25d21b. Absolute paths are kept as-is.
105                    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            // Outside-scope redirect: declaring a scope root OUTSIDE the host
114            // workspace is futile — the file tools reject any write outside the
115            // workspace regardless of scope. The first such scope is refused with
116            // a redirect to an in-workspace scratch subdir; an explicit retry
117            // passes (e.g. read-only or bash-only use). Relocating INTO the
118            // workspace is the correct course-correction and is never refused
119            // (live finding 6a265efd: the old outside→inside refusal told the
120            // agent to "keep building in /tmp" — impossible — so it symlinked and
121            // littered the repo).
122            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        // The SAME guard now denies out-of-scope mutations…
193        let denied = guard
194            .pre_tool(&call("edit", "/tmp/elsewhere/a.rs"))
195            .await
196            .unwrap();
197        assert!(matches!(denied, GuardAction::Deny { .. }));
198        // …and allows in-scope ones.
199        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    // End-to-end (audit 2026-06-09): with_workspace shares the anchor with
207    // the guard, so an absolute scope declared via set_scope and a
208    // workspace-RELATIVE write target resolve identically — the in-scope
209    // write is allowed, the out-of-scope one denied.
210    #[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        // The agent declares a relative scope; set_scope resolves it to
215        // /repo/scratch-x (absolute).
216        tool.execute(
217            &crate::ExecutionContext::default(),
218            json!({"paths": ["scratch-x"], "reason": "scratch work"}),
219        )
220        .await
221        .unwrap();
222        // A workspace-relative write inside the scope is allowed…
223        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        // …one outside it is denied.
232        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        // Live finding 6a25d21b: a relative scope "./scratch-crm" was ambiguous
287        // — write resolved it workspace-relative, bash via its drifting cwd.
288        // set_scope now anchors a relative root to the workspace (one canonical
289        // absolute location).
290        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        // An absolute root INSIDE the workspace is kept verbatim.
303        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        // Live finding 6a265efd: models scoped /tmp for "a temporary directory",
321        // but the file tools reject every write outside the workspace, so the
322        // outside scope is futile. It is refused with a redirect to an
323        // in-workspace scratch subdir; relocating INTO the workspace is the
324        // correct course-correction and is NEVER refused (the old guard refused
325        // it, telling the agent to "keep building in /tmp" — impossible — so it
326        // symlinked and littered the repo).
327        let guard = Arc::new(ScopeGuard::new(vec![]));
328        let tool = SetScopeTool::new(guard.clone()).with_workspace("/repo");
329        // An OUTSIDE scope is refused with the scratch redirect, and NOT applied.
330        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        // Relocating INTO the workspace is the correct move — never refused.
349        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        // The one-shot is consumed: an explicit retry of an outside scope passes
359        // (e.g. read-only / bash-only use).
360        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        // Normal repo work (scope inside the workspace from the start) is
373        // never bothered by the outside-scope redirect.
374        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}