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}