diff --git a/docs/enforcement.md b/docs/enforcement.md index 0f8ddb2..bdd310b 100644 --- a/docs/enforcement.md +++ b/docs/enforcement.md @@ -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 | diff --git a/native/sandbox_exec/command.rs b/native/sandbox_exec/command.rs index fa3f23f..d49de7f 100644 --- a/native/sandbox_exec/command.rs +++ b/native/sandbox_exec/command.rs @@ -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"]; @@ -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); diff --git a/native/sandbox_exec/explain.rs b/native/sandbox_exec/explain.rs index a338138..34bb780 100644 --- a/native/sandbox_exec/explain.rs +++ b/native/sandbox_exec/explain.rs @@ -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 diff --git a/scripts/integration.py b/scripts/integration.py index 9ca2ae1..3702415 100644 --- a/scripts/integration.py +++ b/scripts/integration.py @@ -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".