merge: plugin request host api
This commit is contained in:
commit
8a15cca567
|
|
@ -150,15 +150,15 @@ pub struct PluginGrantConfig {
|
||||||
pub digest: Option<String>,
|
pub digest: Option<String>,
|
||||||
/// Explicit capabilities granted for the pinned package identity/version/digest.
|
/// Explicit capabilities granted for the pinned package identity/version/digest.
|
||||||
pub permissions: Vec<PluginPermission>,
|
pub permissions: Vec<PluginPermission>,
|
||||||
/// Bounded outbound HTTPS allowlist entries for `host_api.https`.
|
/// Bounded outbound request allowlist entries for `host_api.request`.
|
||||||
pub https: Vec<PluginHttpsGrant>,
|
pub request: Vec<PluginRequestGrant>,
|
||||||
/// Scoped filesystem allowlist entries for `host_api.fs`.
|
/// Scoped filesystem allowlist entries for `host_api.fs`.
|
||||||
pub fs: Vec<PluginFsGrant>,
|
pub fs: Vec<PluginFsGrant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginGrantConfig {
|
impl PluginGrantConfig {
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.permissions.is_empty() && self.https.is_empty() && self.fs.is_empty()
|
self.permissions.is_empty() && self.request.is_empty() && self.fs.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn binding_error(
|
pub fn binding_error(
|
||||||
|
|
@ -212,17 +212,32 @@ pub enum PluginPermission {
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct PluginHttpsGrant {
|
pub struct PluginRequestGrant {
|
||||||
/// Exact HTTPS request host allowed by this grant. Wildcards are intentionally unsupported.
|
/// Exact URL scheme allowed by this target, for example `https` or `http`; `*` is broad.
|
||||||
|
pub scheme: String,
|
||||||
|
/// Exact request host allowed by this target. `*` is broad and must be surfaced in diagnostics.
|
||||||
pub host: String,
|
pub host: String,
|
||||||
/// Uppercase HTTP methods allowed for this host, for example `GET` or `POST`.
|
/// Optional exact port. `None` means the scheme default or any explicit port for that host.
|
||||||
|
pub port: Option<u16>,
|
||||||
|
/// Uppercase HTTP methods allowed for this target, for example `GET` or `POST`.
|
||||||
pub methods: Vec<String>,
|
pub methods: Vec<String>,
|
||||||
/// Optional path prefixes allowed for this host. Empty means any absolute path on the host.
|
/// Optional path prefixes allowed for this target. Empty means any absolute path on the host.
|
||||||
pub path_prefixes: Vec<String>,
|
pub path_prefixes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginHttpsGrant {
|
impl PluginRequestGrant {
|
||||||
pub fn label(&self) -> String {
|
pub fn label(&self) -> String {
|
||||||
|
let scheme = if self.scheme.trim().is_empty() {
|
||||||
|
"<no-scheme>"
|
||||||
|
} else {
|
||||||
|
self.scheme.as_str()
|
||||||
|
};
|
||||||
|
let host = if self.host.trim().is_empty() {
|
||||||
|
"<no-host>"
|
||||||
|
} else {
|
||||||
|
self.host.as_str()
|
||||||
|
};
|
||||||
|
let port = self.port.map(|port| format!(":{port}")).unwrap_or_default();
|
||||||
let methods = if self.methods.is_empty() {
|
let methods = if self.methods.is_empty() {
|
||||||
"<no-methods>".to_string()
|
"<no-methods>".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -233,7 +248,16 @@ impl PluginHttpsGrant {
|
||||||
} else {
|
} else {
|
||||||
self.path_prefixes.join(",")
|
self.path_prefixes.join(",")
|
||||||
};
|
};
|
||||||
format!("{} {} {}", self.host, methods, paths)
|
let broad = if self.is_broad() {
|
||||||
|
" [broad-request]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("{scheme}://{host}{port} {methods} {paths}{broad}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_broad(&self) -> bool {
|
||||||
|
self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,14 +346,14 @@ impl PluginPermission {
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum PluginHostApi {
|
pub enum PluginHostApi {
|
||||||
Https,
|
Request,
|
||||||
Fs,
|
Fs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for PluginHostApi {
|
impl fmt::Display for PluginHostApi {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Https => f.write_str("https"),
|
Self::Request => f.write_str("request"),
|
||||||
Self::Fs => f.write_str("fs"),
|
Self::Fs => f.write_str("fs"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -452,6 +476,10 @@ pub struct PluginPackageManifest {
|
||||||
/// enablement grants must match them before runtime surfaces are exposed.
|
/// enablement grants must match them before runtime surfaces are exposed.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permissions: Vec<PluginPermission>,
|
pub permissions: Vec<PluginPermission>,
|
||||||
|
/// Manifest-declared URL targets for `host_api.request`. These are static permission requests;
|
||||||
|
/// enablement grants must explicitly approve matching targets.
|
||||||
|
#[serde(default)]
|
||||||
|
pub request: Vec<PluginRequestGrant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginPackageManifest {
|
impl PluginPackageManifest {
|
||||||
|
|
@ -2586,6 +2614,100 @@ mod tests {
|
||||||
assert_eq!(manifest.tools.len(), 1);
|
assert_eq!(manifest.tools.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_host_api_manifest_and_grant_parse_with_request_names() {
|
||||||
|
let manifest: PluginPackageManifest = toml::from_str(
|
||||||
|
r#"
|
||||||
|
schema_version = 1
|
||||||
|
id = "example"
|
||||||
|
name = "Example"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Example plugin"
|
||||||
|
surfaces = ["tool"]
|
||||||
|
|
||||||
|
[[permissions]]
|
||||||
|
kind = "host_api"
|
||||||
|
api = "request"
|
||||||
|
|
||||||
|
[[request]]
|
||||||
|
scheme = "https"
|
||||||
|
host = "api.example.com"
|
||||||
|
port = 443
|
||||||
|
methods = ["GET", "POST"]
|
||||||
|
path_prefixes = ["/v1/"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
manifest.permissions,
|
||||||
|
vec![PluginPermission::host_api(PluginHostApi::Request)]
|
||||||
|
);
|
||||||
|
assert_eq!(manifest.request.len(), 1);
|
||||||
|
assert_eq!(manifest.request[0].scheme, "https");
|
||||||
|
assert_eq!(manifest.request[0].host, "api.example.com");
|
||||||
|
assert_eq!(manifest.request[0].port, Some(443));
|
||||||
|
assert_eq!(
|
||||||
|
manifest.request[0].label(),
|
||||||
|
"https://api.example.com:443 GET,POST /v1/"
|
||||||
|
);
|
||||||
|
|
||||||
|
let grants: PluginGrantConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
permissions = [{ kind = "host_api", api = "request" }]
|
||||||
|
|
||||||
|
[[request]]
|
||||||
|
scheme = "http"
|
||||||
|
host = "localhost"
|
||||||
|
port = 8080
|
||||||
|
methods = ["GET"]
|
||||||
|
path_prefixes = ["/health"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
grants.permissions,
|
||||||
|
vec![PluginPermission::host_api(PluginHostApi::Request)]
|
||||||
|
);
|
||||||
|
assert_eq!(grants.request[0].scheme, "http");
|
||||||
|
assert_eq!(grants.request[0].host, "localhost");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_https_request_names_are_not_accepted() {
|
||||||
|
let manifest_error = toml::from_str::<PluginPackageManifest>(
|
||||||
|
r#"
|
||||||
|
schema_version = 1
|
||||||
|
id = "example"
|
||||||
|
name = "Example"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Example plugin"
|
||||||
|
surfaces = ["tool"]
|
||||||
|
|
||||||
|
[[permissions]]
|
||||||
|
kind = "host_api"
|
||||||
|
api = "https"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect_err(concat!(
|
||||||
|
"host_api.",
|
||||||
|
"https",
|
||||||
|
" must not be an active alias"
|
||||||
|
));
|
||||||
|
assert!(manifest_error.to_string().contains("unknown variant"));
|
||||||
|
|
||||||
|
let grant_error = toml::from_str::<PluginGrantConfig>(
|
||||||
|
r#"
|
||||||
|
permissions = [{ kind = "host_api", api = "request" }]
|
||||||
|
|
||||||
|
[[https]]
|
||||||
|
host = "api.example.com"
|
||||||
|
methods = ["GET"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect_err(concat!("grants.", "https", " must not be an active alias"));
|
||||||
|
assert!(grant_error.to_string().contains("unknown field"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedded_rust_component_instance_template_is_valid_package_shape() {
|
fn embedded_rust_component_instance_template_is_valid_package_shape() {
|
||||||
let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE
|
let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE
|
||||||
|
|
@ -3067,7 +3189,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
||||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||||
digest: Some(digest.clone()),
|
digest: Some(digest.clone()),
|
||||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
};
|
};
|
||||||
let resolution = resolve_enabled_plugins(
|
let resolution = resolve_enabled_plugins(
|
||||||
|
|
@ -3094,7 +3216,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
||||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||||
digest: Some(digest.clone()),
|
digest: Some(digest.clone()),
|
||||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
PluginGrantConfig {
|
PluginGrantConfig {
|
||||||
|
|
@ -3102,7 +3224,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
||||||
version: Some(PluginExactVersion("0.1.1".to_string())),
|
version: Some(PluginExactVersion("0.1.1".to_string())),
|
||||||
digest: Some(digest.clone()),
|
digest: Some(digest.clone()),
|
||||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
PluginGrantConfig {
|
PluginGrantConfig {
|
||||||
|
|
@ -3110,7 +3232,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
||||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||||
digest: Some("sha256:unrelated".to_string()),
|
digest: Some("sha256:unrelated".to_string()),
|
||||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5378,6 +5378,7 @@ permission = "read"
|
||||||
services: vec![],
|
services: vec![],
|
||||||
ingresses: vec![],
|
ingresses: vec![],
|
||||||
permissions: vec![],
|
permissions: vec![],
|
||||||
|
request: vec![],
|
||||||
},
|
},
|
||||||
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||||
grants: manifest::plugin::PluginGrantConfig::default(),
|
grants: manifest::plugin::PluginGrantConfig::default(),
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ fn inspect_materialized_package(
|
||||||
)),
|
)),
|
||||||
digest: Some(materialized.package.digest.clone()),
|
digest: Some(materialized.package.digest.clone()),
|
||||||
permissions: requested_permissions,
|
permissions: requested_permissions,
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
config: None,
|
config: None,
|
||||||
|
|
@ -723,7 +723,7 @@ fn render_show(reference: &str, args: &PluginCliArgs) -> Result<String> {
|
||||||
return Ok(format!("{}\n", serde_json::to_string_pretty(item)?));
|
return Ok(format!("{}\n", serde_json::to_string_pretty(item)?));
|
||||||
}
|
}
|
||||||
|
|
||||||
render_item_human(item)
|
render_item_human(&item)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
|
fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
|
||||||
|
|
@ -799,8 +799,8 @@ fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
|
||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" configured_https_grants: {}",
|
" configured_request_grants: {}",
|
||||||
join_or_none(&item.configured_https_grants)
|
join_or_none(&item.configured_request_grants)
|
||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
|
|
@ -976,7 +976,7 @@ fn snapshot_from_resolution(
|
||||||
builder.configured = true;
|
builder.configured = true;
|
||||||
builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied());
|
builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied());
|
||||||
builder.configured_grants = permission_strings(&enablement.grants.permissions);
|
builder.configured_grants = permission_strings(&enablement.grants.permissions);
|
||||||
builder.configured_https_grants = https_grant_strings(&enablement.grants.https);
|
builder.configured_request_grants = request_grant_strings(&enablement.grants.request);
|
||||||
builder.configured_fs_grants = fs_grant_strings(&enablement.grants.fs);
|
builder.configured_fs_grants = fs_grant_strings(&enablement.grants.fs);
|
||||||
if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) {
|
if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) {
|
||||||
builder
|
builder
|
||||||
|
|
@ -1069,7 +1069,7 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) {
|
||||||
builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied());
|
builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied());
|
||||||
builder.requested_permissions = permission_strings(&resolved.manifest.permissions);
|
builder.requested_permissions = permission_strings(&resolved.manifest.permissions);
|
||||||
builder.configured_grants = permission_strings(&resolved.grants.permissions);
|
builder.configured_grants = permission_strings(&resolved.grants.permissions);
|
||||||
builder.configured_https_grants = https_grant_strings(&resolved.grants.https);
|
builder.configured_request_grants = request_grant_strings(&resolved.grants.request);
|
||||||
builder.configured_fs_grants = fs_grant_strings(&resolved.grants.fs);
|
builder.configured_fs_grants = fs_grant_strings(&resolved.grants.fs);
|
||||||
|
|
||||||
let record = ResolvedPluginRecord::from_resolved(resolved);
|
let record = ResolvedPluginRecord::from_resolved(resolved);
|
||||||
|
|
@ -1178,7 +1178,7 @@ fn permission_strings(permissions: &[PluginPermission]) -> Vec<String> {
|
||||||
values
|
values
|
||||||
}
|
}
|
||||||
|
|
||||||
fn https_grant_strings(grants: &[manifest::plugin::PluginHttpsGrant]) -> Vec<String> {
|
fn request_grant_strings(grants: &[manifest::plugin::PluginRequestGrant]) -> Vec<String> {
|
||||||
let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect();
|
let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect();
|
||||||
values.sort();
|
values.sort();
|
||||||
values.dedup();
|
values.dedup();
|
||||||
|
|
@ -1262,7 +1262,7 @@ struct PluginInspectionItem {
|
||||||
enabled_surfaces: Vec<String>,
|
enabled_surfaces: Vec<String>,
|
||||||
requested_permissions: Vec<String>,
|
requested_permissions: Vec<String>,
|
||||||
configured_grants: Vec<String>,
|
configured_grants: Vec<String>,
|
||||||
configured_https_grants: Vec<String>,
|
configured_request_grants: Vec<String>,
|
||||||
configured_fs_grants: Vec<String>,
|
configured_fs_grants: Vec<String>,
|
||||||
tools: Vec<ToolSummary>,
|
tools: Vec<ToolSummary>,
|
||||||
static_runtime: Option<PluginStaticInspection>,
|
static_runtime: Option<PluginStaticInspection>,
|
||||||
|
|
@ -1331,7 +1331,7 @@ struct ItemBuilder {
|
||||||
enabled_surfaces: Vec<String>,
|
enabled_surfaces: Vec<String>,
|
||||||
requested_permissions: Vec<String>,
|
requested_permissions: Vec<String>,
|
||||||
configured_grants: Vec<String>,
|
configured_grants: Vec<String>,
|
||||||
configured_https_grants: Vec<String>,
|
configured_request_grants: Vec<String>,
|
||||||
configured_fs_grants: Vec<String>,
|
configured_fs_grants: Vec<String>,
|
||||||
tools: Vec<ToolSummary>,
|
tools: Vec<ToolSummary>,
|
||||||
static_runtime: Option<PluginStaticInspection>,
|
static_runtime: Option<PluginStaticInspection>,
|
||||||
|
|
@ -1358,7 +1358,7 @@ impl ItemBuilder {
|
||||||
enabled_surfaces: Vec::new(),
|
enabled_surfaces: Vec::new(),
|
||||||
requested_permissions: Vec::new(),
|
requested_permissions: Vec::new(),
|
||||||
configured_grants: Vec::new(),
|
configured_grants: Vec::new(),
|
||||||
configured_https_grants: Vec::new(),
|
configured_request_grants: Vec::new(),
|
||||||
configured_fs_grants: Vec::new(),
|
configured_fs_grants: Vec::new(),
|
||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
static_runtime: None,
|
static_runtime: None,
|
||||||
|
|
@ -1430,7 +1430,7 @@ impl ItemBuilder {
|
||||||
enabled_surfaces: self.enabled_surfaces,
|
enabled_surfaces: self.enabled_surfaces,
|
||||||
requested_permissions: self.requested_permissions,
|
requested_permissions: self.requested_permissions,
|
||||||
configured_grants: self.configured_grants,
|
configured_grants: self.configured_grants,
|
||||||
configured_https_grants: self.configured_https_grants,
|
configured_request_grants: self.configured_request_grants,
|
||||||
configured_fs_grants: self.configured_fs_grants,
|
configured_fs_grants: self.configured_fs_grants,
|
||||||
tools: self.tools,
|
tools: self.tools,
|
||||||
static_runtime: self.static_runtime,
|
static_runtime: self.static_runtime,
|
||||||
|
|
@ -1443,6 +1443,7 @@ impl ItemBuilder {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig};
|
use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig};
|
||||||
|
use pod::feature::plugin::{PluginPermissionEligibility, PluginRuntimeEligibility};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1494,7 +1495,7 @@ mod tests {
|
||||||
assert_eq!(show_json["configured_grants"][0], "surfaces.tool");
|
assert_eq!(show_json["configured_grants"][0], "surfaces.tool");
|
||||||
assert_eq!(show_json["tools"][0]["permission"], "tool.Echo");
|
assert_eq!(show_json["tools"][0]["permission"], "tool.Echo");
|
||||||
|
|
||||||
let show = render_item_human(item).unwrap();
|
let show = render_item_human(&item).unwrap();
|
||||||
assert!(show.contains("status: active"));
|
assert!(show.contains("status: active"));
|
||||||
assert!(show.contains("schema_version: 1"));
|
assert!(show.contains("schema_version: 1"));
|
||||||
assert!(show.contains("api_version: 1"));
|
assert!(show.contains("api_version: 1"));
|
||||||
|
|
@ -1503,6 +1504,78 @@ mod tests {
|
||||||
assert!(show.contains("configured_grants: surfaces.tool, tool.Echo"));
|
assert!(show.contains("configured_grants: surfaces.tool, tool.Echo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_show_distinguishes_request_grant_statuses_and_broad_targets() {
|
||||||
|
let item = PluginInspectionItem {
|
||||||
|
reference: "project:req".to_string(),
|
||||||
|
local_ref: Some("project:req".to_string()),
|
||||||
|
status: "configured".to_string(),
|
||||||
|
source: Some("project".to_string()),
|
||||||
|
package: Some("req".to_string()),
|
||||||
|
package_path: None,
|
||||||
|
version: Some("0.1.0".to_string()),
|
||||||
|
schema_version: Some(1),
|
||||||
|
api_version: Some(1),
|
||||||
|
digest: None,
|
||||||
|
configured: true,
|
||||||
|
discovered: true,
|
||||||
|
resolved: true,
|
||||||
|
static_eligible: true,
|
||||||
|
declared_surfaces: vec!["tool".to_string()],
|
||||||
|
enabled_surfaces: vec!["tool".to_string()],
|
||||||
|
requested_permissions: vec!["host_api.request".to_string()],
|
||||||
|
configured_grants: vec!["host_api.request".to_string()],
|
||||||
|
configured_request_grants: vec!["*://* GET * [broad-request]".to_string()],
|
||||||
|
configured_fs_grants: Vec::new(),
|
||||||
|
tools: Vec::new(),
|
||||||
|
static_runtime: Some(PluginStaticInspection {
|
||||||
|
runtime: PluginRuntimeEligibility {
|
||||||
|
eligible: true,
|
||||||
|
status: "component".to_string(),
|
||||||
|
diagnostic: None,
|
||||||
|
},
|
||||||
|
host_apis: vec![
|
||||||
|
PluginPermissionEligibility {
|
||||||
|
permission: "host_api.request target https://api.example.test GET /v1/"
|
||||||
|
.to_string(),
|
||||||
|
requested: true,
|
||||||
|
granted: true,
|
||||||
|
eligible: true,
|
||||||
|
diagnostic: Some(
|
||||||
|
"covered by broad/arbitrary enabled request grant".to_string(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
PluginPermissionEligibility {
|
||||||
|
permission: "host_api.request grant *://* GET * [broad-request]"
|
||||||
|
.to_string(),
|
||||||
|
requested: true,
|
||||||
|
granted: true,
|
||||||
|
eligible: true,
|
||||||
|
diagnostic: Some(
|
||||||
|
"broad/arbitrary enabled request grant is constrained by manifest declarations"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tools: Vec::new(),
|
||||||
|
services: Vec::new(),
|
||||||
|
ingresses: Vec::new(),
|
||||||
|
}),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_value(&item).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
json["configured_request_grants"][0],
|
||||||
|
"*://* GET * [broad-request]"
|
||||||
|
);
|
||||||
|
let human = render_item_human(&item).unwrap();
|
||||||
|
assert!(human.contains("host_api.request target https://api.example.test"));
|
||||||
|
assert!(human.contains("requested=true granted=true eligible=true"));
|
||||||
|
assert!(human.contains("host_api.request grant *://*"));
|
||||||
|
assert!(human.contains("broad/arbitrary"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn service_only_enablement_ignores_unselected_tool_static_grants() {
|
fn service_only_enablement_ignores_unselected_tool_static_grants() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|
@ -1522,7 +1595,7 @@ mod tests {
|
||||||
PluginPermission::surface(PluginSurface::Service),
|
PluginPermission::surface(PluginSurface::Service),
|
||||||
PluginPermission::service("svc"),
|
PluginPermission::service("svc"),
|
||||||
],
|
],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
config: None,
|
config: None,
|
||||||
|
|
@ -1576,7 +1649,7 @@ mod tests {
|
||||||
PluginPermission::surface(PluginSurface::Tool),
|
PluginPermission::surface(PluginSurface::Tool),
|
||||||
PluginPermission::tool("Echo"),
|
PluginPermission::tool("Echo"),
|
||||||
],
|
],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
config: None,
|
config: None,
|
||||||
|
|
@ -1594,7 +1667,7 @@ mod tests {
|
||||||
PluginPermission::surface(PluginSurface::Tool),
|
PluginPermission::surface(PluginSurface::Tool),
|
||||||
PluginPermission::tool("Echo"),
|
PluginPermission::tool("Echo"),
|
||||||
],
|
],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
config: None,
|
config: None,
|
||||||
|
|
@ -1712,7 +1785,7 @@ mod tests {
|
||||||
PluginPermission::surface(PluginSurface::Tool),
|
PluginPermission::surface(PluginSurface::Tool),
|
||||||
PluginPermission::tool("Echo"),
|
PluginPermission::tool("Echo"),
|
||||||
],
|
],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
config: None,
|
config: None,
|
||||||
|
|
@ -2247,7 +2320,7 @@ lifecycle = "host-managed"
|
||||||
PluginPermission::surface(PluginSurface::Tool),
|
PluginPermission::surface(PluginSurface::Tool),
|
||||||
PluginPermission::tool("Echo"),
|
PluginPermission::tool("Echo"),
|
||||||
],
|
],
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
config: None,
|
config: None,
|
||||||
|
|
@ -2278,7 +2351,7 @@ lifecycle = "host-managed"
|
||||||
version: Some(PluginExactVersion(version.to_string())),
|
version: Some(PluginExactVersion(version.to_string())),
|
||||||
digest: Some(digest),
|
digest: Some(digest),
|
||||||
permissions,
|
permissions,
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
config: None,
|
config: None,
|
||||||
|
|
@ -2306,7 +2379,7 @@ lifecycle = "host-managed"
|
||||||
version: Some(PluginExactVersion(version.to_string())),
|
version: Some(PluginExactVersion(version.to_string())),
|
||||||
digest: None,
|
digest: None,
|
||||||
permissions,
|
permissions,
|
||||||
https: Vec::new(),
|
request: Vec::new(),
|
||||||
fs: Vec::new(),
|
fs: Vec::new(),
|
||||||
},
|
},
|
||||||
config: None,
|
config: None,
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ runtime, so registration and execution still flow through the existing
|
||||||
ToolRegistry and Worker Tool-result history path.
|
ToolRegistry and Worker Tool-result history path.
|
||||||
|
|
||||||
Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files
|
Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files
|
||||||
live in `resources/plugin/wit/`. Importing `yoi:host/https@1.0.0` or
|
live in `resources/plugin/wit/`. Importing `yoi:host/request@1.0.0` or
|
||||||
`yoi:host/fs@1.0.0` is not authority. The runtime checks package grants before
|
`yoi:host/fs@1.0.0` is not authority. The runtime checks package grants before
|
||||||
component instantiation and checks again on every host call. No WASI filesystem,
|
component instantiation and checks again on every host call. No WASI filesystem,
|
||||||
network, environment, or other ambient imports are linked.
|
network, environment, or other ambient imports are linked.
|
||||||
|
|
@ -176,7 +176,7 @@ The v1 component world intentionally keeps Tool input, Tool output, and host API
|
||||||
payloads as JSON strings. This is a migration bridge that preserves the existing
|
payloads as JSON strings. This is a migration bridge that preserves the existing
|
||||||
ToolOutput schema, Tool history behavior, grant checks, and raw-Wasm host API
|
ToolOutput schema, Tool history behavior, grant checks, and raw-Wasm host API
|
||||||
semantics while moving package authors onto WIT/canonical ABI bindings.
|
semantics while moving package authors onto WIT/canonical ABI bindings.
|
||||||
Structured WIT records for Tool requests/responses/errors and host HTTPS/FS
|
Structured WIT records for Tool requests/responses/errors and host request/FS
|
||||||
payloads are deferred to a follow-up API-design step rather than accidentally
|
payloads are deferred to a follow-up API-design step rather than accidentally
|
||||||
omitted.
|
omitted.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,29 +294,46 @@ rejected invalid manifest, incompatible API, digest mismatch, grant denial, etc
|
||||||
partial usable package with some rejected surfaces/tools
|
partial usable package with some rejected surfaces/tools
|
||||||
```
|
```
|
||||||
|
|
||||||
## `https` host API
|
## `request` host API
|
||||||
|
|
||||||
The `https` host API is outbound-only and grant-gated. It is meant for Tool calls such as JSON POSTs or REST requests. It is not a WebSocket/Gateway or inbound HTTP surface.
|
The `request` host API is a one-shot outbound HTTP request API. It is meant for bounded Tool calls such as JSON POSTs or REST requests. It is not a WebSocket, SSE/event-stream, gateway, daemon, or inbound HTTP surface; persistent transports require a separate Plugin capability.
|
||||||
|
|
||||||
Manifest permissions should request `host_api.https` in addition to the Tool permissions. Enablement grants must then allow the API and constrain hosts/methods.
|
Manifest permissions should request `host_api.request` in addition to the Tool permissions, and the package manifest must statically declare the URL targets it may call. Enablement grants must then allow the API and grant matching request targets. A grant without a matching manifest target is unsafe/unused and is shown as ineligible rather than expanding authority.
|
||||||
|
|
||||||
Example grant shape:
|
Example manifest shape:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
permissions = [
|
||||||
|
{ kind = "surface", surface = "tool" },
|
||||||
|
{ kind = "tool", name = "http_post_json" },
|
||||||
|
{ kind = "host_api", api = "request" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[request]]
|
||||||
|
scheme = "https"
|
||||||
|
host = "api.example.com"
|
||||||
|
methods = ["POST"]
|
||||||
|
path_prefixes = ["/v1/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Example enablement grant shape:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[plugins.enabled.grants]
|
[plugins.enabled.grants]
|
||||||
permissions = [
|
permissions = [
|
||||||
{ kind = "surface", surface = "tool" },
|
{ kind = "surface", surface = "tool" },
|
||||||
{ kind = "tool", name = "http_post_json" },
|
{ kind = "tool", name = "http_post_json" },
|
||||||
{ kind = "host_api", api = "https" },
|
{ kind = "host_api", api = "request" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[plugins.enabled.grants.https]]
|
[[plugins.enabled.grants.request]]
|
||||||
|
scheme = "https"
|
||||||
host = "api.example.com"
|
host = "api.example.com"
|
||||||
methods = ["POST"]
|
methods = ["POST"]
|
||||||
path_prefixes = ["/v1/"]
|
path_prefixes = ["/v1/"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Yoi rejects `http://`, localhost/private/link-local targets, disallowed hosts/methods, oversize requests/responses, and missing grants. Credentials must come from explicit config/secret references, not ambient environment variables.
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package yoi:host@1.0.0;
|
package yoi:host@1.0.0;
|
||||||
|
|
||||||
/// Grant-bound HTTPS host API. Importing this interface does not grant
|
/// Grant-bound one-shot HTTP request host API. Importing this interface does not grant
|
||||||
/// authority; package grants are checked before registration/execution and on
|
/// authority; package grants are checked before registration/execution and on
|
||||||
/// every host call.
|
/// every host call.
|
||||||
interface https {
|
interface request {
|
||||||
request: func(request-json: string) -> string;
|
request: func(request-json: string) -> string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package yoi:plugin@1.0.0;
|
package yoi:plugin@1.0.0;
|
||||||
|
|
||||||
world instance {
|
world instance {
|
||||||
import yoi:host/https@1.0.0;
|
import yoi:host/request@1.0.0;
|
||||||
import yoi:host/fs@1.0.0;
|
import yoi:host/fs@1.0.0;
|
||||||
|
|
||||||
export start: func(config-json: string) -> string;
|
export start: func(config-json: string) -> string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package yoi:plugin@1.0.0;
|
package yoi:plugin@1.0.0;
|
||||||
|
|
||||||
world tool {
|
world tool {
|
||||||
import yoi:host/https@1.0.0;
|
import yoi:host/request@1.0.0;
|
||||||
import yoi:host/fs@1.0.0;
|
import yoi:host/fs@1.0.0;
|
||||||
|
|
||||||
/// Execute a manifest-declared Tool. `input-json` is the normal Tool input
|
/// Execute a manifest-declared Tool. `input-json` is the normal Tool input
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user