Plug-ins: only a person can approve a plug-in's permissions and heavy budget

`plugin.install` accepted `grant` and `allowHeavy` from any client, so
the program installing a plug-in could answer its own permission
prompt.

The desktop app now shows an approval dialog that names the module,
its hash and what it asks for. Confirming it gives the engine a
one-use approval tied to that module id, hash and permission list. A
control or MCP request and a frame of synthetic input cannot confirm
the dialog, and Enter does not confirm it.

Without such an approval, `grant` and `allowHeavy` are refused. The
new `--allow-plugin-grants` flag on `mcp` and `run` lets a person who
starts a headless session accept them from requests. The flag cannot
be combined with `--connect`. A module already approved in the desktop
app still installs through its stored grant.
This commit is contained in:
dotCooCoo
2026-11-08 11:03:38 -08:00
parent 65a1f364f5
commit a4a516e82f
46 changed files with 565 additions and 159 deletions
+27 -6
View File
@@ -72,7 +72,7 @@ const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation
USAGE:
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... [--allow-plugin-grants]
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
(vectorcraft --control 7979), falling back to a headless in-process session.
--automation-read-root / --automation-write-root confine the files the agent's commands read
@@ -80,8 +80,12 @@ USAGE:
DIR, links followed; each may be given more than once, a path inside any of its folders being
allowed, and a kind left out grants none of its access. They imply --headless: a running app
enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this session,
so whoever sends the commands can give a plug-in the permissions it lists, and a heavy budget,
with nobody approving them. Without it they are refused, and a plug-in installs only when a
person already approved its hash in the desktop app. It implies --headless.
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N]
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] [--allow-plugin-grants]
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). Raster exports use the document's
@@ -91,6 +95,8 @@ USAGE:
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
Errors name the step, counting from 0 as \"$N\" references do.
--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this run, as
for mcp.
vectorcraft-cli commands
Print the command catalogue as JSON.
@@ -213,12 +219,21 @@ fn with_stores(mut h: Headless) -> Headless {
h
}
/// Makes `h`'s `plugin.install` accept `grant` and `allowHeavy` when `allow` (`--allow-plugin-grants`).
fn accepting_grants(mut h: Headless, allow: bool) -> Headless {
if allow {
h.session.allow_plugin_grants_from_requests();
}
h
}
fn mcp(args: &[String]) -> Result<(), String> {
if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) {
return help();
}
let mut connect: Option<String> = None;
let mut headless = false;
let mut allow_grants = false;
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
let mut it = args.iter();
while let Some(a) = it.next() {
@@ -230,6 +245,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
match flag {
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),
"--headless" => headless = true,
"--allow-plugin-grants" => allow_grants = true,
"--automation-read-root" | "--automation-write-root" => {
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
@@ -240,6 +256,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
if headless && connect.is_some() {
return Err("use either --connect or --headless".into());
}
if allow_grants && connect.is_some() {
return Err("--allow-plugin-grants applies to the headless server; in a running app a person approves each plug-in".into());
}
let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
let roots = AutomationRoots::new(&read, &write)?;
@@ -251,9 +270,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
} else if headless {
Box::new(with_stores(Headless::with_document()))
Box::new(accepting_grants(Headless::with_document().with_automation_roots(Some(roots)), allow_grants))
} else if headless || allow_grants {
Box::new(accepting_grants(with_stores(Headless::with_document()), allow_grants))
} else if let Some(addr) = connect {
// Explicit address: fail loudly if the app isn't there.
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
@@ -512,6 +531,7 @@ fn run(args: &[String]) -> Result<(), String> {
let mut stdin_read = false;
let mut scale = None::<f64>;
let mut ppi = None::<f64>;
let mut allow_grants = false;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
@@ -546,6 +566,7 @@ fn run(args: &[String]) -> Result<(), String> {
steps.push(Step::Export(val("a file")?));
params_for = None;
}
"--allow-plugin-grants" => allow_grants = true,
"--scale" | "-s" => {
let raw = val("a number")?;
scale = Some(raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?);
@@ -563,7 +584,7 @@ fn run(args: &[String]) -> Result<(), String> {
}
}
let mut h = with_stores(Headless::new());
let mut h = accepting_grants(with_stores(Headless::new()), allow_grants);
let mut out = std::io::stdout().lock();
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
// steps (an export…) still run, unprinted.