From 94d39c73c2e9f12db161ee511f436f875ec4d12b Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 09:48:42 +1100 Subject: [PATCH 1/3] feat(permissions): include allow-rule hints for blocked commands --- src/automation/mod.rs | 12 +++++++++++- src/cli/permissions.rs | 24 ++++++++++++++++++------ src/permissions/mod.rs | 25 ++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 3b3048b..e6b6937 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -1004,10 +1004,13 @@ impl<'a> WorkflowExecutor<'a> { let policy_decision = evaluate_workflow_command_policy(policy_ctx, &rendered); if !policy_decision.allowed { - let message = format!( + let mut message = format!( "command blocked by permissions policy: '{}'", policy_decision.command ); + if let Some(rule) = policy_decision.suggested_rule.as_deref() { + message.push_str(&format!(" (hint: add allow rule '{rule}')")); + } match fail_mode { WorkflowFailMode::Open => { step_results.push(StepResult { @@ -1857,6 +1860,7 @@ fn evaluate_workflow_command_policy( "step_index": ctx.step_index, "command": decision.command, "matched_rule": decision.matched_rule, + "suggested_rule": decision.suggested_rule, })), }, ); @@ -3475,6 +3479,12 @@ mod tests { .as_deref() .is_some_and(|m| m.contains("blocked by permissions policy")) ); + assert!( + result.step_results[0] + .message + .as_deref() + .is_some_and(|m| m.contains("hint: add allow rule")) + ); let decisions = crate::state::load_policy_decisions(&dir).unwrap(); assert!(decisions.iter().any(|d| { diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index ff7ccf3..c7331d2 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -29,6 +29,7 @@ struct PermissionCheckReport { allowed: bool, policy_configured: bool, matched_rule: Option, + suggested_rule: Option, reason: Option, } @@ -49,6 +50,7 @@ fn run_check(parts: &[String], as_json: bool) -> Result<()> { decision.allowed, decision.policy_configured, decision.matched_rule.as_deref(), + decision.suggested_rule.as_deref(), decision.reason.as_deref(), ); @@ -58,6 +60,7 @@ fn run_check(parts: &[String], as_json: bool) -> Result<()> { allowed: decision.allowed, policy_configured: decision.policy_configured, matched_rule: decision.matched_rule.clone(), + suggested_rule: decision.suggested_rule.clone(), reason: decision.reason.clone(), }; println!("{}", serde_json::to_string_pretty(&report)?); @@ -74,15 +77,19 @@ fn run_check(parts: &[String], as_json: bool) -> Result<()> { } } else if let Some(reason) = decision.reason.as_deref() { eprintln!("{reason}: '{}'", decision.command); + if let Some(rule) = decision.suggested_rule.as_deref() { + eprintln!("hint: add this allow rule: {rule}"); + } } if decision.allowed { Ok(()) } else { - Err(TuttiError::ConfigValidation(format!( - "command blocked by permissions policy: '{}'", - decision.command - ))) + let mut message = format!("command blocked by permissions policy: '{}'", decision.command); + if let Some(rule) = decision.suggested_rule.as_deref() { + message.push_str(&format!(" (hint: add allow rule '{rule}')")); + } + Err(TuttiError::ConfigValidation(message)) } } @@ -108,6 +115,7 @@ fn persist_permission_check_decision( allowed: bool, policy_configured: bool, matched_rule: Option<&str>, + suggested_rule: Option<&str>, reason: Option<&str>, ) { let Some(ctx) = workspace_ctx else { @@ -137,7 +145,8 @@ fn persist_permission_check_decision( reason: reason.map(ToString::to_string), data: Some(json!({ "command": command, - "matched_rule": matched_rule + "matched_rule": matched_rule, + "suggested_rule": suggested_rule })), }, ); @@ -191,7 +200,9 @@ fn collect_blocked_commands( if !decision.allowed && seen_commands.insert(cmd.clone()) { blocked.push(PermissionSuggestion { command: cmd.clone(), - suggested_rule: format!("{cmd} *"), + suggested_rule: decision + .suggested_rule + .unwrap_or_else(|| format!("{cmd} *")), reason: decision.reason, }); } @@ -436,6 +447,7 @@ mod tests { true, Some("git status"), None, + None, ); let records = load_policy_decisions(&dir).unwrap(); diff --git a/src/permissions/mod.rs b/src/permissions/mod.rs index af1c13f..f5cf62e 100644 --- a/src/permissions/mod.rs +++ b/src/permissions/mod.rs @@ -34,6 +34,7 @@ pub struct CommandPolicyDecision { pub allowed: bool, pub policy_configured: bool, pub matched_rule: Option, + pub suggested_rule: Option, pub reason: Option, } @@ -48,6 +49,7 @@ pub fn evaluate_command_policy( allowed: true, policy_configured: false, matched_rule: None, + suggested_rule: None, reason: Some("policy not configured".to_string()), }; }; @@ -58,19 +60,30 @@ pub fn evaluate_command_policy( allowed: true, policy_configured: true, matched_rule: Some(matched_rule.to_string()), + suggested_rule: None, reason: None, }; } CommandPolicyDecision { - command: normalized, + command: normalized.clone(), allowed: false, policy_configured: true, matched_rule: None, + suggested_rule: suggested_wildcard_prefix_rule(&normalized), reason: Some("blocked by permissions policy".to_string()), } } +pub fn suggested_wildcard_prefix_rule(command_line: &str) -> Option { + let tokens: Vec<&str> = command_line.split_whitespace().collect(); + if tokens.len() < 2 { + return None; + } + + Some(format!("{} {} *", tokens[0], tokens[1])) +} + pub fn render_claude_settings(policy: &PermissionsConfig) -> Result { let allow: Vec = policy .allow @@ -247,6 +260,16 @@ mod tests { decision.reason.as_deref(), Some("blocked by permissions policy") ); + assert_eq!(decision.suggested_rule.as_deref(), Some("git stash *")); + } + + #[test] + fn evaluate_command_policy_suggests_rule_from_first_two_tokens_only() { + let policy = PermissionsConfig { + allow: vec!["git status".to_string()], + }; + let decision = evaluate_command_policy(Some(&policy), "cargo test --quiet --all"); + assert_eq!(decision.suggested_rule.as_deref(), Some("cargo test *")); } #[test] From f4bb813c4c52623196f5d0db105ad7ff74584265 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 09:51:55 +1100 Subject: [PATCH 2/3] style: format permissions CLI output changes --- src/cli/permissions.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index c7331d2..9f81c9d 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -85,7 +85,10 @@ fn run_check(parts: &[String], as_json: bool) -> Result<()> { if decision.allowed { Ok(()) } else { - let mut message = format!("command blocked by permissions policy: '{}'", decision.command); + let mut message = format!( + "command blocked by permissions policy: '{}'", + decision.command + ); if let Some(rule) = decision.suggested_rule.as_deref() { message.push_str(&format!(" (hint: add allow rule '{rule}')")); } From 960b55c3dbc9a745eac965b2b91741bfa95fb1b1 Mon Sep 17 00:00:00 2001 From: wren-nutthouse Date: Tue, 17 Mar 2026 10:04:18 +1100 Subject: [PATCH 3/3] fix: make wildcard hint helper internal --- src/permissions/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/permissions/mod.rs b/src/permissions/mod.rs index f5cf62e..6f7efb6 100644 --- a/src/permissions/mod.rs +++ b/src/permissions/mod.rs @@ -75,7 +75,7 @@ pub fn evaluate_command_policy( } } -pub fn suggested_wildcard_prefix_rule(command_line: &str) -> Option { +fn suggested_wildcard_prefix_rule(command_line: &str) -> Option { let tokens: Vec<&str> = command_line.split_whitespace().collect(); if tokens.len() < 2 { return None;