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
17 changes: 13 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,19 @@ jobs:
- name: Integration and escape corpus with user namespaces
if: matrix.os == 'ubuntu-latest'
run: |
sudo bash scripts/apparmor-userns.sh target/porta
target/porta check | grep -E '^ok +own PID, mount and network namespace'
python3 scripts/integration.py target/porta
python3 scripts/escapes.py target/porta
# The build sits in the checkout, which the runner's user can write;
# the helper script refuses it, and porta setup makes a root-owned
# copy with the profile for that copy alone.
! sudo bash scripts/apparmor-userns.sh target/porta
# The runner's /usr/local/bin is not root's alone, so setup puts the
# copy in /usr/libexec/porta and links /usr/local/bin/porta to it.
sudo target/porta setup
set_up=$(readlink -f /usr/local/bin/porta)
"$set_up" check | grep -E '^ok +own PID, mount and network namespace'
python3 scripts/integration.py "$set_up"
python3 scripts/escapes.py "$set_up"
sudo "$set_up" setup --undo
! ls /etc/apparmor.d/*.porta 2>/dev/null
# The seeds that found each fixed bug, so the fix stays fixed, then one
# fresh seed per push, printed, so a new find can be replayed.
- name: Fuzz
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ as a definition does.
| `pid_namespace.rs` + `pid_namespace/{covers,pid_one,probe}.rs` | the command's own user, PID and mount namespace on Linux, a fresh `/proc` in it, and the pid-1 helper that reports how the command ended; where the host refuses them the run goes ahead and says so |
| `ceilings.rs`, `memory_ceiling.rs` | resource ceilings: rlimits set between fork and exec, and the supervised wait that kills the group at `--timeout` or, on macOS, at the CPU or memory ceiling; on Linux the memory ceiling is a cgroup v2 scope asked of the systemd user manager |
| `denials.rs`, `denial_advice.rs`, `sandbox_check.rs` | what the kernel refused during a run (macOS, from the unified log via a per-run tag on every deny rule; Linux, under `--why`, from a `strace` of the run in `sandbox_exec/why.rs`) and which flag would have allowed it; what this host can enforce, for `porta check` |
| `setup.rs` | `sudo porta setup`: a copy in a directory only root can write (`/usr/local/bin`, else `/usr/libexec/porta` linked from it) and the AppArmor profile that gives that copy user namespaces |
| `snapshot.rs` | `--snapshot`: the writable mounts copied before a run to `~/.porta/snapshots`, what the run changed, and `porta rollback` |
| `recipes.rs` + `recipes/*.toml` | `porta init claude` / `codex`: a `porta.toml` measured to what each agent needs |
| `http_proxy.rs`, `proxy_audit.rs` | the loopback CONNECT proxy and its decision trail |
Expand Down Expand Up @@ -128,6 +129,9 @@ accessors use `value.*`; serialization and typed key lookups use `json.*`.
same request to itself, so the policy is the one an untraced run gets.
- Snapshots live outside every mount; a mount that holds them refuses
`--snapshot`, so a run cannot rewrite its own undo.
- A userns AppArmor profile is written only for a binary that root alone can
replace: never for a path its user can write, which would hand the grant to
whatever the user puts there.
- `run`, `up`, and MCP execution must share native policy generation.
- Proxy mode permits only the loopback proxy endpoint, without UDP, Unix
sockets or any other egress channel — including a ring that would open a
Expand Down
5 changes: 3 additions & 2 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ almide install github.com/almide/porta --branch main
Debian / Ubuntu 向けには各リリースに `.deb`(amd64・arm64)も付きます。
`sudo apt install ./porta_<version>_amd64.deb` で `/usr/bin/porta` に入り、
Ubuntu 23.10 以降では porta がコマンドごとに namespace を持てるようにする
AppArmor プロファイルも読み込みます(tar 版では `scripts/apparmor-userns.sh` で
手動で行う手順)。
AppArmor プロファイルも読み込みます。ほかの方法で入れた場合は `sudo porta setup`
で同じ状態にできます(root 所有の `/usr/local/bin/porta` にコピーし、そのコピー
だけにプロファイルを入れます)。

GitHub Actions では action が同じ検証つきでリリースを入れ、Ubuntu ランナーでは
ランナーが本来与えない user namespace を porta にだけ許す AppArmor プロファイルも
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ compiles to WASI runs under it.
On Debian and Ubuntu, each release also carries a `.deb` (amd64, arm64).
Installed with `sudo apt install ./porta_<version>_amd64.deb`, it puts porta
at `/usr/bin/porta` and, on Ubuntu 23.10 and later, loads the AppArmor
profile that lets porta give each command its own namespaces — the step the
tarball leaves to `scripts/apparmor-userns.sh`.
profile that lets porta give each command its own namespaces. Installed any
other way, `sudo porta setup` does the same: it copies porta to a root-owned
`/usr/local/bin/porta` and loads the profile for that copy alone.

In a GitHub Actions workflow, the action installs a release the same checked
way and, on Ubuntu runners, loads the AppArmor profile that gives porta the
Expand Down
17 changes: 12 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,23 @@ runs:
if "$PORTA" check | grep -qE '^ok +own PID, mount and network namespace'; then
echo "this runner already gives porta its namespaces"
elif command -v apparmor_parser >/dev/null; then
sudo bash "$GITHUB_ACTION_PATH/scripts/apparmor-userns.sh" "$PORTA"
# A root-owned copy with the profile for it alone (what `porta setup`
# does, spelled out so any release can use it). The runner's
# /usr/local/bin is writable by its user, so the copy goes in a
# directory root alone owns, put first on the PATH.
sudo install -d -o root -g root -m 0755 /usr/libexec/porta
sudo install -o root -g root -m 0755 "$PORTA" /usr/libexec/porta/porta
sudo bash "$GITHUB_ACTION_PATH/scripts/apparmor-userns.sh" /usr/libexec/porta/porta
echo /usr/libexec/porta >> "$GITHUB_PATH"
else
echo "::warning::this runner refuses unprivileged user namespaces and has no AppArmor to grant them; porta runs without them and says what that leaves open"
fi
- if: runner.os == 'Linux' && inputs.why == 'true'
shell: bash
run: command -v strace >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq strace)
# The porta the following steps find, which after the profile step is the
# root-owned copy.
- shell: bash
env:
PORTA: ${{ steps.install.outputs.path }}
run: |
"$PORTA" --version
"$PORTA" check
porta --version
porta check
2 changes: 1 addition & 1 deletion almide.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "porta"
version = "0.6.15"
version = "0.6.16"

[permissions]
allow = ["FS.read", "FS.write", "IO", "Env", "Time", "Net"]
Expand Down
6 changes: 4 additions & 2 deletions docs/enforcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,10 @@ host execution and HTTP requests go through the checked MCP built-in tools.
restricts them through AppArmor, and most container runtimes refuse them.
There porta runs without them and says so, `porta check` shows it, and
`--no-net` falls back to Landlock and seccomp. On Ubuntu,
`sudo bash scripts/apparmor-userns.sh "$(command -v porta)"` loads the
profile Ubuntu documents for a program that needs them, for porta alone;
`sudo porta setup` copies porta to a root-owned `/usr/local/bin/porta` and
loads the profile Ubuntu documents for a program that needs them, for that
copy alone (a profile for a path its user can write would grant userns to
whatever the user puts there);
in GitHub Actions, `uses: almide/porta@<tag>` does it for the runner, and
the release's `.deb` does it at install for `/usr/bin/porta`.
- **Linux protects inside a mount, and closes credential sockets, only in a
Expand Down
5 changes: 3 additions & 2 deletions docs/roadmap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@

## Done

31 items
32 items

<details>
<summary>Show all 31 completed items</summary>
<summary>Show all 32 completed items</summary>

| Done | Item | Description |
|------|------|-------------|
| 2026-09-27 | [porta setup](done/16-porta-setup.md) | sudo porta setup gives any porta its namespaces on Ubuntu, safely |
| 2026-09-26 | [Monkey testing](done/14-monkey-test.md) | scripts/monkey.py: random sequences of real operations, checked after every step |
| 2026-09-26 | [A .deb that brings the AppArmor profile](done/15-deb-package.md) | A .deb per release whose postinst gives /usr/bin/porta its namespaces on Ubuntu |
| 2026-09-24 | [Why a run was refused, on Linux](done/10-why-on-linux.md) | --why: what the sandbox refused, on Linux too, traced with strace |
Expand Down
35 changes: 35 additions & 0 deletions docs/roadmap/done/16-porta-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!-- description: sudo porta setup gives any porta its namespaces on Ubuntu, safely -->
<!-- done: 2026-09-27 -->
# porta setup

## Why

The `.deb` gives `/usr/bin/porta` its user namespaces on Ubuntu 23.10 and
later. A porta installed by `install.sh` or `almide install` sits under the
home, and `scripts/apparmor-userns.sh` wrote the profile for whatever path it
was given — including one the user can write. A profile for such a path
grants `userns` to anything the user, or malware running as them, puts
there: the restriction undone for that user.

## What

- `sudo porta setup` copies the running porta to `/usr/local/bin/porta` where
that directory and every one above it are root's alone, and otherwise to
`/usr/libexec/porta/porta` with a link from `/usr/local/bin` (the GitHub runner
image leaves `/usr/local/bin` writable by its user, and setup refused it
there). It writes the profile for that copy alone and loads it,
then runs `porta check` as the user who ran sudo to confirm the
namespaces. It says what it will do first; `--dry-run` stops there,
`--undo` takes both away. On a host without the restriction it does
nothing.
- `scripts/apparmor-userns.sh` refuses a binary that it, or any directory
above it, is not root's alone, and points at `porta setup`.
- The runtime notice on such a host names `sudo porta setup`.
- The action installs a root-owned copy the same way before loading the
profile.

## Evidence

CI refuses the helper script on the checkout's build, runs `porta setup` on
the Ubuntu runner, sees the namespaces as the runner's user, runs the
integration suite and the escape corpus against the copy, and undoes it.
2 changes: 1 addition & 1 deletion native/pid_namespace/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub(crate) fn available() -> Result<(), &'static str> {

pub(super) const PROBE_REFUSALS: [&str; 3] = [
"this host refuses an unprivileged user namespace (a container's seccomp profile, user.max_user_namespaces=0, or kernel.unprivileged_userns_clone=0)",
"this host gives an unprivileged user namespace no rights to mount in (on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1: scripts/apparmor-userns.sh grants porta alone)",
"this host gives an unprivileged user namespace no rights to mount in (on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1: `sudo porta setup` grants porta alone)",
"a fresh /proc cannot be mounted here (the host's /proc has mounts over parts of it, as in most containers)",
];

Expand Down
Loading
Loading