@@ -760,6 +760,14 @@ fn generate_worker_mcp_config(
760760 }
761761 } ) ?;
762762
763+ // Restrict directory permissions to owner-only (0o700) to prevent
764+ // other users on the host from reading filtered MCP configs.
765+ #[ cfg( unix) ]
766+ {
767+ use std:: os:: unix:: fs:: PermissionsExt ;
768+ let _ = std:: fs:: set_permissions ( & tmp_dir, std:: fs:: Permissions :: from_mode ( 0o700 ) ) ;
769+ }
770+
763771 let tmp_path = tmp_dir. join ( format ! ( "{}.json" , job_id) ) ;
764772 let config_json = serde_json:: to_string_pretty ( & filtered) . map_err ( |e| {
765773 OrchestratorError :: ContainerCreationFailed {
@@ -982,4 +990,136 @@ mod tests {
982990 ) ;
983991 drop ( mgr) ;
984992 }
993+
994+ // ── Regression tests (CI-required) ────────────────────────────────
995+
996+ #[ test]
997+ fn test_filtered_config_contains_only_requested_server ( ) {
998+ let job_id = Uuid :: new_v4 ( ) ;
999+ let tmp = tempfile:: NamedTempFile :: new ( ) . unwrap ( ) ;
1000+ std:: fs:: write (
1001+ tmp. path ( ) ,
1002+ r#"{"schema_version":2,"servers":[
1003+ {"name":"serpstat","enabled":true,"url":"http://localhost:8062"},
1004+ {"name":"notion","enabled":true,"url":"http://localhost:8063"},
1005+ {"name":"archon","enabled":true,"url":"http://localhost:8064"}
1006+ ]}"# ,
1007+ )
1008+ . unwrap ( ) ;
1009+
1010+ let names = vec ! [ "serpstat" . to_string( ) ] ;
1011+ let result = generate_worker_mcp_config ( tmp. path ( ) , Some ( & names) , job_id) ;
1012+ let out_path = result. unwrap ( ) . expect ( "should produce a filtered config" ) ;
1013+
1014+ let content: serde_json:: Value =
1015+ serde_json:: from_str ( & std:: fs:: read_to_string ( & out_path) . unwrap ( ) ) . unwrap ( ) ;
1016+ let servers = content[ "servers" ] . as_array ( ) . unwrap ( ) ;
1017+
1018+ assert_eq ! ( servers. len( ) , 1 , "only serpstat should be present" ) ;
1019+ assert_eq ! ( servers[ 0 ] [ "name" ] , "serpstat" ) ;
1020+ assert ! (
1021+ !servers. iter( ) . any( |s| s[ "name" ] == "notion" ) ,
1022+ "notion must not leak into filtered config"
1023+ ) ;
1024+ assert ! (
1025+ !servers. iter( ) . any( |s| s[ "name" ] == "archon" ) ,
1026+ "archon must not leak into filtered config"
1027+ ) ;
1028+ assert_eq ! (
1029+ content[ "schema_version" ] , 2 ,
1030+ "schema_version must be preserved"
1031+ ) ;
1032+
1033+ let _ = std:: fs:: remove_file ( & out_path) ;
1034+ }
1035+
1036+ #[ test]
1037+ fn test_feature_flag_disabled_skips_mcp_filtering ( ) {
1038+ // When MCP_PER_JOB_ENABLED is false (the default), the mcp_servers
1039+ // parameter should be ignored and no filtered config should be created.
1040+ let config = ContainerJobConfig :: default ( ) ;
1041+ assert ! (
1042+ !config. mcp_per_job_enabled,
1043+ "mcp_per_job_enabled must default to false"
1044+ ) ;
1045+
1046+ // Verify the gate in create_job_inner: the mcp_per_job_enabled field
1047+ // controls whether generate_worker_mcp_config is called at all.
1048+ let source = include_str ! ( "job_manager.rs" ) ;
1049+ assert ! (
1050+ source. contains( "if self.config.mcp_per_job_enabled" ) ,
1051+ "create_job_inner must gate MCP filtering on config.mcp_per_job_enabled"
1052+ ) ;
1053+ }
1054+
1055+ #[ test]
1056+ fn test_temp_file_cleanup_removes_per_job_config ( ) {
1057+ let job_id = Uuid :: new_v4 ( ) ;
1058+ let tmp = tempfile:: NamedTempFile :: new ( ) . unwrap ( ) ;
1059+ std:: fs:: write (
1060+ tmp. path ( ) ,
1061+ r#"{"servers":[{"name":"serpstat","enabled":true}]}"# ,
1062+ )
1063+ . unwrap ( ) ;
1064+
1065+ let names = vec ! [ "serpstat" . to_string( ) ] ;
1066+ let result = generate_worker_mcp_config ( tmp. path ( ) , Some ( & names) , job_id) ;
1067+ let out_path = result. unwrap ( ) . expect ( "should produce a filtered config" ) ;
1068+ assert ! (
1069+ out_path. exists( ) ,
1070+ "temp config file should exist after creation"
1071+ ) ;
1072+
1073+ // Simulate what cleanup_job does
1074+ let expected_path = std:: env:: temp_dir ( )
1075+ . join ( "ironclaw-mcp-configs" )
1076+ . join ( format ! ( "{}.json" , job_id) ) ;
1077+ assert_eq ! (
1078+ out_path, expected_path,
1079+ "temp path must match cleanup expectation"
1080+ ) ;
1081+ std:: fs:: remove_file ( & out_path) . unwrap ( ) ;
1082+ assert ! ( !out_path. exists( ) , "temp file should be gone after cleanup" ) ;
1083+ }
1084+
1085+ #[ tokio:: test]
1086+ async fn test_cleanup_job_is_idempotent ( ) {
1087+ let config = ContainerJobConfig :: default ( ) ;
1088+ let mgr = ContainerJobManager :: new ( config, TokenStore :: new ( ) ) ;
1089+ let job_id = Uuid :: new_v4 ( ) ;
1090+
1091+ // cleanup_job should not panic or error when called for a job
1092+ // that has no temp file and no container handle.
1093+ mgr. cleanup_job ( job_id) . await ;
1094+ // Second call should also be fine (idempotent).
1095+ mgr. cleanup_job ( job_id) . await ;
1096+ }
1097+
1098+ #[ cfg( unix) ]
1099+ #[ test]
1100+ fn test_temp_dir_has_restrictive_permissions ( ) {
1101+ use std:: os:: unix:: fs:: PermissionsExt ;
1102+
1103+ let job_id = Uuid :: new_v4 ( ) ;
1104+ let tmp = tempfile:: NamedTempFile :: new ( ) . unwrap ( ) ;
1105+ std:: fs:: write (
1106+ tmp. path ( ) ,
1107+ r#"{"servers":[{"name":"test","enabled":true}]}"# ,
1108+ )
1109+ . unwrap ( ) ;
1110+
1111+ let names = vec ! [ "test" . to_string( ) ] ;
1112+ let result = generate_worker_mcp_config ( tmp. path ( ) , Some ( & names) , job_id) ;
1113+ let out_path = result. unwrap ( ) . expect ( "should produce a filtered config" ) ;
1114+
1115+ let dir_path = out_path. parent ( ) . unwrap ( ) ;
1116+ let mode = std:: fs:: metadata ( dir_path) . unwrap ( ) . permissions ( ) . mode ( ) & 0o777 ;
1117+ assert_eq ! (
1118+ mode, 0o700 ,
1119+ "ironclaw-mcp-configs dir must be 0700, got {:o}" ,
1120+ mode
1121+ ) ;
1122+
1123+ let _ = std:: fs:: remove_file ( & out_path) ;
1124+ }
9851125}
0 commit comments