@@ -80,11 +80,19 @@ struct FilesystemDef {
8080 read_write : Vec < String > ,
8181}
8282
83+ #[ derive( Debug , Default , Serialize , Deserialize ) ]
84+ #[ serde( rename_all = "snake_case" ) ]
85+ enum LandlockCompatibilityDef {
86+ #[ default]
87+ BestEffort ,
88+ HardRequirement ,
89+ }
90+
8391#[ derive( Debug , Serialize , Deserialize ) ]
8492#[ serde( deny_unknown_fields) ]
8593struct LandlockDef {
86- #[ serde( default , skip_serializing_if = "String::is_empty" ) ]
87- compatibility : String ,
94+ #[ serde( default ) ]
95+ compatibility : LandlockCompatibilityDef ,
8896}
8997
9098#[ derive( Debug , Serialize , Deserialize ) ]
@@ -918,7 +926,10 @@ fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
918926 read_write : fs. read_write ,
919927 } ) ,
920928 landlock : raw. landlock . map ( |ll| LandlockPolicy {
921- compatibility : ll. compatibility ,
929+ compatibility : match ll. compatibility {
930+ LandlockCompatibilityDef :: BestEffort => "best_effort" . to_string ( ) ,
931+ LandlockCompatibilityDef :: HardRequirement => "hard_requirement" . to_string ( ) ,
932+ } ,
922933 } ) ,
923934 process : raw. process . map ( |p| ProcessPolicy {
924935 run_as_user : p. run_as_user ,
@@ -933,16 +944,28 @@ fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
933944// Proto → YAML conversion
934945// ---------------------------------------------------------------------------
935946
936- fn from_proto ( policy : & SandboxPolicy ) -> PolicyFile {
947+ fn from_proto ( policy : & SandboxPolicy ) -> Result < PolicyFile > {
937948 let filesystem_policy = policy. filesystem . as_ref ( ) . map ( |fs| FilesystemDef {
938949 include_workdir : fs. include_workdir ,
939950 read_only : fs. read_only . clone ( ) ,
940951 read_write : fs. read_write . clone ( ) ,
941952 } ) ;
942953
943- let landlock = policy. landlock . as_ref ( ) . map ( |ll| LandlockDef {
944- compatibility : ll. compatibility . clone ( ) ,
945- } ) ;
954+ let landlock = match policy. landlock . as_ref ( ) {
955+ Some ( ll) => {
956+ let compatibility = match ll. compatibility . as_str ( ) {
957+ "hard_requirement" => LandlockCompatibilityDef :: HardRequirement ,
958+ "best_effort" | "" => LandlockCompatibilityDef :: BestEffort ,
959+ otherwise => miette:: bail!(
960+ "invalid landlock.compatibility {:?}; accepted: {}" ,
961+ otherwise,
962+ openshell_core:: policy:: LANDLOCK_COMPATIBILITY_VALUES . join( ", " )
963+ ) ,
964+ } ;
965+ Some ( LandlockDef { compatibility } )
966+ }
967+ _ => None ,
968+ } ;
946969
947970 let process = policy. process . as_ref ( ) . and_then ( |p| {
948971 if p. run_as_user . is_empty ( ) && p. run_as_group . is_empty ( ) {
@@ -1066,14 +1089,14 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
10661089
10671090 let network_middlewares = middleware:: from_proto ( & policy. network_middlewares ) ;
10681091
1069- PolicyFile {
1092+ Ok ( PolicyFile {
10701093 version : policy. version ,
10711094 filesystem_policy,
10721095 landlock,
10731096 process,
10741097 network_policies,
10751098 network_middlewares,
1076- }
1099+ } )
10771100}
10781101
10791102// ---------------------------------------------------------------------------
@@ -1176,7 +1199,7 @@ pub fn parse_sandbox_policy(yaml: &str) -> Result<SandboxPolicy> {
11761199pub fn serialize_sandbox_policy ( policy : & SandboxPolicy ) -> Result < String > {
11771200 let canonical = validate_and_canonicalize_mcp_policy_schema ( policy. clone ( ) )
11781201 . map_err ( |error| miette:: miette!( "cannot serialize invalid sandbox policy: {error}" ) ) ?;
1179- let yaml_repr = from_proto ( & canonical) ;
1202+ let yaml_repr = from_proto ( & canonical) ? ;
11801203 serde_yml:: to_string ( & yaml_repr)
11811204 . into_diagnostic ( )
11821205 . wrap_err ( "failed to serialize policy to YAML" )
@@ -1189,7 +1212,7 @@ pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result<String> {
11891212pub fn sandbox_policy_to_json_value ( policy : & SandboxPolicy ) -> Result < serde_json:: Value > {
11901213 let canonical = validate_and_canonicalize_mcp_policy_schema ( policy. clone ( ) )
11911214 . map_err ( |error| miette:: miette!( "cannot serialize invalid sandbox policy: {error}" ) ) ?;
1192- let json_repr = from_proto ( & canonical) ;
1215+ let json_repr = from_proto ( & canonical) ? ;
11931216 serde_json:: to_value ( & json_repr)
11941217 . into_diagnostic ( )
11951218 . wrap_err ( "failed to serialize policy to JSON" )
@@ -1372,6 +1395,8 @@ pub enum PolicyViolation {
13721395 policy_name : String ,
13731396 host : String ,
13741397 } ,
1398+ /// `landlock.compatibility` has an unrecognized value.
1399+ InvalidLandlockCompatibility { value : String } ,
13751400 /// An effective MCP endpoint has not materialized a protocol revision.
13761401 MissingMcpVersions { policy_name : String , host : String } ,
13771402 /// A non-MCP endpoint carries MCP-only configuration.
@@ -1554,6 +1579,13 @@ impl fmt::Display for PolicyViolation {
15541579 '{policy_name}' tls: skip endpoint '{host}'"
15551580 )
15561581 }
1582+ Self :: InvalidLandlockCompatibility { value } => {
1583+ write ! (
1584+ f,
1585+ "invalid landlock.compatibility '{value}'; accepted: {}" ,
1586+ openshell_core:: policy:: LANDLOCK_COMPATIBILITY_VALUES . join( ", " )
1587+ )
1588+ }
15571589 Self :: MissingMcpVersions { policy_name, host } => {
15581590 write ! (
15591591 f,
@@ -1650,6 +1682,17 @@ fn validate_sandbox_policy_with_mcp_presence(
16501682 }
16511683 }
16521684
1685+ // Check landlock compatibility mode is a recognized value. Direct gRPC/SDK
1686+ // clients bypass YAML serde validation, so reject invalid values here at the
1687+ // gateway create path rather than deferring rejection to sandbox startup.
1688+ if let Some ( ref landlock) = policy. landlock
1689+ && !openshell_core:: policy:: is_valid_landlock_compatibility ( & landlock. compatibility )
1690+ {
1691+ violations. push ( PolicyViolation :: InvalidLandlockCompatibility {
1692+ value : landlock. compatibility . clone ( ) ,
1693+ } ) ;
1694+ }
1695+
16531696 // Check filesystem paths
16541697 if let Some ( ref fs) = policy. filesystem {
16551698 let total_paths = fs. read_only . len ( ) + fs. read_write . len ( ) ;
@@ -3152,6 +3195,80 @@ network_policies:
31523195 assert_eq ! ( violations. len( ) , 2 ) ;
31533196 }
31543197
3198+ #[ test]
3199+ fn parse_rejects_invalid_landlock_compatibility ( ) {
3200+ let err = parse_sandbox_policy ( "version: 1\n landlock:\n compatibility: bogus\n " )
3201+ . expect_err ( "should reject invalid YAML enum value" ) ;
3202+ let msg = format ! ( "{err:?}" ) ;
3203+ assert ! (
3204+ msg. contains( "best_effort" ) && msg. contains( "hard_requirement" ) ,
3205+ "error should list accepted values, got: {msg}" ,
3206+ ) ;
3207+ }
3208+
3209+ #[ test]
3210+ fn parse_accepts_known_landlock_compatibility ( ) {
3211+ for value in [ "best_effort" , "hard_requirement" ] {
3212+ let yaml = format ! ( "version: 1\n landlock:\n compatibility: {value}\n " ) ;
3213+ let policy = parse_sandbox_policy ( & yaml) . expect ( "should parse" ) ;
3214+ assert_eq ! (
3215+ policy. landlock. as_ref( ) . expect( "landlock" ) . compatibility,
3216+ value,
3217+ ) ;
3218+ }
3219+ }
3220+
3221+ #[ test]
3222+ fn validate_rejects_invalid_landlock_compatibility_proto ( ) {
3223+ let mut policy = restrictive_default_policy ( ) ;
3224+ policy. landlock = Some ( LandlockPolicy {
3225+ compatibility : "nope" . into ( ) ,
3226+ } ) ;
3227+ let violations = validate_sandbox_policy ( & policy) . unwrap_err ( ) ;
3228+ assert ! (
3229+ violations
3230+ . iter( )
3231+ . any( |v| matches!( v, PolicyViolation :: InvalidLandlockCompatibility { .. } ) ) ,
3232+ "expected InvalidLandlockCompatibility, got: {violations:?}" ,
3233+ ) ;
3234+ }
3235+
3236+ #[ test]
3237+ fn validate_accepts_empty_landlock_compatibility ( ) {
3238+ // Empty string is the proto default and maps to best_effort.
3239+ let mut policy = restrictive_default_policy ( ) ;
3240+ policy. landlock = Some ( LandlockPolicy {
3241+ compatibility : String :: new ( ) ,
3242+ } ) ;
3243+ assert ! ( validate_sandbox_policy( & policy) . is_ok( ) ) ;
3244+ }
3245+
3246+ #[ test]
3247+ fn serialize_rejects_invalid_landlock_compatibility ( ) {
3248+ // Old policies persisted before gateway validation can hold invalid
3249+ // values; serialization must error rather than normalize to best_effort.
3250+ let mut policy = restrictive_default_policy ( ) ;
3251+ policy. landlock = Some ( LandlockPolicy {
3252+ compatibility : "hard-requirement" . to_string ( ) ,
3253+ } ) ;
3254+ let err = serialize_sandbox_policy ( & policy) . expect_err ( "should reject" ) ;
3255+ let msg = format ! ( "{err:?}" ) ;
3256+ assert ! (
3257+ msg. contains( "best_effort" ) && msg. contains( "hard_requirement" ) ,
3258+ "error should list accepted values, got: {msg}" ,
3259+ ) ;
3260+ }
3261+
3262+ #[ test]
3263+ fn serialize_accepts_empty_landlock_compatibility ( ) {
3264+ // Empty string is the proto default; serialize must not error on it.
3265+ let mut policy = restrictive_default_policy ( ) ;
3266+ policy. landlock = Some ( LandlockPolicy {
3267+ compatibility : String :: new ( ) ,
3268+ } ) ;
3269+ assert ! ( serialize_sandbox_policy( & policy) . is_ok( ) ) ;
3270+ }
3271+
31553272 #[ test]
31563273 fn validate_rejects_invalid_middleware_control_fields ( ) {
31573274 let cases = [
0 commit comments