diff --git a/Cargo.lock b/Cargo.lock index 1d4a16d..815603b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -43,15 +43,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -168,9 +168,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -210,9 +210,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -220,9 +220,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -232,9 +232,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -244,15 +244,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -554,6 +554,41 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "fuzzy-matcher" version = "0.3.7" @@ -695,9 +730,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", @@ -926,9 +961,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1293,12 +1328,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -1357,6 +1407,32 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1411,6 +1487,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -1697,6 +1779,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "serial_test", "thiserror 2.0.18", "tiny_http", "tokio", @@ -1742,9 +1825,9 @@ dependencies = [ [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index 76ed524..959693f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,6 @@ wait-timeout = "0.2" tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "signal", "macros"] } cron = "0.12" tiny_http = "0.12" + +[dev-dependencies] +serial_test = "3" diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a7a2065..2cdd168 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -400,6 +400,19 @@ pub enum PermissionsSubcommand { #[arg(long)] json: bool, }, + /// Suggest permission rules required for a workflow's command steps + Suggest { + /// Workflow name to inspect + workflow: String, + + /// Append suggested rules to global config + #[arg(long)] + apply: bool, + + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, /// Export runtime scaffolding from policy Export { /// Target runtime settings format diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index a262580..ff7ccf3 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -1,3 +1,4 @@ +use crate::automation::{ExecuteOptions, ExecutionOrigin, ResolvedStep, WorkflowResolver}; use crate::cli::PermissionsSubcommand; use crate::config::{GlobalConfig, PermissionsConfig, TuttiConfig, global_config_path}; use crate::error::{Result, TuttiError}; @@ -11,6 +12,11 @@ use std::path::Path; pub fn run(command: PermissionsSubcommand) -> Result<()> { match command { PermissionsSubcommand::Check { command, json } => run_check(&command, json), + PermissionsSubcommand::Suggest { + workflow, + apply, + json, + } => run_suggest(&workflow, apply, json), PermissionsSubcommand::Export { runtime, output } => { run_export(&runtime, output.as_deref()) } @@ -137,6 +143,186 @@ fn persist_permission_check_decision( ); } +#[derive(Debug, Serialize)] +struct PermissionSuggestion { + command: String, + suggested_rule: String, + reason: Option, +} + +#[derive(Debug, Serialize)] +struct PermissionSuggestReport { + workflow: String, + total_commands: usize, + blocked: Vec, + applied_rules: Vec, +} + +#[allow(clippy::too_many_arguments)] +fn collect_blocked_commands( + resolver: &WorkflowResolver, + workflow: &str, + options: &ExecuteOptions, + active_workflows: &mut std::collections::BTreeSet, + seen_commands: &mut std::collections::BTreeSet, + blocked: &mut Vec, + global: &GlobalConfig, + total_commands: &mut usize, +) -> Result<()> { + // Cycle detection only for the current recursion stack. + if active_workflows.contains(workflow) { + return Err(TuttiError::ConfigValidation(format!( + "cyclic workflow reference detected at '{workflow}'; remove or fix the cycle before running `tt permissions suggest`" + ))); + } + active_workflows.insert(workflow.to_string()); + + let result = (|| -> Result<()> { + let resolved = resolver.resolve(workflow, None, options)?; + for step in resolved.steps { + match step { + ResolvedStep::Command { run, .. } => { + *total_commands += 1; + let cmd = normalize(run); + if cmd.is_empty() { + continue; + } + let decision = evaluate_command_policy(global.permissions.as_ref(), &cmd); + if !decision.allowed && seen_commands.insert(cmd.clone()) { + blocked.push(PermissionSuggestion { + command: cmd.clone(), + suggested_rule: format!("{cmd} *"), + reason: decision.reason, + }); + } + } + ResolvedStep::Workflow { workflow, .. } => { + collect_blocked_commands( + resolver, + &workflow, + options, + active_workflows, + seen_commands, + blocked, + global, + total_commands, + )?; + } + _ => {} + } + } + Ok(()) + })(); + + active_workflows.remove(workflow); + result +} + +fn suggest_workflow_permissions( + workflow: &str, + apply: bool, + config: &TuttiConfig, + project_root: &std::path::Path, + global: &mut GlobalConfig, +) -> Result { + let options = ExecuteOptions { + strict: false, + force_open_commands: false, + command_policy: global.permissions.clone(), + retry_policy: None, + origin: ExecutionOrigin::Run, + hook_event: None, + hook_agent: None, + }; + + let resolver = WorkflowResolver::new(config, project_root); + let mut blocked: Vec = Vec::new(); + let mut seen_commands = std::collections::BTreeSet::new(); + let mut active_workflows = std::collections::BTreeSet::new(); + let mut total_commands = 0usize; + + collect_blocked_commands( + &resolver, + workflow, + &options, + &mut active_workflows, + &mut seen_commands, + &mut blocked, + global, + &mut total_commands, + )?; + + let mut applied_rules = Vec::new(); + if apply && !blocked.is_empty() { + let policy = global + .permissions + .get_or_insert_with(PermissionsConfig::default); + for item in &blocked { + if !policy + .allow + .iter() + .any(|existing| existing == &item.suggested_rule) + { + policy.allow.push(item.suggested_rule.clone()); + applied_rules.push(item.suggested_rule.clone()); + } + } + if !applied_rules.is_empty() { + global.save()?; + } + } + + Ok(PermissionSuggestReport { + workflow: workflow.to_string(), + total_commands, + blocked, + applied_rules, + }) +} + +fn run_suggest(workflow: &str, apply: bool, as_json: bool) -> Result<()> { + let cwd = std::env::current_dir()?; + let (config, config_path) = TuttiConfig::load(&cwd)?; + config.validate()?; + let project_root = config_path.parent().ok_or_else(|| { + TuttiError::ConfigValidation( + "could not determine workspace root; run `tt permissions suggest` from a workspace containing tutti.toml" + .to_string(), + ) + })?; + + let mut global = GlobalConfig::load()?; + let report = suggest_workflow_permissions(workflow, apply, &config, project_root, &mut global)?; + + if as_json { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + if report.blocked.is_empty() { + println!("No blocked workflow commands detected for '{}'.", workflow); + } else { + println!("The following commands should be added to [permissions].allow:"); + for item in &report.blocked { + println!(" {}", item.suggested_rule); + } + } + + if apply { + if report.applied_rules.is_empty() { + println!("No new rules were applied."); + } else { + println!( + "Applied {} rule(s) to {}", + report.applied_rules.len(), + global_config_path().display() + ); + } + } + + Ok(()) +} + fn run_export(runtime: &str, output: Option<&Path>) -> Result<()> { let global = GlobalConfig::load()?; let policy = global.permissions.unwrap_or_default(); @@ -170,6 +356,7 @@ fn render_export(runtime: &str, policy: &PermissionsConfig) -> Result { mod tests { use super::*; use crate::state::load_policy_decisions; + use serial_test::serial; #[test] fn matching_rule_supports_exact_and_prefix() { @@ -258,4 +445,135 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn suggest_reports_blocked_commands_including_nested_workflow_and_deduplicates() { + let temp = std::env::temp_dir().join(format!( + "tutti-test-permissions-suggest-nested-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(&temp).unwrap(); + + let config_text = r#" +[workspace] +name = "ws" + +[[workflow]] +name = "child" + +[[workflow.step]] +type = "command" +run = "echo nested" + +[[workflow]] +name = "root" + +[[workflow.step]] +type = "command" +run = "echo top" + +[[workflow.step]] +type = "command" +run = "echo top" + +[[workflow.step]] +type = "workflow" +workflow = "child" +"#; + std::fs::write(temp.join("tutti.toml"), config_text).unwrap(); + + let (config, config_path) = TuttiConfig::load(&temp).unwrap(); + config.validate().unwrap(); + let project_root = config_path.parent().unwrap(); + let mut global = GlobalConfig { + permissions: Some(PermissionsConfig { + allow: vec!["echo top".to_string()], + }), + ..Default::default() + }; + + let report = + suggest_workflow_permissions("root", false, &config, project_root, &mut global) + .expect("suggest should work"); + + assert_eq!(report.total_commands, 3); + assert_eq!(report.blocked.len(), 1); + assert_eq!(report.blocked[0].command, "echo nested"); + assert_eq!(report.blocked[0].suggested_rule, "echo nested *"); + + let _ = std::fs::remove_dir_all(&temp); + } + + #[test] + #[serial] + fn suggest_apply_writes_global_permissions_and_json_shape_is_stable() { + struct HomeGuard(Option); + + impl Drop for HomeGuard { + fn drop(&mut self) { + if let Some(value) = self.0.take() { + unsafe { + std::env::set_var("HOME", value); + } + } else { + unsafe { + std::env::remove_var("HOME"); + } + } + } + } + + let temp = std::env::temp_dir().join(format!( + "tutti-test-permissions-suggest-apply-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(&temp).unwrap(); + + let _home_guard = HomeGuard(std::env::var("HOME").ok()); + unsafe { + std::env::set_var("HOME", &temp); + } + + let config_text = r#" +[workspace] +name = "ws" + +[[workflow]] +name = "root" + +[[workflow.step]] +type = "command" +run = "echo blocked" +"#; + std::fs::write(temp.join("tutti.toml"), config_text).unwrap(); + + let (config, config_path) = TuttiConfig::load(&temp).unwrap(); + config.validate().unwrap(); + let project_root = config_path.parent().unwrap(); + let mut global = GlobalConfig { + permissions: Some(PermissionsConfig::default()), + ..Default::default() + }; + + let report = suggest_workflow_permissions("root", true, &config, project_root, &mut global) + .expect("suggest should work"); + + assert_eq!(report.workflow, "root"); + assert_eq!(report.total_commands, 1); + assert_eq!(report.blocked.len(), 1); + assert_eq!(report.applied_rules, vec!["echo blocked *".to_string()]); + + let report_json = serde_json::to_value(&report).unwrap(); + assert_eq!(report_json["workflow"], "root"); + assert_eq!(report_json["total_commands"], 1); + assert!(report_json["blocked"].is_array()); + assert!(report_json["applied_rules"].is_array()); + + let saved = std::fs::read_to_string(global_config_path()).unwrap(); + assert!(saved.contains("echo blocked *")); + + let _ = std::fs::remove_dir_all(&temp); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 9de663b..5c6f72d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1045,9 +1045,20 @@ impl GlobalConfig { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } + + let _lock = acquire_global_config_lock(&path)?; let toml_str = toml::to_string_pretty(self).map_err(|e| TuttiError::ConfigParse(e.to_string()))?; - std::fs::write(path, toml_str)?; + + let now_nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let tmp_path = + path.with_extension(format!("toml.tmp.{}.{}", std::process::id(), now_nanos)); + + std::fs::write(&tmp_path, toml_str)?; + std::fs::rename(&tmp_path, &path)?; Ok(()) } @@ -1074,6 +1085,57 @@ impl GlobalConfig { } } +struct GlobalConfigLockGuard { + lock_path: PathBuf, +} + +impl Drop for GlobalConfigLockGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.lock_path); + } +} + +fn acquire_global_config_lock(config_path: &Path) -> Result { + let lock_path = config_path.with_extension("toml.lock"); + let start = std::time::Instant::now(); + let stale_after = std::time::Duration::from_secs(30); + let timeout = std::time::Duration::from_secs(5); + + loop { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + Ok(_) => { + return Ok(GlobalConfigLockGuard { lock_path }); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + if let Ok(meta) = std::fs::metadata(&lock_path) + && let Ok(modified) = meta.modified() + && modified + .elapsed() + .map(|age| age > stale_after) + .unwrap_or(false) + { + let _ = std::fs::remove_file(&lock_path); + continue; + } + + if start.elapsed() > timeout { + return Err(TuttiError::State(format!( + "timed out acquiring global config lock at {}", + lock_path.display() + ))); + } + + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(e) => return Err(TuttiError::Io(e)), + } + } +} + /// Path to the global config file. pub fn global_config_path() -> PathBuf { dirs_or_home()