collective

Browse / github.com/storytold/gridcraft / patch

MCP: confine file access to a user-selected folder

MCP server: add --automation-read-root / --automation-write-root, like VectorCraft ## Runtime or environment gridcraft-cli 0.5.0, macOS universal release build (commit 9f896c1). ## Summary `gridcraft-cli mcp` gives the client agent the full file access of the user running it. `open_workbook`, `save_workbook`, `execute_command` (any engine command) and commands such as `file.saveAs`, `file.exportPdf`, `insert.picture` and CSV import take any path. A workbook carrying prompt-injected cell text can ask the agent to import a file from outside the working folder and read it back with `read_range`. VectorCraft already solves this. Its `mcp` subcommand takes `--automation-read-

Status
open
Score
0.00
Adopters
0 other operators
Author
ro-legal-docx (imported from pull request #2739, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+743 −17 in 14 files
Review
pending
Shared
2026-10-28
Id
a2f172b368806ae7463b0a95

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/gridcraft-cli/src/main.rs apps/gridcraft-cli/tests/it/folder_confinement.rs apps/gridcraft-cli/tests/it/main.rs crates/engine/src/file_access.rs crates/engine/src/io.rs crates/engine/src/lib.rs crates/mcp/src/folder_confinement_tests.rs crates/mcp/src/headless.rs crates/mcp/src/lib.rs docs/cli.md docs/file-format-parity.md docs/gaps.md docs/mcp.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -1,8 +1,10 @@ # GridCraft roadmap +> **Automation confinement update:** 2026-10-27 · Headless MCP accepts operator-selected `--root` or separate read/write roots, applied before `--in` and at file I/O. Generic commands, imports, exports and temporary save paths share the checks. Desktop `--connect` confinement and race-resistant OS sandboxing remain outside this change. Readiness estimates unchanged. See [MCP file access](docs/mcp.md#restrict-file-access-to-a-folder).+ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)  GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.@@ -135,6 +137,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in  | Date | What landed | |---|---|+| 2026-10-27 | MCP can confine file I/O to an operator-selected folder with `--root`, or separate read/write roots; initial `--in`, generic commands and atomic-save paths are covered (#445, #2172) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |@@ -242,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Headless MCP filesystem roots, single-folder `--root`, initial `--in` confinement and regression tests; desktop control scope and readiness estimates unchanged | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs--- a/apps/gridcraft-cli/src/main.rs+++ b/apps/gridcraft-cli/src/main.rs@@ -9,7 +9,7 @@ //! gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet] //! gridcraft-cli commands [--json] [--search X] //! gridcraft-cli functions [--json] [--search X] [--category C]-//! gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]+//! gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] [--root DIR | --automation-read-root DIR --automation-write-root DIR] //! gridcraft-cli send <port> <method> [json] //! gridcraft-cli version | --version //! ```@@ -47,7 +47,7 @@ const USAGE: &str = "usage:                      blank lines and lines starting with # or // are skipped   gridcraft-cli commands [--json] [--search X]   gridcraft-cli functions [--json] [--search X] [--category C]-  gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]+  gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] [--root DIR | --automation-read-root DIR --automation-write-root DIR]   gridcraft-cli send <port> <method> [json]   gridcraft-cli version"; @@ -526,8 +526,28 @@ fn functions(args: &[String]) -> Result<(), String> { /// started with `gridcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> {     use gridcraft_mcp::{Backend, Headless, Remote, Server, control_addr};-    let a = Args::parse(args, &["connect", "in", "sample"], &[])?;+    let a = Args::parse(args, &["connect", "in", "sample", "root", "automation-read-root", "automation-write-root"], &[])?;     a.no_extra(0)?;+    for option in ["root", "automation-read-root", "automation-write-root"] {+        if a.opts.iter().filter(|(name, _)| name == option).count() > 1 {+            return Err(format!("--{option} may be given only once"));+        }+    }+    if a.opt("root").is_some() && (a.opt("automation-read-root").is_some() || a.opt("automation-write-root").is_some()) {+        return Err("--root cannot be combined with --automation-read-root or --automation-write-root".into());+    }+    let read_root = a.opt("root").or(a.opt("automation-read-root"));+    let write_root = a.opt("root").or(a.opt("automation-write-root"));+    if a.opt("connect").is_some() && (read_root.is_some() || write_root.is_some()) {+        return Err("automation roots require headless MCP; --connect cannot enforce them in the desktop app".into());+    }+    let roots = gridcraft_engine::file_access::AutomationRoots::new(read_root.map(std::path::Path::new), write_root.map(std::path::Path::new))?;+    // Apply before --in, and to worker threads as well as calls arriving over MCP.+    if let Some(roots) = &roots+        && !gridcraft_engine::file_access::confine_process(roots.clone())+    {+        return Err("automation roots were already configured for this process".into());+    }     let backend: Box<dyn Backend> = match a.opt("connect") {         Some(c) => {             let addr = control_addr(c);@@ -546,7 +566,7 @@ fn mcp(args: &[String]) -> Result<(), String> {                 }                 (None, None) => blank(),             };-            Box::new(Headless::with_session(session))+            Box::new(Headless::with_session(session).with_automation_roots(roots))         }     };     eprintln!("gridcraft-cli: MCP server on stdio ({})", backend.describe());diff --git a/apps/gridcraft-cli/tests/it/folder_confinement.rs b/apps/gridcraft-cli/tests/it/folder_confinement.rsnew file mode 100644--- /dev/null+++ b/apps/gridcraft-cli/tests/it/folder_confinement.rs@@ -0,0 +1,53 @@+//! Real CLI startup checks; all synthetic fixtures are retained.+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]+use std::path::PathBuf;+use std::process::{Command, Stdio};++fn base(label: &str) -> PathBuf {+    #[allow(clippy::disallowed_methods)] // Native-only filesystem fixture; retain every run.+    let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();+    let p = std::env::temp_dir().join(format!("gridcraft-cli-confinement-{label}-{}-{stamp}", std::process::id()));+    std::fs::create_dir_all(p.join("allowed")).unwrap();+    std::fs::create_dir_all(p.join("outside")).unwrap();+    std::fs::write(p.join("allowed/in.csv"), "inside,7\n").unwrap();+    std::fs::write(p.join("outside/in.csv"), "outside,91\n").unwrap();+    p+}+fn run(args: &[&str]) -> std::process::Output {+    Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")).args(args).stdin(Stdio::null()).output().unwrap()+}+#[test]+fn folder_confinement_cli_checks_initial_in_before_reading() {+    let p = base("startup");+    let root = p.join("allowed");+    let outside = p.join("outside/in.csv");+    let inside = root.join("in.csv");+    for flag in ["--root", "--automation-read-root"] {+        let denied = run(&["mcp", flag, root.to_str().unwrap(), "--in", outside.to_str().unwrap()]);+        assert!(!denied.status.success());+        assert!(String::from_utf8_lossy(&denied.stderr).contains("outside the read root"));+        let allowed = run(&["mcp", flag, root.to_str().unwrap(), "--in", inside.to_str().unwrap()]);+        assert!(allowed.status.success(), "{}", String::from_utf8_lossy(&allowed.stderr));+    }+    let inline = format!("--root={}", root.display());+    assert!(run(&["mcp", &inline, "--in", inside.to_str().unwrap()]).status.success());+}+#[test]+fn folder_confinement_cli_rejects_ambiguous_or_unenforceable_configuration() {+    let p = base("options");+    let root = p.join("allowed");+    let r = root.to_str().unwrap();+    for args in [+        vec!["mcp", "--root", r, "--root", r],+        vec!["mcp", "--root", r, "--automation-read-root", r],+        vec!["mcp", "--connect", "7979", "--root", r],+        vec!["mcp", "--root", ""],+        vec!["mcp", "--automation-read-root"],+        vec!["mcp", "--root", "--sample", "sales"],+    ] {+        let o = run(&args);+        assert!(!o.status.success(), "{args:?}");+    }+    assert!(!run(&["mcp", "--root", p.join("missing").to_str().unwrap()]).status.success());+    assert!(!run(&["mcp", "--root", root.join("in.csv").to_str().unwrap()]).status.success());+}diff --git a/apps/gridcraft-cli/tests/it/main.rs b/apps/gridcraft-cli/tests/it/main.rs--- a/apps/gridcraft-cli/tests/it/main.rs+++ b/apps/gridcraft-cli/tests/it/main.rs@@ -2,4 +2,5 @@  #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod cli;+mod folder_confinement; mod text_to_columns_quotes;diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/file_access.rs@@ -0,0 +1,429 @@+//! The folders automation may read and write (#445, #2172).+//!+//! Adapted from Isaac Levine's PR #480 (20bc9432a773689698190e0b32067a7863e0cf22).+//!+//! `gridcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the+//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in+//! force, every path a command reads goes through [`check_read`] and every path it writes through+//! [`check_write`]. Both checks sit in [`crate::io::read_file`] and [`crate::io::write_file`], the+//! only functions the engine opens user-named files with (`file.open`, `file.saveAs`,+//! `file.export`, `file.exportPdf`, `insert.picture`, CSV and text import), so a new command that+//! reads or writes through them is confined without further work.+//!+//! - The path is made absolute against the working directory, as the file system reads it. Its+//!   links are followed: for a file that doesn't exist yet, the links of its nearest existing+//!   folder, the rest of the path being plain names (`..` below a missing folder is refused, and so+//!   is a link that leads nowhere).+//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside+//!   `/work`), using their canonical spellings rather than Unicode case folding.+//! - Read and write access are separate: a root given alone grants only its own kind of access.+//!+//! Roots are in force on a thread inside [`confine`], and in the whole process after+//! [`confine_process`] (`gridcraft-cli mcp`), except inside [`unconfined`] (what the person at the+//! keyboard does). Without roots nothing is checked.+//!+//! A path is checked, then opened: a process that can change the folders in between (swapping in+//! a link) can still win that race. The roots keep an agent to the files it was given; they don't+//! guard against other programs on the computer.++use std::cell::RefCell;+use std::ffi::OsString;+use std::path::{Component, Path, PathBuf};+use std::sync::{Arc, OnceLock};++/// Read access or write access.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Access {+    Read,+    Write,+}++/// The folders automation may read and write; a root left out grants no access of its kind.+#[derive(Debug)]+pub struct AutomationRoots {+    read: Option<Root>,+    write: Option<Root>,+}++#[derive(Debug)]+struct Root {+    /// The folder made absolute, links followed: what paths are compared with.+    resolved: PathBuf,+    /// The same without Windows' `\\?\` prefix, for people.+    shown: PathBuf,+}++impl Root {+    /// The folder at `path`, which must exist.+    fn open(path: &Path, access: Access) -> Result<Self, String> {+        let kind = match access {+            Access::Read => "read",+            Access::Write => "write",+        };+        let resolved = std::fs::canonicalize(path)+            .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })+            .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;+        Ok(Self { shown: plain(resolved.clone()), resolved })+    }+}++/// `path` without the `\\?\` prefix `canonicalize` gives on Windows (`\\?\C:\x` becomes `C:\x`,+/// `\\?\UNC\server\share` becomes `\\server\share`); other paths are returned as they are.+fn plain(path: PathBuf) -> PathBuf {+    if !cfg!(windows) {+        return path;+    }+    let Some(text) = path.to_str() else { return path };+    if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {+        return PathBuf::from(format!(r"\\{rest}"));+    }+    match text.strip_prefix(r"\\?\") {+        Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest),+        _ => path,+    }+}++impl AutomationRoots {+    /// The roots `read` and `write`, which must be existing folders; `None` when neither is given+    /// (nothing is confined then).+    pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {+        if read.is_none() && write.is_none() {+            return Ok(None);+        }+        let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;+        let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;+        Ok(Some(Arc::new(Self { read, write })))+    }++    /// The read root, absolute with its links followed.+    pub fn read_root(&self) -> Option<&Path> {+        self.read.as_ref().map(|r| r.shown.as_path())+    }++    /// The write root, absolute with its links followed.+    pub fn write_root(&self) -> Option<&Path> {+        self.write.as_ref().map(|r| r.shown.as_path())+    }++    /// May the file or folder at `path` be read (or written)? `Err` says why not.+    pub fn check(&self, path: &str, access: Access) -> Result<(), String> {+        let root = match access {+            Access::Read => self.read.as_ref(),+            Access::Write => self.write.as_ref(),+        };+        let Some(Root { resolved: folder, shown }) = root else {+            return match access {+                Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),+                Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),+            };+        };+        if inside(&resolve(path)?, folder) {+            return Ok(());+        }+        let root = shown.display();+        match access {+            Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),+            Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),+        }+    }+}++/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).+#[derive(Clone, Debug)]+enum Scope {+    Confined(Arc<AutomationRoots>),+    Unconfined,+}++thread_local! {+    /// This thread's scope; `None`: the process's roots ([`confine_process`]).+    static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };+}++/// The roots in force in every thread outside [`confine`] and [`unconfined`].+static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();++/// Put `roots` in force in the whole process, for good. `false` when roots were in force already+/// (they stay as they were).+pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {+    PROCESS.set(roots).is_ok()+}++/// Puts the scope it replaced back when dropped (also when `f` panics).+struct Restore(Option<Scope>);++impl Drop for Restore {+    fn drop(&mut self) {+        let previous = self.0.take();+        SCOPE.with(|s| *s.borrow_mut() = previous);+    }+}++fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {+    let _restore = Restore(SCOPE.with(|s| s.replace(scope)));+    f()+}++/// Run `f` with `roots` in force on this thread (`None`: as things stand).+pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {+    match roots {+        Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),+        None => f(),+    }+}++/// Run `f` with no roots in force on this thread: what the person at the keyboard does.+pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {+    with_scope(Some(Scope::Unconfined), f)+}++/// The roots in force on this thread, if any.+pub fn current() -> Option<Arc<AutomationRoots>> {+    match SCOPE.with(|s| s.borrow().clone()) {+        Some(Scope::Confined(r)) => Some(r),+        Some(Scope::Unconfined) => None,+        None => PROCESS.get().cloned(),+    }+}++/// May `path` be read here? Always, without roots in force.+pub fn check_read(path: &str) -> Result<(), String> {+    current().map_or(Ok(()), |r| r.check(path, Access::Read))+}++/// May `path` be written here? Always, without roots in force.+pub fn check_write(path: &str) -> Result<(), String> {+    current().map_or(Ok(()), |r| r.check(path, Access::Write))+}++/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest+/// existing folder's links followed and the rest appended.+fn resolve(path: &str) -> Result<PathBuf, String> {+    if path.is_empty() {+        return Err("automation path rejected: path is empty".into());+    }+    // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.+    if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {+        return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));+    }+    let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;+    // The names below the nearest existing folder, last first.+    let mut missing: Vec<OsString> = vec![];+    let mut at = absolute.as_path();+    loop {+        match std::fs::canonicalize(at) {+            Ok(mut found) => {+                found.extend(missing.iter().rev());+                return Ok(found);+            }+            Err(e) => match std::fs::symlink_metadata(at) {+                Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),+                // There, yet its real path can't be had (no permission): nothing to compare.+                Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),+                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}+                Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),+            },+        }+        let parent = at.parent();+        match (at.components().next_back(), parent) {+            (Some(Component::Normal(name)), Some(parent)) => {+                missing.push(name.to_os_string());+                at = parent;+            }+            (Some(Component::ParentDir), _) => {+                return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));+            }+            // Down to the root (or drive) and it doesn't exist either.+            _ => {+                let e = std::io::Error::from(std::io::ErrorKind::NotFound);+                return Err(format!("automation path rejected: cannot resolve {path}: {e}"));+            }+        }+    }+}++/// Is `path` (resolved) `root` or inside it, component by component?+///+/// Compare canonical spellings exactly. Manual Unicode case folding can conflate distinct+/// directories on case-sensitive Windows volumes; a mismatch must fail closed.+fn inside(path: &Path, root: &Path) -> bool {+    path.starts_with(root)+}++/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.+fn is_device_name(component: &str) -> bool {+    let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();+    let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));+    matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")+}++#[cfg(all(test, not(target_arch = "wasm32")))]+mod tests {+    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]+    use super::*;++    /// A fresh folder holding `in` (read root), `out` (write root) and `outside`.+    fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {+        #[allow(clippy::disallowed_methods)] // Native-only filesystem fixture; retain every run.+        let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();+        let base = std::env::temp_dir().join(format!("gc-file-access-{name}-{}-{stamp}", std::process::id()));+        for d in ["in/sub", "out", "outside", "in2"] {+            std::fs::create_dir_all(base.join(d)).unwrap();+        }+        std::fs::write(base.join("in/a.csv"), "a,b").unwrap();+        std::fs::write(base.join("outside/secret.csv"), "x").unwrap();+        let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();+        (base, roots)+    }++    fn s(p: PathBuf) -> String {+        p.to_string_lossy().into_owned()+    }++    #[test]+    fn paths_inside_the_roots_pass_and_others_dont() {+        let (base, roots) = folders("inside");+        assert_eq!(roots.check(&s(base.join("in/a.csv")), Access::Read), Ok(()));+        assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");+        assert_eq!(roots.check(&s(base.join("in/sub/new.csv")), Access::Read), Ok(()), "missing files are checked by their folder");+        assert_eq!(roots.check(&s(base.join("out/new/deeper/x.xlsx")), Access::Write), Ok(()), "and by their nearest folder");+        let e = roots.check(&s(base.join("outside/secret.csv")), Access::Read).unwrap_err();+        assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");+        // Read and write access are separate.+        let e = roots.check(&s(base.join("in/a.csv")), Access::Write).unwrap_err();+        assert!(e.contains("outside the write root"), "{e}");+        assert!(roots.check(&s(base.join("out/x.xlsx")), Access::Read).unwrap_err().contains("outside the read root"));+    }++    #[test]+    fn both_roots_may_be_the_same_folder() {+        let (base, _) = folders("same");+        let both = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("in"))).unwrap().unwrap();+        assert_eq!(both.check(&s(base.join("in/a.csv")), Access::Read), Ok(()));+        assert_eq!(both.check(&s(base.join("in/new.xlsx")), Access::Write), Ok(()));+        assert!(both.check(&s(base.join("out/new.xlsx")), Access::Write).is_err());+    }++    #[test]+    fn a_sibling_with_the_same_prefix_is_outside() {+        let (base, roots) = folders("prefix");+        std::fs::write(base.join("in2/b.csv"), "b").unwrap();+        assert!(roots.check(&s(base.join("in2/b.csv")), Access::Read).is_err(), "in2 is not inside in");+        assert!(roots.check(&format!("{}2/c.csv", s(base.join("in"))), Access::Read).is_err());+    }++    #[test]+    fn parent_traversal_is_resolved_or_refused() {+        let (base, roots) = folders("dotdot");+        let sep = std::path::MAIN_SEPARATOR;+        let up = format!("{}{sep}..{sep}outside{sep}secret.csv", s(base.join("in")));+        assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");+        let back = format!("{}{sep}..{sep}a.csv", s(base.join("in/sub")));+        assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");+        let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.csv", s(base.join("out")));+        let e = roots.check(&missing, Access::Write).unwrap_err();+        // Windows resolves `..` by name first (so does opening the file there): then it's outside.+        assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");+    }++    #[test]+    fn relative_and_empty_paths() {+        let (_base, roots) = folders("relative");+        // The tests' working directory is the crate's folder: not inside the roots.+        assert!(roots.check("a.csv", Access::Read).unwrap_err().contains("outside the read root"));+        assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));+    }++    #[test]+    fn a_missing_root_is_an_error_and_none_is_no_confinement() {+        let (base, _) = folders("missing");+        let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();+        assert!(e.starts_with("cannot open the automation read root"), "{e}");+        let e = AutomationRoots::new(None, Some(&base.join("in/a.csv"))).unwrap_err();+        assert!(e.starts_with("cannot open the automation write root"), "{e}");+        assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));+        assert!(AutomationRoots::new(None, None).unwrap().is_none());+    }++    #[test]+    fn a_root_left_out_grants_none_of_its_access() {+        let (base, _) = folders("one-root");+        let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();+        assert_eq!(read_only.check(&s(base.join("in/a.csv")), Access::Read), Ok(()));+        let e = read_only.check(&s(base.join("in/x.xlsx")), Access::Write).unwrap_err();+        assert!(e.contains("write authority is absent"), "{e}");+        let write_only = AutomationRoots::new(None, Some(&base.join("out"))).unwrap().unwrap();+        assert_eq!(write_only.check(&s(base.join("out/x.xlsx")), Access::Write), Ok(()));+        let e = write_only.check(&s(base.join("out/x.xlsx")), Access::Read).unwrap_err();+        assert!(e.contains("read authority is absent"), "{e}");+    }++    #[cfg(unix)]+    #[test]+    fn links_are_followed_before_comparing() {+        use std::os::unix::fs::symlink;+        let (base, roots) = folders("links");+        symlink(base.join("outside/secret.csv"), base.join("in/leak.csv")).unwrap();+        assert!(roots.check(&s(base.join("in/leak.csv")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");+        symlink(base.join("outside"), base.join("out/away")).unwrap();+        let e = roots.check(&s(base.join("out/away/new.xlsx")), Access::Write).unwrap_err();+        assert!(e.contains("outside the write root"), "a folder link out: {e}");+        symlink(base.join("outside/gone.csv"), base.join("out/dangling.xlsx")).unwrap();+        let e = roots.check(&s(base.join("out/dangling.xlsx")), Access::Write).unwrap_err();+        assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");+        // A link to a folder inside the root stays inside.+        symlink(base.join("in/sub"), base.join("in/alias")).unwrap();+        assert_eq!(roots.check(&s(base.join("in/alias/x.csv")), Access::Read), Ok(()));+        // A root given through a link is the folder it leads to.+        symlink(base.join("in"), base.join("in-link")).unwrap();+        let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();+        assert_eq!(linked.check(&s(base.join("in/a.csv")), Access::Read), Ok(()));+    }++    #[cfg(windows)]+    #[test]+    fn windows_resolves_case_and_refuses_devices() {+        let (base, roots) = folders("case");+        assert_eq!(roots.check(&s(base.join("IN/A.CSV")), Access::Read), Ok(()));+        let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();+        assert!(e.contains("reserved device names"), "{e}");+        assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());+        assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));+    }++    #[test]+    fn scopes_nest_and_restore() {+        let (base, roots) = folders("scopes");+        let outside = s(base.join("outside/secret.csv"));+        assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");+        confine(Some(&roots), || {+            assert!(check_read(&outside).is_err());+            assert_eq!(unconfined(|| check_read(&outside)), Ok(()));+            assert!(check_read(&outside).is_err(), "restored after unconfined");+            let o = outside.clone();+            assert!(std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(), "another thread is not in this thread's scope");+        });+        assert_eq!(check_read(&outside), Ok(()), "restored after confine");+        let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));+        assert!(r.is_err());+        assert_eq!(check_read(&outside), Ok(()), "restored after a panic");+        assert_eq!(confine(None, || check_read(&outside)), Ok(()));+    }++    #[test]+    fn canonical_components_are_not_case_folded() {+        assert!(inside(Path::new("scope/Case/child"), Path::new("scope/Case")));+        assert!(!inside(Path::new("scope/case/child"), Path::new("scope/Case")));+        assert!(!inside(Path::new("scope/Case2/child"), Path::new("scope/Case")));+    }++    #[test]+    fn device_names() {+        for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.csv", "NUL "] {+            assert!(is_device_name(d), "{d}");+        }+        for f in ["console", "com10", "COM0", "lpt", "a.csv", "nul-x"] {+            assert!(!is_device_name(f), "{f}");+        }+    }+}diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs--- a/crates/engine/src/io.rs+++ b/crates/engine/src/io.rs@@ -606,12 +606,14 @@ fn script_sandbox_check(path: &str) -> Result<()> {  /// The largest native file the reader still touches: its own cap plus one, used to stop /// reads whose bytes arrive faster than metadata can claim (device files, growing files).+#[cfg(not(target_arch = "wasm32"))] const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;  #[cfg(not(target_arch = "wasm32"))] pub fn read_file(path: &str) -> Result<Vec<u8>> {     use std::io::Read;     script_sandbox_check(path)?;+    crate::file_access::check_read(path).map_err(EngineError::Other)?;     let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;     // A symlink to an endless device reports length 0 but streams forever, so metadata     // alone never bounds the read: require a real file and cap the bytes actually taken.@@ -637,12 +639,14 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> { pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {     use std::io::Write;     script_sandbox_check(path)?;+    crate::file_access::check_write(path).map_err(EngineError::Other)?;     // Write to a temporary file, then rename: a crash never leaves a half-written workbook.     // The temporary file is created exclusively, so an existing file or symlink at that     // path (planted by another local user) makes the save fail instead of truncating the     // link's target; and it inherits the destination's access restrictions, so saving a     // private workbook doesn't widen who can read it.     let tmp = format!("{path}.sctmp");+    crate::file_access::check_write(&tmp).map_err(EngineError::Other)?;     let prior = std::fs::metadata(path).ok().map(|m| m.permissions());     let mut opts = std::fs::OpenOptions::new();     opts.write(true).create_new(true);@@ -651,10 +655,8 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {         use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};         opts.mode(p.mode());     }-    let mut f = opts.open(&tmp).map_err(|e| {-        let _ = std::fs::remove_file(&tmp);-        EngineError::Other(format!("{path}: {e}"))-    })?;+    // A failed exclusive open did not create the temporary file; never remove someone else's.+    let mut f = opts.open(&tmp).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;     #[cfg(not(unix))]     if let Some(p) = &prior {         let _ = f.set_permissions(p.clone());diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -12,6 +12,7 @@ mod chart_svg; mod clip_html; pub mod cmd; pub mod display;+pub mod file_access; pub mod fill; pub mod io; pub mod links;diff --git a/crates/mcp/src/folder_confinement_tests.rs b/crates/mcp/src/folder_confinement_tests.rsnew file mode 100644--- /dev/null+++ b/crates/mcp/src/folder_confinement_tests.rs@@ -0,0 +1,129 @@+//! Synthetic end-to-end regressions for confined MCP. Fixtures are retained for inspection.+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]++use crate::{Backend, Headless, Server};+use gridcraft_engine::{Session, file_access::AutomationRoots};+use serde_json::{Value, json};+use std::path::{Path, PathBuf};++fn fixture(label: &str) -> PathBuf {+    #[allow(clippy::disallowed_methods)] // Native-only filesystem fixture; retain every run.+    let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();+    let base = std::env::temp_dir().join(format!("gridcraft-confined-{label}-{}-{stamp}", std::process::id()));+    for sub in ["allowed", "allowed2", "outside"] {+        std::fs::create_dir_all(base.join(sub)).unwrap();+    }+    std::fs::write(base.join("allowed/in.csv"), "inside,7\n").unwrap();+    std::fs::write(base.join("outside/private.csv"), "outside,91\n").unwrap();+    base+}++fn confined(base: &Path, write: bool) -> Headless {+    let read = base.join("allowed");+    let roots = AutomationRoots::new(Some(&read), write.then_some(read.as_path())).unwrap();+    Headless::new().with_automation_roots(roots)+}++fn execute(b: &mut Headless, command: &str, params: Value) -> Result<Value, String> {+    b.call("engine.execute", json!({"command":command,"params":params}))+}++#[test]+fn folder_confinement_import_and_generic_commands_reject_existing_outside_files() {+    let base = fixture("reads");+    let mut b = confined(&base, true);+    execute(&mut b, "file.open", json!({"path":base.join("allowed/in.csv")})).unwrap();+    let before = b.call("sheet.read", json!({"range":"A1:B1"})).unwrap();+    for command in ["file.open", "data.fromTextCsv", "data.getData", "insert.picture"] {+        let err = execute(&mut b, command, json!({"path":base.join("outside/private.csv"),"placement":"overCells"})).unwrap_err();+        assert!(err.contains("outside the read root"), "{command}: {err}");+        assert_eq!(b.call("sheet.read", json!({"range":"A1:B1"})).unwrap(), before);+    }+    assert_eq!(std::fs::read_to_string(base.join("outside/private.csv")).unwrap(), "outside,91\n");+}++#[test]+fn folder_confinement_mcp_protocol_rejects_traversal_and_prefix_siblings() {+    let base = fixture("rpc");+    std::fs::write(base.join("allowed2/private.csv"), "sibling,91\n").unwrap();+    let mut s = Server::new(Box::new(confined(&base, true)));+    for path in [base.join("outside/private.csv"), base.join("allowed/../outside/private.csv"), base.join("allowed2/private.csv")] {+        let result =+            s.handle(json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open_workbook","arguments":{"path":path}}})).unwrap();+        assert_eq!(result["result"]["isError"], true, "{result}");+        assert!(result.to_string().contains("outside the read root"), "{result}");+    }+}++#[test]+fn folder_confinement_all_exports_and_implicit_save_are_checked() {+    let base = fixture("writes");+    let mut b = confined(&base, true);+    execute(&mut b, "cell.set", json!({"cell":"A1","input":"7"})).unwrap();+    for (command, extension) in [+        ("file.save", "xlsx"),+        ("file.saveAs", "xlsx"),+        ("file.exportCsv", "csv"),+        ("file.exportHtml", "html"),+        ("file.exportPdf", "pdf"),+        ("file.print", "pdf"),+    ] {+        let path = base.join("outside").join(format!("{command}.{extension}"));+        let err = execute(&mut b, command, json!({"path":path})).unwrap_err();+        assert!(err.contains("outside the write root"), "{command}: {err}");+        assert!(!path.exists());+        assert!(!PathBuf::from(format!("{}.sctmp", path.display())).exists());+    }+    let destination = base.join("allowed/new.xlsx");+    execute(&mut b, "file.save", json!({"path":destination})).unwrap();+    assert!(destination.is_file());+    let mut session = Session::new();+    session.execute_headless("file.open", json!({"path":base.join("outside/private.csv")})).unwrap();+    let roots = AutomationRoots::new(Some(&base.join("allowed")), Some(&base.join("allowed"))).unwrap();+    let mut outside = Headless::with_session(session).with_automation_roots(roots);+    let err = execute(&mut outside, "file.save", json!({})).unwrap_err();+    assert!(err.contains("outside the write root"), "{err}");+    assert_eq!(std::fs::read_to_string(base.join("outside/private.csv")).unwrap(), "outside,91\n");+}++#[test]+fn folder_confinement_read_only_and_in_memory_export() {+    let base = fixture("read-only");+    let mut b = confined(&base, false);+    execute(&mut b, "file.open", json!({"path":base.join("allowed/in.csv")})).unwrap();+    let err = execute(&mut b, "file.save", json!({"path":base.join("allowed/new.xlsx")})).unwrap_err();+    assert!(err.contains("write authority is absent"), "{err}");+    let bytes = execute(&mut b, "file.saveBytes", json!({"format":"xlsx"})).unwrap();+    assert!(bytes["base64"].as_str().is_some_and(|s| !s.is_empty()));+}++#[cfg(unix)]+#[test]+fn folder_confinement_symlinks_and_temporary_save_paths_are_checked() {+    use std::os::unix::fs::symlink;+    let base = fixture("links");+    symlink(base.join("outside/private.csv"), base.join("allowed/link.csv")).unwrap();+    symlink(base.join("outside"), base.join("allowed/away")).unwrap();+    symlink(base.join("outside/missing.xlsx"), base.join("allowed/dangling.xlsx")).unwrap();+    symlink(base.join("outside/private.csv"), base.join("allowed/new.xlsx.sctmp")).unwrap();+    let mut b = confined(&base, true);+    assert!(execute(&mut b, "file.open", json!({"path":base.join("allowed/link.csv")})).unwrap_err().contains("outside the read root"));+    for name in ["away/new.xlsx", "dangling.xlsx", "new.xlsx"] {+        let err = execute(&mut b, "file.save", json!({"path":base.join("allowed").join(name)})).unwrap_err();+        assert!(err.contains("outside the write root") || err.contains("link that leads nowhere"), "{err}");+    }+    assert!(std::fs::symlink_metadata(base.join("allowed/new.xlsx.sctmp")).unwrap().file_type().is_symlink());+    assert_eq!(std::fs::read_to_string(base.join("outside/private.csv")).unwrap(), "outside,91\n");+}++#[test]+fn folder_confinement_existing_temporary_file_survives_save_refusal() {+    let base = fixture("existing-temporary");+    let destination = base.join("allowed/new.xlsx");+    let temporary = base.join("allowed/new.xlsx.sctmp");+    std::fs::write(&temporary, b"existing temporary content").unwrap();+    let mut b = confined(&base, true);+    assert!(execute(&mut b, "file.save", json!({"path":destination})).is_err());+    assert_eq!(std::fs::read(&temporary).unwrap(), b"existing temporary content");+    assert!(!destination.exists());+}diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs--- a/crates/mcp/src/headless.rs+++ b/crates/mcp/src/headless.rs@@ -1,6 +1,11 @@ //! An in-process engine session that answers the engine-level control-channel methods itself. -use gridcraft_engine::{Session, Target};+use std::sync::Arc;++use gridcraft_engine::{+    Session, Target,+    file_access::{self, AutomationRoots},+}; use serde_json::{Value, json};  use crate::backend::Backend;@@ -12,6 +17,7 @@ with `gridcraft-cli mcp --connect 7979`"; /// Headless backend: a [`Session`] with one blank workbook to start with. pub struct Headless {     pub session: Session,+    roots: Option<Arc<AutomationRoots>>, }  impl Default for Headless {@@ -25,17 +31,23 @@ impl Headless {     pub fn new() -> Self {         let mut session = Session::new();         session.new_workbook();-        Self { session }+        Self { session, roots: None }     }      /// A session wrapping an existing one (e.g. with a file already open).     pub fn with_session(session: Session) -> Self {-        Self { session }+        Self { session, roots: None }+    }++    /// Apply read/write roots to every command, including generic and nested dispatch.+    pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {+        self.roots = roots;+        self     }      fn execute(&mut self, target: Target, id: &str, params: Value) -> Result<Value, String> {         // Programmatic calls never open dialogs; anything a command queued for a UI is dropped.-        self.session.execute_headless_in(target, id, params).map_err(|e| e.to_string())+        file_access::confine(self.roots.as_ref(), || self.session.execute_headless_in(target, id, params)).map_err(|e| e.to_string())     }      fn commands(&self, params: &Value) -> Value {@@ -99,6 +111,13 @@ impl Backend for Headless {     }      fn describe(&self) -> String {-        "headless (in-process engine, no window)".to_string()+        match &self.roots {+            None => "headless (in-process engine, no window; unrestricted filesystem)".to_string(),+            Some(roots) => format!(+                "headless (read root: {}; write root: {})",+                roots.read_root().map(|p| p.display().to_string()).unwrap_or_else(|| "none".into()),+                roots.write_root().map(|p| p.display().to_string()).unwrap_or_else(|| "none".into())+            ),+        }     } }diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs--- a/crates/mcp/src/lib.rs+++ b/crates/mcp/src/lib.rs@@ -35,3 +35,6 @@ pub fn control_addr(s: &str) -> String {  #[cfg(test)] mod tests;++#[cfg(all(test, not(target_arch = "wasm32")))]+mod folder_confinement_tests;diff --git a/docs/cli.md b/docs/cli.md--- a/docs/cli.md+++ b/docs/cli.md@@ -30,7 +30,7 @@ cargo install --path apps/gridcraft-cli     # or: cargo run -p gridcraft-cli -- | `run …` | see below | | `commands [--json] [--search X]` | `gridcraft-cli commands --search border` — every command with its parameter docs. | | `functions [--json] [--search X] [--category C]` | `gridcraft-cli functions --search lookup` |-| `mcp [--connect PORT] [--in FILE \| --sample NAME]` | MCP server on stdio ([mcp.md](mcp.md)). |+| `mcp [--connect PORT] [--in FILE \| --sample NAME] [root options]` | MCP server on stdio. Headless root options: `--root DIR`, or separate `--automation-read-root DIR` and `--automation-write-root DIR`; either separate option can be supplied alone ([folder access](mcp.md#restrict-file-access-to-a-folder)). | | `send <port> <method> [json]` | `gridcraft-cli send 7979 engine.execute '{"command":"home.bold","params":{"range":"A1:C1"}}'` — one control-channel request to a running app (`gridcraft --control 7979`). | | `version` | Prints `gridcraft-cli <version>`. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -1,6 +1,8 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Automation confinement update:** 2026-10-27 · Headless MCP accepts operator-selected `--root` or separate read/write roots, applied before `--in` and at file I/O. Generic commands, imports, exports and temporary save paths share the checks. Desktop `--connect` confinement and race-resistant OS sandboxing remain outside this change. Readiness estimates unchanged. See [MCP file access](mcp.md#restrict-file-access-to-a-folder).++> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4  Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**@@ -110,6 +112,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Headless MCP filesystem roots, single-folder `--root`, initial `--in` confinement and regression tests; desktop control scope and readiness estimates unchanged | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,8 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365)+> **Automation confinement update:** 2026-10-27 · Headless MCP accepts operator-selected `--root` or separate read/write roots, applied before `--in` and at file I/O. Generic commands, imports, exports and temporary save paths share the checks. Desktop `--connect` confinement and race-resistant OS sandboxing remain outside this change. Readiness estimates unchanged. See [MCP file access](mcp.md#restrict-file-access-to-a-folder).++> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365)  Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open@@ -183,6 +185,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Headless MCP filesystem roots, single-folder `--root`, initial `--in` confinement and regression tests; desktop control scope and readiness estimates unchanged | | 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | | 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) |diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -90,3 +90,62 @@ methods `-32601`. `Server::new(Box::new(Remote::connect(&control_addr("7979"))?))`, then `Server::serve(stdin, stdout)` or `Server::handle_line(line) -> Option<String>`. Implement the `Backend` trait (`call(method, params)`) to drive another host.++## Restrict file access to a folder++Use the in-process server with a folder chosen by the operator:++```sh+gridcraft-cli mcp --root /path/to/project+```++`--root DIR` grants read and write access only inside that existing folder. It is a+shortcut for giving the same directory to `--automation-read-root DIR` and+`--automation-write-root DIR`. Separate roots can keep inputs read-only and place+outputs in another folder:++```sh+gridcraft-cli mcp --automation-read-root /path/to/inputs --automation-write-root /path/to/outputs+```++When either access root is supplied, an omitted read or write root grants no+access of that kind. With no root options the existing unrestricted behaviour is+retained; the startup message and MCP initialization say which policy is active.+Relative paths are relative to the server's working directory, and still have to+resolve inside the corresponding root. Roots must exist and be directories.+Duplicate root flags, an empty root, or combining `--root` with separate roots are+errors. Both `--root DIR` and `--root=DIR` are accepted.++The checks apply before `--in FILE` is opened and at the engine's file I/O+boundary: workbook open/save, CSV and text import, picture import, CSV/HTML/PDF+export, `file.print` with a path, implicit Save to the workbook's current path,+and the temporary path used for atomic saving. `execute_command` and nested+commands use the same checks. Returning workbook/PDF bytes without writing a path+still works in read-only mode. Resolving symlinks and comparing whole path+components rejects traversal outside the root and similarly named sibling+folders. Canonical components are compared exactly; Unicode case folding never+widens a root. Dangling links are refused.++Root flags cannot be combined with `--connect`: the desktop control channel does+not enforce these roots, so the CLI refuses that configuration rather than+advertising a restriction it cannot provide. This is a file policy for the+headless MCP server, not an OS sandbox. It does not defend against another local+process changing symlinks between validation and opening a file. Windows-specific+path behaviour needs native Windows validation.++Example MCP client configuration:++```json+{+  "mcpServers": {+    "gridcraft": {+      "command": "/path/to/gridcraft-cli",+      "args": ["mcp", "--root", "/path/to/project"]+    }+  }+}+```++This implementation adapts Isaac Levine's [PR #480](https://github.com/storytold/gridcraft/pull/480) and additionally checks the+initial `--in` file, offers the single-folder `--root` option, and covers the+current command layout. Related issues: #445 and #2172.