use std::path::Path;
use std::process::Command;
use serde::Deserialize;
pub const ENV_ALLOWLIST: &[&str] = &[
"PATH",
"HOME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TMPDIR",
"TEMP",
"TMP",
"SystemRoot",
"SYSTEMROOT",
"ComSpec",
"PATHEXT",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Permission {
Network,
FsWrite,
EnvPassthrough,
}
impl Permission {
pub fn parse(s: &str) -> Option<Self> {
match s {
"network" => Some(Self::Network),
"fs_write" => Some(Self::FsWrite),
"env_passthrough" => Some(Self::EnvPassthrough),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::FsWrite => "fs_write",
Self::EnvPassthrough => "env_passthrough",
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TrustSpec {
#[serde(default)]
pub permissions: Vec<String>,
}
impl TrustSpec {
pub fn validate(&self) -> Result<(), String> {
for p in &self.permissions {
if Permission::parse(p).is_none() {
return Err(format!("unknown permission '{p}'"));
}
}
Ok(())
}
pub fn policy(&self) -> SandboxPolicy {
let perms: Vec<Permission> = self
.permissions
.iter()
.filter_map(|p| Permission::parse(p))
.collect();
SandboxPolicy::from_permissions(&perms)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SandboxPolicy {
pub env_passthrough: bool,
pub allow_network: bool,
pub allow_fs_write: bool,
}
impl SandboxPolicy {
#[must_use]
pub fn strict() -> Self {
Self::default()
}
#[must_use]
pub fn permissive() -> Self {
Self {
env_passthrough: true,
allow_network: true,
allow_fs_write: true,
}
}
#[must_use]
pub fn from_permissions(perms: &[Permission]) -> Self {
Self {
env_passthrough: perms.contains(&Permission::EnvPassthrough),
allow_network: perms.contains(&Permission::Network),
allow_fs_write: perms.contains(&Permission::FsWrite),
}
}
#[must_use]
pub fn declared_permissions(&self) -> Vec<&'static str> {
let mut out = Vec::new();
if self.allow_network {
out.push(Permission::Network.as_str());
}
if self.allow_fs_write {
out.push(Permission::FsWrite.as_str());
}
if self.env_passthrough {
out.push(Permission::EnvPassthrough.as_str());
}
out
}
pub fn apply(&self, cmd: &mut Command, plugin_dir: &Path) {
if !self.env_passthrough {
cmd.env_clear();
for key in ENV_ALLOWLIST {
if let Ok(val) = std::env::var(key) {
cmd.env(key, val);
}
}
}
if plugin_dir.is_dir() {
cmd.current_dir(plugin_dir);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_least_privilege() {
let p = SandboxPolicy::default();
assert!(!p.env_passthrough);
assert!(!p.allow_network);
assert!(!p.allow_fs_write);
assert!(p.declared_permissions().is_empty());
}
#[test]
fn parse_permissions_roundtrip() {
for s in ["network", "fs_write", "env_passthrough"] {
assert_eq!(Permission::parse(s).unwrap().as_str(), s);
}
assert!(Permission::parse("rm_rf_everything").is_none());
}
#[test]
fn trust_spec_rejects_unknown_permission() {
let spec = TrustSpec {
permissions: vec!["network".into(), "bogus".into()],
};
assert!(spec.validate().unwrap_err().contains("bogus"));
}
#[test]
fn policy_reflects_declared_permissions() {
let spec = TrustSpec {
permissions: vec!["network".into(), "env_passthrough".into()],
};
let policy = spec.policy();
assert!(policy.allow_network);
assert!(policy.env_passthrough);
assert!(!policy.allow_fs_write);
let declared = policy.declared_permissions();
assert!(declared.contains(&"network"));
assert!(declared.contains(&"env_passthrough"));
}
#[cfg(unix)]
#[test]
fn scrubbed_env_hides_host_secret_but_keeps_path() {
use std::time::Duration;
crate::test_env::set_var("LEAN_CTX_TEST_SECRET", "top-secret");
let out = crate::core::plugins::executor::run_subprocess(
"env",
std::path::Path::new("/tmp"),
&[],
"",
Duration::from_secs(2),
&SandboxPolicy::strict(),
)
.unwrap();
let env_dump = String::from_utf8_lossy(&out.stdout);
crate::test_env::remove_var("LEAN_CTX_TEST_SECRET");
assert!(
!env_dump.contains("top-secret"),
"scrubbed child leaked host secret"
);
assert!(env_dump.contains("PATH="));
}
#[cfg(unix)]
#[test]
fn passthrough_env_exposes_host_var() {
use std::time::Duration;
crate::test_env::set_var("LEAN_CTX_TEST_PASSTHRU", "visible");
let out = crate::core::plugins::executor::run_subprocess(
"env",
std::path::Path::new("/tmp"),
&[],
"",
Duration::from_secs(2),
&SandboxPolicy::permissive(),
)
.unwrap();
let env_dump = String::from_utf8_lossy(&out.stdout);
crate::test_env::remove_var("LEAN_CTX_TEST_PASSTHRU");
assert!(env_dump.contains("visible"));
}
}