From 9845ef85c56e53e7664b39d9e845ec21b9b7d122 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 00:03:23 +1100 Subject: [PATCH 1/9] feat(permissions): add workflow suggestion and apply command --- src/cli/mod.rs | 13 +++++ src/cli/permissions.rs | 120 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) 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..d8447ce 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,120 @@ 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, +} + +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".to_string()) + })?; + + let mut global = GlobalConfig::load()?; + 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 resolved = + WorkflowResolver::new(&config, project_root).resolve(workflow, None, &options)?; + + let mut blocked: Vec = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + let mut total_commands = 0usize; + + for step in resolved.steps { + if let ResolvedStep::Command { run, .. } = step { + 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.insert(cmd.clone()) { + blocked.push(PermissionSuggestion { + command: cmd.clone(), + suggested_rule: format!("{cmd} *"), + reason: decision.reason, + }); + } + } + } + + 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()?; + } + } + + if as_json { + let report = PermissionSuggestReport { + workflow: workflow.to_string(), + total_commands, + blocked, + applied_rules, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + if 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 &blocked { + println!(" {}", item.suggested_rule); + } + } + + if apply { + if applied_rules.is_empty() { + println!("No new rules were applied."); + } else { + println!( + "Applied {} rule(s) to {}", + 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(); From 3a8f1dd890b9623f81effe2d189aa4a31a5b0002 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 01:30:12 +1100 Subject: [PATCH 2/9] fix(permissions): handle nested workflows in suggest + add tests --- src/cli/permissions.rs | 249 ++++++++++++++++++++++++++++++++++------- 1 file changed, 210 insertions(+), 39 deletions(-) diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index d8447ce..2e488fe 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -158,15 +158,64 @@ struct PermissionSuggestReport { applied_rules: Vec, } -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".to_string()) - })?; +fn collect_blocked_commands( + resolver: &WorkflowResolver, + workflow: &str, + options: &ExecuteOptions, + seen_workflows: &mut std::collections::BTreeSet, + seen_commands: &mut std::collections::BTreeSet, + blocked: &mut Vec, + global: &GlobalConfig, + total_commands: &mut usize, +) -> Result<()> { + if !seen_workflows.insert(workflow.to_string()) { + return Ok(()); + } - let mut global = GlobalConfig::load()?; + 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, + seen_workflows, + seen_commands, + blocked, + global, + total_commands, + )?; + } + _ => {} + } + } + + Ok(()) +} + +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, @@ -177,30 +226,22 @@ fn run_suggest(workflow: &str, apply: bool, as_json: bool) -> Result<()> { hook_agent: None, }; - let resolved = - WorkflowResolver::new(&config, project_root).resolve(workflow, None, &options)?; - + let resolver = WorkflowResolver::new(config, project_root); let mut blocked: Vec = Vec::new(); - let mut seen = std::collections::BTreeSet::new(); + let mut seen_commands = std::collections::BTreeSet::new(); + let mut seen_workflows = std::collections::BTreeSet::new(); let mut total_commands = 0usize; - for step in resolved.steps { - if let ResolvedStep::Command { run, .. } = step { - 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.insert(cmd.clone()) { - blocked.push(PermissionSuggestion { - command: cmd.clone(), - suggested_rule: format!("{cmd} *"), - reason: decision.reason, - }); - } - } - } + collect_blocked_commands( + &resolver, + workflow, + &options, + &mut seen_workflows, + &mut seen_commands, + &mut blocked, + global, + &mut total_commands, + )?; let mut applied_rules = Vec::new(); if apply && !blocked.is_empty() { @@ -222,33 +263,46 @@ fn run_suggest(workflow: &str, apply: bool, as_json: bool) -> Result<()> { } } + 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".to_string()) + })?; + + let mut global = GlobalConfig::load()?; + let report = suggest_workflow_permissions(workflow, apply, &config, project_root, &mut global)?; + if as_json { - let report = PermissionSuggestReport { - workflow: workflow.to_string(), - total_commands, - blocked, - applied_rules, - }; println!("{}", serde_json::to_string_pretty(&report)?); return Ok(()); } - if blocked.is_empty() { + 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 &blocked { + for item in &report.blocked { println!(" {}", item.suggested_rule); } } if apply { - if applied_rules.is_empty() { + if report.applied_rules.is_empty() { println!("No new rules were applied."); } else { println!( "Applied {} rule(s) to {}", - applied_rules.len(), + report.applied_rules.len(), global_config_path().display() ); } @@ -378,4 +432,121 @@ 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] + fn suggest_apply_writes_global_permissions_and_json_shape_is_stable() { + 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 old_home = 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 *")); + + if let Some(value) = old_home { + unsafe { std::env::set_var("HOME", value); } + } else { + unsafe { std::env::remove_var("HOME"); } + } + + let _ = std::fs::remove_dir_all(&temp); + } } From 94a28ce0aa30a9a9cb392a97416306f369231957 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 01:31:16 +1100 Subject: [PATCH 3/9] style: format permissions suggest changes --- src/cli/permissions.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index 2e488fe..8906da0 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -480,8 +480,9 @@ workflow = "child" ..Default::default() }; - let report = suggest_workflow_permissions("root", false, &config, project_root, &mut global) - .expect("suggest should work"); + 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); @@ -501,7 +502,9 @@ workflow = "child" std::fs::create_dir_all(&temp).unwrap(); let old_home = std::env::var("HOME").ok(); - unsafe { std::env::set_var("HOME", &temp); } + unsafe { + std::env::set_var("HOME", &temp); + } let config_text = r#" [workspace] @@ -542,9 +545,13 @@ run = "echo blocked" assert!(saved.contains("echo blocked *")); if let Some(value) = old_home { - unsafe { std::env::set_var("HOME", value); } + unsafe { + std::env::set_var("HOME", value); + } } else { - unsafe { std::env::remove_var("HOME"); } + unsafe { + std::env::remove_var("HOME"); + } } let _ = std::fs::remove_dir_all(&temp); From 83c48153bd6a143462ab5ec1d0139308485f2cfa Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 01:33:10 +1100 Subject: [PATCH 4/9] fix(clippy): allow helper arity for recursive suggest walker --- src/cli/permissions.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index 8906da0..112ab92 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -158,6 +158,7 @@ struct PermissionSuggestReport { applied_rules: Vec, } +#[allow(clippy::too_many_arguments)] fn collect_blocked_commands( resolver: &WorkflowResolver, workflow: &str, From b53c95f10b9bda9e5a1d0668f3a48c2cedec13b0 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 04:32:08 +1100 Subject: [PATCH 5/9] fix(permissions): use recursion-stack cycle guard for nested workflow suggest --- src/cli/permissions.rs | 83 ++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index 112ab92..6a62195 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -163,51 +163,57 @@ fn collect_blocked_commands( resolver: &WorkflowResolver, workflow: &str, options: &ExecuteOptions, - seen_workflows: &mut std::collections::BTreeSet, + active_workflows: &mut std::collections::BTreeSet, seen_commands: &mut std::collections::BTreeSet, blocked: &mut Vec, global: &GlobalConfig, total_commands: &mut usize, ) -> Result<()> { - if !seen_workflows.insert(workflow.to_string()) { + // Cycle detection only for the current recursion stack. + if active_workflows.contains(workflow) { return Ok(()); } - - 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; + 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, + }); + } } - 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, + )?; } + _ => {} } - ResolvedStep::Workflow { workflow, .. } => { - collect_blocked_commands( - resolver, - &workflow, - options, - seen_workflows, - seen_commands, - blocked, - global, - total_commands, - )?; - } - _ => {} } - } + Ok(()) + })(); - Ok(()) + active_workflows.remove(workflow); + result } fn suggest_workflow_permissions( @@ -230,14 +236,14 @@ fn suggest_workflow_permissions( let resolver = WorkflowResolver::new(config, project_root); let mut blocked: Vec = Vec::new(); let mut seen_commands = std::collections::BTreeSet::new(); - let mut seen_workflows = std::collections::BTreeSet::new(); + let mut active_workflows = std::collections::BTreeSet::new(); let mut total_commands = 0usize; collect_blocked_commands( &resolver, workflow, &options, - &mut seen_workflows, + &mut active_workflows, &mut seen_commands, &mut blocked, global, @@ -277,7 +283,10 @@ fn run_suggest(workflow: &str, apply: bool, as_json: bool) -> Result<()> { 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".to_string()) + TuttiError::ConfigValidation( + "could not determine workspace root; run `tt permissions suggest` from a workspace containing tutti.toml" + .to_string(), + ) })?; let mut global = GlobalConfig::load()?; @@ -345,6 +354,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() { @@ -494,6 +504,7 @@ workflow = "child" } #[test] + #[serial] fn suggest_apply_writes_global_permissions_and_json_shape_is_stable() { let temp = std::env::temp_dir().join(format!( "tutti-test-permissions-suggest-apply-{}", From 2263a7ad300d18c0f872804fcb3882c4ac6fbeb9 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 04:32:32 +1100 Subject: [PATCH 6/9] fix: serialize HOME-mutating permission test --- Cargo.lock | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 ++ 2 files changed, 86 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ddc7e6d..8c9985e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,6 +396,41 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[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" @@ -760,12 +795,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" @@ -824,6 +874,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", +] + [[package]] name = "shlex" version = "1.3.0" @@ -861,6 +937,12 @@ dependencies = [ "libc", ] +[[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" @@ -1033,6 +1115,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "serial_test", "thiserror", "tiny_http", "tokio", diff --git a/Cargo.toml b/Cargo.toml index d52c300..d732d42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,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" From 286d49341837175ca51b037f6d527efe1f561a66 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 04:48:12 +1100 Subject: [PATCH 7/9] test(permissions): restore HOME via RAII guard in serial suggest apply test --- src/cli/permissions.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index 6a62195..01feb02 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -506,6 +506,22 @@ workflow = "child" #[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() @@ -513,7 +529,7 @@ workflow = "child" let _ = std::fs::remove_dir_all(&temp); std::fs::create_dir_all(&temp).unwrap(); - let old_home = std::env::var("HOME").ok(); + let _home_guard = HomeGuard(std::env::var("HOME").ok()); unsafe { std::env::set_var("HOME", &temp); } @@ -556,16 +572,6 @@ run = "echo blocked" let saved = std::fs::read_to_string(global_config_path()).unwrap(); assert!(saved.contains("echo blocked *")); - if let Some(value) = old_home { - unsafe { - std::env::set_var("HOME", value); - } - } else { - unsafe { - std::env::remove_var("HOME"); - } - } - let _ = std::fs::remove_dir_all(&temp); } } From f482629c3f71a15a228506e4570f04d76403bdfb Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 05:10:20 +1100 Subject: [PATCH 8/9] fix(permissions): error on workflow cycles and lock global save --- src/cli/permissions.rs | 4 ++- src/config/mod.rs | 65 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index 01feb02..ff7ccf3 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -171,7 +171,9 @@ fn collect_blocked_commands( ) -> Result<()> { // Cycle detection only for the current recursion stack. if active_workflows.contains(workflow) { - return Ok(()); + 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()); diff --git a/src/config/mod.rs b/src/config/mod.rs index 9de663b..05a5887 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1045,9 +1045,19 @@ 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 +1084,59 @@ 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) { + if let Ok(modified) = meta.modified() { + if 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() From 2e44b2ca7f6e39ba64d5144a56dc861e2dd941a7 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 05:21:32 +1100 Subject: [PATCH 9/9] fix: collapse nested if-let in config lock path for clippy --- src/config/mod.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 05a5887..5c6f72d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1054,7 +1054,8 @@ impl GlobalConfig { .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)); + 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)?; @@ -1110,17 +1111,15 @@ fn acquire_global_config_lock(config_path: &Path) -> Result { - if let Ok(meta) = std::fs::metadata(&lock_path) { - if let Ok(modified) = meta.modified() { - if modified - .elapsed() - .map(|age| age > stale_after) - .unwrap_or(false) - { - let _ = std::fs::remove_file(&lock_path); - continue; - } - } + 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 {