1use hashbrown::HashMap;
8use std::fmt::Debug;
9use std::future::Future;
10use std::hash::Hash;
11use std::path::{Path, PathBuf};
12use std::pin::Pin;
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use serde::Serialize;
17use tokio::sync::RwLock;
18
19use crate::exec_policy::default_exec_approval_requirement as canonical_default_exec_approval_requirement;
20pub use crate::exec_policy::{
21 AskForApproval, ExecApprovalRequirement, ExecPolicyAmendment, RejectConfig,
22};
23use crate::sandboxing::{
24 CommandSpec as CanonicalCommandSpec, ExecEnv as CanonicalExecEnv,
25 ExecExpiration as CanonicalExecExpiration, ResourceLimits,
26 SandboxManager as CanonicalSandboxManager, SandboxPolicy as CanonicalSandboxPolicy,
27 SandboxTransformError as CanonicalSandboxTransformError, SandboxType as CanonicalSandboxType,
28 SeccompProfile,
29};
30
31use super::tool_handler::{ToolSession, TurnContext};
32
33#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum ReviewDecision {
40 Approved,
42 Denied,
44 Abort,
46 ApprovedForSession,
48 ApprovedExecpolicyAmendment {
50 proposed_execpolicy_amendment: ExecPolicyAmendment,
51 },
52}
53
54#[derive(Clone, Default)]
64pub struct ApprovalStore {
65 approvals: Arc<RwLock<HashMap<String, ReviewDecision>>>,
66}
67
68impl ApprovalStore {
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub async fn get(&self, key: &str) -> Option<ReviewDecision> {
75 self.approvals.read().await.get(key).cloned()
76 }
77
78 pub async fn set(&self, key: String, decision: ReviewDecision) {
80 self.approvals.write().await.insert(key, decision);
81 }
82
83 pub async fn contains(&self, key: &str) -> bool {
85 self.approvals.read().await.contains_key(key)
86 }
87}
88
89pub async fn with_cached_approval<K, F, Fut>(
91 store: &ApprovalStore,
92 key: K,
93 fetch: F,
94) -> ReviewDecision
95where
96 K: Serialize + Clone,
97 F: FnOnce() -> Fut,
98 Fut: Future<Output = ReviewDecision>,
99{
100 let key_str = serde_json::to_string(&key).unwrap_or_default();
101
102 if let Some(decision) = store.get(&key_str).await
104 && matches!(decision, ReviewDecision::ApprovedForSession)
105 {
106 return ReviewDecision::Approved;
107 }
108
109 let decision = fetch().await;
111
112 store.set(key_str, decision.clone()).await;
114
115 decision
116}
117
118pub struct ApprovalCtx<'a> {
124 pub session: &'a dyn ToolSession,
125 pub turn: &'a TurnContext,
126 pub call_id: &'a str,
127 pub retry_reason: Option<String>,
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum SandboxOverride {
137 NoOverride,
139 BypassSandboxFirstAttempt,
141}
142
143#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
145pub enum SandboxablePreference {
146 #[default]
148 Auto,
149 Require,
151 Forbid,
153}
154
155pub trait Sandboxable {
161 fn sandbox_preference(&self) -> SandboxablePreference {
163 SandboxablePreference::Auto
164 }
165
166 fn escalate_on_failure(&self) -> bool {
168 true
169 }
170}
171
172pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
178
179pub trait Approvable<Req>: Send + Sync {
181 type ApprovalKey: Hash + Eq + Clone + Debug + Serialize + Send + Sync;
183
184 fn approval_key(&self, req: &Req) -> Self::ApprovalKey;
186
187 fn sandbox_mode_for_first_attempt(&self, _req: &Req) -> SandboxOverride {
189 SandboxOverride::NoOverride
190 }
191
192 fn should_bypass_approval(&self, policy: AskForApproval, already_approved: bool) -> bool {
194 if already_approved {
195 return true;
196 }
197 matches!(policy, AskForApproval::Never)
198 }
199
200 fn exec_approval_requirement(&self, _req: &Req) -> Option<ExecApprovalRequirement> {
202 None
203 }
204
205 fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
207 match policy {
208 AskForApproval::OnFailure => true,
209 AskForApproval::UnlessTrusted => true,
210 AskForApproval::Never => false,
211 AskForApproval::OnRequest => false,
212 AskForApproval::Reject(_) => !policy.rejects_sandbox_prompt(),
213 }
214 }
215
216 fn start_approval_async<'a>(
218 &'a mut self,
219 req: &'a Req,
220 ctx: ApprovalCtx<'a>,
221 ) -> BoxFuture<'a, ReviewDecision>;
222}
223
224#[derive(Clone, Debug, Default, PartialEq, Eq)]
230pub struct SandboxPolicy {
231 pub mode: SandboxMode,
232 pub network_access: NetworkAccess,
233}
234
235#[derive(Clone, Debug, Default, PartialEq, Eq)]
237pub enum SandboxMode {
238 #[default]
240 ReadOnly,
241 WorkspaceWrite,
243 DangerFullAccess,
245 ExternalSandbox,
247}
248
249#[derive(Clone, Debug, Default, PartialEq, Eq)]
251pub enum NetworkAccess {
252 #[default]
254 Restricted,
255 Limited,
257 Full,
259}
260
261const LEGACY_EXTERNAL_SANDBOX_DESCRIPTION: &str = "legacy handler external sandbox";
262
263impl SandboxPolicy {
264 #[must_use]
265 pub fn requires_approval_prompt(&self) -> bool {
266 !matches!(
267 self.mode,
268 SandboxMode::DangerFullAccess | SandboxMode::ExternalSandbox
269 )
270 }
271
272 #[must_use]
273 pub fn uses_runtime_sandbox(&self) -> bool {
274 matches!(
275 self.mode,
276 SandboxMode::ReadOnly | SandboxMode::WorkspaceWrite
277 )
278 }
279
280 #[must_use]
281 pub fn to_canonical_policy(&self, sandbox_cwd: &Path) -> CanonicalSandboxPolicy {
282 match (&self.mode, &self.network_access) {
283 (SandboxMode::ReadOnly, NetworkAccess::Restricted | NetworkAccess::Limited) => {
284 CanonicalSandboxPolicy::read_only()
285 }
286 (SandboxMode::ReadOnly, NetworkAccess::Full) => {
287 CanonicalSandboxPolicy::read_only_with_full_network()
288 }
289 (SandboxMode::WorkspaceWrite, NetworkAccess::Restricted | NetworkAccess::Limited) => {
290 CanonicalSandboxPolicy::workspace_write(vec![sandbox_cwd.to_path_buf()])
291 }
292 (SandboxMode::WorkspaceWrite, NetworkAccess::Full) => {
293 CanonicalSandboxPolicy::workspace_write_full(
294 vec![sandbox_cwd.to_path_buf()],
295 Vec::new(),
296 None,
297 ResourceLimits::default(),
298 SeccompProfile::strict().with_network(),
299 )
300 }
301 (SandboxMode::DangerFullAccess, _) => CanonicalSandboxPolicy::full_access(),
302 (SandboxMode::ExternalSandbox, _) => CanonicalSandboxPolicy::ExternalSandbox {
303 description: LEGACY_EXTERNAL_SANDBOX_DESCRIPTION.to_string(),
304 },
305 }
306 }
307
308 #[must_use]
309 pub fn to_canonical_policy_for_turn(&self, turn: &TurnContext) -> CanonicalSandboxPolicy {
310 self.to_canonical_policy(&turn.cwd)
311 }
312}
313
314#[must_use]
315pub fn canonical_sandbox_policy(turn: &TurnContext) -> CanonicalSandboxPolicy {
316 turn.sandbox_policy.get().to_canonical_policy_for_turn(turn)
317}
318
319pub fn default_exec_approval_requirement(
321 policy: AskForApproval,
322 sandbox_policy: &SandboxPolicy,
323) -> ExecApprovalRequirement {
324 canonical_default_exec_approval_requirement(
325 policy,
326 sandbox_requires_approval_prompt(sandbox_policy),
327 )
328}
329
330fn sandbox_requires_approval_prompt(sandbox_policy: &SandboxPolicy) -> bool {
331 sandbox_policy.requires_approval_prompt()
332}
333
334pub struct ToolCtx {
340 pub session: Arc<dyn ToolSession>,
341 pub turn: Arc<TurnContext>,
342 pub call_id: String,
343 pub tool_name: String,
344}
345
346#[derive(Debug, thiserror::Error)]
352pub enum ToolError {
353 #[error("Tool rejected: {0}")]
354 Rejected(String),
355
356 #[error("Internal error: {0}")]
357 Codex(#[from] anyhow::Error),
358
359 #[error("Sandbox denied: {0}")]
360 SandboxDenied(String),
361
362 #[error("Timeout after {0}ms")]
363 Timeout(u64),
364}
365
366#[derive(Clone, Copy, Debug, PartialEq, Eq)]
372pub enum SandboxType {
373 None,
375 Seatbelt,
377 LinuxSandbox,
379}
380
381impl From<CanonicalSandboxType> for SandboxType {
382 fn from(value: CanonicalSandboxType) -> Self {
383 match value {
384 CanonicalSandboxType::None => Self::None,
385 CanonicalSandboxType::MacosSeatbelt => Self::Seatbelt,
386 CanonicalSandboxType::LinuxLandlock => Self::LinuxSandbox,
387 CanonicalSandboxType::WindowsRestrictedToken => Self::None,
388 }
389 }
390}
391
392pub struct SandboxAttempt<'a> {
394 pub sandbox: SandboxType,
395 pub policy: &'a SandboxPolicy,
396 pub sandbox_cwd: &'a Path,
397 pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
398}
399
400impl<'a> SandboxAttempt<'a> {
401 pub fn env_for(&self, spec: CommandSpec) -> Result<ExecEnv, SandboxTransformError> {
403 let canonical_policy = if self.sandbox == SandboxType::None {
404 CanonicalSandboxPolicy::full_access()
405 } else {
406 self.policy.to_canonical_policy(self.sandbox_cwd)
407 };
408 let canonical_spec = CanonicalCommandSpec::new(spec.program)
409 .with_args(spec.args)
410 .with_cwd(spec.cwd)
411 .with_env(spec.env)
412 .with_expiration(CanonicalExecExpiration::from(spec.timeout_ms));
413 let canonical_env = CanonicalSandboxManager::new()
414 .transform(
415 canonical_spec,
416 &canonical_policy,
417 self.sandbox_cwd,
418 self.codex_linux_sandbox_exe.map(PathBuf::as_path),
419 )
420 .map_err(SandboxTransformError::from)?;
421
422 Ok(ExecEnv::from_canonical(canonical_env))
423 }
424}
425
426#[derive(Clone, Debug)]
428pub struct CommandSpec {
429 pub program: String,
430 pub args: Vec<String>,
431 pub cwd: PathBuf,
432 pub env: HashMap<String, String>,
433 pub timeout_ms: Option<u64>,
434}
435
436#[derive(Clone, Debug)]
438pub struct ExecEnv {
439 pub program: String,
440 pub args: Vec<String>,
441 pub cwd: PathBuf,
442 pub env: HashMap<String, String>,
443 pub timeout_ms: Option<u64>,
444 pub sandbox: SandboxType,
445}
446
447impl ExecEnv {
448 fn from_canonical(env: CanonicalExecEnv) -> Self {
449 Self {
450 program: env.program.to_string_lossy().into_owned(),
451 args: env.args,
452 cwd: env.cwd,
453 env: env.env,
454 timeout_ms: env.expiration.timeout_ms(),
455 sandbox: SandboxType::from(env.sandbox_type),
456 }
457 }
458}
459
460#[derive(Debug, thiserror::Error)]
462pub enum SandboxTransformError {
463 #[error("missing sandbox executable")]
464 MissingSandboxExecutable,
465
466 #[error("sandbox not available on this platform")]
467 SandboxUnavailable,
468
469 #[error("sandbox configuration error: {0}")]
470 ConfigError(String),
471}
472
473impl From<CanonicalSandboxTransformError> for SandboxTransformError {
474 fn from(value: CanonicalSandboxTransformError) -> Self {
475 match value {
476 CanonicalSandboxTransformError::MissingSandboxExecutable => {
477 Self::MissingSandboxExecutable
478 }
479 CanonicalSandboxTransformError::UnavailableSandboxType(_) => Self::SandboxUnavailable,
480 CanonicalSandboxTransformError::CreationFailed(message)
481 | CanonicalSandboxTransformError::InvalidPolicy(message) => Self::ConfigError(message),
482 }
483 }
484}
485
486#[async_trait]
495pub trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable
496where
497 Req: Send + Sync,
498 Out: Send + Sync,
499{
500 async fn run(
502 &mut self,
503 req: &Req,
504 attempt: &SandboxAttempt<'_>,
505 ctx: &ToolCtx,
506 ) -> Result<Out, ToolError>;
507}
508
509#[derive(Default)]
515pub struct SandboxManager;
516
517impl SandboxManager {
518 pub fn new() -> Self {
519 Self
520 }
521
522 pub fn select_initial(
524 &self,
525 policy: &SandboxPolicy,
526 preference: SandboxablePreference,
527 ) -> SandboxType {
528 match preference {
529 SandboxablePreference::Forbid => SandboxType::None,
530 SandboxablePreference::Require => self.platform_sandbox(),
531 SandboxablePreference::Auto => {
532 if policy.uses_runtime_sandbox() {
533 self.platform_sandbox()
534 } else {
535 SandboxType::None
536 }
537 }
538 }
539 }
540
541 pub fn select_initial_for_canonical(
542 &self,
543 policy: &CanonicalSandboxPolicy,
544 preference: SandboxablePreference,
545 ) -> SandboxType {
546 match preference {
547 SandboxablePreference::Forbid => SandboxType::None,
548 SandboxablePreference::Require => self.platform_sandbox(),
549 SandboxablePreference::Auto => {
550 if matches!(
551 policy,
552 CanonicalSandboxPolicy::DangerFullAccess
553 | CanonicalSandboxPolicy::ExternalSandbox { .. }
554 ) {
555 SandboxType::None
556 } else {
557 self.platform_sandbox()
558 }
559 }
560 }
561 }
562
563 #[cfg(target_os = "macos")]
565 fn platform_sandbox(&self) -> SandboxType {
566 SandboxType::Seatbelt
567 }
568
569 #[cfg(target_os = "linux")]
570 fn platform_sandbox(&self) -> SandboxType {
571 SandboxType::LinuxSandbox
572 }
573
574 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
575 fn platform_sandbox(&self) -> SandboxType {
576 SandboxType::None
577 }
578}
579
580#[derive(Clone, Debug, Default)]
586pub struct ExecToolCallOutput {
587 pub stdout: String,
588 pub stderr: String,
589 pub exit_code: i32,
590}
591
592impl ExecToolCallOutput {
593 pub fn success(&self) -> bool {
594 self.exit_code == 0
595 }
596
597 pub fn combined_output(&self) -> String {
598 if self.stderr.is_empty() {
599 self.stdout.clone()
600 } else if self.stdout.is_empty() {
601 self.stderr.clone()
602 } else {
603 format!("{}\n{}", self.stdout, self.stderr)
604 }
605 }
606}
607
608pub async fn execute_env(
610 env: ExecEnv,
611 _policy: &SandboxPolicy,
612) -> Result<ExecToolCallOutput, anyhow::Error> {
613 use tokio::process::Command;
614
615 let mut cmd = Command::new(&env.program);
616 cmd.args(&env.args)
617 .current_dir(&env.cwd)
618 .envs(&env.env)
619 .stdout(std::process::Stdio::piped())
620 .stderr(std::process::Stdio::piped());
621
622 let timeout = env
623 .timeout_ms
624 .map(std::time::Duration::from_millis)
625 .unwrap_or(std::time::Duration::from_secs(300));
626
627 let output = tokio::time::timeout(timeout, cmd.output())
628 .await
629 .map_err(|_| anyhow::anyhow!("Command timed out"))?
630 .map_err(|e| anyhow::anyhow!("Command failed: {}", e))?;
631
632 Ok(ExecToolCallOutput {
633 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
634 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
635 exit_code: output.status.code().unwrap_or(-1),
636 })
637}
638
639#[cfg(test)]
644mod tests {
645 use super::*;
646 use std::path::{Path, PathBuf};
647
648 #[test]
649 fn test_external_sandbox_skips_exec_approval_on_request() {
650 let result = default_exec_approval_requirement(
651 AskForApproval::OnRequest,
652 &SandboxPolicy {
653 mode: SandboxMode::ExternalSandbox,
654 network_access: NetworkAccess::Restricted,
655 },
656 );
657
658 assert_eq!(
659 result,
660 ExecApprovalRequirement::Skip {
661 bypass_sandbox: false,
662 proposed_execpolicy_amendment: None,
663 }
664 );
665 }
666
667 #[test]
668 fn test_restricted_sandbox_requires_exec_approval_on_request() {
669 let result = default_exec_approval_requirement(
670 AskForApproval::OnRequest,
671 &SandboxPolicy {
672 mode: SandboxMode::ReadOnly,
673 network_access: NetworkAccess::Restricted,
674 },
675 );
676
677 assert_eq!(
678 result,
679 ExecApprovalRequirement::NeedsApproval {
680 reason: None,
681 proposed_execpolicy_amendment: None,
682 }
683 );
684 }
685
686 #[test]
687 fn reject_policy_still_flows_through_handler_wrapper() {
688 let policy = AskForApproval::Reject(RejectConfig {
689 sandbox_approval: true,
690 rules: false,
691 request_permissions: false,
692 mcp_elicitations: false,
693 });
694
695 let requirement = default_exec_approval_requirement(
696 policy,
697 &SandboxPolicy {
698 mode: SandboxMode::ReadOnly,
699 network_access: NetworkAccess::Restricted,
700 },
701 );
702
703 assert_eq!(
704 requirement,
705 ExecApprovalRequirement::Forbidden {
706 reason: "approval policy rejected sandbox approval prompt".to_string(),
707 }
708 );
709 }
710
711 #[test]
712 fn read_only_restricted_maps_to_canonical_read_only() {
713 let policy = SandboxPolicy {
714 mode: SandboxMode::ReadOnly,
715 network_access: NetworkAccess::Restricted,
716 };
717
718 assert_eq!(
719 policy.to_canonical_policy(Path::new("/workspace")),
720 CanonicalSandboxPolicy::read_only()
721 );
722 }
723
724 #[test]
725 fn read_only_full_maps_to_canonical_full_network() {
726 let policy = SandboxPolicy {
727 mode: SandboxMode::ReadOnly,
728 network_access: NetworkAccess::Full,
729 };
730
731 assert_eq!(
732 policy.to_canonical_policy(Path::new("/workspace")),
733 CanonicalSandboxPolicy::read_only_with_full_network()
734 );
735 }
736
737 #[test]
738 fn workspace_write_restricted_maps_to_canonical_workspace_write() {
739 let root = PathBuf::from("/workspace");
740 let policy = SandboxPolicy {
741 mode: SandboxMode::WorkspaceWrite,
742 network_access: NetworkAccess::Restricted,
743 };
744
745 assert_eq!(
746 policy.to_canonical_policy(&root),
747 CanonicalSandboxPolicy::workspace_write(vec![root])
748 );
749 }
750
751 #[test]
752 fn workspace_write_full_maps_to_canonical_workspace_write_with_network() {
753 let root = PathBuf::from("/workspace");
754 let policy = SandboxPolicy {
755 mode: SandboxMode::WorkspaceWrite,
756 network_access: NetworkAccess::Full,
757 };
758
759 assert_eq!(
760 policy.to_canonical_policy(&root),
761 CanonicalSandboxPolicy::workspace_write_full(
762 vec![root],
763 Vec::new(),
764 None,
765 ResourceLimits::default(),
766 SeccompProfile::strict().with_network(),
767 )
768 );
769 }
770
771 #[test]
772 fn danger_full_access_maps_to_canonical_full_access() {
773 let policy = SandboxPolicy {
774 mode: SandboxMode::DangerFullAccess,
775 network_access: NetworkAccess::Restricted,
776 };
777
778 assert_eq!(
779 policy.to_canonical_policy(Path::new("/workspace")),
780 CanonicalSandboxPolicy::full_access()
781 );
782 }
783
784 #[test]
785 fn external_sandbox_maps_to_canonical_external_sandbox() {
786 let policy = SandboxPolicy {
787 mode: SandboxMode::ExternalSandbox,
788 network_access: NetworkAccess::Full,
789 };
790
791 assert_eq!(
792 policy.to_canonical_policy(Path::new("/workspace")),
793 CanonicalSandboxPolicy::ExternalSandbox {
794 description: LEGACY_EXTERNAL_SANDBOX_DESCRIPTION.to_string(),
795 }
796 );
797 }
798
799 #[test]
800 fn limited_network_maps_to_restricted_network() {
801 let policy = SandboxPolicy {
802 mode: SandboxMode::WorkspaceWrite,
803 network_access: NetworkAccess::Limited,
804 };
805
806 assert_eq!(
807 policy.to_canonical_policy(Path::new("/workspace")),
808 CanonicalSandboxPolicy::workspace_write(vec![PathBuf::from("/workspace")])
809 );
810 }
811
812 #[tokio::test]
813 async fn test_approval_store() {
814 let store = ApprovalStore::new();
815
816 assert!(!store.contains("test").await);
817
818 store
819 .set("test".to_string(), ReviewDecision::Approved)
820 .await;
821 assert!(store.contains("test").await);
822 assert_eq!(store.get("test").await, Some(ReviewDecision::Approved));
823 }
824
825 #[test]
826 fn test_sandbox_manager_platform_selection() {
827 let manager = SandboxManager::new();
828
829 let sandbox = manager.select_initial(
831 &SandboxPolicy {
832 mode: SandboxMode::DangerFullAccess,
833 ..Default::default()
834 },
835 SandboxablePreference::Auto,
836 );
837 assert_eq!(sandbox, SandboxType::None);
838
839 let sandbox =
841 manager.select_initial(&SandboxPolicy::default(), SandboxablePreference::Forbid);
842 assert_eq!(sandbox, SandboxType::None);
843 }
844
845 #[test]
846 fn test_exec_tool_call_output() {
847 let output = ExecToolCallOutput {
848 stdout: "hello".to_string(),
849 stderr: "".to_string(),
850 exit_code: 0,
851 };
852 assert!(output.success());
853 assert_eq!(output.combined_output(), "hello");
854
855 let output = ExecToolCallOutput {
856 stdout: "out".to_string(),
857 stderr: "err".to_string(),
858 exit_code: 1,
859 };
860 assert!(!output.success());
861 assert_eq!(output.combined_output(), "out\nerr");
862 }
863}