Skip to main content

vtcode_agent_plugins/
expansion.rs

1use std::path::{Path, PathBuf};
2
3/// Single-pass, non-recursive placeholder expansion.
4///
5/// Replaces every exact occurrence of `${PLUGIN_ROOT}` and `${PLUGIN_DATA}`
6/// without rescanning replacement text, per Agent Plugins §9.2. Unrecognized
7/// placeholder-like text is left literal and no other expansion is performed.
8/// Expansion applies only to `args`, `env` values, and `cwd`; callers must not
9/// apply it to `command`, `env` keys, URLs, or headers.
10pub fn expand_placeholders(value: &str, plugin_root: &Path, plugin_data: &Path) -> String {
11    let root = plugin_root.display().to_string();
12    let data = plugin_data.display().to_string();
13    let mut out = String::with_capacity(value.len() + root.len());
14    let mut idx = 0;
15    while idx < value.len() {
16        let Some(rest) = value.get(idx..) else {
17            break;
18        };
19        if rest.starts_with("${PLUGIN_ROOT}") {
20            out.push_str(&root);
21            idx += "${PLUGIN_ROOT}".len();
22        } else if rest.starts_with("${PLUGIN_DATA}") {
23            out.push_str(&data);
24            idx += "${PLUGIN_DATA}".len();
25        } else {
26            // Advance by one char to preserve UTF-8 boundaries.
27            let ch = rest.chars().next().unwrap_or_default();
28            out.push(ch);
29            idx += ch.len_utf8();
30        }
31    }
32    out
33}
34
35/// Returns true for the two reserved env names that a plugin must not set.
36/// Such an entry makes the server configuration invalid (§9.2); the client
37/// supplies them last instead of overlaying.
38pub fn is_reserved_env_key(key: &str) -> bool {
39    // Platform env-name semantics differ (Windows is case-insensitive); the
40    // portable check rejects the exact reserved spellings. Callers on Windows
41    // should compare case-insensitively before invoking.
42    key == "PLUGIN_ROOT" || key == "PLUGIN_DATA"
43}
44
45/// Shell-control bytes that must never appear in a stdio `command` token.
46/// Spawn is argv-based (no shell), so these are inert rather than exploitable;
47/// rejecting them keeps both command branches consistent and prevents a shell
48/// command string from slipping through as one token.
49const SHELL_CONTROL_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '\n', '\r', '\0'];
50
51fn contains_shell_control(value: &str) -> bool {
52    value.chars().any(|c| SHELL_CONTROL_CHARS.contains(&c))
53}
54
55/// Validate a stdio `command` as a single executable token.
56///
57/// Accepts either a bare executable name (no `/`, `\`, or whitespace, resolved
58/// via platform search) or a plugin-relative `./` path (resolved against the
59/// plugin root; spaces allowed since spawn is argv-based). Rejects empty
60/// values, bare directory tokens (`./`, `./.`), NUL/newlines, and
61/// shell-control characters so a shell command string can never slip through
62/// as one token (Agent Plugins §7.2.1: single token, bare or `./`).
63pub fn validate_command_token(command: &str) -> Result<(), String> {
64    if command.is_empty() {
65        return Err("command must not be empty".into());
66    }
67    if command == "./" || command == "./." {
68        return Err("command must name an executable, not a directory".into());
69    }
70    if contains_shell_control(command) {
71        return Err("command must be a single executable token, not a shell string".into());
72    }
73    if command.starts_with("./") {
74        Ok(())
75    } else {
76        if command.contains('/') || command.contains('\\') {
77            return Err("command with a path separator must start with ./".into());
78        }
79        if command.chars().any(|c| c.is_whitespace()) {
80            return Err("command must be a single executable token, not a shell string".into());
81        }
82        Ok(())
83    }
84}
85
86/// Validate the syntactic form of an explicit `cwd` (§7.2.1).
87///
88/// Must be `./`-relative, exactly `${PLUGIN_ROOT}` (or prefixed with
89/// `${PLUGIN_ROOT}/`), or exactly `${PLUGIN_DATA}` (or prefixed with
90/// `${PLUGIN_DATA}/`). Any other form makes the server entry invalid.
91pub fn validate_cwd_form(cwd: &str) -> Result<(), String> {
92    if cwd.starts_with("./")
93        || cwd == "${PLUGIN_ROOT}"
94        || cwd.starts_with("${PLUGIN_ROOT}/")
95        || cwd == "${PLUGIN_DATA}"
96        || cwd.starts_with("${PLUGIN_DATA}/")
97    {
98        Ok(())
99    } else {
100        Err(format!("cwd has unsupported form: {cwd}"))
101    }
102}
103
104/// Client-managed persistent data directory for one installed plugin instance.
105///
106/// Uses `VtCodePaths::data_dir()/plugin-data/<name>` so state survives plugin
107/// updates (§9.1). Fallbacks (in order): platform data dir
108/// (`dirs::data_dir()/vtcode/plugin-data/<name>`), then the process temp dir.
109/// Callers must create it before spawn and preserve it across updates; it may
110/// be deleted on uninstall. Plugin names are charset-constrained
111/// (`manifest.rs` rejects `/`, `\`, `..`), so `join` cannot escape.
112pub fn default_plugin_data_dir(plugin_name: &str) -> PathBuf {
113    if let Ok(paths) = vtcode_commons::VtCodePaths::resolve() {
114        return paths.data_dir().join("plugin-data").join(plugin_name);
115    }
116    if let Some(data_dir) = dirs::data_dir() {
117        return data_dir.join("vtcode").join("plugin-data").join(plugin_name);
118    }
119    std::env::temp_dir().join("vtcode-plugin-data").join(plugin_name)
120}
121
122/// Resolve an explicit or default `cwd` after placeholder expansion and
123/// enforce post-resolution containment.
124///
125/// * `None` → canonical plugin root (spec default).
126/// * `./...` or `${PLUGIN_ROOT}[/...]` → must stay within the canonical
127///   plugin root.
128/// * `${PLUGIN_DATA}[/...]` → must stay within the canonical plugin data dir.
129pub fn resolve_cwd(cwd: Option<&str>, plugin_root: &Path, plugin_data: &Path) -> Result<PathBuf, crate::PluginError> {
130    let canonical_root = vtcode_commons::canonicalize(plugin_root)
131        .map_err(|e| crate::PluginError::PathEscape(format!("plugin root could not be resolved: {e}")))?;
132    // PLUGIN_DATA may not exist yet; canonicalize ancestors when missing so
133    // the containment check still applies to the existing prefix.
134    let canonical_data = canonicalize_allow_missing(plugin_data).unwrap_or_else(|_| plugin_data.to_path_buf());
135
136    let Some(raw) = cwd else {
137        return Ok(canonical_root);
138    };
139    validate_cwd_form(raw).map_err(crate::PluginError::PathEscape)?;
140
141    let expanded = expand_placeholders(raw, plugin_root, plugin_data);
142    if raw.starts_with("./") {
143        // `./` form may itself contain placeholders; after expansion it either
144        // remains `./`-relative (validate normally) or became absolute via an
145        // injected placeholder (check containment directly).
146        if expanded.starts_with("./") {
147            return validate_plugin_relative(&expanded, plugin_root);
148        }
149        let candidate = PathBuf::from(&expanded);
150        let resolved = ensure_within(&canonical_root, &candidate)
151            .map_err(|e| crate::PluginError::PathEscape(format!("cwd escapes plugin root: {e}")))?;
152        return Ok(resolved);
153    }
154    if raw == "${PLUGIN_ROOT}" || raw.starts_with("${PLUGIN_ROOT}/") {
155        let candidate = PathBuf::from(&expanded);
156        let resolved = ensure_within(&canonical_root, &candidate)
157            .map_err(|e| crate::PluginError::PathEscape(format!("cwd escapes plugin root: {e}")))?;
158        return Ok(resolved);
159    }
160    // Remaining valid form is ${PLUGIN_DATA}[/...].
161    let candidate = PathBuf::from(&expanded);
162    let resolved = ensure_within(&canonical_data, &candidate)
163        .map_err(|e| crate::PluginError::PathEscape(format!("cwd escapes plugin data dir: {e}")))?;
164    Ok(resolved)
165}
166
167pub(crate) fn canonicalize_allow_missing(path: &Path) -> std::io::Result<PathBuf> {
168    if let Ok(canonical) = vtcode_commons::canonicalize(path) {
169        return Ok(canonical);
170    }
171    // Walk to the deepest existing ancestor, canonicalize it, re-append suffix.
172    // Any `..` in the missing suffix is rejected: re-appended `..` would make
173    // `starts_with` checks lexically true while escaping at runtime.
174    let mut probe = path;
175    let mut suffix: Vec<std::ffi::OsString> = Vec::new();
176    loop {
177        if let Ok(existing) = vtcode_commons::canonicalize(probe) {
178            for part in suffix.iter().rev() {
179                if *part == ".." {
180                    return Err(std::io::Error::new(
181                        std::io::ErrorKind::InvalidInput,
182                        format!("path escapes its root: {}", path.display()),
183                    ));
184                }
185            }
186            let mut resolved = existing;
187            for part in suffix.iter().rev() {
188                resolved.push(part);
189            }
190            return Ok(resolved);
191        }
192        let Some(name) = probe.file_name() else {
193            return Err(std::io::Error::new(
194                std::io::ErrorKind::NotFound,
195                format!("path could not be resolved: {}", path.display()),
196            ));
197        };
198        suffix.push(name.to_os_string());
199        let Some(parent) = probe.parent() else {
200            return Err(std::io::Error::new(
201                std::io::ErrorKind::NotFound,
202                format!("path could not be resolved: {}", path.display()),
203            ));
204        };
205        probe = parent;
206    }
207}
208
209pub(crate) fn ensure_within(canonical_root: &Path, candidate: &Path) -> Result<PathBuf, String> {
210    let resolved = canonicalize_allow_missing(candidate).map_err(|e| format!("{} ({e})", candidate.display()))?;
211    // A surviving `..` means the tail could not be normalized; fail closed
212    // since lexical `starts_with` cannot be trusted in that state.
213    if resolved.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
214        return Err(format!("{} escapes {}", candidate.display(), canonical_root.display()));
215    }
216    if resolved.starts_with(canonical_root) {
217        Ok(resolved)
218    } else {
219        Err(format!("{} escapes {}", candidate.display(), canonical_root.display()))
220    }
221}
222
223/// Validate that a plugin-relative path stays inside the plugin root.
224///
225/// Uses `canonicalize` (which resolves symlinks) so a symlink inside the
226/// plugin pointing outside (e.g. `bin/link -> /etc`) cannot smuggle a path out
227/// of the sandbox. The candidate must start with `./` per the Agent Plugins
228/// spec.
229///
230/// The target need not exist yet (a plugin may spawn a binary built at
231/// runtime), so when it is absent we canonicalize the deepest *existing*
232/// ancestor and verify the remaining suffix stays lexically inside the
233/// canonicalized root.
234pub fn validate_plugin_relative(value: &str, root: &Path) -> Result<PathBuf, crate::PluginError> {
235    if !value.starts_with("./") {
236        return Err(crate::PluginError::PathEscape(format!("path must start with ./: {}", value)));
237    }
238
239    let canonical_root = vtcode_commons::canonicalize(root)
240        .map_err(|e| crate::PluginError::PathEscape(format!("plugin root could not be resolved: {e}")))?;
241
242    let candidate = root.join(value.get(2..).unwrap_or_default());
243
244    // Reject obvious lexical escapes before touching the filesystem.
245    let lexical_ok = candidate
246        .components()
247        .all(|component| !matches!(component, std::path::Component::ParentDir));
248    if !lexical_ok {
249        return Err(crate::PluginError::PathEscape(format!("path escapes plugin root: {}", value)));
250    }
251
252    // Walk up to the deepest existing ancestor and canonicalize it; the
253    // remaining suffix is appended lexically.
254    let mut probe = candidate.as_path();
255    let mut suffix: Vec<PathBuf> = Vec::new();
256    loop {
257        match vtcode_commons::canonicalize(probe) {
258            Ok(existing) => {
259                let mut resolved = existing;
260                for part in suffix.iter().rev() {
261                    resolved.push(part);
262                }
263                if !resolved.starts_with(&canonical_root) {
264                    return Err(crate::PluginError::PathEscape(format!("path escapes plugin root: {}", value)));
265                }
266                return Ok(resolved);
267            }
268            Err(_) => {
269                let Some(name) = probe.file_name() else {
270                    return Err(crate::PluginError::PathEscape(format!("path could not be resolved: {}", value)));
271                };
272                suffix.push(PathBuf::from(name));
273                let Some(parent) = probe.parent() else {
274                    return Err(crate::PluginError::PathEscape(format!("path could not be resolved: {}", value)));
275                };
276                probe = parent;
277            }
278        }
279    }
280}