blob: c6a44f71c5a9e5d91b7cef0dd1c403221fbce4c8 [file]
//! Tool taps: a component an agent calls mid-turn, fetch or search, through the memory
//! server, with every request it makes decided by the policy.
//!
//! Where a tap proper checks its grant's hosts in the host here, a tool asks the
//! policy's `fetch` action for every request, every redirect hop a request of its own:
//! the scheme, the method, the host against the tool's grant, whether the name
//! resolves inward, and the calling session's label, since a URL or a query is data
//! leaving. Each decision is a line in the decision log. A credential the grant names
//! goes on the request after the decision, in the header the grant names, so the tool
//! never holds it. The request then connects to the addresses the policy was shown, not
//! to whatever the name answers a moment later ([`crate::pinned`]), so a name that
//! answers outward to the check and inward to the connect gets nowhere.
//!
//! Who decides is a [`Gate`]: in the launcher, the calling session's policy
//! ([`PolicyGate`]); in a tool server, which serves the same components to launchers
//! elsewhere and has no session of its own, the hosts the caller passed, inside the
//! server's own grant ([`HostGate`]). Either way every request the gate allows is
//! recorded, so the answer can say where it was fetched from.
use crate::{engine, Error, Limits, EPOCH_TICK};
use dm_policy::decide::FetchInput;
use dm_policy::TapGrant;
pub use dm_server::sources::Answered;
use dm_server::Memory;
use std::future::Future;
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::Store;
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
use wasmtime_wasi_http::{
RequestOptions, WasiBody, WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView,
};
type HttpError = wasmtime_wasi_http::Error;
type SendResult = Result<
(
http::Response<WasiBody>,
Box<dyn Future<Output = Result<(), HttpError>> + Send>,
),
HttpError,
>;
/// Whether an address is this machine's network turned inward: loopback, private,
/// link-local (the cloud metadata address among them), shared, unspecified.
#[must_use]
pub fn is_inward(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v) => {
let o = v.octets();
v.is_private()
|| v.is_loopback()
|| v.is_link_local()
|| v.is_unspecified()
|| v.is_broadcast()
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
}
IpAddr::V6(v) => {
let first = v.segments()[0];
v.is_loopback()
|| v.is_unspecified()
|| (first & 0xfe00) == 0xfc00
|| (first & 0xffc0) == 0xfe80
|| v.to_ipv4_mapped().is_some_and(|m| is_inward(IpAddr::V4(m)))
}
}
}
/// What a host resolves to, once: the addresses the request will connect to, whether
/// any is inward, and whether all are loopback. A name that does not resolve has no
/// address, is neither, and its request goes nowhere.
async fn resolves(host: &str, port: u16) -> (Vec<SocketAddr>, bool, bool) {
let addrs: Vec<SocketAddr> = match host.parse::<IpAddr>() {
Ok(ip) => vec![SocketAddr::new(ip, port)],
Err(_) => tokio::net::lookup_host((host, port))
.await
.map(Iterator::collect)
.unwrap_or_default(),
};
if addrs.is_empty() {
return (addrs, false, false);
}
let inward = addrs.iter().any(|a| is_inward(a.ip()));
let loopback = addrs.iter().all(|a| a.ip().is_loopback());
(addrs, inward, loopback)
}
/// Decides each request a tool makes: `Ok` lets it go, `Err` says why not.
pub trait Gate: Send + Sync {
/// Decide one request.
///
/// # Errors
///
/// The reason the request may not go.
fn decide(&self, ask: FetchInput) -> Result<(), String>;
}
/// The calling session's policy, its `fetch` action at the session's label.
pub struct PolicyGate(pub Arc<Memory>);
impl Gate for PolicyGate {
fn decide(&self, ask: FetchInput) -> Result<(), String> {
match self.0.check_fetch(ask) {
Ok(d) if d.allow => Ok(()),
Ok(d) => Err(d.reason),
Err(e) => Err(format!("the policy failed: {e}")),
}
}
}
/// A tool server's gate: the policy's hard rules (TLS, the granted methods, nothing
/// inward), and a host only when both the server's own grant and the caller name it,
/// so a caller can narrow what the server may reach and never widen it.
#[derive(Clone, Debug, Default)]
pub struct HostGate {
/// The hosts the server's grant names, the trusted ones included.
pub granted: Vec<String>,
/// The hosts the caller's session may reach at its label.
pub caller: Vec<String>,
/// The methods the grant names; `GET` when empty.
pub methods: Vec<String>,
/// Whether the grant lets the tool reach this machine's loopback, for development.
pub loopback: bool,
}
/// Whether `host` is one a pattern names: exactly, or under it for `*.example.org`.
#[must_use]
pub fn host_matches(pattern: &str, host: &str) -> bool {
let p = pattern.to_ascii_lowercase();
let host = host.to_ascii_lowercase();
match p.strip_prefix('*') {
Some(suffix) if suffix.starts_with('.') => host.ends_with(suffix),
_ => p == host,
}
}
impl Gate for HostGate {
fn decide(&self, ask: FetchInput) -> Result<(), String> {
let loopback_ok = self.loopback && ask.loopback_address;
if ask.scheme != "https" && !loopback_ok {
return Err(format!(
"{} to {} would leave in the clear; https only",
ask.scheme, ask.host
));
}
if ask.private_address && !loopback_ok {
return Err(format!(
"{} resolves to a private or loopback address; a tool does not reach inward",
ask.host
));
}
let method = ask.method.to_ascii_uppercase();
let allowed = if self.methods.is_empty() {
method == "GET"
} else {
self.methods.iter().any(|m| m.eq_ignore_ascii_case(&method))
};
if !allowed {
return Err(format!("{method} is not a method the tool is granted"));
}
let named = |hosts: &[String]| hosts.iter().any(|p| host_matches(p, &ask.host));
if !named(&self.granted) {
return Err(format!(
"{} is not a host this tool server is granted",
ask.host
));
}
if !named(&self.caller) {
return Err(format!(
"{} is not a host the calling session may reach",
ask.host
));
}
Ok(())
}
}
/// Ask `gate` about one request a tool wants to make: the host resolved once, the
/// policy's input made of the request, and, when it may go, the addresses it connects
/// to and the URL it is recorded as reaching. A refusal says which host and why.
///
/// # Errors
///
/// The gate's reason.
pub(crate) async fn admit(
gate: &dyn Gate,
tool: &str,
method: &str,
uri: &http::Uri,
hop: u32,
) -> Result<(Vec<SocketAddr>, String), String> {
let scheme = uri.scheme_str().unwrap_or("https").to_ascii_lowercase();
let host = uri
.host()
.unwrap_or_default()
.trim_matches(['[', ']'])
.to_ascii_lowercase();
let port = uri.port_u16();
let default_port = if scheme == "http" { 80 } else { 443 };
let (addrs, private_address, loopback_address) =
resolves(&host, port.unwrap_or(default_port)).await;
let path = uri
.path_and_query()
.map_or_else(|| "/".to_string(), |p| p.as_str().to_string());
let ask = FetchInput {
tool: tool.to_string(),
method: method.to_string(),
scheme: scheme.clone(),
host: host.clone(),
port,
path: path.clone(),
private_address,
loopback_address,
label: dm_core::Label::Public,
hop,
};
gate.decide(ask).map_err(|why| format!("{host}: {why}"))?;
let port = port.map(|p| format!(":{p}")).unwrap_or_default();
Ok((addrs, format!("{scheme}://{host}{port}{path}")))
}
/// The outbound hook for a tool: the gate decides, then the credential goes on.
struct Hooks {
tool: String,
gate: Arc<dyn Gate>,
credential: Option<(http::HeaderName, http::HeaderValue)>,
refused: Arc<Mutex<Vec<String>>>,
reached: Arc<Mutex<Vec<String>>>,
hop: Arc<AtomicU32>,
}
impl WasiHttpHooks for Hooks {
fn send_request(
&mut self,
mut request: http::Request<WasiBody>,
options: Option<RequestOptions>,
_fut: Box<dyn Future<Output = Result<(), HttpError>> + Send>,
) -> Box<dyn Future<Output = SendResult> + Send> {
let gate = self.gate.clone();
let tool = self.tool.clone();
let credential = self.credential.clone();
let refused = self.refused.clone();
let reached = self.reached.clone();
let hop = self.hop.fetch_add(1, Ordering::Relaxed);
Box::new(async move {
let uri = request.uri().clone();
let method = request.method().as_str().to_string();
let addrs = match admit(gate.as_ref(), &tool, &method, &uri, hop).await {
Ok((addrs, url)) => {
reached
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(url);
addrs
}
Err(why) => {
refused
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(why);
return Err(HttpError::HttpRequestDenied);
}
};
if let Some((name, value)) = credential {
request.headers_mut().insert(name, value);
}
crate::pinned::send(request, options, &addrs).await
})
}
}
struct State {
wasi: WasiCtx,
http: WasiHttpCtx,
table: ResourceTable,
hooks: Hooks,
limits: wasmtime::StoreLimits,
}
impl WasiView for State {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
impl WasiHttpView for State {
fn http(&mut self) -> WasiHttpCtxView<'_> {
WasiHttpCtxView {
hooks: &mut self.hooks,
table: &mut self.table,
ctx: &mut self.http,
}
}
}
/// The header a grant's credential goes in, and its value: `Authorization: Bearer`
/// unless the grant names another header, which gets the value as it is.
pub(crate) fn credential_header(
grant: &TapGrant,
credential: Option<&str>,
) -> Result<Option<(http::HeaderName, http::HeaderValue)>, Error> {
let Some(c) = credential else {
return Ok(None);
};
let bad = |e: &dyn std::fmt::Display| Error::Runtime(format!("credential: {e}"));
Ok(Some(match grant.credential_header.as_deref() {
None => (
http::header::AUTHORIZATION,
http::HeaderValue::from_str(&format!("Bearer {c}")).map_err(|e| bad(&e))?,
),
Some(h) => (
http::HeaderName::from_bytes(h.as_bytes()).map_err(|e| bad(&e))?,
http::HeaderValue::from_str(c).map_err(|e| bad(&e))?,
),
}))
}
/// One call of a tool: which, under what grant, with what.
#[derive(Clone, Copy, Debug)]
pub struct Call<'a> {
/// The tool, by its name in the grants.
pub tool: &'a str,
/// Its grant.
pub grant: &'a TapGrant,
/// The value of the variable the grant names, if it was set.
pub credential: Option<&'a str>,
/// The owner's configuration, JSON.
pub config: &'a str,
/// The agent's arguments, JSON.
pub args: &'a str,
}
/// Call a tool once for the session `memory` is: the tool's JSON, and every URL the
/// policy let it reach.
///
/// # Errors
///
/// A component that will not load or link, a trap, the wall clock, a request the
/// policy refused that the tool did not survive, or the tool's own failure.
pub async fn call(
memory: Arc<Memory>,
component: &Path,
c: Call<'_>,
limits: Limits,
) -> Result<Answered, Error> {
call_gated(Arc::new(PolicyGate(memory)), component, c, limits).await
}
/// Call a tool once with `gate` deciding each request it makes.
///
/// # Errors
///
/// As [`call`].
pub async fn call_gated(
gate: Arc<dyn Gate>,
component: &Path,
c: Call<'_>,
limits: Limits,
) -> Result<Answered, Error> {
let engine = engine()?;
let component = Component::from_file(&engine, component)
.map_err(|e| Error::Runtime(format!("{}: {e:#}", component.display())))?;
let mut linker: Linker<State> = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
let refused = Arc::new(Mutex::new(Vec::new()));
let reached = Arc::new(Mutex::new(Vec::new()));
let state = State {
wasi: WasiCtxBuilder::new().build(),
http: WasiHttpCtx::new(),
table: ResourceTable::new(),
hooks: Hooks {
tool: c.tool.to_string(),
gate,
credential: credential_header(c.grant, c.credential)?,
refused: refused.clone(),
reached: reached.clone(),
hop: Arc::new(AtomicU32::new(0)),
},
limits: wasmtime::StoreLimitsBuilder::new()
.memory_size(limits.memory)
.build(),
};
let mut store = Store::new(&engine, state);
store.limiter(|s| &mut s.limits);
store.set_epoch_deadline(1);
store.epoch_deadline_async_yield_and_update(1);
let ticker = {
let engine = engine.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(EPOCH_TICK).await;
engine.increment_epoch();
}
})
};
let outcome = tokio::time::timeout(limits.wall, async {
let t =
crate::bindings::tool::Tool::instantiate_async(&mut store, &component, &linker).await?;
t.call_call(&mut store, c.config, c.args).await
})
.await;
ticker.abort();
let result = match outcome {
Ok(r) => r?,
Err(_) => {
return Err(Error::Runtime(format!(
"the call exceeded its wall clock of {:?}",
limits.wall
)))
}
};
let refused = std::mem::take(&mut *refused.lock().unwrap_or_else(PoisonError::into_inner));
let reached = std::mem::take(&mut *reached.lock().unwrap_or_else(PoisonError::into_inner));
match result {
Ok(answer) => Ok(Answered { answer, reached }),
Err(e) if refused.is_empty() => Err(Error::Tap(e)),
Err(e) => Err(Error::Refused(refused.join("; "), e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ask(scheme: &str, host: &str, method: &str) -> FetchInput {
FetchInput {
tool: "fetch".into(),
method: method.into(),
scheme: scheme.into(),
host: host.into(),
port: None,
path: "/".into(),
private_address: host == "127.0.0.1" || host == "10.0.0.1",
loopback_address: host == "127.0.0.1",
label: dm_core::Label::Public,
hop: 0,
}
}
#[test]
fn a_host_gate_lets_through_only_what_the_grant_and_the_caller_both_name() {
let g = HostGate {
granted: vec!["*.apache.org".into(), "docs.python.org".into()],
caller: vec!["*.apache.org".into()],
methods: Vec::new(),
loopback: false,
};
assert_eq!(g.decide(ask("https", "www.apache.org", "GET")), Ok(()));
assert!(g
.decide(ask("https", "docs.python.org", "GET"))
.unwrap_err()
.contains("calling session"));
assert!(g
.decide(ask("https", "example.com", "GET"))
.unwrap_err()
.contains("tool server is granted"));
assert!(g
.decide(ask("http", "www.apache.org", "GET"))
.unwrap_err()
.contains("https only"));
assert!(g
.decide(ask("https", "www.apache.org", "POST"))
.unwrap_err()
.contains("method"));
assert!(g
.decide(ask("https", "10.0.0.1", "GET"))
.unwrap_err()
.contains("inward"));
// the caller cannot widen what the server is granted
let wide = HostGate {
caller: vec!["example.com".into()],
..g.clone()
};
assert!(wide.decide(ask("https", "example.com", "GET")).is_err());
// loopback only when the grant says so, and then in the clear too
let dev = HostGate {
granted: vec!["127.0.0.1".into()],
caller: vec!["127.0.0.1".into()],
loopback: true,
..HostGate::default()
};
assert_eq!(dev.decide(ask("http", "127.0.0.1", "GET")), Ok(()));
assert!(host_matches("*.apache.org", "WWW.Apache.org"));
assert!(!host_matches("*.apache.org", "apache.org.evil.com"));
assert!(!host_matches("*apache.org", "evilapache.org"));
}
#[test]
fn inward_addresses_are_the_machine_s_own_networks() {
for a in [
"127.0.0.1",
"10.1.2.3",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"100.64.0.1",
"0.0.0.0",
"::1",
"fd00::1",
"fe80::1",
"::ffff:10.0.0.1",
] {
assert!(is_inward(a.parse().unwrap()), "{a}");
}
for a in ["151.101.1.69", "2a04:4e42::223", "100.128.0.1"] {
assert!(!is_inward(a.parse().unwrap()), "{a}");
}
}
}