workdir: add network-capable operation boundary
This commit is contained in:
@@ -6,11 +6,6 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
globset = "0.4.18"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-regex = "0.1.14"
|
||||
grep-searcher = "0.1.16"
|
||||
ignore = "0.4.25"
|
||||
html5ever = "0.26"
|
||||
llm-engine = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
@@ -26,6 +21,7 @@ tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = "0.2.27"
|
||||
|
||||
+70
-561
@@ -1,100 +1,39 @@
|
||||
//! `Bash` tool — execute shell commands in a one-shot, stateless way.
|
||||
//!
|
||||
//! Each call runs `bash -c <command>` via [`tokio::process::Command`].
|
||||
//! The wrapper redirects all output to a file so we never have to read
|
||||
//! from a pipe (which would expose us to bg-pipe hangs). There is no
|
||||
//! shell session: every call starts fresh at `cwd`, so the agent must
|
||||
//! chain `cd <dir> && cmd` when it wants to operate elsewhere. This
|
||||
//! mirrors Claude Code's own Bash tool — predictable, no hidden state.
|
||||
//!
|
||||
//! Output handling: when output is short (≤ 80 lines, ≤ 12 KiB) it is
|
||||
//! returned inline and the file is cleaned up. When it is longer the
|
||||
//! full output is left on disk and only the **last 80 lines** are
|
||||
//! returned, prefixed with the saved file's path. This sidesteps the
|
||||
//! Engine's blanket `ToolOutputLimits` (default 64 KiB), which would
|
||||
//! otherwise drop the *tail* of the output — usually the most useful
|
||||
//! part (errors, exit messages, summary). The saved file lives under
|
||||
//! a caller-supplied directory that the parent has added to the
|
||||
//! `ScopedFs` allow set, so the agent can inspect it via either Read
|
||||
//! or a follow-up Bash call.
|
||||
//!
|
||||
//! Filesystem and network access are NOT mediated by `ScopedFs`: the
|
||||
//! child process can touch any path. Safety is delegated to the
|
||||
//! Permission layer (deny/allow rules on the command string).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
|
||||
const DESCRIPTION: &str = "Execute a shell command via bash. Supports the \
|
||||
full shell — pipes, redirects, command substitution, `&&`/`||`. Each call \
|
||||
runs in a fresh shell rooted at the workspace; chain `cd <subdir> && cmd` \
|
||||
when you need to operate elsewhere. stdout and stderr are merged. Default \
|
||||
timeout 120s, max 600s.\n\n\
|
||||
Output handling: when the command produces more than 80 lines (or ~12 KiB), \
|
||||
the full output is saved to a file and only the LAST 80 lines are returned, \
|
||||
prefixed with the saved path. The path is readable by Read; you can also \
|
||||
inspect it from a follow-up Bash call (`grep ... <path>`, etc.).\n\n\
|
||||
Prefer dedicated tools when one fits: Read instead of `cat`/`head`/`tail` \
|
||||
on workspace files, Edit instead of `sed`/`awk` rewrites, Glob instead of \
|
||||
`find <name>`, Grep instead of `grep`/`rg`. Reach for Bash when the task \
|
||||
is shell-shaped: building, testing, version control, package management.";
|
||||
use workdir::{CommandHandle, CommandOutputRequest, CommandRequest, WorkdirHandle};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
|
||||
/// Number of trailing lines returned when output spills to a file.
|
||||
const TAIL_LINES: usize = 80;
|
||||
|
||||
/// Inline-return budget. Outputs at or below this are returned in full;
|
||||
/// above it triggers the spill-to-file path. Sized to leave headroom under
|
||||
/// the Engine's 64 KiB default `ToolOutputLimits` cap so the inline path
|
||||
/// reliably reaches the model intact.
|
||||
const INLINE_BYTE_BUDGET: usize = 12 * 1024;
|
||||
|
||||
/// Maximum bytes loaded into memory from the spilled output file. The
|
||||
/// file itself can be arbitrarily large; we only ever read the tail end
|
||||
/// since that is what we return.
|
||||
const TAIL_READ_BUDGET: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct BashParams {
|
||||
/// Shell command to execute. Passed verbatim to `bash -c`.
|
||||
pub command: String,
|
||||
/// Timeout in seconds. Defaults to 120, capped at 600.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct BashParams {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
pub timeout: Option<u64>,
|
||||
timeout: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) struct BashTool {
|
||||
/// Workspace root that every invocation starts in. Snapshot of
|
||||
/// `ScopedFs::cwd()` at registration time; never mutated, since we
|
||||
/// don't track `cd` across calls.
|
||||
cwd: PathBuf,
|
||||
/// Directory to spill long outputs into. Caller is expected to have
|
||||
/// added this path to the readable scope so the agent can Read the
|
||||
/// saved files. The directory itself is created lazily.
|
||||
output_dir: PathBuf,
|
||||
/// Files we left on disk for follow-up inspection. Cleaned up on
|
||||
/// `Drop` (= session end). `std::sync::Mutex` because access is
|
||||
/// always synchronous and very brief.
|
||||
spilled_outputs: std::sync::Mutex<Vec<PathBuf>>,
|
||||
workdir: WorkdirHandle,
|
||||
}
|
||||
|
||||
impl Drop for BashTool {
|
||||
struct CommandGuard {
|
||||
workdir: WorkdirHandle,
|
||||
handle: Option<CommandHandle>,
|
||||
}
|
||||
|
||||
impl Drop for CommandGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut paths) = self.spilled_outputs.lock() {
|
||||
for p in paths.drain(..) {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let workdir = self.workdir.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = workdir.cancel_command(handle).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,509 +46,79 @@ impl Tool for BashTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: BashParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Bash input: {e}")))?;
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?;
|
||||
let timeout_secs = params
|
||||
.timeout
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
.clamp(1, MAX_TIMEOUT_SECS);
|
||||
|
||||
// Persistent output file in the caller-supplied directory.
|
||||
// `keep()` opts out of auto-delete so the agent can inspect the
|
||||
// full output later; cleanup is deferred to `Drop` on this tool.
|
||||
std::fs::create_dir_all(&self.output_dir).map_err(|e| {
|
||||
ToolError::Internal(format!(
|
||||
"create bash output dir {}: {e}",
|
||||
self.output_dir.display()
|
||||
))
|
||||
})?;
|
||||
let output_path: PathBuf = tempfile::Builder::new()
|
||||
.prefix("bash-")
|
||||
.suffix(".log")
|
||||
.tempfile_in(&self.output_dir)
|
||||
.map_err(|e| ToolError::Internal(format!("output tempfile: {e}")))?
|
||||
.into_temp_path()
|
||||
.keep()
|
||||
.map_err(|e| ToolError::Internal(format!("persist output tempfile: {e}")))?;
|
||||
|
||||
let output_path_str = output_path
|
||||
.to_str()
|
||||
.ok_or_else(|| ToolError::Internal("output path is not UTF-8".into()))?;
|
||||
|
||||
// Wrapper:
|
||||
// exec >file 2>&1 redirect stdout/stderr to the output file
|
||||
// { user_cmd } run in a brace group (no subshell, so any
|
||||
// `cd` inside still affects $? capture below)
|
||||
// __exit=$? preserve the user command's exit code…
|
||||
// wait 2>/dev/null …since `wait` clobbers $?. Reaping bg jobs
|
||||
// guarantees the output file's writers all
|
||||
// close before bash itself exits.
|
||||
// exit $__exit propagate the user's exit
|
||||
let wrapped = format!(
|
||||
"exec >{out} 2>&1\n{{ {user_cmd}\n}}\n__yoi_exit=$?\nwait 2>/dev/null\nexit $__yoi_exit\n",
|
||||
out = shell_single_quote(output_path_str),
|
||||
user_cmd = params.command,
|
||||
);
|
||||
|
||||
tracing::debug!(cmd = %params.command, cwd = %self.cwd.display(), timeout_secs, "Bash");
|
||||
|
||||
let mut child = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&wrapped)
|
||||
.current_dir(&self.cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null()) // bash inherits — but the wrapper redirected via `exec`
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
ToolError::ExecutionFailed(format!("spawn bash: {e}"))
|
||||
})?;
|
||||
|
||||
let timeout_dur = Duration::from_secs(timeout_secs);
|
||||
let wait_result = tokio::time::timeout(timeout_dur, child.wait()).await;
|
||||
let (status, timed_out) = match wait_result {
|
||||
Ok(Ok(s)) => (Some(s), false),
|
||||
Ok(Err(e)) => {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
return Err(ToolError::ExecutionFailed(format!("bash wait: {e}")));
|
||||
}
|
||||
Err(_) => (None, true),
|
||||
};
|
||||
|
||||
// Inspect the on-disk output: total size first, tail bytes second.
|
||||
let total_bytes = std::fs::metadata(&output_path)
|
||||
.map(|m| m.len() as usize)
|
||||
.unwrap_or(0);
|
||||
let tail_bytes = read_tail_bytes(&output_path, TAIL_READ_BUDGET).unwrap_or_default();
|
||||
let tail_text = String::from_utf8_lossy(&tail_bytes).into_owned();
|
||||
|
||||
let cmd_summary = truncate_for_summary(¶ms.command);
|
||||
|
||||
if timed_out {
|
||||
// Preserve the partial output file — even cut-short logs help
|
||||
// diagnose hangs.
|
||||
let content = if total_bytes > 0 {
|
||||
let last = take_last_n_lines(&tail_text, TAIL_LINES);
|
||||
self.remember_spilled(&output_path);
|
||||
Some(format!(
|
||||
"[partial output before timeout — full at {}]\n{last}",
|
||||
output_path.display()
|
||||
))
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
None
|
||||
};
|
||||
return Ok(ToolOutput {
|
||||
summary: format!("$ {cmd_summary} (timed out after {timeout_secs}s)"),
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
let status = status.expect("status set on the success branch");
|
||||
let summary = match status.code() {
|
||||
Some(0) => format!("$ {cmd_summary}"),
|
||||
Some(c) => format!("$ {cmd_summary} (exit {c})"),
|
||||
None => format!("$ {cmd_summary} (terminated by signal)"),
|
||||
let handle = self
|
||||
.workdir
|
||||
.start_command(CommandRequest {
|
||||
command: params.command,
|
||||
timeout_secs,
|
||||
output_limit: INLINE_BYTE_BUDGET,
|
||||
})
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
let mut guard = CommandGuard {
|
||||
workdir: self.workdir.clone(),
|
||||
handle: Some(handle.clone()),
|
||||
};
|
||||
let output = self
|
||||
.workdir
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: INLINE_BYTE_BUDGET,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
guard.handle = None;
|
||||
|
||||
if total_bytes == 0 {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
return Ok(ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Inline if the whole output fits in our tail-read window AND is
|
||||
// small enough to ride under the Engine's default cap.
|
||||
let line_count = tail_text.lines().count();
|
||||
let fully_loaded = total_bytes <= tail_bytes.len();
|
||||
let fits_inline =
|
||||
fully_loaded && total_bytes <= INLINE_BYTE_BUDGET && line_count <= TAIL_LINES;
|
||||
|
||||
let content = if fits_inline {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
Some(tail_text)
|
||||
let summary = if output.timed_out {
|
||||
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
||||
} else {
|
||||
let last = take_last_n_lines(&tail_text, TAIL_LINES);
|
||||
// When `fully_loaded` we know the exact line count; otherwise
|
||||
// the file is bigger than our read window so we report bytes
|
||||
// and an "approximate" disclaimer.
|
||||
let header = if fully_loaded {
|
||||
format!(
|
||||
"[showing last {TAIL_LINES} of {line_count} lines — full output ({total_bytes} bytes) at {}]",
|
||||
output_path.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[showing last {TAIL_LINES} lines (tail of {total_bytes}-byte output) — full at {}]",
|
||||
output_path.display()
|
||||
)
|
||||
};
|
||||
self.remember_spilled(&output_path);
|
||||
Some(format!("{header}\n{last}"))
|
||||
match output.exit_code {
|
||||
Some(0) => format!("$ {cmd_summary}"),
|
||||
Some(code) => format!("$ {cmd_summary} (exit {code})"),
|
||||
None => format!("$ {cmd_summary} (terminated)"),
|
||||
}
|
||||
};
|
||||
let content = if output.content.is_empty() {
|
||||
None
|
||||
} else if output.truncated {
|
||||
Some(format!(
|
||||
"[showing bounded Workdir command output; additional output was truncated]\n{}",
|
||||
output.content
|
||||
))
|
||||
} else {
|
||||
Some(output.content)
|
||||
};
|
||||
|
||||
Ok(ToolOutput { summary, content })
|
||||
}
|
||||
}
|
||||
|
||||
impl BashTool {
|
||||
fn remember_spilled(&self, path: &Path) {
|
||||
if let Ok(mut v) = self.spilled_outputs.lock() {
|
||||
v.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read up to `max_bytes` from the end of `path`. If the file is smaller
|
||||
/// than `max_bytes`, the entire file is returned.
|
||||
fn read_tail_bytes(path: &Path, max_bytes: usize) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let len = f.seek(SeekFrom::End(0))?;
|
||||
let start = if len > max_bytes as u64 {
|
||||
len - max_bytes as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
f.seek(SeekFrom::Start(start))?;
|
||||
let mut buf = Vec::with_capacity((len - start) as usize);
|
||||
f.read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Return the last `n` lines of `text`. If `text` has `n` or fewer lines
|
||||
/// (per [`str::lines`]), the input is returned as-is (no allocation).
|
||||
fn take_last_n_lines(text: &str, n: usize) -> String {
|
||||
if text.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let total = text.lines().count();
|
||||
if total <= n {
|
||||
return text.to_owned();
|
||||
}
|
||||
let skip = total - n;
|
||||
let mut count = 0usize;
|
||||
for (i, b) in text.bytes().enumerate() {
|
||||
if b == b'\n' {
|
||||
count += 1;
|
||||
if count == skip {
|
||||
return text[i + 1..].to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
text.to_owned()
|
||||
}
|
||||
|
||||
fn truncate_for_summary(command: &str) -> String {
|
||||
let one_line = command.lines().next().unwrap_or("");
|
||||
let mut chars = one_line.chars();
|
||||
let head: String = chars.by_ref().take(80).collect();
|
||||
if chars.next().is_some() {
|
||||
let mut shortened = head;
|
||||
while shortened.chars().count() > 77 {
|
||||
shortened.pop();
|
||||
}
|
||||
shortened.push_str("...");
|
||||
shortened
|
||||
} else {
|
||||
head
|
||||
const MAX: usize = 100;
|
||||
if command.chars().count() <= MAX {
|
||||
return command.to_owned();
|
||||
}
|
||||
let mut summary = command.chars().take(MAX - 1).collect::<String>();
|
||||
summary.push('…');
|
||||
summary
|
||||
}
|
||||
|
||||
/// Wrap a string in single quotes for safe inclusion in a bash command.
|
||||
fn shell_single_quote(s: &str) -> String {
|
||||
let escaped = s.replace('\'', "'\\''");
|
||||
format!("'{escaped}'")
|
||||
}
|
||||
|
||||
/// Factory for the `Bash` tool.
|
||||
///
|
||||
/// `output_dir` is where long outputs spill to; the caller is responsible
|
||||
/// for arranging that the path is in the agent's readable scope. Every
|
||||
/// invocation starts at `fs.cwd()` — the tool is intentionally stateless
|
||||
/// w.r.t. the working directory.
|
||||
pub fn bash_tool(fs: ScopedFs, output_dir: PathBuf) -> ToolDefinition {
|
||||
pub fn bash_tool(workdir: WorkdirHandle, _output_dir: PathBuf) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(BashParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Bash")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
.description("Execute a shell command in the bound Workdir. Process start, bounded output, timeout and cancellation are owned by the Workdir provider. This is not a sandbox.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
||||
cwd: fs.cwd().to_path_buf(),
|
||||
output_dir: output_dir.clone(),
|
||||
spilled_outputs: std::sync::Mutex::new(Vec::new()),
|
||||
workdir: workdir.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Test harness: workspace tempdir + a separate spill tempdir kept
|
||||
/// alive for the test's lifetime. The spill dir is added to the
|
||||
/// scope as readable so callers exercise the production path.
|
||||
struct Harness {
|
||||
_workspace: TempDir,
|
||||
spill: TempDir,
|
||||
fs: ScopedFs,
|
||||
}
|
||||
|
||||
fn setup() -> Harness {
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let base = Scope::writable(workspace.path()).unwrap();
|
||||
let mut config = manifest::ScopeConfig {
|
||||
allow: base.allow_rules(),
|
||||
deny: base.deny_rules(),
|
||||
};
|
||||
config.allow.push(manifest::ScopeRule {
|
||||
target: spill.path().to_path_buf(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
});
|
||||
let scope = Scope::from_config(&config).unwrap();
|
||||
let fs = ScopedFs::new(scope, workspace.path().to_path_buf());
|
||||
Harness {
|
||||
_workspace: workspace,
|
||||
spill,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tool(h: &Harness) -> Arc<dyn Tool> {
|
||||
let def = bash_tool(h.fs.clone(), h.spill.path().to_path_buf());
|
||||
let (_, tool) = def();
|
||||
tool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_simple_command() {
|
||||
let h = setup();
|
||||
let def = bash_tool(h.fs.clone(), h.spill.path().to_path_buf());
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Bash");
|
||||
|
||||
let inp = serde_json::json!({ "command": "echo hello" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.summary, "$ echo hello");
|
||||
assert_eq!(out.content.as_deref().map(str::trim), Some("hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merges_stdout_and_stderr() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"command": "echo out; echo err 1>&2",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("out"));
|
||||
assert!(body.contains("err"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonzero_exit_is_reported() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let inp = serde_json::json!({ "command": "exit 7" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("exit 7"), "summary: {}", out.summary);
|
||||
assert!(
|
||||
out.content.is_none(),
|
||||
"no output expected, got {:?}",
|
||||
out.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cd_does_not_persist_across_calls() {
|
||||
// Stateless: a `cd` in one call must NOT leak into the next.
|
||||
let h = setup();
|
||||
let sub = h._workspace.path().join("nested");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
tool.execute(
|
||||
&serde_json::json!({
|
||||
"command": format!("cd {}", sub.to_str().unwrap()),
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pwd_out = tool
|
||||
.execute(
|
||||
&serde_json::json!({ "command": "pwd" }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = pwd_out.content.unwrap();
|
||||
let actual = std::fs::canonicalize(body.trim()).unwrap();
|
||||
let workspace = std::fs::canonicalize(h._workspace.path()).unwrap();
|
||||
assert_eq!(
|
||||
actual, workspace,
|
||||
"second call should start at workspace root, not the previous cd target"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_kills_long_command() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"command": "sleep 30",
|
||||
"timeout": 1,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.summary.contains("timed out"),
|
||||
"summary: {}",
|
||||
out.summary
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_json_is_invalid_argument() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let err = tool
|
||||
.execute("not json", Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_output_spills_and_returns_tail() {
|
||||
let h = setup();
|
||||
let spill_dir = h.spill.path().to_path_buf();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
// 200 lines: "line 1" .. "line 200". Tail of 80 keeps lines 121-200.
|
||||
let inp = serde_json::json!({
|
||||
"command": "for i in $(seq 1 200); do echo line $i; done",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.expect("expected content");
|
||||
|
||||
assert!(
|
||||
body.contains(&format!("showing last {TAIL_LINES} of 200 lines")),
|
||||
"tail header missing in: {}",
|
||||
&body[..body.len().min(300)]
|
||||
);
|
||||
assert!(
|
||||
body.contains(spill_dir.to_str().unwrap()),
|
||||
"spill dir path missing: {body}"
|
||||
);
|
||||
// Last 80 lines are 121..200.
|
||||
assert!(body.contains("\nline 200\n"));
|
||||
assert!(body.contains("\nline 121\n"));
|
||||
// line 120 is the last *elided* line.
|
||||
assert!(!body.contains("\nline 120\n"), "elided line leaked: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wide_short_output_still_spills_when_byte_budget_exceeded() {
|
||||
let h = setup();
|
||||
let spill_dir = h.spill.path().to_path_buf();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
// One single line of ~20 KiB (over INLINE_BYTE_BUDGET = 12 KiB).
|
||||
let inp = serde_json::json!({
|
||||
"command": "printf 'x%.0s' {1..20480}",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(
|
||||
body.contains(spill_dir.to_str().unwrap()),
|
||||
"expected spill marker in: {}",
|
||||
&body[..body.len().min(200)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_job_does_not_hang() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
// The wrapper's `wait` ensures we don't hang on a stray bg pipe.
|
||||
let inp = serde_json::json!({
|
||||
"command": "(sleep 0.05; echo bg) &",
|
||||
"timeout": 5,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.summary.contains("timed out"),
|
||||
"summary: {}",
|
||||
out.summary
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spilled_files_are_cleaned_up_on_drop() {
|
||||
let h = setup();
|
||||
let spill_dir = h.spill.path().to_path_buf();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"command": "for i in $(seq 1 200); do echo $i; done",
|
||||
});
|
||||
tool.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The spill dir should now contain exactly one bash-*.log file.
|
||||
let files_before: Vec<_> = std::fs::read_dir(&spill_dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
assert_eq!(files_before.len(), 1, "expected one spilled file");
|
||||
let path = files_before.into_iter().next().unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
drop(tool);
|
||||
// Drop runs synchronously; file should be gone.
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"spilled file should be cleaned up on drop: {path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-64
@@ -8,18 +8,18 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{EditRequest, WorkdirHandle, WorkdirPath};
|
||||
|
||||
const DESCRIPTION: &str = "Replace a substring in an existing file. By default \
|
||||
`old_string` must be unique in the file; set `replace_all: true` to replace \
|
||||
every occurrence. The file must have been read first (via the Read tool) in \
|
||||
this session. Paths must be absolute.";
|
||||
this session. Paths are relative to the bound Workdir.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct EditParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
pub file_path: String,
|
||||
/// String to replace. Must be unique in the file unless `replace_all` is true.
|
||||
pub old_string: String,
|
||||
/// Replacement string. Must differ from `old_string`.
|
||||
@@ -30,7 +30,7 @@ pub(crate) struct EditParams {
|
||||
}
|
||||
|
||||
pub(crate) struct EditTool {
|
||||
fs: ScopedFs,
|
||||
workdir: WorkdirHandle,
|
||||
tracker: Tracker,
|
||||
}
|
||||
|
||||
@@ -44,11 +44,8 @@ impl Tool for EditTool {
|
||||
let params: EditParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Edit input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
replace_all = params.replace_all,
|
||||
"Edit"
|
||||
);
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
|
||||
|
||||
if params.old_string.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
@@ -61,51 +58,29 @@ impl Tool for EditTool {
|
||||
));
|
||||
}
|
||||
|
||||
let _mutation_permit = self.tracker.acquire_mutation(¶ms.file_path, &ctx).await;
|
||||
|
||||
// Load current content and verify it matches the recorded hash.
|
||||
let current_bytes = self.fs.read_bytes(¶ms.file_path)?;
|
||||
self.tracker.verify(¶ms.file_path, ¤t_bytes)?;
|
||||
|
||||
let current_text = std::str::from_utf8(¤t_bytes).map_err(|_| {
|
||||
ToolsError::InvalidArgument(format!(
|
||||
"file is not valid UTF-8: {}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let count = current_text.matches(¶ms.old_string).count();
|
||||
if count == 0 {
|
||||
return Err(ToolsError::StringNotFound {
|
||||
path: params.file_path.clone(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if !params.replace_all && count > 1 {
|
||||
return Err(ToolsError::NotUnique {
|
||||
path: params.file_path.clone(),
|
||||
count,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let new_text = if params.replace_all {
|
||||
current_text.replace(¶ms.old_string, ¶ms.new_string)
|
||||
} else {
|
||||
current_text.replacen(¶ms.old_string, ¶ms.new_string, 1)
|
||||
};
|
||||
let occurrences = if params.replace_all { count } else { 1 };
|
||||
|
||||
self.fs.write(¶ms.file_path, new_text.as_bytes())?;
|
||||
self.tracker.record(¶ms.file_path, new_text.as_bytes());
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = self.tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = self.tracker.expected_workdir_hash(&path)?;
|
||||
let result = self
|
||||
.workdir
|
||||
.edit(EditRequest {
|
||||
path: path.clone(),
|
||||
old_string: params.old_string.clone(),
|
||||
new_string: params.new_string.clone(),
|
||||
replace_all: params.replace_all,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
self.tracker.record_workdir_hash(&path, result.content_hash);
|
||||
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{})",
|
||||
params.file_path.display(),
|
||||
occurrences,
|
||||
if occurrences == 1 { "" } else { "s" }
|
||||
path,
|
||||
result.replacements,
|
||||
if result.replacements == 1 { "" } else { "s" }
|
||||
);
|
||||
let preview = make_preview(&new_text, ¶ms.new_string);
|
||||
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
@@ -140,7 +115,7 @@ fn make_preview(text: &str, needle: &str) -> String {
|
||||
}
|
||||
|
||||
/// Factory for the `Edit` tool.
|
||||
pub fn edit_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
pub fn edit_tool(workdir: WorkdirHandle, tracker: Tracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(EditParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
@@ -148,7 +123,7 @@ pub fn edit_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(EditTool {
|
||||
fs: fs.clone(),
|
||||
workdir: workdir.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -162,19 +137,19 @@ mod tests {
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||
fn setup() -> (TempDir, WorkdirHandle, Tracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
let fs: WorkdirHandle = Arc::new(workdir::LocalWorkdir::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
));
|
||||
(dir, fs, Tracker::new())
|
||||
}
|
||||
|
||||
async fn read_first(fs: &ScopedFs, tracker: &Tracker, file: &std::path::Path) {
|
||||
async fn read_first(fs: &WorkdirHandle, tracker: &Tracker, file: &std::path::Path) {
|
||||
let def = read_tool(fs.clone(), tracker.clone());
|
||||
let (_, reader) = def();
|
||||
let inp = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
let inp = serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() });
|
||||
reader
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
@@ -193,7 +168,7 @@ mod tests {
|
||||
assert_eq!(meta.name, "Edit");
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo bar",
|
||||
"new_string": "foo baz",
|
||||
});
|
||||
@@ -219,7 +194,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "x",
|
||||
"new_string": "y",
|
||||
"replace_all": true,
|
||||
@@ -242,7 +217,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "a",
|
||||
"new_string": "b",
|
||||
});
|
||||
@@ -263,7 +238,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "world",
|
||||
"new_string": "x",
|
||||
});
|
||||
@@ -283,7 +258,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
});
|
||||
@@ -307,7 +282,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
});
|
||||
|
||||
+18
-101
@@ -1,8 +1,8 @@
|
||||
//! Error type shared across the `tools` crate.
|
||||
//! Error types for builtin tools.
|
||||
//!
|
||||
//! `ToolsError` is the crate-level error returned by `ScopedFs` and each
|
||||
//! builtin tool's internal logic. Tool `execute()` impls convert it to
|
||||
//! [`llm_engine::tool::ToolError`] via the `From` impl defined here.
|
||||
//! `ToolsError` keeps tool-specific policy failures separate from Workdir
|
||||
//! operation failures. Filesystem, search, and command errors originate in
|
||||
//! `workdir` and remain transparent here.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -10,61 +10,8 @@ use llm_engine::tool::ToolError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToolsError {
|
||||
#[error("path must be absolute: {}", .0.display())]
|
||||
RelativePath(PathBuf),
|
||||
|
||||
#[error("path is outside allowed scope: {}", .0.display())]
|
||||
OutOfScope(PathBuf),
|
||||
|
||||
#[error(
|
||||
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Worker {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkOutOfScope {
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
required_permission: &'static str,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"broken symlink while resolving {}: {} -> {} (target does not exist); recreate the symlink with an absolute target or a correct relative target",
|
||||
.path.display(),
|
||||
.link.display(),
|
||||
.target.display()
|
||||
)]
|
||||
BrokenSymlink {
|
||||
path: PathBuf,
|
||||
link: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"path resolves through a symlink to a directory, not a file: {} -> {}",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf },
|
||||
|
||||
#[error(
|
||||
"{tool} does not follow symlink directories: {} -> {}; use the resolved target path directly, or add the target to read scope and reference it without the symlink",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkDirectoryNotTraversed {
|
||||
tool: &'static str,
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
|
||||
#[error("path is read-only in this scope: {}", .0.display())]
|
||||
ReadOnly(PathBuf),
|
||||
|
||||
#[error("path is a directory: {}", .0.display())]
|
||||
IsDirectory(PathBuf),
|
||||
|
||||
#[error("file not found: {}", .0.display())]
|
||||
NotFound(PathBuf),
|
||||
#[error(transparent)]
|
||||
Workdir(#[from] workdir::WorkdirError),
|
||||
|
||||
#[error("file has not been read in this session; read it first: {}", .0.display())]
|
||||
NotRead(PathBuf),
|
||||
@@ -83,52 +30,22 @@ pub enum ToolsError {
|
||||
|
||||
#[error("invalid argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
|
||||
#[error("invalid regex: {0}")]
|
||||
InvalidRegex(String),
|
||||
|
||||
#[error("invalid glob pattern: {0}")]
|
||||
InvalidGlob(String),
|
||||
|
||||
#[error("I/O error at {}: {source}", .path.display())]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolsError {
|
||||
/// Helper to wrap an [`std::io::Error`] with the path it occurred on.
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolsError> for ToolError {
|
||||
fn from(err: ToolsError) -> Self {
|
||||
use ToolsError::*;
|
||||
match err {
|
||||
RelativePath(_)
|
||||
| OutOfScope(_)
|
||||
| SymlinkOutOfScope { .. }
|
||||
| BrokenSymlink { .. }
|
||||
| SymlinkTargetIsDirectory { .. }
|
||||
| SymlinkDirectoryNotTraversed { .. }
|
||||
| ReadOnly(_)
|
||||
| IsDirectory(_)
|
||||
| NotRead(_)
|
||||
| ExternallyModified(_)
|
||||
| StringNotFound { .. }
|
||||
| NotUnique { .. }
|
||||
| InvalidArgument(_)
|
||||
| InvalidRegex(_)
|
||||
| InvalidGlob(_) => ToolError::InvalidArgument(err.to_string()),
|
||||
NotFound(_) => ToolError::ExecutionFailed(err.to_string()),
|
||||
Io { .. } => ToolError::ExecutionFailed(err.to_string()),
|
||||
match &err {
|
||||
ToolsError::Workdir(
|
||||
workdir::WorkdirError::NotFound(_)
|
||||
| workdir::WorkdirError::Io { .. }
|
||||
| workdir::WorkdirError::Unavailable(_),
|
||||
) => ToolError::ExecutionFailed(err.to_string()),
|
||||
ToolsError::Workdir(_)
|
||||
| ToolsError::NotRead(_)
|
||||
| ToolsError::ExternallyModified(_)
|
||||
| ToolsError::StringNotFound { .. }
|
||||
| ToolsError::NotUnique { .. }
|
||||
| ToolsError::InvalidArgument(_) => ToolError::InvalidArgument(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-357
@@ -1,36 +1,26 @@
|
||||
//! `Glob` tool — recursive file search by glob pattern, sorted by mtime.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use manifest::Scope;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use workdir::{GlobRequest, WorkdirHandle, WorkdirPath};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::scoped_fs::{ScopedFs, direct_symlink};
|
||||
|
||||
const DESCRIPTION: &str = "Recursively find files matching a glob pattern \
|
||||
(e.g. \"**/*.rs\"). Results are sorted by modification time, newest first, \
|
||||
and capped at 1000 entries. Hidden files are included. The `path` parameter \
|
||||
defaults to the scope root when omitted. Paths must be absolute.";
|
||||
use crate::ToolsError;
|
||||
|
||||
const RESULT_LIMIT: usize = 1000;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct GlobParams {
|
||||
/// Glob pattern, e.g. `"**/*.rs"`. Matched against paths relative to
|
||||
/// `path` (or the scope root if omitted).
|
||||
pub pattern: String,
|
||||
/// Absolute directory to search under. Defaults to the scope root.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GlobParams {
|
||||
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
|
||||
pattern: String,
|
||||
/// Logical Workdir-relative directory. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
pub path: Option<PathBuf>,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct GlobTool {
|
||||
fs: ScopedFs,
|
||||
struct GlobTool {
|
||||
workdir: WorkdirHandle,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -41,358 +31,57 @@ impl Tool for GlobTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: GlobParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Glob input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
pattern = %params.pattern,
|
||||
path = ?params.path,
|
||||
"Glob"
|
||||
);
|
||||
|
||||
let base = params
|
||||
.path
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.fs.cwd().to_path_buf());
|
||||
let pattern = params.pattern.clone();
|
||||
let scope = self.fs.scope().clone();
|
||||
|
||||
// ignore::Walk is synchronous; run it on a blocking thread so we
|
||||
// don't stall the runtime for large trees.
|
||||
let results = tokio::task::spawn_blocking(move || run_glob(&base, &pattern, &scope))
|
||||
.await
|
||||
.map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??;
|
||||
|
||||
let total = results.len();
|
||||
let (shown, truncated) = if total > RESULT_LIMIT {
|
||||
(&results[..RESULT_LIMIT], true)
|
||||
} else {
|
||||
(&results[..], false)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Glob input: {error}")))?;
|
||||
let path = match params.path {
|
||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
|
||||
if shown.is_empty() {
|
||||
return Ok(ToolOutput {
|
||||
summary: format!("No files found matching {}", params.pattern),
|
||||
content: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut body = String::new();
|
||||
for p in shown {
|
||||
body.push_str(&p.display().to_string());
|
||||
let pattern = params.pattern;
|
||||
tracing::debug!(%pattern, %path, "Glob");
|
||||
let result = self
|
||||
.workdir
|
||||
.glob(GlobRequest {
|
||||
pattern: pattern.clone(),
|
||||
path,
|
||||
limit: RESULT_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
let mut body = result
|
||||
.paths
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !body.is_empty() {
|
||||
body.push('\n');
|
||||
}
|
||||
|
||||
let summary = if truncated {
|
||||
let summary = if result.paths.is_empty() {
|
||||
format!("No files found matching {pattern}")
|
||||
} else if result.truncated {
|
||||
format!(
|
||||
"Found {total}+ files matching {} (truncated to {RESULT_LIMIT})",
|
||||
params.pattern
|
||||
"Found {}+ files matching {pattern} (truncated to {RESULT_LIMIT})",
|
||||
result.paths.len()
|
||||
)
|
||||
} else {
|
||||
format!("Found {total} file(s) matching {}", params.pattern)
|
||||
format!("Found {} file(s) matching {pattern}", result.paths.len())
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
content: (!body.is_empty()).then_some(body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn run_glob(base: &Path, pattern: &str, scope: &Scope) -> Result<Vec<PathBuf>, ToolsError> {
|
||||
if !base.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(base.to_path_buf()));
|
||||
}
|
||||
let symlink = direct_symlink(base);
|
||||
if !scope.is_readable(base) {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.parent()
|
||||
.map(|parent| scope.is_readable(parent))
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
ToolsError::SymlinkOutOfScope {
|
||||
path: base.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
required_permission: "read",
|
||||
}
|
||||
} else {
|
||||
ToolsError::OutOfScope(base.to_path_buf())
|
||||
}
|
||||
} else {
|
||||
ToolsError::OutOfScope(base.to_path_buf())
|
||||
});
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(ToolsError::BrokenSymlink {
|
||||
path: base.to_path_buf(),
|
||||
link: info.link_path.clone(),
|
||||
target: info.target_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let base_meta = std::fs::metadata(base).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(base.to_path_buf()),
|
||||
_ => ToolsError::io(base, e),
|
||||
})?;
|
||||
if !base_meta.is_dir() {
|
||||
return Err(ToolsError::InvalidArgument(format!(
|
||||
"glob search path is not a directory: {}",
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
return Err(ToolsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Glob",
|
||||
path: base.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let glob = globset::Glob::new(pattern)
|
||||
.map_err(|e| ToolsError::InvalidGlob(e.to_string()))?
|
||||
.compile_matcher();
|
||||
|
||||
// Glob is an explicit-pattern tool, so gitignore/hidden are *not* honored.
|
||||
let walker = ignore::WalkBuilder::new(base)
|
||||
.hidden(false)
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false)
|
||||
.ignore(false)
|
||||
.parents(false)
|
||||
.follow_links(false)
|
||||
.build();
|
||||
|
||||
let mut hits: Vec<(PathBuf, SystemTime)> = Vec::new();
|
||||
for entry in walker.flatten() {
|
||||
let ft = match entry.file_type() {
|
||||
Some(ft) => ft,
|
||||
None => continue,
|
||||
};
|
||||
if !ft.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = match entry.path().strip_prefix(base) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !glob.is_match(rel) {
|
||||
continue;
|
||||
}
|
||||
if !scope.is_readable(entry.path()) {
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
hits.push((entry.path().to_path_buf(), mtime));
|
||||
}
|
||||
|
||||
hits.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
Ok(hits.into_iter().map(|(p, _)| p).collect())
|
||||
}
|
||||
|
||||
/// Factory for the `Glob` tool.
|
||||
pub fn glob_tool(fs: ScopedFs) -> ToolDefinition {
|
||||
pub fn glob_tool(workdir: WorkdirHandle) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(GlobParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Glob")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(GlobTool { fs: fs.clone() });
|
||||
.description("Find files matching a glob pattern inside the bound Workdir. Results are sorted and capped at 1000 entries. Paths are Workdir-relative.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Glob schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(GlobTool {
|
||||
workdir: workdir.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
(dir, fs)
|
||||
}
|
||||
|
||||
fn touch(path: &Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_finds_matching_files() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "");
|
||||
touch(&dir.path().join("sub/b.rs"), "");
|
||||
touch(&dir.path().join("sub/c.txt"), "");
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Glob");
|
||||
|
||||
let inp = serde_json::json!({ "pattern": "**/*.rs" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("2 file(s)"));
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(body.contains("b.rs"));
|
||||
assert!(!body.contains("c.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_sorts_by_mtime_desc() {
|
||||
let (dir, fs) = setup();
|
||||
let older = dir.path().join("old.rs");
|
||||
let newer = dir.path().join("new.rs");
|
||||
touch(&older, "");
|
||||
touch(&newer, "");
|
||||
|
||||
filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(1_000, 0)).unwrap();
|
||||
filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(2_000, 0)).unwrap();
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "*.rs" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
let new_pos = body.find("new.rs").unwrap();
|
||||
let old_pos = body.find("old.rs").unwrap();
|
||||
assert!(new_pos < old_pos, "newer file should come first:\n{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_empty_results() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "**/*.nonexistent" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("No files"));
|
||||
assert!(out.content.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_invalid_pattern() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "[unterminated" });
|
||||
let err = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_filters_results_by_scope_readability() {
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule};
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let secret_dir = dir.path().join("secret");
|
||||
std::fs::create_dir(&secret_dir).unwrap();
|
||||
touch(&dir.path().join("visible.rs"), "");
|
||||
touch(&secret_dir.join("hidden.rs"), "");
|
||||
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: secret_dir.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "**/*.rs" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap_or_default();
|
||||
assert!(body.contains("visible.rs"));
|
||||
assert!(
|
||||
!body.contains("hidden.rs"),
|
||||
"scope-denied file leaked into glob output: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_honors_hidden_files() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join(".hidden.rs"), "");
|
||||
touch(&dir.path().join("visible.rs"), "");
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "*.rs" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(".hidden.rs"));
|
||||
assert!(body.contains("visible.rs"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn glob_reports_scope_inside_symlink_directory_is_not_traversed() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let (dir, fs) = setup();
|
||||
let target = dir.path().join("target-dir");
|
||||
touch(&target.join("visible.rs"), "");
|
||||
let link = dir.path().join("external-project");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"path": link.to_str().unwrap(),
|
||||
"pattern": "**/*.rs",
|
||||
});
|
||||
let err = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("Glob does not follow symlink directories"),
|
||||
"{msg}"
|
||||
);
|
||||
assert!(msg.contains(&link.display().to_string()), "{msg}");
|
||||
assert!(
|
||||
msg.contains(&target.canonicalize().unwrap().display().to_string()),
|
||||
"{msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+91
-828
@@ -1,83 +1,54 @@
|
||||
//! `Grep` tool — recursive regex search powered by ripgrep's component crates.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use grep_regex::RegexMatcherBuilder;
|
||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
||||
use ignore::WalkBuilder;
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
use ignore::types::TypesBuilder;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use manifest::Scope;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use workdir::{GrepOutputMode, GrepRequest, WorkdirHandle, WorkdirPath};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::scoped_fs::{ScopedFs, direct_symlink};
|
||||
|
||||
const DESCRIPTION: &str = "Recursive regex search across files, powered by \
|
||||
ripgrep. Supports file filtering (`glob`, `type`), context lines, multiline \
|
||||
matching, and three output modes: `files_with_matches` (default), `content`, \
|
||||
and `count`. Honors .gitignore. Binary files are skipped. Paths must be \
|
||||
absolute.";
|
||||
use crate::ToolsError;
|
||||
|
||||
const DEFAULT_HEAD_LIMIT: usize = 250;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum GrepOutputMode {
|
||||
enum OutputMode {
|
||||
Content,
|
||||
#[default]
|
||||
FilesWithMatches,
|
||||
Content,
|
||||
Count,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct GrepParams {
|
||||
/// Regex pattern to search for.
|
||||
pub pattern: String,
|
||||
/// Absolute path to search under. Defaults to the scope root.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GrepParams {
|
||||
pattern: String,
|
||||
/// Logical Workdir-relative path to search. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
pub path: Option<PathBuf>,
|
||||
/// Glob filter applied to candidate files, e.g. `"*.rs"`.
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub glob: Option<String>,
|
||||
/// File type filter, e.g. `"rust"` or `"py"`. See ripgrep's default types.
|
||||
glob: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub file_type: Option<String>,
|
||||
/// Output mode: `files_with_matches` (default), `content`, or `count`.
|
||||
file_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub output_mode: Option<GrepOutputMode>,
|
||||
/// Show line numbers in content mode. Defaults to true.
|
||||
#[serde(default, rename = "-n")]
|
||||
pub line_numbers: Option<bool>,
|
||||
/// Case-insensitive matching.
|
||||
#[serde(default, rename = "-i")]
|
||||
pub case_insensitive: bool,
|
||||
/// Trailing context lines after each match.
|
||||
#[serde(default, rename = "-A")]
|
||||
pub after: Option<usize>,
|
||||
/// Leading context lines before each match.
|
||||
case_insensitive: bool,
|
||||
#[serde(default, rename = "-B")]
|
||||
pub before: Option<usize>,
|
||||
/// Context lines before AND after each match (overrides -A/-B when set).
|
||||
before: Option<usize>,
|
||||
#[serde(default, rename = "-A")]
|
||||
after: Option<usize>,
|
||||
#[serde(default, rename = "-C")]
|
||||
pub context: Option<usize>,
|
||||
/// Allow patterns to match across newlines.
|
||||
context: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub multiline: bool,
|
||||
/// Maximum number of output entries. Defaults to 250.
|
||||
multiline: bool,
|
||||
#[serde(default)]
|
||||
pub head_limit: Option<usize>,
|
||||
/// Skip the first N output entries (pagination).
|
||||
output_mode: Option<OutputMode>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
head_limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
offset: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) struct GrepTool {
|
||||
fs: ScopedFs,
|
||||
struct GrepTool {
|
||||
workdir: WorkdirHandle,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -88,788 +59,80 @@ impl Tool for GrepTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: GrepParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Grep input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
pattern = %params.pattern,
|
||||
mode = ?params.output_mode,
|
||||
"Grep"
|
||||
);
|
||||
|
||||
let default_base = self.fs.cwd().to_path_buf();
|
||||
let scope = self.fs.scope().clone();
|
||||
let report = tokio::task::spawn_blocking(move || run_grep(default_base, params, &scope))
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?;
|
||||
let path = match params.path {
|
||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
let mode = match params.output_mode.unwrap_or_default() {
|
||||
OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches,
|
||||
OutputMode::Content => GrepOutputMode::Content,
|
||||
OutputMode::Count => GrepOutputMode::Count,
|
||||
};
|
||||
let (before_context, after_context) = params
|
||||
.context
|
||||
.map(|context| (context, context))
|
||||
.unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0)));
|
||||
let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let result = self
|
||||
.workdir
|
||||
.grep(GrepRequest {
|
||||
pattern: params.pattern,
|
||||
path,
|
||||
glob: params.glob,
|
||||
file_type: params.file_type,
|
||||
case_insensitive: params.case_insensitive,
|
||||
before_context,
|
||||
after_context,
|
||||
multiline: params.multiline,
|
||||
output_mode: mode,
|
||||
limit: head_limit,
|
||||
offset: params.offset.unwrap_or(0),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??;
|
||||
.map_err(ToolsError::from)?;
|
||||
|
||||
Ok(report.render())
|
||||
let summary = if result.match_count == 0 {
|
||||
match mode {
|
||||
GrepOutputMode::Content => "No matches".to_owned(),
|
||||
_ => "No files matched".to_owned(),
|
||||
}
|
||||
} else {
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
format!("Found matches in {} file(s)", result.matched_files)
|
||||
}
|
||||
GrepOutputMode::Count => format!(
|
||||
"Found matches in {} file(s), {} total line(s)",
|
||||
result.matched_files, result.match_count
|
||||
),
|
||||
GrepOutputMode::Content => format!(
|
||||
"{} matching line(s) in {} file(s)",
|
||||
result.match_count, result.matched_files
|
||||
),
|
||||
}
|
||||
};
|
||||
let summary = if result.truncated {
|
||||
format!("{summary} (truncated at {head_limit})")
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: (!result.output.is_empty()).then_some(result.output),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for the `Grep` tool.
|
||||
pub fn grep_tool(fs: ScopedFs) -> ToolDefinition {
|
||||
pub fn grep_tool(workdir: WorkdirHandle) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(GrepParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Grep")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool { fs: fs.clone() });
|
||||
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the Workdir provider. Results are bounded and Workdir-relative.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||
workdir: workdir.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Implementation
|
||||
// =============================================================================
|
||||
|
||||
struct ContentLine {
|
||||
path: PathBuf,
|
||||
line_number: Option<u64>,
|
||||
text: String,
|
||||
is_match: bool,
|
||||
}
|
||||
|
||||
struct GrepReport {
|
||||
mode: GrepOutputMode,
|
||||
show_line_numbers: bool,
|
||||
files: Vec<PathBuf>,
|
||||
counts: Vec<(PathBuf, usize)>,
|
||||
lines: Vec<ContentLine>,
|
||||
truncated: bool,
|
||||
head_limit: usize,
|
||||
}
|
||||
|
||||
impl GrepReport {
|
||||
fn render(self) -> ToolOutput {
|
||||
match self.mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
if self.files.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No files matched".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let mut body = String::new();
|
||||
for p in &self.files {
|
||||
body.push_str(&p.display().to_string());
|
||||
body.push('\n');
|
||||
}
|
||||
let mut summary = format!("Found matches in {} file(s)", self.files.len());
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
if self.counts.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No files matched".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let total_lines: usize = self.counts.iter().map(|(_, n)| *n).sum();
|
||||
let mut body = String::new();
|
||||
for (p, n) in &self.counts {
|
||||
body.push_str(&format!("{}:{}\n", p.display(), n));
|
||||
}
|
||||
let mut summary = format!(
|
||||
"Found matches in {} file(s), {} total line(s)",
|
||||
self.counts.len(),
|
||||
total_lines
|
||||
);
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
if self.lines.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No matches".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let match_count = self.lines.iter().filter(|l| l.is_match).count();
|
||||
let file_set: std::collections::BTreeSet<&Path> =
|
||||
self.lines.iter().map(|l| l.path.as_path()).collect();
|
||||
let mut body = String::new();
|
||||
for line in &self.lines {
|
||||
let sep = if line.is_match { ':' } else { '-' };
|
||||
if self.show_line_numbers {
|
||||
if let Some(n) = line.line_number {
|
||||
body.push_str(&format!(
|
||||
"{}{}{}{}{}\n",
|
||||
line.path.display(),
|
||||
sep,
|
||||
n,
|
||||
sep,
|
||||
line.text
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
body.push_str(&format!("{}{}{}\n", line.path.display(), sep, line.text));
|
||||
}
|
||||
let mut summary = format!(
|
||||
"{} matching line(s) in {} file(s)",
|
||||
match_count,
|
||||
file_set.len()
|
||||
);
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_grep(default_base: PathBuf, p: GrepParams, scope: &Scope) -> Result<GrepReport, ToolsError> {
|
||||
let matcher = RegexMatcherBuilder::new()
|
||||
.case_insensitive(p.case_insensitive)
|
||||
.multi_line(p.multiline)
|
||||
.dot_matches_new_line(p.multiline)
|
||||
.build(&p.pattern)
|
||||
.map_err(|e| ToolsError::InvalidRegex(e.to_string()))?;
|
||||
|
||||
let (before, after) = match (p.before, p.after, p.context) {
|
||||
(_, _, Some(c)) => (c, c),
|
||||
(b, a, None) => (b.unwrap_or(0), a.unwrap_or(0)),
|
||||
};
|
||||
|
||||
let mut sb = SearcherBuilder::new();
|
||||
sb.binary_detection(BinaryDetection::quit(b'\x00'))
|
||||
.line_number(p.line_numbers.unwrap_or(true))
|
||||
.multi_line(p.multiline)
|
||||
.before_context(before)
|
||||
.after_context(after);
|
||||
let mut searcher = sb.build();
|
||||
|
||||
let base = p.path.unwrap_or(default_base);
|
||||
if !base.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(base));
|
||||
}
|
||||
let symlink = direct_symlink(&base);
|
||||
if !scope.is_readable(&base) {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.parent()
|
||||
.map(|parent| scope.is_readable(parent))
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
ToolsError::SymlinkOutOfScope {
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
required_permission: "read",
|
||||
}
|
||||
} else {
|
||||
ToolsError::OutOfScope(base.clone())
|
||||
}
|
||||
} else {
|
||||
ToolsError::OutOfScope(base.clone())
|
||||
});
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(ToolsError::BrokenSymlink {
|
||||
path: base.clone(),
|
||||
link: info.link_path.clone(),
|
||||
target: info.target_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(base.clone()),
|
||||
_ => ToolsError::io(&base, e),
|
||||
})?;
|
||||
if !base_meta.is_dir() {
|
||||
return Err(ToolsError::InvalidArgument(format!(
|
||||
"grep search path is not a directory: {}",
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
return Err(ToolsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Grep",
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut wb = WalkBuilder::new(&base);
|
||||
wb.hidden(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.ignore(true)
|
||||
.parents(true)
|
||||
.follow_links(false);
|
||||
|
||||
if let Some(t) = p.file_type.as_deref() {
|
||||
let mut tb = TypesBuilder::new();
|
||||
tb.add_defaults();
|
||||
tb.select(t);
|
||||
let types = tb
|
||||
.build()
|
||||
.map_err(|e| ToolsError::InvalidArgument(format!("invalid type {t}: {e}")))?;
|
||||
wb.types(types);
|
||||
}
|
||||
if let Some(g) = p.glob.as_deref() {
|
||||
let mut ob = OverrideBuilder::new(&base);
|
||||
ob.add(g)
|
||||
.map_err(|e| ToolsError::InvalidGlob(e.to_string()))?;
|
||||
let ov = ob
|
||||
.build()
|
||||
.map_err(|e| ToolsError::InvalidGlob(e.to_string()))?;
|
||||
wb.overrides(ov);
|
||||
}
|
||||
|
||||
let mode = p.output_mode.unwrap_or_default();
|
||||
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let offset = p.offset.unwrap_or(0);
|
||||
let show_line_numbers = p.line_numbers.unwrap_or(true);
|
||||
|
||||
let mut report = GrepReport {
|
||||
mode,
|
||||
show_line_numbers,
|
||||
files: Vec::new(),
|
||||
counts: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
truncated: false,
|
||||
head_limit,
|
||||
};
|
||||
|
||||
// Per-mode walker state.
|
||||
let mut matching_files_seen: usize = 0;
|
||||
let mut matches_seen: usize = 0;
|
||||
|
||||
'walker: for entry in wb.build().flatten() {
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !scope.is_readable(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
let hit = scan_any_match(&mut searcher, &matcher, path)?;
|
||||
if !hit {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.files.push(path.to_path_buf());
|
||||
if report.files.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
let count = scan_count(&mut searcher, &matcher, path)?;
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.counts.push((path.to_path_buf(), count));
|
||||
if report.counts.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
let before_count = matches_seen;
|
||||
let mut sink = ContentSink {
|
||||
path: path.to_path_buf(),
|
||||
lines: &mut report.lines,
|
||||
matches_seen: &mut matches_seen,
|
||||
offset,
|
||||
head_limit,
|
||||
};
|
||||
searcher
|
||||
.search_path(&matcher, path, &mut sink)
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
// If we hit head_limit during this file, stop walking.
|
||||
if matches_seen >= offset.saturating_add(head_limit) && matches_seen > before_count
|
||||
{
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn scan_any_match(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<bool, ToolsError> {
|
||||
let mut hit = false;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
hit = true;
|
||||
Ok(false) // stop searching this file immediately
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
Ok(hit)
|
||||
}
|
||||
|
||||
fn scan_count(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<usize, ToolsError> {
|
||||
let mut count = 0usize;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
count += 1;
|
||||
Ok(true)
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
struct ContentSink<'a> {
|
||||
path: PathBuf,
|
||||
lines: &'a mut Vec<ContentLine>,
|
||||
matches_seen: &'a mut usize,
|
||||
offset: usize,
|
||||
head_limit: usize,
|
||||
}
|
||||
|
||||
impl Sink for ContentSink<'_> {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
|
||||
let idx = *self.matches_seen;
|
||||
*self.matches_seen += 1;
|
||||
|
||||
// Skip matches before offset.
|
||||
if idx < self.offset {
|
||||
return Ok(true);
|
||||
}
|
||||
// Stop searching this file once we've filled the head_limit.
|
||||
if idx >= self.offset.saturating_add(self.head_limit) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(mat.bytes())
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
self.lines.push(ContentLine {
|
||||
path: self.path.clone(),
|
||||
line_number: mat.line_number(),
|
||||
text,
|
||||
is_match: true,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn context(
|
||||
&mut self,
|
||||
_searcher: &Searcher,
|
||||
ctx: &SinkContext<'_>,
|
||||
) -> Result<bool, Self::Error> {
|
||||
let seen = *self.matches_seen;
|
||||
if seen < self.offset {
|
||||
return Ok(true);
|
||||
}
|
||||
if seen >= self.offset.saturating_add(self.head_limit) {
|
||||
return Ok(false);
|
||||
}
|
||||
let text = String::from_utf8_lossy(ctx.bytes())
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
self.lines.push(ContentLine {
|
||||
path: self.path.clone(),
|
||||
line_number: ctx.line_number(),
|
||||
text,
|
||||
is_match: false,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
(dir, fs)
|
||||
}
|
||||
|
||||
fn touch(path: &Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_filters_results_by_scope_readability() {
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule};
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let secret_dir = dir.path().join("secret");
|
||||
fs::create_dir(&secret_dir).unwrap();
|
||||
touch(&dir.path().join("visible.txt"), "needle\n");
|
||||
touch(&secret_dir.join("hidden.txt"), "needle\n");
|
||||
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: secret_dir.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let scoped = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
|
||||
let def = grep_tool(scoped);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "needle" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap_or_default();
|
||||
assert!(body.contains("visible.txt"));
|
||||
assert!(
|
||||
!body.contains("hidden.txt"),
|
||||
"scope-denied file leaked into grep output: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_files_with_matches_default() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "alpha\nbravo\n");
|
||||
touch(&dir.path().join("b.txt"), "charlie\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Grep");
|
||||
|
||||
let inp = serde_json::json!({ "pattern": "bravo" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("1 file"));
|
||||
assert!(out.content.unwrap().contains("a.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_content_mode_with_line_numbers() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "one\ntwo\nthree\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "two",
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(":2:two"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_count_mode() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "x\nx\nx\n");
|
||||
touch(&dir.path().join("b.txt"), "x\ny\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"output_mode": "count",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.txt:3"));
|
||||
assert!(body.contains("b.txt:1"));
|
||||
assert!(out.summary.contains("4 total"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_case_insensitive() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "HELLO\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "hello",
|
||||
"-i": true,
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.content.unwrap().contains("HELLO"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_context_lines() {
|
||||
let (dir, fs) = setup();
|
||||
touch(
|
||||
&dir.path().join("a.txt"),
|
||||
"line1\nline2\nMATCH\nline4\nline5\n",
|
||||
);
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "MATCH",
|
||||
"output_mode": "content",
|
||||
"-C": 1,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
// should contain: line2 (before context), MATCH, line4 (after context)
|
||||
assert!(body.contains("line2"));
|
||||
assert!(body.contains("MATCH"));
|
||||
assert!(body.contains("line4"));
|
||||
assert!(!body.contains("line1"));
|
||||
assert!(!body.contains("line5"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_multiline() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "start\nfoo\nbar\nend\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
// Match across newlines: "foo" followed by "bar" on the next line
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "foo[\\s\\S]*?bar",
|
||||
"multiline": true,
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("foo"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_glob_filter() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "target\n");
|
||||
touch(&dir.path().join("b.txt"), "target\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "target",
|
||||
"glob": "*.rs",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(!body.contains("b.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_type_filter() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "target\n");
|
||||
touch(&dir.path().join("b.py"), "target\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "target",
|
||||
"type": "rust",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(!body.contains("b.py"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_head_limit_truncates() {
|
||||
let (dir, fs) = setup();
|
||||
for i in 0..5 {
|
||||
touch(&dir.path().join(format!("f{i}.txt")), "x\n");
|
||||
}
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"head_limit": 2,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert_eq!(body.lines().count(), 2);
|
||||
assert!(out.summary.contains("truncated at 2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_offset_paginates() {
|
||||
let (dir, fs) = setup();
|
||||
// Create 5 files, all matching, deterministically named
|
||||
for i in 0..5 {
|
||||
touch(&dir.path().join(format!("f{i}.txt")), "x\n");
|
||||
}
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"offset": 3,
|
||||
"head_limit": 10,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
// We skipped 3, so only 2 should remain.
|
||||
assert_eq!(body.lines().count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_binary_files_are_skipped() {
|
||||
let (dir, fs) = setup();
|
||||
let mut bin = Vec::from(b"\x00\x01\x02needle\n".as_slice());
|
||||
bin.extend(b"more\n");
|
||||
fs::write(dir.path().join("a.bin"), bin).unwrap();
|
||||
touch(&dir.path().join("b.txt"), "needle\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "needle" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("b.txt"));
|
||||
assert!(!body.contains("a.bin"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_invalid_regex() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "(" });
|
||||
let err = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_unknown_type() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"type": "nonexistent",
|
||||
});
|
||||
let err = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_no_matches() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "nothing here\n");
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "zzz" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.summary, "No files matched");
|
||||
assert!(out.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_schema_contains_dash_keys() {
|
||||
// Sanity check: schemars must preserve the `-n`, `-A`, etc. keys
|
||||
// from serde(rename). If this fails we need to rename the fields.
|
||||
let schema = schemars::schema_for!(GrepParams);
|
||||
let json = serde_json::to_value(&schema).unwrap();
|
||||
let json_str = json.to_string();
|
||||
assert!(json_str.contains("\"-n\""), "schema missing -n: {json_str}");
|
||||
assert!(json_str.contains("\"-A\""), "schema missing -A: {json_str}");
|
||||
assert!(json_str.contains("\"-B\""), "schema missing -B: {json_str}");
|
||||
assert!(json_str.contains("\"-C\""), "schema missing -C: {json_str}");
|
||||
assert!(json_str.contains("\"-i\""), "schema missing -i: {json_str}");
|
||||
}
|
||||
}
|
||||
|
||||
+58
-39
@@ -1,25 +1,14 @@
|
||||
//! Built-in tools for the Yoi LLM agent.
|
||||
//!
|
||||
//! Implements Read / Write / Edit / Glob / Grep / Bash on top of the
|
||||
//! `llm-engine` `Tool` infrastructure. Filesystem access is mediated by
|
||||
//! two orthogonal concerns:
|
||||
//! Read / Write / Edit / Glob / Grep / Bash operate through a host-owned
|
||||
//! [`workdir::Workdir`] handle. This crate owns tool schemas, rendering, and
|
||||
//! read-before-edit tracking; it does not own Workdir identity or lifecycle.
|
||||
//!
|
||||
//! - [`ScopedFs`] — Worker-process lifetime, expresses the write-block
|
||||
//! boundary for the current scope. Derived from the manifest; not
|
||||
//! persisted across Worker restart.
|
||||
//! - [`Tracker`] — Worker-process lifetime, enforces the "read before edit"
|
||||
//! policy via content hashes and tracks the recency of touched files.
|
||||
//! Recreated fresh on each Worker start (including resume).
|
||||
//!
|
||||
//! The Worker layer owns both instances and passes them to
|
||||
//! [`core_builtin_tools`] when registering tools on a `Engine`.
|
||||
//!
|
||||
//! `Bash` is the lone exception — its child processes bypass `ScopedFs`
|
||||
//! entirely. Safety for arbitrary command execution is delegated to the
|
||||
//! Permission layer (deny/allow rules on the command string).
|
||||
//! Bash is intentionally not sandboxed. The Workdir supplies its initial cwd
|
||||
//! and command capability, while the Runtime process and OS user remain the
|
||||
//! trusted execution boundary.
|
||||
|
||||
pub mod error;
|
||||
pub mod scoped_fs;
|
||||
pub mod tracker;
|
||||
|
||||
mod bash;
|
||||
@@ -36,36 +25,40 @@ pub use error::ToolsError;
|
||||
pub use glob::glob_tool;
|
||||
pub use grep::grep_tool;
|
||||
pub use read::read_tool;
|
||||
pub use scoped_fs::ScopedFs;
|
||||
pub use tracker::Tracker;
|
||||
pub use web::{web_fetch_tool, web_search_tool};
|
||||
pub use write::write_tool;
|
||||
|
||||
/// Register core builtin tools that do not require Worker-local task state,
|
||||
/// wiring them to a shared `ScopedFs` (Worker-process lifetime) and `Tracker`
|
||||
/// (Worker-process lifetime).
|
||||
///
|
||||
/// All returned factories share the same tracker instance so that
|
||||
/// `Read` / `Write` / `Edit` see a consistent history across tool
|
||||
/// invocations within a single Worker run.
|
||||
///
|
||||
/// `bash_output_dir` is where the Bash tool spills long outputs. The
|
||||
/// caller is responsible for adding that path to the readable scope
|
||||
/// (see [`manifest::Scope::with_extra_read`]) so the agent can `Read`
|
||||
/// the saved files.
|
||||
/// Build the local filesystem/command tool surface implemented by a Workdir.
|
||||
/// Profile/manifest policy may narrow this set further in the Engine.
|
||||
pub fn core_builtin_tools(
|
||||
fs: ScopedFs,
|
||||
workdir: workdir::WorkdirHandle,
|
||||
tracker: Tracker,
|
||||
bash_output_dir: std::path::PathBuf,
|
||||
) -> Vec<llm_engine::tool::ToolDefinition> {
|
||||
vec![
|
||||
read_tool(fs.clone(), tracker.clone()),
|
||||
write_tool(fs.clone(), tracker.clone()),
|
||||
edit_tool(fs.clone(), tracker),
|
||||
glob_tool(fs.clone()),
|
||||
grep_tool(fs.clone()),
|
||||
bash_tool(fs, bash_output_dir),
|
||||
]
|
||||
use workdir::WorkdirCapability;
|
||||
|
||||
let capabilities = workdir.capabilities();
|
||||
let mut tools = Vec::with_capacity(6);
|
||||
if capabilities.supports(WorkdirCapability::Read) {
|
||||
tools.push(read_tool(workdir.clone(), tracker.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirCapability::Write) {
|
||||
tools.push(write_tool(workdir.clone(), tracker.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirCapability::Edit) {
|
||||
tools.push(edit_tool(workdir.clone(), tracker));
|
||||
}
|
||||
if capabilities.supports(WorkdirCapability::Glob) {
|
||||
tools.push(glob_tool(workdir.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirCapability::Grep) {
|
||||
tools.push(grep_tool(workdir.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirCapability::Command) {
|
||||
tools.push(bash_tool(workdir, bash_output_dir));
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
pub fn web_builtin_tools(
|
||||
@@ -76,3 +69,29 @@ pub fn web_builtin_tools(
|
||||
web_fetch_tool(web::WebTools::new(web_config)),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod workdir_tool_tests {
|
||||
use super::*;
|
||||
use manifest::{Scope, SharedScope};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use workdir::{LocalWorkdir, WorkdirCapabilities, WorkdirHandle};
|
||||
|
||||
#[test]
|
||||
fn read_only_workdir_exposes_only_observation_tools() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let workdir: WorkdirHandle = Arc::new(LocalWorkdir::materialized(
|
||||
dir.path().to_path_buf(),
|
||||
dir.path().to_path_buf(),
|
||||
SharedScope::new(Scope::writable(dir.path()).unwrap()),
|
||||
WorkdirCapabilities::READ_ONLY,
|
||||
));
|
||||
let names = core_builtin_tools(workdir, Tracker::new(), dir.path().join("output"))
|
||||
.into_iter()
|
||||
.map(|definition| definition().0.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(names, ["Read", "Glob", "Grep"]);
|
||||
}
|
||||
}
|
||||
|
||||
+66
-35
@@ -1,26 +1,27 @@
|
||||
//! `Read` tool — read a text file with offset/limit, return line-numbered output.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{ReadRequest, WorkdirHandle, WorkdirPath};
|
||||
|
||||
const DESCRIPTION: &str = "Read a text file from the local filesystem. \
|
||||
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||
Directories cannot be read. The file must be read before Write or Edit can \
|
||||
modify it. Paths must be absolute.";
|
||||
modify it. Paths are relative to the bound Workdir.";
|
||||
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct ReadParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
pub file_path: String,
|
||||
/// 0-based line offset from the start. Defaults to 0.
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
@@ -30,7 +31,7 @@ pub(crate) struct ReadParams {
|
||||
}
|
||||
|
||||
pub(crate) struct ReadTool {
|
||||
fs: ScopedFs,
|
||||
workdir: WorkdirHandle,
|
||||
tracker: Tracker,
|
||||
}
|
||||
|
||||
@@ -46,21 +47,29 @@ impl Tool for ReadTool {
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
offset,
|
||||
limit,
|
||||
"Read"
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, offset, limit, "Read");
|
||||
|
||||
let result = self
|
||||
.workdir
|
||||
.read(ReadRequest {
|
||||
path: path.clone(),
|
||||
offset,
|
||||
limit,
|
||||
max_bytes: PROVIDER_BYTE_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
self.tracker.record_workdir_hash(&path, result.content_hash);
|
||||
|
||||
let text = String::from_utf8_lossy(&result.bytes).into_owned();
|
||||
let rendered = render_provider_read(
|
||||
&text,
|
||||
result.start_line,
|
||||
result.total_lines,
|
||||
result.truncated,
|
||||
);
|
||||
|
||||
let bytes = self.fs.read_bytes(¶ms.file_path)?;
|
||||
// Record the raw bytes under the read-history so subsequent Edit /
|
||||
// Write can detect external modification.
|
||||
self.tracker.record(¶ms.file_path, &bytes);
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let rendered = render_numbered(&text, offset, limit);
|
||||
|
||||
let summary = if rendered.truncated {
|
||||
format!(
|
||||
"Read {} line(s) [{}..{}] of {} from {}",
|
||||
@@ -68,14 +77,10 @@ impl Tool for ReadTool {
|
||||
offset + 1,
|
||||
offset + rendered.line_count,
|
||||
rendered.total_lines,
|
||||
params.file_path.display()
|
||||
path
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Read {} line(s) from {}",
|
||||
rendered.line_count,
|
||||
params.file_path.display()
|
||||
)
|
||||
format!("Read {} line(s) from {}", rendered.line_count, path)
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
@@ -92,8 +97,29 @@ struct Rendered {
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn render_provider_read(
|
||||
text: &str,
|
||||
start_line: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
) -> Rendered {
|
||||
use std::fmt::Write as _;
|
||||
let lines = text.lines().collect::<Vec<_>>();
|
||||
let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8));
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line);
|
||||
}
|
||||
Rendered {
|
||||
body,
|
||||
line_count: lines.len(),
|
||||
total_lines,
|
||||
truncated: start_line > 0 || truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a slice of lines from `text` with `cat -n` style 1-based line
|
||||
/// numbers. Pure function — no I/O, no history touching.
|
||||
#[cfg(test)]
|
||||
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
let all_lines: Vec<&str> = text.lines().collect();
|
||||
let total_lines = all_lines.len();
|
||||
@@ -118,7 +144,7 @@ fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
}
|
||||
|
||||
/// Factory for the `Read` tool.
|
||||
pub fn read_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
pub fn read_tool(workdir: WorkdirHandle, tracker: Tracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
@@ -126,7 +152,7 @@ pub fn read_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadTool {
|
||||
fs: fs.clone(),
|
||||
workdir: workdir.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -138,14 +164,15 @@ mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
use workdir::LocalWorkdir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||
fn setup() -> (TempDir, WorkdirHandle, Tracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
let workdir: WorkdirHandle = Arc::new(LocalWorkdir::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
(dir, fs, Tracker::new())
|
||||
));
|
||||
(dir, workdir, Tracker::new())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -158,7 +185,7 @@ mod tests {
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Read");
|
||||
|
||||
let input = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
let input = serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() });
|
||||
let out = tool
|
||||
.execute(&input.to_string(), Default::default())
|
||||
.await
|
||||
@@ -169,7 +196,11 @@ mod tests {
|
||||
assert!(body.contains(" 3\tgamma"));
|
||||
|
||||
// History recorded
|
||||
assert!(tracker.has(&file));
|
||||
assert!(
|
||||
tracker
|
||||
.expected_workdir_hash(&WorkdirPath::new("a.txt").unwrap())
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -181,7 +212,7 @@ mod tests {
|
||||
let def = read_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"offset": 1,
|
||||
"limit": 2,
|
||||
});
|
||||
@@ -201,7 +232,7 @@ mod tests {
|
||||
let def = read_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": dir.path().join("nope.txt").to_str().unwrap()
|
||||
"file_path": "nope.txt"
|
||||
});
|
||||
let err = tool
|
||||
.execute(&input.to_string(), Default::default())
|
||||
|
||||
@@ -1,719 +0,0 @@
|
||||
//! Scope-aware filesystem primitive.
|
||||
//!
|
||||
//! `ScopedFs` is the write/read gate layered on top of a [`manifest::Scope`]
|
||||
//! and a Worker's working directory. The scope decides which paths are
|
||||
//! readable and writable; the cwd is carried alongside for convenience
|
||||
//! (Glob/Grep default their search base to it).
|
||||
//!
|
||||
//! `ScopedFs` is cheap to clone (`Arc` inside) and carries no per-session
|
||||
//! state — the read-before-edit policy lives separately in
|
||||
//! [`crate::Tracker`].
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use manifest::{Scope, SharedScope};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScopedFsInner {
|
||||
scope: SharedScope,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
||||
///
|
||||
/// The wrapped [`SharedScope`] is shared with every clone of this
|
||||
/// `ScopedFs` and with whoever else holds the same `SharedScope`
|
||||
/// handle (typically the owning Worker). Mutations to that `SharedScope`
|
||||
/// propagate atomically; the next permission check inside any
|
||||
/// `ScopedFs` reads the new view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScopedFs {
|
||||
inner: Arc<ScopedFsInner>,
|
||||
}
|
||||
|
||||
/// Outcome of a [`ScopedFs::write`] call.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WriteOutcome {
|
||||
pub bytes_written: usize,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// First symlink encountered while resolving a path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SymlinkInfo {
|
||||
/// The symlink path as it appears in the original path chain.
|
||||
pub link_path: PathBuf,
|
||||
/// The symlink target resolved relative to the symlink's parent when the
|
||||
/// link stores a relative target.
|
||||
pub target_path: PathBuf,
|
||||
/// Best-effort resolved form of the full requested path after replacing
|
||||
/// the symlink component with its target and rejoining any remaining tail.
|
||||
/// Existing targets are canonicalized; broken targets are left absolute.
|
||||
pub resolved_path: PathBuf,
|
||||
/// Whether the symlink target itself exists. A missing target is a broken
|
||||
/// symlink even when the symlink lives inside an allowed scope.
|
||||
pub target_exists: bool,
|
||||
}
|
||||
|
||||
impl ScopedFs {
|
||||
/// Create a new [`ScopedFs`] wrapping `scope` and `cwd` in a fresh
|
||||
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
|
||||
/// need the resulting `ScopedFs` to share scope state with another
|
||||
/// holder of the `SharedScope` (typically the Worker).
|
||||
pub fn new(scope: Scope, cwd: PathBuf) -> Self {
|
||||
Self::with_shared_scope(SharedScope::new(scope), cwd)
|
||||
}
|
||||
|
||||
/// Build a [`ScopedFs`] over an existing [`SharedScope`]. The
|
||||
/// resulting handle and any future updates the caller pushes to
|
||||
/// `scope` are observed by every clone of this `ScopedFs`.
|
||||
pub fn with_shared_scope(scope: SharedScope, cwd: PathBuf) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(ScopedFsInner { scope, cwd }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the current scope. Cheap; the returned `Arc<Scope>` is
|
||||
/// a coherent point-in-time view that subsequent mutations do not
|
||||
/// affect.
|
||||
pub fn scope(&self) -> Arc<Scope> {
|
||||
self.inner.scope.snapshot()
|
||||
}
|
||||
|
||||
/// Shared scope handle backing this `ScopedFs`. Cloning it lets a
|
||||
/// caller (usually the Worker) hold the same view and push updates
|
||||
/// that are immediately reflected in subsequent permission checks.
|
||||
pub fn shared_scope(&self) -> &SharedScope {
|
||||
&self.inner.scope
|
||||
}
|
||||
|
||||
/// The Worker's working directory. Glob/Grep default their search base
|
||||
/// to this path when callers omit an explicit `path` parameter.
|
||||
pub fn cwd(&self) -> &Path {
|
||||
&self.inner.cwd
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Read — scope-checked against readability
|
||||
// =========================================================================
|
||||
|
||||
/// Read the full contents of `path` as raw bytes.
|
||||
///
|
||||
/// Follows symlinks. Rejects directories, relative paths, paths not
|
||||
/// readable by the scope, and missing files.
|
||||
pub fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
let symlink = first_symlink(path);
|
||||
let scope = self.inner.scope.load();
|
||||
if !scope.is_readable(path) {
|
||||
return Err(symlink_out_of_scope_or_plain(
|
||||
path,
|
||||
symlink.as_ref(),
|
||||
"read",
|
||||
&scope,
|
||||
));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(broken_symlink_error(path, info));
|
||||
}
|
||||
}
|
||||
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
|
||||
_ => ToolsError::io(path, e),
|
||||
})?;
|
||||
if meta.is_dir() {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
ToolsError::SymlinkTargetIsDirectory {
|
||||
path: path.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
}
|
||||
} else {
|
||||
ToolsError::IsDirectory(path.to_path_buf())
|
||||
});
|
||||
}
|
||||
std::fs::read(path).map_err(|e| ToolsError::io(path, e))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Write — scope-checked, atomic
|
||||
// =========================================================================
|
||||
|
||||
/// Atomically write `content` to `path`, creating or overwriting it.
|
||||
///
|
||||
/// - `path` must be absolute and writable under the scope.
|
||||
/// - Paths that are readable but not writable return [`ToolsError::ReadOnly`].
|
||||
/// - Paths outside the scope entirely return [`ToolsError::OutOfScope`].
|
||||
/// - Missing parent directories are created.
|
||||
/// - The actual write uses a sibling tempfile + `persist`, so the
|
||||
/// target file transitions atomically between states.
|
||||
///
|
||||
/// This method does **not** consult any read history. Callers that
|
||||
/// want the "must read before overwrite" policy should verify with a
|
||||
/// [`Tracker`](crate::Tracker) beforehand.
|
||||
pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
let symlink = first_symlink(path);
|
||||
let scope = self.inner.scope.load();
|
||||
if !scope.is_writable(path) {
|
||||
return Err(if scope.is_readable(path) {
|
||||
ToolsError::ReadOnly(path.to_path_buf())
|
||||
} else {
|
||||
symlink_out_of_scope_or_plain(path, symlink.as_ref(), "write", &scope)
|
||||
});
|
||||
}
|
||||
drop(scope);
|
||||
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(broken_symlink_error(path, info));
|
||||
}
|
||||
}
|
||||
|
||||
// Reject existing directory targets.
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
ToolsError::SymlinkTargetIsDirectory {
|
||||
path: path.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
}
|
||||
} else {
|
||||
ToolsError::IsDirectory(path.to_path_buf())
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let existed = path.exists();
|
||||
let write_target = if existed {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
let parent = write_target.parent().ok_or_else(|| {
|
||||
ToolsError::InvalidArgument(format!(
|
||||
"path has no parent directory: {}",
|
||||
write_target.display()
|
||||
))
|
||||
})?;
|
||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| ToolsError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let tmp_parent: &Path = if parent.as_os_str().is_empty() {
|
||||
Path::new(".")
|
||||
} else {
|
||||
parent
|
||||
};
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(tmp_parent)
|
||||
.map_err(|e| ToolsError::io(tmp_parent, e))?;
|
||||
tmp.write_all(content)
|
||||
.map_err(|e| ToolsError::io(&write_target, e))?;
|
||||
tmp.as_file()
|
||||
.sync_all()
|
||||
.map_err(|e| ToolsError::io(&write_target, e))?;
|
||||
tmp.persist(&write_target)
|
||||
.map_err(|e| ToolsError::io(&write_target, e.error))?;
|
||||
|
||||
Ok(WriteOutcome {
|
||||
bytes_written: content.len(),
|
||||
created: !existed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the first symlink component in `path`, if one exists.
|
||||
///
|
||||
/// The function only inspects existing path components. It intentionally uses
|
||||
/// `symlink_metadata` so the symlink itself can be diagnosed before any later
|
||||
/// `metadata` call follows it and collapses the reason into `NotFound` or
|
||||
/// `OutOfScope`.
|
||||
pub fn first_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cur = PathBuf::new();
|
||||
let mut components = path.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
cur.push(component.as_os_str());
|
||||
let meta = std::fs::symlink_metadata(&cur).ok()?;
|
||||
if !meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw_target = std::fs::read_link(&cur).ok()?;
|
||||
let target_path = if raw_target.is_absolute() {
|
||||
raw_target
|
||||
} else {
|
||||
cur.parent()
|
||||
.unwrap_or_else(|| Path::new("/"))
|
||||
.join(raw_target)
|
||||
};
|
||||
let target_exists = target_path.exists();
|
||||
let mut resolved_path = target_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| target_path.clone());
|
||||
for remaining in components {
|
||||
resolved_path.push(remaining.as_os_str());
|
||||
}
|
||||
|
||||
return Some(SymlinkInfo {
|
||||
link_path: cur,
|
||||
target_path,
|
||||
resolved_path,
|
||||
target_exists,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn direct_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||
let meta = std::fs::symlink_metadata(path).ok()?;
|
||||
if meta.file_type().is_symlink() {
|
||||
first_symlink(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn symlink_out_of_scope_or_plain(
|
||||
path: &Path,
|
||||
symlink: Option<&SymlinkInfo>,
|
||||
required_permission: &'static str,
|
||||
scope: &Scope,
|
||||
) -> ToolsError {
|
||||
if let Some(info) = symlink {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.parent()
|
||||
.map(|parent| scope.is_readable(parent))
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
return ToolsError::SymlinkOutOfScope {
|
||||
path: path.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
required_permission,
|
||||
};
|
||||
}
|
||||
}
|
||||
ToolsError::OutOfScope(path.to_path_buf())
|
||||
}
|
||||
|
||||
fn broken_symlink_error(path: &Path, info: &SymlinkInfo) -> ToolsError {
|
||||
ToolsError::BrokenSymlink {
|
||||
path: path.to_path_buf(),
|
||||
link: info.link_path.clone(),
|
||||
target: info.target_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_fs(dir: &TempDir) -> ScopedFs {
|
||||
ScopedFs::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// read_bytes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn read_bytes_returns_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"abc").unwrap();
|
||||
assert_eq!(fs.read_bytes(&file).unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_relative() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(Path::new("rel.txt")).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::RelativePath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(dir.path()).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(&dir.path().join("nope.txt")).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_paths_outside_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_file = outside.path().join("x.txt");
|
||||
fs::write(&outside_file, b"hi").unwrap();
|
||||
|
||||
let scoped = make_fs(&dir);
|
||||
let err = scoped.read_bytes(&outside_file).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_reports_broken_symlink_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let link = dir.path().join("external-project");
|
||||
let target = dir.path().join("missing-target");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let err = fs.read_bytes(&link).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolsError::BrokenSymlink { ref path, link: ref err_link, target: ref err_target }
|
||||
if path == &link && err_link == &link && err_target == &target
|
||||
),
|
||||
"expected broken symlink diagnostic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_reports_symlink_target_outside_scope() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let target = outside.path().join("target.txt");
|
||||
fs::write(&target, b"secret").unwrap();
|
||||
let link = dir.path().join("outside-repo.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(&link).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolsError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
|
||||
if path == &link && err_target == &target.canonicalize().unwrap()
|
||||
),
|
||||
"expected symlink out-of-scope diagnostic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_allows_symlink_file_when_target_is_inside_scope() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("target.txt");
|
||||
fs::write(&target, b"visible").unwrap();
|
||||
let link = dir.path().join("link.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
assert_eq!(fs.read_bytes(&link).unwrap(), b"visible");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_reports_symlink_to_directory_as_wrong_file_type() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target_dir = dir.path().join("target-dir");
|
||||
fs::create_dir(&target_dir).unwrap();
|
||||
let link = dir.path().join("dir-link");
|
||||
symlink(&target_dir, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(&link).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolsError::SymlinkTargetIsDirectory { ref path, ref target }
|
||||
if path == &link && target == &target_dir.canonicalize().unwrap()
|
||||
),
|
||||
"expected symlink directory type diagnostic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// write
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn write_creates_new_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("new.txt");
|
||||
let out = fs.write(&file, b"hello").unwrap();
|
||||
assert!(out.created);
|
||||
assert_eq!(out.bytes_written, 5);
|
||||
assert_eq!(fs::read(&file).unwrap(), b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_overwrites_existing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"old").unwrap();
|
||||
let out = fs.write(&file, b"new").unwrap();
|
||||
assert!(!out.created);
|
||||
assert_eq!(fs::read(&file).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_existing_symlink_file_updates_in_scope_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let target = dir.path().join("target.txt");
|
||||
fs::write(&target, b"old").unwrap();
|
||||
let link = dir.path().join("link.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let out = fs.write(&link, b"new").unwrap();
|
||||
assert!(!out.created);
|
||||
assert_eq!(fs::read(&target).unwrap(), b"new");
|
||||
assert!(
|
||||
fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_reports_symlink_target_outside_scope() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let target = outside.path().join("target.txt");
|
||||
fs::write(&target, b"secret").unwrap();
|
||||
let link = dir.path().join("outside-repo.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(&link, b"new").unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolsError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" }
|
||||
if path == &link && err_target == &target.canonicalize().unwrap()
|
||||
),
|
||||
"expected write symlink out-of-scope diagnostic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_out_of_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(&outside.path().join("x"), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_readonly_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sub = dir.path().join("sub");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let scoped = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let err = scoped.write(&sub.join("locked.txt"), b"x").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ToolsError::ReadOnly(_)),
|
||||
"expected ReadOnly, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_relative_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(Path::new("rel.txt"), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::RelativePath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_creates_missing_parents_inside_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let nested = dir.path().join("a/b/c/deep.txt");
|
||||
fs.write(&nested, b"x").unwrap();
|
||||
assert_eq!(fs::read(&nested).unwrap(), b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_directory_target() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(dir.path(), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dynamic scope: SharedScope mutations propagate into ScopedFs decisions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn add_allow_rule_through_shared_scope_grows_readable_set() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let extra = TempDir::new().unwrap();
|
||||
let extra_file = extra.path().join("x.txt");
|
||||
fs::write(&extra_file, b"hi").unwrap();
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
|
||||
// Before: extra is out of scope.
|
||||
let err = fs.read_bytes(&extra_file).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||
|
||||
// Push an allow(Read) rule.
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_allow_rules([ScopeRule {
|
||||
target: extra.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// After: read goes through.
|
||||
assert_eq!(fs.read_bytes(&extra_file).unwrap(), b"hi");
|
||||
// But write still fails — allow only granted Read.
|
||||
let err = fs.write(&extra.path().join("y.txt"), b"x").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ToolsError::ReadOnly(_)),
|
||||
"expected ReadOnly, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_write_through_shared_scope_blocks_subsequent_writes() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sub = dir.path().join("sub");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
let target = sub.join("a.txt");
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
|
||||
// Write succeeds initially.
|
||||
fs.write(&target, b"first").unwrap();
|
||||
|
||||
// Revoke Write on `sub` (push a deny(Write) rule).
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_deny_rules([ScopeRule {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Subsequent write fails with ReadOnly — Read is preserved.
|
||||
let err = fs.write(&target, b"second").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ToolsError::ReadOnly(_)),
|
||||
"expected ReadOnly after revoke, got {err:?}"
|
||||
);
|
||||
// Read still works.
|
||||
assert_eq!(fs.read_bytes(&target).unwrap(), b"first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_scope_changes_propagate_across_clones() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("a.txt");
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs1 = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
let fs2 = fs1.clone();
|
||||
|
||||
// fs1 writes; both clones see the file.
|
||||
fs1.write(&target, b"hi").unwrap();
|
||||
assert_eq!(fs2.read_bytes(&target).unwrap(), b"hi");
|
||||
|
||||
// Revoke write through the original handle.
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_deny_rules([ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Both clones reject writes now — they share the same SharedScope.
|
||||
assert!(matches!(
|
||||
fs1.write(&target, b"x").unwrap_err(),
|
||||
ToolsError::ReadOnly(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
fs2.write(&target, b"x").unwrap_err(),
|
||||
ToolsError::ReadOnly(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -21,20 +21,25 @@
|
||||
//! A `Tracker` is **Worker-process scoped**: the Worker layer creates a fresh
|
||||
//! instance at the start of each Worker run (including resume) and discards
|
||||
//! it when the process exits — it is not persisted, so a resumed
|
||||
//! conversation starts with an empty read/edit history. The `ScopedFs`
|
||||
//! write boundary is likewise Worker-process scoped (derived from the
|
||||
//! conversation starts with an empty read/edit history. The local Workdir
|
||||
//! scope boundary is likewise Worker-process scoped (derived from the
|
||||
//! manifest). The two are orthogonal and the Worker wires them together
|
||||
//! when registering builtin tools.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # use std::path::PathBuf;
|
||||
//! # use std::sync::Arc;
|
||||
//! # use manifest::Scope;
|
||||
//! # use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
//! # use tools::{Tracker, core_builtin_tools};
|
||||
//! # use workdir::{LocalWorkdir, WorkdirHandle};
|
||||
//! let scope = Scope::writable("/workspace").unwrap();
|
||||
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // worker lifetime
|
||||
//! let tracker = Tracker::new(); // session lifetime
|
||||
//! let workdir: WorkdirHandle = Arc::new(LocalWorkdir::new(
|
||||
//! scope,
|
||||
//! PathBuf::from("/workspace"),
|
||||
//! ));
|
||||
//! let tracker = Tracker::new(); // session lifetime
|
||||
//! let bash_outputs = PathBuf::from("/run/yoi/bash-output");
|
||||
//! let defs = core_builtin_tools(fs, tracker, bash_outputs);
|
||||
//! let defs = core_builtin_tools(workdir, tracker, bash_outputs);
|
||||
//! ```
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -182,6 +187,35 @@ impl Tracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_workdir_content(&self, path: &workdir::WorkdirPath, bytes: &[u8]) {
|
||||
self.record_workdir_hash(path, hash_bytes(bytes));
|
||||
}
|
||||
|
||||
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
|
||||
let key = PathBuf::from(path.as_str());
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.hashes.insert(key.clone(), hash);
|
||||
inner.recency.retain(|candidate| candidate != &key);
|
||||
inner.recency.push_front(key);
|
||||
if inner.recency.len() > RECENCY_CAPACITY {
|
||||
inner.recency.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expected_workdir_hash(
|
||||
&self,
|
||||
path: &workdir::WorkdirPath,
|
||||
) -> Result<workdir::ContentHash, ToolsError> {
|
||||
let key = PathBuf::from(path.as_str());
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.hashes
|
||||
.get(&key)
|
||||
.copied()
|
||||
.ok_or_else(|| ToolsError::NotRead(key))
|
||||
}
|
||||
|
||||
/// Verify that `path` was previously recorded and its current bytes
|
||||
/// match the recorded hash.
|
||||
///
|
||||
|
||||
+46
-42
@@ -7,24 +7,25 @@ use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{StatRequest, WorkdirError, WorkdirHandle, WorkdirPath, WriteRequest};
|
||||
|
||||
const DESCRIPTION: &str = "Create a new file or overwrite an existing one with \
|
||||
the given content. Missing parent directories within scope are created \
|
||||
automatically. Existing files must have been read first (via the Read tool) \
|
||||
in this session. Paths must be absolute.";
|
||||
in this session. Paths are relative to the bound Workdir.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct WriteParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
pub file_path: String,
|
||||
/// Full content to write. Overwrites any existing content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub(crate) struct WriteTool {
|
||||
fs: ScopedFs,
|
||||
workdir: WorkdirHandle,
|
||||
tracker: Tracker,
|
||||
}
|
||||
|
||||
@@ -38,30 +39,29 @@ impl Tool for WriteTool {
|
||||
let params: WriteParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Write input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
bytes = params.content.len(),
|
||||
"Write"
|
||||
);
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
|
||||
|
||||
let _mutation_permit = self.tracker.acquire_mutation(¶ms.file_path, &ctx).await;
|
||||
|
||||
// Policy check: if the target already exists, it must have been
|
||||
// observed by the Read tool (via the tracker) and its current
|
||||
// contents must match the recorded hash.
|
||||
if params.file_path.exists() {
|
||||
let current = self.fs.read_bytes(¶ms.file_path)?;
|
||||
self.tracker.verify(¶ms.file_path, ¤t)?;
|
||||
}
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = self.tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = match self.workdir.stat(StatRequest { path: path.clone() }).await {
|
||||
Ok(_) => Some(self.tracker.expected_workdir_hash(&path)?),
|
||||
Err(WorkdirError::NotFound(_)) => None,
|
||||
Err(error) => return Err(ToolsError::from(error).into()),
|
||||
};
|
||||
|
||||
let outcome = self
|
||||
.fs
|
||||
.write(¶ms.file_path, params.content.as_bytes())?;
|
||||
.workdir
|
||||
.write(WriteRequest {
|
||||
path: path.clone(),
|
||||
content: params.content.as_bytes().to_vec(),
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
|
||||
// Refresh the history entry to reflect the newly-written content,
|
||||
// so a subsequent Edit / Write can proceed without a re-read.
|
||||
self.tracker
|
||||
.record(¶ms.file_path, params.content.as_bytes());
|
||||
.record_workdir_content(&path, params.content.as_bytes());
|
||||
|
||||
let summary = format!(
|
||||
"{} {} ({} bytes)",
|
||||
@@ -70,7 +70,7 @@ impl Tool for WriteTool {
|
||||
} else {
|
||||
"Overwrote"
|
||||
},
|
||||
params.file_path.display(),
|
||||
path,
|
||||
outcome.bytes_written
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
@@ -81,7 +81,7 @@ impl Tool for WriteTool {
|
||||
}
|
||||
|
||||
/// Factory for the `Write` tool.
|
||||
pub fn write_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
pub fn write_tool(workdir: WorkdirHandle, tracker: Tracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(WriteParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
@@ -89,7 +89,7 @@ pub fn write_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(WriteTool {
|
||||
fs: fs.clone(),
|
||||
workdir: workdir.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -102,14 +102,15 @@ mod tests {
|
||||
use crate::read::read_tool;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
use workdir::LocalWorkdir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||
fn setup() -> (TempDir, WorkdirHandle, Tracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
let workdir: WorkdirHandle = Arc::new(LocalWorkdir::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
(dir, fs, Tracker::new())
|
||||
));
|
||||
(dir, workdir, Tracker::new())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -121,7 +122,7 @@ mod tests {
|
||||
|
||||
let file = dir.path().join("new.txt");
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "hello\n",
|
||||
});
|
||||
let out = tool
|
||||
@@ -141,7 +142,7 @@ mod tests {
|
||||
let def = write_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "new",
|
||||
});
|
||||
let err = tool
|
||||
@@ -159,7 +160,8 @@ mod tests {
|
||||
|
||||
let read_def = read_tool(fs.clone(), tracker.clone());
|
||||
let (_, reader) = read_def();
|
||||
let read_in = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
let read_in =
|
||||
serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() });
|
||||
reader
|
||||
.execute(&read_in.to_string(), Default::default())
|
||||
.await
|
||||
@@ -168,7 +170,7 @@ mod tests {
|
||||
let write_def = write_tool(fs, tracker);
|
||||
let (_, writer) = write_def();
|
||||
let write_in = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "new\n",
|
||||
});
|
||||
let out = writer
|
||||
@@ -190,7 +192,8 @@ mod tests {
|
||||
let (_, reader) = read_def();
|
||||
reader
|
||||
.execute(
|
||||
&serde_json::json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() })
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -204,7 +207,7 @@ mod tests {
|
||||
let err = writer
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "new",
|
||||
})
|
||||
.to_string(),
|
||||
@@ -248,11 +251,11 @@ mod tests {
|
||||
let (_, editor) = edit_def();
|
||||
|
||||
let write_in = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "hello",
|
||||
});
|
||||
let edit_in = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye",
|
||||
});
|
||||
@@ -282,7 +285,8 @@ mod tests {
|
||||
let (_, reader) = read_def();
|
||||
reader
|
||||
.execute(
|
||||
&serde_json::json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() })
|
||||
.to_string(),
|
||||
ToolExecutionContext::new("read", "pre", 0),
|
||||
)
|
||||
.await
|
||||
@@ -291,12 +295,12 @@ mod tests {
|
||||
let edit_def = edit_tool(fs, tracker);
|
||||
let (_, editor) = edit_def();
|
||||
let bad_edit = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "missing",
|
||||
"new_string": "beta",
|
||||
});
|
||||
let good_edit = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "alpha",
|
||||
"new_string": "beta",
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ use llm_engine::tool::{Tool, ToolDefinition};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
use tools::{Tracker, core_builtin_tools};
|
||||
use workdir::{LocalWorkdir, WorkdirHandle};
|
||||
|
||||
struct Registry {
|
||||
entries: Vec<(llm_engine::tool::ToolMeta, Arc<dyn Tool>)>,
|
||||
@@ -41,7 +42,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
||||
recursive: true,
|
||||
});
|
||||
let scope = Scope::from_config(&config).unwrap();
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let fs: WorkdirHandle = std::sync::Arc::new(LocalWorkdir::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
|
||||
(dir, spill, reg)
|
||||
@@ -57,7 +58,7 @@ async fn unicode_path_and_content() {
|
||||
write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": content,
|
||||
})
|
||||
.to_string(),
|
||||
@@ -69,7 +70,7 @@ async fn unicode_path_and_content() {
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -98,7 +99,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
let read = reg.get("Read");
|
||||
let read_err = read
|
||||
.execute(
|
||||
&json!({ "file_path": link.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": link.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -108,8 +109,8 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
"symlink read escape not rejected: {read_err}"
|
||||
);
|
||||
assert!(
|
||||
format!("{read_err}").contains(&outside_target.display().to_string()),
|
||||
"symlink read diagnostic should include resolved target: {read_err}"
|
||||
!format!("{read_err}").contains(&outside_target.display().to_string()),
|
||||
"symlink diagnostics must not expose provider-internal paths: {read_err}"
|
||||
);
|
||||
|
||||
// Write through the symlink must be rejected for the same reason.
|
||||
@@ -117,7 +118,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
let err = write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": link.to_str().unwrap(),
|
||||
"file_path": link.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "overwritten",
|
||||
})
|
||||
.to_string(),
|
||||
@@ -127,13 +128,17 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope"),
|
||||
msg.contains("outside allowed read scope")
|
||||
|| msg.contains("outside allowed write scope")
|
||||
|| msg.contains("has not been read"),
|
||||
"symlink escape not rejected: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("add the symlink target"),
|
||||
"symlink escape diagnostic should include remediation: {msg}"
|
||||
);
|
||||
if !msg.contains("has not been read") {
|
||||
assert!(
|
||||
msg.contains("add the symlink target"),
|
||||
"symlink escape diagnostic should include remediation: {msg}"
|
||||
);
|
||||
}
|
||||
// Outside file must not have been touched.
|
||||
assert_eq!(std::fs::read_to_string(&outside_target).unwrap(), "secret");
|
||||
}
|
||||
@@ -151,15 +156,15 @@ async fn broken_symlink_reports_target_and_repair_hint() {
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(
|
||||
&json!({ "file_path": link.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": link.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("broken symlink"), "{msg}");
|
||||
assert!(msg.contains(&link.display().to_string()), "{msg}");
|
||||
assert!(msg.contains(&target.display().to_string()), "{msg}");
|
||||
assert!(msg.contains("external-project"), "{msg}");
|
||||
assert!(!msg.contains(&target.display().to_string()), "{msg}");
|
||||
assert!(msg.contains("correct relative target"), "{msg}");
|
||||
}
|
||||
|
||||
@@ -172,7 +177,7 @@ async fn empty_file_read_and_edit() {
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -184,7 +189,7 @@ async fn empty_file_read_and_edit() {
|
||||
let err = edit
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
})
|
||||
@@ -207,7 +212,7 @@ async fn very_long_single_line() {
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -217,17 +222,17 @@ async fn very_long_single_line() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_path_is_rejected() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
async fn absolute_path_is_rejected() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(
|
||||
&json!({ "file_path": "relative.txt" }).to_string(),
|
||||
&json!({ "file_path": dir.path().join("outside.txt") }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("absolute"));
|
||||
assert!(format!("{err}").contains("invalid Workdir path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -235,10 +240,7 @@ async fn directory_target_is_rejected_for_read() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(
|
||||
&json!({ "file_path": dir.path().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("directory"));
|
||||
@@ -252,7 +254,7 @@ async fn deeply_nested_new_file_is_created() {
|
||||
write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": deep.to_str().unwrap(),
|
||||
"file_path": "a/b/c/d/e/deep.txt",
|
||||
"content": "deep\n",
|
||||
})
|
||||
.to_string(),
|
||||
@@ -271,7 +273,7 @@ async fn replace_preserves_unicode() {
|
||||
|
||||
let read = reg.get("Read");
|
||||
read.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -280,7 +282,7 @@ async fn replace_preserves_unicode() {
|
||||
let edit = reg.get("Edit");
|
||||
edit.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "rust",
|
||||
"new_string": "ラスト",
|
||||
})
|
||||
|
||||
@@ -11,7 +11,8 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolMeta};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
use tools::{Tracker, core_builtin_tools};
|
||||
use workdir::{LocalWorkdir, WorkdirHandle};
|
||||
|
||||
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
||||
let base = Scope::writable(workspace).unwrap();
|
||||
@@ -54,7 +55,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let fs: WorkdirHandle = Arc::new(LocalWorkdir::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
|
||||
(dir, spill, reg)
|
||||
@@ -103,7 +104,7 @@ async fn read_then_edit_then_read_roundtrip() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "hello world\n").unwrap();
|
||||
let p = file.to_str().unwrap();
|
||||
let p = "a.txt";
|
||||
|
||||
let read = reg.get("Read");
|
||||
let edit = reg.get("Edit");
|
||||
@@ -140,7 +141,7 @@ async fn write_then_grep_finds_content() {
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "alpha\nNEEDLE\nomega\n",
|
||||
}),
|
||||
)
|
||||
@@ -169,7 +170,7 @@ async fn glob_finds_written_files() {
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": dir.path().join(name).to_str().unwrap(),
|
||||
"file_path": name,
|
||||
"content": "x",
|
||||
}),
|
||||
)
|
||||
@@ -184,7 +185,7 @@ async fn glob_finds_written_files() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn out_of_scope_write_is_rejected() {
|
||||
async fn absolute_path_is_rejected() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let write = reg.get("Write");
|
||||
@@ -197,9 +198,9 @@ async fn out_of_scope_write_is_rejected() {
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// ToolsError::OutOfScope → ToolError::InvalidArgument
|
||||
// Absolute paths are rejected at the logical Workdir boundary.
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("outside allowed scope"), "unexpected: {msg}");
|
||||
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -212,7 +213,7 @@ async fn write_to_existing_without_read_fails() {
|
||||
let err = call_err(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "new",
|
||||
}),
|
||||
)
|
||||
@@ -222,8 +223,8 @@ async fn write_to_existing_without_read_fails() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_scoped_fs_across_tools() {
|
||||
// The key invariant: all builtin tools share the same ScopedFs instance,
|
||||
async fn shared_workdir_across_tools() {
|
||||
// The key invariant: all builtin tools share the same Workdir instance,
|
||||
// so read-history set by Read is visible to Edit and Write.
|
||||
let (dir, _spill, reg) = setup();
|
||||
let file = dir.path().join("shared.txt");
|
||||
@@ -233,12 +234,16 @@ async fn shared_scoped_fs_across_tools() {
|
||||
let write = reg.get("Write");
|
||||
|
||||
// Read via Read tool
|
||||
call(&read, json!({ "file_path": file.to_str().unwrap() })).await;
|
||||
// Write via Write tool — must succeed because the shared ScopedFs has the read
|
||||
call(
|
||||
&read,
|
||||
json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }),
|
||||
)
|
||||
.await;
|
||||
// Write via Write tool — must succeed because the shared Workdir has the read
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "two\n",
|
||||
}),
|
||||
)
|
||||
@@ -257,7 +262,7 @@ async fn edit_requires_read_across_tools() {
|
||||
let err = call_err(
|
||||
&edit,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
}),
|
||||
@@ -296,7 +301,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let fs: WorkdirHandle = Arc::new(LocalWorkdir::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(
|
||||
fs,
|
||||
@@ -309,22 +314,18 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
std::fs::write(&a, "one\n").unwrap();
|
||||
|
||||
// Read `a` — should appear in recency.
|
||||
call(
|
||||
®.get("Read"),
|
||||
json!({ "file_path": a.to_str().unwrap() }),
|
||||
)
|
||||
.await;
|
||||
call(®.get("Read"), json!({ "file_path": "a.txt" })).await;
|
||||
// Write `b` (new file) — should appear ahead of `a`.
|
||||
call(
|
||||
®.get("Write"),
|
||||
json!({ "file_path": b.to_str().unwrap(), "content": "hello\n" }),
|
||||
json!({ "file_path": "b.txt", "content": "hello\n" }),
|
||||
)
|
||||
.await;
|
||||
// Edit `a` — should bump it back to the front.
|
||||
call(
|
||||
®.get("Edit"),
|
||||
json!({
|
||||
"file_path": a.to_str().unwrap(),
|
||||
"file_path": "a.txt",
|
||||
"old_string": "one",
|
||||
"new_string": "two",
|
||||
}),
|
||||
@@ -344,8 +345,8 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_inherits_scoped_fs_pwd() {
|
||||
// The Bash tool starts at the ScopedFs's pwd. Without any `cd`, its
|
||||
async fn bash_inherits_workdir_cwd() {
|
||||
// The Bash tool starts at the Workdir's pwd. Without any `cd`, its
|
||||
// `pwd` should canonicalize to the workspace root we set up.
|
||||
let (dir, _spill, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
@@ -357,40 +358,14 @@ async fn bash_inherits_scoped_fs_pwd() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_spilled_file_is_readable_via_read_tool() {
|
||||
// Long Bash output spills to a path that the controller has added to
|
||||
// the readable scope. The agent should be able to Read that path
|
||||
// exactly like any in-scope file.
|
||||
async fn bash_provider_output_does_not_expose_internal_paths() {
|
||||
let (_dir, spill, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
let out = call(
|
||||
&bash,
|
||||
json!({ "command": "for i in $(seq 1 200); do echo line $i; done" }),
|
||||
)
|
||||
.await;
|
||||
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
|
||||
let body = out.content.unwrap();
|
||||
let spill_str = spill.path().to_str().unwrap();
|
||||
|
||||
// Extract the spilled path from the marker line.
|
||||
let marker = body.lines().next().unwrap();
|
||||
let prefix_pos = marker
|
||||
.find(spill_str)
|
||||
.expect("marker should reference the spill dir");
|
||||
let path_end_rel = marker[prefix_pos..]
|
||||
.find(".log")
|
||||
.expect("marker should end the path with .log");
|
||||
let spilled = &marker[prefix_pos..prefix_pos + path_end_rel + 4];
|
||||
|
||||
// Read the file via the Read tool — must succeed (in scope).
|
||||
let read_out = call(®.get("Read"), json!({ "file_path": spilled })).await;
|
||||
let read_body = read_out.content.expect("Read returned content");
|
||||
// The full 200 lines should be in the saved file even though Bash
|
||||
// returned only the tail of 80.
|
||||
assert!(
|
||||
read_body.contains("line 1\n"),
|
||||
"missing line 1: {read_body}"
|
||||
);
|
||||
assert!(read_body.contains("line 200"), "missing line 200");
|
||||
assert!(body.contains("bounded Workdir command output"));
|
||||
assert!(!body.contains(spill.path().to_str().unwrap()));
|
||||
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
// Sanity: unused Path import guard
|
||||
|
||||
Reference in New Issue
Block a user