mirror of
https://github.com/openai/codex.git
synced 2026-05-15 00:32:51 +00:00
## Why This bug is exposed by Guardian/auto-review approvals. With the managed network proxy enabled, a blocked network request can be reported back through the network approval service as an approval denial after the command has already started. Before this change, the shell and unified exec runtimes registered those network approval calls, but did not have a way to observe an async proxy denial as a cancellation/failure signal for the running process. The result was confusing: Guardian/auto-review could correctly deny network access, but the command path could keep running or unregister the approval without surfacing the denial as the command failure. ## What Changed - `NetworkApprovalService` now attaches a cancellation token to active and deferred network approvals. - Proxy-denial outcomes are recorded only for active registrations, cancel the owning token, and are consumed when the approval is finalized. - The shell runtime combines the normal command timeout with the network-denial cancellation token. - Unified exec stores the deferred network approval object, terminates tracked processes when the proxy denial arrives, and returns the denial as a process failure while polling or completing the process. - Tool orchestration passes the active network approval cancellation token into the sandbox attempt and preserves deferred approval errors instead of silently unregistering them. - App-server `command/exec` now handles the combined timeout-or-cancellation expiration variant used by the runtime. ## Verification - `cargo test -p codex-core network_approval --lib` - `cargo clippy -p codex-app-server --all-targets -- -D warnings` - `cargo clippy -p codex-core --all-targets -- -D warnings` --------- Co-authored-by: Codex <noreply@openai.com>
1076 lines
37 KiB
Rust
1076 lines
37 KiB
Rust
use super::*;
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_sandboxing::SandboxType;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::PathExt;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::time::timeout;
|
|
|
|
fn make_exec_output(
|
|
exit_code: i32,
|
|
stdout: &str,
|
|
stderr: &str,
|
|
aggregated: &str,
|
|
) -> ExecToolCallOutput {
|
|
ExecToolCallOutput {
|
|
exit_code,
|
|
stdout: StreamOutput::new(stdout.to_string()),
|
|
stderr: StreamOutput::new(stderr.to_string()),
|
|
aggregated_output: StreamOutput::new(aggregated.to_string()),
|
|
duration: Duration::from_millis(1),
|
|
timed_out: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detection_requires_keywords() {
|
|
let output = make_exec_output(/*exit_code*/ 1, "", "", "");
|
|
assert!(!is_likely_sandbox_denied(
|
|
SandboxType::LinuxSeccomp,
|
|
&output
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detection_identifies_keyword_in_stderr() {
|
|
let output = make_exec_output(/*exit_code*/ 1, "", "Operation not permitted", "");
|
|
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detection_respects_quick_reject_exit_codes() {
|
|
let output = make_exec_output(/*exit_code*/ 127, "", "command not found", "");
|
|
assert!(!is_likely_sandbox_denied(
|
|
SandboxType::LinuxSeccomp,
|
|
&output
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detection_ignores_non_sandbox_mode() {
|
|
let output = make_exec_output(/*exit_code*/ 1, "", "Operation not permitted", "");
|
|
assert!(!is_likely_sandbox_denied(SandboxType::None, &output));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() {
|
|
let output = make_exec_output(
|
|
/*exit_code*/ 0,
|
|
"",
|
|
"",
|
|
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#,
|
|
);
|
|
assert!(!is_likely_sandbox_denied(SandboxType::None, &output));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detection_uses_aggregated_output() {
|
|
let output = make_exec_output(
|
|
/*exit_code*/ 101,
|
|
"",
|
|
"",
|
|
"cargo failed: Read-only file system when writing target",
|
|
);
|
|
assert!(is_likely_sandbox_denied(
|
|
SandboxType::MacosSeatbelt,
|
|
&output
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_detection_ignores_network_policy_text_with_zero_exit_code() {
|
|
let output = make_exec_output(
|
|
/*exit_code*/ 0,
|
|
"",
|
|
"",
|
|
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"google.com","port":80}"#,
|
|
);
|
|
|
|
assert!(!is_likely_sandbox_denied(
|
|
SandboxType::LinuxSeccomp,
|
|
&output
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_output_limits_retained_bytes_for_shell_capture() {
|
|
let (mut writer, reader) = tokio::io::duplex(1024);
|
|
let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)];
|
|
tokio::spawn(async move {
|
|
writer.write_all(&bytes).await.expect("write");
|
|
});
|
|
|
|
let out = read_output(
|
|
reader,
|
|
/*stream*/ None,
|
|
/*is_stderr*/ false,
|
|
Some(EXEC_OUTPUT_MAX_BYTES),
|
|
)
|
|
.await
|
|
.expect("read");
|
|
assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_output_prefers_stderr_on_contention() {
|
|
let stdout = StreamOutput {
|
|
text: vec![b'a'; EXEC_OUTPUT_MAX_BYTES],
|
|
truncated_after_lines: None,
|
|
};
|
|
let stderr = StreamOutput {
|
|
text: vec![b'b'; EXEC_OUTPUT_MAX_BYTES],
|
|
truncated_after_lines: None,
|
|
};
|
|
|
|
let aggregated = aggregate_output(&stdout, &stderr, Some(EXEC_OUTPUT_MAX_BYTES));
|
|
let stdout_cap = EXEC_OUTPUT_MAX_BYTES / 3;
|
|
let stderr_cap = EXEC_OUTPUT_MAX_BYTES.saturating_sub(stdout_cap);
|
|
|
|
assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES);
|
|
assert_eq!(aggregated.text[..stdout_cap], vec![b'a'; stdout_cap]);
|
|
assert_eq!(aggregated.text[stdout_cap..], vec![b'b'; stderr_cap]);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_output_fills_remaining_capacity_with_stderr() {
|
|
let stdout_len = EXEC_OUTPUT_MAX_BYTES / 10;
|
|
let stdout = StreamOutput {
|
|
text: vec![b'a'; stdout_len],
|
|
truncated_after_lines: None,
|
|
};
|
|
let stderr = StreamOutput {
|
|
text: vec![b'b'; EXEC_OUTPUT_MAX_BYTES],
|
|
truncated_after_lines: None,
|
|
};
|
|
|
|
let aggregated = aggregate_output(&stdout, &stderr, Some(EXEC_OUTPUT_MAX_BYTES));
|
|
let stderr_cap = EXEC_OUTPUT_MAX_BYTES.saturating_sub(stdout_len);
|
|
|
|
assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES);
|
|
assert_eq!(aggregated.text[..stdout_len], vec![b'a'; stdout_len]);
|
|
assert_eq!(aggregated.text[stdout_len..], vec![b'b'; stderr_cap]);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_output_rebalances_when_stderr_is_small() {
|
|
let stdout = StreamOutput {
|
|
text: vec![b'a'; EXEC_OUTPUT_MAX_BYTES],
|
|
truncated_after_lines: None,
|
|
};
|
|
let stderr = StreamOutput {
|
|
text: vec![b'b'; 1],
|
|
truncated_after_lines: None,
|
|
};
|
|
|
|
let aggregated = aggregate_output(&stdout, &stderr, Some(EXEC_OUTPUT_MAX_BYTES));
|
|
let stdout_len = EXEC_OUTPUT_MAX_BYTES.saturating_sub(1);
|
|
|
|
assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES);
|
|
assert_eq!(aggregated.text[..stdout_len], vec![b'a'; stdout_len]);
|
|
assert_eq!(aggregated.text[stdout_len..], vec![b'b'; 1]);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_output_keeps_stdout_then_stderr_when_under_cap() {
|
|
let stdout = StreamOutput {
|
|
text: vec![b'a'; 4],
|
|
truncated_after_lines: None,
|
|
};
|
|
let stderr = StreamOutput {
|
|
text: vec![b'b'; 3],
|
|
truncated_after_lines: None,
|
|
};
|
|
|
|
let aggregated = aggregate_output(&stdout, &stderr, Some(EXEC_OUTPUT_MAX_BYTES));
|
|
let mut expected = Vec::new();
|
|
expected.extend_from_slice(&stdout.text);
|
|
expected.extend_from_slice(&stderr.text);
|
|
|
|
assert_eq!(aggregated.text, expected);
|
|
assert_eq!(aggregated.truncated_after_lines, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_output_retains_all_bytes_for_full_buffer_capture() {
|
|
let (mut writer, reader) = tokio::io::duplex(1024);
|
|
let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)];
|
|
let expected_len = bytes.len();
|
|
// The duplex pipe is smaller than `bytes`, so the writer must run concurrently
|
|
// with `read_output()` or `write_all()` will block once the buffer fills up.
|
|
tokio::spawn(async move {
|
|
writer.write_all(&bytes).await.expect("write");
|
|
});
|
|
|
|
let out = read_output(
|
|
reader, /*stream*/ None, /*is_stderr*/ false, /*max_bytes*/ None,
|
|
)
|
|
.await
|
|
.expect("read");
|
|
assert_eq!(out.text.len(), expected_len);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_output_keeps_all_bytes_when_uncapped() {
|
|
let stdout = StreamOutput {
|
|
text: vec![b'a'; EXEC_OUTPUT_MAX_BYTES],
|
|
truncated_after_lines: None,
|
|
};
|
|
let stderr = StreamOutput {
|
|
text: vec![b'b'; EXEC_OUTPUT_MAX_BYTES],
|
|
truncated_after_lines: None,
|
|
};
|
|
|
|
let aggregated = aggregate_output(&stdout, &stderr, /*max_bytes*/ None);
|
|
|
|
assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES * 2);
|
|
assert_eq!(
|
|
aggregated.text[..EXEC_OUTPUT_MAX_BYTES],
|
|
vec![b'a'; EXEC_OUTPUT_MAX_BYTES]
|
|
);
|
|
assert_eq!(
|
|
aggregated.text[EXEC_OUTPUT_MAX_BYTES..],
|
|
vec![b'b'; EXEC_OUTPUT_MAX_BYTES]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn full_buffer_capture_policy_disables_caps_and_exec_expiration() {
|
|
assert_eq!(ExecCapturePolicy::FullBuffer.retained_bytes_cap(), None);
|
|
assert_eq!(
|
|
ExecCapturePolicy::FullBuffer.io_drain_timeout(),
|
|
Duration::from_millis(IO_DRAIN_TIMEOUT_MS)
|
|
);
|
|
assert!(!ExecCapturePolicy::FullBuffer.uses_expiration());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> {
|
|
#[cfg(windows)]
|
|
let command = vec![
|
|
"powershell.exe".to_string(),
|
|
"-NonInteractive".to_string(),
|
|
"-NoLogo".to_string(),
|
|
"-Command".to_string(),
|
|
"Start-Sleep -Milliseconds 50; [Console]::Out.Write('hello')".to_string(),
|
|
];
|
|
#[cfg(not(windows))]
|
|
let command = vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"sleep 0.05; printf hello".to_string(),
|
|
];
|
|
|
|
let env: HashMap<String, String> = std::env::vars().collect();
|
|
let output = exec(
|
|
ExecParams {
|
|
command,
|
|
cwd: codex_utils_absolute_path::AbsolutePathBuf::current_dir()?,
|
|
expiration: 1.into(),
|
|
capture_policy: ExecCapturePolicy::FullBuffer,
|
|
env,
|
|
network: None,
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
windows_sandbox_private_desktop: false,
|
|
justification: None,
|
|
arg0: None,
|
|
},
|
|
NetworkSandboxPolicy::Enabled,
|
|
/*stdout_stream*/ None,
|
|
/*after_spawn*/ None,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(output.stdout.from_utf8_lossy().text.trim(), "hello");
|
|
assert!(!output.timed_out);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_pipe_open()
|
|
-> Result<()> {
|
|
let output = tokio::time::timeout(
|
|
Duration::from_millis(IO_DRAIN_TIMEOUT_MS * 3),
|
|
exec(
|
|
ExecParams {
|
|
command: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"printf hello; sleep 30 &".to_string(),
|
|
],
|
|
cwd: codex_utils_absolute_path::AbsolutePathBuf::current_dir()?,
|
|
expiration: 1.into(),
|
|
capture_policy: ExecCapturePolicy::FullBuffer,
|
|
env: std::env::vars().collect(),
|
|
network: None,
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
windows_sandbox_private_desktop: false,
|
|
justification: None,
|
|
arg0: None,
|
|
},
|
|
NetworkSandboxPolicy::Enabled,
|
|
/*stdout_stream*/ None,
|
|
/*after_spawn*/ None,
|
|
),
|
|
)
|
|
.await
|
|
.expect("full-buffer exec should return once the I/O drain guard fires")?;
|
|
|
|
assert!(!output.timed_out);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result<()> {
|
|
let byte_count = EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024);
|
|
#[cfg(windows)]
|
|
let command = vec![
|
|
"powershell.exe".to_string(),
|
|
"-NonInteractive".to_string(),
|
|
"-NoLogo".to_string(),
|
|
"-Command".to_string(),
|
|
format!("Start-Sleep -Milliseconds 50; [Console]::Out.Write('a' * {byte_count})"),
|
|
];
|
|
#[cfg(not(windows))]
|
|
let command = vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
format!("sleep 0.05; head -c {byte_count} /dev/zero | tr '\\0' 'a'"),
|
|
];
|
|
|
|
let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?;
|
|
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
|
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy);
|
|
let output = process_exec_tool_call(
|
|
ExecParams {
|
|
command,
|
|
cwd: cwd.clone(),
|
|
expiration: 1.into(),
|
|
capture_policy: ExecCapturePolicy::FullBuffer,
|
|
env: std::env::vars().collect(),
|
|
network: None,
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
windows_sandbox_private_desktop: false,
|
|
justification: None,
|
|
arg0: None,
|
|
},
|
|
&permission_profile,
|
|
&cwd,
|
|
&None,
|
|
/*use_legacy_landlock*/ false,
|
|
/*stdout_stream*/ None,
|
|
)
|
|
.await?;
|
|
|
|
assert!(!output.timed_out);
|
|
assert_eq!(output.stdout.text.len(), byte_count);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn windows_restricted_token_skips_external_sandbox_policies() {
|
|
let policy = SandboxPolicy::ExternalSandbox {
|
|
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
|
|
|
assert_eq!(
|
|
should_use_windows_restricted_token_sandbox(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
),
|
|
false
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_restricted_token_runs_for_legacy_restricted_policies() {
|
|
let policy = SandboxPolicy::new_read_only_policy();
|
|
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
|
|
|
assert_eq!(
|
|
should_use_windows_restricted_token_sandbox(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
),
|
|
true
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_proxy_enforcement_uses_elevated_backend() {
|
|
assert!(!windows_sandbox_uses_elevated_backend(
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
/*proxy_enforced*/ false,
|
|
));
|
|
assert!(windows_sandbox_uses_elevated_backend(
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
/*proxy_enforced*/ true,
|
|
));
|
|
assert!(windows_sandbox_uses_elevated_backend(
|
|
WindowsSandboxLevel::Elevated,
|
|
/*proxy_enforced*/ false,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_restricted_token_rejects_network_only_restrictions() {
|
|
let policy = SandboxPolicy::ExternalSandbox {
|
|
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::unrestricted();
|
|
let sandbox_policy_cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&sandbox_policy_cwd,
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
),
|
|
Some(
|
|
"windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_restricted_token_allows_legacy_restricted_policies() {
|
|
let policy = SandboxPolicy::new_read_only_policy();
|
|
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
|
let sandbox_policy_cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&sandbox_policy_cwd,
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_restricted_token_allows_legacy_workspace_write_policies() {
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
|
let sandbox_policy_cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&sandbox_policy_cwd,
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_elevated_allows_split_restricted_read_policies() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let docs = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
|
|
temp_dir.path().join("docs"),
|
|
)
|
|
.expect("absolute docs");
|
|
std::fs::create_dir_all(docs.as_path()).expect("create docs");
|
|
let policy = SandboxPolicy::ReadOnly {
|
|
network_access: false,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path { path: docs },
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&temp_dir.path().abs(),
|
|
WindowsSandboxLevel::Elevated,
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_restricted_token_rejects_split_only_filesystem_policies() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let docs = temp_dir.path().join("docs");
|
|
std::fs::create_dir_all(&docs).expect("create docs");
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
|
|
/*subpath*/ None,
|
|
),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path {
|
|
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
|
.expect("absolute docs"),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&temp_dir.path().abs(),
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
),
|
|
Some(
|
|
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_restricted_token_rejects_root_write_read_only_carveouts() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let docs = temp_dir.path().join("docs");
|
|
std::fs::create_dir_all(&docs).expect("create docs");
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path {
|
|
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
|
.expect("absolute docs"),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&temp_dir.path().abs(),
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
),
|
|
Some(
|
|
"windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed"
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let cwd = dunce::canonicalize(temp_dir.path())
|
|
.expect("canonicalize temp dir")
|
|
.abs();
|
|
let docs = cwd.join("docs");
|
|
std::fs::create_dir_all(docs.as_path()).expect("create docs");
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
|
|
/*subpath*/ None,
|
|
),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path { path: docs.clone() },
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
// The legacy workspace-write root already protects top-level `.codex`, so
|
|
// the restricted-token overlay only needs the extra read-only docs carveout.
|
|
let expected_deny_write_paths = vec![docs];
|
|
|
|
assert_eq!(
|
|
resolve_windows_restricted_token_filesystem_overrides(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&cwd,
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
),
|
|
Ok(Some(WindowsSandboxFilesystemOverrides {
|
|
read_roots_override: None,
|
|
read_roots_include_platform_defaults: false,
|
|
write_roots_override: None,
|
|
additional_deny_write_paths: expected_deny_write_paths,
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_elevated_supports_split_restricted_read_roots() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let docs = temp_dir.path().join("docs");
|
|
std::fs::create_dir_all(&docs).expect("create docs");
|
|
let expected_docs = dunce::canonicalize(&docs).expect("canonical docs");
|
|
let policy = SandboxPolicy::ReadOnly {
|
|
network_access: false,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path {
|
|
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
|
.expect("absolute docs"),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
resolve_windows_elevated_filesystem_overrides(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&temp_dir.path().abs(),
|
|
/*use_windows_elevated_backend*/ true,
|
|
),
|
|
Ok(Some(WindowsSandboxFilesystemOverrides {
|
|
read_roots_override: Some(vec![expected_docs]),
|
|
read_roots_include_platform_defaults: false,
|
|
write_roots_override: None,
|
|
additional_deny_write_paths: vec![],
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_elevated_supports_split_write_read_carveouts() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let docs = temp_dir.path().join("docs");
|
|
std::fs::create_dir_all(&docs).expect("create docs");
|
|
let expected_docs = dunce::canonicalize(&docs).expect("canonical docs");
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
|
|
/*subpath*/ None,
|
|
),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path {
|
|
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
|
.expect("absolute docs"),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
resolve_windows_elevated_filesystem_overrides(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&temp_dir.path().abs(),
|
|
/*use_windows_elevated_backend*/ true,
|
|
),
|
|
Ok(Some(WindowsSandboxFilesystemOverrides {
|
|
read_roots_override: None,
|
|
read_roots_include_platform_defaults: false,
|
|
write_roots_override: None,
|
|
additional_deny_write_paths: vec![
|
|
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(expected_docs)
|
|
.expect("absolute docs"),
|
|
],
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_elevated_rejects_unreadable_split_carveouts() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let blocked = temp_dir.path().join("blocked");
|
|
std::fs::create_dir_all(&blocked).expect("create blocked");
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
|
|
/*subpath*/ None,
|
|
),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path {
|
|
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&blocked)
|
|
.expect("absolute blocked"),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&temp_dir.path().abs(),
|
|
WindowsSandboxLevel::Elevated,
|
|
),
|
|
Some(
|
|
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_elevated_rejects_unreadable_globs() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
|
|
/*subpath*/ None,
|
|
),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&temp_dir.path().abs(),
|
|
WindowsSandboxLevel::Elevated,
|
|
),
|
|
Some(
|
|
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_elevated_rejects_reopened_writable_descendants() {
|
|
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
|
let docs = temp_dir.path().join("docs");
|
|
let nested = docs.join("nested");
|
|
std::fs::create_dir_all(&nested).expect("create nested");
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
|
|
/*subpath*/ None,
|
|
),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path {
|
|
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
|
.expect("absolute docs"),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
|
},
|
|
codex_protocol::permissions::FileSystemSandboxEntry {
|
|
path: codex_protocol::permissions::FileSystemPath::Path {
|
|
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&nested)
|
|
.expect("absolute nested"),
|
|
},
|
|
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
unsupported_windows_restricted_token_sandbox_reason(
|
|
SandboxType::WindowsRestrictedToken,
|
|
&policy,
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
&temp_dir.path().abs(),
|
|
WindowsSandboxLevel::Elevated,
|
|
),
|
|
Some(
|
|
"windows elevated sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed"
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() {
|
|
let expected = codex_sandboxing::get_platform_sandbox(/*windows_sandbox_enabled*/ false)
|
|
.unwrap_or(SandboxType::None);
|
|
|
|
assert_eq!(
|
|
select_process_exec_tool_sandbox_type(
|
|
&FileSystemSandboxPolicy::unrestricted(),
|
|
NetworkSandboxPolicy::Restricted,
|
|
codex_protocol::config_types::WindowsSandboxLevel::Disabled,
|
|
/*enforce_managed_network*/ false,
|
|
),
|
|
expected
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn sandbox_detection_flags_sigsys_exit_code() {
|
|
let exit_code = EXIT_CODE_SIGNAL_BASE + libc::SIGSYS;
|
|
let output = make_exec_output(exit_code, "", "", "");
|
|
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> {
|
|
// On Linux/macOS, /bin/bash is typically present; on FreeBSD/OpenBSD,
|
|
// prefer /bin/sh to avoid NotFound errors.
|
|
#[cfg(any(target_os = "freebsd", target_os = "openbsd"))]
|
|
let command = vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"sleep 60 & echo $!; sleep 60".to_string(),
|
|
];
|
|
#[cfg(all(unix, not(any(target_os = "freebsd", target_os = "openbsd"))))]
|
|
let command = vec![
|
|
"/bin/bash".to_string(),
|
|
"-c".to_string(),
|
|
"sleep 60 & echo $!; sleep 60".to_string(),
|
|
];
|
|
let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?;
|
|
let env: HashMap<String, String> = std::env::vars().collect();
|
|
let params = ExecParams {
|
|
command,
|
|
cwd,
|
|
expiration: 500.into(),
|
|
capture_policy: ExecCapturePolicy::ShellTool,
|
|
env,
|
|
network: None,
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
|
|
windows_sandbox_private_desktop: false,
|
|
justification: None,
|
|
arg0: None,
|
|
};
|
|
|
|
let output = exec(
|
|
params,
|
|
NetworkSandboxPolicy::Restricted,
|
|
/*stdout_stream*/ None,
|
|
/*after_spawn*/ None,
|
|
)
|
|
.await?;
|
|
assert!(output.timed_out);
|
|
|
|
let stdout = output.stdout.from_utf8_lossy().text;
|
|
let pid_line = stdout.lines().next().unwrap_or("").trim();
|
|
let pid: i32 = pid_line.parse().map_err(|error| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("Failed to parse pid from stdout '{pid_line}': {error}"),
|
|
)
|
|
})?;
|
|
|
|
let mut killed = false;
|
|
for _ in 0..20 {
|
|
// Use kill(pid, 0) to check if the process is alive.
|
|
if unsafe { libc::kill(pid, 0) } == -1
|
|
&& let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error()
|
|
{
|
|
killed = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
|
|
assert!(killed, "grandchild process with pid {pid} is still alive");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
|
|
let command = long_running_command();
|
|
let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?;
|
|
let env: HashMap<String, String> = std::env::vars().collect();
|
|
let cancel_token = CancellationToken::new();
|
|
let cancel_tx = cancel_token.clone();
|
|
let params = ExecParams {
|
|
command,
|
|
cwd: cwd.clone(),
|
|
expiration: ExecExpiration::Cancellation(cancel_token),
|
|
capture_policy: ExecCapturePolicy::ShellTool,
|
|
env,
|
|
network: None,
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
|
|
windows_sandbox_private_desktop: false,
|
|
justification: None,
|
|
arg0: None,
|
|
};
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_millis(1_000)).await;
|
|
cancel_tx.cancel();
|
|
});
|
|
let result = timeout(
|
|
Duration::from_secs(5),
|
|
process_exec_tool_call(
|
|
params,
|
|
&PermissionProfile::Disabled,
|
|
&cwd,
|
|
&None,
|
|
/*use_legacy_landlock*/ false,
|
|
/*stdout_stream*/ None,
|
|
),
|
|
)
|
|
.await
|
|
.expect("cancellation should stop the process promptly");
|
|
let output = result.expect("cancellation should return a non-timeout exec result");
|
|
assert!(!output.timed_out);
|
|
assert_ne!(output.exit_code, 0);
|
|
assert_ne!(output.exit_code, EXEC_TIMEOUT_EXIT_CODE);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn long_running_command() -> Vec<String> {
|
|
vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"sleep 30".to_string(),
|
|
]
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn long_running_command() -> Vec<String> {
|
|
vec![
|
|
"powershell.exe".to_string(),
|
|
"-NonInteractive".to_string(),
|
|
"-NoLogo".to_string(),
|
|
"-Command".to_string(),
|
|
"Start-Sleep -Seconds 30".to_string(),
|
|
]
|
|
}
|