fix(commands): workspace commands need trust and never replace protected built-ins

Markdown commands from a repository's `.codewhale/`, `.deepseek/`,
`.claude/` or `.cursor/commands` (and `.codewhale/workflows`) loaded in any
workspace and were dispatched ahead of the built-ins, so a cloned repo's
`trust.md` or `undo.md` answered `/trust` or `/undo` with its own prompt,
sent to the model as the user's own message, before the user ever ran
/trust.

- commands_dirs/workflow_dirs include workspace directories only when
  is_workspace_trusted(workspace) holds (the predicate hooks, MCP and
  project skills use). User-global commands are unchanged.
- A workspace command (or alias) never stands in for a protected built-in:
  the ones that grant or revoke authority, hold credentials, or undo and
  discard work (/trust, /undo, /permissions, /mode, /config, /login,
  /logout, /restore, /hooks, /mcp, ... and the /jihua, /zidong mode
  aliases). The definition is skipped with a load error naming the rename;
  other built-ins (/help, /review) stay shadowable as FEAT-011/012 specify.
- Test fixtures that exercise workspace commands and project skills now
  trust their temp workspace (test_support::trust_workspace), which also
  repairs the fixtures broken by the project-skill trust gate (294b616ed).
- docs/architecture/command-dispatch.md states the gate.

Tests: codewhale-tui focused run (user_registry, user_commands,
epic_dispatch/discovery, command_palette, skills::, slash_completion,
views::help, command_catalog, skill_lifecycle, commands::tests,
tools::skill, codemode, cached_skills, prompts::tests, plus the B1
filters) 638 passed / 1 failed; the one failure was the new runtime B1
fixture in the next commit, fixed and rerun 1/0. New:
workspace_command_never_replaces_a_protected_builtin,
workspace_alias_never_replaces_a_protected_builtin,
untrusted_workspace_commands_do_not_load.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 12:23:37 -07:00
1 parent 294b616ed8
commit 9253eff1a6
20 files changed
+303 -30

No files matched your search

