Skip to main content

gaze/
sandbox.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::PathBuf;
3
4use thiserror::Error;
5
6/// Agent-controlled execution input. This is never handed directly to a
7/// sandbox backend; core validates it first.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct UntrustedExecRequest {
10    pub program: PathBuf,
11    pub args: Vec<String>,
12    pub env: BTreeMap<String, String>,
13    pub cwd: Option<PathBuf>,
14}
15
16impl UntrustedExecRequest {
17    pub fn validate(self, policy: &ExecPolicy) -> Result<ValidatedExecRequest, SandboxError> {
18        policy.validate(self)
19    }
20}
21
22/// Trusted execution request after core-side validation.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ValidatedExecRequest {
25    pub program: PathBuf,
26    pub args: Vec<String>,
27    pub env: BTreeMap<String, String>,
28    pub cwd: Option<PathBuf>,
29}
30
31/// Sandbox backends operate only on validated requests. The trust boundary
32/// is explicit: core validates agent-controlled argv/env/path input before
33/// any backend-specific wrapping is applied.
34pub trait Sandbox: Send + Sync {
35    fn prepare(&self, request: &ValidatedExecRequest) -> Result<SandboxPlan, SandboxError>;
36}
37
38/// Backend-produced execution plan. v0.2 only lands the trait shape, so this
39/// remains a simple command description rather than a spawned process.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SandboxPlan {
42    pub program: PathBuf,
43    pub args: Vec<String>,
44    pub env: BTreeMap<String, String>,
45    pub cwd: Option<PathBuf>,
46}
47
48impl SandboxPlan {
49    pub fn passthrough(request: &ValidatedExecRequest) -> Self {
50        Self {
51            program: request.program.clone(),
52            args: request.args.clone(),
53            env: request.env.clone(),
54            cwd: request.cwd.clone(),
55        }
56    }
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct ExecPolicy {
61    allowed_programs: BTreeSet<PathBuf>,
62    allowed_env: BTreeSet<String>,
63}
64
65impl ExecPolicy {
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    pub fn allow_program(mut self, path: impl Into<PathBuf>) -> Self {
71        self.allowed_programs.insert(path.into());
72        self
73    }
74
75    pub fn allow_env(mut self, key: impl Into<String>) -> Self {
76        self.allowed_env.insert(key.into());
77        self
78    }
79
80    pub fn validate(
81        &self,
82        request: UntrustedExecRequest,
83    ) -> Result<ValidatedExecRequest, SandboxError> {
84        if !self
85            .allowed_programs
86            .iter()
87            .any(|allowed| allowed == &request.program)
88        {
89            return Err(SandboxError::ProgramNotAllowed(request.program));
90        }
91
92        if request
93            .args
94            .iter()
95            .any(|arg| contains_shell_metachar(arg) || contains_control_chars(arg))
96        {
97            return Err(SandboxError::UnsafeArgv);
98        }
99
100        if request
101            .env
102            .keys()
103            .any(|key| !self.allowed_env.contains(key))
104        {
105            let rejected = request
106                .env
107                .keys()
108                .find(|key| !self.allowed_env.contains(*key))
109                .cloned()
110                .unwrap_or_else(|| unreachable!("env key guaranteed by any check above"));
111            return Err(SandboxError::EnvNotAllowed(rejected));
112        }
113
114        if request
115            .env
116            .iter()
117            .any(|(key, value)| contains_control_chars(key) || contains_control_chars(value))
118        {
119            return Err(SandboxError::InvalidEnvEncoding);
120        }
121
122        if request.cwd.as_deref().is_some_and(|cwd| !cwd.is_absolute()) {
123            return Err(SandboxError::RelativeWorkingDirectory(
124                request
125                    .cwd
126                    .unwrap_or_else(|| unreachable!("cwd guaranteed by is_some_and check above")),
127            ));
128        }
129
130        Ok(ValidatedExecRequest {
131            program: request.program,
132            args: request.args,
133            env: request.env,
134            cwd: request.cwd,
135        })
136    }
137}
138
139#[derive(Debug, Error, Clone, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum SandboxError {
142    #[error("program not allowed: {0}")]
143    ProgramNotAllowed(PathBuf),
144    #[error("argv contains unsafe shell metacharacters")]
145    UnsafeArgv,
146    #[error("env key not allowed: {0}")]
147    EnvNotAllowed(String),
148    #[error("env contains invalid control characters")]
149    InvalidEnvEncoding,
150    #[error("working directory must be absolute: {0}")]
151    RelativeWorkingDirectory(PathBuf),
152    #[error("sandbox backend error: {0}")]
153    Backend(String),
154}
155
156fn contains_shell_metachar(input: &str) -> bool {
157    input
158        .chars()
159        .any(|ch| matches!(ch, ';' | '|' | '&' | '$' | '`'))
160}
161
162fn contains_control_chars(input: &str) -> bool {
163    input.chars().any(|ch| ch.is_control() && ch != '\t')
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn validation_accepts_allowed_program_and_env() {
172        let policy = ExecPolicy::new()
173            .allow_program("/usr/local/bin/gaze-hook")
174            .allow_env("MAIL_FROM");
175        let request = UntrustedExecRequest {
176            program: PathBuf::from("/usr/local/bin/gaze-hook"),
177            args: vec![
178                "send-email".to_string(),
179                format!("<{}>", ["Email", "1"].join("_")),
180            ],
181            env: BTreeMap::from([("MAIL_FROM".to_string(), "bot@example.invalid".to_string())]),
182            cwd: Some(PathBuf::from("/tmp")),
183        };
184
185        let validated = request.validate(&policy).expect("validated request");
186        assert_eq!(validated.program, PathBuf::from("/usr/local/bin/gaze-hook"));
187        assert_eq!(validated.args[1], format!("<{}>", ["Email", "1"].join("_")));
188    }
189
190    #[test]
191    fn validation_rejects_shell_metacharacters() {
192        let policy = ExecPolicy::new().allow_program("/usr/local/bin/gaze-hook");
193        let request = UntrustedExecRequest {
194            program: PathBuf::from("/usr/local/bin/gaze-hook"),
195            args: vec!["send-email;cat".to_string()],
196            env: BTreeMap::new(),
197            cwd: None,
198        };
199
200        assert_eq!(request.validate(&policy), Err(SandboxError::UnsafeArgv));
201    }
202}