From 0e14e7c14e6e9774197d512fbf3a4479019309af Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 21 Jun 2026 17:04:03 +0900 Subject: [PATCH] plugin: align request grant inspection coverage --- crates/pod/src/feature/plugin.rs | 259 ++++++++++++++++++++++++- crates/yoi/src/plugin_cli.rs | 24 +-- docs/development/plugin-development.md | 2 +- 3 files changed, 265 insertions(+), 20 deletions(-) diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index ce543f9c..2f93635e 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -422,23 +422,73 @@ fn append_request_target_inspection( host_apis: &mut Vec, ) { for target in &record.manifest.request { - let granted = record.grants.request.iter().any(|grant| grant == target); - let broad = if target.is_broad() { - "; broad/arbitrary target" - } else { - "" + let covering_grant = record + .grants + .request + .iter() + .find(|grant| request_target_covers(grant, target)); + let intersecting_grant = covering_grant.or_else(|| { + record + .grants + .request + .iter() + .find(|grant| request_targets_intersect(target, grant)) + }); + let granted = intersecting_grant.is_some(); + let diagnostic = match (granted, covering_grant, target.is_broad()) { + (false, _, broad) => Some(format!( + "missing enabled request grant for manifest target{}", + if broad { "; broad/arbitrary target" } else { "" } + )), + (true, None, true) => Some( + "partially covered by enabled request grant; broad manifest target is constrained by narrower grants" + .to_string(), + ), + (true, None, false) => Some( + "partially covered by enabled request grant; only intersecting URLs are allowed" + .to_string(), + ), + (true, Some(grant), _) if grant.is_broad() => { + Some("covered by broad/arbitrary enabled request grant".to_string()) + } + _ => None, }; host_apis.push(PluginPermissionEligibility { permission: format!("host_api.request target {}", target.label()), requested: true, granted, eligible: granted, - diagnostic: (!granted) - .then(|| format!("missing enabled request grant for manifest target{broad}")), + diagnostic, }); } for grant in &record.grants.request { - if !record.manifest.request.iter().any(|target| target == grant) { + let matching_manifest = record + .manifest + .request + .iter() + .find(|target| request_targets_intersect(target, grant)); + if let Some(target) = matching_manifest { + let diagnostic = if grant.is_broad() { + Some( + "broad/arbitrary enabled request grant is constrained by manifest declarations" + .to_string(), + ) + } else if !request_target_covers(target, grant) { + Some( + "enabled request grant is only usable where it intersects manifest declarations" + .to_string(), + ) + } else { + None + }; + host_apis.push(PluginPermissionEligibility { + permission: format!("host_api.request grant {}", grant.label()), + requested: true, + granted: true, + eligible: true, + diagnostic, + }); + } else { let broad = if grant.is_broad() { "; broad/arbitrary target" } else { @@ -1449,6 +1499,104 @@ fn request_target_allows(target: &PluginRequestGrant, method: &str, url: &reqwes .any(|prefix| !prefix.is_empty() && url.path().starts_with(prefix)) } +fn request_targets_intersect(left: &PluginRequestGrant, right: &PluginRequestGrant) -> bool { + request_scheme_intersects(&left.scheme, &right.scheme) + && request_host_intersects(&left.host, &right.host) + && request_port_intersects(left.port, right.port) + && request_methods_intersect(&left.methods, &right.methods) + && request_paths_intersect(&left.path_prefixes, &right.path_prefixes) +} + +fn request_target_covers(covering: &PluginRequestGrant, covered: &PluginRequestGrant) -> bool { + request_scheme_covers(&covering.scheme, &covered.scheme) + && request_host_covers(&covering.host, &covered.host) + && request_port_covers(covering.port, covered.port) + && request_methods_cover(&covering.methods, &covered.methods) + && request_paths_cover(&covering.path_prefixes, &covered.path_prefixes) +} + +fn request_scheme_intersects(left: &str, right: &str) -> bool { + let left = left.trim().to_ascii_lowercase(); + let right = right.trim().to_ascii_lowercase(); + !left.is_empty() && !right.is_empty() && (left == "*" || right == "*" || left == right) +} + +fn request_scheme_covers(covering: &str, covered: &str) -> bool { + let covering = covering.trim().to_ascii_lowercase(); + let covered = covered.trim().to_ascii_lowercase(); + !covering.is_empty() && !covered.is_empty() && (covering == "*" || covering == covered) +} + +fn request_host_intersects(left: &str, right: &str) -> bool { + let left = normalize_host_literal(left); + let right = normalize_host_literal(right); + !left.is_empty() && !right.is_empty() && (left == "*" || right == "*" || left == right) +} + +fn request_host_covers(covering: &str, covered: &str) -> bool { + let covering = normalize_host_literal(covering); + let covered = normalize_host_literal(covered); + !covering.is_empty() && !covered.is_empty() && (covering == "*" || covering == covered) +} + +fn request_port_intersects(left: Option, right: Option) -> bool { + left.is_none() || right.is_none() || left == right +} + +fn request_port_covers(covering: Option, covered: Option) -> bool { + covering.is_none() || covering == covered +} + +fn request_methods_intersect(left: &[String], right: &[String]) -> bool { + !left.is_empty() + && !right.is_empty() + && left.iter().any(|left_method| { + right + .iter() + .any(|right_method| left_method.trim().eq_ignore_ascii_case(right_method.trim())) + }) +} + +fn request_methods_cover(covering: &[String], covered: &[String]) -> bool { + !covering.is_empty() + && !covered.is_empty() + && covered.iter().all(|covered_method| { + covering.iter().any(|covering_method| { + covering_method + .trim() + .eq_ignore_ascii_case(covered_method.trim()) + }) + }) +} + +fn request_paths_intersect(left: &[String], right: &[String]) -> bool { + left.is_empty() + || right.is_empty() + || left.iter().any(|left_prefix| { + !left_prefix.is_empty() + && right.iter().any(|right_prefix| { + !right_prefix.is_empty() + && (left_prefix.starts_with(right_prefix) + || right_prefix.starts_with(left_prefix)) + }) + }) +} + +fn request_paths_cover(covering: &[String], covered: &[String]) -> bool { + if covering.is_empty() { + return true; + } + if covered.is_empty() { + return false; + } + covered.iter().all(|covered_prefix| { + !covered_prefix.is_empty() + && covering.iter().any(|covering_prefix| { + !covering_prefix.is_empty() && covered_prefix.starts_with(covering_prefix) + }) + }) +} + fn normalize_host_literal(host: &str) -> String { host.trim_end_matches('.') .trim_start_matches('[') @@ -5764,6 +5912,101 @@ input_schema = {{ type = "object", additionalProperties = true }} assert_eq!(meta.name, "PluginEcho"); } + #[test] + fn static_inspection_reports_covering_request_grants_as_runtime_eligible() { + let mut exact_manifest_broad_grant = record_with_request_grant(); + exact_manifest_broad_grant.grants.request = vec![PluginRequestGrant { + scheme: "*".to_string(), + host: "*".to_string(), + port: None, + methods: vec!["GET".to_string()], + path_prefixes: Vec::new(), + }]; + + let inspection = inspect_resolved_plugin_static(&exact_manifest_broad_grant); + let target = inspection + .host_apis + .iter() + .find(|api| { + api.permission + .starts_with("host_api.request target https://api.example.test") + }) + .expect("manifest request target inspection"); + assert!(target.requested); + assert!(target.granted); + assert!(target.eligible); + let diagnostic = target.diagnostic.as_deref().unwrap_or_default(); + assert!( + diagnostic.contains("broad/arbitrary") || diagnostic.contains("partially covered"), + "{target:#?}" + ); + let broad_grant = inspection + .host_apis + .iter() + .find(|api| api.permission.starts_with("host_api.request grant *://*")) + .expect("broad grant inspection"); + assert!(broad_grant.requested); + assert!(broad_grant.granted); + assert!(broad_grant.eligible); + assert!( + !inspection + .host_apis + .iter() + .any(|api| api.permission.starts_with("host_api.request grant-only")), + "{:#?}", + inspection.host_apis + ); + } + + #[test] + fn static_inspection_reports_request_grant_intersections_as_runtime_eligible() { + let mut broad_manifest_exact_grant = record_with_request_grant(); + broad_manifest_exact_grant.manifest.request = vec![PluginRequestGrant { + scheme: "*".to_string(), + host: "*".to_string(), + port: None, + methods: vec!["GET".to_string(), "POST".to_string()], + path_prefixes: Vec::new(), + }]; + + let inspection = inspect_resolved_plugin_static(&broad_manifest_exact_grant); + let target = inspection + .host_apis + .iter() + .find(|api| api.permission.starts_with("host_api.request target *://*")) + .expect("broad manifest target inspection"); + assert!(target.requested); + assert!(target.granted); + assert!(target.eligible); + assert!( + target + .diagnostic + .as_deref() + .unwrap_or_default() + .contains("partially covered"), + "{target:#?}" + ); + let exact_grant = inspection + .host_apis + .iter() + .find(|api| { + api.permission + .starts_with("host_api.request grant https://api.example.test") + }) + .expect("exact grant inspection"); + assert!(exact_grant.requested); + assert!(exact_grant.granted); + assert!(exact_grant.eligible); + assert!( + !inspection + .host_apis + .iter() + .any(|api| api.permission.starts_with("host_api.request grant-only")), + "{:#?}", + inspection.host_apis + ); + } + #[test] fn component_static_inspection_reports_component_runtime_without_execution() { let mut record = record(vec![tool("Echo")]); diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 10aa2b3d..b9c19036 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -1520,7 +1520,7 @@ mod tests { configured: true, discovered: true, resolved: true, - static_eligible: false, + static_eligible: true, declared_surfaces: vec!["tool".to_string()], enabled_surfaces: vec!["tool".to_string()], requested_permissions: vec!["host_api.request".to_string()], @@ -1539,18 +1539,20 @@ mod tests { permission: "host_api.request target https://api.example.test GET /v1/" .to_string(), requested: true, - granted: false, - eligible: false, - diagnostic: Some("missing enabled request grant for manifest target".to_string()), + granted: true, + eligible: true, + diagnostic: Some( + "covered by broad/arbitrary enabled request grant".to_string(), + ), }, PluginPermissionEligibility { - permission: "host_api.request grant-only *://* GET * [broad-request]" + permission: "host_api.request grant *://* GET * [broad-request]" .to_string(), - requested: false, + requested: true, granted: true, - eligible: false, + eligible: true, diagnostic: Some( - "enabled request grant has no matching manifest declaration; broad/arbitrary target" + "broad/arbitrary enabled request grant is constrained by manifest declarations" .to_string(), ), }, @@ -1569,9 +1571,9 @@ mod tests { ); let human = render_item_human(&item).unwrap(); assert!(human.contains("host_api.request target https://api.example.test")); - assert!(human.contains("requested=true granted=false eligible=false")); - assert!(human.contains("host_api.request grant-only *://*")); - assert!(human.contains("broad/arbitrary target")); + assert!(human.contains("requested=true granted=true eligible=true")); + assert!(human.contains("host_api.request grant *://*")); + assert!(human.contains("broad/arbitrary")); } #[test] diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 35130f98..502dc303 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -333,7 +333,7 @@ methods = ["POST"] path_prefixes = ["/v1/"] ``` -Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected. +Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected. ## `fs` host API