1use std::future::Future;
25use std::sync::Arc;
26
27use http::Uri;
28use wasmtime_wasi_http::{Error as HttpError, RequestOptions, WasiBody};
29
30use act_policy::Decision;
31use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache};
32use act_policy::provider::{CompiledCeiling, ResourceOp};
33
34use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
35use crate::http_client::ActHttpClient;
36
37pub struct PolicyHttpHooks {
40 ceiling: Arc<dyn CompiledCeiling>,
41 client: Arc<crate::http_client::ActHttpClient>,
42 prompter: Arc<dyn ConsentPrompter>,
43 cache: Arc<DecisionCache>,
44}
45
46impl PolicyHttpHooks {
47 pub fn new(
48 ceiling: Arc<dyn CompiledCeiling>,
49 client: Arc<crate::http_client::ActHttpClient>,
50 prompter: Arc<dyn ConsentPrompter>,
51 cache: Arc<DecisionCache>,
52 ) -> Self {
53 Self {
54 ceiling,
55 client,
56 prompter,
57 cache,
58 }
59 }
60
61 fn http_ask(method: Option<&str>, uri: &Uri) -> ConsentAsk {
64 let host = uri.host().unwrap_or("");
65 let scheme = uri.scheme_str();
66 let port = uri
67 .port_u16()
68 .unwrap_or(if scheme == Some("https") { 443 } else { 80 });
69 ConsentAsk {
70 cap_id: act_types::constants::CAP_HTTP.to_string(),
71 key: format!("{host}:{port}"),
72 summary: format!("HTTP {} {}", method.unwrap_or("?"), uri),
73 }
74 }
75
76 fn decide_uri(&self, method: Option<&str>, uri: &Uri) -> Decision {
82 let host = uri.host().unwrap_or("");
83 let scheme = uri.scheme_str().unwrap_or("https");
84 let port = uri
85 .port_u16()
86 .unwrap_or(if scheme == "https" { 443 } else { 80 });
87 let op = ResourceOp {
88 cap_id: act_types::constants::CAP_HTTP.to_string(),
89 key: format!("{host}:{port}"),
90 action: method.unwrap_or("").to_string(),
91 attrs: serde_json::json!({"scheme": scheme}),
92 };
93 let explained = self.ceiling.classify_explained(&op);
94 let mode = self.ceiling.effective_mode().to_string();
95 match explained.decision {
96 Decision::Allow => {
97 emit_cap_decision(&CapDecisionRecord::statik(
98 act_types::constants::CAP_HTTP,
99 &op.key,
100 &op.action,
101 Decision4::Allow,
102 &mode,
103 explained.rule,
104 ));
105 }
106 Decision::Deny => {
107 emit_cap_decision(&CapDecisionRecord::statik(
108 act_types::constants::CAP_HTTP,
109 &op.key,
110 &op.action,
111 Decision4::Deny,
112 &mode,
113 explained.rule,
114 ));
115 }
116 Decision::Ask => {}
117 }
118 explained.decision
119 }
120}
121
122fn deny_reason(method: Option<&str>, uri: &Uri) -> String {
123 format!("blocked by ACT policy: {} {}", method.unwrap_or("?"), uri)
124}
125
126async fn resolve_http_ask(
134 cache: Arc<DecisionCache>,
135 prompter: Arc<dyn ConsentPrompter>,
136 ask: ConsentAsk,
137) -> bool {
138 let key = ask.key.clone();
139 let has_channel = prompter.has_channel();
140 let allowed = cache.decide_cached(&*prompter, ask).await;
141 emit_cap_decision(&CapDecisionRecord::answered(
142 act_types::constants::CAP_HTTP,
143 &key,
144 allowed,
145 has_channel,
146 ));
147 allowed
148}
149
150impl wasmtime_wasi_http::WasiHttpHooks for PolicyHttpHooks {
158 fn send_request(
159 &mut self,
160 request: http::Request<WasiBody>,
161 options: Option<RequestOptions>,
162 fut: Box<dyn Future<Output = Result<(), HttpError>> + Send>,
163 ) -> Box<
164 dyn Future<
165 Output = Result<
166 (
167 http::Response<WasiBody>,
168 Box<dyn Future<Output = Result<(), HttpError>> + Send>,
169 ),
170 HttpError,
171 >,
172 > + Send,
173 > {
174 let _ = fut;
178
179 let method = Some(request.method().as_str().to_string());
180 let uri = request.uri().clone();
181 let decision = self.decide_uri(method.as_deref(), &uri);
182 let client = self.client.clone();
183
184 match decision {
185 Decision::Allow => {
186 tracing::debug!(?method, %uri, "http policy allow");
187 Box::new(async move { send(client, request, options).await })
188 }
189 Decision::Ask => {
190 let cache = self.cache.clone();
191 let prompter = self.prompter.clone();
192 let ask = Self::http_ask(method.as_deref(), &uri);
193 let log_uri = uri;
194 Box::new(async move {
195 if !resolve_http_ask(cache, prompter, ask).await {
196 tracing::warn!(%log_uri, "http policy ask denied");
197 return Err(HttpError::HttpRequestDenied);
198 }
199 tracing::debug!(%log_uri, "http policy ask allowed");
200 send(client, request, options).await
201 })
202 }
203 Decision::Deny => {
204 tracing::warn!(?method, %uri, "{}", deny_reason(method.as_deref(), &uri));
205 Box::new(async move { Err(HttpError::HttpRequestDenied) })
206 }
207 }
208 }
209}
210
211async fn send(
214 client: Arc<ActHttpClient>,
215 request: http::Request<WasiBody>,
216 options: Option<RequestOptions>,
217) -> Result<
218 (
219 http::Response<WasiBody>,
220 Box<dyn Future<Output = Result<(), HttpError>> + Send>,
221 ),
222 HttpError,
223> {
224 match client.send(request, options).await {
225 Ok((resp, io)) => {
226 let io: Box<dyn Future<Output = Result<(), HttpError>> + Send> = Box::new(io);
227 Ok((resp, io))
228 }
229 Err(code) => Err(code),
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use act_policy::grant::{CapabilityGrant, PolicyMode};
237 use act_policy::provider::CapabilityProvider;
238 use act_policy::providers::http::HttpProvider;
239 use serde_json::json;
240
241 fn uri(s: &str) -> Uri {
242 s.parse().unwrap()
243 }
244
245 fn hooks_from(declared: Vec<serde_json::Value>, grant: CapabilityGrant) -> PolicyHttpHooks {
249 let mode = grant.mode;
251 let ceiling_box = tokio::runtime::Builder::new_current_thread()
255 .build()
256 .unwrap()
257 .block_on(HttpProvider.resolve("wasi:http", Some(&declared), &grant))
258 .expect("HttpProvider::resolve");
259 let ceiling: Arc<dyn act_policy::provider::CompiledCeiling> = Arc::from(ceiling_box);
260 let http_cfg = act_policy::grant::HttpConfig {
261 mode,
262 ..Default::default()
263 };
264 let client =
265 Arc::new(crate::http_client::ActHttpClient::new(http_cfg).expect("client builds"));
266 PolicyHttpHooks::new(
267 ceiling,
268 client,
269 Arc::new(act_policy::consent::DenyPrompter),
270 Arc::new(act_policy::consent::DecisionCache::new()),
271 )
272 }
273
274 #[test]
275 fn mode_deny_blocks_everything() {
276 let h = hooks_from(
278 vec![json!({"host": "api.openai.com"})],
279 CapabilityGrant {
280 mode: PolicyMode::Deny,
281 ..Default::default()
282 },
283 );
284 assert_eq!(
285 h.decide_uri(Some("GET"), &uri("https://api.openai.com/v1/chat")),
286 Decision::Deny
287 );
288 }
289
290 #[test]
291 fn mode_open_allows_everything() {
292 let h = hooks_from(
294 vec![json!({"host": "api.openai.com"})],
295 CapabilityGrant {
296 mode: PolicyMode::Open,
297 ..Default::default()
298 },
299 );
300 assert_eq!(
301 h.decide_uri(Some("GET"), &uri("https://api.openai.com/v1/chat")),
302 Decision::Allow
303 );
304 }
305
306 #[test]
307 fn ask_mode_is_bounded_by_allow_ceiling() {
308 let h = hooks_from(
311 vec![json!({"host": "api.openai.com", "scheme": "https"})],
312 CapabilityGrant {
313 mode: PolicyMode::Ask,
314 allow: vec![json!({"host": "api.openai.com", "scheme": "https"})],
315 ..Default::default()
316 },
317 );
318 assert_eq!(
319 h.decide_uri(Some("POST"), &uri("https://api.openai.com/v1/chat")),
320 Decision::Ask
321 );
322 assert_eq!(
323 h.decide_uri(Some("GET"), &uri("https://evil.com/")),
324 Decision::Deny
325 );
326 }
327
328 #[test]
329 fn ask_mode_deny_rule_beats_ceiling() {
330 let h = hooks_from(
331 vec![json!({"host": "*.example.com"})],
332 CapabilityGrant {
333 mode: PolicyMode::Ask,
334 allow: vec![json!({"host": "*.example.com"})],
335 deny: vec![json!({"host": "admin.example.com"})],
336 },
337 );
338 assert_eq!(
339 h.decide_uri(Some("GET"), &uri("https://api.example.com/")),
340 Decision::Ask
341 );
342 assert_eq!(
343 h.decide_uri(Some("GET"), &uri("https://admin.example.com/")),
344 Decision::Deny
345 );
346 }
347
348 #[test]
349 fn allowlist_host_allow() {
350 let h = hooks_from(
351 vec![json!({"host": "api.openai.com", "scheme": "https"})],
352 CapabilityGrant {
353 mode: PolicyMode::Allowlist,
354 allow: vec![json!({"host": "api.openai.com", "scheme": "https"})],
355 ..Default::default()
356 },
357 );
358 assert_eq!(
359 h.decide_uri(Some("POST"), &uri("https://api.openai.com/v1/chat")),
360 Decision::Allow
361 );
362 assert_eq!(
364 h.decide_uri(Some("GET"), &uri("http://api.openai.com/")),
365 Decision::Deny
366 );
367 assert_eq!(
369 h.decide_uri(Some("GET"), &uri("https://evil.com/")),
370 Decision::Deny
371 );
372 }
373
374 #[test]
375 fn allowlist_wildcard_host() {
376 let h = hooks_from(
377 vec![json!({"host": "*.github.com", "scheme": "https"})],
378 CapabilityGrant {
379 mode: PolicyMode::Allowlist,
380 allow: vec![json!({"host": "*.github.com", "scheme": "https"})],
381 ..Default::default()
382 },
383 );
384 assert_eq!(
385 h.decide_uri(Some("GET"), &uri("https://api.github.com/")),
386 Decision::Allow
387 );
388 assert_eq!(
389 h.decide_uri(Some("GET"), &uri("https://github.com/")),
390 Decision::Allow
391 );
392 assert_eq!(
393 h.decide_uri(Some("GET"), &uri("https://github.com.evil.com/")),
394 Decision::Deny
395 );
396 }
397
398 #[test]
399 fn deny_rule_beats_allow() {
400 let h = hooks_from(
401 vec![json!({"host": "*.example.com"})],
402 CapabilityGrant {
403 mode: PolicyMode::Allowlist,
404 allow: vec![json!({"host": "*.example.com"})],
405 deny: vec![json!({"host": "admin.example.com"})],
406 },
407 );
408 assert_eq!(
409 h.decide_uri(Some("GET"), &uri("https://api.example.com/")),
410 Decision::Allow
411 );
412 assert_eq!(
413 h.decide_uri(Some("GET"), &uri("https://admin.example.com/")),
414 Decision::Deny
415 );
416 }
417
418 #[test]
419 fn method_filter() {
420 let h = hooks_from(
421 vec![json!({"host": "api.example.com", "methods": ["GET", "POST"]})],
422 CapabilityGrant {
423 mode: PolicyMode::Allowlist,
424 allow: vec![json!({"host": "api.example.com"})],
425 ..Default::default()
426 },
427 );
428 assert_eq!(
429 h.decide_uri(Some("get"), &uri("https://api.example.com/")),
430 Decision::Allow
431 );
432 assert_eq!(
433 h.decide_uri(Some("DELETE"), &uri("https://api.example.com/")),
434 Decision::Deny
435 );
436 }
437
438 #[test]
439 fn undeclared_cap_denies_all() {
440 let h = hooks_from(
442 vec![], CapabilityGrant {
444 mode: PolicyMode::Open, ..Default::default()
446 },
447 );
448 assert_eq!(
449 h.decide_uri(Some("GET"), &uri("https://example.com/")),
450 Decision::Deny
451 );
452 }
453
454 #[test]
455 fn http_key_is_host_colon_port_and_action_is_the_method() {
456 let r = crate::audit::CapDecisionRecord::statik(
457 act_types::constants::CAP_HTTP,
458 "api.example.com:443",
459 "GET",
460 crate::audit::Decision4::Deny,
461 "ask",
462 None,
463 );
464 assert_eq!(r.key, "api.example.com:443");
465 assert_eq!(r.action, "GET");
466 assert_eq!(r.reason.as_deref(), Some("outside ceiling"));
467 }
468
469 #[test]
470 fn a_missing_http_method_becomes_an_empty_action() {
471 let r = crate::audit::CapDecisionRecord::statik(
473 act_types::constants::CAP_HTTP,
474 "api.example.com:443",
475 "",
476 crate::audit::Decision4::Allow,
477 "allowlist",
478 Some("*.example.com".into()),
479 );
480 assert_eq!(r.action, "");
481 assert_eq!(r.rule.as_deref(), Some("*.example.com"));
482 assert!(r.reason.is_none());
483 }
484
485 #[tokio::test(flavor = "current_thread")]
501 async fn the_ask_arm_resolves_and_audits_the_denial() {
502 use crate::audit::layer::AuditWriter;
503 use http_body_util::{BodyExt, Empty};
504 use std::sync::Mutex;
505 use tracing_subscriber::prelude::*;
506 use wasmtime_wasi_http::WasiHttpHooks as _;
507
508 #[derive(Clone, Default)]
509 struct CapturingWriter(Arc<Mutex<Vec<String>>>);
510 impl AuditWriter for CapturingWriter {
511 fn write_line(&self, line: &str) {
512 self.0.lock().unwrap().push(line.to_string());
513 }
514 }
515
516 let grant = CapabilityGrant {
523 mode: PolicyMode::Ask,
524 allow: vec![json!({"host": "api.example.com"})],
525 ..Default::default()
526 };
527 let ceiling_box = act_policy::providers::http::HttpProvider
528 .resolve(
529 "wasi:http",
530 Some(&[json!({"host": "api.example.com"})]),
531 &grant,
532 )
533 .await
534 .expect("HttpProvider::resolve");
535 let ceiling: Arc<dyn CompiledCeiling> = Arc::from(ceiling_box);
536 let http_cfg = act_policy::grant::HttpConfig {
537 mode: grant.mode,
538 ..Default::default()
539 };
540 let client =
541 Arc::new(crate::http_client::ActHttpClient::new(http_cfg).expect("client builds"));
542 let mut h = PolicyHttpHooks::new(
543 ceiling,
544 client,
545 Arc::new(act_policy::consent::DenyPrompter),
546 Arc::new(act_policy::consent::DecisionCache::new()),
547 );
548
549 let body: WasiBody = Empty::<bytes::Bytes>::new()
550 .map_err(|_| unreachable!())
551 .boxed_unsync();
552 let request = http::Request::builder()
553 .method("GET")
554 .uri("https://api.example.com/")
555 .body(body)
556 .unwrap();
557 let options = RequestOptions {
558 connect_timeout: Some(std::time::Duration::from_secs(5)),
559 first_byte_timeout: Some(std::time::Duration::from_secs(5)),
560 between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
561 };
562
563 let writer = CapturingWriter::default();
564 let sink = writer.0.clone();
565 let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
566 writer,
567 crate::audit::Detail::Rollup,
568 ));
569 let _guard = tracing::subscriber::set_default(sub);
578
579 let resolved =
583 std::pin::Pin::from(h.send_request(request, Some(options), Box::new(async { Ok(()) })))
584 .await;
585
586 drop(_guard);
587
588 assert!(
592 matches!(resolved, Err(HttpError::HttpRequestDenied)),
593 "expected the ask to degrade to a denied response"
594 );
595
596 let lines = sink.lock().unwrap().clone();
597 let ask_line = lines
598 .iter()
599 .find(|l| l.contains("ask-deny"))
600 .unwrap_or_else(|| panic!("no ask-deny audit line reached the trail, got {lines:?}"));
601 assert!(ask_line.contains("wasi:http"), "got {ask_line}");
602 assert!(ask_line.contains("no prompt channel"), "got {ask_line}");
605 }
606}