diff --git a/README.md b/README.md index 9e838c5..f8e729d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ 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, 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. + 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..fa4efdc --- /dev/null +++ b/cmd/confine/broker.rs @@ -0,0 +1,617 @@ +//! 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 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::OsString, + fs::{self, DirBuilder, File, OpenOptions}, + io::{self, Read, Write}, + mem, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + unix::{ + ffi::{OsStrExt, OsStringExt}, + fs::{DirBuilderExt, PermissionsExt, symlink}, + net::{UnixListener, UnixStream}, + process::{CommandExt, ExitStatusExt}, + }, + }, + path::{Path, PathBuf}, + process::{self, Command, ExitCode, Stdio}, + ptr, + sync::{Arc, Mutex}, + thread, +}; + +use crate::{ + at, + tools::{Policy, Repo, TOOLS, Tool}, +}; + +/// The variable that tells the shim where the broker's socket is. +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"; + +/// 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 tools for a confined command, until it is dropped. +pub struct Broker { + /// 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. + private: PathBuf, +} + +/// What the broker knows, which each request it serves shares. +struct State { + /// The tools that are installed, and where. + tools: Vec<(&'static dyn Tool, PathBuf)>, + /// The directory the command is confined to. + dir: PathBuf, + 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. +enum Answer { + Deny, + Once, + Always, +} + +impl Broker { + /// 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 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 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)?; + 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"))?; + for (tool, _) in &tools { + symlink("../confine", broker.shared.join("bin").join(tool.name()))?; + } + let listener = UnixListener::bind(broker.socket())?; + let state = Arc::new(State { + tools, + dir: dir.to_path_buf(), + repo, + allowed: home.join(ALLOWED), + asking: Mutex::new(()), + }); + 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 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())); + 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 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 { + 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 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!( + "{}: {} runs only in {}", + cwd.display(), + tool.name(), + self.dir.display() + ))); + } + let Some(args) = args + .iter() + .map(|arg| arg.to_str()) + .collect::>>() + else { + return Err(io::Error::other(format!( + "{}'s arguments must be UTF-8", + tool.name() + ))); + }; + 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 [stdin, stdout, stderr] = stdio; + let mut command = Command::new(path); + command + .args(&args) + .current_dir(&cwd) + .stdin(stdin) + .stdout(stdout) + .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 { + command.pre_exec(|| { + for signal in crate::SIGNALS { + libc::signal(signal, libc::SIG_DFL); + } + Ok(()) + }); + } + 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 + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(0))) + } + + /// 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); + } + let question = format!( + "The command confined to {} wants to run:\n\n{line}\n\nin {}", + self.dir.display(), + cwd.display() + ); + Ok(match ask(&question, tool, line, &self.dir, cwd, always)? { + Answer::Deny => false, + Answer::Once => true, + Answer::Always => { + if let Some(always) = always { + remember(&self.allowed, always)?; + } + true + } + }) + } +} + +/// 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()) +} + +/// 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))?; + } + 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)) +} + +/// 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()?, + }; + Ok(match String::from_utf8_lossy(&out.stdout).trim() { + "once" => Answer::Once, + "always" if always.is_some() => Answer::Always, + "always" => Answer::Once, + _ => Answer::Deny, + }) +} + +/// `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(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); + 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 a shim: `confine` run under a tool's name by a +/// confined command. +pub fn is_shim() -> bool { + env::var_os(SOCKET).is_some() + && shim_name().is_some_and(|name| TOOLS.iter().any(|tool| tool.name() == name)) +} + +/// 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 { + let name = shim_name().unwrap_or_default(); + match request(&name) { + Ok(status) => ExitCode::from(status as u8), + Err(e) => { + eprintln!("{}: confine: {e}", name.to_string_lossy()); + ExitCode::FAILURE + } + } +} + +/// 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![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]; + 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 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)); + } + } + 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) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_what_is_not_plain() { + assert_eq!( + quoted("gh", &["pr", "create", "--title", "it's", "a\nb", ""]), + r"gh pr create --title 'it'\''s' $'a\nb' ''" + ); + } + + #[test] + 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 fda57ce..383a65f 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 "ASKING TO RUN OUTSIDE THE SANDBOX" . +.TP .BI \-d " dir" "\fR, \fP\-\-dir " dir Run the command in .I dir @@ -149,6 +160,85 @@ 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 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 +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 +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 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" , +.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 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 @@ -160,6 +250,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 +311,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..358fdd4 100644 --- a/cmd/confine/confine.rs +++ b/cmd/confine/confine.rs @@ -3,10 +3,14 @@ //! 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")] mod linux; +#[cfg(target_os = "macos")] +mod tools; #[cfg(target_os = "macos")] use darwin as os; @@ -17,7 +21,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 +86,11 @@ struct Args { #[argh(switch)] rm: bool, + /// 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, + /// the command to run and its arguments; the shell if none #[argh(positional, greedy)] command: Vec, @@ -94,6 +105,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 +160,53 @@ 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: cannot ask to run tools outside the sandbox: {e}"); + None + }) + }; + #[cfg(target_os = "macos")] + let env = broker + .as_ref() + .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 = { + let _ = args.headless; + 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 +214,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..7eec18e 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -302,6 +302,216 @@ 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("not allowed"), "{}", 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 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] +#[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 it 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 { 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")); + } +}