Skip to main content

vtcode_core/tools/handlers/
sandboxing.rs

1//! Shared approvals and sandboxing traits used by tool runtimes (from Codex)
2//!
3//! Consolidates the approval flow primitives (`ApprovalDecision`, `ApprovalStore`,
4//! `ApprovalCtx`, `Approvable`) together with the sandbox orchestration traits
5//! and helpers (`Sandboxable`, `ToolRuntime`, `SandboxAttempt`, etc.).
6
7use 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// ============================================================================
34// Review Decision Types (from Codex protocol)
35// ============================================================================
36
37/// User's decision on an approval request (from Codex)
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum ReviewDecision {
40    /// Approval granted for this single invocation
41    Approved,
42    /// Approval denied
43    Denied,
44    /// Abort the entire operation
45    Abort,
46    /// Approval granted for the entire session
47    ApprovedForSession,
48    /// Approval granted with exec policy amendment
49    ApprovedExecpolicyAmendment {
50        proposed_execpolicy_amendment: ExecPolicyAmendment,
51    },
52}
53
54// ============================================================================
55// Exec Approval Requirement (from Codex)
56// ============================================================================
57
58// ============================================================================
59// Approval Store (from Codex)
60// ============================================================================
61
62/// Store for cached approval decisions (from Codex)
63#[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    /// Get a cached approval decision
74    pub async fn get(&self, key: &str) -> Option<ReviewDecision> {
75        self.approvals.read().await.get(key).cloned()
76    }
77
78    /// Store an approval decision
79    pub async fn set(&self, key: String, decision: ReviewDecision) {
80        self.approvals.write().await.insert(key, decision);
81    }
82
83    /// Check if an approval exists
84    pub async fn contains(&self, key: &str) -> bool {
85        self.approvals.read().await.contains_key(key)
86    }
87}
88
89/// Helper function to cache approval decisions (from Codex)
90pub 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    // Check if we already have a cached decision
103    if let Some(decision) = store.get(&key_str).await
104        && matches!(decision, ReviewDecision::ApprovedForSession)
105    {
106        return ReviewDecision::Approved;
107    }
108
109    // Fetch new decision
110    let decision = fetch().await;
111
112    // Cache the decision
113    store.set(key_str, decision.clone()).await;
114
115    decision
116}
117
118// ============================================================================
119// Approval Context (from Codex)
120// ============================================================================
121
122/// Context for approval decisions (from Codex)
123pub 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// ============================================================================
131// Sandbox Preferences (from Codex)
132// ============================================================================
133
134/// Sandbox override for first attempt (from Codex)
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum SandboxOverride {
137    /// Use default sandbox selection
138    NoOverride,
139    /// Bypass sandbox on first attempt
140    BypassSandboxFirstAttempt,
141}
142
143/// Sandbox preference for a tool (from Codex)
144#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
145pub enum SandboxablePreference {
146    /// Let the orchestrator decide
147    #[default]
148    Auto,
149    /// Require sandbox execution
150    Require,
151    /// Forbid sandbox
152    Forbid,
153}
154
155// ============================================================================
156// Sandboxable Trait (from Codex)
157// ============================================================================
158
159/// Trait for tools that can be sandboxed (from Codex)
160pub trait Sandboxable {
161    /// Get the sandbox preference for this tool
162    fn sandbox_preference(&self) -> SandboxablePreference {
163        SandboxablePreference::Auto
164    }
165
166    /// Whether to escalate to unsandboxed execution on failure
167    fn escalate_on_failure(&self) -> bool {
168        true
169    }
170}
171
172// ============================================================================
173// Approvable Trait (from Codex)
174// ============================================================================
175
176/// Type alias for boxed future (from Codex)
177pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
178
179/// Trait for tools that require approval (from Codex)
180pub trait Approvable<Req>: Send + Sync {
181    /// The key type used for caching approvals
182    type ApprovalKey: Hash + Eq + Clone + Debug + Serialize + Send + Sync;
183
184    /// Generate an approval key for the request
185    fn approval_key(&self, req: &Req) -> Self::ApprovalKey;
186
187    /// Some tools may request to skip the sandbox on the first attempt
188    fn sandbox_mode_for_first_attempt(&self, _req: &Req) -> SandboxOverride {
189        SandboxOverride::NoOverride
190    }
191
192    /// Check if approval should be bypassed
193    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    /// Return custom exec approval requirement, or None for default
201    fn exec_approval_requirement(&self, _req: &Req) -> Option<ExecApprovalRequirement> {
202        None
203    }
204
205    /// Decide if we can request approval for no-sandbox execution
206    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    /// Start the approval process asynchronously (from Codex)
217    fn start_approval_async<'a>(
218        &'a mut self,
219        req: &'a Req,
220        ctx: ApprovalCtx<'a>,
221    ) -> BoxFuture<'a, ReviewDecision>;
222}
223
224// ============================================================================
225// Sandbox Policy (from Codex)
226// ============================================================================
227
228/// Sandbox policy configuration (from Codex protocol)
229#[derive(Clone, Debug, Default, PartialEq, Eq)]
230pub struct SandboxPolicy {
231    pub mode: SandboxMode,
232    pub network_access: NetworkAccess,
233}
234
235/// Sandbox mode (from Codex)
236#[derive(Clone, Debug, Default, PartialEq, Eq)]
237pub enum SandboxMode {
238    /// Read-only filesystem access
239    #[default]
240    ReadOnly,
241    /// Write access within workspace
242    WorkspaceWrite,
243    /// Full access (dangerous)
244    DangerFullAccess,
245    /// External sandbox (e.g., Docker)
246    ExternalSandbox,
247}
248
249/// Network access policy
250#[derive(Clone, Debug, Default, PartialEq, Eq)]
251pub enum NetworkAccess {
252    /// No network access
253    #[default]
254    Restricted,
255    /// Limited network access
256    Limited,
257    /// Full network access
258    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
319/// Compute default exec approval requirement (from Codex)
320pub 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
334// ============================================================================
335// Tool Context (from Codex)
336// ============================================================================
337
338/// Tool execution context for runtimes (from Codex)
339pub 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// ============================================================================
347// Tool Error (from Codex)
348// ============================================================================
349
350/// Error from tool runtime execution (from Codex)
351#[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// ============================================================================
367// Sandbox Attempt (from Codex)
368// ============================================================================
369
370/// Sandbox type for execution (from Codex)
371#[derive(Clone, Copy, Debug, PartialEq, Eq)]
372pub enum SandboxType {
373    /// No sandbox
374    None,
375    /// macOS seatbelt
376    Seatbelt,
377    /// Linux sandbox
378    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
392/// Sandbox attempt context (from Codex)
393pub 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    /// Create execution environment for a command spec
402    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/// Command specification for execution
427#[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/// Execution environment after sandbox transformation
437#[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/// Error during sandbox transformation
461#[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// ============================================================================
487// Tool Runtime Trait (from Codex)
488// ============================================================================
489
490/// Trait for tool runtimes (from Codex)
491///
492/// A runtime handles the actual execution of a tool request within
493/// a sandbox attempt context. Combines Approvable and Sandboxable traits.
494#[async_trait]
495pub trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable
496where
497    Req: Send + Sync,
498    Out: Send + Sync,
499{
500    /// Execute the tool request
501    async fn run(
502        &mut self,
503        req: &Req,
504        attempt: &SandboxAttempt<'_>,
505        ctx: &ToolCtx,
506    ) -> Result<Out, ToolError>;
507}
508
509// ============================================================================
510// Sandbox Manager (from Codex)
511// ============================================================================
512
513/// Sandbox manager for creating sandbox attempts (from Codex)
514#[derive(Default)]
515pub struct SandboxManager;
516
517impl SandboxManager {
518    pub fn new() -> Self {
519        Self
520    }
521
522    /// Select the initial sandbox type based on policy and preference
523    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    /// Get the platform-specific sandbox type
564    #[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// ============================================================================
581// Execute Environment (from Codex)
582// ============================================================================
583
584/// Output from command execution (from Codex)
585#[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
608/// Execute command with environment (from Codex)
609pub 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// ============================================================================
640// Tests
641// ============================================================================
642
643#[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        // Auto preference respects policy
830        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        // Forbid always returns None
840        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}