diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index de6a41c..f481a3d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -57,9 +57,9 @@ the same safe rebuild manually. The coding agent can use native tools to list a directory, read files, search file contents, write or edit files, inspect Git state, and maintain Dev Brain. It can also run shell commands and use web search or visit pages when the task -needs them. Content search is not filename discovery: until DS4Server ships a -native filename glob tool, recursive filename lookup may require an approved -shell command. A directory listing remains a non-recursive view of one folder. +needs them. The native glob tool finds files recursively by basename or relative +path pattern without a shell; content matching remains the search tool's job, +and a directory listing remains a non-recursive view of one folder. Tool calls are checked against typed schemas before execution. Unknown tools, missing or extra fields, malformed calls, and invalid values return structured diff --git a/src/agent.rs b/src/agent.rs index a2dc915..7b5dd72 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -338,6 +338,7 @@ enum ToolHandler { Write, Edit, Search, + Glob, List, DevBrainInfo, DevBrainSearch, @@ -640,6 +641,30 @@ const TOOLS: &[ToolSpec] = &[ handler: ToolHandler::Search, dev_brain: false, }, + ToolSpec { + name: "glob", + description: "Find regular files recursively by filename or relative path without a shell. Use this for filename discovery before Bash; use search for file contents and list for one directory. A pattern without / matches basenames; a pattern with / matches paths relative to path. * and ? use the search glob matcher, where * spans path separators and repeated * has no separate semantics.", + parameters: &[ + ToolParameter { + name: "pattern", + kind: NON_EMPTY, + required: true, + }, + ToolParameter { + name: "path", + kind: STRING, + required: false, + }, + ToolParameter { + name: "max_results", + kind: ParameterKind::Integer { min: 1, max: 500 }, + required: false, + }, + ], + rule: ToolRule::None, + handler: ToolHandler::Glob, + dev_brain: false, + }, ToolSpec { name: "list", description: "List one directory compactly. path is optional and defaults to the project root.", @@ -1920,7 +1945,8 @@ impl Tools { ToolHandler::More => self.more(call), ToolHandler::Write => self.write(call), ToolHandler::Edit => self.edit(call), - ToolHandler::Search => self.search(call), + ToolHandler::Search => self.search(call, cancel), + ToolHandler::Glob => self.glob(call, cancel), ToolHandler::List => self.list(call), ToolHandler::Bash => self.bash(call, cancel), ToolHandler::BashStatus => self.bash_observe(call, false, cancel), @@ -2391,6 +2417,30 @@ impl Tools { self.inside_readable_root(path, value) } + fn path_contains_readable_symlink(&self, path: &Path) -> Result { + let mut current = PathBuf::new(); + let mut inside = false; + for component in path.components() { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t)?; + let canonical = current.canonicalize()?; + let enters_readable_root = canonical.starts_with(&self.root) + || self + .agent_skill_roots + .iter() + .any(|root| canonical.starts_with(root)) + || self + .dev_brain + .as_ref() + .is_some_and(|brain| canonical.starts_with(brain.folder())); + if metadata.file_type().is_symlink() && (inside || enters_readable_root) { + return Ok(true); + } + inside |= enters_readable_root; + } + Ok(false) + } + fn writable_path(&self, value: &str) -> Result { if Path::new(value) .components() @@ -2702,7 +2752,38 @@ impl Tools { Ok(output) } - fn search(&self, call: &ToolCall) -> Result { + fn glob(&self, call: &ToolCall, cancel: &AtomicBool) -> Result { + let pattern = required_string(call, "pattern")?; + let display = string(call, "path").unwrap_or("."); + let requested = if Path::new(display).is_absolute() { + PathBuf::from(display) + } else { + self.root.join(display) + }; + if self + .path_contains_readable_symlink(&requested) + .map_err(|error| format!("open {display}: {error}"))? + { + return Err(format!("search root is a symlink: {display}")); + } + let path = self.existing_path(display)?; + if !path.is_dir() { + return Err(format!("not a directory: {display}")); + } + let skip_hidden = self + .dev_brain + .as_ref() + .is_some_and(|brain| path.starts_with(brain.folder())); + glob_path( + &path, + pattern, + integer(call, "max_results", 100, 1, 500), + skip_hidden, + cancel, + ) + } + + fn search(&self, call: &ToolCall, cancel: &AtomicBool) -> Result { let query = required_string(call, "query")?; let display = string(call, "path").unwrap_or("."); let path = self.existing_path(display)?; @@ -2721,7 +2802,7 @@ impl Tools { .as_ref() .filter(|brain| path.starts_with(brain.folder())) .map_or(self.root.as_path(), |brain| brain.folder()); - search_path(root, &path, &options, root != self.root.as_path()) + search_path(root, &path, &options, root != self.root.as_path(), cancel) } fn bash(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result { @@ -2989,9 +3070,17 @@ fn search_path( path: &Path, options: &SearchOptions<'_>, skip_hidden: bool, + cancel: &AtomicBool, ) -> Result { let mut files = Vec::new(); - collect_search_files(path, 0, skip_hidden, &mut files)?; + collect_files( + path, + 0, + skip_hidden, + Some(MAX_FILE_BYTES), + cancel, + &mut files, + )?; let mut matches = 0; let mut body = String::new(); for file in files { @@ -3029,12 +3118,62 @@ fn search_path( } } -fn collect_search_files( +fn glob_path( + root: &Path, + pattern: &str, + limit: usize, + skip_hidden: bool, + cancel: &AtomicBool, +) -> Result { + let mut files = Vec::new(); + collect_files(root, 0, skip_hidden, None, cancel, &mut files)?; + let basename_only = !pattern.contains('/'); + let mut matches = files + .into_iter() + .filter_map(|file| { + let relative = file.strip_prefix(root).ok()?; + let relative = relative + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + let value = if basename_only { + file.file_name()?.to_string_lossy().into_owned() + } else { + relative.clone() + }; + wildcard_match(pattern, &value).then_some(relative) + }) + .collect::>(); + matches.sort(); + if matches.is_empty() { + return Ok("No matches\n".into()); + } + let total = matches.len(); + let mut output = matches + .into_iter() + .take(limit) + .collect::>() + .join("\n"); + output.push('\n'); + if total > limit { + output.push_str(&format!( + "... {} more matches omitted; narrow the pattern or raise max_results ...\n", + total - limit + )); + } + Ok(output) +} + +fn collect_files( path: &Path, depth: usize, skip_hidden: bool, + max_file_bytes: Option, + cancel: &AtomicBool, output: &mut Vec, ) -> Result<(), String> { + if cancel.load(Ordering::Relaxed) { + return Err("interrupted".into()); + } if depth > 24 { return Ok(()); } @@ -3043,7 +3182,7 @@ fn collect_search_files( return Ok(()); } if metadata.is_file() { - if metadata.len() <= MAX_FILE_BYTES { + if max_file_bytes.is_none_or(|limit| metadata.len() <= limit) { output.push(path.to_owned()); } return Ok(()); @@ -3057,12 +3196,22 @@ fn collect_search_files( .collect::>(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { + if cancel.load(Ordering::Relaxed) { + return Err("interrupted".into()); + } if entry.file_name() == ".git" || skip_hidden && entry.file_name().to_string_lossy().starts_with('.') { continue; } - collect_search_files(&entry.path(), depth + 1, skip_hidden, output)?; + collect_files( + &entry.path(), + depth + 1, + skip_hidden, + max_file_bytes, + cancel, + output, + )?; } Ok(()) } @@ -3990,6 +4139,14 @@ mod tests { ) .contains("skill reference") ); + let skill_glob = tools.execute( + &call( + "glob", + [("pattern", "*.md"), ("path", allium.to_str().unwrap())], + ), + &cancel, + ); + assert_eq!(skill_glob, "SKILL.md\nreference.md\n"); assert!( tools .execute( @@ -4049,6 +4206,7 @@ mod tests { "write", "edit", "search", + "glob", "list", ] { assert!(prompt.contains(&format!("\"name\":\"{name}\""))); @@ -4316,6 +4474,37 @@ mod tests { present_svg["function"]["parameters"]["required"], serde_json::json!(["svg", "alt"]) ); + let glob = schemas + .iter() + .find(|schema| schema["function"]["name"] == "glob") + .unwrap(); + assert_eq!( + glob["function"]["parameters"]["required"], + serde_json::json!(["pattern"]) + ); + assert_eq!( + glob["function"]["parameters"]["properties"]["max_results"]["minimum"], + 1 + ); + assert_eq!( + glob["function"]["parameters"]["properties"]["max_results"]["maximum"], + 500 + ); + assert!( + glob["function"]["description"] + .as_str() + .unwrap() + .contains("before Bash") + ); + + for invalid in [ + call("glob", []), + call("glob", [("pattern", "")]), + call("glob", [("pattern", "*.rs"), ("max_results", "0")]), + call("glob", [("pattern", "*.rs"), ("max_results", "501")]), + ] { + assert!(validate_tool_call(&invalid).is_err()); + } } #[test] @@ -4905,11 +5094,21 @@ mod tests { .is_err() ); fs::write(vault.join("concepts/private.md"), "# Private\n").unwrap(); + fs::write(vault.join(".obsidian/private.md"), "hidden\n").unwrap(); assert!( tools .writable_path(vault.join("concepts/private.md").to_str().unwrap()) .is_err() ); + let glob = tools.execute( + &call( + "glob", + [("pattern", "*.md"), ("path", vault.to_str().unwrap())], + ), + &AtomicBool::new(false), + ); + assert!(glob.contains("subsystems/inference/modes.md")); + assert!(!glob.contains(".obsidian")); fs::remove_dir_all(directory).unwrap(); } @@ -5318,6 +5517,142 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[test] + fn glob_finds_all_regular_file_names_deterministically_without_reading_them() { + let directory = std::env::temp_dir().join(format!( + "ds4-agent-glob-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(directory.join("src/a")).unwrap(); + fs::create_dir_all(directory.join("src/z")).unwrap(); + fs::create_dir_all(directory.join("skills/demo")).unwrap(); + fs::create_dir_all(directory.join(".git")).unwrap(); + fs::write(directory.join("Cargo.toml"), "").unwrap(); + fs::write(directory.join(".hidden.rs"), "").unwrap(); + fs::write(directory.join("src/a/agent1.rs"), "").unwrap(); + fs::write(directory.join("src/z/agent test.rs"), "").unwrap(); + fs::write(directory.join("src/z/café.rs"), "").unwrap(); + fs::write(directory.join("skills/demo/SKILL.md"), "").unwrap(); + fs::write(directory.join(".git/secret.rs"), "").unwrap(); + fs::write(directory.join("binary.dat"), [0, 159, 146, 150]).unwrap(); + File::create(directory.join("large.bin")) + .unwrap() + .set_len(MAX_FILE_BYTES + 1) + .unwrap(); + let mut tools = Tools::new(&directory, 4096).unwrap(); + let cancel = AtomicBool::new(false); + + assert_eq!( + tools.execute(&call("glob", [("pattern", "Cargo.toml")]), &cancel), + "Cargo.toml\n" + ); + assert_eq!( + tools.execute(&call("glob", [("pattern", "agent?.rs")]), &cancel), + "src/a/agent1.rs\n" + ); + assert_eq!( + tools.execute(&call("glob", [("pattern", "src/*test.rs")]), &cancel,), + "src/z/agent test.rs\n" + ); + assert_eq!( + tools.execute(&call("glob", [("pattern", "café.rs")]), &cancel), + "src/z/café.rs\n" + ); + assert_eq!( + tools.execute(&call("glob", [("pattern", "agent test.rs")]), &cancel), + "src/z/agent test.rs\n" + ); + assert_eq!( + tools.execute(&call("glob", [("pattern", "**/SKILL.md")]), &cancel), + "skills/demo/SKILL.md\n" + ); + assert_eq!( + tools.execute(&call("glob", [("pattern", "*.bin")]), &cancel), + "large.bin\n" + ); + assert_eq!( + tools.execute(&call("glob", [("pattern", "*.dat")]), &cancel), + "binary.dat\n" + ); + assert_eq!( + tools.execute(&call("glob", [("pattern", "missing*")]), &cancel), + "No matches\n" + ); + let truncated = tools.execute( + &call("glob", [("pattern", "*.rs"), ("max_results", "2")]), + &cancel, + ); + assert_eq!( + truncated, + ".hidden.rs\nsrc/a/agent1.rs\n... 2 more matches omitted; narrow the pattern or raise max_results ...\n" + ); + assert!(!truncated.contains("secret.rs")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn glob_confinement_skips_symlinks_and_honors_cancellation() { + let directory = std::env::temp_dir().join(format!( + "ds4-agent-glob-boundary-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let project = directory.join("project"); + let outside = directory.join("outside"); + fs::create_dir_all(project.join("inside/deeper")).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(project.join("inside/visible.rs"), "").unwrap(); + fs::write(project.join("not-a-directory.rs"), "").unwrap(); + fs::write(outside.join("secret.rs"), "").unwrap(); + std::os::unix::fs::symlink(&outside, project.join("escape")).unwrap(); + std::os::unix::fs::symlink(project.join("inside/visible.rs"), project.join("linked.rs")) + .unwrap(); + std::os::unix::fs::symlink(project.join("inside"), project.join("inside-link")).unwrap(); + let mut tools = Tools::new(&project, 4096).unwrap(); + let active = AtomicBool::new(false); + + assert_eq!( + tools.execute(&call("glob", [("pattern", "*.rs")]), &active), + "inside/visible.rs\nnot-a-directory.rs\n" + ); + for invalid in [ + call("glob", [("pattern", "*.rs"), ("path", "..")]), + call( + "glob", + [("pattern", "*.rs"), ("path", outside.to_str().unwrap())], + ), + call("glob", [("pattern", "*.rs"), ("path", "escape")]), + call("glob", [("pattern", "*.rs"), ("path", "inside-link")]), + call( + "glob", + [("pattern", "*.rs"), ("path", "inside-link/deeper")], + ), + call( + "glob", + [("pattern", "*.rs"), ("path", "not-a-directory.rs")], + ), + ] { + assert!( + tools + .execute(&invalid, &active) + .contains("code=execution_failed") + ); + } + + let cancelled = AtomicBool::new(true); + assert!( + tools + .execute(&call("glob", [("pattern", "*.rs")]), &cancelled) + .contains("code=interrupted") + ); + fs::remove_dir_all(directory).unwrap(); + } + fn call(name: &str, arguments: [(&str, &str); N]) -> ToolCall { ToolCall { name: name.to_owned(),