Skip to main content

camel_component_exec/
config.rs

1//! Configuration types for the exec component. Lives under
2//! `[components.exec]` / `[[components.exec.profiles]]` in Camel.toml.
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7/// How args are validated against a profile. Applied **per argv element**.
8#[derive(Clone, Debug, serde::Deserialize)]
9#[serde(deny_unknown_fields, tag = "allow", rename_all = "lowercase")]
10pub enum ArgPolicy {
11    /// Every element accepted (explicit opt-in; operator-curated).
12    Any,
13    /// Every element must string-equal one of `values`.
14    Exact { values: Vec<String> },
15    /// Every element must byte-start-with one of `values`.
16    Prefix { values: Vec<String> },
17}
18
19impl Default for ArgPolicy {
20    fn default() -> Self {
21        // Omitted `args` => deny all non-empty args (safest, fail-closed).
22        // Represented as Exact with empty values: any non-empty arg fails.
23        ArgPolicy::Exact { values: Vec::new() }
24    }
25}
26
27/// Optional flag denylist; applied first regardless of `allow`.
28#[derive(Clone, Debug, Default, serde::Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct DenyFlags(pub Vec<String>);
31
32/// Environment policy for the child.
33#[derive(Clone, Debug, Default, serde::Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct EnvPolicy {
36    /// Var names the child may receive from the host env.
37    #[serde(default)]
38    pub allow: Vec<String>,
39    /// Explicit var=value pairs to set.
40    #[serde(default)]
41    pub set: HashMap<String, String>,
42}
43
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
45#[serde(deny_unknown_fields, rename_all = "lowercase")]
46pub enum OutputMode {
47    #[default]
48    Materialized,
49    /// Reserved for future use; validate() rejects at startup in v1.
50    Stream,
51}
52
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
54#[serde(deny_unknown_fields, rename_all = "lowercase")]
55pub enum Sandbox {
56    #[default]
57    None,
58    /// Reserved for future use; validate() rejects at startup in v1.
59    WorkspaceRead,
60    /// Reserved for future use; validate() rejects at startup in v1.
61    WorkspaceWrite,
62}
63
64/// A single named execution profile.
65#[derive(Clone, Debug, serde::Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct ExecProfile {
68    pub name: String,
69    pub executable: String, // name (PATH-resolved) or absolute path
70    #[serde(default)]
71    pub args: ArgPolicy,
72    #[serde(default)]
73    pub deny_flags: DenyFlags,
74    #[serde(default)]
75    pub allow_shell: bool,
76    #[serde(default)]
77    pub env: EnvPolicy,
78    #[serde(default = "default_dot")]
79    pub working_dir: String,
80    #[serde(default = "default_timeout")]
81    pub timeout_secs: u64,
82    #[serde(default = "default_accepted")]
83    pub accepted_exit_codes: Vec<i32>,
84    #[serde(default = "default_concurrency")]
85    pub concurrency: usize,
86    #[serde(default = "default_stdin_cap")]
87    pub stdin_max_bytes: usize,
88    #[serde(default = "default_stdout_cap")]
89    pub stdout_max_bytes: usize,
90    #[serde(default = "default_stderr_cap")]
91    pub stderr_max_bytes: usize,
92    #[serde(default)]
93    pub sandbox: Sandbox,
94    #[serde(default)]
95    pub output_mode: OutputMode,
96
97    // Resolved at startup pinning (not deserialized):
98    #[serde(skip)]
99    pub canonical_executable: Option<PathBuf>,
100}
101
102fn default_dot() -> String {
103    ".".into()
104}
105fn default_timeout() -> u64 {
106    30
107}
108fn default_accepted() -> Vec<i32> {
109    vec![0]
110}
111fn default_concurrency() -> usize {
112    1
113}
114fn default_stdin_cap() -> usize {
115    1_048_576 // 1 MiB
116}
117fn default_stdout_cap() -> usize {
118    10_485_760 // 10 MiB
119}
120fn default_stderr_cap() -> usize {
121    1_048_576 // 1 MiB
122}
123
124/// Global `[components.exec]` config.
125#[derive(Clone, Debug, Default, serde::Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct ExecGlobalConfig {
128    #[serde(default = "default_dot")]
129    pub workspace_root: String,
130    #[serde(default = "default_timeout")]
131    pub default_timeout_secs: u64,
132    #[serde(default = "default_concurrency")]
133    pub default_concurrency: usize,
134    #[serde(default = "default_deny_env")]
135    pub deny_env: Vec<String>,
136    #[serde(default)]
137    pub profiles: Vec<ExecProfile>,
138    /// Resolved at startup pinning (not deserialized). Runtime confinement uses
139    /// THIS, never a `"."` fallback (I-6: fail-closed, no silent weakening).
140    #[serde(skip)]
141    pub canonical_workspace_root: Option<std::path::PathBuf>,
142}
143
144impl ExecGlobalConfig {
145    /// Fail-fast validation + canonical pinning. Must run at startup (ADR-0033).
146    pub fn validate(&mut self, base: &std::path::Path) -> Result<(), camel_api::CamelError> {
147        if self.profiles.is_empty() {
148            return Err(camel_api::CamelError::Config(
149                "exec: no profiles configured (fail-closed: refusing to execute anything)".into(),
150            ));
151        }
152        let ws_root = base.join(&self.workspace_root);
153        let canonical_root = ws_root.canonicalize().map_err(|e| {
154            camel_api::CamelError::Config(format!("exec workspace_root unresolvable: {e}"))
155        })?;
156        // I-6: pin once, reuse at runtime (never fall back to ".").
157        self.canonical_workspace_root = Some(canonical_root.clone());
158
159        let mut seen = std::collections::HashSet::new();
160        for p in &mut self.profiles {
161            if !seen.insert(p.name.clone()) {
162                return Err(camel_api::CamelError::Config(format!(
163                    "dup profile name {:?}",
164                    p.name
165                )));
166            }
167            if p.concurrency == 0 {
168                return Err(camel_api::CamelError::Config(
169                    "concurrency must be > 0".into(),
170                ));
171            }
172            if p.timeout_secs == 0 {
173                return Err(camel_api::CamelError::Config(
174                    "timeout_secs must be > 0".into(),
175                ));
176            }
177            if p.accepted_exit_codes.is_empty() {
178                return Err(camel_api::CamelError::Config(
179                    "accepted_exit_codes must be non-empty".into(),
180                ));
181            }
182
183            // v1 reserved enums: reject future values at startup.
184            if p.output_mode != OutputMode::Materialized {
185                return Err(camel_api::CamelError::Config(
186                    "output_mode: only \"materialized\" supported in v1".into(),
187                ));
188            }
189            if p.sandbox != Sandbox::None {
190                return Err(camel_api::CamelError::Config(
191                    "sandbox: only \"none\" supported in v1".into(),
192                ));
193            }
194
195            // Canonical-pin the executable (no PATH lookup at spawn time).
196            let resolved = resolve_executable(&p.executable)
197                .map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
198            p.canonical_executable = Some(resolved);
199
200            // Confine working_dir under canonical_root.
201            confine(&canonical_root, &p.working_dir)
202                .map_err(|e| camel_api::CamelError::Config(format!("profile {:?}: {e}", p.name)))?;
203        }
204        Ok(())
205    }
206
207    /// Look up a profile by logical name (used by endpoint + producer).
208    pub fn profile(&self, name: &str) -> Option<&ExecProfile> {
209        self.profiles.iter().find(|p| p.name == name)
210    }
211}
212
213/// Resolve an executable to a canonical path. Name → PATH lookup; absolute → as-is.
214fn resolve_executable(spec: &str) -> Result<std::path::PathBuf, String> {
215    let p = std::path::Path::new(spec);
216    if p.is_absolute() {
217        return p
218            .canonicalize()
219            .map_err(|e| format!("absolute executable unresolvable: {e}"));
220    }
221    which(spec).ok_or_else(|| format!("executable {spec:?} not found on PATH"))
222}
223
224/// Minimal PATH lookup (avoid pulling a crate; std-only).
225///
226/// NOTE: does NOT canonicalize — multi-call binaries (e.g. NixOS coreutils)
227/// need argv[0] to match the symlink name, not the resolved multi-call binary
228/// path. Startup pinning stores the directory-path as-is.
229fn which(name: &str) -> Option<std::path::PathBuf> {
230    let path = std::env::var_os("PATH")?;
231    for dir in std::env::split_paths(&path) {
232        let cand = dir.join(name);
233        if cand.is_file() {
234            return Some(cand);
235        }
236    }
237    None
238}
239
240/// Confine `rel` under `root`: reject absolute and `..`, canonicalize, require starts_with.
241///
242/// NOTE (M-3): for v1, `confine` does NOT create dirs; it canonicalizes existing
243/// paths and errors if the working_dir does not exist. (Operator must pre-create
244/// working dirs; fail-closed over silent creation.)
245pub(crate) fn confine(root: &std::path::Path, rel: &str) -> Result<std::path::PathBuf, String> {
246    let p = std::path::Path::new(rel);
247    if p.is_absolute() {
248        return Err(format!("working_dir must be relative, got {rel:?}"));
249    }
250    if p.components()
251        .any(|c| matches!(c, std::path::Component::ParentDir))
252    {
253        return Err(format!(
254            "working_dir escapes workspace root (must not contain '..'): {rel:?}"
255        ));
256    }
257    let joined = root.join(p);
258    let canon = joined
259        .canonicalize()
260        .map_err(|e| format!("working_dir unresolvable: {e}"))?;
261    if !canon.starts_with(root) {
262        return Err(format!(
263            "working_dir escapes workspace root: {}",
264            canon.display()
265        ));
266    }
267    Ok(canon)
268}
269
270fn default_deny_env() -> Vec<String> {
271    vec![
272        "LD_*".into(),
273        "DYLD_*".into(),
274        "PYTHONPATH".into(),
275        "RUSTFLAGS".into(),
276        "GIT_*".into(),
277        "SSH_AUTH_SOCK".into(),
278        "*_TOKEN".into(),
279        "*_KEY".into(),
280    ]
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn gcfg() -> ExecGlobalConfig {
288        toml::from_str(
289            r#"
290workspace_root = "."
291[[profiles]]
292name = "echo"
293executable = "echo"
294args = { allow = "any" }
295working_dir = "."
296"#,
297        )
298        .unwrap()
299    }
300
301    #[test]
302    fn validate_rejects_empty_profiles_fail_closed() {
303        let mut c = ExecGlobalConfig::default();
304        let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
305        assert!(err.to_string().contains("no profiles"), "{err}");
306    }
307
308    #[test]
309    fn validate_pins_executable_canonical() {
310        let mut c = gcfg();
311        c.validate(&std::env::current_dir().unwrap()).unwrap();
312        assert!(
313            c.profiles[0].canonical_executable.is_some(),
314            "must be pinned"
315        );
316    }
317
318    #[test]
319    fn validate_rejects_unknown_output_mode_stream() {
320        let mut c = toml::from_str::<ExecGlobalConfig>(
321            r#"
322[[profiles]]
323name = "x"
324executable = "echo"
325output_mode = "stream"
326"#,
327        )
328        .unwrap();
329        let err = c.validate(&std::env::current_dir().unwrap());
330        assert!(err.is_err(), "stream must be rejected at startup in v1");
331    }
332
333    #[test]
334    fn validate_rejects_working_dir_escape() {
335        let mut c = toml::from_str::<ExecGlobalConfig>(
336            r#"
337workspace_root = "."
338[[profiles]]
339name = "x"
340executable = "echo"
341working_dir = "../escape"
342"#,
343        )
344        .unwrap();
345        let err = c.validate(&std::env::current_dir().unwrap()).unwrap_err();
346        assert!(
347            err.to_string().to_lowercase().contains("workspace"),
348            "{err}"
349        );
350    }
351}