use std::path::{Path, PathBuf};
use crate::cli::AskArgs;
use crate::cli::PermMode;
pub fn find_agent_file(name: &str, cwd: &Path) -> Option<PathBuf> {
let cwd_candidates = [
cwd.join(format!(".claude/agents/{name}/AGENT.md")),
cwd.join(format!(".claude/agents/{name}.md")),
];
for path in &cwd_candidates {
if path.exists() {
return Some(path.clone());
}
}
if let Some(home) = home_dir() {
let home_candidates = [
home.join(format!(".claude/agents/{name}/AGENT.md")),
home.join(format!(".claude/agents/{name}.md")),
];
for path in &home_candidates {
if path.exists() {
return Some(path.clone());
}
}
}
None
}
fn home_dir() -> Option<PathBuf> {
if let Ok(h) = std::env::var("HOME")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
if let Ok(h) = std::env::var("USERPROFILE")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
None
}
pub fn parse_tools(content: &str) -> Option<Vec<String>> {
let rest = content.strip_prefix("---\n")?;
let fm_end = rest.find("\n---")?;
let frontmatter = &rest[..fm_end];
parse_tools_from_frontmatter(frontmatter)
}
fn parse_tools_from_frontmatter(frontmatter: &str) -> Option<Vec<String>> {
let mut lines = frontmatter.lines().peekable();
while let Some(line) = lines.next() {
if let Some(rest) = line.strip_prefix("tools:") {
let rest = rest.trim();
if !rest.is_empty() {
let tools: Vec<String> = rest
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
return if tools.is_empty() { None } else { Some(tools) };
} else {
let mut tools = Vec::new();
while let Some(next) = lines.peek() {
let trimmed = next.trim();
if let Some(item) = trimmed.strip_prefix("- ") {
tools.push(item.trim().to_string());
lines.next();
} else {
break;
}
}
return if tools.is_empty() { None } else { Some(tools) };
}
}
}
None
}
fn effective_allowlist(args: &AskArgs) -> Vec<String> {
let mut allow: Vec<String> = vec!["Read".to_string(), "Glob".to_string(), "Grep".to_string()];
if args.writable {
if !allow.iter().any(|s| s == "Edit") {
allow.push("Edit".to_string());
}
if !allow.iter().any(|s| s == "Write") {
allow.push("Write".to_string());
}
}
for t in &args.allow_tool {
if !allow.iter().any(|s| s == t) {
allow.push(t.clone());
}
}
allow
}
fn is_covered(tool: &str, allowlist: &[String]) -> bool {
allowlist
.iter()
.any(|entry| entry == tool || entry.starts_with(&format!("{tool}(")))
}
pub fn find_missing_tools(declared: &[String], args: &AskArgs) -> Vec<String> {
if args.full_auto {
return Vec::new();
}
let allowlist = effective_allowlist(args);
declared
.iter()
.filter(|tool| !is_covered(tool, &allowlist))
.cloned()
.collect()
}
pub fn declares_write_tools(declared: &[String]) -> bool {
declared.iter().any(|t| {
matches!(t.as_str(), "Edit" | "Write" | "MultiEdit" | "Bash") || t.starts_with("Bash(")
})
}
pub fn is_default_permission_mode(args: &AskArgs) -> bool {
if args.full_auto || args.writable {
return false;
}
match args.permission_mode {
None | Some(PermMode::Default) | Some(PermMode::Plan) => true,
Some(PermMode::AcceptEdits)
| Some(PermMode::Auto)
| Some(PermMode::BypassPermissions)
| Some(PermMode::DontAsk) => false,
}
}
pub fn maybe_warn(args: &AskArgs, cwd: &Path) {
let agent_name = match &args.agent {
Some(n) => n,
None => return,
};
if args.full_auto || args.quiet || args.no_agent_check {
return;
}
let agent_path = match find_agent_file(agent_name, cwd) {
Some(p) => p,
None => return,
};
let content = match std::fs::read_to_string(&agent_path) {
Ok(c) => c,
Err(_) => return,
};
let declared = match parse_tools(&content) {
Some(t) => t,
None => return,
};
let missing = find_missing_tools(&declared, args);
if !missing.is_empty() {
let tools_str = missing.join(", ");
eprintln!(
"[roba] warning: agent '{agent_name}' declares tools not in the resolved allowlist: [{tools_str}]"
);
eprintln!(
" hint: pass --full-auto, --allow-tool 'Bash(...)', or --no-agent-check to suppress"
);
}
if declares_write_tools(&declared) && is_default_permission_mode(args) {
eprintln!(
"[roba] warning: agent '{agent_name}' declares write tools (Edit/Write) but permission mode is default"
);
eprintln!(" -- dispatch will stall at first write attempt");
eprintln!(" hint: add --full-auto or --permission-mode acceptEdits");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
fn args_from(extra: &[&str]) -> AskArgs {
let mut argv = vec!["roba", "placeholder"];
argv.extend_from_slice(extra);
Cli::try_parse_from(&argv).unwrap().ask
}
#[test]
fn parse_tools_inline() {
let content = "---\nname: My Agent\ntools: Read, Edit, Write, Bash\n---\n# body\n";
let tools = parse_tools(content).unwrap();
assert_eq!(
tools,
vec!["Read", "Edit", "Write", "Bash"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>()
);
}
#[test]
fn parse_tools_yaml_list() {
let content = "---\nname: My Agent\ntools:\n - Read\n - Edit\n - Bash\n---\n";
let tools = parse_tools(content).unwrap();
assert_eq!(
tools,
vec!["Read", "Edit", "Bash"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>()
);
}
#[test]
fn parse_tools_missing_field() {
let content = "---\nname: My Agent\ndescription: no tools field here\n---\n";
assert!(
parse_tools(content).is_none(),
"missing tools field should return None"
);
}
#[test]
fn parse_tools_malformed_frontmatter() {
let content = "name: My Agent\ntools: Read\n";
assert!(
parse_tools(content).is_none(),
"missing frontmatter should return None"
);
}
#[test]
fn parse_tools_no_closing_delimiter() {
let content = "---\nname: My Agent\ntools: Read\n";
assert!(
parse_tools(content).is_none(),
"unclosed frontmatter should return None"
);
}
#[test]
fn parse_tools_inline_trims_whitespace() {
let content = "---\ntools: Read , Bash , Write \n---\n";
let tools = parse_tools(content).unwrap();
assert_eq!(tools, vec!["Read", "Bash", "Write"]);
}
#[test]
fn tool_coverage_bash_satisfied_by_granular() {
let args = args_from(&["--allow-tool", "Bash(git:*)"]);
let declared = vec!["Bash".to_string()];
let missing = find_missing_tools(&declared, &args);
assert!(
missing.is_empty(),
"Bash(git:*) should cover declared Bash; got missing: {missing:?}"
);
}
#[test]
fn tool_coverage_edit_satisfied_by_writable() {
let args = args_from(&["--writable"]);
let declared = vec!["Edit".to_string(), "Write".to_string()];
let missing = find_missing_tools(&declared, &args);
assert!(
missing.is_empty(),
"--writable should cover Edit and Write; got missing: {missing:?}"
);
}
#[test]
fn tool_coverage_full_auto_covers_all() {
let args = args_from(&["--full-auto"]);
let declared = vec![
"Bash".to_string(),
"WebFetch".to_string(),
"Edit".to_string(),
];
let missing = find_missing_tools(&declared, &args);
assert!(
missing.is_empty(),
"--full-auto should cover all tools; got missing: {missing:?}"
);
}
#[test]
fn tool_coverage_missing() {
let args = args_from(&[]);
let declared = vec!["Bash".to_string()];
let missing = find_missing_tools(&declared, &args);
assert_eq!(
missing,
vec!["Bash"],
"Bash should be missing from the default read-only allowlist"
);
}
#[test]
fn tool_coverage_builtin_trio_always_covered() {
let args = args_from(&[]);
let declared = vec!["Read".to_string(), "Glob".to_string(), "Grep".to_string()];
let missing = find_missing_tools(&declared, &args);
assert!(
missing.is_empty(),
"built-in trio should always be covered; got missing: {missing:?}"
);
}
#[test]
fn tool_coverage_exact_match_in_allow_tool() {
let args = args_from(&["--allow-tool", "Bash"]);
let declared = vec!["Bash".to_string()];
let missing = find_missing_tools(&declared, &args);
assert!(
missing.is_empty(),
"exact Bash in allow_tool should cover declared Bash"
);
}
#[test]
fn agent_check_warns_on_write_tools_in_default_mode() {
let declared = vec!["Edit".to_string(), "Write".to_string()];
assert!(
declares_write_tools(&declared),
"Edit/Write should be detected as write tools"
);
let args = args_from(&[]);
assert!(is_default_permission_mode(&args), "no flags = default mode");
}
#[test]
fn agent_check_no_warn_when_full_auto() {
let declared = vec!["Edit".to_string(), "Write".to_string()];
assert!(declares_write_tools(&declared));
let args = args_from(&["--full-auto"]);
assert!(
!is_default_permission_mode(&args),
"--full-auto should not be default mode"
);
}
#[test]
fn agent_check_no_warn_when_writable() {
let declared = vec!["Edit".to_string()];
assert!(declares_write_tools(&declared));
let args = args_from(&["--writable"]);
assert!(
!is_default_permission_mode(&args),
"--writable should not be default mode"
);
}
#[test]
fn agent_check_no_warn_when_permission_mode_set() {
let declared = vec!["Write".to_string()];
assert!(declares_write_tools(&declared));
let args = args_from(&["--permission-mode", "accept-edits"]);
assert!(
!is_default_permission_mode(&args),
"--permission-mode accept-edits should not be default mode"
);
let args2 = args_from(&["--permission-mode", "auto"]);
assert!(!is_default_permission_mode(&args2));
let args3 = args_from(&["--permission-mode", "dont-ask"]);
assert!(!is_default_permission_mode(&args3));
}
#[test]
fn agent_check_no_warn_for_readonly_agent() {
let declared = vec!["Read".to_string(), "Glob".to_string()];
assert!(
!declares_write_tools(&declared),
"Read/Glob are not write tools"
);
let args = args_from(&[]);
assert!(is_default_permission_mode(&args));
assert!(
!(declares_write_tools(&declared) && is_default_permission_mode(&args)),
"readonly agent in default mode should not trigger the warning"
);
}
}