const ALLOW_PLACEHOLDER: &str = "<edit this pattern>";
pub(crate) fn is_placeholder_pattern(p: &str) -> bool {
p == ALLOW_PLACEHOLDER
}
pub(crate) fn allow_always_downgrade_reason(tool: &str, input: &str) -> Option<&'static str> {
if input.trim().is_empty() {
return Some("can't derive a useful pattern from empty input");
}
if tool == "bash" && crate::semantic::adapters::bash::command_is_complex(input) {
return Some(
"commands with shell substitution or a subshell are never covered by a saved rule \
(the inner command can't be inspected), so this can only be allowed once",
);
}
None
}
fn segment_already_allowed(segment: &str) -> bool {
use crate::permission::{Action, default_bash_rules, pattern::Pattern};
default_bash_rules()
.into_iter()
.rfind(|(pat, _)| Pattern::new_command(pat).matches(segment))
.is_some_and(|(_, action)| action == Action::Allow)
}
fn bash_segments(command: &str) -> Vec<String> {
if let Ok((segments, complex)) =
crate::semantic::adapters::bash::parse_bash_segments_full(command)
&& !complex
&& segments.len() > 1
{
return segments;
}
command
.split(['&', '|', ';', '\n'])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn significant_bash_head(command: &str) -> Option<String> {
bash_segments(command).into_iter().find_map(|seg| {
(!segment_already_allowed(&seg))
.then(|| seg.split_whitespace().next().map(str::to_string))
.flatten()
})
}
pub(crate) fn suggest_pattern(tool: &str, input: &str) -> String {
const PLACEHOLDER: &str = ALLOW_PLACEHOLDER;
let trimmed = input.trim();
if allow_always_downgrade_reason(tool, input).is_some() {
return PLACEHOLDER.to_string();
}
match tool {
"bash" => {
let head = significant_bash_head(trimmed).unwrap_or_else(|| {
trimmed
.split_whitespace()
.next()
.unwrap_or(PLACEHOLDER)
.to_string()
});
format!("{} *", head)
}
"read" | "write" | "edit" | "list_dir" | "apply_patch" | "list_symbols"
| "get_symbol_body" | "find_definition" | "find_callers" | "find_callees" => {
let path = std::path::Path::new(trimmed);
let parent = path
.parent()
.map(|p| p.to_string_lossy())
.unwrap_or(std::borrow::Cow::Borrowed(""));
if parent.is_empty() {
"**".to_string()
} else {
format!("{}/**", parent)
}
}
"grep" | "find_files" => {
let first = trimmed.split_whitespace().next().unwrap_or(PLACEHOLDER);
format!("{}*", first)
}
"mcp_tool" => {
let mut parts = trimmed.splitn(3, ':');
let umbrella = parts.next().unwrap_or("");
let server = parts.next().unwrap_or("");
if umbrella.eq_ignore_ascii_case("mcp_tool") && !server.is_empty() {
format!("mcp_tool:{}:*", server)
} else {
PLACEHOLDER.to_string()
}
}
"webfetch" => "webfetch:*".to_string(),
"websearch" => "websearch:*".to_string(),
"task" | "task_status" | "question" => "**".to_string(),
"glob" | "repo_overview" | "skill" | "memory" | "write_todo_list" | "lsp" => {
"**".to_string()
}
_ => PLACEHOLDER.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placeholder_pattern_is_detectable() {
let p = suggest_pattern("bash", "");
assert!(
is_placeholder_pattern(&p),
"empty input should yield a detectable placeholder; got {p:?}",
);
let p = suggest_pattern("grep", " \t ");
assert!(is_placeholder_pattern(&p));
let p = suggest_pattern("bash", "cargo test");
assert!(!is_placeholder_pattern(&p), "real pattern flagged: {p:?}");
}
#[test]
fn suggest_pattern_refuses_wildcard_on_empty_input() {
let p = suggest_pattern("bash", "");
assert_ne!(p, "* *", "empty bash input must not yield catch-all");
assert!(
!p.contains('*'),
"empty input should not contain wildcards: {p:?}"
);
let p = suggest_pattern("bash", " \t ");
assert_ne!(
p, "* *",
"whitespace-only bash input must not yield catch-all"
);
assert!(
!p.contains('*'),
"ws-only input should not contain wildcards: {p:?}"
);
let p = suggest_pattern("grep", "");
assert!(
!p.contains('*'),
"empty grep input must not yield wildcard: {p:?}"
);
let p = suggest_pattern("mcp_tool:foo", "");
assert!(!p.contains('*'), "unknown tool empty input: {p:?}");
}
#[test]
fn compound_bash_suggests_significant_command_not_cd() {
assert_eq!(
suggest_pattern("bash", "cd /tmp/proj && python3 gen.py"),
"python3 *"
);
assert_eq!(
suggest_pattern(
"bash",
"cd src && python3 - <<PY\nwith open('a','w') as f: f.write(x)\nPY"
),
"python3 *"
);
assert_eq!(
suggest_pattern("bash", "export X=1 && cd app && npm install"),
"npm *"
);
assert_eq!(suggest_pattern("bash", "cargo test --all"), "cargo *");
assert_eq!(suggest_pattern("bash", "cd /tmp"), "cd *");
}
#[test]
fn source_is_the_suggestion_target_not_skipped() {
assert_eq!(
suggest_pattern("bash", "source venv/bin/activate && python app.py"),
"source *"
);
assert_eq!(suggest_pattern("bash", ". ./env.sh && cargo run"), ". *");
assert_eq!(
suggest_pattern("bash", "export TOKEN=x && unset Y && mycli run"),
"mycli *"
);
}
#[test]
fn compound_skips_every_already_allowed_prefix_not_just_builtins() {
assert_eq!(
suggest_pattern("bash", "cat f.txt | clojure -M -"),
"clojure *",
"the reported re-prompt case",
);
assert_eq!(suggest_pattern("bash", "ls src | xargs wc"), "xargs *");
assert_eq!(
suggest_pattern("bash", "grep -l TODO . | xargs sed -i s/a/b/"),
"xargs *"
);
assert_eq!(suggest_pattern("bash", "echo hi | mycli stdin"), "mycli *");
assert_eq!(
suggest_pattern("bash", "git status && git push origin main"),
"git *"
);
}
#[test]
fn suggestion_never_lands_on_an_already_allowed_head() {
use crate::permission::{Action, default_bash_rules};
for (pat, action) in default_bash_rules() {
if action != Action::Allow {
continue;
}
let invocation = pat
.trim_end_matches("**")
.trim_end_matches('*')
.trim()
.to_string();
if invocation.is_empty() {
continue;
}
let cmd = format!("{invocation} && zzunallowed --go");
assert_eq!(
suggest_pattern("bash", &cmd),
"zzunallowed *",
"rule {pat:?} left the suggestion on an already-allowed prefix",
);
}
}
#[test]
fn complex_commands_get_no_allow_always_pattern() {
for cmd in [
"echo $(date)",
"rm -rf $(cat /tmp/target)",
"ls `which python3`",
"foo <(bar)",
] {
let p = suggest_pattern("bash", cmd);
assert!(
is_placeholder_pattern(&p),
"a grant for {cmd:?} could never fire, so none should be offered; got {p:?}",
);
}
assert!(!is_placeholder_pattern(&suggest_pattern(
"bash",
"cat f.txt | clojure -M -"
)));
assert!(!is_placeholder_pattern(&suggest_pattern(
"bash",
"cargo test --all"
)));
}
#[test]
fn allow_always_downgrade_reasons_are_distinct_and_accurate() {
let empty = allow_always_downgrade_reason("bash", "");
let complex = allow_always_downgrade_reason("bash", "echo $(date)");
assert!(empty.is_some() && complex.is_some());
assert_ne!(empty, complex);
assert!(
complex.unwrap().contains("substitution"),
"complex reason should name the cause: {complex:?}",
);
assert_eq!(allow_always_downgrade_reason("bash", "cargo test"), None);
}
#[test]
fn suggest_pattern_works_for_non_empty_inputs() {
assert_eq!(suggest_pattern("bash", "cargo test --all"), "cargo *");
assert_eq!(suggest_pattern("grep", "fn foo bar"), "fn*");
}
#[test]
fn suggest_pattern_path_tools_use_recursive_glob() {
assert_eq!(suggest_pattern("write", "src/main.rs"), "src/**");
assert_eq!(suggest_pattern("edit", "src/main.rs"), "src/**");
assert_eq!(
suggest_pattern("write", "src/agent/tools/foo.rs"),
"src/agent/tools/**"
);
assert_eq!(suggest_pattern("read", "src/main.rs"), "src/**");
assert_eq!(suggest_pattern("list_dir", "src/agent"), "src/**");
assert_eq!(suggest_pattern("write", "main.rs"), "**");
}
#[test]
fn suggest_pattern_derives_server_wildcard_for_mcp_tool() {
let p = suggest_pattern("mcp_tool", "mcp_tool:lattice:lattice_expand");
assert_eq!(p, "mcp_tool:lattice:*");
let p = suggest_pattern("mcp_tool", "mcp_tool:my-server:do_thing");
assert_eq!(p, "mcp_tool:my-server:*");
}
#[test]
fn suggest_pattern_mcp_tool_malformed_input_uses_placeholder() {
assert!(is_placeholder_pattern(&suggest_pattern(
"mcp_tool", "garbage"
)));
assert!(is_placeholder_pattern(&suggest_pattern(
"mcp_tool",
"mcp_tool:"
)));
assert!(is_placeholder_pattern(&suggest_pattern(
"mcp_tool",
"mcp_tool::"
)));
assert!(is_placeholder_pattern(&suggest_pattern(
"mcp_tool",
"wrong:lattice:foo"
)));
}
}