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 43242d6..6edb3da 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 @@ -96,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/.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/CLAUDE.md b/CLAUDE.md index fb46f07..2fd8a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,10 +25,12 @@ 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 | +| `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 | @@ -136,6 +138,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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..65617fb --- /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 +# 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 +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.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..957d774 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 | @@ -167,8 +167,17 @@ 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` + 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/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/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..bad6a14 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,348 @@ +# 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+), 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 | 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 | 7 | +| Apache-2.0 | 4 | +| 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 | + +300 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_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 | +| 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 | +| 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 | +| 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 | +| 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-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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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-rustls | 0.26.6 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls | +| 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 | +| 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 | +| 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 | +| 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/almide.toml b/almide.toml index e5adf37..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"] @@ -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 } @@ -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/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/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 0f8ddb2..3178b32 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 | @@ -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 @@ -80,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/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..6fa536c --- /dev/null +++ b/docs/enterprise/api.md @@ -0,0 +1,207 @@ +# 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 +`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` +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..01922aa --- /dev/null +++ b/docs/enterprise/compatibility.md @@ -0,0 +1,71 @@ +# 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: 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 +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..46ef6ba --- /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`, 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 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, +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 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: + +```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..0f34e9d --- /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 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` | +| 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..89a9edb --- /dev/null +++ b/docs/enterprise/threat-model.md @@ -0,0 +1,93 @@ +# 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 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 | + +## 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. 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 + 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/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/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..aec6d3f --- /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.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'}]), + 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.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}), + 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.get('stdout', {}).get('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 0000000..7d92190 Binary files /dev/null and b/examples/enterprise/sample-job/sample-job.wasm differ 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/credential_broker.rs b/native/credential_broker.rs new file mode 100644 index 0000000..e5a7144 --- /dev/null +++ b/native/credential_broker.rs @@ -0,0 +1,225 @@ +//! 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. +//! +//! 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_egress::mint_token; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +mod exchange; +mod tls; + +/// 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, +} + +/// 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. +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 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 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 = request_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, at: Target, request_target: &str) -> Rewrite { + let mut out = head.to_string(); + let mut used = Vec::new(); + 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); + used.push(credential.name.clone()); + } + Rewrite::Send(out, used) +} + +/// The run's credentials, its CA, and the leaf certificates signed so far. +pub(crate) struct Broker { + pub(crate) credentials: Vec, + 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, + ca_path: String, + 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, +} + +/// 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. + fn new(requested: Vec) -> Result { + let credentials = merged(requested)?; + 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, 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, 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 + /// 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.ca.server_config_for(host)?); + leaves.insert(host.to_string(), config.clone()); + Ok(config) + } +} + +/// 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) +} + +#[cfg(test)] +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 f83a4f7..84d5103 100644 --- a/native/http_proxy.rs +++ b/native/http_proxy.rs @@ -8,8 +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_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}; @@ -33,11 +36,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 +57,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) { @@ -77,25 +86,16 @@ 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 }; 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(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)) => { let _ = client_for_write.write_all(status.as_bytes()); audit_log(&audit_path, &decision); @@ -162,41 +162,23 @@ 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). -/// 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()) { - 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), }; let listener = match loopback_listener() { Ok(listener) => listener, @@ -209,28 +191,56 @@ 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 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 +/// 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,23 +281,10 @@ 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 } - - -/// 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/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..697e715 --- /dev/null +++ b/native/job_service/policy.rs @@ -0,0 +1,291 @@ +//! 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, 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); + // 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()))?; + } + 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 { + !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/proxy_egress.rs b/native/proxy_egress.rs index 2928a6d..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, @@ -79,6 +81,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 +100,66 @@ 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 } + }) +} + +/// 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/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/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&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" diff --git a/scripts/integration.py b/scripts/integration.py index 9ca2ae1..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 @@ -219,6 +307,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". 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..8b7746e --- /dev/null +++ b/scripts/job_server_checks.py @@ -0,0 +1,204 @@ +"""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) + 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']) + 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..8425bf2 --- /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+), 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|---|---|') + 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/cli.almd b/src/cli.almd index 6160fab..ea19b81 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({...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}), @@ -252,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 [] @@ -272,28 +269,29 @@ fn policy_toml(opts: Options) -> String = { let proxy_on = list.len(opts.proxy_allow) > 0 or list.len(opts.proxy_deny) > 0 or opts.proxy_audit != "" 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)) + + 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/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..6991fa7 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)") @@ -147,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"), 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..cc91386 100644 --- a/src/proxy.almd +++ b/src/proxy.almd @@ -3,12 +3,27 @@ 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 +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 +// 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 +33,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 +61,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 +71,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(()) @@ -64,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/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/util.almd b/src/util.almd index 711ec44..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 --- @@ -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), ", ") + "]" 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