| 1 | //! Numbered, confirmation-gated editor for the active `permissions.toml`. |
| 2 | |
| 3 | use codewhale_config::{PermissionsFileState, PermissionsSnapshot, ToolAskRule}; |
| 4 | use codewhale_execpolicy::PermissionAction; |
| 5 | |
| 6 | use crate::commands::CommandResult; |
| 7 | use crate::localization::{MessageId, tr}; |
| 8 | use crate::tui::app::{App, AppAction}; |
| 9 | |
| 10 | pub(super) fn permissions_command(app: &App, arg: Option<&str>) -> CommandResult { |
| 11 | let raw = arg.map(str::trim).unwrap_or(""); |
| 12 | if raw.is_empty() || raw.eq_ignore_ascii_case("list") || raw.eq_ignore_ascii_case("status") { |
| 13 | return list_permissions(app); |
| 14 | } |
| 15 | |
| 16 | let parts = raw.split_whitespace().collect::<Vec<_>>(); |
| 17 | if parts |
| 18 | .first() |
| 19 | .is_some_and(|part| part.eq_ignore_ascii_case("remove")) |
| 20 | { |
| 21 | return remove_permission(app, &parts); |
| 22 | } |
| 23 | usage_error(app) |
| 24 | } |
| 25 | |
| 26 | fn list_permissions(app: &App) -> CommandResult { |
| 27 | let snapshot = match load_snapshot(app) { |
| 28 | Ok(snapshot) => snapshot, |
| 29 | Err(error) => return operation_error(app, &error), |
| 30 | }; |
| 31 | CommandResult::message(format_snapshot(app, &snapshot)) |
| 32 | } |
| 33 | |
| 34 | fn remove_permission(app: &App, parts: &[&str]) -> CommandResult { |
| 35 | if !matches!(parts.len(), 2 | 4) { |
| 36 | return usage_error(app); |
| 37 | } |
| 38 | let Ok(display_index) = parts[1].parse::<usize>() else { |
| 39 | return usage_error(app); |
| 40 | }; |
| 41 | let Some(index) = display_index.checked_sub(1) else { |
| 42 | return rule_not_found(app, display_index); |
| 43 | }; |
| 44 | |
| 45 | if parts.len() == 2 { |
| 46 | let snapshot = match load_snapshot(app) { |
| 47 | Ok(snapshot) => snapshot, |
| 48 | Err(error) => return operation_error(app, &error), |
| 49 | }; |
| 50 | let Some(rule) = snapshot.rules().get(index) else { |
| 51 | return rule_not_found(app, display_index); |
| 52 | }; |
| 53 | let token = snapshot |
| 54 | .removal_token(index) |
| 55 | .expect("snapshots carry one removal token per rule"); |
| 56 | let command = format!("/permissions remove {display_index} --confirm {token}"); |
| 57 | let rule = format_rule(app, display_index, rule); |
| 58 | let message = tr(app.ui_locale, MessageId::PermissionsRemovePreview) |
| 59 | .replace("{index}", &display_index.to_string()) |
| 60 | .replace("{rule}", &rule) |
| 61 | .replace("{command}", &command); |
| 62 | return CommandResult::message(message); |
| 63 | } |
| 64 | |
| 65 | if !parts[2].eq_ignore_ascii_case("--confirm") || parts[3].is_empty() { |
| 66 | return usage_error(app); |
| 67 | } |
| 68 | let removed = |
| 69 | match codewhale_config::remove_permission_rule(app.config_path.clone(), index, parts[3]) { |
| 70 | Ok(rule) => rule, |
| 71 | Err(error) => return operation_error(app, &error), |
| 72 | }; |
| 73 | let message = tr(app.ui_locale, MessageId::PermissionsRemoved) |
| 74 | .replace("{index}", &display_index.to_string()) |
| 75 | .replace("{action}", action_name(removed.action)) |
| 76 | .replace("{tool}", &escape_field(&removed.tool)); |
| 77 | CommandResult::with_message_and_action(message, AppAction::PermissionRulesChanged) |
| 78 | } |
| 79 | |
| 80 | fn load_snapshot(app: &App) -> anyhow::Result<PermissionsSnapshot> { |
| 81 | codewhale_config::load_permissions_snapshot(app.config_path.clone()) |
| 82 | } |
| 83 | |
| 84 | fn format_snapshot(app: &App, snapshot: &PermissionsSnapshot) -> String { |
| 85 | let file_state = match snapshot.file_state() { |
| 86 | PermissionsFileState::Missing => MessageId::PermissionsFileMissing, |
| 87 | PermissionsFileState::Empty => MessageId::PermissionsFileEmpty, |
| 88 | PermissionsFileState::Present => MessageId::PermissionsFilePresent, |
| 89 | }; |
| 90 | let path = codewhale_config::quote_os_path(snapshot.path()); |
| 91 | let mut output = tr(app.ui_locale, MessageId::PermissionsListHeader) |
| 92 | .replace("{count}", &snapshot.rules().len().to_string()) |
| 93 | .replace("{file_state}", &tr(app.ui_locale, file_state)) |
| 94 | .replace("{path}", &path); |
| 95 | if snapshot.rules().is_empty() { |
| 96 | output.push('\n'); |
| 97 | output.push_str(&tr(app.ui_locale, MessageId::PermissionsNoRules)); |
| 98 | return output; |
| 99 | } |
| 100 | |
| 101 | for (index, rule) in snapshot.rules().iter().enumerate() { |
| 102 | output.push_str("\n\n"); |
| 103 | output.push_str(&format_rule(app, index + 1, rule)); |
| 104 | } |
| 105 | output |
| 106 | } |
| 107 | |
| 108 | fn format_rule(app: &App, display_index: usize, rule: &ToolAskRule) -> String { |
| 109 | let scope = rule.workspace.as_deref().map_or_else( |
| 110 | || tr(app.ui_locale, MessageId::PermissionsScopeGlobal).into_owned(), |
| 111 | |workspace| { |
| 112 | tr(app.ui_locale, MessageId::PermissionsScopeRepo) |
| 113 | .replace("{workspace}", &escape_field(workspace)) |
| 114 | }, |
| 115 | ); |
| 116 | let applicability = if rule_applies_in_workspace(rule, &app.workspace) { |
| 117 | tr(app.ui_locale, MessageId::PermissionsAppliesHere) |
| 118 | } else { |
| 119 | tr(app.ui_locale, MessageId::PermissionsInactiveHere) |
| 120 | }; |
| 121 | tr(app.ui_locale, MessageId::PermissionsRuleEntry) |
| 122 | .replace("{index}", &display_index.to_string()) |
| 123 | .replace("{action}", action_name(rule.action)) |
| 124 | .replace("{tool}", &escape_field(&rule.tool)) |
| 125 | .replace("{matcher}", &format_matcher(app, rule)) |
| 126 | .replace("{scope}", &scope) |
| 127 | .replace("{applicability}", &applicability) |
| 128 | } |
| 129 | |
| 130 | fn format_matcher(app: &App, rule: &ToolAskRule) -> String { |
| 131 | let mut matchers = Vec::new(); |
| 132 | if let Some(command) = rule.command.as_deref() { |
| 133 | let message_id = if rule.command_exact { |
| 134 | MessageId::PermissionsMatchExactCommand |
| 135 | } else { |
| 136 | MessageId::PermissionsMatchCommandPrefix |
| 137 | }; |
| 138 | matchers.push(tr(app.ui_locale, message_id).replace("{command}", &escape_field(command))); |
| 139 | } |
| 140 | if let Some(path) = rule.path.as_deref() { |
| 141 | matchers.push( |
| 142 | tr(app.ui_locale, MessageId::PermissionsMatchExactPath) |
| 143 | .replace("{path}", &escape_field(path)), |
| 144 | ); |
| 145 | } |
| 146 | if matchers.is_empty() { |
| 147 | tr(app.ui_locale, MessageId::PermissionsMatchAnyInvocation).into_owned() |
| 148 | } else { |
| 149 | matchers.join(" + ") |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | fn rule_applies_in_workspace(rule: &ToolAskRule, workspace: &std::path::Path) -> bool { |
| 154 | let Some(rule_workspace) = rule.workspace.as_deref() else { |
| 155 | return true; |
| 156 | }; |
| 157 | let workspace = workspace.to_string_lossy(); |
| 158 | let Some(rule_workspace) = codewhale_execpolicy::normalize_workspace_scope(rule_workspace) |
| 159 | else { |
| 160 | return false; |
| 161 | }; |
| 162 | let Some(workspace) = codewhale_execpolicy::normalize_workspace_scope(&workspace) else { |
| 163 | return false; |
| 164 | }; |
| 165 | rule_workspace == workspace |
| 166 | } |
| 167 | |
| 168 | fn action_name(action: PermissionAction) -> &'static str { |
| 169 | match action { |
| 170 | PermissionAction::Allow => "allow", |
| 171 | PermissionAction::Ask => "ask", |
| 172 | PermissionAction::Deny => "deny", |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | fn escape_field(value: &str) -> String { |
| 177 | let mut escaped = String::with_capacity(value.len()); |
| 178 | for character in value.chars() { |
| 179 | match character { |
| 180 | '\n' => escaped.push_str("\\n"), |
| 181 | '\r' => escaped.push_str("\\r"), |
| 182 | '\t' => escaped.push_str("\\t"), |
| 183 | character if character.is_control() || is_bidi_format_control(character) => { |
| 184 | escaped.extend(character.escape_unicode()); |
| 185 | } |
| 186 | character => escaped.push(character), |
| 187 | } |
| 188 | } |
| 189 | escaped |
| 190 | } |
| 191 | |
| 192 | fn is_bidi_format_control(character: char) -> bool { |
| 193 | matches!( |
| 194 | character, |
| 195 | '\u{061c}' |
| 196 | | '\u{200e}' |
| 197 | | '\u{200f}' |
| 198 | | '\u{2028}' |
| 199 | | '\u{2029}' |
| 200 | | '\u{202a}'..='\u{202e}' |
| 201 | | '\u{2066}'..='\u{2069}' |
| 202 | ) |
| 203 | } |
| 204 | |
| 205 | fn usage_error(app: &App) -> CommandResult { |
| 206 | CommandResult::error(tr(app.ui_locale, MessageId::PermissionsUsage)) |
| 207 | } |
| 208 | |
| 209 | fn rule_not_found(app: &App, display_index: usize) -> CommandResult { |
| 210 | CommandResult::error( |
| 211 | tr(app.ui_locale, MessageId::PermissionsRuleNotFound) |
| 212 | .replace("{index}", &display_index.to_string()), |
| 213 | ) |
| 214 | } |
| 215 | |
| 216 | fn operation_error(app: &App, error: &anyhow::Error) -> CommandResult { |
| 217 | CommandResult::error( |
| 218 | tr(app.ui_locale, MessageId::PermissionsOperationFailed) |
| 219 | .replace("{error}", &format!("{error:#}")), |
| 220 | ) |
| 221 | } |
| 222 | |
| 223 | #[cfg(test)] |
| 224 | mod tests { |
| 225 | use std::fs; |
| 226 | |
| 227 | use crate::localization::Locale; |
| 228 | use crate::tui::app::TuiOptions; |
| 229 | |
| 230 | use super::*; |
| 231 | |
| 232 | fn test_app(config_path: std::path::PathBuf, workspace: std::path::PathBuf) -> App { |
| 233 | let config = crate::config::Config::default(); |
| 234 | let mut app = App::new( |
| 235 | TuiOptions { |
| 236 | workspace, |
| 237 | ..crate::test_support::test_tui_options(std::path::PathBuf::from(".")) |
| 238 | }, |
| 239 | &config, |
| 240 | ); |
| 241 | app.config_path = Some(config_path); |
| 242 | app.ui_locale = Locale::En; |
| 243 | app |
| 244 | } |
| 245 | |
| 246 | #[test] |
| 247 | fn list_shows_source_scope_matcher_and_workspace_applicability() { |
| 248 | let dir = tempfile::tempdir().expect("tempdir"); |
| 249 | let other = tempfile::tempdir().expect("other tempdir"); |
| 250 | let config_path = dir.path().join("config.toml"); |
| 251 | let permissions_path = dir.path().join("permissions.toml"); |
| 252 | fs::write( |
| 253 | &permissions_path, |
| 254 | format!( |
| 255 | r#" |
| 256 | [[rules]] |
| 257 | tool = "exec_shell" |
| 258 | command = "cargo test" |
| 259 | command_exact = true |
| 260 | workspace = {workspace:?} |
| 261 | action = "allow" |
| 262 | |
| 263 | [[rules]] |
| 264 | tool = "edit_file" |
| 265 | path = "src/lib.rs" |
| 266 | workspace = {other:?} |
| 267 | "#, |
| 268 | workspace = dir.path().to_string_lossy(), |
| 269 | other = other.path().to_string_lossy(), |
| 270 | ), |
| 271 | ) |
| 272 | .expect("write permissions"); |
| 273 | let displayed_permissions_path = |
| 274 | codewhale_config::resolve_permissions_path(Some(config_path.clone())) |
| 275 | .expect("resolve permissions path"); |
| 276 | let app = test_app(config_path, dir.path().to_path_buf()); |
| 277 | |
| 278 | let result = permissions_command(&app, Some("list")); |
| 279 | let message = result.message.expect("list message"); |
| 280 | |
| 281 | assert!(!result.is_error); |
| 282 | assert!(message.contains(&codewhale_config::quote_os_path( |
| 283 | &displayed_permissions_path |
| 284 | ))); |
| 285 | assert!(message.contains("#1 | allow | exec_shell")); |
| 286 | assert!(message.contains("exact command `cargo test`")); |
| 287 | assert!(message.contains("active in this workspace")); |
| 288 | assert!(message.contains("#2 | ask | edit_file")); |
| 289 | assert!(message.contains("exact normalized path `src/lib.rs`")); |
| 290 | assert!(message.contains("not active in this workspace")); |
| 291 | } |
| 292 | |
| 293 | #[test] |
| 294 | fn list_preserves_missing_empty_and_malformed_diagnostics() { |
| 295 | let dir = tempfile::tempdir().expect("tempdir"); |
| 296 | let config_path = dir.path().join("config.toml"); |
| 297 | let permissions_path = dir.path().join("permissions.toml"); |
| 298 | let displayed_permissions_path = |
| 299 | codewhale_config::resolve_permissions_path(Some(config_path.clone())) |
| 300 | .expect("resolve permissions path"); |
| 301 | let app = test_app(config_path, dir.path().to_path_buf()); |
| 302 | |
| 303 | let missing = permissions_command(&app, None); |
| 304 | let missing_message = missing.message.expect("missing message"); |
| 305 | assert!(!missing.is_error); |
| 306 | assert!(missing_message.contains("File status: missing")); |
| 307 | assert!(missing_message.contains("Rule count: 0")); |
| 308 | |
| 309 | fs::write(&permissions_path, "").expect("write empty permissions"); |
| 310 | let empty = permissions_command(&app, Some("status")); |
| 311 | let empty_message = empty.message.expect("empty message"); |
| 312 | assert!(!empty.is_error); |
| 313 | assert!(empty_message.contains("File status: empty")); |
| 314 | |
| 315 | fs::write( |
| 316 | &permissions_path, |
| 317 | "[[rules]]\ntool = \"do-not-echo-this\"\ncommand = ", |
| 318 | ) |
| 319 | .expect("write malformed permissions"); |
| 320 | let malformed = permissions_command(&app, Some("list")); |
| 321 | let malformed_message = malformed.message.expect("malformed message"); |
| 322 | assert!(malformed.is_error); |
| 323 | assert!(malformed_message.contains("Permission rule operation failed")); |
| 324 | assert!(malformed_message.contains(&codewhale_config::quote_os_path( |
| 325 | &displayed_permissions_path |
| 326 | ))); |
| 327 | assert!(malformed_message.contains("file contents were omitted")); |
| 328 | assert!(!malformed_message.contains("do-not-echo-this")); |
| 329 | } |
| 330 | |
| 331 | #[test] |
| 332 | fn remove_requires_preview_token_then_emits_live_reload_action() { |
| 333 | let dir = tempfile::tempdir().expect("tempdir"); |
| 334 | let config_path = dir.path().join("config.toml"); |
| 335 | let permissions_path = dir.path().join("permissions.toml"); |
| 336 | let original = "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo test\"\n"; |
| 337 | fs::write(&permissions_path, original).expect("write permissions"); |
| 338 | let app = test_app(config_path, dir.path().to_path_buf()); |
| 339 | |
| 340 | let preview = permissions_command(&app, Some("remove 1")); |
| 341 | let preview_message = preview.message.expect("preview message"); |
| 342 | assert!(!preview.is_error); |
| 343 | assert_eq!( |
| 344 | fs::read_to_string(&permissions_path).expect("read previewed permissions"), |
| 345 | original |
| 346 | ); |
| 347 | let confirm_command = preview_message |
| 348 | .split('`') |
| 349 | .find(|part| part.starts_with("/permissions remove 1 --confirm ")) |
| 350 | .expect("confirmation command"); |
| 351 | let confirm_arg = confirm_command |
| 352 | .strip_prefix("/permissions ") |
| 353 | .expect("command prefix"); |
| 354 | |
| 355 | let confirmed = permissions_command(&app, Some(confirm_arg)); |
| 356 | |
| 357 | assert!(!confirmed.is_error); |
| 358 | assert_eq!(confirmed.action, Some(AppAction::PermissionRulesChanged)); |
| 359 | let persisted = fs::read_to_string(&permissions_path).expect("read edited permissions"); |
| 360 | let parsed: codewhale_config::PermissionsToml = |
| 361 | toml::from_str(&persisted).expect("parse edited permissions"); |
| 362 | assert!(parsed.rules.is_empty()); |
| 363 | } |
| 364 | |
| 365 | #[test] |
| 366 | fn legacy_config_ask_rules_entry_uses_the_permissions_editor_list() { |
| 367 | let dir = tempfile::tempdir().expect("tempdir"); |
| 368 | let config_path = dir.path().join("config.toml"); |
| 369 | fs::write( |
| 370 | dir.path().join("permissions.toml"), |
| 371 | "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo test\"\n", |
| 372 | ) |
| 373 | .expect("write permissions"); |
| 374 | let mut app = test_app(config_path, dir.path().to_path_buf()); |
| 375 | |
| 376 | let result = super::super::config::config_command(&mut app, Some("ask-rules list")); |
| 377 | let message = result.message.expect("compatibility list message"); |
| 378 | |
| 379 | assert!(!result.is_error); |
| 380 | assert!(message.contains("Permission rules")); |
| 381 | assert!(message.contains("#1 | ask | exec_shell")); |
| 382 | } |
| 383 | |
| 384 | #[test] |
| 385 | fn permissions_command_is_registered_with_compatibility_aliases() { |
| 386 | let info = crate::commands::get_command_info("permissions").expect("permissions command"); |
| 387 | |
| 388 | assert_eq!(info.name, "permissions"); |
| 389 | assert!(info.aliases.contains(&"permission-rules")); |
| 390 | assert!(info.usage.contains("remove <rule-number>")); |
| 391 | } |
| 392 | |
| 393 | #[test] |
| 394 | fn invalid_workspace_scopes_never_appear_active() { |
| 395 | let mut rule = ToolAskRule::exec_shell("cargo test"); |
| 396 | rule.workspace = Some("../not-an-absolute-scope".to_string()); |
| 397 | |
| 398 | assert!(!rule_applies_in_workspace( |
| 399 | &rule, |
| 400 | std::path::Path::new("also-relative") |
| 401 | )); |
| 402 | } |
| 403 | |
| 404 | #[test] |
| 405 | fn displayed_rule_fields_escape_terminal_and_bidi_controls() { |
| 406 | assert_eq!( |
| 407 | escape_field("cargo\u{1b}\n\u{202e}test"), |
| 408 | "cargo\\u{1b}\\n\\u{202e}test" |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | #[test] |
| 413 | fn permission_messages_keep_placeholder_parity_across_complete_locales() { |
| 414 | let ids = [ |
| 415 | MessageId::PermissionsListHeader, |
| 416 | MessageId::PermissionsRuleEntry, |
| 417 | MessageId::PermissionsMatchExactCommand, |
| 418 | MessageId::PermissionsMatchCommandPrefix, |
| 419 | MessageId::PermissionsMatchExactPath, |
| 420 | MessageId::PermissionsScopeRepo, |
| 421 | MessageId::PermissionsRemovePreview, |
| 422 | MessageId::PermissionsRemoved, |
| 423 | MessageId::PermissionsRuleNotFound, |
| 424 | MessageId::PermissionsOperationFailed, |
| 425 | ]; |
| 426 | for id in ids { |
| 427 | let english = placeholders(&tr(Locale::En, id)); |
| 428 | for locale in Locale::shipped_complete() { |
| 429 | assert_eq!( |
| 430 | placeholders(&tr(*locale, id)), |
| 431 | english, |
| 432 | "{} {id:?} placeholder drift", |
| 433 | locale.tag() |
| 434 | ); |
| 435 | } |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | fn placeholders(message: &str) -> std::collections::BTreeSet<String> { |
| 440 | message |
| 441 | .split('{') |
| 442 | .skip(1) |
| 443 | .filter_map(|suffix| suffix.split_once('}').map(|(name, _)| name.to_string())) |
| 444 | .collect() |
| 445 | } |
| 446 | } |
| 447 |