use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;
use tracing::{debug, warn};
#[derive(Debug, Clone)]
enum ProcessPattern {
Regex(Regex),
Exact(String),
}
impl ProcessPattern {
fn matches(&self, comm: &str, cmdline: &str) -> bool {
match self {
ProcessPattern::Regex(re) => re.is_match(comm) || re.is_match(cmdline),
ProcessPattern::Exact(s) => comm == s || cmdline == s,
}
}
}
fn parse_process_pattern(token: &str) -> Option<ProcessPattern> {
if let Some(exact) = token.strip_prefix('=') {
Some(ProcessPattern::Exact(exact.to_string()))
} else {
match Regex::new(token) {
Ok(re) => Some(ProcessPattern::Regex(re)),
Err(e) => {
warn!("Invalid regex in .agentallow {:?}: {}", token, e);
None
}
}
}
}
#[derive(Debug, Clone)]
enum AllowEntry {
ProcessName(ProcessPattern),
ProcessNameExact(ProcessPattern),
BinaryPath(PathBuf),
BinaryPathExact(PathBuf),
}
#[derive(Debug, Clone)]
struct CachedProcess {
comm: String,
cmdline: String,
exe: Option<PathBuf>,
ppid: Option<u32>,
loaded_at: Instant,
}
impl CachedProcess {
fn is_fresh(&self) -> bool {
self.loaded_at.elapsed() < PROC_CACHE_TTL
}
}
const PROC_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
#[derive(Debug, Clone, Default)]
struct ProcessCache {
map: HashMap<u32, CachedProcess>,
}
impl ProcessCache {
fn get_or_load(&mut self, pid: u32) -> CachedProcess {
if let Some(cached) = self.map.get(&pid)
&& cached.is_fresh()
{
return cached.clone();
}
let proc_info = load_process(pid);
self.map.insert(pid, proc_info.clone());
proc_info
}
}
fn load_process(pid: u32) -> CachedProcess {
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "<unknown>".to_string());
let cmdline = std::fs::read(format!("/proc/{pid}/cmdline"))
.map(|bytes| {
bytes
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let exe = std::fs::read_link(format!("/proc/{pid}/exe")).ok();
let ppid = get_parent_pid_from_proc(pid);
CachedProcess {
comm,
cmdline,
exe,
ppid,
loaded_at: Instant::now(),
}
}
fn get_parent_pid_from_proc(pid: u32) -> Option<u32> {
let stat_content = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat_content.rfind(')')?;
let rest = &stat_content[after_comm + 1..];
let fields: Vec<&str> = rest.split_whitespace().collect();
fields.get(1)?.parse().ok()
}
#[derive(Debug)]
pub struct AllowList {
entries: Vec<AllowEntry>,
process_cache: Mutex<ProcessCache>,
}
impl AllowList {
pub fn load(root: &Path) -> Self {
let path = root.join(".agentallow");
Self::load_from_file(&path)
}
pub fn load_from_file(path: &Path) -> Self {
let mut entries = Vec::new();
if let Ok(content) = std::fs::read_to_string(path) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (token, ancestor_walk) = if let Some(t) = line.strip_suffix('!') {
(t.trim(), false)
} else {
(line, true)
};
if token.starts_with('/') {
let pb = PathBuf::from(token);
if ancestor_walk {
debug!("Allow binary path or child: {} from {:?}", token, path);
entries.push(AllowEntry::BinaryPath(pb));
} else {
debug!("Allow exact binary path: {} from {:?}", token, path);
entries.push(AllowEntry::BinaryPathExact(pb));
}
} else {
if let Some(pattern) = parse_process_pattern(token) {
if ancestor_walk {
debug!(
"Allow process pattern or child: {:?} from {:?}",
token, path
);
entries.push(AllowEntry::ProcessName(pattern));
} else {
debug!("Allow exact process pattern: {:?} from {:?}", token, path);
entries.push(AllowEntry::ProcessNameExact(pattern));
}
}
}
}
}
if !entries.is_empty() {
debug!("Loaded {} allow entries from {:?}", entries.len(), path);
}
Self {
entries,
process_cache: Mutex::new(ProcessCache::default()),
}
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn is_allowed(&self, pid: u32) -> bool {
if self.entries.is_empty() {
return false;
}
for entry in &self.entries {
match entry {
AllowEntry::ProcessName(pattern) => {
if self.is_process_or_child_by_name(pid, pattern, None) {
debug!("Allow PID {} as process or child matching pattern", pid);
return true;
}
}
AllowEntry::ProcessNameExact(pattern) => {
if self.is_process_or_child_by_name(pid, pattern, Some(0)) {
debug!("Allow PID {} by exact-process pattern match", pid);
return true;
}
}
AllowEntry::BinaryPath(path) => {
if self.is_process_or_child_by_path(pid, path) {
debug!("Allow PID {} as process or child of {:?}", pid, path);
return true;
}
}
AllowEntry::BinaryPathExact(path) => {
let info = self.get_process_info(pid);
if info.exe.as_deref() == Some(path) {
debug!("Allow PID {} by exact exe {:?}", pid, path);
return true;
}
}
}
}
false
}
fn get_process_info(&self, pid: u32) -> CachedProcess {
self.process_cache
.lock()
.expect("process_cache Mutex poisoned — fatal process state")
.get_or_load(pid)
}
fn check_parent_chain<F>(&self, mut pid: u32, max_depth: Option<u32>, should_stop: F) -> bool
where
F: Fn(u32, &str, &str) -> bool,
{
let current_pid = std::process::id();
let mut depth = 0u32;
loop {
let info = self.get_process_info(pid);
debug!("pid={pid} name={:?} cmdline={:?}", info.comm, info.cmdline);
if should_stop(pid, &info.comm, &info.cmdline) {
return true;
}
if let Some(max) = max_depth
&& depth >= max
{
break;
}
let ppid = info.ppid;
match ppid {
Some(0) | None => break,
Some(parent) if parent == current_pid => break,
Some(parent) => {
pid = parent;
depth += 1;
}
}
}
false
}
fn is_process_or_child_by_name(
&self,
pid: u32,
pattern: &ProcessPattern,
max_depth: Option<u32>,
) -> bool {
debug!(
"is_process_or_child_by_name pid={} pattern={:?} max_depth={:?}",
pid, pattern, max_depth
);
self.check_parent_chain(pid, max_depth, |_pid, pname, cmdline| {
pattern.matches(pname, cmdline)
})
}
fn is_process_or_child_by_path(&self, mut pid: u32, path: &Path) -> bool {
loop {
let info = self.get_process_info(pid);
if info.exe.as_deref() == Some(path) {
return true;
}
match info.ppid {
Some(0) | None => return false,
Some(parent) => {
pid = parent;
}
}
}
}
}
#[derive(Debug)]
pub struct CascadingAllowList {
root: PathBuf,
root_allow_list: AllowList,
dir_allow_lists: HashMap<PathBuf, Option<AllowList>>,
}
impl CascadingAllowList {
pub fn load(root: &Path) -> Self {
let root_allow_list = AllowList::load(root);
let mut dir_allow_lists = HashMap::new();
Self::scan_dir_allow_lists(root, &mut dir_allow_lists);
Self {
root: root.to_path_buf(),
root_allow_list,
dir_allow_lists,
}
}
fn scan_dir_allow_lists(current: &Path, allow_lists: &mut HashMap<PathBuf, Option<AllowList>>) {
if let Ok(entries) = std::fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let agentallow = path.join(".agentallow");
if agentallow.exists() {
let allow_list = AllowList::load_from_file(&agentallow);
allow_lists.insert(path.clone(), Some(allow_list));
} else {
allow_lists.insert(path.clone(), None);
}
Self::scan_dir_allow_lists(&path, allow_lists);
}
}
}
}
fn get_combined_allow_lists_for_path(&self, path: &Path) -> Vec<&AllowList> {
let mut allow_lists = Vec::new();
allow_lists.push(&self.root_allow_list);
let mut current = path.to_path_buf();
while current.starts_with(&self.root) && current != self.root() {
if let Some(Some(allow_list)) = self.dir_allow_lists.get(¤t) {
allow_lists.push(allow_list);
}
if let Some(parent) = current.parent() {
current = parent.to_path_buf();
} else {
break;
}
}
allow_lists
}
fn root(&self) -> &Path {
&self.root
}
pub fn is_allowed(&self, path: &Path, pid: u32) -> bool {
let allow_lists = self.get_combined_allow_lists_for_path(path);
for allow_list in &allow_lists {
if allow_list.is_allowed(pid) {
return true;
}
}
false
}
pub fn has_any_entries(&self) -> bool {
if !self.root_allow_list.is_empty() {
return true;
}
for allow_list_opt in self.dir_allow_lists.values() {
if let Some(allow_list) = allow_list_opt
&& !allow_list.is_empty()
{
return true;
}
}
false
}
}