use std::collections::HashSet;
use super::jsonrpc::{self, ParseError};
pub const HEADER_VERSION: &str = "a2a-version";
pub const HEADER_EXTENSIONS: &str = "a2a-extensions";
pub const AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
pub const MEDIA_TYPE: &str = "application/a2a+json";
pub const KNOWN_METHODS: &[&str] = &[
"SendMessage",
"SendStreamingMessage",
"GetTask",
"ListTasks",
"CancelTask",
"SubscribeToTask",
"CreateTaskPushNotificationConfig",
"GetTaskPushNotificationConfig",
"ListTaskPushNotificationConfigs",
"DeleteTaskPushNotificationConfig",
"GetExtendedAgentCard",
];
pub const WORK_INITIATING_METHODS: &[&str] = &["SendMessage", "SendStreamingMessage"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow {
method: Option<String>,
},
Deny {
reason: DenyReason,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DenyReason {
MethodNotAllowed {
method: String,
},
UnknownMethod {
method: String,
},
Unparseable {
error: String,
},
}
impl std::fmt::Display for DenyReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MethodNotAllowed { method } => {
write!(f, "A2A method {method:?} is not permitted on this route")
}
Self::UnknownMethod { method } => write!(
f,
"A2A method {method:?} is not one this proxy recognises, and this route \
refuses methods it cannot classify"
),
Self::Unparseable { error } => write!(
f,
"request body could not be inspected ({error}) and this route requires \
inspection to apply its policy"
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UnknownMethods {
#[default]
Allow,
Deny,
}
#[derive(Debug, Clone, Default)]
pub struct Policy {
pub allowed_methods: HashSet<String>,
pub denied_methods: HashSet<String>,
pub unknown_methods: UnknownMethods,
pub deny_uninspectable: bool,
}
pub fn is_known_method(method: &str) -> bool {
KNOWN_METHODS.contains(&method)
}
pub fn is_work_initiating(method: &str) -> bool {
WORK_INITIATING_METHODS.contains(&method)
}
pub fn is_agent_card_path(path: &str) -> bool {
path == AGENT_CARD_PATH
}
pub fn evaluate(policy: &Policy, body: &[u8]) -> Decision {
let envelope = match jsonrpc::parse(body) {
Ok(envelope) => envelope,
Err(error) => {
return if policy.deny_uninspectable {
Decision::Deny {
reason: DenyReason::Unparseable {
error: error.to_string(),
},
}
} else {
Decision::Allow { method: None }
};
}
};
let Some(method) = envelope.method.as_deref() else {
return Decision::Allow { method: None };
};
if policy.unknown_methods == UnknownMethods::Deny && !is_known_method(method) {
return Decision::Deny {
reason: DenyReason::UnknownMethod {
method: method.to_string(),
},
};
}
let allowed = policy.allowed_methods.is_empty() || policy.allowed_methods.contains(method);
if !allowed || policy.denied_methods.contains(method) {
return Decision::Deny {
reason: DenyReason::MethodNotAllowed {
method: method.to_string(),
},
};
}
Decision::Allow {
method: Some(method.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn permissive() -> Policy {
Policy {
allowed_methods: HashSet::new(),
denied_methods: HashSet::new(),
unknown_methods: UnknownMethods::Allow,
deny_uninspectable: true,
}
}
fn request(method: &str) -> Vec<u8> {
format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#).into_bytes()
}
#[test]
fn a_permitted_method_is_allowed() {
assert_eq!(
evaluate(&permissive(), &request("GetTask")),
Decision::Allow {
method: Some("GetTask".to_string())
}
);
}
#[test]
fn a_denied_method_is_refused() {
let mut policy = permissive();
policy.denied_methods = ["CancelTask".to_string()].into_iter().collect();
assert!(matches!(
evaluate(&policy, &request("CancelTask")),
Decision::Deny {
reason: DenyReason::MethodNotAllowed { .. }
}
));
}
#[test]
fn work_initiating_methods_can_be_refused_as_a_group() {
let mut policy = permissive();
policy.denied_methods = WORK_INITIATING_METHODS
.iter()
.map(|m| (*m).to_string())
.collect();
for method in WORK_INITIATING_METHODS {
assert!(
matches!(
evaluate(&policy, &request(method)),
Decision::Deny {
reason: DenyReason::MethodNotAllowed { .. }
}
),
"{method} should be refused"
);
}
assert!(matches!(
evaluate(&policy, &request("GetTask")),
Decision::Allow { .. }
));
}
#[test]
fn an_allowlist_refuses_everything_outside_it() {
let mut policy = permissive();
policy.allowed_methods = ["GetTask".to_string(), "ListTasks".to_string()]
.into_iter()
.collect();
assert!(matches!(
evaluate(&policy, &request("GetTask")),
Decision::Allow { .. }
));
assert!(matches!(
evaluate(&policy, &request("SendMessage")),
Decision::Deny {
reason: DenyReason::MethodNotAllowed { .. }
}
));
}
#[test]
fn unknown_methods_are_forwarded_by_default() {
assert!(matches!(
evaluate(&permissive(), &request("SomeMethodAddedLater")),
Decision::Allow { .. }
));
}
#[test]
fn unknown_methods_can_be_refused_deliberately() {
let mut policy = permissive();
policy.unknown_methods = UnknownMethods::Deny;
assert!(matches!(
evaluate(&policy, &request("SomeMethodAddedLater")),
Decision::Deny {
reason: DenyReason::UnknownMethod { .. }
}
));
assert!(matches!(
evaluate(&policy, &request("GetTask")),
Decision::Allow { .. }
));
}
#[test]
fn an_uninspectable_body_is_refused_when_configured() {
assert!(matches!(
evaluate(&permissive(), b"{not json"),
Decision::Deny {
reason: DenyReason::Unparseable { .. }
}
));
}
#[test]
fn an_uninspectable_body_can_be_forwarded() {
let mut policy = permissive();
policy.deny_uninspectable = false;
assert!(matches!(
evaluate(&policy, b"{not json"),
Decision::Allow { method: None }
));
}
#[test]
fn a_body_without_a_method_is_left_to_the_upstream() {
let body = br#"{"jsonrpc":"2.0","id":1,"result":{}}"#;
assert_eq!(
evaluate(&permissive(), body),
Decision::Allow { method: None }
);
}
#[test]
fn every_v1_method_is_recognised() {
for method in KNOWN_METHODS {
assert!(is_known_method(method), "{method} should be known");
}
assert!(
!is_known_method("message/send"),
"that is the v0.x spelling"
);
}
#[test]
fn work_initiating_methods_are_a_subset_of_known_methods() {
for method in WORK_INITIATING_METHODS {
assert!(
KNOWN_METHODS.contains(method),
"{method} is listed as work-initiating but is not a known method"
);
}
}
mod agent_card {
use super::*;
#[test]
fn the_well_known_path_is_recognised() {
assert!(is_agent_card_path("/.well-known/agent-card.json"));
}
#[test]
fn near_misses_are_not_the_agent_card() {
assert!(!is_agent_card_path("/.well-known/agent-card.json/"));
assert!(!is_agent_card_path("/.well-known/agent.json"));
assert!(!is_agent_card_path("/agent-card.json"));
assert!(!is_agent_card_path("/.well-known/agent-card.json?x=1"));
}
}
}