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;
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#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum ReviewDecision {
38 Approved,
40 Denied,
42 Abort,
44 ApprovedForSession,
46 ApprovedExecpolicyAmendment {
48 proposed_execpolicy_amendment: ExecPolicyAmendment,
49 },
50}
51
52#[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 pub async fn get(&self, key: &str) -> Option<ReviewDecision> {
73 self.approvals.read().await.get(key).cloned()
74 }
75
76 pub async fn set(&self, key: String, decision: ReviewDecision) {
78 self.approvals.write().await.insert(key, decision);
79 }
80
81 pub async fn contains(&self, key: &str) -> bool {
83 self.approvals.read().await.contains_key(key)
84 }
85}
86
87pub 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 if let Some(decision) = store.get(&key_str).await
98 && matches!(decision, ReviewDecision::ApprovedForSession)
99 {
100 return ReviewDecision::Approved;
101 }
102
103 let decision = fetch().await;
105
106 store.set(key_str, decision.clone()).await;
108
109 decision
110}
111
112pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum SandboxOverride {
131 NoOverride,
133 BypassSandboxFirstAttempt,
135}
136
137#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139pub enum SandboxablePreference {
140 #[default]
142 Auto,
143 Require,
145 Forbid,
147}
148
149pub trait Sandboxable {
155 fn sandbox_preference(&self) -> SandboxablePreference {
157 SandboxablePreference::Auto
158 }
159
160 fn escalate_on_failure(&self) -> bool {
162 true
163 }
164}
165
166pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
172
173pub trait Approvable<Req>: Send + Sync {
175 type ApprovalKey: Hash + Eq + Clone + Debug + Serialize + Send + Sync;
177
178 fn approval_key(&self, req: &Req) -> Self::ApprovalKey;
180
181 fn sandbox_policy_override_for_first_attempt(&self, _req: &Req) -> SandboxOverride {
183 SandboxOverride::NoOverride
184 }
185
186 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 fn exec_approval_requirement(&self, _req: &Req) -> Option<ExecApprovalRequirement> {
196 None
197 }
198
199 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 fn start_approval_async<'a>(&'a mut self, req: &'a Req, ctx: ApprovalCtx<'a>) -> BoxFuture<'a, ReviewDecision>;
212}
213
214#[derive(Clone, Debug, Default, PartialEq, Eq)]
220pub struct SandboxConfig {
221 pub policy: SandboxPolicy,
222 pub network_access: NetworkAccess,
223}
224
225#[derive(Clone, Debug, Default, PartialEq, Eq)]
227pub enum SandboxPolicy {
228 #[default]
230 ReadOnly,
231 WorkspaceWrite,
233 DangerFullAccess,
235 ExternalSandbox,
237}
238
239#[derive(Clone, Debug, Default, PartialEq, Eq)]
241pub enum NetworkAccess {
242 #[default]
244 Restricted,
245 Limited,
247 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
299pub 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
311pub 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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
352pub enum SandboxType {
353 None,
355 Seatbelt,
357 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
372pub 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 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#[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#[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#[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#[async_trait]
468pub trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable
469where
470 Req: Send + Sync,
471 Out: Send + Sync,
472{
473 async fn run(&mut self, req: &Req, attempt: &SandboxAttempt<'_>, ctx: &ToolCtx) -> Result<Out, ToolError>;
475}
476
477#[derive(Default)]
483pub struct SandboxManager;
484
485impl SandboxManager {
486 pub fn new() -> Self {
487 Self
488 }
489
490 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 #[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#[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
571pub 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#[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 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 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}