1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
//! `set_scope` — the entry agent declares its working scope after planning.
//! Shares the [`ScopeGuard`] with the runner's guardrails: mutating tool calls
//! (edit/write) outside the declared roots are then denied with actionable
//! feedback. Re-declaring (or widening) the scope is itself a visible,
//! auditable tool call — out-of-scope work can't drift in silently.
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use serde_json::json;
use crate::agent::guardrails::ScopeGuard;
use crate::error::Error;
use crate::llm::types::ToolDefinition;
use crate::tool::{Tool, ToolOutput};
/// Declare/replace the working scope enforced by the shared [`ScopeGuard`].
pub struct SetScopeTool {
guard: Arc<ScopeGuard>,
/// The host workspace root. A scope root OUTSIDE it is futile: the file
/// tools reject any write outside the workspace regardless of scope (live
/// finding 6a265efd — models scoped /tmp for "a temporary directory", every
/// write was rejected, then they thrashed into the repo via a symlink and
/// stray dirs). The first outside scope is refused with a redirect to an
/// in-workspace scratch subdir; relocating INTO the workspace is the correct
/// course-correction, never refused.
workspace: Option<std::path::PathBuf>,
/// One-shot: the first scope declaring an OUTSIDE root is refused with the
/// scratch redirect; an explicit retry passes (e.g. read-only / bash use).
outside_warned: std::sync::atomic::AtomicBool,
}
impl SetScopeTool {
/// `guard` must be the SAME guard registered in the runner's guardrails.
pub fn new(guard: Arc<ScopeGuard>) -> Self {
Self {
guard,
workspace: None,
outside_warned: std::sync::atomic::AtomicBool::new(false),
}
}
/// Set the host workspace root enabling the outside-scope redirect.
/// Also shared with the guard so it anchors relative file paths against
/// the same root (set_scope declares ABSOLUTE roots; the file tools pass
/// workspace-RELATIVE paths — without a common anchor the guard falsely
/// denied in-scope writes).
pub fn with_workspace(mut self, root: impl Into<std::path::PathBuf>) -> Self {
let root = root.into();
self.guard.set_workspace(&root);
self.workspace = Some(root);
self
}
}
impl Tool for SetScopeTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "set_scope".into(),
description: "Declare the files/directories your current task is allowed to \
MODIFY (edit/write outside them will be denied). Call it after \
planning substantive work; call it again to widen the scope — \
widening is deliberate and visible, drift is not. An empty list \
removes the restriction."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": {"type": "string"},
"description": "In-scope roots (files or directories)."
},
"reason": {
"type": "string",
"description": "One line on why this is the scope (or why it widened)."
}
},
"required": ["paths"]
}),
}
}
fn execute(
&self,
_ctx: &crate::ExecutionContext,
input: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
Box::pin(async move {
let Some(paths) = input.get("paths").and_then(|v| v.as_array()) else {
return Ok(ToolOutput::error("paths is required (an array of strings)"));
};
let roots: Vec<PathBuf> = paths
.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(|s| {
// Resolve RELATIVE roots against the workspace so the scope
// is ONE canonical absolute location. A relative `./scratch`
// is ambiguous (the write tool reads it workspace-relative,
// bash relative to its drifting cwd) — live finding
// 6a25d21b. Absolute paths are kept as-is.
let p = PathBuf::from(s);
match (&self.workspace, p.is_relative()) {
(Some(ws), true) => ws.join(p),
_ => p,
}
})
.collect();
let n = roots.len();
// Outside-scope redirect: declaring a scope root OUTSIDE the host
// workspace is futile — the file tools reject any write outside the
// workspace regardless of scope. The first such scope is refused with
// a redirect to an in-workspace scratch subdir; an explicit retry
// passes (e.g. read-only or bash-only use). Relocating INTO the
// workspace is the correct course-correction and is never refused
// (live finding 6a265efd: the old outside→inside refusal told the
// agent to "keep building in /tmp" — impossible — so it symlinked and
// littered the repo).
if let Some(ws) = &self.workspace {
let outside: Vec<&PathBuf> = roots.iter().filter(|r| !r.starts_with(ws)).collect();
if !outside.is_empty()
&& !self
.outside_warned
.swap(true, std::sync::atomic::Ordering::Relaxed)
{
return Ok(ToolOutput::error(format!(
"scope root(s) {} are OUTSIDE this workspace ({}) — the file tools can \
only write INSIDE it, so an outside scope cannot be acted on. For \
temporary or scratch work, scope a SUBDIRECTORY inside the workspace \
instead (e.g. ./scratch-<name>), kept gitignored so it stays \
disposable. Re-issue unchanged to override (e.g. read-only or \
bash-only use).",
outside
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", "),
ws.display()
)));
}
}
self.guard.set(roots);
if n == 0 {
return Ok(ToolOutput::success(
"scope cleared — mutations are unrestricted",
));
}
let listing = self
.guard
.roots()
.iter()
.map(|p| format!("- {}", p.display()))
.collect::<Vec<_>>()
.join("\n");
Ok(ToolOutput::success(format!(
"scope set ({n} roots) — edit/write outside these will be denied:\n{listing}"
)))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::guardrail::{GuardAction, Guardrail};
use crate::llm::types::ToolCall;
fn call(name: &str, path: &str) -> ToolCall {
ToolCall {
id: "c1".into(),
name: name.into(),
input: json!({"file_path": path}),
}
}
#[tokio::test]
async fn set_scope_seeds_the_shared_guard() {
let guard = Arc::new(ScopeGuard::new(vec![]));
let tool = SetScopeTool::new(guard.clone());
let out = tool
.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["/tmp/x/src"], "reason": "feature work"}),
)
.await
.unwrap();
assert!(!out.is_error);
assert!(out.content.contains("/tmp/x/src"));
// The SAME guard now denies out-of-scope mutations…
let denied = guard
.pre_tool(&call("edit", "/tmp/elsewhere/a.rs"))
.await
.unwrap();
assert!(matches!(denied, GuardAction::Deny { .. }));
// …and allows in-scope ones.
let allowed = guard
.pre_tool(&call("write", "/tmp/x/src/new.rs"))
.await
.unwrap();
assert!(matches!(allowed, GuardAction::Allow));
}
// End-to-end (audit 2026-06-09): with_workspace shares the anchor with
// the guard, so an absolute scope declared via set_scope and a
// workspace-RELATIVE write target resolve identically — the in-scope
// write is allowed, the out-of-scope one denied.
#[tokio::test]
async fn with_workspace_anchors_relative_writes_against_absolute_scope() {
let guard = Arc::new(ScopeGuard::new(vec![]));
let tool = SetScopeTool::new(guard.clone()).with_workspace("/repo");
// The agent declares a relative scope; set_scope resolves it to
// /repo/scratch-x (absolute).
tool.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["scratch-x"], "reason": "scratch work"}),
)
.await
.unwrap();
// A workspace-relative write inside the scope is allowed…
let allowed = guard
.pre_tool(&call("write", "scratch-x/new.rs"))
.await
.unwrap();
assert!(
matches!(allowed, GuardAction::Allow),
"in-scope relative write must be allowed: {allowed:?}"
);
// …one outside it is denied.
let denied = guard.pre_tool(&call("edit", "src/main.rs")).await.unwrap();
assert!(
matches!(denied, GuardAction::Deny { .. }),
"out-of-scope relative write must be denied: {denied:?}"
);
}
#[tokio::test]
async fn set_scope_replaces_not_extends() {
let guard = Arc::new(ScopeGuard::new(vec![PathBuf::from("/tmp/old")]));
let tool = SetScopeTool::new(guard.clone());
tool.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["/tmp/new"]}),
)
.await
.unwrap();
let denied = guard
.pre_tool(&call("edit", "/tmp/old/a.rs"))
.await
.unwrap();
assert!(
matches!(denied, GuardAction::Deny { .. }),
"the old root must be gone (replace semantics)"
);
}
#[tokio::test]
async fn empty_paths_clears_the_restriction() {
let guard = Arc::new(ScopeGuard::new(vec![PathBuf::from("/tmp/x")]));
let tool = SetScopeTool::new(guard.clone());
tool.execute(&crate::ExecutionContext::default(), json!({"paths": []}))
.await
.unwrap();
let action = guard
.pre_tool(&call("edit", "/anywhere/a.rs"))
.await
.unwrap();
assert!(matches!(action, GuardAction::Allow));
}
#[tokio::test]
async fn missing_paths_is_an_error() {
let guard = Arc::new(ScopeGuard::new(vec![]));
let tool = SetScopeTool::new(guard);
let out = tool
.execute(&crate::ExecutionContext::default(), json!({}))
.await
.unwrap();
assert!(out.is_error);
}
#[tokio::test]
async fn relative_scope_is_resolved_against_the_workspace() {
// Live finding 6a25d21b: a relative scope "./scratch-crm" was ambiguous
// — write resolved it workspace-relative, bash via its drifting cwd.
// set_scope now anchors a relative root to the workspace (one canonical
// absolute location).
let guard = Arc::new(ScopeGuard::new(vec![]));
let tool = SetScopeTool::new(guard.clone()).with_workspace("/repo");
let out = tool
.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["./scratch-crm"]}),
)
.await
.unwrap();
assert!(!out.is_error);
let roots = guard.roots();
assert_eq!(roots, vec![PathBuf::from("/repo/scratch-crm")]);
// An absolute root INSIDE the workspace is kept verbatim.
tool.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["/repo/elsewhere"]}),
)
.await
.unwrap();
assert_eq!(guard.roots(), vec![PathBuf::from("/repo/elsewhere")]);
}
#[test]
fn definition_name() {
let tool = SetScopeTool::new(Arc::new(ScopeGuard::new(vec![])));
assert_eq!(tool.definition().name, "set_scope");
}
#[tokio::test]
async fn outside_scope_is_refused_once_with_scratch_redirect() {
// Live finding 6a265efd: models scoped /tmp for "a temporary directory",
// but the file tools reject every write outside the workspace, so the
// outside scope is futile. It is refused with a redirect to an
// in-workspace scratch subdir; relocating INTO the workspace is the
// correct course-correction and is NEVER refused (the old guard refused
// it, telling the agent to "keep building in /tmp" — impossible — so it
// symlinked and littered the repo).
let guard = Arc::new(ScopeGuard::new(vec![]));
let tool = SetScopeTool::new(guard.clone()).with_workspace("/repo");
// An OUTSIDE scope is refused with the scratch redirect, and NOT applied.
let out = tool
.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["/tmp/proj"]}),
)
.await
.unwrap();
assert!(out.is_error, "an outside scope must be refused");
assert!(out.content.contains("OUTSIDE this workspace"));
assert!(
out.content.contains("scratch"),
"must redirect to an in-workspace scratch subdir: {}",
out.content
);
assert!(
guard.roots().is_empty(),
"the futile outside scope must NOT be applied"
);
// Relocating INTO the workspace is the correct move — never refused.
let inside = tool
.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["/repo/scratch-crm"]}),
)
.await
.unwrap();
assert!(!inside.is_error, "an in-workspace scope is always allowed");
assert_eq!(guard.roots(), vec![PathBuf::from("/repo/scratch-crm")]);
// The one-shot is consumed: an explicit retry of an outside scope passes
// (e.g. read-only / bash-only use).
let retry = tool
.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["/tmp/proj"]}),
)
.await
.unwrap();
assert!(!retry.is_error, "the explicit retry overrides the redirect");
}
#[tokio::test]
async fn inside_workspace_rescope_is_normal() {
// Normal repo work (scope inside the workspace from the start) is
// never bothered by the outside-scope redirect.
let guard = Arc::new(ScopeGuard::new(vec![]));
let tool = SetScopeTool::new(guard).with_workspace("/repo");
tool.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["/repo/src"]}),
)
.await
.unwrap();
let out = tool
.execute(
&crate::ExecutionContext::default(),
json!({"paths": ["/repo/src", "/repo/tests"]}),
)
.await
.unwrap();
assert!(!out.is_error);
}
}