Skip to content

Commit b1141b4

Browse files
committed
core: route view_image through a sandbox-backed fs helper
1 parent 2e1d275 commit b1141b4

File tree

2 files changed

+150
-34
lines changed

2 files changed

+150
-34
lines changed

codex-rs/core/src/tools/handlers/view_image.rs

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use async_trait::async_trait;
2-
use codex_exec_server::ExecutorFileSystem;
32
use codex_protocol::models::FunctionCallOutputBody;
43
use codex_protocol::models::FunctionCallOutputContentItem;
54
use codex_protocol::models::FunctionCallOutputPayload;
@@ -15,6 +14,7 @@ use crate::function_tool::FunctionCallError;
1514
use crate::original_image_detail::can_request_original_image_detail;
1615
use crate::protocol::EventMsg;
1716
use crate::protocol::ViewImageToolCallEvent;
17+
use crate::sandboxed_fs;
1818
use crate::tools::context::ToolInvocation;
1919
use crate::tools::context::ToolOutput;
2020
use crate::tools::context::ToolPayload;
@@ -94,36 +94,6 @@ impl ToolHandler for ViewImageHandler {
9494
AbsolutePathBuf::try_from(turn.resolve_path(Some(args.path))).map_err(|error| {
9595
FunctionCallError::RespondToModel(format!("unable to resolve image path: {error}"))
9696
})?;
97-
98-
let metadata = turn
99-
.environment
100-
.get_filesystem()
101-
.get_metadata(&abs_path)
102-
.await
103-
.map_err(|error| {
104-
FunctionCallError::RespondToModel(format!(
105-
"unable to locate image at `{}`: {error}",
106-
abs_path.display()
107-
))
108-
})?;
109-
110-
if !metadata.is_file {
111-
return Err(FunctionCallError::RespondToModel(format!(
112-
"image path `{}` is not a file",
113-
abs_path.display()
114-
)));
115-
}
116-
let file_bytes = turn
117-
.environment
118-
.get_filesystem()
119-
.read_file(&abs_path)
120-
.await
121-
.map_err(|error| {
122-
FunctionCallError::RespondToModel(format!(
123-
"unable to read image at `{}`: {error}",
124-
abs_path.display()
125-
))
126-
})?;
12797
let event_path = abs_path.to_path_buf();
12898

12999
let can_request_original_detail =
@@ -136,14 +106,23 @@ impl ToolHandler for ViewImageHandler {
136106
PromptImageMode::ResizeToFit
137107
};
138108
let image_detail = use_original_detail.then_some(ImageDetail::Original);
109+
let image_bytes = sandboxed_fs::read_file(&session, &turn, abs_path.as_path())
110+
.await
111+
.map_err(|error| {
112+
FunctionCallError::RespondToModel(render_view_image_read_error(
113+
abs_path.as_path(),
114+
&error,
115+
))
116+
})?;
139117

140-
let image =
141-
load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode).map_err(|error| {
118+
let image = load_for_prompt_bytes(abs_path.as_path(), image_bytes, image_mode).map_err(
119+
|error| {
142120
FunctionCallError::RespondToModel(format!(
143121
"unable to process image at `{}`: {error}",
144122
abs_path.display()
145123
))
146-
})?;
124+
},
125+
)?;
147126
let image_url = image.into_data_url();
148127

149128
session
@@ -202,6 +181,33 @@ impl ToolOutput for ViewImageOutput {
202181
}
203182
}
204183

184+
fn render_view_image_read_error(
185+
path: &std::path::Path,
186+
error: &sandboxed_fs::SandboxedFsError,
187+
) -> String {
188+
let operation_message = error
189+
.operation_error_message()
190+
.map(str::to_owned)
191+
.unwrap_or_else(|| error.to_string());
192+
match error.operation_error_kind() {
193+
Some(codex_fs_ops::FsErrorKind::IsADirectory) => {
194+
format!("image path `{}` is not a file", path.display())
195+
}
196+
Some(codex_fs_ops::FsErrorKind::NotFound) => {
197+
format!(
198+
"unable to locate image at `{}`: {operation_message}",
199+
path.display()
200+
)
201+
}
202+
Some(_) | None => {
203+
format!(
204+
"unable to read image at `{}`: {operation_message}",
205+
path.display()
206+
)
207+
}
208+
}
209+
}
210+
205211
#[cfg(test)]
206212
mod tests {
207213
use super::*;

codex-rs/core/tests/suite/view_image.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use base64::Engine;
44
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
55
use codex_core::CodexAuth;
6+
use codex_core::config::Constrained;
67
use codex_core::features::Feature;
78
use codex_protocol::config_types::ReasoningSummary;
89
use codex_protocol::openai_models::ConfigShellToolType;
@@ -13,9 +14,15 @@ use codex_protocol::openai_models::ModelsResponse;
1314
use codex_protocol::openai_models::ReasoningEffort;
1415
use codex_protocol::openai_models::ReasoningEffortPreset;
1516
use 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;
1622
use codex_protocol::protocol::AskForApproval;
1723
use codex_protocol::protocol::EventMsg;
1824
use codex_protocol::protocol::Op;
25+
use codex_protocol::protocol::ReadOnlyAccess;
1926
use codex_protocol::protocol::SandboxPolicy;
2027
use codex_protocol::user_input::UserInput;
2128
use 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)]
12471357
async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> anyhow::Result<()> {
12481358
skip_if_no_network!(Ok(()));

0 commit comments

Comments
 (0)