33use base64:: Engine ;
44use base64:: engine:: general_purpose:: STANDARD as BASE64_STANDARD ;
55use codex_core:: CodexAuth ;
6+ use codex_core:: config:: Constrained ;
67use codex_core:: features:: Feature ;
78use codex_protocol:: config_types:: ReasoningSummary ;
89use codex_protocol:: openai_models:: ConfigShellToolType ;
@@ -13,9 +14,15 @@ use codex_protocol::openai_models::ModelsResponse;
1314use codex_protocol:: openai_models:: ReasoningEffort ;
1415use codex_protocol:: openai_models:: ReasoningEffortPreset ;
1516use codex_protocol:: openai_models:: TruncationPolicyConfig ;
17+ use codex_protocol:: permissions:: FileSystemAccessMode ;
18+ use codex_protocol:: permissions:: FileSystemPath ;
19+ use codex_protocol:: permissions:: FileSystemSandboxEntry ;
20+ use codex_protocol:: permissions:: FileSystemSandboxPolicy ;
21+ use codex_protocol:: permissions:: FileSystemSpecialPath ;
1622use codex_protocol:: protocol:: AskForApproval ;
1723use codex_protocol:: protocol:: EventMsg ;
1824use codex_protocol:: protocol:: Op ;
25+ use codex_protocol:: protocol:: ReadOnlyAccess ;
1926use codex_protocol:: protocol:: SandboxPolicy ;
2027use codex_protocol:: user_input:: UserInput ;
2128use core_test_support:: responses;
@@ -1243,6 +1250,109 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
12431250 Ok ( ( ) )
12441251}
12451252
1253+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
1254+ async fn view_image_tool_respects_filesystem_sandbox ( ) -> anyhow:: Result < ( ) > {
1255+ skip_if_no_network ! ( Ok ( ( ) ) ) ;
1256+
1257+ let server = start_mock_server ( ) . await ;
1258+ let sandbox_policy_for_config = SandboxPolicy :: ReadOnly {
1259+ access : ReadOnlyAccess :: Restricted {
1260+ include_platform_defaults : true ,
1261+ readable_roots : Vec :: new ( ) ,
1262+ } ,
1263+ network_access : false ,
1264+ } ;
1265+ let mut builder = test_codex ( ) . with_config ( {
1266+ let sandbox_policy_for_config = sandbox_policy_for_config. clone ( ) ;
1267+ move |config| {
1268+ config. permissions . sandbox_policy = Constrained :: allow_any ( sandbox_policy_for_config) ;
1269+ config. permissions . file_system_sandbox_policy =
1270+ FileSystemSandboxPolicy :: restricted ( vec ! [
1271+ FileSystemSandboxEntry {
1272+ path: FileSystemPath :: Special {
1273+ value: FileSystemSpecialPath :: Minimal ,
1274+ } ,
1275+ access: FileSystemAccessMode :: Read ,
1276+ } ,
1277+ FileSystemSandboxEntry {
1278+ path: FileSystemPath :: Special {
1279+ value: FileSystemSpecialPath :: CurrentWorkingDirectory ,
1280+ } ,
1281+ access: FileSystemAccessMode :: Read ,
1282+ } ,
1283+ ] ) ;
1284+ }
1285+ } ) ;
1286+ let TestCodex {
1287+ codex,
1288+ config,
1289+ cwd,
1290+ session_configured,
1291+ ..
1292+ } = builder. build ( & server) . await ?;
1293+
1294+ let outside_dir = tempfile:: tempdir ( ) ?;
1295+ let abs_path = outside_dir. path ( ) . join ( "blocked.png" ) ;
1296+ let image = ImageBuffer :: from_pixel ( 256 , 128 , Rgba ( [ 10u8 , 20 , 30 , 255 ] ) ) ;
1297+ image. save ( & abs_path) ?;
1298+
1299+ let call_id = "view-image-sandbox-denied" ;
1300+ let arguments = serde_json:: json!( { "path" : abs_path } ) . to_string ( ) ;
1301+
1302+ let first_response = sse ( vec ! [
1303+ ev_response_created( "resp-1" ) ,
1304+ ev_function_call( call_id, "view_image" , & arguments) ,
1305+ ev_completed( "resp-1" ) ,
1306+ ] ) ;
1307+ responses:: mount_sse_once ( & server, first_response) . await ;
1308+
1309+ let second_response = sse ( vec ! [
1310+ ev_assistant_message( "msg-1" , "done" ) ,
1311+ ev_completed( "resp-2" ) ,
1312+ ] ) ;
1313+ let mock = responses:: mount_sse_once ( & server, second_response) . await ;
1314+
1315+ let session_model = session_configured. model . clone ( ) ;
1316+
1317+ codex
1318+ . submit ( Op :: UserTurn {
1319+ items : vec ! [ UserInput :: Text {
1320+ text: "please attach the outside image" . into( ) ,
1321+ text_elements: Vec :: new( ) ,
1322+ } ] ,
1323+ final_output_json_schema : None ,
1324+ cwd : cwd. path ( ) . to_path_buf ( ) ,
1325+ approval_policy : AskForApproval :: Never ,
1326+ sandbox_policy : config. permissions . sandbox_policy . get ( ) . clone ( ) ,
1327+ model : session_model,
1328+ effort : None ,
1329+ summary : None ,
1330+ service_tier : None ,
1331+ collaboration_mode : None ,
1332+ personality : None ,
1333+ } )
1334+ . await ?;
1335+
1336+ wait_for_event ( & codex, |event| matches ! ( event, EventMsg :: TurnComplete ( _) ) ) . await ;
1337+
1338+ let request = mock. single_request ( ) ;
1339+ assert ! (
1340+ request. inputs_of_type( "input_image" ) . is_empty( ) ,
1341+ "sandbox-denied image should not produce an input_image message"
1342+ ) ;
1343+ let output_text = request
1344+ . function_call_output_content_and_success ( call_id)
1345+ . and_then ( |( content, _) | content)
1346+ . expect ( "output text present" ) ;
1347+ let expected_prefix = format ! ( "unable to read image at `{}`:" , abs_path. display( ) ) ;
1348+ assert ! (
1349+ output_text. starts_with( & expected_prefix) ,
1350+ "expected sandbox denial prefix `{expected_prefix}` but got `{output_text}`"
1351+ ) ;
1352+
1353+ Ok ( ( ) )
1354+ }
1355+
12461356#[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
12471357async fn view_image_tool_returns_unsupported_message_for_text_only_model ( ) -> anyhow:: Result < ( ) > {
12481358 skip_if_no_network ! ( Ok ( ( ) ) ) ;
0 commit comments