use std::path::PathBuf;
use std::time::Instant;
use anyhow::{Context as _, Result};
use openmoat_audit::{KnownSecrets, NewEvent, Store, Ticket};
use openmoat_core::{Action, CompiledPolicy, Decision, Verdict};
use openmoat_hosts::Host;
use serde_json::Value;
use crate::environment::{Snapshot, WithOwnBinary};
use crate::home::Home;
use crate::mcp::relay::{Call, Gate, Method};
use crate::realpath::FsPathResolver;
use crate::{context, integrity, secrets};
const AUDIT_HOST: &str = "mcp-proxy";
const PASSED_RULE: &str = "mcp-proxy";
const HELD_RULE: &str = "mcp-tool-held";
const UNATTRIBUTED_RULE: &str = "mcp-unattributed";
const SERVER_REQUEST_RULE: &str = "mcp-server-request";
pub struct Gateway {
home: Home,
server: String,
cwd: PathBuf,
session: String,
}
struct Outcome {
action: Action,
decision: Decision,
ticket: Option<Ticket>,
cwd: Option<String>,
known: KnownSecrets,
}
impl Gateway {
pub fn new(home: Home, server: &str) -> Result<Self> {
Ok(Self {
home,
server: server.to_owned(),
cwd: std::env::current_dir().context("reading the working directory")?,
session: format!(
"mcp-{server}-{}-{}",
std::process::id(),
crate::time::now_ms()
),
})
}
fn name(&self, call: &Call<'_>) -> String {
match call.method {
Method::Tool | Method::Prompt => format!("mcp__{}__{}", self.server, call.name),
Method::Resource => format!("mcp__{}__read_resource", self.server),
}
}
fn decide(&self, call: &Call<'_>, store: &Store) -> Result<Outcome> {
let name = self.name(call);
let none = Value::Null;
let action = match call.method {
Method::Tool => {
openmoat_hosts::mcp_call_action(&name, call.arguments.unwrap_or(&none))?
}
Method::Resource => openmoat_hosts::mcp_resource_action(&self.server, call.name)?,
Method::Prompt => {
let decision = Decision::single(Verdict::Allow, PASSED_RULE, "a prompt, recorded");
return Ok(self.outcome(Action::mcp(name), decision, None, None));
}
};
let ticket = match call.method {
Method::Tool => self.take_ticket(store, call)?,
_ => None,
};
let cwd = match &ticket {
Some(t) => store.get(t.event_id)?.and_then(|e| e.cwd),
None => None,
};
let (decided, known) = self.policy(&action, cwd.as_deref())?;
let decision = match (&ticket, call.method) {
_ if decided.verdict == Verdict::Deny => decided,
(None, Method::Tool) => Decision::single(
Verdict::Deny,
UNATTRIBUTED_RULE,
"OpenMoat's hook made no decision on this exact call (the same tool and \
arguments, within the hour): the host did not run the hook for it, or sent \
other arguments than it checked, so it is not passed on",
),
(Some(t), _) => Decision::single(
Verdict::Allow,
PASSED_RULE,
format!(
"passed to the server: the hook decided `{}` for this call (event {})",
t.verdict, t.event_id.0
),
),
(None, _) => Decision::single(Verdict::Allow, PASSED_RULE, "passed to the server"),
};
Ok(Outcome {
action,
decision,
ticket,
cwd,
known,
})
}
fn take_ticket(&self, store: &Store, call: &Call<'_>) -> Result<Option<Ticket>> {
let arguments = openmoat_hosts::mcp_call_arguments(call.arguments);
let digest = integrity::sha256_hex(arguments.as_bytes());
let (server, tool) = (self.server.as_str(), call.name);
Ok(store.take_mcp_ticket(&digest, |t| {
t.host
.parse::<Host>()
.is_ok_and(|host| names_tool(host, &t.tool, server, tool))
})?)
}
fn policy(&self, action: &Action, cwd: Option<&str>) -> Result<(Decision, KnownSecrets)> {
let verified = integrity::check(&self.home)?;
if let Some(deny) = verified.checked.deny {
return Ok((deny, self.known()));
}
let home = verified.home;
let cwd = cwd.map_or_else(|| self.cwd.clone(), PathBuf::from);
let ctx = context::eval_context(&home, Some(&cwd), None)?;
let policy = crate::repo::effective_policy(&home, &ctx)?;
let compiled = CompiledPolicy::compile(&policy, &ctx)?;
let snapshot = Snapshot::load_in(&home)?;
let programs = WithOwnBinary {
snapshot: &snapshot,
binary: &verified.lock.binary,
};
let known = secrets::known(&policy.secrets, &ctx.home)?;
Ok((
compiled.decide_with(action, &programs, &FsPathResolver),
known,
))
}
fn known(&self) -> KnownSecrets {
let Ok(policy) = self.home.load_policy() else {
return KnownSecrets::default();
};
context::home_spellings()
.and_then(|(home, _)| secrets::known(&policy.secrets, &home))
.unwrap_or_default()
}
fn outcome(
&self,
action: Action,
decision: Decision,
ticket: Option<Ticket>,
cwd: Option<String>,
) -> Outcome {
Outcome {
action,
decision,
ticket,
cwd,
known: self.known(),
}
}
fn store(&self) -> Result<Store> {
Store::open_existing(&self.home.audit_path()).context("audit log unavailable")
}
fn record(&self, store: Store, tool: &str, outcome: Outcome, started: Instant) -> Result<()> {
let store = store.with_secrets(outcome.known);
let ticket = outcome.ticket.as_ref();
let latency_us = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX);
store.record(&NewEvent {
host: AUDIT_HOST,
session_id: ticket.map_or(self.session.as_str(), |t| t.session_id.as_str()),
call_id: ticket.and_then(|t| t.call_id.as_deref()),
cwd: outcome.cwd.as_deref(),
tool,
action: Some(&outcome.action),
decision: &outcome.decision,
latency_us,
})?;
Ok(())
}
}
impl Gate for Gateway {
fn check(&self, call: &Call<'_>, held: Option<&str>) -> Result<Option<String>> {
let started = Instant::now();
let store = self.store()?;
let name = self.name(call);
let outcome = match held {
Some(why) => self.outcome(
Action::mcp(name.clone()),
Decision::single(Verdict::Deny, HELD_RULE, why),
None,
None,
),
None => self.decide(call, &store)?,
};
let refused = match outcome.decision.verdict {
Verdict::Deny => Some(refusal(&outcome.decision)),
_ => None,
};
self.record(store, &name, outcome, started)?;
Ok(refused)
}
fn server_request(&self, method: &str) -> Result<()> {
let store = self.store()?.with_secrets(self.known());
let decision = Decision::single(
Verdict::Allow,
SERVER_REQUEST_RULE,
format!("MCP server `{}` asked the agent: {method}", self.server),
);
store.record(&NewEvent {
host: AUDIT_HOST,
session_id: &self.session,
call_id: None,
cwd: None,
tool: method,
action: None,
decision: &decision,
latency_us: 0,
})?;
Ok(())
}
}
fn refusal(decision: &Decision) -> String {
let rules = decision.rules.join(", ");
let reasons = decision.reasons.join("; ");
format!("OpenMoat refused this call [{rules}]: {reasons}")
}
pub(super) fn names_tool(host: Host, hook_name: &str, server: &str, tool: &str) -> bool {
if tool.is_empty() || !crate::mcp::tool_of(host, hook_name, server) {
return false;
}
let spelled = |keep: fn(char) -> bool| -> String {
tool.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || keep(c) {
c
} else {
'_'
}
})
.collect()
};
let spellings = [
tool.to_owned(),
spelled(|c| c == '_' || c == '-'),
spelled(|c| c == '_'),
];
let joiner = if hook_name.starts_with("mcp__") {
"__"
} else {
"-"
};
spellings
.iter()
.any(|t| hook_name.ends_with(&format!("{joiner}{t}")))
}
#[cfg(test)]
mod tests;