use anyhow::{bail, Result};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxMode {
ReadOnly,
WorkspaceWrite,
DangerFullAccess,
DockerIsolated,
}
impl SandboxMode {
pub fn parse(s: &str) -> Self {
match s {
"read-only" => Self::ReadOnly,
"danger-full-access" => Self::DangerFullAccess,
"docker-isolated" => Self::DockerIsolated,
_ => Self::WorkspaceWrite,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
Self::DockerIsolated => "docker-isolated",
}
}
}
#[derive(Debug, Clone)]
pub struct Jail {
pub cwd: PathBuf,
pub extra: Vec<PathBuf>,
pub mode: SandboxMode,
pub shell_allowlist: Vec<String>,
}
fn strip_verbatim(p: PathBuf) -> PathBuf {
let s = p.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\") {
PathBuf::from(rest)
} else {
p
}
}
fn normalize_existing_or_parent(path: &Path) -> PathBuf {
if let Ok(c) = path.canonicalize() {
return strip_verbatim(c);
}
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut cur = path.to_path_buf();
while let Some(parent) = cur.parent() {
if let Ok(c) = parent.canonicalize() {
if let Some(name) = cur.file_name() {
tail.push(name.to_os_string());
}
let mut out = strip_verbatim(c);
for comp in tail.iter().rev() {
out.push(comp);
}
return out;
}
if let Some(name) = cur.file_name() {
tail.push(name.to_os_string());
}
cur = parent.to_path_buf();
}
strip_verbatim(path.to_path_buf())
}
fn path_is_within(path: &Path, root: &Path) -> bool {
let p = strip_verbatim(path.to_path_buf());
let r = strip_verbatim(root.to_path_buf());
p.starts_with(&r)
}
impl Jail {
pub fn new(cwd: PathBuf, extra: Vec<PathBuf>, mode: SandboxMode, allow: Vec<String>) -> Self {
Self {
cwd,
extra,
mode,
shell_allowlist: allow,
}
}
pub fn allowed_roots(&self) -> Vec<PathBuf> {
let mut roots = vec![self.cwd.clone()];
roots.extend(self.extra.iter().cloned());
roots
}
pub fn resolve(&self, p: &str) -> Result<PathBuf> {
let path = if Path::new(p).is_absolute() {
PathBuf::from(p)
} else {
self.cwd.join(p)
};
let resolved = normalize_existing_or_parent(&path);
if self.mode == SandboxMode::DangerFullAccess {
return Ok(resolved);
}
let ok = self.allowed_roots().iter().any(|root| {
let r = normalize_existing_or_parent(root);
path_is_within(&resolved, &r)
});
if !ok {
bail!("path {} is outside the workspace jail", resolved.display());
}
Ok(resolved)
}
pub fn assert_write(&self, p: &str) -> Result<PathBuf> {
if self.mode == SandboxMode::ReadOnly {
bail!("sandbox is read-only; writes are blocked");
}
self.resolve(p)
}
pub fn assert_shell(&self, command: &str) -> Result<()> {
if self.mode == SandboxMode::DangerFullAccess {
return Ok(());
}
if command
.chars()
.any(|c| matches!(c, ';' | '&' | '|' | '>' | '<' | '`' | '$' | '\n' | '\r'))
|| command.contains("$((")
|| command.contains("$(")
{
bail!("shell command chaining and substitution are blocked by the sandbox");
}
let first = command
.split_whitespace()
.next()
.unwrap_or("")
.trim_matches('"')
.trim_matches('\'');
let base = Path::new(first)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(first)
.to_ascii_lowercase();
if self
.shell_allowlist
.iter()
.any(|a| a.eq_ignore_ascii_case(&base))
{
return Ok(());
}
bail!("shell command `{base}` is not on the sandbox allowlist");
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn jail_blocks_outside_path() {
let dir = tempdir().unwrap();
let jail = Jail::new(
dir.path().to_path_buf(),
vec![],
SandboxMode::WorkspaceWrite,
vec!["git".into()],
);
let outside = std::env::temp_dir().join("varynth-jail-should-fail.txt");
if outside.starts_with(dir.path()) {
return;
}
assert!(jail.resolve(outside.to_str().unwrap()).is_err());
assert!(jail.resolve("inside.txt").is_ok());
}
#[test]
fn allowlist_rejects_unknown_bin() {
let dir = tempdir().unwrap();
let jail = Jail::new(
dir.path().to_path_buf(),
vec![],
SandboxMode::WorkspaceWrite,
vec!["git".into()],
);
assert!(jail.assert_shell("format C:").is_err());
assert!(jail.assert_shell("git status").is_ok());
assert!(jail.assert_shell("git; format C:").is_err());
assert!(jail.assert_shell("git && whoami").is_err());
}
#[test]
fn symlink_escape_blocked_even_for_missing_paths() {
let dir = tempdir().unwrap();
let jail_root = dir.path();
std::fs::create_dir_all(jail_root.join("real")).unwrap();
let outside = tempdir().unwrap();
if outside.path().starts_with(jail_root) {
return;
}
#[cfg(windows)]
let linked = std::os::windows::fs::symlink_dir(outside.path(), jail_root.join("link"));
#[cfg(not(windows))]
let linked = std::os::unix::fs::symlink(outside.path(), jail_root.join("link"));
if linked.is_err() {
#[cfg(windows)]
{
let made = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(jail_root.join("link"))
.arg(outside.path())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !made {
return;
}
}
#[cfg(not(windows))]
return;
}
let jail = Jail::new(
jail_root.to_path_buf(),
vec![],
SandboxMode::WorkspaceWrite,
vec![],
);
std::fs::write(outside.path().join("existing.txt"), b"x").unwrap();
assert!(jail.resolve("link/existing.txt").is_err());
assert!(jail.resolve("link/missing.txt").is_err());
assert!(jail.resolve("link/sub/dir/newfile.txt").is_err());
}
#[test]
fn benign_missing_paths_still_resolve() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("real/deep")).unwrap();
let jail = Jail::new(
dir.path().to_path_buf(),
vec![],
SandboxMode::WorkspaceWrite,
vec![],
);
assert!(jail.resolve("inside.txt").is_ok());
assert!(jail.resolve("real/a.txt").is_ok());
assert!(jail.resolve("real/deep/very/new.txt").is_ok());
}
#[test]
fn sandbox_mode_roundtrip_including_docker_isolated() {
for mode in [
SandboxMode::ReadOnly,
SandboxMode::WorkspaceWrite,
SandboxMode::DangerFullAccess,
SandboxMode::DockerIsolated,
] {
assert_eq!(SandboxMode::parse(mode.as_str()), mode);
}
assert_eq!(
SandboxMode::parse("docker-isolated"),
SandboxMode::DockerIsolated
);
assert_eq!(SandboxMode::DockerIsolated.as_str(), "docker-isolated");
assert_eq!(SandboxMode::parse("yolo"), SandboxMode::WorkspaceWrite);
}
#[test]
fn docker_isolated_matches_workspace_write_rules() {
let dir = tempdir().unwrap();
let mk = |mode| Jail::new(dir.path().to_path_buf(), vec![], mode, vec!["git".into()]);
let docker = mk(SandboxMode::DockerIsolated);
let workspace = mk(SandboxMode::WorkspaceWrite);
assert!(docker.resolve("inside.txt").is_ok());
assert!(docker.assert_write("new.txt").is_ok());
let outside = std::env::temp_dir().join("varynth-docker-jail-escape.txt");
if !outside.starts_with(dir.path()) {
assert_eq!(
docker.resolve(outside.to_str().unwrap()).is_err(),
workspace.resolve(outside.to_str().unwrap()).is_err()
);
assert!(docker.resolve(outside.to_str().unwrap()).is_err());
assert!(docker.assert_write(outside.to_str().unwrap()).is_err());
}
assert!(docker.assert_shell("git status").is_ok());
assert!(docker.assert_shell("format C:").is_err());
assert!(docker.assert_shell("git && whoami").is_err());
assert!(docker.assert_shell("git $(whoami)").is_err());
}
#[test]
fn normalize_reappends_multi_level_missing_tail() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("real")).unwrap();
let p = dir.path().join("real/sub/deep/newfile.txt");
let out = normalize_existing_or_parent(&p);
assert_eq!(
out,
dir.path()
.join("real")
.join("sub")
.join("deep")
.join("newfile.txt")
);
}
}