plugin: align request grant inspection coverage
This commit is contained in:
parent
962b769989
commit
0e14e7c14e
|
|
@ -422,23 +422,73 @@ fn append_request_target_inspection(
|
||||||
host_apis: &mut Vec<PluginPermissionEligibility>,
|
host_apis: &mut Vec<PluginPermissionEligibility>,
|
||||||
) {
|
) {
|
||||||
for target in &record.manifest.request {
|
for target in &record.manifest.request {
|
||||||
let granted = record.grants.request.iter().any(|grant| grant == target);
|
let covering_grant = record
|
||||||
let broad = if target.is_broad() {
|
.grants
|
||||||
"; broad/arbitrary target"
|
.request
|
||||||
} else {
|
.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 {
|
host_apis.push(PluginPermissionEligibility {
|
||||||
permission: format!("host_api.request target {}", target.label()),
|
permission: format!("host_api.request target {}", target.label()),
|
||||||
requested: true,
|
requested: true,
|
||||||
granted,
|
granted,
|
||||||
eligible: granted,
|
eligible: granted,
|
||||||
diagnostic: (!granted)
|
diagnostic,
|
||||||
.then(|| format!("missing enabled request grant for manifest target{broad}")),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for grant in &record.grants.request {
|
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() {
|
let broad = if grant.is_broad() {
|
||||||
"; broad/arbitrary target"
|
"; broad/arbitrary target"
|
||||||
} else {
|
} 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))
|
.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<u16>, right: Option<u16>) -> bool {
|
||||||
|
left.is_none() || right.is_none() || left == right
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_port_covers(covering: Option<u16>, covered: Option<u16>) -> 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 {
|
fn normalize_host_literal(host: &str) -> String {
|
||||||
host.trim_end_matches('.')
|
host.trim_end_matches('.')
|
||||||
.trim_start_matches('[')
|
.trim_start_matches('[')
|
||||||
|
|
@ -5764,6 +5912,101 @@ input_schema = {{ type = "object", additionalProperties = true }}
|
||||||
assert_eq!(meta.name, "PluginEcho");
|
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]
|
#[test]
|
||||||
fn component_static_inspection_reports_component_runtime_without_execution() {
|
fn component_static_inspection_reports_component_runtime_without_execution() {
|
||||||
let mut record = record(vec![tool("Echo")]);
|
let mut record = record(vec![tool("Echo")]);
|
||||||
|
|
|
||||||
|
|
@ -1520,7 +1520,7 @@ mod tests {
|
||||||
configured: true,
|
configured: true,
|
||||||
discovered: true,
|
discovered: true,
|
||||||
resolved: true,
|
resolved: true,
|
||||||
static_eligible: false,
|
static_eligible: true,
|
||||||
declared_surfaces: vec!["tool".to_string()],
|
declared_surfaces: vec!["tool".to_string()],
|
||||||
enabled_surfaces: vec!["tool".to_string()],
|
enabled_surfaces: vec!["tool".to_string()],
|
||||||
requested_permissions: vec!["host_api.request".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/"
|
permission: "host_api.request target https://api.example.test GET /v1/"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
requested: true,
|
requested: true,
|
||||||
granted: false,
|
granted: true,
|
||||||
eligible: false,
|
eligible: true,
|
||||||
diagnostic: Some("missing enabled request grant for manifest target".to_string()),
|
diagnostic: Some(
|
||||||
|
"covered by broad/arbitrary enabled request grant".to_string(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
PluginPermissionEligibility {
|
PluginPermissionEligibility {
|
||||||
permission: "host_api.request grant-only *://* GET * [broad-request]"
|
permission: "host_api.request grant *://* GET * [broad-request]"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
requested: false,
|
requested: true,
|
||||||
granted: true,
|
granted: true,
|
||||||
eligible: false,
|
eligible: true,
|
||||||
diagnostic: Some(
|
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(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -1569,9 +1571,9 @@ mod tests {
|
||||||
);
|
);
|
||||||
let human = render_item_human(&item).unwrap();
|
let human = render_item_human(&item).unwrap();
|
||||||
assert!(human.contains("host_api.request target https://api.example.test"));
|
assert!(human.contains("host_api.request target https://api.example.test"));
|
||||||
assert!(human.contains("requested=true granted=false eligible=false"));
|
assert!(human.contains("requested=true granted=true eligible=true"));
|
||||||
assert!(human.contains("host_api.request grant-only *://*"));
|
assert!(human.contains("host_api.request grant *://*"));
|
||||||
assert!(human.contains("broad/arbitrary target"));
|
assert!(human.contains("broad/arbitrary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,7 @@ methods = ["POST"]
|
||||||
path_prefixes = ["/v1/"]
|
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
|
## `fs` host API
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user