feat: add output even in sandbox denied (#5908)
This commit is contained in:
@@ -268,11 +268,22 @@ impl Expectation {
|
||||
"expected non-zero exit for {path:?}"
|
||||
);
|
||||
for needle in *message_contains {
|
||||
assert!(
|
||||
result.stdout.contains(needle),
|
||||
"stdout missing {needle:?}: {}",
|
||||
result.stdout
|
||||
);
|
||||
if needle.contains('|') {
|
||||
let options: Vec<&str> = needle.split('|').collect();
|
||||
let matches_any =
|
||||
options.iter().any(|option| result.stdout.contains(option));
|
||||
assert!(
|
||||
matches_any,
|
||||
"stdout missing one of {options:?}: {}",
|
||||
result.stdout
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
result.stdout.contains(needle),
|
||||
"stdout missing {needle:?}: {}",
|
||||
result.stdout
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!path.exists(),
|
||||
@@ -901,7 +912,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
message_contains: if cfg!(target_os = "linux") {
|
||||
&["Permission denied"]
|
||||
} else {
|
||||
&["failed in sandbox"]
|
||||
&["Permission denied|Operation not permitted|Read-only file system"]
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1045,7 +1056,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
message_contains: if cfg!(target_os = "linux") {
|
||||
&["Permission denied"]
|
||||
} else {
|
||||
&["failed in sandbox"]
|
||||
&["Permission denied|Operation not permitted|Read-only file system"]
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::model_family::find_family_for_model;
|
||||
@@ -227,6 +228,103 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn sandbox_denied_shell_returns_original_output() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.model = "gpt-5-codex".to_string();
|
||||
config.model_family =
|
||||
find_family_for_model("gpt-5-codex").expect("gpt-5-codex model family");
|
||||
});
|
||||
let fixture = builder.build(&server).await?;
|
||||
|
||||
let call_id = "sandbox-denied-shell";
|
||||
let target_path = fixture.workspace_path("sandbox-denied.txt");
|
||||
let sentinel = "sandbox-denied sentinel output";
|
||||
let command = vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
format!(
|
||||
"printf {sentinel:?}; printf {content:?} > {path:?}",
|
||||
sentinel = format!("{sentinel}\n"),
|
||||
content = "sandbox denied",
|
||||
path = &target_path
|
||||
),
|
||||
];
|
||||
let args = json!({
|
||||
"command": command,
|
||||
"timeout_ms": 1_000,
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
];
|
||||
let mock = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
fixture
|
||||
.submit_turn_with_policy(
|
||||
"run a command that should be denied by the read-only sandbox",
|
||||
SandboxPolicy::ReadOnly,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let output_text = mock
|
||||
.function_call_output_text(call_id)
|
||||
.context("shell output present")?;
|
||||
let exit_code_line = output_text
|
||||
.lines()
|
||||
.next()
|
||||
.context("exit code line present")?;
|
||||
let exit_code = exit_code_line
|
||||
.strip_prefix("Exit code: ")
|
||||
.context("exit code prefix present")?
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.context("exit code is integer")?;
|
||||
let body = output_text;
|
||||
|
||||
let body_lower = body.to_lowercase();
|
||||
// Required for multi-OS.
|
||||
let has_denial = body_lower.contains("permission denied")
|
||||
|| body_lower.contains("operation not permitted")
|
||||
|| body_lower.contains("read-only file system");
|
||||
assert!(
|
||||
has_denial,
|
||||
"expected sandbox denial details in tool output: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(sentinel),
|
||||
"expected sentinel output from command to reach the model: {body}"
|
||||
);
|
||||
let target_path_str = target_path
|
||||
.to_str()
|
||||
.context("target path string representation")?;
|
||||
assert!(
|
||||
body.contains(target_path_str),
|
||||
"expected sandbox error to mention denied path: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body_lower.contains("failed in sandbox"),
|
||||
"expected original tool output, found fallback message: {body}"
|
||||
);
|
||||
assert_ne!(
|
||||
exit_code, 0,
|
||||
"sandbox denial should surface a non-zero exit code"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user