use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::path::Path;
use anyhow::{Context as _, Result, bail};
use openmoat_core::{Action, CompiledPolicy, Decision, Taint, Verdict};
use openmoat_hosts::{HookEvent, Host};
use crate::approvals::{self, Grants};
use crate::context;
use crate::home::Home;
use crate::realpath::FsPathResolver;
const TAINT_RULE: &str = "session-taint";
#[derive(Debug, Default)]
pub struct SessionTaint {
pub taint: Taint,
pub untrusted_last_ms: Option<i64>,
}
pub fn of_session(
home: &Home,
host: Host,
session: &str,
policy: &CompiledPolicy<'_>,
cwd: &str,
) -> Result<SessionTaint> {
let events = home
.open_audit()?
.session(session)
.context("reading this session from the audit log")?;
let mut out = SessionTaint::default();
let mut seen: HashMap<(String, String), Taint> = HashMap::new();
for event in events
.iter()
.filter(|e| e.host == host.id() && ran(host, e.verdict))
{
if event.action_unreadable {
bail!("audit event {} of this session cannot be decoded", event.id);
}
let Some(action) = &event.action else {
continue;
};
let cwd = event
.cwd
.as_deref()
.map_or_else(|| cwd.to_owned(), |c| context::path_string(Path::new(c)));
let key = (serde_json::to_string(action)?, cwd);
let exposure = match seen.entry(key) {
Entry::Occupied(known) => known.get().clone(),
Entry::Vacant(slot) => {
let exposure = policy.exposure(action, &slot.key().1, &FsPathResolver);
slot.insert(exposure).clone()
}
};
if exposure.untrusted.is_some() {
out.untrusted_last_ms = out.untrusted_last_ms.max(Some(event.ts_ms));
}
out.taint.absorb(exposure);
}
Ok(out)
}
pub struct Call<'a> {
pub home: &'a Home,
pub host: Host,
pub session: &'a str,
pub action: &'a Action,
pub cwd: &'a str,
pub user_home: &'a str,
}
pub fn answered(
call: &Call<'_>,
policy: &CompiledPolicy<'_>,
session: &SessionTaint,
untainted: Decision,
tainted: Decision,
now_ms: i64,
) -> Result<Decision> {
let Some(last_read) = session.untrusted_last_ms else {
return Ok(tainted);
};
if tainted.verdict != Verdict::Ask || !tainted.rules.iter().any(|r| r == TAINT_RULE) {
return Ok(tainted);
}
let secrets_only = Taint {
secrets: session.taint.secrets.clone(),
untrusted: None,
};
let with_secrets = policy.with_taint(
untainted.clone(),
call.action,
&FsPathResolver,
&secrets_only,
);
if with_secrets.rules.iter().any(|r| r == TAINT_RULE) {
return Ok(tainted);
}
let granted_at = approvals::approvable(call.action, Some(call.cwd), call.user_home)
.map(|asked| {
Grants::load(&call.home.grants_path())
.map(|g| g.granted_at(call.host.id(), call.session, &asked, now_ms))
})
.transpose()?
.flatten();
if let Some(at) = granted_at
&& at > last_read
{
let mut decision = untainted;
decision.reasons.push(
"approved with `moat allow` after the session last read untrusted content".to_owned(),
);
return Ok(decision);
}
let mut decision = tainted;
if call.host == Host::ClaudeCode {
decision.reasons.push(
"to stop asking for this in this session, run `moat allow --last` in a terminal"
.to_owned(),
);
}
Ok(decision)
}
fn ran(host: Host, verdict: Verdict) -> bool {
host.answer(&HookEvent::PreToolUse, &Decision::new(verdict))
.verdict
!= Verdict::Deny
}