use std::io::Write as _;
use std::path::Path;
use anyhow::{Context as _, Result, bail, ensure};
use openmoat_audit::Event;
use openmoat_core::{Action, PathResolver as _, RuleGroup, Verdict};
use crate::approvals::{self, GRANT_TTL_MS, Grants, Overlay};
use crate::cli::AllowArgs;
use crate::context::{absolute, path_string};
use crate::exit::Code;
use crate::home::Home;
use crate::integrity::{self, HookPins};
use crate::realpath::FsPathResolver;
use crate::render::Deferred;
const LAST_ASK_SEARCH: usize = 200;
pub fn run(args: &AllowArgs) -> Result<Code> {
let home = checked_home()?;
let mut out = Deferred::default();
if args.site.is_some() || args.dir.is_some() || args.remove.is_some() {
change_overlay(&home, args, &mut out)?;
return repin(&home, out);
}
let (host, session, action) = match (&args.command, args.last) {
(Some(command), false) => (
args.host.map(|h| h.id().to_owned()),
args.session.clone(),
Action::Shell {
command: command.trim().to_owned(),
},
),
(None, true) => {
let event = newest_ask(&home.open_audit()?)?
.context("no recent `ask` for a shell command or a file in the audit log")?;
let action = asked(&event)?;
writeln!(
out,
"last ask: {} on {} (session {}) wanted to {}",
event.id,
event.host,
event.session_id,
approvals::describe(&action)
)?;
(Some(event.host), Some(event.session_id), action)
}
_ => bail!("give a command, or --last to take the most recent ask"),
};
approve(&home, host, session, &action, args.always, out)
}
pub(super) fn run_for(host: &str, session: &str, action: &Action, always: bool) -> Result<Code> {
let home = checked_home()?;
let (host, session) = (Some(host.to_owned()), Some(session.to_owned()));
approve(&home, host, session, action, always, Deferred::default())
}
fn checked_home() -> Result<Home> {
if !crate::terminal::interactive() {
bail!("`moat allow` must be run by a person in a terminal, not from a hook or script");
}
let home = Home::locate()?;
if !home.exists() {
bail!("{} does not exist; run `moat init`", home.root().display());
}
if home.lock_path().exists()
&& let Some(deny) = integrity::violation(&home)?
{
bail!(
"refusing to approve while the policy lock shows drift ({}):\n {}",
integrity::INTEGRITY_RULE,
deny.reasons.join("\n ")
);
}
Ok(home)
}
fn approve(
home: &Home,
host: Option<String>,
session: Option<String>,
action: &Action,
always: bool,
mut out: Deferred,
) -> Result<Code> {
if always {
let path = home.overlay_path();
let mut overlay = Overlay::load(&path)?;
let rule = if let Action::Shell { command } = action {
overlay.allow_command(command)?.clone()
} else {
let (reads, writes) = approvals::files(action);
overlay
.allow_files(&spellings(reads), &spellings(writes))?
.clone()
};
writeln!(out, "{}", will_allow(&rule))?;
overlay.save(&path)?;
if let Action::Shell { command } = action {
writeln!(
out,
"✔ permanent rule {} allows shell \"{command}\"",
rule.id
)?;
} else {
writeln!(out, "✔ added to {}:", path.display())?;
for line in serde_yaml_ng::to_string(&[&rule])?.lines() {
writeln!(out, " {line}")?;
}
writeln!(
out,
" `moat allow --dir <dir>` allows a whole directory, `moat edit` any pattern"
)?;
}
writeln!(out, " undo: moat allow --remove {}", rule.id)?;
warn_if_shadowed(home, &rule.id, &mut out)?;
} else {
let session = session.context(
"give --host and --session for a session grant, --always for a permanent rule, or --last",
)?;
let host = host.context("--session needs --host")?;
let path = home.grants_path();
let mut grants = Grants::load(&path)?;
let now = crate::time::now_ms();
grants.grant(&host, &session, action, now);
grants.save(&path, now)?;
writeln!(
out,
"✔ session {session} on {host} may {} for {}",
approvals::describe(action),
crate::time::duration(GRANT_TTL_MS)
)?;
let resume = if host == openmoat_hosts::Host::ClaudeCode.id() {
" (`claude --continue` resumes this one)"
} else {
""
};
writeln!(
out,
" only in this agent session: a new session asks again{resume}"
)?;
}
repin(home, out)
}
fn will_allow(rule: &RuleGroup) -> String {
let what = if rule.shell.is_empty() {
let files: Vec<String> = [("read", &rule.fs_read), ("write", &rule.fs_write)]
.iter()
.filter(|(_, paths)| !paths.is_empty())
.map(|(verb, paths)| format!("{verb} exactly {}", paths.join(", ")))
.collect();
format!("{} (no other path)", files.join(" and "))
} else {
format!(
"{} (and the same command with extra arguments)",
rule.shell.join(", ")
)
};
format!("will allow: {what}; deny rules still win")
}
fn spellings(paths: &[String]) -> Vec<String> {
let mut out = paths.to_vec();
for real in paths.iter().filter_map(|p| FsPathResolver.resolve(p)) {
if !out.contains(&real) {
out.push(real);
}
}
out
}
fn repin(home: &Home, mut out: Deferred) -> Result<Code> {
let binary = crate::install::hook_binary()?;
let lock = integrity::repin(home, &binary, HookPins::Keep)?;
writeln!(out, "✔ lock re-pinned ({} files)", lock.entries.len())?;
out.finish()?;
Ok(Code::Ok)
}
fn change_overlay(home: &Home, args: &AllowArgs, out: &mut Deferred) -> Result<()> {
let path = home.overlay_path();
let before = Overlay::load(&path)?;
let mut overlay = before.clone();
let (verb, rule) = if let Some(id) = &args.remove {
let ids: Vec<String> = overlay.allow.iter().map(|g| g.id.clone()).collect();
let rule = overlay.remove(id).with_context(|| {
format!(
"no rule `{id}` in {}; rules there: {}",
path.display(),
if ids.is_empty() {
"none".to_owned()
} else {
ids.join(", ")
}
)
})?;
("removed from", rule)
} else if let Some(host) = &args.site {
("added to", overlay.allow_site(host)?.clone())
} else {
let dir = args
.dir
.as_deref()
.context("give --site, --dir or --remove")?;
("added to", overlay.allow_dir(&dir_spellings(dir)?).clone())
};
overlay.save(&path)?;
if let Err(error) = home.load_policy() {
before.save(&path)?;
return Err(error.context(format!("{} left unchanged", path.display())));
}
writeln!(out, "✔ {verb} {}:", path.display())?;
for line in serde_yaml_ng::to_string(&[&rule])?.lines() {
writeln!(out, " {line}")?;
}
if args.remove.is_none() {
if args.dir.is_some() {
writeln!(
out,
" deny rules still win: secrets and kernel files in it stay denied"
)?;
}
writeln!(out, " undo: moat allow --remove {}", rule.id)?;
warn_if_shadowed(home, &rule.id, out)?;
}
Ok(())
}
fn dir_spellings(dir: &Path) -> Result<Vec<String>> {
let written = absolute(dir)?;
let real = std::fs::canonicalize(&written)
.with_context(|| format!("{} does not exist", written.display()))?;
ensure!(real.is_dir(), "{} is not a directory", written.display());
ensure!(
crate::project::trusted(&written, &crate::home::user_home()?),
"refusing to allow {}: it is the home directory, one of its ancestors or a \
filesystem root; name a directory inside it",
written.display()
);
let mut spellings = vec![path_string(&written)];
let real = path_string(&real);
if real != spellings[0] {
spellings.push(real);
}
ensure!(
!spellings
.iter()
.any(|s| s.contains(['*', '?', '[', ']', '{', '}', '\\'])),
"{} contains glob characters; write it in the policy with `moat edit` instead",
written.display()
);
Ok(spellings)
}
fn warn_if_shadowed(home: &Home, id: &str, out: &mut Deferred) -> Result<()> {
let Ok(policy) = home.load_policy() else {
return Ok(());
};
for warning in openmoat_core::lint::warnings(&policy) {
if warning.rule == id {
writeln!(out, "warning: {warning}")?;
}
}
Ok(())
}
pub(super) fn asked(event: &Event) -> Result<Action> {
let cwd = match event.cwd.as_deref() {
Some(cwd) => Some(path_string(&absolute(Path::new(cwd))?)),
None => None,
};
let home = path_string(&crate::home::user_home()?);
event
.action
.as_ref()
.and_then(|a| approvals::approvable(a, cwd.as_deref(), &home))
.with_context(|| {
format!(
"ask {} names no file `moat allow` can approve (none, or a relative path \
without a working directory); allow it with `moat edit` instead",
event.id
)
})
}
pub(super) fn newest_ask(store: &openmoat_audit::Store) -> Result<Option<Event>> {
Ok(store.recent(LAST_ASK_SEARCH)?.into_iter().find(|e| {
e.verdict == Verdict::Ask
&& matches!(
e.action,
Some(
Action::Shell { .. }
| Action::FsRead { .. }
| Action::FsWrite { .. }
| Action::Patch { .. }
| Action::ReadFiles { .. }
)
)
}))
}