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
2 changes: 1 addition & 1 deletion docs/enforcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ does not show you where.
| **Write** | denied outside `-v` mounts, `/tmp`, `/dev` | denied outside `-v` mounts, `/tmp`, `/dev` |
| **Inside a writable mount** | the existing repository's `.git/hooks` and `.git/config`, and the names the preset protects (shell rc files, `.gitconfig`, `.mcp.json`, `.envrc`, `.claude/commands`, `.vscode`, `porta.toml`, …) stay unwritable; the mount root and those paths cannot be renamed away | same, each bind-mounted read-only onto itself in the command's mount namespace; where the host refuses one, not protected (the run says so) |
| **Read, default** | what the preset closes denied — by default credential stores: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config/gh`, `~/.config/gcloud`, `~/.docker`, `~/.kube`, `~/.netrc`, Keychains, browser profiles, …; everything else readable | same, each covered by an empty mount in the command's mount namespace; where the host refuses one, Landlock grants reads everywhere else, and a closed path inside a grant (`-v ~`, `/tmp`) refuses the run |
| **Environment** | empty, plus `PATH` `HOME` `USER` `LOGNAME` `SHELL` `TERM` `COLORTERM` `LANG` `LANGUAGE` `LC_*` `TZ`, `-e` and `--env-pass`; and `SSL_CERT_FILE=/etc/ssl/cert.pem`, since the Keychain a tool would list its roots from is closed | same, without `SSL_CERT_FILE`; under `--allow-net`, `RES_OPTIONS=use-vc` |
| **Environment** | empty, plus `PATH` `HOME` `USER` `LOGNAME` `SHELL` `TERM` `COLORTERM` `LANG` `LANGUAGE` `LC_*` `TZ`, `-e` and `--env-pass`; `TMPDIR=/tmp`, the temporary directory the run is granted (a program without it asks the OS and gets the closed per-user `/var/folders/…/T`); and `SSL_CERT_FILE=/etc/ssl/cert.pem`, since the Keychain a tool would list its roots from is closed | same, with `TMPDIR=/tmp` and without `SSL_CERT_FILE`; under `--allow-net`, `RES_OPTIONS=use-vc` |
| **Other processes** | their arguments and environment unreadable (`procargs`, `proc_pidinfo`); signals to them not restricted | invisible: the command runs in PID, mount and user namespaces of its own with a fresh `/proc`, where the host allows unprivileged user namespaces (otherwise porta says so and only `strict` closes `/proc`); signals and abstract sockets scoped to the sandbox on Landlock ABI 6 |
| **Host facilities** | Keychain, `open(1)`/Launch Services, mounting, disk and packet devices, Apple Events, network-share agents closed | `ptrace`, `process_vm_*`, `pidfd_getfd`, `mount*`, `unshare`/`setns`/`clone(CLONE_NEW*)`, `bpf`, `perf_event_open`, `userfaultfd`, `keyctl`, `io_uring`, `clone3`, `execveat(AT_EMPTY_PATH)`, kernel modules, `TIOCSTI` refused by seccomp in every mode |
| **Read, `--read-policy strict`** | your mounts plus `/usr`, `/System`, `/bin`, `/sbin`, `/etc`, `/tmp`, `/dev` | your mounts plus `/usr`, `/lib`, `/bin`, `/sbin`, `/tmp`, `/dev`, and under `/etc` only the files a command needs to start (loader cache, resolver, trust store, `passwd`, `localtime`…) — never `shadow`, `sudoers` or the host keys, and not the listing |
Expand Down
10 changes: 8 additions & 2 deletions native/sandbox_exec/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ use super::*;
/// Host variables a child keeps. Everything else the caller's shell holds —
/// API keys, tokens, the SSH agent's socket — stays outside unless `-e` or
/// `--env-pass` names it. A locale, a terminal and a path are what a command
/// needs to start; a credential is not. `TMPDIR` is left out on purpose: the
/// sandbox's temporary directory is `/tmp`, the one it is granted.
/// needs to start; a credential is not. The caller's `TMPDIR` is left out on
/// purpose: the sandbox's temporary directory is `/tmp`, the one it is granted,
/// and `bare_command` says so to the child (almide/porta#38).
pub(super) const INHERITED_ENV: [&str; 10] =
["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TERM", "COLORTERM", "LANG", "LANGUAGE", "TZ"];

Expand Down Expand Up @@ -62,6 +63,11 @@ impl SandboxRequest {
// bundle stands in for them. `-e` can override it.
#[cfg(target_os = "macos")]
command.env("SSL_CERT_FILE", "/etc/ssl/cert.pem");
// The temporary directory the sandbox grants. Without it a program
// asks the OS: Rust's `std::env::temp_dir` on macOS takes the per-user
// `/var/folders/…/T`, which stays closed, so every temporary file it
// made was refused (#38). `-e TMPDIR=…` overrides it.
command.env("TMPDIR", "/tmp");
#[cfg(target_os = "linux")]
if self.egress() == crate::seccomp::Egress::TcpPorts {
command.env(RESOLVER_OVER_TCP.0, RESOLVER_OVER_TCP.1);
Expand Down
3 changes: 3 additions & 0 deletions native/sandbox_exec/explain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ impl SandboxRequest {
let mut inherited: Vec<&str> = INHERITED_ENV.iter().copied().filter(|key| std::env::var_os(key).is_some()).collect();
let named: Vec<&str> = self.env_vars.iter().map(|(key, _)| key.as_str()).collect();
inherited.extend(named.iter().copied());
if !named.contains(&"TMPDIR") {
inherited.push("TMPDIR=/tmp");
}
text.push_str(&format!("environment {}\n", inherited.join(" ")));
text.push_str(&self.explain_enforcement());
text
Expand Down
9 changes: 9 additions & 0 deletions scripts/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,15 @@ def denied(attempt):
assert '--allow-net' in result.stderr, result.stderr
print('PASS: the host environment stays outside unless named; --allow-bind needs --allow-net')

# The temporary directory is the one the sandbox grants, and the child is
# told so: a program that asks the OS instead (Rust's temp_dir on macOS)
# would reach the per-user directory porta closes (#38). -e overrides it.
result = run('run', '/bin/sh', '--', '-c', 'echo "tmp=$TMPDIR"; python3 -c "import tempfile; tempfile.NamedTemporaryFile()" && echo made', env={**os.environ, 'TMPDIR': '/var/folders/x/T/'})
assert result.returncode == 0 and result.stdout.split() == ['tmp=/tmp', 'made'], result
result = run('run', '/bin/sh', '-e', 'TMPDIR=/tmp/own', '--', '-c', 'echo "$TMPDIR"')
assert result.returncode == 0 and result.stdout.strip() == '/tmp/own', result
print('PASS: the child is told its temporary directory is /tmp, and -e overrides it')

# What a shell sees. The command's own exit code passes through in every
# mode; a run porta refused exits with porta's own code, so a script can
# tell "the command failed" from "the command never ran".
Expand Down
Loading