Skip to main content

synwire_core/agents/
sandbox.rs

1//! Sandbox configuration for agents.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7// ── Isolation level ───────────────────────────────────────────────────────────
8
9/// How strongly to isolate processes spawned by this agent.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11#[non_exhaustive]
12pub enum IsolationLevel {
13    /// No isolation — plain `tokio::process::Command`. Approval prompts remain active.
14    #[default]
15    None,
16    /// cgroup v2 tracking + optional `AppArmor` (Linux) or Seatbelt (macOS).
17    /// Falls back to `None` gracefully when unavailable.
18    /// When active, terminal commands are auto-approved.
19    CgroupTracking,
20    /// Full Linux namespace container via OCI runtime (runc/crun).
21    /// Provides PID, UTS, IPC, mount, cgroup, and optional network/user namespaces.
22    Namespace,
23    /// macOS `sandbox-exec` with a generated Seatbelt SBPL profile.
24    Seatbelt,
25    /// OCI container via Podman or Lima (macOS strong isolation).
26    Container,
27}
28
29// ── Filesystem config ─────────────────────────────────────────────────────────
30
31/// Filesystem access rules.
32///
33/// Designed for coding agent scenarios: the full host filesystem remains
34/// readable by default (binaries, dotfiles, shared libraries), while writes
35/// are restricted to the working directory. Enforcement mechanism varies by
36/// [`IsolationLevel`]:
37///
38/// - `None` / `CgroupTracking`: `AppArmor` (Linux) or Seatbelt (macOS) profile
39/// - `Namespace`: bind-mount policy derived from these rules
40/// - `Container`: translated to `podman --volume` flags
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[non_exhaustive]
43pub struct FilesystemConfig {
44    /// Paths where writes are explicitly permitted.
45    ///
46    /// Supports absolute paths and cwd-relative paths (including glob patterns
47    /// such as `"./src/**"`). Default: `["."]` — working directory only.
48    pub allow_write: Vec<String>,
49    /// Paths where writes are blocked, evaluated after `allow_write`.
50    ///
51    /// Example: `["./secrets/", ".env"]`
52    pub deny_write: Vec<String>,
53    /// Paths where reads are blocked.
54    ///
55    /// Example: `["/etc/shadow", "~/.ssh/id_rsa"]`
56    pub deny_read: Vec<String>,
57    /// Expose the entire host filesystem as readable.
58    ///
59    /// `true` by default — preserves access to binaries, dotfiles, and shared
60    /// libraries. In `Namespace` mode this causes the host root to be
61    /// bind-mounted read-only; `deny_read` entries are excluded.
62    /// Set to `false` for a stripped environment.
63    pub inherit_readable: bool,
64}
65
66impl Default for FilesystemConfig {
67    fn default() -> Self {
68        Self {
69            allow_write: vec![".".into()],
70            deny_write: vec![],
71            deny_read: vec![],
72            inherit_readable: true,
73        }
74    }
75}
76
77// ── Network config ────────────────────────────────────────────────────────────
78
79/// Network access rules.
80///
81/// Disabled by default — coding agents should explicitly opt-in to the domains
82/// they require.
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[non_exhaustive]
85pub struct NetworkConfig {
86    /// Enable outbound network access. Default: `false`.
87    pub enabled: bool,
88    /// Domain allowlist. Supports wildcards: `"*.npmjs.org"`.
89    ///
90    /// `None` means all domains are permitted when `enabled = true`.
91    pub allowed_domains: Option<Vec<String>>,
92    /// Domains that are always blocked, regardless of `allowed_domains`.
93    pub denied_domains: Vec<String>,
94    /// Also allow domains from a system or user trusted-domains list
95    /// (e.g. a corporate proxy allowlist).
96    pub allow_trusted_domains: bool,
97}
98
99// ── Environment config ────────────────────────────────────────────────────────
100
101/// Controls environment variable inheritance for sandboxed processes.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[non_exhaustive]
104pub struct EnvConfig {
105    /// Inherit all environment variables from the parent process.
106    ///
107    /// `true` by default — preserves `PATH`, `HOME`, dotfile locations, tool
108    /// configurations, and editor settings. Set to `false` for a clean
109    /// environment.
110    pub inherit_parent: bool,
111    /// Additional variables to set. Merged after parent env when
112    /// `inherit_parent = true`; these values take precedence.
113    pub set: HashMap<String, String>,
114    /// Variable names to remove from the inherited set.
115    ///
116    /// Example: `["AWS_SECRET_ACCESS_KEY", "GITHUB_TOKEN"]`
117    pub unset: Vec<String>,
118}
119
120impl Default for EnvConfig {
121    fn default() -> Self {
122        Self {
123            inherit_parent: true,
124            set: HashMap::new(),
125            unset: vec![],
126        }
127    }
128}
129
130// ── Resource limits ───────────────────────────────────────────────────────────
131
132/// Resource limits applied via cgroup v2 or container runtime.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[non_exhaustive]
135#[derive(Default)]
136pub struct ResourceLimits {
137    /// Maximum memory in bytes. Maps to cgroup `memory.max`.
138    pub memory_bytes: Option<u64>,
139    /// CPU quota as a fraction of one core (e.g. `0.5` = 50% of one core).
140    /// Maps to cgroup `cpu.max` with a 100 ms period.
141    pub cpu_quota: Option<f32>,
142    /// Maximum number of PIDs in the cgroup. Maps to `pids.max`.
143    pub max_pids: Option<u32>,
144    /// Wall-clock timeout for a single `execute()` call, in seconds.
145    pub exec_timeout_secs: Option<u64>,
146}
147
148impl ResourceLimits {
149    /// Create resource limits with all fields set to `None` (no restrictions).
150    #[must_use]
151    pub fn none() -> Self {
152        Self::default()
153    }
154}
155
156// ── Process tracking ──────────────────────────────────────────────────────────
157
158/// Controls whether spawned processes are tracked and exposed as LLM tools.
159///
160/// When enabled, the agent gains `list_processes`, `kill_process`, and
161/// `process_stats` tools automatically.
162#[derive(Debug, Clone, Default, Serialize, Deserialize)]
163#[non_exhaustive]
164pub struct ProcessTracking {
165    /// Enable process tracking and the associated LLM tools.
166    pub enabled: bool,
167    /// Maximum number of concurrently tracked processes. New spawns are
168    /// rejected with an error once this limit is reached. `None` = unlimited.
169    pub max_tracked: Option<usize>,
170}
171
172// ── Security profile ──────────────────────────────────────────────────────────
173
174/// High-level security preset, inspired by (but not implementing) Kubernetes PSA.
175///
176/// The preset expands to concrete `seccomp`, `capabilities`, and
177/// `no_new_privileges` values before being passed to the sandbox init binary.
178/// Individual fields in [`SecurityProfile`] can override the preset.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
180#[non_exhaustive]
181pub enum SecurityPreset {
182    /// No restrictions. Use only in fully-trusted environments.
183    Privileged,
184    /// Prevent known privilege escalations. **Default.**
185    ///
186    /// Enables `PR_SET_NO_NEW_PRIVS`, applies `RuntimeDefault` seccomp, and
187    /// drops `NET_RAW`, `SYS_PTRACE`, and `SYS_ADMIN` capabilities.
188    #[default]
189    Baseline,
190    /// Defence-in-depth. All `Baseline` restrictions plus drop-all
191    /// capabilities and `RuntimeDefault` seccomp (required).
192    Restricted,
193}
194
195/// Seccomp filter selection.
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197#[non_exhaustive]
198pub enum SeccompProfile {
199    /// No seccomp filter applied.
200    Unconfined,
201    /// Bundled default profile (~50 blocked syscalls: `ptrace`,
202    /// `perf_event_open`, `process_vm_readv`, `kexec_load`, etc.).
203    ///
204    /// Translated to OCI seccomp format and applied by the container runtime.
205    #[default]
206    RuntimeDefault,
207    /// Load a custom seccomp profile JSON from the given path.
208    Localhost {
209        /// Absolute path to the seccomp profile JSON file.
210        path: String,
211    },
212}
213
214/// Linux capability adjustments applied before exec.
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
216#[non_exhaustive]
217pub struct CapabilityConfig {
218    /// Capabilities to drop. `["ALL"]` drops every capability.
219    pub drop: Vec<String>,
220    /// Capabilities to add back after dropping.
221    /// Only meaningful when combined with `drop: ["ALL"]`.
222    pub add: Vec<String>,
223}
224
225/// Per-process security context applied by the sandbox.
226///
227/// The `standard` preset provides safe defaults; individual fields can be
228/// overridden for fine-grained control.
229///
230/// Expansion table:
231///
232/// | Preset       | `no_new_privileges` | seccomp          | capabilities                      |
233/// |--------------|---------------------|------------------|-----------------------------------|
234/// | `Privileged` | false               | `Unconfined`     | no drops                          |
235/// | `Baseline`   | true                | `RuntimeDefault` | drop `NET_RAW`, `SYS_PTRACE`, `SYS_ADMIN` |
236/// | `Restricted` | true                | `RuntimeDefault` | drop `ALL`, no adds allowed       |
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[non_exhaustive]
239pub struct SecurityProfile {
240    /// High-level policy preset. Overrides other fields to safe defaults.
241    pub standard: SecurityPreset,
242    /// Seccomp profile to apply before exec.
243    pub seccomp: SeccompProfile,
244    /// Linux capability adjustments.
245    pub capabilities: CapabilityConfig,
246    /// Set `PR_SET_NO_NEW_PRIVS` before exec, preventing privilege escalation
247    /// via setuid binaries or file capabilities.
248    pub no_new_privileges: bool,
249    /// Run as this UID. `None` = inherit from calling process.
250    pub run_as_user: Option<u32>,
251    /// Run as this GID. `None` = inherit from calling process.
252    pub run_as_group: Option<u32>,
253}
254
255impl Default for SecurityProfile {
256    /// Baseline preset: `no_new_privileges`, `RuntimeDefault` seccomp,
257    /// drop `NET_RAW` / `SYS_PTRACE` / `SYS_ADMIN`.
258    fn default() -> Self {
259        Self {
260            standard: SecurityPreset::Baseline,
261            seccomp: SeccompProfile::RuntimeDefault,
262            capabilities: CapabilityConfig {
263                drop: vec!["NET_RAW".into(), "SYS_PTRACE".into(), "SYS_ADMIN".into()],
264                add: vec![],
265            },
266            no_new_privileges: true,
267            run_as_user: None,
268            run_as_group: None,
269        }
270    }
271}
272
273// ── SandboxConfig ─────────────────────────────────────────────────────────────
274
275/// Agent-level sandbox configuration.
276///
277/// Use [`SandboxConfig::coding_agent`] for a sensible default suitable for
278/// coding agents: full environment inherited, writes restricted to the working
279/// directory, network off, cgroup tracking enabled.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[non_exhaustive]
282pub struct SandboxConfig {
283    /// Enable sandboxing. When `false`, all other fields are ignored and
284    /// processes run with no restrictions.
285    pub enabled: bool,
286    /// Isolation mechanism to use. When `isolation != None` and
287    /// `enabled = true`, terminal commands are auto-approved.
288    pub isolation: IsolationLevel,
289    /// Filesystem access rules.
290    pub filesystem: Option<FilesystemConfig>,
291    /// Network access rules.
292    pub network: Option<NetworkConfig>,
293    /// Environment variable inheritance.
294    pub env: EnvConfig,
295    /// Process-level security context (seccomp, capabilities, NNP).
296    pub security: SecurityProfile,
297    /// cgroup / container resource limits.
298    pub resources: Option<ResourceLimits>,
299    /// Process tracking and associated LLM tools.
300    pub process_tracking: ProcessTracking,
301    /// Command allowlist. `None` = all commands permitted.
302    pub allowed_commands: Option<Vec<String>>,
303    /// Command blocklist (evaluated after `allowed_commands`).
304    pub denied_commands: Vec<String>,
305}
306
307impl Default for SandboxConfig {
308    fn default() -> Self {
309        Self {
310            enabled: false,
311            isolation: IsolationLevel::None,
312            filesystem: None,
313            network: None,
314            env: EnvConfig::default(),
315            security: SecurityProfile::default(),
316            resources: None,
317            process_tracking: ProcessTracking::default(),
318            allowed_commands: None,
319            denied_commands: vec![],
320        }
321    }
322}
323
324impl SandboxConfig {
325    /// Preset for coding agents.
326    ///
327    /// - Full host filesystem readable; writes restricted to working directory
328    /// - Network disabled by default
329    /// - Full parent environment inherited (`PATH`, `HOME`, dotfiles, etc.)
330    /// - cgroup v2 tracking on Linux (auto-approved terminal commands)
331    /// - Baseline security (NNP + `RuntimeDefault` seccomp)
332    /// - Process tracking enabled (`list_processes`, `kill_process`,
333    ///   `process_stats` available as LLM tools)
334    #[must_use]
335    pub fn coding_agent() -> Self {
336        Self {
337            enabled: true,
338            isolation: IsolationLevel::CgroupTracking,
339            filesystem: Some(FilesystemConfig::default()),
340            network: Some(NetworkConfig::default()),
341            env: EnvConfig::default(),
342            security: SecurityProfile::default(),
343            resources: None,
344            process_tracking: ProcessTracking {
345                enabled: true,
346                max_tracked: Some(64),
347            },
348            allowed_commands: None,
349            denied_commands: vec![],
350        }
351    }
352}
353
354#[cfg(test)]
355#[allow(clippy::unwrap_used)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn coding_agent_preset_has_expected_defaults() {
361        let cfg = SandboxConfig::coding_agent();
362        assert!(cfg.enabled);
363        assert_eq!(cfg.isolation, IsolationLevel::CgroupTracking);
364        assert!(cfg.process_tracking.enabled);
365        assert_eq!(cfg.process_tracking.max_tracked, Some(64));
366        assert!(cfg.env.inherit_parent);
367        let fs = cfg.filesystem.unwrap();
368        assert_eq!(fs.allow_write, vec!["."]);
369        assert!(fs.inherit_readable);
370        let net = cfg.network.unwrap();
371        assert!(!net.enabled);
372    }
373
374    #[test]
375    fn default_security_is_baseline() {
376        let sp = SecurityProfile::default();
377        assert_eq!(sp.standard, SecurityPreset::Baseline);
378        assert!(sp.no_new_privileges);
379        assert!(sp.capabilities.drop.contains(&"NET_RAW".to_string()));
380        assert!(sp.capabilities.drop.contains(&"SYS_PTRACE".to_string()));
381        assert!(sp.capabilities.drop.contains(&"SYS_ADMIN".to_string()));
382    }
383
384    #[test]
385    fn serde_round_trip() {
386        let cfg = SandboxConfig::coding_agent();
387        let json = serde_json::to_string(&cfg).unwrap();
388        let back: SandboxConfig = serde_json::from_str(&json).unwrap();
389        assert_eq!(back.isolation, cfg.isolation);
390        assert_eq!(
391            back.security.no_new_privileges,
392            cfg.security.no_new_privileges
393        );
394    }
395}