use crate::adapters::guard::{AdapterGuardConfig, enter_adapter_guard};
use crate::audit::AuditSink;
use crate::engine::evaluate_and_audit_with_state;
use crate::models::HostContext;
use crate::revocation::{RuntimeTrustConfig, TrustStateStore, trust_state_from_runtime_config};
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HttpAdapterResponse {
pub status_code: u16,
pub body: Value,
}
pub fn handle_http_json_request(
raw_body: &str,
now: DateTime<Utc>,
sink: &dyn AuditSink,
) -> HttpAdapterResponse {
handle_http_json_request_with_runtime_config(
raw_body,
now,
sink,
&RuntimeTrustConfig::default(),
)
}
pub fn handle_http_json_request_with_runtime_config(
raw_body: &str,
now: DateTime<Utc>,
sink: &dyn AuditSink,
runtime_config: &RuntimeTrustConfig,
) -> HttpAdapterResponse {
let trust_state = trust_state_from_runtime_config(runtime_config);
handle_http_json_request_with_state(
raw_body,
now,
sink,
trust_state.as_ref(),
&HostContext::default(),
)
}
pub fn handle_http_json_request_with_state(
raw_body: &str,
now: DateTime<Utc>,
sink: &dyn AuditSink,
trust_state: &dyn TrustStateStore,
host_context: &HostContext,
) -> HttpAdapterResponse {
handle_http_json_request_with_state_and_guard_config(
raw_body,
now,
sink,
trust_state,
&AdapterGuardConfig::default(),
host_context,
)
}
pub fn handle_http_json_request_with_state_and_guard_config(
raw_body: &str,
now: DateTime<Utc>,
sink: &dyn AuditSink,
trust_state: &dyn TrustStateStore,
guard_config: &AdapterGuardConfig,
host_context: &HostContext,
) -> HttpAdapterResponse {
let raw_request: Value = match serde_json::from_str(raw_body) {
Ok(value) => value,
Err(error) => {
return HttpAdapterResponse {
status_code: 400,
body: json!({
"allowed": false,
"stage": "http_adapter",
"reason": format!("malformed JSON body: {error}")
}),
};
}
};
let agent_id = raw_request
.get("agent_id")
.and_then(Value::as_str)
.unwrap_or("unknown-agent");
let delegator_id = raw_request
.get("delegator_id")
.and_then(Value::as_str)
.unwrap_or("unknown-delegator");
let _guard_lease = match enter_adapter_guard(agent_id, delegator_id, now, guard_config) {
Ok(lease) => lease,
Err(violation) => {
return HttpAdapterResponse {
status_code: 429,
body: json!({
"allowed": false,
"stage": "adapter_guard",
"reason": violation.reason
}),
};
}
};
match evaluate_and_audit_with_state(&raw_request, now, sink, trust_state, host_context) {
Ok(decision) => {
if decision.allowed {
HttpAdapterResponse {
status_code: 200,
body: json!({
"allowed": true,
"stage": decision.stage,
"reason": decision.reason
}),
}
} else {
HttpAdapterResponse {
status_code: 403,
body: json!({
"allowed": false,
"stage": decision.stage,
"reason": decision.reason
}),
}
}
}
Err(error) => HttpAdapterResponse {
status_code: 500,
body: json!({
"allowed": false,
"stage": "audit_sink",
"reason": format!("failed to write audit event: {error}")
}),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::guard::AdapterGuardConfig;
use crate::audit::{AuditSink, JsonlFileAuditSink};
use crate::crypto::{
TOKEN_SIGNATURE_ALG_ED25519, sign_delegation_token, sign_identity_document,
};
use crate::models::{
AgentEndpoint, AgentIdentityDocument, AuditEvent, DelegationToken, PublicKeyRecord,
RequestEnvelope, RuntimeContext, TrustProfile,
};
use crate::revocation::InMemoryTrustState;
use base64ct::{Base64UrlUnpadded, Encoding};
use chrono::TimeZone;
use ed25519_dalek::SigningKey;
use std::io;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 1, 20, 20, 0)
.single()
.expect("valid test timestamp")
}
fn signing_key() -> SigningKey {
SigningKey::from_bytes(&[9u8; 32])
}
fn unique_id() -> String {
let counter = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
format!("{counter}_{nanos}")
}
fn valid_request_body() -> String {
let unique_id = unique_id();
let key = signing_key();
let mut identity_document = AgentIdentityDocument {
spec_version: "0.1".to_string(),
kind: "AgentIdentityDocument".to_string(),
agent_id: "agent:example:scheduler:v1".to_string(),
display_name: Some("example Scheduler Agent".to_string()),
owner_id: "org:example".to_string(),
issuer: "https://trust.example.ai".to_string(),
identity_type: "spiffe".to_string(),
subject: "spiffe://example.ai/agents/scheduler".to_string(),
public_keys: vec![PublicKeyRecord {
kid: "key-2026-01".to_string(),
kty: "OKP".to_string(),
crv: Some(TOKEN_SIGNATURE_ALG_ED25519.to_string()),
x: Some(Base64UrlUnpadded::encode_string(
&key.verifying_key().to_bytes(),
)),
}],
supported_protocols: vec!["http".to_string()],
supported_auth_methods: vec!["delegation_token".to_string()],
capabilities: None,
endpoints: vec![AgentEndpoint {
protocol: "http".to_string(),
url: "https://agents.example.ai/scheduler".to_string(),
}],
attestation: None,
created_at: Utc
.with_ymd_and_hms(2026, 6, 1, 20, 0, 0)
.single()
.expect("valid timestamp"),
expires_at: Utc
.with_ymd_and_hms(2026, 6, 8, 20, 0, 0)
.single()
.expect("valid timestamp"),
signature: String::new(),
};
identity_document.signature =
sign_identity_document(&identity_document, &key).expect("identity signing should work");
let mut token = DelegationToken {
spec_version: "0.1".to_string(),
kind: "DelegationToken".to_string(),
token_id: format!("dlg_http_{unique_id}"),
issuer: "https://trust.example.ai".to_string(),
agent_id: "agent:example:scheduler:v1".to_string(),
delegator_id: "user:jake-abendroth".to_string(),
owner_id: "org:example".to_string(),
audience: vec!["tool:google-calendar".to_string()],
allowed_actions: vec!["calendar.create_event".to_string()],
resource_constraints: None,
max_spend: None,
max_delegation_depth: None,
issued_at: Utc
.with_ymd_and_hms(2026, 6, 1, 20, 10, 0)
.single()
.expect("valid timestamp"),
expires_at: Utc
.with_ymd_and_hms(2026, 6, 1, 20, 40, 0)
.single()
.expect("valid timestamp"),
intent: None,
nonce: format!("random-nonce-{unique_id}"),
key_id: "key-2026-01".to_string(),
signature_alg: TOKEN_SIGNATURE_ALG_ED25519.to_string(),
signature: String::new(),
};
token.signature = sign_delegation_token(&token, &key).expect("token signing should work");
let request = RequestEnvelope {
spec_version: "0.1".to_string(),
kind: "TrustRequestEnvelope".to_string(),
request_id: Some(format!("req_http_{unique_id}")),
profile: TrustProfile::Developer,
agent_id: "agent:example:scheduler:v1".to_string(),
delegator_id: "user:jake-abendroth".to_string(),
audience: "tool:google-calendar".to_string(),
action: "calendar.create_event".to_string(),
resource: None,
runtime_context: RuntimeContext::default(),
identity_document: Some(identity_document),
token,
};
serde_json::to_string(&request).expect("request serialization should work")
}
fn resign_token(request: &mut Value) {
let key = signing_key();
let mut token: DelegationToken =
serde_json::from_value(request["delegation_token"].clone())
.expect("token should parse");
token.signature = sign_delegation_token(&token, &key).expect("token resign should work");
request["delegation_token"] = serde_json::to_value(token).expect("token serialization");
}
fn request_with_delegator(delegator_id: &str) -> String {
let mut request: Value =
serde_json::from_str(&valid_request_body()).expect("test request should parse");
request["delegator_id"] = json!(delegator_id);
request["delegation_token"]["delegator_id"] = json!(delegator_id);
resign_token(&mut request);
request.to_string()
}
#[test]
fn returns_200_on_allow() {
let path = std::env::temp_dir().join(format!(
"delegated_http_allow_{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos()
));
let sink = JsonlFileAuditSink::new(path.clone());
let response = handle_http_json_request(&valid_request_body(), now(), &sink);
assert_eq!(response.status_code, 200);
assert_eq!(response.body["allowed"], json!(true));
std::fs::remove_file(path).expect("temporary audit file should be removable");
}
#[test]
fn returns_403_on_policy_deny() {
let path = std::env::temp_dir().join(format!(
"delegated_http_deny_{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos()
));
let sink = JsonlFileAuditSink::new(path.clone());
let mut request: Value =
serde_json::from_str(&valid_request_body()).expect("test request should parse");
request["action"] = json!("calendar.delete_event");
let response = handle_http_json_request(&request.to_string(), now(), &sink);
assert_eq!(response.status_code, 403);
assert_eq!(response.body["allowed"], json!(false));
std::fs::remove_file(path).expect("temporary audit file should be removable");
}
#[test]
fn returns_400_on_malformed_json() {
let sink = FailingSink;
let response = handle_http_json_request("{invalid", now(), &sink);
assert_eq!(response.status_code, 400);
assert_eq!(response.body["stage"], json!("http_adapter"));
}
#[test]
fn returns_500_when_audit_sink_fails() {
let sink = FailingSink;
let response = handle_http_json_request(&valid_request_body(), now(), &sink);
assert_eq!(response.status_code, 500);
assert_eq!(response.body["stage"], json!("audit_sink"));
}
#[test]
fn denies_nonce_replay_with_shared_state() {
let path = std::env::temp_dir().join(format!(
"delegated_http_replay_{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos()
));
let sink = JsonlFileAuditSink::new(path.clone());
let trust_state = InMemoryTrustState::new();
let body = valid_request_body();
let first = handle_http_json_request_with_state(
&body,
now(),
&sink,
&trust_state,
&HostContext::default(),
);
let second = handle_http_json_request_with_state(
&body,
now(),
&sink,
&trust_state,
&HostContext::default(),
);
assert_eq!(first.status_code, 200);
assert_eq!(second.status_code, 403);
assert_eq!(
second.body["reason"],
json!("delegation token nonce replay detected")
);
std::fs::remove_file(path).expect("temporary audit file should be removable");
}
#[test]
fn returns_429_when_rate_limited_by_tuple() {
let path = std::env::temp_dir().join(format!(
"delegated_http_rate_limit_{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos()
));
let sink = JsonlFileAuditSink::new(path.clone());
let config = AdapterGuardConfig {
max_requests_per_minute: 1,
max_inflight_per_tuple: 8,
};
let delegator = format!(
"user:rate-limit:{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos()
);
let first_body = request_with_delegator(&delegator);
let second_body = request_with_delegator(&delegator);
let state = InMemoryTrustState::new();
let first = handle_http_json_request_with_state_and_guard_config(
&first_body,
now(),
&sink,
&state,
&config,
&HostContext::default(),
);
let second = handle_http_json_request_with_state_and_guard_config(
&second_body,
now(),
&sink,
&state,
&config,
&HostContext::default(),
);
assert_eq!(first.status_code, 200);
assert_eq!(second.status_code, 429);
assert_eq!(
second.body["reason"],
json!("rate limit exceeded for agent/delegator tuple")
);
std::fs::remove_file(path).expect("temporary audit file should be removable");
}
#[test]
fn returns_429_when_concurrency_limited_by_tuple() {
let config = AdapterGuardConfig {
max_requests_per_minute: 100,
max_inflight_per_tuple: 1,
};
let delegator = format!(
"user:inflight-limit:{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos()
);
let first_body = request_with_delegator(&delegator);
let second_body = request_with_delegator(&delegator);
let sink = Arc::new(SlowSink { delay_ms: 200 });
let sink_first = Arc::clone(&sink);
let config_first = config.clone();
let first = std::thread::spawn(move || {
let state = InMemoryTrustState::new();
handle_http_json_request_with_state_and_guard_config(
&first_body,
now(),
sink_first.as_ref(),
&state,
&config_first,
&HostContext::default(),
)
});
std::thread::sleep(Duration::from_millis(20));
let second_state = InMemoryTrustState::new();
let second = handle_http_json_request_with_state_and_guard_config(
&second_body,
now(),
sink.as_ref(),
&second_state,
&config,
&HostContext::default(),
);
let first_response = first.join().expect("first request thread should finish");
assert_eq!(first_response.status_code, 200);
assert_eq!(second.status_code, 429);
assert_eq!(
second.body["reason"],
json!("concurrency limit exceeded for agent/delegator tuple")
);
}
struct FailingSink;
impl AuditSink for FailingSink {
fn write_event(&self, _event: &AuditEvent) -> io::Result<()> {
Err(io::Error::other("sink unavailable"))
}
}
struct SlowSink {
delay_ms: u64,
}
impl AuditSink for SlowSink {
fn write_event(&self, _event: &AuditEvent) -> io::Result<()> {
std::thread::sleep(Duration::from_millis(self.delay_ms));
Ok(())
}
}
}