plugin: replace https host api with request grants

This commit is contained in:
Keisuke Hirata 2026-06-21 16:47:06 +09:00
parent 4cd4a06e98
commit 962b769989
No known key found for this signature in database
9 changed files with 768 additions and 448 deletions

View File

@ -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

View File

@ -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(),

View File

@ -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,76 @@ 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: false,
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: false,
eligible: false,
diagnostic: Some("missing enabled request grant for manifest target".to_string()),
},
PluginPermissionEligibility {
permission: "host_api.request grant-only *://* GET * [broad-request]"
.to_string(),
requested: false,
granted: true,
eligible: false,
diagnostic: Some(
"enabled request grant has no matching manifest declaration; broad/arbitrary target"
.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=false eligible=false"));
assert!(human.contains("host_api.request grant-only *://*"));
assert!(human.contains("broad/arbitrary target"));
}
#[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 +1593,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 +1647,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 +1665,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 +1783,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 +2318,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 +2349,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 +2377,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,

View File

@ -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.

View File

@ -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. 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

View File

@ -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;
} }

View File

@ -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;

View File

@ -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