From b0cf3ccad5275475134a7a5c6ae9e6f507321bf4 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sun, 27 Sep 2026 22:55:37 +0900 Subject: [PATCH] Tell the child its temporary directory is /tmp The child's environment starts empty, and the caller's TMPDIR stays out on purpose: on macOS it is the per-user /var/folders/.../T, which the profile keeps closed. But a program with no TMPDIR asks the OS instead, and Rust's std::env::temp_dir on macOS gets that same closed directory. So every temporary file an Almide or Rust program made under porta was refused. golemide's solve failed all six attempts that way inside onogoro. The child now gets TMPDIR=/tmp, the temporary directory the run is granted, on both platforms; -e TMPDIR=... still overrides it. explain lists it, and an integration test checks both. Closes #38 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/enforcement.md | 2 +- native/sandbox_exec/command.rs | 10 ++++++++-- native/sandbox_exec/explain.rs | 3 +++ scripts/integration.py | 9 +++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) 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".