use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, 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)
}
pub fn for_session(root: &Path, project_state_dir: &Path) -> Arc<Self> {
let trust = Self::new(Some(root.to_path_buf()), TrustLevel::Restricted);
let store = TrustStore::for_project_dir(project_state_dir);
let decided = store.is_decided();
trust.set_store(Some(store));
if !decided && executable_content_markers(root).is_empty() {
trust.set_level_transient(TrustLevel::Trusted);
}
Arc::new(trust)
}
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_level_transient(&self, level: TrustLevel) {
self.level.store(level.as_u8(), Ordering::Relaxed);
}
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> {
let mut found = Vec::new();
for d in crate::config::default_env_detectors() {
for m in &d.markers {
if root.join(m).exists() {
found.push(m.clone());
}
}
}
const FILE_MARKERS: &[&str] = &[
"Cargo.toml", "go.mod", "package.json", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts", "CMakeLists.txt", "compile_commands.json", "Gemfile", "composer.json", ];
for m in FILE_MARKERS {
if root.join(m).is_file() {
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
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DetectedEnv {
pub name: String,
pub kind: crate::config::EnvKind,
pub snippet: String,
}
pub fn detect_env(root: &Path, detectors: &[crate::config::EnvDetector]) -> Option<DetectedEnv> {
for d in detectors {
if !d.markers.iter().any(|m| root.join(m).exists()) {
continue;
}
if !d.require.is_empty() && !d.require.iter().any(|r| root.join(r).exists()) {
continue;
}
return Some(DetectedEnv {
name: d.name.clone(),
kind: d.kind,
snippet: d.snippet.replace("{dir}", &root.to_string_lossy()),
});
}
None
}
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());
}
use crate::config::{default_env_detectors, EnvKind};
fn detect_default(root: &Path) -> Option<DetectedEnv> {
detect_env(root, &default_env_detectors())
}
#[test]
fn detect_env_none_for_plain_folder() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(detect_default(tmp.path()), None);
}
#[test]
fn detect_env_venv_requires_an_interpreter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".venv")).unwrap();
assert_eq!(detect_default(root), None);
assert!(executable_content_markers(root)
.iter()
.any(|m| m == ".venv"));
std::fs::create_dir_all(root.join(".venv/bin")).unwrap();
std::fs::write(root.join(".venv/bin/python"), "").unwrap();
let det = detect_default(root).expect("venv detected");
assert_eq!(det.name, ".venv");
assert_eq!(det.kind, EnvKind::PathOnly);
assert_eq!(det.snippet, "source .venv/bin/activate");
}
#[test]
fn detect_env_direnv_and_mise_are_shell() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join(".envrc"), "use flake\n").unwrap();
let det = detect_default(root).expect("direnv detected");
assert_eq!(det.name, "direnv");
assert_eq!(det.kind, EnvKind::Shell);
assert_eq!(det.snippet, "eval \"$(direnv export bash)\"");
let mise = tmp.path().join("mise");
std::fs::create_dir_all(&mise).unwrap();
std::fs::write(mise.join(".tool-versions"), "python 3.12\n").unwrap();
let det = detect_default(&mise).expect("mise detected");
assert_eq!(det.name, "mise");
assert_eq!(det.kind, EnvKind::Shell);
}
#[test]
fn detect_env_pipenv_and_poetry() {
let tmp = tempfile::tempdir().unwrap();
let pip = tmp.path().join("pip");
std::fs::create_dir_all(&pip).unwrap();
std::fs::write(pip.join("Pipfile"), "[packages]\n").unwrap();
assert_eq!(detect_default(&pip).map(|d| d.name), Some("pipenv".into()));
let poetry = tmp.path().join("poetry");
std::fs::create_dir_all(&poetry).unwrap();
std::fs::write(poetry.join("poetry.lock"), "\n").unwrap();
assert_eq!(
detect_default(&poetry).map(|d| d.name),
Some("poetry".into())
);
}
#[test]
fn detect_env_first_detector_wins() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".venv/bin")).unwrap();
std::fs::write(root.join(".venv/bin/python3"), "").unwrap();
std::fs::write(root.join(".envrc"), "\n").unwrap();
assert_eq!(detect_default(root).map(|d| d.name), Some(".venv".into()));
}
#[test]
fn detect_env_honors_custom_config() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join(".nvmrc"), "20\n").unwrap();
let detectors = vec![crate::config::EnvDetector {
name: "node".into(),
markers: vec![".nvmrc".into()],
kind: EnvKind::Shell,
snippet: "eval \"$(fnm env)\"".into(),
require: vec![],
}];
let det = detect_env(root, &detectors).expect("custom env detected");
assert_eq!(det.name, "node");
assert_eq!(det.snippet, "eval \"$(fnm env)\"");
}
}