use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::RwLock;
use crate::services::remote::SpawnError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TrustLevel {
#[default]
Restricted,
Trusted,
Blocked,
}
impl TrustLevel {
fn from_u8(v: u8) -> Self {
match v {
1 => TrustLevel::Trusted,
2 => TrustLevel::Blocked,
_ => TrustLevel::Restricted,
}
}
fn as_u8(self) -> u8 {
match self {
TrustLevel::Restricted => 0,
TrustLevel::Trusted => 1,
TrustLevel::Blocked => 2,
}
}
pub fn as_str(self) -> &'static str {
match self {
TrustLevel::Restricted => "restricted",
TrustLevel::Trusted => "trusted",
TrustLevel::Blocked => "blocked",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpawnDecision {
Allow,
Deny(String),
}
pub struct WorkspaceTrust {
roots: RwLock<Vec<PathBuf>>,
root: RwLock<Option<PathBuf>>,
level: AtomicU8,
store: RwLock<Option<TrustStore>>,
}
impl WorkspaceTrust {
pub fn new(root: Option<PathBuf>, level: TrustLevel) -> Self {
Self::build(root, level, None)
}
pub fn new_persistent(root: Option<PathBuf>, level: TrustLevel, store: TrustStore) -> Self {
Self::build(root, level, Some(store))
}
pub fn permissive() -> Self {
Self::new(None, TrustLevel::Trusted)
}
fn build(root: Option<PathBuf>, level: TrustLevel, store: Option<TrustStore>) -> Self {
Self {
roots: RwLock::new(compute_roots(root.clone())),
root: RwLock::new(root),
level: AtomicU8::new(level.as_u8()),
store: RwLock::new(store),
}
}
pub fn level(&self) -> TrustLevel {
TrustLevel::from_u8(self.level.load(Ordering::Relaxed))
}
pub fn set_level(&self, level: TrustLevel) {
self.level.store(level.as_u8(), Ordering::Relaxed);
if let Ok(store) = self.store.read() {
if let Some(store) = store.as_ref() {
if let Err(e) = store.record(level) {
tracing::warn!("workspace trust: failed to persist level: {e}");
}
}
}
}
pub fn set_root(&self, root: Option<PathBuf>) {
if let Ok(mut guard) = self.roots.write() {
*guard = compute_roots(root.clone());
}
if let Ok(mut guard) = self.root.write() {
*guard = root;
}
}
pub fn set_store(&self, store: Option<TrustStore>) {
if let Some(store) = &store {
self.level.store(store.level().as_u8(), Ordering::Relaxed);
}
if let Ok(mut guard) = self.store.write() {
*guard = store;
}
}
pub fn decide(&self, command: &str, cwd: Option<&str>) -> SpawnDecision {
match self.level() {
TrustLevel::Trusted => SpawnDecision::Allow,
TrustLevel::Blocked => SpawnDecision::Deny(
"workspace trust is set to Blocked — no processes may run".to_string(),
),
TrustLevel::Restricted => self.decide_restricted(command, cwd),
}
}
fn decide_restricted(&self, command: &str, cwd: Option<&str>) -> SpawnDecision {
if !looks_like_path(command) {
return SpawnDecision::Allow;
}
let roots = match self.roots.read() {
Ok(g) => g,
Err(_) => return SpawnDecision::Allow,
};
if roots.is_empty() {
return SpawnDecision::Allow;
}
let base = roots[0].as_path();
let candidate = resolve_against(command, cwd, base);
if roots.iter().any(|r| path_is_within(&candidate, r)) {
SpawnDecision::Deny(format!(
"workspace trust is Restricted — refusing to run '{command}' \
from inside the project; trust this folder to allow it"
))
} else {
SpawnDecision::Allow
}
}
}
fn compute_roots(root: Option<PathBuf>) -> Vec<PathBuf> {
let Some(root) = root else {
return Vec::new();
};
let mut roots = vec![lexical_normalize(&root)];
if let Ok(canonical) = std::fs::canonicalize(&root) {
let canonical = lexical_normalize(&canonical);
if !roots.contains(&canonical) {
roots.push(canonical);
}
}
roots
}
fn looks_like_path(command: &str) -> bool {
command.contains('/') || command.contains('\\')
}
fn resolve_against(command: &str, cwd: Option<&str>, base: &Path) -> PathBuf {
let p = Path::new(command);
if p.is_absolute() {
return lexical_normalize(p);
}
let cwd_base = match cwd {
Some(c) if Path::new(c).is_absolute() => PathBuf::from(c),
Some(c) => base.join(c),
None => base.to_path_buf(),
};
lexical_normalize(&cwd_base.join(p))
}
fn lexical_normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if out.file_name().is_some() {
out.pop();
} else {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
fn path_is_within(candidate: &Path, root: &Path) -> bool {
candidate == root || candidate.starts_with(root)
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct StoredTrust {
level: TrustLevel,
}
#[derive(Debug, Clone)]
pub struct TrustStore {
path: PathBuf,
}
impl TrustStore {
pub fn for_project_dir(project_state_dir: &Path) -> Self {
Self {
path: project_state_dir.join("trust.json"),
}
}
pub fn level(&self) -> TrustLevel {
self.recorded_level().unwrap_or_default()
}
pub fn is_decided(&self) -> bool {
self.recorded_level().is_some()
}
fn recorded_level(&self) -> Option<TrustLevel> {
let text = std::fs::read_to_string(&self.path).ok()?;
serde_json::from_str::<StoredTrust>(&text)
.ok()
.map(|s| s.level)
}
pub fn record(&self, level: TrustLevel) -> io::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json =
serde_json::to_string_pretty(&StoredTrust { level }).map_err(io::Error::other)?;
let tmp = self
.path
.with_extension(format!("json.{}.tmp", std::process::id()));
std::fs::write(&tmp, json.as_bytes())?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
pub fn workspace_has_executable_content(root: &Path) -> bool {
!executable_content_markers(root).is_empty()
}
pub fn executable_content_markers(root: &Path) -> Vec<String> {
const FILE_MARKERS: &[&str] = &[
".envrc", "mise.toml", ".mise.toml", ".tool-versions", "Pipfile", "poetry.lock", "Cargo.toml", "go.mod", "package.json", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts", "CMakeLists.txt", "compile_commands.json", "Gemfile", "composer.json", ];
const DIR_MARKERS: &[&str] = &[".venv", "venv"];
let mut found = Vec::new();
for m in FILE_MARKERS {
if root.join(m).is_file() {
found.push((*m).to_string());
}
}
for m in DIR_MARKERS {
if root.join(m).is_dir() {
found.push((*m).to_string());
}
}
if root
.join(".devcontainer")
.join("devcontainer.json")
.is_file()
|| root.join(".devcontainer.json").is_file()
{
found.push("devcontainer.json".to_string());
}
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if matches!(ext, "sln" | "csproj" | "fsproj") {
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
found.push(name.to_string());
}
}
}
}
}
found
}
pub fn gate(trust: &WorkspaceTrust, command: &str, cwd: Option<&str>) -> Result<(), SpawnError> {
match trust.decide(command, cwd) {
SpawnDecision::Allow => Ok(()),
SpawnDecision::Deny(reason) => Err(SpawnError::Process(reason)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn trust(root: &str, level: TrustLevel) -> WorkspaceTrust {
WorkspaceTrust::new(Some(PathBuf::from(root)), level)
}
#[test]
fn trusted_allows_everything() {
let t = trust("/home/u/proj", TrustLevel::Trusted);
assert_eq!(
t.decide("/home/u/proj/.venv/bin/python", None),
SpawnDecision::Allow
);
assert_eq!(t.decide("rg", None), SpawnDecision::Allow);
}
#[test]
fn blocked_denies_everything() {
let t = trust("/home/u/proj", TrustLevel::Blocked);
assert!(matches!(t.decide("rg", None), SpawnDecision::Deny(_)));
assert!(matches!(
t.decide("/usr/bin/git", None),
SpawnDecision::Deny(_)
));
}
#[test]
fn restricted_allows_bare_command_names() {
let t = trust("/home/u/proj", TrustLevel::Restricted);
assert_eq!(t.decide("git", None), SpawnDecision::Allow);
assert_eq!(t.decide("rg", Some("/home/u/proj")), SpawnDecision::Allow);
assert_eq!(t.decide("python3", None), SpawnDecision::Allow);
}
#[test]
fn restricted_blocks_absolute_path_inside_workspace() {
let t = trust("/home/u/proj", TrustLevel::Restricted);
assert!(matches!(
t.decide("/home/u/proj/.venv/bin/python", None),
SpawnDecision::Deny(_)
));
}
#[test]
fn restricted_allows_absolute_path_outside_workspace() {
let t = trust("/home/u/proj", TrustLevel::Restricted);
assert_eq!(t.decide("/usr/bin/python3", None), SpawnDecision::Allow);
}
#[test]
fn restricted_blocks_relative_path_resolving_into_workspace() {
let t = trust("/home/u/proj", TrustLevel::Restricted);
assert!(matches!(
t.decide("./.venv/bin/python", Some("/home/u/proj")),
SpawnDecision::Deny(_)
));
assert!(matches!(
t.decide("../.venv/bin/python", Some("/home/u/proj/src")),
SpawnDecision::Deny(_)
));
}
#[test]
fn restricted_allows_relative_path_escaping_workspace() {
let t = trust("/home/u/proj", TrustLevel::Restricted);
assert_eq!(
t.decide("../evil", Some("/home/u/proj")),
SpawnDecision::Allow
);
}
#[test]
fn restricted_does_not_confuse_sibling_prefix() {
let t = trust("/home/u/proj", TrustLevel::Restricted);
assert_eq!(
t.decide("/home/u/proj-evil/bin/x", None),
SpawnDecision::Allow
);
}
#[test]
fn restricted_without_root_allows() {
let t = WorkspaceTrust::new(None, TrustLevel::Restricted);
assert_eq!(t.decide("/anything/at/all", None), SpawnDecision::Allow);
}
#[test]
fn set_level_takes_effect_immediately() {
let t = trust("/home/u/proj", TrustLevel::Restricted);
let cmd = "/home/u/proj/.venv/bin/python";
assert!(matches!(t.decide(cmd, None), SpawnDecision::Deny(_)));
t.set_level(TrustLevel::Trusted);
assert_eq!(t.decide(cmd, None), SpawnDecision::Allow);
t.set_level(TrustLevel::Blocked);
assert!(matches!(t.decide("rg", None), SpawnDecision::Deny(_)));
}
#[test]
fn set_root_updates_containment() {
let t = trust("/home/u/proj", TrustLevel::Restricted);
let cmd = "/home/u/other/.venv/bin/python";
assert_eq!(t.decide(cmd, None), SpawnDecision::Allow);
t.set_root(Some(PathBuf::from("/home/u/other")));
assert!(matches!(t.decide(cmd, None), SpawnDecision::Deny(_)));
}
#[test]
fn level_round_trips_through_u8() {
for lvl in [
TrustLevel::Restricted,
TrustLevel::Trusted,
TrustLevel::Blocked,
] {
assert_eq!(TrustLevel::from_u8(lvl.as_u8()), lvl);
}
assert_eq!(TrustLevel::from_u8(99), TrustLevel::Restricted);
}
#[test]
fn lexical_normalize_resolves_dot_segments() {
assert_eq!(
lexical_normalize(Path::new("/a/b/../c/./d")),
PathBuf::from("/a/c/d")
);
}
#[test]
fn store_round_trips_level_for_one_project() {
let tmp = tempfile::tempdir().unwrap();
let proj_dir = tmp.path().join("a/b/proj");
let store = TrustStore::for_project_dir(&proj_dir);
assert!(!store.is_decided());
assert_eq!(store.level(), TrustLevel::default());
store.record(TrustLevel::Trusted).unwrap();
assert!(store.is_decided());
assert_eq!(store.level(), TrustLevel::Trusted);
store.record(TrustLevel::Blocked).unwrap();
assert_eq!(store.level(), TrustLevel::Blocked);
assert!(proj_dir.join("trust.json").exists());
}
#[test]
fn separate_projects_use_separate_files() {
let tmp = tempfile::tempdir().unwrap();
let a = TrustStore::for_project_dir(&tmp.path().join("a"));
let b = TrustStore::for_project_dir(&tmp.path().join("b"));
a.record(TrustLevel::Trusted).unwrap();
assert_eq!(a.level(), TrustLevel::Trusted);
assert!(a.is_decided());
assert!(!b.is_decided());
assert_eq!(b.level(), TrustLevel::default());
}
#[test]
fn set_level_persists_through_store() {
let tmp = tempfile::tempdir().unwrap();
let proj_dir = tmp.path().join("proj");
let wt = WorkspaceTrust::new_persistent(
Some(proj_dir.clone()),
TrustLevel::Restricted,
TrustStore::for_project_dir(&proj_dir),
);
wt.set_level(TrustLevel::Trusted);
assert_eq!(
TrustStore::for_project_dir(&proj_dir).level(),
TrustLevel::Trusted
);
}
#[test]
fn set_store_adopts_new_projects_persisted_level() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a");
let b = tmp.path().join("b");
TrustStore::for_project_dir(&b)
.record(TrustLevel::Blocked)
.unwrap();
let wt = WorkspaceTrust::new_persistent(
Some(a.clone()),
TrustLevel::Trusted,
TrustStore::for_project_dir(&a),
);
assert_eq!(wt.level(), TrustLevel::Trusted);
wt.set_root(Some(b.clone()));
wt.set_store(Some(TrustStore::for_project_dir(&b)));
assert_eq!(wt.level(), TrustLevel::Blocked);
}
#[test]
fn in_memory_set_level_does_not_require_store() {
let wt = WorkspaceTrust::new(Some(PathBuf::from("/home/u/proj")), TrustLevel::Restricted);
wt.set_level(TrustLevel::Blocked);
assert_eq!(wt.level(), TrustLevel::Blocked);
}
#[test]
fn set_store_to_undecided_project_resets_to_default() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a");
let b = tmp.path().join("b"); TrustStore::for_project_dir(&a)
.record(TrustLevel::Trusted)
.unwrap();
let wt = WorkspaceTrust::new_persistent(
Some(a.clone()),
TrustLevel::Trusted,
TrustStore::for_project_dir(&a),
);
assert_eq!(wt.level(), TrustLevel::Trusted);
wt.set_store(Some(TrustStore::for_project_dir(&b)));
assert_eq!(wt.level(), TrustLevel::default());
assert_eq!(TrustLevel::default(), TrustLevel::Restricted);
}
#[test]
fn executable_content_detection() {
let tmp = tempfile::tempdir().unwrap();
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(!workspace_has_executable_content(&empty));
let envrc = tmp.path().join("envrc");
std::fs::create_dir_all(&envrc).unwrap();
std::fs::write(envrc.join(".envrc"), "use flake\n").unwrap();
assert!(workspace_has_executable_content(&envrc));
let venv = tmp.path().join("venv");
std::fs::create_dir_all(venv.join(".venv")).unwrap();
assert!(workspace_has_executable_content(&venv));
let dotnet = tmp.path().join("dotnet");
std::fs::create_dir_all(&dotnet).unwrap();
std::fs::write(dotnet.join("App.csproj"), "<Project/>\n").unwrap();
assert!(workspace_has_executable_content(&dotnet));
}
#[test]
fn executable_content_markers_lists_what_triggered() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join(".envrc"), "use flake\n").unwrap();
std::fs::write(root.join("mise.toml"), "[tools]\n").unwrap();
std::fs::create_dir_all(root.join(".venv")).unwrap();
std::fs::create_dir_all(root.join(".devcontainer")).unwrap();
std::fs::write(root.join(".devcontainer").join("devcontainer.json"), "{}\n").unwrap();
std::fs::write(root.join("App.csproj"), "<Project/>\n").unwrap();
let markers = executable_content_markers(root);
for expected in [
".envrc",
"mise.toml",
".venv",
"devcontainer.json",
"App.csproj",
] {
assert!(
markers.iter().any(|m| m == expected),
"expected '{expected}' in {markers:?}"
);
}
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(executable_content_markers(&empty).is_empty());
}
}