use crossterm::style::Color;
use crate::ui::events::sanitize_output;
use crate::ui::theme;
const ALLOW_PLACEHOLDER: &str = "<edit this pattern>";
pub(crate) const PERMISSION_ACTION_KEYS: &str =
"[y] allow once [a] allow always [n] deny [d] deny + redirect [ESC] abort";
pub(crate) const DENY_NOTE_PROMPT: &str =
"denying — tell the agent what to do instead (Enter sends · Esc goes back):";
pub(crate) struct PermissionPrompt<'a> {
pub(crate) tool: &'a str,
pub(crate) input: &'a str,
pub(crate) details: Option<&'a str>,
pub(crate) reason: Option<&'a str>,
pub(crate) working_dir: &'a str,
pub(crate) deny_note: Option<&'a str>,
}
impl<'a> PermissionPrompt<'a> {
pub(crate) fn new(
req: &'a crate::permission::ask::AskRequest,
working_dir: &'a str,
deny_note: Option<&'a str>,
) -> Self {
Self {
tool: &req.tool,
input: &req.input,
details: req.details.as_deref(),
reason: req.reason.as_deref(),
working_dir,
deny_note,
}
}
}
pub(crate) fn build_permission_overlay(p: &PermissionPrompt<'_>) -> Vec<(String, Color)> {
let &PermissionPrompt {
tool,
input,
details,
reason,
working_dir,
deny_note,
} = p;
let color = theme::perm();
let safe_tool = sanitize_output(tool);
let safe_input = sanitize_output(input);
let mut overlay: Vec<(String, Color)> = Vec::new();
overlay.push(("⚠ PERMISSION REQUIRED".to_string(), color));
overlay.push((String::new(), color));
overlay.push((format!("tool: {}", safe_tool), color));
let arg_label = match tool {
"read" | "write" | "edit" | "list_dir" | "apply_patch" | "find_files" | "glob"
| "list_symbols" | "get_symbol_body" | "find_definition" | "find_callers"
| "find_callees" => {
if !working_dir.is_empty() {
let abs = crate::permission::checker::resolve_absolute(input, working_dir);
let hint = if abs.starts_with(working_dir) {
"(inside project)"
} else {
"(outside project)"
};
if abs == input || abs == safe_input {
format!("path: {} {}", abs, hint)
} else {
format!("path: {} → {} {}", safe_input, abs, hint)
}
} else {
format!("path: {}", safe_input)
}
}
"bash" => format!("command: {}", safe_input),
"task" | "task_status" => format!("task: {}", safe_input),
"webfetch" | "websearch" => format!("url: {}", safe_input),
_ if tool.starts_with("mcp_tool") => format!("mcp: {}", safe_input),
_ => format!("args: {}", safe_input),
};
overlay.push((arg_label, color));
if let Some(details) = details.filter(|d| !d.trim().is_empty()) {
let safe = sanitize_output(details);
let mut lines = safe.lines();
if let Some(first) = lines.next() {
overlay.push((format!("args: {}", first), color));
for line in lines {
overlay.push((line.to_string(), color));
}
}
}
if let Some(reason) = reason {
overlay.push((
format!("flagged by approval check: {}", sanitize_output(reason)),
color,
));
}
overlay.push((String::new(), color));
match deny_note {
Some(note) => {
overlay.push((DENY_NOTE_PROMPT.to_string(), color));
overlay.push((format!("> {}▌", sanitize_output(note)), color));
}
None => overlay.push((PERMISSION_ACTION_KEYS.to_string(), color)),
}
overlay
}
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::*;
fn body(rows: &[(String, Color)]) -> String {
rows.iter()
.map(|(t, _)| t.as_str())
.collect::<Vec<_>>()
.join("\n")
}
fn prompt<'a>(tool: &'a str, input: &'a str) -> PermissionPrompt<'a> {
PermissionPrompt {
tool,
input,
details: None,
reason: None,
working_dir: "",
deny_note: None,
}
}
fn rows_for(p: &PermissionPrompt<'_>) -> Vec<(String, Color)> {
build_permission_overlay(p)
}
#[test]
fn bash_prompt_carries_the_entire_command_verbatim() {
let cmd = format!(
"cd /srv && ./deploy.sh --target prod --token {} && rm -rf ./stale",
"x".repeat(600)
);
let text = body(&rows_for(&prompt("bash", &cmd)));
assert!(
text.contains(&cmd),
"command was altered or clipped:\n{text}"
);
assert!(!text.contains('…'), "command must not be elided:\n{text}");
let heredoc = "python3 - <<PY\nimport os\nos.remove('/etc/hosts')\nPY";
let text = body(&rows_for(&prompt("bash", heredoc)));
for line in heredoc.lines() {
assert!(text.contains(line), "dropped {line:?} from:\n{text}");
}
}
#[test]
fn action_keys_are_the_sticky_last_row() {
for (tool, input) in [
("bash", "cargo test"),
("write", "src/main.rs"),
("mcp_tool", "mcp_tool:db:query"),
] {
let p = PermissionPrompt {
details: Some("{}"),
reason: Some("risky"),
working_dir: "/proj",
..prompt(tool, input)
};
assert_eq!(
rows_for(&p).last().map(|(t, _)| t.as_str()),
Some(PERMISSION_ACTION_KEYS),
"{tool}: action keys must stay last",
);
}
}
#[test]
fn deny_note_entry_replaces_the_action_keys_as_the_sticky_row() {
let long = "rm -rf /var/data\n".repeat(40);
let p = PermissionPrompt {
deny_note: Some("use git clean -n first"),
..prompt("bash", &long)
};
let rows = rows_for(&p);
let text = body(&rows);
let last = rows.last().map(|(t, _)| t.as_str()).unwrap_or_default();
assert!(
last.contains("use git clean -n first"),
"the entry field must be the pinned last row, got {last:?}"
);
assert!(
!text.contains(PERMISSION_ACTION_KEYS),
"y/a/n keys are inert while typing and must not be shown:\n{text}"
);
assert!(text.contains(DENY_NOTE_PROMPT), "missing hint:\n{text}");
assert!(text.contains("rm -rf /var/data"));
}
#[test]
fn empty_deny_note_still_renders_the_field() {
let rows = rows_for(&PermissionPrompt {
deny_note: Some(""),
..prompt("bash", "rm -rf /")
});
let last = rows.last().map(|(t, _)| t.as_str()).unwrap_or_default();
assert!(last.starts_with('>'), "no entry field: {last:?}");
assert!(last.contains('▌'), "no cursor: {last:?}");
}
#[test]
fn mcp_prompt_shows_the_call_arguments() {
let args = "{\n \"query\": \"DROP TABLE users\",\n \"confirm\": true\n}";
let rows = rows_for(&PermissionPrompt {
details: Some(args),
working_dir: "/proj",
..prompt("mcp_tool", "mcp_tool:db:execute_sql")
});
let text = body(&rows);
assert!(text.contains("mcp_tool:db:execute_sql"));
assert!(
text.contains("DROP TABLE users"),
"the payload the user is approving must be visible:\n{text}"
);
assert!(
rows.iter().any(|(t, _)| t.trim() == "\"confirm\": true"),
"JSON lines should map to their own rows:\n{text}"
);
}
#[test]
fn blank_details_add_no_rows() {
let base = body(&rows_for(&prompt("mcp_tool", "mcp_tool:x:y")));
for blank in ["", " ", "\n\t"] {
let with = body(&rows_for(&PermissionPrompt {
details: Some(blank),
..prompt("mcp_tool", "mcp_tool:x:y")
}));
assert_eq!(with, base, "blank {blank:?} added rows");
}
}
#[test]
fn overlay_strips_escape_sequences() {
let text = body(&rows_for(&PermissionPrompt {
details: Some("\x1b[31mred"),
reason: Some("\x1b[5mflagged"),
deny_note: Some("\x1b[2Jwipe"),
..prompt("bash", "echo \x1b[2J\x1b[1;31mSAFE\x1b[0m && rm -rf /")
}));
assert!(
!text.contains('\x1b'),
"escape leaked into prompt: {text:?}"
);
assert!(text.contains("rm -rf /"));
}
#[test]
fn path_tools_show_the_resolved_absolute_path() {
let text = body(&rows_for(&PermissionPrompt {
working_dir: "/proj",
..prompt("write", "1")
}));
assert!(text.contains("/proj/1"), "unresolved path in:\n{text}");
assert!(text.contains("(inside project)"), "missing hint:\n{text}");
let text = body(&rows_for(&PermissionPrompt {
working_dir: "/proj",
..prompt("write", "/etc/passwd")
}));
assert!(text.contains("(outside project)"), "missing hint:\n{text}");
}
#[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"
)));
}
}