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.rs
new 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.rs
new 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.rs
new 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.
