Plug-ins: a permission grant covers only the module that was approved

A grant records the module's SHA-256, but only its permissions were
compared, so a module with other bytes under a granted id inherited the
grant when it listed no new permission.

A grant now covers a module only when the id, the SHA-256 and every
listed permission match. plugin.inspect reports needsApproval for a
changed module, plugin.install refuses it without a new grant and leaves
the installed module and its grant as they are, Install Plug-in and
File > Open ask again, and the plug-in store and plug-in folders skip it.
The same bytes install again without asking, and a module with no
permissions list or an empty one needs no grant. A grant whose hash is
not 64 lower-case hexadecimal digits covers nothing.

plugin.install still trusts the client that sends grant; the docs say so.
This commit is contained in:
dotCooCoo
2026-11-08 08:40:00 -08:00
parent 0a702cc0a5
commit 91d9639e6a
9 changed files with 232 additions and 47 deletions
+8 -7
View File
@@ -333,9 +333,10 @@ fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> {
}
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id
/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant
/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted this
/// very module unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records
/// the grant with the module's hash, keeps it for the next start and installs it → its
/// `plugin.list` entry.
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?);
@@ -362,7 +363,7 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
));
}
// A plug-in that lists permissions installs once the user grants them: whoever installs it
// asks them first, unless they already granted its id all of them.
// asks them first, unless they already granted this very module all of them.
let m = plugin.manifest();
let wanted = m.permissions();
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
@@ -526,7 +527,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Install Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
always,
install
),
@@ -535,7 +536,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Inspect Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)",
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant)",
always,
inspect
),
@@ -545,7 +546,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reload Plug-ins",
["Object", "Plug-ins"],
None,
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}",
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included) → {folder, loaded: [id], failed: [{file, error}]}",
always,
reload
),
+9 -8
View File
@@ -224,8 +224,8 @@ impl Session {
}
/// Keep the plug-ins this session installs in `dir`, and install the ones already there (a
/// module that doesn't load, and one that lists permissions the grants there don't grant it,
/// is skipped with a log record).
/// module that doesn't load, and one that lists permissions the grants there don't grant that
/// very module, is skipped with a log record, and stays in `dir` with its grant).
pub fn attach_plugin_store(&mut self, dir: PathBuf) {
let grants = stored_grants(&dir);
let limits = vectorcraft_plugins::Limits::default();
@@ -244,12 +244,12 @@ impl Session {
// module stored here load.
Ok(plugin) if plugin.manifest().asks_permissions() => {
let m = plugin.manifest();
match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) {
match grants.get(&m.id).filter(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) {
Some(g) => {
vectorcraft_plugins::registry::grant(g.clone());
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()),
None => log::warn!("skipping the stored plug-in {}: the user hasn't approved this module's permissions", path.display()),
}
}
Ok(plugin) => {
@@ -261,9 +261,10 @@ impl Session {
self.plugin_dir = Some(dir);
}
/// Whether `plugin` installs without asking the user: it lists no permissions, or its id was
/// granted every one it lists, in the grants of the attached plug-in store when there is one,
/// else in this process's ([`vectorcraft_plugins::registry::approved`]).
/// Whether `plugin` installs without asking the user: it lists no permissions, or this very
/// module (its id and SHA-256) was granted every one it lists, in the grants of the attached
/// plug-in store when there is one, else in this process's
/// ([`vectorcraft_plugins::registry::approved`]).
pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool {
self.plugin_approver()(plugin)
}
@@ -275,7 +276,7 @@ impl Session {
move |plugin| {
let m = plugin.manifest();
match &grants {
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())),
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())),
_ => vectorcraft_plugins::registry::approved(plugin),
}
}
+140 -7
View File
@@ -532,9 +532,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same
/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the
/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies.
/// hash. The grant is recorded with that hash and covers only that module: one with other bytes
/// needs a new grant. Removing it forgets the grant. A plug-in without `permissions` installs as
/// before, with the ones its kind implies.
#[test]
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
let mut s = session();
@@ -570,11 +570,10 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
assert_eq!(installed["permissions"], json!(["doc.read"]));
let g = vectorcraft_plugins::registry::granted(id).unwrap();
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
// Replaced with another module that lists the same permissions: no new grant needed, and the
// grant now records the new module's hash.
// Replaced with another module that lists the same permissions: granted again, the grant now
// records the new module's hash.
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}));
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"]}));
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
// One that asks for more is refused until it is granted all of them.
let more = data(r#"["doc.read","doc.write"]"#);
@@ -594,6 +593,140 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
}
/// A module with other bytes under an id that was granted is not covered, even when it lists the
/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed
/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes
/// install again without asking, and a module that lists no permissions never asks.
#[test]
fn a_grant_covers_only_the_module_that_was_approved() {
use vectorcraft_plugins::permissions::sha256_hex;
let mut s = session();
let id = "test.engine.permissions.bytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed));
let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed));
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true);
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data})).unwrap_err().to_string();
assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}");
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "hash": changed_hash})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "`hash` is not an approval: {e}");
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays");
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays");
// The same bytes need no new grant.
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false);
run(&mut s, "plugin.install", json!({"dataBase64": first_data}));
// A new grant installs the changed module, and the old bytes are no longer covered.
run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash}));
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash);
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash);
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true);
run(&mut s, "plugin.remove", json!({"id": id}));
// A manifest without a list, or with an empty one, asks for nothing, whatever its bytes.
let exempt = "test.engine.permissions.exempt";
for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] {
let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}");
run(&mut s, "plugin.install", json!({"dataBase64": data}));
}
run(&mut s, "plugin.remove", json!({"id": exempt}));
}
/// A stored module whose bytes changed is skipped at the next start and stays in the store with its
/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing.
/// With a store attached, a matching grant held only by the process doesn't count.
#[test]
fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
let folder = crate::tests_links::Folder::new("plugin-store-grant-hash");
let id = "test.engine.permissions.storedbytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
let module = std::fs::read_dir(&folder.0)
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.extension().is_some_and(|x| x == "wasm"))
.expect("the stored module");
let forget = || {
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
};
let starts = || {
forget();
let mut next = session();
next.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::get(id).is_some()
};
assert!(starts(), "the module that was approved loads");
std::fs::write(&module, &changed).unwrap();
assert!(!starts(), "other bytes under the old grant are skipped");
assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store");
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays");
std::fs::write(&module, &first).unwrap();
assert!(starts(), "the approved bytes load again");
// A malformed hash is no wildcard, even for the module on disk.
let upper = sha256_hex(&first).to_uppercase();
for hash in ["", "00", upper.as_str()] {
std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap();
assert!(!starts(), "hash {hash:?}");
}
// The store's grants are the ones that count, even when the process holds one for these bytes.
std::fs::write(&grants, &recorded).unwrap();
assert!(starts());
let mut attached = session();
attached.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] });
let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)}));
assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached");
run(&mut attached, "plugin.remove", json!({"id": id}));
}
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
/// granted on its own, the changed module loads from the folder. A session without a store uses the
/// grants held by the process.
#[test]
fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() {
let mut s = session();
let id = "test.engine.permissions.folderbytes";
let plain = "test.engine.permissions.folderplain";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash");
let file = dir.0.join("asks.wasm");
std::fs::write(&file, &first).unwrap();
std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
let path = json!({"path": dir.0.display().to_string()});
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}");
std::fs::write(&file, &changed).unwrap();
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!(r["loaded"], json!([plain]), "the other file still loads");
let failed = r["failed"].as_array().unwrap();
assert_eq!(failed.len(), 1, "{r}");
assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}");
let why = failed[0]["error"].as_str().unwrap();
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
let hash = vectorcraft_plugins::permissions::sha256_hex(&first);
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays");
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"]}));
let r = run(&mut s, "plugin.reload", path);
assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}");
run(&mut s, "plugin.remove", json!({"id": id}));
run(&mut s, "plugin.remove", json!({"id": plain}));
}
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
/// `state` it is given no data kept with the document and may not write any.
+41 -8
View File
@@ -199,6 +199,11 @@ pub fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// Is `s` a SHA-256 as [`sha256_hex`] writes it: 64 lower-case hexadecimal digits?
fn is_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved
/// ([`sha256_hex`]) and the permissions it was granted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -209,9 +214,10 @@ pub struct Grant {
}
impl Grant {
/// Does it grant every one of `wanted`?
pub fn covers(&self, wanted: &[Permission]) -> bool {
wanted.iter().all(|w| self.permissions.contains(w))
/// Does it grant every one of `wanted` to the module with this `id` and SHA-256 `hash`? A grant
/// whose own hash is not a valid SHA-256 ([`sha256_hex`]) covers nothing.
pub fn covers(&self, id: &str, hash: &str, wanted: &[Permission]) -> bool {
self.id == id && is_sha256_hex(&self.hash) && self.hash == hash && wanted.iter().all(|w| self.permissions.contains(w))
}
}
@@ -281,10 +287,37 @@ mod tests {
#[test]
fn a_grant_covers_its_permissions_and_no_more() {
let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
assert!(g.covers(&[Permission::DocRead]));
assert!(g.covers(&[]));
assert!(!g.covers(&[Permission::State]));
assert!(!g.covers(&[Permission::DocRead, Permission::State]));
let hash = sha256_hex(b"module");
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
assert!(g.covers("a", &hash, &[Permission::DocRead]));
assert!(g.covers("a", &hash, &[]));
assert!(!g.covers("a", &hash, &[Permission::State]));
assert!(!g.covers("a", &hash, &[Permission::DocRead, Permission::State]));
}
#[test]
fn a_grant_covers_only_the_module_that_was_approved() {
let hash = sha256_hex(b"module");
let wanted = [Permission::DocRead];
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead] };
assert!(g.covers("a", &hash, &wanted));
assert!(!g.covers("b", &hash, &wanted), "another id");
assert!(!g.covers("a", &sha256_hex(b"other"), &wanted), "other bytes");
assert!(!g.covers("a", &sha256_hex(b"other"), &[]), "other bytes, even when nothing is wanted");
}
/// Damaged data can't stand for every module, even when the hash asked about is just as damaged.
#[test]
fn a_grant_with_an_invalid_hash_covers_nothing() {
let upper = sha256_hex(b"module").to_uppercase();
let short = "ab".repeat(31);
let long = "ab".repeat(33);
let not_hex = "g".repeat(64);
let wide = "é".repeat(32);
for hash in ["", "00", upper.as_str(), short.as_str(), long.as_str(), not_hex.as_str(), wide.as_str()] {
let g = Grant { id: "a".into(), hash: hash.into(), permissions: vec![Permission::DocRead] };
assert!(!g.covers("a", hash, &[Permission::DocRead]), "{hash:?}");
assert!(!g.covers("a", hash, &[]), "{hash:?}");
}
}
}
+4 -4
View File
@@ -38,11 +38,11 @@ pub fn revoke(id: &str) -> bool {
}
/// May `plugin` be installed without asking the user: does it list no permissions, or did the user
/// already grant its id every permission it lists? A module with other bytes but no new permission
/// needs no new approval.
/// already grant this very module (its id and SHA-256) every permission it lists? A module with
/// other bytes needs a new approval, even when it lists the same permissions.
pub fn approved(plugin: &Plugin) -> bool {
let m = plugin.manifest();
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions()))
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions()))
}
/// Installs `plugin`, replacing one with the same id.
@@ -125,7 +125,7 @@ pub struct FolderReport {
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]),
/// which installs only once the user agrees, and one that lists permissions the user has not
/// granted its id ([`approved`]).
/// granted this very module ([`approved`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
load_folder_where(dir, |_| Ok(()), approved)
+15 -5
View File
@@ -130,8 +130,8 @@ fn asking(id: &str, name: &str, permissions: &str) -> Vec<u8> {
/// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash
/// and each permission in plain words, with hidden characters from the manifest escaped. Cancel
/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and
/// one that asks for another does.
/// installs nothing, OK installs it; installing the same module again doesn't ask, and one with
/// other bytes does, even when it lists the same permissions, as does one that asks for another.
#[test]
fn installing_a_plugin_that_lists_permissions_asks_first() {
let mut app = app();
@@ -165,11 +165,21 @@ fn installing_a_plugin_that_lists_permissions_asks_first() {
assert!(app.ui.dialog.is_none());
assert!(vectorcraft_plugins::registry::get(id).is_some());
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash));
// Again with the same permissions: installed without asking.
// The same module again: installed without asking.
app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap();
assert!(app.ui.dialog.is_none(), "the same bytes don't ask again");
// Other bytes with the same permissions ask, from both ways in; Cancel keeps the one installed.
let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#);
app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap();
assert!(app.ui.dialog.is_none(), "the same permissions don't ask again");
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap();
assert_eq!(r["pending"], dialogs::confirm::KIND, "other bytes ask again");
dialogs::cancel(&mut app);
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed");
io::open_bytes(&mut app, "tidy2.wasm", &again, None).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "File › Open asks too");
dialogs::confirm(&mut app).unwrap();
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()));
let new_hash = vectorcraft_plugins::permissions::sha256_hex(&again);
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(new_hash), "OK records the new module's hash");
// One that asks for another permission asks again.
let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#);
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap();
+4 -2
View File
@@ -6097,8 +6097,10 @@ runs it where it is called, so the call may take minutes; `notifications/cancell
A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`)
installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass
the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns
an error naming the permissions needed. The grant is kept with the module's hash; a module
with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A
an error naming the permissions needed. The grant is kept with the module's hash and covers only that module: the
same bytes install again without `grant`, and a module with other bytes has `needsApproval: true` and needs a new
`grant` even when it lists the same permissions. `plugin.install` does not check that a person approved `grant`: it
trusts the client that sends it. `plugin.remove` forgets the grant. A
filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged.
A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write
log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log
+10 -6
View File
@@ -26,7 +26,7 @@ This page is the contract for plug-in authors and the reference for the host (`c
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) |
| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). |
| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. |
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. |
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed, unless the user already granted a module with the same SHA-256. With `hash`, a module whose SHA-256 differs is refused. |
| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). |
| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. |
| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). |
@@ -131,16 +131,20 @@ capability model proposed for PhotoCraft plug-ins
names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters.
`plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent
passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the
`plugin.inspect` entry's `hash` so that the module installed is the one the user saw.
`plugin.inspect` entry's `hash` so that the module installed is the one the user saw. `plugin.install` does not
check that a person approved `grant`: it trusts the client that sends it.
- The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the
`Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that
lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a
`Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with
other bytes asks again, even when it lists the same permissions. Until it is approved, the installed module and its
grant stay as they are; once it is, the grant records the new hash. A grant whose hash is not 64 lower-case
hexadecimal digits covers nothing. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a
permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or
removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than
1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept
in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup,
a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload`
install only plug-ins whose permissions were granted, and report the others.
a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins
Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a
`permissions` list, or with an empty one, needs no grant.
- The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its
`documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the
document is refused. A refused output leaves the document as it was.
+1
View File
@@ -1487,6 +1487,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged.
- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module with other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.