Record normalized sandbox violation events (#36207)

## Why

Filesystem denials and managed-network blocks did not share a structured event
shape, requiring downstream consumers to rediscover enforcement paths and parse
backend-specific output. See https://github.com/openai/codex/pull/17573.

## What changed

- Add normalized filesystem and network violation types in `codex-sandboxing`
  and emit them through a shared tracing seam.
- Classify filesystem denials by backend and reason, retaining an optional path
  and bounded output snippet, and preserve managed-network block context.
- Report the sandbox type through exec-server responses so unified exec can
  classify remote denials without guessing; omitted values remain compatible
  with older peers.
- Record violations from exec, apply-patch, shell-escalation, unified-exec, and
  managed-network enforcement paths without changing denial behavior.

## Testing

- Cover filesystem classification, path extraction, `SIGSYS`, network event
  conversion, protocol compatibility, and remote sandbox-type propagation.

GitOrigin-RevId: d673173b4fa6bdf0a24421194a8a61c81fab9c96
This commit is contained in:
viyatb-oai
2026-07-30 19:23:33 +00:00
committed by copyberry
parent 789c72dcf6
commit 0042b00986
21 changed files with 724 additions and 29 deletions
+2
View File
@@ -44,6 +44,7 @@ use codex_sandboxing::WindowsSandboxFilesystemOverrides;
pub(crate) use codex_sandboxing::is_likely_sandbox_denied;
#[cfg(test)]
use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox;
use codex_sandboxing::record_filesystem_sandbox_violation;
use codex_sandboxing::resolve_windows_elevated_filesystem_overrides;
use codex_sandboxing::resolve_windows_restricted_token_filesystem_overrides;
#[cfg(test)]
@@ -814,6 +815,7 @@ fn finalize_exec_result(
}
if is_likely_sandbox_denied(sandbox_type, &exec_output) {
record_filesystem_sandbox_violation(sandbox_type, &exec_output);
return Err(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(exec_output),
network_policy_decision: None,
+3 -5
View File
@@ -1112,11 +1112,9 @@ impl Session {
current_exec_policy.as_ref(),
&session_configuration.permission_profile(),
/*network_policy_decider*/ None,
self.services
.managed_network_requirements_configured
.then(|| {
build_blocked_request_observer(Arc::clone(&self.services.network_approval))
}),
Some(build_blocked_request_observer(Arc::clone(
&self.services.network_approval,
))),
self.services.managed_network_requirements_configured,
self.services.network_proxy_audit_metadata.clone(),
)
+5 -9
View File
@@ -996,15 +996,11 @@ impl Session {
} else {
None
};
let blocked_request_observer = if managed_network_requirements_configured {
config
.permissions
.network
.as_ref()
.map(|_| build_blocked_request_observer(Arc::clone(&network_approval)))
} else {
None
};
let blocked_request_observer = config
.permissions
.network
.as_ref()
.map(|_| build_blocked_request_observer(Arc::clone(&network_approval)));
let network_policy_decider =
network_policy_decider_session
.as_ref()
@@ -28,6 +28,7 @@ use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::WarningEvent;
use codex_sandboxing::record_network_sandbox_violation;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -1047,6 +1048,7 @@ pub(crate) fn build_blocked_request_observer(
Arc::new(move |blocked: BlockedRequest| {
let network_approval = Arc::clone(&network_approval);
async move {
record_network_sandbox_violation(&blocked);
network_approval.record_blocked_request(blocked).await;
}
})
@@ -31,6 +31,7 @@ use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_sandboxing::policy_transforms::effective_permission_profile;
use codex_sandboxing::record_filesystem_sandbox_violation;
use codex_utils_path_uri::PathUri;
use futures::future::BoxFuture;
use std::path::PathBuf;
@@ -259,6 +260,7 @@ impl ToolRuntime<ApplyPatchRequest, ApplyPatchRuntimeOutput> for ApplyPatchRunti
timed_out: false,
};
if failed && is_likely_sandbox_denied(attempt.sandbox, &output) {
record_filesystem_sandbox_violation(attempt.sandbox, &output);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
@@ -45,6 +45,7 @@ use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_sandboxing::record_filesystem_sandbox_violation;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
use codex_shell_escalation::EscalateServer;
@@ -1061,6 +1062,7 @@ fn map_exec_result(
}
if is_likely_sandbox_denied(sandbox, &output) {
record_filesystem_sandbox_violation(sandbox, &output);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
@@ -304,6 +304,7 @@ async fn blocking_terminate_unified_process(
allow_terminate,
wake_tx,
}),
sandbox_type: Some(codex_sandboxing::SandboxType::None),
})
.await?,
))
+11 -7
View File
@@ -12,7 +12,6 @@ use tokio::task::JoinHandle;
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::exec::is_likely_sandbox_denied;
use codex_exec_server::ExecProcess;
use codex_exec_server::ExecProcessEvent;
use codex_exec_server::ProcessSignal as ExecServerProcessSignal;
@@ -23,6 +22,8 @@ use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::exec_output::StreamOutput;
use codex_protocol::protocol::TruncationPolicy;
use codex_sandboxing::SandboxType;
use codex_sandboxing::is_likely_sandbox_denied;
use codex_sandboxing::record_filesystem_sandbox_violation;
use codex_utils_output_truncation::formatted_truncate_text;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::ProcessSignal as PtyProcessSignal;
@@ -315,7 +316,11 @@ impl UnifiedExecProcess {
aggregated_output: StreamOutput::new(text.to_string()),
..Default::default()
};
if executor_reported_denial || is_likely_sandbox_denied(sandbox_type, &exec_output) {
let likely_sandbox_denial = is_likely_sandbox_denied(sandbox_type, &exec_output);
if likely_sandbox_denial {
record_filesystem_sandbox_violation(sandbox_type, &exec_output);
}
if executor_reported_denial || likely_sandbox_denial {
let snippet = formatted_truncate_text(
text,
TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS),
@@ -394,11 +399,10 @@ impl UnifiedExecProcess {
started: StartedExecProcess,
) -> Result<Self, UnifiedExecError> {
let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process));
let mut managed = Self::new(
process_handle,
SandboxType::None,
/*spawn_lifecycle*/ None,
);
// Older peers do not report this field. In that case, skip local
// classification rather than attributing a violation to a guessed backend.
let sandbox_type = started.sandbox_type.unwrap_or(SandboxType::None);
let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None);
let output_handles = managed.output_handles();
managed.output_task = Some(Self::spawn_exec_server_output_task(
started,
@@ -512,6 +512,7 @@ async fn pruning_does_not_evict_live_process_while_exited_process_is_finalizing(
crate::unified_exec::process_tests::remote_process(
codex_exec_server::WriteStatus::Accepted,
/*terminate_error*/ None,
codex_sandboxing::SandboxType::None,
)
.await,
);
@@ -523,6 +524,7 @@ async fn pruning_does_not_evict_live_process_while_exited_process_is_finalizing(
crate::unified_exec::process_tests::remote_process(
codex_exec_server::WriteStatus::Accepted,
/*terminate_error*/ None,
codex_sandboxing::SandboxType::None,
)
.await,
);
@@ -88,6 +88,7 @@ impl ExecProcess for MockExecProcess {
pub(super) async fn remote_process(
write_status: WriteStatus,
terminate_error: Option<String>,
sandbox_type: codex_sandboxing::SandboxType,
) -> UnifiedExecProcess {
let (wake_tx, _wake_rx) = watch::channel(0);
let started = StartedExecProcess {
@@ -100,6 +101,7 @@ pub(super) async fn remote_process(
terminate_error,
wake_tx,
}),
sandbox_type: Some(sandbox_type),
};
UnifiedExecProcess::from_exec_server_started(started)
@@ -109,7 +111,12 @@ pub(super) async fn remote_process(
#[tokio::test]
async fn remote_write_unknown_process_marks_process_exited() {
let process = remote_process(WriteStatus::UnknownProcess, /*terminate_error*/ None).await;
let process = remote_process(
WriteStatus::UnknownProcess,
/*terminate_error*/ None,
codex_sandboxing::SandboxType::None,
)
.await;
let err = process
.write(b"hello")
@@ -122,7 +129,12 @@ async fn remote_write_unknown_process_marks_process_exited() {
#[tokio::test]
async fn remote_write_closed_stdin_marks_process_exited() {
let process = remote_process(WriteStatus::StdinClosed, /*terminate_error*/ None).await;
let process = remote_process(
WriteStatus::StdinClosed,
/*terminate_error*/ None,
codex_sandboxing::SandboxType::None,
)
.await;
let err = process
.write(b"hello")
@@ -135,7 +147,12 @@ async fn remote_write_closed_stdin_marks_process_exited() {
#[tokio::test]
async fn fail_and_terminate_preserves_failure_message() {
let process = remote_process(WriteStatus::Accepted, /*terminate_error*/ None).await;
let process = remote_process(
WriteStatus::Accepted,
/*terminate_error*/ None,
codex_sandboxing::SandboxType::None,
)
.await;
process.fail_and_terminate("network denied".to_string());
process.fail_and_terminate("second failure".to_string());
@@ -152,6 +169,7 @@ async fn remote_terminate_confirmed_updates_state_on_success_only() {
let process = remote_process(
WriteStatus::Accepted,
Some("terminate unavailable".to_string()),
codex_sandboxing::SandboxType::None,
)
.await;
@@ -163,7 +181,12 @@ async fn remote_terminate_confirmed_updates_state_on_success_only() {
assert!(matches!(err, UnifiedExecError::ProcessFailed { .. }));
assert!(!process.has_exited());
let process = remote_process(WriteStatus::Accepted, /*terminate_error*/ None).await;
let process = remote_process(
WriteStatus::Accepted,
/*terminate_error*/ None,
codex_sandboxing::SandboxType::None,
)
.await;
process
.terminate_confirmed()
@@ -172,3 +195,18 @@ async fn remote_terminate_confirmed_updates_state_on_success_only() {
assert!(process.has_exited());
}
#[tokio::test]
async fn remote_process_preserves_executor_sandbox_type() {
let process = remote_process(
WriteStatus::Accepted,
/*terminate_error*/ None,
codex_sandboxing::SandboxType::LinuxSeccomp,
)
.await;
assert_eq!(
process.sandbox_type(),
codex_sandboxing::SandboxType::LinuxSeccomp
);
}
@@ -215,6 +215,20 @@ pub struct ExecEnvPolicy {
#[serde(rename_all = "camelCase")]
pub struct ExecResponse {
pub process_id: ProcessId,
/// `None` means the peer did not report its sandbox type. Current peers
/// report [`ProcessSandboxType::None`] when the process was not sandboxed.
#[serde(default)]
pub sandbox_type: Option<ProcessSandboxType>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProcessSandboxType {
/// The process was explicitly started without a platform sandbox.
None,
MacosSeatbelt,
LinuxSeccomp,
WindowsRestrictedToken,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -747,9 +761,11 @@ mod tests {
use super::EnvironmentInfo;
use super::ExecExitedNotification;
use super::ExecParams;
use super::ExecResponse;
use super::FsReadFileParams;
use super::HttpRequestParams;
use super::ProcessId;
use super::ProcessSandboxType;
use super::ShellInfo;
use codex_file_system::FileSystemSandboxContext;
use codex_network_proxy::ManagedNetworkSandboxContext;
@@ -1050,4 +1066,22 @@ mod tests {
assert_eq!(notification.sandbox_denied, None);
}
#[test]
fn exec_response_distinguishes_unknown_from_explicitly_unsandboxed() {
let unknown: ExecResponse = serde_json::from_value(serde_json::json!({
"processId": "legacy",
}))
.expect("legacy response should deserialize");
let unsandboxed: ExecResponse = serde_json::from_value(serde_json::json!({
"processId": "current",
"sandboxType": "none",
}))
.expect("explicitly unsandboxed response should deserialize");
assert_eq!(
(unknown.sandbox_type, unsandboxed.sandbox_type),
(None, Some(ProcessSandboxType::None))
);
}
}
+15 -1
View File
@@ -104,6 +104,7 @@ use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::InitializeResponse;
use crate::protocol::ProcessOutputChunk;
use crate::protocol::ProcessSandboxType;
use crate::protocol::ProcessSignal;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
@@ -211,6 +212,7 @@ struct OrderedSessionEvents {
pub(crate) struct Session {
client: ExecServerClient,
process_id: ProcessId,
sandbox_type: Option<ProcessSandboxType>,
state: Arc<SessionState>,
}
@@ -936,11 +938,12 @@ impl ExecServerClient {
.call_rpc::<_, ExecResponse>(&rpc_client, EXEC_METHOD, &params)
.await
{
Ok(_) => {
Ok(response) => {
state.recoverable.store(true, Ordering::Release);
let session = Session {
client: client.clone(),
process_id: process_id.clone(),
sandbox_type: response.sandbox_type,
state: Arc::clone(&state),
};
// Wait for caller receipt so cancellation after send still triggers cleanup.
@@ -990,6 +993,7 @@ impl ExecServerClient {
Ok(Session {
client: self.clone(),
process_id: process_id.clone(),
sandbox_type: None,
state,
})
}
@@ -1340,6 +1344,10 @@ impl Session {
&self.process_id
}
pub(crate) fn sandbox_type(&self) -> Option<ProcessSandboxType> {
self.sandbox_type
}
pub(crate) fn subscribe_wake(&self) -> watch::Receiver<u64> {
self.state.subscribe()
}
@@ -1662,6 +1670,7 @@ mod tests {
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeResponse;
use crate::protocol::ProcessOutputChunk;
use crate::protocol::ProcessSandboxType;
use crate::protocol::ReadResponse;
use crate::protocol::WriteParams;
use crate::protocol::WriteResponse;
@@ -1733,6 +1742,7 @@ mod tests {
id: request.id,
result: serde_json::to_value(ExecResponse {
process_id: params.process_id,
sandbox_type: Some(ProcessSandboxType::LinuxSeccomp),
})
.expect("process start response should serialize"),
}),
@@ -1790,6 +1800,10 @@ mod tests {
.expect("process start should succeed");
assert_eq!(session.process_id(), &process_id);
assert_eq!(
session.sandbox_type(),
Some(ProcessSandboxType::LinuxSeccomp)
);
let trace = server.await.expect("server task").expect("trace context");
let expected_traceparent = expected_trace
.traceparent
+20 -1
View File
@@ -36,6 +36,7 @@ use crate::ProcessId;
use crate::StartedExecProcess;
use crate::network_policy_decisions::network_policy_decider;
use crate::process::ExecProcessEventLog;
use crate::process::sandbox_type_from_protocol;
use crate::process_sandbox::prepare_exec_request;
use crate::protocol::EXEC_CLOSED_METHOD;
use crate::protocol::ExecClosedNotification;
@@ -47,6 +48,7 @@ use crate::protocol::ExecParams;
use crate::protocol::ExecResponse;
use crate::protocol::MAX_NETWORK_POLICY_PROCESS_ID_BYTES;
use crate::protocol::ProcessOutputChunk;
use crate::protocol::ProcessSandboxType;
use crate::protocol::ProcessSignal;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
@@ -302,6 +304,12 @@ impl LocalProcess {
if prepared.command.is_empty() {
return Err(invalid_params("argv must not be empty".to_string()));
}
let sandbox_type = match prepared.sandbox {
SandboxType::None => Some(ProcessSandboxType::None),
SandboxType::MacosSeatbelt => Some(ProcessSandboxType::MacosSeatbelt),
SandboxType::LinuxSeccomp => Some(ProcessSandboxType::LinuxSeccomp),
SandboxType::WindowsRestrictedToken => Some(ProcessSandboxType::WindowsRestrictedToken),
};
let start = Arc::new(ProcessStart);
{
@@ -417,7 +425,14 @@ impl LocalProcess {
output_notify,
));
Ok((ExecResponse { process_id }, wake_tx, events))
Ok((
ExecResponse {
process_id,
sandbox_type,
},
wake_tx,
events,
))
}
pub(crate) async fn exec(&self, params: ExecParams) -> Result<ExecResponse, JSONRPCErrorError> {
@@ -662,6 +677,7 @@ impl LocalProcess {
.start_process(params)
.await
.map_err(map_handler_error)?;
let sandbox_type = sandbox_type_from_protocol(response.sandbox_type);
Ok(StartedExecProcess {
process: Arc::new(LocalExecProcess {
process_id: response.process_id,
@@ -669,6 +685,7 @@ impl LocalProcess {
wake_tx,
events,
}),
sandbox_type,
})
}
}
@@ -928,6 +945,8 @@ async fn watch_exit(
),
..Default::default()
};
// Transport the classification to the caller; recording there
// attaches audit context once and avoids duplicate events.
process.sandbox_denied = is_likely_sandbox_denied(process.sandbox, &exec_output);
}
let _ = process.wake_tx.send(seq);
+18
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use codex_network_proxy::NetworkPolicyDecider;
use codex_sandboxing::SandboxType;
use tokio::sync::broadcast;
use tokio::sync::watch;
@@ -12,12 +13,29 @@ use crate::ExecServerError;
use crate::ProcessId;
use crate::protocol::ExecParams;
use crate::protocol::ProcessOutputChunk;
use crate::protocol::ProcessSandboxType;
use crate::protocol::ProcessSignal;
use crate::protocol::ReadResponse;
use crate::protocol::WriteResponse;
pub struct StartedExecProcess {
pub process: Arc<dyn ExecProcess>,
/// `None` means the exec-server peer did not report its sandbox type.
pub sandbox_type: Option<SandboxType>,
}
pub(crate) fn sandbox_type_from_protocol(
sandbox_type: Option<ProcessSandboxType>,
) -> Option<SandboxType> {
match sandbox_type {
None => None,
Some(ProcessSandboxType::None) => Some(SandboxType::None),
Some(ProcessSandboxType::MacosSeatbelt) => Some(SandboxType::MacosSeatbelt),
Some(ProcessSandboxType::LinuxSeccomp) => Some(SandboxType::LinuxSeccomp),
Some(ProcessSandboxType::WindowsRestrictedToken) => {
Some(SandboxType::WindowsRestrictedToken)
}
}
}
/// Pushed process events for consumers that want to follow process output as it
@@ -12,6 +12,7 @@ use crate::ExecProcessFuture;
use crate::StartedExecProcess;
use crate::client::LazyRemoteExecServerClient;
use crate::client::Session;
use crate::process::sandbox_type_from_protocol;
use crate::protocol::ExecParams;
use crate::protocol::ProcessSignal;
use crate::protocol::ReadResponse;
@@ -39,9 +40,11 @@ impl RemoteProcess {
) -> Result<StartedExecProcess, crate::ExecServerError> {
let client = self.client.get().await?;
let session = client.start_process(params, network_policy_decider).await?;
let sandbox_type = sandbox_type_from_protocol(session.sandbox_type());
Ok(StartedExecProcess {
process: Arc::new(RemoteExecProcess { session }),
sandbox_type,
})
}
}
+3
View File
@@ -12,6 +12,7 @@ use codex_exec_server::WriteResponse;
use codex_exec_server::WriteStatus;
use codex_exec_server_protocol::JSONRPCMessage;
use codex_exec_server_protocol::JSONRPCResponse;
use codex_exec_server_protocol::ProcessSandboxType;
use codex_utils_path_uri::PathUri;
use common::exec_server::exec_server;
use pretty_assertions::assert_eq;
@@ -72,6 +73,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
process_start_response,
ExecResponse {
process_id: ProcessId::from("proc-1"),
sandbox_type: Some(ProcessSandboxType::None),
}
);
@@ -137,6 +139,7 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re
process_start_response,
ExecResponse {
process_id: ProcessId::from("proc-default-stdin"),
sandbox_type: Some(ProcessSandboxType::None),
}
);
+2
View File
@@ -35,6 +35,7 @@ use codex_exec_server::NoiseRendezvousConnectBundle;
use codex_exec_server::NoiseRendezvousConnectProvider;
use codex_exec_server::ProcessId;
use codex_exec_server::RemoteEnvironmentConfig;
use codex_exec_server_protocol::ProcessSandboxType;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_http_client::cache_system_proxy_route_for_test;
@@ -414,6 +415,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> {
response,
ExecResponse {
process_id: ProcessId::from("proc-1"),
sandbox_type: Some(ProcessSandboxType::None),
}
);
+14 -2
View File
@@ -2,7 +2,14 @@ use codex_protocol::exec_output::ExecToolCallOutput;
use crate::SandboxType;
/// Returns whether a failed command was likely denied by the selected sandbox.
/// We don't have a fully deterministic way to tell if our command failed
/// because of the sandbox - a command in the user's zshrc file might hit an
/// error, but the command itself might fail or succeed for other reasons.
/// For now, we conservatively check for well known command failure exit codes and
/// also look for common sandbox denial keywords in the command output.
///
/// This predicate is intentionally side-effect free. Callers that handle a
/// denial should record it where the relevant audit context is available.
pub fn is_likely_sandbox_denied(
sandbox_type: SandboxType,
exec_output: &ExecToolCallOutput,
@@ -11,6 +18,10 @@ pub fn is_likely_sandbox_denied(
return false;
}
// Quick rejects: well-known non-sandbox shell exit codes
// 2: misuse of shell builtins
// 126: permission denied
// 127: command not found
const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [
"operation not permitted",
"permission denied",
@@ -46,8 +57,9 @@ pub fn is_likely_sandbox_denied(
#[cfg(unix)]
{
const EXIT_CODE_SIGNAL_BASE: i32 = 128;
const SIGSYS_CODE: i32 = libc::SIGSYS;
if sandbox_type == SandboxType::LinuxSeccomp
&& exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + libc::SIGSYS
&& exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE
{
return true;
}
+9
View File
@@ -7,6 +7,7 @@ pub mod policy_transforms;
#[cfg(target_os = "macos")]
pub mod seatbelt;
mod spawn;
mod violation;
mod windows;
#[cfg(target_os = "linux")]
@@ -29,6 +30,14 @@ pub use manager::with_managed_mitm_ca_readable_root;
pub use spawn::SpawnRequest;
pub use spawn::WindowsSandboxSpawnRequest;
pub use spawn::spawn_process;
pub use violation::FileSystemSandboxViolation;
pub use violation::FileSystemSandboxViolationReason;
pub use violation::NetworkSandboxViolation;
pub use violation::SandboxViolationBackend;
pub use violation::SandboxViolationEvent;
pub use violation::record_filesystem_sandbox_violation;
pub use violation::record_network_sandbox_violation;
pub use violation::record_sandbox_violation;
pub use windows::WindowsSandboxFilesystemOverrides;
pub use windows::permission_profile_supports_windows_restricted_token_sandbox;
pub use windows::resolve_windows_elevated_filesystem_overrides;
+297
View File
@@ -0,0 +1,297 @@
use crate::SandboxType;
use codex_network_proxy::BlockedRequest;
use codex_network_proxy::NetworkMode;
use codex_protocol::exec_output::ExecToolCallOutput;
use tracing::warn;
#[cfg(unix)]
const EXIT_CODE_SIGNAL_BASE: i32 = 128;
const OUTPUT_SNIPPET_MAX_CHARS: usize = 512;
const SANDBOX_DENIED_KEYWORDS: [(FileSystemSandboxViolationReason, &str); 7] = [
(
FileSystemSandboxViolationReason::OperationNotPermitted,
"operation not permitted",
),
(
FileSystemSandboxViolationReason::PermissionDenied,
"permission denied",
),
(
FileSystemSandboxViolationReason::ReadOnlyFileSystem,
"read-only file system",
),
(FileSystemSandboxViolationReason::PolicyDenied, "seccomp"),
(FileSystemSandboxViolationReason::PolicyDenied, "sandbox"),
(FileSystemSandboxViolationReason::PolicyDenied, "landlock"),
(
FileSystemSandboxViolationReason::FailedToWriteFile,
"failed to write file",
),
];
// Quick rejects: well-known non-sandbox shell exit codes.
// 2: misuse of shell builtins
// 126: permission denied
// 127: command not found
const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127];
/// A normalized sandbox violation observed by Codex sandbox enforcement.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SandboxViolationEvent {
FileSystem(FileSystemSandboxViolation),
Network(NetworkSandboxViolation),
}
/// Enforcement backend that observed a sandbox violation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SandboxViolationBackend {
LinuxSandbox,
ManagedNetworkProxy,
Seatbelt,
WindowsSandbox,
}
impl SandboxViolationBackend {
pub const fn as_str(self) -> &'static str {
match self {
Self::LinuxSandbox => "linux_sandbox",
Self::ManagedNetworkProxy => "managed_network_proxy",
Self::Seatbelt => "seatbelt",
Self::WindowsSandbox => "windows_sandbox",
}
}
}
/// A filesystem sandbox denial inferred from a sandboxed process result.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileSystemSandboxViolation {
pub backend: SandboxViolationBackend,
pub reason: FileSystemSandboxViolationReason,
pub path: Option<String>,
pub output_snippet: String,
}
/// Normalized reasons used when classifying filesystem sandbox denials.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileSystemSandboxViolationReason {
OperationNotPermitted,
PermissionDenied,
ReadOnlyFileSystem,
PolicyDenied,
FailedToWriteFile,
SignalSyscall,
}
impl FileSystemSandboxViolationReason {
pub const fn as_str(self) -> &'static str {
match self {
Self::OperationNotPermitted => "operation_not_permitted",
Self::PermissionDenied => "permission_denied",
Self::ReadOnlyFileSystem => "read_only_file_system",
Self::PolicyDenied => "policy_denied",
Self::FailedToWriteFile => "failed_to_write_file",
Self::SignalSyscall => "sigsys",
}
}
}
/// A network sandbox denial reported by the managed network proxy.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NetworkSandboxViolation {
pub backend: SandboxViolationBackend,
pub host: String,
pub reason: String,
pub client: Option<String>,
pub method: Option<String>,
pub mode: Option<NetworkMode>,
pub protocol: String,
pub decision: Option<String>,
pub source: Option<String>,
pub port: Option<u16>,
pub timestamp: i64,
}
impl NetworkSandboxViolation {
pub fn from_blocked_request(blocked: &BlockedRequest) -> Self {
Self {
backend: SandboxViolationBackend::ManagedNetworkProxy,
host: blocked.host.clone(),
reason: blocked.reason.clone(),
client: blocked.client.clone(),
method: blocked.method.clone(),
mode: blocked.mode,
protocol: blocked.protocol.clone(),
decision: blocked.decision.clone(),
source: blocked.source.clone(),
port: blocked.port,
timestamp: blocked.timestamp,
}
}
}
/// Classify a sandboxed process result as a filesystem sandbox violation.
fn classify_filesystem_sandbox_violation(
sandbox_type: SandboxType,
exec_output: &ExecToolCallOutput,
) -> Option<FileSystemSandboxViolation> {
if exec_output.exit_code == 0 {
return None;
}
let backend = match sandbox_type {
SandboxType::None => return None,
SandboxType::MacosSeatbelt => SandboxViolationBackend::Seatbelt,
SandboxType::LinuxSeccomp => SandboxViolationBackend::LinuxSandbox,
SandboxType::WindowsRestrictedToken => SandboxViolationBackend::WindowsSandbox,
};
if let Some((reason, output)) = filesystem_reason_from_output(exec_output) {
return Some(FileSystemSandboxViolation {
backend,
reason,
path: extract_denied_path_from_text(output),
output_snippet: output_snippet(output),
});
}
if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) {
return None;
}
#[cfg(unix)]
{
if sandbox_type == SandboxType::LinuxSeccomp
&& exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + libc::SIGSYS
{
return Some(FileSystemSandboxViolation {
backend,
reason: FileSystemSandboxViolationReason::SignalSyscall,
path: None,
output_snippet: [
&exec_output.stderr.text,
&exec_output.stdout.text,
&exec_output.aggregated_output.text,
]
.into_iter()
.find(|section| !section.trim().is_empty())
.map_or_else(String::new, |section| output_snippet(section)),
});
}
}
None
}
/// Record a filesystem sandbox violation, returning the classified event when one was found.
pub fn record_filesystem_sandbox_violation(
sandbox_type: SandboxType,
exec_output: &ExecToolCallOutput,
) -> Option<FileSystemSandboxViolation> {
let violation = classify_filesystem_sandbox_violation(sandbox_type, exec_output)?;
record_sandbox_violation(&SandboxViolationEvent::FileSystem(violation.clone()));
Some(violation)
}
/// Record a network sandbox violation from a managed-proxy blocked request.
pub fn record_network_sandbox_violation(blocked: &BlockedRequest) -> NetworkSandboxViolation {
let violation = NetworkSandboxViolation::from_blocked_request(blocked);
record_sandbox_violation(&SandboxViolationEvent::Network(violation.clone()));
violation
}
/// Emit a sandbox violation to the tracing stack.
pub fn record_sandbox_violation(event: &SandboxViolationEvent) {
match event {
SandboxViolationEvent::FileSystem(violation) => {
let path = violation.path.as_deref().unwrap_or("unknown");
warn!(
"recorded sandbox violation: resource=filesystem backend={} reason={} path={}",
violation.backend.as_str(),
violation.reason.as_str(),
path
);
}
SandboxViolationEvent::Network(violation) => {
warn!(
"recorded sandbox violation: resource=network backend={} protocol={} host={} port={:?} reason={} method={:?} mode={:?} client={:?} decision={:?} source={:?}",
violation.backend.as_str(),
violation.protocol,
violation.host,
violation.port,
violation.reason,
violation.method,
violation.mode,
violation.client,
violation.decision,
violation.source
);
}
}
}
fn filesystem_reason_from_output(
exec_output: &ExecToolCallOutput,
) -> Option<(FileSystemSandboxViolationReason, &str)> {
[
&exec_output.stderr.text,
&exec_output.stdout.text,
&exec_output.aggregated_output.text,
]
.into_iter()
.find_map(|section| {
let section = section.as_str();
let lower = section.to_lowercase();
SANDBOX_DENIED_KEYWORDS
.iter()
.find_map(|(reason, needle)| lower.contains(needle).then_some(*reason))
.map(|reason| (reason, section))
})
}
fn extract_denied_path_from_text(text: &str) -> Option<String> {
const PATH_MARKERS: [&str; 3] = [
": operation not permitted",
": permission denied",
": read-only file system",
];
for line in text.lines() {
for marker in PATH_MARKERS {
let Some(marker_start) = line.match_indices(':').find_map(|(marker_start, _)| {
line.get(marker_start..)
.and_then(|suffix| suffix.get(..marker.len()))
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(marker))
.then_some(marker_start)
}) else {
continue;
};
let candidate_prefix = &line[..marker_start];
let candidate = candidate_prefix
.rsplit_once(": ")
.map_or(candidate_prefix, |(_, path)| path)
.trim()
.trim_matches('"')
.trim_matches('\'');
if candidate.starts_with('/')
|| candidate.starts_with("./")
|| candidate.starts_with("../")
{
return Some(candidate.to_string());
}
}
}
None
}
fn output_snippet(output: &str) -> String {
output
.trim()
.chars()
.take(OUTPUT_SNIPPET_MAX_CHARS)
.collect()
}
#[cfg(test)]
#[path = "violation_tests.rs"]
mod tests;
+237
View File
@@ -0,0 +1,237 @@
use super::*;
use codex_network_proxy::BlockedRequest;
use codex_network_proxy::BlockedRequestArgs;
use codex_network_proxy::NetworkMode;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::exec_output::StreamOutput;
use pretty_assertions::assert_eq;
use std::time::Duration;
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 classifies_legacy_denial_keywords() {
for keyword in [
"operation not permitted",
"permission denied",
"read-only file system",
"seccomp",
"sandbox",
"landlock",
"failed to write file",
] {
let output = make_exec_output(/*exit_code*/ 1, "", keyword, "");
assert!(
classify_filesystem_sandbox_violation(SandboxType::LinuxSeccomp, &output).is_some(),
"{keyword}"
);
}
}
#[test]
fn normalizes_backend_keywords_as_policy_denied() {
for keyword in ["seccomp", "sandbox", "landlock"] {
let output = make_exec_output(/*exit_code*/ 1, "", keyword, "");
assert_eq!(
classify_filesystem_sandbox_violation(SandboxType::LinuxSeccomp, &output),
Some(FileSystemSandboxViolation {
backend: SandboxViolationBackend::LinuxSandbox,
reason: FileSystemSandboxViolationReason::PolicyDenied,
path: None,
output_snippet: keyword.to_string(),
})
);
}
}
#[test]
fn preserves_legacy_denial_ordering() {
let quick_reject_without_keyword =
make_exec_output(/*exit_code*/ 127, "", "command not found", "");
let quick_reject_with_keyword =
make_exec_output(/*exit_code*/ 127, "", "Permission denied", "");
let zero_exit_with_keyword =
make_exec_output(/*exit_code*/ 0, "", "Operation not permitted", "");
let non_sandbox_with_keyword =
make_exec_output(/*exit_code*/ 1, "", "Operation not permitted", "");
assert!(
classify_filesystem_sandbox_violation(
SandboxType::LinuxSeccomp,
&quick_reject_without_keyword
)
.is_none()
);
assert!(
classify_filesystem_sandbox_violation(
SandboxType::LinuxSeccomp,
&quick_reject_with_keyword
)
.is_some()
);
assert!(
classify_filesystem_sandbox_violation(SandboxType::LinuxSeccomp, &zero_exit_with_keyword)
.is_none()
);
assert!(
classify_filesystem_sandbox_violation(SandboxType::None, &non_sandbox_with_keyword)
.is_none()
);
}
#[test]
fn classifies_filesystem_violation_with_path() {
let output = make_exec_output(
/*exit_code*/ 1,
"",
"bash: /private/tmp/denied: Operation not permitted",
"",
);
assert_eq!(
classify_filesystem_sandbox_violation(SandboxType::MacosSeatbelt, &output),
Some(FileSystemSandboxViolation {
backend: SandboxViolationBackend::Seatbelt,
reason: FileSystemSandboxViolationReason::OperationNotPermitted,
path: Some("/private/tmp/denied".to_string()),
output_snippet: "bash: /private/tmp/denied: Operation not permitted".to_string(),
})
);
}
#[test]
fn classifies_filesystem_violation_with_unicode_before_marker() {
let output = make_exec_output(
/*exit_code*/ 1,
"",
"bash: /private/tmp/\u{130}-denied: Operation not permitted",
"",
);
assert_eq!(
classify_filesystem_sandbox_violation(SandboxType::MacosSeatbelt, &output),
Some(FileSystemSandboxViolation {
backend: SandboxViolationBackend::Seatbelt,
reason: FileSystemSandboxViolationReason::OperationNotPermitted,
path: Some("/private/tmp/\u{130}-denied".to_string()),
output_snippet: "bash: /private/tmp/\u{130}-denied: Operation not permitted"
.to_string(),
})
);
}
#[test]
fn classifies_filesystem_violation_from_aggregated_output() {
let output = make_exec_output(
/*exit_code*/ 101,
"",
"",
"cargo failed: Read-only file system when writing target",
);
assert_eq!(
classify_filesystem_sandbox_violation(SandboxType::MacosSeatbelt, &output),
Some(FileSystemSandboxViolation {
backend: SandboxViolationBackend::Seatbelt,
reason: FileSystemSandboxViolationReason::ReadOnlyFileSystem,
path: None,
output_snippet: "cargo failed: Read-only file system when writing target".to_string(),
})
);
}
#[test]
fn keeps_output_snippet_on_the_stream_that_matched() {
let output = make_exec_output(
/*exit_code*/ 1,
"bash: /private/tmp/denied: Permission denied",
"unrelated warning",
"unrelated warning\nbash: /private/tmp/denied: Permission denied",
);
assert_eq!(
classify_filesystem_sandbox_violation(SandboxType::MacosSeatbelt, &output),
Some(FileSystemSandboxViolation {
backend: SandboxViolationBackend::Seatbelt,
reason: FileSystemSandboxViolationReason::PermissionDenied,
path: Some("/private/tmp/denied".to_string()),
output_snippet: "bash: /private/tmp/denied: Permission denied".to_string(),
})
);
}
#[cfg(unix)]
#[test]
fn classifies_linux_sigsys_exit() {
let output = make_exec_output(
/*exit_code*/ EXIT_CODE_SIGNAL_BASE + libc::SIGSYS,
"",
"",
"",
);
assert_eq!(
classify_filesystem_sandbox_violation(SandboxType::LinuxSeccomp, &output),
Some(FileSystemSandboxViolation {
backend: SandboxViolationBackend::LinuxSandbox,
reason: FileSystemSandboxViolationReason::SignalSyscall,
path: None,
output_snippet: String::new(),
})
);
}
#[test]
fn does_not_classify_non_sandbox_mode() {
let output = make_exec_output(/*exit_code*/ 1, "", "Operation not permitted", "");
assert!(classify_filesystem_sandbox_violation(SandboxType::None, &output).is_none());
}
#[test]
fn converts_blocked_request_to_network_violation() {
let blocked = BlockedRequest::new(BlockedRequestArgs {
host: "example.com".to_string(),
reason: "not_allowed".to_string(),
client: Some("curl".to_string()),
method: Some("CONNECT".to_string()),
mode: Some(NetworkMode::Limited),
protocol: "https".to_string(),
decision: Some("block".to_string()),
source: Some("policy".to_string()),
port: Some(443),
});
assert_eq!(
NetworkSandboxViolation::from_blocked_request(&blocked),
NetworkSandboxViolation {
backend: SandboxViolationBackend::ManagedNetworkProxy,
host: "example.com".to_string(),
reason: "not_allowed".to_string(),
client: Some("curl".to_string()),
method: Some("CONNECT".to_string()),
mode: Some(NetworkMode::Limited),
protocol: "https".to_string(),
decision: Some("block".to_string()),
source: Some("policy".to_string()),
port: Some(443),
timestamp: blocked.timestamp,
}
);
}