返回 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::{ApprovalMode, PermissionAction};
5
6 use crate::commands::CommandResult;
7 use crate::tui::app::{App, AppAction};
8 use codewhale_localization::{MessageId, tr};
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 } else {
99 for (index, rule) in snapshot.rules().iter().enumerate() {
100 output.push_str("\n\n");
101 output.push_str(&format_rule(app, index + 1, rule));
102 }
103 }
104 output.push_str("\n\n");
105 output.push_str(&format_posture_explainer(app));
106 output
107 }
108
109 /// What the active permission posture decides on its own and what it never
110 /// decides, so a person can predict Auto-Review without reading the policy
111 /// engine. Rules above are the durable allow/ask/deny surface; the posture is
112 /// the session-only layer that decides everything the rules did not.
113 fn format_posture_explainer(app: &App) -> String {
114 let posture = app.approval_mode;
115 let mut text = tr(app.ui_locale, MessageId::PermissionsPostureHeader)
116 .replace("{posture}", posture.permission_chip_label());
117 text.push('\n');
118 text.push_str(&tr(
119 app.ui_locale,
120 match posture {
121 ApprovalMode::Suggest => MessageId::PermissionsPostureAsk,
122 ApprovalMode::Auto => MessageId::PermissionsPostureAuto,
123 ApprovalMode::Bypass => MessageId::PermissionsPostureBypass,
124 ApprovalMode::Never => MessageId::PermissionsPostureNever,
125 },
126 ));
127 text.push('\n');
128 let audit_path = crate::audit::audit_log_path()
129 .map(|path| codewhale_config::quote_os_path(&path))
130 .unwrap_or_else(|| "$CODEWHALE_HOME/audit.log".to_string());
131 text.push_str(
132 &tr(app.ui_locale, MessageId::PermissionsReceiptsNote).replace("{audit_path}", &audit_path),
133 );
134 text
135 }
136
137 fn format_rule(app: &App, display_index: usize, rule: &ToolAskRule) -> String {
138 let scope = rule.workspace.as_deref().map_or_else(
139 || tr(app.ui_locale, MessageId::PermissionsScopeGlobal).into_owned(),
140 |workspace| {
141 tr(app.ui_locale, MessageId::PermissionsScopeRepo)
142 .replace("{workspace}", &escape_field(workspace))
143 },
144 );
145 let applicability = if rule_applies_in_workspace(rule, &app.workspace) {
146 tr(app.ui_locale, MessageId::PermissionsAppliesHere)
147 } else {
148 tr(app.ui_locale, MessageId::PermissionsInactiveHere)
149 };
150 tr(app.ui_locale, MessageId::PermissionsRuleEntry)
151 .replace("{index}", &display_index.to_string())
152 .replace("{action}", action_name(rule.action))
153 .replace("{tool}", &escape_field(&rule.tool))
154 .replace("{matcher}", &format_matcher(app, rule))
155 .replace("{scope}", &scope)
156 .replace("{applicability}", &applicability)
157 }
158
159 fn format_matcher(app: &App, rule: &ToolAskRule) -> String {
160 let mut matchers = Vec::new();
161 if let Some(command) = rule.command.as_deref() {
162 let message_id = if rule.command_exact {
163 MessageId::PermissionsMatchExactCommand
164 } else {
165 MessageId::PermissionsMatchCommandPrefix
166 };
167 matchers.push(tr(app.ui_locale, message_id).replace("{command}", &escape_field(command)));
168 }
169 if let Some(path) = rule.path.as_deref() {
170 matchers.push(
171 tr(app.ui_locale, MessageId::PermissionsMatchExactPath)
172 .replace("{path}", &escape_field(path)),
173 );
174 }
175 if matchers.is_empty() {
176 tr(app.ui_locale, MessageId::PermissionsMatchAnyInvocation).into_owned()
177 } else {
178 matchers.join(" + ")
179 }
180 }
181
182 fn rule_applies_in_workspace(rule: &ToolAskRule, workspace: &std::path::Path) -> bool {
183 let Some(rule_workspace) = rule.workspace.as_deref() else {
184 return true;
185 };
186 let workspace = workspace.to_string_lossy();
187 let Some(rule_workspace) = codewhale_execpolicy::normalize_workspace_scope(rule_workspace)
188 else {
189 return false;
190 };
191 let Some(workspace) = codewhale_execpolicy::normalize_workspace_scope(&workspace) else {
192 return false;
193 };
194 rule_workspace == workspace
195 }
196
197 fn action_name(action: PermissionAction) -> &'static str {
198 match action {
199 PermissionAction::Allow => "allow",
200 PermissionAction::Ask => "ask",
201 PermissionAction::Deny => "deny",
202 }
203 }
204
205 fn escape_field(value: &str) -> String {
206 let mut escaped = String::with_capacity(value.len());
207 for character in value.chars() {
208 match character {
209 '\n' => escaped.push_str("\\n"),
210 '\r' => escaped.push_str("\\r"),
211 '\t' => escaped.push_str("\\t"),
212 character if character.is_control() || is_bidi_format_control(character) => {
213 escaped.extend(character.escape_unicode());
214 }
215 character => escaped.push(character),
216 }
217 }
218 escaped
219 }
220
221 fn is_bidi_format_control(character: char) -> bool {
222 matches!(
223 character,
224 '\u{061c}'
225 | '\u{200e}'
226 | '\u{200f}'
227 | '\u{2028}'
228 | '\u{2029}'
229 | '\u{202a}'..='\u{202e}'
230 | '\u{2066}'..='\u{2069}'
231 )
232 }
233
234 fn usage_error(app: &App) -> CommandResult {
235 CommandResult::error(tr(app.ui_locale, MessageId::PermissionsUsage))
236 }
237
238 fn rule_not_found(app: &App, display_index: usize) -> CommandResult {
239 CommandResult::error(
240 tr(app.ui_locale, MessageId::PermissionsRuleNotFound)
241 .replace("{index}", &display_index.to_string()),
242 )
243 }
244
245 fn operation_error(app: &App, error: &anyhow::Error) -> CommandResult {
246 CommandResult::error(
247 tr(app.ui_locale, MessageId::PermissionsOperationFailed)
248 .replace("{error}", &format!("{error:#}")),
249 )
250 }
251
252 #[cfg(test)]
253 mod tests {
254 use std::fs;
255
256 use crate::tui::app::TuiOptions;
257 use codewhale_localization::Locale;
258
259 use super::*;
260
261 fn test_app(config_path: std::path::PathBuf, workspace: std::path::PathBuf) -> App {
262 let config = crate::config::Config::default();
263 let mut app = App::new(
264 TuiOptions {
265 workspace,
266 ..crate::test_support::test_tui_options(std::path::PathBuf::from("."))
267 },
268 &config,
269 );
270 app.config_path = Some(config_path);
271 app.ui_locale = Locale::En;
272 app
273 }
274
275 #[test]
276 fn list_shows_source_scope_matcher_and_workspace_applicability() {
277 let dir = tempfile::tempdir().expect("tempdir");
278 let other = tempfile::tempdir().expect("other tempdir");
279 let config_path = dir.path().join("config.toml");
280 let permissions_path = dir.path().join("permissions.toml");
281 fs::write(
282 &permissions_path,
283 format!(
284 r#"
285 [[rules]]
286 tool = "exec_shell"
287 command = "cargo test"
288 command_exact = true
289 workspace = {workspace:?}
290 action = "allow"
291
292 [[rules]]
293 tool = "edit_file"
294 path = "src/lib.rs"
295 workspace = {other:?}
296 "#,
297 workspace = dir.path().to_string_lossy(),
298 other = other.path().to_string_lossy(),
299 ),
300 )
301 .expect("write permissions");
302 let displayed_permissions_path =
303 codewhale_config::resolve_permissions_path(Some(config_path.clone()))
304 .expect("resolve permissions path");
305 let app = test_app(config_path, dir.path().to_path_buf());
306
307 let result = permissions_command(&app, Some("list"));
308 let message = result.message.expect("list message");
309
310 assert!(!result.is_error);
311 assert!(message.contains(&codewhale_config::quote_os_path(
312 &displayed_permissions_path
313 )));
314 assert!(message.contains("#1 | allow | exec_shell"));
315 assert!(message.contains("exact command `cargo test`"));
316 assert!(message.contains("active in this workspace"));
317 assert!(message.contains("#2 | ask | edit_file"));
318 assert!(message.contains("exact normalized path `src/lib.rs`"));
319 assert!(message.contains("not active in this workspace"));
320 }
321
322 #[test]
323 fn list_preserves_missing_empty_and_malformed_diagnostics() {
324 let dir = tempfile::tempdir().expect("tempdir");
325 let config_path = dir.path().join("config.toml");
326 let permissions_path = dir.path().join("permissions.toml");
327 let displayed_permissions_path =
328 codewhale_config::resolve_permissions_path(Some(config_path.clone()))
329 .expect("resolve permissions path");
330 let app = test_app(config_path, dir.path().to_path_buf());
331
332 let missing = permissions_command(&app, None);
333 let missing_message = missing.message.expect("missing message");
334 assert!(!missing.is_error);
335 assert!(missing_message.contains("File status: missing"));
336 assert!(missing_message.contains("Rule count: 0"));
337
338 fs::write(&permissions_path, "").expect("write empty permissions");
339 let empty = permissions_command(&app, Some("status"));
340 let empty_message = empty.message.expect("empty message");
341 assert!(!empty.is_error);
342 assert!(empty_message.contains("File status: empty"));
343
344 fs::write(
345 &permissions_path,
346 "[[rules]]\ntool = \"do-not-echo-this\"\ncommand = ",
347 )
348 .expect("write malformed permissions");
349 let malformed = permissions_command(&app, Some("list"));
350 let malformed_message = malformed.message.expect("malformed message");
351 assert!(malformed.is_error);
352 assert!(malformed_message.contains("Could not read or change permission rules"));
353 assert!(malformed_message.contains(&codewhale_config::quote_os_path(
354 &displayed_permissions_path
355 )));
356 assert!(malformed_message.contains("file contents were omitted"));
357 assert!(!malformed_message.contains("do-not-echo-this"));
358 }
359
360 #[test]
361 fn remove_requires_preview_token_then_emits_live_reload_action() {
362 let dir = tempfile::tempdir().expect("tempdir");
363 let config_path = dir.path().join("config.toml");
364 let permissions_path = dir.path().join("permissions.toml");
365 let original = "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo test\"\n";
366 fs::write(&permissions_path, original).expect("write permissions");
367 let app = test_app(config_path, dir.path().to_path_buf());
368
369 let preview = permissions_command(&app, Some("remove 1"));
370 let preview_message = preview.message.expect("preview message");
371 assert!(!preview.is_error);
372 assert_eq!(
373 fs::read_to_string(&permissions_path).expect("read previewed permissions"),
374 original
375 );
376 let confirm_command = preview_message
377 .split('`')
378 .find(|part| part.starts_with("/permissions remove 1 --confirm "))
379 .expect("confirmation command");
380 let confirm_arg = confirm_command
381 .strip_prefix("/permissions ")
382 .expect("command prefix");
383
384 let confirmed = permissions_command(&app, Some(confirm_arg));
385
386 assert!(!confirmed.is_error);
387 assert_eq!(confirmed.action, Some(AppAction::PermissionRulesChanged));
388 let persisted = fs::read_to_string(&permissions_path).expect("read edited permissions");
389 let parsed: codewhale_config::PermissionsToml =
390 toml::from_str(&persisted).expect("parse edited permissions");
391 assert!(parsed.rules.is_empty());
392 }
393
394 #[test]
395 fn legacy_config_ask_rules_entry_uses_the_permissions_editor_list() {
396 let dir = tempfile::tempdir().expect("tempdir");
397 let config_path = dir.path().join("config.toml");
398 fs::write(
399 dir.path().join("permissions.toml"),
400 "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo test\"\n",
401 )
402 .expect("write permissions");
403 let mut app = test_app(config_path, dir.path().to_path_buf());
404
405 let result = super::super::config::config_command(&mut app, Some("ask-rules list"));
406 let message = result.message.expect("compatibility list message");
407
408 assert!(!result.is_error);
409 assert!(message.contains("Permission rules"));
410 assert!(message.contains("#1 | ask | exec_shell"));
411 }
412
413 #[test]
414 fn permissions_command_is_registered_with_compatibility_aliases() {
415 let info = crate::commands::get_command_info("permissions").expect("permissions command");
416
417 assert_eq!(info.name, "permissions");
418 assert!(info.aliases.contains(&"permission-rules"));
419 assert!(info.usage.contains("remove <rule-number>"));
420 }
421
422 #[test]
423 fn invalid_workspace_scopes_never_appear_active() {
424 let mut rule = ToolAskRule::exec_shell("cargo test");
425 rule.workspace = Some("../not-an-absolute-scope".to_string());
426
427 assert!(!rule_applies_in_workspace(
428 &rule,
429 std::path::Path::new("also-relative")
430 ));
431 }
432
433 #[test]
434 fn displayed_rule_fields_escape_terminal_and_bidi_controls() {
435 assert_eq!(
436 escape_field("cargo\u{1b}\n\u{202e}test"),
437 "cargo\\u{1b}\\n\\u{202e}test"
438 );
439 }
440
441 #[test]
442 fn permission_messages_keep_placeholder_parity_across_complete_locales() {
443 let ids = [
444 MessageId::PermissionsListHeader,
445 MessageId::PermissionsRuleEntry,
446 MessageId::PermissionsMatchExactCommand,
447 MessageId::PermissionsMatchCommandPrefix,
448 MessageId::PermissionsMatchExactPath,
449 MessageId::PermissionsScopeRepo,
450 MessageId::PermissionsRemovePreview,
451 MessageId::PermissionsRemoved,
452 MessageId::PermissionsRuleNotFound,
453 MessageId::PermissionsOperationFailed,
454 ];
455 for id in ids {
456 let english = placeholders(&tr(Locale::En, id));
457 for locale in Locale::shipped_complete() {
458 assert_eq!(
459 placeholders(&tr(*locale, id)),
460 english,
461 "{} {id:?} placeholder drift",
462 locale.tag()
463 );
464 }
465 }
466 }
467
468 fn placeholders(message: &str) -> std::collections::BTreeSet<String> {
469 message
470 .split('{')
471 .skip(1)
472 .filter_map(|suffix| suffix.split_once('}').map(|(name, _)| name.to_string()))
473 .collect()
474 }
475 }
476
476 lines RUST