Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/agent/tools/find_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ impl Tool for FindFilesTool {
"path": {
"type": "string",
"description": "Directory to search in (defaults to current working directory)"
},
"include_hidden": {
"type": "boolean",
"description": "Include dotfiles (.env, .gitignore, etc.) in results. Default false to avoid surfacing secrets and config files."
}
},
"required": ["pattern"]
Expand All @@ -69,9 +73,10 @@ impl Tool for FindFilesTool {
check_perm(&self.permission, &self.ask_tx, "find_files", &args.pattern).await?;

let cache_key = format!(
"find_files:{}:{}",
"find_files:{}:{}:hidden={}",
args.pattern,
args.path.as_deref().unwrap_or("."),
args.include_hidden,
);

if let Some(ref cache) = self.cache {
Expand All @@ -85,12 +90,18 @@ impl Tool for FindFilesTool {

let search_path = args.path.as_deref().unwrap_or(".");

// `WalkBuilder::hidden(true)` means SKIP hidden entries.
// Default behavior (`include_hidden = false`) hides
// dotfiles so .env / .git / .DS_Store don't leak into the
// LLM's view of the filesystem unintentionally. The LLM
// can pass `include_hidden: true` when it explicitly needs
// them. Matches pi + opencode defaults.
let walker = WalkBuilder::new(search_path)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(false)
.hidden(false)
.hidden(!args.include_hidden)
.filter_entry(|entry| {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
!is_skip_dir(entry.file_name().to_str().unwrap_or(""))
Expand Down
71 changes: 69 additions & 2 deletions src/agent/tools/glob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ impl GlobTool {
pub struct GlobArgs {
pub pattern: String,
pub path: Option<String>,
/// Include dotfiles in the walk. Default `false`. See the
/// equivalent doc on `FindFilesArgs::include_hidden`.
#[serde(default)]
pub include_hidden: bool,
}

fn glob_to_regex(pattern: &str) -> Result<regex::Regex, String> {
Expand Down Expand Up @@ -105,6 +109,10 @@ impl Tool for GlobTool {
"path": {
"type": "string",
"description": "Root directory to search in (default: current working directory)"
},
"include_hidden": {
"type": "boolean",
"description": "Include dotfiles (.env, .gitignore, etc.). Default false to avoid surfacing secrets and config files."
}
},
"required": ["pattern"]
Expand All @@ -122,9 +130,10 @@ impl Tool for GlobTool {
.await?;

let cache_key = format!(
"glob:{}:{}",
"glob:{}:{}:hidden={}",
args.pattern,
args.path.as_deref().unwrap_or("."),
args.include_hidden,
);
if let Some(ref cache) = self.cache
&& let Some(cached) = cache.get(&cache_key)
Expand All @@ -144,7 +153,8 @@ impl Tool for GlobTool {
let mut matches: Vec<(String, std::path::PathBuf)> = Vec::new();

let walker = WalkBuilder::new(root)
.hidden(false)
// Hide dotfiles by default. See `FindFilesArgs::include_hidden`.
.hidden(!args.include_hidden)
.git_global(false)
.git_ignore(true)
.git_exclude(true)
Expand Down Expand Up @@ -297,6 +307,7 @@ mod tests {
.call(GlobArgs {
pattern: "**/*.rs".into(),
path: Some(tree.root_str()),
include_hidden: false,
})
.await
.unwrap();
Expand All @@ -323,6 +334,7 @@ mod tests {
.call(GlobArgs {
pattern: "**/*.nonexistent".into(),
path: Some(tree.root_str()),
include_hidden: false,
})
.await
.unwrap();
Expand All @@ -348,6 +360,7 @@ mod tests {
.call(GlobArgs {
pattern: "*.rs".into(),
path: Some(tree.root_str()),
include_hidden: false,
})
.await
.unwrap();
Expand All @@ -372,6 +385,7 @@ mod tests {
.call(GlobArgs {
pattern: "*.rs".into(),
path: Some(tree.root_str()),
include_hidden: false,
})
.await
.unwrap();
Expand Down Expand Up @@ -400,4 +414,57 @@ mod tests {
assert!(re.is_match("main.rs"));
assert!(!re.is_match("src/main.rs"));
}

/// F2: dotfiles must be skipped by default. `.env`, `.gitignore`,
/// `.DS_Store` etc. previously appeared in glob results,
/// risking secret leakage into the LLM context.
#[tokio::test]
async fn glob_skips_dotfiles_by_default() {
let tree = TempTree::new("hidden-default");
tree.write("main.rs", "");
tree.write(".env", "SECRET=hunter2");
tree.write(".gitignore", "target/");

let tool = GlobTool::new(None, None);
let out = tool
.call(GlobArgs {
pattern: "*".into(),
path: Some(tree.root_str()),
include_hidden: false,
})
.await
.unwrap();

let lines: Vec<&str> = out.lines().collect();
assert!(lines.contains(&"main.rs"), "main.rs missing: {out}");
assert!(
!lines.iter().any(|l| l.starts_with('.')),
"dotfile leaked into default glob: {out}",
);
}

/// F2: setting `include_hidden: true` opts back in to seeing
/// dotfiles. The LLM uses this when it explicitly needs to
/// inspect `.gitignore`, `.env.example`, etc.
#[tokio::test]
async fn glob_includes_dotfiles_when_asked() {
let tree = TempTree::new("hidden-opt-in");
tree.write("main.rs", "");
tree.write(".gitignore", "target/");

let tool = GlobTool::new(None, None);
let out = tool
.call(GlobArgs {
pattern: "*".into(),
path: Some(tree.root_str()),
include_hidden: true,
})
.await
.unwrap();
assert!(out.contains("main.rs"), "main.rs missing: {out}");
assert!(
out.contains(".gitignore"),
"dotfile missing when opt-in: {out}"
);
}
}
10 changes: 8 additions & 2 deletions src/agent/tools/list_dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ impl Tool for ListDirTool {
"path": {
"type": "string",
"description": "Directory path (defaults to current working directory)"
},
"include_hidden": {
"type": "boolean",
"description": "Include dotfiles (.env, .gitignore, etc.) in the listing. Default false to avoid surfacing secrets and config files."
}
},
"required": []
Expand All @@ -87,7 +91,7 @@ impl Tool for ListDirTool {
let path = args.path.as_deref().unwrap_or(".");
check_perm_path(&self.permission, &self.ask_tx, "list_dir", path).await?;

let cache_key = format!("list_dir:{}", path);
let cache_key = format!("list_dir:{}:hidden={}", path, args.include_hidden);

if let Some(ref cache) = self.cache {
if let Some(cached) = cache.get(&cache_key) {
Expand All @@ -100,7 +104,9 @@ impl Tool for ListDirTool {
.git_global(true)
.git_exclude(true)
.require_git(false)
.hidden(false)
// Hide dotfiles by default to avoid leaking .env etc.
// into LLM context. See `FindFilesArgs::include_hidden`.
.hidden(!args.include_hidden)
.max_depth(Some(1))
.filter_entry(|entry| {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
Expand Down
11 changes: 11 additions & 0 deletions src/agent/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,22 @@ pub struct GrepArgs {
pub struct FindFilesArgs {
pub pattern: String,
pub path: Option<String>,
/// Include dotfiles / hidden files (e.g. `.env`, `.gitignore`).
/// Default `false` — by default the listing skips hidden files
/// so secrets in `.env` or `.git/` internals don't get pulled
/// into LLM context inadvertently. Set `true` when the agent
/// explicitly needs to inspect dotfiles.
#[serde(default)]
pub include_hidden: bool,
}

#[derive(Deserialize)]
pub struct ListDirArgs {
pub path: Option<String>,
/// Include dotfiles in the listing. See `FindFilesArgs::include_hidden`
/// for the rationale; default `false` for safety.
#[serde(default)]
pub include_hidden: bool,
}

async fn handle_ask_inner(
Expand Down
Loading