Skip to main content

ferridriver_script/
command_spec.rs

1//! The `allow.commands` capability schema and `${var}` resolution.
2//!
3//! A plugin declares each command it may run; the handler can only
4//! invoke declared names (default-deny). A spec is either a shorthand
5//! string (a `sh -c` line) or an object with explicit execution policy.
6//! Resolution is *strict*: every `${placeholder}` must have a supplied
7//! value and every value must be a scalar — a typo fails loudly instead
8//! of expanding to empty.
9//!
10//! Deserializer-agnostic by hand (no `#[serde(untagged)]`): the same
11//! types are read from `serde_json` (the MCP manifest round-trip) and
12//! from `rquickjs-serde` (a `defineTool` call), so the impls only use
13//! `deserialize_any` + visitors, which both back-ends support.
14
15use std::collections::BTreeMap;
16use std::fmt;
17
18use serde::de::{self, MapAccess, SeqAccess, Visitor};
19use serde::{Deserialize, Deserializer, Serialize};
20
21/// What to execute. A string runs through `sh -c` (shell features
22/// live); an array is executed directly with no shell (each element is
23/// one argv entry — no quoting, no metacharacter interpretation).
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25#[serde(untagged)]
26pub enum CommandRun {
27  Shell(String),
28  Argv(Vec<String>),
29}
30
31impl<'de> Deserialize<'de> for CommandRun {
32  fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
33    struct V;
34    impl<'de> Visitor<'de> for V {
35      type Value = CommandRun;
36      fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str("a shell string or an argv array of strings")
38      }
39      fn visit_str<E: de::Error>(self, v: &str) -> Result<CommandRun, E> {
40        Ok(CommandRun::Shell(v.to_owned()))
41      }
42      fn visit_string<E: de::Error>(self, v: String) -> Result<CommandRun, E> {
43        Ok(CommandRun::Shell(v))
44      }
45      fn visit_seq<A: SeqAccess<'de>>(self, mut s: A) -> Result<CommandRun, A::Error> {
46        let mut out = Vec::new();
47        while let Some(e) = s.next_element::<String>()? {
48          out.push(e);
49        }
50        if out.is_empty() {
51          return Err(de::Error::custom("argv array must have at least one element"));
52        }
53        Ok(CommandRun::Argv(out))
54      }
55    }
56    d.deserialize_any(V)
57  }
58}
59
60/// How `commands.run` interprets the command's stdout.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum CommandOutput {
64  /// Trimmed string (default — no guessing).
65  #[default]
66  Text,
67  /// Parsed as JSON; invalid JSON is an error.
68  Json,
69  /// Split into an array of non-empty trimmed lines.
70  Lines,
71}
72
73/// One declared command. Constructed only via deserialization (manifest
74/// or `defineTool`); never hand-built by a handler.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct CommandSpec {
78  pub run: CommandRun,
79  /// Hard wall-clock bound; on expiry the process group is killed.
80  #[serde(skip_serializing_if = "Option::is_none")]
81  pub timeout_ms: Option<u64>,
82  /// Server env var names passed through to the child. The child env is
83  /// otherwise scrubbed (only `PATH` is kept so binaries resolve), so a
84  /// command never inherits ambient server secrets it did not ask for.
85  #[serde(skip_serializing_if = "Vec::is_empty")]
86  pub env: Vec<String>,
87  /// Working directory for the child (absolute, or relative to the
88  /// server process cwd). Default: inherit.
89  #[serde(skip_serializing_if = "Option::is_none")]
90  pub cwd: Option<String>,
91  pub output: CommandOutput,
92  /// A long-running process (server/watcher): managed via
93  /// `start`/`status`/`stop`, lifetime tied to the session, never via
94  /// `run`. A one-shot spec rejects `start`/`status`/`stop` and vice
95  /// versa.
96  #[serde(skip_serializing_if = "std::ops::Not::not")]
97  pub persistent: bool,
98}
99
100impl<'de> Deserialize<'de> for CommandSpec {
101  fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
102    struct V;
103    impl<'de> Visitor<'de> for V {
104      type Value = CommandSpec;
105      fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str("a shell-command string or a command spec object")
107      }
108      fn visit_str<E: de::Error>(self, v: &str) -> Result<CommandSpec, E> {
109        Ok(CommandSpec::shell(v))
110      }
111      fn visit_string<E: de::Error>(self, v: String) -> Result<CommandSpec, E> {
112        Ok(CommandSpec::shell(&v))
113      }
114      fn visit_map<A: MapAccess<'de>>(self, mut m: A) -> Result<CommandSpec, A::Error> {
115        let mut run: Option<CommandRun> = None;
116        let mut timeout_ms: Option<u64> = None;
117        let mut env: Vec<String> = Vec::new();
118        let mut cwd: Option<String> = None;
119        let mut output = CommandOutput::Text;
120        let mut persistent = false;
121        while let Some(k) = m.next_key::<String>()? {
122          match k.as_str() {
123            "run" => run = Some(m.next_value()?),
124            "timeoutMs" | "timeout_ms" => timeout_ms = m.next_value()?,
125            "env" => env = m.next_value()?,
126            "cwd" => cwd = m.next_value()?,
127            "output" => output = m.next_value()?,
128            "persistent" => persistent = m.next_value()?,
129            _ => {
130              let _: de::IgnoredAny = m.next_value()?;
131            },
132          }
133        }
134        let run = run.ok_or_else(|| de::Error::missing_field("run"))?;
135        Ok(CommandSpec {
136          run,
137          timeout_ms,
138          env,
139          cwd,
140          output,
141          persistent,
142        })
143      }
144    }
145    d.deserialize_any(V)
146  }
147}
148
149/// A command resolved against caller `vars`: ready to spawn.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ResolvedExec {
152  /// `sh -c <line>` — values shell-escaped.
153  Shell(String),
154  /// Direct argv — no shell, values substituted literally per element.
155  Argv(Vec<String>),
156}
157
158#[derive(Debug, Clone)]
159pub struct ResolvedCommand {
160  pub exec: ResolvedExec,
161  pub timeout_ms: Option<u64>,
162  pub env: Vec<String>,
163  pub cwd: Option<String>,
164  pub output: CommandOutput,
165  pub persistent: bool,
166}
167
168impl CommandSpec {
169  fn shell(s: &str) -> Self {
170    Self {
171      run: CommandRun::Shell(s.to_owned()),
172      timeout_ms: None,
173      env: Vec::new(),
174      cwd: None,
175      output: CommandOutput::Text,
176      persistent: false,
177    }
178  }
179
180  /// Strictly substitute `${name}` placeholders with `vars`. Every
181  /// placeholder must be supplied and scalar; an unknown placeholder or
182  /// a non-scalar value is an error (no silent empty, no JSON blob).
183  ///
184  /// # Errors
185  /// A `${name}` with no value, or a value that is an object/array/null.
186  pub fn resolve(&self, vars: &BTreeMap<String, serde_json::Value>) -> Result<ResolvedCommand, String> {
187    let scalar = |k: &str| -> Result<String, String> {
188      match vars.get(k) {
189        None => Err(format!("missing value for `${{{k}}}`")),
190        Some(serde_json::Value::String(s)) => Ok(s.clone()),
191        Some(serde_json::Value::Number(n)) => Ok(n.to_string()),
192        Some(serde_json::Value::Bool(b)) => Ok(b.to_string()),
193        Some(_) => Err(format!("value for `${{{k}}}` must be a string, number, or boolean")),
194      }
195    };
196
197    let exec = match &self.run {
198      CommandRun::Shell(tpl) => ResolvedExec::Shell(subst(tpl, |k| scalar(k).map(|v| shell_single_quote(&v)))?),
199      CommandRun::Argv(args) => {
200        let mut out = Vec::with_capacity(args.len());
201        for a in args {
202          out.push(subst(a, &scalar)?);
203        }
204        ResolvedExec::Argv(out)
205      },
206    };
207    Ok(ResolvedCommand {
208      exec,
209      timeout_ms: self.timeout_ms,
210      env: self.env.clone(),
211      cwd: self.cwd.clone(),
212      output: self.output,
213      persistent: self.persistent,
214    })
215  }
216}
217
218/// Replace every `${name}` in `tpl`. `repl` produces the replacement (it
219/// errors on an unknown name). A lone `$`, or `${` with no closing `}`,
220/// is a literal. `$${x}` is NOT special — `${x}` still substitutes;
221/// templates that need a literal `${` should use argv form.
222fn subst(tpl: &str, mut repl: impl FnMut(&str) -> Result<String, String>) -> Result<String, String> {
223  let mut out = String::with_capacity(tpl.len());
224  let b = tpl.as_bytes();
225  let mut i = 0;
226  while i < b.len() {
227    if b[i] == b'$' && i + 1 < b.len() && b[i + 1] == b'{' {
228      if let Some(end_rel) = tpl[i + 2..].find('}') {
229        let name = &tpl[i + 2..i + 2 + end_rel];
230        out.push_str(&repl(name)?);
231        i = i + 2 + end_rel + 1;
232        continue;
233      }
234    }
235    // push one UTF-8 char from i
236    let ch = tpl[i..].chars().next().unwrap_or('\u{FFFD}');
237    out.push(ch);
238    i += ch.len_utf8();
239  }
240  Ok(out)
241}
242
243fn shell_single_quote(s: &str) -> String {
244  format!("'{}'", s.replace('\'', r"'\''"))
245}
246
247#[cfg(test)]
248mod tests {
249  use super::*;
250
251  fn vars(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
252    pairs.iter().map(|(k, v)| ((*k).to_string(), v.clone())).collect()
253  }
254
255  #[test]
256  fn shorthand_string_is_a_shell_oneshot() {
257    let s: CommandSpec = serde_json::from_str(r#""git status""#).unwrap();
258    assert_eq!(s.run, CommandRun::Shell("git status".into()));
259    assert!(!s.persistent);
260    assert_eq!(s.output, CommandOutput::Text);
261  }
262
263  #[test]
264  fn object_with_argv_and_policy() {
265    let s: CommandSpec = serde_json::from_str(
266      r#"{"run":["git","-C","${repo}","rev-parse","HEAD"],"timeoutMs":5000,"env":["HOME"],"output":"json"}"#,
267    )
268    .unwrap();
269    assert_eq!(s.timeout_ms, Some(5000));
270    assert_eq!(s.env, vec!["HOME"]);
271    assert_eq!(s.output, CommandOutput::Json);
272    let r = s.resolve(&vars(&[("repo", serde_json::json!("/srv/app"))])).unwrap();
273    assert_eq!(
274      r.exec,
275      ResolvedExec::Argv(vec![
276        "git".into(),
277        "-C".into(),
278        "/srv/app".into(),
279        "rev-parse".into(),
280        "HEAD".into()
281      ])
282    );
283  }
284
285  #[test]
286  fn argv_substitution_is_not_shell_escaped() {
287    let s: CommandSpec = serde_json::from_str(r#"{"run":["echo","${msg}"]}"#).unwrap();
288    let r = s.resolve(&vars(&[("msg", serde_json::json!("a; rm -rf /"))])).unwrap();
289    // Raw, single argv element — no shell, so the metacharacters are inert.
290    assert_eq!(r.exec, ResolvedExec::Argv(vec!["echo".into(), "a; rm -rf /".into()]));
291  }
292
293  #[test]
294  fn shell_substitution_is_single_quoted() {
295    let s = CommandSpec::shell("echo ${msg}");
296    let r = s
297      .resolve(&vars(&[("msg", serde_json::json!("a'b; rm -rf /"))]))
298      .unwrap();
299    assert_eq!(r.exec, ResolvedExec::Shell(r"echo 'a'\''b; rm -rf /'".to_string()));
300  }
301
302  #[test]
303  fn missing_placeholder_is_an_error() {
304    let s = CommandSpec::shell("deploy ${env} ${tag}");
305    let e = s.resolve(&vars(&[("env", serde_json::json!("prod"))])).unwrap_err();
306    assert!(e.contains("${tag}"), "{e}");
307  }
308
309  #[test]
310  fn non_scalar_value_is_an_error() {
311    let s = CommandSpec::shell("x ${o}");
312    let e = s.resolve(&vars(&[("o", serde_json::json!({"a":1}))])).unwrap_err();
313    assert!(e.contains("must be a string, number, or boolean"), "{e}");
314  }
315
316  #[test]
317  fn persistent_flag_round_trips() {
318    let s: CommandSpec = serde_json::from_str(r#"{"run":"node server.js","persistent":true}"#).unwrap();
319    assert!(s.persistent);
320  }
321}