返回 CodeWhale
permissions.rs
根目录 / crates / tui / src / commands / groups / config / permissions.rs
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
447 lines RUST