Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.

Expand All @@ -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.

Expand All @@ -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
54 changes: 45 additions & 9 deletions cmd/confine/confine.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
53 changes: 42 additions & 11 deletions cmd/confine/confine.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<PathBuf>,

/// another path the command may read, in the home directory; can be
/// repeated
#[argh(option, short = 'r')]
read: Vec<PathBuf>,

/// the directory the command runs in and may write to, rather than a
/// fork of the working directory
#[argh(option, short = 'd')]
Expand Down Expand Up @@ -105,11 +131,15 @@ fn confine(args: &Args) -> io::Result<Ran> {
for path in &args.write {
writable.push(path.canonicalize().map_err(|e| at(path, e))?);
}
let hidden: Vec<PathBuf> = SECRETS
let mut readable: Vec<PathBuf> = 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<PathBuf> = home.canonicalize().into_iter().collect();
let command = if args.command.is_empty() {
vec![env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())]
} else {
Expand All @@ -123,11 +153,11 @@ fn confine(args: &Args) -> io::Result<Ran> {
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)
{
Expand All @@ -142,10 +172,11 @@ fn run(
command: &[String],
dir: &Path,
writable: &[PathBuf],
readable: &[PathBuf],
hidden: &[PathBuf],
wait: bool,
) -> io::Result<Ran> {
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");
Expand Down
56 changes: 33 additions & 23 deletions cmd/confine/darwin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,33 +19,30 @@ pub fn confine(
command: &[String],
dir: &Path,
writable: &[PathBuf],
readable: &[PathBuf],
hidden: &[PathBuf],
) -> io::Result<Command> {
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<PathBuf> = 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.
Expand All @@ -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<String>, 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
}
20 changes: 12 additions & 8 deletions cmd/confine/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Command> {
let abi = unsafe {
Expand Down Expand Up @@ -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;
Expand All @@ -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],
}

Expand Down
Loading
Loading