@@ -42,7 +42,14 @@ pub enum PermissionDecision {
4242/// - `Read(src/**/*.rs)` - matches Rust files in src/
4343/// - `Grep(*)` - matches all grep invocations
4444/// - `mcp__pencil` - matches all pencil MCP tools
45- #[ derive( Debug , Clone , Serialize , Deserialize , PartialEq , Eq ) ]
45+ ///
46+ /// Deserialization supports both plain strings and `{rule: "..."}` objects:
47+ /// ```yaml
48+ /// allow:
49+ /// - read # plain string
50+ /// - rule: "Bash(cargo:*)" # struct form
51+ /// ```
52+ #[ derive( Debug , Clone , Serialize , PartialEq , Eq ) ]
4653pub struct PermissionRule {
4754 /// The original rule string
4855 pub rule : String ,
@@ -54,6 +61,28 @@ pub struct PermissionRule {
5461 arg_pattern : Option < String > ,
5562}
5663
64+ impl < ' de > Deserialize < ' de > for PermissionRule {
65+ fn deserialize < D > ( deserializer : D ) -> Result < Self , D :: Error >
66+ where
67+ D : serde:: Deserializer < ' de > ,
68+ {
69+ /// Helper enum to accept both `"read"` and `{rule: "read"}` in YAML/JSON.
70+ #[ derive( Deserialize ) ]
71+ #[ serde( untagged) ]
72+ enum RuleRepr {
73+ Plain ( String ) ,
74+ Struct { rule : String } ,
75+ }
76+
77+ let rule_str = match RuleRepr :: deserialize ( deserializer) ? {
78+ RuleRepr :: Plain ( s) => s,
79+ RuleRepr :: Struct { rule } => rule,
80+ } ;
81+ // `new()` calls `parse_rule()` to populate tool_name and arg_pattern.
82+ Ok ( PermissionRule :: new ( & rule_str) )
83+ }
84+ }
85+
5786impl PermissionRule {
5887 /// Create a new permission rule from a pattern string
5988 pub fn new ( rule : & str ) -> Self {
@@ -827,6 +856,55 @@ mod tests {
827856 assert ! ( policy. is_allowed( "Grep" , & json!( { "pattern" : "foo" } ) ) ) ;
828857 }
829858
859+ // ========================================================================
860+ // PermissionRule Deserialization Tests
861+ // ========================================================================
862+
863+ #[ test]
864+ fn test_rule_deserialize_plain_string ( ) {
865+ // YAML: `- read`
866+ let rule: PermissionRule = serde_yaml:: from_str ( "read" ) . unwrap ( ) ;
867+ assert_eq ! ( rule. rule, "read" ) ;
868+ assert ! ( rule. matches( "read" , & json!( { } ) ) ) ;
869+ assert ! ( !rule. matches( "write" , & json!( { } ) ) ) ;
870+ }
871+
872+ #[ test]
873+ fn test_rule_deserialize_plain_string_with_pattern ( ) {
874+ // YAML: `- "Bash(cargo:*)"`
875+ let rule: PermissionRule = serde_yaml:: from_str ( "\" Bash(cargo:*)\" " ) . unwrap ( ) ;
876+ assert_eq ! ( rule. rule, "Bash(cargo:*)" ) ;
877+ assert ! ( rule. matches( "Bash" , & json!( { "command" : "cargo build" } ) ) ) ;
878+ }
879+
880+ #[ test]
881+ fn test_rule_deserialize_struct_form ( ) {
882+ // YAML: `- rule: read`
883+ let rule: PermissionRule = serde_yaml:: from_str ( "rule: read" ) . unwrap ( ) ;
884+ assert_eq ! ( rule. rule, "read" ) ;
885+ assert ! ( rule. matches( "read" , & json!( { } ) ) ) ;
886+ }
887+
888+ #[ test]
889+ fn test_rule_deserialize_in_policy ( ) {
890+ // Full policy YAML with mixed formats
891+ let yaml = r#"
892+ allow:
893+ - read
894+ - "Bash(cargo:*)"
895+ - rule: grep
896+ deny:
897+ - write
898+ "# ;
899+ let policy: PermissionPolicy = serde_yaml:: from_str ( yaml) . unwrap ( ) ;
900+ assert_eq ! ( policy. allow. len( ) , 3 ) ;
901+ assert_eq ! ( policy. deny. len( ) , 1 ) ;
902+ assert ! ( policy. is_allowed( "read" , & json!( { } ) ) ) ;
903+ assert ! ( policy. is_allowed( "Bash" , & json!( { "command" : "cargo build" } ) ) ) ;
904+ assert ! ( policy. is_allowed( "grep" , & json!( { } ) ) ) ;
905+ assert ! ( policy. is_denied( "write" , & json!( { } ) ) ) ;
906+ }
907+
830908 // ========================================================================
831909 // PermissionManager Tests
832910 // ========================================================================
0 commit comments