use anyhow::{Result, bail};
use openmoat_core::ir::{Access, Checker, Effect, Enforcement};
use openmoat_core::{AtomicAction, Kind};
use super::landlock::{self, INSIDE_GRANTS_ALLOWANCE, Rules, TCP_PORT_ALLOWANCE};
use super::patterns::{
AGENT_CONFIG, folders_above, has_glob, is_below, literal_tree, push_unique,
runs_later_exclusion, split,
};
use super::{Grants, Report};
const PROBE: &str = "__moat_probe__";
const MAX_ENTRIES: usize = 1_000_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mount {
ReadOnly(String),
ReadWrite(String),
Tmpfs(String),
Hide { path: String, dir: bool },
}
#[derive(Debug, Clone)]
pub struct Generated {
pub rules: Rules,
pub mounts: Vec<Mount>,
pub placeholders: Vec<String>,
pub report: Report,
}
pub fn generate(ir: &Enforcement, grants: &Grants, project: &[String]) -> Result<Generated> {
let checker = ir.checker()?;
let landlock::Generated { rules, mut report } = landlock::generate(ir, grants)?;
let mut unenforced = Vec::new();
for allowance in report
.allowances
.iter()
.filter(|a| a.rule == INSIDE_GRANTS_ALLOWANCE)
{
for pattern in &allowance.patterns {
push_unique(&mut unenforced, pattern.clone());
}
}
let replaced = [INSIDE_GRANTS_ALLOWANCE, TCP_PORT_ALLOWANCE];
report
.allowances
.retain(|a| !replaced.contains(&a.rule.as_str()));
if !unenforced.is_empty() {
let patterns = unenforced;
let message = "hidden, or read-only for writes, where they exist when the agent starts, \
and `.env`, `.envrc` and `.moat` directly in the project even when \
missing; a match created later stays open, and the hook still decides it";
report.allowance(Kind::FsRead, "bwrap.created-later", patterns, message);
}
let mut trees = Vec::new();
for path in rules.read.iter().filter(|p| !p.starts_with("/dev/")) {
trees.push(Mount::ReadOnly(path.clone()));
}
for path in rules.write.iter().filter(|p| !p.starts_with("/dev/")) {
trees.retain(|m| m != &Mount::ReadOnly(path.clone()));
let tmp = grants.tmpdir.as_deref() == Some(path.as_str());
trees.push(if tmp {
Mount::Tmpfs(path.clone())
} else {
Mount::ReadWrite(path.clone())
});
}
trees.sort_by_key(|m| depth(path_of(m)));
let real = project.first().map(String::as_str);
let mut masks = Masks {
checker: &checker,
project: real,
mounts: Vec::new(),
entries: 0,
};
if let Some(real) = real {
masks.walk(real, false)?;
}
let mut placeholders = Vec::new();
for name in denied_names(ir) {
let Some(real) = real else { break };
let path = format!("{real}/{name}");
let missing = std::fs::symlink_metadata(&path).is_err();
if missing && (masks.denied(Kind::FsRead, &path) || masks.denied_below(&path)) {
placeholders.push(path.clone());
masks.mounts.push(Mount::Hide { path, dir: false });
}
}
masks.mounts.extend(literal_masks(ir, &trees, real));
let mut mounts = trees;
mounts.extend(respelled(&masks.mounts, project));
Ok(Generated {
rules,
mounts,
placeholders,
report,
})
}
pub fn grant_masks(ir: &Enforcement, rules: &Rules, project: &[String]) -> Vec<Mount> {
let trees = |paths: &[String], mount: fn(String) -> Mount| {
paths
.iter()
.filter(|p| !p.starts_with("/dev/"))
.map(|p| mount(p.clone()))
.collect::<Vec<_>>()
};
let mut held = trees(&rules.read, Mount::ReadOnly);
for tree in trees(&rules.write, Mount::ReadWrite) {
held.retain(|m| path_of(m) != path_of(&tree));
held.push(tree);
}
held.sort_by_key(|m| depth(path_of(m)));
literal_masks(ir, &held, project.first().map(String::as_str))
}
pub fn project_masks(ir: &Enforcement, project: &[String]) -> Result<Vec<Mount>> {
let Some(real) = project.first() else {
return Ok(Vec::new());
};
let checker = ir.checker()?;
let mut masks = Masks {
checker: &checker,
project: Some(real),
mounts: Vec::new(),
entries: 0,
};
for folder in agent_config_holders() {
let path = format!("{real}/{folder}");
if is_directory(&path) {
push_mount(&mut masks.mounts, Mount::ReadWrite(path));
}
}
for entry in AGENT_CONFIG {
let rest = entry.strip_prefix("**/").unwrap_or(entry);
let path = format!("{real}/{}", rest.strip_suffix("/**").unwrap_or(rest));
if std::fs::symlink_metadata(&path).is_ok() {
push_mount(&mut masks.mounts, Mount::ReadOnly(path));
}
}
for rest in nested_write_denials(ir) {
let mut paths = folders_above(&rest);
paths.push(rest);
for path in paths.iter().map(|p| format!("{real}/{p}")) {
if let Ok(meta) = std::fs::symlink_metadata(&path) {
masks.pin(&path, meta.is_dir());
}
}
}
let mut mounts = masks.mounts;
mounts.sort_by_key(|m| depth(path_of(m)));
Ok(respelled(&mounts, project))
}
fn nested_write_denials(ir: &Enforcement) -> Vec<String> {
let mut found = Vec::new();
for pattern in ir.fs.write.deny.iter().flat_map(|r| split(&r.patterns).0) {
let Some(rest) = pattern.strip_prefix("**/") else {
continue;
};
let rest = rest.strip_suffix("/**").unwrap_or(rest);
if rest.contains('/') && !has_glob(rest) {
push_unique(&mut found, rest.to_owned());
}
}
found
}
fn agent_config_holders() -> impl Iterator<Item = String> {
AGENT_CONFIG.iter().flat_map(|entry| folders_above(entry))
}
fn is_directory(path: &str) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir())
}
fn push_mount(mounts: &mut Vec<Mount>, mount: Mount) {
if !mounts.iter().any(|m| path_of(m) == path_of(&mount)) {
mounts.push(mount);
}
}
fn literal_masks(ir: &Enforcement, trees: &[Mount], real: Option<&str>) -> Vec<Mount> {
let mut masks = Vec::new();
for (path, kind, subtree) in denied_literals(ir) {
let in_project = real.is_some_and(|p| path == p || is_below(&path, p));
let holder = trees.iter().rev().find(|m| {
let tree = path_of(m);
path == tree || is_below(&path, tree)
});
let Ok(meta) = std::fs::symlink_metadata(&path) else {
continue;
};
let dir = meta.is_dir();
let mask = match (holder, kind) {
_ if in_project => None,
(Some(Mount::ReadOnly(_) | Mount::ReadWrite(_)), Kind::FsRead) => {
Some(Mount::Hide { path, dir })
}
(Some(Mount::ReadWrite(_)), _) if dir && !subtree => Some(Mount::ReadWrite(path)),
(Some(Mount::ReadWrite(_)), _) => Some(Mount::ReadOnly(path)),
_ => None,
};
masks.extend(mask);
}
masks
}
pub fn args(mounts: &[Mount], blank_file: &str, blank_dir: &str) -> Vec<String> {
let mut args = Vec::new();
for mount in mounts {
let (flag, from, to) = match mount {
Mount::ReadOnly(p) => ("--ro-bind-try", p.as_str(), p.as_str()),
Mount::ReadWrite(p) => ("--bind-try", p.as_str(), p.as_str()),
Mount::Tmpfs(p) => {
args.extend(["--tmpfs".to_owned(), p.clone()]);
continue;
}
Mount::Hide { path, dir } => {
let blank = if *dir { blank_dir } else { blank_file };
("--ro-bind", blank, path.as_str())
}
};
args.extend([flag.to_owned(), from.to_owned(), to.to_owned()]);
}
args
}
fn path_of(mount: &Mount) -> &str {
match mount {
Mount::ReadOnly(p) | Mount::ReadWrite(p) | Mount::Tmpfs(p) => p,
Mount::Hide { path, .. } => path,
}
}
fn depth(path: &str) -> usize {
path.split('/').filter(|c| !c.is_empty()).count()
}
fn respelled(masks: &[Mount], project: &[String]) -> Vec<Mount> {
let Some((real, others)) = project.split_first() else {
return Vec::new();
};
let mut all = masks.to_vec();
for other in others.iter().filter(|o| *o != real) {
for mount in masks {
let Some(rest) = path_of(mount).strip_prefix(real.as_str()) else {
continue;
};
let path = format!("{other}{rest}");
all.push(match mount {
Mount::ReadOnly(_) => Mount::ReadOnly(path),
Mount::ReadWrite(_) => Mount::ReadWrite(path),
Mount::Tmpfs(_) => Mount::Tmpfs(path),
Mount::Hide { dir, .. } => Mount::Hide { path, dir: *dir },
});
}
}
all
}
fn denied_names(ir: &Enforcement) -> Vec<String> {
let mut names = Vec::new();
for pattern in deny_patterns(ir) {
let name = pattern
.strip_prefix("**/")
.map(|n| n.strip_suffix("/**").unwrap_or(n));
if let Some(name) = name.filter(|n| !n.is_empty() && !n.contains('/') && !has_glob(n)) {
push_unique(&mut names, name.to_owned());
}
}
names
}
fn denied_literals(ir: &Enforcement) -> Vec<(String, Kind, bool)> {
let mut found: Vec<(String, Kind, bool)> = Vec::new();
for (access, kind) in [(&ir.fs.read, Kind::FsRead), (&ir.fs.write, Kind::FsWrite)] {
for pattern in denials(access).filter(|p| p.starts_with('/')) {
let Some(path) = literal_tree(pattern) else {
continue;
};
if !found
.iter()
.any(|(p, k, _)| p == path && *k == Kind::FsRead)
{
found.retain(|(p, ..)| p != path);
found.push((path.to_owned(), kind, pattern.ends_with("/**")));
}
}
}
found.sort_by_key(|(path, ..)| depth(path));
found
}
fn deny_patterns(ir: &Enforcement) -> impl Iterator<Item = &str> {
[&ir.fs.read, &ir.fs.write].into_iter().flat_map(denials)
}
fn denials(access: &Access) -> impl Iterator<Item = &str> {
let denied = access.deny.iter().flat_map(|r| split(&r.patterns).0);
let excepted = access.allow.iter().flat_map(|r| split(&r.patterns).1);
denied.chain(excepted.filter(|p| !runs_later_exclusion(p)))
}
struct Masks<'a> {
checker: &'a Checker,
project: Option<&'a str>,
mounts: Vec<Mount>,
entries: usize,
}
impl Masks<'_> {
fn denied(&self, kind: Kind, path: &str) -> bool {
let atom = match kind {
Kind::FsWrite => AtomicAction::FsWrite {
path: path.to_owned(),
},
_ => AtomicAction::FsRead {
path: path.to_owned(),
},
};
self.checker.check_os(&atom) != Some(Effect::Allow)
}
fn denied_below(&self, dir: &str) -> bool {
self.denied(Kind::FsWrite, &format!("{dir}/{PROBE}"))
}
fn classify(&mut self, path: &str, dir: bool, read_only_above: bool) -> bool {
let below = |kind| dir && self.denied(kind, &format!("{path}/{PROBE}"));
if self.denied(Kind::FsRead, path) || below(Kind::FsRead) {
self.mounts.push(Mount::Hide {
path: path.to_owned(),
dir,
});
return false;
}
if read_only_above {
return dir;
}
self.pin(path, dir);
let holder = dir
&& self
.project
.is_some_and(|root| agent_config_holders().any(|f| path == format!("{root}/{f}")));
if holder {
push_mount(&mut self.mounts, Mount::ReadWrite(path.to_owned()));
}
dir
}
fn pin(&mut self, path: &str, dir: bool) {
if dir && self.denied_below(path) {
push_mount(&mut self.mounts, Mount::ReadOnly(path.to_owned()));
} else if self.denied(Kind::FsWrite, path) {
let pin = if dir {
Mount::ReadWrite
} else {
Mount::ReadOnly
};
push_mount(&mut self.mounts, pin(path.to_owned()));
}
}
fn walk(&mut self, dir: &str, read_only: bool) -> Result<()> {
let mut entries: Vec<_> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
self.entries += 1;
if self.entries > MAX_ENTRIES {
bail!(
"the project has more than {MAX_ENTRIES} files and directories; `openmoat run \
--isolate` looks at each to hide what the policy denies"
);
}
let is_dir = entry.file_type()?.is_dir();
let Ok(name) = entry.file_name().into_string() else {
bail!(
"{dir} holds a name that is not UTF-8, which `openmoat run --isolate` cannot mount over"
);
};
let path = format!("{dir}/{name}");
let read_only_here = read_only || (is_dir && self.denied_below(&path));
if self.classify(&path, is_dir, read_only) {
self.walk(&path, read_only_here)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests;