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>,
|
||||
) {
|
||||
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<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 {
|
||||
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")]);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user