feat: enforce allowed tools for custom commands
This commit is contained in:
committed by
Hunter Bown
parent
2e73634ab0
commit
568fbe2c54
@@ -31,7 +31,7 @@ mod skills;
|
||||
mod stash;
|
||||
mod status;
|
||||
mod task;
|
||||
mod user_commands;
|
||||
pub mod user_commands;
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
@@ -966,6 +966,7 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
|
||||
///
|
||||
/// `workspace` is used to also scan workspace-local command directories;
|
||||
/// pass `None` when no workspace context is available.
|
||||
#[allow(dead_code)]
|
||||
pub fn all_command_names_matching(
|
||||
prefix: &str,
|
||||
workspace: Option<&std::path::Path>,
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! (without `.md` extension) becomes a slash command. When invoked via
|
||||
//! `/name`, the file contents are sent as a user message.
|
||||
//!
|
||||
//! Files may include optional YAML-like frontmatter between `---` markers.
|
||||
//! Supported fields are `description`, `argument-hint`, and `allowed-tools`.
|
||||
//! Frontmatter is stripped before the command body is sent to the model.
|
||||
//!
|
||||
//! ## Precedence
|
||||
//!
|
||||
//! Workspace-local directories shadow user-global by name:
|
||||
@@ -95,6 +99,72 @@ pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> {
|
||||
commands
|
||||
}
|
||||
|
||||
pub(crate) fn parse_frontmatter(content: &str) -> (Vec<(String, String)>, &str) {
|
||||
let Some(first_line_end) = content.find('\n') else {
|
||||
return (Vec::new(), content);
|
||||
};
|
||||
let first = content[..first_line_end].trim_end_matches('\r');
|
||||
|
||||
if first.trim().chars().all(|ch| ch == '-') && first.trim().len() >= 3 {
|
||||
let mut metadata = Vec::new();
|
||||
let mut offset = first_line_end + 1;
|
||||
let mut unclosed_body_start = None;
|
||||
for raw_line in content[offset..].split_inclusive('\n') {
|
||||
let line_start = offset;
|
||||
let line = raw_line.trim_end_matches(['\r', '\n']);
|
||||
offset += raw_line.len();
|
||||
let trimmed = line.trim();
|
||||
if unclosed_body_start.is_none() {
|
||||
if trimmed.chars().all(|ch| ch == '-') && trimmed.len() >= 3 {
|
||||
let body = content[offset..].trim_start_matches(['\r', '\n']);
|
||||
return (metadata, body);
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let raw_value = value.trim();
|
||||
let value = if key == "allowed-tools" {
|
||||
raw_value.to_string()
|
||||
} else {
|
||||
strip_matched_quotes(raw_value).to_string()
|
||||
};
|
||||
if !key.is_empty() {
|
||||
metadata.push((key, value));
|
||||
}
|
||||
} else if !trimmed.is_empty() {
|
||||
unclosed_body_start = Some(line_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
let body_start = unclosed_body_start.unwrap_or(content.len());
|
||||
let body = content[body_start..].trim_start_matches(['\r', '\n']);
|
||||
return (metadata, body);
|
||||
}
|
||||
|
||||
(Vec::new(), content)
|
||||
}
|
||||
|
||||
fn strip_matched_quotes(value: &str) -> &str {
|
||||
if let Some(stripped) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
|
||||
return stripped;
|
||||
}
|
||||
if let Some(stripped) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
|
||||
return stripped;
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn parse_allowed_tools(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|tool| {
|
||||
strip_matched_quotes(tool.trim())
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
})
|
||||
.filter(|tool| !tool.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if the input matches a user-defined command and return the
|
||||
/// content as a `SendMessage` action.
|
||||
///
|
||||
@@ -121,7 +191,23 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe
|
||||
|
||||
for (name, content) in &user_commands {
|
||||
if name == command {
|
||||
let message = apply_template(content, args);
|
||||
let (metadata, body) = parse_frontmatter(content);
|
||||
app.goal.goal_objective = None;
|
||||
app.goal.goal_started_at = None;
|
||||
app.active_allowed_tools = None;
|
||||
for (key, value) in &metadata {
|
||||
match key.as_str() {
|
||||
"description" => {
|
||||
app.goal.goal_objective = Some(value.clone());
|
||||
app.goal.goal_started_at = Some(std::time::Instant::now());
|
||||
}
|
||||
"allowed-tools" => {
|
||||
app.active_allowed_tools = Some(parse_allowed_tools(value));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let message = apply_template(body, args);
|
||||
return Some(CommandResult::action(AppAction::SendMessage(message)));
|
||||
}
|
||||
}
|
||||
@@ -217,6 +303,30 @@ mod tests {
|
||||
std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
|
||||
}
|
||||
|
||||
fn test_options(workspace: PathBuf) -> crate::tui::app::TuiOptions {
|
||||
crate::tui::app::TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace,
|
||||
config_path: None,
|
||||
config_profile: None,
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
initial_input: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_user_commands_scans_workspace_local_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -363,4 +473,174 @@ mod tests {
|
||||
"got: {matches:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_is_stripped_before_dispatch() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"secure",
|
||||
"---\ndescription: Secure scan\nallowed-tools: Bash, Read\n---\nRun $ARGUMENTS",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let result = try_dispatch_user_command(&mut app, "/secure checks").unwrap();
|
||||
match result.action {
|
||||
Some(AppAction::SendMessage(msg)) => assert_eq!(msg, "Run checks"),
|
||||
other => panic!("expected SendMessage action, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_unclosed_frontmatter_keeps_metadata_and_strips_header() {
|
||||
let (metadata, body) = parse_frontmatter(
|
||||
"---\ndescription: Broken command\nallowed-tools: Bash\nRun the safe body",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
vec![
|
||||
("description".to_string(), "Broken command".to_string()),
|
||||
("allowed-tools".to_string(), "Bash".to_string())
|
||||
]
|
||||
);
|
||||
assert_eq!(body, "Run the safe body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_unclosed_frontmatter_without_metadata_strips_header() {
|
||||
let (metadata, body) =
|
||||
parse_frontmatter("---\nRun the command body without a closing delimiter");
|
||||
|
||||
assert!(metadata.is_empty());
|
||||
assert_eq!(body, "Run the command body without a closing delimiter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_frontmatter_strips_only_matched_quote_pairs() {
|
||||
let (metadata, body) = parse_frontmatter("---\ndescription: 'Read\"\n---\nrun");
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
vec![("description".to_string(), "'Read\"".to_string())]
|
||||
);
|
||||
assert_eq!(body, "run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_frontmatter_sets_app_state() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"secure",
|
||||
"---\nallowed-tools: Bash, Grep\n---\nrun tests",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/secure").unwrap();
|
||||
assert_eq!(
|
||||
app.active_allowed_tools,
|
||||
Some(vec!["bash".to_string(), "grep".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_empty_allowed_tools_blocks_all_tools() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"locked",
|
||||
"---\nallowed-tools: \"\"\n---\nrun nothing",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/locked").unwrap();
|
||||
assert_eq!(app.active_allowed_tools, Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_allowed_tools_accepts_per_item_quotes() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"quoted",
|
||||
"---\nallowed-tools: \"exec_shell\", 'read_file'\n---\nrun quoted tools",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/quoted").unwrap();
|
||||
assert_eq!(
|
||||
app.active_allowed_tools,
|
||||
Some(vec!["exec_shell".to_string(), "read_file".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_dispatch_without_frontmatter_resets_previous_command_state() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
let commands_dir = ws.join(".deepseek").join("commands");
|
||||
write_command(
|
||||
&commands_dir,
|
||||
"described",
|
||||
"---\ndescription: Scan repos\nallowed-tools: Bash\n---\nscan",
|
||||
);
|
||||
write_command(&commands_dir, "plain", "plain command");
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/described").unwrap();
|
||||
assert_eq!(app.goal.goal_objective.as_deref(), Some("Scan repos"));
|
||||
assert!(app.goal.goal_started_at.is_some());
|
||||
assert_eq!(app.active_allowed_tools, Some(vec!["bash".to_string()]));
|
||||
|
||||
let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
|
||||
assert_eq!(app.goal.goal_objective, None);
|
||||
assert_eq!(app.goal.goal_started_at, None);
|
||||
assert_eq!(app.active_allowed_tools, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_frontmatter_sets_work_objective_and_autocomplete_description() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"git-scan",
|
||||
"---\ndescription: Scan nested git repositories\nargument-hint: <root>\n---\nscan",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws.clone()), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/git-scan").unwrap();
|
||||
assert_eq!(
|
||||
app.goal.goal_objective.as_deref(),
|
||||
Some("Scan nested git repositories")
|
||||
);
|
||||
let commands = load_user_commands(Some(&ws));
|
||||
let (_, content) = commands
|
||||
.iter()
|
||||
.find(|(name, _)| name == "git-scan")
|
||||
.expect("git-scan command should load");
|
||||
let (metadata, _) = parse_frontmatter(content);
|
||||
assert!(metadata.contains(&(
|
||||
"description".to_string(),
|
||||
"Scan nested git repositories".to_string()
|
||||
)));
|
||||
assert!(metadata.contains(&("argument-hint".to_string(), "<root>".to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,9 @@ pub struct EngineConfig {
|
||||
pub memory_path: PathBuf,
|
||||
pub vision_config: Option<crate::config::VisionModelConfig>,
|
||||
pub goal_objective: Option<String>,
|
||||
/// Tool restriction from custom slash command frontmatter.
|
||||
/// `None` means the current turn may use the normal tool set.
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// Resolved BCP-47 locale tag (e.g. `"en"`, `"zh-Hans"`, `"ja"`)
|
||||
/// for the `## Environment` block in the system prompt. The
|
||||
/// caller resolves this from `Settings` once at engine
|
||||
@@ -223,6 +226,7 @@ impl Default for EngineConfig {
|
||||
vision_config: None,
|
||||
strict_tool_mode: false,
|
||||
goal_objective: None,
|
||||
allowed_tools: None,
|
||||
locale_tag: "en".to_string(),
|
||||
workshop: None,
|
||||
search_provider: crate::config::SearchProvider::default(),
|
||||
@@ -626,6 +630,7 @@ impl Engine {
|
||||
approval_mode,
|
||||
translation_enabled,
|
||||
show_thinking,
|
||||
allowed_tools,
|
||||
} => {
|
||||
self.handle_send_message(
|
||||
content,
|
||||
@@ -641,6 +646,7 @@ impl Engine {
|
||||
approval_mode,
|
||||
translation_enabled,
|
||||
show_thinking,
|
||||
allowed_tools,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -848,6 +854,7 @@ impl Engine {
|
||||
self.session.approval_mode,
|
||||
self.config.translation_enabled,
|
||||
self.config.show_thinking,
|
||||
self.config.allowed_tools.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -937,6 +944,7 @@ impl Engine {
|
||||
approval_mode: crate::tui::approval::ApprovalMode,
|
||||
translation_enabled: bool,
|
||||
show_thinking: bool,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
) {
|
||||
// Reset cancel token for fresh turn (in case previous was cancelled)
|
||||
self.reset_cancel_token();
|
||||
@@ -1034,6 +1042,7 @@ impl Engine {
|
||||
false,
|
||||
);
|
||||
}
|
||||
self.config.allowed_tools = allowed_tools;
|
||||
self.session.reasoning_effort = reasoning_effort;
|
||||
self.session.reasoning_effort_auto = reasoning_effort_auto;
|
||||
self.session.auto_model = auto_model;
|
||||
|
||||
@@ -1198,6 +1198,13 @@ impl Engine {
|
||||
"Planning tool '{tool_name}' with input: {tool_input:?}"
|
||||
));
|
||||
|
||||
let requested_tool_name = tool_name.clone();
|
||||
let tool_def =
|
||||
resolve_tool_definition(&mut tool_name, &tool_catalog, tool_registry);
|
||||
if requested_tool_name != tool_name {
|
||||
tool.name = tool_name.clone();
|
||||
}
|
||||
|
||||
let interactive = (tool_name == "exec_shell"
|
||||
&& tool_input
|
||||
.get("interactive")
|
||||
@@ -1229,25 +1236,10 @@ impl Engine {
|
||||
)));
|
||||
}
|
||||
|
||||
let requested_tool_name = tool_name.clone();
|
||||
let mut tool_def = tool_catalog.iter().find(|def| def.name == tool_name);
|
||||
|
||||
// Resolve hallucinated tool names when the model emits a
|
||||
// non-canonical variant (Read_file, readFile, read-file, etc.).
|
||||
if tool_def.is_none()
|
||||
&& let Some(registry) = tool_registry
|
||||
&& let Some(canonical) = registry.resolve(&tool_name)
|
||||
{
|
||||
crate::logging::info(format!(
|
||||
"Resolved hallucinated tool name '{tool_name}' -> '{canonical}'"
|
||||
));
|
||||
tool_def = tool_catalog.iter().find(|d| d.name == canonical);
|
||||
if tool_def.is_some() {
|
||||
tool_name = canonical.to_string();
|
||||
// Update the tool_uses entry so the result is
|
||||
// attributed to the canonical name.
|
||||
tool.name = tool_name.clone();
|
||||
}
|
||||
if !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) {
|
||||
blocked_error = Some(ToolError::permission_denied(format!(
|
||||
"Tool '{tool_name}' is not in the allowed-tools list for the current command"
|
||||
)));
|
||||
}
|
||||
|
||||
if !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) {
|
||||
@@ -2122,6 +2114,40 @@ fn should_hold_turn_for_subagents(queued_completions: usize, running_children: u
|
||||
queued_completions > 0 || running_children > 0
|
||||
}
|
||||
|
||||
fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
|
||||
let Some(allowed_tools) = allowed_tools else {
|
||||
return true;
|
||||
};
|
||||
allowed_tools.contains(&tool_name.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn resolve_tool_definition<'a>(
|
||||
tool_name: &mut String,
|
||||
tool_catalog: &'a [Tool],
|
||||
tool_registry: Option<&crate::tools::ToolRegistry>,
|
||||
) -> Option<&'a Tool> {
|
||||
let mut tool_def = tool_catalog
|
||||
.iter()
|
||||
.find(|def| def.name.as_str() == tool_name.as_str());
|
||||
|
||||
// Resolve hallucinated tool names before policy gates run, so aliases like
|
||||
// ReadFile are checked against the canonical registered tool name.
|
||||
if tool_def.is_none()
|
||||
&& let Some(registry) = tool_registry
|
||||
&& let Some(canonical) = registry.resolve(tool_name.as_str())
|
||||
{
|
||||
crate::logging::info(format!(
|
||||
"Resolved hallucinated tool name '{tool_name}' -> '{canonical}'"
|
||||
));
|
||||
tool_def = tool_catalog.iter().find(|d| d.name == canonical);
|
||||
if tool_def.is_some() {
|
||||
*tool_name = canonical.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
tool_def
|
||||
}
|
||||
|
||||
/// Issue #1727: decide whether to surface a "thinking-only, no output" status.
|
||||
///
|
||||
/// Reached when the assistant turn had no sendable content (no Text, no
|
||||
@@ -2393,4 +2419,45 @@ mod tests {
|
||||
"auto thinking should classify the user request, not stored metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_gate_blocks_unlisted_tool() {
|
||||
let allowed = vec!["bash".to_string(), "grep".to_string()];
|
||||
assert!(!command_allows_tool(Some(&allowed), "read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_gate_allows_listed_tool_case_insensitively() {
|
||||
let allowed = vec!["bash".to_string(), "read".to_string()];
|
||||
assert!(command_allows_tool(Some(&allowed), "Read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_gate_allows_all_tools_when_not_set() {
|
||||
assert!(command_allows_tool(None, "write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_allowed_tools_gate_blocks_all_tools_when_empty() {
|
||||
let allowed = Vec::new();
|
||||
assert!(!command_allows_tool(Some(&allowed), "bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_allowed_tools_gate_checks_canonical_tool_name() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf());
|
||||
let registry = crate::tools::ToolRegistryBuilder::new()
|
||||
.with_file_tools()
|
||||
.build(context);
|
||||
let catalog = registry.to_api_tools();
|
||||
let mut tool_name = "ReadFile".to_string();
|
||||
|
||||
let tool_def = resolve_tool_definition(&mut tool_name, &catalog, Some(®istry));
|
||||
|
||||
assert!(tool_def.is_some());
|
||||
assert_eq!(tool_name, "read_file");
|
||||
let allowed = vec!["read_file".to_string()];
|
||||
assert!(command_allows_tool(Some(&allowed), &tool_name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ pub enum Op {
|
||||
approval_mode: ApprovalMode,
|
||||
translation_enabled: bool,
|
||||
show_thinking: bool,
|
||||
/// Tool restriction from custom slash command frontmatter.
|
||||
/// `None` means the current turn may use the normal tool set.
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Cancel the current request
|
||||
|
||||
@@ -5280,6 +5280,7 @@ async fn run_exec_agent(
|
||||
vision_config: config.vision_model_config(),
|
||||
strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
|
||||
goal_objective: None,
|
||||
allowed_tools: None,
|
||||
locale_tag: crate::localization::resolve_locale(&settings.locale)
|
||||
.tag()
|
||||
.to_string(),
|
||||
@@ -5334,6 +5335,7 @@ async fn run_exec_agent(
|
||||
mode,
|
||||
model: effective_model.clone(),
|
||||
goal_objective: None,
|
||||
allowed_tools: None,
|
||||
reasoning_effort: effective_reasoning_effort,
|
||||
reasoning_effort_auto: auto_model,
|
||||
auto_model,
|
||||
|
||||
@@ -1629,6 +1629,7 @@ impl RuntimeThreadManager {
|
||||
auto_approve,
|
||||
translation_enabled: false,
|
||||
show_thinking,
|
||||
allowed_tools: None,
|
||||
approval_mode: if auto_approve {
|
||||
crate::tui::approval::ApprovalMode::Auto
|
||||
} else {
|
||||
@@ -1989,6 +1990,7 @@ impl RuntimeThreadManager {
|
||||
vision_config: self.config.vision_model_config(),
|
||||
strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false),
|
||||
goal_objective: None,
|
||||
allowed_tools: None,
|
||||
locale_tag: crate::localization::resolve_locale(&settings.locale)
|
||||
.tag()
|
||||
.to_string(),
|
||||
|
||||
@@ -1108,6 +1108,9 @@ pub struct App {
|
||||
pub goal: GoalState,
|
||||
/// Session sub-state (cost, tokens, telemetry).
|
||||
pub session: SessionState,
|
||||
/// Active tool restriction from custom slash command frontmatter.
|
||||
/// `None` means the current turn may use the normal tool set.
|
||||
pub active_allowed_tools: Option<Vec<String>>,
|
||||
pub history: Vec<HistoryCell>,
|
||||
pub history_version: u64,
|
||||
/// Per-cell revision counter, kept in lockstep with `history`.
|
||||
@@ -1856,6 +1859,7 @@ impl App {
|
||||
viewport: ViewportState::default(),
|
||||
goal: GoalState::default(),
|
||||
session: SessionState::default(),
|
||||
active_allowed_tools: None,
|
||||
history: Vec::new(),
|
||||
history_version: 0,
|
||||
history_revisions: Vec::new(),
|
||||
|
||||
@@ -744,6 +744,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
app.goal.goal_completed,
|
||||
),
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
allowed_tools: app.active_allowed_tools.clone(),
|
||||
network_policy: config.network.clone().map(|toml_cfg| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
}),
|
||||
@@ -1440,6 +1441,7 @@ async fn run_event_loop(
|
||||
} => {
|
||||
let was_locally_cancelled = app.suppress_stream_events_until_turn_complete;
|
||||
app.suppress_stream_events_until_turn_complete = false;
|
||||
app.active_allowed_tools = None;
|
||||
if !matches!(status, crate::core::events::TurnOutcomeStatus::Completed)
|
||||
|| draws_since_last_full_repaint >= PERIODIC_FULL_REPAINT_EVERY_N
|
||||
{
|
||||
@@ -4463,6 +4465,7 @@ async fn dispatch_user_message(
|
||||
approval_mode: app.approval_mode,
|
||||
translation_enabled: app.translation_enabled,
|
||||
show_thinking: app.show_thinking,
|
||||
allowed_tools: app.active_allowed_tools.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -2080,14 +2080,26 @@ pub(crate) fn slash_completion_hints(
|
||||
let mut entries: Vec<SlashMenuEntry> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let prefix_lower = prefix.to_ascii_lowercase();
|
||||
let user_commands = if completing_skill_arg.is_none() {
|
||||
commands::user_commands::load_user_commands(workspace)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// ── Phase 1: prefix (starts_with) matches ─────────────────────────
|
||||
// Highest priority — preserves existing exact-prefix completion.
|
||||
if completing_skill_arg.is_none() {
|
||||
for name in commands::all_command_names_matching(prefix, workspace) {
|
||||
for name in all_command_names_matching_loaded(prefix, &user_commands) {
|
||||
seen.insert(name.clone());
|
||||
let command_key = name.trim_start_matches('/');
|
||||
push_command_entry(&mut entries, &name, command_key, &prefix_lower, locale);
|
||||
push_command_entry(
|
||||
&mut entries,
|
||||
&name,
|
||||
command_key,
|
||||
&prefix_lower,
|
||||
locale,
|
||||
&user_commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2106,7 +2118,14 @@ pub(crate) fn slash_completion_hints(
|
||||
.any(|a| a.to_ascii_lowercase().contains(&prefix_lower));
|
||||
if cmd_lower.contains(&prefix_lower) || alias_match {
|
||||
seen.insert(name.clone());
|
||||
push_command_entry(&mut entries, &name, cmd.name, &prefix_lower, locale);
|
||||
push_command_entry(
|
||||
&mut entries,
|
||||
&name,
|
||||
cmd.name,
|
||||
&prefix_lower,
|
||||
locale,
|
||||
&user_commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2126,7 +2145,14 @@ pub(crate) fn slash_completion_hints(
|
||||
.any(|a| fuzzy_chars_in_order(&prefix_lower, &a.to_ascii_lowercase()));
|
||||
if fuzzy_chars_in_order(&prefix_lower, &cmd_lower) || alias_match {
|
||||
seen.insert(name.clone());
|
||||
push_command_entry(&mut entries, &name, cmd.name, &prefix_lower, locale);
|
||||
push_command_entry(
|
||||
&mut entries,
|
||||
&name,
|
||||
cmd.name,
|
||||
&prefix_lower,
|
||||
locale,
|
||||
&user_commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2219,6 +2245,31 @@ pub(crate) fn slash_completion_hints(
|
||||
entries.into_iter().take(limit).collect()
|
||||
}
|
||||
|
||||
fn all_command_names_matching_loaded(
|
||||
prefix: &str,
|
||||
user_commands: &[(String, String)],
|
||||
) -> Vec<String> {
|
||||
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
|
||||
let mut result: Vec<String> = commands::COMMANDS
|
||||
.iter()
|
||||
.filter(|cmd| {
|
||||
cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))
|
||||
})
|
||||
.map(|cmd| format!("/{}", cmd.name))
|
||||
.collect();
|
||||
|
||||
result.extend(
|
||||
user_commands
|
||||
.iter()
|
||||
.filter(|(name, _)| name.starts_with(&prefix))
|
||||
.map(|(name, _)| format!("/{name}")),
|
||||
);
|
||||
|
||||
result.sort();
|
||||
result.dedup();
|
||||
result
|
||||
}
|
||||
|
||||
/// Push a built-in command entry to the slash menu, resolving description
|
||||
/// and alias hints.
|
||||
fn push_command_entry(
|
||||
@@ -2227,6 +2278,7 @@ fn push_command_entry(
|
||||
command_key: &str,
|
||||
prefix_lower: &str,
|
||||
locale: crate::localization::Locale,
|
||||
user_commands: &[(String, String)],
|
||||
) {
|
||||
let (description, alias_hint) = if let Some(info) = commands::get_command_info(command_key) {
|
||||
let hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) {
|
||||
@@ -2256,7 +2308,25 @@ fn push_command_entry(
|
||||
};
|
||||
(desc, hint)
|
||||
} else {
|
||||
(String::from("User-defined command"), None)
|
||||
let mut description = String::from("User-defined command");
|
||||
let mut argument_hint = None;
|
||||
if let Some((_, content)) = user_commands.iter().find(|(key, _)| key == command_key) {
|
||||
let (metadata, _) = commands::user_commands::parse_frontmatter(content);
|
||||
for (key, value) in metadata {
|
||||
match key.as_str() {
|
||||
"description" => description = value,
|
||||
"argument-hint" => argument_hint = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(hint) = argument_hint {
|
||||
if !hint.trim().is_empty() {
|
||||
description.push_str(" ");
|
||||
description.push_str(hint.trim());
|
||||
}
|
||||
}
|
||||
(description, None)
|
||||
};
|
||||
entries.push(SlashMenuEntry {
|
||||
name: name.to_string(),
|
||||
@@ -2501,7 +2571,8 @@ mod tests {
|
||||
SlashMenuEntry, apply_selection_to_line, build_empty_state_lines, composer_height,
|
||||
composer_max_height, composer_min_input_rows, composer_top_padding, compute_takeover_area,
|
||||
cursor_row_col, layout_input, pad_lines_to_bottom, placeholder_visual_lines,
|
||||
should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text,
|
||||
push_command_entry, should_render_empty_state, slash_completion_hints, wrap_input_lines,
|
||||
wrap_text,
|
||||
};
|
||||
use crate::config::{ApiProvider, Config};
|
||||
use crate::localization::Locale;
|
||||
@@ -2761,6 +2832,80 @@ mod tests {
|
||||
assert!(!hints.iter().any(|hint| hint.name == "/codewhale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_completion_hints_use_user_command_frontmatter_description() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".deepseek").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("git-scan.md"),
|
||||
"---\ndescription: Scan nested git repositories\n---\nscan",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let hints = slash_completion_hints(
|
||||
"/git",
|
||||
128,
|
||||
&[],
|
||||
Locale::En,
|
||||
Some(tmp.path()),
|
||||
ApiProvider::Deepseek,
|
||||
);
|
||||
let entry = hints
|
||||
.iter()
|
||||
.find(|hint| hint.name == "/git-scan")
|
||||
.expect("custom command should be present");
|
||||
assert_eq!(entry.description, "Scan nested git repositories");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_completion_hints_use_user_command_argument_hint() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".deepseek").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("deploy.md"),
|
||||
"---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let hints = slash_completion_hints(
|
||||
"/deploy",
|
||||
128,
|
||||
&[],
|
||||
Locale::En,
|
||||
Some(tmp.path()),
|
||||
ApiProvider::Deepseek,
|
||||
);
|
||||
let entry = hints
|
||||
.iter()
|
||||
.find(|hint| hint.name == "/deploy")
|
||||
.expect("custom command should be present");
|
||||
assert_eq!(entry.description, "Deploy target <env>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_push_command_entry_uses_preloaded_user_command_frontmatter() {
|
||||
let user_commands = vec![(
|
||||
"deploy".to_string(),
|
||||
"---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy".to_string(),
|
||||
)];
|
||||
let mut entries = Vec::new();
|
||||
|
||||
push_command_entry(
|
||||
&mut entries,
|
||||
"/deploy",
|
||||
"deploy",
|
||||
"deploy",
|
||||
Locale::En,
|
||||
&user_commands,
|
||||
);
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].name, "/deploy");
|
||||
assert_eq!(entries[0].description, "Deploy target <env>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_completion_hints_hide_skills_from_top_level_menu() {
|
||||
let cached_skills = vec![
|
||||
|
||||
Reference in New Issue
Block a user