From b0cf3ccad5275475134a7a5c6ae9e6f507321bf4 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sun, 27 Sep 2026 22:55:37 +0900 Subject: [PATCH 01/11] 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". From 017c2c018d3bb4aea5bfa54404e6b5f7b20d9381 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sun, 27 Sep 2026 23:20:25 +0900 Subject: [PATCH 02/11] Ship a Linux binary that starts on glibc 2.31 and needs no libssl The Linux release was linked on the Ubuntu 24.04 runner, so it asked for glibc 2.39 and libssl.so.3 and did not start on Debian 12, Ubuntu 22.04 or the python:*-slim images: most containers an agent runs in. - reqwest without its default features: TLS is rustls only - scripts/build-linux.sh builds in Debian bullseye, with the pinned Almide built from its tag there, and refuses a result past glibc 2.31 or one linking OpenSSL - release.yml builds Linux with it; the integration suite still runs the result on the runner - ci.yml checks that no OpenSSL is linked on Linux - README: the glibc floor Closes #35 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 8 +++++++ .github/workflows/release.yml | 10 +++++++++ README.ja.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- almide.toml | 2 +- scripts/build-linux.sh | 42 +++++++++++++++++++++++++++++++++++ 7 files changed, 64 insertions(+), 4 deletions(-) create mode 100755 scripts/build-linux.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43242d6..fc63c55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,14 @@ jobs: run: .tools/almide/almide check src/main.almd - name: Build run: .tools/almide/almide build src/main.almd -o target/porta + # TLS is rustls only (reqwest without its default features), so the Linux + # binary needs no libssl.so.3 from whatever image it lands in (#35). A + # dependency that turns native-tls back on shows up here. + - name: No OpenSSL linked + if: runner.os == 'Linux' + run: | + ldd target/porta + ! ldd target/porta | grep -E 'libssl|libcrypto' - name: Test run: .tools/almide/almide test --ci # The memory ceiling is a cgroup v2 scope asked of the systemd user diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24aa1f8..18a479f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,9 +39,19 @@ jobs: python-version: "3.12" - uses: dtolnay/rust-toolchain@stable - name: Install pinned Almide + if: runner.os == 'macOS' run: bash scripts/install-almide.sh - name: Build + if: runner.os == 'macOS' run: .tools/almide/almide build src/main.almd -o target/porta + # Linux builds in Debian bullseye, not on the runner: a binary linked on + # Ubuntu 24.04 asks for glibc 2.39 and does not start on Debian 12, + # Ubuntu 22.04 or the python:*-slim images, which is most containers an + # agent runs in (#35). The script refuses a result past glibc 2.31 or one + # linking OpenSSL. + - name: Build (Debian bullseye, glibc 2.31) + if: runner.os == 'Linux' + run: bash scripts/build-linux.sh target/porta # A binary nobody checked is worse than no binary: the same suite that # gates a commit gates a release, on the machine that produced the file. - name: Verify the binary that will ship diff --git a/README.ja.md b/README.ja.md index b7b31da..4b3b37d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -46,7 +46,7 @@ curl -fsSL https://raw.githubusercontent.com/almide/porta/main/scripts/install.s | 環境 | 方法 | |---|---| -| macOS (Apple silicon)、Linux (x86-64, arm64) | 上のスクリプト。SHA-256 を検証し、`cosign` があれば Sigstore 署名も検証 | +| macOS (Apple silicon)、Linux (x86-64, arm64。glibc 2.31 以降:Debian 11、Ubuntu 20.04 以降) | 上のスクリプト。SHA-256 を検証し、`cosign` があれば Sigstore 署名も検証 | | Debian、Ubuntu | [リリース](https://github.com/almide/porta/releases)の `sudo apt install ./porta__amd64.deb`。Ubuntu 23.10 以降では namespace に必要な AppArmor プロファイルも読み込む | | それ以外の方法で入れた Ubuntu | 一度だけ `sudo porta setup` で同じプロファイルを入れる | | GitHub Actions | `- uses: almide/porta@v0.6.16` の後、どのステップでも `porta run …` | diff --git a/README.md b/README.md index 43c9a7e..ed5f321 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ curl -fsSL https://raw.githubusercontent.com/almide/porta/main/scripts/install.s | Where | How | |---|---| -| macOS (Apple silicon), Linux (x86-64, arm64) | the script above: checks the SHA-256, and the Sigstore signature where `cosign` is installed | +| macOS (Apple silicon), Linux (x86-64, arm64; glibc 2.31+: Debian 11, Ubuntu 20.04 and later) | the script above: checks the SHA-256, and the Sigstore signature where `cosign` is installed | | Debian, Ubuntu | `sudo apt install ./porta__amd64.deb` from a [release](https://github.com/almide/porta/releases); on Ubuntu 23.10+ it also loads the AppArmor profile porta's namespaces need | | Ubuntu, installed any other way | `sudo porta setup` once, for the same profile | | GitHub Actions | `- uses: almide/porta@v0.6.16`, then `porta run …` in any step | diff --git a/README.zh-CN.md b/README.zh-CN.md index d9ac93c..25a30fa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,7 +45,7 @@ curl -fsSL https://raw.githubusercontent.com/almide/porta/main/scripts/install.s | 平台 | 方法 | |---|---| -| macOS (Apple silicon)、Linux (x86-64, arm64) | 上面的脚本:校验 SHA-256,安装了 `cosign` 时还会校验 Sigstore 签名 | +| macOS (Apple silicon)、Linux (x86-64, arm64;glibc 2.31+:Debian 11、Ubuntu 20.04 及以上) | 上面的脚本:校验 SHA-256,安装了 `cosign` 时还会校验 Sigstore 签名 | | Debian、Ubuntu | 从 [release](https://github.com/almide/porta/releases) 下载后 `sudo apt install ./porta__amd64.deb`;在 Ubuntu 23.10+ 上还会加载命名空间所需的 AppArmor 配置 | | 以其他方式安装的 Ubuntu | 运行一次 `sudo porta setup`,加载同样的配置 | | GitHub Actions | `- uses: almide/porta@v0.6.16`,之后任意步骤中都可 `porta run …` | diff --git a/almide.toml b/almide.toml index e5adf37..592520f 100644 --- a/almide.toml +++ b/almide.toml @@ -8,7 +8,7 @@ allow = ["FS.read", "FS.write", "IO", "Env", "Time", "Net"] [native-deps] wasmtime = "47.0.3" wasmtime-wasi = { version = "47.0.3", features = ["p3"] } -reqwest = { version = "0.12.28", features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.12.28", default-features = false, features = ["blocking", "rustls-tls"] } serde_json = "1" sha2 = "0.10" jsonschema = { version = "=0.56.0", default-features = false } diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100755 index 0000000..6223f65 --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Builds the Linux porta that ships: inside Debian bullseye, so the binary asks for +# nothing newer than glibc 2.31 and runs on Debian 11 and later, Ubuntu 20.04 and later, +# and the python:*-slim images (#35). A binary built on the runner itself (Ubuntu 24.04) +# needed glibc 2.39 and did not start on bookworm. +# +# scripts/build-linux.sh [OUT] (default: target/porta) +# +# The pinned Almide is built from its tag in the same container, because its own Linux +# release needs glibc 2.39 too. The result is checked before it is handed back: no glibc +# symbol past 2.31, and no libssl or libcrypto (porta speaks TLS through rustls only). +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +out="${1:-target/porta}" +tag="${ALMIDE_RELEASE_TAG:-$(sed -n 's/^tag="\${ALMIDE_RELEASE_TAG:-\(.*\)}"$/\1/p' scripts/install-almide.sh)}" +[ -n "$tag" ] || { echo "no pinned Almide tag in scripts/install-almide.sh" >&2; exit 2; } +image="${PORTA_LINUX_BUILD_IMAGE:-rust:1-bullseye}" +mkdir -p "$(dirname "$out")" .tools/linux-build + +# As the caller's user, so what lands in target/ can be stripped and packaged after. +docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -w /src \ + -e ALMIDE_TAG="$tag" -e OUT="$out" -e CARGO_HOME=/src/.tools/linux-build/cargo \ + ${CARGO_BUILD_JOBS:+-e CARGO_BUILD_JOBS="$CARGO_BUILD_JOBS"} "$image" bash -euo pipefail -c ' + almide=/src/.tools/linux-build/almide-$ALMIDE_TAG + if [ ! -x "$almide" ]; then + git init -q /tmp/almide && cd /tmp/almide + git remote add origin https://github.com/almide/almide + git fetch -q --depth 1 origin "refs/tags/$ALMIDE_TAG:refs/tags/$ALMIDE_TAG" + git checkout -q "$ALMIDE_TAG" + cargo build --release --locked --bin almide > /tmp/almide-build.log 2>&1 || { tail -30 /tmp/almide-build.log; exit 1; } + install -m 0755 target/release/almide "$almide" + cd /src + fi + "$almide" --version + "$almide" build src/main.almd -o "$OUT" + newest=$(objdump -T "$OUT" | grep -o "GLIBC_[0-9.]*" | sort -uV | tail -1) + echo "needs $newest" + [ "$(printf "%s\nGLIBC_2.31\n" "$newest" | sort -V | tail -1)" = GLIBC_2.31 ] || { echo "needs $newest, past bullseye" >&2; exit 1; } + if ldd "$OUT" | grep -E "libssl|libcrypto"; then echo "links OpenSSL; porta is rustls only" >&2; exit 1; fi + ' +echo "== built $out" From 2a095c9c21ea22f6e6b35d3ae8eae87115e703d5 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sun, 27 Sep 2026 23:24:27 +0900 Subject: [PATCH 03/11] Say before the run when a writable mount is a Docker Desktop shared folder On Docker Desktop, a macOS folder bind-mounted into a container is a fakeowner filesystem, and under Landlock a command there can create a file but not write to it. The ruleset grants the mount; the kernel refuses the open anyway, leaving empty files and a bare Permission denied. porta now reads /proc/self/mountinfo and, when a writable -v lies on fakeowner, says so once, with what to use instead. docs/enforcement.md describes the limit. Closes #36 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/enforcement.md | 6 +++ native/sandbox_exec/linux.rs | 102 +++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/docs/enforcement.md b/docs/enforcement.md index 0f8ddb2..8728e05 100644 --- a/docs/enforcement.md +++ b/docs/enforcement.md @@ -59,6 +59,12 @@ Linux uses Landlock unprivileged, without namespaces and without an external runtime. A rule the running kernel cannot express refuses the run rather than widening it: partial enforcement is never silently accepted. +One place a grant does not hold is Docker Desktop's shared folders: a macOS +directory bind-mounted into a container is a `fakeowner` filesystem, and under +Landlock a command there can create a file but not write to it. porta cannot +change that, so it says so before the run when a writable `-v` lies on one; give +the command a Docker volume or a tmpfs to write to, and copy out afterwards. + ## Host-filtered HTTPS ```bash diff --git a/native/sandbox_exec/linux.rs b/native/sandbox_exec/linux.rs index 00ae426..29969ae 100644 --- a/native/sandbox_exec/linux.rs +++ b/native/sandbox_exec/linux.rs @@ -40,6 +40,7 @@ impl SandboxRequest { /// the macOS ones come through [`Self::profile`]. #[cfg(target_os = "linux")] pub(super) fn ruleset(&self) -> Result { + self.note_unwritable_mounts(); let abi = crate::landlock::abi_version().unwrap_or(0); if self.no_network && !self.network_isolated() && abi < 4 { return Err(format!( @@ -70,6 +71,32 @@ impl SandboxRequest { crate::landlock_policy::ruleset(&self.allowed_dirs, network, &self.read_policy, closed) } + /// Says, once, which writable mounts sit on a filesystem where Landlock + /// lets a file be created but not written. The ruleset grants them all the + /// same; the kernel refuses the open for writing anyway, and the command is + /// left with empty files and a bare `Permission denied` (#36). + #[cfg(target_os = "linux")] + fn note_unwritable_mounts(&self) { + static NOTED: std::sync::Once = std::sync::Once::new(); + let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else { return }; + let unwritable: Vec = self + .allowed_dirs + .iter() + .filter(|dir| !dir.ends_with(":ro")) + .filter(|dir| filesystem_of(&mountinfo, dir).is_some_and(|fstype| UNWRITABLE_UNDER_LANDLOCK.contains(&fstype))) + .cloned() + .collect(); + if !unwritable.is_empty() { + NOTED.call_once(|| { + eprintln!( + "porta: {} is a Docker Desktop shared folder (fakeowner), where under Landlock a command can create a file but not write to it; \ + give the command a Docker volume or a tmpfs to write to, and copy out after the run", + unwritable.join(", ") + ) + }); + } + } + /// The credential sockets bound on this host now that the preset closes /// and `--allow-unix` does not open. The patterns were checked at parse. #[cfg(target_os = "linux")] @@ -182,6 +209,56 @@ impl SandboxRequest { } } +/// Filesystems a granted directory cannot be written on under Landlock: +/// Docker Desktop's `fakeowner`, which shares a macOS folder into its Linux VM. +/// Creating a file passes and opening it for writing does not, whatever the +/// ruleset grants (seen on Docker Desktop 4.82, Landlock ABI 6). +#[cfg(target_os = "linux")] +const UNWRITABLE_UNDER_LANDLOCK: [&str; 1] = ["fakeowner"]; + +/// The filesystem type `path` lives on, from `/proc/self/mountinfo`: the +/// deepest mount point containing it, and of several on the same point the +/// last, which is the one on top. +#[cfg(target_os = "linux")] +fn filesystem_of<'a>(mountinfo: &'a str, path: &str) -> Option<&'a str> { + mountinfo + .lines() + .filter_map(|line| { + let fields: Vec<&str> = line.split(' ').collect(); + let point = unescape_mount_point(fields.get(4)?); + let fstype = fields.iter().position(|field| *field == "-").and_then(|dash| fields.get(dash + 1))?; + let within = point == "/" || path == point || path.starts_with(&format!("{point}/")); + within.then_some((point.len(), *fstype)) + }) + .fold(None, |deepest: Option<(usize, &str)>, (depth, fstype)| match deepest { + Some((best, _)) if best > depth => deepest, + _ => Some((depth, fstype)), + }) + .map(|(_, fstype)| fstype) +} + +/// mountinfo writes a space, tab, newline and backslash in a path as `\ooo`. +#[cfg(target_os = "linux")] +fn unescape_mount_point(field: &str) -> String { + let bytes = field.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let octal = bytes.get(i + 1..i + 4).filter(|digits| bytes[i] == b'\\' && digits.iter().all(|d| (b'0'..=b'7').contains(d))); + match octal { + Some(digits) => { + out.push(digits.iter().fold(0u8, |n, d| n.wrapping_mul(8).wrapping_add(d - b'0'))); + i += 4; + } + None => { + out.push(bytes[i]); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + #[cfg(target_os = "linux")] pub(super) fn exec_sandboxed_linux(request: &SandboxRequest) -> String { use std::os::unix::process::CommandExt; @@ -273,3 +350,28 @@ pub(super) fn supervise_sandboxed(request: &SandboxRequest) -> Result Date: Mon, 28 Sep 2026 09:52:51 +0900 Subject: [PATCH 04/11] Credentials as placeholders, put on by the proxy for bound hosts only `--credential NAME=HOST[/path]` and `[[credentials]]` hand the command a placeholder, porta-cred-NAME-, instead of the key. The real value, read from porta's environment, stays in porta. A credential turns the proxy on; for a bound host the proxy terminates the command's TLS with a certificate from a CA made for this run, puts the value where the placeholder is (request line, query, headers), and sends the request on over its own TLS connection verified against the webpki roots. The same placeholder on a path it is not bound to is refused with 403 before anything is sent. Substitutions and refusals are recorded by name, never by value. Hosts no credential names are tunnelled as before. The command trusts the CA through SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE and GIT_SSL_CAINFO (system roots plus the CA) and NODE_EXTRA_CA_CERTS (the CA alone). The CA lives for a week, in memory; its files are removed when the proxy stops. rustls and rcgen on ring, the provider reqwest already builds: no second crypto library and no OpenSSL. PORTA_TEST_UPSTREAM(_CA) lets the integration suite send a CONNECT to a local server; it is read from porta's own environment. Closes #37 Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 6 +- README.md | 4 + almide.toml | 6 + docs/cli.md | 6 + docs/enforcement.md | 46 ++- docs/threat-model.md | 5 + native/credential_broker.rs | 548 ++++++++++++++++++++++++++++++++++++ native/http_proxy.rs | 76 ++++- native/proxy_egress.rs | 16 ++ scripts/integration.py | 88 ++++++ src/cli.almd | 20 ++ src/config.almd | 13 + src/engine.almd | 4 +- src/help.almd | 2 + src/project.almd | 1 + src/proxy.almd | 52 +++- src/proxy_test.almd | 35 ++- src/wasm_rt.almd | 2 +- 18 files changed, 901 insertions(+), 29 deletions(-) create mode 100644 native/credential_broker.rs diff --git a/CLAUDE.md b/CLAUDE.md index fb46f07..59446f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,8 @@ as a definition does. | `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 | +| `http_proxy.rs`, `proxy_egress.rs`, `proxy_audit.rs` | the loopback CONNECT proxy, its run credential and address guard, and its decision trail | +| `credential_broker.rs` | credentials as placeholders: the run's CA, TLS terminated for bound hosts only, the real value put on where it is bound and refused elsewhere | | `http_client.rs`, `host_process.rs`, `wasm_inspect.rs` | checked host services: one HTTP request, process helpers, module inspection | | `agent_runtime.rs` + `agent_runtime/` | the broker: `loading` (config, pins, team), `guest`, `model`, `verification`, `tools`, `inspect`, `ffi` | | `agent_journal.rs` | durable broker records, exclusive access, replay validation | @@ -136,6 +137,9 @@ accessors use `value.*`; serialization and typed key lookups use `json.*`. - Proxy mode permits only the loopback proxy endpoint, without UDP, Unix sockets or any other egress channel — including a ring that would open a socket without `socket(2)`. +- A credential's value never enters the child's environment, never reaches a + log, and is put on only for a host and path it is bound to; a host no + credential names is tunnelled, never intercepted. - MCP stdio uses newline-delimited JSON; diagnostics belong on stderr. - MCP result adaptation must preserve multi-field payloads and error status; only legacy single-key Result envelopes unwrap. - Agent and tool instances inherit no host environment or implicit directories. diff --git a/README.md b/README.md index ed5f321..2f4c604 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ Linux ([overhead](docs/benchmarks/overhead.md)). ([agent runtime](docs/agent-runtime.md)). - **One API and a log.** `--proxy-allow api.example.com --proxy-audit egress.jsonl` lets through only that host and records every decision. +- **Keys the command cannot read.** `--credential ANTHROPIC_API_KEY=api.anthropic.com` + hands the command a placeholder; porta's proxy puts the real key on requests + to that host alone, and refuses the placeholder anywhere else it looks + ([enforcement](docs/enforcement.md#credentials-as-placeholders)). ## Docs diff --git a/almide.toml b/almide.toml index 592520f..baaacac 100644 --- a/almide.toml +++ b/almide.toml @@ -17,3 +17,9 @@ libc = "0.2" regex = "1" toml = "0.8" +# The credential broker (#37): TLS terminated for bound hosts with a CA made +# per run, and re-opened to the real server. ring, the provider reqwest's +# rustls already builds, so no second crypto library and no OpenSSL. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rcgen = { version = "0.13", default-features = false, features = ["crypto", "ring", "pem"] } +webpki-roots = "1" diff --git a/docs/cli.md b/docs/cli.md index fed1957..3781f53 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -64,6 +64,7 @@ line, never dropped. | `--proxy-allow ` | Route egress through porta's CONNECT proxy and allow only these hosts | | `--proxy-deny ` | Same, denying these hosts | | `--proxy-audit ` | Append every proxy decision to a JSONL file | +| `--credential ` | Hand host variable NAME in as a placeholder; the proxy puts the real value on requests to `host[:443][/path]` only (repeatable) | | `--read-policy ` | `strict` confines reads to your mounts and the system directories (default `open`) | | `--allow-root` | Run as root anyway. Refused by default: for root, the file permissions this policy leans on separate nothing | | `--env-pass ` | Copy these host variables into the command. The child starts from an empty environment plus `PATH`, `HOME`, `USER`, `SHELL`, `TERM` and the locale; nothing else of your shell crosses unless `-e` or this names it | @@ -124,6 +125,11 @@ network = ["*:443"] # Restrict to these ports (empty = all open) # allow = ["api.example.com"] # egress only through the CONNECT proxy, these hosts # audit = "egress.jsonl" +# [[credentials]] # a placeholder inside, the real value put on by the proxy +# name = "ANTHROPIC_API_KEY" +# from = { env = "ANTHROPIC_API_KEY" } +# hosts = ["api.anthropic.com/v1"] + [env] NODE_ENV = "production" diff --git a/docs/enforcement.md b/docs/enforcement.md index e408746..3178b32 100644 --- a/docs/enforcement.md +++ b/docs/enforcement.md @@ -86,8 +86,50 @@ allow-list. An allowed name that resolves to loopback, a link-local or cloud metadata address, or a multicast group is refused: a hostname on the list is a promise about a public service, not a route to this machine. Private ranges stay reachable. Every decision is written and synced to the audit file before -the connection proceeds. Deny lists are weaker than explicit allow lists, and -this is not a credential broker. +the connection proceeds. Deny lists are weaker than explicit allow lists. + +## Credentials as placeholders + +```bash +porta run claude -v . --credential ANTHROPIC_API_KEY=api.anthropic.com +``` + +Or in `porta.toml`: + +```toml +[[credentials]] +name = "ANTHROPIC_API_KEY" # the variable the command sees +from = { env = "ANTHROPIC_API_KEY" } # where porta reads the real value (default: the name) +hosts = ["api.anthropic.com/v1"] # host[:443][/path], *.wildcard allowed +``` + +The command's `ANTHROPIC_API_KEY` is `porta-cred-ANTHROPIC_API_KEY-`, +minted for this run. The real value stays in porta. A credential turns the +proxy on (with no `--proxy-allow`, every host is reachable through it; with one, +the bound hosts are added to it), and for a bound host porta terminates the +command's TLS with a certificate from a CA made for this run, replaces the +placeholder wherever it appears in the request line, query or headers +(`Authorization: Bearer …`, `x-api-key: …` alike), and sends the request on +over its own TLS connection, which verifies the real server against the +webpki roots. The same placeholder in a request to a bound host on a path its +credential is not bound to is refused with 403 before anything is sent. Every +substitution and refusal goes to stderr and the audit file with the +credential's name, never its value. + +The command is pointed at the run's CA with `SSL_CERT_FILE`, +`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` and `GIT_SSL_CAINFO` (a bundle of the +system's roots plus the CA, since those replace the trust store) and +`NODE_EXTRA_CA_CERTS` (the CA alone, since Node adds it). The CA is valid for +a week, lives in memory, and its certificate and the bundle are removed when +the run ends. A client that ignores all of these (Go on macOS, which asks the +Keychain) will not trust the intercepted connection, and fails closed. + +Only bound hosts are intercepted. Every other CONNECT is tunnelled as before, +end to end and unread; a placeholder sent there is only a random string. Each +intercepted connection carries one request (porta asks the server for +`Connection: close`), and speaks HTTP/1.1 only; request bodies are passed on, +not inspected. Values come from porta's environment for now; a file, the +Keychain or a command as the source are left for later. ## WASM sandbox diff --git a/docs/threat-model.md b/docs/threat-model.md index e85c36c..2b8265a 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -51,6 +51,7 @@ true. | Proxy mode: the loopback proxy is the only egress | Landlock TCP port rule + seccomp socket-family filter; Seatbelt profile | integration "proxy mode is the only egress" | | The proxy serves only this run's client, and never tunnels to loopback/metadata addresses | per-run credential; resolved-address guard | integration "CONNECT needs the run credential" | | Syscalls that reach around a file policy are refused (Linux, every mode) | seccomp baseline: `ptrace`, `process_vm_*`, mounts, namespaces, `io_uring`, `execveat(AT_EMPTY_PATH)`, … | `escapes.py` "ptrace", "fileless exec", "io_uring", "new user namespace" | +| A credential given with `--credential` / `[[credentials]]` is never in the child's environment, and its value goes only on requests to the hosts and paths it is bound to; a placeholder on any other path of an intercepted host is refused and recorded | per-run placeholder; the proxy terminates TLS for bound hosts only, with a per-run CA, and verifies the real server | integration "a credential is a placeholder inside" | | The child inherits no host environment beyond `PATH`, `HOME`, locale, terminal | `env_clear` then a named allow-list | integration "the host environment stays outside" | | A policy the platform cannot express refuses the run | `prepare` returns an error; `run` exits 125 | integration "unexpressible rule refused" | @@ -59,6 +60,10 @@ true. These are out of scope by design, not undiscovered. A report that porta does not stop one of them is accurate and not a vulnerability. +- **Use of a credential where it is bound.** A credential handed in as a + placeholder cannot be read or sent anywhere else, but the command can still + make any request it likes to the host the credential is bound to, and porta + will put the key on it. Bind to the narrowest path that does the job. - **Kernel exploits.** porta's boundary is the kernel's. A privilege escalation through a kernel bug defeats it — and defeats a container on the same kernel. Those belong upstream. diff --git a/native/credential_broker.rs b/native/credential_broker.rs new file mode 100644 index 0000000..95a842a --- /dev/null +++ b/native/credential_broker.rs @@ -0,0 +1,548 @@ +//! Credentials handed in as placeholders, and put on in the proxy (#37). +//! +//! A command confined by porta that needs an API key used to get the key +//! itself, in its environment, where every process it starts can read it and +//! send it anywhere its network allows. Here it gets a placeholder instead: +//! `porta-cred-NAME-`, worth nothing outside this run. The real value +//! stays in porta. When the command's HTTPS request to a host the credential +//! is bound to comes through the proxy, porta terminates that TLS connection +//! with a certificate from a CA made for this run, puts the real value where +//! the placeholder is, and sends the request on over a TLS connection of its +//! own that verifies the real server. The same placeholder on its way to +//! anywhere else among the intercepted hosts is refused, and recorded. +//! +//! Only bound hosts are intercepted. Every other CONNECT is tunnelled exactly +//! as before, encrypted end to end and not read: a placeholder sent there is +//! only a random string. +//! +//! Each intercepted connection carries one request: porta asks the server for +//! `Connection: close` and closes the client's side after the response, so a +//! client sends its next request on a fresh connection. That costs a +//! handshake per request to a bound host and buys a proxy that never has to +//! find where one response ends and the next begins. + +use crate::proxy_audit::{audit_log, Decision}; +use crate::proxy_egress::{dial, mint_token, Dial}; +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Where one credential may go: a host pattern (`*.example.com` allowed), the +/// port, and a path prefix ("" for any path). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Binding { + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) path: String, +} + +/// One credential: the name the command sees it under, the real value, the +/// placeholder the command is given instead, and where it may be put on. +pub(crate) struct Credential { + pub(crate) name: String, + value: String, + pub(crate) placeholder: String, + pub(crate) bindings: Vec, +} + +/// `host[:port][/path]` as `--credential` and `[[credentials]]` write it. The +/// proxy tunnels port 443 alone, so a binding to another port could never be +/// used, and is refused here rather than silently doing nothing. +pub(crate) fn parse_binding(text: &str) -> Result { + let text = text.trim(); + let (authority, path) = match text.find('/') { + Some(slash) => (&text[..slash], &text[slash..]), + None => (text, ""), + }; + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) => (host, port.parse::().map_err(|_| format!("credential binding {text}: the port is not a number"))?), + None => (authority, 443), + }; + if host.is_empty() { + return Err(format!("credential binding {text}: no host")); + } + if port != 443 { + return Err(format!("credential binding {text}: the proxy carries HTTPS on port 443 only")); + } + Ok(Binding { host: host.to_ascii_lowercase(), port, path: path.trim_end_matches('/').to_string() }) +} + +impl Binding { + fn covers(&self, host: &str, port: u16, path: &str) -> bool { + crate::http_proxy::host_matches(host, &self.host) && self.port == port && path_under(path, &self.path) + } +} + +/// Whether `path` (a request target, query and all) is `prefix` or below it: +/// `/v1` covers `/v1`, `/v1/messages` and `/v1?x`, not `/v10`. +fn path_under(target: &str, prefix: &str) -> bool { + if prefix.is_empty() { + return true; + } + let path = target.split(['?', '#']).next().unwrap_or(""); + path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/')) +} + +/// What the request looks like once the placeholders in it have been dealt +/// with: every bound one replaced, or the first one that may not go here. +#[derive(Debug, PartialEq)] +pub(crate) enum Rewrite { + /// The head to send on, and the names of the credentials put on. + Send(String, Vec), + /// A placeholder for this credential is in a request it is not bound to. + Refuse(String), +} + +/// Replaces each credential's placeholder in the request head (request line, +/// query and headers alike) with its value, where the credential is bound to +/// this host, port and path; refuses the request when a placeholder is there +/// and it is not. +pub(crate) fn rewrite_head(credentials: &[Credential], head: &str, host: &str, port: u16, target: &str) -> Rewrite { + let mut out = head.to_string(); + let mut used = Vec::new(); + for credential in credentials { + if !out.contains(&credential.placeholder) { + continue; + } + if !credential.bindings.iter().any(|binding| binding.covers(host, port, target)) { + return Rewrite::Refuse(credential.name.clone()); + } + out = out.replace(&credential.placeholder, &credential.value); + used.push(credential.name.clone()); + } + Rewrite::Send(out, used) +} + +/// The CA made for one run, and the leaf certificates it has signed so far. +pub(crate) struct Broker { + pub(crate) credentials: Vec, + ca: rcgen::Certificate, + ca_key: rcgen::KeyPair, + leaves: Mutex>>, + upstream: Arc, + /// The directory holding the CA certificate and the trust bundle, removed + /// when the proxy stops. + pub(crate) dir: String, + pub(crate) ca_path: String, + pub(crate) bundle_path: Option, +} + +/// One credential as the caller hands it over: the value read from where the +/// configuration said, and the bindings as written. +#[derive(serde::Deserialize)] +pub(crate) struct Requested { + name: String, + value: String, + hosts: Vec, +} + +fn provider() -> Arc { + Arc::new(rustls::crypto::ring::default_provider()) +} + +impl Broker { + /// A broker for these credentials: placeholders minted, the run's CA made + /// and written out with a trust bundle the command is pointed at. + pub(crate) fn new(requested: Vec) -> Result { + let credentials = merged(requested)?; + let (ca, ca_key) = make_ca()?; + let dir = format!("/tmp/porta-credentials-{}", mint_token()); + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {dir}: {e}"))?; + let ca_path = format!("{dir}/ca.pem"); + std::fs::write(&ca_path, ca.pem()).map_err(|e| format!("cannot write {ca_path}: {e}"))?; + // SSL_CERT_FILE and the variables like it replace the trust store + // rather than add to it, so what they point at has to hold the + // system's roots as well, or every other host the command reaches + // would stop verifying. + let bundle_path = match system_roots() { + Some(roots) => { + let path = format!("{dir}/bundle.pem"); + let mut bundle = roots; + if !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(&ca.pem()); + std::fs::write(&path, bundle).map_err(|e| format!("cannot write {path}: {e}"))?; + Some(path) + } + None => None, + }; + Ok(Broker { credentials, ca, ca_key, leaves: Mutex::new(HashMap::new()), upstream: upstream_config()?, dir, ca_path, bundle_path }) + } + + /// Whether any credential is bound to this host and port, so its + /// connections are opened rather than tunnelled. + pub(crate) fn intercepts(&self, host: &str, port: u16) -> bool { + self.credentials.iter().any(|c| c.bindings.iter().any(|b| crate::http_proxy::host_matches(host, &b.host) && b.port == port)) + } + + /// The hosts to add to an allow-list: a credential bound to a host the + /// list does not reach would be handed in for nothing. + pub(crate) fn hosts(&self) -> Vec { + self.credentials.iter().flat_map(|c| c.bindings.iter().map(|b| b.host.clone())).collect() + } + + /// What the command's environment gains: each credential's placeholder + /// under its name, and the variables that make its TLS clients trust the + /// run's CA. + pub(crate) fn child_env(&self) -> Vec<(String, String)> { + let mut env: Vec<(String, String)> = self.credentials.iter().map(|c| (c.name.clone(), c.placeholder.clone())).collect(); + // Node adds this file to its own roots; it is the one client here that + // takes an addition rather than a replacement. + env.push(("NODE_EXTRA_CA_CERTS".into(), self.ca_path.clone())); + if let Some(bundle) = &self.bundle_path { + for name in ["SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO"] { + env.push((name.into(), bundle.clone())); + } + } + env + } + + fn leaf_for(&self, host: &str) -> Result, String> { + let mut leaves = crate::locking::locked(&self.leaves); + if let Some(config) = leaves.get(host) { + return Ok(config.clone()); + } + let config = Arc::new(self.mint_leaf(host)?); + leaves.insert(host.to_string(), config.clone()); + Ok(config) + } + + fn mint_leaf(&self, host: &str) -> Result { + let mut params = rcgen::CertificateParams::new(vec![host.to_string()]).map_err(|e| format!("leaf for {host}: {e}"))?; + params.distinguished_name.push(rcgen::DnType::CommonName, host); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + // Strict verifiers (Python 3.13 and later, by default) refuse a leaf + // that does not say which key signed it. + params.use_authority_key_identifier_extension = true; + set_validity(&mut params); + let key = rcgen::KeyPair::generate().map_err(|e| format!("leaf key: {e}"))?; + let leaf = params.signed_by(&key, &self.ca, &self.ca_key).map_err(|e| format!("leaf for {host}: {e}"))?; + let chain = vec![leaf.der().clone(), self.ca.der().clone()]; + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())); + let mut config = rustls::ServerConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .map_err(|e| format!("server config: {e}"))? + .with_no_client_auth() + .with_single_cert(chain, key) + .map_err(|e| format!("server config: {e}"))?; + // HTTP/1.1 alone: the proxy reads one request head and one body, and + // a client that would speak HTTP/2 falls back when it is not offered. + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(config) + } + + /// Takes over a CONNECT to a bound host: answers it, speaks TLS to the + /// command as `host`, and carries one request on to the real server with + /// the credential put on. + pub(crate) fn intercept(&self, mut client: TcpStream, audit: &Option, host: &str, port: u16) { + let config = match self.leaf_for(host) { + Ok(config) => config, + Err(reason) => { + let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); + return audit_log(audit, &Decision::new(host, port, "error", reason)); + } + }; + let _ = client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); + let Ok(connection) = rustls::ServerConnection::new(config) else { return }; + let _ = client.set_read_timeout(Some(Duration::from_secs(120))); + let mut reader = BufReader::new(rustls::StreamOwned::new(connection, client)); + let Some((request_line, headers)) = read_head(&mut reader) else { return }; + let target = request_line.split_whitespace().nth(1).unwrap_or("/").to_string(); + let path = target.split(['?', '#']).next().unwrap_or("/").to_string(); + let head = format!("{}\r\n{}\r\n", request_line, headers.join("\r\n")); + match rewrite_head(&self.credentials, &head, host, port, &target) { + Rewrite::Refuse(name) => { + let _ = reader.get_mut().write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + close(reader.get_mut()); + audit_log(audit, &Decision::new(host, port, "deny", format!("credential {name} is not bound to {host}{path}: the request was not sent"))); + } + Rewrite::Send(head, used) => { + if used.is_empty() { + audit_log(audit, &Decision::new(host, port, "allow", format!("opened for its credentials; {path} carried none"))); + } + for name in &used { + audit_log(audit, &Decision::new(host, port, "substitute", format!("credential {name} put on for {path}"))); + } + if let Err(reason) = self.forward(&mut reader, &head, host, port) { + let _ = reader.get_mut().write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + audit_log(audit, &Decision::new(host, port, "error", reason)); + } + close(reader.get_mut()); + } + } + } + + /// Sends the rewritten head and the body after it to the real server, then + /// the whole response back. + fn forward(&self, client: &mut BufReader, head: &str, host: &str, port: u16) -> Result<(), String> { + let upstream = match dial(host, port) { + Ok(stream) => stream, + Err(Dial::Blocked(reason)) | Err(Dial::Failed(reason)) => return Err(reason), + }; + let _ = upstream.set_read_timeout(Some(Duration::from_secs(300))); + let name = ServerName::try_from(host.to_string()).map_err(|e| format!("{host}: {e}"))?; + let connection = rustls::ClientConnection::new(self.upstream.clone(), name).map_err(|e| format!("upstream TLS: {e}"))?; + let mut server = rustls::StreamOwned::new(connection, upstream); + let (lines, framing) = outgoing_head(head); + server.write_all(lines.as_bytes()).map_err(|e| format!("upstream TLS: {e}"))?; + copy_body(client, &mut server, framing).map_err(|e| format!("request body: {e}"))?; + server.flush().map_err(|e| format!("upstream: {e}"))?; + let mut buffer = [0u8; 16384]; + loop { + match server.read(&mut buffer) { + Ok(0) => break, + Ok(n) => client.get_mut().write_all(&buffer[..n]).map_err(|e| format!("client: {e}"))?, + // A server that closes without close_notify has still sent the + // whole response: it was asked for `Connection: close`. + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(format!("upstream read: {e}")), + } + } + Ok(()) + } +} + +/// Credentials by name: two bindings given for one name are one credential +/// with one placeholder. A name with no value is refused rather than handed +/// in as an empty key. +fn merged(requested: Vec) -> Result, String> { + let mut out: Vec = Vec::new(); + for r in requested { + if r.value.is_empty() { + return Err(format!("credential {}: no value to hand over (is it set in porta's environment?)", r.name)); + } + let bindings = r.hosts.iter().map(|h| parse_binding(h)).collect::, _>>()?; + match out.iter_mut().find(|c| c.name == r.name) { + Some(existing) => existing.bindings.extend(bindings), + None => out.push(Credential { placeholder: format!("porta-cred-{}-{}", r.name, mint_token()), name: r.name, value: r.value, bindings }), + } + } + Ok(out) +} + +fn make_ca() -> Result<(rcgen::Certificate, rcgen::KeyPair), String> { + let mut params = rcgen::CertificateParams::default(); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.distinguished_name.push(rcgen::DnType::CommonName, "porta run CA (this run only)"); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign, rcgen::KeyUsagePurpose::DigitalSignature]; + set_validity(&mut params); + let key = rcgen::KeyPair::generate().map_err(|e| format!("CA key: {e}"))?; + let ca = params.self_signed(&key).map_err(|e| format!("CA: {e}"))?; + Ok((ca, key)) +} + +/// From yesterday to a week from now: long enough for any run, short enough +/// that a leaked certificate is soon worthless, and within the lifetimes +/// clients accept for a server certificate. +fn set_validity(params: &mut rcgen::CertificateParams) { + let days = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86400).unwrap_or(0) as i64; + let (y1, m1, d1) = civil_from_days(days - 1); + let (y2, m2, d2) = civil_from_days(days + 7); + params.not_before = rcgen::date_time_ymd(y1, m1, d1); + params.not_after = rcgen::date_time_ymd(y2, m2, d2); +} + +/// Days since 1970-01-01 as a calendar date (Howard Hinnant's algorithm). +fn civil_from_days(days: i64) -> (i32, u8, u8) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 }; + (year as i32, month as u8, day as u8) +} + +/// The system's trust store as PEM: the host's own SSL_CERT_FILE if it has +/// one, else the first bundle a distribution keeps in its usual place. +fn system_roots() -> Option { + let mut candidates: Vec = std::env::var("SSL_CERT_FILE").into_iter().collect(); + candidates.extend( + ["/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/ca-bundle.pem"] + .iter() + .map(|s| s.to_string()), + ); + candidates.iter().find_map(|path| std::fs::read_to_string(path).ok().filter(|text| text.contains("BEGIN CERTIFICATE"))) +} + +/// How porta verifies the real server: the webpki roots, plus, for the +/// integration suite alone, the CA named by PORTA_TEST_UPSTREAM_CA. +fn upstream_config() -> Result, String> { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + if let Ok(path) = std::env::var("PORTA_TEST_UPSTREAM_CA") { + for cert in CertificateDer::pem_file_iter(&path).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))? { + let cert = cert.map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; + roots.add(cert).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; + } + } + let mut config = rustls::ClientConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .map_err(|e| format!("client config: {e}"))? + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(Arc::new(config)) +} + +/// How the request's body is framed, from its headers. +#[derive(Debug, PartialEq, Clone, Copy)] +pub(crate) enum Framing { + None, + Length(u64), + Chunked, +} + +/// The head as it goes to the server: hop-by-hop headers dropped, `Expect` +/// dropped (the body follows at once; a client waiting on 100 Continue sends +/// it after its own short wait), and `Connection: close` asked for. +pub(crate) fn outgoing_head(head: &str) -> (String, Framing) { + let mut lines = head.split("\r\n").filter(|l| !l.is_empty()); + let request_line = lines.next().unwrap_or(""); + let mut out = format!("{request_line}\r\n"); + let mut framing = Framing::None; + for line in lines { + let Some((name, value)) = line.split_once(':') else { continue }; + let lower = name.trim().to_ascii_lowercase(); + match lower.as_str() { + "connection" | "proxy-connection" | "keep-alive" | "expect" | "proxy-authorization" => continue, + "transfer-encoding" if value.to_ascii_lowercase().contains("chunked") => framing = Framing::Chunked, + "content-length" if framing != Framing::Chunked => framing = Framing::Length(value.trim().parse().unwrap_or(0)), + _ => {} + } + out.push_str(line); + out.push_str("\r\n"); + } + out.push_str("Connection: close\r\n\r\n"); + (out, framing) +} + +fn copy_body(from: &mut R, to: &mut W, framing: Framing) -> std::io::Result<()> { + match framing { + Framing::None => Ok(()), + Framing::Length(n) => { + let copied = std::io::copy(&mut from.take(n), to)?; + if copied < n { Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "body shorter than Content-Length")) } else { Ok(()) } + } + Framing::Chunked => loop { + let mut size_line = String::new(); + if from.read_line(&mut size_line)? == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "chunked body ended early")); + } + to.write_all(size_line.as_bytes())?; + let size = u64::from_str_radix(size_line.trim().split(';').next().unwrap_or("0").trim(), 16).unwrap_or(0); + if size == 0 { + // Trailers, then the blank line that ends them. + loop { + let mut line = String::new(); + if from.read_line(&mut line)? == 0 { + return Ok(()); + } + to.write_all(line.as_bytes())?; + if line == "\r\n" || line == "\n" { + return Ok(()); + } + } + } + std::io::copy(&mut from.take(size + 2), to)?; + }, + } +} + +/// The request line and headers, up to 64 KiB of them. +fn read_head(reader: &mut R) -> Option<(String, Vec)> { + let mut request_line = String::new(); + if reader.read_line(&mut request_line).ok()? == 0 { + return None; + } + let mut headers = Vec::new(); + let mut total = request_line.len(); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => return None, + Ok(_) if line == "\r\n" || line == "\n" => return Some((request_line.trim_end().to_string(), headers)), + Ok(n) => { + total += n; + if total > 65536 { + return None; + } + headers.push(line.trim_end().to_string()); + } + } + } +} + +fn close(stream: &mut rustls::StreamOwned) { + stream.conn.send_close_notify(); + let _ = stream.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn credential(name: &str, value: &str, hosts: &[&str]) -> Credential { + Credential { + name: name.into(), + value: value.into(), + placeholder: format!("porta-cred-{name}-0123"), + bindings: hosts.iter().map(|h| parse_binding(h).unwrap()).collect(), + } + } + + #[test] + fn bindings_parse_and_refuse_other_ports() { + assert_eq!(parse_binding("API.example.com").unwrap(), Binding { host: "api.example.com".into(), port: 443, path: "".into() }); + assert_eq!(parse_binding("api.example.com:443/v1/").unwrap().path, "/v1"); + assert!(parse_binding("api.example.com:8443").is_err()); + assert!(parse_binding(":443").is_err()); + } + + #[test] + fn a_placeholder_is_put_on_only_where_it_is_bound() { + let creds = vec![credential("KEY", "real-secret", &["api.example.com/v1"])]; + let head = "GET /v1/models?k=porta-cred-KEY-0123 HTTP/1.1\r\nx-api-key: porta-cred-KEY-0123\r\n\r\n"; + match rewrite_head(&creds, head, "api.example.com", 443, "/v1/models?k=porta-cred-KEY-0123") { + Rewrite::Send(out, used) => { + assert!(!out.contains("porta-cred-KEY")); + assert_eq!(out.matches("real-secret").count(), 2); + assert_eq!(used, vec!["KEY".to_string()]); + } + other => panic!("{other:?}"), + } + assert_eq!(rewrite_head(&creds, head, "api.example.com", 443, "/admin"), Rewrite::Refuse("KEY".into())); + assert_eq!(rewrite_head(&creds, head, "evil.example.com", 443, "/v1/models"), Rewrite::Refuse("KEY".into())); + assert!(!path_under("/v10", "/v1")); + } + + #[test] + fn a_request_without_a_placeholder_goes_as_it_is() { + let creds = vec![credential("KEY", "real-secret", &["api.example.com"])]; + let head = "GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n"; + assert_eq!(rewrite_head(&creds, head, "api.example.com", 443, "/"), Rewrite::Send(head.into(), vec![])); + } + + #[test] + fn the_outgoing_head_asks_for_close_and_knows_its_body() { + let (head, framing) = outgoing_head("POST /v1 HTTP/1.1\r\nHost: a\r\nConnection: keep-alive\r\nExpect: 100-continue\r\nContent-Length: 12\r\n\r\n"); + assert!(head.ends_with("Connection: close\r\n\r\n") && !head.contains("keep-alive") && !head.contains("Expect")); + assert_eq!(framing, Framing::Length(12)); + assert_eq!(outgoing_head("POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n").1, Framing::Chunked); + } + + #[test] + fn days_become_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(20_724), (2026, 9, 28)); + assert_eq!(civil_from_days(11_016), (2000, 2, 29)); + } +} diff --git a/native/http_proxy.rs b/native/http_proxy.rs index f83a4f7..dd0b850 100644 --- a/native/http_proxy.rs +++ b/native/http_proxy.rs @@ -8,6 +8,7 @@ //! Split out of wasmtime_bridge so the FFI surface there stays a surface: this //! module owns the listener and the policy matching. +use crate::credential_broker::{Broker, Requested}; use crate::locking::locked; use crate::proxy_egress::{basic_credential, dial, mint_token, Dial}; use crate::proxy_audit::{audit_log, Decision}; @@ -33,11 +34,17 @@ struct ProxyPolicy { /// carrying this run's allow-list. The token is minted per run and handed /// to the child in its `HTTPS_PROXY`. credential: String, + /// The credentials handed in as placeholders (#37), and the CA their + /// hosts' connections are opened with. None when the run has none: then + /// every CONNECT is a tunnel, as it always was. + broker: Option>, } struct ProxyInstance { port: u16, shutdown: Arc, + /// The broker's CA and trust bundle, removed when the proxy stops. + credentials_dir: Option, } static PROXIES: Mutex>> = Mutex::new(Vec::new()); @@ -48,7 +55,7 @@ static PROXIES: Mutex>> = Mutex::new(Vec::new()); /// the same name, as DNS reads it: `evil.com.` resolves where `evil.com` does, /// and before this a deny-list naming `evil.com` let it through (found by /// `scripts/fuzz.py proxy`). -fn host_matches(host: &str, pattern: &str) -> bool { +pub(crate) fn host_matches(host: &str, pattern: &str) -> bool { let host = host.strip_suffix('.').unwrap_or(host); let pattern = pattern.strip_suffix('.').unwrap_or(pattern); if pattern.eq_ignore_ascii_case(host) { @@ -95,7 +102,10 @@ fn handle_connection(client: TcpStream, policy: Arc, audit_path: Ar let mut reader = BufReader::new(client); let Some((request_line, headers)) = read_request(&mut reader) else { return }; match connect_target(&request_line, &headers, &policy) { - Ok((host, port)) => tunnel(client_for_write, &audit_path, &host, port), + Ok((host, port)) => match &policy.broker { + Some(broker) if broker.intercepts(&host, port) => broker.intercept(client_for_write, &audit_path, &host, port), + _ => tunnel(client_for_write, &audit_path, &host, port), + }, Err((status, decision)) => { let _ = client_for_write.write_all(status.as_bytes()); audit_log(&audit_path, &decision); @@ -188,16 +198,38 @@ fn tunnel(mut client: TcpStream, audit_path: &Option, host: &str, port: /// Start the CONNECT proxy on 127.0.0.1:. /// `allow_json` and `deny_json` are JSON arrays of hostname patterns; only one /// should be non-empty. `audit_path` is a file path for JSONL logging (empty = disabled). -/// Returns JSON: {"handle":,"port":} on success, {"error":"..."} otherwise. +/// `credentials_json` is `[{"name","value","hosts":[...]}]`, the credentials to +/// hand in as placeholders; with any, the proxy may run with neither list, +/// and then every host is reachable through it. +/// Returns JSON: {"handle","port","token","env":[[name,value],...]} on success, +/// {"error":"..."} otherwise. `env` is what the command's environment gains. pub fn wt_proxy_start( allow_json: impl AsRef, deny_json: impl AsRef, audit_path: impl AsRef, + credentials_json: impl AsRef, ) -> String { - let policy = match requested_policy(allow_json.as_ref(), deny_json.as_ref()) { + let requested: Vec = match serde_json::from_str(credentials_json.as_ref()) { + Ok(requested) => requested, + Err(e) => return error_json(&format!("credentials: {e}")), + }; + let broker = if requested.is_empty() { + None + } else { + match Broker::new(requested) { + Ok(broker) => Some(Arc::new(broker)), + Err(reason) => return error_json(&reason), + } + }; + let mut policy = match requested_policy(allow_json.as_ref(), deny_json.as_ref(), broker.is_some()) { Ok(policy) => policy, Err(reason) => return format!("{{\"error\":\"{}\"}}", reason), }; + // A credential bound to a host the allow-list leaves out would be handed + // in for nothing; binding it says the host is meant to be reached. + if let (ProxyMode::Allow, Some(broker)) = (policy.mode, &broker) { + policy.patterns.extend(broker.hosts()); + } let listener = match loopback_listener() { Ok(listener) => listener, Err(reason) => return format!("{{\"error\":\"{}\"}}", reason), @@ -209,28 +241,43 @@ pub fn wt_proxy_start( let shutdown = Arc::new(AtomicBool::new(false)); let audit = Some(audit_path.as_ref().to_string()).filter(|path| !path.is_empty()); let token = mint_token(); - let policy = ProxyPolicy { credential: basic_credential(&token), ..policy }; + let env: Vec<(String, String)> = broker.as_ref().map(|b| b.child_env()).unwrap_or_default(); + let credentials_dir = broker.as_ref().map(|b| b.dir.clone()); + let policy = ProxyPolicy { credential: basic_credential(&token), broker, ..policy }; serve(listener, Arc::new(policy), Arc::new(audit), shutdown.clone()); let handle = { let mut proxies = locked(&PROXIES); - proxies.push(Some(ProxyInstance { port, shutdown })); + proxies.push(Some(ProxyInstance { port, shutdown, credentials_dir })); (proxies.len() - 1) as i64 }; - format!("{{\"handle\":{},\"port\":{},\"token\":\"{}\"}}", handle, port, token) + format!( + "{{\"handle\":{},\"port\":{},\"token\":\"{}\",\"env\":{}}}", + handle, + port, + token, + serde_json::to_string(&env).unwrap_or_else(|_| "[]".into()) + ) } -/// The policy these two lists ask for. Exactly one of them must be given: -/// an allow-list and a deny-list together have no single meaning. The -/// credential is filled in by the caller once the run's token is minted. -fn requested_policy(allow_json: &str, deny_json: &str) -> Result { +fn error_json(reason: &str) -> String { + format!("{{\"error\":{}}}", serde_json::to_string(reason).unwrap_or_else(|_| "\"\"".into())) +} + +/// The policy these two lists ask for. At most one of them may be given: an +/// allow-list and a deny-list together have no single meaning. Neither is an +/// error unless the run hands credentials in, which is reason enough for a +/// proxy: then nothing is denied. The credential and the broker are filled in +/// by the caller. +fn requested_policy(allow_json: &str, deny_json: &str, has_credentials: bool) -> Result { let allow: Vec = serde_json::from_str(allow_json).unwrap_or_default(); let deny: Vec = serde_json::from_str(deny_json).unwrap_or_default(); let credential = String::new(); match (allow.is_empty(), deny.is_empty()) { (false, false) => Err("allow and deny are mutually exclusive"), - (false, true) => Ok(ProxyPolicy { mode: ProxyMode::Allow, patterns: allow, credential }), - (true, false) => Ok(ProxyPolicy { mode: ProxyMode::Deny, patterns: deny, credential }), + (false, true) => Ok(ProxyPolicy { mode: ProxyMode::Allow, patterns: allow, credential, broker: None }), + (true, false) => Ok(ProxyPolicy { mode: ProxyMode::Deny, patterns: deny, credential, broker: None }), + (true, true) if has_credentials => Ok(ProxyPolicy { mode: ProxyMode::Deny, patterns: vec![], credential, broker: None }), (true, true) => Err("neither allow nor deny list provided"), } } @@ -271,6 +318,9 @@ pub fn wt_proxy_stop(handle: i64) -> i64 { } if let Some(inst) = &proxies[idx] { inst.shutdown.store(true, Ordering::Relaxed); + if let Some(dir) = &inst.credentials_dir { + let _ = std::fs::remove_dir_all(dir); + } } proxies[idx] = None; 0 diff --git a/native/proxy_egress.rs b/native/proxy_egress.rs index 2928a6d..739f0e2 100644 --- a/native/proxy_egress.rs +++ b/native/proxy_egress.rs @@ -79,6 +79,9 @@ fn blocked_v6(v6: &std::net::Ipv6Addr) -> bool { /// connected within ten seconds. Resolved once and dialled by address, so /// what was checked is what is connected. pub(crate) fn dial(host: &str, port: u16) -> Result { + if let Some(address) = test_upstream(host) { + return TcpStream::connect_timeout(&address, Duration::from_secs(10)).map_err(|e| Dial::Failed(format!("upstream connect failed: {e}"))); + } let addresses: Vec = (host, port) .to_socket_addrs() .map_err(|e| Dial::Failed(format!("resolve failed: {e}")))? @@ -95,3 +98,16 @@ pub(crate) fn dial(host: &str, port: u16) -> Result { TcpStream::connect_timeout(address, Duration::from_secs(10)) .map_err(|e| Dial::Failed(format!("upstream connect failed: {e}"))) } + +/// For scripts/integration.py alone: `PORTA_TEST_UPSTREAM=host=127.0.0.1:port,...` +/// sends a CONNECT for `host` to a local test server, past the address guard, +/// so the credential broker can be exercised end to end without the internet. +/// It is read from porta's own environment, which the confined command +/// cannot set; whoever can set it already runs porta. +fn test_upstream(host: &str) -> Option { + let spec = std::env::var("PORTA_TEST_UPSTREAM").ok()?; + spec.split(',').find_map(|entry| { + let (name, address) = entry.split_once('=')?; + if name.trim().eq_ignore_ascii_case(host) { address.trim().parse().ok() } else { None } + }) +} diff --git a/scripts/integration.py b/scripts/integration.py index 3702415..f34be1e 100644 --- a/scripts/integration.py +++ b/scripts/integration.py @@ -2,6 +2,8 @@ """Offline process-level regressions; run against the newly built binary.""" import json import http.server +import os +import ssl import threading import pathlib import platform @@ -168,6 +170,92 @@ def denied(attempt): thread.join() print('PASS: checked HTTP tool does not follow redirects') + # Credentials handed in as placeholders (#37). The command sees + # porta-cred-TEST_KEY-; the proxy opens its TLS to the bound host + # with the run's CA, puts the real value on, and verifies the real server + # (here a local one, through the test-only PORTA_TEST_UPSTREAM and + # PORTA_TEST_UPSTREAM_CA). The same placeholder on a path it is not bound + # to is refused before anything is sent; a host no credential names is + # tunnelled as before, its TLS never touched. + tls = root / 'credential-tls' + tls.mkdir() + def openssl(*args): + subprocess.run(['openssl', *args], cwd=tls, check=True, capture_output=True) + (tls / 'ca.ext').write_text('basicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,digitalSignature\nsubjectKeyIdentifier=hash\n') + (tls / 'server.ext').write_text('subjectAltName=DNS:api.example.test,DNS:other.example.test\nextendedKeyUsage=serverAuth\n' + 'subjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid,issuer\n') + openssl('req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', 'ca.key', '-out', 'ca.csr', '-subj', '/CN=porta test upstream CA') + openssl('x509', '-req', '-in', 'ca.csr', '-signkey', 'ca.key', '-out', 'ca.pem', '-days', '2', '-extfile', 'ca.ext') + openssl('req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', 'server.key', '-out', 'server.csr', '-subj', '/CN=api.example.test') + openssl('x509', '-req', '-in', 'server.csr', '-CA', 'ca.pem', '-CAkey', 'ca.key', '-CAcreateserial', '-out', 'server.pem', + '-days', '2', '-extfile', 'server.ext') + seen = [] + class KeyedServer(http.server.BaseHTTPRequestHandler): + def do_GET(self): + seen.append((self.headers.get('Host', '').split(':')[0], self.path, self.headers.get('x-api-key'))) + body = b'upstream ok' + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + """Silence the request log; these tests assert on their own output.""" + upstream = http.server.ThreadingHTTPServer(('127.0.0.1', 0), KeyedServer) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(tls / 'server.pem', tls / 'server.key') + upstream.socket = context.wrap_socket(upstream.socket, server_side=True) + upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True) + upstream_thread.start() + probe = r"""import os, ssl, sys, urllib.error, urllib.request +key = os.environ['TEST_KEY'] +print('placeholder', key.startswith('porta-cred-TEST_KEY-') and 'real-secret' not in key) +def get(url, cafile=None): + context = ssl.create_default_context(cafile=cafile) + opener = urllib.request.build_opener(urllib.request.ProxyHandler(), urllib.request.HTTPSHandler(context=context)) + try: + with opener.open(urllib.request.Request(url, headers={'x-api-key': key}), timeout=10) as reply: + return reply.status + except urllib.error.HTTPError as error: + return error.code +print('bound', get('https://api.example.test/v1/models')) +print('unbound-path', get('https://api.example.test/admin')) +print('unbound-host', get('https://other.example.test/v1/models', cafile=sys.argv[1])) +""" + audit = root / 'credential-audit.jsonl' + secret = 'real-secret-value-for-the-test' + try: + environment = {**os.environ, 'TEST_KEY': secret, 'PORTA_TEST_UPSTREAM_CA': str(tls / 'ca.pem'), + 'PORTA_TEST_UPSTREAM': f'api.example.test=127.0.0.1:{upstream.server_port},' + f'other.example.test=127.0.0.1:{upstream.server_port}'} + result = run('run', sys.executable, '--credential', 'TEST_KEY=api.example.test/v1', '--proxy-audit', str(audit), + '--', '-c', probe, str(tls / 'ca.pem'), env=environment) + assert result.returncode == 0, result.stderr + lines = dict(line.split(' ', 1) for line in result.stdout.splitlines()) + assert lines == {'placeholder': 'True', 'bound': '200', 'unbound-path': '403', 'unbound-host': '200'}, (result.stdout, result.stderr) + # The bound request reached the server with the real value; the refused + # one never reached it; the tunnelled one carried the placeholder. + assert ('api.example.test', '/v1/models', secret) in seen, seen + assert not any(path == '/admin' for _, path, _ in seen), seen + assert any(host == 'other.example.test' and key.startswith('porta-cred-TEST_KEY-') for host, _, key in seen), seen + trail = audit.read_text() + assert '"substitute"' in trail and 'credential TEST_KEY put on for /v1/models' in trail, trail + assert 'credential TEST_KEY is not bound to api.example.test/admin' in trail, trail + assert secret not in trail and secret not in result.stderr and secret not in result.stdout + # The run's CA and trust bundle go when the run does. + assert not list(pathlib.Path('/tmp').glob('porta-credentials-*')) or all( + p.stat().st_mtime < time.time() - 60 for p in pathlib.Path('/tmp').glob('porta-credentials-*')) + result = run('run', '/bin/sh', '--credential', 'PORTA_UNSET_FOR_TEST=api.example.test', '--', '-c', 'exit 0', + env={k: v for k, v in os.environ.items() if k != 'PORTA_UNSET_FOR_TEST'}) + assert result.returncode != 0 and 'PORTA_UNSET_FOR_TEST is not set' in result.stderr, result + result = run('run', '/bin/sh', '--credential', 'TEST_KEY=api.example.test:8443', '--', '-c', 'exit 0', env=environment) + assert result.returncode != 0 and 'port 443 only' in result.stderr, result + finally: + upstream.shutdown() + upstream.server_close() + upstream_thread.join() + print('PASS: a credential is a placeholder inside, put on for its bound host and path only; ' + 'refused elsewhere and recorded without its value; other hosts tunnelled untouched') + # A directory the run is never granted. It has to be somewhere an ordinary # user can create — porta refuses to run as root, so /opt is out — and # outside both the always-writable roots and the strict read set, or the diff --git a/src/cli.almd b/src/cli.almd index 6160fab..a272baf 100644 --- a/src/cli.almd +++ b/src/cli.almd @@ -4,6 +4,7 @@ import process import self.dispatch import self.sandbox import self.manifest +import self.proxy import self.util type Options = { path: String, @@ -25,6 +26,8 @@ type Options = { proxy_allow: List[String], proxy_deny: List[String], proxy_audit: String, + // Credentials handed in as placeholders and put on in the proxy (--credential, [[credentials]]). + credentials: List[proxy.Credential], read_policy: String, allow_root: Bool, // Host variables copied into the child by name; its environment otherwise starts empty. @@ -75,6 +78,7 @@ fn default_options() -> Options = { proxy_allow: [], proxy_deny: [], proxy_audit: "", + credentials: [], read_policy: "open", allow_root: false, env_pass: [], @@ -130,6 +134,7 @@ fn apply_option(opts: Options, arg: String, next: String) -> Result[ParsedOption "--proxy-allow" => valued({...opts, proxy_allow: opts.proxy_allow + string.split(next, ",")}), "--proxy-deny" => valued({...opts, proxy_deny: opts.proxy_deny + string.split(next, ",")}), "--proxy-audit" => valued({...opts, proxy_audit: next}), + "--credential" => valued(with_credential(opts, next)), "--read-policy" => valued({...opts, read_policy: next}), "--allow-root" => flag({...opts, allow_root: true}), "--no-net" => flag({...opts, no_net: true}), @@ -178,6 +183,15 @@ fn with_env(opts: Options, text: String) -> Options = none => opts, } +// --credential NAME=HOST[,HOST...]: the host variable NAME, handed in as a +// placeholder and put on for requests to these hosts (`host[:443][/path]`). +fn with_credential(opts: Options, text: String) -> Options = + match util.split_at_eq(text) { + some(kv) => {...opts, credentials: opts.credentials + [{name: kv.key, from_env: kv.key, + hosts: string.split(kv.val, ",") |> list.map(string.trim) |> list.filter((h) => h != "")}]}, + none => opts, + } + fn with_secret(opts: Options, text: String) -> Options = match util.split_at_eq(text) { some(kv) => {...opts, secrets: opts.secrets + [kv]}, @@ -270,6 +284,11 @@ fn line_when(present: Bool, line: String) -> List[String] = fn policy_toml(opts: Options) -> String = { let mounts = if list.len(opts.preopen_dirs) > 0 then opts.preopen_dirs else ["."] let proxy_on = list.len(opts.proxy_allow) > 0 or list.len(opts.proxy_deny) > 0 or opts.proxy_audit != "" + // Where each credential goes is policy and is saved; its value is read from + // the named variable when the run starts, never written here. + let credential_tables = opts.credentials |> list.flat_map((c) => + ["", "[[credentials]]", "name = " + toml_quote(c.name), "from = { env = " + toml_quote(c.from_env) + " }", + "hosts = " + toml_strings(c.hosts)]) let has_env = list.len(opts.env_vars) > 0 or list.len(opts.secrets) > 0 let lines = ["[runtime]", "type = \"native\"", "command = " + toml_quote(opts.path), "", "[sandbox]", @@ -294,6 +313,7 @@ fn policy_toml(opts: Options) -> String = { + line_when(list.len(opts.proxy_allow) > 0, "allow = " + toml_strings(opts.proxy_allow)) + line_when(list.len(opts.proxy_deny) > 0, "deny = " + toml_strings(opts.proxy_deny)) + line_when(opts.proxy_audit != "", "audit = " + toml_quote(opts.proxy_audit)) + + credential_tables + line_when(has_env, "\n# -e values and secrets were not saved; add [env] and [secrets] yourself") string.join(lines, "\n") + "\n" } diff --git a/src/config.almd b/src/config.almd index bf0b14c..48486c5 100644 --- a/src/config.almd +++ b/src/config.almd @@ -6,6 +6,7 @@ import json import process import self.wasm_rt import self.dispatch +import self.proxy type PortaConfig = { runtime_type: String, @@ -24,6 +25,7 @@ type PortaConfig = { proxy_allow: List[String], proxy_deny: List[String], proxy_audit: String, + credentials: List[proxy.Credential], env_pass: List[String], allow_unix: List[String], allow_bind: List[String], @@ -56,6 +58,7 @@ fn default_porta_config() -> PortaConfig = { proxy_allow: [], proxy_deny: [], proxy_audit: "", + credentials: [], env_pass: [], allow_unix: [], allow_bind: [], @@ -97,6 +100,7 @@ effect fn load_porta_config(path: String) -> Result[PortaConfig, String] = { proxy_allow: strings_at(px, "allow", []), proxy_deny: strings_at(px, "deny", []), proxy_audit: json.get_string(px, "audit") ?? "", + credentials: credential_tables(v), env_pass: strings_at(sb, "env-pass", []), allow_unix: strings_at(sb, "unix", []), allow_bind: strings_at(sb, "bind", []), @@ -122,6 +126,15 @@ fn strings_at(table: Value, key: String, fallback: List[String]) -> List[String] some(items) => items |> list.filter_map((item) => match value.as_string(item) { ok(s) => some(s), err(_) => none }), } +// [[credentials]] — name, from = { env = "VAR" } (default: the name itself), +// hosts = ["host[:443][/path]", ...]. A table without a name is left out. +fn credential_tables(v: Value) -> List[proxy.Credential] = + (json.get_array(v, "credentials") ?? []) |> list.filter_map((t) => { + let name = json.get_string(t, "name") ?? "" + let from = json.get_string(value.field(t, "from") ?? value.object([]), "env") ?? name + if name == "" then none else some({name: name, from_env: from, hosts: strings_at(t, "hosts", [])}) + }) + // [env] — a plain key=value table. fn env_table(v: Value) -> List[dispatch.EnvVar] = { let env_obj = value.field(v, "env") ?? value.object([]) diff --git a/src/engine.almd b/src/engine.almd index b390b0f..95ffbdb 100644 --- a/src/engine.almd +++ b/src/engine.almd @@ -66,7 +66,7 @@ effect fn run_foreground(opts: cli.Options) -> Result[Unit, String] = { // --- Native sandbox execution --- fn proxy_config(opts: cli.Options) -> proxy.ProxyConfig = - {allow: opts.proxy_allow, deny: opts.proxy_deny, audit_path: opts.proxy_audit} + {allow: opts.proxy_allow, deny: opts.proxy_deny, audit_path: opts.proxy_audit, credentials: opts.credentials} // Runs a native command under the policy the options describe and returns // the command's own exit code, so the caller can exit with it. @@ -164,7 +164,7 @@ effect fn run_native_proxied(opts: cli.Options, proxy_cfg: proxy.ProxyConfig) -> {key: "http_proxy", val: endpoint}, {key: "https_proxy", val: endpoint}, {key: "NODE_USE_ENV_PROXY", val: "1"}, - ] + ] + h.env // Net: strictly the proxy port. Anything else must be blocked at the OS level. let request = native_request(opts, [proxy.sandbox_net_rule(h)], proxy_env, true)! let reply = wasm_rt.wt_exec_supervised(dispatch.exec_request_json(request)) diff --git a/src/help.almd b/src/help.almd index 5b32234..0588cbe 100644 --- a/src/help.almd +++ b/src/help.almd @@ -18,6 +18,8 @@ fn print_common_runtime_opts() -> Unit = { println(" --proxy-allow HTTPS proxy allowlist (comma-separated, *.wildcard ok)") println(" --proxy-deny HTTPS proxy denylist (comma-separated, *.wildcard ok)") println(" --proxy-audit Append JSONL proxy decisions to file") + println(" --credential Hand host variable NAME in as a placeholder; the proxy puts the real") + println(" value on for HOST[/path] only (repeatable, comma-separated hosts)") println(" --read-policy open (default) or strict: confine reads to mounts") println(" --allow-root Run as root anyway (file permissions then protect nothing)") println(" --env-pass Copy these host variables in (the rest stay outside)") diff --git a/src/project.almd b/src/project.almd index b188f1b..961d1d8 100644 --- a/src/project.almd +++ b/src/project.almd @@ -28,6 +28,7 @@ effect fn porta_up(extra_args: List[String]) -> Result[Int, String] = { proxy_allow: cfg.proxy_allow, proxy_deny: cfg.proxy_deny, proxy_audit: cfg.proxy_audit, + credentials: cfg.credentials, env_pass: cfg.env_pass, allow_unix: cfg.allow_unix, allow_bind: cfg.allow_bind, diff --git a/src/proxy.almd b/src/proxy.almd index 95f461e..82d2c88 100644 --- a/src/proxy.almd +++ b/src/proxy.almd @@ -3,12 +3,26 @@ import value // Almide owns config shape and lifecycle; the accept loop runs in a native thread. import json +import process import self.wasm_rt +import self.dispatch + +// A credential handed in as a placeholder (#37): the name the command sees it +// under, the host variable its real value is read from, and the hosts it may +// be put on for (`host[:443][/path]`). The command never holds the value; the +// proxy puts it on requests to those hosts and refuses the placeholder +// anywhere else it opens. +type Credential = { + name: String, + from_env: String, + hosts: List[String], +} type ProxyConfig = { allow: List[String], deny: List[String], audit_path: String, + credentials: List[Credential], } type ProxyHandle = { @@ -18,16 +32,23 @@ type ProxyHandle = { // process of the same user can reach; only a client presenting the token // is served, so the proxy is not an open relay carrying this run's policy. token: String, + // What the command's environment gains with credentials: each placeholder + // under its credential's name, and the variables that make TLS clients + // trust the run's CA. Empty without credentials. + env: List[dispatch.EnvVar], } fn empty_config() -> ProxyConfig = { allow: [], deny: [], audit_path: "", + credentials: [], } +// A credential needs the proxy even with no list: it is where the real value +// is put on. fn is_enabled(c: ProxyConfig) -> Bool = - list.len(c.allow) > 0 or list.len(c.deny) > 0 + list.len(c.allow) > 0 or list.len(c.deny) > 0 or list.len(c.credentials) > 0 // Mutually exclusive: returns an error message when both allow and deny are set. fn validate(c: ProxyConfig) -> Result[Unit, String] = @@ -39,7 +60,8 @@ effect fn start(c: ProxyConfig) -> Result[ProxyHandle, String] = { validate(c)! let allow_json = json.stringify(value.array(c.allow |> list.map(value.str))) let deny_json = json.stringify(value.array(c.deny |> list.map(value.str))) - let result_json = wasm_rt.wt_proxy_start(allow_json, deny_json, c.audit_path) + let credentials_json = json.stringify(value.array(credential_values(c.credentials)!)) + let result_json = wasm_rt.wt_proxy_start(allow_json, deny_json, c.audit_path, credentials_json) let parsed = json.parse(result_json)! let error = json.get_string(parsed, "error") ?? "" if error != "" then err(error) @@ -48,10 +70,34 @@ effect fn start(c: ProxyConfig) -> Result[ProxyHandle, String] = { let port = json.get_int(parsed, "port") ?? 0 let token = json.get_string(parsed, "token") ?? "" if handle < 0 or port == 0 or token == "" then err("proxy start returned invalid handle, port or token") - else ok({handle: handle, port: port, token: token}) + else ok({handle: handle, port: port, token: token, env: env_pairs(parsed)}) + } +} + +// The credentials as the proxy takes them, each with its real value read from +// porta's own environment now. A variable the host does not have is an error +// here, before anything runs, rather than a key handed in empty. +effect fn credential_values(credentials: List[Credential]) -> Result[List[Value], String] = { + var out: List[Value] = [] + var missing = "" + for c in credentials { + let found = process.env(c.from_env) ?? "" + if found == "" and missing == "" then missing = "credential ${c.name}: ${c.from_env} is not set in porta's environment, so there is nothing to hand in" + out = out + [value.object([("name", value.str(c.name)), ("value", value.str(found)), + ("hosts", value.array(c.hosts |> list.map(value.str)))])] } + if missing != "" then err(missing) else ok(out) } +fn env_pairs(parsed: Value) -> List[dispatch.EnvVar] = + (json.get_array(parsed, "env") ?? []) |> list.filter_map((pair) => { + let items = value.as_array(pair) ?? [] + match (list.get(items, 0), list.get(items, 1)) { + (some(k), some(v)) => some({key: value.as_string(k) ?? "", val: value.as_string(v) ?? ""}), + _ => none, + } + }) + effect fn stop(h: ProxyHandle) -> Result[Unit, String] = { let rc = wasm_rt.wt_proxy_stop(h.handle) if rc == 0 then ok(()) diff --git a/src/proxy_test.almd b/src/proxy_test.almd index 4c2a469..10f0388 100644 --- a/src/proxy_test.almd +++ b/src/proxy_test.almd @@ -1,3 +1,4 @@ +import self.cli import self.proxy test "is_enabled: empty config is disabled" { @@ -6,17 +7,17 @@ test "is_enabled: empty config is disabled" { } test "is_enabled: allow-only enables proxy" { - let c: proxy.ProxyConfig = {allow: ["api.anthropic.com"], deny: [], audit_path: ""} + let c: proxy.ProxyConfig = {allow: ["api.anthropic.com"], deny: [], audit_path: "", credentials: []} assert_eq(proxy.is_enabled(c), true) } test "is_enabled: deny-only enables proxy" { - let c: proxy.ProxyConfig = {allow: [], deny: ["google.com"], audit_path: ""} + let c: proxy.ProxyConfig = {allow: [], deny: ["google.com"], audit_path: "", credentials: []} assert_eq(proxy.is_enabled(c), true) } test "validate: allow only is ok" { - let c: proxy.ProxyConfig = {allow: ["api.anthropic.com"], deny: [], audit_path: ""} + let c: proxy.ProxyConfig = {allow: ["api.anthropic.com"], deny: [], audit_path: "", credentials: []} match proxy.validate(c) { ok(_) => assert(true), err(_) => assert(false), @@ -24,7 +25,7 @@ test "validate: allow only is ok" { } test "validate: deny only is ok" { - let c: proxy.ProxyConfig = {allow: [], deny: ["google.com"], audit_path: ""} + let c: proxy.ProxyConfig = {allow: [], deny: ["google.com"], audit_path: "", credentials: []} match proxy.validate(c) { ok(_) => assert(true), err(_) => assert(false), @@ -32,7 +33,7 @@ test "validate: deny only is ok" { } test "validate: both allow and deny is an error" { - let c: proxy.ProxyConfig = {allow: ["a.com"], deny: ["b.com"], audit_path: ""} + let c: proxy.ProxyConfig = {allow: ["a.com"], deny: ["b.com"], audit_path: "", credentials: []} match proxy.validate(c) { ok(_) => assert(false), err(msg) => assert(string.contains(msg, "mutually exclusive")), @@ -40,11 +41,31 @@ test "validate: both allow and deny is an error" { } test "endpoint: carries the run's credential to 127.0.0.1:PORT" { - let h: proxy.ProxyHandle = {handle: 0, port: 42123, token: "abc123"} + let h: proxy.ProxyHandle = {handle: 0, port: 42123, token: "abc123", env: []} assert_eq(proxy.endpoint(h), "http://porta:abc123@127.0.0.1:42123") } test "sandbox_net_rule: formats as 127.0.0.1:PORT, no credential" { - let h: proxy.ProxyHandle = {handle: 0, port: 42123, token: "abc123"} + let h: proxy.ProxyHandle = {handle: 0, port: 42123, token: "abc123", env: []} assert_eq(proxy.sandbox_net_rule(h), "127.0.0.1:42123") } + +test "is_enabled: a credential alone needs the proxy" { + let c: proxy.ProxyConfig = {...proxy.empty_config(), credentials: [{name: "KEY", from_env: "KEY", hosts: ["api.example.com"]}]} + assert_eq(proxy.is_enabled(c), true) +} + +test "--credential NAME=HOSTS reads NAME from the host and binds it to each host" { + let opts = cli.parse_options(["run", "--credential", "KEY=api.example.com/v1, other.example.com", "--credential", "B=b.example.com"], 1, cli.default_options()) ?? cli.default_options() + assert_eq(list.len(opts.credentials), 2) + let first = list.get(opts.credentials, 0) ?? {name: "", from_env: "", hosts: []} + assert_eq(first.name, "KEY") + assert_eq(first.from_env, "KEY") + assert_eq(first.hosts, ["api.example.com/v1", "other.example.com"]) +} + +test "explain --save writes where a credential goes, never its value" { + let opts = {...cli.default_options(), path: "/bin/sh", credentials: [{name: "KEY", from_env: "HOST_KEY", hosts: ["api.example.com"]}]} + let saved = cli.policy_toml(opts) + assert(string.contains(saved, "[[credentials]]\nname = \"KEY\"\nfrom = { env = \"HOST_KEY\" }\nhosts = [\"api.example.com\"]")) +} diff --git a/src/wasm_rt.almd b/src/wasm_rt.almd index 5f44211..3fedcea 100644 --- a/src/wasm_rt.almd +++ b/src/wasm_rt.almd @@ -100,7 +100,7 @@ fn wt_sandbox_check() -> String fn wt_sandbox_check_json() -> String @extern(rs, "wasmtime_bridge", "wt_proxy_start") -fn wt_proxy_start(allow_json: String, deny_json: String, audit_path: String) -> String +fn wt_proxy_start(allow_json: String, deny_json: String, audit_path: String, credentials_json: String) -> String @extern(rs, "wasmtime_bridge", "wt_proxy_stop") fn wt_proxy_stop(handle: Int) -> Int From 83487ab8ceef8ca73aa2500dbaac2f816f61b4fc Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Mon, 28 Sep 2026 10:09:51 +0900 Subject: [PATCH 05/11] Split the credential broker by concern, back to grade A CI's code-quality floor (90) failed at 89: credential_broker.rs was one 488-line file with a deeply nested body copier and five-parameter functions, http_proxy.rs had passed 300 lines, and so had cli.almd. - credential_broker.rs keeps the credentials and where each may go; credential_broker/tls.rs makes the run's CA, its leaves, the trust bundle and the upstream verifier; credential_broker/exchange.rs carries one intercepted request; the unit tests move to credential_broker/tests.rs. A Target (host, port) replaces the separate arguments. - The tunnel and wt_is_host_allowed move beside the dial in proxy_egress.rs; the proxy's setup with credentials is one function. - The TOML quoting helpers move to util.almd; --credential parsing and the saved [[credentials]] tables to proxy.almd. No behaviour change: 163 Almide tests, 5 broker unit tests, the whole integration suite and escapes.py pass as before; codopsy grades 90 (A). Co-Authored-By: Claude Opus 5.5 (1M context) --- native/credential_broker.rs | 417 +++------------------------ native/credential_broker/exchange.rs | 211 ++++++++++++++ native/credential_broker/tests.rs | 63 ++++ native/credential_broker/tls.rs | 142 +++++++++ native/http_proxy.rs | 95 ++---- native/proxy_egress.rs | 54 +++- src/cli.almd | 54 +--- src/proxy.almd | 17 ++ src/util.almd | 8 + 9 files changed, 578 insertions(+), 483 deletions(-) create mode 100644 native/credential_broker/exchange.rs create mode 100644 native/credential_broker/tests.rs create mode 100644 native/credential_broker/tls.rs diff --git a/native/credential_broker.rs b/native/credential_broker.rs index 95a842a..e5a7144 100644 --- a/native/credential_broker.rs +++ b/native/credential_broker.rs @@ -15,21 +15,16 @@ //! as before, encrypted end to end and not read: a placeholder sent there is //! only a random string. //! -//! Each intercepted connection carries one request: porta asks the server for -//! `Connection: close` and closes the client's side after the response, so a -//! client sends its next request on a fresh connection. That costs a -//! handshake per request to a bound host and buys a proxy that never has to -//! find where one response ends and the next begins. +//! This file holds the credentials and the rule for where each may go; `tls` +//! makes the run's CA and the certificates it signs; `exchange` carries one +//! intercepted request across. -use crate::proxy_audit::{audit_log, Decision}; -use crate::proxy_egress::{dial, mint_token, Dial}; -use rustls::pki_types::pem::PemObject; -use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; +use crate::proxy_egress::mint_token; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +mod exchange; +mod tls; /// Where one credential may go: a host pattern (`*.example.com` allowed), the /// port, and a path prefix ("" for any path). @@ -49,6 +44,13 @@ pub(crate) struct Credential { pub(crate) bindings: Vec, } +/// The host and port a CONNECT asked for. +#[derive(Clone, Copy)] +pub(crate) struct Target<'a> { + pub(crate) host: &'a str, + pub(crate) port: u16, +} + /// `host[:port][/path]` as `--credential` and `[[credentials]]` write it. The /// proxy tunnels port 443 alone, so a binding to another port could never be /// used, and is refused here rather than silently doing nothing. @@ -72,18 +74,22 @@ pub(crate) fn parse_binding(text: &str) -> Result { } impl Binding { - fn covers(&self, host: &str, port: u16, path: &str) -> bool { - crate::http_proxy::host_matches(host, &self.host) && self.port == port && path_under(path, &self.path) + fn reaches(&self, at: Target) -> bool { + crate::http_proxy::host_matches(at.host, &self.host) && self.port == at.port + } + + fn covers(&self, at: Target, request_target: &str) -> bool { + self.reaches(at) && path_under(request_target, &self.path) } } -/// Whether `path` (a request target, query and all) is `prefix` or below it: -/// `/v1` covers `/v1`, `/v1/messages` and `/v1?x`, not `/v10`. -fn path_under(target: &str, prefix: &str) -> bool { +/// Whether a request target (query and all) is `prefix` or below it: `/v1` +/// covers `/v1`, `/v1/messages` and `/v1?x`, not `/v10`. +fn path_under(request_target: &str, prefix: &str) -> bool { if prefix.is_empty() { return true; } - let path = target.split(['?', '#']).next().unwrap_or(""); + let path = request_target.split(['?', '#']).next().unwrap_or(""); path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/')) } @@ -101,14 +107,11 @@ pub(crate) enum Rewrite { /// query and headers alike) with its value, where the credential is bound to /// this host, port and path; refuses the request when a placeholder is there /// and it is not. -pub(crate) fn rewrite_head(credentials: &[Credential], head: &str, host: &str, port: u16, target: &str) -> Rewrite { +pub(crate) fn rewrite_head(credentials: &[Credential], head: &str, at: Target, request_target: &str) -> Rewrite { let mut out = head.to_string(); let mut used = Vec::new(); - for credential in credentials { - if !out.contains(&credential.placeholder) { - continue; - } - if !credential.bindings.iter().any(|binding| binding.covers(host, port, target)) { + for credential in credentials.iter().filter(|c| head.contains(&c.placeholder)) { + if !credential.bindings.iter().any(|binding| binding.covers(at, request_target)) { return Rewrite::Refuse(credential.name.clone()); } out = out.replace(&credential.placeholder, &credential.value); @@ -117,18 +120,17 @@ pub(crate) fn rewrite_head(credentials: &[Credential], head: &str, host: &str, p Rewrite::Send(out, used) } -/// The CA made for one run, and the leaf certificates it has signed so far. +/// The run's credentials, its CA, and the leaf certificates signed so far. pub(crate) struct Broker { pub(crate) credentials: Vec, - ca: rcgen::Certificate, - ca_key: rcgen::KeyPair, + ca: tls::Authority, leaves: Mutex>>, upstream: Arc, /// The directory holding the CA certificate and the trust bundle, removed /// when the proxy stops. pub(crate) dir: String, - pub(crate) ca_path: String, - pub(crate) bundle_path: Option, + ca_path: String, + bundle_path: Option, } /// One credential as the caller hands it over: the value read from where the @@ -140,44 +142,32 @@ pub(crate) struct Requested { hosts: Vec, } -fn provider() -> Arc { - Arc::new(rustls::crypto::ring::default_provider()) +/// The broker a `[{"name","value","hosts"}]` list asks for, or None for an +/// empty one: a run with no credentials tunnels everything, as before. +pub(crate) fn broker_from_json(credentials_json: &str) -> Result>, String> { + let requested: Vec = serde_json::from_str(credentials_json).map_err(|e| format!("credentials: {e}"))?; + if requested.is_empty() { + return Ok(None); + } + Broker::new(requested).map(|broker| Some(Arc::new(broker))) } impl Broker { /// A broker for these credentials: placeholders minted, the run's CA made /// and written out with a trust bundle the command is pointed at. - pub(crate) fn new(requested: Vec) -> Result { + fn new(requested: Vec) -> Result { let credentials = merged(requested)?; - let (ca, ca_key) = make_ca()?; + let ca = tls::Authority::new()?; let dir = format!("/tmp/porta-credentials-{}", mint_token()); std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {dir}: {e}"))?; - let ca_path = format!("{dir}/ca.pem"); - std::fs::write(&ca_path, ca.pem()).map_err(|e| format!("cannot write {ca_path}: {e}"))?; - // SSL_CERT_FILE and the variables like it replace the trust store - // rather than add to it, so what they point at has to hold the - // system's roots as well, or every other host the command reaches - // would stop verifying. - let bundle_path = match system_roots() { - Some(roots) => { - let path = format!("{dir}/bundle.pem"); - let mut bundle = roots; - if !bundle.ends_with('\n') { - bundle.push('\n'); - } - bundle.push_str(&ca.pem()); - std::fs::write(&path, bundle).map_err(|e| format!("cannot write {path}: {e}"))?; - Some(path) - } - None => None, - }; - Ok(Broker { credentials, ca, ca_key, leaves: Mutex::new(HashMap::new()), upstream: upstream_config()?, dir, ca_path, bundle_path }) + let (ca_path, bundle_path) = ca.write_trust(&dir)?; + Ok(Broker { credentials, ca, leaves: Mutex::new(HashMap::new()), upstream: tls::upstream_config()?, dir, ca_path, bundle_path }) } /// Whether any credential is bound to this host and port, so its /// connections are opened rather than tunnelled. - pub(crate) fn intercepts(&self, host: &str, port: u16) -> bool { - self.credentials.iter().any(|c| c.bindings.iter().any(|b| crate::http_proxy::host_matches(host, &b.host) && b.port == port)) + pub(crate) fn intercepts(&self, at: Target) -> bool { + self.credentials.iter().any(|c| c.bindings.iter().any(|b| b.reaches(at))) } /// The hosts to add to an allow-list: a credential bound to a host the @@ -207,104 +197,10 @@ impl Broker { if let Some(config) = leaves.get(host) { return Ok(config.clone()); } - let config = Arc::new(self.mint_leaf(host)?); + let config = Arc::new(self.ca.server_config_for(host)?); leaves.insert(host.to_string(), config.clone()); Ok(config) } - - fn mint_leaf(&self, host: &str) -> Result { - let mut params = rcgen::CertificateParams::new(vec![host.to_string()]).map_err(|e| format!("leaf for {host}: {e}"))?; - params.distinguished_name.push(rcgen::DnType::CommonName, host); - params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; - // Strict verifiers (Python 3.13 and later, by default) refuse a leaf - // that does not say which key signed it. - params.use_authority_key_identifier_extension = true; - set_validity(&mut params); - let key = rcgen::KeyPair::generate().map_err(|e| format!("leaf key: {e}"))?; - let leaf = params.signed_by(&key, &self.ca, &self.ca_key).map_err(|e| format!("leaf for {host}: {e}"))?; - let chain = vec![leaf.der().clone(), self.ca.der().clone()]; - let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())); - let mut config = rustls::ServerConfig::builder_with_provider(provider()) - .with_safe_default_protocol_versions() - .map_err(|e| format!("server config: {e}"))? - .with_no_client_auth() - .with_single_cert(chain, key) - .map_err(|e| format!("server config: {e}"))?; - // HTTP/1.1 alone: the proxy reads one request head and one body, and - // a client that would speak HTTP/2 falls back when it is not offered. - config.alpn_protocols = vec![b"http/1.1".to_vec()]; - Ok(config) - } - - /// Takes over a CONNECT to a bound host: answers it, speaks TLS to the - /// command as `host`, and carries one request on to the real server with - /// the credential put on. - pub(crate) fn intercept(&self, mut client: TcpStream, audit: &Option, host: &str, port: u16) { - let config = match self.leaf_for(host) { - Ok(config) => config, - Err(reason) => { - let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); - return audit_log(audit, &Decision::new(host, port, "error", reason)); - } - }; - let _ = client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); - let Ok(connection) = rustls::ServerConnection::new(config) else { return }; - let _ = client.set_read_timeout(Some(Duration::from_secs(120))); - let mut reader = BufReader::new(rustls::StreamOwned::new(connection, client)); - let Some((request_line, headers)) = read_head(&mut reader) else { return }; - let target = request_line.split_whitespace().nth(1).unwrap_or("/").to_string(); - let path = target.split(['?', '#']).next().unwrap_or("/").to_string(); - let head = format!("{}\r\n{}\r\n", request_line, headers.join("\r\n")); - match rewrite_head(&self.credentials, &head, host, port, &target) { - Rewrite::Refuse(name) => { - let _ = reader.get_mut().write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); - close(reader.get_mut()); - audit_log(audit, &Decision::new(host, port, "deny", format!("credential {name} is not bound to {host}{path}: the request was not sent"))); - } - Rewrite::Send(head, used) => { - if used.is_empty() { - audit_log(audit, &Decision::new(host, port, "allow", format!("opened for its credentials; {path} carried none"))); - } - for name in &used { - audit_log(audit, &Decision::new(host, port, "substitute", format!("credential {name} put on for {path}"))); - } - if let Err(reason) = self.forward(&mut reader, &head, host, port) { - let _ = reader.get_mut().write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); - audit_log(audit, &Decision::new(host, port, "error", reason)); - } - close(reader.get_mut()); - } - } - } - - /// Sends the rewritten head and the body after it to the real server, then - /// the whole response back. - fn forward(&self, client: &mut BufReader, head: &str, host: &str, port: u16) -> Result<(), String> { - let upstream = match dial(host, port) { - Ok(stream) => stream, - Err(Dial::Blocked(reason)) | Err(Dial::Failed(reason)) => return Err(reason), - }; - let _ = upstream.set_read_timeout(Some(Duration::from_secs(300))); - let name = ServerName::try_from(host.to_string()).map_err(|e| format!("{host}: {e}"))?; - let connection = rustls::ClientConnection::new(self.upstream.clone(), name).map_err(|e| format!("upstream TLS: {e}"))?; - let mut server = rustls::StreamOwned::new(connection, upstream); - let (lines, framing) = outgoing_head(head); - server.write_all(lines.as_bytes()).map_err(|e| format!("upstream TLS: {e}"))?; - copy_body(client, &mut server, framing).map_err(|e| format!("request body: {e}"))?; - server.flush().map_err(|e| format!("upstream: {e}"))?; - let mut buffer = [0u8; 16384]; - loop { - match server.read(&mut buffer) { - Ok(0) => break, - Ok(n) => client.get_mut().write_all(&buffer[..n]).map_err(|e| format!("client: {e}"))?, - // A server that closes without close_notify has still sent the - // whole response: it was asked for `Connection: close`. - Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, - Err(e) => return Err(format!("upstream read: {e}")), - } - } - Ok(()) - } } /// Credentials by name: two bindings given for one name are one credential @@ -325,224 +221,5 @@ fn merged(requested: Vec) -> Result, String> { Ok(out) } -fn make_ca() -> Result<(rcgen::Certificate, rcgen::KeyPair), String> { - let mut params = rcgen::CertificateParams::default(); - params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - params.distinguished_name.push(rcgen::DnType::CommonName, "porta run CA (this run only)"); - params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign, rcgen::KeyUsagePurpose::DigitalSignature]; - set_validity(&mut params); - let key = rcgen::KeyPair::generate().map_err(|e| format!("CA key: {e}"))?; - let ca = params.self_signed(&key).map_err(|e| format!("CA: {e}"))?; - Ok((ca, key)) -} - -/// From yesterday to a week from now: long enough for any run, short enough -/// that a leaked certificate is soon worthless, and within the lifetimes -/// clients accept for a server certificate. -fn set_validity(params: &mut rcgen::CertificateParams) { - let days = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86400).unwrap_or(0) as i64; - let (y1, m1, d1) = civil_from_days(days - 1); - let (y2, m2, d2) = civil_from_days(days + 7); - params.not_before = rcgen::date_time_ymd(y1, m1, d1); - params.not_after = rcgen::date_time_ymd(y2, m2, d2); -} - -/// Days since 1970-01-01 as a calendar date (Howard Hinnant's algorithm). -fn civil_from_days(days: i64) -> (i32, u8, u8) { - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 }; - (year as i32, month as u8, day as u8) -} - -/// The system's trust store as PEM: the host's own SSL_CERT_FILE if it has -/// one, else the first bundle a distribution keeps in its usual place. -fn system_roots() -> Option { - let mut candidates: Vec = std::env::var("SSL_CERT_FILE").into_iter().collect(); - candidates.extend( - ["/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/ca-bundle.pem"] - .iter() - .map(|s| s.to_string()), - ); - candidates.iter().find_map(|path| std::fs::read_to_string(path).ok().filter(|text| text.contains("BEGIN CERTIFICATE"))) -} - -/// How porta verifies the real server: the webpki roots, plus, for the -/// integration suite alone, the CA named by PORTA_TEST_UPSTREAM_CA. -fn upstream_config() -> Result, String> { - let mut roots = rustls::RootCertStore::empty(); - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - if let Ok(path) = std::env::var("PORTA_TEST_UPSTREAM_CA") { - for cert in CertificateDer::pem_file_iter(&path).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))? { - let cert = cert.map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; - roots.add(cert).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; - } - } - let mut config = rustls::ClientConfig::builder_with_provider(provider()) - .with_safe_default_protocol_versions() - .map_err(|e| format!("client config: {e}"))? - .with_root_certificates(roots) - .with_no_client_auth(); - config.alpn_protocols = vec![b"http/1.1".to_vec()]; - Ok(Arc::new(config)) -} - -/// How the request's body is framed, from its headers. -#[derive(Debug, PartialEq, Clone, Copy)] -pub(crate) enum Framing { - None, - Length(u64), - Chunked, -} - -/// The head as it goes to the server: hop-by-hop headers dropped, `Expect` -/// dropped (the body follows at once; a client waiting on 100 Continue sends -/// it after its own short wait), and `Connection: close` asked for. -pub(crate) fn outgoing_head(head: &str) -> (String, Framing) { - let mut lines = head.split("\r\n").filter(|l| !l.is_empty()); - let request_line = lines.next().unwrap_or(""); - let mut out = format!("{request_line}\r\n"); - let mut framing = Framing::None; - for line in lines { - let Some((name, value)) = line.split_once(':') else { continue }; - let lower = name.trim().to_ascii_lowercase(); - match lower.as_str() { - "connection" | "proxy-connection" | "keep-alive" | "expect" | "proxy-authorization" => continue, - "transfer-encoding" if value.to_ascii_lowercase().contains("chunked") => framing = Framing::Chunked, - "content-length" if framing != Framing::Chunked => framing = Framing::Length(value.trim().parse().unwrap_or(0)), - _ => {} - } - out.push_str(line); - out.push_str("\r\n"); - } - out.push_str("Connection: close\r\n\r\n"); - (out, framing) -} - -fn copy_body(from: &mut R, to: &mut W, framing: Framing) -> std::io::Result<()> { - match framing { - Framing::None => Ok(()), - Framing::Length(n) => { - let copied = std::io::copy(&mut from.take(n), to)?; - if copied < n { Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "body shorter than Content-Length")) } else { Ok(()) } - } - Framing::Chunked => loop { - let mut size_line = String::new(); - if from.read_line(&mut size_line)? == 0 { - return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "chunked body ended early")); - } - to.write_all(size_line.as_bytes())?; - let size = u64::from_str_radix(size_line.trim().split(';').next().unwrap_or("0").trim(), 16).unwrap_or(0); - if size == 0 { - // Trailers, then the blank line that ends them. - loop { - let mut line = String::new(); - if from.read_line(&mut line)? == 0 { - return Ok(()); - } - to.write_all(line.as_bytes())?; - if line == "\r\n" || line == "\n" { - return Ok(()); - } - } - } - std::io::copy(&mut from.take(size + 2), to)?; - }, - } -} - -/// The request line and headers, up to 64 KiB of them. -fn read_head(reader: &mut R) -> Option<(String, Vec)> { - let mut request_line = String::new(); - if reader.read_line(&mut request_line).ok()? == 0 { - return None; - } - let mut headers = Vec::new(); - let mut total = request_line.len(); - loop { - let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) | Err(_) => return None, - Ok(_) if line == "\r\n" || line == "\n" => return Some((request_line.trim_end().to_string(), headers)), - Ok(n) => { - total += n; - if total > 65536 { - return None; - } - headers.push(line.trim_end().to_string()); - } - } - } -} - -fn close(stream: &mut rustls::StreamOwned) { - stream.conn.send_close_notify(); - let _ = stream.flush(); -} - #[cfg(test)] -mod tests { - use super::*; - - fn credential(name: &str, value: &str, hosts: &[&str]) -> Credential { - Credential { - name: name.into(), - value: value.into(), - placeholder: format!("porta-cred-{name}-0123"), - bindings: hosts.iter().map(|h| parse_binding(h).unwrap()).collect(), - } - } - - #[test] - fn bindings_parse_and_refuse_other_ports() { - assert_eq!(parse_binding("API.example.com").unwrap(), Binding { host: "api.example.com".into(), port: 443, path: "".into() }); - assert_eq!(parse_binding("api.example.com:443/v1/").unwrap().path, "/v1"); - assert!(parse_binding("api.example.com:8443").is_err()); - assert!(parse_binding(":443").is_err()); - } - - #[test] - fn a_placeholder_is_put_on_only_where_it_is_bound() { - let creds = vec![credential("KEY", "real-secret", &["api.example.com/v1"])]; - let head = "GET /v1/models?k=porta-cred-KEY-0123 HTTP/1.1\r\nx-api-key: porta-cred-KEY-0123\r\n\r\n"; - match rewrite_head(&creds, head, "api.example.com", 443, "/v1/models?k=porta-cred-KEY-0123") { - Rewrite::Send(out, used) => { - assert!(!out.contains("porta-cred-KEY")); - assert_eq!(out.matches("real-secret").count(), 2); - assert_eq!(used, vec!["KEY".to_string()]); - } - other => panic!("{other:?}"), - } - assert_eq!(rewrite_head(&creds, head, "api.example.com", 443, "/admin"), Rewrite::Refuse("KEY".into())); - assert_eq!(rewrite_head(&creds, head, "evil.example.com", 443, "/v1/models"), Rewrite::Refuse("KEY".into())); - assert!(!path_under("/v10", "/v1")); - } - - #[test] - fn a_request_without_a_placeholder_goes_as_it_is() { - let creds = vec![credential("KEY", "real-secret", &["api.example.com"])]; - let head = "GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n"; - assert_eq!(rewrite_head(&creds, head, "api.example.com", 443, "/"), Rewrite::Send(head.into(), vec![])); - } - - #[test] - fn the_outgoing_head_asks_for_close_and_knows_its_body() { - let (head, framing) = outgoing_head("POST /v1 HTTP/1.1\r\nHost: a\r\nConnection: keep-alive\r\nExpect: 100-continue\r\nContent-Length: 12\r\n\r\n"); - assert!(head.ends_with("Connection: close\r\n\r\n") && !head.contains("keep-alive") && !head.contains("Expect")); - assert_eq!(framing, Framing::Length(12)); - assert_eq!(outgoing_head("POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n").1, Framing::Chunked); - } - - #[test] - fn days_become_dates() { - assert_eq!(civil_from_days(0), (1970, 1, 1)); - assert_eq!(civil_from_days(20_724), (2026, 9, 28)); - assert_eq!(civil_from_days(11_016), (2000, 2, 29)); - } -} +mod tests; diff --git a/native/credential_broker/exchange.rs b/native/credential_broker/exchange.rs new file mode 100644 index 0000000..38aa236 --- /dev/null +++ b/native/credential_broker/exchange.rs @@ -0,0 +1,211 @@ +//! One intercepted request, carried across: the command's TLS answered with +//! the run's certificate for the host, the head read and rewritten, the +//! request sent on over porta's own TLS to the real server, and the response +//! carried back. +//! +//! Each intercepted connection carries one request: porta asks the server for +//! `Connection: close` and closes the client's side after the response, so a +//! client sends its next request on a fresh connection. That costs a +//! handshake per request to a bound host and buys a proxy that never has to +//! find where one response ends and the next begins. + +use super::{rewrite_head, Broker, Rewrite, Target}; +use crate::proxy_audit::{audit_log, Decision}; +use crate::proxy_egress::dial; +use rustls::pki_types::ServerName; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::time::Duration; + +type ClientSide = BufReader>; + +impl Broker { + /// Takes over a CONNECT to a bound host: answers it, speaks TLS to the + /// command as the host, and carries one request on to the real server + /// with the credential put on. + pub(crate) fn intercept(&self, mut client: TcpStream, audit: &Option, at: Target) { + let config = match self.leaf_for(at.host) { + Ok(config) => config, + Err(reason) => { + let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); + return audit_log(audit, &Decision::new(at.host, at.port, "error", reason)); + } + }; + let _ = client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); + let Ok(connection) = rustls::ServerConnection::new(config) else { return }; + let _ = client.set_read_timeout(Some(Duration::from_secs(120))); + let mut reader = BufReader::new(rustls::StreamOwned::new(connection, client)); + if let Some((request_line, headers)) = read_head(&mut reader) { + self.answer(&mut reader, audit, at, &format!("{}\r\n{}\r\n", request_line, headers.join("\r\n"))); + } + close(reader.get_mut()); + } + + /// The request refused, or sent on with its credentials put on, and the + /// decision recorded either way. + fn answer(&self, reader: &mut ClientSide, audit: &Option, at: Target, head: &str) { + let request_target = head.split_whitespace().nth(1).unwrap_or("/").to_string(); + let path = request_target.split(['?', '#']).next().unwrap_or("/").to_string(); + let record = |verdict: &'static str, reason: String| audit_log(audit, &Decision::new(at.host, at.port, verdict, reason)); + match rewrite_head(&self.credentials, head, at, &request_target) { + Rewrite::Refuse(name) => { + let _ = reader.get_mut().write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + record("deny", format!("credential {name} is not bound to {}{path}: the request was not sent", at.host)); + } + Rewrite::Send(head, used) => { + if used.is_empty() { + record("allow", format!("opened for its credentials; {path} carried none")); + } + for name in &used { + record("substitute", format!("credential {name} put on for {path}")); + } + if let Err(reason) = self.forward(reader, &head, at) { + let _ = reader.get_mut().write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + record("error", reason); + } + } + } + } + + /// Sends the rewritten head and the body after it to the real server, then + /// the whole response back. + fn forward(&self, client: &mut ClientSide, head: &str, at: Target) -> Result<(), String> { + let upstream = dial(at.host, at.port).map_err(|failure| match failure { + crate::proxy_egress::Dial::Blocked(reason) | crate::proxy_egress::Dial::Failed(reason) => reason, + })?; + let _ = upstream.set_read_timeout(Some(Duration::from_secs(300))); + let name = ServerName::try_from(at.host.to_string()).map_err(|e| format!("{}: {e}", at.host))?; + let connection = rustls::ClientConnection::new(self.upstream.clone(), name).map_err(|e| format!("upstream TLS: {e}"))?; + let mut server = rustls::StreamOwned::new(connection, upstream); + let (lines, framing) = outgoing_head(head); + server.write_all(lines.as_bytes()).map_err(|e| format!("upstream TLS: {e}"))?; + copy_body(client, &mut server, framing).map_err(|e| format!("request body: {e}"))?; + server.flush().map_err(|e| format!("upstream: {e}"))?; + copy_response(&mut server, client.get_mut()) + } +} + +/// The response, to the end of the server's stream. +fn copy_response(server: &mut R, client: &mut W) -> Result<(), String> { + let mut buffer = [0u8; 16384]; + loop { + match server.read(&mut buffer) { + Ok(0) => return Ok(()), + Ok(n) => client.write_all(&buffer[..n]).map_err(|e| format!("client: {e}"))?, + // A server that closes without close_notify has still sent the + // whole response: it was asked for `Connection: close`. + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), + Err(e) => return Err(format!("upstream read: {e}")), + } + } +} + +/// How the request's body is framed, from its headers. +#[derive(Debug, PartialEq, Clone, Copy)] +pub(crate) enum Framing { + None, + Length(u64), + Chunked, +} + +/// The head as it goes to the server: hop-by-hop headers dropped, `Expect` +/// dropped (the body follows at once; a client waiting on 100 Continue sends +/// it after its own short wait), and `Connection: close` asked for. +pub(crate) fn outgoing_head(head: &str) -> (String, Framing) { + let mut lines = head.split("\r\n").filter(|l| !l.is_empty()); + let mut out = format!("{}\r\n", lines.next().unwrap_or("")); + let mut framing = Framing::None; + for line in lines { + let Some((name, value)) = line.split_once(':') else { continue }; + let name = name.trim().to_ascii_lowercase(); + if matches!(name.as_str(), "connection" | "proxy-connection" | "keep-alive" | "expect" | "proxy-authorization") { + continue; + } + framing = framing_after(framing, &name, value); + out.push_str(line); + out.push_str("\r\n"); + } + out.push_str("Connection: close\r\n\r\n"); + (out, framing) +} + +/// Chunked wins over a length, as HTTP/1.1 says it must. +fn framing_after(framing: Framing, name: &str, value: &str) -> Framing { + match name { + "transfer-encoding" if value.to_ascii_lowercase().contains("chunked") => Framing::Chunked, + "content-length" if framing != Framing::Chunked => Framing::Length(value.trim().parse().unwrap_or(0)), + _ => framing, + } +} + +fn copy_body(from: &mut R, to: &mut W, framing: Framing) -> std::io::Result<()> { + match framing { + Framing::None => Ok(()), + Framing::Length(n) => { + let copied = std::io::copy(&mut from.take(n), to)?; + if copied < n { Err(ended_early("body shorter than Content-Length")) } else { Ok(()) } + } + Framing::Chunked => copy_chunks(from, to), + } +} + +fn ended_early(what: &str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, what.to_string()) +} + +/// Chunks as they come, through the last one and its trailers. +fn copy_chunks(from: &mut R, to: &mut W) -> std::io::Result<()> { + loop { + let mut size_line = String::new(); + if from.read_line(&mut size_line)? == 0 { + return Err(ended_early("chunked body ended early")); + } + to.write_all(size_line.as_bytes())?; + let size = u64::from_str_radix(size_line.trim().split(';').next().unwrap_or("0").trim(), 16).unwrap_or(0); + if size == 0 { + return copy_trailers(from, to); + } + std::io::copy(&mut from.take(size + 2), to)?; + } +} + +/// Trailers, then the blank line that ends them. +fn copy_trailers(from: &mut R, to: &mut W) -> std::io::Result<()> { + loop { + let mut line = String::new(); + if from.read_line(&mut line)? == 0 { + return Ok(()); + } + to.write_all(line.as_bytes())?; + if line == "\r\n" || line == "\n" { + return Ok(()); + } + } +} + +/// The request line and headers, up to 64 KiB of them. +fn read_head(reader: &mut R) -> Option<(String, Vec)> { + let mut request_line = String::new(); + if reader.read_line(&mut request_line).ok()? == 0 { + return None; + } + let mut headers = Vec::new(); + let mut total = request_line.len(); + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).ok().filter(|n| *n > 0)?; + if line == "\r\n" || line == "\n" { + return Some((request_line.trim_end().to_string(), headers)); + } + total += n; + if total > 65536 { + return None; + } + headers.push(line.trim_end().to_string()); + } +} + +fn close(stream: &mut rustls::StreamOwned) { + stream.conn.send_close_notify(); + let _ = stream.flush(); +} diff --git a/native/credential_broker/tests.rs b/native/credential_broker/tests.rs new file mode 100644 index 0000000..b123945 --- /dev/null +++ b/native/credential_broker/tests.rs @@ -0,0 +1,63 @@ +use super::exchange::{outgoing_head, Framing}; +use super::tls::civil_from_days; +use super::*; + +fn credential(name: &str, value: &str, hosts: &[&str]) -> Credential { + Credential { + name: name.into(), + value: value.into(), + placeholder: format!("porta-cred-{name}-0123"), + bindings: hosts.iter().map(|h| parse_binding(h).unwrap()).collect(), + } +} + +fn at(host: &str) -> Target<'_> { + Target { host, port: 443 } +} + +#[test] +fn bindings_parse_and_refuse_other_ports() { + assert_eq!(parse_binding("API.example.com").unwrap(), Binding { host: "api.example.com".into(), port: 443, path: "".into() }); + assert_eq!(parse_binding("api.example.com:443/v1/").unwrap().path, "/v1"); + assert!(parse_binding("api.example.com:8443").is_err()); + assert!(parse_binding(":443").is_err()); +} + +#[test] +fn a_placeholder_is_put_on_only_where_it_is_bound() { + let creds = vec![credential("KEY", "real-secret", &["api.example.com/v1"])]; + let head = "GET /v1/models?k=porta-cred-KEY-0123 HTTP/1.1\r\nx-api-key: porta-cred-KEY-0123\r\n\r\n"; + match rewrite_head(&creds, head, at("api.example.com"), "/v1/models?k=porta-cred-KEY-0123") { + Rewrite::Send(out, used) => { + assert!(!out.contains("porta-cred-KEY")); + assert_eq!(out.matches("real-secret").count(), 2); + assert_eq!(used, vec!["KEY".to_string()]); + } + other => panic!("{other:?}"), + } + assert_eq!(rewrite_head(&creds, head, at("api.example.com"), "/admin"), Rewrite::Refuse("KEY".into())); + assert_eq!(rewrite_head(&creds, head, at("evil.example.com"), "/v1/models"), Rewrite::Refuse("KEY".into())); + assert!(!path_under("/v10", "/v1")); +} + +#[test] +fn a_request_without_a_placeholder_goes_as_it_is() { + let creds = vec![credential("KEY", "real-secret", &["api.example.com"])]; + let head = "GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n"; + assert_eq!(rewrite_head(&creds, head, at("api.example.com"), "/"), Rewrite::Send(head.into(), vec![])); +} + +#[test] +fn the_outgoing_head_asks_for_close_and_knows_its_body() { + let (head, framing) = outgoing_head("POST /v1 HTTP/1.1\r\nHost: a\r\nConnection: keep-alive\r\nExpect: 100-continue\r\nContent-Length: 12\r\n\r\n"); + assert!(head.ends_with("Connection: close\r\n\r\n") && !head.contains("keep-alive") && !head.contains("Expect")); + assert_eq!(framing, Framing::Length(12)); + assert_eq!(outgoing_head("POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\nContent-Length: 3\r\n\r\n").1, Framing::Chunked); +} + +#[test] +fn days_become_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(20_724), (2026, 9, 28)); + assert_eq!(civil_from_days(11_016), (2000, 2, 29)); +} diff --git a/native/credential_broker/tls.rs b/native/credential_broker/tls.rs new file mode 100644 index 0000000..202ec42 --- /dev/null +++ b/native/credential_broker/tls.rs @@ -0,0 +1,142 @@ +//! The run's CA, the certificates it signs for bound hosts, the trust bundle +//! the command is pointed at, and how porta verifies the real server. +//! +//! Everything runs on `ring`, the provider reqwest's rustls already builds: +//! no second crypto library, and no OpenSSL. + +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn provider() -> Arc { + Arc::new(rustls::crypto::ring::default_provider()) +} + +/// A CA made for one run. It lives in memory; its certificate alone is +/// written out, for the command to trust. +pub(super) struct Authority { + cert: rcgen::Certificate, + key: rcgen::KeyPair, +} + +impl Authority { + pub(super) fn new() -> Result { + let mut params = rcgen::CertificateParams::default(); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.distinguished_name.push(rcgen::DnType::CommonName, "porta run CA (this run only)"); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign, rcgen::KeyUsagePurpose::DigitalSignature]; + set_validity(&mut params); + let key = rcgen::KeyPair::generate().map_err(|e| format!("CA key: {e}"))?; + let cert = params.self_signed(&key).map_err(|e| format!("CA: {e}"))?; + Ok(Authority { cert, key }) + } + + /// Writes the CA certificate, and a bundle of the system's roots plus the + /// CA, into `dir`. SSL_CERT_FILE and the variables like it replace the + /// trust store rather than add to it, so what they point at has to hold the + /// system's roots as well, or every other host would stop verifying. No + /// bundle when the system's roots cannot be found. + pub(super) fn write_trust(&self, dir: &str) -> Result<(String, Option), String> { + let ca_path = format!("{dir}/ca.pem"); + std::fs::write(&ca_path, self.cert.pem()).map_err(|e| format!("cannot write {ca_path}: {e}"))?; + let Some(mut bundle) = system_roots() else { return Ok((ca_path, None)) }; + if !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(&self.cert.pem()); + let bundle_path = format!("{dir}/bundle.pem"); + std::fs::write(&bundle_path, bundle).map_err(|e| format!("cannot write {bundle_path}: {e}"))?; + Ok((ca_path, Some(bundle_path))) + } + + /// A TLS server configuration presenting a certificate for `host`, signed + /// by this CA. + pub(super) fn server_config_for(&self, host: &str) -> Result { + let mut params = rcgen::CertificateParams::new(vec![host.to_string()]).map_err(|e| format!("leaf for {host}: {e}"))?; + params.distinguished_name.push(rcgen::DnType::CommonName, host); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + // Strict verifiers (Python 3.13 and later, by default) refuse a leaf + // that does not say which key signed it. + params.use_authority_key_identifier_extension = true; + set_validity(&mut params); + let key = rcgen::KeyPair::generate().map_err(|e| format!("leaf key: {e}"))?; + let leaf = params.signed_by(&key, &self.cert, &self.key).map_err(|e| format!("leaf for {host}: {e}"))?; + let chain = vec![leaf.der().clone(), self.cert.der().clone()]; + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())); + let mut config = rustls::ServerConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .map_err(|e| format!("server config: {e}"))? + .with_no_client_auth() + .with_single_cert(chain, key) + .map_err(|e| format!("server config: {e}"))?; + // HTTP/1.1 alone: the proxy reads one request head and one body, and + // a client that would speak HTTP/2 falls back when it is not offered. + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(config) + } +} + +/// From yesterday to a week from now: long enough for any run, short enough +/// that a leaked certificate is soon worthless, and within the lifetimes +/// clients accept for a server certificate. +fn set_validity(params: &mut rcgen::CertificateParams) { + let days = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86400).unwrap_or(0) as i64; + let (y1, m1, d1) = civil_from_days(days - 1); + let (y2, m2, d2) = civil_from_days(days + 7); + params.not_before = rcgen::date_time_ymd(y1, m1, d1); + params.not_after = rcgen::date_time_ymd(y2, m2, d2); +} + +/// Days since 1970-01-01 as a calendar date (Howard Hinnant's algorithm). +pub(super) fn civil_from_days(days: i64) -> (i32, u8, u8) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 }; + (year as i32, month as u8, day as u8) +} + +/// The system's trust store as PEM: the host's own SSL_CERT_FILE if it has +/// one, else the first bundle a distribution keeps in its usual place. +fn system_roots() -> Option { + let mut candidates: Vec = std::env::var("SSL_CERT_FILE").into_iter().collect(); + candidates.extend( + ["/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/ca-bundle.pem"] + .iter() + .map(|s| s.to_string()), + ); + candidates.iter().find_map(|path| std::fs::read_to_string(path).ok().filter(|text| text.contains("BEGIN CERTIFICATE"))) +} + +/// How porta verifies the real server: the webpki roots, plus, for the +/// integration suite alone, the CA named by PORTA_TEST_UPSTREAM_CA in porta's +/// own environment. +pub(super) fn upstream_config() -> Result, String> { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + if let Ok(path) = std::env::var("PORTA_TEST_UPSTREAM_CA") { + add_test_roots(&mut roots, &path)?; + } + let mut config = rustls::ClientConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .map_err(|e| format!("client config: {e}"))? + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(Arc::new(config)) +} + +fn add_test_roots(roots: &mut rustls::RootCertStore, path: &str) -> Result<(), String> { + let certs = CertificateDer::pem_file_iter(path).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; + for cert in certs { + let cert = cert.map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; + roots.add(cert).map_err(|e| format!("PORTA_TEST_UPSTREAM_CA {path}: {e}"))?; + } + Ok(()) +} diff --git a/native/http_proxy.rs b/native/http_proxy.rs index dd0b850..84d5103 100644 --- a/native/http_proxy.rs +++ b/native/http_proxy.rs @@ -8,9 +8,11 @@ //! Split out of wasmtime_bridge so the FFI surface there stays a surface: this //! module owns the listener and the policy matching. -use crate::credential_broker::{Broker, Requested}; +use crate::credential_broker::{broker_from_json, Broker, Target}; use crate::locking::locked; -use crate::proxy_egress::{basic_credential, dial, mint_token, Dial}; +use crate::proxy_egress::{basic_credential, mint_token, tunnel}; +/// Kept reachable here, where the FFI re-exports have always found it. +pub use crate::proxy_egress::wt_is_host_allowed; use crate::proxy_audit::{audit_log, Decision}; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -84,18 +86,6 @@ fn policy_allows(policy: &ProxyPolicy, host: &str) -> bool { } -fn copy_bytes(mut src: TcpStream, mut dst: TcpStream) -> std::io::Result<()> { - let mut buf = [0u8; 8192]; - loop { - let n = src.read(&mut buf)?; - if n == 0 { - let _ = dst.shutdown(std::net::Shutdown::Write); - return Ok(()); - } - dst.write_all(&buf[..n])?; - } -} - fn handle_connection(client: TcpStream, policy: Arc, audit_path: Arc>) { let _ = client.set_read_timeout(Some(Duration::from_secs(30))); let Ok(mut client_for_write) = client.try_clone() else { return }; @@ -103,7 +93,7 @@ fn handle_connection(client: TcpStream, policy: Arc, audit_path: Ar let Some((request_line, headers)) = read_request(&mut reader) else { return }; match connect_target(&request_line, &headers, &policy) { Ok((host, port)) => match &policy.broker { - Some(broker) if broker.intercepts(&host, port) => broker.intercept(client_for_write, &audit_path, &host, port), + Some(broker) if broker.intercepts(Target { host: &host, port }) => broker.intercept(client_for_write, &audit_path, Target { host: &host, port }), _ => tunnel(client_for_write, &audit_path, &host, port), }, Err((status, decision)) => { @@ -172,29 +162,6 @@ fn split_authority(target: &str) -> (String, u16) { } } -/// Opens the tunnel and copies bytes both ways until either side closes. An -/// unreachable upstream is answered and recorded rather than left hanging. -fn tunnel(mut client: TcpStream, audit_path: &Option, host: &str, port: u16) { - let upstream = match dial(host, port) { - Ok(upstream) => upstream, - Err(Dial::Blocked(reason)) => { - let _ = client.write_all(b"HTTP/1.1 403 Forbidden\r\n\r\n"); - return audit_log(audit_path, &Decision::new(host, port, "deny", reason)); - } - Err(Dial::Failed(reason)) => { - let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); - return audit_log(audit_path, &Decision::new(host, port, "error", reason)); - } - }; - let _ = client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); - audit_log(audit_path, &Decision::new(host, port, "allow", "policy match")); - let (Ok(client_side), Ok(upstream_side)) = (client.try_clone(), upstream.try_clone()) else { return }; - let outbound = thread::spawn(move || { let _ = copy_bytes(client_side, upstream); }); - let inbound = thread::spawn(move || { let _ = copy_bytes(upstream_side, client); }); - let _ = outbound.join(); - let _ = inbound.join(); -} - /// Start the CONNECT proxy on 127.0.0.1:. /// `allow_json` and `deny_json` are JSON arrays of hostname patterns; only one /// should be non-empty. `audit_path` is a file path for JSONL logging (empty = disabled). @@ -209,27 +176,10 @@ pub fn wt_proxy_start( audit_path: impl AsRef, credentials_json: impl AsRef, ) -> String { - let requested: Vec = match serde_json::from_str(credentials_json.as_ref()) { - Ok(requested) => requested, - Err(e) => return error_json(&format!("credentials: {e}")), - }; - let broker = if requested.is_empty() { - None - } else { - match Broker::new(requested) { - Ok(broker) => Some(Arc::new(broker)), - Err(reason) => return error_json(&reason), - } - }; - let mut policy = match requested_policy(allow_json.as_ref(), deny_json.as_ref(), broker.is_some()) { - Ok(policy) => policy, - Err(reason) => return format!("{{\"error\":\"{}\"}}", reason), + let (broker, policy) = match proxy_policy(allow_json.as_ref(), deny_json.as_ref(), credentials_json.as_ref()) { + Ok(ready) => ready, + Err(reason) => return error_json(&reason), }; - // A credential bound to a host the allow-list leaves out would be handed - // in for nothing; binding it says the host is meant to be reached. - if let (ProxyMode::Allow, Some(broker)) = (policy.mode, &broker) { - policy.patterns.extend(broker.hosts()); - } let listener = match loopback_listener() { Ok(listener) => listener, Err(reason) => return format!("{{\"error\":\"{}\"}}", reason), @@ -264,6 +214,19 @@ fn error_json(reason: &str) -> String { format!("{{\"error\":{}}}", serde_json::to_string(reason).unwrap_or_else(|_| "\"\"".into())) } +/// The broker for the run's credentials, if any, and the policy the lists ask +/// for. A credential bound to a host an allow-list leaves out would be handed +/// in for nothing; binding it says the host is meant to be reached, so it is +/// added. +fn proxy_policy(allow_json: &str, deny_json: &str, credentials_json: &str) -> Result<(Option>, ProxyPolicy), String> { + let broker = broker_from_json(credentials_json)?; + let mut policy = requested_policy(allow_json, deny_json, broker.is_some()).map_err(str::to_string)?; + if let (ProxyMode::Allow, Some(broker)) = (policy.mode, &broker) { + policy.patterns.extend(broker.hosts()); + } + Ok((broker, policy)) +} + /// The policy these two lists ask for. At most one of them may be given: an /// allow-list and a deny-list together have no single meaning. Neither is an /// error unless the run hands credentials in, which is reason enough for a @@ -325,19 +288,3 @@ pub fn wt_proxy_stop(handle: i64) -> i64 { proxies[idx] = None; 0 } - - -/// Use the same URL parser as the HTTP transport, never a string split. -pub fn wt_is_host_allowed(url: impl AsRef, allowed_json: impl AsRef) -> bool { - let Ok(url) = reqwest::Url::parse(url.as_ref()) else { return false }; - if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() { - return false; - } - let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else { return false }; - let Ok(allowed) = serde_json::from_str::>(allowed_json.as_ref()) else { return false }; - allowed.iter().any(|rule| { - let Some((allowed_host, allowed_port)) = rule.rsplit_once(':') else { return false }; - (allowed_host == "*" || allowed_host.eq_ignore_ascii_case(host)) && - (allowed_port == "*" || allowed_port.parse::().ok() == Some(port)) - }) -} diff --git a/native/proxy_egress.rs b/native/proxy_egress.rs index 739f0e2..f5e4a19 100644 --- a/native/proxy_egress.rs +++ b/native/proxy_egress.rs @@ -6,8 +6,10 @@ //! Kept apart from `http_proxy` so that module stays the listener and the //! policy match, and this one holds the credential and the dial. -use std::io::Read; +use crate::proxy_audit::{audit_log, Decision}; +use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; +use std::thread; use std::time::Duration; /// A fresh token for one run: 16 random bytes, as hex. A label for a client, @@ -111,3 +113,53 @@ fn test_upstream(host: &str) -> Option { if name.trim().eq_ignore_ascii_case(host) { address.trim().parse().ok() } else { None } }) } + +/// Opens the tunnel and copies bytes both ways until either side closes. An +/// unreachable upstream is answered and recorded rather than left hanging. +pub(crate) fn tunnel(mut client: TcpStream, audit_path: &Option, host: &str, port: u16) { + let upstream = match dial(host, port) { + Ok(upstream) => upstream, + Err(Dial::Blocked(reason)) => { + let _ = client.write_all(b"HTTP/1.1 403 Forbidden\r\n\r\n"); + return audit_log(audit_path, &Decision::new(host, port, "deny", reason)); + } + Err(Dial::Failed(reason)) => { + let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); + return audit_log(audit_path, &Decision::new(host, port, "error", reason)); + } + }; + let _ = client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); + audit_log(audit_path, &Decision::new(host, port, "allow", "policy match")); + let (Ok(client_side), Ok(upstream_side)) = (client.try_clone(), upstream.try_clone()) else { return }; + let outbound = thread::spawn(move || { let _ = copy_bytes(client_side, upstream); }); + let inbound = thread::spawn(move || { let _ = copy_bytes(upstream_side, client); }); + let _ = outbound.join(); + let _ = inbound.join(); +} + +fn copy_bytes(mut src: TcpStream, mut dst: TcpStream) -> std::io::Result<()> { + let mut buf = [0u8; 8192]; + loop { + let n = src.read(&mut buf)?; + if n == 0 { + let _ = dst.shutdown(std::net::Shutdown::Write); + return Ok(()); + } + dst.write_all(&buf[..n])?; + } +} + +/// Use the same URL parser as the HTTP transport, never a string split. +pub fn wt_is_host_allowed(url: impl AsRef, allowed_json: impl AsRef) -> bool { + let Ok(url) = reqwest::Url::parse(url.as_ref()) else { return false }; + if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() { + return false; + } + let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else { return false }; + let Ok(allowed) = serde_json::from_str::>(allowed_json.as_ref()) else { return false }; + allowed.iter().any(|rule| { + let Some((allowed_host, allowed_port)) = rule.rsplit_once(':') else { return false }; + (allowed_host == "*" || allowed_host.eq_ignore_ascii_case(host)) && + (allowed_port == "*" || allowed_port.parse::().ok() == Some(port)) + }) +} diff --git a/src/cli.almd b/src/cli.almd index a272baf..ea19b81 100644 --- a/src/cli.almd +++ b/src/cli.almd @@ -134,7 +134,7 @@ fn apply_option(opts: Options, arg: String, next: String) -> Result[ParsedOption "--proxy-allow" => valued({...opts, proxy_allow: opts.proxy_allow + string.split(next, ",")}), "--proxy-deny" => valued({...opts, proxy_deny: opts.proxy_deny + string.split(next, ",")}), "--proxy-audit" => valued({...opts, proxy_audit: next}), - "--credential" => valued(with_credential(opts, next)), + "--credential" => valued({...opts, credentials: opts.credentials + list.filter_map([next], proxy.parse_credential)}), "--read-policy" => valued({...opts, read_policy: next}), "--allow-root" => flag({...opts, allow_root: true}), "--no-net" => flag({...opts, no_net: true}), @@ -183,15 +183,6 @@ fn with_env(opts: Options, text: String) -> Options = none => opts, } -// --credential NAME=HOST[,HOST...]: the host variable NAME, handed in as a -// placeholder and put on for requests to these hosts (`host[:443][/path]`). -fn with_credential(opts: Options, text: String) -> Options = - match util.split_at_eq(text) { - some(kv) => {...opts, credentials: opts.credentials + [{name: kv.key, from_env: kv.key, - hosts: string.split(kv.val, ",") |> list.map(string.trim) |> list.filter((h) => h != "")}]}, - none => opts, - } - fn with_secret(opts: Options, text: String) -> Options = match util.split_at_eq(text) { some(kv) => {...opts, secrets: opts.secrets + [kv]}, @@ -266,14 +257,6 @@ fn build_config(wasm_path: String, opts: Options, caps: sandbox.CapabilitySet, e // --- Policy capture --- -// A TOML basic string: backslash and double-quote escaped so a path holding -// either stays valid TOML rather than closing the string early. -fn toml_quote(s: String) -> String = - "\"" + string.replace(string.replace(s, "\\", "\\\\"), "\"", "\\\"") + "\"" - -fn toml_strings(items: List[String]) -> String = - "[" + string.join(items |> list.map(toml_quote), ", ") + "]" - fn line_when(present: Bool, line: String) -> List[String] = if present then [line] else [] @@ -284,36 +267,31 @@ fn line_when(present: Bool, line: String) -> List[String] = fn policy_toml(opts: Options) -> String = { let mounts = if list.len(opts.preopen_dirs) > 0 then opts.preopen_dirs else ["."] let proxy_on = list.len(opts.proxy_allow) > 0 or list.len(opts.proxy_deny) > 0 or opts.proxy_audit != "" - // Where each credential goes is policy and is saved; its value is read from - // the named variable when the run starts, never written here. - let credential_tables = opts.credentials |> list.flat_map((c) => - ["", "[[credentials]]", "name = " + toml_quote(c.name), "from = { env = " + toml_quote(c.from_env) + " }", - "hosts = " + toml_strings(c.hosts)]) let has_env = list.len(opts.env_vars) > 0 or list.len(opts.secrets) > 0 let lines = - ["[runtime]", "type = \"native\"", "command = " + toml_quote(opts.path), "", "[sandbox]", - "mounts = " + toml_strings(mounts)] - + line_when(list.len(opts.allow_net) > 0, "network = " + toml_strings(opts.allow_net)) + ["[runtime]", "type = \"native\"", "command = " + util.toml_quote(opts.path), "", "[sandbox]", + "mounts = " + util.toml_strings(mounts)] + + line_when(list.len(opts.allow_net) > 0, "network = " + util.toml_strings(opts.allow_net)) + line_when(opts.no_net, "no-net = true") - + line_when(opts.preset != "", "preset = " + toml_quote(opts.preset)) - + line_when(list.len(opts.deny_read) > 0, "deny-read = " + toml_strings(opts.deny_read)) - + line_when(list.len(opts.protect) > 0, "protect = " + toml_strings(opts.protect)) - + line_when(list.len(opts.deny_unix) > 0, "deny-unix = " + toml_strings(opts.deny_unix)) + + line_when(opts.preset != "", "preset = " + util.toml_quote(opts.preset)) + + line_when(list.len(opts.deny_read) > 0, "deny-read = " + util.toml_strings(opts.deny_read)) + + line_when(list.len(opts.protect) > 0, "protect = " + util.toml_strings(opts.protect)) + + line_when(list.len(opts.deny_unix) > 0, "deny-unix = " + util.toml_strings(opts.deny_unix)) + line_when(opts.read_policy == "strict", "read-policy = \"strict\"") - + line_when(list.len(opts.env_pass) > 0, "env-pass = " + toml_strings(opts.env_pass)) - + line_when(list.len(opts.allow_unix) > 0, "unix = " + toml_strings(opts.allow_unix)) - + line_when(list.len(opts.allow_bind) > 0, "bind = " + toml_strings(opts.allow_bind)) + + line_when(list.len(opts.env_pass) > 0, "env-pass = " + util.toml_strings(opts.env_pass)) + + line_when(list.len(opts.allow_unix) > 0, "unix = " + util.toml_strings(opts.allow_unix)) + + line_when(list.len(opts.allow_bind) > 0, "bind = " + util.toml_strings(opts.allow_bind)) + line_when(opts.timeout > 0, "timeout = " + int.to_string(opts.timeout)) + line_when(opts.max_cpu > 0, "max-cpu = " + int.to_string(opts.max_cpu)) + line_when(opts.max_procs > 0, "max-procs = " + int.to_string(opts.max_procs)) + line_when(opts.max_file_size > 0, "max-file-size = " + int.to_string(opts.max_file_size)) + line_when(opts.max_memory_mb > 0, "max-memory-mb = " + int.to_string(opts.max_memory_mb)) - + line_when(opts.profile != "", "profile = " + toml_quote(opts.profile)) + + line_when(opts.profile != "", "profile = " + util.toml_quote(opts.profile)) + (if proxy_on then ["", "[proxy]"] else []) - + line_when(list.len(opts.proxy_allow) > 0, "allow = " + toml_strings(opts.proxy_allow)) - + line_when(list.len(opts.proxy_deny) > 0, "deny = " + toml_strings(opts.proxy_deny)) - + line_when(opts.proxy_audit != "", "audit = " + toml_quote(opts.proxy_audit)) - + credential_tables + + line_when(list.len(opts.proxy_allow) > 0, "allow = " + util.toml_strings(opts.proxy_allow)) + + line_when(list.len(opts.proxy_deny) > 0, "deny = " + util.toml_strings(opts.proxy_deny)) + + line_when(opts.proxy_audit != "", "audit = " + util.toml_quote(opts.proxy_audit)) + + proxy.toml_tables(opts.credentials) + line_when(has_env, "\n# -e values and secrets were not saved; add [env] and [secrets] yourself") string.join(lines, "\n") + "\n" } diff --git a/src/proxy.almd b/src/proxy.almd index 82d2c88..cc91386 100644 --- a/src/proxy.almd +++ b/src/proxy.almd @@ -6,6 +6,7 @@ import json import process import self.wasm_rt import self.dispatch +import self.util // A credential handed in as a placeholder (#37): the name the command sees it // under, the host variable its real value is read from, and the hosts it may @@ -110,3 +111,19 @@ fn endpoint(h: ProxyHandle) -> String = "http://porta:${h.token}@127.0.0.1:${int // sandbox-exec allow rule that restricts net to just the proxy port. fn sandbox_net_rule(h: ProxyHandle) -> String = "127.0.0.1:${int.to_string(h.port)}" + +// --credential NAME=HOST[,HOST...]: the host variable NAME, handed in as a +// placeholder and put on for requests to these hosts (`host[:443][/path]`). +fn parse_credential(text: String) -> Credential? = + match util.split_at_eq(text) { + some(kv) => some({name: kv.key, from_env: kv.key, + hosts: string.split(kv.val, ",") |> list.map(string.trim) |> list.filter((h) => h != "")}), + none => none, + } + +// [[credentials]] tables for a saved policy: where each credential goes and +// which variable it is read from, never its value. +fn toml_tables(credentials: List[Credential]) -> List[String] = + credentials |> list.flat_map((c) => + ["", "[[credentials]]", "name = " + util.toml_quote(c.name), "from = { env = " + util.toml_quote(c.from_env) + " }", + "hosts = " + util.toml_strings(c.hosts)]) diff --git a/src/util.almd b/src/util.almd index 711ec44..223a25e 100644 --- a/src/util.almd +++ b/src/util.almd @@ -73,3 +73,11 @@ fn resolve_serve_caps(profile: String, manifest_caps: List[String]) -> sandbox.C } else if list.len(manifest_caps) > 0 then sandbox.from_manifest(manifest_caps) else sandbox.profile_ai_agent() + +// A TOML basic string: backslash and double-quote escaped so a path holding +// either stays valid TOML rather than closing the string early. +fn toml_quote(s: String) -> String = + "\"" + string.replace(string.replace(s, "\\", "\\\\"), "\"", "\\\"") + "\"" + +fn toml_strings(items: List[String]) -> String = + "[" + string.join(items |> list.map(toml_quote), ", ") + "]" From ee6c5a8cdf9e90f51bcfc2a928d0ac9a965333fc Mon Sep 17 00:00:00 2001 From: O6lvl4 <96694331+O6lvl4@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:01:25 +0900 Subject: [PATCH 06/11] Name kexec_file_load by number, so porta builds as a static musl binary (#43) Co-authored-by: Claude Opus 5.5 (1M context) --- native/seccomp.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/native/seccomp.rs b/native/seccomp.rs index 7f4a132..f585c23 100644 --- a/native/seccomp.rs +++ b/native/seccomp.rs @@ -56,6 +56,13 @@ const SYS_CLONE3: u32 = 435; const SYS_PIDFD_GETFD: u32 = 438; const SYS_MOUNT_SETATTR: u32 = 442; +/// By number: the libc crate's musl table for aarch64 has no `SYS_kexec_file_load`, +/// so a static musl build would not compile with it. +#[cfg(target_arch = "x86_64")] +const SYS_KEXEC_FILE_LOAD: u32 = 320; +#[cfg(target_arch = "aarch64")] +const SYS_KEXEC_FILE_LOAD: u32 = 294; + /// Syscalls refused with `EPERM` in every mode: attaching to or reading /// another process, mounting, entering or creating namespaces, loading kernel /// code, machine-wide state, and the fileless-exec and bypass primitives. @@ -85,7 +92,7 @@ const DENIED: &[u32] = &[ libc::SYS_open_by_handle_at as u32, libc::SYS_personality as u32, libc::SYS_kexec_load as u32, - libc::SYS_kexec_file_load as u32, + SYS_KEXEC_FILE_LOAD, libc::SYS_init_module as u32, libc::SYS_finit_module as u32, libc::SYS_delete_module as u32, From 22de2ce98cfb6377fe58f97b0de387defd9152f5 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sat, 3 Oct 2026 17:03:03 +0900 Subject: [PATCH 07/11] Add a job API for evaluating porta as a WASM job runtime porta job-check, job-run and job-serve take a WASM job and an operator policy, run each job in its own worker process under fuel, memory, deadline and output limits with only the granted directories, and write a run record: run id, policy label and digest, module digest, times, outcome and stop reason. With os_sandbox = "required" every worker also runs under porta run. A container image, cloud templates (not deployed), the threat model, compatibility table and gap list are in docs/enterprise. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 7 + .github/workflows/ci.yml | 4 + CLAUDE.md | 1 + Dockerfile | 47 +++ README.md | 5 + THIRD_PARTY_LICENSES.md | 360 ++++++++++++++++++ deploy/aws-agentcore/README.md | 27 ++ deploy/aws-ecs/README.md | 49 +++ deploy/aws-ecs/task-definition.json | 35 ++ deploy/azure-container-apps/README.md | 33 ++ deploy/azure-container-apps/containerapp.yaml | 38 ++ deploy/cloudflare-containers/README.md | 41 ++ deploy/cloudflare-containers/package.json | 7 + deploy/cloudflare-containers/src/index.ts | 29 ++ deploy/cloudflare-containers/wrangler.jsonc | 20 + deploy/container/policy-wasm-only.toml | 42 ++ deploy/container/policy.toml | 41 ++ deploy/gcp-cloud-run/README.md | 45 +++ deploy/gcp-cloud-run/service.yaml | 36 ++ docs/enterprise/README.ja.md | 98 +++++ docs/enterprise/README.md | 127 ++++++ docs/enterprise/api.md | 206 ++++++++++ docs/enterprise/compatibility.md | 68 ++++ docs/enterprise/distribution.md | 76 ++++ docs/enterprise/gaps.md | 60 +++ docs/enterprise/operations.md | 124 ++++++ docs/enterprise/threat-model.md | 90 +++++ docs/roadmap/README.md | 3 +- .../active/05-enterprise-evaluation.md | 45 +++ examples/enterprise/.gitignore | 1 + examples/enterprise/catalog/prices.csv | 4 + examples/enterprise/evaluate.py | 113 ++++++ examples/enterprise/jobs/flood.json | 10 + examples/enterprise/jobs/fuel.json | 6 + examples/enterprise/jobs/grow.json | 10 + examples/enterprise/jobs/probe.json | 16 + examples/enterprise/jobs/refused-limit.json | 6 + examples/enterprise/jobs/refused-module.json | 5 + .../enterprise/jobs/refused-write-grant.json | 6 + examples/enterprise/jobs/report.json | 7 + examples/enterprise/jobs/spin.json | 6 + examples/enterprise/policy.toml | 40 ++ examples/enterprise/sample-job/almide.toml | 3 + .../enterprise/sample-job/sample-job.wasm | Bin 0 -> 51778 bytes examples/enterprise/sample-job/src/main.almd | 95 +++++ native/job_service.rs | 166 ++++++++ native/job_service/http.rs | 195 ++++++++++ native/job_service/policy.rs | 285 ++++++++++++++ native/job_service/spec.rs | 154 ++++++++ native/job_service/supervisor.rs | 286 ++++++++++++++ native/job_service/supervisor/collect.rs | 142 +++++++ native/job_service/supervisor/launch.rs | 163 ++++++++ native/job_service/worker.rs | 250 ++++++++++++ native/wasmtime_bridge.rs | 1 + scripts/job_integration.py | 216 +++++++++++ scripts/job_server_checks.py | 198 ++++++++++ scripts/third_party_licenses.py | 56 +++ src/help.almd | 5 + src/jobs.almd | 111 ++++++ src/main.almd | 5 + 60 files changed, 4324 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 THIRD_PARTY_LICENSES.md create mode 100644 deploy/aws-agentcore/README.md create mode 100644 deploy/aws-ecs/README.md create mode 100644 deploy/aws-ecs/task-definition.json create mode 100644 deploy/azure-container-apps/README.md create mode 100644 deploy/azure-container-apps/containerapp.yaml create mode 100644 deploy/cloudflare-containers/README.md create mode 100644 deploy/cloudflare-containers/package.json create mode 100644 deploy/cloudflare-containers/src/index.ts create mode 100644 deploy/cloudflare-containers/wrangler.jsonc create mode 100644 deploy/container/policy-wasm-only.toml create mode 100644 deploy/container/policy.toml create mode 100644 deploy/gcp-cloud-run/README.md create mode 100644 deploy/gcp-cloud-run/service.yaml create mode 100644 docs/enterprise/README.ja.md create mode 100644 docs/enterprise/README.md create mode 100644 docs/enterprise/api.md create mode 100644 docs/enterprise/compatibility.md create mode 100644 docs/enterprise/distribution.md create mode 100644 docs/enterprise/gaps.md create mode 100644 docs/enterprise/operations.md create mode 100644 docs/enterprise/threat-model.md create mode 100644 docs/roadmap/active/05-enterprise-evaluation.md create mode 100644 examples/enterprise/.gitignore create mode 100644 examples/enterprise/catalog/prices.csv create mode 100644 examples/enterprise/evaluate.py create mode 100644 examples/enterprise/jobs/flood.json create mode 100644 examples/enterprise/jobs/fuel.json create mode 100644 examples/enterprise/jobs/grow.json create mode 100644 examples/enterprise/jobs/probe.json create mode 100644 examples/enterprise/jobs/refused-limit.json create mode 100644 examples/enterprise/jobs/refused-module.json create mode 100644 examples/enterprise/jobs/refused-write-grant.json create mode 100644 examples/enterprise/jobs/report.json create mode 100644 examples/enterprise/jobs/spin.json create mode 100644 examples/enterprise/policy.toml create mode 100644 examples/enterprise/sample-job/almide.toml create mode 100644 examples/enterprise/sample-job/sample-job.wasm create mode 100644 examples/enterprise/sample-job/src/main.almd create mode 100644 native/job_service.rs create mode 100644 native/job_service/http.rs create mode 100644 native/job_service/policy.rs create mode 100644 native/job_service/spec.rs create mode 100644 native/job_service/supervisor.rs create mode 100644 native/job_service/supervisor/collect.rs create mode 100644 native/job_service/supervisor/launch.rs create mode 100644 native/job_service/worker.rs create mode 100644 scripts/job_integration.py create mode 100644 scripts/job_server_checks.py create mode 100644 scripts/third_party_licenses.py create mode 100644 src/jobs.almd diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..01633e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +target +.tools +.references +docs/benchmarks +examples/enterprise/state +**/__pycache__ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc63c55..6edb3da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,10 @@ jobs: run: command -v strace || (sudo apt-get update -qq && sudo apt-get install -y -qq strace) - name: Integration run: python3 scripts/integration.py target/porta + # The job service: policy, worker, records and the HTTP API, with every + # worker under the OS sandbox (os_sandbox = "required"). + - name: Job service + run: python3 scripts/job_integration.py target/porta - name: Escape corpus run: python3 scripts/escapes.py target/porta # The runner restricts unprivileged user namespaces through AppArmor, as diff --git a/CLAUDE.md b/CLAUDE.md index 59446f9..2fd8a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ as a definition does. | `http_client.rs`, `host_process.rs`, `wasm_inspect.rs` | checked host services: one HTTP request, process helpers, module inspection | | `agent_runtime.rs` + `agent_runtime/` | the broker: `loading` (config, pins, team), `guest`, `model`, `verification`, `tools`, `inspect`, `ffi` | | `agent_journal.rs` | durable broker records, exclusive access, replay validation | +| `job_service.rs` + `job_service/{policy,spec,worker,http,supervisor}.rs`, `supervisor/{launch,collect}.rs` | the evaluation job API (`job-check`, `job-run`, `job-serve`): operator policy, job validation, one worker process per job (under `porta run` when `os_sandbox = "required"`), run records and `events.jsonl`; see `docs/enterprise/` | | `agent_mcp.rs` | explicitly granted remote MCP tool calls | | `json_text.rs`, `locking.rs` | JSON escaping for hand-built replies; lock acquisition that survives poisoning | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5580cb2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Porta Enterprise evaluation image: `porta job-serve` with the sample policy. +# +# docker build -t porta-eval . +# docker run --rm -p 8080:8080 -e PORTA_JOB_TOKEN=<16+ chars> porta-eval +# +# Stages: `build` compiles porta from this checkout with the pinned Almide; +# `test` runs the job integration suite on that binary as an unprivileged +# user; `runtime` is the image that ships. Build `--target test` to run the +# suite; the default target does not. + +FROM rust:1-trixie AS build +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --create-home dev +USER dev +WORKDIR /home/dev/src +COPY --chown=dev . . +RUN rm -rf target .tools \ + && bash scripts/install-almide.sh \ + && .tools/almide/almide build src/main.almd -o target/porta \ + && strip target/porta + +FROM build AS test +RUN python3 scripts/job_integration.py target/porta + +FROM debian:trixie-slim AS runtime +ARG PORTA_UID=10001 +# libssl: porta's HTTP client links OpenSSL on Linux (see THIRD_PARTY_LICENSES.md). +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends libssl3t64 ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --uid ${PORTA_UID} --create-home --home-dir /var/lib/porta porta +COPY --from=build /home/dev/src/target/porta /usr/local/bin/porta +COPY deploy/container/policy.toml deploy/container/policy-wasm-only.toml /etc/porta/ +COPY examples/enterprise/sample-job/sample-job.wasm /opt/porta/modules/sample-job.wasm +COPY examples/enterprise/catalog/ /opt/porta/catalog/ +COPY LICENSE THIRD_PARTY_LICENSES.md /usr/share/doc/porta/ +USER porta +ENV HOME=/var/lib/porta +# Fails the build when a policy's pinned digest does not match the module. +RUN porta job-check --policy /etc/porta/policy.toml > /dev/null \ + && porta job-check --policy /etc/porta/policy-wasm-only.toml > /dev/null +EXPOSE 8080 +STOPSIGNAL SIGTERM +# policy.toml needs the OS sandbox and refuses to start where the kernel +# cannot apply it; policy-wasm-only.toml runs on the WASM boundary alone. +ENTRYPOINT ["porta", "job-serve"] +CMD ["--policy", "/etc/porta/policy.toml", "--listen", "0.0.0.0:8080"] diff --git a/README.md b/README.md index 2f4c604..957d774 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,11 @@ Linux ([overhead](docs/benchmarks/overhead.md)). loop and each tool in separate instances, keeps model credentials in the host, and resumes a crashed run without repeating completed writes ([agent runtime](docs/agent-runtime.md)). +- **A job API with records.** `porta job-serve --policy policy.toml` takes + WASM jobs over HTTP, checks each against an operator policy, runs it in its + own worker under fuel, memory, time and output limits, and returns the + result, why it stopped, and a run record — the evaluation build of + [Porta Enterprise](docs/enterprise/README.md). - **One API and a log.** `--proxy-allow api.example.com --proxy-audit egress.jsonl` lets through only that host and records every decision. - **Keys the command cannot read.** `--credential ANTHROPIC_API_KEY=api.anthropic.com` diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..7937930 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,360 @@ +# Third-party licenses + +Rust crates compiled into the `porta` binary, as resolved for Linux (x86-64, arm64) and macOS (arm64). +Generated by `scripts/third_party_licenses.py`; each crate's full licence text is in its source +package on crates.io. Where a crate offers a choice, porta takes it under the permissive option +(MIT or Apache-2.0; BSD-3-Clause for crates also offered under GPL-2.0). + +Not compiled in, but loaded at run time on Linux: the C library (glibc, LGPL-2.1+) and OpenSSL's +libssl/libcrypto (Apache-2.0), both dynamically linked from the host or image. +Almide, the compiler porta is written in, is not part of the binary beyond its generated code. + +## Summary + +| Licence expression | Crates | +|---|---| +| MIT OR Apache-2.0 | 137 | +| MIT | 43 | +| Apache-2.0 WITH LLVM-exception | 36 | +| Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | 20 | +| Unicode-3.0 | 18 | +| Apache-2.0 OR MIT | 16 | +| MIT/Apache-2.0 | 12 | +| Apache-2.0 | 5 | +| Unlicense OR MIT | 3 | +| BSD-3-Clause | 3 | +| Apache-2.0 OR ISC OR MIT | 2 | +| GPL-2.0-only OR BSD-3-Clause | 2 | +| ISC | 2 | +| MIT-0 | 1 | +| Apache-2.0/MIT | 1 | +| (Apache-2.0 OR MIT) AND BSD-3-Clause | 1 | +| Apache-2.0 / MIT | 1 | +| Zlib | 1 | +| MIT OR Apache-2.0 OR Zlib | 1 | +| BSD-2-Clause OR MIT OR Apache-2.0 | 1 | +| Apache-2.0 AND ISC | 1 | +| Apache-2.0 OR BSL-1.0 | 1 | +| Zlib OR Apache-2.0 OR MIT | 1 | +| (MIT OR Apache-2.0) AND Unicode-3.0 | 1 | +| CDLA-Permissive-2.0 | 1 | +| BSD-2-Clause OR Apache-2.0 OR MIT | 1 | + +312 crates. Without a permissive option: 0. + +## Crates + +| Crate | Version | Licence | Source | +|---|---|---|---| +| addr2line | 0.26.1 | Apache-2.0 OR MIT | https://github.com/gimli-rs/addr2line | +| ahash | 0.8.12 | MIT OR Apache-2.0 | https://github.com/tkaitchuck/ahash | +| aho-corasick | 1.1.5 | Unlicense OR MIT | https://github.com/BurntSushi/aho-corasick | +| allocator-api2 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/zakarumych/allocator-api2 | +| ambient-authority | 0.0.2 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/ambient-authority | +| anyhow | 1.0.104 | MIT OR Apache-2.0 | https://github.com/dtolnay/anyhow | +| arbitrary | 1.4.2 | MIT OR Apache-2.0 | https://github.com/rust-fuzz/arbitrary/ | +| async-trait | 0.1.92 | MIT OR Apache-2.0 | https://github.com/dtolnay/async-trait | +| atomic-waker | 1.1.2 | Apache-2.0 OR MIT | https://github.com/smol-rs/atomic-waker | +| autocfg | 1.5.1 | Apache-2.0 OR MIT | https://github.com/cuviper/autocfg | +| base64 | 0.22.1 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 | +| base64 | 0.23.1 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 | +| bit-set | 0.8.0 | Apache-2.0 OR MIT | https://github.com/contain-rs/bit-set | +| bit-vec | 0.8.0 | Apache-2.0 OR MIT | https://github.com/contain-rs/bit-vec | +| bitflags | 2.13.2 | MIT OR Apache-2.0 | https://github.com/bitflags/bitflags | +| block-buffer | 0.10.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| borrow-or-share | 0.2.4 | MIT-0 | https://github.com/yescallop/borrow-or-share | +| bumpalo | 3.20.3 | MIT OR Apache-2.0 | https://github.com/fitzgen/bumpalo | +| bytecount | 0.6.9 | Apache-2.0/MIT | https://github.com/llogiq/bytecount | +| bytes | 1.12.1 | MIT | https://github.com/tokio-rs/bytes | +| cap-fs-ext | 4.0.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/cap-std | +| cap-primitives | 4.0.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/cap-std | +| cap-std | 4.0.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/cap-std | +| cc | 1.6.0 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs | +| cfg-if | 1.0.5 | MIT OR Apache-2.0 | https://github.com/rust-lang/cfg-if | +| cfg_aliases | 0.2.2 | MIT | https://github.com/katharostech/cfg_aliases | +| chacha20 | 0.10.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers | +| cobs | 0.3.0 | MIT OR Apache-2.0 | https://github.com/jamesmunns/cobs.rs | +| core-foundation | 0.10.1 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | +| core-foundation | 0.9.4 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | +| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | +| core_detect | 1.0.0 | MIT/Apache-2.0 | https://github.com/thomcc/core_detect | +| cpp_demangle | 0.5.1 | MIT OR Apache-2.0 | https://github.com/gimli-rs/cpp_demangle | +| cpufeatures | 0.2.17 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| cpufeatures | 0.3.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| cranelift-assembler-x64 | 0.134.4 | Apache-2.0 WITH LLVM-exception | | +| cranelift-assembler-x64-meta | 0.134.4 | Apache-2.0 WITH LLVM-exception | | +| cranelift-bforest | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-bitset | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-codegen | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-codegen-meta | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-codegen-shared | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-control | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-entity | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-frontend | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-isle | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime/tree/main/cranelift/isle | +| cranelift-native | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| cranelift-srcgen | 0.134.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| crc32fast | 1.5.2 | MIT OR Apache-2.0 | https://github.com/srijs/rust-crc32fast | +| crossbeam-deque | 0.8.8 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam | +| crossbeam-epoch | 0.9.21 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam | +| crossbeam-utils | 0.8.23 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam | +| crypto-common | 0.1.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| data-encoding | 2.11.1 | MIT | https://github.com/ia0/data-encoding | +| debugid | 0.8.0 | Apache-2.0 | https://github.com/getsentry/rust-debugid | +| digest | 0.10.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| directories-next | 2.0.0 | MIT OR Apache-2.0 | https://github.com/xdg-rs/dirs/tree/master/directories | +| dirs-sys-next | 0.1.2 | MIT OR Apache-2.0 | https://github.com/xdg-rs/dirs/tree/master/dirs-sys | +| displaydoc | 0.2.7 | MIT OR Apache-2.0 | https://github.com/yaahc/displaydoc | +| either | 1.18.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/either | +| email_address | 0.2.9 | MIT | https://github.com/johnstonskj/rust-email_address.git | +| embedded-io | 0.4.0 | MIT OR Apache-2.0 | https://github.com/embassy-rs/embedded-io | +| embedded-io | 0.6.1 | MIT OR Apache-2.0 | https://github.com/rust-embedded/embedded-hal | +| encoding_rs | 0.8.42 | (Apache-2.0 OR MIT) AND BSD-3-Clause | https://github.com/hsivonen/encoding_rs | +| equivalent | 1.0.2 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/equivalent | +| errno | 0.3.14 | MIT OR Apache-2.0 | https://github.com/lambda-fairy/rust-errno | +| fancy-regex | 0.19.2 | MIT | https://github.com/fancy-regex/fancy-regex | +| fastrand | 2.5.0 | Apache-2.0 OR MIT | https://github.com/smol-rs/fastrand | +| find-msvc-tools | 0.1.14 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs | +| fixedbitset | 0.4.2 | MIT/Apache-2.0 | https://github.com/petgraph/fixedbitset | +| fluent-uri | 0.4.1 | MIT | https://github.com/yescallop/fluent-uri-rs | +| fnv | 1.0.7 | Apache-2.0 / MIT | https://github.com/servo/rust-fnv | +| foldhash | 0.2.0 | Zlib | https://github.com/orlp/foldhash | +| foreign-types | 0.3.2 | MIT/Apache-2.0 | https://github.com/sfackler/foreign-types | +| foreign-types-shared | 0.1.1 | MIT/Apache-2.0 | https://github.com/sfackler/foreign-types | +| form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url | +| fraction | 0.17.0 | MIT OR Apache-2.0 | https://github.com/dnsl48/fraction.git | +| fs-set-times | 0.20.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/fs-set-times | +| futures | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-channel | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-core | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-io | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-sink | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-task | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-util | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| fxprof-processed-profile | 0.8.1 | MIT OR Apache-2.0 | https://github.com/mstange/samply/ | +| generic-array | 0.14.7 | MIT | https://github.com/fizyk20/generic-array.git | +| getrandom | 0.2.17 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom | +| getrandom | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom | +| getrandom | 0.4.3 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom | +| gimli | 0.33.0 | MIT OR Apache-2.0 | https://github.com/gimli-rs/gimli | +| h2 | 0.4.19 | MIT | https://github.com/hyperium/h2 | +| hashbrown | 0.16.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown | +| hashbrown | 0.17.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown | +| heck | 0.5.0 | MIT OR Apache-2.0 | https://github.com/withoutboats/heck | +| http | 1.5.0 | MIT OR Apache-2.0 | https://github.com/hyperium/http | +| http-body | 1.1.0 | MIT | https://github.com/hyperium/http-body | +| http-body-util | 0.1.5 | MIT | https://github.com/hyperium/http-body | +| httparse | 1.10.1 | MIT OR Apache-2.0 | https://github.com/seanmonstar/httparse | +| hyper | 1.11.1 | MIT | https://github.com/hyperium/hyper | +| hyper-rustls | 0.27.10 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls | +| hyper-tls | 0.6.0 | MIT/Apache-2.0 | https://github.com/hyperium/hyper-tls | +| hyper-util | 0.1.21 | MIT | https://github.com/hyperium/hyper-util | +| icu_collections | 2.3.0 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_locale_core | 2.3.0 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_normalizer | 2.3.0 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_normalizer_data | 2.3.0 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_properties | 2.3.0 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_properties_data | 2.3.0 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_provider | 2.3.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| id-arena | 2.3.0 | MIT/Apache-2.0 | https://github.com/fitzgen/id-arena | +| idna | 1.1.0 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ | +| idna_adapter | 1.2.2 | Apache-2.0 OR MIT | https://github.com/hsivonen/idna_adapter | +| indexmap | 2.14.2 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/indexmap | +| io-extras | 0.19.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/io-extras | +| io-lifetimes | 2.0.4 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/io-lifetimes | +| io-lifetimes | 3.0.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/io-lifetimes | +| ipnet | 2.12.2 | MIT OR Apache-2.0 | https://github.com/krisprice/ipnet | +| itertools | 0.14.0 | MIT OR Apache-2.0 | https://github.com/rust-itertools/itertools | +| itoa | 1.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/itoa | +| ittapi | 0.4.0 | GPL-2.0-only OR BSD-3-Clause | https://github.com/intel/ittapi | +| ittapi-sys | 0.4.0 | GPL-2.0-only OR BSD-3-Clause | https://github.com/intel/ittapi | +| jobserver | 0.1.35 | MIT OR Apache-2.0 | https://github.com/rust-lang/jobserver-rs | +| jsonschema | 0.56.0 | MIT | https://github.com/Stranger6667/jsonschema | +| jsonschema-regex | 0.56.0 | MIT | https://github.com/Stranger6667/jsonschema | +| jsonschema-value | 0.56.0 | MIT | https://github.com/Stranger6667/jsonschema | +| leb128 | 0.2.7 | MIT OR Apache-2.0 | https://github.com/gimli-rs/leb128 | +| leb128fmt | 0.1.0 | MIT OR Apache-2.0 | https://github.com/bluk/leb128fmt | +| libc | 0.2.190 | MIT OR Apache-2.0 | https://github.com/rust-lang/libc | +| libm | 0.2.16 | MIT | https://github.com/rust-lang/compiler-builtins | +| linux-raw-sys | 0.12.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/linux-raw-sys | +| litemap | 0.8.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| lock_api | 0.4.14 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot | +| log | 0.4.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/log | +| lru-slab | 0.1.3 | MIT OR Apache-2.0 OR Zlib | https://github.com/Ralith/lru-slab | +| mach2 | 0.6.0 | BSD-2-Clause OR MIT OR Apache-2.0 | https://github.com/JohnTitor/mach2 | +| maybe-owned | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rustonaut/maybe-owned | +| memchr | 2.8.3 | Unlicense OR MIT | https://github.com/BurntSushi/memchr | +| memfd | 0.6.6 | MIT OR Apache-2.0 | https://github.com/lucab/memfd-rs | +| micromap | 0.3.0 | MIT | https://github.com/yegor256/micromap | +| mime | 0.3.17 | MIT OR Apache-2.0 | https://github.com/hyperium/mime | +| mio | 1.2.3 | MIT | https://github.com/tokio-rs/mio | +| multiversion_no_op | 1.0.0 | Apache-2.0 OR MIT | https://github.com/hsivonen/multiversion_no_op | +| native-tls | 0.2.18 | MIT OR Apache-2.0 | https://github.com/rust-native-tls/rust-native-tls | +| num | 0.4.3 | MIT OR Apache-2.0 | https://github.com/rust-num/num | +| num-bigint | 0.4.8 | MIT OR Apache-2.0 | https://github.com/rust-num/num-bigint | +| num-cmp | 0.1.0 | MIT/Apache-2.0 | https://github.com/lifthrasiir/num-cmp | +| num-complex | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rust-num/num-complex | +| num-integer | 0.1.47 | MIT OR Apache-2.0 | https://github.com/rust-num/num-integer | +| num-iter | 0.1.46 | MIT OR Apache-2.0 | https://github.com/rust-num/num-iter | +| num-rational | 0.4.2 | MIT OR Apache-2.0 | https://github.com/rust-num/num-rational | +| num-traits | 0.2.19 | MIT OR Apache-2.0 | https://github.com/rust-num/num-traits | +| object | 0.39.1 | Apache-2.0 OR MIT | https://github.com/gimli-rs/object | +| once_cell | 1.21.4 | MIT OR Apache-2.0 | https://github.com/matklad/once_cell | +| openssl | 0.10.81 | Apache-2.0 | https://github.com/rust-openssl/rust-openssl | +| openssl-macros | 0.1.1 | MIT/Apache-2.0 | | +| openssl-probe | 0.2.1 | MIT OR Apache-2.0 | https://github.com/rustls/openssl-probe | +| openssl-sys | 0.9.117 | MIT | https://github.com/rust-openssl/rust-openssl | +| outref | 0.5.2 | MIT | https://github.com/Nugine/outref | +| parking_lot | 0.12.5 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot | +| parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot | +| percent-encoding | 2.3.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ | +| petgraph | 0.6.5 | MIT OR Apache-2.0 | https://github.com/petgraph/petgraph | +| pin-project-lite | 0.2.17 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project-lite | +| pkg-config | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/pkg-config-rs | +| postcard | 1.1.3 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard | +| potential_utf | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| proc-macro2 | 1.0.107 | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro2 | +| pulley-interpreter | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime/tree/main/pulley | +| pulley-macros | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime/tree/main/pulley/macros | +| quinn | 0.11.12 | MIT OR Apache-2.0 | https://github.com/quinn-rs/quinn | +| quinn-proto | 0.11.19 | MIT OR Apache-2.0 | https://github.com/quinn-rs/quinn | +| quinn-udp | 0.5.16 | MIT OR Apache-2.0 | https://github.com/quinn-rs/quinn | +| quote | 1.0.47 | MIT OR Apache-2.0 | https://github.com/dtolnay/quote | +| rand | 0.10.3 | MIT OR Apache-2.0 | https://github.com/rust-random/rand | +| rand_core | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand_core | +| rand_pcg | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rngs | +| rayon | 1.12.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/rayon | +| rayon-core | 1.13.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/rayon | +| ref-cast | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast | +| ref-cast-impl | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast | +| referencing | 0.56.0 | MIT | https://github.com/Stranger6667/jsonschema | +| regalloc2 | 0.15.2 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/regalloc2 | +| regex | 1.13.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex | +| regex-automata | 0.4.18 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex | +| regex-syntax | 0.8.11 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex | +| reqwest | 0.12.28 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest | +| ring | 0.17.14 | Apache-2.0 AND ISC | https://github.com/briansmith/ring | +| rustc-demangle | 0.1.28 | MIT/Apache-2.0 | https://github.com/rust-lang/rustc-demangle | +| rustc-hash | 2.1.3 | Apache-2.0 OR MIT | https://github.com/rust-lang/rustc-hash | +| rustix | 1.1.5 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/rustix | +| rustix-linux-procfs | 0.1.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/rustix-linux-procfs | +| rustls | 0.23.45 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls | +| rustls-pki-types | 1.15.1 | MIT OR Apache-2.0 | https://github.com/rustls/pki-types | +| rustls-webpki | 0.103.15 | ISC | https://github.com/rustls/webpki | +| rustversion | 1.0.23 | MIT OR Apache-2.0 | https://github.com/dtolnay/rustversion | +| ryu | 1.0.23 | Apache-2.0 OR BSL-1.0 | https://github.com/dtolnay/ryu | +| scopeguard | 1.2.0 | MIT OR Apache-2.0 | https://github.com/bluss/scopeguard | +| security-framework | 3.7.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework | +| security-framework-sys | 2.17.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework | +| semver | 1.0.28 | MIT OR Apache-2.0 | https://github.com/dtolnay/semver | +| serde | 1.0.229 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde | +| serde_core | 1.0.229 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde | +| serde_derive | 1.0.229 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde | +| serde_json | 1.0.151 | MIT OR Apache-2.0 | https://github.com/serde-rs/json | +| serde_spanned | 0.6.9 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| serde_spanned | 1.1.1 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| serde_urlencoded | 0.7.1 | MIT/Apache-2.0 | https://github.com/nox/serde_urlencoded | +| sha2 | 0.10.9 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes | +| shlex | 2.0.1 | MIT OR Apache-2.0 | https://github.com/comex/rust-shlex | +| simdutf8 | 0.1.5 | MIT OR Apache-2.0 | https://github.com/rusticstuff/simdutf8 | +| slab | 0.4.12 | MIT | https://github.com/tokio-rs/slab | +| smallvec | 1.16.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-smallvec | +| socket2 | 0.6.5 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 | +| stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 | https://github.com/storyyeller/stable_deref_trait | +| strum | 0.28.0 | MIT | https://github.com/Peternator7/strum | +| strum_macros | 0.28.0 | MIT | https://github.com/Peternator7/strum | +| subtle | 2.6.1 | BSD-3-Clause | https://github.com/dalek-cryptography/subtle | +| syn | 2.0.119 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn | +| syn | 3.0.6 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn | +| sync_wrapper | 1.0.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper | +| synstructure | 0.14.0 | MIT | https://github.com/mystor/synstructure | +| system-configuration | 0.7.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | +| system-configuration-sys | 0.6.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | +| target-lexicon | 0.13.5 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/target-lexicon | +| tempfile | 3.27.0 | MIT OR Apache-2.0 | https://github.com/Stebalien/tempfile | +| termcolor | 1.4.1 | Unlicense OR MIT | https://github.com/BurntSushi/termcolor | +| thiserror | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| thiserror | 2.0.21 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| thiserror-impl | 2.0.21 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| tinystr | 0.8.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| tinyvec | 1.13.3 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/tinyvec | +| tokio | 1.53.1 | MIT | https://github.com/tokio-rs/tokio | +| tokio-native-tls | 0.3.1 | MIT | https://github.com/tokio-rs/tls | +| tokio-rustls | 0.26.6 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls | +| tokio-util | 0.7.19 | MIT | https://github.com/tokio-rs/tokio | +| toml | 0.8.23 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml | 0.9.12+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_datetime | 0.6.11 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_datetime | 0.7.5+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_edit | 0.22.27 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_parser | 1.1.3+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_write | 0.1.2 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_writer | 1.1.2+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| tower | 0.5.3 | MIT | https://github.com/tower-rs/tower | +| tower-http | 0.6.11 | MIT | https://github.com/tower-rs/tower-http | +| tower-layer | 0.3.3 | MIT | https://github.com/tower-rs/tower | +| tower-service | 0.3.3 | MIT | https://github.com/tower-rs/tower | +| tracing | 0.1.44 | MIT | https://github.com/tokio-rs/tracing | +| tracing-attributes | 0.1.31 | MIT | https://github.com/tokio-rs/tracing | +| tracing-core | 0.1.36 | MIT | https://github.com/tokio-rs/tracing | +| try-lock | 0.2.5 | MIT | https://github.com/seanmonstar/try-lock | +| typenum | 1.20.1 | MIT OR Apache-2.0 | https://github.com/paholg/typenum | +| unicode-general-category | 1.1.0 | Apache-2.0 | https://github.com/yeslogic/unicode-general-category | +| unicode-ident | 1.0.26 | (MIT OR Apache-2.0) AND Unicode-3.0 | https://github.com/dtolnay/unicode-ident | +| unicode-width | 0.2.2 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-width | +| untrusted | 0.9.0 | ISC | https://github.com/briansmith/untrusted | +| url | 2.5.8 | MIT OR Apache-2.0 | https://github.com/servo/rust-url | +| utf8_iter | 1.0.4 | Apache-2.0 OR MIT | https://github.com/hsivonen/utf8_iter | +| uuid | 1.27.0 | Apache-2.0 OR MIT | https://github.com/uuid-rs/uuid | +| uuid-simd | 0.8.0 | MIT | https://github.com/Nugine/simd | +| vcpkg | 0.2.15 | MIT/Apache-2.0 | https://github.com/mcgoo/vcpkg-rs | +| version_check | 0.9.5 | MIT/Apache-2.0 | https://github.com/SergioBenitez/version_check | +| vsimd | 0.8.0 | MIT | https://github.com/Nugine/simd | +| want | 0.3.1 | MIT | https://github.com/seanmonstar/want | +| wasm-compose | 0.252.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-compose | +| wasm-encoder | 0.252.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-encoder | +| wasm-encoder | 0.261.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-encoder | +| wasmparser | 0.252.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmparser | +| wasmparser | 0.261.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmparser | +| wasmprinter | 0.252.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmprinter | +| wasmtime | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-environ | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-cache | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-component-macro | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-component-util | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-core | 47.0.4 | Apache-2.0 WITH LLVM-exception | | +| wasmtime-internal-cranelift | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-fiber | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-jit-debug | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-jit-icache-coherence | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-unwinder | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-versioned-export-macros | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-internal-wit-bindgen | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-wasi | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wasmtime-wasi-io | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wast | 261.0.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wast | +| wast | 35.0.2 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wast | +| wat | 1.261.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wat | +| webpki-roots | 1.0.9 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots | +| wiggle | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wiggle-generate | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| wiggle-macro | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime | +| winnow | 0.7.15 | MIT | https://github.com/winnow-rs/winnow | +| winnow | 1.0.4 | MIT | https://github.com/winnow-rs/winnow | +| wit-parser | 0.252.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-parser | +| witx | 0.9.1 | Apache-2.0 | https://github.com/WebAssembly/WASI | +| writeable | 0.6.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| yoke | 0.8.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| yoke-derive | 0.8.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zerocopy | 0.8.59 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy | +| zerofrom | 0.1.8 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zerofrom-derive | 0.1.8 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zeroize | 1.9.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils | +| zerotrie | 0.2.5 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zerovec | 0.11.8 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zerovec-derive | 0.11.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zmij | 1.0.23 | MIT | https://github.com/dtolnay/zmij | +| zstd | 0.13.3 | MIT | https://github.com/gyscos/zstd-rs | +| zstd-safe | 7.3.0 | BSD-3-Clause | https://github.com/gyscos/zstd-rs | +| zstd-sys | 2.1.0+zstd.1.5.7 | BSD-3-Clause | https://github.com/gyscos/zstd-rs | diff --git a/deploy/aws-agentcore/README.md b/deploy/aws-agentcore/README.md new file mode 100644 index 0000000..2fea936 --- /dev/null +++ b/deploy/aws-agentcore/README.md @@ -0,0 +1,27 @@ +# Amazon Bedrock AgentCore Runtime (additional candidate) + +**Status: not implemented. This page is the adapter design only.** + +AgentCore Runtime runs one container per session in its own microVM, but its +contract is not porta's API +([HTTP protocol contract](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-http-protocol-contract.html)): + +| AgentCore requires | porta job-serve has | +|---|---| +| arm64 image, port 8080 on 0.0.0.0 | either architecture, any port | +| `POST /invocations`, JSON in, JSON or SSE out | `POST /v1/runs?wait=S` | +| `GET /ping` → `{"status": "Healthy" \| "HealthyBusy"}` | `GET /healthz` → `{"ok": true}` | +| inbound auth (SigV4 or JWT) at the AWS edge | its own bearer token | +| session storage under `/mnt/` (preview) | `records` / `workspaces` paths from the policy | + +The adapter would be a `--protocol agentcore` mode of `job-serve`: +`/invocations` = submit and wait, `/ping` = health (`HealthyBusy` while a job +runs). The open question is authentication: AgentCore authenticates the +caller before the request reaches the container, and porta today refuses to +listen on a non-loopback address without its own token. Relaxing that only +for AgentCore needs a decision on whether the edge's authentication is +sufficient for the customer — so it is left undone rather than guessed. + +Limits to plan around: 2 vCPU / 8 GB per session, 15 minutes per +synchronous request, sessions of up to 8 hours +([limits](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/bedrock-agentcore-limits.html)). diff --git a/deploy/aws-ecs/README.md b/deploy/aws-ecs/README.md new file mode 100644 index 0000000..15fdde3 --- /dev/null +++ b/deploy/aws-ecs/README.md @@ -0,0 +1,49 @@ +# AWS ECS (Fargate) + +**Status: template written, not deployed.** Nothing here has run on AWS. + +[`task-definition.json`](task-definition.json) runs the evaluation image as +one Fargate task: arm64, 0.5 vCPU / 1 GiB, read-only root filesystem with an +ephemeral volume at `/var/lib/porta` for records and workspaces, the token +from Secrets Manager, logs to CloudWatch, 30 s between SIGTERM and SIGKILL +(porta finishes running jobs as `service_shutdown` within 8 s). + +What has to be decided or verified on a real account: + +1. **Whether `os_sandbox = "required"` starts.** Fargate's docs say nothing + about Landlock, seccomp filters a process installs itself, or namespaces + ([security considerations](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-security-considerations.html)). + The service checks at start and refuses with the reason if it cannot. If + it refuses, switch `command` to `/etc/porta/policy-wasm-only.toml` and + record that the OS layer is absent there. On ECS **on EC2** you own the + kernel and the Docker seccomp profile, so `required` can be arranged. +2. **Records survive only as long as the task.** For durable records mount + EFS at `/var/lib/porta` (`efsVolumeConfiguration`); porta writes records + with rename, which EFS supports. Not tested. +3. **TLS and access.** porta speaks plain HTTP. Put an ALB with an HTTPS + listener in front, health check `GET /healthz`, and keep the task's + security group closed to anything but the ALB. + +## Steps (need approval: they create billable resources) + +```bash +# 1. Image (arm64 to match the task definition) +docker buildx build --platform linux/arm64 -t .dkr.ecr..amazonaws.com/porta-eval: --push . +# 2. Token +aws secretsmanager create-secret --name porta-eval-token --secret-string "$(openssl rand -hex 24)" +# 3. Task definition and a service in an existing cluster/VPC +aws ecs register-task-definition --cli-input-json file://deploy/aws-ecs/task-definition.json +aws ecs create-service --cluster --service-name porta-eval --task-definition porta-eval \ + --desired-count 1 --launch-type FARGATE \ + --network-configuration 'awsvpcConfiguration={subnets=[],securityGroups=[],assignPublicIp=DISABLED}' +# 4. Evaluate through a tunnel or the ALB +PORTA_JOB_TOKEN=... python3 examples/enterprise/evaluate.py --url https:// +# Remove +aws ecs delete-service --cluster --service porta-eval --force +aws ecs deregister-task-definition --task-definition porta-eval:1 +aws secretsmanager delete-secret --secret-id porta-eval-token --force-delete-without-recovery +``` + +The execution role needs `AmazonECSTaskExecutionRolePolicy` and +`secretsmanager:GetSecretValue` on the token. The task needs no task role: +porta calls no AWS API. diff --git a/deploy/aws-ecs/task-definition.json b/deploy/aws-ecs/task-definition.json new file mode 100644 index 0000000..f78a2b0 --- /dev/null +++ b/deploy/aws-ecs/task-definition.json @@ -0,0 +1,35 @@ +{ + "family": "porta-eval", + "requiresCompatibilities": ["FARGATE"], + "networkMode": "awsvpc", + "cpu": "512", + "memory": "1024", + "runtimePlatform": { "operatingSystemFamily": "LINUX", "cpuArchitecture": "ARM64" }, + "executionRoleArn": "arn:aws:iam:::role/porta-eval-execution", + "volumes": [{ "name": "state" }], + "containerDefinitions": [ + { + "name": "porta", + "image": ".dkr.ecr..amazonaws.com/porta-eval:", + "essential": true, + "user": "10001", + "readonlyRootFilesystem": true, + "command": ["--policy", "/etc/porta/policy.toml", "--listen", "0.0.0.0:8080"], + "portMappings": [{ "containerPort": 8080, "protocol": "tcp" }], + "secrets": [ + { "name": "PORTA_JOB_TOKEN", "valueFrom": "arn:aws:secretsmanager:::secret:porta-eval-token" } + ], + "mountPoints": [{ "sourceVolume": "state", "containerPath": "/var/lib/porta" }], + "stopTimeout": 30, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/porta-eval", + "awslogs-region": "", + "awslogs-stream-prefix": "porta", + "awslogs-create-group": "true" + } + } + } + ] +} diff --git a/deploy/azure-container-apps/README.md b/deploy/azure-container-apps/README.md new file mode 100644 index 0000000..d5c1723 --- /dev/null +++ b/deploy/azure-container-apps/README.md @@ -0,0 +1,33 @@ +# Azure Container Apps + +**Status: template written, not deployed.** Nothing here has run on Azure. + +[`containerapp.yaml`](containerapp.yaml) runs the evaluation image as one +replica with internal ingress on port 8080, the token from Key Vault through +the app's system identity, `GET /healthz` as the startup probe. + +Constraints ([containers](https://learn.microsoft.com/en-us/azure/container-apps/containers), +[storage](https://learn.microsoft.com/en-us/azure/container-apps/storage-mounts), +[lifecycle](https://learn.microsoft.com/en-us/azure/container-apps/application-lifecycle-management)): + +- **amd64 image.** Build with `--platform linux/amd64`. +- **The OS sandbox is unverified.** Azure documents no privileged containers + and nothing about seccomp, Landlock or namespaces for ordinary apps. If + `policy.toml` refuses to start, use `/etc/porta/policy-wasm-only.toml`. +- **Records are ephemeral** (1–8 GiB by vCPU). Durable records need an + Azure Files mount at `/var/lib/porta`; porta's rename-based writes over SMB + are untested. +- **Ingress times out at 240 s** by default; keep `?wait=` below it. +- **SIGTERM, then 30 s.** +- The system identity needs `AcrPull` on the registry and + `Key Vault Secrets User` on the secret before the first revision starts. + +## Steps (need approval: they create billable resources) + +```bash +docker buildx build --platform linux/amd64 -t .azurecr.io/porta-eval: --push . +az keyvault secret set --vault-name -n porta-eval-token --value "$(openssl rand -hex 24)" +az containerapp create -n porta-eval -g --yaml deploy/azure-container-apps/containerapp.yaml +# Remove +az containerapp delete -n porta-eval -g +``` diff --git a/deploy/azure-container-apps/containerapp.yaml b/deploy/azure-container-apps/containerapp.yaml new file mode 100644 index 0000000..42830cf --- /dev/null +++ b/deploy/azure-container-apps/containerapp.yaml @@ -0,0 +1,38 @@ +# az containerapp create -n porta-eval -g --yaml deploy/azure-container-apps/containerapp.yaml +location: +properties: + managedEnvironmentId: /subscriptions//resourceGroups//providers/Microsoft.App/managedEnvironments/ + configuration: + activeRevisionsMode: Single + ingress: + external: false + targetPort: 8080 + transport: http + registries: + - server: .azurecr.io + identity: system + secrets: + - name: porta-job-token + keyVaultUrl: https://.vault.azure.net/secrets/porta-eval-token + identity: system + template: + scale: + minReplicas: 1 + maxReplicas: 1 + containers: + - name: porta + image: .azurecr.io/porta-eval: + args: ["--policy", "/etc/porta/policy.toml", "--listen", "0.0.0.0:8080"] + env: + - name: PORTA_JOB_TOKEN + secretRef: porta-job-token + resources: + cpu: 0.5 + memory: 1Gi + probes: + - type: Startup + httpGet: + path: /healthz + port: 8080 +identity: + type: SystemAssigned diff --git a/deploy/cloudflare-containers/README.md b/deploy/cloudflare-containers/README.md new file mode 100644 index 0000000..9b76689 --- /dev/null +++ b/deploy/cloudflare-containers/README.md @@ -0,0 +1,41 @@ +# Cloudflare Containers + +**Status: template written, not deployed, not type-checked.** Nothing here +has run on Cloudflare. `package.json` leaves `@cloudflare/containers` +unpinned (`*`); pin the version `npm install` resolves on first use. + +Cloudflare runs a container behind a Worker and a Durable Object +([container package](https://developers.cloudflare.com/containers/container-package/), +[architecture](https://developers.cloudflare.com/containers/platform-details/architecture/)). +This is an adapter, not just a manifest: [`src/index.ts`](src/index.ts) +routes every request to one named container instance and hands it the token +from a Worker secret; [`wrangler.jsonc`](wrangler.jsonc) builds the +repository's Dockerfile. + +Differences from the other targets: + +- **amd64 only**; wrangler builds the image. +- **Each instance is a Firecracker microVM with its own kernel.** Whether it + takes Landlock and porta's seccomp filter is undocumented; if `policy.toml` + refuses to start, set `entrypoint` / args to the wasm-only policy. +- **The disk is fresh after every sleep.** Records vanish when the instance + sleeps (`sleepAfter`). Durable records would need the snapshot beta or an + R2 FUSE mount — neither is wired up here. +- **SIGTERM, then up to 15 minutes.** +- `max_instances: 1` keeps every run on one instance; more instances would + each have their own records. + +## Steps (need approval: Workers Paid plan, billable usage) + +```bash +cd deploy/cloudflare-containers +npm install +npx wrangler secret put PORTA_JOB_TOKEN +npx wrangler deploy +PORTA_JOB_TOKEN=... python3 ../../examples/enterprise/evaluate.py --url https://porta-eval..workers.dev +# Remove +npx wrangler delete +``` + +The Worker is public at its workers.dev URL; porta's token is the only gate. +Add Cloudflare Access in front for anything beyond a short evaluation. diff --git a/deploy/cloudflare-containers/package.json b/deploy/cloudflare-containers/package.json new file mode 100644 index 0000000..d1c2e4b --- /dev/null +++ b/deploy/cloudflare-containers/package.json @@ -0,0 +1,7 @@ +{ + "name": "porta-eval-cloudflare", + "private": true, + "scripts": { "deploy": "wrangler deploy" }, + "dependencies": { "@cloudflare/containers": "*" }, + "devDependencies": { "wrangler": "^4.0.0", "typescript": "^5.0.0" } +} diff --git a/deploy/cloudflare-containers/src/index.ts b/deploy/cloudflare-containers/src/index.ts new file mode 100644 index 0000000..4cbcab8 --- /dev/null +++ b/deploy/cloudflare-containers/src/index.ts @@ -0,0 +1,29 @@ +// The Worker in front of the porta container. Every request goes to one named +// instance, so every run's record lives in one place while it is awake. The +// client's Authorization header passes through unchanged; porta checks it. +import { Container, getContainer } from "@cloudflare/containers"; + +interface Env { + PORTA: DurableObjectNamespace; + PORTA_JOB_TOKEN: string; // `npx wrangler secret put PORTA_JOB_TOKEN` +} + +export class PortaContainer extends Container { + defaultPort = 8080; + // Records and retained output are on the instance's disk, which starts + // fresh after it sleeps. Keep it awake for the length of an evaluation. + sleepAfter = "30m"; + // Jobs get no network from porta either way; the container needs none. + enableInternet = false; + + constructor(ctx: DurableObjectState<{}>, env: Env) { + super(ctx, env); + this.envVars = { PORTA_JOB_TOKEN: env.PORTA_JOB_TOKEN }; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + return getContainer(env.PORTA, "porta").fetch(request); + }, +}; diff --git a/deploy/cloudflare-containers/wrangler.jsonc b/deploy/cloudflare-containers/wrangler.jsonc new file mode 100644 index 0000000..66654bf --- /dev/null +++ b/deploy/cloudflare-containers/wrangler.jsonc @@ -0,0 +1,20 @@ +// npx wrangler deploy (from this directory; needs a Workers Paid plan) +{ + "name": "porta-eval", + "main": "src/index.ts", + "compatibility_date": "2026-09-01", + "containers": [ + { + "class_name": "PortaContainer", + // The repository's Dockerfile; wrangler builds it for linux/amd64. + "image": "../../Dockerfile", + "image_build_context": "../..", + "instance_type": "standard-1", + "max_instances": 1 + } + ], + "durable_objects": { + "bindings": [{ "name": "PORTA", "class_name": "PortaContainer" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["PortaContainer"] }] +} diff --git a/deploy/container/policy-wasm-only.toml b/deploy/container/policy-wasm-only.toml new file mode 100644 index 0000000..3178556 --- /dev/null +++ b/deploy/container/policy-wasm-only.toml @@ -0,0 +1,42 @@ +# The evaluation policy for platforms where the OS sandbox cannot be applied: +# the WASM and worker-process boundaries only. Otherwise identical to +# policy.toml. Paths here are absolute inside the image. +version = 1 +label = "container-evaluation-1-wasm-only" +env_names = ["REPORT_CURRENCY"] + +[service] +records = "/var/lib/porta/records" +workspaces = "/var/lib/porta/workspaces" +max_concurrent = 4 +retain_output = true +max_request_bytes = 1048576 +# "required": every worker also runs under porta's own OS sandbox, and the +# service refuses to start where the kernel cannot apply it. "off": the WASM +# boundary alone. See docs/enterprise/compatibility.md for which platforms +# allow "required". +os_sandbox = "off" + +[limits] +timeout_ms = 10000 +fuel = 100000000000 +memory_mib = 64 +max_output_bytes = 1048576 +max_input_bytes = 524288 + +[defaults] +timeout_ms = 3000 +fuel = 1000000000 +memory_mib = 32 +max_output_bytes = 65536 +max_input_bytes = 65536 + +[[modules]] +name = "sample-job" +wasm = "/opt/porta/modules/sample-job.wasm" +sha256 = "6a187188ce3dda6d5252e46bfc70899a21728336abd8857dbc7aca3a56c90a00" + +[[directories]] +name = "catalog" +host = "/opt/porta/catalog" +access = "read" diff --git a/deploy/container/policy.toml b/deploy/container/policy.toml new file mode 100644 index 0000000..c650af1 --- /dev/null +++ b/deploy/container/policy.toml @@ -0,0 +1,41 @@ +# The policy the evaluation image starts with. Replace it by mounting your own +# at /etc/porta/policy.toml. Paths here are absolute inside the image. +version = 1 +label = "container-evaluation-1" +env_names = ["REPORT_CURRENCY"] + +[service] +records = "/var/lib/porta/records" +workspaces = "/var/lib/porta/workspaces" +max_concurrent = 4 +retain_output = true +max_request_bytes = 1048576 +# "required": every worker also runs under porta's own OS sandbox, and the +# service refuses to start where the kernel cannot apply it. "off": the WASM +# boundary alone. See docs/enterprise/compatibility.md for which platforms +# allow "required". +os_sandbox = "required" + +[limits] +timeout_ms = 10000 +fuel = 100000000000 +memory_mib = 64 +max_output_bytes = 1048576 +max_input_bytes = 524288 + +[defaults] +timeout_ms = 3000 +fuel = 1000000000 +memory_mib = 32 +max_output_bytes = 65536 +max_input_bytes = 65536 + +[[modules]] +name = "sample-job" +wasm = "/opt/porta/modules/sample-job.wasm" +sha256 = "6a187188ce3dda6d5252e46bfc70899a21728336abd8857dbc7aca3a56c90a00" + +[[directories]] +name = "catalog" +host = "/opt/porta/catalog" +access = "read" diff --git a/deploy/gcp-cloud-run/README.md b/deploy/gcp-cloud-run/README.md new file mode 100644 index 0000000..3943acb --- /dev/null +++ b/deploy/gcp-cloud-run/README.md @@ -0,0 +1,45 @@ +# Google Cloud Run + +**Status: template written, not deployed.** Nothing here has run on Google Cloud. + +[`service.yaml`](service.yaml) runs the evaluation image as a Cloud Run +service on the **gen2** execution environment, one instance at most, the +token from Secret Manager, `GET /healthz` as the startup probe. + +Constraints that follow from the platform +([container contract](https://docs.cloud.google.com/run/docs/container-contract), +[execution environments](https://docs.cloud.google.com/run/docs/about-execution-environments)): + +- **amd64 image.** Build with `--platform linux/amd64`. +- **Pin gen2.** gen1 is gVisor; whether Landlock and porta's seccomp filter + work there is undocumented, and porta refuses `os_sandbox = "required"` + where they do not. On gen2 it is also undocumented — verify; if the service + refuses to start, use `/etc/porta/policy-wasm-only.toml`. +- **Records are in memory.** The writable filesystem is in-memory, counts + against the instance's memory and disappears with the instance. One + instance (`maxScale: 1`) keeps every record in one place while it lives; + across instances a client would see a 404 for a run another instance ran. + Durable records need a Cloud Storage FUSE or NFS volume at + `/var/lib/porta` (gen2 only; FUSE has no file locking — porta does not need + locks, but this is untested). +- **Requests end at `timeoutSeconds`.** `?wait=` must stay below it. +- **SIGTERM, then 10 s.** porta stops running jobs within 8 s. + +## Steps (need approval: they create billable resources) + +```bash +gcloud artifacts repositories create porta --repository-format docker --location +docker buildx build --platform linux/amd64 -t -docker.pkg.dev//porta/porta-eval: --push . +printf '%s' "$(openssl rand -hex 24)" | gcloud secrets create porta-eval-token --data-file=- +gcloud run services replace deploy/gcp-cloud-run/service.yaml --region +# grant roles/secretmanager.secretAccessor on the secret to the service's identity +PORTA_JOB_TOKEN=... python3 examples/enterprise/evaluate.py --url https:// +# Remove +gcloud run services delete porta-eval --region +gcloud secrets delete porta-eval-token +gcloud artifacts repositories delete porta --location +``` + +`ingress: internal-and-cloud-load-balancing` keeps the service off the public +internet; reach it from inside the VPC or through a load balancer, or change +it for an evaluation you accept being public (the token still applies). diff --git a/deploy/gcp-cloud-run/service.yaml b/deploy/gcp-cloud-run/service.yaml new file mode 100644 index 0000000..6b65779 --- /dev/null +++ b/deploy/gcp-cloud-run/service.yaml @@ -0,0 +1,36 @@ +# gcloud run services replace deploy/gcp-cloud-run/service.yaml --region +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: porta-eval + annotations: + run.googleapis.com/ingress: internal-and-cloud-load-balancing +spec: + template: + metadata: + annotations: + # gen2 is a microVM with full syscall compatibility; gen1 is gVisor. + # A service without this may land on either. + run.googleapis.com/execution-environment: gen2 + autoscaling.knative.dev/maxScale: "1" + spec: + containerConcurrency: 16 + timeoutSeconds: 300 + containers: + - image: -docker.pkg.dev//porta/porta-eval: + args: ["--policy", "/etc/porta/policy.toml", "--listen", "0.0.0.0:8080"] + ports: + - containerPort: 8080 + env: + - name: PORTA_JOB_TOKEN + valueFrom: + secretKeyRef: + name: porta-eval-token + key: latest + resources: + limits: + cpu: "1" + memory: 1Gi + startupProbe: + httpGet: + path: /healthz diff --git a/docs/enterprise/README.ja.md b/docs/enterprise/README.ja.md new file mode 100644 index 0000000..d466642 --- /dev/null +++ b/docs/enterprise/README.ja.md @@ -0,0 +1,98 @@ +# Porta Enterprise 評価版(仮称) + +*これは評価版であり、製品リリースではありません。ライセンスキー、有効期限、課金、遠隔停止の仕組みはありません。価格と条件が決まる前に作る予定もありません。* + +Porta は、信頼できない WebAssembly のジョブを、運用者が書いたポリシーの範囲内で実行します。 + +1. クライアントが HTTP API でジョブを送ります。ジョブに含めるのは、登録済みのどのモジュールを動かすか、入力、触ってよいディレクトリ、実行上限です。 +2. Porta はジョブをポリシーと照合します。 +3. ジョブごとに専用プロセスを立て、その中の WebAssembly で実行します。 +4. 上限に達したら止めます。 +5. 結果、停止理由、実行記録を返します。 + +仕様の正本は英語版です。このページは評価手順の要約です。 + +| 文書 | 内容 | +|---|---| +| [API と設定](api.md) | ポリシー、ジョブ、実行記録、HTTP エンドポイント、停止理由 | +| [脅威モデル](threat-model.md) | 信頼できないモジュールにできること・できないこと、各主張の試験 | +| [対応表](compatibility.md) | 実行を確認した環境、未確認の環境、各クラウドの制約 | +| [運用](operations.md) | 導入、削除、更新、記録、ログ、評価版の制約 | +| [配布](distribution.md) | ビルド、コンテナイメージ、同梱物のライセンス | +| [不足項目](gaps.md) | 製品化に足りないもの(優先順位と根拠つき) | + +## 10 分で評価する + +必要なのは Docker だけです。外部へ送信されるデータはありません。 + +```bash +git clone https://github.com/almide/porta && cd porta +docker build -t porta-eval . # ソースからビルドします。初回は約 10 分 +export PORTA_JOB_TOKEN=$(openssl rand -hex 24) +docker run -d --name porta-eval --read-only --tmpfs /tmp \ + -v porta-eval-state:/var/lib/porta -p 127.0.0.1:8080:8080 \ + -e PORTA_JOB_TOKEN porta-eval +python3 examples/enterprise/evaluate.py --url http://127.0.0.1:8080 +``` + +`evaluate.py` は標準ライブラリだけで動きます。サンプルジョブを送り、期待どおりかを `PASS` / `FAIL` で表示します。確認する項目は次のとおりです。 + +| 確認すること | 期待される結果 | +|---|---| +| 許可された仕事 | 成功し、正しい集計結果が返る | +| ポリシーを超える要求(書き込み権限、未登録ディレクトリ、上限超過) | 実行前に拒否され、その拒否も記録される | +| 許可されていない読み書き(6 種類) | サンドボックス内で失敗する | +| 無限ループ | 時間上限、または燃料(命令数)上限で止まる | +| メモリの際限ない確保 | メモリ上限で止まる | +| 出力の垂れ流し | 出力上限で止まる | +| 実行中ジョブの取消 | すぐに止まる | +| 上の全ての実行 | 完了済みの記録が残る | + +自分の目で確かめるには次を実行します。 + +```bash +H="Authorization: Bearer $PORTA_JOB_TOKEN" +curl -s -H "$H" http://127.0.0.1:8080/v1/policy # 適用中のポリシー(ラベルとハッシュ) +curl -s -H "$H" 'http://127.0.0.1:8080/v1/runs?limit=5' # 直近の実行 +curl -s -H "$H" http://127.0.0.1:8080/v1/runs/ # 1 件の実行記録 +docker exec porta-eval tail -3 /var/lib/porta/records/events.jsonl # イベントログ +docker stop porta-eval && docker start porta-eval # 記録は残る。実行中だったジョブは service_shutdown で終わる +``` + +片付けは次のとおりです。 + +```bash +docker rm -f porta-eval && docker volume rm porta-eval-state && docker rmi porta-eval +``` + +## 実行記録に残るもの + +実行記録に残す項目は次のとおりです。 + +- 実行 ID +- 適用したポリシーのラベルと SHA-256 +- モジュール名と SHA-256 +- ジョブの SHA-256 +- 受付・開始・終了の時刻(UTC、ミリ秒) +- 結果(`succeeded` / `failed` / `stopped` / `refused`)と停止理由 +- 適用した上限と付与した権限(名前のみ) +- 使用量(燃料、メモリ、経過時間) +- 出力のサイズとハッシュ + +ログに無条件では出さないものは次のとおりです。 + +- トークン、環境変数の値、ジョブの入力:出しません。 +- 標準出力の本文と出力ファイル:ポリシーで `retain_output = true` を指定した場合だけ保存します。 + +## 何が証明済みで、何が未検証か + +| 項目 | 状態 | +|---|---| +| 許可した操作の成功、許可しない操作の拒否・失敗、上限による停止、取消、記録 | 実装済み。macOS 26 (arm64) と Linux 6.12 (Docker, arm64) で試験済み(84 項目) | +| ワーカーへの OS サンドボックス適用(多層防御) | 実装済み。上記 2 環境で適用を確認 | +| 停止(SIGTERM)、強制終了後の再起動、記録の保持 | 実装済み。コンテナで確認 | +| AWS ECS / Cloud Run / Azure Container Apps / Cloudflare Containers | **テンプレート作成済み/実環境未検証** | +| AgentCore Runtime | 設計メモのみ(未実装) | +| ネットワーク許可、利用者ごとの認証、TLS、改ざん検知つき記録 | **未実装** | + +第三者による監査は受けていません。この評価版は「完全分離」や「安全」を主張するものではありません。 diff --git a/docs/enterprise/README.md b/docs/enterprise/README.md new file mode 100644 index 0000000..52ef3c9 --- /dev/null +++ b/docs/enterprise/README.md @@ -0,0 +1,127 @@ +# Porta Enterprise — evaluation build + +*Working name. This is an evaluation build, not a product release: there is +no licence key, expiry, billing or remote shutdown, and none is planned +before pricing and terms are decided.* + +Porta runs WebAssembly jobs you do not trust under a policy you write. A +client submits a job — which registered module to run, its input, the +directories it may touch, its limits — over an HTTP API; porta checks it +against the policy, runs it in WebAssembly inside its own process, stops it +at its limits, and returns the result, why it stopped, and a run record. + +[日本語の評価手順](README.ja.md) + +| Document | What it covers | +|---|---| +| [API and configuration](api.md) | policy, job, record, HTTP endpoints, stop reasons — the contract | +| [Threat model](threat-model.md) | what an untrusted module can and cannot do, and how each claim is tested | +| [Compatibility](compatibility.md) | where it has run, where it has not, and what each platform allows | +| [Operations](operations.md) | install, remove, upgrade, records, logs, limits of the evaluation build | +| [Distribution](distribution.md) | building it, the container image, licences of what is inside | +| [Gaps](gaps.md) | what a product still needs, ranked, with the evidence for each | +| [Cloud templates](../../deploy/) | ECS, Cloud Run, Container Apps, Cloudflare — written, not deployed | + +## Evaluate it in ten minutes + +You need Docker. Nothing leaves your machine. + +```bash +git clone https://github.com/almide/porta && cd porta +docker build -t porta-eval . # builds porta from source; ~10 min the first time +export PORTA_JOB_TOKEN=$(openssl rand -hex 24) +docker run -d --name porta-eval --read-only --tmpfs /tmp \ + -v porta-eval-state:/var/lib/porta -p 127.0.0.1:8080:8080 \ + -e PORTA_JOB_TOKEN porta-eval +python3 examples/enterprise/evaluate.py --url http://127.0.0.1:8080 +``` + +`evaluate.py` (standard library only) submits the sample jobs and prints +`PASS`/`FAIL` for each expectation: + +```text +PASS granted work succeeds +PASS asking for write access the policy does not grant is refused +PASS asking for a directory the policy does not list is refused +PASS asking past a limit ceiling is refused +PASS ungranted reads and writes are denied inside the sandbox +PASS an infinite loop stops at its deadline +PASS an infinite loop stops when its fuel runs out +PASS unbounded allocation stops at the memory ceiling +PASS unbounded output stops at its cap +PASS cancel stops a running job +PASS every run above has a finished record (10 listed) +``` + +Then look for yourself: + +```bash +H="Authorization: Bearer $PORTA_JOB_TOKEN" +curl -s -H "$H" http://127.0.0.1:8080/v1/policy # the rules in force, and their digest +curl -s -H "$H" 'http://127.0.0.1:8080/v1/runs?limit=5' # newest runs +curl -s -H "$H" http://127.0.0.1:8080/v1/runs/ # one full record +docker exec porta-eval tail -3 /var/lib/porta/records/events.jsonl # the event log +docker stop porta-eval && docker start porta-eval # records survive; a job running at stop ends as service_shutdown +``` + +Remove everything: + +```bash +docker rm -f porta-eval && docker volume rm porta-eval-state && docker rmi porta-eval +``` + +### Without Docker + +On macOS (Apple silicon) or Linux, with the pinned Almide: + +```bash +bash scripts/install-almide.sh +.tools/almide/almide build src/main.almd -o target/porta +target/porta job-check --policy examples/enterprise/policy.toml # the policy, and every module's digest +target/porta job-run --policy examples/enterprise/policy.toml examples/enterprise/jobs/report.json +target/porta job-run --policy examples/enterprise/policy.toml examples/enterprise/jobs/probe.json +export PORTA_JOB_TOKEN=$(openssl rand -hex 24) +target/porta job-serve --policy examples/enterprise/policy.toml & # 127.0.0.1:8640 +python3 examples/enterprise/evaluate.py --url http://127.0.0.1:8640 +``` + +Records go to `examples/enterprise/state/`; delete that directory to start over. + +## The sample job + +[`examples/enterprise/sample-job`](../../examples/enterprise/sample-job/src/main.almd) +is an order report written in Almide and compiled to WASI: it reads +`/input/orders.csv` (the job's own file), prices from `/data/catalog` +(a directory the policy grants read-only), writes `/output/report.json`. The +same module misbehaves on request — `probe` tries six reads and writes it was +not granted, `spin` loops, `grow` allocates, `flood` prints — because the +point is to watch porta contain it. Porta treats every module as untrusted. + +Bring your own: any WASI preview 1 module with `_start`, or WASI 0.2 command +component. Add it to the policy with its SHA-256 (`shasum -a 256 x.wasm`). + +## What is proven, and how + +| Claim | Status | Evidence | +|---|---|---| +| Granted work succeeds; its result, output files and digests are in the record | implemented, tested locally | `scripts/job_integration.py`, `evaluate.py` | +| A job asking beyond the policy is refused and recorded | implemented, tested locally | 9 refusal cases | +| Ungranted file access fails inside the sandbox | implemented, tested locally | `probe` (6 attempts), symlink fixture | +| Ungranted host functions are refused before the module starts | implemented, tested locally | `host-import` fixture | +| Deadline, fuel, memory and output limits stop a job with their own reason | implemented, tested locally | `spin`, `fuel`, `grow`, `flood` | +| Cancel, SIGTERM and a killed service each leave a correct record and no process | implemented, tested locally | integration suite, container stop/kill | +| Concurrent jobs see only their own input | implemented, tested locally (2 jobs) | integration suite | +| Every worker also runs under porta's OS sandbox | implemented, tested on macOS 26 and Linux 6.12 (Docker) | `isolation.os_sandbox` in the record | +| Runs on AWS, Google Cloud, Azure, Cloudflare | **templates written, never deployed** | [compatibility](compatibility.md) | +| Network grants, per-client identity, TLS, signed records | **not implemented** | [gaps](gaps.md) | + +"Tested locally" means on the machines in [compatibility](compatibility.md), +by the scripts named; there has been no third-party audit, and nothing here +claims the isolation is complete. + +## Running the test suites + +```bash +python3 scripts/job_integration.py target/porta # 84 checks: policy, worker, records, API +docker build --target test . # the same suite on Linux, as an unprivileged user +``` diff --git a/docs/enterprise/api.md b/docs/enterprise/api.md new file mode 100644 index 0000000..b3bb5cc --- /dev/null +++ b/docs/enterprise/api.md @@ -0,0 +1,206 @@ +# Job API and configuration, version 1 + +This is the contract of the evaluation build: the operator's **policy**, a +client's **job**, the **run record** porta returns, and the **HTTP API** that +carries them. It describes what `native/job_service/` implements; where this +page and the code disagree, the code is right and this page is a bug. + +- Policy: `policy_version = 1` — [`native/job_service/policy.rs`](../../native/job_service/policy.rs) +- Job: `version = 1` — [`native/job_service/spec.rs`](../../native/job_service/spec.rs) +- Record: `record_version = 1` — [`native/job_service/supervisor.rs`](../../native/job_service/supervisor.rs) +- HTTP — [`native/job_service/http.rs`](../../native/job_service/http.rs) + +## Commands + +| Command | What it does | Exit code | +|---|---|---| +| `porta job-check --policy P` | Loads the policy, checks every module's digest, prints what a client may see. Nothing runs. | 0, or 1 when the policy is invalid | +| `porta job-check --policy P job.json` | Says whether the policy accepts the job and what it would be granted. Nothing runs. | 0 accepted, 2 refused | +| `porta job-run --policy P job.json` | Runs one job through the same supervisor and worker as the server; prints the finished record. | 0 succeeded, 2 refused, 1 anything else | +| `porta job-serve --policy P [--listen ADDR] [--no-auth]` | Serves the HTTP API until SIGTERM/SIGINT. | 0 after a signal | + +`job-serve` reads its bearer token from `PORTA_JOB_TOKEN` (16 characters or +more) and removes it from its own environment. Without a token it refuses to +start unless `--no-auth` is given *and* `--listen` is a loopback address. +`--listen` defaults to `127.0.0.1:8640`. + +## Policy (TOML) + +Owned by the operator. Unknown keys are rejected. Relative paths resolve +against the policy file's directory. The policy's identity is its `label` +plus the SHA-256 of the file's bytes; both go into every record. + +```toml +version = 1 +label = "evaluation-2026-10" # 1–128 chars; your name for this version +env_names = ["REPORT_CURRENCY"] # variables a job may set; default none + +[service] +records = "state/records" # created 0700 if missing +workspaces = "state/workspaces" # created 0700; must not contain/contain records +max_concurrent = 4 # 1–64; default 2 +retain_output = true # default false: records keep only sizes and digests +max_request_bytes = 1048576 # 1 KiB–256 MiB; default 4 MiB +os_sandbox = "required" # "required" (default) or "off" + +[limits] # ceilings; every one is required +timeout_ms = 10000 # ≤ 3 600 000 +fuel = 100000000000 # wasmtime fuel units +memory_mib = 64 # linear memory per store; ≤ 4096 +max_output_bytes = 1048576 # stdout cap, and the /output total; ≤ 64 MiB +max_input_bytes = 524288 # input + files + args + env; ≤ 64 MiB + +[defaults] # optional; each missing value = its ceiling +timeout_ms = 3000 + +[[modules]] # what may run; nothing else can +name = "sample-job" # [A-Za-z0-9._-]{1,64} +wasm = "sample-job/sample-job.wasm" +sha256 = "…" # checked at load and again by every worker + +[[directories]] # host directories a job may ask for +name = "catalog" # mounted at /data/catalog +host = "catalog" +access = "read" # "read" or "read-write": the most a job may ask +``` + +Load-time refusals (the service does not start): running as root; a module whose bytes do not +match its `sha256`; a directory that does not exist, overlaps `records` or +`workspaces`, or is writable and holds the policy file or a module; a default +above its ceiling; `os_sandbox = "required"` on a host where `porta run` +cannot apply the worker's sandbox (the message names what is missing). + +## Job (JSON) + +Sent by a client. Unknown fields are rejected — a job cannot carry a field the +broker would silently ignore, such as a network grant. + +```json +{ + "version": 1, + "module": "sample-job", + "input": "text given to the module on stdin", + "args": ["report"], + "env": {"REPORT_CURRENCY": "JPY"}, + "files": {"orders.csv": "item,quantity\nwidget,4\n"}, + "directories": [{"name": "catalog", "access": "read"}], + "output": true, + "limits": {"timeout_ms": 2000, "fuel": 50000000, "memory_mib": 16, "max_output_bytes": 4096} +} +``` + +| Field | Default | Rule | +|---|---|---| +| `version` | — | must be 1 | +| `module` | — | a name in the policy's `[[modules]]` | +| `input` | `""` | becomes stdin | +| `args` | `[]` | ≤ 64; argv[0] is the module name | +| `env` | `{}` | names must be in `env_names`; values are never recorded | +| `files` | `{}` | ≤ 64; one path component of `[A-Za-z0-9._-]`; written to `/input`, read-only | +| `directories` | `[]` | names from `[[directories]]`; `read-write` only where the policy grants it | +| `output` | `true` | `false` gives the job no writable `/output` | +| `limits` | policy defaults | each ≤ its ceiling; a larger value is refused, not lowered | + +### What the module sees + +| Inside | Is | Access | +|---|---|---| +| stdin | `input` | read | +| stdout, stderr | memory pipes; stdout capped at `max_output_bytes`, stderr at min(that, 256 KiB) | write | +| `/input` | the job's `files`, a per-run copy | read | +| `/output` | an empty per-run directory | read-write | +| `/data/` | a granted policy directory | as granted | +| environment | exactly the job's `env` | read | +| network | none | — | +| host functions | WASI only: preview 1 for a core module, 0.2 for a component | — | + +Anything else — another path, `..` past a mount, a symlink out of one, an +import porta does not provide — fails inside the sandbox (a WASI error the +module sees) or before the module starts (`link_refused`). + +## Run record (JSON) + +One file per run, `records/.json`, written when the run starts +(`status: running`) and replaced when it ends (`status: finished`). A refused +job gets a record too. `GET /v1/runs/{id}` returns it unchanged. + +| Field | Meaning | +|---|---| +| `record_version` | 1 | +| `run_id` | `YYYYMMDDTHHMMSSZ-` + 12 random hex digits | +| `status` | `running` or `finished` | +| `outcome` | `succeeded`, `failed`, `stopped`, `refused` | +| `stop_reason` | see below | +| `detail` | a sentence for people; may include the trap message (≤ 2 KiB) | +| `exit_code` | the module's exit code, or `null` when it did not exit | +| `porta_version` | the binary that ran it | +| `policy` | `{label, sha256}` of the policy in force | +| `module` | `{name, sha256}` | +| `job_sha256` | SHA-256 of the accepted job as canonical JSON; of the raw request body for a refused one | +| `limits` | the limits applied after defaults | +| `grants` | input file names, `/output`, directories with access, env *names*, `network: none`, `host_functions: wasi` | +| `isolation` | `{wasm: "wasmtime", worker_process: true, os_sandbox: bool}` | +| `submitted_at`, `started_at`, `finished_at` | RFC 3339 UTC, milliseconds | +| `usage` | `fuel_consumed` (`null` after a deadline stop, which wasmtime does not report exactly), `peak_memory_bytes`, `memory_grow_denied`, `wall_ms` | +| `stdout`, `stderr` | `{bytes, sha256, capped}`, plus `text` (first 64 KiB) and `truncated` when the policy retains output | +| `output_files` | `[{path, bytes, sha256}]`; a symlink or special file is listed with `ignored` and never read | +| `workspace_removed` | whether the per-run workspace is gone | + +### Outcomes and stop reasons + +| `outcome` | `stop_reason` | When | +|---|---|---| +| succeeded | `exit` | the module returned or exited 0 | +| failed | `exit_nonzero` | the module exited with another code | +| failed | `trap` | the module trapped (unreachable, out-of-bounds, stack overflow…) | +| failed | `invalid_module` | not a WASM module or component, or a core module without `_start` | +| failed | `worker_crashed`, `worker_error`, `module_unavailable` | the worker ended without a result, failed itself, or could not read the module | +| stopped | `timeout` | the epoch deadline interrupted it, or the supervisor killed it 5 s later | +| stopped | `fuel_exhausted` | fuel ran out | +| stopped | `memory_limit` | it trapped or exited after a `memory.grow` past `memory_mib` was refused | +| stopped | `output_limit` | it trapped writing past the stdout cap, or exited 0 leaving more than `max_output_bytes` in `/output` | +| stopped | `cancelled` | a client cancelled it | +| stopped | `service_shutdown` | the service got SIGTERM/SIGINT while it ran | +| stopped | `interrupted` | the service died while it ran; found at the next start, never re-run | +| refused | `module_not_registered`, `directory_not_granted`, `access_exceeds_grant`, `limit_exceeds_ceiling`, `env_not_allowed`, `input_too_large`, `invalid_job`, `invalid_file_name`, `request_too_large`, `capacity`, `service_stopping` | the policy or the service refused it before anything was created | +| refused | `link_refused` | the module imports something porta does not provide (for a component, also: no `wasi:cli/run` export) | +| refused | `module_changed` | the module's bytes no longer match the pinned digest | +| refused | `sandbox_refused` | `porta run` refused to start the worker under the OS sandbox | + +A module that keeps printing after its stdout cap may ignore the write errors +and run on; it then ends by its fuel or deadline, with `stdout.capped: true`. + +### Event log + +`records/events.jsonl` gets one line per change — `started`, `finished`, +`refused`, `stop_requested`, `interrupted`, `deleted` — with the time, run id, +outcome, stop reason, module name and policy digest. It never contains input, +output, environment values or the token. It is append-only by convention, not +tamper-evident. + +## HTTP API + +HTTP/1.1, one request per connection, JSON bodies, `Content-Length` +required for bodies (chunked is refused with 411). Every path except +`/healthz` needs `Authorization: Bearer `. + +| Method and path | Success | Body | +|---|---|---| +| `GET /healthz` | 200 | `{"ok": true}` | +| `GET /v1/policy` | 200 | label, digest, ceilings, defaults, env names, modules (name, digest), directories (name, access), `network`, `os_sandbox`, `max_concurrent`, `retain_output` — never host paths | +| `POST /v1/runs` | 202 | the record with `status: running` | +| `POST /v1/runs?wait=S` | 200 / 202 | waits up to S seconds (≤ 300); 200 with the finished record, 202 if still running | +| `GET /v1/runs?limit=N` | 200 | `{"runs": [...]}`, newest first, N ≤ 1000 (default 50) | +| `GET /v1/runs/{id}?wait=S` | 200 | the record, waiting up to S seconds for the end | +| `POST /v1/runs/{id}/cancel` | 202 | `{"run_id", "status": "stopping"}`; 409 if finished | +| `GET /v1/runs/{id}/output/{path}` | 200 | a retained `/output` file the record lists | +| `DELETE /v1/runs/{id}` | 204 | removes the record and retained output; 409 while running | + +Refusals of a job answer with its record: 400 (`invalid_job`, +`invalid_file_name`), 403 (policy refusals), 413 (`request_too_large`), 429 +(`capacity`), 503 (`service_stopping`). Other errors are +`{"error": {"code", "message"}}`: 401, 404, 405, 411, 415, 431, 500, 503. + +There is no TLS: terminate it at the platform's load balancer or keep the +listener on loopback. There is one token with full access; there are no +per-client identities or roles yet ([gaps](gaps.md)). diff --git a/docs/enterprise/compatibility.md b/docs/enterprise/compatibility.md new file mode 100644 index 0000000..248e23d --- /dev/null +++ b/docs/enterprise/compatibility.md @@ -0,0 +1,68 @@ +# Compatibility + +Three columns, kept apart on purpose: **ran here** (the suite passed on that +machine), **template** (a deployment file exists and has never been applied), +**not started**. Nothing in the second or third column is "supported". + +## Where it has run + +| Environment | Build | `job_integration.py` (84 checks) | `evaluate.py` | OS sandbox (`required`) | +|---|---|---|---|---| +| macOS 26.3, Apple silicon (arm64), native binary | `almide build` | passed | passed | applied (Seatbelt) | +| Linux 6.12 (Docker Desktop 29.6, linuxkit), arm64, `rust:1-trixie`, unprivileged user | `docker build --target test` | passed | — | applied (Landlock + seccomp; user namespaces refused by Docker's default profile, which porta reports and proceeds without) | +| Same kernel, `porta-eval` runtime image (`debian:trixie-slim`), `--read-only` root, non-root uid 10001 | `docker build` | — | passed with `policy.toml` and with `policy-wasm-only.toml` | applied | + +Also checked in the container: `docker stop` during a run → the run ends as +`service_shutdown`; `docker kill` during a run → after `docker start` the run +is `interrupted`, its workspace removed, nothing re-run; records persist on +the volume across restarts; without `PORTA_JOB_TOKEN` the image refuses to +start. + +Not run: Linux x86-64 (the code is the same; porta's CI builds it, but the job +suite has not run there), Intel macOS, any distribution other than Debian +trixie, a kernel without Landlock, gVisor. + +## Cloud platforms + +All facts below are from vendor documentation read on 2026-10-03; "ND" means +the documentation says nothing. No resource was created on any of them. + +| | AWS ECS Fargate | Google Cloud Run | Azure Container Apps | Cloudflare Containers | AgentCore Runtime | +|---|---|---|---|---|---| +| **Status** | template | template | template | template + Worker adapter | design note only | +| Files | [`deploy/aws-ecs`](../../deploy/aws-ecs/) | [`deploy/gcp-cloud-run`](../../deploy/gcp-cloud-run/) | [`deploy/azure-container-apps`](../../deploy/azure-container-apps/) | [`deploy/cloudflare-containers`](../../deploy/cloudflare-containers/) | [`deploy/aws-agentcore`](../../deploy/aws-agentcore/) | +| Image arch | arm64 or amd64 | amd64 | amd64 | amd64 | arm64 only | +| Isolation of the container | "isolated virtual environment" | gen1 gVisor / gen2 microVM | ND | Firecracker microVM | microVM per session | +| porta's OS sandbox | ND — the service probes at start; fall back to `policy-wasm-only.toml` if refused | ND on gen2; pin gen2 | ND | ND | ND | +| Fits porta's API as is | yes | yes | yes | needs the Worker in front | no: `/invocations` + `/ping` contract, edge auth | +| Records survive restart | with EFS (untested) | with GCS FUSE / NFS, gen2 (untested) | with Azure Files (untested) | no (fresh disk after sleep) | session storage (preview) | +| Stop signal and grace | SIGTERM, `stopTimeout` ≤ 120 s | SIGTERM, 10 s | SIGTERM, 30 s | SIGTERM, up to 15 min | ND | +| Request timeout to keep `?wait=` under | load balancer's | `timeoutSeconds` (≤ 60 min) | 240 s default | ND | 15 min sync | + +Sources: [ECS task definitions](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html), +[Fargate security](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-security-considerations.html), +[Cloud Run container contract](https://docs.cloud.google.com/run/docs/container-contract), +[Cloud Run execution environments](https://docs.cloud.google.com/run/docs/about-execution-environments), +[Container Apps containers](https://learn.microsoft.com/en-us/azure/container-apps/containers), +[Container Apps lifecycle](https://learn.microsoft.com/en-us/azure/container-apps/application-lifecycle-management), +[Cloudflare Containers architecture](https://developers.cloudflare.com/containers/platform-details/architecture/), +[Cloudflare container package](https://developers.cloudflare.com/containers/container-package/), +[AgentCore HTTP contract](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-http-protocol-contract.html). + +### What a real-cloud check would need (awaiting approval) + +Each step creates billable resources and publishes an image to a registry, +so none has been done. Proposed order, cheapest first, one hour each, then +deleted: + +| Step | Creates | Rough cost of one hour (list prices, verify before approving) | +|---|---|---| +| Cloud Run gen2, 1 vCPU / 1 GiB, max 1 instance | Artifact Registry repo, secret, service | under USD 1; often inside the free tier | +| ECS Fargate arm64, 0.5 vCPU / 1 GiB | ECR repo, secret, task definition, service (no ALB: test through a bastion or ECS Exec) | under USD 1, plus Secrets Manager's monthly per-secret charge | +| Azure Container Apps, 0.5 vCPU / 1 GiB, 1 replica | ACR, Key Vault secret, environment, app | under USD 1 plus ACR Basic's daily charge | +| Cloudflare Containers, standard-1 | Worker, container image, secret | requires the Workers Paid plan (monthly subscription) plus usage | + +For each: run `evaluate.py` against it, record whether `os_sandbox = +"required"` started, `docker stop`-equivalent behaviour (scale to zero), +and delete everything with the commands in that directory's README. The +result goes into the table above as "ran here", with the date. diff --git a/docs/enterprise/distribution.md b/docs/enterprise/distribution.md new file mode 100644 index 0000000..0084ca5 --- /dev/null +++ b/docs/enterprise/distribution.md @@ -0,0 +1,76 @@ +# Distribution + +## What is distributed + +| Artifact | Built by | Contains | +|---|---|---| +| `porta` binary | `almide build src/main.almd -o target/porta` (or the release workflow) | porta, including the job service, statically linking its Rust dependencies | +| Evaluation image | `docker build -t porta-eval .` ([`Dockerfile`](../../Dockerfile)) | `debian:trixie-slim`, libssl, CA certificates, the binary, the sample module and catalog, two policies, `LICENSE`, `THIRD_PARTY_LICENSES.md` | +| Sample job | `almide build examples/enterprise/sample-job/src/main.almd --target wasm` | `sample-job.wasm`, committed; its digest is pinned in both example policies | +| Deployment templates | — | [`deploy/`](../../deploy/) | + +The image is built from source in its first stage, so `docker build` on a +clean checkout is the whole build. The `test` stage runs the job suite as an +unprivileged user: `docker build --target test .`. The image refuses to build +if a policy's pinned digest does not match the module it ships. + +## Reproducibility — what holds and what does not + +- **Pinned exactly**: the Almide compiler (a release, checksum-verified by + `scripts/install-almide.sh`) and jsonschema (`=0.56.0`). +- **Pinned to a minor version only**: wasmtime and wasmtime-wasi. + `almide.toml` says `47.0.3`, which Cargo reads as `^47.0.3`; the build that + produced `THIRD_PARTY_LICENSES.md` resolved **47.0.4**. +- **Not pinned**: the rest of the Rust dependency graph. Almide generates the + crate and resolves `almide.toml`'s semver ranges at build time; there is no + committed `Cargo.lock`, so two builds on different days can link different + patch versions of, for example, `reqwest`, `rustls` or `serde_json`. + `THIRD_PARTY_LICENSES.md` is generated from one such resolution and records + exactly which versions that build used. +- **Not pinned**: base images are referenced by tag (`rust:1-trixie`, + `debian:trixie-slim`), not digest. + +So the build is repeatable but not bit-for-bit reproducible. Fixing that is +item 3 in [gaps](gaps.md): commit the generated lockfile (needs an Almide +option to use one) and pin base images by digest. + +## Licences + +porta is Apache-2.0 ([`LICENSE`](../../LICENSE)). + +[`THIRD_PARTY_LICENSES.md`](../../THIRD_PARTY_LICENSES.md) lists the 312 +Rust crates in the binary for Linux (x86-64, arm64) and macOS (arm64), with +their licence expressions. All are permissive or offer a permissive option: +MIT, Apache-2.0 (with or without the LLVM exception), BSD-2/3-Clause, ISC, +Zlib, Unicode-3.0, BSL-1.0, MIT-0, Unlicense, CDLA-Permissive-2.0. Two crates +are dual `GPL-2.0-only OR BSD-3-Clause`; porta uses them under BSD-3-Clause. +None is copyleft-only. + +Redistribution in a commercial product is compatible with these licences on +the usual conditions: ship the licence texts and notices (Apache-2.0 §4, +the BSD and MIT notice clauses, Unicode-3.0's notice). The image ships +`LICENSE` and the list; it does **not** yet ship each crate's full licence +text, which a release should (`cargo about` or similar can collect them). +This is item 6 in [gaps](gaps.md). This page is an engineering inventory, not +legal advice. + +Run-time libraries not compiled in: glibc (LGPL-2.1+) and OpenSSL 3 +(Apache-2.0), dynamically linked from the image or host. The base image's +Debian packages carry their own copyright files under `/usr/share/doc`. + +Regenerate the list after a dependency change: + +```bash +# after `almide build`, in the generated crate directory almide reports +cargo metadata --format-version 1 --locked \ + --filter-platform x86_64-unknown-linux-gnu --filter-platform aarch64-unknown-linux-gnu \ + --filter-platform aarch64-apple-darwin > meta.json +python3 scripts/third_party_licenses.py meta.json > THIRD_PARTY_LICENSES.md +``` + +## Releases + +The binary follows porta's existing release process ([CLAUDE.md](../../CLAUDE.md#releasing)): +tagged builds, signed, with provenance, verified by `scripts/verify_release.sh`. +The evaluation image is not published to any registry; publishing it is a +decision for whoever owns the product (see [gaps](gaps.md)). diff --git a/docs/enterprise/gaps.md b/docs/enterprise/gaps.md new file mode 100644 index 0000000..dbfbd41 --- /dev/null +++ b/docs/enterprise/gaps.md @@ -0,0 +1,60 @@ +# What a product still needs + +The evaluation build proves the core: a policy-checked job API whose limits +and denials hold and whose records say what happened. It is not something a +customer can buy yet. This is what is missing, ranked by how directly it +blocks a paying customer, with the evidence for each. "Evidence" points at +code, a test, or a measurement — not at an opinion. + +## P0 — blocks a first paid deployment + +| # | Gap | Why it blocks | Evidence | +|---|---|---|---| +| 1 | **No real-cloud run.** Every cloud target is a template. | A customer deploying to their cloud is the product. Whether the OS sandbox starts there is undocumented by every vendor. | [compatibility](compatibility.md): all five platforms "template" or "design only"; vendor docs say ND for Landlock/seccomp | +| 2 | **One shared token, no TLS.** No per-client identity, roles, rate limits or audit of *who* submitted a run. | Enterprise security review asks "who ran this" first. The record cannot answer it. | `http.rs`: one `PORTA_JOB_TOKEN`; record has no caller field | +| 3 | **Builds are not bit-for-bit reproducible.** Rust dependencies resolve at build time; wasmtime resolved to 47.0.4 while `almide.toml` names 47.0.3. | A supported product needs to rebuild the exact binary a customer runs, to ship a security fix against it. | [distribution](distribution.md); no committed `Cargo.lock`; base images by tag | +| 4 | **No network grants.** A job cannot reach any host at all. | Most agent work calls an API. Today such a job cannot run here; porta's proxy (`--proxy-allow`) exists for native commands but is not wired to jobs. | `spec.rs` rejects a `network` field; [threat model](threat-model.md) T6 | +| 5 | **Agents are not jobs yet.** `porta agent` (WASM decision loop, model broker, tools, journals) runs from the CLI only. | "WASM agent runtime" is the product's name; the API runs single modules. | `docs/agent-runtime.md`; no agent path in `native/job_service` | + +## P1 — needed before a second customer + +| # | Gap | Evidence | +|---|---|---| +| 6 | Full licence texts of all 312 crates are not shipped in the image, only the list. | [distribution](distribution.md) § Licences | +| 7 | Total disk use per job is not capped (only per file). | threat model T12 | +| 8 | Host memory beyond linear memory is not capped per job. | threat model, "not defended"; `memory_mib` is a store limit | +| 9 | No queue: the job after `max_concurrent` is refused with 429. | `supervisor.rs` `admit` | +| 10 | Records are plain files on one instance; no shared store, no retention policy, no export. Two instances cannot share a state directory. | [operations](operations.md) | +| 11 | Event log is not tamper-evident (no hash chain or signature). | threat model T17 | +| 12 | No metrics endpoint; health does not cover disk or sandbox after start. | [operations](operations.md) § Health | +| 13 | Component network denial is untested (no WASI 0.2 socket fixture). | threat model T6 | +| 14 | Concurrency isolation is tested with 2 jobs, not under load; no soak or fuzz of the HTTP parser. | `job_integration.py` | +| 15 | Linux x86-64 has not run the job suite (arm64 only). | [compatibility](compatibility.md) | + +## P2 — product and commercial + +| # | Gap | Note | +|---|---|---| +| 16 | Pricing, concurrency tiers, evaluation period, licence enforcement | Undecided by design; nothing built ahead of the decision. | +| 17 | Support process: severity levels, response targets, a security contact for the product | `SECURITY.md` covers the open-source project. | +| 18 | Customer-facing release notes and an upgrade compatibility promise for `record_version` / policy / job formats | All are version 1; [operations](operations.md) § Upgrade | +| 19 | Japanese and English contract-grade documentation, reviewed by someone other than the authors | These pages were written by the implementers. | +| 20 | Third-party security assessment | None has happened. Required before any claim stronger than "tested by its authors". | +| 21 | AgentCore adapter | Needs a decision on edge-only authentication ([note](../../deploy/aws-agentcore/README.md)). | + +## Recommended order + +1 → 2 → 4 → 3 → 5. A one-hour cloud run per platform (item 1) is cheap and +removes the largest unknown; identity (2) and network grants (4) are what an +evaluator will ask for next; reproducibility (3) must exist before the first +support contract; agents as jobs (5) is the product's headline but builds on +all of the above. + +## Decisions that are not engineering's + +- Approval and budget for the real-cloud runs in [compatibility](compatibility.md). +- Whether to publish the evaluation image to a registry, and under which name + ("Porta Enterprise" is a working name; it has not been checked for + trademark conflicts). +- Whether AgentCore's edge authentication may replace porta's token (item 21). +- Licence terms for the product, given porta itself is Apache-2.0. diff --git a/docs/enterprise/operations.md b/docs/enterprise/operations.md new file mode 100644 index 0000000..c0c55fd --- /dev/null +++ b/docs/enterprise/operations.md @@ -0,0 +1,124 @@ +# Operations + +How to install, run, upgrade and remove the evaluation build, and what an +operator has to know that the API page does not say. + +## Install + +**Container** (recommended for evaluation): build the image from this +repository (`docker build -t porta-eval .`). It runs as uid 10001, needs no +capabilities, works with a read-only root filesystem, and keeps all state in +`/var/lib/porta` — mount a volume there. See [distribution](distribution.md). + +**Binary on a host**: install porta as for its other uses +([install](../../README.md#install)) or build it +(`almide build src/main.almd -o target/porta`). Then: + +```bash +sudo useradd --system --home-dir /var/lib/porta --create-home porta +sudo install -d -o root -m 0755 /etc/porta /opt/porta/modules +sudo cp policy.toml /etc/porta/ && sudo cp *.wasm /opt/porta/modules/ +sudo -u porta porta job-check --policy /etc/porta/policy.toml # refuses a wrong digest or an unsafe grant +``` + +A minimal systemd unit: + +```ini +[Service] +User=porta +Environment=HOME=/var/lib/porta +EnvironmentFile=/etc/porta/token.env # PORTA_JOB_TOKEN=..., mode 0600, owned by root +ExecStart=/usr/local/bin/porta job-serve --policy /etc/porta/policy.toml --listen 127.0.0.1:8640 +KillSignal=SIGTERM +TimeoutStopSec=15 +Restart=on-failure +``` + +Rules that matter: + +- `job-run` and `job-serve` refuse to run as root. Run them as a dedicated user. +- `HOME` must be set: the OS sandbox resolves the preset's `~/` paths. +- The policy file and module directory should be writable by root only. The + policy refuses a *granted* directory that holds them, but cannot see who + else can write them. +- Keep `--listen` on loopback and put TLS in front (a reverse proxy or the + platform's load balancer). porta speaks plain HTTP. + +## Remove + +```bash +sudo systemctl disable --now porta-jobs # or: docker rm -f porta-eval +sudo rm -rf /var/lib/porta /etc/porta /opt/porta # records, workspaces, policy, modules +docker volume rm porta-eval-state && docker rmi porta-eval # container installs +``` + +Nothing else is written outside these paths. porta keeps no global state +for the job service; `porta setup`, if it was ever run for other uses, has its +own `porta setup --undo`. + +## Upgrade + +1. Read the release notes for changes to `record_version`, the policy or the + job format. In the evaluation build all three are version 1; a change in + any of them is a breaking change and will be called out. +2. Stop the service with SIGTERM. Running jobs end as `service_shutdown` + within 8 seconds and their records are complete. (A job is never resumed + or re-run by an upgrade.) +3. Replace the binary or image. Run `porta job-check --policy …` with the new + binary before starting it: it re-checks the policy and every module digest. +4. Start the service. Records written by the old version stay readable; each + carries the `porta_version` that wrote it. + +To roll back, do the same with the old binary. The record format has not +changed, so records written by the new version remain readable. + +Changing the **policy** is the same procedure: edit, `job-check`, restart. +The new digest appears in every later record, so a record always says which +rules it ran under. The service does not reload the policy while running. + +Changing a **module**: replace the file, update its `sha256` in the policy, +restart. Replacing the file without updating the policy makes every run of +that module `refused` / `module_changed` — by design. + +## Records and logs + +| Path | Content | Contains job data? | +|---|---|---| +| `records/.json` | the run record | sizes and digests always; stdout/stderr text only with `retain_output = true` | +| `records//` | retained stdout, stderr, `/output` files | yes, with `retain_output = true` | +| `records/events.jsonl` | one line per state change | no: ids, outcome, reason, module name, policy digest | +| `workspaces//` | input copy, output, worker files | yes, while the run lasts; removed after it | +| stderr of the service | start line, shutdown line | no | + +Never logged anywhere: the token, environment values, job input. Input file +*names* and environment variable *names* are in the record. + +Retention is manual: `DELETE /v1/runs/{id}` removes a record and its retained +output (the event log keeps a `deleted` line). There is no automatic expiry. +Size `records/` for the number of runs you keep; with `retain_output` each +run can hold up to `max_output_bytes` of stdout plus the same in `/output`. + +## Capacity and limits + +- `max_concurrent` jobs run at once; the next is refused with 429 (no queue). +- Each job is one process. Memory per job is about its `memory_mib` plus + wasmtime's own use plus compiled code — measure with your modules before + setting the container's memory limit. `memory_mib` alone does not bound + the process. +- Compiling a module happens in every run (a whole run of the 50 KB sample + takes about 50–70 ms end to end on Apple silicon, compilation included). There is no compiled-code cache, deliberately: + porta never loads native code from files a job could influence. +- Disk: a job can fill `/output` and any writable grant up to the volume's + size (each file is capped at `max_output_bytes` + 1 MiB by `RLIMIT_FSIZE`, + the number of files is not). Put `workspaces` on a size-limited volume. +- One service instance owns its `records` and `workspaces`. Two instances + must not share them: each would mark the other's running jobs + `interrupted` at start. + +## Health and monitoring + +`GET /healthz` answers `{"ok": true}` without a token while the HTTP loop +runs. It does not check the disk or the OS sandbox; the sandbox is checked +once at start, and the service refuses to start without it when the policy +requires it. Metrics endpoints do not exist yet; `events.jsonl` is the +machine-readable trail. diff --git a/docs/enterprise/threat-model.md b/docs/enterprise/threat-model.md new file mode 100644 index 0000000..1625417 --- /dev/null +++ b/docs/enterprise/threat-model.md @@ -0,0 +1,90 @@ +# Threat model: untrusted WASM jobs + +This extends the [general threat model](../threat-model.md) to the job +service: a customer runs `porta job-serve` and submits WASM modules it did not +write, or that were written by something it does not trust (a model, a +third party). It says what the evaluation build defends, how, how that is +tested, and what it does not defend. "Tested" means a check in +[`scripts/job_integration.py`](../../scripts/job_integration.py) that runs the +real binary; nothing here has had a third-party audit. + +## Parties and trust + +| Party | Trusted? | Controls | +|---|---|---| +| Operator | yes | the policy file, the host, the module files, the token | +| API client | authenticated, not trusted with more than the policy | job requests: which registered module, input, files, which granted directories, limits up to the ceilings | +| Module (guest) | **no** | everything its code does at run time, including its trap messages and output | +| Worker process | trusted code, untrusted data | runs one guest; may be compromised if wasmtime is | + +The module is the adversary. It is assumed to try to read or write beyond its +grants, reach the network, exhaust CPU, memory, disk or output, outlive its +deadline, poison what the next job sees, or make the host misreport what it +did. + +## Boundaries + +1. **WASM (primary).** wasmtime compiles the module; the guest can only call + what is linked. Only WASI is linked (preview 1 for a core module, 0.2 for a + component); porta links none of its own host functions. Directories are + WASI preopens with read-only or read-write permissions; wasmtime-wasi + confines `..` and symlinks to the preopen. Fuel, an epoch deadline and a + per-store memory limiter bound CPU, time and linear memory. +2. **Worker process.** Each job runs in its own process + (`porta __job-worker`): an empty environment, no stdin, its own process + group, `RLIMIT_CPU` past the deadline, `RLIMIT_FSIZE` at the output limit, + no core dumps, and on Linux `PR_SET_PDEATHSIG`. The supervisor kills the + group at the deadline plus 5 s or on cancel. +3. **OS sandbox (defence in depth, `os_sandbox = "required"`).** The worker + runs under `porta run`: Seatbelt on macOS; Landlock, seccomp and, where the + host allows, namespaces on Linux. Writes only to its workspace and granted + read-write directories; reads confined to those, the module directory and + porta's own; no network. This holds if boundary 1 fails; it is porta's + existing sandbox, tested by its [escape corpus](../benchmarks/escapes.md), + not by a WASM escape (none is known to us to test with). + +## Threats and controls + +| # | Threat | Control | Evidence | +|---|---|---|---| +| T1 | Read host files outside the grant (`/etc/passwd`, `..` traversal, the worker's own `job.json`) | WASI preopens only; wasmtime-wasi path resolution; OS sandbox strict reads | `probe` job: 3 reads denied; tested | +| T2 | Write to a read-only grant or outside every mount | preopen `DirPerms::READ`/`FilePerms::READ`; no preopen elsewhere; OS sandbox | `probe` job: 3 writes denied, catalog unchanged; tested | +| T3 | Link or rename a read-only file into `/output` and write through it | wasmtime-wasi requires mutate permission on *both* directories for `link_at` / `rename_at` | source reading of wasmtime-wasi 47.0.3 `filesystem.rs`; not a test | +| T4 | Leave a symlink in `/output` so the host reads a host file into the record | the supervisor lists `/output` with `symlink_metadata`, opens with `O_NOFOLLOW`, and reports symlinks as ignored | `symlink-out` fixture; tested | +| T5 | Call a host function to run commands or make requests | nothing but WASI is linked; an unknown import fails instantiation | `host-import` fixture → `link_refused`; tested | +| T6 | Reach the network | WASI p1 has no outbound sockets; for a 0.2 component, socket and name-lookup permissions are left denied; OS sandbox `--no-net` | p1: by construction. Component sockets: **not tested** (no component fixture yet) | +| T7 | Read host credentials or environment | the worker starts from an empty environment; the guest gets only the job's `env`, whose names the policy lists; `PORTA_JOB_TOKEN` is removed from the server's environment | probe of HOME absent; `env_not_allowed` refusal; tested | +| T8 | Run forever | epoch deadline; supervisor kill at deadline + 5 s; `RLIMIT_CPU` | `spin` → `timeout` at ~2 s; tested | +| T9 | Exhaust CPU within the deadline | fuel | `spin` with small fuel → `fuel_exhausted`; tested | +| T10 | Exhaust memory | per-store memory limiter (`memory_mib`), instance/table/memory counts | `grow` → `memory_limit`, peak ≤ ceiling; tested | +| T11 | Flood output | stdout/stderr memory pipes with caps; `/output` total checked after exit | `flood` → `capped: true`; tested | +| T12 | Fill the disk through `/output` or a writable grant | `RLIMIT_FSIZE` per file only | **not controlled in total** — see limits | +| T13 | Outlive the server / leave processes behind | process group kill; PDEATHSIG (Linux); a parent watchdog in the worker (all platforms) | killed server → no worker left; tested | +| T14 | See or change another job's data | per-run workspace (0700) under its own run id; a fresh process and store per job; no shared writable grant unless the operator lists one | two concurrent jobs see only their own input; tested | +| T15 | Persist state into the next job | workspaces are removed after every run, including stopped ones; records are outside every mount | `workspace_removed`; tested | +| T16 | Replace the module after it was approved | SHA-256 pinned in the policy, checked at load and by every worker on the bytes it compiles | `module_changed`; tested | +| T17 | Make the record lie | the record is written by the supervisor, not the guest; the guest controls only its own output and trap text, which are bounded and labelled as such | design; partially tested | +| T18 | Escalate through the policy | a writable grant may not contain the policy or a module; state directories may not overlap a grant | load-time refusal; not yet a test | +| T19 | Unauthenticated API use | bearer token, constant-time compare; no-token mode only on loopback | tested | +| T20 | Oversized or slow requests | header ≤ 16 KiB, body ≤ `max_request_bytes`, 30 s socket timeouts, ≤ 64 concurrent connections | oversized body → 413; tested | + +## What is not defended + +- **A wasmtime or Cranelift bug** that lets a guest escape linear memory. The + OS sandbox narrows what such an escape reaches; with `os_sandbox = "off"` it + reaches whatever the service user can. +- **Kernel bugs.** Same kernel as the host; no VM. +- **Side channels** (timing, Spectre-class) between jobs on one host. +- **Total disk use** by a job: `RLIMIT_FSIZE` bounds each file, not their + number. Put `workspaces` and any writable grant on a size-limited volume. +- **Host memory outside linear memory**: wasmtime's own allocations, compiled + code and the worker process are not capped by `memory_mib`. Cap the + container or service instead. +- **CPU fairness** between concurrent jobs; `max_concurrent` bounds how many. +- **A malicious operator or client with the token.** There is one token with + full access, no per-client identity, no roles, no rate limit, no TLS. +- **Record integrity** against someone with write access to `records/`. The + event log is not signed or chained. +- **Data at rest**: records and retained output are plain files; encryption + is the volume's job. +- **Network grants.** There are none; a job that needs the network cannot run. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 7d2d7d4..f2b997a 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -4,7 +4,7 @@ ## Active -4 items +5 items | Item | Description | |------|-------------| @@ -12,6 +12,7 @@ | [WASM Agent Runtime](active/02-wasm-agent-runtime.md) | Guest-driven WASM agents and teams with shared execution budgets | | [Linux Native Enforcement](active/03-linux-native-enforcement.md) | Enforce native command restrictions on Linux, not just macOS | | [The Production Sandbox](active/04-production-sandbox.md) | What porta must do to be the sandbox people trust in production | +| [Porta Enterprise evaluation build](active/05-enterprise-evaluation.md) | Porta Enterprise evaluation build: job API, run records, container | ## On Hold diff --git a/docs/roadmap/active/05-enterprise-evaluation.md b/docs/roadmap/active/05-enterprise-evaluation.md new file mode 100644 index 0000000..82f51e0 --- /dev/null +++ b/docs/roadmap/active/05-enterprise-evaluation.md @@ -0,0 +1,45 @@ + +# Porta Enterprise evaluation build + +Working name: Porta Enterprise. Annual licence plus updates and support, with +limited paid deployment help. Price, concurrency tiers, evaluation period and +licence enforcement are undecided; nothing for billing, expiry or remote +shutdown is built ahead of that decision. + +## Smallest unit that proves the product + +One environment (a Linux container, or a macOS/Linux host), one use: an +operator runs `porta job-serve` with a policy file; a client submits a job — +an operator-registered, hash-pinned WASM module, its input, the files and +directories it may touch, and its limits — and gets back the result, why it +stopped, and a run record. + +Why this unit: Porta already enforces WASM isolation (wasmtime, WASI preopens, +fuel, memory, epoch deadline) and native process isolation. What a customer +cannot evaluate today is a service boundary: an API that takes untrusted work +and a permission set, and returns evidence. `porta agent` needs a model +endpoint, so it is the second step, not the first. + +## Plan + +1. Job spec v1 and policy v1: what a job may ask for, what the operator allows, + limit ceilings, the version and digest of the policy applied. +2. `porta job-run` (one job, one process) and `porta job-check` (validation + only), both on the same code path the server uses. +3. Each job in its own worker process: wasmtime with fuel, a memory ceiling, + an epoch deadline, WASI preopens only for granted directories, no network, + no host functions beyond WASI; the supervisor kills the process at a hard + deadline or on cancel. +4. Run records: run id, policy version and digest, module digest, start/end, + outcome, stop reason, resource use; input and output contents only as + digests and sizes unless retention is configured. Restart marks unfinished + runs `interrupted` and never re-runs them. +5. `porta job-serve`: a minimal HTTP API (submit, get with wait, cancel, list, + delete, policy, health), bearer token required. +6. Sample job and evaluation script: success, refused grant, denied access + inside the sandbox, infinite loop stopped, memory ceiling, cancellation, + concurrency isolation, restart handling. +7. Container image, local container test, cloud templates (unverified on real + clouds), threat model, licence inventory, gaps ranked by evidence. + +Status of each step lives in `docs/enterprise/`. diff --git a/examples/enterprise/.gitignore b/examples/enterprise/.gitignore new file mode 100644 index 0000000..dc3b76b --- /dev/null +++ b/examples/enterprise/.gitignore @@ -0,0 +1 @@ +state/ diff --git a/examples/enterprise/catalog/prices.csv b/examples/enterprise/catalog/prices.csv new file mode 100644 index 0000000..c518d49 --- /dev/null +++ b/examples/enterprise/catalog/prices.csv @@ -0,0 +1,4 @@ +item,price_cents +widget,250 +gadget,1200 +bolt,15 diff --git a/examples/enterprise/evaluate.py b/examples/enterprise/evaluate.py new file mode 100644 index 0000000..bb27145 --- /dev/null +++ b/examples/enterprise/evaluate.py @@ -0,0 +1,113 @@ +"""Walk a running `porta job-serve` through the evaluation scenario. + +Submits the sample jobs over the API and prints, for each, what was asked, +what came back, and whether that is what the evaluation expects: a granted +job succeeds, an over-broad request is refused, ungranted access is denied +inside the sandbox, limits stop runaway jobs, cancel stops a running one, and +every run has a record. Uses only the Python standard library. + + PORTA_JOB_TOKEN=... python3 examples/enterprise/evaluate.py --url http://127.0.0.1:8080 +""" +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.request + +ORDERS = 'item,quantity\nwidget,4\ngadget,1\nbolt,20\n' +CATALOG = [{'name': 'catalog', 'access': 'read'}] + + +def job(args, **extra): + body = {'version': 1, 'module': 'sample-job', 'args': args} + body.update(extra) + return body + + +SCENARIO = [ + ('granted work succeeds', job(['report'], files={'orders.csv': ORDERS}, directories=CATALOG), + lambda r: r['outcome'] == 'succeeded' and 'total 2500 cents' in r['stdout'].get('text', '')), + ('asking for write access the policy does not grant is refused', job(['report'], directories=[{'name': 'catalog', 'access': 'read-write'}]), + lambda r: r['outcome'] == 'refused' and r['stop_reason'] == 'access_exceeds_grant'), + ('asking for a directory the policy does not list is refused', job(['report'], directories=[{'name': 'home', 'access': 'read'}]), + lambda r: r['outcome'] == 'refused' and r['stop_reason'] == 'directory_not_granted'), + ('asking past a limit ceiling is refused', job(['spin'], limits={'timeout_ms': 3600000}), + lambda r: r['outcome'] == 'refused' and r['stop_reason'] == 'limit_exceeds_ceiling'), + ('ungranted reads and writes are denied inside the sandbox', job(['probe'], directories=CATALOG), + lambda r: r['outcome'] == 'succeeded' and r['stdout'].get('text', '').count(': denied') == 6), + ('an infinite loop stops at its deadline', job(['spin'], limits={'timeout_ms': 2000, 'fuel': 100000000000}), + lambda r: r['stop_reason'] == 'timeout'), + ('an infinite loop stops when its fuel runs out', job(['spin'], limits={'fuel': 50000000}), + lambda r: r['stop_reason'] == 'fuel_exhausted'), + ('unbounded allocation stops at the memory ceiling', job(['grow'], limits={'memory_mib': 16}), + lambda r: r['stop_reason'] == 'memory_limit'), + ('unbounded output stops at its cap', job(['flood'], limits={'max_output_bytes': 4096}), + lambda r: r['stdout']['capped'] and r['outcome'] == 'stopped'), +] + + +class Client: + def __init__(self, url, token): + self.url, self.token = url.rstrip('/'), token + + def call(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(self.url + path, data=data, method=method) + request.add_header('Authorization', f'Bearer {self.token}') + if data is not None: + request.add_header('Content-Type', 'application/json') + try: + with urllib.request.urlopen(request, timeout=120) as response: + return response.status, json.loads(response.read() or b'null') + except urllib.error.HTTPError as error: + return error.code, json.loads(error.read() or b'null') + + +def line(passed, title, record): + mark = 'PASS' if passed else 'FAIL' + print(f"{mark} {title}\n run {record.get('run_id')} outcome={record.get('outcome')} " + f"stop_reason={record.get('stop_reason')} wall_ms={(record.get('usage') or {}).get('wall_ms')}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--url', default='http://127.0.0.1:8080') + parser.add_argument('--token-env', default='PORTA_JOB_TOKEN') + options = parser.parse_args() + token = os.environ.get(options.token_env, '') + if not token: + sys.exit(f'set {options.token_env} to the token the server was started with') + client = Client(options.url, token) + status, policy = client.call('GET', '/v1/policy') + if status != 200: + sys.exit(f'GET /v1/policy answered {status}: {policy}') + print(f"policy {policy['label']} sha256={policy['sha256'][:16]}… os_sandbox={policy.get('os_sandbox')}\n") + failures = 0 + for title, body, expected in SCENARIO: + _, record = client.call('POST', '/v1/runs?wait=60', body) + passed = bool(expected(record)) + failures += not passed + line(passed, title, record) + + _, running = client.call('POST', '/v1/runs', job(['spin'], limits={'timeout_ms': 10000, 'fuel': 100000000000})) + time.sleep(0.5) + asked = time.time() + client.call('POST', f"/v1/runs/{running['run_id']}/cancel") + _, record = client.call('GET', f"/v1/runs/{running['run_id']}?wait=30") + passed = record['stop_reason'] == 'cancelled' and time.time() - asked < 5 + failures += not passed + line(passed, 'cancel stops a running job', record) + + _, listing = client.call('GET', '/v1/runs?limit=20') + recorded = all(r['status'] == 'finished' for r in listing['runs']) + failures += not recorded + print(f"\n{'PASS' if recorded else 'FAIL'} every run above has a finished record ({len(listing['runs'])} listed)") + print(' read one in full: GET /v1/runs/; the event log is events.jsonl beside the records') + print(f"\n{'all checks passed' if not failures else f'{failures} check(s) failed'}") + sys.exit(1 if failures else 0) + + +if __name__ == '__main__': + main() diff --git a/examples/enterprise/jobs/flood.json b/examples/enterprise/jobs/flood.json new file mode 100644 index 0000000..0be8450 --- /dev/null +++ b/examples/enterprise/jobs/flood.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "module": "sample-job", + "args": [ + "flood" + ], + "limits": { + "timeout_ms": 2000 + } +} diff --git a/examples/enterprise/jobs/fuel.json b/examples/enterprise/jobs/fuel.json new file mode 100644 index 0000000..2eac285 --- /dev/null +++ b/examples/enterprise/jobs/fuel.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "module": "sample-job", + "args": ["spin"], + "limits": {"fuel": 50000000} +} diff --git a/examples/enterprise/jobs/grow.json b/examples/enterprise/jobs/grow.json new file mode 100644 index 0000000..2c5adb1 --- /dev/null +++ b/examples/enterprise/jobs/grow.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "module": "sample-job", + "args": [ + "grow" + ], + "limits": { + "timeout_ms": 2000 + } +} diff --git a/examples/enterprise/jobs/probe.json b/examples/enterprise/jobs/probe.json new file mode 100644 index 0000000..15a2aac --- /dev/null +++ b/examples/enterprise/jobs/probe.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "module": "sample-job", + "args": [ + "probe" + ], + "directories": [ + { + "name": "catalog", + "access": "read" + } + ], + "limits": { + "timeout_ms": 2000 + } +} diff --git a/examples/enterprise/jobs/refused-limit.json b/examples/enterprise/jobs/refused-limit.json new file mode 100644 index 0000000..c766a4e --- /dev/null +++ b/examples/enterprise/jobs/refused-limit.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "module": "sample-job", + "args": ["spin"], + "limits": {"timeout_ms": 600000} +} diff --git a/examples/enterprise/jobs/refused-module.json b/examples/enterprise/jobs/refused-module.json new file mode 100644 index 0000000..c4d5518 --- /dev/null +++ b/examples/enterprise/jobs/refused-module.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "module": "not-registered", + "input": "anything" +} diff --git a/examples/enterprise/jobs/refused-write-grant.json b/examples/enterprise/jobs/refused-write-grant.json new file mode 100644 index 0000000..333ab22 --- /dev/null +++ b/examples/enterprise/jobs/refused-write-grant.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "module": "sample-job", + "args": ["report"], + "directories": [{"name": "catalog", "access": "read-write"}] +} diff --git a/examples/enterprise/jobs/report.json b/examples/enterprise/jobs/report.json new file mode 100644 index 0000000..2696cb5 --- /dev/null +++ b/examples/enterprise/jobs/report.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "module": "sample-job", + "args": ["report"], + "files": {"orders.csv": "item,quantity\nwidget,4\ngadget,1\nbolt,20\n"}, + "directories": [{"name": "catalog", "access": "read"}] +} diff --git a/examples/enterprise/jobs/spin.json b/examples/enterprise/jobs/spin.json new file mode 100644 index 0000000..605d7a2 --- /dev/null +++ b/examples/enterprise/jobs/spin.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "module": "sample-job", + "args": ["spin"], + "limits": {"timeout_ms": 2000, "fuel": 100000000000} +} diff --git a/examples/enterprise/policy.toml b/examples/enterprise/policy.toml new file mode 100644 index 0000000..4786427 --- /dev/null +++ b/examples/enterprise/policy.toml @@ -0,0 +1,40 @@ +# Porta Enterprise evaluation policy, version 1. +# Paths are relative to this file. Run: porta job-check --policy policy.toml +version = 1 +label = "evaluation-2026-10" # your name for this version of the rules + +# Environment variable names a job may set. Nothing of the host's crosses. +env_names = ["REPORT_CURRENCY"] + +[service] +records = "state/records" # run records and events.jsonl +workspaces = "state/workspaces" # one per running job, removed when it ends +max_concurrent = 4 +retain_output = true # keep stdout and /output files with the record +max_request_bytes = 1048576 + +# Ceilings: a job asking for more is refused, not narrowed. +[limits] +timeout_ms = 10000 +fuel = 100000000000 +memory_mib = 64 +max_output_bytes = 1048576 +max_input_bytes = 524288 + +# What a job gets when it does not say. +[defaults] +timeout_ms = 3000 +fuel = 1000000000 +memory_mib = 32 +max_output_bytes = 65536 +max_input_bytes = 65536 + +[[modules]] +name = "sample-job" +wasm = "sample-job/sample-job.wasm" +sha256 = "6a187188ce3dda6d5252e46bfc70899a21728336abd8857dbc7aca3a56c90a00" + +[[directories]] +name = "catalog" # mounted at /data/catalog +host = "catalog" +access = "read" # the most a job may ask for diff --git a/examples/enterprise/sample-job/almide.toml b/examples/enterprise/sample-job/almide.toml new file mode 100644 index 0000000..f009e69 --- /dev/null +++ b/examples/enterprise/sample-job/almide.toml @@ -0,0 +1,3 @@ +[package] +name = "porta-sample-job" +version = "0.1.0" diff --git a/examples/enterprise/sample-job/sample-job.wasm b/examples/enterprise/sample-job/sample-job.wasm new file mode 100644 index 0000000000000000000000000000000000000000..7d92190460d1745b557bb58cb5dce5824922e58d GIT binary patch literal 51778 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 Uv&()VF0RjC_Te8v0$Ak#2l)bf;s5{u literal 0 HcmV?d00001 diff --git a/examples/enterprise/sample-job/src/main.almd b/examples/enterprise/sample-job/src/main.almd new file mode 100644 index 0000000..c40182c --- /dev/null +++ b/examples/enterprise/sample-job/src/main.almd @@ -0,0 +1,95 @@ +// The evaluation's sample job: an order report. It reads orders from the +// job's own input (/input/orders.csv) and prices from a directory the policy +// grants read-only (/data/catalog/prices.csv), writes /output/report.json and +// prints a one-line summary. +// +// The same module carries the misbehaviour an evaluator needs to see refused +// or stopped, chosen by its first argument: `probe` tries to reach what it +// was not granted, `spin` never ends, `grow` keeps allocating, `flood` keeps +// printing. Porta treats it as untrusted either way. +import env +import fs +import json +import value + +fn price_of(prices: List[String], item: String) -> Int = { + let row = list.find(prices, (line) => string.starts_with(line, item + ",")) ?? "" + int.parse(string.trim(list.get(string.split(row, ","), 1) ?? "0")) ?? 0 +} + +effect fn report() -> Result[Unit, String] = { + let orders = fs.read_text("/input/orders.csv")! + |> string.split("\n") |> list.map((l) => string.trim(l)) |> list.filter((l) => l != "") + let prices = fs.read_text("/data/catalog/prices.csv")! |> string.split("\n") + var total = 0 + var lines = 0 + for order in list.drop(orders, 1) { + let fields = string.split(order, ",") + let item = string.trim(list.get(fields, 0) ?? "") + let quantity = int.parse(string.trim(list.get(fields, 1) ?? "0")) ?? 0 + total = total + quantity * price_of(prices, item) + lines = lines + 1 + } + let summary = value.object([("orders", value.int(lines)), ("total_cents", value.int(total))]) + fs.write("/output/report.json", json.stringify(summary))! + println("report: ${int.to_string(lines)} orders, total ${int.to_string(total)} cents") + ok(()) +} + +// Each attempt is reported as "denied" or "ALLOWED"; the probe exits 0 only +// when every one was denied. +effect fn attempt(name: String, outcome: Result[String, String]) -> Bool = { + let allowed = match outcome { ok(_) => true, err(_) => false } + println("${name}: ${if allowed then "ALLOWED" else "denied"}") + allowed +} + +effect fn write_attempt(name: String, path: String) -> Bool = { + let allowed = match fs.write(path, "x") { ok(_) => true, err(_) => false } + println("${name}: ${if allowed then "ALLOWED" else "denied"}") + allowed +} + +effect fn probe() -> Result[Unit, String] = { + var allowed = 0 + if attempt("read /etc/passwd", fs.read_text("/etc/passwd"))! then allowed = allowed + 1 else () + if attempt("read the worker's job file via /input/..", fs.read_text("/input/../job.json"))! then allowed = allowed + 1 else () + if attempt("read the host via /data/catalog/../..", fs.read_text("/data/catalog/../../etc/hosts"))! then allowed = allowed + 1 else () + if write_attempt("write into the read-only catalog", "/data/catalog/tampered.txt")! then allowed = allowed + 1 else () + if write_attempt("write into the read-only input", "/input/tampered.txt")! then allowed = allowed + 1 else () + if write_attempt("write outside every mount", "/tmp/escaped.txt")! then allowed = allowed + 1 else () + if allowed > 0 then err("a probe was allowed") else ok(()) +} + +effect fn spin() -> Result[Unit, String] = { + var n = 0 + while true { n = n + 1 } + ok(()) +} + +effect fn grow() -> Result[Unit, String] = { + var chunks: List[String] = [] + var chunk = "0123456789abcdef" + while true { + chunk = chunk + chunk + chunks = chunks + [chunk] + } + ok(()) +} + +effect fn flood() -> Result[Unit, String] = { + while true { println("flooding the output with a line that keeps repeating forever") } + ok(()) +} + +effect fn main() -> Result[Unit, String] = { + let mode = list.get(env.args(), 0) ?? "report" + match mode { + "report" => report(), + "probe" => probe(), + "spin" => spin(), + "grow" => grow(), + "flood" => flood(), + _ => err("unknown mode: ${mode}"), + } +} diff --git a/native/job_service.rs b/native/job_service.rs new file mode 100644 index 0000000..a059d1d --- /dev/null +++ b/native/job_service.rs @@ -0,0 +1,166 @@ +//! Jobs: an operator's policy, a client's job, a worker process that runs it +//! in WASM, and the record of what happened. `porta job-check`, `job-run` and +//! `job-serve` all go through here; the Almide side binds to the functions at +//! the bottom through `wasmtime_bridge`. +//! +//! The boundary a job cannot cross is wasmtime's and WASI's: the module gets +//! fuel, a linear-memory ceiling, an epoch deadline, the directories it was +//! granted and nothing else. The process around it is a second, coarser +//! boundary: its own process group, an empty environment, a CPU-time and file +//! size ceiling, and a supervisor that kills it. + +mod http; +mod policy; +mod spec; +mod supervisor; +mod worker; + +use crate::locking::locked; +use crate::wasmtime_bridge::is_component; +use policy::{sha256_hex, valid_name, Access, Limits, LimitsFile, Policy}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use spec::{Mount, Resolved}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use supervisor::Service; +use worker::WorkerResult; + +static VERSION: OnceLock = OnceLock::new(); + +fn util_version() -> String { VERSION.get().cloned().unwrap_or_default() } + +/// `YYYYMMDDTHHMMSSZ-` and twelve random hex digits: sortable by start time, +/// unguessable enough that one client cannot name another's run by counting. +fn new_run_id() -> String { + let mut random = [0u8; 6]; + if let Ok(mut file) = std::fs::File::open("/dev/urandom") { + use std::io::Read; + let _ = file.read_exact(&mut random); + } + let stamp: String = rfc3339(SystemTime::now()).chars().filter(|c| c.is_ascii_digit() || *c == 'T').take(15).collect(); + format!("{stamp}Z-{}", random.iter().map(|b| format!("{b:02x}")).collect::()) +} + +fn valid_run_id(id: &str) -> bool { + id.len() == 29 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') +} + +/// UTC, to the millisecond, without a date library. +fn rfc3339(time: SystemTime) -> String { + let since = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + let (secs, millis) = (since.as_secs() as i64, since.subsec_millis()); + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + // Howard Hinnant's civil_from_days. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 }; + format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z", rem / 3600, rem % 3600 / 60, rem % 60) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let temporary = path.with_extension("tmp"); + std::fs::write(&temporary, bytes).map_err(|e| format!("write {}: {e}", temporary.display()))?; + std::fs::rename(&temporary, path).map_err(|e| format!("rename {}: {e}", path.display())) +} + +fn read_json(path: &Path) -> Result { + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string()) +} + +fn pretty(value: &Value) -> Vec { + let mut bytes = serde_json::to_vec_pretty(value).unwrap_or_default(); + bytes.push(b'\n'); + bytes +} + +fn failure(message: impl std::fmt::Display) -> String { json!({"error": message.to_string()}).to_string() } + +/// `porta job-check`: the policy as a client would see it, and, given a job, +/// what it would be granted or why it would be refused. Nothing is created +/// beyond the policy's state directories and nothing runs. +pub fn job_check(policy: impl AsRef, job: impl AsRef) -> String { + let policy = match Policy::load(Path::new(policy.as_ref())) { Ok(p) => p, Err(e) => return failure(e) }; + if job.as_ref().is_empty() { return json!({"policy": policy.summary()}).to_string(); } + let body = match std::fs::read(job.as_ref()) { Ok(b) => b, Err(e) => return failure(format!("read job: {e}")) }; + match spec::resolve(&policy, &body) { + Ok((job, digest)) => json!({"policy": policy.identity(), "accepted": true, "job_sha256": digest, "module": {"name": job.module, "sha256": job.module_sha256}, "limits": job.limits, "grants": job.grants()}).to_string(), + Err(refusal) => json!({"policy": policy.identity(), "accepted": false, "code": refusal.code, "message": refusal.message}).to_string(), + } +} + +/// `porta job-run`: one job, through the same supervisor and worker the +/// service uses; returns the finished record. +pub fn job_run(version: impl AsRef, policy: impl AsRef, job: impl AsRef) -> String { + let _ = VERSION.set(version.as_ref().to_string()); + let service = match Policy::load(Path::new(policy.as_ref())).and_then(Service::open) { Ok(s) => s, Err(e) => return failure(e) }; + let body = match std::fs::read(job.as_ref()) { Ok(b) => b, Err(e) => return failure(format!("read job: {e}")) }; + match service.submit(&body) { + Ok(record) => { + let id = record["run_id"].as_str().unwrap_or_default().to_string(); + while service.is_live(&id) { let _ = service.record(&id, Duration::from_secs(1)); } + service.record(&id, Duration::ZERO).map(|r| String::from_utf8_lossy(&pretty(&r)).trim_end().to_string()).unwrap_or_else(|| failure("the record is missing")) + } + Err(rejected) => String::from_utf8_lossy(&pretty(&rejected.record)).trim_end().to_string(), + } +} + +static SHUTDOWN: AtomicBool = AtomicBool::new(false); + +extern "C" fn on_terminate(_: libc::c_int) { SHUTDOWN.store(true, Ordering::SeqCst); } + +/// `porta job-serve`: the HTTP API until SIGTERM or SIGINT, which stop the +/// running jobs, finish their records as `service_shutdown`, and exit. +pub fn job_serve(version: impl AsRef, policy: impl AsRef, listen: impl AsRef, no_auth: bool) -> String { + let _ = VERSION.set(version.as_ref().to_string()); + let token = std::env::var("PORTA_JOB_TOKEN").ok().filter(|t| !t.is_empty()); + // The token stays in this process only; workers start from an empty + // environment, and the variable is removed so nothing later inherits it. + std::env::remove_var("PORTA_JOB_TOKEN"); + if let Err(reason) = authentication(token.as_deref(), no_auth, listen.as_ref()) { return failure(reason); } + let service = match Policy::load(Path::new(policy.as_ref())).and_then(Service::open) { Ok(s) => s, Err(e) => return failure(e) }; + stop_on_signal(Arc::clone(&service)); + match http::serve(service, listen.as_ref(), token) { Ok(()) => String::new(), Err(e) => failure(e) } +} + +/// A token, or no token on loopback when the operator says so in as many words. +fn authentication(token: Option<&str>, no_auth: bool, listen: &str) -> Result<(), &'static str> { + let loopback = listen.parse::().map(|a| a.ip().is_loopback()).unwrap_or(false); + match (token, no_auth) { + (Some(t), _) if t.len() < 16 => Err("PORTA_JOB_TOKEN must be at least 16 characters"), + (Some(_), true) => Err("--no-auth and PORTA_JOB_TOKEN together are ambiguous; choose one"), + (None, false) => Err("set PORTA_JOB_TOKEN, or pass --no-auth to serve without one on a loopback address"), + (None, true) if !loopback => Err("--no-auth is accepted only on a loopback address such as 127.0.0.1:8640"), + _ => Ok(()), + } +} + +/// SIGTERM and SIGINT stop the running jobs, finish their records as +/// `service_shutdown`, and exit. +fn stop_on_signal(service: Arc) { + // The handler only stores to an atomic, which is async-signal-safe. + for signal in [libc::SIGTERM, libc::SIGINT] { + unsafe { libc::signal(signal, on_terminate as *const () as libc::sighandler_t); } + } + std::thread::spawn(move || { + while !SHUTDOWN.load(Ordering::SeqCst) { std::thread::sleep(Duration::from_millis(100)); } + eprintln!("[porta job-serve] stopping: running jobs end as service_shutdown"); + service.shutdown(Duration::from_secs(8)); + std::process::exit(0); + }); +} + +/// `porta __job-worker `: never run by hand; the +/// supervisor starts it. +pub fn job_worker(dir: impl AsRef, lifeline: impl AsRef) -> i64 { + worker::main(Path::new(dir.as_ref()), lifeline.as_ref().parse().ok()) +} diff --git a/native/job_service/http.rs b/native/job_service/http.rs new file mode 100644 index 0000000..37c93d5 --- /dev/null +++ b/native/job_service/http.rs @@ -0,0 +1,195 @@ +//! The job API over HTTP/1.1: one request per connection, JSON in and out, a +//! bearer token on everything but `/healthz`. Deliberately small — no TLS, no +//! keep-alive — and meant to sit behind the platform's load balancer or on +//! loopback. +//! +//! | Method and path | What it does | +//! |-----------------------------------|-----------------------------------------------| +//! | `GET /healthz` | liveness, no token | +//! | `GET /v1/policy` | the policy in force: label, digest, ceilings | +//! | `POST /v1/runs[?wait=S]` | submit a job; 202, or the finished record | +//! | `GET /v1/runs[?limit=N]` | newest runs first | +//! | `GET /v1/runs/{id}[?wait=S]` | one record, optionally waiting for the end | +//! | `POST /v1/runs/{id}/cancel` | stop a running job | +//! | `GET /v1/runs/{id}/output/{path}` | a retained output file | +//! | `DELETE /v1/runs/{id}` | remove a finished run's record and output | + +use super::*; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; + +const MAX_HEADER: usize = 16 * 1024; +const MAX_WAIT_S: u64 = 300; +const MAX_CONNECTIONS: usize = 64; + +pub(super) struct Request { method: String, path: String, query: HashMap, headers: HashMap, body: Vec } +pub(super) struct Response { status: u16, content_type: &'static str, body: Vec } + +fn reply(status: u16, body: Value) -> Response { + let mut bytes = serde_json::to_vec_pretty(&body).unwrap_or_default(); + bytes.push(b'\n'); + Response { status, content_type: "application/json", body: bytes } +} +fn error(status: u16, code: &str, message: &str) -> Response { reply(status, json!({"error": {"code": code, "message": message}})) } + +pub(super) fn serve(service: Arc, listen: &str, token: Option) -> Result<(), String> { + let listener = TcpListener::bind(listen).map_err(|e| format!("listen on {listen}: {e}"))?; + let address = listener.local_addr().map_err(|e| e.to_string())?; + eprintln!("[porta job-serve] listening on http://{address} policy={} sha256={}{}", service.policy.label, service.policy.sha256, + if token.is_some() { "" } else { " (no token: loopback only)" }); + let open = Arc::new(AtomicUsize::new(0)); + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + if open.load(Ordering::SeqCst) >= MAX_CONNECTIONS { + let _ = write_response(&stream, &error(503, "busy", "too many open connections")); + continue; + } + open.fetch_add(1, Ordering::SeqCst); + let (service, token, open) = (Arc::clone(&service), token.clone(), Arc::clone(&open)); + std::thread::spawn(move || { + let _ = stream.set_read_timeout(Some(Duration::from_secs(30))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(30))); + let limit = service.policy.max_request_bytes; + let response = match read_request(&stream, limit) { + Ok(request) => handle(&service, token.as_deref(), request), + Err(response) => response, + }; + let _ = write_response(&stream, &response); + open.fetch_sub(1, Ordering::SeqCst); + }); + } + Ok(()) +} + +fn read_request(stream: &TcpStream, max_body: usize) -> Result { + let mut reader = BufReader::new(stream.take((MAX_HEADER + max_body) as u64)); + let (method, target, headers) = read_head(&mut reader)?; + if headers.contains_key("transfer-encoding") { return Err(error(411, "length_required", "send Content-Length; chunked bodies are not accepted")); } + let length: usize = headers.get("content-length").map(|v| v.parse().unwrap_or(usize::MAX)).unwrap_or(0); + if length > max_body { return Err(error(413, "request_too_large", &format!("the body is limited to {max_body} bytes"))); } + let mut body = vec![0; length]; + reader.read_exact(&mut body).map_err(|_| error(400, "bad_request", "body shorter than Content-Length"))?; + let (path, query) = target.split_once('?').unwrap_or((&target, "")); + let query = query.split('&').filter_map(|pair| pair.split_once('=')).map(|(k, v)| (k.to_string(), v.to_string())).collect(); + Ok(Request { method, path: path.to_string(), query, headers, body }) +} + +type Head = (String, String, HashMap); + +/// The request line and headers, refused past 16 KiB. +fn read_head(reader: &mut impl BufRead) -> Result { + let unreadable = |_| error(400, "bad_request", "unreadable request head"); + let mut line = String::new(); + reader.read_line(&mut line).map_err(unreadable)?; + let mut parts = line.trim_end().splitn(3, ' '); + let (method, target) = (parts.next().unwrap_or("").to_string(), parts.next().unwrap_or("").to_string()); + if method.is_empty() || !target.starts_with('/') { return Err(error(400, "bad_request", "malformed request line")); } + let mut headers = HashMap::new(); + let mut seen = line.len(); + loop { + let mut header = String::new(); + let n = reader.read_line(&mut header).map_err(unreadable)?; + seen += n; + if seen > MAX_HEADER { return Err(error(431, "headers_too_large", "request headers are too large")); } + let Some((name, value)) = header.trim_end().split_once(':') else { break }; + headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); + } + Ok((method, target, headers)) +} + +const REASONS: [(u16, &str); 17] = [ + (200, "OK"), (202, "Accepted"), (204, "No Content"), (400, "Bad Request"), (401, "Unauthorized"), + (403, "Forbidden"), (404, "Not Found"), (405, "Method Not Allowed"), (409, "Conflict"), (411, "Length Required"), + (413, "Payload Too Large"), (415, "Unsupported Media Type"), (429, "Too Many Requests"), + (431, "Request Header Fields Too Large"), (500, "Internal Server Error"), (503, "Service Unavailable"), (0, "Error"), +]; + +fn write_response(mut stream: &TcpStream, response: &Response) -> std::io::Result<()> { + let reason = REASONS.iter().find(|(code, _)| *code == response.status || *code == 0).map(|(_, r)| *r).unwrap_or("Error"); + write!(stream, "HTTP/1.1 {} {reason}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + response.status, response.content_type, response.body.len())?; + stream.write_all(&response.body)?; + stream.flush() +} + +/// A token comparison whose time does not depend on where the strings differ. +fn token_matches(expected: &str, given: &str) -> bool { + let (a, b) = (expected.as_bytes(), given.as_bytes()); + let mut diff = a.len() ^ b.len(); + for (i, byte) in a.iter().enumerate() { diff |= (byte ^ b.get(i).copied().unwrap_or(0)) as usize; } + diff == 0 +} + +fn wait_of(query: &HashMap) -> Duration { + Duration::from_secs(query.get("wait").and_then(|v| v.parse().ok()).unwrap_or(0u64).min(MAX_WAIT_S)) +} + +fn handle(service: &Arc, token: Option<&str>, request: Request) -> Response { + if request.path == "/healthz" { return reply(200, json!({"ok": true})); } + let given = request.headers.get("authorization").and_then(|v| v.strip_prefix("Bearer ")).unwrap_or(""); + if token.is_some_and(|token| !token_matches(token, given)) { return error(401, "unauthorized", "a valid bearer token is required"); } + let path = request.path.trim_matches('/').to_string(); + let segments: Vec<&str> = path.split('/').collect(); + match (request.method.as_str(), segments.as_slice()) { + ("GET", ["v1", rest @ ..]) => read(service, &request, rest), + ("POST", ["v1", "runs"]) => submit(service, &request), + ("POST", ["v1", "runs", id, "cancel"]) => cancel(service, id), + ("DELETE", ["v1", "runs", id]) => delete(service, id), + (_, ["v1", ..]) => error(405, "method_not_allowed", "see the API reference for the supported methods"), + _ => error(404, "not_found", "no such endpoint"), + } +} + +/// Every GET under `/v1`. +fn read(service: &Service, request: &Request, path: &[&str]) -> Response { + match path { + ["policy"] => reply(200, service.policy.summary()), + ["runs"] => { + let limit = request.query.get("limit").and_then(|v| v.parse().ok()).unwrap_or(50usize).min(1000); + reply(200, json!({"runs": service.list(limit)})) + } + ["runs", id] => service.record(id, wait_of(&request.query)).map(|r| reply(200, r)).unwrap_or_else(|| error(404, "not_found", "no such run")), + ["runs", id, "output", rest @ ..] => output_file(service, id, &rest.join("/")), + _ => error(404, "not_found", "no such endpoint"), + } +} + +fn submit(service: &Arc, request: &Request) -> Response { + if request.headers.get("content-type").is_some_and(|t| !t.starts_with("application/json")) { + return error(415, "unsupported_media_type", "send the job as application/json"); + } + let record = match service.submit(&request.body) { Ok(record) => record, Err(rejected) => return reply(rejected.status, rejected.record) }; + let waited = wait_of(&request.query); + if waited.is_zero() { return reply(202, record); } + let latest = service.record(record["run_id"].as_str().unwrap_or_default(), waited).unwrap_or(record); + reply(if latest["status"] == "finished" { 200 } else { 202 }, latest) +} + +fn cancel(service: &Service, id: &str) -> Response { + if service.stop(id, "cancelled") { return reply(202, json!({"run_id": id, "status": "stopping"})); } + match service.record(id, Duration::ZERO) { + Some(_) => error(409, "not_running", "the run has already finished"), + None => error(404, "not_found", "no such run"), + } +} + +fn delete(service: &Service, id: &str) -> Response { + match service.delete(id) { + Ok(()) => Response { status: 204, content_type: "application/json", body: Vec::new() }, + Err(409) => error(409, "running", "cancel the run before deleting it"), + Err(404) => error(404, "not_found", "no such run"), + Err(_) => error(500, "delete_failed", "the record could not be removed"), + } +} + +/// Serves only a path the record lists as a retained regular file, so the +/// request path never reaches the filesystem on its own. +fn output_file(service: &Service, id: &str, path: &str) -> Response { + let Some(record) = service.record(id, Duration::ZERO) else { return error(404, "not_found", "no such run") }; + let listed = record["output_files"].as_array().is_some_and(|files| files.iter().any(|f| f["path"] == path && f.get("sha256").is_some())); + if !listed || !service.policy.retain_output { return error(404, "not_found", "no such retained output file"); } + match std::fs::read(service.policy.records.join(id).join("output").join(path)) { + Ok(body) => Response { status: 200, content_type: "application/octet-stream", body }, + Err(_) => error(404, "not_found", "no such retained output file"), + } +} diff --git a/native/job_service/policy.rs b/native/job_service/policy.rs new file mode 100644 index 0000000..8263666 --- /dev/null +++ b/native/job_service/policy.rs @@ -0,0 +1,285 @@ +//! The operator's policy: which modules may run, which host directories a job +//! may be given and how, which environment names it may set, and the ceilings +//! no job can exceed. A job can only ask for less than this, never more. +//! +//! The policy is identified by the operator's `label` and by the SHA-256 of +//! the file's bytes; every run record carries both, so a record says which +//! version of the rules it ran under. + +use super::*; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyFile { + version: u32, + label: String, + service: ServiceFile, + limits: LimitsFile, + defaults: Option, + #[serde(default)] + env_names: Vec, + #[serde(default)] + modules: Vec, + #[serde(default)] + directories: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ServiceFile { + records: String, + workspaces: String, + #[serde(default = "default_concurrency")] + max_concurrent: usize, + #[serde(default)] + retain_output: bool, + #[serde(default = "default_request_bytes")] + max_request_bytes: usize, + #[serde(default = "default_os_sandbox")] + os_sandbox: String, +} +fn default_os_sandbox() -> String { "required".into() } +fn default_concurrency() -> usize { 2 } +fn default_request_bytes() -> usize { 4 * 1024 * 1024 } + +#[derive(Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] +pub(super) struct LimitsFile { + pub(super) timeout_ms: Option, + pub(super) fuel: Option, + pub(super) memory_mib: Option, + pub(super) max_output_bytes: Option, + pub(super) max_input_bytes: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModuleFile { name: String, wasm: String, sha256: String } + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DirectoryFile { name: String, host: String, access: Access } + +/// How a directory is reachable from inside the guest. +#[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Debug)] +#[serde(rename_all = "kebab-case")] +pub(super) enum Access { Read, ReadWrite } + +impl Access { + pub(super) fn name(self) -> &'static str { match self { Access::Read => "read", Access::ReadWrite => "read-write" } } +} + +/// Limits a run executes under. Every field is set once resolved. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +pub(super) struct Limits { + pub(super) timeout_ms: u64, + pub(super) fuel: u64, + pub(super) memory_mib: u64, + pub(super) max_output_bytes: u64, + pub(super) max_input_bytes: u64, +} + +/// What the platform will accept as a ceiling at all, whatever a policy says: +/// an hour, 4 GiB of linear memory, 64 MiB of output or input. +const HARD: Limits = Limits { + timeout_ms: 3_600_000, + fuel: u64::MAX / 2, + memory_mib: 4096, + max_output_bytes: 64 * 1024 * 1024, + max_input_bytes: 64 * 1024 * 1024, +}; + +pub(super) struct Module { pub(super) path: PathBuf, pub(super) sha256: String } +pub(super) struct Directory { pub(super) host: PathBuf, pub(super) access: Access } + +pub(super) struct Policy { + pub(super) label: String, + pub(super) sha256: String, + pub(super) records: PathBuf, + pub(super) workspaces: PathBuf, + pub(super) max_concurrent: usize, + pub(super) retain_output: bool, + pub(super) max_request_bytes: usize, + pub(super) os_sandbox: bool, + pub(super) ceilings: Limits, + pub(super) defaults: Limits, + pub(super) env_names: BTreeSet, + pub(super) modules: BTreeMap, + pub(super) directories: BTreeMap, +} + +impl Policy { + pub(super) fn load(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("read policy {}: {e}", path.display()))?; + let text = std::str::from_utf8(&bytes).map_err(|_| "policy must be UTF-8".to_string())?; + let file: PolicyFile = toml::from_str(text).map_err(|e| format!("policy {}: {e}", path.display()))?; + if file.version != 1 { return Err(format!("policy version {} is not supported; expected 1", file.version)); } + if file.label.trim().is_empty() || file.label.len() > 128 { return Err("policy label must be 1-128 characters".into()); } + let base = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + let base = std::fs::canonicalize(base).map_err(|e| format!("resolve policy directory: {e}"))?; + let ceilings = complete(&file.limits, None, "limits")?; + let defaults = complete(file.defaults.as_ref().unwrap_or(&LimitsFile::default()), Some(ceilings), "defaults")?; + let (records, workspaces, os_sandbox) = service_settings(&base, &file.service)?; + let modules = load_modules(&base, file.modules)?; + let protected = Protected { policy: std::fs::canonicalize(path).map_err(|e| format!("resolve policy: {e}"))?, records: &records, workspaces: &workspaces, modules: &modules }; + let directories = load_directories(&base, file.directories, &protected)?; + Ok(Policy { + label: file.label, + sha256: sha256_hex(&bytes), + env_names: env_names(file.env_names)?, + max_concurrent: file.service.max_concurrent, + retain_output: file.service.retain_output, + max_request_bytes: file.service.max_request_bytes, + records, + workspaces, + os_sandbox, + ceilings, + defaults, + modules, + directories, + }) + } + + /// What a client may see: names, digests and limits, never host paths. + pub(super) fn summary(&self) -> Value { + json!({ + "label": self.label, + "sha256": self.sha256, + "limits": self.ceilings, + "defaults": self.defaults, + "env_names": self.env_names, + "modules": self.modules.iter().map(|(name, m)| json!({"name": name, "sha256": m.sha256})).collect::>(), + "directories": self.directories.iter().map(|(name, d)| json!({"name": name, "access": d.access.name()})).collect::>(), + "network": "none", + "os_sandbox": self.os_sandbox, + "max_concurrent": self.max_concurrent, + "retain_output": self.retain_output, + }) + } + + pub(super) fn identity(&self) -> Value { json!({"label": self.label, "sha256": self.sha256}) } +} + +/// What a writable grant must not reach: the policy, the service's state and +/// the modules it would otherwise be able to rewrite. +struct Protected<'a> { policy: PathBuf, records: &'a Path, workspaces: &'a Path, modules: &'a BTreeMap } + +fn service_settings(base: &Path, service: &ServiceFile) -> Result<(PathBuf, PathBuf, bool), String> { + if !(1..=64).contains(&service.max_concurrent) { return Err("service.max_concurrent must be 1-64".into()); } + if service.max_request_bytes < 1024 || service.max_request_bytes > 256 * 1024 * 1024 { + return Err("service.max_request_bytes must be between 1 KiB and 256 MiB".into()); + } + let os_sandbox = match service.os_sandbox.as_str() { + "required" => true, + "off" => false, + other => return Err(format!("service.os_sandbox must be \"required\" or \"off\", not \"{other}\"")), + }; + let records = state_dir(base, &service.records, "service.records")?; + let workspaces = state_dir(base, &service.workspaces, "service.workspaces")?; + if records.starts_with(&workspaces) || workspaces.starts_with(&records) { + return Err("service.records and service.workspaces must not contain one another".into()); + } + Ok((records, workspaces, os_sandbox)) +} + +fn env_names(names: Vec) -> Result, String> { + match names.iter().find(|name| !valid_env_name(name)) { + Some(bad) => Err(format!("env_names: '{bad}' is not a valid variable name")), + None => Ok(names.into_iter().collect()), + } +} + +/// Every module's bytes must match the digest the policy pins, now. +fn load_modules(base: &Path, listed: Vec) -> Result, String> { + let mut modules = BTreeMap::new(); + for module in listed { + if !valid_name(&module.name) { return Err(format!("module name '{}' must be 1-64 of [A-Za-z0-9._-]", module.name)); } + let wasm = std::fs::canonicalize(base.join(&module.wasm)).map_err(|e| format!("module {}: {}: {e}", module.name, module.wasm))?; + let expected = module.sha256.to_ascii_lowercase(); + let actual = sha256_hex(&std::fs::read(&wasm).map_err(|e| format!("module {}: {e}", module.name))?); + if actual != expected { return Err(format!("module {}: sha256 is {actual}, the policy pins {expected}", module.name)); } + if modules.insert(module.name.clone(), Module { path: wasm, sha256: expected }).is_some() { + return Err(format!("module {} is listed twice", module.name)); + } + } + Ok(modules) +} + +fn load_directories(base: &Path, listed: Vec, protected: &Protected) -> Result, String> { + let mut directories = BTreeMap::new(); + for dir in listed { + if !valid_name(&dir.name) { return Err(format!("directory name '{}' must be 1-64 of [A-Za-z0-9._-]", dir.name)); } + let host = std::fs::canonicalize(base.join(&dir.host)).map_err(|e| format!("directory {}: {}: {e}", dir.name, dir.host))?; + if !host.is_dir() { return Err(format!("directory {}: {} is not a directory", dir.name, host.display())); } + check_grant(&dir.name, &host, dir.access, protected)?; + if directories.insert(dir.name.clone(), Directory { host, access: dir.access }).is_some() { + return Err(format!("directory {} is listed twice", dir.name)); + } + } + Ok(directories) +} + +/// A grant may not overlap the service's state, and a writable one may not +/// hold the policy or a module. +fn check_grant(name: &str, host: &Path, access: Access, protected: &Protected) -> Result<(), String> { + if let Some(state) = [protected.records, protected.workspaces].into_iter().find(|s| host.starts_with(s) || s.starts_with(host)) { + return Err(format!("directory {name} overlaps the service state directory {}", state.display())); + } + if access == Access::Read { return Ok(()); } + if protected.policy.starts_with(host) { return Err(format!("directory {name} is writable and holds the policy itself")); } + match protected.modules.iter().find(|(_, m)| m.path.starts_with(host)) { + Some((module, _)) => Err(format!("directory {name} is writable and holds module {module}")), + None => Ok(()), + } +} + +/// Fills every limit: a ceiling must be given, a default falls back to its +/// ceiling, and neither may pass what the platform accepts. +fn complete(file: &LimitsFile, ceilings: Option, section: &str) -> Result { + let pick = |value: Option, ceiling: Option, hard: u64, name: &str| -> Result { + let value = match (value, ceiling) { + (Some(v), _) => v, + (None, Some(c)) => c, + (None, None) => return Err(format!("{section}.{name} is required")), + }; + if value == 0 { return Err(format!("{section}.{name} must be greater than zero")); } + let bound = ceiling.unwrap_or(hard).min(hard); + if value > bound { return Err(format!("{section}.{name} = {value} exceeds {bound}")); } + Ok(value) + }; + Ok(Limits { + timeout_ms: pick(file.timeout_ms, ceilings.map(|c| c.timeout_ms), HARD.timeout_ms, "timeout_ms")?, + fuel: pick(file.fuel, ceilings.map(|c| c.fuel), HARD.fuel, "fuel")?, + memory_mib: pick(file.memory_mib, ceilings.map(|c| c.memory_mib), HARD.memory_mib, "memory_mib")?, + max_output_bytes: pick(file.max_output_bytes, ceilings.map(|c| c.max_output_bytes), HARD.max_output_bytes, "max_output_bytes")?, + max_input_bytes: pick(file.max_input_bytes, ceilings.map(|c| c.max_input_bytes), HARD.max_input_bytes, "max_input_bytes")?, + }) +} + +/// A state directory is created owner-only when missing and named by the +/// path the kernel resolves, never by a symlink to it. +fn state_dir(base: &Path, value: &str, field: &str) -> Result { + let path = base.join(value); + std::fs::create_dir_all(&path).map_err(|e| format!("{field}: create {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)); + } + std::fs::canonicalize(&path).map_err(|e| format!("{field}: resolve {}: {e}", path.display())) +} + +pub(super) fn valid_name(name: &str) -> bool { + !name.is_empty() && name.len() <= 64 && name != "." && name != ".." + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-') +} + +fn valid_env_name(name: &str) -> bool { + !name.is_empty() && name.len() <= 128 && !name.as_bytes()[0].is_ascii_digit() + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +pub(super) fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/native/job_service/spec.rs b/native/job_service/spec.rs new file mode 100644 index 0000000..e9b70d1 --- /dev/null +++ b/native/job_service/spec.rs @@ -0,0 +1,154 @@ +//! A job, version 1: which registered module to run, its input, the files and +//! directories it may touch, the environment it gets, and its limits. A job +//! is checked against the policy before anything is created for it; a job +//! that asks for more than the policy grants is refused, never narrowed. + +use super::*; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct JobFile { + version: u32, + module: String, + #[serde(default)] + input: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + #[serde(default)] + files: BTreeMap, + #[serde(default)] + directories: Vec, + #[serde(default = "yes")] + output: bool, + #[serde(default)] + limits: LimitsFile, +} +fn yes() -> bool { true } + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DirectoryRequest { name: String, access: Access } + +/// Why a job never ran. `code` is stable for clients; `message` is for people. +#[derive(Debug)] +pub(super) struct Refusal { pub(super) code: &'static str, pub(super) message: String } + +fn refuse(code: &'static str, message: impl Into) -> Result { + Err(Refusal { code, message: message.into() }) +} + +/// One directory the guest will see, as the worker opens it. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub(super) struct Mount { pub(super) host: PathBuf, pub(super) guest: String, pub(super) writable: bool } + +/// A job after the policy has accepted it: everything the worker needs, and +/// nothing it could use to reach further. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub(super) struct Resolved { + pub(super) module: String, + pub(super) module_path: PathBuf, + pub(super) module_sha256: String, + pub(super) input: String, + pub(super) args: Vec, + pub(super) env: Vec<(String, String)>, + pub(super) files: BTreeMap, + pub(super) directories: Vec<(String, Access)>, + pub(super) output: bool, + pub(super) limits: Limits, + pub(super) mounts: Vec, +} + +impl Resolved { + /// What the record says was granted: names and access, never values. + pub(super) fn grants(&self) -> Value { + json!({ + "input_files": self.files.keys().collect::>(), + "output": self.output, + "directories": self.directories.iter().map(|(n, a)| json!({"name": n, "access": a.name(), "guest_path": format!("/data/{n}")})).collect::>(), + "env_names": self.env.iter().map(|(n, _)| n).collect::>(), + "network": "none", + "host_functions": "wasi", + }) + } +} + +const MAX_ARGS: usize = 64; +const MAX_FILES: usize = 64; + +/// Parses and checks one job. The digest is of the canonical JSON, so the +/// same job always has the same digest whatever its whitespace or key order. +pub(super) fn resolve(policy: &Policy, body: &[u8]) -> Result<(Resolved, String), Refusal> { + if body.len() > policy.max_request_bytes { return refuse("request_too_large", format!("the job is {} bytes; the policy accepts {}", body.len(), policy.max_request_bytes)); } + let value: Value = serde_json::from_slice(body).or_else(|e| refuse("invalid_job", format!("not JSON: {e}")))?; + let digest = sha256_hex(value.to_string().as_bytes()); + let job: JobFile = serde_json::from_value(value).or_else(|e| refuse("invalid_job", e.to_string()))?; + if job.version != 1 { return refuse("invalid_job", format!("job version {} is not supported; expected 1", job.version)); } + let Some(module) = policy.modules.get(&job.module) else { return refuse("module_not_registered", format!("module '{}' is not in the policy", job.module)) }; + let limits = limits(policy, &job.limits)?; + check_inputs(policy, &job, &limits)?; + let directories = directories(policy, &job.directories)?; + let resolved = Resolved { + module: job.module.clone(), + module_path: module.path.clone(), + module_sha256: module.sha256.clone(), + input: job.input, + args: job.args, + env: job.env.into_iter().collect(), + files: job.files, + directories, + output: job.output, + limits, + mounts: Vec::new(), + }; + Ok((resolved, digest)) +} + +/// Arguments, environment names, file names and the total input size. +fn check_inputs(policy: &Policy, job: &JobFile, limits: &Limits) -> Result<(), Refusal> { + if job.args.len() > MAX_ARGS { return refuse("invalid_job", format!("at most {MAX_ARGS} arguments")); } + if let Some(name) = job.env.keys().find(|name| !policy.env_names.contains(*name)) { + return refuse("env_not_allowed", format!("environment variable {name} is not allowed by the policy")); + } + if job.files.len() > MAX_FILES { return refuse("invalid_job", format!("at most {MAX_FILES} input files")); } + if let Some(name) = job.files.keys().find(|name| !valid_name(name)) { + return refuse("invalid_file_name", format!("input file name '{name}' must be one path component of [A-Za-z0-9._-]")); + } + let input_bytes = job.input.len() + job.files.values().map(String::len).sum::() + + job.args.iter().map(String::len).sum::() + job.env.iter().map(|(k, v)| k.len() + v.len()).sum::(); + if input_bytes as u64 > limits.max_input_bytes { return refuse("input_too_large", format!("input is {input_bytes} bytes; the limit is {}", limits.max_input_bytes)); } + Ok(()) +} + +/// Each requested directory must be in the policy, at no more than the access +/// it grants, and asked for once. +fn directories(policy: &Policy, requests: &[DirectoryRequest]) -> Result, Refusal> { + let mut granted: Vec<(String, Access)> = Vec::new(); + for request in requests { + let Some(grant) = policy.directories.get(&request.name) else { return refuse("directory_not_granted", format!("directory '{}' is not in the policy", request.name)) }; + if request.access == Access::ReadWrite && grant.access == Access::Read { + return refuse("access_exceeds_grant", format!("directory '{}' is granted read-only; the job asked for read-write", request.name)); + } + if granted.iter().any(|(name, _)| name == &request.name) { return refuse("invalid_job", format!("directory '{}' is requested twice", request.name)); } + granted.push((request.name.clone(), request.access)); + } + Ok(granted) +} + +fn limits(policy: &Policy, asked: &LimitsFile) -> Result { + let pick = |value: Option, default: u64, ceiling: u64, name: &str| -> Result { + let value = value.unwrap_or(default); + if value == 0 { return refuse("invalid_job", format!("limits.{name} must be greater than zero")); } + if value > ceiling { return refuse("limit_exceeds_ceiling", format!("limits.{name} = {value} exceeds the policy ceiling {ceiling}")); } + Ok(value) + }; + let (d, c) = (policy.defaults, policy.ceilings); + Ok(Limits { + timeout_ms: pick(asked.timeout_ms, d.timeout_ms, c.timeout_ms, "timeout_ms")?, + fuel: pick(asked.fuel, d.fuel, c.fuel, "fuel")?, + memory_mib: pick(asked.memory_mib, d.memory_mib, c.memory_mib, "memory_mib")?, + max_output_bytes: pick(asked.max_output_bytes, d.max_output_bytes, c.max_output_bytes, "max_output_bytes")?, + max_input_bytes: pick(asked.max_input_bytes, d.max_input_bytes, c.max_input_bytes, "max_input_bytes")?, + }) +} diff --git a/native/job_service/supervisor.rs b/native/job_service/supervisor.rs new file mode 100644 index 0000000..77f50e0 --- /dev/null +++ b/native/job_service/supervisor.rs @@ -0,0 +1,286 @@ +//! The supervisor: turns an accepted job into a workspace and a worker +//! process, stops that process at its hard deadline or on cancel, and writes +//! the run record. It is the same code under `porta job-run` and under +//! `porta job-serve`. +//! +//! A run record is written when the run starts (`status: running`) and again +//! when it ends (`status: finished`). A service that starts and finds a record +//! still `running` marks it `interrupted`; it never runs it again. + +use super::*; +use std::process::{Child, Command, Stdio}; + +mod collect; +mod launch; +use collect::{outcome, stop_reason, stream, usage, Ended, Outputs}; +use launch::{kill_group, prepare, probe_os_sandbox, worker_command, Spawner, GRACE}; + +pub(super) struct Service { + pub(super) policy: Policy, + live: Mutex>, + changed: Condvar, + events: Mutex<()>, + shutting_down: AtomicBool, + spawner: Spawner, +} + +#[derive(Default)] +struct Live { pid: i32, stop: Option<&'static str> } + +/// Why a submission produced no running job, and the HTTP status that says so. +pub(super) struct Rejected { pub(super) status: u16, pub(super) record: Value } + +/// A refusal on its way into a record. +struct Refused<'a> { status: u16, code: &'a str, message: String, digest: Option } + +/// A worker that has started, and what its record needs when it ends. +struct Running { child: Child, record: Value, workspace: PathBuf, limits: Limits, run_id: String } + +impl Service { + pub(super) fn open(policy: Policy) -> Result, String> { + // As root, file permissions protect nothing a job could reach after a + // wasmtime escape; porta's other commands refuse root for the same reason. + if unsafe { libc::geteuid() } == 0 { return Err("refusing to run jobs as root: run porta as an ordinary user".into()); } + if policy.os_sandbox { probe_os_sandbox(&policy.workspaces)?; } + let spawner = Spawner::start()?; + let service = Arc::new(Service { policy, live: Mutex::new(HashMap::new()), changed: Condvar::new(), events: Mutex::new(()), shutting_down: AtomicBool::new(false), spawner }); + service.recover()?; + Ok(service) + } + + /// Marks every run a previous service left `running` as interrupted, and + /// removes its workspace. Nothing is re-run: a job whose effects are + /// unknown is not repeated behind the client's back. + fn recover(&self) -> Result<(), String> { + let entries = std::fs::read_dir(&self.policy.records).map_err(|e| format!("read records: {e}"))?; + let unfinished = entries.flatten().map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json")) + .filter_map(|p| read_json(&p).ok().map(|r| (p, r))) + .filter(|(_, r)| r["status"] == "running" && valid_run_id(r["run_id"].as_str().unwrap_or_default())); + for (path, mut record) in unfinished { + let run_id = record["run_id"].as_str().unwrap_or_default().to_string(); + let _ = std::fs::remove_dir_all(self.policy.workspaces.join(&run_id)); + record["status"] = json!("finished"); + record["outcome"] = json!("stopped"); + record["stop_reason"] = json!("interrupted"); + record["detail"] = json!("the service stopped before this run finished; it was not run again"); + record["finished_at"] = json!(rfc3339(SystemTime::now())); + write_atomic(&path, &pretty(&record))?; + self.event(&run_id, "interrupted", &record); + } + Ok(()) + } + + /// Checks a job and, if the policy accepts it and there is capacity, + /// starts it. Returns the record as it stands once the worker is running. + pub(super) fn submit(self: &Arc, body: &[u8]) -> Result { + let run_id = new_run_id(); + let mut record = json!({ + "record_version": 1, + "run_id": run_id, + "porta_version": util_version(), + "policy": self.policy.identity(), + "submitted_at": rfc3339(SystemTime::now()), + }); + let (mut job, digest) = self.admit(&run_id, body).map_err(|refused| self.reject(record.clone(), refused))?; + record["job_sha256"] = json!(digest); + record["module"] = json!({"name": job.module, "sha256": job.module_sha256}); + record["limits"] = json!(job.limits); + record["grants"] = job.grants(); + record["isolation"] = json!({"wasm": "wasmtime", "worker_process": true, "os_sandbox": self.policy.os_sandbox}); + let workspace = self.policy.workspaces.join(&run_id); + let child = match self.launch(&workspace, &mut job) { + Ok(child) => child, + Err(message) => { + let _ = std::fs::remove_dir_all(&workspace); + self.release(&run_id); + return Err(self.reject(record, Refused { status: 500, code: "workspace_error", message, digest: None })); + } + }; + record["status"] = json!("running"); + record["started_at"] = json!(rfc3339(SystemTime::now())); + if let Some(live) = locked(&self.live).get_mut(&run_id) { live.pid = child.id() as i32; } + let _ = write_atomic(&self.record_path(&run_id), &pretty(&record)); + self.event(&run_id, "started", &record); + let running = Running { child, record: record.clone(), workspace, limits: job.limits, run_id }; + let service = Arc::clone(self); + std::thread::spawn(move || service.supervise(running)); + Ok(record) + } + + /// The policy's verdict and a reserved slot, or why there is neither. + fn admit(&self, run_id: &str, body: &[u8]) -> Result<(Resolved, String), Refused<'static>> { + if self.shutting_down.load(Ordering::SeqCst) { + return Err(Refused { status: 503, code: "service_stopping", message: "the service is shutting down".into(), digest: None }); + } + let (job, digest) = spec::resolve(&self.policy, body).map_err(|refusal| Refused { + status: match refusal.code { "request_too_large" => 413, "invalid_job" | "invalid_file_name" => 400, _ => 403 }, + code: refusal.code, + message: refusal.message, + digest: Some(sha256_hex(body)), + })?; + let mut live = locked(&self.live); + if live.len() >= self.policy.max_concurrent { + let message = format!("{0} runs are already running; the policy allows {0}", self.policy.max_concurrent); + return Err(Refused { status: 429, code: "capacity", message, digest: Some(digest) }); + } + live.insert(run_id.to_string(), Live::default()); + Ok((job, digest)) + } + + fn launch(&self, workspace: &Path, job: &mut Resolved) -> Result { + prepare(workspace, job, &self.policy)?; + let command = worker_command(workspace, job, self.policy.os_sandbox, self.spawner.lifeline())?; + self.spawner.spawn(command) + } + + fn reject(&self, mut record: Value, refused: Refused) -> Rejected { + record["status"] = json!("finished"); + record["outcome"] = json!("refused"); + record["stop_reason"] = json!(refused.code); + record["detail"] = json!(refused.message); + record["finished_at"] = json!(rfc3339(SystemTime::now())); + if let Some(digest) = refused.digest { record["job_sha256"] = json!(digest); } + if let Some(run_id) = record["run_id"].as_str() { + let _ = write_atomic(&self.record_path(run_id), &pretty(&record)); + self.event(run_id, "refused", &record); + } + Rejected { status: refused.status, record } + } + + /// Waits for the worker, killing its process group at the hard deadline or + /// when a stop was asked for. + fn wait(&self, running: &mut Running) -> Ended { + let started = Instant::now(); + let hard = Duration::from_millis(running.limits.timeout_ms) + GRACE; + let mut killed_for = None; + let status = loop { + if let Ok(Some(status)) = running.child.try_wait() { break Some(status); } + if killed_for.is_none() { + killed_for = self.stop_asked(&running.run_id).or((started.elapsed() > hard).then_some("timeout")); + if killed_for.is_some() { kill_group(running.child.id() as i32); } + } + std::thread::sleep(Duration::from_millis(10)); + }; + // `stop` kills the group itself, so the worker can be gone before this + // loop saw why; the reason asked for still decides. + Ended { status, killed_for: killed_for.or_else(|| self.stop_asked(&running.run_id)), wall: started.elapsed() } + } + + fn stop_asked(&self, run_id: &str) -> Option<&'static str> { locked(&self.live).get(run_id).and_then(|l| l.stop) } + + /// Finishes the record and removes the workspace, whatever happened. + fn supervise(self: Arc, mut running: Running) { + let ended = self.wait(&mut running); + let Running { mut record, workspace, limits, run_id, .. } = running; + let result: Option = std::fs::read(workspace.join("result.json")).ok().and_then(|b| serde_json::from_slice(&b).ok()); + let (reason, detail) = stop_reason(&ended, result.as_ref(), &workspace, self.policy.os_sandbox); + record["status"] = json!("finished"); + record["stop_reason"] = json!(reason); + record["detail"] = json!(detail); + record["exit_code"] = json!(result.as_ref().and_then(|r| r.exit_code)); + record["usage"] = usage(result.as_ref(), ended.wall); + let kept = |name: &str| self.policy.retain_output.then(|| self.policy.records.join(&run_id).join(name)); + record["stdout"] = stream(&workspace.join("stdout"), kept("stdout"), limits.max_output_bytes); + record["stderr"] = stream(&workspace.join("stderr"), kept("stderr"), limits.max_output_bytes.min(256 * 1024)); + let outputs = Outputs::collect(&workspace.join("output"), kept("output")); + record["output_files"] = json!(outputs.files); + if reason == "exit" && outputs.total > limits.max_output_bytes { + record["stop_reason"] = json!("output_limit"); + record["detail"] = json!(format!("output files total {} bytes; the limit is {}", outputs.total, limits.max_output_bytes)); + } + record["outcome"] = json!(outcome(record["stop_reason"].as_str().unwrap_or_default(), record["exit_code"].as_i64())); + let _ = std::fs::remove_dir_all(&workspace); + record["workspace_removed"] = json!(!workspace.exists()); + record["finished_at"] = json!(rfc3339(SystemTime::now())); + let _ = write_atomic(&self.record_path(&run_id), &pretty(&record)); + self.event(&run_id, "finished", &record); + self.release(&run_id); + } + + fn release(&self, run_id: &str) { + locked(&self.live).remove(run_id); + self.changed.notify_all(); + } + + /// Asks a running job to stop; false when no such run is running. + pub(super) fn stop(&self, run_id: &str, reason: &'static str) -> bool { + let mut live = locked(&self.live); + let Some(run) = live.get_mut(run_id) else { return false }; + run.stop.get_or_insert(reason); + kill_group(run.pid); + drop(live); + if let Ok(record) = read_json(&self.record_path(run_id)) { self.event(run_id, "stop_requested", &record); } + true + } + + /// Stops every running job and waits, up to `within`, for their records. + pub(super) fn shutdown(&self, within: Duration) { + self.shutting_down.store(true, Ordering::SeqCst); + let ids: Vec = locked(&self.live).keys().cloned().collect(); + for id in &ids { self.stop(id, "service_shutdown"); } + let deadline = Instant::now() + within; + let mut live = locked(&self.live); + while !live.is_empty() && Instant::now() < deadline { + live = self.changed.wait_timeout(live, Duration::from_millis(50)).map(|(g, _)| g).unwrap_or_else(|e| e.into_inner().0); + } + } + + pub(super) fn record_path(&self, run_id: &str) -> PathBuf { self.policy.records.join(format!("{run_id}.json")) } + + /// The record, waiting up to `wait` for the run to finish. + pub(super) fn record(&self, run_id: &str, wait: Duration) -> Option { + if !valid_run_id(run_id) { return None; } + let deadline = Instant::now() + wait; + let mut live = locked(&self.live); + while live.contains_key(run_id) && Instant::now() < deadline { + live = self.changed.wait_timeout(live, deadline.saturating_duration_since(Instant::now())).map(|(g, _)| g).unwrap_or_else(|e| e.into_inner().0); + } + drop(live); + read_json(&self.record_path(run_id)).ok() + } + + pub(super) fn is_live(&self, run_id: &str) -> bool { locked(&self.live).contains_key(run_id) } + + /// Newest first: run ids begin with their UTC start time. + pub(super) fn list(&self, limit: usize) -> Vec { + let mut ids: Vec = std::fs::read_dir(&self.policy.records).into_iter().flatten().flatten() + .filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".json")).map(String::from)) + .filter(|id| valid_run_id(id)).collect(); + ids.sort_unstable_by(|a, b| b.cmp(a)); + ids.into_iter().take(limit).filter_map(|id| read_json(&self.record_path(&id)).ok()) + .map(|r| json!({"run_id": r["run_id"], "status": r["status"], "outcome": r["outcome"], "stop_reason": r["stop_reason"], "module": r["module"]["name"], "submitted_at": r["submitted_at"], "finished_at": r["finished_at"]})) + .collect() + } + + /// Removes a finished run's record and retained output. The event log + /// keeps the line saying it was removed. + pub(super) fn delete(&self, run_id: &str) -> Result<(), u16> { + if !valid_run_id(run_id) { return Err(404); } + if self.is_live(run_id) { return Err(409); } + let path = self.record_path(run_id); + let record = read_json(&path).map_err(|_| 404u16)?; + let _ = std::fs::remove_dir_all(self.policy.records.join(run_id)); + std::fs::remove_file(&path).map_err(|_| 500u16)?; + self.event(run_id, "deleted", &record); + Ok(()) + } + + /// One line per state change, appended to `events.jsonl` beside the + /// records: identifiers, outcome and reason, never inputs or outputs. + fn event(&self, run_id: &str, event: &str, record: &Value) { + use std::io::Write; + let line = json!({ + "at": rfc3339(SystemTime::now()), + "run_id": run_id, + "event": event, + "outcome": record.get("outcome"), + "stop_reason": record.get("stop_reason"), + "module": record.get("module").and_then(|m| m.get("name")), + "policy_sha256": self.policy.sha256, + }); + let _guard = locked(&self.events); + let log = std::fs::OpenOptions::new().create(true).append(true).open(self.policy.records.join("events.jsonl")); + if let Ok(mut file) = log { let _ = writeln!(file, "{line}"); } + } +} diff --git a/native/job_service/supervisor/collect.rs b/native/job_service/supervisor/collect.rs new file mode 100644 index 0000000..aa5c897 --- /dev/null +++ b/native/job_service/supervisor/collect.rs @@ -0,0 +1,142 @@ +//! Turning a finished worker into the end of its record: why it stopped, what +//! it used, what it printed and what it left in `/output`. + +use super::*; + +const MAX_OUTPUT_FILES: usize = 256; +const MAX_OUTPUT_DEPTH: usize = 8; +const INLINE_TEXT: usize = 64 * 1024; + +/// How the worker process ended, as the supervisor saw it. +pub(super) struct Ended { + pub(super) status: Option, + pub(super) killed_for: Option<&'static str>, + pub(super) wall: Duration, +} + +/// The reason and the sentence for it. A stop the supervisor asked for wins +/// over whatever the worker managed to write first. +pub(super) fn stop_reason(ended: &Ended, result: Option<&WorkerResult>, workspace: &Path, os_sandbox: bool) -> (String, String) { + match (ended.killed_for, result) { + (Some("timeout"), _) => ("timeout".into(), "the worker passed its deadline and was killed by the supervisor".into()), + (Some(reason), _) => (reason.into(), format!("stopped by the supervisor: {reason}")), + (None, Some(r)) => (r.stop_reason.clone(), r.detail.clone()), + // `porta run` exits 125 when it refuses to start the command. + (None, None) if os_sandbox && ended.status.and_then(|s| s.code()) == Some(125) => + ("sandbox_refused".into(), format!("the OS sandbox refused to start the worker: {}", log_tail(workspace))), + (None, None) => ("worker_crashed".into(), format!("the worker ended without a result ({})", describe(ended.status))), + } +} + +pub(super) fn usage(result: Option<&WorkerResult>, wall: Duration) -> Value { + json!({ + // An interrupted store does not report the fuel it burned; say so + // rather than print a number that looks exact. + "fuel_consumed": result.filter(|r| r.stop_reason != "timeout").map(|r| r.fuel_consumed), + "peak_memory_bytes": result.map(|r| r.peak_memory_bytes).unwrap_or(0), + "memory_grow_denied": result.is_some_and(|r| r.memory_denied), + "wall_ms": wall.as_millis() as u64, + }) +} + +/// How a stop reason reads as an outcome. +pub(super) fn outcome(reason: &str, exit_code: Option) -> &'static str { + match reason { + "exit" if exit_code == Some(0) => "succeeded", + "timeout" | "fuel_exhausted" | "memory_limit" | "output_limit" | "cancelled" | "service_shutdown" | "interrupted" => "stopped", + "link_refused" | "module_changed" | "sandbox_refused" => "refused", + _ => "failed", + } +} + +/// The last line porta wrote about the worker, never guest output: the +/// guest's streams are in memory pipes, not on the worker's stderr. +fn log_tail(workspace: &Path) -> String { + let text = std::fs::read_to_string(workspace.join("worker.log")).unwrap_or_default(); + text.trim().lines().last().unwrap_or("no message").chars().take(500).collect() +} + +fn describe(status: Option) -> String { + use std::os::unix::process::ExitStatusExt; + match status { + None => "unknown status".into(), + Some(status) => match status.signal() { + Some(signal) => format!("signal {signal}"), + None => format!("exit {}", status.code().unwrap_or(-1)), + }, + } +} + +/// A guest stream as the record holds it: always its size and digest, whether +/// it reached its cap, and its text only when the policy retains output. +pub(super) fn stream(path: &Path, keep: Option, cap: u64) -> Value { + let bytes = std::fs::read(path).unwrap_or_default(); + // At the cap, the guest's further writes failed; what is here is a prefix. + let mut entry = json!({"bytes": bytes.len(), "sha256": sha256_hex(&bytes), "capped": bytes.len() as u64 >= cap}); + if let Some(keep) = keep { + if let Some(parent) = keep.parent() { let _ = std::fs::create_dir_all(parent); } + let _ = std::fs::write(&keep, &bytes); + let shown = &bytes[..bytes.len().min(INLINE_TEXT)]; + entry["text"] = json!(String::from_utf8_lossy(shown)); + entry["truncated"] = json!(bytes.len() > INLINE_TEXT); + } + entry +} + +/// What the guest left in `/output`, walked without following links. +pub(super) struct Outputs { pub(super) files: Vec, pub(super) total: u64, keep: Option } + +impl Outputs { + /// Lists regular files with their size and digest. A symlink is never + /// followed — the guest made it and it could point anywhere on the host — + /// and is reported as ignored, as is anything else that is not a regular + /// file or directory. + pub(super) fn collect(root: &Path, keep: Option) -> Outputs { + let mut outputs = Outputs { files: Vec::new(), total: 0, keep }; + outputs.walk(root, "", 0); + outputs.files.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str())); + outputs + } + + fn walk(&mut self, dir: &Path, prefix: &str, depth: usize) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten().take(MAX_OUTPUT_FILES) { + if self.files.len() >= MAX_OUTPUT_FILES { return; } + let name = entry.file_name().to_string_lossy().to_string(); + let relative = if prefix.is_empty() { name } else { format!("{prefix}/{name}") }; + self.visit(&entry.path(), relative, depth); + } + } + + fn visit(&mut self, path: &Path, relative: String, depth: usize) { + let Ok(meta) = std::fs::symlink_metadata(path) else { return }; + let kind = meta.file_type(); + if kind.is_dir() { + if depth < MAX_OUTPUT_DEPTH { self.walk(path, &relative, depth + 1); } + } else if kind.is_file() { + self.file(path, relative); + } else { + let ignored = if kind.is_symlink() { "symlink" } else { "not a regular file" }; + self.files.push(json!({"path": relative, "ignored": ignored})); + } + } + + fn file(&mut self, path: &Path, relative: String) { + let Ok(bytes) = read_no_follow(path) else { return }; + self.total += bytes.len() as u64; + if let Some(keep) = &self.keep { + let target = keep.join(&relative); + if let Some(parent) = target.parent() { let _ = std::fs::create_dir_all(parent); } + let _ = std::fs::write(&target, &bytes); + } + self.files.push(json!({"path": relative, "bytes": bytes.len(), "sha256": sha256_hex(&bytes)})); + } +} + +fn read_no_follow(path: &Path) -> std::io::Result> { + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; + let mut bytes = Vec::new(); + std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(path)?.read_to_end(&mut bytes)?; + Ok(bytes) +} diff --git a/native/job_service/supervisor/launch.rs b/native/job_service/supervisor/launch.rs new file mode 100644 index 0000000..bcdf40d --- /dev/null +++ b/native/job_service/supervisor/launch.rs @@ -0,0 +1,163 @@ +//! Getting a worker running: its workspace, its command line, the thread that +//! forks it, and whether the OS sandbox around it can be applied here. + +use super::*; +use std::os::unix::io::AsRawFd; +use std::os::unix::net::UnixStream; + +/// Time the supervisor allows past the job's own deadline — for compiling the +/// module and writing the result — before it kills the worker itself. +pub(super) const GRACE: Duration = Duration::from_secs(5); + +type Request = (Command, std::sync::mpsc::Sender>); + +/// Starts workers from one thread that lives as long as the service. On Linux +/// a child's `PR_SET_PDEATHSIG` fires when the *thread* that forked it ends, +/// not the process; a worker forked from a short-lived request thread would +/// be killed as soon as that request was answered. +/// +/// It also holds the lifeline: a socket pair whose one end only this process +/// keeps (close-on-exec) and whose other end every worker inherits. When the +/// service dies, however it dies, the workers read end-of-file and stop — +/// including under `porta run`, where a worker's parent is not the service. +pub(super) struct Spawner { + requests: Mutex>, + _held: UnixStream, + inherited: UnixStream, +} + +impl Spawner { + pub(super) fn start() -> Result { + let (held, inherited) = UnixStream::pair().map_err(|e| format!("create the worker lifeline: {e}"))?; + let (requests, incoming) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + for (mut command, reply) in incoming { let _ = reply.send(command.spawn()); } + }); + Ok(Spawner { requests: Mutex::new(requests), _held: held, inherited }) + } + + pub(super) fn lifeline(&self) -> i32 { self.inherited.as_raw_fd() } + + pub(super) fn spawn(&self, command: Command) -> Result { + let stopped = || "the worker spawner has stopped".to_string(); + let (reply, answer) = std::sync::mpsc::channel(); + locked(&self.requests).send((command, reply)).map_err(|_| stopped())?; + answer.recv().map_err(|_| stopped())?.map_err(|e| format!("start worker: {e}")) + } +} + +/// The workspace: `input/` written from the job and mounted read-only, +/// `output/` empty and writable, and the job the worker will read. Granted +/// directories are mounted at `/data/`. +pub(super) fn prepare(workspace: &Path, job: &mut Resolved, policy: &Policy) -> Result<(), String> { + let input = workspace.join("input"); + let output = workspace.join("output"); + for dir in [workspace, &input, &output] { + std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + } + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(workspace, std::fs::Permissions::from_mode(0o700)); + } + for (name, content) in &job.files { + std::fs::write(input.join(name), content).map_err(|e| format!("write input {name}: {e}"))?; + } + job.mounts.push(Mount { host: input, guest: "/input".into(), writable: false }); + if job.output { job.mounts.push(Mount { host: output, guest: "/output".into(), writable: true }); } + for (name, access) in &job.directories { + let granted = &policy.directories[name]; + job.mounts.push(Mount { host: granted.host.clone(), guest: format!("/data/{name}"), writable: *access == Access::ReadWrite }); + } + write_atomic(&workspace.join("job.json"), &serde_json::to_vec(job).map_err(|e| e.to_string())?) +} + +/// `porta __job-worker ` with an empty environment, no +/// stdin, its own process group, no core dumps, a CPU-time backstop past the +/// deadline and a largest-file ceiling at the output limit; on Linux it also +/// dies with the spawner thread, which lives as long as the service. +/// +/// With the OS sandbox on, the worker runs under `porta run` itself: writes +/// only to its workspace and to the directories the job was granted +/// read-write, reads confined to those, the module's directory and porta's +/// own, and no network. That boundary does not depend on wasmtime being right. +pub(super) fn worker_command(workspace: &Path, job: &Resolved, os_sandbox: bool, lifeline: i32) -> Result { + let exe = std::env::current_exe().map_err(|e| format!("locate porta: {e}"))?; + let log = std::fs::File::create(workspace.join("worker.log")).map_err(|e| format!("worker log: {e}"))?; + let mut command = Command::new(&exe); + command.env_clear(); + if os_sandbox { + command.args(sandbox_arguments(&exe, workspace, job)?); + pass_home_and_path(&mut command); + } + command.arg("__job-worker").arg(workspace).arg(lifeline.to_string()).current_dir(workspace) + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(log); + let ceilings = [ + (libc::RLIMIT_CORE, 0), + (libc::RLIMIT_CPU, job.limits.timeout_ms / 1000 + GRACE.as_secs() + 1), + (libc::RLIMIT_FSIZE, job.limits.max_output_bytes + 1024 * 1024), + ]; + use std::os::unix::process::CommandExt; + // Between fork and exec: only async-signal-safe calls. + unsafe { + command.pre_exec(move || { + libc::setpgid(0, 0); + for (resource, value) in ceilings { + let limit = libc::rlimit { rlim_cur: value as libc::rlim_t, rlim_max: value as libc::rlim_t }; + libc::setrlimit(resource, &limit); + } + // The lifeline is close-on-exec in the service; the worker keeps it. + libc::fcntl(lifeline, libc::F_SETFD, 0); + #[cfg(target_os = "linux")] + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); + Ok(()) + }); + } + Ok(command) +} + +/// `porta run` resolves the preset's `~/` paths and finds its tools with +/// these; the guest never sees them, its environment is the job's. +fn pass_home_and_path(command: &mut Command) { + for name in ["PATH", "HOME"] { + if let Ok(value) = std::env::var(name) { command.env(name, value); } + } +} + +fn text(path: &Path) -> Result { + path.to_str().map(String::from).ok_or_else(|| format!("{} is not UTF-8", path.display())) +} + +/// `porta run -v ... --no-net --read-policy strict --`, to which the +/// caller appends the worker's own arguments. +fn sandbox_arguments(exe: &Path, workspace: &Path, job: &Resolved) -> Result, String> { + let read_only = |path: &Path| text(path).map(|p| format!("{p}:ro")); + let mut args = vec!["run".to_string(), text(exe)?, "-v".into(), text(workspace)?]; + // Under strict reads porta has to be able to read itself to start. + args.extend(["-v".into(), read_only(exe.parent().ok_or("porta has no directory")?)?]); + args.extend(["-v".into(), read_only(job.module_path.parent().ok_or("the module has no directory")?)?]); + for mount in job.mounts.iter().filter(|m| !m.host.starts_with(workspace)) { + args.extend(["-v".into(), if mount.writable { text(&mount.host)? } else { read_only(&mount.host)? }]); + } + args.extend(["--no-net", "--read-policy", "strict", "--"].map(String::from)); + Ok(args) +} + +/// Whether `porta run` can apply the worker's sandbox on this host, tried +/// once at start with the same flags a worker gets. +pub(super) fn probe_os_sandbox(workspaces: &Path) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| format!("locate porta: {e}"))?; + let install = format!("{}:ro", text(exe.parent().unwrap_or(Path::new("/")))?); + let mut command = Command::new(&exe); + command.args(["run", &text(&exe)?, "-v", &text(workspaces)?, "-v", &install, "--no-net", "--read-policy", "strict", "--", "--version"]) + .env_clear().stdin(Stdio::null()); + pass_home_and_path(&mut command); + let output = command.output().map_err(|e| format!("start porta run: {e}"))?; + if output.status.success() && String::from_utf8_lossy(&output.stdout).starts_with("porta ") { return Ok(()); } + let said = String::from_utf8_lossy(&output.stderr); + Err(format!("service.os_sandbox is \"required\" but this host cannot apply it: {}. Set service.os_sandbox = \"off\" to run jobs with the WASM boundary alone.", + said.trim().lines().last().unwrap_or("porta run failed"))) +} + +pub(super) fn kill_group(pid: i32) { + if pid > 0 { unsafe { libc::kill(-pid, libc::SIGKILL); } } +} diff --git a/native/job_service/worker.rs b/native/job_service/worker.rs new file mode 100644 index 0000000..9ee67f6 --- /dev/null +++ b/native/job_service/worker.rs @@ -0,0 +1,250 @@ +//! The worker: one process per job, started by the supervisor as +//! `porta __job-worker `. It reads the resolved job, compiles the module +//! from bytes whose digest it checks itself, runs it under fuel, a memory +//! ceiling and an epoch deadline with only WASI linked and only the granted +//! directories preopened, and writes what happened to `result.json`. +//! +//! No network is linked for a core module (WASI preview 1 has no outbound +//! sockets); a component gets WASI 0.2 with socket access and name lookup left +//! at their default, denied. No host function of porta's own is linked. + +use super::*; +use wasmtime::{Config, Engine, Linker, Module, ResourceLimiter, Store, StoreLimits, StoreLimitsBuilder, Trap}; +use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView, ResourceTable, I32Exit}; +use wasmtime_wasi::filesystem::{DirPerms, FilePerms}; +use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe}; + +/// How the guest ended, as the worker saw it. +#[derive(Serialize, Deserialize, Default, Debug)] +pub(super) struct WorkerResult { + pub(super) stop_reason: String, + pub(super) detail: String, + pub(super) exit_code: Option, + pub(super) fuel_consumed: u64, + pub(super) memory_denied: bool, + pub(super) peak_memory_bytes: u64, +} + +/// A memory limiter that remembers whether it said no, so a trap that follows +/// a refused `memory.grow` is reported as the memory ceiling, not as a crash. +struct Tracked { inner: StoreLimits, denied: bool, peak: usize } + +impl ResourceLimiter for Tracked { + fn memory_growing(&mut self, current: usize, desired: usize, maximum: Option) -> wasmtime::Result { + let allowed = self.inner.memory_growing(current, desired, maximum)?; + if allowed { self.peak = self.peak.max(desired) } else { self.denied = true } + Ok(allowed) + } + fn table_growing(&mut self, current: usize, desired: usize, maximum: Option) -> wasmtime::Result { + self.inner.table_growing(current, desired, maximum) + } + fn instances(&self) -> usize { self.inner.instances() } + fn tables(&self) -> usize { self.inner.tables() } + fn memories(&self) -> usize { self.inner.memories() } +} + +struct CoreCtx { wasi: wasmtime_wasi::p1::WasiP1Ctx, limits: Tracked } +struct ComponentCtx { wasi: WasiCtx, table: ResourceTable, limits: Tracked } +impl WasiView for ComponentCtx { + fn ctx(&mut self) -> WasiCtxView<'_> { WasiCtxView { ctx: &mut self.wasi, table: &mut self.table } } +} + +/// Entry point for `porta __job-worker `. The exit code only says whether +/// a result was written; the result itself says how the guest ended. +pub(super) fn main(dir: &Path, lifeline: Option) -> i64 { + watch_parent(lifeline); + let result = match std::fs::read(dir.join("job.json")).map_err(|e| e.to_string()) + .and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| e.to_string())) { + Ok(job) => execute(dir, &job), + Err(e) => WorkerResult { stop_reason: "worker_error".into(), detail: format!("read job: {e}"), ..Default::default() }, + }; + match write_atomic(&dir.join("result.json"), &serde_json::to_vec(&result).unwrap_or_default()) { + Ok(()) => 0, + Err(_) => 1, + } +} + +/// A worker whose supervisor is gone has no one to report to and nothing to +/// stop it but its own deadline; it ends itself instead. The lifeline socket +/// reads end-of-file once the service has exited, whoever stands between the +/// two; where it did not arrive, a change of parent is the sign. +fn watch_parent(lifeline: Option) { + use std::io::Read; + use std::os::unix::io::FromRawFd; + if let Some(fd) = lifeline { + // The supervisor put this descriptor here for this purpose alone. + let mut socket = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) }; + std::thread::spawn(move || { + let mut byte = [0u8; 1]; + loop { + match socket.read(&mut byte) { + Ok(0) => std::process::exit(137), + Ok(_) => continue, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + // Not a socket we were given: fall back to the parent check. + Err(_) => return, + } + } + }); + } + let parent = std::os::unix::process::parent_id(); + std::thread::spawn(move || loop { + std::thread::sleep(Duration::from_millis(200)); + if std::os::unix::process::parent_id() != parent { std::process::exit(137); } + }); +} + +/// What one run needs from the job, prepared once for either kind of module. +struct Setup<'a> { engine: Engine, bytes: &'a [u8], wasi: WasiCtxBuilder, limits: Tracked, fuel: u64 } + +fn execute(dir: &Path, job: &Resolved) -> WorkerResult { + let fail = |reason: &str, detail: String| WorkerResult { stop_reason: reason.into(), detail, ..Default::default() }; + // The bytes compiled are the bytes hashed: read once, check, compile those. + let bytes = match std::fs::read(&job.module_path) { Ok(b) => b, Err(e) => return fail("module_unavailable", e.to_string()) }; + let actual = sha256_hex(&bytes); + if actual != job.module_sha256 { return fail("module_changed", format!("module sha256 is {actual}, the policy pins {}", job.module_sha256)); } + let cap = job.limits.max_output_bytes as usize; + let stdout = MemoryOutputPipe::new(cap); + let stderr = MemoryOutputPipe::new(cap.min(256 * 1024)); + let setup = match prepare(job, &bytes, &stdout, &stderr) { Ok(s) => s, Err(e) => return fail("worker_error", e) }; + let deadline = Deadline::start(&setup.engine, Duration::from_millis(job.limits.timeout_ms)); + let mut result = if is_component(&bytes) { run_component(setup) } else { run_module(setup) }; + drop(deadline); + let _ = std::fs::write(dir.join("stdout"), stdout.contents()); + let _ = std::fs::write(dir.join("stderr"), stderr.contents()); + if result.stop_reason == "trap" && result.detail.contains("write beyond capacity") { + result.stop_reason = "output_limit".into(); + } + result +} + +fn prepare<'a>(job: &Resolved, bytes: &'a [u8], stdout: &MemoryOutputPipe, stderr: &MemoryOutputPipe) -> Result, String> { + let mut config = Config::new(); + config.consume_fuel(true); + config.epoch_interruption(true); + config.wasm_component_model(true); + let engine = Engine::new(&config).map_err(|e| e.to_string())?; + let limits = Tracked { + inner: StoreLimitsBuilder::new().memory_size((job.limits.memory_mib * 1024 * 1024) as usize).instances(64).tables(64).memories(16).table_elements(1_000_000).build(), + denied: false, + peak: 0, + }; + Ok(Setup { engine, bytes, wasi: wasi_builder(job, stdout, stderr)?, limits, fuel: job.limits.fuel }) +} + +/// Bumps the engine's epoch once the wall-clock deadline passes, which traps +/// the guest at its next check; dropping it stops the timer. +struct Deadline { cancel: std::sync::mpsc::Sender<()>, timer: Option> } + +impl Deadline { + fn start(engine: &Engine, after: Duration) -> Deadline { + let engine = engine.clone(); + let (cancel, receiver) = std::sync::mpsc::channel::<()>(); + let timer = std::thread::spawn(move || { + if matches!(receiver.recv_timeout(after), Err(std::sync::mpsc::RecvTimeoutError::Timeout)) { engine.increment_epoch(); } + }); + Deadline { cancel, timer: Some(timer) } + } +} + +impl Drop for Deadline { + fn drop(&mut self) { + let _ = self.cancel.send(()); + if let Some(timer) = self.timer.take() { let _ = timer.join(); } + } +} + +fn wasi_builder(job: &Resolved, stdout: &MemoryOutputPipe, stderr: &MemoryOutputPipe) -> Result { + let mut wasi = WasiCtxBuilder::new(); + let mut argv = vec![job.module.clone()]; + argv.extend(job.args.iter().cloned()); + wasi.args(&argv); + for (name, value) in &job.env { wasi.env(name, value); } + wasi.stdin(MemoryInputPipe::new(job.input.clone().into_bytes())); + wasi.stdout(stdout.clone()); + wasi.stderr(stderr.clone()); + for mount in &job.mounts { + let (dirs, files) = if mount.writable { (DirPerms::all(), FilePerms::all()) } else { (DirPerms::READ, FilePerms::READ) }; + wasi.preopened_dir(&mount.host, &mount.guest, dirs, files).map_err(|e| format!("preopen {}: {e}", mount.guest))?; + } + Ok(wasi) +} + +fn run_module(setup: Setup) -> WorkerResult { + let Setup { engine, bytes, mut wasi, limits, fuel } = setup; + let engine = &engine; + let mut store = Store::new(engine, CoreCtx { wasi: wasi.build_p1(), limits }); + store.limiter(|ctx| &mut ctx.limits); + let _ = store.set_fuel(fuel); + store.set_epoch_deadline(1); + let module = match Module::from_binary(engine, bytes) { Ok(m) => m, Err(e) => return refused("invalid_module", &e) }; + let mut linker: Linker = Linker::new(engine); + if let Err(e) = wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |ctx: &mut CoreCtx| &mut ctx.wasi) { return refused("worker_error", &e) } + let instance = match linker.instantiate(&mut store, &module) { Ok(i) => i, Err(e) => return refused("link_refused", &e) }; + let outcome = match instance.get_typed_func::<(), ()>(&mut store, "_start") { + Ok(start) => start.call(&mut store, ()), + Err(_) => return WorkerResult { stop_reason: "invalid_module".into(), detail: "the module exports no _start".into(), ..Default::default() }, + }; + let left = store.get_fuel().unwrap_or(0); + let ctx = store.data(); + finish(outcome, fuel.saturating_sub(left), &ctx.limits) +} + +fn run_component(setup: Setup) -> WorkerResult { + use wasmtime::component::{Component, Linker}; + use wasmtime_wasi::p2::bindings::sync::Command; + let Setup { engine, bytes, mut wasi, limits, fuel } = setup; + let engine = &engine; + let mut store = Store::new(engine, ComponentCtx { wasi: wasi.build(), table: ResourceTable::new(), limits }); + store.limiter(|ctx| &mut ctx.limits); + let _ = store.set_fuel(fuel); + store.set_epoch_deadline(1); + let component = match Component::from_binary(engine, bytes) { Ok(c) => c, Err(e) => return refused("invalid_module", &e) }; + let mut linker: Linker = Linker::new(engine); + if let Err(e) = wasmtime_wasi::p2::add_to_linker_sync(&mut linker) { return refused("worker_error", &e) } + let command = match Command::instantiate(&mut store, &component, &linker) { Ok(c) => c, Err(e) => return refused("link_refused", &e) }; + let outcome = match command.wasi_cli_run().call_run(&mut store) { + Ok(Ok(())) => Ok(()), + Ok(Err(())) => Err(wasmtime::Error::new(I32Exit(1))), + Err(e) => Err(e), + }; + let left = store.get_fuel().unwrap_or(0); + finish(outcome, fuel.saturating_sub(left), &store.data().limits) +} + +fn refused(reason: &str, error: &wasmtime::Error) -> WorkerResult { + WorkerResult { stop_reason: reason.into(), detail: bounded(format!("{error:#}")), ..Default::default() } +} + +/// Names the reason a run ended. A trap after a refused memory grow is the +/// memory ceiling; fuel and the epoch deadline have their own trap codes. +fn finish(outcome: wasmtime::Result<()>, fuel_consumed: u64, limits: &Tracked) -> WorkerResult { + let mut result = WorkerResult { fuel_consumed, memory_denied: limits.denied, peak_memory_bytes: limits.peak as u64, ..Default::default() }; + match outcome { + Ok(()) => { result.stop_reason = "exit".into(); result.exit_code = Some(0); } + Err(error) => { + if let Some(exit) = error.downcast_ref::() { + result.exit_code = Some(exit.0); + result.stop_reason = if exit.0 == 0 { "exit" } else if limits.denied { "memory_limit" } else { "exit_nonzero" }.into(); + } else { + result.stop_reason = match error.downcast_ref::() { + Some(Trap::OutOfFuel) => "fuel_exhausted", + Some(Trap::Interrupt) => "timeout", + _ if limits.denied => "memory_limit", + _ => "trap", + }.into(); + } + result.detail = bounded(format!("{error:#}")); + } + } + result +} + +/// A guest controls its trap messages through its names and its output; keep +/// what reaches the record short. +fn bounded(text: String) -> String { + if text.len() <= 2048 { return text; } + let mut end = 2048; + while !text.is_char_boundary(end) { end -= 1; } + format!("{}…", &text[..end]) +} diff --git a/native/wasmtime_bridge.rs b/native/wasmtime_bridge.rs index 2dd6e4c..fd1b3df 100644 --- a/native/wasmtime_bridge.rs +++ b/native/wasmtime_bridge.rs @@ -55,6 +55,7 @@ pub use crate::sandbox_profile::wt_sandbox_profile; pub use crate::http_client::wt_http_request; pub use crate::host_process::{wt_exec_command, wt_getpid, wt_home_dir, wt_kill, wt_spawn}; pub use crate::wasm_inspect::wt_inspect; +pub use crate::job_service::{job_check, job_run, job_serve, job_worker}; mod run; pub use run::wt_run; diff --git a/scripts/job_integration.py b/scripts/job_integration.py new file mode 100644 index 0000000..d03d97b --- /dev/null +++ b/scripts/job_integration.py @@ -0,0 +1,216 @@ +"""Jobs: policy, worker, records and the HTTP API, against the real binary. + +Every case runs the committed sample module or a WASM fixture assembled here, +through `porta job-run` or `porta job-serve`, and checks the record and the +host afterwards: what was granted worked, what was not was refused or denied, +limits stopped the run with their own reason, and nothing was left behind. + + python3 scripts/job_integration.py target/porta +""" +import hashlib +import json +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from wasm_fixtures import no_environment_guest, section, string, uleb # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +SAMPLE = ROOT / 'examples' / 'enterprise' / 'sample-job' / 'sample-job.wasm' +TOKEN = 'evaluation-token-0123456789' + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def host_import_module(): + """A module that imports a host function porta never provides.""" + types = b'\x01\x60\x00\x00' + imports = b'\x01' + string('porta') + string('exec_command') + b'\x00\x00' + exports = b'\x01' + string('_start') + b'\x00\x01' + body = b'\x02\x00\x0b' + return (b'\x00asm\x01\x00\x00\x00' + section(1, types) + section(2, imports) + + section(3, b'\x01\x00') + section(7, exports) + section(10, b'\x01' + body)) + + +def symlink_module(target): + """Creates /output/leak -> target with WASI path_symlink (fd 4 is /output).""" + types = b'\x02\x60\x05\x7f\x7f\x7f\x7f\x7f\x01\x7f\x60\x00\x00' + imports = b'\x01' + string('wasi_snapshot_preview1') + string('path_symlink') + b'\x00\x00' + exports = b'\x02' + string('memory') + b'\x02\x00' + string('_start') + b'\x00\x01' + old, new = target.encode(), b'leak' + # path_symlink(old_ptr=16, old_len, fd=4, new_ptr=256, new_len); drop errno + code = (b'\x41\x10' + b'\x41' + uleb(len(old)) + b'\x41\x04' + b'\x41\x80\x02' + b'\x41' + uleb(len(new)) + + b'\x10\x00\x1a\x0b') + body = b'\x00' + code + data = (b'\x00\x41\x10\x0b' + uleb(len(old)) + old) + (b'\x00\x41\x80\x02\x0b' + uleb(len(new)) + new) + return (b'\x00asm\x01\x00\x00\x00' + section(1, types) + section(2, imports) + + section(3, b'\x01\x01') + section(5, b'\x01\x00\x01') + section(7, exports) + + section(10, b'\x01' + uleb(len(body)) + body) + section(11, b'\x02' + data)) + + +class Env: + def __init__(self, porta): + self.porta = str(Path(porta).resolve()) + self.root = Path(tempfile.mkdtemp(prefix='porta-jobs-')) + (self.root / 'modules').mkdir() + (self.root / 'catalog').mkdir() + (self.root / 'scratch').mkdir() + (self.root / 'catalog' / 'prices.csv').write_text('item,price_cents\nwidget,250\ngadget,1200\nbolt,15\n') + modules = { + 'sample-job': SAMPLE.read_bytes(), + 'host-import': host_import_module(), + 'symlink-out': symlink_module('../../../../../../../../../../etc/hosts'), + 'no-env': no_environment_guest(), + } + entries = [] + for name, data in modules.items(): + (self.root / 'modules' / f'{name}.wasm').write_bytes(data) + entries.append(f'[[modules]]\nname = "{name}"\nwasm = "modules/{name}.wasm"\nsha256 = "{sha(data)}"\n') + self.policy = self.root / 'policy.toml' + self.policy.write_text('\n'.join([ + 'version = 1', 'label = "integration"', 'env_names = ["REPORT_CURRENCY"]', '', + '[service]', 'records = "state/records"', 'workspaces = "state/workspaces"', + 'max_concurrent = 2', 'retain_output = true', 'max_request_bytes = 65536', '', + '[limits]', 'timeout_ms = 15000', 'fuel = 100000000000', 'memory_mib = 64', + 'max_output_bytes = 1048576', 'max_input_bytes = 32768', '', + '[defaults]', 'timeout_ms = 3000', 'fuel = 1000000000', 'memory_mib = 32', + 'max_output_bytes = 65536', 'max_input_bytes = 32768', '', + *entries, + '[[directories]]', 'name = "catalog"', 'host = "catalog"', 'access = "read"', '', + '[[directories]]', 'name = "scratch"', 'host = "scratch"', 'access = "read-write"', '', + ])) + self.records = self.root / 'state' / 'records' + self.workspaces = self.root / 'state' / 'workspaces' + + def job(self, **fields): + job = {'version': 1, 'module': 'sample-job'} + job.update(fields) + return job + + def run(self, job): + path = self.root / 'job.json' + path.write_text(json.dumps(job) if not isinstance(job, str) else job) + done = subprocess.run([self.porta, 'job-run', '--policy', str(self.policy), str(path)], + capture_output=True, text=True, timeout=60) + assert done.stdout.strip(), done + return done.returncode, json.loads(done.stdout) + + def events(self): + return [json.loads(line) for line in (self.records / 'events.jsonl').read_text().splitlines()] + + +def workers_running(settle=2.0): + """Worker processes still alive after up to `settle` seconds of teardown.""" + deadline = time.time() + settle + while True: + listing = subprocess.run(['ps', '-axo', 'pid=,command='], capture_output=True, text=True).stdout + found = [line for line in listing.splitlines() + if len(line.split()) > 2 and line.split()[1].endswith('porta') and '__job-worker' in line.split()[2:]] + if not found or time.time() > deadline: + return found + time.sleep(0.1) + + +def check(condition, message, detail=None): + if not condition: + raise AssertionError(f'{message}: {json.dumps(detail, indent=2) if detail is not None else ""}') + print(f'ok {message}') + + +REPORT_ORDERS = 'item,quantity\nwidget,4\ngadget,1\nbolt,20\n' + + +def one_shot(env): + code, record = env.run(env.job(args=['report'], files={'orders.csv': REPORT_ORDERS}, + directories=[{'name': 'catalog', 'access': 'read'}])) + check(code == 0 and record['outcome'] == 'succeeded' and record['stop_reason'] == 'exit', 'a granted job succeeds', record) + check(record['stdout']['text'] == 'report: 3 orders, total 2500 cents\n', 'its result is the expected one', record['stdout']) + report = env.records / record['run_id'] / 'output' / 'report.json' + check(json.loads(report.read_text()) == {'orders': 3, 'total_cents': 2500}, 'its output file is retained with the record') + check(record['output_files'][0]['sha256'] == sha(report.read_bytes()), 'the record carries the output digest') + check(record['policy']['label'] == 'integration' and record['policy']['sha256'] == sha(env.policy.read_bytes()), + 'the record names the policy version and digest', record['policy']) + check(record['module']['sha256'] == sha(SAMPLE.read_bytes()), 'the record names the module digest') + for key in ('run_id', 'submitted_at', 'started_at', 'finished_at', 'job_sha256', 'limits', 'grants', 'usage'): + check(key in record, f'the record has {key}') + check(record['workspace_removed'] and not (env.workspaces / record['run_id']).exists(), 'the workspace is removed') + events = [e['event'] for e in env.events() if e['run_id'] == record['run_id']] + check(events == ['started', 'finished'], 'events.jsonl has the start and the end', events) + check(REPORT_ORDERS not in (env.records / 'events.jsonl').read_text(), 'the event log holds no job input') + + code, record = env.run(env.job(args=['probe'], directories=[{'name': 'catalog', 'access': 'read'}])) + lines = record['stdout']['text'].splitlines() + check(code == 0 and len(lines) == 6 and all(line.endswith(': denied') for line in lines), + 'every ungranted read and write is denied inside the sandbox', lines) + check(sorted(p.name for p in (env.root / 'catalog').iterdir()) == ['prices.csv'], 'the read-only directory is unchanged') + + code, record = env.run(env.job(args=['spin'], limits={'timeout_ms': 1500, 'fuel': 100000000000})) + check(code == 1 and record['outcome'] == 'stopped' and record['stop_reason'] == 'timeout', 'an infinite loop stops at the deadline', record) + check(1400 <= record['usage']['wall_ms'] < 6000, 'it stops near the deadline', record['usage']) + check(not workers_running(), 'no worker process is left', workers_running()) + check(not any(env.workspaces.iterdir()), 'no workspace is left after a stopped run') + + code, record = env.run(env.job(args=['spin'], limits={'fuel': 20000000})) + check(record['stop_reason'] == 'fuel_exhausted' and record['usage']['fuel_consumed'] == 20000000, 'fuel exhaustion stops the run', record) + code, record = env.run(env.job(args=['grow'], limits={'memory_mib': 16})) + check(record['stop_reason'] == 'memory_limit' and record['usage']['memory_grow_denied'], 'the memory ceiling stops the run', record) + check(record['usage']['peak_memory_bytes'] <= 16 * 1024 * 1024, 'linear memory never passed the ceiling', record['usage']) + code, record = env.run(env.job(args=['flood'], limits={'max_output_bytes': 4096})) + check(record['stdout']['bytes'] == 4096 and record['stdout']['capped'] and record['outcome'] == 'stopped', + 'output stops at its cap', record['stdout'] | {'reason': record['stop_reason']}) + + refusals = [ + (env.job(module='nope'), 'module_not_registered'), + (env.job(directories=[{'name': 'catalog', 'access': 'read-write'}]), 'access_exceeds_grant'), + (env.job(directories=[{'name': 'home', 'access': 'read'}]), 'directory_not_granted'), + (env.job(limits={'timeout_ms': 999999}), 'limit_exceeds_ceiling'), + (env.job(env={'HOME': '/root'}), 'env_not_allowed'), + (env.job(files={'../escape': 'x'}), 'invalid_file_name'), + (env.job(network=True), 'invalid_job'), + (env.job(input='x' * 40000), 'input_too_large'), + ('{"version": 1, "module": ', 'invalid_job'), + ] + for job, reason in refusals: + code, record = env.run(job) + check(code == 2 and record['outcome'] == 'refused' and record['stop_reason'] == reason, + f'a job asking beyond the policy is refused: {reason}', record) + check((env.records / f"{record['run_id']}.json").exists(), f'the refusal is recorded: {reason}') + code, record = env.run(env.job(args=['report'], env={'REPORT_CURRENCY': 'JPY'}, files={'orders.csv': REPORT_ORDERS}, + directories=[{'name': 'catalog', 'access': 'read'}])) + check(code == 0 and record['grants']['env_names'] == ['REPORT_CURRENCY'], 'a granted environment name is accepted') + check('JPY' not in json.dumps(record), 'environment values stay out of the record') + + code, record = env.run(env.job(module='no-env')) + check(code == 0 and record['outcome'] == 'succeeded', 'a module sees no environment variable of the host', record) + code, record = env.run(env.job(module='no-env', env={'REPORT_CURRENCY': 'JPY'})) + check(record['outcome'] == 'failed' and record['stop_reason'] == 'trap', 'and sees exactly the variables its job set', record) + + code, record = env.run(env.job(module='host-import')) + check(record['outcome'] == 'refused' and record['stop_reason'] == 'link_refused', 'a module importing a host function porta does not grant never starts', record) + code, record = env.run(env.job(module='symlink-out')) + leak = [f for f in record['output_files'] if f['path'] == 'leak'] + check(leak and leak[0].get('ignored') == 'symlink' and 'sha256' not in leak[0], 'a symlink left in /output is never followed', record['output_files']) + check(not (env.records / record['run_id'] / 'output' / 'leak').exists(), 'nothing behind the symlink is retained') + + +def main(): + from job_server_checks import policy_refusals, service, startup_refusals + env = Env(sys.argv[1]) + try: + one_shot(env) + policy_refusals(env) + startup_refusals(env) + service(env) + finally: + shutil.rmtree(env.root, ignore_errors=True) + print('job integration: all checks passed') + + +if __name__ == '__main__': + main() diff --git a/scripts/job_server_checks.py b/scripts/job_server_checks.py new file mode 100644 index 0000000..dcbff52 --- /dev/null +++ b/scripts/job_server_checks.py @@ -0,0 +1,198 @@ +"""The policy and HTTP half of the job suite: policies refused at load, the +server's start-up refusals, the API, concurrency, capacity, cancel, deletion, +SIGTERM and restart after a kill. Run through `job_integration.py`, which +builds the environment.""" +import json +import os +import signal +import socket +import subprocess +import threading +import time +import urllib.error +import urllib.request + +from job_integration import REPORT_ORDERS, TOKEN, check, workers_running + + +class Server: + def __init__(self, env, token=TOKEN, flags=None): + """`flags` replaces the default `--listen 127.0.0.1:`.""" + self.env = env + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + self.port = probe.getsockname()[1] + environment = {'PATH': os.environ.get('PATH', ''), 'HOME': os.environ.get('HOME', '/')} + if token is not None: + environment['PORTA_JOB_TOKEN'] = token + flags = flags if flags is not None else ['--listen', f'127.0.0.1:{self.port}'] + self.process = subprocess.Popen([env.porta, 'job-serve', '--policy', str(env.policy), *flags], + env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + def ready(self): + for _ in range(100): + try: + if self.anonymous('/healthz')[0] == 200: + return self + except OSError: + time.sleep(0.05) + raise AssertionError('the server did not start: ' + (self.process.stderr.read() if self.process.poll() is not None else '')) + + def anonymous(self, path): + """A GET without the token.""" + return self.send(urllib.request.Request(f'http://127.0.0.1:{self.port}{path}')) + + def request(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(f'http://127.0.0.1:{self.port}{path}', data=data, method=method) + if data is not None: + request.add_header('Content-Type', 'application/json') + request.add_header('Authorization', f'Bearer {TOKEN}') + return self.send(request) + + @staticmethod + def send(request): + try: + with urllib.request.urlopen(request, timeout=60) as response: + payload = response.read() + status = response.status + except urllib.error.HTTPError as error: + payload, status = error.read(), error.code + kind = 'json' if payload[:1] in (b'{', b'[') else 'raw' + return status, (json.loads(payload) if kind == 'json' else payload) + + def stop(self, sig=signal.SIGTERM): + if self.process.poll() is None: + self.process.send_signal(sig) + self.process.wait(timeout=20) + return self.process.returncode + + +def policy_refusals(env): + original = env.policy.read_text() + cases = [ + (original.replace('host = "scratch"', 'host = "modules"'), 'is writable and holds module'), + (original.replace('host = "catalog"', 'host = "state"'), 'overlaps the service state directory'), + (original.replace('timeout_ms = 3000', 'timeout_ms = 30000'), 'exceeds 15000'), + (original.replace('sha256 = "', 'sha256 = "00', 1), 'the policy pins'), + (original.replace('[service]', '[service]\nos_sandbox = "sometimes"'), 'must be "required" or "off"'), + ] + try: + for text, needle in cases: + env.policy.write_text(text) + done = subprocess.run([env.porta, 'job-check', '--policy', str(env.policy)], capture_output=True, text=True, timeout=30) + check(done.returncode != 0 and needle in done.stdout + done.stderr, f'a policy is refused at load: {needle}', done.stdout + done.stderr) + env.policy.write_text(original.replace('[service]', '[service]\nos_sandbox = "off"')) + code, record = env.run(env.job(args=['report'], files={'orders.csv': REPORT_ORDERS}, directories=[{'name': 'catalog', 'access': 'read'}])) + check(code == 0 and record['isolation']['os_sandbox'] is False, 'with os_sandbox off a job runs on the WASM boundary alone', record['isolation']) + finally: + env.policy.write_text(original) + code, record = env.run(env.job(args=['report'], files={'orders.csv': REPORT_ORDERS}, directories=[{'name': 'catalog', 'access': 'read'}])) + check(record['isolation'] == {'wasm': 'wasmtime', 'worker_process': True, 'os_sandbox': True}, 'by default every worker also runs under the OS sandbox', record['isolation']) + + +def startup_refusals(env): + for token, flags, needle in [ + (None, None, 'set PORTA_JOB_TOKEN'), + ('short', None, 'at least 16 characters'), + (None, ['--listen', '0.0.0.0:0', '--no-auth'], 'only on a loopback address'), + ]: + server = Server(env, token=token, flags=flags) + out, err = server.process.communicate(timeout=20) + check(server.process.returncode != 0 and needle in out + err, f'the server refuses to start: {needle}', out + err) + + +def service(env): + server = Server(env).ready() + try: + status, _ = server.anonymous('/v1/policy') + check(status == 401, 'the API requires the token') + status, policy = server.request('GET', '/v1/policy') + check(status == 200 and policy['label'] == 'integration' and str(env.root) not in json.dumps(policy), + 'the policy is visible without host paths', policy) + + report = env.job(args=['report'], files={'orders.csv': REPORT_ORDERS}, directories=[{'name': 'catalog', 'access': 'read'}]) + status, record = server.request('POST', '/v1/runs?wait=30', report) + check(status == 200 and record['outcome'] == 'succeeded', 'a job submitted over HTTP succeeds', record) + status, body = server.request('GET', f"/v1/runs/{record['run_id']}/output/report.json") + check(status == 200 and body == {'orders': 3, 'total_cents': 2500}, 'its output file is served', body) + status, _ = server.request('GET', f"/v1/runs/{record['run_id']}/output/../../policy.toml") + check(status == 404, 'an output path outside the record is not served') + status, record = server.request('POST', '/v1/runs', env.job(directories=[{'name': 'catalog', 'access': 'read-write'}])) + check(status == 403 and record['stop_reason'] == 'access_exceeds_grant', 'an over-broad grant is refused with 403', record) + + # Two jobs at once, each with its own input; neither sees the other's. + jobs = [env.job(args=['report'], files={'orders.csv': f'item,quantity\nwidget,{n}\n'}, + directories=[{'name': 'catalog', 'access': 'read'}]) for n in (1, 7)] + results = [None, None] + + def submit(index): + results[index] = server.request('POST', '/v1/runs?wait=30', jobs[index])[1] + threads = [threading.Thread(target=submit, args=(i,)) for i in (0, 1)] + [t.start() for t in threads] + [t.join() for t in threads] + totals = [r['stdout']['text'] for r in results] + check(totals == ['report: 1 orders, total 250 cents\n', 'report: 1 orders, total 1750 cents\n'], + 'concurrent jobs each see only their own input', totals) + check(results[0]['run_id'] != results[1]['run_id'], 'each run has its own id') + + # Capacity: two long runs fill the policy's two slots; a third is refused. + spin = env.job(args=['spin'], limits={'timeout_ms': 10000, 'fuel': 100000000000}) + first = server.request('POST', '/v1/runs', spin)[1] + second = server.request('POST', '/v1/runs', spin)[1] + status, third = server.request('POST', '/v1/runs', spin) + check(status == 429 and third['stop_reason'] == 'capacity', 'a job beyond max_concurrent is refused', third) + started = time.time() + status, _ = server.request('POST', f"/v1/runs/{first['run_id']}/cancel") + check(status == 202, 'a running job accepts cancel') + status, cancelled = server.request('GET', f"/v1/runs/{first['run_id']}?wait=10") + check(cancelled['outcome'] == 'stopped' and cancelled['stop_reason'] == 'cancelled' and time.time() - started < 5, + 'cancel stops the job promptly', cancelled) + check(not (env.workspaces / first['run_id']).exists(), 'a cancelled job leaves no workspace') + server.request('POST', f"/v1/runs/{second['run_id']}/cancel") + server.request('GET', f"/v1/runs/{second['run_id']}?wait=10") + status, _ = server.request('POST', f"/v1/runs/{first['run_id']}/cancel") + check(status == 409, 'cancelling a finished run is a conflict') + status, _ = server.request('DELETE', f"/v1/runs/{first['run_id']}") + check(status == 204 and not (env.records / f"{first['run_id']}.json").exists(), 'a finished run can be deleted') + check(any(e['event'] == 'deleted' and e['run_id'] == first['run_id'] for e in env.events()), 'the deletion is in the event log') + status, listing = server.request('GET', '/v1/runs?limit=5') + check(status == 200 and len(listing['runs']) == 5, 'runs are listed newest first') + + # Headers only: the refusal must come before the body is read. + with socket.create_connection(('127.0.0.1', server.port), timeout=10) as raw: + raw.sendall(f'POST /v1/runs HTTP/1.1\r\nAuthorization: Bearer {TOKEN}\r\nContent-Length: 70000\r\n\r\n'.encode()) + reply = raw.recv(4096).decode() + check(reply.startswith('HTTP/1.1 413'), 'an oversized request is refused before it is read', reply) + + # The module changes on disk after the policy pinned it. + module = env.root / 'modules' / 'sample-job.wasm' + original = module.read_bytes() + module.write_bytes(original + b'\x00') + status, record = server.request('POST', '/v1/runs?wait=30', env.job(args=['report'])) + module.write_bytes(original) + check(record['outcome'] == 'refused' and record['stop_reason'] == 'module_changed', 'a module changed after pinning never runs', record) + + # SIGTERM: the running job ends as service_shutdown and the server exits. + running = server.request('POST', '/v1/runs', spin)[1] + time.sleep(0.3) + code = server.stop() + record = json.loads((env.records / f"{running['run_id']}.json").read_text()) + check(code == 0 and record['stop_reason'] == 'service_shutdown', 'SIGTERM stops running jobs and records why', record) + finally: + server.stop(signal.SIGKILL) + + # A server killed outright: its worker dies with it, and the next start + # marks the run interrupted without running it again. + server = Server(env).ready() + running = server.request('POST', '/v1/runs', spin)[1] + time.sleep(0.3) + server.stop(signal.SIGKILL) + check(not workers_running(settle=5), 'a worker does not outlive a killed server', workers_running(0)) + server = Server(env).ready() + try: + status, record = server.request('GET', f"/v1/runs/{running['run_id']}") + check(record['status'] == 'finished' and record['stop_reason'] == 'interrupted', 'after a restart the run is marked interrupted', record) + check(not (env.workspaces / running['run_id']).exists(), 'and its workspace is removed') + finally: + server.stop() diff --git a/scripts/third_party_licenses.py b/scripts/third_party_licenses.py new file mode 100644 index 0000000..38b15de --- /dev/null +++ b/scripts/third_party_licenses.py @@ -0,0 +1,56 @@ +"""Writes THIRD_PARTY_LICENSES.md from `cargo metadata` of porta's generated crate. + +Almide generates the Rust crate porta is compiled from; run this against +that crate after a build, for the platforms porta ships on: + + cargo metadata --format-version 1 --locked \ + --filter-platform x86_64-unknown-linux-gnu --filter-platform aarch64-unknown-linux-gnu \ + --filter-platform aarch64-apple-darwin --manifest-path /Cargo.toml > meta.json + python3 scripts/third_party_licenses.py meta.json > THIRD_PARTY_LICENSES.md + +The list is what the resolver chose for that build; see docs/enterprise/ +distribution.md for why it can differ between builds. +""" +import collections +import json +import sys + +PERMISSIVE = {'MIT', 'Apache-2.0', 'Apache-2.0 WITH LLVM-exception', 'BSD-2-Clause', 'BSD-3-Clause', 'ISC', + 'Unicode-3.0', 'Zlib', 'MIT-0', 'Unlicense', 'BSL-1.0', 'CDLA-Permissive-2.0'} + + +def terms(expression): + cleaned = expression.replace('/', ' OR ').replace('(', ' ').replace(')', ' ') + parts = [p.strip() for p in cleaned.replace(' AND ', ' OR ').split(' OR ')] + return {p for p in parts if p} + + +def main(): + metadata = json.load(open(sys.argv[1])) + resolved = {node['id'] for node in metadata['resolve']['nodes']} + packages = sorted((p for p in metadata['packages'] if p['id'] in resolved and p['name'] != 'almide-out'), + key=lambda p: (p['name'], p['version'])) + counts = collections.Counter(p.get('license') or 'UNKNOWN' for p in packages) + unknown = [p for p in packages if not p.get('license') or not terms(p['license']) & PERMISSIVE] + print('# Third-party licenses\n') + print('Rust crates compiled into the `porta` binary, as resolved for Linux (x86-64, arm64) and macOS (arm64).') + print('Generated by `scripts/third_party_licenses.py`; each crate\'s full licence text is in its source') + print('package on crates.io. Where a crate offers a choice, porta takes it under the permissive option') + print('(MIT or Apache-2.0; BSD-3-Clause for crates also offered under GPL-2.0).\n') + print('Not compiled in, but loaded at run time on Linux: the C library (glibc, LGPL-2.1+) and OpenSSL\'s') + print('libssl/libcrypto (Apache-2.0), both dynamically linked from the host or image.') + print('Almide, the compiler porta is written in, is not part of the binary beyond its generated code.\n') + print('## Summary\n') + print('| Licence expression | Crates |\n|---|---|') + for expression, count in counts.most_common(): + print(f'| {expression} | {count} |') + print(f'\n{len(packages)} crates. Without a permissive option: {len(unknown)}' + + (' — ' + ', '.join(p['name'] for p in unknown) if unknown else '') + '.\n') + print('## Crates\n') + print('| Crate | Version | Licence | Source |\n|---|---|---|---|') + for p in packages: + print(f"| {p['name']} | {p['version']} | {p.get('license') or 'UNKNOWN'} | {p.get('repository') or ''} |") + + +if __name__ == '__main__': + main() diff --git a/src/help.almd b/src/help.almd index 0588cbe..6991fa7 100644 --- a/src/help.almd +++ b/src/help.almd @@ -149,6 +149,11 @@ fn print_help(topic: String) -> Unit = println(" init Create porta.toml") println(" up Run from porta.toml") println("") + println("Jobs (evaluation build):") + println(" job-check Show the job policy, or whether it accepts a job") + println(" job-run Run one job under a policy and print its record") + println(" job-serve Serve the job API over HTTP") + println("") println("Runtime:") println(" agent-journal Inspect a stopped journal without replay or effects") println(" agent-check Validate and inspect a team without executing it") diff --git a/src/jobs.almd b/src/jobs.almd new file mode 100644 index 0000000..e9413fc --- /dev/null +++ b/src/jobs.almd @@ -0,0 +1,111 @@ +// Jobs: an operator's policy, a client's job, and the record of each run. +// The broker is native/job_service.rs; this file is the command line. +import json +import process +import self.util + +@extern(rs, "wasmtime_bridge", "job_check") +fn check(policy: String, job: String) -> String + +@extern(rs, "wasmtime_bridge", "job_run") +fn run_one(version: String, policy: String, job: String) -> String + +@extern(rs, "wasmtime_bridge", "job_serve") +fn serve(version: String, policy: String, listen: String, no_auth: Bool) -> String + +@extern(rs, "wasmtime_bridge", "job_worker") +fn worker(dir: String, lifeline: String) -> Int + +type JobArgs = { policy: String, listen: String, no_auth: Bool, rest: List[String] } + +fn print_usage() -> Unit = { + println("porta job-check --policy [job.json]") + println(" Show the policy as a client sees it, or whether it accepts a job. Nothing runs.") + println("porta job-run --policy ") + println(" Run one job and print its record. Exit 0 succeeded, 2 refused, 1 otherwise.") + println("porta job-serve --policy [--listen 127.0.0.1:8640] [--no-auth]") + println(" Serve the job API. The bearer token is read from PORTA_JOB_TOKEN;") + println(" --no-auth is accepted only on a loopback address.") +} + +effect fn parse(args: List[String]) -> Result[JobArgs, String] = { + var policy = "" + var listen = "127.0.0.1:8640" + var no_auth = false + var rest: List[String] = [] + var problem = "" + var index = 0 + while index < list.len(args) { + let arg = list.get(args, index) ?? "" + let next = list.get(args, index + 1) ?? "" + if arg == "--policy" or arg == "--listen" then { + if next == "" or string.starts_with(next, "--") then problem = "${arg} requires a value" + else if arg == "--policy" then policy = next + else listen = next + index = index + 2 + } else if arg == "--no-auth" then { + no_auth = true + index = index + 1 + } else if string.starts_with(arg, "--") then { + problem = "unknown option: ${arg}" + index = index + 1 + } else { + rest = rest + [arg] + index = index + 1 + } + } + if problem != "" then err(problem) + else if policy == "" then err("--policy is required") + else ok({ policy: policy, listen: listen, no_auth: no_auth, rest: rest }) +} + +fn wants_help(args: List[String]) -> Bool = + list.is_empty(args) or list.any(args, (a) => a == "--help" or a == "-h") + +// A reply carrying `error` is a refusal by porta itself, before any job. +effect fn print_reply(reply: String) -> Result[Unit, String] = { + let parsed = json.parse(reply)! + let error = json.get_string(parsed, "error") ?? "" + if error != "" then err(error) else { println(reply); ok(()) } +} + +effect fn check_command(args: List[String]) -> Result[Unit, String] = + if wants_help(args) then { print_usage(); ok(()) } + else { + let parsed = parse(args)! + let reply = check(parsed.policy, list.get(parsed.rest, 0) ?? "") + print_reply(reply)! + let accepted = json.get_bool(json.parse(reply)!, "accepted") ?? true + if not accepted then process.exit(2) else () + ok(()) + } + +effect fn run_command(args: List[String]) -> Result[Unit, String] = + if wants_help(args) then { print_usage(); ok(()) } + else { + let parsed = parse(args)! + if list.len(parsed.rest) != 1 then err("expected one job file") + else { + let reply = run_one(util.version(), parsed.policy, list.get(parsed.rest, 0) ?? "") + print_reply(reply)! + let outcome = json.get_string(json.parse(reply)!, "outcome") ?? "" + if outcome == "refused" then process.exit(2) + else if outcome != "succeeded" then process.exit(1) + else () + ok(()) + } + } + +effect fn serve_command(args: List[String]) -> Result[Unit, String] = + if wants_help(args) then { print_usage(); ok(()) } + else { + let parsed = parse(args)! + let reply = serve(util.version(), parsed.policy, parsed.listen, parsed.no_auth) + if reply == "" then ok(()) else print_reply(reply) + } + +effect fn worker_command(args: List[String]) -> Result[Unit, String] = { + let code = worker(list.get(args, 0) ?? "", list.get(args, 1) ?? "") + if code != 0 then process.exit(code) else () + ok(()) +} diff --git a/src/main.almd b/src/main.almd index 4cb4965..939c539 100644 --- a/src/main.almd +++ b/src/main.almd @@ -8,6 +8,7 @@ import self.ops import self.build import self.project import self.agent +import self.jobs import self.util import self.wasm_rt @@ -87,6 +88,10 @@ effect fn main() -> Result[Unit, String] = { process.exit(125) ok(()) }, + "__job-worker" => jobs.worker_command(list.drop(args, 2)), + "job-check" => jobs.check_command(list.drop(args, 2)), + "job-run" => jobs.run_command(list.drop(args, 2)), + "job-serve" => jobs.serve_command(list.drop(args, 2)), "agent-journal" => agent.journal_info(list.drop(args, 2)), "agent-check" => agent.inspect_config(list.drop(args, 2)), "agent" => agent.command(list.drop(args, 2), "run"), From fa5249425dc0b75c8d55830c9750db34d5d0b080 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sat, 3 Oct 2026 17:07:30 +0900 Subject: [PATCH 08/11] Record the clean-clone check, and the forged-result limit of a wasmtime escape Co-Authored-By: Claude Opus 5.5 --- docs/enterprise/compatibility.md | 5 ++++- docs/enterprise/threat-model.md | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/enterprise/compatibility.md b/docs/enterprise/compatibility.md index 248e23d..01922aa 100644 --- a/docs/enterprise/compatibility.md +++ b/docs/enterprise/compatibility.md @@ -12,7 +12,10 @@ machine), **template** (a deployment file exists and has never been applied), | Linux 6.12 (Docker Desktop 29.6, linuxkit), arm64, `rust:1-trixie`, unprivileged user | `docker build --target test` | passed | — | applied (Landlock + seccomp; user namespaces refused by Docker's default profile, which porta reports and proceeds without) | | Same kernel, `porta-eval` runtime image (`debian:trixie-slim`), `--read-only` root, non-root uid 10001 | `docker build` | — | passed with `policy.toml` and with `policy-wasm-only.toml` | applied | -Also checked in the container: `docker stop` during a run → the run ends as +Also checked: a fresh `git clone` of the commit, `docker build --no-cache` +(3 minutes on this machine) and the README's run steps verbatim — all 11 +`evaluate.py` checks passed. In the container: either policy refuses to start +as root; `docker stop` during a run → the run ends as `service_shutdown`; `docker kill` during a run → after `docker start` the run is `interrupted`, its workspace removed, nothing re-run; records persist on the volume across restarts; without `PORTA_JOB_TOKEN` the image refuses to diff --git a/docs/enterprise/threat-model.md b/docs/enterprise/threat-model.md index 1625417..89a9edb 100644 --- a/docs/enterprise/threat-model.md +++ b/docs/enterprise/threat-model.md @@ -64,7 +64,7 @@ did. | T15 | Persist state into the next job | workspaces are removed after every run, including stopped ones; records are outside every mount | `workspace_removed`; tested | | T16 | Replace the module after it was approved | SHA-256 pinned in the policy, checked at load and by every worker on the bytes it compiles | `module_changed`; tested | | T17 | Make the record lie | the record is written by the supervisor, not the guest; the guest controls only its own output and trap text, which are bounded and labelled as such | design; partially tested | -| T18 | Escalate through the policy | a writable grant may not contain the policy or a module; state directories may not overlap a grant | load-time refusal; not yet a test | +| T18 | Escalate through the policy | a writable grant may not contain the policy or a module; state directories may not overlap a grant | load-time refusals (module in a writable grant, grant overlapping state); tested | | T19 | Unauthenticated API use | bearer token, constant-time compare; no-token mode only on loopback | tested | | T20 | Oversized or slow requests | header ≤ 16 KiB, body ≤ `max_request_bytes`, 30 s socket timeouts, ≤ 64 concurrent connections | oversized body → 413; tested | @@ -72,7 +72,10 @@ did. - **A wasmtime or Cranelift bug** that lets a guest escape linear memory. The OS sandbox narrows what such an escape reaches; with `os_sandbox = "off"` it - reaches whatever the service user can. + reaches whatever the service user can. Either way, such an escape runs as + the worker and can write the worker's `result.json`, so the stop reason and + usage in that run's record would be the attacker's. The supervisor's own + facts — timestamps, kills it performed, output digests — stay its own. - **Kernel bugs.** Same kernel as the host; no VM. - **Side channels** (timing, Spectre-class) between jobs on one host. - **Total disk use** by a job: `RLIMIT_FSIZE` bounds each file, not their From 561ff73cb2c2493f9c28a56354f6c4e3ea0f361b Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sat, 3 Oct 2026 17:33:15 +0900 Subject: [PATCH 09/11] Refuse to start when a state directory is not writable, instead of refusing every job Co-Authored-By: Claude Opus 5.5 --- docs/enterprise/api.md | 3 ++- examples/enterprise/evaluate.py | 6 +++--- native/job_service/policy.rs | 22 ++++++++++++++-------- scripts/job_server_checks.py | 6 ++++++ 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/enterprise/api.md b/docs/enterprise/api.md index b3bb5cc..6fa536c 100644 --- a/docs/enterprise/api.md +++ b/docs/enterprise/api.md @@ -64,7 +64,8 @@ host = "catalog" access = "read" # "read" or "read-write": the most a job may ask ``` -Load-time refusals (the service does not start): running as root; a module whose bytes do not +Load-time refusals (the service does not start): running as root; a +`records` or `workspaces` directory it cannot write; a module whose bytes do not match its `sha256`; a directory that does not exist, overlaps `records` or `workspaces`, or is writable and holds the policy file or a module; a default above its ceiling; `os_sandbox = "required"` on a host where `porta run` diff --git a/examples/enterprise/evaluate.py b/examples/enterprise/evaluate.py index bb27145..aec6d3f 100644 --- a/examples/enterprise/evaluate.py +++ b/examples/enterprise/evaluate.py @@ -28,7 +28,7 @@ def job(args, **extra): SCENARIO = [ ('granted work succeeds', job(['report'], files={'orders.csv': ORDERS}, directories=CATALOG), - lambda r: r['outcome'] == 'succeeded' and 'total 2500 cents' in r['stdout'].get('text', '')), + lambda r: r['outcome'] == 'succeeded' and 'total 2500 cents' in r.get('stdout', {}).get('text', '')), ('asking for write access the policy does not grant is refused', job(['report'], directories=[{'name': 'catalog', 'access': 'read-write'}]), lambda r: r['outcome'] == 'refused' and r['stop_reason'] == 'access_exceeds_grant'), ('asking for a directory the policy does not list is refused', job(['report'], directories=[{'name': 'home', 'access': 'read'}]), @@ -36,7 +36,7 @@ def job(args, **extra): ('asking past a limit ceiling is refused', job(['spin'], limits={'timeout_ms': 3600000}), lambda r: r['outcome'] == 'refused' and r['stop_reason'] == 'limit_exceeds_ceiling'), ('ungranted reads and writes are denied inside the sandbox', job(['probe'], directories=CATALOG), - lambda r: r['outcome'] == 'succeeded' and r['stdout'].get('text', '').count(': denied') == 6), + lambda r: r['outcome'] == 'succeeded' and r.get('stdout', {}).get('text', '').count(': denied') == 6), ('an infinite loop stops at its deadline', job(['spin'], limits={'timeout_ms': 2000, 'fuel': 100000000000}), lambda r: r['stop_reason'] == 'timeout'), ('an infinite loop stops when its fuel runs out', job(['spin'], limits={'fuel': 50000000}), @@ -44,7 +44,7 @@ def job(args, **extra): ('unbounded allocation stops at the memory ceiling', job(['grow'], limits={'memory_mib': 16}), lambda r: r['stop_reason'] == 'memory_limit'), ('unbounded output stops at its cap', job(['flood'], limits={'max_output_bytes': 4096}), - lambda r: r['stdout']['capped'] and r['outcome'] == 'stopped'), + lambda r: r.get('stdout', {}).get('capped') and r['outcome'] == 'stopped'), ] diff --git a/native/job_service/policy.rs b/native/job_service/policy.rs index 8263666..697e715 100644 --- a/native/job_service/policy.rs +++ b/native/job_service/policy.rs @@ -256,17 +256,23 @@ fn complete(file: &LimitsFile, ceilings: Option, section: &str) -> Resul }) } -/// A state directory is created owner-only when missing and named by the -/// path the kernel resolves, never by a symlink to it. +/// A state directory is created owner-only when missing, must be writable, +/// and is named by the path the kernel resolves, never by a symlink to it. fn state_dir(base: &Path, value: &str, field: &str) -> Result { let path = base.join(value); - std::fs::create_dir_all(&path).map_err(|e| format!("{field}: create {}: {e}", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)); + // Owner-only when porta creates it; an existing directory keeps the + // permissions the operator gave it. + if !path.exists() { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&path).map_err(|e| format!("{field}: create {}: {e}", path.display()))?; } - std::fs::canonicalize(&path).map_err(|e| format!("{field}: resolve {}: {e}", path.display())) + let resolved = std::fs::canonicalize(&path).map_err(|e| format!("{field}: resolve {}: {e}", path.display()))?; + // Refuse now, not on every job: a state directory the service cannot + // write (a read-only root filesystem without a volume, say) runs nothing. + let probe = resolved.join(".porta-write-check"); + std::fs::write(&probe, b"").and_then(|_| std::fs::remove_file(&probe)) + .map_err(|e| format!("{field}: {} is not writable: {e}", resolved.display()))?; + Ok(resolved) } pub(super) fn valid_name(name: &str) -> bool { diff --git a/scripts/job_server_checks.py b/scripts/job_server_checks.py index dcbff52..8b7746e 100644 --- a/scripts/job_server_checks.py +++ b/scripts/job_server_checks.py @@ -82,6 +82,12 @@ def policy_refusals(env): env.policy.write_text(text) done = subprocess.run([env.porta, 'job-check', '--policy', str(env.policy)], capture_output=True, text=True, timeout=30) check(done.returncode != 0 and needle in done.stdout + done.stderr, f'a policy is refused at load: {needle}', done.stdout + done.stderr) + env.policy.write_text(original) + env.records.chmod(0o500) + done = subprocess.run([env.porta, 'job-check', '--policy', str(env.policy)], capture_output=True, text=True, timeout=30) + env.records.chmod(0o700) + check(done.returncode != 0 and 'is not writable' in done.stdout + done.stderr, + 'a state directory the service cannot write is refused at load', done.stdout + done.stderr) env.policy.write_text(original.replace('[service]', '[service]\nos_sandbox = "off"')) code, record = env.run(env.job(args=['report'], files={'orders.csv': REPORT_ORDERS}, directories=[{'name': 'catalog', 'access': 'read'}])) check(code == 0 and record['isolation']['os_sandbox'] is False, 'with os_sandbox off a job runs on the WASM boundary alone', record['isolation']) From 8316320f9f371d755b0d8938f7bffda7555a39b4 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sat, 3 Oct 2026 17:34:34 +0900 Subject: [PATCH 10/11] Drop libssl from the evaluation image and the licence list: porta speaks rustls only Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 4 ++-- THIRD_PARTY_LICENSES.md | 42 ++++++++++++--------------------- docs/enterprise/distribution.md | 8 +++---- docs/enterprise/gaps.md | 2 +- scripts/third_party_licenses.py | 4 ++-- 5 files changed, 24 insertions(+), 36 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5580cb2..65617fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,8 @@ RUN python3 scripts/job_integration.py target/porta FROM debian:trixie-slim AS runtime ARG PORTA_UID=10001 -# libssl: porta's HTTP client links OpenSSL on Linux (see THIRD_PARTY_LICENSES.md). -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends libssl3t64 ca-certificates \ +# CA certificates for the TLS porta's own clients speak (rustls; no OpenSSL). +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* RUN useradd --system --uid ${PORTA_UID} --create-home --home-dir /var/lib/porta porta COPY --from=build /home/dev/src/target/porta /usr/local/bin/porta diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 7937930..bad6a14 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -5,22 +5,22 @@ Generated by `scripts/third_party_licenses.py`; each crate's full licence text i package on crates.io. Where a crate offers a choice, porta takes it under the permissive option (MIT or Apache-2.0; BSD-3-Clause for crates also offered under GPL-2.0). -Not compiled in, but loaded at run time on Linux: the C library (glibc, LGPL-2.1+) and OpenSSL's -libssl/libcrypto (Apache-2.0), both dynamically linked from the host or image. +Not compiled in, but loaded at run time on Linux: the C library (glibc, LGPL-2.1+), dynamically +linked from the host or image. TLS is rustls; porta links no OpenSSL. Almide, the compiler porta is written in, is not part of the binary beyond its generated code. ## Summary | Licence expression | Crates | |---|---| -| MIT OR Apache-2.0 | 137 | -| MIT | 43 | +| MIT OR Apache-2.0 | 134 | +| MIT | 40 | | Apache-2.0 WITH LLVM-exception | 36 | | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | 20 | | Unicode-3.0 | 18 | | Apache-2.0 OR MIT | 16 | -| MIT/Apache-2.0 | 12 | -| Apache-2.0 | 5 | +| MIT/Apache-2.0 | 7 | +| Apache-2.0 | 4 | | Unlicense OR MIT | 3 | | BSD-3-Clause | 3 | | Apache-2.0 OR ISC OR MIT | 2 | @@ -40,7 +40,7 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | CDLA-Permissive-2.0 | 1 | | BSD-2-Clause OR Apache-2.0 OR MIT | 1 | -312 crates. Without a permissive option: 0. +300 crates. Without a permissive option: 0. ## Crates @@ -74,9 +74,6 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | cfg_aliases | 0.2.2 | MIT | https://github.com/katharostech/cfg_aliases | | chacha20 | 0.10.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers | | cobs | 0.3.0 | MIT OR Apache-2.0 | https://github.com/jamesmunns/cobs.rs | -| core-foundation | 0.10.1 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | -| core-foundation | 0.9.4 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | -| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | | core_detect | 1.0.0 | MIT/Apache-2.0 | https://github.com/thomcc/core_detect | | cpp_demangle | 0.5.1 | MIT OR Apache-2.0 | https://github.com/gimli-rs/cpp_demangle | | cpufeatures | 0.2.17 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | @@ -101,6 +98,7 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | crypto-common | 0.1.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | | data-encoding | 2.11.1 | MIT | https://github.com/ia0/data-encoding | | debugid | 0.8.0 | Apache-2.0 | https://github.com/getsentry/rust-debugid | +| deranged | 0.5.8 | MIT OR Apache-2.0 | https://github.com/jhpratt/deranged | | digest | 0.10.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | | directories-next | 2.0.0 | MIT OR Apache-2.0 | https://github.com/xdg-rs/dirs/tree/master/directories | | dirs-sys-next | 0.1.2 | MIT OR Apache-2.0 | https://github.com/xdg-rs/dirs/tree/master/dirs-sys | @@ -119,8 +117,6 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | fluent-uri | 0.4.1 | MIT | https://github.com/yescallop/fluent-uri-rs | | fnv | 1.0.7 | Apache-2.0 / MIT | https://github.com/servo/rust-fnv | | foldhash | 0.2.0 | Zlib | https://github.com/orlp/foldhash | -| foreign-types | 0.3.2 | MIT/Apache-2.0 | https://github.com/sfackler/foreign-types | -| foreign-types-shared | 0.1.1 | MIT/Apache-2.0 | https://github.com/sfackler/foreign-types | | form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url | | fraction | 0.17.0 | MIT OR Apache-2.0 | https://github.com/dnsl48/fraction.git | | fs-set-times | 0.20.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/fs-set-times | @@ -137,7 +133,6 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | getrandom | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom | | getrandom | 0.4.3 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom | | gimli | 0.33.0 | MIT OR Apache-2.0 | https://github.com/gimli-rs/gimli | -| h2 | 0.4.19 | MIT | https://github.com/hyperium/h2 | | hashbrown | 0.16.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown | | hashbrown | 0.17.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown | | heck | 0.5.0 | MIT OR Apache-2.0 | https://github.com/withoutboats/heck | @@ -147,7 +142,6 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | httparse | 1.10.1 | MIT OR Apache-2.0 | https://github.com/seanmonstar/httparse | | hyper | 1.11.1 | MIT | https://github.com/hyperium/hyper | | hyper-rustls | 0.27.10 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls | -| hyper-tls | 0.6.0 | MIT/Apache-2.0 | https://github.com/hyperium/hyper-tls | | hyper-util | 0.1.21 | MIT | https://github.com/hyperium/hyper-util | | icu_collections | 2.3.0 | Unicode-3.0 | https://github.com/unicode-org/icu4x | | icu_locale_core | 2.3.0 | Unicode-3.0 | https://github.com/unicode-org/icu4x | @@ -186,33 +180,30 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | memchr | 2.8.3 | Unlicense OR MIT | https://github.com/BurntSushi/memchr | | memfd | 0.6.6 | MIT OR Apache-2.0 | https://github.com/lucab/memfd-rs | | micromap | 0.3.0 | MIT | https://github.com/yegor256/micromap | -| mime | 0.3.17 | MIT OR Apache-2.0 | https://github.com/hyperium/mime | | mio | 1.2.3 | MIT | https://github.com/tokio-rs/mio | | multiversion_no_op | 1.0.0 | Apache-2.0 OR MIT | https://github.com/hsivonen/multiversion_no_op | -| native-tls | 0.2.18 | MIT OR Apache-2.0 | https://github.com/rust-native-tls/rust-native-tls | | num | 0.4.3 | MIT OR Apache-2.0 | https://github.com/rust-num/num | | num-bigint | 0.4.8 | MIT OR Apache-2.0 | https://github.com/rust-num/num-bigint | | num-cmp | 0.1.0 | MIT/Apache-2.0 | https://github.com/lifthrasiir/num-cmp | | num-complex | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rust-num/num-complex | +| num-conv | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jhpratt/num-conv | | num-integer | 0.1.47 | MIT OR Apache-2.0 | https://github.com/rust-num/num-integer | | num-iter | 0.1.46 | MIT OR Apache-2.0 | https://github.com/rust-num/num-iter | | num-rational | 0.4.2 | MIT OR Apache-2.0 | https://github.com/rust-num/num-rational | | num-traits | 0.2.19 | MIT OR Apache-2.0 | https://github.com/rust-num/num-traits | | object | 0.39.1 | Apache-2.0 OR MIT | https://github.com/gimli-rs/object | | once_cell | 1.21.4 | MIT OR Apache-2.0 | https://github.com/matklad/once_cell | -| openssl | 0.10.81 | Apache-2.0 | https://github.com/rust-openssl/rust-openssl | -| openssl-macros | 0.1.1 | MIT/Apache-2.0 | | -| openssl-probe | 0.2.1 | MIT OR Apache-2.0 | https://github.com/rustls/openssl-probe | -| openssl-sys | 0.9.117 | MIT | https://github.com/rust-openssl/rust-openssl | | outref | 0.5.2 | MIT | https://github.com/Nugine/outref | | parking_lot | 0.12.5 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot | | parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot | +| pem | 3.0.6 | MIT | https://github.com/jcreekmore/pem-rs.git | | percent-encoding | 2.3.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ | | petgraph | 0.6.5 | MIT OR Apache-2.0 | https://github.com/petgraph/petgraph | | pin-project-lite | 0.2.17 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project-lite | | pkg-config | 0.3.34 | MIT OR Apache-2.0 | https://github.com/rust-lang/pkg-config-rs | | postcard | 1.1.3 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard | | potential_utf | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| powerfmt | 0.2.0 | MIT OR Apache-2.0 | https://github.com/jhpratt/powerfmt | | proc-macro2 | 1.0.107 | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro2 | | pulley-interpreter | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime/tree/main/pulley | | pulley-macros | 47.0.4 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/wasmtime/tree/main/pulley/macros | @@ -225,6 +216,7 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | rand_pcg | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rngs | | rayon | 1.12.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/rayon | | rayon-core | 1.13.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/rayon | +| rcgen | 0.13.2 | MIT OR Apache-2.0 | https://github.com/rustls/rcgen | | ref-cast | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast | | ref-cast-impl | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast | | referencing | 0.56.0 | MIT | https://github.com/Stranger6667/jsonschema | @@ -244,8 +236,6 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | rustversion | 1.0.23 | MIT OR Apache-2.0 | https://github.com/dtolnay/rustversion | | ryu | 1.0.23 | Apache-2.0 OR BSL-1.0 | https://github.com/dtolnay/ryu | | scopeguard | 1.2.0 | MIT OR Apache-2.0 | https://github.com/bluss/scopeguard | -| security-framework | 3.7.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework | -| security-framework-sys | 2.17.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework | | semver | 1.0.28 | MIT OR Apache-2.0 | https://github.com/dtolnay/semver | | serde | 1.0.229 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde | | serde_core | 1.0.229 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde | @@ -268,8 +258,6 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | syn | 3.0.6 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn | | sync_wrapper | 1.0.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper | | synstructure | 0.14.0 | MIT | https://github.com/mystor/synstructure | -| system-configuration | 0.7.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | -| system-configuration-sys | 0.6.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | | target-lexicon | 0.13.5 | Apache-2.0 WITH LLVM-exception | https://github.com/bytecodealliance/target-lexicon | | tempfile | 3.27.0 | MIT OR Apache-2.0 | https://github.com/Stebalien/tempfile | | termcolor | 1.4.1 | Unlicense OR MIT | https://github.com/BurntSushi/termcolor | @@ -277,12 +265,12 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | thiserror | 2.0.21 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | | thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | | thiserror-impl | 2.0.21 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| time | 0.3.55 | MIT OR Apache-2.0 | https://github.com/time-rs/time | +| time-core | 0.1.9 | MIT OR Apache-2.0 | https://github.com/time-rs/time | | tinystr | 0.8.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x | | tinyvec | 1.13.3 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/tinyvec | | tokio | 1.53.1 | MIT | https://github.com/tokio-rs/tokio | -| tokio-native-tls | 0.3.1 | MIT | https://github.com/tokio-rs/tls | | tokio-rustls | 0.26.6 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls | -| tokio-util | 0.7.19 | MIT | https://github.com/tokio-rs/tokio | | toml | 0.8.23 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | | toml | 0.9.12+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | | toml_datetime | 0.6.11 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | @@ -308,7 +296,6 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | utf8_iter | 1.0.4 | Apache-2.0 OR MIT | https://github.com/hsivonen/utf8_iter | | uuid | 1.27.0 | Apache-2.0 OR MIT | https://github.com/uuid-rs/uuid | | uuid-simd | 0.8.0 | MIT | https://github.com/Nugine/simd | -| vcpkg | 0.2.15 | MIT/Apache-2.0 | https://github.com/mcgoo/vcpkg-rs | | version_check | 0.9.5 | MIT/Apache-2.0 | https://github.com/SergioBenitez/version_check | | vsimd | 0.8.0 | MIT | https://github.com/Nugine/simd | | want | 0.3.1 | MIT | https://github.com/seanmonstar/want | @@ -345,6 +332,7 @@ Almide, the compiler porta is written in, is not part of the binary beyond its g | wit-parser | 0.252.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-parser | | witx | 0.9.1 | Apache-2.0 | https://github.com/WebAssembly/WASI | | writeable | 0.6.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| yasna | 0.5.2 | MIT OR Apache-2.0 | https://github.com/qnighy/yasna.rs | | yoke | 0.8.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x | | yoke-derive | 0.8.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x | | zerocopy | 0.8.59 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy | diff --git a/docs/enterprise/distribution.md b/docs/enterprise/distribution.md index 0084ca5..46ef6ba 100644 --- a/docs/enterprise/distribution.md +++ b/docs/enterprise/distribution.md @@ -5,7 +5,7 @@ | Artifact | Built by | Contains | |---|---|---| | `porta` binary | `almide build src/main.almd -o target/porta` (or the release workflow) | porta, including the job service, statically linking its Rust dependencies | -| Evaluation image | `docker build -t porta-eval .` ([`Dockerfile`](../../Dockerfile)) | `debian:trixie-slim`, libssl, CA certificates, the binary, the sample module and catalog, two policies, `LICENSE`, `THIRD_PARTY_LICENSES.md` | +| Evaluation image | `docker build -t porta-eval .` ([`Dockerfile`](../../Dockerfile)) | `debian:trixie-slim`, CA certificates, the binary, the sample module and catalog, two policies, `LICENSE`, `THIRD_PARTY_LICENSES.md` | | Sample job | `almide build examples/enterprise/sample-job/src/main.almd --target wasm` | `sample-job.wasm`, committed; its digest is pinned in both example policies | | Deployment templates | — | [`deploy/`](../../deploy/) | @@ -38,7 +38,7 @@ option to use one) and pin base images by digest. porta is Apache-2.0 ([`LICENSE`](../../LICENSE)). -[`THIRD_PARTY_LICENSES.md`](../../THIRD_PARTY_LICENSES.md) lists the 312 +[`THIRD_PARTY_LICENSES.md`](../../THIRD_PARTY_LICENSES.md) lists the 300 Rust crates in the binary for Linux (x86-64, arm64) and macOS (arm64), with their licence expressions. All are permissive or offer a permissive option: MIT, Apache-2.0 (with or without the LLVM exception), BSD-2/3-Clause, ISC, @@ -54,8 +54,8 @@ text, which a release should (`cargo about` or similar can collect them). This is item 6 in [gaps](gaps.md). This page is an engineering inventory, not legal advice. -Run-time libraries not compiled in: glibc (LGPL-2.1+) and OpenSSL 3 -(Apache-2.0), dynamically linked from the image or host. The base image's +Run-time library not compiled in: glibc (LGPL-2.1+), dynamically linked +from the image or host. TLS is rustls; porta links no OpenSSL. The base image's Debian packages carry their own copyright files under `/usr/share/doc`. Regenerate the list after a dependency change: diff --git a/docs/enterprise/gaps.md b/docs/enterprise/gaps.md index dbfbd41..0f34e9d 100644 --- a/docs/enterprise/gaps.md +++ b/docs/enterprise/gaps.md @@ -20,7 +20,7 @@ code, a test, or a measurement — not at an opinion. | # | Gap | Evidence | |---|---|---| -| 6 | Full licence texts of all 312 crates are not shipped in the image, only the list. | [distribution](distribution.md) § Licences | +| 6 | Full licence texts of all 300 crates are not shipped in the image, only the list. | [distribution](distribution.md) § Licences | | 7 | Total disk use per job is not capped (only per file). | threat model T12 | | 8 | Host memory beyond linear memory is not capped per job. | threat model, "not defended"; `memory_mib` is a store limit | | 9 | No queue: the job after `max_concurrent` is refused with 429. | `supervisor.rs` `admit` | diff --git a/scripts/third_party_licenses.py b/scripts/third_party_licenses.py index 38b15de..8425bf2 100644 --- a/scripts/third_party_licenses.py +++ b/scripts/third_party_licenses.py @@ -37,8 +37,8 @@ def main(): print('Generated by `scripts/third_party_licenses.py`; each crate\'s full licence text is in its source') print('package on crates.io. Where a crate offers a choice, porta takes it under the permissive option') print('(MIT or Apache-2.0; BSD-3-Clause for crates also offered under GPL-2.0).\n') - print('Not compiled in, but loaded at run time on Linux: the C library (glibc, LGPL-2.1+) and OpenSSL\'s') - print('libssl/libcrypto (Apache-2.0), both dynamically linked from the host or image.') + print('Not compiled in, but loaded at run time on Linux: the C library (glibc, LGPL-2.1+), dynamically') + print('linked from the host or image. TLS is rustls; porta links no OpenSSL.') print('Almide, the compiler porta is written in, is not part of the binary beyond its generated code.\n') print('## Summary\n') print('| Licence expression | Crates |\n|---|---|') From 058542e2b81d8d374d9afe5d27e7ce8f82a66f11 Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Sat, 3 Oct 2026 17:34:34 +0900 Subject: [PATCH 11/11] Release 0.6.17 Co-Authored-By: Claude Opus 5.5 --- almide.toml | 2 +- src/util.almd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/almide.toml b/almide.toml index baaacac..7c5527d 100644 --- a/almide.toml +++ b/almide.toml @@ -1,6 +1,6 @@ [package] name = "porta" -version = "0.6.16" +version = "0.6.17" [permissions] allow = ["FS.read", "FS.write", "IO", "Env", "Time", "Net"] diff --git a/src/util.almd b/src/util.almd index 223a25e..7001e56 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.16" +fn version() -> String = "0.6.17" // --- Int parsing ---