diff --git a/crates/panday-plugins/src/lib.rs b/crates/panday-plugins/src/lib.rs index fff9da3..c167bd8 100644 --- a/crates/panday-plugins/src/lib.rs +++ b/crates/panday-plugins/src/lib.rs @@ -60,7 +60,16 @@ pub enum FsCapability { pub struct Capabilities { #[serde(default)] pub fs: FsCapability, - /// Domain allowlist for egress; empty = no network. + /// Domains the plugin *requests* egress to. + /// + /// **Requested, never granted.** No sandbox tier can enforce a per-domain allowlist — the + /// egress proxy docs/14 §policy describes is not built — and since M14.8 a `NetPolicy` naming + /// a host is refused outright rather than approximated. So a plugin declaring `net` gets no + /// network, and the consent prompt says so in those words. + /// + /// Kept in the manifest rather than rejected at parse time, so a plugin author can express the + /// requirement once and have it mean something the day the proxy lands. What is not kept is + /// the *impression* that it already does. #[serde(default)] pub net: Vec, /// Secret names the plugin may receive (user consents at install). @@ -216,10 +225,19 @@ impl PluginManifest { FsCapability::WorkspaceRw => " filesystem: READ AND WRITE the workspace".into(), }); + // "a capability that grants nothing in the sandbox is a lie told at the consent prompt" + // — the note on `requested_fs_writable` below, which this line used to be an example of. + // It read `network: api.github.com`, which a person would reasonably take as "this plugin + // may reach that host". Nothing granted it then and nothing grants it now, so the prompt + // has to say which of the two it means. if self.capabilities.net.is_empty() { lines.push(" network: none".into()); } else { - lines.push(format!(" network: {}", self.capabilities.net.join(", "))); + lines.push(format!( + " network: NONE — requested {} but no tier can grant it (M14.8); the plugin will \ + have no egress", + self.capabilities.net.join(", ") + )); } if self.capabilities.secrets.is_empty() { diff --git a/crates/panday-plugins/tests/plugins.rs b/crates/panday-plugins/tests/plugins.rs index 57946d0..2d5e1c8 100644 --- a/crates/panday-plugins/tests/plugins.rs +++ b/crates/panday-plugins/tests/plugins.rs @@ -169,6 +169,33 @@ fn the_consent_summary_names_every_grant_individually() { ); } +#[test] +fn a_requested_network_capability_is_not_presented_as_a_grant() { + // The consent prompt is the only place a person decides. It used to render + // `network: api.github.com`, which reads as "this plugin may reach that host" — and nothing + // granted it. No tier can: the egress proxy docs/14 describes is unbuilt, and since M14.8 a + // `NetPolicy` naming a host is refused rather than approximated. + // + // The hosts stay in the text (a user refusing a plugin needs specifics), but the line has to + // say the request is not honoured, or the prompt is asking consent for something that will + // not happen. + let m = PluginManifest::load(&fixture("example-plugin/plugin.toml")).unwrap(); + let text = m.consent_summary(); + let line = text + .lines() + .find(|l| l.trim_start().starts_with("network:")) + .expect("a network line"); + + assert!( + line.contains("api.github.com"), + "the requested host must still be named: {line}" + ); + assert!( + line.contains("NONE"), + "the line must say the request is not granted, not merely list it: {line}" + ); +} + #[test] fn a_manifest_requesting_nothing_says_so_explicitly() { // Silence in a consent prompt reads as "unknown", which is worse than diff --git a/docs/16-plugins.md b/docs/16-plugins.md index 1147e20..dbf97bd 100644 --- a/docs/16-plugins.md +++ b/docs/16-plugins.md @@ -22,6 +22,17 @@ my-plugin/ `fs: workspace-ro`, `net: [api.github.com]`, `secrets: [GITHUB_TOKEN]`, `hooks: [pre_tool]`. Install-time consent; the sandbox tiers enforce (T1 WIT world for WASM, MCP servers run as T2 children with that policy). + +**Except `net`, which is requested and never granted.** No tier can enforce a +per-domain allowlist — the egress proxy `docs/14` §policy describes is not +built — and since M14.8 a `NetPolicy` naming a host is refused outright rather +than approximated. A plugin declaring `net` therefore gets *no* egress, and the +consent prompt says so in those words rather than listing the hosts as though +they were granted. Sentence corrected here because it claimed enforcement that +does not exist, which is the specific failure `panday_plugins`' own note warns +about: "a capability that grants nothing in the sandbox is a lie told at the +consent prompt." The field stays in the manifest so the requirement can be +expressed once and mean something the day the proxy lands. Distribution: `.plugin` archive, ed25519-signed; registry tiers `verified | community | unlisted`, with the marketplace being a phase-6 storefront over the same registry API.