From d87ced49a57fabc9df43914fcf70a8bbcb44c709 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Thu, 29 Oct 2026 12:41:06 +0200 Subject: [PATCH 1/2] Make confine run gh for the command, each time you allow it A confined command cannot run `gh`: it cannot read `~/.config/gh`, so `gh` fails before it does anything, and an agent in `confine` cannot open a pull request. Now, on macOS, if `gh` is installed, the command finds a shim first on its `PATH` in place of `gh`: `confine` itself, under that name, which sends its arguments, working directory and standard streams over a socket to `confine`, outside the sandbox. `confine` shows a dialog with the command line, and runs the real `gh` with the command's streams only if you allow it: once, or always, until it exits, for that command, such as `gh pr create`. `gh api` is asked about every time, and `gh auth`, `config`, `alias`, `extension`, `secret`, `variable`, `ssh-key` and `gpg-key` are never run. `CONFINE_ASK` names a program to ask with instead of a dialog. `--headless` turns the broker off, for when no one is at the screen. `gh` runs git, which the repository's configuration and hooks can tell to run any program, and the command can change both. So `gh` runs with a clone of `.git` made where the command cannot write, with hooks and the file system monitor off, and only if its configuration differs from what it was when `confine` started in nothing but what `gh` and `git push` write. The clone then replaces `.git`, so what `gh` changed is kept. With the broker, `confine` waits for the command rather than becoming it. `gh`'s token is in the keychain, which the command can read, so the broker keeps the command from running `gh`, but not from reading its token. --- README.md | 7 + cmd/confine/broker.rs | 659 +++++++++++++++++++++++++++++++++++ cmd/confine/confine.1 | 96 +++++ cmd/confine/confine.rs | 80 ++++- cmd/confine/tests/confine.rs | 191 ++++++++++ 5 files changed, 1016 insertions(+), 17 deletions(-) create mode 100644 cmd/confine/broker.rs diff --git a/README.md b/README.md index 9e838c5..d141d3a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,13 @@ Code in the fork. `-w ~/.claude` lets it save its sessions and settings. With `--rm`, the fork is removed when Claude Code exits; with `-d dir`, there is no fork, and Claude Code runs in `dir` itself. +On macOS, it can open pull requests: when it runs `gh`, a dialog asks you +whether to, showing the command line, and `confine` runs the real `gh` +outside the sandbox only if you allow it. With `--headless`, for when no one +is at the screen to answer, `gh` is not run for it. See +[`confine.1`](cmd/confine/confine.1) for how git in the fork is kept from +running anything. + When it is done, see what it changed, apply the changes, and remove the fork: diff --git a/cmd/confine/broker.rs b/cmd/confine/broker.rs new file mode 100644 index 0000000..145a885 --- /dev/null +++ b/cmd/confine/broker.rs @@ -0,0 +1,659 @@ +//! macOS: the broker, which runs `gh` for the confined command, outside the +//! sandbox, each time you allow it, so that the command can open pull +//! requests though it cannot read `gh`'s configuration. +//! +//! The command finds a shim first on its `PATH` in place of `gh`: `confine` +//! itself, under that name, which sends its arguments, working directory +//! and standard streams over a socket to the broker, and exits as the broker +//! says `gh` did. The broker asks you, with a dialog, whether to run it, and +//! runs the real `gh` with the shim's streams, so that it talks to the +//! terminal itself. +//! +//! `gh` runs git, and git runs what the repository's configuration and hooks +//! tell it to, both of which the command can write. So `gh` runs with a +//! clone of the repository's `.git` made where the command cannot write it, +//! and only if its configuration differs from what it was when `confine` +//! started in nothing but what `gh` and `git push` write themselves, with +//! hooks off. The clone then takes the place of `.git`, so that what `gh` +//! did to it, such as setting the branch it pushed to, is kept. + +use std::{ + collections::HashSet, + env, + ffi::{OsStr, OsString}, + fs::{self, DirBuilder, File}, + io::{self, Read, Write}, + mem, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + unix::{ + ffi::{OsStrExt, OsStringExt}, + fs::{DirBuilderExt, PermissionsExt}, + net::{UnixListener, UnixStream}, + process::{CommandExt, ExitStatusExt}, + }, + }, + path::{Path, PathBuf}, + process::{self, Command, ExitCode, Stdio}, + ptr, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + thread, +}; + +use crate::at; + +/// The variable that tells the shim where the broker's socket is. +const SOCKET: &str = "CONFINE_BROKER"; + +/// The variable that names a program to ask with instead of a dialog: it is +/// given the question, and prints `once` or `always` to allow, and anything +/// else to deny. +const ASK: &str = "CONFINE_ASK"; + +/// `gh`'s commands that manage credentials or configuration, or run +/// extensions, which are never run. +const DENIED: &[&str] = &[ + "alias", + "auth", + "config", + "ext", + "extension", + "extensions", + "gpg-key", + "secret", + "ssh-key", + "variable", +]; + +/// `gh`'s commands that can do anything, which you are asked about every +/// time. +const UNBOUNDED: &[&str] = &["api"]; + +/// The longest argument the shim may send, to bound what the broker reads. +const MAX_LEN: u64 = 1 << 24; + +/// Runs `gh` for a confined command, until it is dropped. +pub struct Broker { + /// Where the shim and the socket are, which the command may write to. + shared: PathBuf, + /// Where the clones of `.git` are made, which the command may not write + /// to, or read. + private: PathBuf, +} + +/// What the broker knows, which each request it serves shares. +struct State { + /// The real `gh`. + gh: PathBuf, + /// The directory the command is confined to. + dir: PathBuf, + private: PathBuf, + /// The configuration of the directory's `.git` when `confine` started, + /// if it was a repository. + config: Option>>, + /// The commands you allowed always, such as `gh pr create`, which is + /// locked while you are asked, so that you are asked one thing at a time. + always: Mutex>, + /// How many clones of `.git` have been made, to name the next. + clones: AtomicU64, +} + +/// What you answered. +enum Answer { + Deny, + Once, + Always, +} + +impl Broker { + /// Starts brokering `gh` for a command confined to `dir`, in which `home` + /// is the home directory, unless `gh` is not installed. + pub fn start(dir: &Path, home: &Path) -> io::Result> { + let Some(gh) = find("gh") else { + return Ok(None); + }; + let git = dir.join(".git"); + let config = if git.is_dir() { + Some(config(&git)?) + } else { + None + }; + let id = format!("confine-{}", process::id()); + let shared = env::temp_dir().canonicalize()?.join(&id); + DirBuilder::new().mode(0o700).create(&shared)?; + let private = home.join(".local/state/confine").join(&id); + let broker = Broker { shared, private }; + DirBuilder::new() + .mode(0o700) + .recursive(true) + .create(&broker.private)?; + fs::create_dir(broker.shared.join("bin"))?; + fs::copy(env::current_exe()?, broker.shared.join("bin/gh"))?; + let listener = UnixListener::bind(broker.socket())?; + let state = Arc::new(State { + gh, + dir: dir.to_path_buf(), + private: broker.private.clone(), + config, + always: Mutex::new(HashSet::new()), + clones: AtomicU64::new(0), + }); + thread::spawn(move || { + for stream in listener.incoming().flatten() { + let state = state.clone(); + thread::spawn(move || state.serve(stream)); + } + }); + Ok(Some(broker)) + } + + /// The environment the command needs for `gh` to be the shim. + pub fn env(&self) -> io::Result> { + let mut paths = vec![self.shared.join("bin")]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let path = env::join_paths(paths).map_err(io::Error::other)?; + Ok(vec![("PATH", path), (SOCKET, self.socket().into())]) + } + + fn socket(&self) -> PathBuf { + self.shared.join("socket") + } +} + +impl Drop for Broker { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.shared); + let _ = fs::remove_dir_all(&self.private); + } +} + +impl State { + /// Serves the shim at the other end of `stream`: runs `gh` for it, if + /// you allow it, and tells it how `gh` exited. + fn serve(&self, mut stream: UnixStream) { + let Ok(Ok([stdin, stdout, stderr])) = receive_fds(&stream).map(<[OwnedFd; 3]>::try_from) + else { + return; + }; + let Ok(mut err) = stderr.try_clone().map(File::from) else { + return; + }; + let status = read_strings(&mut stream) + .and_then(|request| self.run(request, [stdin, stdout, stderr], &mut err)) + .unwrap_or_else(|e| { + let _ = writeln!(err, "confine: {e}"); + 1 + }); + let _ = stream.write_all(&status.to_be_bytes()); + } + + /// Runs `gh` as `request`, its working directory and then its + /// arguments, asks, with `stdio`, if you allow it, and returns its exit + /// status. + fn run(&self, request: Vec, stdio: [OwnedFd; 3], err: &mut File) -> io::Result { + let mut request = request.into_iter(); + let cwd = PathBuf::from(request.next().unwrap_or_default()); + let args: Vec = request.collect(); + let cwd = cwd.canonicalize().map_err(|e| at(&cwd, e))?; + if !cwd.starts_with(&self.dir) { + return Err(io::Error::other(format!( + "{}: gh runs only in {}", + cwd.display(), + self.dir.display() + ))); + } + let Some(args) = args + .iter() + .map(|arg| arg.to_str()) + .collect::>>() + else { + return Err(io::Error::other("gh's arguments must be UTF-8")); + }; + // The command, such as `gh pr create`: the words before the first + // flag, up to two. + let words: Vec<&str> = args + .iter() + .take_while(|arg| !arg.starts_with('-')) + .take(2) + .copied() + .collect(); + if let Some(word) = words.first().filter(|word| DENIED.contains(word)) { + return Err(io::Error::other(format!("gh {word}: never run for it"))); + } + let line = quoted(&args); + let always = (!words.is_empty() && !UNBOUNDED.contains(&words[0])) + .then(|| format!("gh {}", words.join(" "))); + if !self.allowed(&line, &cwd, always)? { + return Err(io::Error::other(format!("{line}: you did not allow it"))); + } + let git = self.clone_git()?; + let [stdin, stdout, stderr] = stdio; + let mut gh = Command::new(&self.gh); + gh.args(&args) + .current_dir(&cwd) + .stdin(stdin) + .stdout(stdout) + .stderr(stderr) + .env( + "GIT_DIR", + git.clone().unwrap_or_else(|| self.private.join("none")), + ) + .env("GIT_WORK_TREE", &self.dir); + // Hooks and the file system monitor are programs in, or named by, + // `.git`, so are turned off whatever it says. Variables of this kind + // the user set are kept. + let n: usize = env::var("GIT_CONFIG_COUNT") + .ok() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + let off = [("core.hooksPath", "/dev/null"), ("core.fsmonitor", "false")]; + for (i, (key, value)) in off.iter().enumerate() { + gh.env(format!("GIT_CONFIG_KEY_{}", n + i), key) + .env(format!("GIT_CONFIG_VALUE_{}", n + i), value); + } + gh.env("GIT_CONFIG_COUNT", (n + off.len()).to_string()); + // `confine` ignores the terminal's signals while it waits, which `gh` + // gets as they are by default. + unsafe { + gh.pre_exec(|| { + for signal in crate::SIGNALS { + libc::signal(signal, libc::SIG_DFL); + } + Ok(()) + }); + } + let status = gh.status().map_err(|e| at(&self.gh, e)); + if let Some(clone) = git + && let Err(e) = self.restore(&clone) + { + let _ = writeln!(err, "confine: what gh changed in .git is lost: {e}"); + let _ = fs::remove_dir_all(&clone); + } + let status = status?; + Ok(status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(0))) + } + + /// Whether you allow running `line` in `cwd`: once, or, if `always` is + /// given, every time the command runs it until `confine` exits. + fn allowed(&self, line: &str, cwd: &Path, always: Option) -> io::Result { + let mut allowed = self.always.lock().unwrap_or_else(|e| e.into_inner()); + if always + .as_ref() + .is_some_and(|always| allowed.contains(always)) + { + return Ok(true); + } + let question = format!( + "The command confined to {} wants to run:\n\n{line}\n\nin {}", + self.dir.display(), + cwd.display() + ); + let label = always + .as_ref() + .map(|always| format!("Always allow {always}")); + Ok(match ask(&question, label.as_deref())? { + Answer::Deny => false, + Answer::Once => true, + Answer::Always => { + allowed.extend(always); + true + } + }) + } + + /// Clones the directory's `.git` to where the command cannot write, and + /// returns where, if its configuration is safe for git to run with. If + /// the directory was not a repository, there is none. + fn clone_git(&self) -> io::Result> { + let Some(original) = &self.config else { + return Ok(None); + }; + let git = self.dir.join(".git"); + if !git.symlink_metadata().is_ok_and(|meta| meta.is_dir()) { + return Err(io::Error::other(format!( + "{}: no longer a directory", + git.display() + ))); + } + let n = self.clones.fetch_add(1, Ordering::Relaxed); + let clone = self.private.join(format!("{n}.git")); + clonefile(&git, &clone).map_err(|e| at(&clone, e))?; + let checked = check(&clone, original); + if checked.is_err() { + let _ = fs::remove_dir_all(&clone); + } + checked.map(|()| Some(clone)) + } + + /// Puts `clone` in the place of the directory's `.git`. + fn restore(&self, clone: &Path) -> io::Result<()> { + let git = self.dir.join(".git"); + let old = clone.with_extension("old"); + fs::rename(&git, &old)?; + if let Err(e) = fs::rename(clone, &git) { + let _ = fs::rename(&old, &git); + return Err(e); + } + fs::remove_dir_all(&old) + } +} + +/// Checks that the configuration of `git` differs from `original` in +/// nothing but what `gh` and `git push` write, and that it does not name +/// another directory to take it from. +fn check(git: &Path, original: &HashSet>) -> io::Result<()> { + if git.join("commondir").symlink_metadata().is_ok() { + return Err(io::Error::other( + ".git/commondir: made by the command, so gh is not run", + )); + } + for entry in config(git)? { + if !original.contains(&entry) && !written_by_gh(&entry) { + let entry = String::from_utf8_lossy(&entry); + let key = entry.split('\n').next().unwrap_or_default(); + return Err(io::Error::other(format!( + ".git/config: {key} changed by the command, so gh is not run" + ))); + } + } + Ok(()) +} + +/// The entries of the configuration of `git`, each a key and its value, as +/// `git config --list` gives them, from `config` and `config.worktree`. +fn config(git: &Path) -> io::Result>> { + let mut entries = HashSet::new(); + for file in ["config", "config.worktree"] { + let path = git.join(file); + if path.symlink_metadata().is_err() { + continue; + } + // Run outside the repository, so that git reads only the file. + let out = Command::new("git") + .current_dir("/") + .args(["config", "--no-includes", "--null", "--list", "--file"]) + .arg(&path) + .stderr(Stdio::piped()) + .output()?; + if !out.status.success() { + return Err(io::Error::other(format!( + "{}: {}", + path.display(), + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + entries.extend( + out.stdout + .split(|&b| b == 0) + .filter(|entry| !entry.is_empty()) + .map(<[u8]>::to_vec), + ); + } + Ok(entries) +} + +/// Whether `entry`, a key and its value, is one that `gh` or `git push` +/// write: the branch a branch is pushed to, and the remotes `gh` adds and +/// marks, by URLs that run nothing. +fn written_by_gh(entry: &[u8]) -> bool { + let entry = String::from_utf8_lossy(entry); + let (key, value) = entry.split_once('\n').unwrap_or((&entry, "")); + let (Some((section, _)), Some((_, name))) = (key.split_once('.'), key.rsplit_once('.')) else { + return false; + }; + match (section, name) { + ("branch", "remote" | "merge" | "pushremote" | "gh-merge-base") => true, + ("remote", "fetch" | "gh-resolved") => true, + ("remote", "url" | "pushurl") => ["https://", "ssh://", "git@"] + .iter() + .any(|scheme| value.starts_with(scheme)), + _ => false, + } +} + +/// Asks you `question`, with a dialog, or the program `CONFINE_ASK` names, +/// offering to allow once, and, if `always` is given, with it as the label. +fn ask(question: &str, always: Option<&str>) -> io::Result { + if let Some(program) = env::var_os(ASK) { + let out = Command::new(&program).arg(question).output()?; + return Ok(match String::from_utf8_lossy(&out.stdout).trim() { + "once" => Answer::Once, + "always" if always.is_some() => Answer::Always, + "always" => Answer::Once, + _ => Answer::Deny, + }); + } + // The question and the label are arguments, so they need no quoting. + // Deny is the default, and what a dialog left unanswered gives. + let script = r#"on run argv + set choices to {"Deny", "Allow once"} + if (count of argv) > 1 then set end of choices to item 2 of argv + display dialog (item 1 of argv) with title "confine" buttons choices ¬ + default button "Deny" cancel button "Deny" with icon caution ¬ + giving up after 300 + return button returned of result + end run"#; + let out = Command::new("/usr/bin/osascript") + .arg("-e") + .arg(script) + .arg(question) + .args(always) + .stderr(Stdio::null()) + .output()?; + let answer = String::from_utf8_lossy(&out.stdout); + Ok(match answer.trim() { + "Allow once" => Answer::Once, + answer if Some(answer) == always => Answer::Always, + _ => Answer::Deny, + }) +} + +/// `gh` and `args` as a shell would take them, with what is not plain +/// quoted, and control characters escaped, so that you see each argument as +/// it is. +fn quoted(args: &[&str]) -> String { + let mut line = String::from("gh"); + for arg in args { + line.push(' '); + let plain = |b: u8| b.is_ascii_alphanumeric() || b"%+,-./:=@_".contains(&b); + if !arg.is_empty() && arg.bytes().all(plain) { + line.push_str(arg); + } else if arg.chars().any(char::is_control) { + line.push_str(&format!("$'{}'", arg.escape_default())); + } else { + line.push_str(&format!("'{}'", arg.replace('\'', r"'\''"))); + } + } + line +} + +/// Whether this process is the shim: `confine` run as `gh` by a confined +/// command. +pub fn is_shim() -> bool { + env::var_os(SOCKET).is_some() + && env::args_os() + .next() + .is_some_and(|arg0| Path::new(&arg0).file_name() == Some(OsStr::new("gh"))) +} + +/// Runs as the shim: has the broker run `gh`, and exits as it did. +pub fn shim() -> ExitCode { + match request() { + Ok(status) => ExitCode::from(status as u8), + Err(e) => { + eprintln!("gh: confine: {e}"); + ExitCode::FAILURE + } + } +} + +/// Sends the broker the standard streams, the working directory and the +/// arguments, and returns the exit status it sends back. +fn request() -> io::Result { + let mut stream = UnixStream::connect(env::var_os(SOCKET).unwrap_or_default())?; + send_fds(&stream, &[0, 1, 2])?; + let mut request = vec![env::current_dir()?.into_os_string()]; + request.extend(env::args_os().skip(1)); + write_strings(&mut stream, &request)?; + let mut status = [0; 4]; + stream.read_exact(&mut status)?; + Ok(i32::from_be_bytes(status)) +} + +/// Sends `fds` over `stream`, with a byte, as they can be sent only with +/// data. +fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> { + let byte = 0u8; + let mut iov = libc::iovec { + iov_base: ptr::from_ref(&byte).cast_mut().cast(), + iov_len: 1, + }; + let len = mem::size_of_val(fds) as u32; + let space = unsafe { libc::CMSG_SPACE(len) } as usize; + // Of `u64`s, for the alignment of a `cmsghdr`. + let mut control = vec![0u64; space.div_ceil(8)]; + let mut msg: libc::msghdr = unsafe { mem::zeroed() }; + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = control.as_mut_ptr().cast(); + msg.msg_controllen = space as _; + unsafe { + let cmsg = libc::CMSG_FIRSTHDR(&msg); + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(len) as _; + ptr::copy_nonoverlapping(fds.as_ptr(), libc::CMSG_DATA(cmsg).cast(), fds.len()); + if libc::sendmsg(stream.as_raw_fd(), &msg, 0) != 1 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +/// Receives the file descriptors `send_fds` sends over `stream`. +fn receive_fds(stream: &UnixStream) -> io::Result> { + let mut byte = 0u8; + let mut iov = libc::iovec { + iov_base: ptr::from_mut(&mut byte).cast(), + iov_len: 1, + }; + let space = unsafe { libc::CMSG_SPACE(3 * size_of::() as u32) } as usize; + let mut control = vec![0u64; space.div_ceil(8)]; + let mut msg: libc::msghdr = unsafe { mem::zeroed() }; + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = control.as_mut_ptr().cast(); + msg.msg_controllen = space as _; + if unsafe { libc::recvmsg(stream.as_raw_fd(), &mut msg, 0) } < 0 { + return Err(io::Error::last_os_error()); + } + let mut fds = Vec::new(); + unsafe { + let mut cmsg = libc::CMSG_FIRSTHDR(&msg); + while !cmsg.is_null() { + if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS { + let count = + ((*cmsg).cmsg_len as usize - libc::CMSG_LEN(0) as usize) / size_of::(); + let data = libc::CMSG_DATA(cmsg).cast::(); + for i in 0..count { + let fd = data.add(i).read_unaligned(); + // So that the `gh` run for another request gets none of + // these. macOS has no flag to receive them so. + libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC); + fds.push(OwnedFd::from_raw_fd(fd)); + } + } + cmsg = libc::CMSG_NXTHDR(&msg, cmsg); + } + } + if msg.msg_flags & libc::MSG_CTRUNC != 0 { + return Err(io::Error::other("too many file descriptors")); + } + Ok(fds) +} + +/// Writes `strings`, their count and then each, its length first. +fn write_strings(stream: &mut UnixStream, strings: &[OsString]) -> io::Result<()> { + let mut buf = (strings.len() as u32).to_be_bytes().to_vec(); + for string in strings { + buf.extend((string.len() as u32).to_be_bytes()); + buf.extend(string.as_bytes()); + } + stream.write_all(&buf) +} + +/// Reads what `write_strings` writes. +fn read_strings(stream: &mut UnixStream) -> io::Result> { + let count = read_u32(stream)?; + let mut strings = Vec::new(); + for _ in 0..count { + let len = u64::from(read_u32(stream)?); + if len > MAX_LEN { + return Err(io::Error::other("an argument is too long")); + } + let mut string = Vec::new(); + stream.take(len).read_to_end(&mut string)?; + if string.len() as u64 != len { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + strings.push(OsString::from_vec(string)); + } + Ok(strings) +} + +fn read_u32(stream: &mut UnixStream) -> io::Result { + let mut buf = [0; 4]; + stream.read_exact(&mut buf)?; + Ok(u32::from_be_bytes(buf)) +} + +/// The first executable file named `name` in a directory on `PATH`. +fn find(name: &str) -> Option { + env::split_paths(&env::var_os("PATH").unwrap_or_default()) + .map(|dir| dir.join(name)) + .find(|path| { + fs::metadata(path) + .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) + }) +} + +/// Clones the directory tree at `from` to `to`, sharing its blocks. +fn clonefile(from: &Path, to: &Path) -> io::Result<()> { + let from = std::ffi::CString::new(from.as_os_str().as_bytes())?; + let to = std::ffi::CString::new(to.as_os_str().as_bytes())?; + // `CLONE_NOFOLLOW`: symbolic links are cloned as links. + if unsafe { libc::clonefile(from.as_ptr(), to.as_ptr(), 0x0001) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_what_is_not_plain() { + assert_eq!( + quoted(&["pr", "create", "--title", "it's", "a\nb", ""]), + r"gh pr create --title 'it'\''s' $'a\nb' ''" + ); + } + + #[test] + fn allows_only_what_gh_writes() { + assert!(written_by_gh(b"branch.fix/a.b.remote\norigin")); + assert!(written_by_gh( + b"remote.fork.url\nhttps://github.com/a/b.git" + )); + assert!(!written_by_gh(b"remote.fork.url\n/tmp/x")); + assert!(!written_by_gh(b"remote.origin.uploadpack\nsh -c x")); + assert!(!written_by_gh(b"core.pager\nsh -c x")); + assert!(!written_by_gh(b"include.path\n/tmp/x")); + } +} diff --git a/cmd/confine/confine.1 b/cmd/confine/confine.1 index fda57ce..6acadd4 100644 --- a/cmd/confine/confine.1 +++ b/cmd/confine/confine.1 @@ -9,6 +9,7 @@ confine \- run a command that may write only to a fork of a directory .IR path ]... .RB [ \-\-rm | \-d .IR dir ] +.RB [ \-\-headless ] .RI [ command .RI [ argument ...]] .SH DESCRIPTION @@ -62,6 +63,16 @@ to .BR claude . .SH OPTIONS .TP +.B \-\-headless +Do not run +.BR gh (1) +for the command when it asks to, as there is no one at the screen to ask +whether to, so that +.B gh +fails in the sandbox. +See +.BR "THE BROKER" . +.TP .BI \-d " dir" "\fR, \fP\-\-dir " dir Run the command in .I dir @@ -149,6 +160,69 @@ read, and files made in the directories above the home directory after the command starts cannot be read. .PP The network and the GPU are not confined. +.SH THE BROKER +On macOS, if +.BR gh (1) +is installed, the command can open pull requests and the like though it +cannot read +.BR gh 's +configuration: unless +.B \-\-headless +is given, it finds a stand-in for +.B gh +first on its +.BR PATH . +When the command runs it, +.B confine +shows a dialog with the command line, and runs the real +.BR gh , +found on +.BR confine 's +own +.BR PATH , +with the command's standard input and output, only if you allow it: once, +or always, until +.B confine +exits, for that command, such as +.BR "gh pr create" . +.B gh api +is asked about every time, and +.BR "gh auth" , +.BR config , +.BR alias , +.BR extension , +.BR secret , +.BR variable , +.B ssh\-key +and +.B gpg\-key +are never run. +.PP +.B gh +runs git, which the repository's configuration and hooks can tell to run +any program, and the command can change both. +So +.B gh +runs with a clone of the directory's +.B .git +made where the command cannot write, with hooks and the file system +monitor off, and only if its configuration differs from what it was when +.B confine +started in nothing but what +.B gh +and +.B git push +write: the remote and branch a branch is pushed to, and remotes with +.BR https:// , +.B ssh:// +or +.B git@ +URLs. +The clone then replaces +.BR .git , +so that what +.B gh +changed there is kept. .SH ENVIRONMENT .B confine sets @@ -160,6 +234,14 @@ it would otherwise ask in every new directory, and accepting would write to .IR ~/.claude.json , which it may not. +.TP +.B CONFINE_ASK +A program to ask with, rather than a dialog, given the question as its +argument: it prints +.B once +or +.B always +to allow, and anything else to deny. .SH EXIT STATUS The command's, as it becomes the command, or exits as it did. If it cannot run the command, @@ -213,6 +295,20 @@ exits. The network is not confined: a command can send anything it can read to anywhere, and can push to remotes whose credentials it can reach, such as through the macOS keychain or an SSH agent. +On macOS, +.BR gh 's +token is in the keychain, where any program can read it, so the broker keeps the command from running +.BR gh , +but not from reading its token. +.PP +What the command changes in +.B .git +while +.B gh +runs is lost, and +.B gh +goes on if the command is killed. +The repository must be on the same volume as the home directory. .SH SEE ALSO .BR fsdiff (1), .BR fsfork (1), diff --git a/cmd/confine/confine.rs b/cmd/confine/confine.rs index 8db0fa2..733b1c7 100644 --- a/cmd/confine/confine.rs +++ b/cmd/confine/confine.rs @@ -3,6 +3,8 @@ //! temporary directories, and to caches, but nowhere else, and may read in //! the home directory only what it needs, and so no secrets such as SSH keys. +#[cfg(target_os = "macos")] +mod broker; #[cfg(target_os = "macos")] mod darwin; #[cfg(target_os = "linux")] @@ -17,7 +19,9 @@ use linux as os; compile_error!("confine runs only on macOS and Linux for now"); use std::{ - env, io, + env, + ffi::OsString, + io, os::unix::process::{CommandExt, ExitStatusExt}, path::{Path, PathBuf}, process::{ExitCode, ExitStatus}, @@ -80,6 +84,11 @@ struct Args { #[argh(switch)] rm: bool, + /// do not run gh for the command when it asks to, as there is no one to + /// ask whether to + #[argh(switch)] + headless: bool, + /// the command to run and its arguments; the shell if none #[argh(positional, greedy)] command: Vec, @@ -94,6 +103,10 @@ enum Ran { } fn main() -> ExitCode { + #[cfg(target_os = "macos")] + if broker::is_shim() { + return broker::shim(); + } let args: Args = argh::from_env(); if args.rm && args.dir.is_some() { eprintln!("confine: --rm removes a fork, and -d runs the command without one"); @@ -145,20 +158,49 @@ fn confine(args: &Args) -> io::Result { } else { args.command.clone() }; - if let Some(dir) = &args.dir { - let dir = dir.canonicalize().map_err(|e| at(dir, e))?; - if !dir.is_dir() { - return Err(io::Error::other(format!( - "{}: not a directory", - dir.display() - ))); + let (dir, fork) = match &args.dir { + Some(dir) => { + let dir = dir.canonicalize().map_err(|e| at(dir, e))?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "{}: not a directory", + dir.display() + ))); + } + (dir, None) } - return run(&command, &dir, &writable, &readable, &hidden, false); - } - let fork = fsfork::fork(Path::new("."))?; - eprintln!("confine: forked to {}", fork.display()); - let ran = run(&command, &fork, &writable, &readable, &hidden, args.rm); - if (args.rm || !matches!(ran, Ok(Ran::Exited(_)))) + None => { + let fork = fsfork::fork(Path::new("."))?; + eprintln!("confine: forked to {}", fork.display()); + (fork.clone(), Some(fork)) + } + }; + // The broker serves the command while `confine` waits for it, and stops + // when it is dropped, after. + let ran = (|| { + #[cfg(target_os = "macos")] + let broker = if args.headless { + None + } else { + // The command can still run without it, as with `--headless`. + broker::Broker::start(&dir, &home).unwrap_or_else(|e| { + eprintln!("confine: gh is not brokered: {e}"); + None + }) + }; + #[cfg(target_os = "macos")] + let env = broker + .as_ref() + .map(broker::Broker::env) + .transpose()? + .unwrap_or_default(); + #[cfg(not(target_os = "macos"))] + let env = Vec::new(); + let wait = args.rm || !env.is_empty(); + run(&command, &dir, &writable, &readable, &hidden, &env, wait) + })(); + if let Some(fork) = fork + && (args.rm || !matches!(ran, Ok(Ran::Exited(_)))) && let Err(e) = fsfork::remove(&fork) { eprintln!("confine: {e}"); @@ -166,20 +208,24 @@ fn confine(args: &Args) -> io::Result { ran } -/// Runs `command` in `dir`, confined to it: in place of this process, or, -/// to `wait` for it, as a child. +/// Runs `command` in `dir`, confined to it, with `env` added to its +/// environment: in place of this process, or, to `wait` for it, as a child. fn run( command: &[String], dir: &Path, writable: &[PathBuf], readable: &[PathBuf], hidden: &[PathBuf], + env: &[(&str, OsString)], wait: bool, ) -> io::Result { let mut confined = os::confine(command, dir, writable, readable, hidden)?; // Claude Code would otherwise ask whether to trust every new directory, // and accepting would write to `~/.claude.json`, which it may not. - confined.current_dir(dir).env("CLAUDE_CODE_SANDBOXED", "1"); + confined + .current_dir(dir) + .env("CLAUDE_CODE_SANDBOXED", "1") + .envs(env.iter().map(|(key, value)| (key, value))); if !wait { return Ok(Ran::Failed(confined.exec())); } diff --git a/cmd/confine/tests/confine.rs b/cmd/confine/tests/confine.rs index 5e03006..ffd8795 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -302,6 +302,197 @@ fn fails_for_paths_that_do_not_exist() { assert_no_forks(&world); } +/// A world with a repository, `project`, a fake `gh` that prints what it was +/// run with, and an answer, `once`, `always` or `deny`, that you give when +/// asked whether to run it, which is recorded in `asked`. +#[cfg(target_os = "macos")] +fn broker_world(answer: &str) -> (World, PathBuf) { + let world = World::new(); + let dir = world.dir("project"); + let git = Command::new("git") + .args(["init", "-q"]) + .current_dir(&dir) + .env("HOME", world.home()) + .status() + .unwrap(); + assert!(git.success()); + let bin = world.dir("bin"); + write( + &bin, + "gh", + "#!/bin/sh\n\ + echo \"gh $*\"\n\ + pwd\n\ + git config core.hooksPath\n\ + git config branch.main.remote || true\n", + ); + write( + &bin, + "ask", + &format!( + "#!/bin/sh\nprintf '%s\\n--\\n' \"$1\" >> {}\necho {answer}\n", + world.dir.join("asked").display() + ), + ); + for program in ["gh", "ask"] { + let path = bin.join(program); + let mut perms = fs::metadata(&path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + fs::set_permissions(&path, perms).unwrap(); + } + (world, dir) +} + +/// Runs `cmd` with `sh -c`, confined to `dir` itself, with `gh` brokered. +#[cfg(target_os = "macos")] +fn brokered(world: &World, dir: &Path, cmd: &str) -> Output { + let path = format!( + "{}:{}", + world.dir.join("bin").display(), + env::var("PATH").unwrap() + ); + world + .confine_command(dir, &["-d", ".", "sh", "-c", cmd]) + .env("PATH", path) + .env("CONFINE_ASK", world.dir.join("bin/ask")) + .output() + .unwrap() +} + +#[test] +#[cfg(target_os = "macos")] +fn does_not_broker_gh_with_headless() { + need_sandbox!(); + let (world, dir) = broker_world("once"); + let path = format!( + "{}:{}", + world.dir.join("bin").display(), + env::var("PATH").unwrap() + ); + let out = world + .confine_command( + &dir, + &["--headless", "-d", ".", "sh", "-c", "command -v gh"], + ) + .env("PATH", path) + .output() + .unwrap(); + assert_eq!( + stdout(&out), + format!("{}\n", world.dir.join("bin/gh").display()) + ); + assert!(asked(&world).is_empty()); +} + +/// The questions asked in `world`. +#[cfg(target_os = "macos")] +fn asked(world: &World) -> Vec { + fs::read_to_string(world.dir.join("asked")) + .unwrap_or_default() + .split("\n--\n") + .filter(|question| !question.is_empty()) + .map(str::to_string) + .collect() +} + +#[test] +#[cfg(target_os = "macos")] +fn brokers_gh_once_allowed_with_hooks_off() { + need_sandbox!(); + let (world, dir) = broker_world("once"); + let out = brokered( + &world, + &dir, + "git config branch.main.remote origin && gh pr create --title \"a b\"", + ); + assert!(out.status.success(), "{}", stderr(&out)); + assert_eq!( + stdout(&out), + format!( + "gh pr create --title a b\n{}\n/dev/null\norigin\n", + dir.display() + ) + ); + let asked = asked(&world); + assert_eq!(asked.len(), 1); + assert!( + asked[0].contains("gh pr create --title 'a b'"), + "{}", + asked[0] + ); +} + +#[test] +#[cfg(target_os = "macos")] +fn does_not_run_gh_if_denied() { + need_sandbox!(); + let (world, dir) = broker_world("deny"); + let out = brokered(&world, &dir, "gh pr create"); + assert_eq!(out.status.code(), Some(1)); + assert!(stdout(&out).is_empty()); + assert!( + stderr(&out).contains("you did not allow it"), + "{}", + stderr(&out) + ); +} + +#[test] +#[cfg(target_os = "macos")] +fn asks_once_for_what_is_always_allowed() { + need_sandbox!(); + let (world, dir) = broker_world("always"); + let out = brokered(&world, &dir, "gh pr create && gh pr create && gh pr view"); + assert!(out.status.success(), "{}", stderr(&out)); + let asked = asked(&world); + assert_eq!(asked.len(), 2, "{asked:?}"); + assert!(asked[1].contains("gh pr view")); +} + +#[test] +#[cfg(target_os = "macos")] +fn never_runs_gh_auth() { + need_sandbox!(); + let (world, dir) = broker_world("always"); + let out = brokered(&world, &dir, "gh auth token"); + assert_eq!(out.status.code(), Some(1)); + assert!(stdout(&out).is_empty()); + assert!(asked(&world).is_empty()); +} + +#[test] +#[cfg(target_os = "macos")] +fn does_not_run_gh_with_git_configured_to_run_programs() { + need_sandbox!(); + let (world, dir) = broker_world("once"); + for cmd in [ + "git config core.pager 'touch pwned' && gh pr view", + "git config remote.x.uploadpack 'touch pwned' && gh pr view", + "echo /tmp > .git/commondir && gh pr view", + ] { + let out = brokered(&world, &dir, cmd); + assert_eq!(out.status.code(), Some(1), "{cmd}"); + assert!(stdout(&out).is_empty(), "{cmd}: {}", stdout(&out)); + assert!( + stderr(&out).contains("so gh is not run"), + "{}", + stderr(&out) + ); + world.sh(&dir, "rm -rf .git && git init -q"); + } +} + +#[test] +#[cfg(target_os = "macos")] +fn removes_what_the_broker_made() { + need_sandbox!(); + let (world, dir) = broker_world("once"); + let out = brokered(&world, &dir, "gh pr view"); + assert!(out.status.success(), "{}", stderr(&out)); + let state = world.home().join(".local/state/confine"); + assert_eq!(fs::read_dir(state).unwrap().count(), 0); +} + /// Whether `sandbox-exec` can run here. #[cfg(target_os = "macos")] fn sandbox() -> bool { From 09cabaed73958db3ad5c55bec8e5e285eaf1c966 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Thu, 29 Oct 2026 12:49:39 +0200 Subject: [PATCH 2/2] Make confine's dialog deny by itself, and remember what you allow always The dialog waited 5 minutes for an answer, so an agent no one was watching stopped for as long, and what you allowed always was forgotten when `confine` exited. Now Deny, the default button, counts down from 10, and is pressed when it reaches zero; the command is told it was not allowed. The dialog is an alert in JavaScript for Automation, as AppleScript's `display dialog` can give up but not count down. What you allow always, such as `gh pr create`, is added to `~/.config/confine/allow`, which the command can neither read nor write, and is not asked again. What each tool may be asked to do, and how it is kept from running what the command wrote, moves out of the broker to `tools/`, one file each, so that tools can be added: `tools/gh.rs` has `gh`'s, and `tools/git.rs` the clone of `.git` that `gh`, and any tool that runs git, runs with. "Broker" stays in the code, where it is the usual word, and leaves the man page, which says what you see: `confine` asking to run a tool outside the sandbox. The shim's socket is in `CONFINE_SOCKET`. --- README.md | 4 +- cmd/confine/broker.rs | 584 ++++++++++++++++------------------- cmd/confine/confine.1 | 30 +- cmd/confine/confine.rs | 14 +- cmd/confine/tests/confine.rs | 37 ++- cmd/confine/tools.rs | 44 +++ cmd/confine/tools/gh.rs | 78 +++++ cmd/confine/tools/git.rs | 229 ++++++++++++++ 8 files changed, 686 insertions(+), 334 deletions(-) create mode 100644 cmd/confine/tools.rs create mode 100644 cmd/confine/tools/gh.rs create mode 100644 cmd/confine/tools/git.rs diff --git a/README.md b/README.md index d141d3a..f8e729d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ there is no fork, and Claude Code runs in `dir` itself. On macOS, it can open pull requests: when it runs `gh`, a dialog asks you whether to, showing the command line, and `confine` runs the real `gh` -outside the sandbox only if you allow it. With `--headless`, for when no one +outside the sandbox only if you allow it, once or always. The dialog denies +by itself after 10 seconds, so an agent no one is watching is not kept +waiting. With `--headless`, for when no one is at the screen to answer, `gh` is not run for it. See [`confine.1`](cmd/confine/confine.1) for how git in the fork is kept from running anything. diff --git a/cmd/confine/broker.rs b/cmd/confine/broker.rs index 145a885..fa4efdc 100644 --- a/cmd/confine/broker.rs +++ b/cmd/confine/broker.rs @@ -1,34 +1,28 @@ -//! macOS: the broker, which runs `gh` for the confined command, outside the -//! sandbox, each time you allow it, so that the command can open pull -//! requests though it cannot read `gh`'s configuration. +//! macOS: the broker, which runs tools such as `gh` for the confined +//! command, outside the sandbox, each time you allow it, so that the command +//! can open pull requests though it cannot read `gh`'s configuration. //! -//! The command finds a shim first on its `PATH` in place of `gh`: `confine` -//! itself, under that name, which sends its arguments, working directory -//! and standard streams over a socket to the broker, and exits as the broker -//! says `gh` did. The broker asks you, with a dialog, whether to run it, and -//! runs the real `gh` with the shim's streams, so that it talks to the -//! terminal itself. -//! -//! `gh` runs git, and git runs what the repository's configuration and hooks -//! tell it to, both of which the command can write. So `gh` runs with a -//! clone of the repository's `.git` made where the command cannot write it, -//! and only if its configuration differs from what it was when `confine` -//! started in nothing but what `gh` and `git push` write themselves, with -//! hooks off. The clone then takes the place of `.git`, so that what `gh` -//! did to it, such as setting the branch it pushed to, is kept. +//! The command finds a shim first on its `PATH` in place of each tool: +//! `confine` itself, under the tool's name, which sends its arguments, +//! working directory and standard streams over a socket to the broker, and +//! exits as the broker says the tool did. The broker asks you, with a +//! dialog that denies by itself if no one answers, whether to run it, and +//! runs the real tool with the shim's streams, so that it talks to the +//! terminal itself. What each tool may be asked to do, and how it is kept +//! from running what the command wrote, is in `tools`. use std::{ collections::HashSet, env, - ffi::{OsStr, OsString}, - fs::{self, DirBuilder, File}, + ffi::OsString, + fs::{self, DirBuilder, File, OpenOptions}, io::{self, Read, Write}, mem, os::{ fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, unix::{ ffi::{OsStrExt, OsStringExt}, - fs::{DirBuilderExt, PermissionsExt}, + fs::{DirBuilderExt, PermissionsExt, symlink}, net::{UnixListener, UnixStream}, process::{CommandExt, ExitStatusExt}, }, @@ -36,48 +30,102 @@ use std::{ path::{Path, PathBuf}, process::{self, Command, ExitCode, Stdio}, ptr, - sync::{ - Arc, Mutex, - atomic::{AtomicU64, Ordering}, - }, + sync::{Arc, Mutex}, thread, }; -use crate::at; +use crate::{ + at, + tools::{Policy, Repo, TOOLS, Tool}, +}; /// The variable that tells the shim where the broker's socket is. -const SOCKET: &str = "CONFINE_BROKER"; +const SOCKET: &str = "CONFINE_SOCKET"; /// The variable that names a program to ask with instead of a dialog: it is /// given the question, and prints `once` or `always` to allow, and anything /// else to deny. const ASK: &str = "CONFINE_ASK"; -/// `gh`'s commands that manage credentials or configuration, or run -/// extensions, which are never run. -const DENIED: &[&str] = &[ - "alias", - "auth", - "config", - "ext", - "extension", - "extensions", - "gpg-key", - "secret", - "ssh-key", - "variable", -]; - -/// `gh`'s commands that can do anything, which you are asked about every -/// time. -const UNBOUNDED: &[&str] = &["api"]; +/// The file, in the home directory, that lists what you allowed always, +/// one per line, such as `gh pr create`. The command can neither read nor +/// write it. +const ALLOWED: &str = ".config/confine/allow"; + +/// How long the dialog waits for an answer before it denies, in seconds, so +/// that a command no one is watching is not kept waiting. +const TIMEOUT: u32 = 10; + +/// The dialog, in JavaScript for Automation: an alert that asks whether to +/// let the tool run outside the sandbox, with the command line in a box of +/// its own, and whose default button, Deny, counts down, and is pressed when +/// it reaches zero. It is given the tool, the command line, the directory the +/// command is confined to, the one the tool is to run in, the time out, and, +/// to offer to allow always, what to allow so, and prints `once`, `always` +/// or `deny`. +const DIALOG: &str = r#"ObjC.import('Cocoa'); +function run(argv) { + const [tool, line, dir, cwd, timeout, always] = + [argv[0], argv[1], argv[2], argv[3], Number(argv[4]), argv[5]]; + const tilde = path => $(path).stringByAbbreviatingWithTildeInPath.js; + const app = $.NSApplication.sharedApplication; + app.setActivationPolicy($.NSApplicationActivationPolicyAccessory); + const alert = $.NSAlert.alloc.init; + alert.messageText = `Allow ${tool} to run outside the sandbox?`; + alert.informativeText = cwd == dir + ? `The command confined to ${tilde(dir)} wants to run:` + : `The command confined to ${tilde(dir)} wants to run, in ${tilde(cwd)}:`; + const symbol = $.NSImage.imageWithSystemSymbolNameAccessibilityDescription('lock.shield', 'confine'); + if (symbol && !symbol.isNil()) { + const config = $.NSImageSymbolConfiguration.configurationWithPointSizeWeight(48, $.NSFontWeightRegular) + .configurationByApplyingConfiguration( + $.NSImageSymbolConfiguration.configurationWithHierarchicalColor($.NSColor.systemOrangeColor)); + alert.icon = symbol.imageWithSymbolConfiguration(config); + } + // The command line, monospaced, wrapped, and selectable, on a rounded + // background, wide enough that the buttons sit in a row. + const width = 420, pad = 10; + const text = $.NSTextField.wrappingLabelWithString(line); + text.font = $.NSFont.monospacedSystemFontOfSizeWeight(12, $.NSFontWeightRegular); + text.selectable = true; + const size = text.cell.cellSizeForBounds($.NSMakeRect(0, 0, width - 2 * pad, 10000)); + const height = Math.min(size.height, 200) + 2 * pad; + text.frame = $.NSMakeRect(pad, pad, width - 2 * pad, height - 2 * pad); + const box = $.NSView.alloc.initWithFrame($.NSMakeRect(0, 0, width, height)); + box.wantsLayer = true; + box.layer.cornerRadius = 6; + box.layer.backgroundColor = $.NSColor.quaternaryLabelColor.CGColor; + box.addSubview(text); + alert.accessoryView = box; + const deny = alert.addButtonWithTitle(`Deny (${timeout})`); + alert.addButtonWithTitle('Allow Once'); + if (always) alert.addButtonWithTitle(`Always Allow “${always}”`); + let left = timeout; + const timer = $.NSTimer.timerWithTimeIntervalRepeatsBlock(1, true, t => { + left -= 1; + if (left > 0) { + deny.title = `Deny (${left})`; + } else { + t.invalidate; + app.abortModal; + } + }); + $.NSRunLoop.currentRunLoop.addTimerForMode(timer, $.NSModalPanelRunLoopMode); + app.activateIgnoringOtherApps(true); + alert.window.initialFirstResponder = deny; + const response = alert.runModal; + timer.invalidate; + return response == $.NSAlertSecondButtonReturn ? 'once' + : response == $.NSAlertThirdButtonReturn ? 'always' + : 'deny'; +}"#; /// The longest argument the shim may send, to bound what the broker reads. const MAX_LEN: u64 = 1 << 24; -/// Runs `gh` for a confined command, until it is dropped. +/// Runs tools for a confined command, until it is dropped. pub struct Broker { - /// Where the shim and the socket are, which the command may write to. + /// Where the shims and the socket are, which the command may write to. shared: PathBuf, /// Where the clones of `.git` are made, which the command may not write /// to, or read. @@ -86,19 +134,16 @@ pub struct Broker { /// What the broker knows, which each request it serves shares. struct State { - /// The real `gh`. - gh: PathBuf, + /// The tools that are installed, and where. + tools: Vec<(&'static dyn Tool, PathBuf)>, /// The directory the command is confined to. dir: PathBuf, - private: PathBuf, - /// The configuration of the directory's `.git` when `confine` started, - /// if it was a repository. - config: Option>>, - /// The commands you allowed always, such as `gh pr create`, which is - /// locked while you are asked, so that you are asked one thing at a time. - always: Mutex>, - /// How many clones of `.git` have been made, to name the next. - clones: AtomicU64, + repo: Repo, + /// The file that lists what you allowed always. + allowed: PathBuf, + /// Locked while you are asked, so that you are asked one thing at a + /// time. + asking: Mutex<()>, } /// What you answered. @@ -109,18 +154,16 @@ enum Answer { } impl Broker { - /// Starts brokering `gh` for a command confined to `dir`, in which `home` - /// is the home directory, unless `gh` is not installed. + /// Starts running tools for a command confined to `dir`, in which + /// `home` is the home directory, unless none is installed. pub fn start(dir: &Path, home: &Path) -> io::Result> { - let Some(gh) = find("gh") else { + let tools: Vec<(&'static dyn Tool, PathBuf)> = TOOLS + .iter() + .filter_map(|&tool| find(tool.name()).map(|path| (tool, path))) + .collect(); + if tools.is_empty() { return Ok(None); - }; - let git = dir.join(".git"); - let config = if git.is_dir() { - Some(config(&git)?) - } else { - None - }; + } let id = format!("confine-{}", process::id()); let shared = env::temp_dir().canonicalize()?.join(&id); DirBuilder::new().mode(0o700).create(&shared)?; @@ -130,16 +173,21 @@ impl Broker { .mode(0o700) .recursive(true) .create(&broker.private)?; + let repo = Repo::new(dir, &broker.private)?; + // The command may not read `confine` where it is installed, so a + // copy is where it can. + fs::copy(env::current_exe()?, broker.shared.join("confine"))?; fs::create_dir(broker.shared.join("bin"))?; - fs::copy(env::current_exe()?, broker.shared.join("bin/gh"))?; + for (tool, _) in &tools { + symlink("../confine", broker.shared.join("bin").join(tool.name()))?; + } let listener = UnixListener::bind(broker.socket())?; let state = Arc::new(State { - gh, + tools, dir: dir.to_path_buf(), - private: broker.private.clone(), - config, - always: Mutex::new(HashSet::new()), - clones: AtomicU64::new(0), + repo, + allowed: home.join(ALLOWED), + asking: Mutex::new(()), }); thread::spawn(move || { for stream in listener.incoming().flatten() { @@ -150,7 +198,7 @@ impl Broker { Ok(Some(broker)) } - /// The environment the command needs for `gh` to be the shim. + /// The environment the command needs for the tools to be the shims. pub fn env(&self) -> io::Result> { let mut paths = vec![self.shared.join("bin")]; paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); @@ -171,8 +219,8 @@ impl Drop for Broker { } impl State { - /// Serves the shim at the other end of `stream`: runs `gh` for it, if - /// you allow it, and tells it how `gh` exited. + /// Serves the shim at the other end of `stream`: runs its tool for it, + /// if you allow it, and tells it how the tool exited. fn serve(&self, mut stream: UnixStream) { let Ok(Ok([stdin, stdout, stderr])) = receive_fds(&stream).map(<[OwnedFd; 3]>::try_from) else { @@ -190,18 +238,27 @@ impl State { let _ = stream.write_all(&status.to_be_bytes()); } - /// Runs `gh` as `request`, its working directory and then its - /// arguments, asks, with `stdio`, if you allow it, and returns its exit - /// status. + /// Runs the tool as `request`, its name, its working directory and then + /// its arguments, says, if you allow it, with `stdio`, and returns its + /// exit status. fn run(&self, request: Vec, stdio: [OwnedFd; 3], err: &mut File) -> io::Result { let mut request = request.into_iter(); + let name = request.next().unwrap_or_default(); + let Some(&(tool, ref path)) = self.tools.iter().find(|(tool, _)| name == tool.name()) + else { + return Err(io::Error::other(format!( + "{}: not a tool", + name.to_string_lossy() + ))); + }; let cwd = PathBuf::from(request.next().unwrap_or_default()); let args: Vec = request.collect(); let cwd = cwd.canonicalize().map_err(|e| at(&cwd, e))?; if !cwd.starts_with(&self.dir) { return Err(io::Error::other(format!( - "{}: gh runs only in {}", + "{}: {} runs only in {}", cwd.display(), + tool.name(), self.dir.display() ))); } @@ -210,67 +267,46 @@ impl State { .map(|arg| arg.to_str()) .collect::>>() else { - return Err(io::Error::other("gh's arguments must be UTF-8")); + return Err(io::Error::other(format!( + "{}'s arguments must be UTF-8", + tool.name() + ))); }; - // The command, such as `gh pr create`: the words before the first - // flag, up to two. - let words: Vec<&str> = args - .iter() - .take_while(|arg| !arg.starts_with('-')) - .take(2) - .copied() - .collect(); - if let Some(word) = words.first().filter(|word| DENIED.contains(word)) { - return Err(io::Error::other(format!("gh {word}: never run for it"))); - } - let line = quoted(&args); - let always = (!words.is_empty() && !UNBOUNDED.contains(&words[0])) - .then(|| format!("gh {}", words.join(" "))); - if !self.allowed(&line, &cwd, always)? { - return Err(io::Error::other(format!("{line}: you did not allow it"))); + let line = quoted(tool.name(), &args); + let always = match tool.policy(&args) { + Policy::Never(what) => { + return Err(io::Error::other(format!("{what}: never run for it"))); + } + Policy::AskEachTime => None, + Policy::Ask(what) => Some(what), + }; + if !self.allowed(tool.name(), &line, &cwd, always.as_deref())? { + return Err(io::Error::other(format!( + "{line}: not allowed; no one allowed it in time, or it was denied" + ))); } - let git = self.clone_git()?; let [stdin, stdout, stderr] = stdio; - let mut gh = Command::new(&self.gh); - gh.args(&args) + let mut command = Command::new(path); + command + .args(&args) .current_dir(&cwd) .stdin(stdin) .stdout(stdout) - .stderr(stderr) - .env( - "GIT_DIR", - git.clone().unwrap_or_else(|| self.private.join("none")), - ) - .env("GIT_WORK_TREE", &self.dir); - // Hooks and the file system monitor are programs in, or named by, - // `.git`, so are turned off whatever it says. Variables of this kind - // the user set are kept. - let n: usize = env::var("GIT_CONFIG_COUNT") - .ok() - .and_then(|n| n.parse().ok()) - .unwrap_or(0); - let off = [("core.hooksPath", "/dev/null"), ("core.fsmonitor", "false")]; - for (i, (key, value)) in off.iter().enumerate() { - gh.env(format!("GIT_CONFIG_KEY_{}", n + i), key) - .env(format!("GIT_CONFIG_VALUE_{}", n + i), value); - } - gh.env("GIT_CONFIG_COUNT", (n + off.len()).to_string()); - // `confine` ignores the terminal's signals while it waits, which `gh` - // gets as they are by default. + .stderr(stderr); + let finish = tool.prepare(&self.repo, &mut command)?; + // `confine` ignores the terminal's signals while it waits, which the + // tool gets as they are by default. unsafe { - gh.pre_exec(|| { + command.pre_exec(|| { for signal in crate::SIGNALS { libc::signal(signal, libc::SIG_DFL); } Ok(()) }); } - let status = gh.status().map_err(|e| at(&self.gh, e)); - if let Some(clone) = git - && let Err(e) = self.restore(&clone) - { - let _ = writeln!(err, "confine: what gh changed in .git is lost: {e}"); - let _ = fs::remove_dir_all(&clone); + let status = command.status().map_err(|e| at(path, e)); + if let Err(e) = finish() { + let _ = writeln!(err, "confine: {e}"); } let status = status?; Ok(status @@ -278,13 +314,19 @@ impl State { .unwrap_or_else(|| 128 + status.signal().unwrap_or(0))) } - /// Whether you allow running `line` in `cwd`: once, or, if `always` is - /// given, every time the command runs it until `confine` exits. - fn allowed(&self, line: &str, cwd: &Path, always: Option) -> io::Result { - let mut allowed = self.always.lock().unwrap_or_else(|e| e.into_inner()); - if always - .as_ref() - .is_some_and(|always| allowed.contains(always)) + /// Whether you allow `tool` running `line` in `cwd`: once, or, if + /// `always` is given, always, which is kept in the file of what you + /// allowed so. + fn allowed( + &self, + tool: &str, + line: &str, + cwd: &Path, + always: Option<&str>, + ) -> io::Result { + let _asking = self.asking.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(always) = always + && allowed(&self.allowed)?.contains(always) { return Ok(true); } @@ -293,171 +335,86 @@ impl State { self.dir.display(), cwd.display() ); - let label = always - .as_ref() - .map(|always| format!("Always allow {always}")); - Ok(match ask(&question, label.as_deref())? { + Ok(match ask(&question, tool, line, &self.dir, cwd, always)? { Answer::Deny => false, Answer::Once => true, Answer::Always => { - allowed.extend(always); + if let Some(always) = always { + remember(&self.allowed, always)?; + } true } }) } - - /// Clones the directory's `.git` to where the command cannot write, and - /// returns where, if its configuration is safe for git to run with. If - /// the directory was not a repository, there is none. - fn clone_git(&self) -> io::Result> { - let Some(original) = &self.config else { - return Ok(None); - }; - let git = self.dir.join(".git"); - if !git.symlink_metadata().is_ok_and(|meta| meta.is_dir()) { - return Err(io::Error::other(format!( - "{}: no longer a directory", - git.display() - ))); - } - let n = self.clones.fetch_add(1, Ordering::Relaxed); - let clone = self.private.join(format!("{n}.git")); - clonefile(&git, &clone).map_err(|e| at(&clone, e))?; - let checked = check(&clone, original); - if checked.is_err() { - let _ = fs::remove_dir_all(&clone); - } - checked.map(|()| Some(clone)) - } - - /// Puts `clone` in the place of the directory's `.git`. - fn restore(&self, clone: &Path) -> io::Result<()> { - let git = self.dir.join(".git"); - let old = clone.with_extension("old"); - fs::rename(&git, &old)?; - if let Err(e) = fs::rename(clone, &git) { - let _ = fs::rename(&old, &git); - return Err(e); - } - fs::remove_dir_all(&old) - } } -/// Checks that the configuration of `git` differs from `original` in -/// nothing but what `gh` and `git push` write, and that it does not name -/// another directory to take it from. -fn check(git: &Path, original: &HashSet>) -> io::Result<()> { - if git.join("commondir").symlink_metadata().is_ok() { - return Err(io::Error::other( - ".git/commondir: made by the command, so gh is not run", - )); - } - for entry in config(git)? { - if !original.contains(&entry) && !written_by_gh(&entry) { - let entry = String::from_utf8_lossy(&entry); - let key = entry.split('\n').next().unwrap_or_default(); - return Err(io::Error::other(format!( - ".git/config: {key} changed by the command, so gh is not run" - ))); - } - } - Ok(()) +/// What the file `path` lists as allowed always: its lines, but blank ones +/// and comments. +fn allowed(path: &Path) -> io::Result> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(HashSet::new()), + Err(e) => return Err(at(path, e)), + }; + Ok(contents + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_string) + .collect()) } -/// The entries of the configuration of `git`, each a key and its value, as -/// `git config --list` gives them, from `config` and `config.worktree`. -fn config(git: &Path) -> io::Result>> { - let mut entries = HashSet::new(); - for file in ["config", "config.worktree"] { - let path = git.join(file); - if path.symlink_metadata().is_err() { - continue; - } - // Run outside the repository, so that git reads only the file. - let out = Command::new("git") - .current_dir("/") - .args(["config", "--no-includes", "--null", "--list", "--file"]) - .arg(&path) - .stderr(Stdio::piped()) - .output()?; - if !out.status.success() { - return Err(io::Error::other(format!( - "{}: {}", - path.display(), - String::from_utf8_lossy(&out.stderr).trim() - ))); - } - entries.extend( - out.stdout - .split(|&b| b == 0) - .filter(|entry| !entry.is_empty()) - .map(<[u8]>::to_vec), - ); +/// Adds `always` to the file `path` of what is allowed always. +fn remember(path: &Path, always: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| at(parent, e))?; } - Ok(entries) + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| at(path, e))?; + writeln!(file, "{always}").map_err(|e| at(path, e)) } -/// Whether `entry`, a key and its value, is one that `gh` or `git push` -/// write: the branch a branch is pushed to, and the remotes `gh` adds and -/// marks, by URLs that run nothing. -fn written_by_gh(entry: &[u8]) -> bool { - let entry = String::from_utf8_lossy(entry); - let (key, value) = entry.split_once('\n').unwrap_or((&entry, "")); - let (Some((section, _)), Some((_, name))) = (key.split_once('.'), key.rsplit_once('.')) else { - return false; +/// Asks you whether to let `tool` run `line`, from the command confined to +/// `dir`, in `cwd`, with a dialog, or `question`, with the program +/// `CONFINE_ASK` names, offering to allow once, and, if `always` is given, +/// always so. +fn ask( + question: &str, + tool: &str, + line: &str, + dir: &Path, + cwd: &Path, + always: Option<&str>, +) -> io::Result { + let out = match env::var_os(ASK) { + Some(program) => Command::new(&program).arg(question).output()?, + // What the dialog shows is given as arguments, so it needs no + // quoting. + None => Command::new("/usr/bin/osascript") + .args(["-l", "JavaScript", "-e", DIALOG, tool, line]) + .arg(dir) + .arg(cwd) + .arg(TIMEOUT.to_string()) + .args(always) + .stderr(Stdio::null()) + .output()?, }; - match (section, name) { - ("branch", "remote" | "merge" | "pushremote" | "gh-merge-base") => true, - ("remote", "fetch" | "gh-resolved") => true, - ("remote", "url" | "pushurl") => ["https://", "ssh://", "git@"] - .iter() - .any(|scheme| value.starts_with(scheme)), - _ => false, - } -} - -/// Asks you `question`, with a dialog, or the program `CONFINE_ASK` names, -/// offering to allow once, and, if `always` is given, with it as the label. -fn ask(question: &str, always: Option<&str>) -> io::Result { - if let Some(program) = env::var_os(ASK) { - let out = Command::new(&program).arg(question).output()?; - return Ok(match String::from_utf8_lossy(&out.stdout).trim() { - "once" => Answer::Once, - "always" if always.is_some() => Answer::Always, - "always" => Answer::Once, - _ => Answer::Deny, - }); - } - // The question and the label are arguments, so they need no quoting. - // Deny is the default, and what a dialog left unanswered gives. - let script = r#"on run argv - set choices to {"Deny", "Allow once"} - if (count of argv) > 1 then set end of choices to item 2 of argv - display dialog (item 1 of argv) with title "confine" buttons choices ¬ - default button "Deny" cancel button "Deny" with icon caution ¬ - giving up after 300 - return button returned of result - end run"#; - let out = Command::new("/usr/bin/osascript") - .arg("-e") - .arg(script) - .arg(question) - .args(always) - .stderr(Stdio::null()) - .output()?; - let answer = String::from_utf8_lossy(&out.stdout); - Ok(match answer.trim() { - "Allow once" => Answer::Once, - answer if Some(answer) == always => Answer::Always, + Ok(match String::from_utf8_lossy(&out.stdout).trim() { + "once" => Answer::Once, + "always" if always.is_some() => Answer::Always, + "always" => Answer::Once, _ => Answer::Deny, }) } -/// `gh` and `args` as a shell would take them, with what is not plain +/// `name` and `args` as a shell would take them, with what is not plain /// quoted, and control characters escaped, so that you see each argument as /// it is. -fn quoted(args: &[&str]) -> String { - let mut line = String::from("gh"); +fn quoted(name: &str, args: &[&str]) -> String { + let mut line = String::from(name); for arg in args { line.push(' '); let plain = |b: u8| b.is_ascii_alphanumeric() || b"%+,-./:=@_".contains(&b); @@ -472,32 +429,37 @@ fn quoted(args: &[&str]) -> String { line } -/// Whether this process is the shim: `confine` run as `gh` by a confined -/// command. +/// Whether this process is a shim: `confine` run under a tool's name by a +/// confined command. pub fn is_shim() -> bool { env::var_os(SOCKET).is_some() - && env::args_os() - .next() - .is_some_and(|arg0| Path::new(&arg0).file_name() == Some(OsStr::new("gh"))) + && shim_name().is_some_and(|name| TOOLS.iter().any(|tool| tool.name() == name)) } -/// Runs as the shim: has the broker run `gh`, and exits as it did. +/// The name this process was run under. +fn shim_name() -> Option { + let arg0 = env::args_os().next()?; + Path::new(&arg0).file_name().map(OsString::from) +} + +/// Runs as a shim: has the broker run the tool, and exits as it did. pub fn shim() -> ExitCode { - match request() { + let name = shim_name().unwrap_or_default(); + match request(&name) { Ok(status) => ExitCode::from(status as u8), Err(e) => { - eprintln!("gh: confine: {e}"); + eprintln!("{}: confine: {e}", name.to_string_lossy()); ExitCode::FAILURE } } } -/// Sends the broker the standard streams, the working directory and the -/// arguments, and returns the exit status it sends back. -fn request() -> io::Result { +/// Sends the broker the standard streams, `name`, the working directory and +/// the arguments, and returns the exit status it sends back. +fn request(name: &OsString) -> io::Result { let mut stream = UnixStream::connect(env::var_os(SOCKET).unwrap_or_default())?; send_fds(&stream, &[0, 1, 2])?; - let mut request = vec![env::current_dir()?.into_os_string()]; + let mut request = vec![name.clone(), env::current_dir()?.into_os_string()]; request.extend(env::args_os().skip(1)); write_strings(&mut stream, &request)?; let mut status = [0; 4]; @@ -562,7 +524,7 @@ fn receive_fds(stream: &UnixStream) -> io::Result> { let data = libc::CMSG_DATA(cmsg).cast::(); for i in 0..count { let fd = data.add(i).read_unaligned(); - // So that the `gh` run for another request gets none of + // So that a tool run for another request gets none of // these. macOS has no flag to receive them so. libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC); fds.push(OwnedFd::from_raw_fd(fd)); @@ -622,17 +584,6 @@ fn find(name: &str) -> Option { }) } -/// Clones the directory tree at `from` to `to`, sharing its blocks. -fn clonefile(from: &Path, to: &Path) -> io::Result<()> { - let from = std::ffi::CString::new(from.as_os_str().as_bytes())?; - let to = std::ffi::CString::new(to.as_os_str().as_bytes())?; - // `CLONE_NOFOLLOW`: symbolic links are cloned as links. - if unsafe { libc::clonefile(from.as_ptr(), to.as_ptr(), 0x0001) } != 0 { - return Err(io::Error::last_os_error()); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -640,20 +591,27 @@ mod tests { #[test] fn quotes_what_is_not_plain() { assert_eq!( - quoted(&["pr", "create", "--title", "it's", "a\nb", ""]), + quoted("gh", &["pr", "create", "--title", "it's", "a\nb", ""]), r"gh pr create --title 'it'\''s' $'a\nb' ''" ); } #[test] - fn allows_only_what_gh_writes() { - assert!(written_by_gh(b"branch.fix/a.b.remote\norigin")); - assert!(written_by_gh( - b"remote.fork.url\nhttps://github.com/a/b.git" - )); - assert!(!written_by_gh(b"remote.fork.url\n/tmp/x")); - assert!(!written_by_gh(b"remote.origin.uploadpack\nsh -c x")); - assert!(!written_by_gh(b"core.pager\nsh -c x")); - assert!(!written_by_gh(b"include.path\n/tmp/x")); + fn reads_what_is_allowed_always() { + let dir = env::temp_dir().join(format!("confine-allowed-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + let path = dir.join("confine/allow"); + assert!(allowed(&path).unwrap().is_empty()); + remember(&path, "gh pr create").unwrap(); + fs::write( + &path, + fs::read_to_string(&path).unwrap() + "\n# x\n gh pr view \n", + ) + .unwrap(); + assert_eq!( + allowed(&path).unwrap(), + HashSet::from(["gh pr create".to_string(), "gh pr view".to_string()]) + ); + fs::remove_dir_all(&dir).unwrap(); } } diff --git a/cmd/confine/confine.1 b/cmd/confine/confine.1 index 6acadd4..383a65f 100644 --- a/cmd/confine/confine.1 +++ b/cmd/confine/confine.1 @@ -71,7 +71,7 @@ whether to, so that .B gh fails in the sandbox. See -.BR "THE BROKER" . +.BR "ASKING TO RUN OUTSIDE THE SANDBOX" . .TP .BI \-d " dir" "\fR, \fP\-\-dir " dir Run the command in @@ -160,7 +160,7 @@ read, and files made in the directories above the home directory after the command starts cannot be read. .PP The network and the GPU are not confined. -.SH THE BROKER +.SH ASKING TO RUN OUTSIDE THE SANDBOX On macOS, if .BR gh (1) is installed, the command can open pull requests and the like though it @@ -174,17 +174,23 @@ first on its .BR PATH . When the command runs it, .B confine -shows a dialog with the command line, and runs the real +asks you, with a dialog that shows the command line, and runs the real .BR gh , found on .BR confine 's own .BR PATH , with the command's standard input and output, only if you allow it: once, -or always, until -.B confine -exits, for that command, such as -.BR "gh pr create" . +or always for that command, such as +.BR "gh pr create" , +which is kept in +.IR ~/.config/confine/allow . +The dialog denies by itself if no one answers it in 10 seconds, so that a +command no one is watching is not kept waiting, and denies if Return is +pressed. +The command is told it was not allowed, and +.B gh +exits with 1. .B gh api is asked about every time, and .BR "gh auth" , @@ -223,6 +229,16 @@ The clone then replaces so that what .B gh changed there is kept. +.SH FILES +.TP +.I ~/.config/confine/allow +The commands you allowed always, one per line, such as +.BR "gh pr create" ; +blank lines and lines that start with +.B # +are left out. +Remove a line to be asked again. +The confined command can neither read nor write it. .SH ENVIRONMENT .B confine sets diff --git a/cmd/confine/confine.rs b/cmd/confine/confine.rs index 733b1c7..358fdd4 100644 --- a/cmd/confine/confine.rs +++ b/cmd/confine/confine.rs @@ -9,6 +9,8 @@ mod broker; mod darwin; #[cfg(target_os = "linux")] mod linux; +#[cfg(target_os = "macos")] +mod tools; #[cfg(target_os = "macos")] use darwin as os; @@ -84,8 +86,8 @@ struct Args { #[argh(switch)] rm: bool, - /// do not run gh for the command when it asks to, as there is no one to - /// ask whether to + /// do not ask whether to run tools such as gh outside the sandbox for the + /// command, and so never run them #[argh(switch)] headless: bool, @@ -184,7 +186,7 @@ fn confine(args: &Args) -> io::Result { } else { // The command can still run without it, as with `--headless`. broker::Broker::start(&dir, &home).unwrap_or_else(|e| { - eprintln!("confine: gh is not brokered: {e}"); + eprintln!("confine: cannot ask to run tools outside the sandbox: {e}"); None }) }; @@ -194,8 +196,12 @@ fn confine(args: &Args) -> io::Result { .map(broker::Broker::env) .transpose()? .unwrap_or_default(); + // Tools are run outside the sandbox only on macOS, for now. #[cfg(not(target_os = "macos"))] - let env = Vec::new(); + let env = { + let _ = args.headless; + Vec::new() + }; let wait = args.rm || !env.is_empty(); run(&command, &dir, &writable, &readable, &hidden, &env, wait) })(); diff --git a/cmd/confine/tests/confine.rs b/cmd/confine/tests/confine.rs index ffd8795..7eec18e 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -430,11 +430,7 @@ fn does_not_run_gh_if_denied() { let out = brokered(&world, &dir, "gh pr create"); assert_eq!(out.status.code(), Some(1)); assert!(stdout(&out).is_empty()); - assert!( - stderr(&out).contains("you did not allow it"), - "{}", - stderr(&out) - ); + assert!(stderr(&out).contains("not allowed"), "{}", stderr(&out)); } #[test] @@ -444,9 +440,32 @@ fn asks_once_for_what_is_always_allowed() { let (world, dir) = broker_world("always"); let out = brokered(&world, &dir, "gh pr create && gh pr create && gh pr view"); assert!(out.status.success(), "{}", stderr(&out)); - let asked = asked(&world); - assert_eq!(asked.len(), 2, "{asked:?}"); - assert!(asked[1].contains("gh pr view")); + let questions = asked(&world); + assert_eq!(questions.len(), 2, "{questions:?}"); + assert!(questions[1].contains("gh pr view")); + let config = world.home().join(".config/confine"); + assert_eq!(read(&config, "allow"), "gh pr create\ngh pr view\n"); + // And is remembered the next time. + let out = brokered(&world, &dir, "gh pr create --title again"); + assert!(out.status.success(), "{}", stderr(&out)); + assert_eq!(asked(&world).len(), 2); +} + +#[test] +#[cfg(target_os = "macos")] +fn the_command_cannot_allow_itself_always() { + need_sandbox!(); + let (world, dir) = broker_world("deny"); + let config = world.home().join(".config/confine"); + write(&config, "allow", "gh pr view\n"); + let out = brokered( + &world, + &dir, + "echo 'gh pr create' >> ~/.config/confine/allow || cat ~/.config/confine/allow", + ); + assert!(!out.status.success()); + assert!(stdout(&out).is_empty()); + assert_eq!(read(&config, "allow"), "gh pr view\n"); } #[test] @@ -474,7 +493,7 @@ fn does_not_run_gh_with_git_configured_to_run_programs() { assert_eq!(out.status.code(), Some(1), "{cmd}"); assert!(stdout(&out).is_empty(), "{cmd}: {}", stdout(&out)); assert!( - stderr(&out).contains("so gh is not run"), + stderr(&out).contains("so it is not run"), "{}", stderr(&out) ); diff --git a/cmd/confine/tools.rs b/cmd/confine/tools.rs new file mode 100644 index 0000000..cd342a2 --- /dev/null +++ b/cmd/confine/tools.rs @@ -0,0 +1,44 @@ +//! The tools that `confine` can run for the command outside the sandbox, +//! each time you allow it: what each may be asked to do, and how each is +//! kept from running what the command wrote. +//! +//! A tool is code rather than configuration, as whether it is safe to run +//! outside the sandbox depends on what it does: `make`, `npm` and `cargo` +//! run what is in the directory, and so can never be tools. + +mod gh; +mod git; + +use std::{io, process::Command}; + +pub use git::Repo; + +/// A tool that `confine` can run for the command. +pub trait Tool: Sync { + /// The program it is, as the command runs it. + fn name(&self) -> &'static str; + + /// What to do when the command runs it with `args`. + fn policy(&self, args: &[&str]) -> Policy; + + /// Makes `command`, which runs it, safe to run outside the sandbox in + /// `repo`, and returns what to do once it has run. + fn prepare(&self, repo: &Repo, command: &mut Command) -> io::Result; +} + +/// What to do when the command runs a tool. +pub enum Policy { + /// Never run it, for this reason. + Never(String), + /// Ask you every time. + AskEachTime, + /// Ask you, unless you allowed it always: what this names, such as + /// `gh pr create`, which is what you allow. + Ask(String), +} + +/// What to do once a tool has run. +pub type Finish = Box io::Result<()> + Send>; + +/// Every tool. +pub const TOOLS: &[&dyn Tool] = &[&gh::Gh]; diff --git a/cmd/confine/tools/gh.rs b/cmd/confine/tools/gh.rs new file mode 100644 index 0000000..d79f271 --- /dev/null +++ b/cmd/confine/tools/gh.rs @@ -0,0 +1,78 @@ +//! `gh`, GitHub's command, so that the command can open pull requests and +//! the like though it cannot read `gh`'s configuration. + +use std::{io, process::Command}; + +use super::{Finish, Policy, Repo, Tool}; + +/// `gh`'s commands that manage credentials or configuration, or run +/// extensions, which are never run. +const NEVER: &[&str] = &[ + "alias", + "auth", + "config", + "ext", + "extension", + "extensions", + "gpg-key", + "secret", + "ssh-key", + "variable", +]; + +/// `gh`'s commands that can do anything, which you are asked about every +/// time. +const UNBOUNDED: &[&str] = &["api"]; + +pub struct Gh; + +impl Tool for Gh { + fn name(&self) -> &'static str { + "gh" + } + + fn policy(&self, args: &[&str]) -> Policy { + // The command, such as `gh pr create`: the words before the first + // flag, up to two. + let words: Vec<&str> = args + .iter() + .take_while(|arg| !arg.starts_with('-')) + .take(2) + .copied() + .collect(); + match words.first() { + Some(word) if NEVER.contains(word) => Policy::Never(format!("gh {word}")), + Some(word) if !UNBOUNDED.contains(word) => { + Policy::Ask(format!("gh {}", words.join(" "))) + } + _ => Policy::AskEachTime, + } + } + + /// `gh` runs git. + fn prepare(&self, repo: &Repo, command: &mut Command) -> io::Result { + repo.isolate(command) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn policy(args: &[&str]) -> String { + match Gh.policy(args) { + Policy::Never(why) => format!("never: {why}"), + Policy::AskEachTime => "each time".to_string(), + Policy::Ask(what) => what, + } + } + + #[test] + fn allows_always_what_the_command_names() { + assert_eq!(policy(&["pr", "create", "--title", "x"]), "gh pr create"); + assert_eq!(policy(&["status"]), "gh status"); + assert_eq!(policy(&["auth", "token"]), "never: gh auth"); + assert_eq!(policy(&["api", "/user"]), "each time"); + assert_eq!(policy(&["--version"]), "each time"); + } +} diff --git a/cmd/confine/tools/git.rs b/cmd/confine/tools/git.rs new file mode 100644 index 0000000..c822450 --- /dev/null +++ b/cmd/confine/tools/git.rs @@ -0,0 +1,229 @@ +//! Git, which tools such as `gh` run, and which runs what the repository's +//! configuration and hooks tell it to, both of which the command can write. +//! +//! So a tool runs git with a clone of the repository's `.git` made where the +//! command cannot write it, and only if its configuration differs from what +//! it was when `confine` started in nothing but what `gh` and `git push` +//! write themselves, with hooks off. The clone then takes the place of +//! `.git`, so that what the tool did to it, such as setting the branch it +//! pushed to, is kept. + +use std::{ + collections::HashSet, + env, fs, io, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use super::Finish; +use crate::at; + +/// The repository of the directory the command is confined to, if it is +/// one. +pub struct Repo { + /// The directory. + dir: PathBuf, + /// Where clones of `.git` are made, which the command may not write to. + private: PathBuf, + /// The configuration of `.git` when `confine` started, if it was a + /// repository. + config: Option>>, + /// How many clones of `.git` have been made, to name the next. + clones: AtomicU64, +} + +impl Repo { + /// The repository of `dir`, whose `.git` is cloned to `private`. + pub fn new(dir: &Path, private: &Path) -> io::Result { + let git = dir.join(".git"); + let config = if git.is_dir() { + Some(config(&git)?) + } else { + None + }; + Ok(Repo { + dir: dir.to_path_buf(), + private: private.to_path_buf(), + config, + clones: AtomicU64::new(0), + }) + } + + /// Has git, as `command` runs it, use a clone of `.git`, with hooks and + /// the file system monitor off, and returns what puts the clone in its + /// place once `command` has run. + pub fn isolate(&self, command: &mut Command) -> io::Result { + let clone = self.clone_git()?; + command + .env( + "GIT_DIR", + clone.clone().unwrap_or_else(|| self.private.join("none")), + ) + .env("GIT_WORK_TREE", &self.dir); + // Hooks and the file system monitor are programs in, or named by, + // `.git`, so are turned off whatever it says. Variables of this kind + // the user set are kept. + let n: usize = env::var("GIT_CONFIG_COUNT") + .ok() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + let off = [("core.hooksPath", "/dev/null"), ("core.fsmonitor", "false")]; + for (i, (key, value)) in off.iter().enumerate() { + command + .env(format!("GIT_CONFIG_KEY_{}", n + i), key) + .env(format!("GIT_CONFIG_VALUE_{}", n + i), value); + } + command.env("GIT_CONFIG_COUNT", (n + off.len()).to_string()); + let git = self.dir.join(".git"); + Ok(Box::new(move || { + let Some(clone) = clone else { + return Ok(()); + }; + restore(&git, &clone).map_err(|e| { + let _ = fs::remove_dir_all(&clone); + io::Error::new( + e.kind(), + format!(".git: what was changed in it is lost: {e}"), + ) + }) + })) + } + + /// Clones `.git` to where the command cannot write, and returns where, + /// if its configuration is safe for git to run with. If the directory + /// was not a repository, there is none. + fn clone_git(&self) -> io::Result> { + let Some(original) = &self.config else { + return Ok(None); + }; + let git = self.dir.join(".git"); + if !git.symlink_metadata().is_ok_and(|meta| meta.is_dir()) { + return Err(io::Error::other(format!( + "{}: no longer a directory", + git.display() + ))); + } + let n = self.clones.fetch_add(1, Ordering::Relaxed); + let clone = self.private.join(format!("{n}.git")); + clonefile(&git, &clone).map_err(|e| at(&clone, e))?; + let checked = check(&clone, original); + if checked.is_err() { + let _ = fs::remove_dir_all(&clone); + } + checked.map(|()| Some(clone)) + } +} + +/// Puts `clone` in the place of `git`. +fn restore(git: &Path, clone: &Path) -> io::Result<()> { + let old = clone.with_extension("old"); + fs::rename(git, &old)?; + if let Err(e) = fs::rename(clone, git) { + let _ = fs::rename(&old, git); + return Err(e); + } + fs::remove_dir_all(&old) +} + +/// Checks that the configuration of `git` differs from `original` in +/// nothing but what `gh` and `git push` write, and that it does not name +/// another directory to take it from. +fn check(git: &Path, original: &HashSet>) -> io::Result<()> { + if git.join("commondir").symlink_metadata().is_ok() { + return Err(io::Error::other( + ".git/commondir: made by the command, so it is not run", + )); + } + for entry in config(git)? { + if !original.contains(&entry) && !written_by_gh(&entry) { + let entry = String::from_utf8_lossy(&entry); + let key = entry.split('\n').next().unwrap_or_default(); + return Err(io::Error::other(format!( + ".git/config: {key} changed by the command, so it is not run" + ))); + } + } + Ok(()) +} + +/// The entries of the configuration of `git`, each a key and its value, as +/// `git config --list` gives them, from `config` and `config.worktree`. +fn config(git: &Path) -> io::Result>> { + let mut entries = HashSet::new(); + for file in ["config", "config.worktree"] { + let path = git.join(file); + if path.symlink_metadata().is_err() { + continue; + } + // Run outside the repository, so that git reads only the file. + let out = Command::new("git") + .current_dir("/") + .args(["config", "--no-includes", "--null", "--list", "--file"]) + .arg(&path) + .stderr(Stdio::piped()) + .output()?; + if !out.status.success() { + return Err(io::Error::other(format!( + "{}: {}", + path.display(), + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + entries.extend( + out.stdout + .split(|&b| b == 0) + .filter(|entry| !entry.is_empty()) + .map(<[u8]>::to_vec), + ); + } + Ok(entries) +} + +/// Whether `entry`, a key and its value, is one that `gh` or `git push` +/// write: the branch a branch is pushed to, and the remotes `gh` adds and +/// marks, by URLs that run nothing. +fn written_by_gh(entry: &[u8]) -> bool { + let entry = String::from_utf8_lossy(entry); + let (key, value) = entry.split_once('\n').unwrap_or((&entry, "")); + let (Some((section, _)), Some((_, name))) = (key.split_once('.'), key.rsplit_once('.')) else { + return false; + }; + match (section, name) { + ("branch", "remote" | "merge" | "pushremote" | "gh-merge-base") => true, + ("remote", "fetch" | "gh-resolved") => true, + ("remote", "url" | "pushurl") => ["https://", "ssh://", "git@"] + .iter() + .any(|scheme| value.starts_with(scheme)), + _ => false, + } +} + +/// Clones the directory tree at `from` to `to`, sharing its blocks. +fn clonefile(from: &Path, to: &Path) -> io::Result<()> { + let from = std::ffi::CString::new(from.as_os_str().as_bytes())?; + let to = std::ffi::CString::new(to.as_os_str().as_bytes())?; + // `CLONE_NOFOLLOW`: symbolic links are cloned as links. + if unsafe { libc::clonefile(from.as_ptr(), to.as_ptr(), 0x0001) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_only_what_gh_writes() { + assert!(written_by_gh(b"branch.fix/a.b.remote\norigin")); + assert!(written_by_gh( + b"remote.fork.url\nhttps://github.com/a/b.git" + )); + assert!(!written_by_gh(b"remote.fork.url\n/tmp/x")); + assert!(!written_by_gh(b"remote.origin.uploadpack\nsh -c x")); + assert!(!written_by_gh(b"core.pager\nsh -c x")); + assert!(!written_by_gh(b"include.path\n/tmp/x")); + } +}