diff --git a/AGENTS.md b/AGENTS.md index 963fc6d74..ccead14c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Running and looking at the app - `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel). -- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. +- Drive it: `gridcraft-cli send 7979 ''` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. - **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`. - Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`. - **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 280fe8bcb..2a7ee363e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Running and looking at the app - `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel). -- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. +- Drive it: `gridcraft-cli send 7979 ''` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. - **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`. - Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`. - **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. diff --git a/Cargo.lock b/Cargo.lock index ade3a5adb..c3fa06650 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1503,7 +1503,9 @@ dependencies = [ "eframe", "egui", "fmv-macos-events", + "getrandom 0.4.3", "gridcraft-engine", + "gridcraft-mcp", "gridcraft-ui-egui", "image", "log", diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..930ded70d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index c867d24a2..14bc66b69 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -27,6 +27,9 @@ winresource = "0.1" craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } rfd = { workspace = true } arboard = { workspace = true } +gridcraft-mcp = { workspace = true } +# The control channel's token (OS random numbers); already in the tree. +getrandom = "0.4" [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] arboard = { workspace = true, features = ["wayland-data-control"] } diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs index e9e05a2e6..ab2046e3c 100644 --- a/apps/gridcraft/src/control_server.rs +++ b/apps/gridcraft/src/control_server.rs @@ -1,34 +1,82 @@ //! Localhost JSON-lines control server. Each connection may send many requests; each line //! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`. +//! +//! The first line of a connection must authenticate with the app's token (#777; see +//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't +//! JSON, is answered once and closed. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; +use gridcraft_mcp::control_auth; use gridcraft_ui_egui::ControlRequest; use serde_json::{Value as Json, json}; const MAX_LINE: usize = 64 * 1024 * 1024; +/// How long a new connection may take to send its token. +const AUTH_TIMEOUT: Duration = Duration::from_secs(10); -pub fn start(port: u16, ctx: egui::Context) -> std::io::Result> { +/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`), +/// else 256 random bits from the operating system, written to a file only the user can read. +fn make_token(port: u16, chosen: Option) -> std::io::Result { + if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) { + if t.len() < control_auth::MIN_TOKEN_LEN { + return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN))); + } + eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)"); + return Ok(t); + } + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?; + let token = control_auth::hex(&bytes); + let path = control_auth::write_token_file(port, &token)?; + eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display()); + Ok(token) +} + +pub fn start(port: u16, chosen_token: Option, ctx: egui::Context) -> std::io::Result> { let listener = TcpListener::bind(("127.0.0.1", port))?; + let token: Arc = make_token(port, chosen_token)?.into(); let (tx, rx) = channel(); std::thread::Builder::new().name("control".into()).spawn(move || { for stream in listener.incoming().flatten() { let tx = tx.clone(); let ctx = ctx.clone(); - let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx)); + let token = Arc::clone(&token); + let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx)); } })?; log::info!("control channel listening on 127.0.0.1:{port}"); Ok(rx) } -fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { +/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within +/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on. +fn authenticate(reader: &mut BufReader, out: &mut TcpStream, token: &str) -> bool { + let _ = out.set_read_timeout(Some(AUTH_TIMEOUT)); + let mut first = String::new(); + let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first); + let verdict = match read { + Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token), + _ => Err(json!({"ok": false, "error": "authentication required"})), + }; + let allowed = verdict.is_ok(); + let reply = verdict.unwrap_or_else(|e| e); + let _ = writeln!(out, "{reply}"); + let _ = out.set_read_timeout(None); + allowed +} + +fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; - let reader = BufReader::new(read); + let mut reader = BufReader::new(read); + if !authenticate(&mut reader, &mut out, token) { + return; + } for line in reader.lines() { let Ok(line) = line else { break }; if line.trim().is_empty() { @@ -42,7 +90,7 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { Ok(v) => v, Err(e) => { let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")})); - continue; + break; // Not a client of ours; don't keep reading. } }; let id = msg.get("id").cloned().unwrap_or(Json::Null); @@ -65,3 +113,82 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + const TOKEN: &str = "0123456789abcdef0123456789abcdef"; + + /// A server on a loopback port with a stand-in UI that echoes each method. + fn spawn() -> std::net::SocketAddr { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true, "result": req.method})); + } + }); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default())); + } + }); + addr + } + + fn lines(addr: std::net::SocketAddr, send: &str) -> Vec { + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(send.as_bytes()).unwrap(); + // Replies up to the server closing the connection. + BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect() + } + + #[test] + fn the_token_opens_the_connection() { + let addr = spawn(); + let auth = control_auth::auth_line(1, TOKEN); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap(); + let mut r = BufReader::new(c); + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["ok"], json!(true)); + line.clear(); + r.read_line(&mut line).unwrap(); + let v: Json = serde_json::from_str(&line).unwrap(); + assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping"))); + } + + #[test] + fn anything_but_the_token_first_closes_the_connection() { + let addr = spawn(); + let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff"); + for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] { + // The second request is never served: one refusal, then end of stream. + let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n")); + assert_eq!(got.len(), 1, "{first}: {got:?}"); + assert_eq!(got[0]["ok"], json!(false), "{first}"); + } + } + + #[test] + fn an_unparsable_line_after_auth_closes_the_connection() { + let addr = spawn(); + let auth = control_auth::auth_line(1, TOKEN); + let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n")); + assert_eq!(got.len(), 2, "{got:?}"); + assert_eq!(got[1]["ok"], json!(false)); + } + + #[test] + fn a_chosen_token_must_be_long_enough() { + assert!(make_token(1, Some("short".into())).is_err()); + assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN); + } +} diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 8093205c4..18cff9844 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -1,9 +1,10 @@ //! GridCraft desktop app. //! -//! Usage: `gridcraft [--control ] [--sample ] [files…]` +//! Usage: `gridcraft [--control [--control-token ]] [--sample ] [files…]` //! //! `--control ` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control //! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. +//! The first line of each connection must carry the token (`docs/control-protocol.md`). //! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -242,6 +243,7 @@ fn main() -> eframe::Result<()> { return Ok(()); } let mut control_port: Option = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut control_token: Option = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok(); let mut sample: Option = None; let mut files: Vec = Vec::new(); let mut i = 0; @@ -251,6 +253,10 @@ fn main() -> eframe::Result<()> { i += 1; control_port = args.get(i).and_then(|p| p.parse().ok()); } + "--control-token" => { + i += 1; + control_token = args.get(i).cloned(); + } "--sample" => { i += 1; sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into())); @@ -338,7 +344,7 @@ fn main() -> eframe::Result<()> { }; SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language); if let Some(port) = control_port { - match control_server::start(port, cc.egui_ctx.clone()) { + match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) { Ok(rx) => app.control_rx = Some(rx), Err(e) => log::error!("control channel on port {port}: {e}"), } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index adc866d2f..c77b612f7 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -25,14 +25,22 @@ const MAX_REPLY: u64 = 256 * 1024 * 1024; /// A running GridCraft app, reached through its loopback control port. pub struct Remote { addr: String, + token: String, conn: Option<(BufReader, TcpStream)>, next_id: u64, } impl Remote { - /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. + /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. The app + /// requires a token (#777): `GRIDCRAFT_CONTROL_TOKEN`, else the file the app wrote for this port. pub fn connect(addr: &str) -> std::io::Result { - let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 }; + let token = crate::control_auth::client_token(addr).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + Self::connect_with_token(addr, token) + } + + /// Like [`Remote::connect`] with a token the caller already has. + pub fn connect_with_token(addr: &str, token: String) -> std::io::Result { + let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 }; r.reconnect()?; Ok(r) } @@ -51,7 +59,9 @@ impl Remote { s.set_read_timeout(Some(IO_TIMEOUT)).ok(); s.set_write_timeout(Some(IO_TIMEOUT)).ok(); let read = s.try_clone()?; - self.conn = Some((BufReader::new(read), s)); + let mut conn = (BufReader::new(read), s); + Self::authenticate(&mut conn, &self.token)?; + self.conn = Some(conn); return Ok(()); } Err(e) => last = e, @@ -60,6 +70,25 @@ impl Remote { Err(last) } + /// The handshake every connection starts with: the app closes one that doesn't send the token first. + fn authenticate(conn: &mut (BufReader, TcpStream), token: &str) -> std::io::Result<()> { + let (reader, writer) = conn; + writer.write_all(crate::control_auth::auth_line(0, token).as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut reply = String::new(); + let n = std::io::Read::take(reader, 64 * 1024).read_line(&mut reply)?; + let ok = n > 0 && serde_json::from_str::(reply.trim()).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true); + if ok { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "the app refused the control token (is GRIDCRAFT_CONTROL_TOKEN or the app's token file for this port out of date?)", + )) + } + } + fn roundtrip(&mut self, line: &str) -> std::io::Result { if self.conn.is_none() { self.reconnect()?; diff --git a/crates/mcp/src/control_auth.rs b/crates/mcp/src/control_auth.rs new file mode 100644 index 000000000..7ff589353 --- /dev/null +++ b/crates/mcp/src/control_auth.rs @@ -0,0 +1,135 @@ +//! Authentication of the control channel (#777). +//! +//! Anything on the machine, and a web page that POSTs `text/plain` to `127.0.0.1:`, can +//! reach a loopback port. So the first line of every connection must be +//! `{"id":…,"method":"auth","params":{"token":"…"}}`, with the secret the app made at start. The +//! app keeps it in memory and writes it to a file only the user can read (or the user chooses it +//! with `GRIDCRAFT_CONTROL_TOKEN`); [`Remote`](crate::Remote) reads it from the same places. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +/// Environment variable holding a token chosen by the user. The app uses it instead of making +/// one (and writes no file); clients use it instead of reading the file. +pub const TOKEN_ENV: &str = "GRIDCRAFT_CONTROL_TOKEN"; +/// The method of the handshake line. +pub const AUTH_METHOD: &str = "auth"; +/// Shortest token the app accepts from the user (a made one has 256 bits). +pub const MIN_TOKEN_LEN: usize = 16; +/// Longest first line the app reads from a peer that hasn't authenticated yet. +pub const MAX_AUTH_LINE: usize = 4096; + +/// Compare two secrets without stopping at the first difference. Unequal lengths are unequal +/// at once (the length of a token isn't secret). +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b) { + diff |= x ^ y; + } + // Keep the optimiser from turning the loop into an early exit. + std::hint::black_box(diff) == 0 +} + +/// A made token as lowercase hex (two characters per byte). +pub fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len().saturating_mul(2)); + for b in bytes { + s.push(char::from(DIGITS[usize::from(b >> 4)])); + s.push(char::from(DIGITS[usize::from(b & 15)])); + } + s +} + +/// The handshake line a client sends first. +pub fn auth_line(id: u64, token: &str) -> String { + json!({"id": id, "method": AUTH_METHOD, "params": {"token": token}}).to_string() +} + +/// Check a connection's first line. `Ok` is the reply to send before serving further requests; +/// `Err` is the reply for a refused connection, which the caller sends and then closes. The +/// refusal is the same whatever was wrong, so it tells a stranger nothing. +pub fn check_first_line(line: &str, token: &str) -> Result { + let refused = || json!({"ok": false, "error": "authentication required"}); + let msg: Value = serde_json::from_str(line.trim()).map_err(|_| refused())?; + if msg.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) { + return Err(refused()); + } + let given = msg.get("params").and_then(|p| p.get("token")).and_then(Value::as_str).unwrap_or(""); + // Always compare, so a missing token costs the same as a wrong one. + if constant_time_eq(given.as_bytes(), token.as_bytes()) && !token.is_empty() { + Ok(json!({"id": msg.get("id").cloned().unwrap_or(Value::Null), "ok": true, "result": {"authenticated": true}})) + } else { + Err(refused()) + } +} + +/// Where the app writes the token it made for `port`, and where clients look for it: a folder +/// only the user can open (`$XDG_RUNTIME_DIR/gridcraft`, else `~/.cache/gridcraft`; on Windows +/// `%USERPROFILE%\.gridcraft`), file `control-.token`. `None` when no such folder is known. +pub fn token_path(port: u16) -> Option { + let dir = if cfg!(windows) { + PathBuf::from(std::env::var_os("USERPROFILE").filter(|v| !v.is_empty())?).join(".gridcraft") + } else if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) { + PathBuf::from(run).join("gridcraft") + } else { + PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache").join("gridcraft") + }; + Some(dir.join(format!("control-{port}.token"))) +} + +/// The port of a control address (`127.0.0.1:7979` → 7979). +fn port_of(addr: &str) -> Option { + addr.rsplit(':').next()?.parse().ok() +} + +/// The token a client uses for `addr`: [`TOKEN_ENV`] if set, else the file the app wrote. +pub fn client_token(addr: &str) -> Result { + if let Ok(t) = std::env::var(TOKEN_ENV) + && !t.trim().is_empty() + { + return Ok(t.trim().to_string()); + } + let path = port_of(addr).and_then(token_path).ok_or_else(|| format!("no control token: set {TOKEN_ENV}"))?; + match std::fs::read_to_string(&path) { + Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()), + _ => Err(format!("no control token: set {TOKEN_ENV}, or start the app on this machine and user (it writes {})", path.display())), + } +} + +/// Write `token` to [`token_path`] for the app: the folder is made private (mode 0700 on Unix), +/// an older file is replaced, and the new one is created readable by the user only (mode 0600). +/// Returns the path. +pub fn write_token_file(port: u16, token: &str) -> std::io::Result { + use std::io::Write; + let path = token_path(port).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user folder for the control token"))?; + let dir = path.parent().ok_or_else(|| std::io::Error::other("token path has no folder"))?; + std::fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Only our own folder: leave a shared one (`$XDG_RUNTIME_DIR` itself) as it is. + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; + } + // Replace rather than open: a leftover file may have looser permissions, or be a link. + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(&path)?; + f.write_all(token.as_bytes())?; + f.write_all(b"\n")?; + Ok(path) +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index de3e25044..7c66a26f9 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -15,6 +15,7 @@ #![forbid(unsafe_code)] mod backend; +pub mod control_auth; mod headless; mod server; mod tools; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index eb1f20503..df6c954e7 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -363,6 +363,11 @@ fn remote_backend_over_tcp() { let stream = stream.unwrap(); let mut w = stream.try_clone().unwrap(); let mut r = BufReader::new(stream); + // Every connection starts with the token, as the app requires. + let mut first = String::new(); + r.read_line(&mut first).unwrap(); + let hello = crate::control_auth::check_first_line(&first, "secret-token-0123456789").unwrap(); + w.write_all(format!("{hello}\n").as_bytes()).unwrap(); loop { let mut line = String::new(); if r.read_line(&mut line).unwrap_or(0) == 0 { @@ -382,7 +387,7 @@ fn remote_backend_over_tcp() { }); let addr = control_addr(&port.to_string()); assert_eq!(addr, format!("127.0.0.1:{port}")); - let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let mut s = Server::new(Box::new(Remote::connect_with_token(&addr, "secret-token-0123456789".into()).unwrap())); assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0)); // Second call reconnects transparently. ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"})); @@ -391,7 +396,49 @@ fn remote_backend_over_tcp() { assert!(msg.contains("unknown command"), "{msg}"); drop(s); handle.join().unwrap(); - assert!(Remote::connect("127.0.0.1:1").is_err()); + assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err()); +} + +#[test] +fn control_auth_handshake_and_compare() { + use crate::control_auth::{auth_line, check_first_line, constant_time_eq, hex}; + let token = "0123456789abcdef0123456789abcdef"; + let ok = check_first_line(&auth_line(7, token), token).unwrap(); + assert_eq!((ok["id"].clone(), ok["ok"].clone()), (json!(7), json!(true))); + // Wrong or missing token, another method, and non-JSON are all refused the same way. + let wrong = check_first_line(&auth_line(1, "0123456789abcdef0123456789abcdee"), token).unwrap_err(); + for bad in [ + auth_line(1, ""), + r#"{"id":1,"method":"auth","params":{}}"#.into(), + r#"{"id":1,"method":"app.save","params":{"path":"/tmp/x","token":"0123456789abcdef0123456789abcdef"}}"#.into(), + "POST / HTTP/1.1".into(), + String::new(), + ] { + assert_eq!(check_first_line(&bad, token).unwrap_err(), wrong, "{bad}"); + } + // An empty configured token never matches an empty given one. + assert!(check_first_line(&auth_line(1, ""), "").is_err()); + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"abcd")); + assert_eq!(hex(&[0x00, 0xab, 0xff]), "00abff"); +} + +#[cfg(unix)] +#[test] +fn control_token_file_is_private() { + use std::os::unix::fs::PermissionsExt; + // Unique port per run so parallel tests don't share a file; the folder is the real one. + let port = 40_000 + (std::process::id() % 20_000) as u16; + let path = crate::control_auth::write_token_file(port, "tok-0123456789abcdef").unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(std::fs::metadata(path.parent().unwrap()).unwrap().permissions().mode() & 0o777, 0o700); + // A looser leftover is replaced, not reused. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap(); + crate::control_auth::write_token_file(port, "tok-1123456789abcdef").unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "tok-1123456789abcdef"); + std::fs::remove_file(&path).unwrap(); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7cc6ec230..39a3e5d67 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -6,7 +6,31 @@ Start the desktop app with a control port: gridcraft --control 7979 # or GRIDCRAFT_CONTROL_PORT=7979 ``` -Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets one reply line: +## Authentication + +The control port is reachable by every program on the machine, and by a web page that POSTs +`text/plain` to `127.0.0.1:7979`, so every connection must prove it knows the app's token +(#777). **There is no way to turn this off.** + +- The app makes a token at start (256 random bits from the operating system, kept in memory) and + writes it to a file only you can read: `$XDG_RUNTIME_DIR/gridcraft/control-.token` (else + `~/.cache/gridcraft/…`; Windows: `%USERPROFILE%\.gridcraft\…`; the folder is mode 0700 and the + file 0600 on Unix). The path is printed on stderr. The token itself is never printed or logged. +- Or choose the token yourself: `GRIDCRAFT_CONTROL_TOKEN=…` (preferred) or `--control-token …` + (visible in the process list), at least 16 characters. The app then writes no file. +- The first line of a connection must be + `{"id":0,"method":"auth","params":{"token":"…"}}`; the reply is + `{"id":0,"ok":true,"result":{"authenticated":true}}`. Any other first line (a wrong or missing + token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or + one longer than 4 KiB) gets `{"ok":false,"error":"authentication required"}` and the + connection is closed. After that, a line that isn't JSON also closes the connection. +- `gridcraft-cli send`/`mcp --connect` and `gridcraft_mcp::Remote` do this themselves: they use + `GRIDCRAFT_CONTROL_TOKEN` if set, else the token file of that port (so they work for the same + user on the same machine without setup). Your own client reads the file or the variable. + +## Requests + +Then send newline-delimited JSON to `127.0.0.1:7979` (after the `auth` line). Every request line gets one reply line: ```json {"id": 1, "method": "engine.execute", "params": {"command": "cell.set", "params": {"cell": "B2", "input": "=SUM(A1:A9)"}}} diff --git a/docs/mcp.md b/docs/mcp.md index 3e6e7bc11..811fc9500 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9,7 +9,7 @@ Two backends: | Mode | Command | What it drives | |---|---|---| | Headless (default) | `gridcraft-cli mcp [--in book.xlsx \| --sample budget]` | An in-process engine session — no window. | -| Remote | `gridcraft-cli mcp --connect 7979` | A running app started with `gridcraft --control 7979` (TCP JSON lines on `127.0.0.1`). | +| Remote | `gridcraft-cli mcp --connect 7979` | A running app started with `gridcraft --control 7979` (TCP JSON lines on `127.0.0.1`, authenticated with the app's token: it is read from the app's token file or `GRIDCRAFT_CONTROL_TOKEN`, see [`control-protocol.md`](control-protocol.md)). | ## Claude Code