use crate::error::{Error, Result};
use crate::policy::{Act, Effect, Policy, Verdict};
use crate::state::{PolicyEvent, Store};
pub(crate) fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
pub(crate) fn target(url: &str) -> Option<String> {
let (scheme, rest) = url.split_once("://")?;
let authority = rest
.split(['/', '?', '#'])
.next()
.filter(|a| !a.is_empty())?;
let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let default_port = match scheme.to_ascii_lowercase().as_str() {
"https" | "wss" => "443",
"http" | "ws" => "80",
_ => return None,
};
if let Some(close) = hostport.strip_prefix('[').and_then(|_| hostport.find(']')) {
let host = &hostport[..=close];
return match hostport[close + 1..].strip_prefix(':') {
Some(port) if !port.is_empty() => Some(format!("{host}:{port}")),
_ => Some(format!("{host}:{default_port}")),
};
}
match hostport.split_once(':') {
Some((host, port)) if !host.is_empty() && !port.is_empty() => {
Some(format!("{host}:{port}"))
}
Some(_) => None,
None => Some(format!("{hostport}:{default_port}")),
}
}
pub(crate) struct NetGuard<'a> {
policy: &'a Policy,
trace: Option<(&'a Store, i64, u32)>,
}
impl<'a> NetGuard<'a> {
pub(crate) fn new(policy: &'a Policy) -> Self {
Self {
policy,
trace: None,
}
}
pub(crate) fn tracing(mut self, store: &'a Store, run_id: i64, step: u32) -> Self {
self.trace = Some((store, run_id, step));
self
}
pub(crate) fn check(&self, url: &str) -> Result<Verdict> {
let Some(target) = target(url) else {
return Err(Error::Refused {
act: "net".into(),
target: url.to_string(),
rule: None,
layer: None,
});
};
self.check_target(&target)
}
pub(crate) fn check_target(&self, target: &str) -> Result<Verdict> {
let verdict = self.policy.check(Act::Net, target);
if let Some((store, run_id, step)) = self.trace {
let mut ev = match verdict.effect {
Effect::Allow => PolicyEvent::decision(step, "net", target, "allow", "policy"),
Effect::Ask => PolicyEvent::decision(step, "net", target, "ask", "policy"),
Effect::Deny => PolicyEvent::refusal(step, "net", target),
};
ev.rule = verdict.rule.clone();
ev.layer = verdict.layer.clone();
let _ = store.record_event(run_id, &ev);
}
if verdict.effect == Effect::Deny {
return Err(Error::Refused {
act: "net".into(),
target: target.to_string(),
rule: verdict.rule,
layer: verdict.layer,
});
}
Ok(verdict)
}
}
pub(crate) const PROVIDER_LAYER: &str = "provider";
pub(crate) fn provider_layer(target: &str) -> Policy {
Policy::permissive().layer(PROVIDER_LAYER).allow_net(target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_url_becomes_host_and_port() {
for (url, want) in [
("https://api.openai.com/v1/chat/completions", "api.openai.com:443"),
("http://127.0.0.1:8931/mcp", "127.0.0.1:8931"),
("https://openrouter.ai/api/v1/chat/completions", "openrouter.ai:443"),
("http://example.com", "example.com:80"),
("https://example.com:8443/x?y=1#z", "example.com:8443"),
("https://user:pw@example.com/x", "example.com:443"),
("https://[::1]/x", "[::1]:443"),
("https://[::1]:8080/x", "[::1]:8080"),
] {
assert_eq!(target(url).as_deref(), Some(want), "{url}");
}
}
#[test]
fn an_uncheckable_url_is_refused_not_waved_through() {
for url in ["", "not a url", "file:///etc/passwd", "https://", "https://host:/x"] {
assert_eq!(target(url), None, "{url}");
let p = Policy::permissive();
assert!(matches!(
NetGuard::new(&p).check(url),
Err(Error::Refused { .. })
));
}
}
#[test]
fn deny_is_an_error_and_allow_is_a_verdict() {
let p = Policy::default().layer("l").allow_net("api.example.com");
let guard = NetGuard::new(&p);
assert_eq!(
guard.check("https://api.example.com/v1").unwrap().effect,
Effect::Allow
);
assert!(matches!(
guard.check("https://evil.example.com/v1"),
Err(Error::Refused { act, .. }) if act == "net"
));
}
#[test]
fn the_provider_layer_is_named_and_a_caller_deny_still_wins() {
let base = Policy::default(); let with_provider = base.merge(provider_layer("api.example.com:443"));
let v = with_provider.explain(Act::Net, "api.example.com:443");
assert_eq!(v.effect, Effect::Allow);
assert_eq!(v.layer.as_deref(), Some(PROVIDER_LAYER));
let locked = with_provider.layer("caller").deny_net("api.example.com");
assert_eq!(locked.check(Act::Net, "api.example.com:443").effect, Effect::Deny);
}
}