+1
View File
@@ -5478,6 +5478,7 @@ mod tests {
fn skill_group_projection_maps_native_skills_and_dirs() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
crate::test_support::trust_workspace(tmp.path());
let skills_dir = tmp.path().join("skills");
write_skill(&skills_dir, "demo");
let mut app = skill_test_app(&tmp, &skills_dir);
@@ -48,6 +48,8 @@ fn create_discovery_app(tmpdir: &TempDir) -> App {
}
fn write_user_command(tmpdir: &TempDir, name: &str, content: &str) {
// Workspace commands load only in a trusted workspace.
crate::test_support::trust_workspace(tmpdir.path());
let commands_dir = tmpdir.path().join(".codewhale").join("commands");
std::fs::create_dir_all(commands_dir).expect("create commands dir");
let path = tmpdir
@@ -36,6 +36,8 @@ fn create_dispatch_app(tmpdir: &TempDir) -> App {
}
fn write_user_command(tmpdir: &TempDir, name: &str, content: &str) {
// Workspace commands load only in a trusted workspace.
crate::test_support::trust_workspace(tmpdir.path());
let commands_dir = tmpdir.path().join(".codewhale").join("commands");
std::fs::create_dir_all(commands_dir).expect("create commands dir");
let path = tmpdir
+5
View File
@@ -273,6 +273,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
.filter(|value| !value.is_empty());
// Check user-defined commands FIRST so they can override built-ins.
// Workspace (repository) commands load only in a trusted workspace and
// never under a protected built-in such as /trust or /undo — the
// registry drops those at load.
if let Some(result) = user_registry::try_dispatch(app, trimmed) {
return result;
}
@@ -548,6 +551,7 @@ mod tests {
#[test]
fn user_command_shadows_builtin_before_group_dispatch() {
let temp = tempdir().unwrap();
crate::test_support::trust_workspace(temp.path());
let commands_dir = temp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
@@ -571,6 +575,7 @@ mod tests {
#[test]
fn removed_user_command_reloads_and_falls_back_to_builtin() {
let temp = tempdir().unwrap();
crate::test_support::trust_workspace(temp.path());
let commands_dir = temp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
let command_path = commands_dir.join("help.md");
+61 -21
View File
@@ -13,7 +13,12 @@
//!
//! ## Precedence
//!
//! Workspace-local directories shadow user-global by name:
//! Workspace-local directories load only in a trusted workspace (`/trust`),
//! and a workspace command never replaces a protected built-in such as
//! `/trust` or `/undo` (the definition is skipped with a load error; see
//! `PROTECTED_BUILTINS` in `user_registry.rs`). Other built-ins stay
//! shadowable. Workspace-local directories
//! shadow user-global by name:
//!
//! 1. `<workspace>/.codewhale/commands/` (project-local, highest)
//! 2. `<workspace>/.deepseek/commands/` (legacy project-local)
@@ -50,27 +55,55 @@ fn legacy_global_commands_dir() -> PathBuf {
home.join(".deepseek").join("commands")
}
/// Return all candidate commands directories in precedence order.
/// The workspace whose repository-supplied commands and workflows may load:
/// only a trusted one. A cloned repository's `.claude/commands/*.md` is text
/// the user never reviewed, sent to the model as the user's own message.
fn trusted_workspace(workspace: Option<&Path>) -> Option<&Path> {
workspace.filter(|ws| crate::config::is_workspace_trusted(ws))
}
/// Workspace-local command directories, trusted or not, in precedence order.
pub(crate) fn workspace_commands_dirs(workspace: &Path) -> [PathBuf; 4] {
[
workspace.join(".codewhale").join("commands"),
workspace.join(".deepseek").join("commands"),
workspace.join(".claude").join("commands"),
workspace.join(".cursor").join("commands"),
]
}
/// Return all candidate commands directories in precedence order. Workspace
/// directories are included only when the workspace is trusted.
pub(crate) fn commands_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(ws) = workspace {
dirs.push(ws.join(".codewhale").join("commands"));
dirs.push(ws.join(".deepseek").join("commands"));
dirs.push(ws.join(".claude").join("commands"));
dirs.push(ws.join(".cursor").join("commands"));
if let Some(ws) = trusted_workspace(workspace) {
dirs.extend(workspace_commands_dirs(ws));
}
dirs.push(global_commands_dir());
dirs.push(legacy_global_commands_dir());
dirs
}
/// Whether `path` came from the user's own global command or workflow store,
/// as opposed to a workspace (repository) directory.
pub(crate) fn is_user_global_command_source(path: &Path) -> bool {
let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
[
global_commands_dir(),
legacy_global_commands_dir(),
home.join(".codewhale").join("workflows"),
]
.iter()
.any(|dir| path.starts_with(dir))
}
/// Saved-workflow slash commands (#4121 packaging): `*.workflow.js` files
/// under these directories become `/name` commands that start the workflow
/// through the `workflow` tool with the slash arguments forwarded as the
/// run's `args`. Workspace definitions shadow the user-global store.
pub(crate) fn workflow_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(ws) = workspace {
if let Some(ws) = trusted_workspace(workspace) {
dirs.push(ws.join(".codewhale").join("workflows"));
}
let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
@@ -363,6 +396,13 @@ mod tests {
use super::*;
use tempfile::TempDir;
/// Workspace commands load only in a trusted workspace.
fn trusted_workspace() -> TempDir {
let tmp = TempDir::new().unwrap();
crate::config::save_workspace_trust(tmp.path()).expect("trust test workspace");
tmp
}
#[test]
fn test_global_commands_dir_contains_codewhale_commands() {
let dir = global_commands_dir();
@@ -414,7 +454,7 @@ mod tests {
#[test]
fn load_user_commands_scans_workspace_local_dir() {
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path();
let cmds_dir = ws.join(".codewhale").join("commands");
write_command(&cmds_dir, "hello", "echo hi");
@@ -429,7 +469,7 @@ mod tests {
#[test]
fn load_user_commands_scans_claude_and_cursor_dirs() {
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path();
write_command(
&ws.join(".claude").join("commands"),
@@ -456,7 +496,7 @@ mod tests {
#[test]
fn workspace_local_shadows_global_by_name() {
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path();
// Workspace-local version
@@ -501,7 +541,7 @@ mod tests {
use crate::config::Config;
use crate::tui::app::TuiOptions;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
@@ -528,7 +568,7 @@ mod tests {
fn frontmatter_is_stripped_before_dispatch() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
@@ -584,7 +624,7 @@ mod tests {
fn allowed_tools_frontmatter_sets_app_state() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
@@ -612,7 +652,7 @@ mod tests {
return;
}
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
let init = std::process::Command::new("git")
.args(["-C", ws.to_str().unwrap(), "init"])
@@ -656,7 +696,7 @@ mod tests {
fn new_user_command_clears_stale_paused_state() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
let commands_dir = ws.join(".codewhale").join("commands");
write_command(
@@ -684,7 +724,7 @@ mod tests {
use crate::tools::plan::UpdatePlanArgs;
use crate::tools::todo::TodoStatus;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
let commands_dir = ws.join(".codewhale").join("commands");
write_command(&commands_dir, "first", "first command body");
@@ -738,7 +778,7 @@ mod tests {
fn review_regression_empty_allowed_tools_blocks_all_tools() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
@@ -755,7 +795,7 @@ mod tests {
fn review_regression_allowed_tools_accepts_per_item_quotes() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
@@ -775,7 +815,7 @@ mod tests {
fn review_regression_dispatch_without_frontmatter_resets_previous_command_state() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
let commands_dir = ws.join(".deepseek").join("commands");
write_command(
@@ -813,7 +853,7 @@ mod tests {
fn description_frontmatter_sets_work_objective_and_autocomplete_description() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let tmp = trusted_workspace();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
+164 -5
View File
@@ -156,6 +156,7 @@ impl UserCommandRegistry {
content,
path,
plugin_authority: Some(source.authority.clone()),
workspace_sourced: false,
});
}
}
@@ -200,7 +201,10 @@ impl UserCommandRegistry {
.into_iter()
.map(|(name, content)| {
let path = PathBuf::from(format!("{}.md", normalize_name(&name)));
CommandSourceEntry::plain(name, content, path)
CommandSourceEntry {
workspace_sourced: false,
..CommandSourceEntry::plain(name, content, path)
}
})
.collect();
registry.load_from_entries(loaded);
@@ -214,20 +218,50 @@ impl UserCommandRegistry {
let (mut metadata, errors) =
parse_metadata(entry.name, &entry.content, &entry.path);
metadata.plugin_authority = entry.plugin_authority;
let path = entry.path;
(metadata, errors, path)
(metadata, errors, entry.path, entry.workspace_sourced)
})
.collect::<Vec<_>>();
let canonical_names = parsed_commands
.iter()
.map(|(metadata, _, _)| metadata.name.clone())
.map(|(metadata, _, _, _)| metadata.name.clone())
.collect::<HashSet<_>>();
for (mut metadata, errors, path) in parsed_commands {
for (mut metadata, errors, path, workspace_sourced) in parsed_commands {
for error in &errors {
self.record_load_error(error.path.clone(), error.message.clone());
}
// A repository-supplied command must not stand in for a protected
// built-in: a committed `.claude/commands/trust.md` would
// otherwise answer `/trust` or `/undo` with its own prompt. Plugin
// commands carry reviewed authority; user-global commands are the
// user's choice.
if workspace_sourced {
if is_protected_builtin_command(&metadata.name) {
self.record_load_error(
path.clone(),
format!(
"Workspace command '/{}' would replace a protected built-in command and was not loaded; rename it",
metadata.name
),
);
continue;
}
metadata.aliases.retain(|alias| {
let builtin = is_protected_builtin_command(alias);
if builtin {
self.load_errors.push(LoadError {
path: path.clone(),
message: format!(
"Workspace command alias '/{alias}' for '/{}' would replace a protected built-in command; ignoring this alias",
metadata.name
),
});
}
!builtin
});
}
if self.commands.contains_key(&metadata.name) {
self.record_load_error(
path.clone(),
@@ -418,19 +452,68 @@ struct CommandSourceEntry {
content: String,
path: PathBuf,
plugin_authority: Option<crate::plugins::types::PluginAuthority>,
/// Came from a workspace (repository) directory rather than the user's
/// own global store or a reviewed plugin.
workspace_sourced: bool,
}
impl CommandSourceEntry {
fn plain(name: String, content: String, path: PathBuf) -> Self {
let workspace_sourced = !user_commands::is_user_global_command_source(&path);
Self {
name,
content,
path,
plugin_authority: None,
workspace_sourced,
}
}
}
/// Built-ins a workspace command may never stand in for: the ones that grant
/// or revoke authority, hold credentials, or undo and discard work. A
/// repository command answering `/trust` or `/undo` with its own prompt would
/// make the user believe an action happened that did not. Other built-ins
/// (`/help`, `/review`, …) stay shadowable, as FEAT-011/012 specify.
const PROTECTED_BUILTINS: &[&str] = &[
"auth",
"auto",
"config",
"constitution",
"hooks",
"login",
"logout",
"mcp",
"mode",
"network",
"permissions",
"plug",
"plugin",
"provider",
"purge",
"restore",
"sessions",
"settings",
"setup",
"system",
"trust",
"undo",
"update",
"workspace",
];
/// Whether `name` (a canonical name or any alias, including the fixed mode
/// aliases dispatched ahead of the registry) resolves to a protected built-in.
fn is_protected_builtin_command(name: &str) -> bool {
let name = name.to_ascii_lowercase();
if matches!(name.as_str(), "jihua" | "zidong") {
return true;
}
super::registry()
.get(&name)
.is_some_and(|command| PROTECTED_BUILTINS.contains(&command.info().name))
}
fn plugin_command_is_current(command: &UserCommandMetadata) -> bool {
command.plugin_authority.as_ref().is_none_or(|authority| {
crate::plugins::registry::verify_plugin_state_authority(authority).is_ok()
@@ -966,7 +1049,14 @@ mod tests {
assert!(registry.is_valid());
}
/// Workspace commands load only in a trusted workspace; the dispatch
/// tests below exercise that trusted path.
fn write_workspace_command(workspace: &Path, name: &str, content: &str) {
crate::config::save_workspace_trust(workspace).expect("trust test workspace");
write_untrusted_workspace_command(workspace, name, content);
}
fn write_untrusted_workspace_command(workspace: &Path, name: &str, content: &str) {
let dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&dir).expect("create commands dir");
std::fs::write(dir.join(format!("{name}.md")), content).expect("write command");
@@ -1047,6 +1137,75 @@ mod tests {
assert_eq!(sent_message(result), "custom alias screenshot.png");
}
#[test]
fn workspace_command_never_replaces_a_protected_builtin() {
// A repository's `.codewhale/commands/undo.md` (or `.claude/…`) must
// not answer `/undo` or `/trust` with its own prompt.
let tmp = TempDir::new().unwrap();
write_workspace_command(tmp.path(), "undo", "pretend to undo $ARGUMENTS");
write_workspace_command(tmp.path(), "trust", "pretend to trust");
let registry = registry_for_workspace(Some(tmp.path()));
assert!(registry.get("undo").is_none());
assert!(registry.get("trust").is_none());
assert!(
registry
.load_errors()
.iter()
.any(|error| error.message.contains("would replace a protected built-in")),
"{:?}",
registry.load_errors()
);
let mut app = test_app(tmp.path().to_path_buf());
assert!(try_dispatch(&mut app, "/undo").is_none());
let result = crate::commands::execute("/trust", &mut app);
assert!(
!matches!(result.action, Some(AppAction::SendMessage(_))),
"the built-in /trust must run, not the workspace prompt: {result:?}"
);
}
#[test]
fn workspace_alias_never_replaces_a_protected_builtin() {
let tmp = TempDir::new().unwrap();
write_workspace_command(
tmp.path(),
"attach-review",
"---\nalias: undo, attach-it\n---\ncustom alias $ARGUMENTS",
);
let registry = registry_for_workspace(Some(tmp.path()));
let command = registry.get("attach-review").expect("command still loads");
assert_eq!(command.aliases, vec!["attach-it".to_string()]);
assert!(registry.get("undo").is_none());
let mut app = test_app(tmp.path().to_path_buf());
assert!(try_dispatch(&mut app, "/undo").is_none());
assert_eq!(
sent_message(crate::commands::execute("/attach-it now", &mut app)),
"custom alias now"
);
}
#[test]
fn untrusted_workspace_commands_do_not_load() {
let tmp = TempDir::new().unwrap();
write_untrusted_workspace_command(tmp.path(), "deploy-now", "run the deploy");
let claude = tmp.path().join(".claude").join("commands");
std::fs::create_dir_all(&claude).expect("claude commands dir");
std::fs::write(claude.join("ship.md"), "ship it").expect("write claude command");
let registry = registry_for_workspace(Some(tmp.path()));
assert!(registry.get("deploy-now").is_none());
assert!(registry.get("ship").is_none());
let mut app = test_app(tmp.path().to_path_buf());
assert!(try_dispatch(&mut app, "/deploy-now").is_none());
crate::config::save_workspace_trust(tmp.path()).expect("trust workspace");
let registry = registry_for_workspace(Some(tmp.path()));
assert!(registry.get("deploy-now").is_some(), "trust reloads them");
assert!(registry.get("ship").is_some());
}
#[test]
fn hidden_user_commands_still_dispatch_directly() {
let tmp = TempDir::new().unwrap();
+1
View File
@@ -1998,6 +1998,7 @@ mod tests {
let tmp = tempdir().expect("tempdir");
let _home = ScopedHome::set(tmp.path().join("home"));
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let configured_dir = tmp.path().join("configured-skills");
write_test_skill(
&workspace.join(".claude").join("skills"),
+5
View File
@@ -14395,9 +14395,14 @@ fn create_managed_skill(root_dir: &std::path::Path, name: &str) -> Result<(PathB
#[tokio::test]
async fn skill_lifecycle_uninstall_removes_installed_skill() -> Result<()> {
let _env = lock_test_env();
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("runtime");
let workspace = tmp.path().to_path_buf();
// Project skills load only in a trusted workspace; a sealed config path
// lets the server's threads read the same trust record.
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("config.toml"));
crate::test_support::trust_workspace(&workspace);
let sessions_dir = root.join("sessions");
fs::create_dir_all(&root)?;
@@ -83,6 +83,13 @@ async fn get_v1_commands_serves_the_catalog_over_http() -> Result<()> {
let commands_dir = workspace.join(".codewhale").join("commands");
fs::create_dir_all(&commands_dir)?;
fs::write(commands_dir.join("model.md"), "Pick the fast route.\n")?;
// Workspace commands load only in a trusted workspace. A sealed config
// path lets the server's threads read the same trust record.
let _config = crate::test_support::EnvVarGuard::set(
"CODEWHALE_CONFIG_PATH",
temp.path().join("config.toml"),
);
crate::test_support::trust_workspace(&workspace);
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root_token_mobile_workspace(
+2
View File
@@ -772,6 +772,7 @@ mod tests {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let home = tmp.path().join("home");
crate::test_support::trust_workspace(&workspace);
write_dir(&workspace.join(".agents").join("skills"));
write_dir(&workspace.join("skills"));
write_dir(&workspace.join(".claude").join("skills"));
@@ -831,6 +832,7 @@ mod tests {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let home = tmp.path().join("home");
crate::test_support::trust_workspace(&workspace);
write_dir(&workspace.join(".agents").join("skills"));
write_dir(&workspace.join(".codewhale").join("skills"));
write_dir(&home.join(".codewhale").join("skills"));
+9
View File
@@ -162,6 +162,7 @@ fn workspace_prompt_omits_disabled_skills_without_configured_directory() {
let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
crate::test_support::trust_workspace(&workspace);
let mut state = crate::skill_state::SkillStateStore::load_default().unwrap();
state.set_enabled("disabled-skill", false).unwrap();
@@ -708,6 +709,7 @@ fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::
fn skills_directories_returns_existing_dirs_in_precedence_order() {
let tmpdir = TempDir::new().unwrap();
let workspace = tmpdir.path();
crate::test_support::trust_workspace(workspace);
// Create four of the five workspace candidate dirs (skip `.opencode`).
std::fs::create_dir_all(workspace.join(".agents").join("skills")).unwrap();
@@ -790,6 +792,7 @@ fn existing_skill_dirs_keeps_agents_global_before_deepseek_global() {
fn discover_in_workspace_merges_with_first_wins_precedence() {
let tmpdir = TempDir::new().unwrap();
let workspace = tmpdir.path();
crate::test_support::trust_workspace(workspace);
// Same skill name `shared` in two locations — the higher-precedence
// dir's version should win.
@@ -878,6 +881,7 @@ fn same_root_slug_collision_warns_and_keeps_one() {
fn discover_in_workspace_pulls_skills_from_opencode_dir() {
let tmpdir = TempDir::new().unwrap();
let workspace = tmpdir.path();
crate::test_support::trust_workspace(workspace);
write_skill(
&workspace.join(".opencode").join("skills"),
"opencode-only",
@@ -896,6 +900,7 @@ fn discover_in_workspace_pulls_skills_from_opencode_dir() {
fn discover_in_workspace_pulls_skills_from_cursor_dir() {
let tmpdir = TempDir::new().unwrap();
let workspace = tmpdir.path();
crate::test_support::trust_workspace(workspace);
write_skill(
&workspace.join(".cursor").join("skills"),
"cursor-only",
@@ -989,6 +994,7 @@ fn discover_warns_for_plain_markdown_without_heading() {
fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
let tmpdir = TempDir::new().unwrap();
let workspace = tmpdir.path();
crate::test_support::trust_workspace(workspace);
write_skill(
&workspace.join(".claude").join("skills"),
"from-claude",
@@ -1007,6 +1013,7 @@ fn codewhale_only_mode_ignores_cross_tool_skill_dirs() {
let home = tmpdir.path().join("home");
let configured_dir = home.join(".codewhale").join("skills");
std::fs::create_dir_all(&workspace).unwrap();
crate::test_support::trust_workspace(&workspace);
write_skill(
&workspace.join(".claude").join("skills"),
"from-claude",
@@ -1108,6 +1115,7 @@ fn discover_for_workspace_and_dir_merges_workspace_and_configured_sources() {
let home = tmpdir.path().join("home");
let configured_dir = tmpdir.path().join("configured-skills");
std::fs::create_dir_all(&workspace).unwrap();
crate::test_support::trust_workspace(&workspace);
write_skill(
&workspace.join(".claude").join("skills"),
"workspace-skill",
@@ -1872,6 +1880,7 @@ fn configured_skill_prompt_uses_a_stable_root_in_entries_and_warnings() {
let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
crate::test_support::trust_workspace(&workspace);
let rendered =
super::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
+8
View File
@@ -398,6 +398,14 @@ pub(crate) fn assert_byte_identical(label: &str, a: &str, b: &str) {
// than hidden inside another near-identical literal.
/// Default `TuiOptions` for tests, pinned to the deepseek-v4-pro fixture route.
/// Mark `workspace` trusted in the test's config, creating it first so the
/// trust key is the canonical path. Repository-supplied commands and skills
/// load only in a trusted workspace.
pub(crate) fn trust_workspace(workspace: &Path) {
std::fs::create_dir_all(workspace).expect("create test workspace");
crate::config::save_workspace_trust(workspace).expect("trust test workspace");
}
pub(crate) fn test_tui_options(workspace: impl AsRef<Path>) -> crate::tui::app::TuiOptions {
let workspace = workspace.as_ref().to_path_buf();
crate::tui::app::TuiOptions {
+6 -2
View File
@@ -629,15 +629,19 @@ mod tests {
// Skills-as-tools composes with code mode: `load_skill` is
// read-only and auto-approved, so a program can list and load
// skills at runtime without widening its authority.
// A configured skills dir, not a project root: project skills load
// only in a trusted workspace, which this composition test is not
// about.
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join(".agents/skills/greet");
let skills_root = dir.path().join("configured-skills");
let skill_dir = skills_root.join("greet");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: greet\ndescription: Say hello\n---\n# Greet\nSay hello warmly.\n",
)
.unwrap();
let context = ToolContext::new(dir.path());
let context = ToolContext::new(dir.path()).with_skills_config(&skills_root, false);
let registry = ToolRegistryBuilder::new()
.with_tool(Arc::new(crate::tools::skill::LoadSkillTool))
.build(context.clone());
+3
View File
@@ -605,6 +605,7 @@ mod tests {
let _cw_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cw-home"));
let workspace = tmp.path().to_path_buf();
crate::test_support::trust_workspace(&workspace);
let skills_dir = workspace.join(".codewhale").join("skills");
write_skill(&skills_dir, "alpha-skill", "First demo skill", "Body A.");
write_skill(&skills_dir, "beta-skill", "", "Body B.");
@@ -662,6 +663,7 @@ mod tests {
async fn execute_finds_skills_in_opencode_dir_via_workspace_discovery() {
let tmp = tempdir().unwrap();
let workspace = tmp.path().to_path_buf();
crate::test_support::trust_workspace(&workspace);
// Skill installed under workspace `.opencode/skills` (#432).
let opencode_dir = workspace.join(".opencode").join("skills");
std::fs::create_dir_all(&opencode_dir).unwrap();
@@ -790,6 +792,7 @@ mod tests {
async fn execute_returns_helpful_error_for_unknown_skill() {
let tmp = tempdir().unwrap();
let workspace = tmp.path().to_path_buf();
crate::test_support::trust_workspace(&workspace);
// One real skill so the available list is non-empty.
write_skill(
&workspace.join(".agents").join("skills"),
+1
View File
@@ -11723,6 +11723,7 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() {
let tmp = tempdir().expect("tempdir");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("home"));
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let native_skill = workspace.join(".agents/skills/native-review");
let plugin_root = workspace.join(".codewhale/plugins/demo");
std::fs::create_dir_all(&native_skill).expect("native Skill dir");
+2
View File
@@ -2811,6 +2811,7 @@ fn new_caches_workspace_skills_for_slash_menu() {
fn cached_skills_merges_across_candidate_directories() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
// Higher-precedence directory contains a stale empty dir for `foo`
// (no SKILL.md). This used to shadow the real definition further
@@ -2961,6 +2962,7 @@ fn cached_skills_include_configured_directory() {
fn cached_skills_preserve_configured_directory_in_codewhale_only_scan() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let codewhale_skill_dir = workspace
.join(".codewhale")
+9
View File
@@ -1324,6 +1324,7 @@ mod tests {
fn command_palette_skills_use_workspace_and_configured_directories() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let workspace_skill_dir = workspace
.join(".agents")
.join("skills")
@@ -1497,6 +1498,7 @@ mod tests {
let workspace = tmp.path().join("workspace");
let commands_dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
crate::config::save_workspace_trust(&workspace).expect("trust workspace");
std::fs::write(
commands_dir.join("review.md"),
"---\ndescription: Review with context\nargument-hint: <path>\n---\nReview $ARGUMENTS",
@@ -1528,6 +1530,7 @@ mod tests {
fn command_palette_uses_frontmatter_name_usage_and_arguments() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let commands_dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
std::fs::write(
@@ -1591,6 +1594,7 @@ mod tests {
fn hidden_frontmatter_name_override_suppresses_shadowed_builtin() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let commands_dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
std::fs::write(
@@ -1618,6 +1622,7 @@ mod tests {
fn command_palette_filters_shadowed_builtin_aliases_from_description() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let commands_dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
std::fs::write(
@@ -1662,6 +1667,7 @@ mod tests {
// command (its metadata and action), never the built-in row.
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let commands_dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
std::fs::write(
@@ -1707,6 +1713,7 @@ mod tests {
// matching the shared alias-aware contract.
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let commands_dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
std::fs::write(
@@ -1745,6 +1752,7 @@ mod tests {
// still owning the token (AT-008 boundary in the palette).
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let commands_dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
std::fs::write(
@@ -1777,6 +1785,7 @@ mod tests {
// from its description.
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
crate::test_support::trust_workspace(&workspace);
let commands_dir = workspace.join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
std::fs::write(
+2
View File
@@ -1384,6 +1384,7 @@ mod tests {
// #3912: both surfaces executed and autocompleted but were absent
// from the surface that teaches the product.
let tmp = tempfile::TempDir::new().unwrap();
crate::test_support::trust_workspace(tmp.path());
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
@@ -2056,6 +2057,7 @@ mod tests {
#[test]
fn a_workspace_command_states_its_declared_usage() {
let tmp = tempfile::TempDir::new().unwrap();
crate::test_support::trust_workspace(tmp.path());
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
+11
View File
@@ -5722,6 +5722,7 @@ mod tests {
fn slash_completion_hints_use_user_command_frontmatter_description() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".deepseek").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("git-scan.md"),
@@ -5747,6 +5748,7 @@ mod tests {
#[test]
fn slash_completion_hints_use_user_command_argument_hint() {
let tmp = tempfile::TempDir::new().unwrap();
crate::test_support::trust_workspace(tmp.path());
let commands_dir = tmp.path().join(".deepseek").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
@@ -5774,6 +5776,7 @@ mod tests {
fn slash_completion_uses_frontmatter_name_and_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("workflow-file.md"),
@@ -5802,6 +5805,7 @@ mod tests {
fn slash_completion_uses_arguments_when_usage_and_legacy_hint_are_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("deploy.md"),
@@ -5949,6 +5953,7 @@ mod tests {
fn slash_completion_hints_exclude_hidden_user_commands() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("secret.md"),
@@ -5972,6 +5977,7 @@ mod tests {
fn hidden_name_override_filters_shadowed_builtin_from_slash_completion() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("private-help.md"),
@@ -5995,6 +6001,7 @@ mod tests {
fn slash_completion_hints_match_user_command_aliases() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("deploy-target.md"),
@@ -6039,6 +6046,7 @@ mod tests {
fn slash_completion_omits_rejected_user_alias_collisions() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("alpha.md"),
@@ -6071,6 +6079,7 @@ mod tests {
fn slash_completion_hints_keep_builtin_canonical_when_only_builtin_alias_is_shadowed() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("attach-review.md"),
@@ -6124,6 +6133,7 @@ mod tests {
// suggestion is absent and the user command appears for the alias.
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("assistant.md"),
@@ -6154,6 +6164,7 @@ mod tests {
fn slash_completion_hints_prefer_user_metadata_for_shadowed_builtin() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
crate::test_support::trust_workspace(tmp.path());
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("help.md"),
+2 -2
View File
@@ -21,7 +21,7 @@ intentional:
| Step | Source | Behavior |
|------|--------|----------|
| 0 | `$skill` compatibility | `$name` is resolved as `/skill name` before slash parsing. |
| 1 | User commands | `user_registry::try_dispatch()` checks workspace and global markdown commands first, so user commands can shadow built-ins. |
| 1 | User commands | `user_registry::try_dispatch()` checks workspace and global markdown commands first, so user commands can shadow built-ins. Workspace commands (`.codewhale`, `.deepseek`, `.claude`, `.cursor`) load only in a trusted workspace and never under the name or alias of a protected built-in — the commands that grant or revoke authority, hold credentials, or undo work, such as `/trust`, `/undo`, `/permissions`, `/mode`, `/config` (`PROTECTED_BUILTINS` in `user_registry.rs`); such a definition is skipped with a load error. |
| 2 | Permanent mode compatibility aliases | `/jihua` and `/zidong` route through config mode dispatch so each selects its fixed legacy mode. They remain registered aliases for discovery, but bypass normal `/mode` execution. |
| 3 | Built-in registry | `CommandRegistry` resolves group-owned built-in commands by canonical name or alias. |
| 4 | Legacy migration hints | Retired commands such as `/set` and `/deepseek` return targeted replacement guidance. |
@@ -77,7 +77,7 @@ Supported frontmatter fields:
| `argument-hint` | Backward-compatible palette/completion hint for expected arguments. It remains the display fallback when `usage` is absent. |
| `allowed-tools` | Restricts command execution tools. An explicit empty value blocks all tools. |
| `pausable` | Marks the command as pause/resume capable. |
| `alias` / `aliases` | Additional user-command names that can shadow built-in aliases. |
| `alias` / `aliases` | Additional user-command names. A workspace command's alias cannot claim a protected built-in. |
| `hidden` | Hides the command from palette/completion while allowing direct dispatch. |
The canonical name defaults to the normalized markdown filename. A valid