77//! debugging permission rules and for surfacing context in the TUI.
88
99use super :: PermissionDecision ;
10- use super :: rule_parser:: PermissionRule ;
10+ use super :: rule_parser:: { PermissionRule , matches_rule } ;
1111
1212// ---------------------------------------------------------------------------
1313// Types
@@ -34,40 +34,120 @@ pub struct PermissionExplanation {
3434/// Given a tool name, the final decision, and the full set of permission rules,
3535/// produces a [`PermissionExplanation`] describing why the decision was reached
3636/// and what rule (if any) matched.
37- ///
38- /// # Arguments
39- ///
40- /// * `tool_name` - Name of the tool that was checked.
41- /// * `decision` - The final [`PermissionDecision`] that was reached.
42- /// * `rules` - The list of [`PermissionRule`]s that were evaluated.
43- ///
44- /// # Examples
45- ///
46- /// ```ignore
47- /// let explanation = explain_decision("Bash", &PermissionDecision::Allow, &rules);
48- /// println!("{}", explanation.decision);
49- /// // => "Allowed: tool 'Bash' matches whitelist rule 'Bash(command:git*)'"
50- /// ```
5137pub fn explain_decision (
5238 tool_name : & str ,
5339 decision : & PermissionDecision ,
5440 rules : & [ PermissionRule ] ,
5541) -> PermissionExplanation {
56- todo ! (
57- "Explain why tool '{tool_name}' got decision {:?} given {} rules" ,
58- decision,
59- rules. len( )
60- )
42+ // Try to find the first matching rule for context
43+ let empty_input = serde_json:: json!( { } ) ;
44+ let matched = rules
45+ . iter ( )
46+ . find ( |r| matches_rule ( r, tool_name, & empty_input) ) ;
47+
48+ match decision {
49+ PermissionDecision :: Allow => {
50+ if let Some ( rule) = matched {
51+ PermissionExplanation {
52+ decision : format ! ( "Allowed: tool '{tool_name}' matches rule '{rule}'" ) ,
53+ matched_rule : Some ( rule. to_string ( ) ) ,
54+ suggestion : None ,
55+ }
56+ } else {
57+ PermissionExplanation {
58+ decision : format ! (
59+ "Allowed: tool '{tool_name}' permitted by current permission mode"
60+ ) ,
61+ matched_rule : None ,
62+ suggestion : None ,
63+ }
64+ }
65+ }
66+ PermissionDecision :: Deny ( reason) => {
67+ let suggestion = suggest_allow_rule ( tool_name, & empty_input) ;
68+ if let Some ( rule) = matched {
69+ PermissionExplanation {
70+ decision : format ! (
71+ "Denied: tool '{tool_name}' blocked by rule '{rule}' — {reason}"
72+ ) ,
73+ matched_rule : Some ( rule. to_string ( ) ) ,
74+ suggestion,
75+ }
76+ } else {
77+ PermissionExplanation {
78+ decision : format ! ( "Denied: tool '{tool_name}' — {reason}" ) ,
79+ matched_rule : None ,
80+ suggestion,
81+ }
82+ }
83+ }
84+ PermissionDecision :: AskUser ( prompt) => {
85+ let suggestion = suggest_allow_rule ( tool_name, & empty_input) ;
86+ PermissionExplanation {
87+ decision : format ! ( "Requires confirmation: tool '{tool_name}' — {prompt}" ) ,
88+ matched_rule : matched. map ( std:: string:: ToString :: to_string) ,
89+ suggestion,
90+ }
91+ }
92+ }
6193}
6294
6395/// Generate a suggestion for how to allow a denied tool invocation.
6496///
6597/// Returns `None` if no useful suggestion can be generated.
6698pub fn suggest_allow_rule ( tool_name : & str , tool_input : & serde_json:: Value ) -> Option < String > {
67- todo ! (
68- "Generate a suggestion for allowing tool '{tool_name}' with input {:?}" ,
69- tool_input
70- )
99+ // For Bash tools, suggest a command-specific allow rule if possible
100+ if tool_name == "Bash" || tool_name == "bash" {
101+ if let Some ( command) = tool_input. get ( "command" ) . and_then ( |v| v. as_str ( ) ) {
102+ // Extract the base command (first word) for a prefix suggestion
103+ let base_cmd = command. split_whitespace ( ) . next ( ) . unwrap_or ( command) ;
104+ return Some ( format ! (
105+ "Add 'Bash(command:{base_cmd}*)' to `allowed_tools` to auto-approve {base_cmd} commands"
106+ ) ) ;
107+ }
108+ return Some (
109+ "Add 'Bash(*)' to `allowed_tools` to auto-approve all shell commands" . to_string ( ) ,
110+ ) ;
111+ }
112+
113+ // For Edit/Write tools, suggest a path-scoped rule if possible
114+ if ( tool_name == "Edit" || tool_name == "Write" || tool_name == "edit" || tool_name == "write" )
115+ && let Some ( path) = tool_input. get ( "file_path" ) . and_then ( |v| v. as_str ( ) )
116+ && let Some ( parent) = std:: path:: Path :: new ( path) . parent ( )
117+ {
118+ return Some ( format ! (
119+ "Add '{tool_name}(file_path:{}/**)' to `allowed_tools` to auto-approve edits in that directory" ,
120+ parent. display( )
121+ ) ) ;
122+ }
123+
124+ // For Read tools, suggest a path-scoped rule if possible
125+ if ( tool_name == "Read" || tool_name == "read" )
126+ && let Some ( path) = tool_input. get ( "file_path" ) . and_then ( |v| v. as_str ( ) )
127+ && let Some ( parent) = std:: path:: Path :: new ( path) . parent ( )
128+ {
129+ return Some ( format ! (
130+ "Add 'Read(file_path:{}/**)' to `allowed_tools` to auto-approve reads in that directory" ,
131+ parent. display( )
132+ ) ) ;
133+ }
134+
135+ // For MCP tools, suggest the server-level wildcard
136+ if tool_name. starts_with ( "mcp__" ) {
137+ // Extract the server name: mcp__<server>__<tool>
138+ let parts: Vec < & str > = tool_name. splitn ( 3 , "__" ) . collect ( ) ;
139+ if parts. len ( ) >= 2 {
140+ let server = parts[ 1 ] ;
141+ return Some ( format ! (
142+ "Add 'mcp__{server}__*' to `allowed_tools` to auto-approve all tools from this MCP server"
143+ ) ) ;
144+ }
145+ }
146+
147+ // Generic: suggest allowing the tool by name
148+ Some ( format ! (
149+ "Add '{tool_name}' to `allowed_tools` to auto-approve this tool"
150+ ) )
71151}
72152
73153// ---------------------------------------------------------------------------
@@ -76,10 +156,94 @@ pub fn suggest_allow_rule(tool_name: &str, tool_input: &serde_json::Value) -> Op
76156
77157#[ cfg( test) ]
78158mod tests {
79- // Tests will be added as the implementation progresses.
80- // Key test scenarios:
81- // - Explain an Allow decision with a matching whitelist rule
82- // - Explain a Deny decision with a matching denied rule
83- // - Explain an AskUser decision (no matching rule, default behavior)
84- // - Suggestion generation for common tool patterns
159+ use super :: * ;
160+ use crate :: permission:: rule_parser:: parse_rule;
161+
162+ #[ test]
163+ fn explain_allow_with_matching_rule ( ) {
164+ // Use a tool-wide rule (no content constraint) so it matches with empty input
165+ let rules = vec ! [ parse_rule( "Bash" ) . unwrap( ) ] ;
166+ let explanation = explain_decision ( "Bash" , & PermissionDecision :: Allow , & rules) ;
167+ assert ! ( explanation. decision. contains( "Allowed" ) ) ;
168+ assert ! ( explanation. decision. contains( "Bash" ) ) ;
169+ assert ! ( explanation. matched_rule. is_some( ) ) ;
170+ assert ! ( explanation. suggestion. is_none( ) ) ;
171+ }
172+
173+ #[ test]
174+ fn explain_allow_without_matching_rule ( ) {
175+ let rules = vec ! [ parse_rule( "Edit" ) . unwrap( ) ] ;
176+ let explanation = explain_decision ( "Bash" , & PermissionDecision :: Allow , & rules) ;
177+ assert ! ( explanation. decision. contains( "Allowed" ) ) ;
178+ assert ! ( explanation. decision. contains( "permission mode" ) ) ;
179+ assert ! ( explanation. matched_rule. is_none( ) ) ;
180+ }
181+
182+ #[ test]
183+ fn explain_deny_with_reason ( ) {
184+ let rules = vec ! [ parse_rule( "Bash" ) . unwrap( ) ] ;
185+ let explanation = explain_decision (
186+ "Bash" ,
187+ & PermissionDecision :: Deny ( "tool is in denied list" . to_string ( ) ) ,
188+ & rules,
189+ ) ;
190+ assert ! ( explanation. decision. contains( "Denied" ) ) ;
191+ assert ! ( explanation. decision. contains( "denied list" ) ) ;
192+ assert ! ( explanation. matched_rule. is_some( ) ) ;
193+ assert ! ( explanation. suggestion. is_some( ) ) ;
194+ }
195+
196+ #[ test]
197+ fn explain_ask_user ( ) {
198+ let rules = vec ! [ ] ;
199+ let explanation = explain_decision (
200+ "Bash" ,
201+ & PermissionDecision :: AskUser ( "confirm execution" . to_string ( ) ) ,
202+ & rules,
203+ ) ;
204+ assert ! ( explanation. decision. contains( "Requires confirmation" ) ) ;
205+ assert ! ( explanation. suggestion. is_some( ) ) ;
206+ }
207+
208+ #[ test]
209+ fn suggest_bash_command_rule ( ) {
210+ let input = serde_json:: json!( { "command" : "git status" } ) ;
211+ let suggestion = suggest_allow_rule ( "Bash" , & input) ;
212+ assert ! ( suggestion. is_some( ) ) ;
213+ let s = suggestion. unwrap ( ) ;
214+ assert ! ( s. contains( "git" ) ) ;
215+ assert ! ( s. contains( "allowed_tools" ) ) ;
216+ }
217+
218+ #[ test]
219+ fn suggest_bash_generic ( ) {
220+ let input = serde_json:: json!( { } ) ;
221+ let suggestion = suggest_allow_rule ( "Bash" , & input) ;
222+ assert ! ( suggestion. is_some( ) ) ;
223+ assert ! ( suggestion. unwrap( ) . contains( "Bash(*)" ) ) ;
224+ }
225+
226+ #[ test]
227+ fn suggest_mcp_server_rule ( ) {
228+ let input = serde_json:: json!( { } ) ;
229+ let suggestion = suggest_allow_rule ( "mcp__playwright__click" , & input) ;
230+ assert ! ( suggestion. is_some( ) ) ;
231+ assert ! ( suggestion. unwrap( ) . contains( "mcp__playwright__*" ) ) ;
232+ }
233+
234+ #[ test]
235+ fn suggest_generic_tool_rule ( ) {
236+ let input = serde_json:: json!( { } ) ;
237+ let suggestion = suggest_allow_rule ( "CustomTool" , & input) ;
238+ assert ! ( suggestion. is_some( ) ) ;
239+ assert ! ( suggestion. unwrap( ) . contains( "CustomTool" ) ) ;
240+ }
241+
242+ #[ test]
243+ fn explain_allow_with_wildcard_rule ( ) {
244+ let rules = vec ! [ parse_rule( "*" ) . unwrap( ) ] ;
245+ let explanation = explain_decision ( "AnyTool" , & PermissionDecision :: Allow , & rules) ;
246+ assert ! ( explanation. decision. contains( "Allowed" ) ) ;
247+ assert_eq ! ( explanation. matched_rule, Some ( "*" . to_string( ) ) ) ;
248+ }
85249}
0 commit comments