diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c25223..43242d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 8ad1f89..fb46f07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | @@ -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 diff --git a/README.ja.md b/README.ja.md index ab5daa6..36b68cc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -63,8 +63,9 @@ almide install github.com/almide/porta --branch main Debian / Ubuntu 向けには各リリースに `.deb`(amd64・arm64)も付きます。 `sudo apt install ./porta__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 プロファイルも diff --git a/README.md b/README.md index 77b5ba4..1578dc4 100644 --- a/README.md +++ b/README.md @@ -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__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 diff --git a/action.yml b/action.yml index 22408a0..df55afb 100644 --- a/action.yml +++ b/action.yml @@ -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 diff --git a/almide.toml b/almide.toml index bb93868..e5adf37 100644 --- a/almide.toml +++ b/almide.toml @@ -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"] diff --git a/docs/enforcement.md b/docs/enforcement.md index 09cf776..0f8ddb2 100644 --- a/docs/enforcement.md +++ b/docs/enforcement.md @@ -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@` 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 diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 1b23e3d..7d2d7d4 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -30,13 +30,14 @@ ## Done -31 items +32 items
-Show all 31 completed items +Show all 32 completed items | 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 | diff --git a/docs/roadmap/done/16-porta-setup.md b/docs/roadmap/done/16-porta-setup.md new file mode 100644 index 0000000..80b4d00 --- /dev/null +++ b/docs/roadmap/done/16-porta-setup.md @@ -0,0 +1,35 @@ + + +# 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. diff --git a/native/pid_namespace/probe.rs b/native/pid_namespace/probe.rs index 9415d74..281518b 100644 --- a/native/pid_namespace/probe.rs +++ b/native/pid_namespace/probe.rs @@ -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)", ]; diff --git a/native/setup.rs b/native/setup.rs new file mode 100644 index 0000000..f72f8d9 --- /dev/null +++ b/native/setup.rs @@ -0,0 +1,195 @@ +//! `sudo porta setup`: give this porta the user namespaces a host restricts. +//! +//! Ubuntu from 23.10 lets an unprivileged process create a user namespace but +//! gives it no rights there, unless an AppArmor profile grants `userns` to the +//! program. The `.deb` brings that profile for `/usr/bin/porta`. A porta +//! installed any other way — the tarball's installer, `almide install` — sits +//! in a directory its user can write, and a profile for that path would let +//! anything the user runs take the name and the grant with it. So setup +//! copies this binary to a directory only root can write, every directory +//! above it too, and writes the profile for that path alone: +//! `/usr/local/bin/porta` where `/usr/local/bin` is root's alone, otherwise +//! `/usr/libexec/porta/porta` with a link to it from `/usr/local/bin` (a CI +//! runner's image leaves `/usr/local/bin`, and `/opt`, writable by its user; +//! `/usr/libexec` is the package manager's). A profile +//! attaches to the file a link resolves to, so replacing the link grants +//! nothing. + +use std::path::{Path, PathBuf}; + +/// The directories setup may put porta in, in order of preference. +const DIRECTORIES: [&str; 2] = ["/usr/local/bin", "/usr/libexec/porta"]; +/// Where a shell finds porta once set up. +const LINK: &str = "/usr/local/bin/porta"; + +/// Why this host needs no setup, or `None` when it does. +fn not_needed() -> Option<&'static str> { + if !cfg!(target_os = "linux") { + return Some("only Linux restricts user namespaces through AppArmor; nothing to set up here"); + } + let restricted = std::fs::read_to_string("/proc/sys/kernel/apparmor_restrict_unprivileged_userns").map(|value| value.trim() == "1").unwrap_or(false); + if !restricted { + return Some("this host does not restrict unprivileged user namespaces; porta has them already"); + } + if !Path::new("/etc/apparmor.d/abi/4.0").exists() { + return Some("this host's AppArmor predates the userns rule (Ubuntu 22.04 and earlier) and does not restrict user namespaces with it"); + } + None +} + +/// The first of `dir` and the directories above it that someone other than +/// root can write, or `None` when root alone can. One that does not exist yet +/// is setup's to create, as root, with no write for anyone else. +fn writable_by_others(dir: &Path) -> Option { + use std::os::unix::fs::MetadataExt; + dir.ancestors() + .find(|path| std::fs::metadata(path).is_ok_and(|meta| meta.uid() != 0 || meta.mode() & 0o022 != 0)) + .map(Path::to_path_buf) +} + +/// The first of [`DIRECTORIES`] only root can write, as the path porta goes to. +fn target() -> Result { + let mut reasons = Vec::new(); + for dir in DIRECTORIES.iter().map(Path::new) { + match writable_by_others(dir) { + None => return Ok(dir.join("porta")), + Some(open) => reasons.push(format!("{} (through {})", dir.display(), open.display())), + } + } + Err(format!("someone other than root can write {}, so a profile for porta there would grant userns to whoever replaces it", reasons.join(" and "))) +} + +/// The profile for `target`, named after its path as Ubuntu names them. +fn profile_path(target: &Path) -> PathBuf { + let name = target.to_string_lossy().trim_start_matches('/').replace('/', "."); + PathBuf::from("/etc/apparmor.d").join(name) +} + +fn profile_text(target: &Path) -> String { + let name = profile_path(target).file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default(); + format!( + "# Written by `porta setup`: lets {} create user namespaces, and\n\ + # nothing else; the program is otherwise unconfined, as it was before.\n\ + abi ,\ninclude \n\n\ + profile {name} {} flags=(unconfined) {{\n userns,\n\n include if exists \n}}\n", + target.display(), + target.display() + ) +} + +/// What setup will do, in words, before it does it. +fn plan(source: &Path, target: &Path) -> String { + let mut text = format!( + "porta setup will:\n copy {} to {}, owned by root, mode 0755\n write {} granting userns to {} alone, and load it\n", + source.display(), + target.display(), + profile_path(target).display(), + target.display() + ); + if target != Path::new(LINK) { + text.push_str(&format!(" link {LINK} to it (/usr/local/bin is not root's alone here)\n")); + } + text +} + +fn install(source: &Path, target: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + let dir = target.parent().unwrap_or(Path::new("/")); + std::fs::create_dir_all(dir).map_err(|error| format!("cannot create {}: {error}", dir.display()))?; + let staged = target.with_extension("setup"); + std::fs::copy(source, &staged).map_err(|error| format!("cannot copy porta to {}: {error}", staged.display()))?; + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).map_err(|error| error.to_string())?; + std::os::unix::fs::chown(&staged, Some(0), Some(0)).map_err(|error| error.to_string())?; + std::fs::rename(&staged, target).map_err(|error| format!("cannot put porta at {}: {error}", target.display()))?; + if target != Path::new(LINK) && Path::new(LINK).parent().is_some_and(Path::exists) { + let _ = std::fs::remove_file(LINK); + std::os::unix::fs::symlink(target, LINK).map_err(|error| format!("cannot link {LINK}: {error}"))?; + } + let profile = profile_path(target); + std::fs::write(&profile, profile_text(target)).map_err(|error| format!("cannot write {}: {error}", profile.display()))?; + apparmor_parser(&["-r", &profile.to_string_lossy()]) +} + +fn apparmor_parser(args: &[&str]) -> Result<(), String> { + let status = std::process::Command::new("apparmor_parser").args(args).status().map_err(|error| format!("cannot run apparmor_parser: {error}"))?; + status.success().then_some(()).ok_or_else(|| format!("apparmor_parser {} failed", args.join(" "))) +} + +/// Whether the installed porta, run as the user who ran sudo, now has its +/// namespaces: the check that matters is the one without root. +fn verified(target: &Path) -> String { + use std::os::unix::process::CommandExt; + let id = |name: &str| std::env::var(name).ok().and_then(|value| value.parse::().ok()); + let (Some(uid), Some(gid)) = (id("SUDO_UID"), id("SUDO_GID")) else { + return format!("run `{} check` as yourself to see the namespaces\n", target.display()); + }; + let output = std::process::Command::new(target).arg("check").uid(uid).gid(gid).output(); + match output { + Ok(output) if String::from_utf8_lossy(&output.stdout).lines().any(|line| line.starts_with("ok") && line.contains("namespace")) => { + format!("verified: {} gives each command its own PID, mount and network namespace\n", target.display()) + } + _ => format!("the profile is loaded, but `{} check` does not show the namespaces yet; run it as yourself to see why\n", target.display()), + } +} + +/// The porta a shell finds first, when it is not the one setup installed. +fn shadowed(target: &Path) -> Option { + let path = std::env::var_os("PATH")?; + let found = std::env::split_paths(&path).map(|dir| dir.join("porta")).find(|candidate| candidate.is_file())?; + let found = std::fs::canonicalize(found).ok()?; + (found != target).then(|| format!("note: `porta` on this PATH is {}; put /usr/local/bin first, or remove that one, to use the one set up\n", found.display())) +} + +fn undo() -> Result { + let mut removed = Vec::new(); + for target in DIRECTORIES.iter().map(|dir| Path::new(dir).join("porta")) { + let profile = profile_path(&target); + if profile.exists() { + let _ = apparmor_parser(&["-R", &profile.to_string_lossy()]); + std::fs::remove_file(&profile).map_err(|error| format!("cannot remove {}: {error}", profile.display()))?; + let _ = std::fs::remove_file(&target); + removed.push(format!("{} and {}", profile.display(), target.display())); + } + } + if std::fs::read_link(LINK).is_ok() { + let _ = std::fs::remove_file(LINK); + } + Ok(if removed.is_empty() { "nothing to undo\n".to_string() } else { format!("removed {}\n", removed.join("; ")) }) +} + +/// `porta setup [--dry-run | --undo]`. +pub fn wt_setup(mode: impl AsRef) -> String { + setup(mode.as_ref()).trim_end().to_string() +} + +fn setup(mode: &str) -> String { + let root = unsafe { libc::geteuid() } == 0; + if mode == "undo" { + return if root { undo().unwrap_or_else(|reason| format!("Error: {reason}\n")) } else { "Error: porta setup --undo needs root; run it with sudo\n".into() }; + } + if let Some(reason) = not_needed() { + return format!("{reason}\n"); + } + let source = match std::env::current_exe().and_then(std::fs::canonicalize) { + Ok(source) => source, + Err(error) => return format!("Error: cannot find porta's own binary: {error}\n"), + }; + let target = match target() { + Ok(target) => target, + Err(reason) => return format!("Error: {reason}\n"), + }; + let mut text = plan(&source, &target); + if mode == "dry-run" { + return text; + } + if !root { + text.push_str("it needs root for both; run: sudo porta setup\n"); + return text; + } + if let Err(reason) = install(&source, &target) { + return format!("{text}Error: {reason}\n"); + } + text.push_str(&verified(&target)); + text.push_str(&shadowed(&target).unwrap_or_default()); + text +} diff --git a/native/wasmtime_bridge.rs b/native/wasmtime_bridge.rs index f9b9230..2dd6e4c 100644 --- a/native/wasmtime_bridge.rs +++ b/native/wasmtime_bridge.rs @@ -47,6 +47,7 @@ static INSTANCES: Mutex>> = Mutex::new(Vec::new()); // the implementations live next to the state they own. pub use crate::http_proxy::{wt_is_host_allowed, wt_proxy_start, wt_proxy_stop}; pub use crate::recipes::{wt_recipe, wt_recipe_names}; +pub use crate::setup::wt_setup; pub use crate::snapshot::wt_rollback; pub use crate::sandbox_exec::{wt_exec_inner, wt_exec_replace, wt_exec_sandboxed, wt_exec_supervised, wt_parse_toml, wt_sandbox_explain, wt_sandbox_explain_json}; pub use crate::sandbox_check::{wt_sandbox_check, wt_sandbox_check_json}; diff --git a/scripts/apparmor-userns.sh b/scripts/apparmor-userns.sh index c18ba58..46da33c 100755 --- a/scripts/apparmor-userns.sh +++ b/scripts/apparmor-userns.sh @@ -11,11 +11,27 @@ set -euo pipefail # It grants `userns` and nothing else; the profile is otherwise unconfined, as # the program was before. # -# sudo bash scripts/apparmor-userns.sh "$(command -v porta)" +# sudo bash scripts/apparmor-userns.sh /usr/local/bin/porta +# +# The binary, and every directory above it, must be root's and writable by +# nobody else: a profile for a path its user can write grants userns to +# whatever that user puts there, which is the restriction undone. For a porta +# installed under the home, `sudo porta setup` copies it somewhere root owns +# and writes the profile for that copy. binary=$(readlink -f "${1:?usage: apparmor-userns.sh /path/to/porta}") [ -x "$binary" ] || { echo "not an executable: $binary" >&2; exit 2; } case "$binary" in *'"'*|*$'\n'*) echo "refusing a path with a quote or newline in it: $binary" >&2; exit 2 ;; esac +path=$binary +while :; do + owner=$(stat -c %u "$path") mode=$(stat -c %a "$path") + if [ "$owner" != 0 ] || [ $(( 8#$mode & 8#022 )) != 0 ]; then + echo "refusing: $path is not root's alone (owner $owner, mode $mode), so a profile for $binary would grant userns to whoever replaces it; run 'sudo porta setup' instead" >&2 + exit 2 + fi + [ "$path" = / ] && break + path=$(dirname "$path") +done command -v apparmor_parser >/dev/null || { echo "apparmor_parser is not installed; this host does not restrict user namespaces through AppArmor" >&2; exit 2; } profile=${APPARMOR_DIR:-/etc/apparmor.d}/porta diff --git a/src/help.almd b/src/help.almd index eb3d97e..5b32234 100644 --- a/src/help.almd +++ b/src/help.almd @@ -77,6 +77,14 @@ fn print_help(topic: String) -> Unit = println("Run agent from porta.toml in the current directory.") println("Supports both WASM and native runtimes.") }, + "setup" => { + println("porta setup [--dry-run | --undo]") + println("") + println("On a Linux host that restricts user namespaces through AppArmor (Ubuntu 23.10+),") + println("copy this porta to /usr/local/bin/porta, owned by root, and load the profile that") + println("lets that copy give each command its own namespaces. Run it with sudo. The .deb") + println("does this for /usr/bin/porta; this is for a porta installed any other way.") + }, "init" => { println("porta init | porta init [native|wasm] ") println("") diff --git a/src/main.almd b/src/main.almd index d406c81..4cb4965 100644 --- a/src/main.almd +++ b/src/main.almd @@ -68,6 +68,14 @@ effect fn main() -> Result[Unit, String] = { help.print_help("") ok(()) }, + "setup" => { + let flag = list.get(args, 2) ?? "" + let mode = if flag == "--undo" then "undo" else if flag == "--dry-run" then "dry-run" else "" + let reply = wasm_rt.wt_setup(mode) + println(reply) + if string.starts_with(reply, "Error:") or string.contains(reply, "\nError:") then process.exit(125) else () + ok(()) + }, "rollback" => { let rest = list.drop(args, 2) let id = list.find(rest, (a) => a != "--yes") ?? "" diff --git a/src/util.almd b/src/util.almd index 4224654..711ec44 100644 --- a/src/util.almd +++ b/src/util.almd @@ -7,7 +7,7 @@ import self.sandbox // chances for `porta --version` and the manifest porta generates to disagree // about what produced a file. `almide.toml` carries it too, because the build // needs it before this module exists; `src/mod_test.almd` asserts they match. -fn version() -> String = "0.6.15" +fn version() -> String = "0.6.16" // --- Int parsing --- diff --git a/src/wasm_rt.almd b/src/wasm_rt.almd index 3ce5640..5f44211 100644 --- a/src/wasm_rt.almd +++ b/src/wasm_rt.almd @@ -71,6 +71,11 @@ fn wt_exec_inner(request_json: String) -> String @extern(rs, "wasmtime_bridge", "wt_rollback") fn wt_rollback(id: String, apply: Bool) -> String +// sudo porta setup: a root-owned copy at /usr/local/bin/porta and the AppArmor +// profile that gives it user namespaces. mode is "", "dry-run" or "undo". +@extern(rs, "wasmtime_bridge", "wt_setup") +fn wt_setup(mode: String) -> String + // A ready-made porta.toml for a coding agent, or "" when there is none. @extern(rs, "wasmtime_bridge", "wt_recipe") fn wt_recipe(name: String) -> String