From 2e67bbaddaf4d8c88dc53f439937b1386eb53da8 Mon Sep 17 00:00:00 2001 From: Evgeniy Kovrov <7254010+nyxene@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:14:45 +0100 Subject: [PATCH 1/2] fix: plugins are let in only by the owner, across restarts and into isolated panes - An agent plugin's data paths are part of what it can do: the install and update question names them, and an update that adds one asks again, also under --yes. - The plugins let in are kept in plugins-admitted.json; a server start loads the lock's, those and the built-ins, never a folder put in since. The first start with the file keeps every folder already there; a missing file says so in daemon.log, and one that does not parse lets in the lock's only and stays as it is until the owner's Reload. - The pane wrapper is told the server's plugins with --plugins; without it (an older server) it reads the file, so a folder never let in adds nothing to a pane's profile. - Reload from a window inside a pane loads again only what was let in, and says so when a folder waits. - Remote shells, database and language prompts, debuggers, multiplexers, sudo and su, and every program in /bin, /sbin, /usr/bin and /usr/sbin are refused as agent names. - The Isolation window prefixes a plugin's sandbox note with "plugin : " and indents it, so no wrapped line of it reads as a built-in's note. - Tests: the not-JSON config test names its agent vimlike (vim, in /usr/bin, is now refused); the restart test stops the server through stop_and_wait. Part of GHSA-jr8q-vrww-r6hm Co-Authored-By: Claude --- README.md | 2 +- docs/plugins.md | 12 +++- src/agents.rs | 151 ++++++++++++++++++++++++++++++++++++++++++- src/app/commands.rs | 14 +++- src/app/lifecycle.rs | 11 +--- src/app/mod.rs | 48 +++++++++++++- src/hook.rs | 10 +-- src/install.rs | 1 + src/isolation.rs | 42 +++++++++--- src/plugins.rs | 18 ++++-- tests/daemon.rs | 22 +++++++ tests/pane_exec.rs | 30 +++++++++ 12 files changed, 323 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 5ae3a44..1001949 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ A click on a subagent in the sidebar opens its tool calls beside the agent. A re Other agents are recognised by name, and their state is read from what they print. It can lag, be wrong, or miss "waiting for you". -Each agent is described by a manifest, `plugins//plugin.json` in the source. To add another agent without a new nerd, put its manifest in `~/Library/Application Support/nerd/plugins//plugin.json` and choose **Reload plugins and agents** in the main menu. Such a manifest adds its hook to a JSON config under `~/.` or `~/.config/` and never replaces a file. A manifest that breaks a rule, or names a built-in agent, is skipped, and the log says why. +Each agent is described by a manifest, `plugins//plugin.json` in the source. To add another agent without a new nerd, put its manifest in `~/Library/Application Support/nerd/plugins//plugin.json` and choose **Reload plugins and agents** in the main menu, in a nerd window opened from your terminal. Such a manifest adds its hook to a JSON config under `~/.` or `~/.config/` and never replaces a file. A manifest that breaks a rule, or names a built-in agent, is skipped, and the log says why. ## Requirements diff --git a/docs/plugins.md b/docs/plugins.md index 217e237..63d3b6f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -58,9 +58,15 @@ your answer the window comes back with the same row chosen. When there is nothin the fetch fails, they say why in a line or two, and show log opens git's full output. **By hand.** Put the folder at `~/Library/Application Support/nerd/plugins//` (the state root's `plugins/`; -the folder name is the manifest's `name`) and choose Reload plugins and agents; until then the Plugins window says -**waits for Reload**, and Enter on its row does it. A folder that appears there is never loaded on its own, so nothing -can slip a plugin in unseen. Such a plugin is never updated. A +the folder name is the manifest's `name`) and choose Reload plugins and agents in a nerd window opened from your +terminal; until then the Plugins window says **waits for Reload**, and Enter on its row does it. A folder that appears +there is never loaded on its own, not when nerd starts again and not into an isolated pane, so nothing can slip a +plugin in unseen. From a window inside a pane, Reload loads again only the plugins already let in. nerd keeps the +names it let in, the lock's included, in `plugins-admitted.json` in the state root. The first start of a version that +keeps the file lets in every folder already there, and a missing `plugins-admitted.json` lets every folder in, as the +first start did; `daemon.log` says so. A file that does not parse lets in only the lock's plugins and is left as it +is until your next Reload. The file holds names, not contents: a plugin let in whose manifest is edited by hand later +loads as edited, with no question. Such a plugin is never updated. A manifest that breaks a rule is skipped, and `daemon.log` says why (`agent manifest skipped: : `). **The lock.** `plugins.lock` in the state root records each plugin installed from git: its name, address, tag and diff --git a/src/agents.rs b/src/agents.rs index 8a05e0a..45f1c03 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -1,6 +1,7 @@ //! Agents as data: each one is a `plugin.json` manifest, built in or dropped into `/plugins//`. //! A manifest says how nerd finds the agent, hooks into its config and resumes its session. +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; @@ -377,6 +378,21 @@ pub struct Strip { /// The words the hook script acts on; anything else it ignores. pub const WORDS: &[&str] = &["working", "idle", "blocked", "fail", "stop", "notify", "task", "sub-start", "sub-stop", "sub-reset", "tool-start", "tool-ok", "tool-fail", "mail"]; +/// Remote shells, database and language prompts, debuggers, other users' shells and multiplexers. +/// Those of macOS's own are refused by `SYSTEM_BIN` too; a name with a dot, such as python3.12, never passes `token`. +// ceiling: a list; the owner's yes to the names in the install question is the wall. +const NOT_AGENTS:&[&str] = &[ + "ssh", "mosh", "mosh-client", "et", "telnet", "sftp", "psql", "pgcli", "mysql", "mycli", "mariadb", "sqlite3", "litecli", "redis-cli", "valkey-cli", "mongosh", "mongo", + "irb", "ghci", "ipython", "lldb", "gdb", "sudo", "su", "screen", "tmux", "zellij", "byobu", "dtach", "abduco", "kitten", "nu", "pwsh", "xonsh", "elvish", "docker", "podman", + "kubectl", "perl", "ruby", "mksh", "yash", "lua", "luajit", "julia", "ocaml", "racket", "sbcl", "guile", "erl", "iex", "duckdb", "mysqlsh", "usql", "cqlsh", "nvim", "hx", "emacs", "kak", +]; +const SYSTEM_BIN: &[&str] = &["/bin", "/sbin", "/usr/bin", "/usr/sbin"]; + +fn system_program(p: &Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + const BUILTIN: &[&str] = &[ include_str!("../plugins/claude/plugin.json"), include_str!("../plugins/codex/plugin.json"), @@ -401,16 +417,82 @@ pub fn builtins() -> Vec { static LOADED: RwLock>>> = RwLock::new(None); -/// Built-ins first, then the user's; read once, then again on `reload`. +/// Built-ins first, then the user's let in; read once, then again on `reload`. pub fn all() -> Arc> { if let Some(ms) = LOADED.read().unwrap_or_else(|e| e.into_inner()).as_ref() { return ms.clone(); } - reload() + // The first start that keeps the file: every folder loaded before stays let in. + admit(if crate::paths::state_root().join(ADMITTED).exists() { BTreeSet::new() } else { admitted() }, false); + reload_admitted() } +/// The owner's Reload, from a window outside a pane: every folder there is let in. pub fn reload() -> Arc> { - reload_admitting(&|_| true) + let ms = reload_admitting(&|_| true); + admit(ms.iter().filter(|m| !m.builtin).map(|m| m.name.clone()).collect(), true); + ms +} + +/// Reads the manifests again, loading only plugins let in before: a folder new in `plugins/` waits. +pub fn reload_admitted() -> Arc> { + let a = admitted(); + reload_admitting(&|n| a.contains(n)) +} + +/// Plugins put in by hand and let in by a Reload outside a pane, by name; the lock's are let in without it. +pub const ADMITTED: &str = "plugins-admitted.json"; + +/// The lock's plugins and `plugins-admitted.json`'s; before that file exists, every folder in `plugins/`, as nerd loaded them. +pub fn admitted() -> BTreeSet { + let root = crate::paths::state_root(); + let mut out: BTreeSet = crate::install::read_lock().unwrap_or_default().into_iter().map(|p| p.name).collect(); + match std::fs::read(root.join(ADMITTED)) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + static SAID: std::sync::Once = std::sync::Once::new(); + SAID.call_once(|| crate::log::warn(&format!("{ADMITTED} is missing: every folder in plugins/ is let in, as at the first start"))); + out.extend(folders(&root.join("plugins"))) + } + r => match r.map_err(|e| e.to_string()).and_then(|b| serde_json::from_slice::>(&b).map_err(|e| e.to_string())) { + Ok(names) => out.extend(names), + // Only the lock's, until the owner's Reload writes it again. + Err(e) => crate::log::warn_limited("admitted", &format!("{ADMITTED} does not parse ({e}): only the lock's plugins load until Reload plugins and agents")), + }, + } + out +} + +/// Adds `names` to the file; a name whose folder is gone leaves it, so a folder put there later under it waits. +/// ceiling: a folder deleted by hand keeps its name let in until a start, a Reload or nerd's remove finds it gone; one put back +/// under that name before then loads. Removing through nerd is the way past. +pub fn admit(names: BTreeSet, owner: bool) { + let path = crate::paths::state_root().join(ADMITTED); + if !crate::paths::state_root().is_dir() { + return; + } + let before: Vec = match std::fs::read(&path).map_err(|e| e.kind() == std::io::ErrorKind::NotFound).and_then(|b| serde_json::from_slice(&b).map_err(|_| false)) { + Ok(names) => names, + Err(true) => Vec::new(), + // Broken or unreadable: left as it is until the owner's Reload writes it again. + Err(false) if owner => Vec::new(), + Err(false) => return, + }; + let present = folders(&crate::paths::state_root().join("plugins")); + let now: BTreeSet = before.into_iter().chain(names).filter(|n| present.contains(n)).collect(); + let text = serde_json::to_string(&now).unwrap_or_default() + "\n"; + if std::fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) && crate::paths::write_private(&path, "tmp", text.as_bytes()).is_err() { + crate::log::warn(&format!("plugins let in not written to {}", path.display())); + } +} + +/// Plugins in `plugins/` not let in yet, the ones turned off aside. +pub fn waiting() -> Vec { + let (a, off) = (admitted(), crate::install::read_off().unwrap_or_default()); + folders(&crate::paths::state_root().join("plugins")).into_iter().filter(|n| !a.contains(n) && !off.contains_key(n)).collect() +} + +fn folders(dir: &Path) -> BTreeSet { + std::fs::read_dir(dir).into_iter().flatten().flatten().filter(|e| e.path().join("plugin.json").is_file()).map(|e| e.file_name().to_string_lossy().into_owned()).collect() } /// Reads the manifests again, loading only the folders `admit` names. @@ -648,6 +730,14 @@ pub fn validate(m: &Manifest, others: &[Manifest]) -> Result<(), String> { if let Some(n) = a.names.iter().find(|n| *n == "nerd" || crate::detect::LAUNCHERS.contains(&n.as_str())) { return Err(format!("{n} runs other programs, it is not an agent")); } + // nerd types messages into an agent: into one of these they would reach another machine, database or prompt + if let Some(n) = a.names.iter().find(|n| NOT_AGENTS.contains(&n.as_str())) { + return Err(format!("{n} passes what is typed on to another machine or program, it is not an agent")); + } + // macOS's own shells, interpreters and tools; a built-in is checked once, here, not on each Mac + if !m.builtin && let Some((n, d)) = a.names.iter().find_map(|n| SYSTEM_BIN.iter().find(|d| system_program(&Path::new(d).join(n))).map(|d| (n, d))) { + return Err(format!("{n} is macOS's own {d}/{n}, it is not an agent")); + } // a file of nerd's own replaces what is there: only built-ins may, a user's manifest merges into a JSON file if !m.builtin && a.config.as_ref().is_some_and(|c| matches!(c.shape, Shape::Own | Shape::Plugin)) { return Err("a user's manifest may use the nested or flat shape only".into()); @@ -1196,6 +1286,17 @@ mod tests { assert!(blocked_text(None).is_empty(), "a shell is never asked a question"); } + #[test] + fn a_remote_shell_or_a_prompt_is_not_an_agent() { + for n in ["ssh", "psql", "irb", "tmux", "sudo", "mosh-client", "zellij", "perl", "lua", "nvim"] { + assert!(refused(serde_json::json!({ "names": ["fake", n] })).contains("passes what is typed on"), "{n}"); + } + for n in ["ksh", "tcsh", "nc"] { + assert!(refused(serde_json::json!({ "names": ["fake", n] })).contains("is macOS's own"), "{n}"); + } + assert!(refused(serde_json::json!({ "names": ["fake", "python3.12"] })).contains("lowercase letters, digits and dashes")); + } + #[test] fn a_user_manifest_cannot_take_a_builtin_name() { let e = refused(serde_json::json!({ "names": ["fake", "claude"] })); @@ -1220,6 +1321,45 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A server start is the first `all()` of a process: a folder put in by hand since waits, across starts too. + #[test] + fn a_folder_put_in_by_hand_is_let_in_only_by_reload_and_stays_so_across_starts() { + crate::session::tests::with_temp_state_dir("admitted", || { + let root = crate::paths::state_root(); + let put = |n: &str| { + std::fs::create_dir_all(root.join("plugins").join(n)).unwrap(); + std::fs::write(root.join("plugins").join(n).join("plugin.json"), serde_json::json!({ "name": n, "title": "T", "agent": { "names": [n] } }).to_string()).unwrap(); + }; + let start = || { + *LOADED.write().unwrap() = None; + all().iter().filter(|m| !m.builtin).map(|m| m.name.clone()).collect::>() + }; + for n in ["had", "pin", "rest"] { + put(n); + } + std::fs::write(root.join(crate::install::LOCK), format!(r#"[{{"name":"pin","source":"https://github.com/o/pin","commit":"{}"}}]"#, "a".repeat(40))).unwrap(); + std::fs::write(root.join(crate::install::OFF), r#"{"rest": "turned off"}"#).unwrap(); + assert_eq!(start(), ["had", "pin"], "the first start that keeps the file loads what the one before did"); + put("planted"); + assert_eq!(start(), ["had", "pin"], "a folder put in since waits, across a start"); + assert_eq!(waiting(), ["planted"]); + std::fs::write(root.join(crate::install::OFF), "{}").unwrap(); + assert_eq!(start(), ["had", "pin", "rest"], "one off at the first start is let in once turned on"); + assert_eq!(reload_admitted().iter().filter(|m| !m.builtin).count(), 3, "a Reload from inside a pane lets nothing new in"); + assert_eq!(reload().iter().filter(|m| !m.builtin).count(), 4); + assert_eq!(start(), ["had", "pin", "planted", "rest"], "let in by Reload, it stays let in"); + crate::install::remove("planted", false).unwrap(); + put("planted"); + assert_eq!(start(), ["had", "pin", "rest"], "removed and put back, it waits again"); + std::fs::write(root.join(ADMITTED), "[\"had\", broken").unwrap(); + assert_eq!(start(), ["pin"], "a file that does not parse lets in the lock's only"); + assert_eq!(std::fs::read_to_string(root.join(ADMITTED)).unwrap(), "[\"had\", broken", "and is left for the owner to see"); + reload(); + assert_eq!(start(), ["had", "pin", "planted", "rest"], "until the owner's Reload writes it again"); + *LOADED.write().unwrap() = None; + }); + } + #[test] fn an_off_plugin_is_not_loaded_and_a_broken_off_file_loads_none() { let root = std::env::temp_dir().join(format!("nerd-off-{}", std::process::id())); @@ -1598,6 +1738,11 @@ mod tests { let a = abilities(&agent).unwrap(); assert_eq!(risk(&a), 3); assert_eq!(a.iter().map(|x| x.to_string()).collect::>(), ["Speaks for the agent fake, fake-cli, adding nerd's hooks to ~/.fake/hooks.json, and restores its panes by typing `fake --resume {id}`"]); + let with = |data: serde_json::Value| abilities(&fake(serde_json::json!({ "data": data }))).unwrap(); + let one = with(serde_json::json!(["~/.fake/sessions"])); + assert!(one.iter().any(|x| x.to_string().starts_with("Speaks for the agent fake, fake-cli, lets isolated panes write ~/.fake/sessions, adding")), "{one:?}"); + assert_eq!(crate::plugins::new_abilities(&with(serde_json::json!(["~/.fake/sessions", "~/.fake/logs"])), &one).len(), 1, "an added data path asks again"); + assert!(crate::plugins::new_abilities(&with(serde_json::json!([])), &one).is_empty(), "a data path dropped asks nothing"); } #[test] diff --git a/src/app/commands.rs b/src/app/commands.rs index 9598549..339f112 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1449,7 +1449,15 @@ impl App { (Action::Settings, _) => return Overlay::Settings(crate::settings::Settings::new(&self.cfg)), (Action::Keybinds, _) => return Overlay::Keys(crate::keyshelp::KeysHelp::new(self.cfg.prefix)), (Action::ReloadConfig, _) => { - crate::agents::reload(); + // From a window inside a pane, an agent could let its own folder in: only what was let in before loads. + if self.client_inside { + crate::agents::reload_admitted(); + if !crate::agents::waiting().is_empty() { + self.notice = Some("Not from a window inside a pane: open nerd from your terminal to load a plugin put in by hand.".into()); + } + } else { + crate::agents::reload(); + } self.plugins.reload(); self.reload_cfg(); } @@ -1789,7 +1797,9 @@ impl App { pub(super) fn isolation_overlay(&self, target: IsolationTarget) -> Overlay { let agents = crate::agents::all(); let providers: Vec = agents.iter().filter(|m| matches!(m.agent.network.net, crate::agents::Network::Internet { .. })).map(|m| m.name.clone()).collect(); - let notes = agents.iter().filter_map(|m| Some(format!("{}: {}", m.title, m.agent.own_sandbox.as_ref()?))).collect(); + // A plugin's title is its own words: it could name itself after a built-in. Indented, so no line of its note, + // wrapped or not, starts where a built-in's does. + let notes = agents.iter().filter_map(|m| Some(format!("{}{}: {}", if m.builtin { String::new() } else { format!(" plugin {}: ", m.name) }, m.title, m.agent.own_sandbox.as_ref()?))).collect(); let accounts = crate::policy::current().accounts.iter().map(|a| a.name.clone()).collect(); let (about, mut pick, inherit, agent, folder) = match target { IsolationTarget::Pane(id) => { diff --git a/src/app/lifecycle.rs b/src/app/lifecycle.rs index 4fb8236..1164b08 100644 --- a/src/app/lifecycle.rs +++ b/src/app/lifecycle.rs @@ -513,7 +513,7 @@ impl App { } /// A plugin added, updated, removed or turned off or on outside the server: only the ones whose manifest changed - /// restart. A folder that no lock names and that was not loaded waits for Reload plugins. + /// restart. A folder that no lock names and that was never let in waits for Reload plugins, turned off and on or not. pub(super) fn reload_plugins_if_changed(&mut self) { let seen = crate::install::seen(); if seen == self.plugins_seen { @@ -521,13 +521,8 @@ impl App { } self.plugins_seen = seen; let before = crate::agents::all(); - let pinned: HashSet = crate::install::read_lock().unwrap_or_default().into_iter().map(|p| p.name).collect(); - // A plugin just turned on was known before it was turned off, pinned or not. - let now_off: HashSet = crate::install::read_off().unwrap_or_default().into_keys().collect(); - // Only a plugin that was loaded when it was turned off: a folder dropped in and toggled still waits. - let now_off: HashSet = now_off.into_iter().filter(|n| self.plugins_off.contains(n) || before.iter().any(|m| m.name == *n)).collect(); - let was_off = std::mem::replace(&mut self.plugins_off, now_off); - let after = crate::agents::reload_admitting(&|n| pinned.contains(n) || was_off.contains(n) || before.iter().any(|m| m.name == n)); + let admitted = crate::agents::admitted(); + let after = crate::agents::reload_admitting(&|n| admitted.contains(n) || before.iter().any(|m| m.name == n)); let commits = std::mem::replace(&mut self.plugins_commits, crate::install::commits()); for m in before.iter().filter(|m| !m.builtin) { // Removed and added again within one poll, or updated with only its code changed: the manifest reads the same. diff --git a/src/app/mod.rs b/src/app/mod.rs index d0617aa..1b0fe1a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -117,8 +117,6 @@ pub struct App { pub plugin_job: Option, /// Plugins a pane staged that the Plugins window has asked about. plugins_asked: HashSet, - /// The plugins off when plugins-off.json was last read. - plugins_off: HashSet, /// config.json did not parse: the last good settings stay and nothing writes the file. pub cfg_broken: bool, /// Right-hand file tree panel; None means hidden. @@ -348,7 +346,6 @@ impl App { servers_back: None, servers: crate::worker::Worker::spawn("servers", bell.clone(), crate::server_list::work, crate::server_list::failed), plugin_job: None, - plugins_off: crate::install::read_off().unwrap_or_default().into_keys().collect(), cfg_broken: false, tree: None, focus: Focus::default(), @@ -3024,6 +3021,30 @@ mod tests { } } + #[test] + fn isolation_names_the_plugin_a_sandbox_note_comes_from() { + crate::session::tests::with_temp_state_dir("iso-notes", || { + let dir = crate::paths::state_root().join("plugins/mimic"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("plugin.json"), serde_json::json!({ "name": "mimic", "title": "Claude Code", "agent": { "names": ["mimic"], "own_sandbox": "turn its sandbox off" } }).to_string()).unwrap(); + // Its title fills the first line to the wrap width, the " plugin long: " prefix counted, so the wrapped rest would start a line as Claude Code's does. + let long = crate::paths::state_root().join("plugins/long"); + std::fs::create_dir_all(&long).unwrap(); + let own = "Claude Code: turn /sandbox off; nerd's limits already cover the whole agent"; + std::fs::write(long.join("plugin.json"), serde_json::json!({ "name": "long", "title": "a".repeat(76 - 14), "agent": { "names": ["long"], "own_sandbox": own } }).to_string()).unwrap(); + crate::agents::reload(); + let Overlay::Isolation(w) = with_one_space().isolation_overlay(IsolationTarget::Space(0)) else { panic!("it opens") }; + let rows = crate::messages::wrap(w.lines(&crate::render::Theme::dark()), 76); + let text: Vec = rows.iter().map(|l| l.iter().map(|s| s.0.as_str()).collect()).collect(); + assert_eq!(text.iter().filter(|l| l.starts_with("Claude Code:")).count(), 1, "only the built-in's note starts a line so: {text:#?}"); + assert!(w.notes.iter().any(|n| n == " plugin mimic: Claude Code: turn its sandbox off"), "{:?}", w.notes); + assert!(w.notes.iter().any(|n| n.starts_with("Claude Code: turn /sandbox off")), "a built-in's note is as before: {:?}", w.notes); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&long); + crate::agents::reload(); + }); + } + #[test] fn isolation_knows_a_pane_whose_folder_is_the_home_folder() { crate::config::tests::with_temp_home(|| { @@ -4335,6 +4356,8 @@ mod tests { fn a_folder_put_in_by_hand_waits_for_reload_and_enter_on_its_row_loads_it() { crate::session::tests::with_temp_state_dir("plugins-waits", || { let mut app = with_one_space(); + // Started before the folder appears: the first start would let in what it finds. + crate::agents::reload(); let dir = crate::paths::state_root().join("plugins/hand"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("plugin.json"), serde_json::json!({ "name": "hand", "title": "H", "agent": { "names": ["hand"] } }).to_string()).unwrap(); @@ -4357,6 +4380,25 @@ mod tests { }); } + #[test] + fn reload_from_a_window_inside_a_pane_lets_no_folder_in() { + crate::session::tests::with_temp_state_dir("plugins-inside", || { + let mut app = with_one_space(); + crate::agents::reload(); + let dir = crate::paths::state_root().join("plugins/hand"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("plugin.json"), serde_json::json!({ "name": "hand", "title": "H", "agent": { "names": ["hand"] } }).to_string()).unwrap(); + app.client_inside = true; + app.run_action(Action::ReloadConfig, &MenuCtx::Global); + assert!(crate::agents::get("hand").is_none(), "an agent's window cannot let its own folder in"); + assert_eq!(app.notice.as_deref(), Some("Not from a window inside a pane: open nerd from your terminal to load a plugin put in by hand.")); + app.client_inside = false; + app.run_action(Action::ReloadConfig, &MenuCtx::Global); + assert!(crate::agents::get("hand").is_some(), "the owner's Reload does"); + crate::agents::reload(); + }); + } + #[test] fn the_plugins_window_lists_each_plugin_and_its_menu_acts_on_it() { crate::session::tests::with_temp_state_dir("plugins-window", || { diff --git a/src/hook.rs b/src/hook.rs index ccdd133..da74c5d 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -2907,13 +2907,13 @@ console.log("{}") #[test] fn a_user_manifest_aimed_at_a_file_that_is_not_json_writes_nothing() { let home = scratch_home("vim"); - std::fs::create_dir_all(home.join(".vim")).unwrap(); - std::fs::write(home.join(".vim/vimrc"), "set nu\n").unwrap(); - let m: Manifest = serde_json::from_value(json!({ "name": "vim", "title": "V", "agent": { "names": ["vim"], "config": { "path": "~/.vim/vimrc", "shape": "nested" }, "arg": "vim", "events": [{ "on": "x", "do": "idle" }] } })).unwrap(); + std::fs::create_dir_all(home.join(".vimlike")).unwrap(); + std::fs::write(home.join(".vimlike/vimrc"), "set nu\n").unwrap(); + let m: Manifest = serde_json::from_value(json!({ "name": "vimlike", "title": "V", "agent": { "names": ["vimlike"], "config": { "path": "~/.vimlike/vimrc", "shape": "nested" }, "arg": "vimlike", "events": [{ "on": "x", "do": "idle" }] } })).unwrap(); assert!(crate::agents::validate(&m, &crate::agents::builtins()).is_ok(), "the path rule alone lets it through"); assert!(install_one(&m, &home).is_err_and(|e| e.to_string().contains("does not parse"))); - assert_eq!(std::fs::read_to_string(home.join(".vim/vimrc")).unwrap(), "set nu\n"); - assert!(!script(&m, &home).exists() && !home.join(".vim/vimrc.json.bak-nerd").exists(), "nothing of ours beside it"); + assert_eq!(std::fs::read_to_string(home.join(".vimlike/vimrc")).unwrap(), "set nu\n"); + assert!(!script(&m, &home).exists() && !home.join(".vimlike/vimrc.json.bak-nerd").exists(), "nothing of ours beside it"); let _ = std::fs::remove_dir_all(&home); } diff --git a/src/install.rs b/src/install.rs index 1a1e026..dc8216f 100644 --- a/src/install.rs +++ b/src/install.rs @@ -666,6 +666,7 @@ pub fn remove(name: &str, data: bool) -> Result<(), String> { std::fs::rename(&dir, out.join(name)).map_err(|e| format!("{}: {e}", dir.display()))?; pins.retain(|p| p.name != name); write_lock(&pins)?; + crate::agents::admit(Default::default(), false); if let Ok(mut off) = read_off() && off.remove(name).is_some() { diff --git a/src/isolation.rs b/src/isolation.rs index a3ff54b..6ecc174 100644 --- a/src/isolation.rs +++ b/src/isolation.rs @@ -1,5 +1,6 @@ //! Pane isolation: the wrapper that starts a pane in its folder or account, and its network proxy. +use std::collections::BTreeSet; use std::io::Write as _; use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; use std::path::{Path, PathBuf}; @@ -80,6 +81,8 @@ pub fn wrap(id: PaneId, argv: &[String], cwd: &Path, iso: &Isolation, env: &mut let files = if iso.files == Files::Folder { "folder" } else { "everything" }; flags.extend(["--files", files, "--cwd", &cwd.to_string_lossy(), "--msock", &msock, "--msock-uid", &uid, "--bin", &bin].map(String::from)); flags.extend(["--sock".into(), crate::session::socket_path().to_string_lossy().into_owned()]); + // The plugins this server let in: a folder put in since never reaches the pane's profile. + flags.extend(["--plugins".into(), crate::agents::all().iter().filter(|m| !m.builtin).map(|m| m.name.as_str()).collect::>().join(",")]); if let Ok(dump) = std::env::var("NERD_HOOK_DUMP") { flags.extend(["--dump".into(), dump]); } @@ -229,12 +232,12 @@ impl Places { } } - fn here() -> Result { + fn here(plugins: &BTreeSet) -> Result { let home = crate::paths::home().unwrap_or_default(); if !home.is_absolute() || home == Path::new("/") { return Err("nerd has no home folder, so it cannot keep this pane out of ~/.ssh".into()); } - let agents = crate::agents::load_admitting(&crate::paths::state_root().join("plugins"), &|_| true); + let agents = crate::agents::load_admitting(&crate::paths::state_root().join("plugins"), &|n| plugins.contains(n)); let (mut terminal_dirs, terminal_files) = crate::presets::config_places(); terminal_dirs.retain(|d| !RUN_LATER_DIRS.iter().any(|r| home.join(r) == *d)); Ok(Places { @@ -281,9 +284,9 @@ const UNREAD: &[&str] = &[".ssh", ".aws", ".gnupg", ".netrc", ".docker", ".kube" /// The pane's Seatbelt profile. An account pane passes `Everything` or `Account`: the account confines its files. /// `sock`: the server's socket, whose plugin and broker sockets sit beside it; None from a wrapper an older server started. -pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, sock: Option<&Path>, port: Option) -> Result { +pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, sock: Option<&Path>, port: Option, plugins: &BTreeSet) -> Result { let nerd = sock.map(|s| ["sock", "psock", "xsock"].map(|e| crate::plugins::canon(&s.with_extension(e))).to_vec()).unwrap_or_default(); - let p = Places { nerd, ..Places::here()? }; + let p = Places { nerd, ..Places::here(plugins)? }; if *files == Files::Folder { p.make_agent_folders(); } @@ -292,7 +295,7 @@ pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, sock: /// At hook install: the agent's folders a This-folder-only pane could otherwise move in whole. pub fn make_agent_folders(data: Vec, home: &Path) { - if let Ok(p) = Places::here() { + if let Ok(p) = Places::here(&crate::agents::admitted()) { Places { data, home: home.to_path_buf(), ..p }.make_agent_folders(); } } @@ -586,7 +589,9 @@ pub fn pane_exec_main(args: &[String]) -> std::io::Result<()> { if let Some(d) = get("--dump") { cmd.env("NERD_HOOK_DUMP", d); } - let data = if files == Files::Folder { Places::here().map(|p| p.data).unwrap_or_default() } else { Vec::new() }; + // An older server names none: then the plugins let in on disk. + let plugins: BTreeSet = get("--plugins").map_or_else(crate::agents::admitted, |p| p.split(',').filter(|n| !n.is_empty()).map(String::from).collect()); + let data = if files == Files::Folder { Places::here(&plugins).map(|p| p.data).unwrap_or_default() } else { Vec::new() }; let network = match need("--network") { "open" => Network::Open, "none" => Network::None, @@ -595,7 +600,7 @@ pub fn pane_exec_main(args: &[String]) -> std::io::Result<()> { }; if !matches!(network, Network::Providers(_)) { let confined = files == Files::Folder || network != Network::Open; - let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), None).unwrap_or_else(|e| fail(e)); + let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), None, &plugins).unwrap_or_else(|e| fail(e)); fail(become_pane(cmd, confined.then_some(profile.as_str()), &data)); } let net = crate::proxy::Net::parse(need("--hosts")).unwrap_or_else(|| fail("--pane-exec: --hosts does not parse")); @@ -605,7 +610,7 @@ pub fn pane_exec_main(args: &[String]) -> std::io::Result<()> { cmd.env(k, format!("http://127.0.0.1:{port}")); } cmd.env("NODE_USE_ENV_PROXY", "1").env("NO_PROXY", "").env("no_proxy", ""); - let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), Some(port)).unwrap_or_else(|e| fail(e)); + let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), Some(port), &plugins).unwrap_or_else(|e| fail(e)); // SAFETY: no thread exists yet, so the child may allocate; it never returns into the proxy's code. match unsafe { nix::unistd::fork() }? { nix::unistd::ForkResult::Child => { @@ -683,6 +688,25 @@ mod tests { Some(out.status.code().unwrap_or(-1)) } + #[test] + fn the_wrapper_is_told_only_the_plugins_the_server_let_in() { + crate::session::tests::with_temp_state_dir("wrap-plugins", || { + let put = |n: &str| { + let d = crate::paths::state_root().join("plugins").join(n); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("plugin.json"), serde_json::json!({ "name": n, "title": n, "agent": { "names": [n] } }).to_string()).unwrap(); + }; + put("kept"); + crate::agents::reload(); + put("planted"); + let iso = Isolation { files: Files::Folder, network: Network::None }; + let argv = wrap(1, &["/bin/sh".into()], Path::new("/tmp"), &iso, &mut Vec::new()).unwrap(); + let at = argv.iter().position(|a| a == "--plugins").expect("named"); + assert_eq!(argv[at + 1], "kept", "{argv:?}"); + crate::agents::reload(); + }); + } + #[test] fn terminal_configs_the_environment_moved_are_not_writable_either() { crate::config::tests::with_temp_home(|| { @@ -690,7 +714,7 @@ mod tests { crate::presets::set_env("XDG_CONFIG_HOME", home.join("xdg")); crate::presets::set_env("KITTY_CONFIG_DIRECTORY", home.join("k")); crate::presets::set_env("WEZTERM_CONFIG_FILE", home.join("w.lua")); - let p = Places::here().unwrap(); + let p = Places::here(&BTreeSet::new()).unwrap(); let s = text(&p, &Files::Folder, &Network::None, &home.join("work"), &home.join("m.sock"), None).unwrap(); let rule = |kind: &str, sub: &str| format!("({kind} {})", quote(&canon(&home.join(sub))).unwrap()); for d in ["xdg/ghostty", "k", "xdg/wezterm", ".config/kitty"] { diff --git a/src/plugins.rs b/src/plugins.rs index 7d9b5a3..b718518 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -372,7 +372,7 @@ pub enum Ability { FilesSelected(Vec), Writes(PathBuf), Network(Network), - Agent { names: Vec, config: Option, resume: Vec, network: Network }, + Agent { names: Vec, data: Vec, config: Option, resume: Vec, network: Network }, } impl std::fmt::Display for Ability { @@ -401,8 +401,11 @@ impl std::fmt::Display for Ability { write!(f, "; could send anything it can read to those hosts, the environment of your other programs included") } Ability::Network(Network::None) => write!(f, "Reaches no network"), - Ability::Agent { names, config, resume, network } => { + Ability::Agent { names, data, config, resume, network } => { write!(f, "Speaks for the agent {}", names.join(", "))?; + if !data.is_empty() { + write!(f, ", lets isolated panes write {}", data.join(", "))?; + } if let Network::Internet { hosts, ports } = network { write!(f, ", lets a pane set to Only these providers reach {} on {} {}", hosts.join(", "), if ports.len() == 1 { "port" } else { "ports" }, crate::agents::join(ports))?; } @@ -435,11 +438,18 @@ impl Ability { } } -/// What `now` can do that `before` could not; a network inside the one approved before is nothing new. +/// What `now` can do that `before` could not; a network inside the one approved before, or an agent that only lost data paths, is nothing new. pub fn new_abilities(now: &std::collections::BTreeSet, before: &std::collections::BTreeSet) -> Vec { + let fewer_paths = |a: &Ability, b: &Ability| match (a, b) { + (Ability::Agent { names, data, config, resume, network }, Ability::Agent { names: n, data: d, config: c, resume: r, network: w }) => { + (names, config, resume, network) == (n, c, r, w) && data.iter().all(|p| d.contains(p)) + } + _ => false, + }; now.iter() .filter(|a| !before.contains(a)) .filter(|a| !matches!(a, Ability::Network(n) if before.iter().any(|b| matches!(b, Ability::Network(w) if n.within(w))))) + .filter(|a| !before.iter().any(|b| fewer_paths(a, b))) .cloned() .collect() } @@ -471,7 +481,7 @@ pub fn abilities(m: &Manifest) -> Result, St out.insert(Ability::Network(m.permissions.network.clone())); } if !m.agent.names.is_empty() { - out.insert(Ability::Agent { names: m.agent.names.clone(), config: m.agent.config.as_ref().map(|c| c.path.clone()), resume: m.agent.resume.clone(), network: m.agent.network.net.clone() }); + out.insert(Ability::Agent { names: m.agent.names.clone(), data: m.agent.data.clone(), config: m.agent.config.as_ref().map(|c| c.path.clone()), resume: m.agent.resume.clone(), network: m.agent.network.net.clone() }); } Ok(out) } diff --git a/tests/daemon.rs b/tests/daemon.rs index c61c720..49efbbe 100644 --- a/tests/daemon.rs +++ b/tests/daemon.rs @@ -2680,6 +2680,28 @@ fn group_gone(pid: i32) -> bool { nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pid), None).is_err() } +/// The first start with the file keeps what was there; a folder put in by hand later waits across a restart. +#[test] +fn a_plugin_folder_put_in_by_hand_waits_across_a_restart() { + let env = setup_env("plugin-admitted"); + install_plugin(&env, "kept", test_plugin("kept", &["command"], &["command"], "ping")); + let mut daemon = spawn_daemon(&env); + let mut client = connect(&env, 100, 30); + assert!(wait_for(&mut client, "spaces", Duration::from_secs(5)), "no frame: {}", daemon_log(&env)); + assert_eq!(read(&env.dir.join("plugins-admitted.json")), "[\"kept\"]\n", "{}", daemon_log(&env)); + assert_eq!(daemon_log(&env).matches("plugins-admitted.json is missing: every folder in plugins/ is let in").count(), 1, "{}", daemon_log(&env)); + stop_and_wait(&env, &mut daemon); + + install_plugin(&env, "planted", test_plugin("planted", &["command"], &["command"], "pong")); + let _daemon = spawn_daemon(&env); + let mut client = connect(&env, 100, 30); + assert!(wait_for(&mut client, "spaces", Duration::from_secs(5)), "no frame: {}", daemon_log(&env)); + let log = daemon_log(&env); + assert_eq!(log.matches("agent manifest loaded: kept").count(), 2, "{log}"); + assert!(log.contains("plugin planted waits for Reload plugins") && !log.contains("agent manifest loaded: planted"), "{log}"); + let _ = std::fs::remove_dir_all(&env.dir); +} + #[test] fn plugins_start_after_an_update_from_a_broker_too_old_to_launch_them() { let env = setup_env("plugin-old-broker"); diff --git a/tests/pane_exec.rs b/tests/pane_exec.rs index f48f619..11a0c68 100644 --- a/tests/pane_exec.rs +++ b/tests/pane_exec.rs @@ -16,6 +16,11 @@ struct Pane { impl Pane { /// `nerd --pane-exec` with `flags` running `script` as `/bin/sh script `; HOME is outside every temp tree. fn start(name: &str, flags: &[&str], script: &str, args: &[&str]) -> Pane { + Pane::start_with(name, flags, script, args, |_| {}) + } + + /// As `start`, with `setup` given the test's folder (home, state, work) before the pane starts. + fn start_with(name: &str, flags: &[&str], script: &str, args: &[&str], setup: impl FnOnce(&Path)) -> Pane { let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("pane-exec-{name}-{}", std::process::id())); let socks = PathBuf::from(format!("/tmp/nt-s5-{}-{name}", std::process::id())); for d in [&dir, &socks] { @@ -26,6 +31,7 @@ impl Pane { } let dir = dir.canonicalize().unwrap(); std::fs::write(dir.join("pane.sh"), script).unwrap(); + setup(&dir); let (own, msock) = (socks.join("s.sock"), socks.join("m.sock")); let _listeners = [&own, &msock].map(|p| std::os::unix::net::UnixListener::bind(p).unwrap()); let nerd = env!("CARGO_BIN_EXE_nerd"); @@ -136,6 +142,30 @@ fn folder_and_no_network_keep_the_pane_in_its_folder_and_off_every_socket_but_th assert!(!pane.dir.join("home/x").exists()); } +/// Each plugin's agent `data` is writable in a folder pane; a folder put in by hand and never let in adds nothing. +#[test] +fn a_plugin_folder_never_let_in_adds_nothing_to_a_folder_pane() { + let setup = |dir: &Path| { + for n in ["kept", "planted"] { + let p = dir.join("state/plugins").join(n); + std::fs::create_dir_all(&p).unwrap(); + let m = serde_json::json!({ "name": n, "title": n, "agent": { "names": [n], "data": [format!("~/.{n}/sessions")] } }); + std::fs::write(p.join("plugin.json"), m.to_string()).unwrap(); + std::fs::create_dir_all(dir.join("home").join(format!(".{n}/sessions"))).unwrap(); + } + std::fs::write(dir.join("state/plugins-admitted.json"), r#"["kept"]"#).unwrap(); + }; + let script = "touch \"$HOME/.kept/sessions/x\" 2>/dev/null; echo \"kept $?\" > out\n\ + touch \"$HOME/.planted/sessions/x\" 2>/dev/null; echo \"planted $?\" >> out\n\ + echo > done\n"; + // Named by the server, and from an older server that names none: the file then. + for (name, flags) in [("named", &["--files", "folder", "--network", "none", "--plugins", "kept"][..]), ("unnamed", &["--files", "folder", "--network", "none"][..])] { + let pane = Pane::start_with(name, flags, script, &[], setup); + pane.wait_for("done"); + assert_eq!(pane.wait_for("out"), "kept 0\nplanted 1\n", "{name}: {}", pane.read("stderr")); + } +} + #[test] fn folder_only_cannot_move_a_folder_above_a_denied_one_nor_link_one_in() { let script = "P=\"$HOME/.claude/projects\"\n\ From 9048173528f52974cdcafba5abc442086105cbd6 Mon Sep 17 00:00:00 2001 From: Evgeniy Kovrov <7254010+nyxene@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:14:50 +0100 Subject: [PATCH 2/2] fix: isolated panes cannot ask macOS to start programs, and Ctrl+click opens files only in an app chosen at the click - every pane profile refuses lsopen, job-creation, appleevent-send and user-preference-write, and looking up the services that run Automator workflows, shortcuts and user scripts; Isolation::profiled names where a profile applies - the Isolation window shows what that costs only for picks with a profile, a tab's or space's inherited one included - Ctrl+click and Open in its app refuse program types, Finder aliases, sockets and program folders; scripts, extensionless files and files without a trusted app open in the plain-text app; other files and packages open in the app LaunchServices names at the click, trusted only under the sealed system, /Applications, /Library or ~/Applications, with no part of the app or its executable in a folder another account may write, and outside the clicked path and the pane's folder - file:// links open only on this Mac; open -t is gone; links run /usr/bin/open - tmux, Zellij and screen in a This-folder-only pane get a 0700 folder of the pane's own that other panes can neither write nor connect into; the profile names that folder without following a link at its path, and allows nothing there when the folder could not be made; every This-folder-only pane may neither write where tmux, Zellij, screen and kitty outside it keep their sockets nor, with Network Open, connect there, the tmux nerd runs inside included - ~/Applications stays read-only in a This-folder-only pane - docs/isolation.md, SECURITY.md and docs/privacy-and-security.md say what the profile and the click now do and what they do not cover - the plugin-note test picks This folder only, no network, since notes show only for a pick with a profile Part of GHSA-649j-c4px-mfjq fix: the Isolation window says what an isolated pane's launch refusals cover and that osascript still runs The line read as if osascript were refused outright; it still runs scripts and reads the clipboard, only its events to other apps are refused. The docs now record that user scripts and launchctl setenv were checked by hand; Automator, Shortcuts and launchctl kickstart are still unchecked. Co-Authored-By: Claude --- SECURITY.md | 5 +- docs/isolation.md | 10 +- docs/privacy-and-security.md | 2 +- src/app/commands.rs | 10 +- src/app/mod.rs | 4 +- src/cf.rs | 15 +- src/isolation.rs | 297 +++++++++++++++++++++++--- src/isolation_ui.rs | 37 +++- src/links.rs | 393 ++++++++++++++++++++++++++++++++--- src/policy.rs | 16 ++ tests/egress.rs | 2 +- tests/pane_exec.rs | 109 +++++++++- 12 files changed, 816 insertions(+), 84 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 85885c5..3e5ef0b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,8 +33,9 @@ Out of scope: - **A plugin reaches only its network tier.** Its own Seatbelt profile allows nothing, or `localhost` only. With hosts and ports it reaches only the port of a proxy nerd runs for it alone, and the proxy connects only to those hosts on those ports: a name only at its public internet addresses, never this Mac or a network it is on; an IP address as the manifest writes it. A plugin never opens a terminal device, and does not read the credential folders and `~/Library` listed in docs/plugins.md; it can read the command line and environment of the user's other programs, which macOS does not let a sandbox hide. - **The broker runs what the server asks.** It starts panes and plugins outside the sandbox from the server's requests, so the server's sandbox is a guarantee about nerd's own code (no network, no telemetry), not a wall around a compromised server. - **A nerd reached over ssh is trusted like a local one.** What the remote server sends reaches your terminal as it would from a local server. -- **Panes are outside it.** A helper started before the sandbox launches every pane. A pane left at Everything and Open keeps your full access to files and the network; its isolation (**Isolation…**) narrows that with a Seatbelt profile of its own, a proxy for Only these providers, or another account. An agent's own Seatbelt sandbox cannot run inside an isolated pane. -- **`open` is outside it.** Opening a file or link "the way the system would", and opening a new terminal window, go through LaunchServices, which starts those programs outside the sandbox. +- **Panes are outside it.** A helper started before the sandbox launches every pane. A pane left at Everything and Open keeps your full access to files and the network; its isolation (**Isolation…**) narrows that with a Seatbelt profile of its own, a proxy for Only these providers, or another account. An agent's own Seatbelt sandbox cannot run inside a pane with This folder only, Only these providers or None. +- **A pane with a Seatbelt profile (every isolated pane but Another account with Network Open) cannot ask macOS to start programs.** Its profile refuses LaunchServices (`open`), new launchd jobs (`launchctl`), AppleEvents to other apps (`osascript -e 'tell application …'`), saving macOS preferences (`defaults write`), and lookups of the services that run Automator workflows, shortcuts and user scripts (checked by hand for user scripts; Automator workflows and shortcuts are not yet). It does not cover a program that talks to a system service directly over XPC, launchers nerd does not know of, files a pane with Files Everything writes that run later outside it (`~/Library/LaunchAgents`, `~/.zshrc`), or a pane started before an update until its program restarts. A pane set to Another account with Network Open has no Seatbelt profile. The way past is a profile that denies by default and allows only the Mach services agents need. +- **`open` is outside it.** Opening a file or link "the way the system would", and opening a new terminal window, go through LaunchServices, which starts those programs outside the sandbox. nerd does not hand a file that runs a program to its app. It refuses app, workflow, playground and terminal types, Finder aliases, sockets and devices. It opens a file only in an app chosen at the click that has a bundle id, is not a terminal or script runner, and lives in the system, `/Applications`, `/Library` or `~/Applications` folders, with no folder holding it or its program in a folder another account may write (such as `/Library/Caches`), outside the clicked path and the clicked pane's folder; scripts, files with no extension and every other file open in the plain-text app chosen the same way (TextEdit when that app does not qualify). A package opens in such an app, another folder with an extension is shown in Finder, a plain folder opens in Finder, and a plain folder macOS would open with anything else is refused. A plain folder that a pane replaces with a program at the instant of the click is opened by what it became; an app that a pane with Files Everything installs in those folders counts like any other, as does one installed by a pane whose folder holds `/Applications` or `/Library` (`/` itself, or those folders or inside them) when the click comes from elsewhere. - **A sandbox that fails to start does not stop the server.** The failure is written to `daemon.log`, shown by `nerd --list`, and shown in the window of every client that attaches. - **A release is signed, and what that covers depends on the way in.** Its `.sha256` is signed with nerd's release key, kept off GitHub; install.sh carries the public halves of that key and its backup, and installs nothing whose signature does not check. An update from nerd's menu uses the install.sh built into the running nerd, so someone who can replace a release on GitHub cannot make it install theirs. `curl … | sh` downloads install.sh itself from nerdterm.dev, so it is only as safe as nerdterm/nerd's release tags and the site; it refuses a release older than the nerd already in its install folder (by default ~/.local/bin), but a first install takes whichever signed release GitHub calls the latest, older ones included. Homebrew pins the archive's sha256, written into the formula only after the release script checked the signature, so it is as safe as nerdterm/homebrew-tap. It cannot stop someone holding the key, a release held back or with its signature removed (nerd then reports the release has no signature yet and stays where it is), or an older nerd that never updates; the changelog shown before installing is not signed. Releases before 0.15.0 were not signed, and a nerd before 0.15.0 installs the next release without checking its signature; every update after that is checked. The installer refuses an unsigned archive given by hand unless `NERD_INSTALL_UNVERIFIED=1` is set, and a signed archive whose nerd does not report the version in the archive's name. The release key signs only the release commit on master: the release script refuses to build and sign any other commit, or a binary reporting another version. Building and testing run every dependency's build code as the maintainer, so the script reads its signing script, formula script and install.sh from the release commit in git before it builds and runs that text afterwards, and after the build runs only the system's own commands and the nerd it built until the key has been used: a build that rewrites those files or puts commands of its own on the maintainer's PATH cannot run them in their place. It cannot stop build code that keeps running as the maintainer from reading the terminal while the key's passphrase is typed, using the maintainer's GitHub login, changing the binary being built, or waiting for a later release; only building releases as a separate macOS user would. - **Both ends check the other's user on every connection.** The server talks only to processes of its own user, and a client refuses a server socket another user created first, for example at a `NERD_SOCKET` in a shared directory, before it sends anything. Such a socket still keeps nerd from starting there: keep `NERD_SOCKET` in a directory only you can write. That check is the client's, at nerd's own socket; when the plugin socket beside it cannot be nerd's, the server starts without plugins and logs why. diff --git a/docs/isolation.md b/docs/isolation.md index 4a4be3a..cd63fd8 100644 --- a/docs/isolation.md +++ b/docs/isolation.md @@ -13,7 +13,7 @@ A pane starts with your access: its program, an agent included, can read and wri |---|---| | Open | Your network, as you. | | Only these providers | Only the hosts and ports that the agents you choose name in their manifests, through a proxy nerd runs for that pane alone. For pi, **Choose providers…** narrows that to some of its providers. The agent must honour `HTTPS_PROXY`; one that does not gets no network. Agents whose manifests name different ports cannot share a pane. | -| None | No internet and no name lookups. Services on this Mac (`localhost`) stay reachable. | +| None | No internet and no name lookups. Services on this Mac (`localhost`) stay reachable, `ssh localhost` too when Remote Login is on. | The hosts in the agents' `plugin.json` are a starting point: check them against what your agent contacts. pi's names every provider pi ships with, except four you add yourself if you use them: Bedrock outside us-east-1 and eu-central-1, Vertex and Azure use addresses of your own account; Cloudflare (`api.cloudflare.com`, `gateway.ai.cloudflare.com`) can fetch any URL for an account token, so it would make the pane's network open. In a pane set to Only these providers, Bedrock works with keys, not with SSO or profile login. The proxy checks the name a program asks it to connect to, not the name inside the encrypted connection, so a service behind the same address as an allowed host (a shared CDN) is reached too. @@ -21,14 +21,18 @@ The hosts in the agents' `plugin.json` are a starting point: check them against A pane's `~/.pi/agent/*.lock` is writable from every This-folder-only pane: one session can hold another pi session's lock, which only delays that session until the lock is deleted. -**This folder only** also keeps one session from planting code or instructions for another where agents and tools look for them. The temp folders (`/private/tmp`, `/private/var/folders`) stay writable and are shared by every session and tool: what one session leaves there, another can read. Inside the places it may write, these stay read-only: shell, git and editor startup files, `~/.local/bin` and other folders ahead on `PATH`, agents' settings and memory, Claude Code's task list, Codex's shared databases, Qwen Code's workflow scripts, what agents source or run later from their own folders, and the known caches whose contents run later (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot, the system Python's bytecode, Homebrew, ccache, sccache, esbuild, Bazelisk, Coursier); another cache under `~/Library/Caches` stays writable. Seatbelt sees a folder moved in whole as one being made, so a missing `~/./projects` folder, or a project folder in it an agent has not made yet, can still be put in place with memory or Qwen Code's workflow scripts inside. When the pane's folder is your home folder, its dot files, `~/Library` and `~/bin` stay read-only too, except `~/Library/Caches` and the agents' session folders; choose a project folder for a real limit. The **Isolation** window lists what stops working. +**This folder only** also keeps one session from planting code or instructions for another where agents and tools look for them. The temp folders (`/private/tmp`, `/private/var/folders`) stay writable and are shared by every session and tool: what one session leaves there, another can read. Inside the places it may write, these stay read-only: shell, git and editor startup files, `~/.local/bin` and other folders ahead on `PATH`, agents' settings and memory, Claude Code's task list, Codex's shared databases, Qwen Code's workflow scripts, what agents source or run later from their own folders, apps in `~/Applications`, and the known caches whose contents run later (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot, the system Python's bytecode, Homebrew, ccache, sccache, esbuild, Bazelisk, Coursier); another cache under `~/Library/Caches` stays writable. Seatbelt sees a folder moved in whole as one being made, so a missing `~/./projects` folder, or a project folder in it an agent has not made yet, can still be put in place with memory or Qwen Code's workflow scripts inside. When the pane's folder is your home folder, its dot files, `~/Library` and `~/bin` stay read-only too, except `~/Library/Caches` and the agents' session folders; choose a project folder for a real limit. The **Isolation** window lists what stops working. A pane that can write its folder can write the repository's `.git` too. nerd's own git there (the file tree's marks, diffs and review, **New worktree…** and **Delete worktree checkout…**) runs none of the repository's own hooks, filters, fsmonitor or lazy fetch; your global git config, such as Git LFS, still applies. A worktree made from the menu is therefore checked out without the repository's post-checkout hook or its local filters, and the notice names the filters left out (Git LFS installed with `--local`, for example). nerd refuses to make or delete a worktree from the menu when the repository's own config includes other files (`include.path`, `includeIf`), since what they make git run in another checkout cannot be checked first. Your own `git` in that folder runs all of it as usual: a hook a pane planted runs at your next `git checkout` or `git commit` there. nerd reads the repository's config just before its git does, so a pane that swaps the repository's config files in between can still, at the right moment, have a filter of its own run. A git older than 2.31 cannot be told to leave the filters off: with it, nerd shows no marks, diffs or review, refuses **New worktree…** and **Delete worktree checkout…**, and `daemon.log` says why. -Local sockets: with Network **Open**, a This-folder-only pane reaches no ssh agent and no Docker, colima, OrbStack, Rancher Desktop, lima or podman socket, so `git` over ssh and `docker` do not work there. With **Only these providers** or **None**, a pane reaches no Unix socket but nerd's message socket. +Local sockets: with Network **Open**, a This-folder-only pane reaches no ssh agent and no Docker, colima, OrbStack, Rancher Desktop, lima or podman socket, so `git` over ssh and `docker` do not work there, and it cannot reach the sockets nerd knows of for tmux, screen, WezTerm, Zellij, iTerm2 and kitty outside it: tmux's and Zellij's own folders, screen's, WezTerm's and iTerm2's, and kitty's where `KITTY_LISTEN_ON` names a file. Whatever its network, a This-folder-only pane cannot write in those places either, so it cannot leave a socket there for them to trust. With **Only these providers** or **None**, a pane reaches no Unix socket but nerd's message socket. With Network **Open**, tmux and Zellij inside the pane work and keep sessions of that pane's running program, which tmux outside nerd and other panes neither see nor change; after the program restarts or the pane closes, detached sessions keep running but cannot be reached from a pane. macOS's own `screen` starts no session under any Seatbelt profile, so it does not run in a pane with This folder only, Only these providers or None. A `TMUX_TMPDIR`, `ZELLIJ_SOCKET_DIR` or `SCREENDIR` set in a shell startup file replaces nerd's: tmux there then fails, or reaches the outer session when nerd's server was started without that value. Still reachable with Network Open: kitty listening on `tcp:` and kitty instances other than the one in `KITTY_LISTEN_ON`, a tmux started with `-S ` unless nerd's server was started inside it, iTerm2's websocket on `localhost`, and the servers of VS Code, Emacs and Neovim; a pane with Files Everything can write a This-folder-only pane's tmux folder, as it can that pane's own folder. + +A pane with This folder only, Only these providers or None cannot ask macOS to start a program for it: `open`, `osascript -e 'tell application …'`, `launchctl` and `defaults write` are refused, and so are lookups of the services that run Automator workflows, shortcuts and user scripts (checked by hand for user scripts; Automator workflows and shortcuts are not yet), so links and files do not open from there, AppleScript cannot control other apps, new launchd jobs are refused, and programs cannot save macOS preferences (`defaults`, Xcode and other Apple tools). A `terminal-notifier` hook loses what its notification opens. Ctrl+click in nerd still opens links. This does not cover a program that talks to a system service directly over XPC, launchers nerd does not know of, `launchctl kickstart`, which is not yet checked, or a pane that started before an update, until its program restarts. Programs that need the setuid bit (`ps`, `top`, `sudo`) do not run in a pane with This folder only, Only these providers or None: Seatbelt refuses them under a profile. An agent's own Seatbelt sandbox cannot run there either; the **Isolation** window says how to turn it off for each agent. +A pane set to Another account with Network Open has no Seatbelt profile: setuid programs and an agent's own sandbox work there, and the refusals above do not apply. Network None or Only these providers closes that. + **Another account.** **Settings… → Messages → Accounts…** shows the commands that create the account; nerd never runs them. Its panes ask for your password (sudo) each time they start, and run a copy of nerd that only root can change. Put it in place once, and again after each nerd update: ```sh diff --git a/docs/privacy-and-security.md b/docs/privacy-and-security.md index a019db4..ff9af87 100644 --- a/docs/privacy-and-security.md +++ b/docs/privacy-and-security.md @@ -28,7 +28,7 @@ Each way out starts from something you do or approved, and writes a line to `dae | `ssh` | You open a machine in **Servers…**, which lists its servers, or open one of them there, or run `nerd --remote`. | The host you named in `remotes`, reached through your own `~/.ssh/config`, keys and agent, so a `ProxyJump` there decides the path. | nerd's protocol to the nerd on that machine, including what you type and what its panes print, this Mac's name, and the path of your home folder here (where a server there with no spaces would start). | `remote: ssh …` | | A plugin with network | A plugin you installed with a network permission runs; approving it at install is what lets it reach the network later. | `"localhost"`: services on this Mac only, and whatever they pass on. Hosts and ports: only those hosts on those ports, through a proxy nerd runs for that plugin alone in a sandbox of its own; the plugin itself reaches only that proxy's port on this Mac's addresses and looks up no name. A name is reached at whatever public address its owner gives it, never this Mac or a network it is on; an IP address in the manifest is reached as written, and one on your local network or this Mac is named so when you are asked. The plugin must honour `HTTPS_PROXY`; plain `http://` does not work. | Anything the plugin can read, the command line and environment of your other programs included (macOS lets any of your processes read those). | `plugin … started: network …`; each connection and refusal as `plugin … proxy: …` | | A pane set to Only these providers | You choose **Isolation…** → Network: Only these providers for a pane, and its program starts. | Only the chosen agents' or chosen providers' hosts, on the ports their manifests name, through a proxy nerd runs for that pane alone in a sandbox of its own; the pane's programs reach only that proxy's port on this Mac and look up no names. The agent must honour `HTTPS_PROXY`; one that does not gets no network. | Anything the pane's programs send. | `pane N proxy: …` | -| A link or a file | You open a link in a pane, a help page from **Search**, or a file with **Open in its app**. | `http(s)` in your browser, `mailto:` in your mail app, a file in its app. Files that hold an address of their own (`.webloc`, `.inetloc`, `.url`, `.fileloc`, `.ftploc`, `.afploc`, `.vncloc`, `.mailloc`) are not opened; a page opened in the browser may still load addresses of its own, and a file opened in its app runs as that app does (a `.command` or an app runs code as you). | The address. | `link opened …`, with the host for `http(s)` and only the kind otherwise | +| A link or a file | You open a link in a pane, a help page from **Search**, or a file with **Open in its app**. | `http(s)` in your browser, `mailto:` in your mail app. A file or a package in the app macOS names for it when you click, held to that app; scripts, files with no extension, files no app claims and files whose app is a terminal, a script runner, outside the system, `/Applications`, `/Library` and `~/Applications` folders, or in a folder another account may write, in the app macOS uses for plain text; another folder with an extension shown in Finder; a plain folder in Finder. Not opened: files that hold an address of their own (`.webloc`, `.inetloc`, `.url`, `.fileloc`, `.ftploc`, `.afploc`, `.vncloc`, `.mailloc`), files and folders that run a program (`.command`, `.tool`, `.app`, `.workflow`, `.jar`, `.playground` and others), Finder aliases, sockets and devices, a plain folder macOS would open with anything but Finder, and `file://` links to another computer. A page opened in the browser may still load addresses of its own, and an app opened this way runs what a document asks of it, as that app does. | The address. | `link opened …`, with the host for `http(s)` and only the kind otherwise | | The clipboard | You copy. | The macOS clipboard, and through OSC 52 the terminal you run nerd in, which may be on another machine over `ssh`. | The text you copied. | `copied N characters` | | Claude Code's inbox | A message from another pane, or from you, is delivered to a Claude Code pane whose session reported its inbox socket. | That session's own socket on this Mac, used only after nerd has checked that a process of that pane listens on it. | The message, its first line naming the sender: `[message from via nerd, id ; …]`. | `message … via inbox: delivered` | diff --git a/src/app/commands.rs b/src/app/commands.rs index 339f112..ce191ae 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1801,18 +1801,18 @@ impl App { // wrapped or not, starts where a built-in's does. let notes = agents.iter().filter_map(|m| Some(format!("{}{}: {}", if m.builtin { String::new() } else { format!(" plugin {}: ", m.name) }, m.title, m.agent.own_sandbox.as_ref()?))).collect(); let accounts = crate::policy::current().accounts.iter().map(|a| a.name.clone()).collect(); - let (about, mut pick, inherit, agent, folder) = match target { + let (about, mut pick, inherit, inherited, agent, folder) = match target { IsolationTarget::Pane(id) => { let Some(p) = self.pane(id) else { return Overlay::None }; - (p.label(), Some(p.isolation.clone()), None, p.agent.clone().or_else(|| p.agent_session.as_ref().map(|s| s.0.clone())), p.cwd.clone()) + (p.label(), Some(p.isolation.clone()), None, Default::default(), p.agent.clone().or_else(|| p.agent_session.as_ref().map(|s| s.0.clone())), p.cwd.clone()) } IsolationTarget::Tab(si, ti) => { let Some((sp, t)) = self.spaces.get(si).and_then(|sp| Some((sp, sp.tabs.get(ti)?))) else { return Overlay::None }; - (format!("new panes in tab {}/{}", sp.label(), t.label(ti)), t.isolation_new.clone(), Some("as the space"), None, sp.cwd.clone()) + (format!("new panes in tab {}/{}", sp.label(), t.label(ti)), t.isolation_new.clone(), Some("as the space"), sp.isolation_new.clone().unwrap_or_default(), None, sp.cwd.clone()) } IsolationTarget::Space(si) => { let Some(sp) = self.spaces.get(si) else { return Overlay::None }; - (format!("new panes in space {}", sp.label()), sp.isolation_new.clone(), Some("as nerd's default"), None, sp.cwd.clone()) + (format!("new panes in space {}", sp.label()), sp.isolation_new.clone(), Some("as nerd's default"), Default::default(), None, sp.cwd.clone()) } }; let canon = |p: &std::path::Path| std::fs::canonicalize(p).ok(); @@ -1826,7 +1826,7 @@ impl App { if let Some(crate::policy::Isolation { network: crate::policy::Network::Providers(p), .. }) = pick.as_mut() { *p = crate::policy::entries(&chosen); } - Overlay::Isolation(crate::isolation_ui::IsolationWin { target, about, pick, inherit, accounts, providers, groups, chosen, stale, view: None, notes, home, scroll: Default::default() }) + Overlay::Isolation(crate::isolation_ui::IsolationWin { target, about, pick, inherit, inherited, accounts, providers, groups, chosen, stale, view: None, notes, home, scroll: Default::default() }) } pub(super) fn isolation_outcome(&mut self, w: crate::isolation_ui::IsolationWin, out: crate::isolation_ui::Out) -> Overlay { diff --git a/src/app/mod.rs b/src/app/mod.rs index 1b0fe1a..aa8e411 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3033,7 +3033,9 @@ mod tests { let own = "Claude Code: turn /sandbox off; nerd's limits already cover the whole agent"; std::fs::write(long.join("plugin.json"), serde_json::json!({ "name": "long", "title": "a".repeat(76 - 14), "agent": { "names": ["long"], "own_sandbox": own } }).to_string()).unwrap(); crate::agents::reload(); - let Overlay::Isolation(w) = with_one_space().isolation_overlay(IsolationTarget::Space(0)) else { panic!("it opens") }; + let Overlay::Isolation(mut w) = with_one_space().isolation_overlay(IsolationTarget::Space(0)) else { panic!("it opens") }; + // Notes show only for a pick with a profile. + w.pick = Some(crate::policy::Isolation { files: crate::policy::Files::Folder, network: crate::policy::Network::None }); let rows = crate::messages::wrap(w.lines(&crate::render::Theme::dark()), 76); let text: Vec = rows.iter().map(|l| l.iter().map(|s| s.0.as_str()).collect()).collect(); assert_eq!(text.iter().filter(|l| l.starts_with("Claude Code:")).count(), 1, "only the built-in's note starts a line so: {text:#?}"); diff --git a/src/cf.rs b/src/cf.rs index 826fd54..f279d50 100644 --- a/src/cf.rs +++ b/src/cf.rs @@ -1,4 +1,4 @@ -//! CoreFoundation, declared by hand for the few calls nerd makes: the battery and the clock. +//! CoreFoundation, declared by hand for the few calls nerd makes: the battery, the clock and a file's default app. use std::ffi::{c_char, c_void}; @@ -37,6 +37,19 @@ unsafe extern "C" { pub fn CFDateFormatterCreateStringWithAbsoluteTime(alloc: CFRef, formatter: CFRef, at: f64) -> CFRef; pub fn CFAbsoluteTimeGetCurrent() -> f64; pub fn CFTimeZoneResetSystem(); + pub fn CFURLCreateFromFileSystemRepresentation(alloc: CFRef, buf: *const u8, len: isize, is_dir: u8) -> CFRef; + pub fn CFURLGetFileSystemRepresentation(url: CFRef, resolve: u8, buf: *mut u8, max: isize) -> u8; + pub fn CFBundleCreate(alloc: CFRef, url: CFRef) -> CFRef; + /// A Get, not released; NULL for a bundle with no identifier. + pub fn CFBundleGetIdentifier(bundle: CFRef) -> CFRef; + pub fn CFBundleCopyExecutableURL(bundle: CFRef) -> CFRef; +} + +// The default app of a file or of a type, as LaunchServices names it; launches nothing. +#[link(name = "CoreServices", kind = "framework")] +unsafe extern "C" { + pub fn LSCopyDefaultApplicationURLForURL(url: CFRef, roles: u32, err: *mut CFRef) -> CFRef; + pub fn LSCopyDefaultApplicationURLForContentType(uti: CFRef, roles: u32, err: *mut CFRef) -> CFRef; } pub const UTF8: u32 = 0x0800_0100; diff --git a/src/isolation.rs b/src/isolation.rs index 6ecc174..4f58faa 100644 --- a/src/isolation.rs +++ b/src/isolation.rs @@ -39,6 +39,10 @@ pub fn wrap(id: PaneId, argv: &[String], cwd: &Path, iso: &Isolation, env: &mut return Ok(argv.to_vec()); } env.retain(|(k, _)| k != "NERD_STATE"); + // The broker strips TMUX from every pane; the wrapper still denies the socket it names. + if let Ok(t) = std::env::var("TMUX") { + set(env, "NERD_OUTER_TMUX", t); + } let var = |env: &[(String, String)], k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); let exe = std::env::current_exe()?.to_string_lossy().into_owned(); let network = match iso.network { @@ -214,6 +218,10 @@ struct Places { agent: Option, /// The server's own sockets by path: `NERD_SOCKET` may put them outside `root`. nerd: Vec, + /// Control sockets of the terminal and multiplexers nerd runs inside, from the environment. + outer: Vec, + /// The pane's own folder for tmux, Zellij and screen, when the wrapper made it. + mux: Option, } impl Places { @@ -232,7 +240,7 @@ impl Places { } } - fn here(plugins: &BTreeSet) -> Result { + fn here(plugins: &BTreeSet, mux: Option<&Path>) -> Result { let home = crate::paths::home().unwrap_or_default(); if !home.is_absolute() || home == Path::new("/") { return Err("nerd has no home folder, so it cannot keep this pane out of ~/.ssh".into()); @@ -242,7 +250,7 @@ impl Places { terminal_dirs.retain(|d| !RUN_LATER_DIRS.iter().any(|r| home.join(r) == *d)); Ok(Places { root: crate::paths::state_root(), - tmp: PathBuf::from(std::env::var("TMPDIR").unwrap_or_else(|_| "/private/tmp".into())), + tmp: tmp_dir(), exe: std::env::current_exe().map_err(|e| e.to_string())?, data: agents.iter().flat_map(|m| m.data_paths(&home)).collect(), roots: agents.iter().flat_map(|m| m.dir(&home).into_iter().chain(m.agent.data.iter().filter_map(|d| crate::agents::data_root(d)).map(|r| home.join(r)))).flat_map(|r| [r.with_extension("json"), r]).collect(), @@ -253,17 +261,54 @@ impl Places { run: "/private/var/run".into(), agent: std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from).filter(|p| p.is_absolute()), nerd: Vec::new(), + outer: outer(|k| std::env::var(k).ok(), &tmp_dir(), crate::policy::owner_uid()), + mux: mux.map(Path::to_path_buf), home, }) } } -/// Under HOME, run later by a shell, git, ssh, an editor, tmux, a package manager or toolchain from its cache, launchd or `defaults`, or ahead on PATH: +/// `$TMPDIR` as this process was given it. +fn tmp_dir() -> PathBuf { + PathBuf::from(std::env::var("TMPDIR").unwrap_or_else(|_| "/private/tmp".into())) +} + +/// Where kitty, Zellij, screen and tmux outside the pane listen, as the environment names it. +fn outer(var: impl Fn(&str) -> Option, tmp: &Path, uid: u32) -> Vec { + let inside = var("NERD_OUTER_TMUX").and_then(|t| t.split(',').next().map(PathBuf::from)); + let kitty = var("KITTY_LISTEN_ON").and_then(|v| v.strip_prefix("unix:").filter(|p| !p.is_empty() && !p.starts_with('@')).map(|p| tmp.join(p))); + let dirs = ["ZELLIJ_SOCKET_DIR", "SCREENDIR"].map(|k| var(k).map(PathBuf::from)); + let tmux = var("TMUX_TMPDIR").filter(|t| !t.is_empty()).map(|t| Path::new(&t).join(format!("tmux-{uid}"))); + kitty.into_iter().chain(dirs.into_iter().flatten()).chain(tmux).chain(inside).filter(|p| p.is_absolute()).collect() +} + +/// A folder of the pane's program alone, named from `$TMPDIR` as given: from its canonical form screen's and Zellij's socket paths grow too long. +/// ceiling: the folder stays in `$TMPDIR` after the program ends, until macOS clears old temp files or its pid comes back. +fn mux_dir(tmp: &Path, pid: u32) -> PathBuf { + tmp.join(format!("nm{pid}")) +} + +/// An empty 0700 `mux` with `s` inside; a leftover is replaced only when it is a real folder of `uid`'s. +fn fresh_mux(mux: &Path, uid: u32) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt as _; + match std::fs::symlink_metadata(mux) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Ok(m) if m.file_type().is_dir() && m.uid() == uid => std::fs::remove_dir_all(mux)?, + Ok(_) => return Err(other(format!("{} is not a folder of this user's own", mux.display()))), + Err(e) => return Err(e), + } + let mut b = std::fs::DirBuilder::new(); + b.mode(0o700); + b.create(mux)?; + b.create(mux.join("s")) +} + +/// Under HOME, run later by a shell, git, ssh, an editor, tmux, a package manager or toolchain from its cache, launchd, LaunchServices or `defaults`, or ahead on PATH: /// never writable, even when the pane's folder is HOME itself. const RUN_LATER: &[&str] = &[".zshrc", ".zprofile", ".zshenv", ".zlogin", ".zlogout", ".bashrc", ".bash_profile", ".bash_login", ".bash_logout", ".profile", ".gitconfig", ".netrc", ".npmrc", ".cargo/config.toml", ".vimrc", ".tmux.conf", ".emacs", ".wezterm.lua"]; // ceiling: a denylist, so a cache that runs code and is not named here stays writable; an allowlist of the agents' own caches under ~/Library/Caches is the way past. const RUN_LATER_DIRS: &[&str] = &[ - ".config/git", ".config/fish", "Library/LaunchAgents", ".ssh", ".aws", ".gnupg", ".docker", ".kube", ".azure", ".config/gh", ".config/gcloud", ".cargo/bin", ".local/bin", + ".config/git", ".config/fish", "Library/LaunchAgents", "Applications", ".ssh", ".aws", ".gnupg", ".docker", ".kube", ".azure", ".config/gh", ".config/gcloud", ".cargo/bin", ".local/bin", ".vim", ".config/nvim", ".config/tmux", ".emacs.d", "Library/Preferences", ".pyenv/shims", ".rbenv/shims", ".nvm", ".bun/bin", "go/bin", ".volta/bin", ".deno/bin", ".asdf/shims", ".local/share/nvim", "Library/Caches/pnpm", "Library/Caches/uv", "Library/Caches/deno", "Library/Caches/go-build", "Library/Caches/ms-playwright", "Library/Caches/pypoetry", "Library/Caches/Yarn", "Library/Caches/node-gyp", "Library/Caches/pip", "Library/Caches/Cypress", "Library/Caches/org.swift.swiftpm", "Library/Caches/copilot", "go/pkg/mod", @@ -275,18 +320,27 @@ const RUN_LATER_DIRS: &[&str] = &[ // Terminals' configs, which start programs and which `nerd --preset` edits. ".config/ghostty", "Library/Application Support/com.mitchellh.ghostty", ".config/kitty", ".config/wezterm", "Library/Application Support/iTerm2/DynamicProfiles", ]; -/// Under HOME, sockets that act as the owner outside the pane: ssh agents and connections, Docker, colima, OrbStack, Rancher, lima, podman. +/// Under HOME, sockets that act as the owner outside the pane: ssh agents and connections, Docker, colima, OrbStack, Rancher, lima, podman, WezTerm, iTerm2, screen. const SOCKETS: &[&str] = &[ ".ssh", ".docker", "Library/Containers/com.docker.docker", ".colima", ".orbstack", ".rd", ".lima", "Library/Application Support/rancher-desktop", ".local/share/containers/podman", - "Library/Group Containers/2BUA8C4S2C.com.1password/t", "Library/Containers/com.maxgoedjen.Secretive.SecretAgent", + "Library/Group Containers/2BUA8C4S2C.com.1password/t", "Library/Containers/com.maxgoedjen.Secretive.SecretAgent", ".local/share/wezterm", "Library/Application Support/iTerm2", ".screen", ]; -const UNREAD: &[&str] = &[".ssh", ".aws", ".gnupg", ".netrc", ".docker", ".kube", ".azure", ".config/gh", ".config/gcloud", "Library/Mail", "Library/Messages", "Library/Cookies"]; +/// The part of SOCKETS whose folders hold a terminal's or multiplexer's sockets: a pane writes there no more than it connects. +const MUX_HOMES: &[&str] = &[".local/share/wezterm", "Library/Application Support/iTerm2", ".screen"]; +const UNREAD:&[&str] = &[".ssh", ".aws", ".gnupg", ".netrc", ".docker", ".kube", ".azure", ".config/gh", ".config/gcloud", "Library/Mail", "Library/Messages", "Library/Cookies"]; + +/// ceiling: LaunchServices (`open`), launchd, AppleEvents, cfprefsd and the Automator, Shortcuts and user-script runners act outside the pane, so asking them is refused; an allow-default profile closes only these, raw XPC and unknown launchers stay open; the way past is deny default with a Mach-service allowlist. +const LAUNCH: &str = "(deny lsopen)\n(deny job-creation)\n(deny appleevent-send)\n(deny user-preference-write)\n\ + (deny mach-lookup (xpc-service-name \"com.apple.automator.xpc.runner\") (xpc-service-name \"com.apple.WorkflowKit.BackgroundShortcutRunner\") \ + (xpc-service-name \"com.apple.WorkflowKit.MacHelper\") (xpc-service-name \"com.apple.foundation.UserScriptService\") \ + (global-name \"com.apple.siri.VoiceShortcuts.xpc\") (global-name \"com.apple.shortcuts.systemshortcutservice.runner\") (global-name \"com.apple.siriactionsd.xpc\"))\n"; /// The pane's Seatbelt profile. An account pane passes `Everything` or `Account`: the account confines its files. /// `sock`: the server's socket, whose plugin and broker sockets sit beside it; None from a wrapper an older server started. -pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, sock: Option<&Path>, port: Option, plugins: &BTreeSet) -> Result { +#[allow(clippy::too_many_arguments)] +pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, sock: Option<&Path>, port: Option, plugins: &BTreeSet, mux: Option<&Path>) -> Result { let nerd = sock.map(|s| ["sock", "psock", "xsock"].map(|e| crate::plugins::canon(&s.with_extension(e))).to_vec()).unwrap_or_default(); - let p = Places { nerd, ..Places::here(plugins)? }; + let p = Places { nerd, ..Places::here(plugins, mux)? }; if *files == Files::Folder { p.make_agent_folders(); } @@ -295,7 +349,7 @@ pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, sock: /// At hook install: the agent's folders a This-folder-only pane could otherwise move in whole. pub fn make_agent_folders(data: Vec, home: &Path) { - if let Ok(p) = Places::here(&crate::agents::admitted()) { + if let Ok(p) = Places::here(&crate::agents::admitted(), None) { Places { data, home: home.to_path_buf(), ..p }.make_agent_folders(); } } @@ -328,12 +382,22 @@ fn text(p: &Places, files: &Files, network: &Network, cwd: &Path, msock: &Path, let root = canon(&p.root); let tmp = canon(&p.tmp); let msock = quote(&canon(msock))?; + // Only its folder is resolved: a link planted at the mux path must not become what the allows name. + let mux = p.mux.as_ref().map(|m| quote(&canon(m.parent().unwrap_or(Path::new("/"))).join(m.file_name().unwrap_or_default()))).transpose()?; + let mux_allow = |op: &str| mux.as_ref().map_or(String::new(), |m| format!("(allow {op} (subpath {m}))\n")); + let tmp_re = crate::plugins::regex_escape(&tmp.to_string_lossy()); + // Where tmux, Zellij and screen keep sockets other programs trust: a pane may neither plant nor reach one there. + let muxes = format!( + " (regex #\"^/private/tmp/tmux-[0-9]+(/|$)\") (regex #\"^/private/tmp/zellij-[0-9]+(/|$)\") (regex #\"^/private/tmp/u?screens(/|$)\") \ + (regex #\"^{tmp_re}/zellij-[0-9]+(/|$)\") (regex #\"^{tmp_re}/\\.screen(/|$)\") (regex #\"^{tmp_re}/nm[0-9]+(/|$)\"){}", + rules("subpath", p.outer.iter().cloned())? + ); // Allow default: a pane needs its tty. The last matching rule wins, so the denies after the allows hold. - let mut out = String::from("(version 1)\n(allow default)\n"); + let mut out = format!("(version 1)\n(allow default)\n{LAUNCH}"); if *files == Files::Folder { // Temp folders and caches stay writable: compilers, package managers and agents need them. let writes = [cwd.to_path_buf(), tmp.clone(), "/private/tmp".into(), "/private/var/folders".into(), p.home.join("Library/Caches")]; - // A folder holding HOME writes no dot entry, ~/Library or ~/bin there but caches and the data paths below; cfprefsd still writes `defaults`. + // A folder holding HOME writes no dot entry, ~/Library or ~/bin there but caches and the data paths below. let home = canon(&p.home); quote(&home)?; let esc = crate::plugins::regex_escape(&home.to_string_lossy()); @@ -397,7 +461,8 @@ fn text(p: &Places, files: &Files, network: &Network, cwd: &Path, msock: &Path, let (theirs, fixed): (Vec, Vec) = above.iter().cloned().partition(|a| data.iter().any(|d| a.starts_with(d))); out += &format!( "(deny file-write*)\n(allow file-write*{} (regex #\"^/dev/\"))\n{dots}(deny file-write*{})\n(allow file-write*{})\n(deny file-write*{}{}{})\n\ - (deny file-write-unlink{}{door_parents})\n(deny file-write-create{})\n(deny file-write-create (require-all (vnode-type SYMLINK) (require-any{}{door_parents})))\n(deny file-read-data{})\n", + (deny file-write-unlink{}{door_parents})\n(deny file-write-create{})\n(deny file-write-create (require-all (vnode-type SYMLINK) (require-any{}{door_parents})))\n\ + (deny file-write*{muxes}{})\n{}(deny file-read-data{})\n", rules("subpath", writes)?, rules("subpath", p.roots.iter().cloned())?, data_rules(&p.data)?, @@ -407,6 +472,8 @@ fn text(p: &Places, files: &Files, network: &Network, cwd: &Path, msock: &Path, raw_rules("literal", above)?, raw_rules("literal", fixed)?, raw_rules("literal", theirs)?, + rules("subpath", MUX_HOMES.iter().map(|s| p.home.join(s)))?, + mux_allow("file-write*"), rules("subpath", UNREAD.iter().map(|s| p.home.join(s)).chain([root.clone()]))?, ); } @@ -415,11 +482,12 @@ fn text(p: &Places, files: &Files, network: &Network, cwd: &Path, msock: &Path, // DNS's own socket must keep working, so unix sockets are denied by place; Codex's daemon runs its tools outside the pane, an ssh agent signs and Docker runs as the owner. let run = canon(&p.run); out += &format!( - "(deny network-outbound (regex #\"^/private/tmp/cc-socks\") (regex #\"^{}/cc-socks\") (regex #\"^{}/com\\.apple\\.launchd\\.[^/]+/Listeners$\"){}{})\n(allow network-outbound (literal {msock}))\n", - crate::plugins::regex_escape(&tmp.to_string_lossy()), + "(deny network-outbound (regex #\"^/private/tmp/cc-socks\") (regex #\"^{tmp_re}/cc-socks\") (regex #\"^{}/com\\.apple\\.launchd\\.[^/]+/Listeners$\"){muxes}{}{})\n\ + (allow network-outbound (literal {msock}))\n{}", crate::plugins::regex_escape(&run.to_string_lossy()), rules("literal", [run.join("docker.sock")].into_iter().chain(p.agent.clone()).chain(p.nerd.iter().cloned()))?, - rules("subpath", [root.clone(), p.home.join(".codex"), tmp.join("podman")].into_iter().chain(SOCKETS.iter().map(|s| p.home.join(s))))? + rules("subpath", [root.clone(), p.home.join(".codex"), tmp.join("podman")].into_iter().chain(SOCKETS.iter().map(|s| p.home.join(s))))?, + mux_allow("network-outbound"), ); } Network::Open => {} @@ -585,13 +653,28 @@ pub fn pane_exec_main(args: &[String]) -> std::io::Result<()> { f => fail(format!("--pane-exec: --files {f}?")), }; let mut cmd = crate::spawn::cmd(program); - cmd.args(&argv[1..]).env("NERD_PANE", pane.to_string()).env("NERD_MSOCK", &msock).env("NERD_MSOCK_UID", uid.to_string()).env("NERD_BIN", need("--bin")).env_remove("NERD_STATE"); + cmd.args(&argv[1..]).env("NERD_PANE", pane.to_string()).env("NERD_MSOCK", &msock).env("NERD_MSOCK_UID", uid.to_string()).env("NERD_BIN", need("--bin")).env_remove("NERD_STATE").env_remove("NERD_OUTER_TMUX"); if let Some(d) = get("--dump") { cmd.env("NERD_HOOK_DUMP", d); } // An older server names none: then the plugins let in on disk. let plugins: BTreeSet = get("--plugins").map_or_else(crate::agents::admitted, |p| p.split(',').filter(|n| !n.is_empty()).map(String::from).collect()); - let data = if files == Files::Folder { Places::here(&plugins).map(|p| p.data).unwrap_or_default() } else { Vec::new() }; + // Named once, before any fork: the profile, the folder and the variables name the same one. + let mux = mux_dir(&tmp_dir(), std::process::id()); + let mux = match files { + Files::Folder => match fresh_mux(&mux, crate::policy::owner_uid()) { + Ok(()) => { + cmd.env("TMUX_TMPDIR", &mux).env("ZELLIJ_SOCKET_DIR", &mux).env("SCREENDIR", mux.join("s")); + Some(mux) + } + Err(e) => { + let _ = writeln!(std::io::stderr(), "nerd: no folder of this pane's own for tmux: {e}"); + None + } + }, + _ => None, + }; + let data = if files == Files::Folder { Places::here(&plugins, mux.as_deref()).map(|p| p.data).unwrap_or_default() } else { Vec::new() }; let network = match need("--network") { "open" => Network::Open, "none" => Network::None, @@ -599,8 +682,8 @@ pub fn pane_exec_main(args: &[String]) -> std::io::Result<()> { n => fail(format!("--pane-exec: --network {n}?")), }; if !matches!(network, Network::Providers(_)) { - let confined = files == Files::Folder || network != Network::Open; - let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), None, &plugins).unwrap_or_else(|e| fail(e)); + let confined = Isolation::profiled(&files, &network); + let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), None, &plugins, mux.as_deref()).unwrap_or_else(|e| fail(e)); fail(become_pane(cmd, confined.then_some(profile.as_str()), &data)); } let net = crate::proxy::Net::parse(need("--hosts")).unwrap_or_else(|| fail("--pane-exec: --hosts does not parse")); @@ -610,7 +693,7 @@ pub fn pane_exec_main(args: &[String]) -> std::io::Result<()> { cmd.env(k, format!("http://127.0.0.1:{port}")); } cmd.env("NODE_USE_ENV_PROXY", "1").env("NO_PROXY", "").env("no_proxy", ""); - let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), Some(port), &plugins).unwrap_or_else(|e| fail(e)); + let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), Some(port), &plugins, mux.as_deref()).unwrap_or_else(|e| fail(e)); // SAFETY: no thread exists yet, so the child may allocate; it never returns into the proxy's code. match unsafe { nix::unistd::fork() }? { nix::unistd::ForkResult::Child => { @@ -673,6 +756,8 @@ mod tests { run: d.join("run"), agent: Some(d.join("agent/s.sock")), nerd: vec![], + outer: vec![], + mux: Some(d.join("tmp/nm2")), home, } } @@ -714,7 +799,7 @@ mod tests { crate::presets::set_env("XDG_CONFIG_HOME", home.join("xdg")); crate::presets::set_env("KITTY_CONFIG_DIRECTORY", home.join("k")); crate::presets::set_env("WEZTERM_CONFIG_FILE", home.join("w.lua")); - let p = Places::here(&BTreeSet::new()).unwrap(); + let p = Places::here(&BTreeSet::new(), None).unwrap(); let s = text(&p, &Files::Folder, &Network::None, &home.join("work"), &home.join("m.sock"), None).unwrap(); let rule = |kind: &str, sub: &str| format!("({kind} {})", quote(&canon(&home.join(sub))).unwrap()); for d in ["xdg/ghostty", "k", "xdg/wezterm", ".config/kitty"] { @@ -742,6 +827,8 @@ mod tests { run: "/private/var/run".into(), agent: Some("/Users/u/.1p/agent.sock".into()), nerd: vec![], + outer: vec!["/k/kitty.sock".into()], + mux: Some("/private/var/folders/ab/T/nm7".into()), }; let m = Path::new("/Users/u/Library/Application Support/nerd/nerd.msock"); let t = |f: Files, n: Network, port| text(&p, &f, &n, d, m, port).unwrap(); @@ -758,7 +845,7 @@ mod tests { "(deny file-write*)\n(allow file-write* (subpath \"/Users/u/x\") (subpath \"/private/var/folders/ab/T\") (subpath \"/private/tmp\") (subpath \"/private/var/folders\") (subpath \"/Users/u/Library/Caches\") (regex #\"^/dev/\"))\n\ (deny file-write* (subpath \"/Users/u/.claude.json\") (subpath \"/Users/u/.claude\"))\n\ (allow file-write* (subpath \"/Users/u/.claude/projects\") (regex #\"^/Users/u/\\.codex/[^/]*\\.sqlite[^/]*$\"))\n\ - (deny file-write* (subpath \"/Users/u/Library/Application Support/nerd\") (subpath \"/private/var/folders/ab/T/podman\") (subpath \"/Users/u/.config/git\") (subpath \"/Users/u/.config/fish\") (subpath \"/Users/u/Library/LaunchAgents\") (subpath \"/Users/u/.ssh\") (subpath \"/Users/u/.aws\") (subpath \"/Users/u/.gnupg\") (subpath \"/Users/u/.docker\") (subpath \"/Users/u/.kube\") (subpath \"/Users/u/.azure\") (subpath \"/Users/u/.config/gh\") (subpath \"/Users/u/.config/gcloud\") (subpath \"/Users/u/.cargo/bin\") (subpath \"/Users/u/.local/bin\") \ + (deny file-write* (subpath \"/Users/u/Library/Application Support/nerd\") (subpath \"/private/var/folders/ab/T/podman\") (subpath \"/Users/u/.config/git\") (subpath \"/Users/u/.config/fish\") (subpath \"/Users/u/Library/LaunchAgents\") (subpath \"/Users/u/Applications\") (subpath \"/Users/u/.ssh\") (subpath \"/Users/u/.aws\") (subpath \"/Users/u/.gnupg\") (subpath \"/Users/u/.docker\") (subpath \"/Users/u/.kube\") (subpath \"/Users/u/.azure\") (subpath \"/Users/u/.config/gh\") (subpath \"/Users/u/.config/gcloud\") (subpath \"/Users/u/.cargo/bin\") (subpath \"/Users/u/.local/bin\") \ (subpath \"/Users/u/.vim\") (subpath \"/Users/u/.config/nvim\") (subpath \"/Users/u/.config/tmux\") (subpath \"/Users/u/.emacs.d\") (subpath \"/Users/u/Library/Preferences\") (subpath \"/Users/u/.pyenv/shims\") (subpath \"/Users/u/.rbenv/shims\") (subpath \"/Users/u/.nvm\") (subpath \"/Users/u/.bun/bin\") (subpath \"/Users/u/go/bin\") (subpath \"/Users/u/.volta/bin\") (subpath \"/Users/u/.deno/bin\") (subpath \"/Users/u/.asdf/shims\") (subpath \"/Users/u/.local/share/nvim\") \ (subpath \"/Users/u/Library/Caches/pnpm\") (subpath \"/Users/u/Library/Caches/uv\") (subpath \"/Users/u/Library/Caches/deno\") (subpath \"/Users/u/Library/Caches/go-build\") (subpath \"/Users/u/Library/Caches/ms-playwright\") (subpath \"/Users/u/Library/Caches/pypoetry\") (subpath \"/Users/u/Library/Caches/Yarn\") (subpath \"/Users/u/Library/Caches/node-gyp\") \ (subpath \"/Users/u/Library/Caches/pip\") (subpath \"/Users/u/Library/Caches/Cypress\") (subpath \"/Users/u/Library/Caches/org.swift.swiftpm\") (subpath \"/Users/u/Library/Caches/copilot\") (subpath \"/Users/u/go/pkg/mod\") \ @@ -771,19 +858,29 @@ mod tests { (deny file-write-unlink {above} {parents})\n\ (deny file-write-create {above})\n\ (deny file-write-create (require-all (vnode-type SYMLINK) (require-any {parents})))\n\ + (deny file-write* (regex #\"^/private/tmp/tmux-[0-9]+(/|$)\") (regex #\"^/private/tmp/zellij-[0-9]+(/|$)\") (regex #\"^/private/tmp/u?screens(/|$)\") \ + (regex #\"^/private/var/folders/ab/T/zellij-[0-9]+(/|$)\") (regex #\"^/private/var/folders/ab/T/\\.screen(/|$)\") (regex #\"^/private/var/folders/ab/T/nm[0-9]+(/|$)\") (subpath \"/k/kitty.sock\") \ + (subpath \"/Users/u/.local/share/wezterm\") (subpath \"/Users/u/Library/Application Support/iTerm2\") (subpath \"/Users/u/.screen\"))\n\ + (allow file-write* (subpath \"/private/var/folders/ab/T/nm7\"))\n\ (deny file-read-data (subpath \"/Users/u/.ssh\") (subpath \"/Users/u/.aws\") (subpath \"/Users/u/.gnupg\") (subpath \"/Users/u/.netrc\") (subpath \"/Users/u/.docker\") (subpath \"/Users/u/.kube\") (subpath \"/Users/u/.azure\") (subpath \"/Users/u/.config/gh\") (subpath \"/Users/u/.config/gcloud\") (subpath \"/Users/u/Library/Mail\") (subpath \"/Users/u/Library/Messages\") (subpath \"/Users/u/Library/Cookies\") (subpath \"/Users/u/Library/Application Support/nerd\"))\n" ); - let head = "(version 1)\n(allow default)\n"; + let head = "(version 1)\n(allow default)\n(deny lsopen)\n(deny job-creation)\n(deny appleevent-send)\n(deny user-preference-write)\n\ + (deny mach-lookup (xpc-service-name \"com.apple.automator.xpc.runner\") (xpc-service-name \"com.apple.WorkflowKit.BackgroundShortcutRunner\") \ + (xpc-service-name \"com.apple.WorkflowKit.MacHelper\") (xpc-service-name \"com.apple.foundation.UserScriptService\") \ + (global-name \"com.apple.siri.VoiceShortcuts.xpc\") (global-name \"com.apple.shortcuts.systemshortcutservice.runner\") (global-name \"com.apple.siriactionsd.xpc\"))\n"; let msock = "(allow network-outbound (literal \"/Users/u/Library/Application Support/nerd/nerd.msock\"))\n"; let closed = |local: &str| format!("(deny network*)\n(allow network-bind network-inbound (local unix-socket))\n{msock}(allow network-outbound (remote ip \"localhost:{local}\"))\n"); assert_eq!( t(Files::Folder, Network::Open, None), format!( "{head}{folder}(deny network-outbound (regex #\"^/private/tmp/cc-socks\") (regex #\"^/private/var/folders/ab/T/cc-socks\") (regex #\"^/private/var/run/com\\.apple\\.launchd\\.[^/]+/Listeners$\") \ + (regex #\"^/private/tmp/tmux-[0-9]+(/|$)\") (regex #\"^/private/tmp/zellij-[0-9]+(/|$)\") (regex #\"^/private/tmp/u?screens(/|$)\") \ + (regex #\"^/private/var/folders/ab/T/zellij-[0-9]+(/|$)\") (regex #\"^/private/var/folders/ab/T/\\.screen(/|$)\") (regex #\"^/private/var/folders/ab/T/nm[0-9]+(/|$)\") (subpath \"/k/kitty.sock\") \ (literal \"/private/var/run/docker.sock\") (literal \"/Users/u/.1p/agent.sock\") (subpath \"/Users/u/Library/Application Support/nerd\") (subpath \"/Users/u/.codex\") (subpath \"/private/var/folders/ab/T/podman\") \ (subpath \"/Users/u/.ssh\") (subpath \"/Users/u/.docker\") (subpath \"/Users/u/Library/Containers/com.docker.docker\") (subpath \"/Users/u/.colima\") (subpath \"/Users/u/.orbstack\") (subpath \"/Users/u/.rd\") (subpath \"/Users/u/.lima\") \ (subpath \"/Users/u/Library/Application Support/rancher-desktop\") (subpath \"/Users/u/.local/share/containers/podman\") (subpath \"/Users/u/Library/Group Containers/2BUA8C4S2C.com.1password/t\") \ - (subpath \"/Users/u/Library/Containers/com.maxgoedjen.Secretive.SecretAgent\"))\n{msock}" + (subpath \"/Users/u/Library/Containers/com.maxgoedjen.Secretive.SecretAgent\") (subpath \"/Users/u/.local/share/wezterm\") (subpath \"/Users/u/Library/Application Support/iTerm2\") \ + (subpath \"/Users/u/.screen\"))\n{msock}(allow network-outbound (subpath \"/private/var/folders/ab/T/nm7\"))\n" ) ); assert_eq!(t(Files::Folder, Network::None, None), format!("{head}{folder}{}", closed("*"))); @@ -800,6 +897,17 @@ mod tests { assert!(text(&p, &Files::Folder, &Network::None, Path::new("/a\"b"), m, None).is_err(), "a path that cannot be quoted"); } + #[test] + fn every_profile_compiles() { + let d = scratch("compiles"); + for (f, n) in [(Files::Folder, Network::Open), (Files::Folder, Network::None), (Files::Folder, Network::Providers(Vec::new())), (Files::Everything, Network::None), (Files::Everything, Network::Providers(Vec::new()))] { + let profile = text(&places(&d), &f, &n, &d.join("work"), Path::new("/tmp/none.msock"), Some(9)).unwrap(); + let Some(rc) = sandboxed(&profile, "exit 0") else { return }; + assert_eq!(rc, 0, "a misspelt operation exits 65: {f:?} {n:?}"); + } + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn a_split_inherits_the_isolation_of_its_pane() { use crate::app::{App, Pane, Space, Tab}; @@ -1081,6 +1189,22 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + #[test] + fn folder_only_cannot_write_where_wezterm_iterm2_or_screen_keep_sockets_even_from_a_folder_around_them() { + let d = scratch("mux-homes"); + for (cwd, no) in [("home/.local/share", "home/.local/share/wezterm/x"), ("home/Library/Application Support", "home/Library/Application Support/iTerm2/x"), ("home/.screen", "home/.screen/x")] { + std::fs::create_dir_all(d.join(no).parent().unwrap()).unwrap(); + let profile = text(&places(&d), &Files::Folder, &Network::None, &d.join(cwd), Path::new("/tmp/none.msock"), None).unwrap(); + // A folder that is itself the socket folder is denied as a whole; one around it keeps the rest. + let ok = if cwd == "home/.screen" { Some(0) } else { sandboxed(&profile, &format!("echo x > '{}/{cwd}/ok'", d.display())) }; + let Some(ok) = ok else { return }; + assert_eq!(ok, 0, "its own folder {cwd}"); + assert_ne!(sandboxed(&profile, &format!("echo x > '{}/{no}'", d.display())), Some(0), "{no}"); + assert!(!d.join(no).exists(), "{no}"); + } + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn folder_only_cannot_replace_a_linked_nerd_nor_what_it_points_at() { let d = scratch("brew"); @@ -1177,6 +1301,7 @@ mod tests { ".zlogin", ".config/git/config", "Library/LaunchAgents/x.plist", + "Applications/x", ".zshrc", ".ssh/authorized_keys", ".ssh/config", @@ -1328,14 +1453,26 @@ mod tests { std::fs::create_dir_all(&base).unwrap(); let (own, msock, daemon) = (base.join("s.sock"), base.join("m.sock"), base.join("home/.codex/app-server-daemon/d.sock")); // launchd's ssh agent, the pane's own agent elsewhere, Docker's sockets, colima's. - let owners = ["run/com.apple.launchd.x/Listeners", "agent/s.sock", "run/docker.sock", "home/.docker/run/docker.sock", "home/.colima/default/docker.sock"].map(|p| base.join(p)); - let other = base.join("run/com.apple.launchd.x/Other"); - let _l: Vec<_> = [&own, &msock, &daemon, &other].into_iter().chain(&owners).map(|p| { + // Names no account or session uses: digits only, so the multiplexers' patterns cover them. + let (tmux, zellij) = (PathBuf::from(format!("/private/tmp/tmux-{}{}", u32::MAX, std::process::id())), PathBuf::from(format!("/private/tmp/zellij-{}{}", u32::MAX, std::process::id()))); + // launchd's ssh agent, the pane's own agent elsewhere, Docker's, colima's, WezTerm's, iTerm2's, screen's, Zellij's, another pane's tmux folder, tmux's and Zellij's own. + let mut owners = ["run/com.apple.launchd.x/Listeners", "agent/s.sock", "run/docker.sock", "home/.docker/run/docker.sock", "home/.colima/default/docker.sock", + "home/.local/share/wezterm/gui-sock-1", "home/Library/Application Support/iTerm2/private/socket", "home/.screen/s", "tmp/zellij-501/s", "tmp/.screen/s", "tmp/nm1/s", "kitty/k.sock"] + .map(|p| base.join(p)).to_vec(); + let zellij_here = base.join(format!("tmp/zellij-{}{}", u32::MAX, std::process::id())); + owners.extend([tmux.join("default"), zellij.join("s"), zellij_here.join("s")]); + let (other, mine) = (base.join("run/com.apple.launchd.x/Other"), base.join("tmp/nm2/tmux-1/default")); + let _l: Vec<_> = [&own, &msock, &daemon, &other, &mine].into_iter().chain(&owners).map(|p| { std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::os::unix::net::UnixListener::bind(p).unwrap() }).collect(); - for (files, network) in [(Files::Everything, Network::None), (Files::Folder, Network::Providers(Vec::new())), (Files::Folder, Network::Open)] { - let profile = text(&places(&base), &files, &network, &base.join("work"), &msock, Some(9)).unwrap(); + std::fs::create_dir_all(base.join("work")).unwrap(); + std::fs::write(base.join("tmp/nm1/f"), "x").unwrap(); + std::os::unix::fs::symlink(base.join("home/.docker/run/docker.sock"), base.join("work/l")).unwrap(); + std::os::unix::fs::symlink(&tmux, base.join("tmp/nm2/l")).unwrap(); + let places = Places { outer: vec![base.join("kitty/k.sock")], ..places(&base) }; + for (files, network) in [(Files::Everything, Network::None), (Files::Folder, Network::Providers(Vec::new())), (Files::Folder, Network::None), (Files::Folder, Network::Open)] { + let profile = text(&places, &files, &network, &base.join("work"), &msock, Some(9)).unwrap(); let connect = |p: &Path| sandboxed(&profile, &format!("/usr/bin/perl -MIO::Socket::UNIX -e 'IO::Socket::UNIX->new(Peer => \"{}\") or exit 1'", p.display())); let Some(rc) = connect(&msock) else { return }; assert_eq!(rc, 0, "the msock: {files:?} {network:?}"); @@ -1348,9 +1485,109 @@ mod tests { } if network == Network::Open { assert_eq!(connect(&other), Some(0), "another socket beside launchd's agent: {files:?} {network:?}"); + assert_eq!(connect(&mine), Some(0), "the pane's own tmux: {files:?} {network:?}"); + assert_ne!(connect(&base.join("work/l")), Some(0), "a link in the pane's folder to Docker's socket"); + assert_ne!(connect(&base.join("tmp/nm2/l/default")), Some(0), "a link in the pane's own tmux folder to tmux's"); + let ln = sandboxed(&profile, &format!("/bin/ln '{}' '{}'", base.join("home/.docker/run/docker.sock").display(), base.join("work/h").display())); + assert_ne!(ln, Some(0), "a hard link to Docker's socket"); + if base.join("work/h").exists() { + assert_ne!(connect(&base.join("work/h")), Some(0), "a hard link to Docker's socket that was made anyway"); + } + } + if files == Files::Folder { + let nm1 = base.join("tmp/nm1"); + for (what, script) in [ + ("a write", format!("echo x > '{}/x'", nm1.display())), + ("a rename", format!("mv '{0}/s' '{0}/t'", nm1.display())), + ("a removal", format!("rm '{}/f'", nm1.display())), + ("a folder of the name", format!("mkdir '{}'", base.join("tmp/nm3").display())), + ("a bind", format!("/usr/bin/perl -MIO::Socket::UNIX -e 'IO::Socket::UNIX->new(Local => \"{}/b\", Listen => 1) or exit 1'", nm1.display())), + ] { + assert_ne!(sandboxed(&profile, &script), Some(0), "{what} in another pane's tmux folder: {files:?} {network:?}"); + } + assert!(nm1.join("s").exists() && nm1.join("f").exists() && !nm1.join("x").exists() && !nm1.join("b").exists() && !base.join("tmp/nm3").exists()); + // A socket planted where tmux, Zellij or kitty outside nerd look would be trusted by them. + // kitty's rule is its socket alone, so only that file is kept; tmux's and Zellij's are whole folders. + for (sock, folder) in [(tmux.join("default"), true), (zellij_here.join("s"), true), (base.join("kitty/k.sock"), false)] { + let dir = sock.parent().unwrap(); + let plant = match folder { + true => format!("/usr/bin/perl -MIO::Socket::UNIX -e 'IO::Socket::UNIX->new(Local => \"{}/b\", Listen => 1) or exit 1'", dir.display()), + false => format!("rm '{}'", sock.display()), + }; + assert_ne!(sandboxed(&profile, &format!("mv '{}' '{}/moved'", sock.display(), dir.display())), Some(0), "a rename in {dir:?}: {files:?} {network:?}"); + assert_ne!(sandboxed(&profile, &plant), Some(0), "{plant}: {files:?} {network:?}"); + assert!(sock.exists() && !dir.join("moved").exists() && !dir.join("b").exists(), "{dir:?}"); + } + assert_eq!(sandboxed(&profile, &format!("echo x > '{}'", base.join("tmp/nm2/x").display())), Some(0), "its own: {files:?} {network:?}"); } } let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&tmux); + let _ = std::fs::remove_dir_all(&zellij); + } + + #[test] + fn a_link_at_the_mux_path_is_not_what_the_profile_opens() { + let d = scratch("muxlink"); + std::fs::create_dir_all(d.join("tmp")).unwrap(); + std::os::unix::fs::symlink(d.join("home/.ssh"), d.join("tmp/nm2")).unwrap(); + let profile = text(&places(&d), &Files::Folder, &Network::Open, &d.join("work"), Path::new("/tmp/none.msock"), None).unwrap(); + let named = quote(&d.join("tmp/nm2")).unwrap(); + assert!(profile.contains(&format!("(allow file-write* (subpath {named}))")) && profile.contains(&format!("(allow network-outbound (subpath {named}))")), "{profile}"); + let Some(rc) = sandboxed(&profile, &format!("echo x > '{}'", d.join("tmp/nm2/x").display())) else { return }; + assert_ne!(rc, 0, "a write through the link"); + assert!(!d.join("home/.ssh/x").exists()); + let none = text(&Places { mux: None, ..places(&d) }, &Files::Folder, &Network::Open, &d.join("work"), Path::new("/tmp/none.msock"), None).unwrap(); + assert!(!none.contains("/nm2"), "no folder made, no allow: {none}"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn outer_sockets_come_from_the_environment() { + let tmp = Path::new("/v/T"); + let got = |k: &str, v: &str| outer(|n| (n == k).then(|| v.to_string()), tmp, 501); + assert_eq!(got("KITTY_LISTEN_ON", "unix:/x/k"), [PathBuf::from("/x/k")]); + assert_eq!(got("KITTY_LISTEN_ON", "unix:k"), [PathBuf::from("/v/T/k")], "relative: under the temp folder"); + assert!(got("KITTY_LISTEN_ON", "unix:@k").is_empty(), "an abstract name is no file"); + assert!(got("KITTY_LISTEN_ON", "tcp:localhost:1").is_empty()); + assert_eq!(got("ZELLIJ_SOCKET_DIR", "/z"), [PathBuf::from("/z")]); + assert_eq!(got("SCREENDIR", "/s"), [PathBuf::from("/s")]); + assert_eq!(got("TMUX_TMPDIR", "/t"), [PathBuf::from("/t/tmux-501")]); + assert_eq!(got("NERD_OUTER_TMUX", "/private/tmp/tmux-501/default,812,0"), [PathBuf::from("/private/tmp/tmux-501/default")], "the tmux nerd runs inside"); + assert!(got("SCREENDIR", "s").is_empty() && got("TMUX_TMPDIR", "").is_empty(), "nothing relative or empty becomes a rule"); + } + + #[test] + fn mux_socket_paths_fit_a_socket_address() { + let mux = mux_dir(Path::new("/var/folders/gf/p1wr_45n3wj77wg3x5dnpx1r0000gn/T/"), 99998).display().to_string(); + let n = |k: usize| "x".repeat(k); + // tmux resolves its folder first; sun_path holds 104 bytes with its NUL. + for (what, path) in [("tmux", format!("/private{mux}/tmux-4294967295/{}", n(20))), ("screen", format!("{mux}/s/99998.ttys999.{}", n(24))), ("Zellij", format!("{mux}/contract_version_1/{}", n(20)))] { + assert!(path.len() < 104, "{what}: {} bytes: {path}", path.len()); + } + } + + #[test] + fn a_leftover_mux_folder_is_replaced_only_when_it_is_the_users() { + let base = std::env::temp_dir().join(format!("nt-s5-mux-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("nm1")).unwrap(); + std::fs::write(base.join("nm1/old"), "x").unwrap(); + let me = crate::policy::owner_uid(); + fresh_mux(&base.join("nm1"), me).unwrap(); + let m = std::fs::symlink_metadata(base.join("nm1")).unwrap(); + assert_eq!(m.mode() & 0o7777, 0o700); + assert_eq!(std::fs::read_dir(base.join("nm1")).unwrap().map(|e| e.unwrap().file_name()).collect::>(), ["s"], "emptied, with screen's folder"); + assert_eq!(std::fs::symlink_metadata(base.join("nm1/s")).unwrap().mode() & 0o7777, 0o700); + std::fs::create_dir_all(base.join("target")).unwrap(); + std::fs::write(base.join("target/keep"), "x").unwrap(); + std::os::unix::fs::symlink(base.join("target"), base.join("nm2")).unwrap(); + assert!(fresh_mux(&base.join("nm2"), me).is_err(), "a link"); + assert!(std::fs::symlink_metadata(base.join("nm2")).unwrap().file_type().is_symlink() && base.join("target/keep").exists(), "the link and its target untouched"); + std::fs::write(base.join("nm3"), "x").unwrap(); + assert!(fresh_mux(&base.join("nm3"), me).is_err(), "a file"); + assert_eq!(std::fs::read_to_string(base.join("nm3")).unwrap(), "x"); + let _ = std::fs::remove_dir_all(&base); } #[test] diff --git a/src/isolation_ui.rs b/src/isolation_ui.rs index d54d86f..0a24ad0 100644 --- a/src/isolation_ui.rs +++ b/src/isolation_ui.rs @@ -53,6 +53,8 @@ pub struct IsolationWin { pub pick: Option, /// A tab's or a space's word for nothing of its own; None for a pane, which always has one. pub inherit: Option<&'static str>, + /// What `pick: None` stands for: the space's for a tab, nerd's default for a space. + pub inherited: Isolation, pub accounts: Vec, /// Agents whose manifest names their provider's hosts. pub providers: Vec, @@ -94,6 +96,9 @@ const FOLDER_COSTS: [&str; 4] = [ " uv, pnpm, Go, pip and others fail unless their cache is in this folder", ]; const NO_AGENT: &str = " git over ssh and docker do not work: no ssh agent, no Docker"; +const NO_MUX: &str = " tmux and Zellij here run only this pane's sessions; the WezTerm, iTerm2 and kitty sockets nerd knows are closed"; +const NO_LAUNCH: &str = "open cannot open files or links, launchctl cannot add jobs, and osascript cannot control other apps here; scripts and the clipboard still work"; +const NO_PREFS: &str = "programs cannot save macOS settings here: defaults, Xcode and other Apple tools"; const SETUID: &str = "ps, top, sudo and other setuid programs do not run in an isolated pane"; /// Down on a frame: its close button or outside it closes; otherwise what was hit. @@ -163,17 +168,20 @@ impl IsolationWin { } out.push(radio(network == Some(&Network::None), "None".into(), Some(Click::Network(Network::None)))); let folder = files == Some(&Files::Folder); - out.push(Vec::new()); + let mut costs: Vec<&str> = Vec::new(); if folder { - out.extend(FOLDER_COSTS.iter().map(|t| vec![((*t).into(), g.fg(theme.faint), None)])); + costs.extend(FOLDER_COSTS); } if folder && network == Some(&Network::Open) { - out.push(vec![(NO_AGENT.into(), g.fg(theme.faint), None)]); + costs.extend([NO_AGENT, NO_MUX]); } - out.push(vec![(SETUID.into(), g.fg(theme.faint), None)]); - for n in &self.notes { - out.push(vec![(n.clone(), g.fg(theme.faint), None)]); + let shown = self.pick.as_ref().unwrap_or(&self.inherited); + if Isolation::profiled(&shown.files, &shown.network) { + costs.extend([NO_LAUNCH, NO_PREFS, SETUID]); + costs.extend(self.notes.iter().map(String::as_str)); } + out.push(Vec::new()); + out.extend(costs.into_iter().map(|t| vec![(t.to_string(), g.fg(theme.faint), None)])); if folder && self.home { out.push(vec![(HOME_FOLDER.into(), g.fg(theme.warn), None)]); } @@ -484,6 +492,7 @@ pub(crate) mod tests { about: "reader".into(), pick: Some(Isolation::default()), inherit: None, + inherited: Isolation::default(), accounts: vec!["scout".into()], providers: vec!["claude".into(), "codex".into()], groups: Vec::new(), @@ -702,11 +711,19 @@ pub(crate) mod tests { crate::app::snapshots::dump(&buf, Rect::new(0, 0, 100, 40)) }; assert!(FOLDER_COSTS.iter().all(|c| shown.contains(c.trim())) && shown.contains("Codex: run it with") && shown.contains(SETUID), "{shown}"); - assert!(!shown.contains(NO_AGENT), "only these providers: {shown}"); + let has = |w: &IsolationWin, t: &str| w.lines(&Theme::dark()).iter().flatten().any(|(s, _, _)| s.starts_with(t)); + assert!(has(&w, NO_LAUNCH) && has(&w, NO_PREFS), "wrapped where the window is narrow: {shown}"); + assert!(!has(&w, NO_AGENT) && !has(&w, NO_MUX), "only these providers: {shown}"); + let profiled = [NO_LAUNCH, NO_PREFS, SETUID, "Codex: run it with"]; click_on(&mut w, "Open"); - assert!(w.lines(&Theme::dark()).iter().flatten().any(|(t, _, _)| t == NO_AGENT), "this folder only with open network"); + assert!(has(&w, NO_AGENT) && has(&w, NO_MUX), "this folder only with open network"); + assert!(profiled.iter().all(|t| has(&w, t)), "a profile with open network too"); + click_on(&mut w, "Everything"); + assert!(!profiled.iter().any(|t| has(&w, t)), "everything and open: no profile, nothing it costs"); click_on(&mut w, "Another account: scout"); + assert!(!profiled.iter().any(|t| has(&w, t)), "another account with open network: no profile"); click_on(&mut w, "None"); + assert!(profiled.iter().all(|t| has(&w, t)), "another account with no network"); let want = Isolation { files: Files::Account("scout".into()), network: Network::None }; assert_eq!(click_on(&mut w, "[r] save and restart"), Out::Save(Some(want.clone()), true)); assert_eq!(w.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Out::Save(Some(want), false)); @@ -726,7 +743,11 @@ pub(crate) mod tests { #[test] fn a_tab_can_go_back_to_its_space() { + let launch = |w: &IsolationWin| [NO_LAUNCH, NO_PREFS].map(|t| w.lines(&Theme::dark()).iter().flatten().any(|(s, _, _)| s == t)); + let w = IsolationWin { target: IsolationTarget::Tab(0, 1), inherit: Some("as the space"), pick: None, inherited: Isolation { files: Files::Folder, network: Network::None }, ..pane() }; + assert_eq!(launch(&w), [true, true], "the space's profile is what the tab's new panes get"); let mut w = IsolationWin { target: IsolationTarget::Tab(0, 1), inherit: Some("as the space"), pick: None, ..pane() }; + assert_eq!(launch(&w), [false, false], "a space left open"); click_on(&mut w, "None"); assert_eq!(w.pick, Some(Isolation { files: Files::Everything, network: Network::None })); click_on(&mut w, "as the space"); diff --git a/src/links.rs b/src/links.rs index 27a378e..ffdd60f 100644 --- a/src/links.rs +++ b/src/links.rs @@ -90,35 +90,200 @@ fn is_path_like(s: &str) -> bool { pub fn open(link: &Link, cwd: &Path) -> Result<(), String> { let target = match link { Link::Url(u) => u.clone(), - Link::Path { path, .. } => { - let resolved = resolve_path(path, cwd); - if !resolved.exists() { - return Err(format!("path not found: {}", resolved.display())); - } - resolved.display().to_string() - } + Link::Path { path, .. } => resolve_path(path, cwd).display().to_string(), }; - if holds_an_address(&target) { - return Err("this file opens an address nerd does not show; open it from Finder".into()); - } - spawn_open(open_command(&[&target]))?; - crate::log::info(&format!("link opened {}", said(&target))); + let home = crate::paths::home().unwrap_or_default(); + let opens = judge(&target, cwd, &home, default_app)?; + let text = match &opens { + Opens::Text(p) => text_app(plain_text_app(), p, cwd, &home), + _ => PathBuf::new(), + }; + spawn_open(command(&opens, &text))?; + crate::log::info(&match opens { + Opens::Text(_) => "file opened in the text editor".to_string(), + Opens::Reveal(_) => "file shown in Finder".to_string(), + _ => format!("link opened {}", said(&target)), + }); Ok(()) } /// Files whose app opens an address or a path written inside them, which nerd never showed. const LOCATIONS: [&str; 8] = [".webloc", ".inetloc", ".url", ".fileloc", ".ftploc", ".afploc", ".vncloc", ".mailloc"]; +/// Opened, these start a program, whatever app is theirs. +const RUNS: [&str; 17] = ["command", "tool", "terminal", "term", "app", "workflow", "action", "jar", "its", "itermscript", "itermtab", "iterm2arrangement", "it2triggers", "pyc", "pyo", "playground", "playgroundbook"]; +/// Their default apps are terminals and interpreters, so they open as text. +const SCRIPTS: [&str; 10] = ["sh", "bash", "zsh", "fish", "csh", "ksh", "pl", "py", "pyw", "rb"]; +/// ceiling: a terminal or script runner not listed, installed under the trusted roots, that becomes a file's default app is handed the file; the way past is treating as a runner any app that claims public.unix-executable or com.apple.terminal.shell-script. +const RUNNERS: [&str; 11] = [ + "com.apple.Terminal", "com.googlecode.iterm2", "com.mitchellh.ghostty", "net.kovidgoyal.kitty", "com.github.wez.wezterm", "com.cmuxterm.app", + "org.python.PythonLauncher", "com.apple.ScriptEditor2", "com.apple.Automator", "com.apple.AutomatorInstaller", "com.apple.JavaLauncher", +]; +const FINDER: &str = "com.apple.finder"; +const TEXTEDIT: &str = "/System/Applications/TextEdit.app"; + +/// An app LaunchServices names, its bundle id and its executable when it has them. +#[derive(Debug, Clone, PartialEq, Eq)] +struct App { + path: PathBuf, + id: Option, + exe: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Opens { + Target(std::ffi::OsString), + Text(PathBuf), + With(PathBuf, PathBuf), + Reveal(PathBuf), +} + +const ROLES_ALL: u32 = 0xFFFF_FFFF; + +fn url_path(url: &crate::cf::Owned) -> Option { + use std::os::unix::ffi::OsStrExt as _; + if url.0.is_null() { + return None; + } + let mut buf = vec![0u8; 4096]; + // SAFETY: url is a live CFURL; the call writes at most buf.len() bytes, NUL included. + if unsafe { crate::cf::CFURLGetFileSystemRepresentation(url.0, 1, buf.as_mut_ptr(), buf.len() as isize) } == 0 { + return None; + } + Some(PathBuf::from(std::ffi::OsStr::from_bytes(std::ffi::CStr::from_bytes_until_nul(&buf).ok()?.to_bytes()))) +} + +fn app_at(url: crate::cf::Owned) -> Option { + let path = url_path(&url)?; + // SAFETY: url is a live CFURL; every Create/Copy is held in an Owned, the identifier is a Get. + let bundle = crate::cf::Owned(unsafe { crate::cf::CFBundleCreate(std::ptr::null(), url.0) }); + if bundle.0.is_null() { + return Some(App { path, id: None, exe: None }); + } + let id = Some(crate::cf::text(unsafe { crate::cf::CFBundleGetIdentifier(bundle.0) })).filter(|s| !s.is_empty()); + let exe = url_path(&crate::cf::Owned(unsafe { crate::cf::CFBundleCopyExecutableURL(bundle.0) })); + Some(App { path, id, exe }) +} + +/// The app `open` would use for this file now; None when LaunchServices names none. +fn default_app(p: &Path) -> Option { + use std::os::unix::ffi::OsStrExt as _; + let b = p.as_os_str().as_bytes(); + // SAFETY: b outlives the call; every Create/Copy is held in an Owned. + let url = crate::cf::Owned(unsafe { crate::cf::CFURLCreateFromFileSystemRepresentation(std::ptr::null(), b.as_ptr(), b.len() as isize, p.is_dir() as u8) }); + if url.0.is_null() { + return None; + } + app_at(crate::cf::Owned(unsafe { crate::cf::LSCopyDefaultApplicationURLForURL(url.0, ROLES_ALL, std::ptr::null_mut()) })) +} + +fn plain_text_app() -> Option { + let uti = crate::cf::Owned::string("public.plain-text"); + // SAFETY: uti is a live CFString; the result is held in an Owned. + app_at(crate::cf::Owned(unsafe { crate::cf::LSCopyDefaultApplicationURLForContentType(uti.0, ROLES_ALL, std::ptr::null_mut()) })) +} + +fn is_finder(a: &App) -> bool { + a.id.as_deref().is_some_and(|i| i.eq_ignore_ascii_case(FINDER)) +} + +/// The app's real path when a click may hand it a file: a bundle id, no runner, under the system or Applications folders, no part of it in a folder another account may write, and outside the sealed system neither the clicked path, inside it, nor in the clicked pane's folder. +fn trusted(a: &App, clicked: &Path, cwd: &Path, home: &Path) -> Option { + let id = a.id.as_deref()?; + if RUNNERS.iter().any(|r| r.eq_ignore_ascii_case(id)) { + return None; + } + let (p, cwd, home) = (std::fs::canonicalize(&a.path).ok()?, std::fs::canonicalize(cwd).ok()?, std::fs::canonicalize(home).ok()?); + // The data volume's other name: /System/Volumes/Data/Users is /Users. + let sealed = p.starts_with("/System") && !p.starts_with("/System/Volumes/Data"); + let rooted = sealed || p.starts_with("/Applications") || p.starts_with("/Library") || p.starts_with(home.join("Applications")); + // /Library/Caches is world-writable, some apps group-writable for staff: another account's pane may change what is there. + let others = |q: &Path| { + use std::os::unix::fs::MetadataExt as _; + q.ancestors().any(|a| std::fs::metadata(a).is_ok_and(|m| m.mode() & 0o002 != 0 || (m.mode() & 0o020 != 0 && m.gid() != 0 && m.gid() != 80))) + }; + let exe = a.exe.as_ref().and_then(|e| std::fs::canonicalize(e).ok()); + let open_to_others = others(&p.join("Contents/MacOS")) || exe.as_deref().is_some_and(others); + (rooted && !open_to_others && (sealed || !(p.starts_with(clicked) || p.starts_with(&cwd)))).then_some(p) +} + +/// The plain-text app under the same trust; TextEdit when it falls short. +fn text_app(found: Option, clicked: &Path, cwd: &Path, home: &Path) -> PathBuf { + found.filter(|a| !is_finder(a)).and_then(|a| trusted(&a, clicked, cwd, home)).unwrap_or_else(|| TEXTEDIT.into()) +} + +/// A Finder alias: `kIsAlias` in the flags of its FinderInfo, which `canonicalize` does not follow. +fn alias(p: &Path) -> bool { + use std::os::unix::ffi::OsStrExt as _; + let Ok(c) = std::ffi::CString::new(p.as_os_str().as_bytes()) else { return true }; + let mut info = [0u8; 32]; + // SAFETY: getxattr writes at most info.len() bytes into info. + let n = unsafe { nix::libc::getxattr(c.as_ptr(), c"com.apple.FinderInfo".as_ptr(), info.as_mut_ptr().cast(), info.len(), 0, 0) }; + n >= 10 && info[8] & 0x80 != 0 +} -/// Judged on the file a path or `file://` link really names: decoded, without its query, through links. -fn holds_an_address(target: &str) -> bool { - let path = match target.get(..7).filter(|p| p.eq_ignore_ascii_case("file://")) { - Some(_) => decoded(target[7..].split(['?', '#']).next().unwrap_or("")), - None if target.contains("://") || target.to_ascii_lowercase().starts_with("mailto:") => return false, - None => target.to_string(), +/// How a link or a path opens, judged on the file it really names (decoded, without its query, through links), with its app chosen now. +/// ceiling: a plain folder whose app is Finder that a pane swaps for a program between this check and `open` is opened by its new type, since Finder pins nothing; the way past is revealing plain folders too. +fn judge(target: &str, cwd: &Path, home: &Path, app: impl Fn(&Path) -> Option) -> Result { + let lower = target.to_ascii_lowercase(); + if ["http://", "https://", "mailto:"].iter().any(|p| lower.starts_with(p)) { + return Ok(Opens::Target(target.into())); + } + let path = if lower.starts_with("file://") { + let rest = &target[7..]; + let (host, path) = rest.split_at(rest.find('/').unwrap_or(rest.len())); + if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") { + return Err("nerd opens file links to this Mac only".into()); + } + decoded(path.split(['?', '#']).next().unwrap_or("")) + } else { + target.to_string() }; - let real = std::fs::canonicalize(&path).map(|p| p.to_string_lossy().into_owned()).unwrap_or(path); - let lower = real.to_ascii_lowercase(); - LOCATIONS.iter().any(|e| lower.ends_with(e)) + let c = std::fs::canonicalize(&path).map_err(|_| format!("path not found: {path}"))?; + if LOCATIONS.iter().any(|e| c.to_string_lossy().to_ascii_lowercase().ends_with(e)) { + return Err("this file opens an address nerd does not show; open it from Finder".into()); + } + let ext = c.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()); + let is = |list: &[&str]| ext.as_deref().is_some_and(|e| list.contains(&e)); + if is(&RUNS) { + return Err("this file runs a program; open it from Finder".into()); + } + let m = std::fs::metadata(&c).map_err(|_| format!("path not found: {path}"))?; + if !m.is_file() && !m.is_dir() { + return Err("nerd opens files and folders only".into()); + } + if alias(&c) { + return Err("this file is a Finder alias; open it from Finder".into()); + } + let dotted = ext.as_deref().is_some_and(|e| !e.is_empty()); + if m.is_file() { + if !dotted || is(&SCRIPTS) { + return Ok(Opens::Text(c)); + } + // Finder would hand the file to its default app as it is when Finder opens it. + return Ok(match app(&c).filter(|a| !is_finder(a)).and_then(|a| trusted(&a, &c, cwd, home)) { + Some(a) => Opens::With(a, c), + None => Opens::Text(c), + }); + } + let found = app(&c); + let finder = found.as_ref().is_some_and(is_finder); + match (found.and_then(|a| trusted(&a, &c, cwd, home)), dotted) { + (Some(_), true) if finder => Ok(Opens::Reveal(c)), + (Some(_), false) if finder => Ok(Opens::Target(c.into_os_string())), + (Some(a), _) => Ok(Opens::With(a, c)), + (None, true) => Ok(Opens::Reveal(c)), + (None, false) => Err("this folder is a program; open it from Finder".into()), + } +} + +fn command(o: &Opens, text: &Path) -> std::process::Command { + use std::ffi::OsStr; + match o { + Opens::Target(t) => open_command(&[t]), + Opens::Text(p) => text_command(text, p), + Opens::With(a, p) => open_command(&[OsStr::new("-a"), a.as_os_str(), p.as_os_str()]), + Opens::Reveal(p) => open_command(&[OsStr::new("-R"), p.as_os_str()]), + } } /// `%XX` escapes decoded; anything malformed stays as written. @@ -158,22 +323,23 @@ fn said(target: &str) -> String { } } -/// A file in the default text editor: `open -t`. +/// A file in the plain-text app, chosen now. pub fn open_text(path: &Path) -> Result<(), String> { if !path.exists() { return Err(format!("path not found: {}", path.display())); } - spawn_open(text_command(path))?; + let home = crate::paths::home().unwrap_or_default(); + spawn_open(text_command(&text_app(plain_text_app(), path, path.parent().unwrap_or(path), &home), path))?; crate::log::info("file opened in the text editor"); Ok(()) } -fn text_command(path: &Path) -> std::process::Command { - open_command(&["-t", &path.display().to_string()]) +fn text_command(app: &Path, path: &Path) -> std::process::Command { + open_command(&[std::ffi::OsStr::new("-a"), app.as_os_str(), path.as_os_str()]) } -fn open_command(args: &[&str]) -> std::process::Command { - let mut c = crate::spawn::cmd("open"); +fn open_command>(args: &[S]) -> std::process::Command { + let mut c = crate::spawn::cmd("/usr/bin/open"); c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); c } @@ -198,9 +364,174 @@ mod tests { #[test] fn a_config_file_opens_in_the_default_text_editor() { - let c = text_command(Path::new("/x/config.json")); + let c = text_command(Path::new("/A/T.app"), Path::new("/x/config.json")); assert!(c.get_program().to_string_lossy().ends_with("open")); - assert_eq!(c.get_args().collect::>(), ["-t", "/x/config.json"]); + assert_eq!(c.get_args().collect::>(), ["-a", "/A/T.app", "/x/config.json"]); + } + + #[test] + fn the_default_app_of_a_text_file_is_found() { + let dir = std::env::temp_dir().join(format!("nerd-links-app-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("x.txt"), "x").unwrap(); + let a = default_app(&dir.join("x.txt")); + let _ = std::fs::remove_dir_all(&dir); + let a = a.expect("LaunchServices names an app for a .txt"); + assert!(a.id.as_deref().is_some_and(|i| !i.is_empty()) && a.path.extension().is_some_and(|e| e == "app"), "{a:?}"); + } + + #[test] + fn the_plain_text_app_is_found() { + let a = plain_text_app().expect("LaunchServices names the plain-text app"); + assert!(a.id.as_deref().is_some_and(|i| !i.is_empty()), "{a:?}"); + } + + #[test] + fn a_file_that_runs_a_program_is_not_opened() { + use std::os::unix::fs::{PermissionsExt as _, symlink}; + let base = std::env::temp_dir().join(format!("nerd-judge-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("work")).unwrap(); + let d = base.canonicalize().unwrap(); + let (work, home) = (d.join("work"), d.join("home")); + let apps = home.join("Applications"); + let (te, fi, tm) = (PathBuf::from(TEXTEDIT), PathBuf::from("/System/Library/CoreServices/Finder.app"), PathBuf::from("/System/Applications/Utilities/Terminal.app")); + let (e_app, h_app) = (d.join("E.app"), apps.join("H.app")); + let dv_app = PathBuf::from(format!("/System/Volumes/Data{}", e_app.display())); + let w_app = apps.join("ww/W.app"); + let (g_app, gx_app, gy_app) = (apps.join("gw/G.app"), apps.join("GX.app"), apps.join("GY.app")); + for dir in [&e_app, &h_app, &w_app, &g_app, &gx_app.join("Contents/MacOS"), &gy_app.join("Contents/MacOS"), &apps.join("pf"), &apps.join("pf.v2"), &apps.join("in.v4/Contents/I.app")] { + std::fs::create_dir_all(dir).unwrap(); + } + // As /Library/Caches: a trusted root, but anyone may write there. + std::fs::set_permissions(apps.join("ww"), std::fs::Permissions::from_mode(0o1777)).unwrap(); + // As an app installed group-writable for staff, which another account's panes are in; and one whose executable alone is. + // SAFETY: getgid has no preconditions. + let gid = unsafe { nix::libc::getgid() }; + assert!(gid != 0 && gid != 80, "the test needs a group other than wheel and admin"); + std::fs::write(gx_app.join("Contents/MacOS/x"), "").unwrap(); + std::fs::write(gy_app.join("Contents/MacOS/y"), "").unwrap(); + for (p, mode) in [(apps.join("gw"), 0o775), (gx_app.join("Contents/MacOS/x"), 0o775), (gy_app.join("Contents/MacOS/y"), 0o755)] { + std::os::unix::fs::chown(&p, None, Some(gid)).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode)).unwrap(); + } + let id = |s: &str| Some(s.to_string()); + let fake = |c: &Path| -> Option { + let name = c.file_name().unwrap().to_string_lossy().into_owned(); + let ext = c.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); + let at = |path: &Path, i: Option| Some(App { path: path.to_path_buf(), id: i, exe: None }); + match (name.as_str(), ext.as_str()) { + ("pf" | "pf.v2", _) => at(c, id("dev.nerdterm.probe")), + ("in.v4", _) => at(&c.join("Contents/I.app"), id("dev.nerdterm.probe")), + ("x.bundle" | "tbin", _) | (_, "v2") => at(&tm, id("com.apple.Terminal")), + (_, "v3") => at(&te, id("NET.KOVIDGOYAL.KITTY")), + ("x.plugin" | "nbin", _) | (_, "q") => None, + ("ebin", _) | (_, "e") => at(&e_app, id("dev.nerdterm.editor")), + ("dvbin", _) | (_, "dv") => at(&dv_app, id("dev.nerdterm.editor")), + (_, "h") => at(&h_app, id("dev.nerdterm.editor")), + (_, "n") => at(&te, None), + ("bin" | "conf.d", _) | (_, "f") => at(&fi, id(FINDER)), + ("fbin", _) => at(&e_app, id(FINDER)), + ("wbin", _) | (_, "w") => at(&w_app, id("dev.nerdterm.editor")), + ("gbin", _) | (_, "g") => at(&g_app, id("dev.nerdterm.editor")), + (_, "gx") => Some(App { path: gx_app.clone(), id: id("dev.nerdterm.editor"), exe: Some(gx_app.join("Contents/MacOS/x")) }), + (_, "gy") => Some(App { path: gy_app.clone(), id: id("dev.nerdterm.editor"), exe: Some(gy_app.join("Contents/MacOS/y")) }), + _ => at(&te, id("dev.nerdterm.editor")), + } + }; + let file = |name: &str, mode: u32| { + let p = work.join(name); + std::fs::write(&p, "echo hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode)).unwrap(); + }; + for n in ["x.command", "X.TOOL", "s.terminal", "s.term", "w.workflow", "a.action", "j.jar", "t.its", "t.itermscript", "t.itermtab", "t.iterm2arrangement", "t.it2triggers", "c.pyc", "c.PYO"] { + file(n, 0o644); + } + for n in ["run.sh", "tool", "run.", "run.v2"] { + file(n, 0o755); + } + for n in ["R.PY", "x.pyw", "x.bash", "x.zsh", "x.fish", "x.csh", "x.ksh", "x.pl", "x.rb", "notes", "k.v3", "odd.q", "x.e", "x.n", "x.f", "x.h", "x.dv", "x.w", "x.g", "x.gx", "x.gy", "readme.md", "doc.md", "doc2.md"] { + file(n, 0o644); + } + for n in ["A.app", "p.playground", "b.PLAYGROUNDBOOK", "P.xcodeproj", "proj", "x.bundle", "x.plugin", "dir.q", "conf.d", "s.e", "n.n", "s.dv", "s.w", "s.g", "bin", "tbin", "nbin", "ebin", "fbin", "dvbin", "wbin", "gbin"] { + std::fs::create_dir_all(work.join(n)).unwrap(); + } + symlink(work.join("x.command"), work.join("notes.txt")).unwrap(); + symlink(work.join("run.sh"), work.join("link.txt")).unwrap(); + symlink(work.join("readme.md"), work.join("see.md")).unwrap(); + let _sock = std::os::unix::net::UnixListener::bind(work.join("s.sock")).unwrap(); + let finder_info = |name: &str, byte8: u8| { + let mut info = [0u8; 32]; + info[8] = byte8; + let p = std::ffi::CString::new(work.join(name).into_os_string().into_encoded_bytes()).unwrap(); + // SAFETY: both strings are NUL-terminated; info is 32 bytes. + assert_eq!(unsafe { nix::libc::setxattr(p.as_ptr(), c"com.apple.FinderInfo".as_ptr(), info.as_ptr().cast(), info.len(), 0, 0) }, 0, "{name}"); + }; + finder_info("doc.md", 0x80); + finder_info("doc2.md", 0); + + let w = |n: &str| work.join(n).display().to_string(); + let runs = Err::("this file runs a program; open it from Finder".into()); + let program = Err::("this folder is a program; open it from Finder".into()); + let text = |n: &str| Ok(Opens::Text(work.join(n))); + let with = |a: &Path, n: &str| Ok(Opens::With(a.to_path_buf(), work.join(n))); + let reveal = |p: PathBuf| Ok(Opens::Reveal(p)); + let missing = d.join("missing"); + let sys = Path::new("/System/Applications"); + let mut cases: Vec<(String, &Path, Result)> = Vec::new(); + for n in ["x.command", "X.TOOL", "s.terminal", "s.term", "A.app", "w.workflow", "a.action", "j.jar", "t.its", "t.itermscript", "t.itermtab", "t.iterm2arrangement", "t.it2triggers", "c.pyc", "c.PYO", "p.playground", "b.PLAYGROUNDBOOK", "notes.txt"] { + cases.push((w(n), &work, runs.clone())); + } + cases.push((format!("file://{}/x%2Ecommand", work.display()), &work, runs.clone())); + cases.push((format!("file://localhost{}/A.app/", work.display()), &work, runs.clone())); + cases.push((w("doc.md"), &work, Err("this file is a Finder alias; open it from Finder".into()))); + cases.push((w("doc2.md"), &work, with(&te, "doc2.md"))); + cases.push((w("s.sock"), &work, Err("nerd opens files and folders only".into()))); + for n in ["run.sh", "R.PY", "x.pyw", "x.bash", "x.zsh", "x.fish", "x.csh", "x.ksh", "x.pl", "x.rb", "tool", "notes", "run.", "run.v2", "k.v3", "odd.q", "x.e", "x.n", "x.f", "x.dv", "x.w", "x.g", "x.gx"] { + cases.push((w(n), &work, text(n))); + } + cases.push((format!("file://localhost{}/tool", work.display()), &work, text("tool"))); + cases.push((w("link.txt"), &work, text("run.sh"))); + cases.push((w("x.h"), &home, text("x.h"))); + cases.push((w("readme.md"), &missing, text("readme.md"))); + cases.push((w("readme.md"), &work, with(&te, "readme.md"))); + cases.push((w("readme.md"), sys, with(&te, "readme.md"))); + cases.push((w("see.md"), &work, with(&te, "readme.md"))); + cases.push((w("P.xcodeproj"), &work, with(&te, "P.xcodeproj"))); + cases.push((w("proj"), &work, with(&te, "proj"))); + cases.push((w("x.h"), &work, with(&h_app, "x.h"))); + cases.push((w("x.gy"), &work, with(&gy_app, "x.gy"))); + for n in ["x.bundle", "x.plugin", "dir.q", "conf.d", "s.e", "n.n", "s.dv", "s.w", "s.g"] { + cases.push((w(n), &work, reveal(work.join(n)))); + } + cases.push((apps.join("pf.v2").display().to_string(), &work, reveal(apps.join("pf.v2")))); + cases.push((apps.join("in.v4").display().to_string(), &work, reveal(apps.join("in.v4")))); + cases.push((w("bin"), &work, Ok(Opens::Target(work.join("bin").into())))); + cases.push(("https://example.com/a.command".into(), &work, Ok(Opens::Target("https://example.com/a.command".into())))); + cases.push((apps.join("pf").display().to_string(), &work, program.clone())); + for n in ["tbin", "nbin", "ebin", "fbin", "dvbin", "wbin", "gbin"] { + cases.push((w(n), &work, program.clone())); + } + cases.push(("file://server/x".into(), &work, Err("nerd opens file links to this Mac only".into()))); + cases.push((format!("file://{}/missing", work.display()), &work, Err(format!("path not found: {}/missing", work.display())))); + let bad: Vec = cases.iter().filter_map(|(t, cwd, want)| { + let got = judge(t, cwd, &home, fake); + (got != *want).then(|| format!("{t} (cwd {}): got {got:?}, want {want:?}", cwd.display())) + }).collect(); + assert!(bad.is_empty(), "{}", bad.join("\n")); + + let app = |p: &Path, i: Option| Some(App { path: p.to_path_buf(), id: i, exe: None }); + let clicked = work.join("readme.md"); + assert_eq!(text_app(app(&fi, id("dev.nerdterm.editor")), &clicked, &work, &home), fi, "a trusted app that is not the fallback"); + for (found, why) in [(app(&fi, id("com.googlecode.iterm2")), "a runner"), (app(&fi, None), "no id"), (app(&e_app, id("dev.nerdterm.editor")), "outside the roots"), (app(&fi, id(FINDER)), "Finder"), (None, "none")] { + assert_eq!(text_app(found, &clicked, &work, &home), PathBuf::from(TEXTEDIT), "{why}"); + } + let args = |o: Opens| command(&o, Path::new("/A/T.app")).get_args().map(|a| a.to_string_lossy().into_owned()).collect::>(); + assert_eq!(args(Opens::Text("/p".into())), ["-a", "/A/T.app", "/p"]); + assert_eq!(args(Opens::With("/X.app".into(), "/p".into())), ["-a", "/X.app", "/p"]); + assert_eq!(args(Opens::Reveal("/p".into())), ["-R", "/p"]); + assert_eq!(args(Opens::Target("https://x".into())), ["https://x"]); + let _ = std::fs::remove_dir_all(&d); } #[test] @@ -232,8 +563,8 @@ mod tests { let link = Link::Path { path: dir.join("plain.txt").display().to_string(), line: None }; assert!(open(&link, &dir).unwrap_err().contains("open it from Finder"), "a link to one is one"); std::fs::write(dir.join("e.vncloc"), "").unwrap(); - assert!(holds_an_address(&dir.join("e.vncloc").display().to_string())); - assert!(!holds_an_address("https://example.com/a.webloc"), "a web address is the browser's to open"); + assert!(judge(&dir.join("e.vncloc").display().to_string(), &dir, &dir, |_| None).unwrap_err().contains("open it from Finder")); + assert_eq!(judge("https://example.com/a.webloc", &dir, &dir, |_| None), Ok(Opens::Target("https://example.com/a.webloc".into())), "a web address is the browser's to open"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/policy.rs b/src/policy.rs index 7bd02d0..1223563 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -407,6 +407,11 @@ impl Isolation { self.files == Files::Everything && self.network == Network::Open } + /// A pane nerd applies a Seatbelt profile to. + pub fn profiled(files: &Files, network: &Network) -> bool { + *files == Files::Folder || *network != Network::Open + } + /// "" when open. pub fn words(&self) -> String { let files = match &self.files { @@ -665,6 +670,17 @@ mod tests { assert_eq!(only(&["pi/deepseek", "pi"]), "only pi", "the agent beside its groups is the whole agent"); } + #[test] + fn profiled_is_where_a_profile_applies() { + let acct = || Files::Account("scout".into()); + let p = || Network::Providers(vec!["claude".into()]); + for (f, n) in [(Files::Folder, Network::Open), (Files::Folder, Network::None), (Files::Folder, p()), (Files::Everything, Network::None), (Files::Everything, p()), (acct(), Network::None), (acct(), p())] { + assert!(Isolation::profiled(&f, &n), "{f:?} {n:?}"); + } + assert!(!Isolation::profiled(&Files::Everything, &Network::Open)); + assert!(!Isolation::profiled(&acct(), &Network::Open), "the account confines its files; its own sandbox and setuid programs keep working"); + } + #[test] fn a_providers_entry_is_an_agent_or_an_agent_and_one_group() { for (s, want) in [("pi", Some(("pi", None))), ("pi/deepseek", Some(("pi", Some("deepseek")))), ("open-code2/x-1", Some(("open-code2", Some("x-1"))))] { diff --git a/tests/egress.rs b/tests/egress.rs index 67c5dde..4221062 100644 --- a/tests/egress.rs +++ b/tests/egress.rs @@ -35,7 +35,7 @@ const ALLOW: &[(&str, &str, &str, &str, &str, &str)] = &[ ("src/isolation.rs", "pane_exec_main", "syscall", "nix :: unistd :: fork", "pane-network", "forks the pane's program off its proxy when the pane may reach only its providers"), ("src/proxy.rs", "relay", "socket", "TcpStream", "plugin-network", "copies a tunnel's bytes"), // link: a link or a file you open. - ("src/links.rs", "open_command", "spawn", "\"open\"", "link", "opens a link or a file in its app"), + ("src/links.rs", "open_command", "spawn", "\"/usr/bin/open\"", "link", "opens a link or a file in its app"), // clipboard: you copy. ("src/select.rs", "copy", "spawn", "\"pbcopy\"", "clipboard", "puts the copied text on the macOS clipboard"), // pane: programs in panes are yours; these are the doors nerd opens into them. diff --git a/tests/pane_exec.rs b/tests/pane_exec.rs index 11a0c68..3f20bc1 100644 --- a/tests/pane_exec.rs +++ b/tests/pane_exec.rs @@ -8,6 +8,8 @@ use std::time::{Duration, Instant}; struct Pane { dir: PathBuf, socks: PathBuf, + /// A Folder pane's own tmux folder, which its wrapper makes: `$TMPDIR/nm`. + mux: Option, wrapper: Child, /// A stand-in nerd.sock and msock, bound before the pane starts: a refusal is the profile's, not a missing file. _listeners: [std::os::unix::net::UnixListener; 2], @@ -67,7 +69,8 @@ impl Pane { .stderr(std::fs::File::create(dir.join("stderr")).unwrap()) .spawn() .unwrap(); - Pane { dir, socks, wrapper, _listeners } + let mux = flags.contains(&"folder").then(|| std::env::temp_dir().join(format!("nm{}", wrapper.id()))); + Pane { dir, socks, mux, wrapper, _listeners } } fn wait_for(&self, file: &str) -> String { @@ -109,6 +112,9 @@ impl Drop for Pane { let _ = self.wrapper.wait(); let _ = std::fs::remove_dir_all(&self.dir); let _ = std::fs::remove_dir_all(&self.socks); + if let Some(m) = &self.mux { + let _ = std::fs::remove_dir_all(m); + } } } @@ -258,3 +264,104 @@ fn a_shared_door_that_is_not_nerds_own_starts_nothing() { assert_eq!(String::from_utf8_lossy(&out.stderr), format!("nerd: /tmp/nerd-{} is not nerd's own; this pane does not start\n", u32::MAX - 7)); assert!(!Path::new("/tmp/never").exists()); } + +/// Removed when the test ends, failed or not. +struct Gone(PathBuf); + +impl Drop for Gone { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn a_folder_pane_with_open_network_has_a_tmux_folder_of_its_own() { + // Another pane's folder: digits only, as the pattern denies, and longer than any real pid. + let other = Gone(std::env::temp_dir().join(format!("nm{}{}", u32::MAX, std::process::id()))); + std::fs::create_dir_all(&other.0).unwrap(); + let _l = std::os::unix::net::UnixListener::bind(other.0.join("s")).unwrap(); + let script = format!( + "echo $$ > pid\n\ + if [ \"$TMUX_TMPDIR\" = \"${{TMPDIR%/}}/nm$$\" ] && [ \"$ZELLIJ_SOCKET_DIR\" = \"$TMUX_TMPDIR\" ] && [ \"$SCREENDIR\" = \"$TMUX_TMPDIR/s\" ]; then echo vars; else echo \"novars $TMUX_TMPDIR $ZELLIJ_SOCKET_DIR $SCREENDIR\"; fi > out\n\ + ls -ld \"$TMUX_TMPDIR\" | cut -c1-10 >> out\n\ + ls -ld \"$SCREENDIR\" | cut -c1-10 >> out\n\ + /usr/bin/perl -MIO::Socket::UNIX -e '$l = IO::Socket::UNIX->new(Local => $ARGV[0], Listen => 1) or exit 1; IO::Socket::UNIX->new(Peer => $ARGV[0]) or exit 2' \"$TMUX_TMPDIR/x\"; echo \"own $?\" >> out\n\ + {CONNECT} \"$3/s\"; echo \"other $?\" >> out\n\ + echo x 2>/dev/null > \"$3/w\"; echo \"other-write $?\" >> out\n\ + echo > done\n" + ); + let pane = Pane::start("mux-open", &["--files", "folder", "--network", "open"], &script, &[other.0.to_str().unwrap()]); + pane.wait_for("done"); + assert_eq!(pane.wait_for("out"), "vars\ndrwx------\ndrwx------\nown 0\nother 1\nother-write 1\n", "stderr: {}", pane.read("stderr")); + assert!(!other.0.join("w").exists()); +} + +#[test] +fn a_providers_folder_pane_names_its_tmux_folder_in_the_parent() { + let script = "echo \"$TMUX_TMPDIR\" > out\n\ + ls -ld \"$TMUX_TMPDIR\" | cut -c1-10 >> out\n\ + echo x > \"$TMUX_TMPDIR/x\"; echo \"w $?\" >> out\n\ + echo > done\n"; + let pane = Pane::start("mux-provider", &["--files", "folder", "--network", "provider", "--hosts", "example.com;443"], script, &[]); + pane.wait_for("done"); + let out = pane.wait_for("out"); + let mux = pane.mux.as_ref().unwrap().display().to_string(); + assert_eq!(out, format!("{mux}\ndrwx------\nw 0\n"), "named from the proxy's pid, before the fork; stderr: {}", pane.read("stderr")); +} + +unsafe extern "C" { + fn sandbox_check(pid: nix::libc::pid_t, operation: *const std::ffi::c_char, kind: std::ffi::c_int, ...) -> std::ffi::c_int; +} + +/// `sandboxed lsopen job-creation appleevent-send user-preference-write shortcuts-xpc shortcuts-runner siriactionsd denied-file allowed-file` as this process's sandbox answers them; only asks, launches nothing. +fn launch_checks(denied: &Path, allowed: &Path) -> String { + use std::os::unix::ffi::OsStrExt as _; + const NO_REPORT: std::ffi::c_int = 0x4000_0000; + let pid = std::process::id() as nix::libc::pid_t; + // SAFETY: sandbox_check reads only the NUL-terminated strings passed, which outlive each call. + let mut got = vec![unsafe { sandbox_check(pid, std::ptr::null(), 0) }]; + for op in [c"lsopen", c"job-creation", c"appleevent-send", c"user-preference-write"] { + got.push(unsafe { sandbox_check(pid, op.as_ptr(), NO_REPORT) }); + } + for name in [c"com.apple.siri.VoiceShortcuts.xpc", c"com.apple.shortcuts.systemshortcutservice.runner", c"com.apple.siriactionsd.xpc"] { + got.push(unsafe { sandbox_check(pid, c"mach-lookup".as_ptr(), 2 | NO_REPORT, name.as_ptr()) }); + } + for f in [denied, allowed] { + let path = std::ffi::CString::new(f.as_os_str().as_bytes()).unwrap(); + got.push(unsafe { sandbox_check(pid, c"file-write-data".as_ptr(), 1 | NO_REPORT, path.as_ptr()) }); + } + got.iter().map(i32::to_string).collect::>().join(" ") +} + +/// Run by a pane (below) as this test binary: writes the pane's own `launch_checks` to `$NERD_LAUNCH_PROBE`. +#[test] +fn launch_probe_helper() { + let Some(out) = std::env::var_os("NERD_LAUNCH_PROBE").map(PathBuf::from) else { return }; + let work = out.parent().unwrap(); + std::fs::write(work.join("allowed"), "").unwrap(); + std::fs::write(&out, launch_checks(&work.parent().unwrap().join("pane.sh"), &work.join("allowed")) + "\n").unwrap(); +} + +/// The pane's line, then this process's own on the same files, which exist by then. +fn launch_lines(name: &str, flags: &[&str]) -> (String, String) { + let exe = std::env::current_exe().unwrap(); + let script = "NERD_LAUNCH_PROBE=$PWD/probe \"$3\" --exact launch_probe_helper --nocapture >/dev/null 2>&1\n"; + let pane = Pane::start(name, flags, script, &[exe.to_str().unwrap()]); + let probe = pane.wait_for("probe"); + (probe.trim_end().to_string(), launch_checks(&pane.dir.join("pane.sh"), &pane.dir.join("work/allowed"))) +} + +#[test] +fn launching_is_refused_in_a_folder_pane() { + let (pane, own) = launch_lines("launch-folder", &["--files", "folder", "--network", "none"]); + // job-creation reads 1 under any Seatbelt profile, so its deny cannot be told from its absence here; the profile text test pins it. + assert_eq!(pane, "1 1 1 1 1 1 1 1 1 0", "sandboxed lsopen job-creation appleevent-send user-preference-write shortcuts-xpc shortcuts-runner siriactionsd denied-file allowed-file"); + assert_eq!(own, "0 0 0 0 0 0 0 0 0 0", "this test process is not under Seatbelt, so the pane's answers are its profile's"); +} + +#[test] +fn an_account_pane_with_open_network_has_no_profile() { + let (pane, own) = launch_lines("launch-account", &["--files", "everything", "--network", "open"]); + assert_eq!(pane, "0 0 0 0 0 0 0 0 0 0", "no Seatbelt profile: setuid programs and an agent's own sandbox keep working"); + assert_eq!(own, "0 0 0 0 0 0 0 0 0 0"); +}