use std::path::{Component, Path, PathBuf};
fn workspace_canonical(workspace: &Path) -> PathBuf {
workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf())
}
fn validate_within_workspace(
workspace: &Path,
resolved: &Path,
original: &str,
) -> anyhow::Result<PathBuf> {
let canonical_workspace = workspace_canonical(workspace);
if !resolved.starts_with(&canonical_workspace) {
anyhow::bail!(
"Access denied: path '{}' is outside the workspace.",
original
);
}
Ok(resolved.to_path_buf())
}
const DENIED_FILE_NAMES: &[&str] = &[".env", "apollo.json"];
const DENIED_FILE_PREFIXES: &[&str] = &[".env."];
const DENIED_HOME_DIRS: &[&str] = &[".apollo"];
const DENIED_WORKSPACE_WRITE_DIRS: &[&str] = &[".apollo"];
fn ensure_write_not_denied(
workspace: &Path,
resolved: &Path,
original: &str,
) -> anyhow::Result<()> {
let canonical_workspace = workspace_canonical(workspace);
for dir in DENIED_WORKSPACE_WRITE_DIRS {
if resolved.starts_with(canonical_workspace.join(dir)) {
anyhow::bail!(
"Access denied: '{}' is inside the workspace's {} directory, which holds \
apollo's own skills, plugins and state.",
original,
dir
);
}
}
Ok(())
}
fn ensure_not_denied(resolved: &Path, original: &str) -> anyhow::Result<()> {
if let Some(name) = resolved.file_name().and_then(|n| n.to_str()) {
let denied = DENIED_FILE_NAMES.contains(&name)
|| DENIED_FILE_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix));
if denied {
anyhow::bail!(
"Access denied: '{}' holds apollo's own credentials or configuration.",
original
);
}
}
if let Some(home) = dirs::home_dir() {
for dir in DENIED_HOME_DIRS {
let denied_root = home.join(dir);
if resolved.starts_with(&denied_root) {
anyhow::bail!(
"Access denied: '{}' is inside {}, which holds apollo's own credentials.",
original,
denied_root.display()
);
}
}
}
Ok(())
}
fn resolve_write_candidate(requested: &Path) -> PathBuf {
let mut prefix = if requested.is_absolute() {
PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
} else {
PathBuf::new()
};
let mut canonical_prefix = prefix.clone();
let mut remainder = PathBuf::new();
let mut saw_missing_component = false;
for component in requested.components() {
match component {
Component::RootDir | Component::Prefix(_) => {}
Component::CurDir => {}
Component::ParentDir => {
if saw_missing_component {
remainder.pop();
} else {
prefix.pop();
if prefix.exists() {
canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
}
}
}
Component::Normal(part) => {
if saw_missing_component {
remainder.push(part);
continue;
}
prefix.push(part);
if prefix.exists() {
canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
} else {
saw_missing_component = true;
remainder.push(part);
}
}
}
}
if saw_missing_component {
canonical_prefix.join(remainder)
} else {
canonical_prefix
}
}
pub fn resolve_workspace_existing_path(
workspace: &Path,
raw_path: &str,
) -> anyhow::Result<PathBuf> {
let path = raw_path.trim();
if path.is_empty() {
anyhow::bail!("Path is required");
}
if path.starts_with('~') {
anyhow::bail!("Home directory expansion (~) is disabled for security.");
}
let requested = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
workspace.join(path)
};
let resolved = requested
.canonicalize()
.map_err(|e| anyhow::anyhow!("Cannot resolve '{}': {}", raw_path, e))?;
ensure_not_denied(&resolved, raw_path)?;
validate_within_workspace(workspace, &resolved, raw_path)
}
pub fn resolve_workspace_write_path(workspace: &Path, raw_path: &str) -> anyhow::Result<PathBuf> {
let path = raw_path.trim();
if path.is_empty() {
anyhow::bail!("Path is required");
}
if path.starts_with('~') {
anyhow::bail!("Home directory expansion (~) is disabled for security.");
}
let requested = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
workspace.join(path)
};
let resolved = resolve_write_candidate(&requested);
ensure_not_denied(&resolved, raw_path)?;
ensure_write_not_denied(workspace, &resolved, raw_path)?;
validate_within_workspace(workspace, &resolved, raw_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_absolute_escape() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
let err = resolve_workspace_write_path(&ws, "/etc/passwd").unwrap_err();
assert!(err.to_string().contains("outside the workspace"));
}
#[test]
fn refuses_to_read_workspace_secrets() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
for name in [".env", ".env.local", "apollo.json"] {
std::fs::write(ws.join(name), "secret").unwrap();
let err = resolve_workspace_existing_path(&ws, name).unwrap_err();
assert!(err.to_string().contains("Access denied"), "{name}: {err}");
}
}
#[test]
fn refuses_to_write_workspace_secrets() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
for name in [".env", ".env.production", "apollo.json", "sub/.env"] {
let err = resolve_workspace_write_path(&ws, name).unwrap_err();
assert!(err.to_string().contains("Access denied"), "{name}: {err}");
}
}
#[test]
fn allows_ordinary_workspace_files() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(ws.join("notes.md"), "hello").unwrap();
resolve_workspace_existing_path(&ws, "notes.md").unwrap();
resolve_workspace_write_path(&ws, "sub/new.txt").unwrap();
resolve_workspace_write_path(&ws, "environment.json").unwrap();
}
#[test]
fn refuses_to_write_anywhere_under_the_workspace_apollo_dir() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
for path in [
".apollo/skills/evil/SKILL.md",
".apollo/plugins/manifest.json",
".apollo/state.surreal",
".apollo/graph_memory.json",
"./.apollo/skills/x/SKILL.md",
"sub/../.apollo/skills/x/SKILL.md",
] {
let err = resolve_workspace_write_path(&ws, path).unwrap_err();
assert!(err.to_string().contains("Access denied"), "{path}: {err}");
}
let absolute = ws.join(".apollo/skills/evil/SKILL.md");
let err = resolve_workspace_write_path(&ws, absolute.to_str().unwrap()).unwrap_err();
assert!(err.to_string().contains("Access denied"), "{err}");
}
#[test]
fn apollo_deny_does_not_swallow_neighbouring_names() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
resolve_workspace_write_path(&ws, ".apollo-notes.md").unwrap();
resolve_workspace_write_path(&ws, "src/apollo/main.rs").unwrap();
resolve_workspace_write_path(&ws, "docs/.apollos/x.md").unwrap();
}
#[test]
fn reads_under_workspace_apollo_still_work() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(ws.join(".apollo/skills/demo")).unwrap();
std::fs::write(ws.join(".apollo/skills/demo/SKILL.md"), "# demo").unwrap();
resolve_workspace_existing_path(&ws, ".apollo/skills/demo/SKILL.md").unwrap();
}
#[test]
fn rejects_relative_traversal() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
let outside = tmp.path().join("secrets.txt");
std::fs::write(&outside, "secret").unwrap();
let err = resolve_workspace_existing_path(&ws, "../secrets.txt").unwrap_err();
assert!(err.to_string().contains("outside the workspace"));
}
}