Skip to main content

whycodes_core/
sandbox.rs

1//! Leaf sandbox policy types used by [`crate::ToolContext`] and the sandbox runtime.
2//!
3//! Kept in `whycodes-core` (not `whycodes-config`) so tool execution can depend on
4//! policy without pulling config load/merge.
5
6use serde::{Deserialize, Serialize};
7
8/// How aggressively shell commands are OS-sandboxed.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum SandboxMode {
12    /// Host `bash -c` with no namespace isolation.
13    Off,
14    /// Project directory RW, host root RO (bubblewrap on Linux).
15    #[default]
16    Workspace,
17}
18
19impl std::str::FromStr for SandboxMode {
20    type Err = String;
21
22    fn from_str(s: &str) -> Result<Self, Self::Err> {
23        match s.to_ascii_lowercase().as_str() {
24            "off" | "none" | "false" | "0" => Ok(Self::Off),
25            "workspace" | "on" | "true" | "1" => Ok(Self::Workspace),
26            other => Err(format!(
27                "unknown sandbox mode '{other}' (expected off or workspace)"
28            )),
29        }
30    }
31}
32
33impl SandboxMode {
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Self::Off => "off",
37            Self::Workspace => "workspace",
38        }
39    }
40}
41
42/// What to do when the requested sandbox backend is missing.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum SandboxFallback {
46    /// Log a warning and run the command on the host.
47    #[default]
48    Allow,
49    /// Fail the tool call; do not run unsandboxed.
50    Deny,
51}
52
53impl std::str::FromStr for SandboxFallback {
54    type Err = String;
55
56    fn from_str(s: &str) -> Result<Self, Self::Err> {
57        match s.to_ascii_lowercase().as_str() {
58            "allow" | "warn" | "host" => Ok(Self::Allow),
59            "deny" | "error" | "strict" => Ok(Self::Deny),
60            other => Err(format!(
61                "unknown sandbox_fallback '{other}' (expected allow or deny)"
62            )),
63        }
64    }
65}
66
67/// Resolved sandbox policy carried on [`crate::ToolContext`].
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct SandboxSettings {
70    pub mode: SandboxMode,
71    pub network: bool,
72    pub fallback: SandboxFallback,
73}
74
75impl Default for SandboxSettings {
76    fn default() -> Self {
77        Self {
78            mode: SandboxMode::Workspace,
79            network: true,
80            fallback: SandboxFallback::Allow,
81        }
82    }
83}
84
85impl SandboxSettings {
86    pub fn off() -> Self {
87        Self {
88            mode: SandboxMode::Off,
89            network: true,
90            fallback: SandboxFallback::Allow,
91        }
92    }
93
94    /// Build settings from raw security strings (config layer values).
95    pub fn from_raw(mode: &str, network: bool, fallback: &str) -> Self {
96        let mode = mode.parse().unwrap_or_else(|e| {
97            tracing::warn!("{e}; falling back to workspace");
98            SandboxMode::Workspace
99        });
100        let fallback = fallback.parse().unwrap_or_else(|e| {
101            tracing::warn!("{e}; falling back to allow");
102            SandboxFallback::Allow
103        });
104        Self {
105            mode,
106            network,
107            fallback,
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn mode_fallback_parse_and_from_raw() {
118        for (raw, mode) in [
119            ("off", SandboxMode::Off),
120            ("none", SandboxMode::Off),
121            ("false", SandboxMode::Off),
122            ("0", SandboxMode::Off),
123            ("workspace", SandboxMode::Workspace),
124            ("on", SandboxMode::Workspace),
125            ("true", SandboxMode::Workspace),
126            ("1", SandboxMode::Workspace),
127        ] {
128            assert_eq!(raw.parse::<SandboxMode>().unwrap(), mode);
129        }
130        assert!("nope".parse::<SandboxMode>().is_err());
131        assert_eq!(SandboxMode::Off.as_str(), "off");
132        assert_eq!(SandboxMode::Workspace.as_str(), "workspace");
133        assert_eq!(SandboxMode::default(), SandboxMode::Workspace);
134
135        for (raw, fb) in [
136            ("allow", SandboxFallback::Allow),
137            ("warn", SandboxFallback::Allow),
138            ("host", SandboxFallback::Allow),
139            ("deny", SandboxFallback::Deny),
140            ("error", SandboxFallback::Deny),
141            ("strict", SandboxFallback::Deny),
142        ] {
143            assert_eq!(raw.parse::<SandboxFallback>().unwrap(), fb);
144        }
145        assert!("nope".parse::<SandboxFallback>().is_err());
146
147        let off = SandboxSettings::off();
148        assert_eq!(off.mode, SandboxMode::Off);
149        assert!(SandboxSettings::default().network);
150        let parsed = SandboxSettings::from_raw("off", false, "deny");
151        assert_eq!(parsed.mode, SandboxMode::Off);
152        assert!(!parsed.network);
153        assert_eq!(parsed.fallback, SandboxFallback::Deny);
154        let fallback = SandboxSettings::from_raw("???", true, "???");
155        assert_eq!(fallback.mode, SandboxMode::Workspace);
156        assert_eq!(fallback.fallback, SandboxFallback::Allow);
157    }
158}