scripts: trust gate review fixes (#2178)

- automate.trustScripts: only a missing `trusted` defaults to true; a
  non-boolean value is an error and leaves the workbook untrusted.
- The refusal message points at Trust Scripts in View > Macros (Alt+F8)
  and at automate.trustScripts.
- Catalog row for automate.trustScripts; parity checklist regenerated.
- Script steps run inside an io::ScriptSandbox (thread-local) so
  read_file/write_file refuse whatever the parameter is called, and a
  step that raises OpenUrl is dropped and stops the script. The id/param
  deny list stays as the up-front refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Higgs32584
2026-10-22 12:43:48 -04:00
co-authored by Claude Opus 5.5
parent 6dedb9310b
commit b5b3969377
5 changed files with 129 additions and 7 deletions
+1
View File
@@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
// Table Design
("table.rename", "Table Design", "Properties", "Table Name"),
("table.resize", "Table Design", "Properties", "Resize Table"),
+24 -4
View File
@@ -1017,7 +1017,10 @@ pub(crate) fn has_scripts(wb: &Workbook) -> bool {
/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.)
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by
/// command id and parameter name, so it is only the up-front refusal: the steps also run inside
/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and
/// [`run_script`] drops any `OpenUrl` request a step raises.
fn denied_script_step(id: &str, params: &Json) -> Option<String> {
if id.starts_with("file.") || id.starts_with("link.") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
@@ -1029,7 +1032,12 @@ fn denied_script_step(id: &str, params: &Json) -> Option<String> {
}
fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
let trusted = bool_param(p, "trusted").unwrap_or(true);
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
let trusted = match p.get("trusted") {
None => true,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")),
};
s.doc_mut()?.scripts_trusted = trusted;
Ok(json!({"trusted": trusted}))
}
@@ -1045,7 +1053,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
return Err(bad(
"automate.runScript",
"scripts in this workbook came from a file and are disabled; trust the workbook first (Automate › Macros › Trust Scripts, or `trusted: true`)",
"scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)",
));
}
let steps: Vec<Json> = steps.into_iter().take(100_000).collect();
@@ -1055,13 +1063,25 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("automate.runScript", why));
}
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
s.execute(id, st["params"].clone())?;
let before = s.ui_requests.len();
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
let mut i = 0;
s.ui_requests.retain(|r| {
i += 1;
i <= before || !matches!(r, crate::UiRequest::OpenUrl(_))
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
n += 1;
}
Ok(json!({"ran": n}))
+62
View File
@@ -556,8 +556,41 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
Ok(out)
}
thread_local! {
/// How many [`ScriptSandbox`]es are alive on this thread.
static SCRIPT_SANDBOX: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
/// stored script's steps run inside one (#2178), so no command reaches the file system from a
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
pub(crate) struct ScriptSandbox(());
impl ScriptSandbox {
pub(crate) fn enter() -> Self {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
ScriptSandbox(())
}
}
impl Drop for ScriptSandbox {
fn drop(&mut self) {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1)));
}
}
/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the
/// thread is being torn down).
fn script_sandbox_check(path: &str) -> Result<()> {
if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
return Err(EngineError::Other(format!("{path}: scripts can't read or write files")));
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
script_sandbox_check(path)?;
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 2 * 1024 * 1024 * 1024 {
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
@@ -567,11 +600,13 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
#[cfg(target_arch = "wasm32")]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
script_sandbox_check(path)?;
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
script_sandbox_check(path)?;
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
@@ -584,6 +619,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(target_arch = "wasm32")]
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
script_sandbox_check(path)?;
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
@@ -902,6 +938,32 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// While a script runs (#2178), file reads and writes are refused whatever command or
/// parameter asked for them; nesting and dropping the sandbox restore access.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn script_sandbox_refuses_file_access_until_dropped() {
let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("in.csv");
std::fs::write(&existing, b"a,b\n").unwrap();
let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string());
{
let _outer = ScriptSandbox::enter();
{
let _inner = ScriptSandbox::enter();
assert!(read_file(&existing).is_err());
}
assert!(read_file(&existing).is_err(), "the outer sandbox still holds");
assert!(write_file(&out, b"leak").is_err());
assert!(!std::path::Path::new(&out).exists());
}
assert_eq!(read_file(&existing).unwrap(), b"a,b\n");
write_file(&out, b"ok").unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
+38
View File
@@ -1147,6 +1147,44 @@ fn untrusted_workbook_scripts_are_refused_until_trusted() {
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
}
#[test]
fn trust_scripts_only_defaults_to_trusted_when_the_flag_is_missing() {
let mut s = opened_with_script();
// A `trusted` that isn't a boolean is an error and leaves the workbook untrusted.
for wrong in [json!("false"), json!("true"), json!(0), json!(1), json!(null), json!([true])] {
let err = s.execute("automate.trustScripts", json!({"trusted": wrong})).unwrap_err().to_string();
assert!(err.contains("trusted"), "{wrong}: {err}");
assert!(!s.doc().unwrap().scripts_trusted, "{wrong} must not trust the workbook");
assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
}
// The refusal names where the Trust Scripts button actually is.
let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string();
assert!(err.contains("View › Macros") && err.contains("automate.trustScripts"), "{err}");
assert_eq!(s.execute("automate.trustScripts", json!({"trusted": false})).unwrap()["trusted"], false);
assert!(!s.doc().unwrap().scripts_trusted);
assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true);
assert!(s.doc().unwrap().scripts_trusted);
}
#[test]
fn scripts_leave_file_access_working_after_they_run() {
// The sandbox a script runs in is lifted afterwards, also when a step fails.
let dir = std::env::temp_dir().join(format!("gridcraft-script-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("in.csv");
std::fs::write(&csv, b"a,b\n1,2\n").unwrap();
let csv = csv.to_string_lossy().to_string();
let mut s = s();
let bad_step = json!({"command": "cell.set", "params": {"cell": "not a cell", "input": "x"}});
s.execute("automate.newScript", json!({"name": "Bad", "commands": [bad_step]})).unwrap();
assert!(s.execute("automate.runScript", json!({"name": "Bad"})).is_err());
s.execute("automate.newScript", json!({"name": "Ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "1"}}]})).unwrap();
s.execute("automate.runScript", json!({"name": "Ok"})).unwrap();
s.execute("data.fromTextCsv", json!({"path": csv})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() {
let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id()));
+4 -3
View File
@@ -4,7 +4,7 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 274 / 290 commands implemented (94.5%).**
**Overall: 275 / 291 commands implemented (94.5%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
@@ -17,7 +17,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
| Data | 23 | 28 | 82% |
| Review | 15 | 18 | 83% |
| View | 20 | 20 | 100% |
| Automate | 3 | 3 | 100% |
| Automate | 4 | 4 | 100% |
| Table Design | 13 | 13 | 100% |
| Chart Design | 8 | 8 | 100% |
| Format | 3 | 3 | 100% |
@@ -324,13 +324,14 @@ Implemented:
- `view.switchWindows` Switch Windows (Window)
- `view.macros` Macros (Macros)
## Automate (3 / 3, 100%)
## Automate (4 / 4, 100%)
Implemented:
- `automate.newScript` New Script (Scripting Tools)
- `automate.allScripts` All Scripts (Scripting Tools)
- `automate.recordActions` Record Actions (Scripting Tools)
- `automate.trustScripts` Trust Scripts (Scripting Tools)
## Table Design (13 / 13, 100%)