diff --git a/README.md b/README.md index e312281..c90f900 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,16 @@ outside the fork cannot bring that file into the patch. directory given with `-d`, which the command may write to, along with temporary directories, devices, build and package manager caches (`~/.cargo`, `~/.rustup`, `~/.cache`, `~/.npm`, `~/Library/Caches`), and -paths given with `-w`. It cannot read `~/.ssh`, `~/.aws`, `~/.gnupg`, or -`~/.config/gh`. Everything else is allowed. `confine` then becomes the command, which exits with its -own status; with `--rm`, it waits for the command instead, removes the -fork, and exits as the command did. +paths given with `-w`. In the home directory, it can read only those, paths +given with `-r`, and what commands need to run: git's configuration, +`~/.claude.json`, toolchains such as `~/.local/bin` and `~/.nvm`, and on +macOS the keychains, whose items the keychain itself guards. So secrets, +such as `~/.ssh`, `~/.aws`, `~/.netrc`, or tokens exported in a shell's +startup files, are kept from the command without having to know where each +one is. Everything else, outside the home directory, is allowed. `confine` +then becomes the command, which exits with its own status; with `--rm`, it +waits for the command instead, removes the fork, and exits as the command +did. It sets `CLAUDE_CODE_SANDBOXED=1`, so that Claude Code trusts the directory without asking, which would write to `~/.claude.json`. @@ -124,9 +130,9 @@ How the command is confined depends on the platform. The command runs under Seatbelt, the macOS sandbox, through `sandbox-exec`. `confine` writes a profile that allows everything, then denies writing -everywhere but the writable paths, and denies listing, reading, and writing -the hidden paths. Since later rules win, the directory, which may lie among -the hidden paths, is allowed last. The profile gives the paths as +everywhere but the writable paths, and denies listing and reading the home +directory but the readable and writable paths. Since later rules win, the +directory, which may lie in the home directory, is allowed last. The profile gives the paths as parameters rather than in the text, and Seatbelt matches them after resolving symbolic links, so they cannot be escaped by quoting or linking. @@ -144,16 +150,16 @@ unconfined. Landlock can only grant rights, to a file and everything under it, and cannot take them away from something inside. So rather than denying the -hidden paths, `confine` grants the rights to what is around them: it lists -each directory above a hidden path, and grants reading to every entry in it -but that path, and so on down. Writing is granted the same way, to the -writable paths, and then reading and writing to the directory itself. -Because of this, on Linux: - -- The command can list the files in a hidden directory, such as the names - of the keys in `~/.ssh`, but cannot read them. -- Files made in a directory above a hidden path after the command starts, - such as a new file in the home directory, cannot be read. +home directory, `confine` grants reading to what is around it: it lists +each directory above it, and grants reading to every entry in it but the +one the home directory is in, and so on down. Then it grants reading to +the readable paths, reading and writing to the writable ones, and both to +the directory itself. Because of this, on Linux: + +- The command can list the files in any directory, such as the names of + the keys in `~/.ssh`, but cannot read them. +- Files made in a directory above the home directory after the command + starts, such as a new home directory in `/home`, cannot be read. - The command cannot run set-user-ID programs such as `sudo`, as Landlock confines a process without privileges only if it can gain none. @@ -169,6 +175,9 @@ keychain or an SSH agent. Commits made in a fork are not signed if signing needs `~/.gnupg` or `~/.ssh`, which the command cannot read. +What the command may write to, it may read, so secrets kept in a writable +path can be read, such as a registry token in `~/.cargo/credentials.toml`. + ## License MIT diff --git a/cmd/confine/confine.1 b/cmd/confine/confine.1 index 1826ca8..fda57ce 100644 --- a/cmd/confine/confine.1 +++ b/cmd/confine/confine.1 @@ -5,6 +5,8 @@ confine \- run a command that may write only to a fork of a directory .B confine .RB [ \-w .IR path ]... +.RB [ \-r +.IR path ]... .RB [ \-\-rm | \-d .IR dir ] .RI [ command @@ -24,7 +26,8 @@ in the fork, or your shell or .B /bin/sh if it is not set) if no command is given, confined: the command may write -only to the fork, and may not read secrets. +only to the fork, and may read in your home directory only what it needs, +and so no secrets. Given a directory with .BR \-d , it runs the command in that directory, confined to it, and makes no fork. @@ -72,8 +75,14 @@ It cannot be given with .BR \-d , as there is no fork to remove. .TP +.BI \-r " path" "\fR, \fP\-\-read " path +Let the command read +.I path +too, and everything under it, even in the home directory. +Can be repeated. +.TP .BI \-w " path" "\fR, \fP\-\-write " path -Let the command write to +Let the command read and write to .I path too, and to everything under it. Can be repeated. @@ -102,15 +111,42 @@ and the paths given with may be written to. .TP Reading -The contents of +Nothing in the home directory can be read but what may be written to, the +paths given with +.BR \-r , +and what commands need to run: git's configuration +.RB ( ~/.gitconfig +and +.BR ~/.config/git ), +Claude Code's +.RB ( ~/.claude.json ), +toolchains and the commands installed with them +.RB ( ~/.local/bin , +.BR ~/.local/lib , +.BR ~/.local/share/claude , +.BR ~/.nvm , +.BR ~/.volta , +.BR ~/.bun , +.BR ~/.deno , +.B ~/.pyenv +and +.BR ~/.rbenv ), +and, on macOS, +.B ~/.CFUserTextEncoding +and the keychains in +.BR ~/Library/Keychains , +whose items the keychain itself guards. +So secrets, such as those in .BR ~/.ssh , .BR ~/.aws , -.B ~/.gnupg -and -.B ~/.config/gh -cannot be read. -On Linux, their files can still be listed, and files made in the -directories above them after the command starts cannot be read. +.BR ~/.netrc , +or a shell's startup files, cannot be read, wherever in it they are. +On macOS, the directories above the fork, or +.IR dir , +can be listed. +On Linux, every directory can be listed, though the files in it cannot be +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 ENVIRONMENT diff --git a/cmd/confine/confine.rs b/cmd/confine/confine.rs index c7b1d5d..8db0fa2 100644 --- a/cmd/confine/confine.rs +++ b/cmd/confine/confine.rs @@ -1,7 +1,7 @@ //! `confine` runs a command confined to a directory, a fork of the working //! directory unless it is given one: the command may write there, to -//! temporary directories, and to caches, but nowhere else, and may not read -//! secrets such as SSH keys. +//! 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 darwin; @@ -29,22 +29,48 @@ use argh::FromArgs; /// that builds and package managers keep working. const CACHES: &[&str] = &[".cargo", ".rustup", ".cache", ".npm", "Library/Caches"]; -/// Directories under the home directory that the command may not read. -const SECRETS: &[&str] = &[".ssh", ".aws", ".gnupg", ".config/gh"]; +/// Paths under the home directory that the command may read. It may read +/// nothing else there but what it may write to, so that secrets are kept +/// from it without knowing where each one is. +const READABLE: &[&str] = &[ + // Configuration. + ".gitconfig", + ".config/git", + ".claude.json", + ".CFUserTextEncoding", + // Toolchains, and the commands installed with them. + ".local/bin", + ".local/lib", + ".local/share/claude", + ".nvm", + ".volta", + ".bun", + ".deno", + ".pyenv", + ".rbenv", + // The macOS keychains, whose items the keychain itself guards, and which + // Claude Code keeps its login in. + "Library/Keychains", +]; /// The terminal's signals, which reach the command too, and which `confine` /// outlives, with `--rm`, to remove the fork. const SIGNALS: [i32; 3] = [libc::SIGINT, libc::SIGQUIT, libc::SIGHUP]; /// Run a command in a fork of the working directory, or in a directory, that -/// may write only there, to temporary directories and caches, and may not -/// read secrets. +/// may write only there, to temporary directories and caches, and may read +/// in the home directory only what it needs. #[derive(FromArgs)] struct Args { /// another path the command may write to; can be repeated #[argh(option, short = 'w')] write: Vec, + /// another path the command may read, in the home directory; can be + /// repeated + #[argh(option, short = 'r')] + read: Vec, + /// the directory the command runs in and may write to, rather than a /// fork of the working directory #[argh(option, short = 'd')] @@ -105,11 +131,15 @@ fn confine(args: &Args) -> io::Result { for path in &args.write { writable.push(path.canonicalize().map_err(|e| at(path, e))?); } - let hidden: Vec = SECRETS + let mut readable: Vec = READABLE .iter() - .map(|secret| home.join(secret)) + .map(|path| home.join(path)) .filter_map(|path| path.canonicalize().ok()) .collect(); + for path in &args.read { + readable.push(path.canonicalize().map_err(|e| at(path, e))?); + } + let hidden: Vec = home.canonicalize().into_iter().collect(); let command = if args.command.is_empty() { vec![env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())] } else { @@ -123,11 +153,11 @@ fn confine(args: &Args) -> io::Result { dir.display() ))); } - return run(&command, &dir, &writable, &hidden, false); + 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, &hidden, args.rm); + let ran = run(&command, &fork, &writable, &readable, &hidden, args.rm); if (args.rm || !matches!(ran, Ok(Ran::Exited(_)))) && let Err(e) = fsfork::remove(&fork) { @@ -142,10 +172,11 @@ fn run( command: &[String], dir: &Path, writable: &[PathBuf], + readable: &[PathBuf], hidden: &[PathBuf], wait: bool, ) -> io::Result { - let mut confined = os::confine(command, dir, writable, hidden)?; + 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"); diff --git a/cmd/confine/darwin.rs b/cmd/confine/darwin.rs index 97632e2..0b403d2 100644 --- a/cmd/confine/darwin.rs +++ b/cmd/confine/darwin.rs @@ -7,9 +7,10 @@ use std::{ }; /// A command that runs `command` under Seatbelt: it may write only under -/// `writable` and to devices, and may not list, read, or write anything -/// under `hidden` except `dir`, which it may read and write. Everything -/// else, the GPU and the network included, is allowed. +/// `writable` and to devices, and may not list or read anything under +/// `hidden` but what is under `readable` or `writable`, and `dir`, which it +/// may read and write. Everything else, the GPU and the network included, is +/// allowed. /// /// The paths are passed as parameters rather than written into the profile, /// so they need no quoting, and must be canonical, as Seatbelt matches the @@ -18,33 +19,30 @@ pub fn confine( command: &[String], dir: &Path, writable: &[PathBuf], + readable: &[PathBuf], hidden: &[PathBuf], ) -> io::Result { let mut profile = String::from("(version 1)\n(allow default)\n(deny file-write*)\n"); let mut params = Vec::new(); - profile.push_str("(allow file-write* (subpath \"/dev\")"); - for (i, path) in writable.iter().enumerate() { - profile.push_str(&format!(" (subpath (param \"W{i}\"))")); - params.push(format!("W{i}={}", path.display())); - } - profile.push_str(")\n"); + let writable = filters(&mut params, "W", "subpath", writable); + profile.push_str(&format!( + "(allow file-write* (subpath \"/dev\"){writable})\n" + )); // Later rules win, so the hidden paths are denied after the writable - // ones, and the directory, which may be under one of them, is allowed - // last. Their metadata stays readable, and so do the directories above - // the directory, which may be hidden, but which resolving a path in the - // directory stats and `getcwd` lists. + // ones are allowed, what may be read under them is allowed after, and + // the directory, which may be under them too, last. Their metadata stays + // readable, and so do the directories above the directory, which + // resolving a path in the directory stats and `getcwd` lists. if !hidden.is_empty() { - profile.push_str("(deny file-read-data file-write*"); - for (i, path) in hidden.iter().enumerate() { - profile.push_str(&format!(" (subpath (param \"H{i}\"))")); - params.push(format!("H{i}={}", path.display())); - } - profile.push_str(")\n(allow file-read-data"); - for (i, path) in dir.ancestors().skip(1).enumerate() { - profile.push_str(&format!(" (literal (param \"A{i}\"))")); - params.push(format!("A{i}={}", path.display())); + let hidden = filters(&mut params, "H", "subpath", hidden); + profile.push_str(&format!("(deny file-read-data{hidden})\n")); + let ancestors: Vec = dir.ancestors().skip(1).map(Path::to_path_buf).collect(); + let ancestors = filters(&mut params, "A", "literal", &ancestors); + let readable = filters(&mut params, "R", "subpath", readable); + let allowed = format!("{ancestors}{readable}{writable}"); + if !allowed.is_empty() { + profile.push_str(&format!("(allow file-read-data{allowed})\n")); } - profile.push_str(")\n"); } // A rule for `file-read-data` wins over one for `file-read*` whatever // their order, so it is allowed by name. @@ -58,3 +56,15 @@ pub fn confine( sandbox.arg("--").args(command); Ok(sandbox) } + +/// The filters, each starting with a space, that match `paths` with `kind`, +/// `subpath` or `literal`, given as parameters named `prefix` and their +/// index, which are added to `params`. +fn filters(params: &mut Vec, prefix: &str, kind: &str, paths: &[PathBuf]) -> String { + let mut filters = String::new(); + for (i, path) in paths.iter().enumerate() { + filters.push_str(&format!(" ({kind} (param \"{prefix}{i}\"))")); + params.push(format!("{prefix}{i}={}", path.display())); + } + filters +} diff --git a/cmd/confine/linux.rs b/cmd/confine/linux.rs index c48ca38..cad43a4 100644 --- a/cmd/confine/linux.rs +++ b/cmd/confine/linux.rs @@ -70,19 +70,20 @@ struct PathBeneathAttr { } /// A command that runs `command` under Landlock: it may write only under -/// `writable` and to devices, and may not read or write anything under -/// `hidden` except `dir`, which it may read and write. Everything else, the -/// network included, is allowed. +/// `writable` and to devices, and may not read anything under `hidden` but +/// what is under `readable` or `writable`, and `dir`, which it may read and +/// write. Everything else, the network included, is allowed. /// /// Landlock only grants rights, to a file and everything under it, so the -/// command is granted the rights to what is around the hidden paths rather -/// than denied those to the hidden paths: what is in a directory above them -/// when the command starts. The paths must be canonical, as Landlock grants +/// command is granted reading what is around the hidden paths rather than +/// denied reading the hidden paths: what is in a directory above them when +/// the command starts. The paths must be canonical, as Landlock grants /// rights to the file a path resolves to. pub fn confine( command: &[String], dir: &Path, writable: &[PathBuf], + readable: &[PathBuf], hidden: &[PathBuf], ) -> io::Result { let abi = unsafe { @@ -123,8 +124,11 @@ pub fn confine( }; ruleset.allow_except_hidden(Path::new("/"), READ)?; ruleset.allow_except_hidden(Path::new("/dev"), WRITE)?; + for path in readable { + ruleset.allow(path, READ)?; + } for path in writable { - ruleset.allow_except_hidden(path, WRITE)?; + ruleset.allow(path, READ | WRITE)?; } ruleset.allow(dir, READ | WRITE)?; let fd = ruleset.fd; @@ -150,7 +154,7 @@ struct Ruleset<'a> { fd: OwnedFd, /// The rights the ruleset handles: those it denies unless granted. handled: u64, - /// The paths no rights are granted under. + /// The paths no rights are granted under, but by name. hidden: &'a [PathBuf], } diff --git a/cmd/confine/tests/confine.rs b/cmd/confine/tests/confine.rs index 48b8e73..5e03006 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -206,7 +206,7 @@ fn the_command_writes_to_temporary_directories_and_caches() { } #[test] -fn the_command_cannot_read_secrets() { +fn the_command_reads_only_what_it_needs_in_the_home_directory() { need_sandbox!(); let world = World::new(); let dir = world.dir("project"); @@ -216,15 +216,59 @@ fn the_command_cannot_read_secrets() { ".aws/credentials", ".gnupg/key", ".config/gh/hosts.yml", + ".netrc", + ".npmrc", + ".docker/config.json", + ".config/other", + "notes.txt", ] { write(&home, secret, "secret\n"); let out = world.confine(&dir, &["cat", home.join(secret).to_str().unwrap()]); assert!(!out.status.success(), "read {secret}"); assert!(!stdout(&out).contains("secret")); } - write(&home, ".config/other", "readable\n"); - let out = world.confine(&dir, &["cat", home.join(".config/other").to_str().unwrap()]); - assert_eq!(stdout(&out), "readable\n"); + for readable in [".gitconfig", ".config/git/ignore", ".cargo/config.toml"] { + write(&home, readable, "readable\n"); + let out = world.confine(&dir, &["cat", home.join(readable).to_str().unwrap()]); + assert_eq!(stdout(&out), "readable\n", "{readable}: {}", stderr(&out)); + } +} + +#[test] +fn the_command_reads_paths_given_with_r() { + need_sandbox!(); + let world = World::new(); + let dir = world.dir("project"); + let home = world.home(); + write(&home, ".npmrc", "readable\n"); + write(&home, "notes/a", "readable\n"); + let out = world.confine( + &dir, + &[ + "-r", + home.join(".npmrc").to_str().unwrap(), + "--read", + home.join("notes").to_str().unwrap(), + "sh", + "-c", + "cat ~/.npmrc ~/notes/a && ! echo x > ~/notes/b", + ], + ); + assert!(out.status.success(), "{}", stderr(&out)); + assert_eq!(stdout(&out), "readable\nreadable\n"); + assert!(!home.join("notes/b").exists()); +} + +#[test] +fn the_command_reads_the_directory_under_the_home_directory() { + need_sandbox!(); + let world = World::new(); + let dir = world.home().join("src/project"); + write(&dir, "a", "a\n"); + let out = world.confine(&dir, &["sh", "-c", "pwd && cat a"]); + assert!(out.status.success(), "{}", stderr(&out)); + let fork = world.home().join("src/project.1"); + assert_eq!(stdout(&out), format!("{}\na\n", fork.display())); } #[test] @@ -246,6 +290,10 @@ fn fails_for_paths_that_do_not_exist() { &world.confine(&dir, &["-w", missing.to_str().unwrap(), "true"]), "missing", ); + assert_fails( + &world.confine(&dir, &["-r", missing.to_str().unwrap(), "true"]), + "missing", + ); write(&dir, "file", ""); assert_fails( &world.confine(&dir, &["-d", "file", "true"]),