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;
18use vtcode_macros::DebugNoInline;
19
20use crate::exec_policy::default_exec_approval_requirement as canonical_default_exec_approval_requirement;
21pub use crate::exec_policy::{AskForApproval, ExecApprovalRequirement, ExecPolicyAmendment, RejectConfig};
22use crate::sandboxing::{
23    CommandSpec as CanonicalCommandSpec, ExecEnv as CanonicalExecEnv, ExecExpiration as CanonicalExecExpiration,
24    LinuxSandboxLauncher, ResourceLimits, SandboxManager as CanonicalSandboxManager,
25    SandboxPolicy as CanonicalSandboxPolicy, SandboxTransformError as CanonicalSandboxTransformError,
26    SandboxType as CanonicalSandboxType, SeccompProfile,
27};
28
29use super::tool_handler::{ToolSession, TurnContext};
30
31// ============================================================================
32// Review Decision Types (from Codex protocol)
33// ============================================================================
34
35/// User's decision on an approval request (from Codex)
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum ReviewDecision {
38    /// Approval granted for this single invocation
39    Approved,
40    /// Approval denied
41    Denied,
42    /// Abort the entire operation
43    Abort,
44    /// Approval granted for the entire session
45    ApprovedForSession,
46    /// Approval granted with exec policy amendment
47    ApprovedExecpolicyAmendment {
48        proposed_execpolicy_amendment: ExecPolicyAmendment,
49    },
50}
51
52// ============================================================================
53// Exec Approval Requirement (from Codex)
54// ============================================================================
55
56// ============================================================================
57// Approval Store (from Codex)
58// ============================================================================
59
60/// Store for cached approval decisions (from Codex)
61#[derive(Clone, Default)]
62pub struct ApprovalStore {
63    approvals: Arc<RwLock<HashMap<String, ReviewDecision>>>,
64}
65
66impl ApprovalStore {
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Get a cached approval decision
72    pub async fn get(&self, key: &str) -> Option<ReviewDecision> {
73        self.approvals.read().await.get(key).cloned()
74    }
75
76    /// Store an approval decision
77    pub async fn set(&self, key: String, decision: ReviewDecision) {
78        self.approvals.write().await.insert(key, decision);
79    }
80
81    /// Check if an approval exists
82    pub async fn contains(&self, key: &str) -> bool {
83        self.approvals.read().await.contains_key(key)
84    }
85}
86
87/// Helper function to cache approval decisions (from Codex)
88pub async fn with_cached_approval<K, F, Fut>(store: &ApprovalStore, key: K, fetch: F) -> ReviewDecision
89where
90    K: Serialize + Clone,
91    F: FnOnce() -> Fut,
92    Fut: Future<Output = ReviewDecision>,
93{
94    let key_str = serde_json::to_string(&key).unwrap_or_default();
95
96    // Check if we already have a cached decision
97    if let Some(decision) = store.get(&key_str).await
98        && matches!(decision, ReviewDecision::ApprovedForSession)
99    {
100        return ReviewDecision::Approved;
101    }
102
103    // Fetch new decision
104    let decision = fetch().await;
105
106    // Cache the decision
107    store.set(key_str, decision.clone()).await;
108
109    decision
110}
111
112// ============================================================================
113// Approval Context (from Codex)
114// ============================================================================
115
116/// Context for approval decisions (from Codex)
117pub struct ApprovalCtx<'a> {
118    pub session: &'a dyn ToolSession,
119    pub turn: &'a TurnContext,
120    pub call_id: &'a str,
121    pub retry_reason: Option<String>,
122}
123
124// ============================================================================
125// Sandbox Preferences (from Codex)
126// ============================================================================
127
128/// Sandbox override for first attempt (from Codex)
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum SandboxOverride {
131    /// Use default sandbox selection
132    NoOverride,
133    /// Bypass sandbox on first attempt
134    BypassSandboxFirstAttempt,
135}
136
137/// Sandbox preference for a tool (from Codex)
138#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139pub enum SandboxablePreference {
140    /// Let the orchestrator decide
141    #[default]
142    Auto,
143    /// Require sandbox execution
144    Require,
145    /// Forbid sandbox
146    Forbid,
147}
148
149// ============================================================================
150// Sandboxable Trait (from Codex)
151// ============================================================================
152
153/// Trait for tools that can be sandboxed (from Codex)
154pub trait Sandboxable {
155    /// Get the sandbox preference for this tool
156    fn sandbox_preference(&self) -> SandboxablePreference {
157        SandboxablePreference::Auto
158    }
159
160    /// Whether to escalate to unsandboxed execution on failure
161    fn escalate_on_failure(&self) -> bool {
162        true
163    }
164}
165
166// ============================================================================
167// Approvable Trait (from Codex)
168// ============================================================================
169
170/// Type alias for boxed future (from Codex)
171pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
172
173/// Trait for tools that require approval (from Codex)
174pub trait Approvable<Req>: Send + Sync {
175    /// The key type used for caching approvals
176    type ApprovalKey: Hash + Eq + Clone + Debug + Serialize + Send + Sync;
177
178    /// Generate an approval key for the request
179    fn approval_key(&self, req: &Req) -> Self::ApprovalKey;
180
181    /// Some tools may request a sandbox policy override on the first attempt
182    fn sandbox_policy_override_for_first_attempt(&self, _req: &Req) -> SandboxOverride {
183        SandboxOverride::NoOverride
184    }
185
186    /// Check if approval should be bypassed
187    fn should_bypass_approval(&self, policy: AskForApproval, already_approved: bool) -> bool {
188        if already_approved {
189            return true;
190        }
191        matches!(policy, AskForApproval::Never)
192    }
193
194    /// Return custom exec approval requirement, or None for default
195    fn exec_approval_requirement(&self, _req: &Req) -> Option<ExecApprovalRequirement> {
196        None
197    }
198
199    /// Decide if we can request approval for no-sandbox execution
200    fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
201        match policy {
202            AskForApproval::OnFailure => true,
203            AskForApproval::UnlessTrusted => true,
204            AskForApproval::Never => false,
205            AskForApproval::OnRequest => false,
206            AskForApproval::Reject(_) => !policy.rejects_sandbox_prompt(),
207        }
208    }
209
210    /// Start the approval process asynchronously (from Codex)
211    fn start_approval_async<'a>(&'a mut self, req: &'a Req, ctx: ApprovalCtx<'a>) -> BoxFuture<'a, ReviewDecision>;
212}
213
214// ============================================================================
215// Sandbox Policy (from Codex)
216// ============================================================================
217
218/// Sandbox policy configuration (from Codex protocol)
219#[derive(Clone, Debug, Default, PartialEq, Eq)]
220pub struct SandboxConfig {
221    pub policy: SandboxPolicy,
222    pub network_access: NetworkAccess,
223}
224
225/// Sandbox policy (from Codex)
226#[derive(Clone, Debug, Default, PartialEq, Eq)]
227pub enum SandboxPolicy {
228    /// Read-only filesystem access
229    #[default]
230    ReadOnly,
231    /// Write access within workspace
232    WorkspaceWrite,
233    /// Full access (dangerous)
234    DangerFullAccess,
235    /// External sandbox (e.g., Docker)
236    ExternalSandbox,
237}
238
239/// Network access policy
240#[derive(Clone, Debug, Default, PartialEq, Eq)]
241pub enum NetworkAccess {
242    /// No network access
243    #[default]
244    Restricted,
245    /// Limited network access
246    Limited,
247    /// Full network access
248    Full,
249}
250
251const LEGACY_EXTERNAL_SANDBOX_DESCRIPTION: &str = "legacy handler external sandbox";
252
253impl SandboxConfig {
254    #[must_use]
255    pub fn requires_approval_prompt(&self) -> bool {
256        !matches!(self.policy, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox)
257    }
258
259    #[must_use]
260    pub fn uses_runtime_sandbox(&self) -> bool {
261        matches!(self.policy, SandboxPolicy::ReadOnly | SandboxPolicy::WorkspaceWrite)
262    }
263
264    #[must_use]
265    pub fn to_canonical_policy(&self, sandbox_cwd: &Path) -> CanonicalSandboxPolicy {
266        match (&self.policy, &self.network_access) {
267            (SandboxPolicy::ReadOnly, NetworkAccess::Restricted | NetworkAccess::Limited) => {
268                CanonicalSandboxPolicy::read_only()
269            }
270            (SandboxPolicy::ReadOnly, NetworkAccess::Full) => CanonicalSandboxPolicy::read_only_with_full_network(),
271            (SandboxPolicy::WorkspaceWrite, NetworkAccess::Restricted | NetworkAccess::Limited) => {
272                CanonicalSandboxPolicy::workspace_write(vec![sandbox_cwd.to_path_buf()])
273            }
274            (SandboxPolicy::WorkspaceWrite, NetworkAccess::Full) => CanonicalSandboxPolicy::workspace_write_full(
275                vec![sandbox_cwd.to_path_buf()],
276                Vec::new(),
277                None,
278                ResourceLimits::default(),
279                SeccompProfile::strict().with_network(),
280            ),
281            (SandboxPolicy::DangerFullAccess, _) => CanonicalSandboxPolicy::full_access(),
282            (SandboxPolicy::ExternalSandbox, _) => CanonicalSandboxPolicy::ExternalSandbox {
283                description: LEGACY_EXTERNAL_SANDBOX_DESCRIPTION.to_string(),
284            },
285        }
286    }
287
288    #[must_use]
289    pub fn to_canonical_policy_for_turn(&self, turn: &TurnContext) -> CanonicalSandboxPolicy {
290        self.to_canonical_policy(&turn.cwd)
291    }
292}
293
294#[must_use]
295pub fn canonical_sandbox_policy(turn: &TurnContext) -> CanonicalSandboxPolicy {
296    turn.sandbox_policy.get().to_canonical_policy_for_turn(turn)
297}
298
299/// Compute default exec approval requirement (from Codex)
300pub fn default_exec_approval_requirement(
301    policy: AskForApproval,
302    sandbox_policy: &SandboxConfig,
303) -> ExecApprovalRequirement {
304    canonical_default_exec_approval_requirement(policy, sandbox_requires_approval_prompt(sandbox_policy))
305}
306
307fn sandbox_requires_approval_prompt(sandbox_policy: &SandboxConfig) -> bool {
308    sandbox_policy.requires_approval_prompt()
309}
310
311// ============================================================================
312// Tool Context (from Codex)
313// ============================================================================
314
315/// Tool execution context for runtimes (from Codex)
316pub struct ToolCtx {
317    pub session: Arc<dyn ToolSession>,
318    pub turn: Arc<TurnContext>,
319    pub call_id: String,
320    pub tool_name: String,
321}
322
323// ============================================================================
324// Tool Error (from Codex)
325// ============================================================================
326
327/// Error from tool runtime execution (from Codex)
328///
329/// `Debug` via [`DebugNoInline`]: `Codex(anyhow::Error)` nests an arbitrary source
330/// chain on the tool-failure path.
331#[derive(DebugNoInline, thiserror::Error)]
332pub enum ToolError {
333    #[error("Tool rejected: {0}")]
334    Rejected(String),
335
336    #[error("Internal error: {0}")]
337    Codex(#[from] anyhow::Error),
338
339    #[error("Sandbox denied: {0}")]
340    SandboxDenied(String),
341
342    #[error("Timeout after {0}ms")]
343    Timeout(u64),
344}
345
346// ============================================================================
347// Sandbox Attempt (from Codex)
348// ============================================================================
349
350/// Sandbox type for execution (from Codex)
351#[derive(Clone, Copy, Debug, PartialEq, Eq)]
352pub enum SandboxType {
353    /// No sandbox
354    None,
355    /// macOS seatbelt
356    Seatbelt,
357    /// Linux sandbox
358    LinuxSandbox,
359}
360
361impl From<CanonicalSandboxType> for SandboxType {
362    fn from(value: CanonicalSandboxType) -> Self {
363        match value {
364            CanonicalSandboxType::None => Self::None,
365            CanonicalSandboxType::MacosSeatbelt => Self::Seatbelt,
366            CanonicalSandboxType::LinuxLandlock => Self::LinuxSandbox,
367            CanonicalSandboxType::WindowsRestrictedToken => Self::None,
368        }
369    }
370}
371
372/// Sandbox attempt context (from Codex)
373pub struct SandboxAttempt<'a> {
374    pub sandbox: SandboxType,
375    pub policy: &'a SandboxConfig,
376    pub sandbox_cwd: &'a Path,
377    pub linux_sandbox_launcher: Option<&'a LinuxSandboxLauncher>,
378}
379
380impl<'a> SandboxAttempt<'a> {
381    /// Create execution environment for a command spec
382    pub fn env_for(&self, spec: CommandSpec) -> Result<ExecEnv, SandboxTransformError> {
383        let canonical_policy = if self.sandbox == SandboxType::None {
384            CanonicalSandboxPolicy::full_access()
385        } else {
386            self.policy.to_canonical_policy(self.sandbox_cwd)
387        };
388        let canonical_spec = CanonicalCommandSpec::new(spec.program)
389            .with_args(spec.args)
390            .with_cwd(spec.cwd)
391            .with_env(spec.env)
392            .with_expiration(CanonicalExecExpiration::from(spec.timeout_ms));
393        let canonical_env = CanonicalSandboxManager::new()
394            .transform(canonical_spec, &canonical_policy, self.sandbox_cwd, self.linux_sandbox_launcher)
395            .map_err(SandboxTransformError::from)?;
396
397        Ok(ExecEnv::from_canonical(canonical_env))
398    }
399}
400
401/// Command specification for execution
402#[derive(Clone, Debug)]
403pub struct CommandSpec {
404    pub program: String,
405    pub args: Vec<String>,
406    pub cwd: PathBuf,
407    pub env: HashMap<String, String>,
408    pub timeout_ms: Option<u64>,
409}
410
411/// Execution environment after sandbox transformation
412#[derive(Clone, Debug)]
413pub struct ExecEnv {
414    pub program: String,
415    pub args: Vec<String>,
416    pub cwd: PathBuf,
417    pub env: HashMap<String, String>,
418    pub timeout_ms: Option<u64>,
419    pub sandbox: SandboxType,
420}
421
422impl ExecEnv {
423    fn from_canonical(env: CanonicalExecEnv) -> Self {
424        Self {
425            program: env.program.to_string_lossy().into_owned(),
426            args: env.args,
427            cwd: env.cwd,
428            env: env.env,
429            timeout_ms: env.expiration.timeout_ms(),
430            sandbox: SandboxType::from(env.sandbox_type),
431        }
432    }
433}
434
435/// Error during sandbox transformation
436#[derive(Debug, thiserror::Error)]
437pub enum SandboxTransformError {
438    #[error("missing sandbox executable")]
439    MissingSandboxExecutable,
440
441    #[error("sandbox not available on this platform")]
442    SandboxUnavailable,
443
444    #[error("sandbox configuration error: {0}")]
445    ConfigError(String),
446}
447
448impl From<CanonicalSandboxTransformError> for SandboxTransformError {
449    fn from(value: CanonicalSandboxTransformError) -> Self {
450        match value {
451            CanonicalSandboxTransformError::MissingSandboxExecutable => Self::MissingSandboxExecutable,
452            CanonicalSandboxTransformError::UnavailableSandboxType(_) => Self::SandboxUnavailable,
453            CanonicalSandboxTransformError::CreationFailed(message)
454            | CanonicalSandboxTransformError::InvalidPolicy(message) => Self::ConfigError(message),
455        }
456    }
457}
458
459// ============================================================================
460// Tool Runtime Trait (from Codex)
461// ============================================================================
462
463/// Trait for tool runtimes (from Codex)
464///
465/// A runtime handles the actual execution of a tool request within
466/// a sandbox attempt context. Combines Approvable and Sandboxable traits.
467#[async_trait]
468pub trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable
469where
470    Req: Send + Sync,
471    Out: Send + Sync,
472{
473    /// Execute the tool request
474    async fn run(&mut self, req: &Req, attempt: &SandboxAttempt<'_>, ctx: &ToolCtx) -> Result<Out, ToolError>;
475}
476
477// ============================================================================
478// Sandbox Manager (from Codex)
479// ============================================================================
480
481/// Sandbox manager for creating sandbox attempts (from Codex)
482#[derive(Default)]
483pub struct SandboxManager;
484
485impl SandboxManager {
486    pub fn new() -> Self {
487        Self
488    }
489
490    /// Select the initial sandbox type based on policy and preference
491    pub fn select_initial(&self, policy: &SandboxConfig, preference: SandboxablePreference) -> SandboxType {
492        match preference {
493            SandboxablePreference::Forbid => SandboxType::None,
494            SandboxablePreference::Require => self.platform_sandbox(),
495            SandboxablePreference::Auto => {
496                if policy.uses_runtime_sandbox() {
497                    self.platform_sandbox()
498                } else {
499                    SandboxType::None
500                }
501            }
502        }
503    }
504
505    pub fn select_initial_for_canonical(
506        &self,
507        policy: &CanonicalSandboxPolicy,
508        preference: SandboxablePreference,
509    ) -> SandboxType {
510        match preference {
511            SandboxablePreference::Forbid => SandboxType::None,
512            SandboxablePreference::Require => self.platform_sandbox(),
513            SandboxablePreference::Auto => {
514                if matches!(
515                    policy,
516                    CanonicalSandboxPolicy::DangerFullAccess | CanonicalSandboxPolicy::ExternalSandbox { .. }
517                ) {
518                    SandboxType::None
519                } else {
520                    self.platform_sandbox()
521                }
522            }
523        }
524    }
525
526    /// Get the platform-specific sandbox type
527    #[cfg(target_os = "macos")]
528    fn platform_sandbox(&self) -> SandboxType {
529        SandboxType::Seatbelt
530    }
531
532    #[cfg(target_os = "linux")]
533    fn platform_sandbox(&self) -> SandboxType {
534        SandboxType::LinuxSandbox
535    }
536
537    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
538    fn platform_sandbox(&self) -> SandboxType {
539        SandboxType::None
540    }
541}
542
543// ============================================================================
544// Execute Environment (from Codex)
545// ============================================================================
546
547/// Output from command execution (from Codex)
548#[derive(Clone, Debug, Default)]
549pub struct ExecToolCallOutput {
550    pub stdout: String,
551    pub stderr: String,
552    pub exit_code: i32,
553}
554
555impl ExecToolCallOutput {
556    pub fn success(&self) -> bool {
557        self.exit_code == 0
558    }
559
560    pub fn combined_output(&self) -> String {
561        if self.stderr.is_empty() {
562            self.stdout.clone()
563        } else if self.stdout.is_empty() {
564            self.stderr.clone()
565        } else {
566            format!("{}\n{}", self.stdout, self.stderr)
567        }
568    }
569}
570
571/// Execute command with environment (from Codex)
572pub async fn execute_env(env: ExecEnv, _policy: &SandboxConfig) -> Result<ExecToolCallOutput, anyhow::Error> {
573    use tokio::process::Command;
574
575    let mut cmd = Command::new(&env.program);
576    cmd.args(&env.args)
577        .current_dir(&env.cwd)
578        .envs(&env.env)
579        .stdout(std::process::Stdio::piped())
580        .stderr(std::process::Stdio::piped());
581
582    let timeout = env
583        .timeout_ms
584        .map(std::time::Duration::from_millis)
585        .unwrap_or(std::time::Duration::from_secs(300));
586
587    let output = tokio::time::timeout(timeout, cmd.output())
588        .await
589        .map_err(|e| anyhow::anyhow!("Command timed out: {e}"))?
590        .map_err(|e| anyhow::anyhow!("Command failed: {e}"))?;
591
592    Ok(ExecToolCallOutput {
593        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
594        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
595        exit_code: output.status.code().unwrap_or(-1),
596    })
597}
598
599// ============================================================================
600// Tests
601// ============================================================================
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use std::path::{Path, PathBuf};
607
608    #[test]
609    fn test_external_sandbox_skips_exec_approval_on_request() {
610        let result = default_exec_approval_requirement(
611            AskForApproval::OnRequest,
612            &SandboxConfig {
613                policy: SandboxPolicy::ExternalSandbox,
614                network_access: NetworkAccess::Restricted,
615            },
616        );
617
618        assert_eq!(
619            result,
620            ExecApprovalRequirement::Skip {
621                bypass_sandbox: false,
622                proposed_execpolicy_amendment: None,
623            }
624        );
625    }
626
627    #[test]
628    fn test_restricted_sandbox_requires_exec_approval_on_request() {
629        let result = default_exec_approval_requirement(
630            AskForApproval::OnRequest,
631            &SandboxConfig {
632                policy: SandboxPolicy::ReadOnly,
633                network_access: NetworkAccess::Restricted,
634            },
635        );
636
637        assert_eq!(
638            result,
639            ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None }
640        );
641    }
642
643    #[test]
644    fn reject_policy_still_flows_through_handler_wrapper() {
645        let policy = AskForApproval::Reject(RejectConfig {
646            sandbox_approval: true,
647            rules: false,
648            request_permissions: false,
649            mcp_elicitations: false,
650        });
651
652        let requirement = default_exec_approval_requirement(
653            policy,
654            &SandboxConfig {
655                policy: SandboxPolicy::ReadOnly,
656                network_access: NetworkAccess::Restricted,
657            },
658        );
659
660        assert_eq!(
661            requirement,
662            ExecApprovalRequirement::Forbidden {
663                reason: "approval policy rejected sandbox approval prompt".to_string(),
664            }
665        );
666    }
667
668    #[test]
669    fn read_only_restricted_maps_to_canonical_read_only() {
670        let policy = SandboxConfig {
671            policy: SandboxPolicy::ReadOnly,
672            network_access: NetworkAccess::Restricted,
673        };
674
675        assert_eq!(policy.to_canonical_policy(Path::new("/workspace")), CanonicalSandboxPolicy::read_only());
676    }
677
678    #[test]
679    fn read_only_full_maps_to_canonical_full_network() {
680        let policy = SandboxConfig {
681            policy: SandboxPolicy::ReadOnly,
682            network_access: NetworkAccess::Full,
683        };
684
685        assert_eq!(
686            policy.to_canonical_policy(Path::new("/workspace")),
687            CanonicalSandboxPolicy::read_only_with_full_network()
688        );
689    }
690
691    #[test]
692    fn workspace_write_restricted_maps_to_canonical_workspace_write() {
693        let root = PathBuf::from("/workspace");
694        let policy = SandboxConfig {
695            policy: SandboxPolicy::WorkspaceWrite,
696            network_access: NetworkAccess::Restricted,
697        };
698
699        assert_eq!(policy.to_canonical_policy(&root), CanonicalSandboxPolicy::workspace_write(vec![root]));
700    }
701
702    #[test]
703    fn workspace_write_full_maps_to_canonical_workspace_write_with_network() {
704        let root = PathBuf::from("/workspace");
705        let policy = SandboxConfig {
706            policy: SandboxPolicy::WorkspaceWrite,
707            network_access: NetworkAccess::Full,
708        };
709
710        assert_eq!(
711            policy.to_canonical_policy(&root),
712            CanonicalSandboxPolicy::workspace_write_full(
713                vec![root],
714                Vec::new(),
715                None,
716                ResourceLimits::default(),
717                SeccompProfile::strict().with_network(),
718            )
719        );
720    }
721
722    #[test]
723    fn danger_full_access_maps_to_canonical_full_access() {
724        let policy = SandboxConfig {
725            policy: SandboxPolicy::DangerFullAccess,
726            network_access: NetworkAccess::Restricted,
727        };
728
729        assert_eq!(policy.to_canonical_policy(Path::new("/workspace")), CanonicalSandboxPolicy::full_access());
730    }
731
732    #[test]
733    fn external_sandbox_maps_to_canonical_external_sandbox() {
734        let policy = SandboxConfig {
735            policy: SandboxPolicy::ExternalSandbox,
736            network_access: NetworkAccess::Full,
737        };
738
739        assert_eq!(
740            policy.to_canonical_policy(Path::new("/workspace")),
741            CanonicalSandboxPolicy::ExternalSandbox {
742                description: LEGACY_EXTERNAL_SANDBOX_DESCRIPTION.to_string(),
743            }
744        );
745    }
746
747    #[test]
748    fn limited_network_maps_to_restricted_network() {
749        let policy = SandboxConfig {
750            policy: SandboxPolicy::WorkspaceWrite,
751            network_access: NetworkAccess::Limited,
752        };
753
754        assert_eq!(
755            policy.to_canonical_policy(Path::new("/workspace")),
756            CanonicalSandboxPolicy::workspace_write(vec![PathBuf::from("/workspace")])
757        );
758    }
759
760    #[tokio::test]
761    async fn test_approval_store() {
762        let store = ApprovalStore::new();
763
764        assert!(!store.contains("test").await);
765
766        store.set("test".to_string(), ReviewDecision::Approved).await;
767        assert!(store.contains("test").await);
768        assert_eq!(store.get("test").await, Some(ReviewDecision::Approved));
769    }
770
771    #[test]
772    fn test_sandbox_manager_platform_selection() {
773        let manager = SandboxManager::new();
774
775        // Auto preference respects policy
776        let sandbox = manager.select_initial(
777            &SandboxConfig {
778                policy: SandboxPolicy::DangerFullAccess,
779                ..Default::default()
780            },
781            SandboxablePreference::Auto,
782        );
783        assert_eq!(sandbox, SandboxType::None);
784
785        // Forbid always returns None
786        let sandbox = manager.select_initial(&SandboxConfig::default(), SandboxablePreference::Forbid);
787        assert_eq!(sandbox, SandboxType::None);
788    }
789
790    #[test]
791    fn test_exec_tool_call_output() {
792        let output = ExecToolCallOutput {
793            stdout: "hello".to_string(),
794            stderr: "".to_string(),
795            exit_code: 0,
796        };
797        assert!(output.success());
798        assert_eq!(output.combined_output(), "hello");
799
800        let output = ExecToolCallOutput {
801            stdout: "out".to_string(),
802            stderr: "err".to_string(),
803            exit_code: 1,
804        };
805        assert!(!output.success());
806        assert_eq!(output.combined_output(), "out\nerr");
807    }
808}