use std::future::Future;
use std::sync::Arc;
use http::Uri;
use wasmtime_wasi_http::{Error as HttpError, RequestOptions, WasiBody};
use act_policy::Decision;
use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache};
use act_policy::provider::{CompiledCeiling, ResourceOp};
use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
use crate::http_client::ActHttpClient;
pub struct PolicyHttpHooks {
ceiling: Arc<dyn CompiledCeiling>,
client: Arc<crate::http_client::ActHttpClient>,
prompter: Arc<dyn ConsentPrompter>,
cache: Arc<DecisionCache>,
}
impl PolicyHttpHooks {
pub fn new(
ceiling: Arc<dyn CompiledCeiling>,
client: Arc<crate::http_client::ActHttpClient>,
prompter: Arc<dyn ConsentPrompter>,
cache: Arc<DecisionCache>,
) -> Self {
Self {
ceiling,
client,
prompter,
cache,
}
}
fn http_ask(method: Option<&str>, uri: &Uri) -> ConsentAsk {
let host = uri.host().unwrap_or("");
let scheme = uri.scheme_str();
let port = uri
.port_u16()
.unwrap_or(if scheme == Some("https") { 443 } else { 80 });
ConsentAsk {
cap_id: act_types::constants::CAP_HTTP.to_string(),
key: format!("{host}:{port}"),
summary: format!("HTTP {} {}", method.unwrap_or("?"), uri),
}
}
fn decide_uri(&self, method: Option<&str>, uri: &Uri) -> Decision {
let host = uri.host().unwrap_or("");
let scheme = uri.scheme_str().unwrap_or("https");
let port = uri
.port_u16()
.unwrap_or(if scheme == "https" { 443 } else { 80 });
let op = ResourceOp {
cap_id: act_types::constants::CAP_HTTP.to_string(),
key: format!("{host}:{port}"),
action: method.unwrap_or("").to_string(),
attrs: serde_json::json!({"scheme": scheme}),
};
let explained = self.ceiling.classify_explained(&op);
let mode = self.ceiling.effective_mode().to_string();
match explained.decision {
Decision::Allow => {
emit_cap_decision(&CapDecisionRecord::statik(
act_types::constants::CAP_HTTP,
&op.key,
&op.action,
Decision4::Allow,
&mode,
explained.rule,
));
}
Decision::Deny => {
emit_cap_decision(&CapDecisionRecord::statik(
act_types::constants::CAP_HTTP,
&op.key,
&op.action,
Decision4::Deny,
&mode,
explained.rule,
));
}
Decision::Ask => {}
}
explained.decision
}
}
fn deny_reason(method: Option<&str>, uri: &Uri) -> String {
format!("blocked by ACT policy: {} {}", method.unwrap_or("?"), uri)
}
async fn resolve_http_ask(
cache: Arc<DecisionCache>,
prompter: Arc<dyn ConsentPrompter>,
ask: ConsentAsk,
) -> bool {
let key = ask.key.clone();
let has_channel = prompter.has_channel();
let allowed = cache.decide_cached(&*prompter, ask).await;
emit_cap_decision(&CapDecisionRecord::answered(
act_types::constants::CAP_HTTP,
&key,
allowed,
has_channel,
));
allowed
}
impl wasmtime_wasi_http::WasiHttpHooks for PolicyHttpHooks {
fn send_request(
&mut self,
request: http::Request<WasiBody>,
options: Option<RequestOptions>,
fut: Box<dyn Future<Output = Result<(), HttpError>> + Send>,
) -> Box<
dyn Future<
Output = Result<
(
http::Response<WasiBody>,
Box<dyn Future<Output = Result<(), HttpError>> + Send>,
),
HttpError,
>,
> + Send,
> {
let _ = fut;
let method = Some(request.method().as_str().to_string());
let uri = request.uri().clone();
let decision = self.decide_uri(method.as_deref(), &uri);
let client = self.client.clone();
match decision {
Decision::Allow => {
tracing::debug!(?method, %uri, "http policy allow");
Box::new(async move { send(client, request, options).await })
}
Decision::Ask => {
let cache = self.cache.clone();
let prompter = self.prompter.clone();
let ask = Self::http_ask(method.as_deref(), &uri);
let log_uri = uri;
Box::new(async move {
if !resolve_http_ask(cache, prompter, ask).await {
tracing::warn!(%log_uri, "http policy ask denied");
return Err(HttpError::HttpRequestDenied);
}
tracing::debug!(%log_uri, "http policy ask allowed");
send(client, request, options).await
})
}
Decision::Deny => {
tracing::warn!(?method, %uri, "{}", deny_reason(method.as_deref(), &uri));
Box::new(async move { Err(HttpError::HttpRequestDenied) })
}
}
}
}
async fn send(
client: Arc<ActHttpClient>,
request: http::Request<WasiBody>,
options: Option<RequestOptions>,
) -> Result<
(
http::Response<WasiBody>,
Box<dyn Future<Output = Result<(), HttpError>> + Send>,
),
HttpError,
> {
match client.send(request, options).await {
Ok((resp, io)) => {
let io: Box<dyn Future<Output = Result<(), HttpError>> + Send> = Box::new(io);
Ok((resp, io))
}
Err(code) => Err(code),
}
}
#[cfg(test)]
mod tests {
use super::*;
use act_policy::grant::{CapabilityGrant, PolicyMode};
use act_policy::provider::CapabilityProvider;
use act_policy::providers::http::HttpProvider;
use serde_json::json;
fn uri(s: &str) -> Uri {
s.parse().unwrap()
}
fn hooks_from(declared: Vec<serde_json::Value>, grant: CapabilityGrant) -> PolicyHttpHooks {
let mode = grant.mode;
let ceiling_box = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(HttpProvider.resolve("wasi:http", Some(&declared), &grant))
.expect("HttpProvider::resolve");
let ceiling: Arc<dyn act_policy::provider::CompiledCeiling> = Arc::from(ceiling_box);
let http_cfg = act_policy::grant::HttpConfig {
mode,
..Default::default()
};
let client =
Arc::new(crate::http_client::ActHttpClient::new(http_cfg).expect("client builds"));
PolicyHttpHooks::new(
ceiling,
client,
Arc::new(act_policy::consent::DenyPrompter),
Arc::new(act_policy::consent::DecisionCache::new()),
)
}
#[test]
fn mode_deny_blocks_everything() {
let h = hooks_from(
vec![json!({"host": "api.openai.com"})],
CapabilityGrant {
mode: PolicyMode::Deny,
..Default::default()
},
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://api.openai.com/v1/chat")),
Decision::Deny
);
}
#[test]
fn mode_open_allows_everything() {
let h = hooks_from(
vec![json!({"host": "api.openai.com"})],
CapabilityGrant {
mode: PolicyMode::Open,
..Default::default()
},
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://api.openai.com/v1/chat")),
Decision::Allow
);
}
#[test]
fn ask_mode_is_bounded_by_allow_ceiling() {
let h = hooks_from(
vec![json!({"host": "api.openai.com", "scheme": "https"})],
CapabilityGrant {
mode: PolicyMode::Ask,
allow: vec![json!({"host": "api.openai.com", "scheme": "https"})],
..Default::default()
},
);
assert_eq!(
h.decide_uri(Some("POST"), &uri("https://api.openai.com/v1/chat")),
Decision::Ask
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://evil.com/")),
Decision::Deny
);
}
#[test]
fn ask_mode_deny_rule_beats_ceiling() {
let h = hooks_from(
vec![json!({"host": "*.example.com"})],
CapabilityGrant {
mode: PolicyMode::Ask,
allow: vec![json!({"host": "*.example.com"})],
deny: vec![json!({"host": "admin.example.com"})],
},
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://api.example.com/")),
Decision::Ask
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://admin.example.com/")),
Decision::Deny
);
}
#[test]
fn allowlist_host_allow() {
let h = hooks_from(
vec![json!({"host": "api.openai.com", "scheme": "https"})],
CapabilityGrant {
mode: PolicyMode::Allowlist,
allow: vec![json!({"host": "api.openai.com", "scheme": "https"})],
..Default::default()
},
);
assert_eq!(
h.decide_uri(Some("POST"), &uri("https://api.openai.com/v1/chat")),
Decision::Allow
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("http://api.openai.com/")),
Decision::Deny
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://evil.com/")),
Decision::Deny
);
}
#[test]
fn allowlist_wildcard_host() {
let h = hooks_from(
vec![json!({"host": "*.github.com", "scheme": "https"})],
CapabilityGrant {
mode: PolicyMode::Allowlist,
allow: vec![json!({"host": "*.github.com", "scheme": "https"})],
..Default::default()
},
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://api.github.com/")),
Decision::Allow
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://github.com/")),
Decision::Allow
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://github.com.evil.com/")),
Decision::Deny
);
}
#[test]
fn deny_rule_beats_allow() {
let h = hooks_from(
vec![json!({"host": "*.example.com"})],
CapabilityGrant {
mode: PolicyMode::Allowlist,
allow: vec![json!({"host": "*.example.com"})],
deny: vec![json!({"host": "admin.example.com"})],
},
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://api.example.com/")),
Decision::Allow
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://admin.example.com/")),
Decision::Deny
);
}
#[test]
fn method_filter() {
let h = hooks_from(
vec![json!({"host": "api.example.com", "methods": ["GET", "POST"]})],
CapabilityGrant {
mode: PolicyMode::Allowlist,
allow: vec![json!({"host": "api.example.com"})],
..Default::default()
},
);
assert_eq!(
h.decide_uri(Some("get"), &uri("https://api.example.com/")),
Decision::Allow
);
assert_eq!(
h.decide_uri(Some("DELETE"), &uri("https://api.example.com/")),
Decision::Deny
);
}
#[test]
fn undeclared_cap_denies_all() {
let h = hooks_from(
vec![], CapabilityGrant {
mode: PolicyMode::Open, ..Default::default()
},
);
assert_eq!(
h.decide_uri(Some("GET"), &uri("https://example.com/")),
Decision::Deny
);
}
#[test]
fn http_key_is_host_colon_port_and_action_is_the_method() {
let r = crate::audit::CapDecisionRecord::statik(
act_types::constants::CAP_HTTP,
"api.example.com:443",
"GET",
crate::audit::Decision4::Deny,
"ask",
None,
);
assert_eq!(r.key, "api.example.com:443");
assert_eq!(r.action, "GET");
assert_eq!(r.reason.as_deref(), Some("outside ceiling"));
}
#[test]
fn a_missing_http_method_becomes_an_empty_action() {
let r = crate::audit::CapDecisionRecord::statik(
act_types::constants::CAP_HTTP,
"api.example.com:443",
"",
crate::audit::Decision4::Allow,
"allowlist",
Some("*.example.com".into()),
);
assert_eq!(r.action, "");
assert_eq!(r.rule.as_deref(), Some("*.example.com"));
assert!(r.reason.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn the_ask_arm_resolves_and_audits_the_denial() {
use crate::audit::layer::AuditWriter;
use http_body_util::{BodyExt, Empty};
use std::sync::Mutex;
use tracing_subscriber::prelude::*;
use wasmtime_wasi_http::WasiHttpHooks as _;
#[derive(Clone, Default)]
struct CapturingWriter(Arc<Mutex<Vec<String>>>);
impl AuditWriter for CapturingWriter {
fn write_line(&self, line: &str) {
self.0.lock().unwrap().push(line.to_string());
}
}
let grant = CapabilityGrant {
mode: PolicyMode::Ask,
allow: vec![json!({"host": "api.example.com"})],
..Default::default()
};
let ceiling_box = act_policy::providers::http::HttpProvider
.resolve(
"wasi:http",
Some(&[json!({"host": "api.example.com"})]),
&grant,
)
.await
.expect("HttpProvider::resolve");
let ceiling: Arc<dyn CompiledCeiling> = Arc::from(ceiling_box);
let http_cfg = act_policy::grant::HttpConfig {
mode: grant.mode,
..Default::default()
};
let client =
Arc::new(crate::http_client::ActHttpClient::new(http_cfg).expect("client builds"));
let mut h = PolicyHttpHooks::new(
ceiling,
client,
Arc::new(act_policy::consent::DenyPrompter),
Arc::new(act_policy::consent::DecisionCache::new()),
);
let body: WasiBody = Empty::<bytes::Bytes>::new()
.map_err(|_| unreachable!())
.boxed_unsync();
let request = http::Request::builder()
.method("GET")
.uri("https://api.example.com/")
.body(body)
.unwrap();
let options = RequestOptions {
connect_timeout: Some(std::time::Duration::from_secs(5)),
first_byte_timeout: Some(std::time::Duration::from_secs(5)),
between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
};
let writer = CapturingWriter::default();
let sink = writer.0.clone();
let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
writer,
crate::audit::Detail::Rollup,
));
let _guard = tracing::subscriber::set_default(sub);
let resolved =
std::pin::Pin::from(h.send_request(request, Some(options), Box::new(async { Ok(()) })))
.await;
drop(_guard);
assert!(
matches!(resolved, Err(HttpError::HttpRequestDenied)),
"expected the ask to degrade to a denied response"
);
let lines = sink.lock().unwrap().clone();
let ask_line = lines
.iter()
.find(|l| l.contains("ask-deny"))
.unwrap_or_else(|| panic!("no ask-deny audit line reached the trail, got {lines:?}"));
assert!(ask_line.contains("wasi:http"), "got {ask_line}");
assert!(ask_line.contains("no prompt channel"), "got {ask_line}");
}
}