From 8c2dec56324ac14dbfd31c87c770249e10ed3301 Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Mon, 24 Aug 2026 04:27:54 +0800 Subject: [PATCH] =?UTF-8?q?plugins:=20`net:`=20is=20requested,=20never=20g?= =?UTF-8?q?ranted=20=E2=80=94=20stop=20the=20consent=20prompt=20implying?= =?UTF-8?q?=20otherwise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `panday_plugins` carries this note above `requested_fs_writable`: The manifest *requests*; the sandbox *enforces* (docs/16). Deriving the policy here keeps the two from drifting — a capability that grants nothing in the sandbox is a lie told at the consent prompt. `net` was that lie. The prompt rendered `network: api.github.com`, which a person reasonably reads as "this plugin may reach that host". Nothing granted it: the value never reached `SandboxPolicy`, no tier can enforce a per-domain allowlist, and since M14.8 a `NetPolicy` naming a host is refused outright. docs/16 meanwhile said "Install-time consent; the sandbox tiers enforce", which was true of `fs`, `secrets` and `hooks` and false of `net`. **Kept the field, fixed the prompt** — the choice the brief asks me to state. Rejecting `net:` at parse time was the alternative; it would break the shipped `example-plugin` fixture and force every author to re-edit the day the proxy lands, to buy nothing a truthful prompt does not already buy. So the manifest still expresses the requirement once, and the prompt now reads: network: NONE — requested api.github.com but no tier can grant it (M14.8); the plugin will have no egress The hosts stay in the text because a user refusing a plugin needs specifics — that is what `the_consent_summary_names_every_grant_individually` exists for, and it still passes. Falsified: restoring the old line turns `a_requested_network_capability_is_not_presented_as_a_grant` red with "the line must say the request is not granted, not merely list it: network: api.github.com". Green after restoring. docs/16 corrected in the same commit, with the reason, per CLAUDE.md §1. Verified: fmt, clippy -D warnings, 1093 workspace tests, schemas, ts-sdk, sbom, deny. Claude-Session: https://claude.ai/code/session_017kFpYDqvz6sKGSkM4YKaRf --- crates/panday-plugins/src/lib.rs | 22 +++++++++++++++++++-- crates/panday-plugins/tests/plugins.rs | 27 ++++++++++++++++++++++++++ docs/16-plugins.md | 11 +++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) 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.