diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 992ad0c2..bab6eb7e 100644 --- a/integrations/isolation/acq-kits/kits.yaml +++ b/integrations/isolation/acq-kits/kits.yaml @@ -80,6 +80,28 @@ kits: ONE port. The daemon runs unsecured (no PASEO_PASSWORD); the sandbox is the security boundary and the port is host loopback only. + oci-engine: + backends: [sbx, msb] + parity: | + Opt-in mixin that provides a rootless OCI container engine (podman) so + agents can run OCI images (docker run / docker compose). Written entirely + in the neutral vocabulary with no backend shortcut or extras: neither sbx + nor msb has a native "provide a container engine" primitive, so both apply + the identical sequence — an install-phase script + (files/home/oci-engine-install.sh) installs podman + the rootless + prerequisites at create time and drops the storage/registry config and the + docker->podman wrapper (files/home/oci-engine-config/), and a startup-phase + script (files/home/oci-engine-grant-devs.sh) re-grants /dev/net/tun + + /dev/fuse to the agent on every start (/dev is re-created each boot). The + engine runs ROOTLESS as the agent (never root); registry resolution is + Docker-Hub-first with short-name-mode=enforcing by default (least-privilege + / prompt-injection defense, ADR-0020). Publishes NO ports and needs NO + volumes. Off by default: acq applies it only when the operator opts in via + ACQ_ENABLE_OCI_KIT (ADR-0030 moved this provisioning out of the msb adapter + into this neutral kit). Egress: none declared — the OS package mirror is + already in the balanced baseline (ADR-0002); a strict tier fails the install + soft. + prime-agent: backends: [sbx, msb] parity: | diff --git a/integrations/isolation/acq-kits/oci-engine/README.md b/integrations/isolation/acq-kits/oci-engine/README.md new file mode 100644 index 00000000..9bbe34e0 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -0,0 +1,204 @@ +# oci-engine — rootless OCI container engine (podman) for acq sandboxes + +A neutral, **opt-in** acq mixin kit (`schemaVersion: "hybrid/v1"`, `kind: mixin`, +`name: oci-engine`) that gives an agent a working OCI container engine inside the +sandbox, so it can run OCI images (`docker run`, `docker build`, +`docker compose`). + +## What it installs + +At sandbox **create time** (the `install` phase, run once as root) it: + +- **Early-out on a base that already works.** If the base image already provides + a **functional** container engine (a clean `docker info`), the kit leaves it + alone and does nothing — it will not layer podman on top or shadow a working + `docker`. It probes `docker info`, not merely the presence of a `docker` + binary: many bases ship a docker CLI whose daemon socket is dead in the sandbox + (the exact case this kit exists to fix), and only a working engine short-circuits. +- Installs **podman** and the rootless prerequisites (`podman-compose`, + `fuse-overlayfs`, `uidmap`, `passt`, `slirp4netns`) via the base image's + package manager (apt / dnf / apk, auto-detected). +- Writes `/etc/containers/storage.conf` selecting a storage driver that works on + the sandbox's **overlay root** filesystem: `overlay` + + `mount_program=fuse-overlayfs` when `fuse-overlayfs` and `/dev/fuse` are + available (fast, thin on disk), otherwise a `vfs` fallback (works everywhere, + no `/dev/fuse`, but disk-heavy). +- Writes a **Docker-Hub-first** registry config + (`/etc/containers/registries.conf.d/00-acq-docker-first.conf` + + `01-acq-shortnames.conf`): unqualified image names resolve to `docker.io`, and + the podman `hello`/`hello-world` aliases map back to Docker Hub. +- Installs a `docker` -> `podman` wrapper at `/usr/local/bin/docker` (ahead of + `/usr/bin` on `PATH`), **shadowing** the base image's bundled docker CLI (which + talks to a dead socket here, since no `dockerd` runs). It never touches the base + image's `/usr/bin/docker`. + +The storage/registry config and the `docker` wrapper are **decoupled from the +package install**: they are applied whenever podman ends up present at that point +— whether this kit installed it or the base already had podman — not only when +the kit ran the package step. + +On **every start** (the `startup` phase, run as root) — **only if podman is +installed** — it group-scopes `/dev/net/tun` and `/dev/fuse` to the agent +(`root:agent`, `0660`) so rootless podman networking and the fuse-overlayfs +storage mount work, and it **re-evaluates the system storage driver**. The elevated install and startup scripts, plus the storage helper sourced by +them, are copied into `/usr/local/lib/acq/oci-engine/` and executed/sourced only +from those root-owned copies, not from the agent-home staging files. `/dev` is a devtmpfs +re-created each boot, so both must re-run on restart, not just at create; +re-evaluating the driver each boot lets it **converge from `vfs` to `overlay`** +once `fuse-overlayfs` and `/dev/fuse` are both available (a transient `/dev/fuse` +miss at create is not permanently baked in). If podman is absent (install fell +soft, or the base had its own engine and the kit early-outed) this step +best-effort revokes any stale device grant and exits — it never leaves a fresh +wide grant for an engine that does not exist. + +Also on **every start** (a second `startup` step, run as the **agent**) it runs a +rootless self-test — a real `podman build` FROM scratch, which opens `/dev/fuse` +and mounts a layer (stronger than `podman info`, which does not). The build is +bounded by `timeout` (`OCI_ENGINE_SELFTEST_TIMEOUT`, positive integer, default +120s) so a wedged mount cannot hang the boot. If `timeout` is unavailable, the +script skips the self-test/fallback path rather than running an unbounded +boot-path build. If the +bounded self-test fails, it writes a **user-level +`~/.config/containers/storage.conf`** selecting the `vfs` driver and retries. This +is the documented recovery for a base whose overlay+fuse-overlayfs combo is +rejected under rootless (where `podman info` passes but a layer mount fails). It +is idempotent (only writes on a failed build when no user driver is already set) +and fails soft (a failed self-test never aborts the boot). + +Every step is **idempotent** and **best-effort**: a missing package mirror or an +unsupported base image produces a clear warning and leaves provision to continue +(OCI is simply unavailable) rather than aborting the sandbox. In particular the +`install` step **exits 0 even when the package install fails** — a non-zero +`install`-phase exit would fail `sbx create` (a dead sandbox), the opposite of +the promised fail-soft behavior, so the package-manager step is caught and falls +through to a single warn-and-exit-0 re-check. So that a fail-soft miss is not +*silent*, a genuinely-configured sandbox records a **success marker** at +`/var/lib/acq/oci-engine-ready` (`state=configured`, or `state=base-engine` for +the functional-engine early-out); the fail-soft paths deliberately do not write +it, so an operator, CI, or `scripts/verify` can tell a provisioned sandbox from +one where OCI setup fell soft. See `docs/decisions/`. + +## Rootless / security posture (ADR-0020) + +This kit reproduces the design of the isolation **ADR-0020 ("rootless podman OCI +engine")** decision, declaratively: + +- **Rootless.** The engine runs as the **agent user, never root**. Only the + install/config step and the device-node grant need root; the containers + themselves are unprivileged (defense-in-depth, aligned container/host UIDs). +- **No docker-in-docker daemon.** podman is daemonless — it forks `runc`/`crun` + per invocation. There is no Docker socket and no supervisor process to start or + keep alive across restarts. +- **Explicit, minimal device handling.** Exactly two device nodes are exposed, + and they are **group-scoped `0660` to the agent** — not world-writable + (`0666`). The startup script is podman-gated and best-effort reverts stale + grants if podman disappears. No new host attack surface is opened: this is all + inside the sandbox, which is itself the security boundary. +- **Trusted elevated code.** Files are staged in the agent home by the neutral + kit mechanism, but root phases do not execute or source those mutable copies. + The root command wrappers publish the install script, startup script, and sourced + helper into `/usr/local/lib/acq/oci-engine/` and use only those root-owned files. +- **Least-privilege registry resolution.** `short-name-mode` defaults to + **`enforcing`**, not `permissive`. Because there is a single search registry + (`docker.io`), unqualified names still resolve deterministically to Docker Hub + (migration ergonomics preserved), but an interactively-ambiguous short name + **fails closed** instead of being silently substituted. This is the + prompt-injection / image-substitution (typosquatting) defense a federal sandbox + wants: an injected `docker run ` cannot be silently resolved to an + attacker's image without a fully-qualified name or an explicit alias. An + operator MAY opt into the weaker `permissive` via `OCI_ENGINE_SHORT_NAME_MODE`, + which explicitly removes that guardrail. + +Nothing in this kit is a secret. podman needs no credentials to run rootless. + +## Network egress + +Two distinct egress needs, handled differently on purpose: + +- **Container registry (declared).** The kit's *purpose* — pulling images at + runtime (`docker run` / `docker compose`) — needs Docker Hub, which is *not* + distro-dependent. The kit declares the Docker Hub hosts explicitly in + `caps.network.allow` (`registry-1.docker.io`, `auth.docker.io`, + `index.docker.io`, and the `production.cloudflare.docker.com` / + `production.cloudfront.docker.com` blob CDNs), so pulls work even on a `strict` + tier that carries no baseline. deny-by-default is preserved (it is an + allow-list). +- **OS package mirror (left to the baseline).** The create-time install pulls + podman from the distro package mirror, whose host *is* distro-dependent + (`archive/ports/security.ubuntu.com`, `*.debian.org`, + `dl-cdn.alpinelinux.org`, …). Under acq's default **balanced** tier (isolation + ADR-0002; see `integrations/isolation/network-tiers/balanced.yaml`) those hosts + are already in the curated `core` baseline, so hardcoding one distro's mirror + here would be wrong for the others and redundant. With `ACQ_NETWORK_TIER=strict` + or a narrowed base the mirror is unreachable and the install fails soft + (recorded by the absence of the success marker); the operator then widens + egress or bakes podman into the base image. + +## Opt-in + +This capability is **off by default**. Per **ADR-0030** the OCI-engine +provisioning was moved out of the msb backend adapter into this neutral kit so it +is a deliberate, auditable opt-in rather than always-on behavior. acq selects +this kit only when the operator sets **`ACQ_ENABLE_OCI_KIT`** on the quickstart +side, which fetches and validates this kit at the pinned patterns ref (requiring +exactly `schemaVersion: "hybrid/v1"`, `kind: mixin`, `name: oci-engine`). + +## Backend parity + +Written entirely in the neutral hybrid/v1 vocabulary (`caps` / `files` / +`commands` / `environment`) with **no backend shortcut or extras**. Neither `sbx` +nor `msb` has a native primitive for "provide a rootless container engine", so +both apply the identical file-drop + install + device-grant sequence through +acq's kit translation. Behavioral parity: the guest ends up with a working +rootless podman (and `docker` aliased to it) either way. + +## Verifying (host-side live check) + +`scripts/verify` creates a throwaway **msb** sandbox with this kit applied +**through acq** (`acq --backend msb create shell … --kit …`) and confirms rootless +podman actually works: podman reports `rootless=true`, a real `podman build` from +`scratch` mounts a layer as the agent, and the `docker` -> `podman` wrapper this +kit drops resolves and routes to podman. + +msb-only, on purpose: msb is acq's default backend and (per ADR-0020) the backend +that demonstrates the OCI-engine behavior; sbx has its own base-image selection +logic that is out of scope here. + +The check disables the msb adapter's **legacy OCI auto-install** +(`ACQ_MSB_ENSURE_OCI=0`) for its run. That auto-install is the pre-kit path +ADR-0030 is retiring; leaving it on would install podman regardless of whether +this kit did, masking a broken kit with a false pass. With it off, podman — and +especially the `docker` wrapper at `/usr/local/bin/docker` — is attributable to +this kit. Once the adapter's auto-install is removed, that setting becomes a +harmless no-op and this script is the standing regression test that the kit still +provisions podman on its own. + +For the same attribution reason it pins an **engine-free base image** +(`VERIFY_OCI_IMAGE`, default `docker/sandbox-templates:shell`) via acq's +`--image`. Per the Docker sandbox-templates docs, the msb *default* base +(`…:shell-docker`, a `*-docker` variant) runs a full Docker Engine (`dockerd`) +inside the sandbox — so `docker info` succeeds there, the kit correctly +early-outs (it will not shadow a working engine), and no podman is installed: +right behavior, but it proves nothing about this kit. The non-`-docker` `shell` +template ships the Docker *CLI* but no engine (a dead socket) — the exact case +this kit fixes, which does not early-out — so on it the kit is the only thing +that can produce a working rootless podman. It installs podman from the OS +package mirror at create, so the base must reach that mirror under the active +egress tier (the balanced baseline, ADR-0002, allows the common Debian/Ubuntu +mirrors). Override with `VERIFY_OCI_IMAGE=` (do NOT use a +`*-docker` variant — its live engine would trigger the early-out). + +It needs a sandbox-capable host with `acq` on `PATH` and a ready msb backend +(`msb doctor`), and is **skipped in CI** (CI cannot nest sandboxes), like the +`zscaler-ca-certificate` kit's verify. It cleans up the temporary sandbox on +exit/interrupt; set `KEEP=1` to retain it for inspection. + +```sh +integrations/isolation/acq-kits/oci-engine/scripts/verify +KEEP=1 integrations/isolation/acq-kits/oci-engine/scripts/verify # keep the sandbox +VERIFY_OCI_IMAGE=docker.io/library/ubuntu:24.04 \ + integrations/isolation/acq-kits/oci-engine/scripts/verify # different base +``` + +The backend-agnostic gate (schema + cross-field rules) is +`integrations/isolation/acq-kits/validate-kits.py` (`make validate-kits`). diff --git a/integrations/isolation/acq-kits/oci-engine/docs/decisions/port-oci-engine-failsoft-and-vfs-fallback.md b/integrations/isolation/acq-kits/oci-engine/docs/decisions/port-oci-engine-failsoft-and-vfs-fallback.md new file mode 100644 index 00000000..4c3899a2 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/docs/decisions/port-oci-engine-failsoft-and-vfs-fallback.md @@ -0,0 +1,161 @@ +# Decision: port the msb-adapter OCI engine into a neutral kit, fail-soft install, and a rootless vfs fallback + +**Status:** accepted + +## TL;DR + +The `oci-engine` kit provides a rootless podman OCI engine as a neutral, opt-in +acq mixin. Three decisions are worth recording: + +1. **Where the logic lives (ADR-0030).** The podman provisioning used to live in + the msb backend adapter (sibling `agentic-coding-quickstart` repo). It is + ported here into the neutral `hybrid/v1` vocabulary so every acq backend + applies it the same way, and so it is **off by default** — acq selects this + kit only when the operator opts in via `ACQ_ENABLE_OCI_KIT`. + +2. **The `install` step must fail SOFT.** A non-zero `commands.install` exit + **fails `sbx create`** (a dead sandbox) — the same lifecycle fact the sibling + `zscaler-ca-certificate` kit documents in + [`ship-ca-in-files-install-at-create.md`](../../zscaler-ca-certificate/docs/decisions/ship-ca-in-files-install-at-create.md) + and its superseding note. Our promise is the opposite: an unreachable package + mirror (e.g. `ACQ_NETWORK_TIER=strict`) or an unsupported base must leave the + sandbox usable, just without OCI support. + +3. **A rootless vfs fallback belongs in a startup step run as the agent.** The + reference adapter did not stop at writing a system `storage.conf`; it ran a + rootless self-test and, on failure, wrote a user-level vfs `storage.conf` and + retried. We preserve that recovery. + +## Context + +### Fail-soft install + +The reference `_acq_msb_ensure_oci` ran its `set -e` install block inside an +outer `if _acq_msb_cli exec … ; then … else ; return 0; fi`, so a failed +package install was caught and treated as non-fatal. As a **standalone kit +script** there is no outer catch: a top-level `set -eu` plus bare `apt-get` / +`dnf` / `apk` calls means the first non-zero return aborts the script with a +non-zero exit — which fails `sbx create` before the fail-soft `command -v podman` +re-check is ever reached. + +**Decision.** Run the distro-detected install inside a subshell under its own +`set -e` and swallow its exit (`( set -e; … ) || echo WARNING >&2`). Any failure +falls through to a single fail-soft exit at the `command -v podman` re-check, +which warns and `exit 0`s so provision continues. Verified by stubbing a failing +`apt-get`/`dnf`/`apk` on `PATH` and confirming the script prints the warning and +exits 0. + +### Rootless overlay+fuse-overlayfs can be rejected under rootless + +On most bases the system `storage.conf` (overlay + fuse-overlayfs) works. On some +bases the combo is rejected **under rootless**: `podman info` passes (it does not +open `/dev/fuse` or mount a layer) but a real `podman build`/`run` fails at mount +time. The documented recovery is a **user-level** +`~/.config/containers/storage.conf` selecting `vfs`, which takes precedence over +the system file for the rootless agent. + +**Decision.** Because the `install` phase runs as root (uid 0) and cannot cleanly +own the agent's `~/.config`, and because the effective rootless situation is only +knowable at start (after the per-boot device grant), the recovery is a separate +`startup` step run **as the agent (uid 1000)**: +`files/home/oci-engine-vfs-fallback.sh`. It runs a real layer-mount self-test +(`podman build` FROM scratch) and, on failure, writes the user-level vfs +`storage.conf` and retries. It is idempotent (only writes on a failed build when +no user driver is set) and **fails soft** (always exits 0 — a failed self-test +must not abort the boot). + +## Consequences + +- The sandbox is never bricked by an OCI provisioning failure; OCI support simply + degrades to "unavailable" with a clear warning. +- Bases that reject rootless overlay+fuse-overlayfs still get a working (if + disk-heavier) engine via the vfs fallback, matching the reference adapter. +- Security posture (rootless, no dind daemon, group-scoped devices, + Docker-Hub-first with `short-name-mode=enforcing` by default) follows the + isolation ADR-0020 decision; the neutral-egress-tier reliance follows ADR-0002. + +## Addendum: functional-engine early-out and config/install decoupling + +Live testing on a base that already bundles a docker CLI (the msb default +`…:shell-docker`) surfaced two structural issues: + +1. **Do not shadow a working engine.** If the base already provides a *functional* + engine, layering podman on top and shadowing `docker` only complicates a setup + that already works. The install script now **early-outs on a clean + `docker info`** — probing the engine's *function*, not merely the presence of a + `docker` binary (many bases ship a docker CLI whose daemon socket is dead in + the sandbox — the exact case this kit fixes, which must NOT early-out). + +2. **Config/wrapper decoupled from the package step.** Previously the storage + + registry config and the `docker`->podman wrapper only ran as a consequence of + the kit's package install succeeding; on a base that already had podman (or + where the wrapper was wanted regardless), a skipped package step skipped the + config too. The config + wrapper now run whenever **podman is present** at that + point — installed by the kit *or* pre-existing — so the outcome is the same + regardless of who installed the engine. + +Both keep the fail-soft posture: the early-out and every skip path exit 0. + +## Addendum: adversarial-review hardening + +An AI-assisted 5-role adversarial PR review (advisory) confirmed the design +direction and flagged six implementation defects, all addressed here: + +1. **Fail-SILENT vs fail-soft / no verifiability.** Fail-soft is right (a + non-zero `install` exit fails `sbx create`), but a silent one is not + observable. The install now writes a durable success marker + `/var/lib/acq/oci-engine-ready` (`state=configured` on a real install, + `state=base-engine` on the deliberate functional-engine early-out); the + fail-soft paths deliberately do NOT write it. `scripts/verify` (the + kit-library convention this kit follows) checks the marker first, so a + "kit applied but did nothing" sandbox is legible to CI/operators. + +2. **Package-manager flag injection.** The old `OCI_ENGINE_PKGS` sanitizer + allowed a leading `-` in a token, so `OCI_ENGINE_PKGS="podman + --allow-unauthenticated"` (apt) / `--nogpgcheck` (dnf) / `--allow-untrusted` + (apk) passed validation and word-split into the elevated install, disabling + signature verification. The sanitizer now validates **token by token** and + rejects any leading-`-` token (and any non-word-safe char), failing closed + (skip OCI setup) rather than running a tampered install. + +3. **Unconditional device grant.** `oci-engine-grant-devs.sh` chown/chmod'd + `/dev/net/tun` + `/dev/fuse` on every boot even when podman was never + installed — widening in-guest device access (`/dev/net/tun` is + egress-relevant) for a capability that does not exist. It is now **gated on + `command -v podman`**: no engine, no grant. + +4. **Storage driver permanently mis-pinned from a boot-transient probe.** The + driver was chosen from `[ -e /dev/fuse ]` at create and then never rewritten, + so a transient miss at create permanently pinned slow `vfs`. Driver selection + moved into a shared helper (`oci-engine-storage-driver.sh`) that the + every-boot grant step also runs, **converging** vfs→overlay once + fuse-overlayfs + `/dev/fuse` are both present. It only ever rewrites a + kit-owned `storage.conf` (sentinel comment), never an operator's. + +5. **Missing `caps.network` for the registry.** The kit's *purpose* (pulling + images) needs Docker Hub, which — unlike the distro package mirror — is not + distro-dependent. `spec.yaml` now declares the Docker Hub registry + auth + + blob-CDN hosts in `caps.network.allow`, so runtime pulls work even on a + `strict` tier. The distro mirror is still left to the balanced baseline (it + is distro-specific and already in the baseline `core` set). + +6. **Unbounded self-test build on the boot path.** The per-boot rootless + `podman build` self-test now requires `timeout` and runs under it + (`OCI_ENGINE_SELFTEST_TIMEOUT`, positive integer, default 120s), so a wedged + mount / stalled `newuidmap` cannot hang the boot. If `timeout` is unavailable, + the script skips the self-test/fallback path rather than running an unbounded boot-path + build; a timeout is treated as a failed self-test and triggers the vfs + fallback. + +7. **Root code sourced from agent-writable staging.** The neutral file-drop path + stages payloads under `/home/agent`, but root commands must not execute or + source those files after the agent has had a session to modify them. The spec's + root command wrappers publish `oci-engine-install.sh`, + `oci-engine-grant-devs.sh`, and `oci-engine-storage-driver.sh` into + `/usr/local/lib/acq/oci-engine/` as root-owned files, and execute/source only + those trusted copies. The grant script does not fall back to agent-home staging. + +8. **Stale device grant after podman disappears.** The no-podman startup path now + best-effort resets `/dev/net/tun` and `/dev/fuse` to `root:root` `0600` before + exiting, so a grant from an earlier successful boot is not intentionally left in + place for a capability that is no longer present. diff --git a/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/01-acq-shortnames.conf b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/01-acq-shortnames.conf new file mode 100644 index 00000000..6eb54fbf --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/01-acq-shortnames.conf @@ -0,0 +1,10 @@ +# 01-acq-shortnames.conf — podman short-name aliases (installed by the oci-engine +# kit into /etc/containers/registries.conf.d/). +# +# Remaps podman's built-in `hello`/`hello-world` short names back to Docker Hub +# (docker.io/library/hello-world) so a user migrating from Docker gets the image +# they expect. Paired with the Docker-Hub-first search-registry drop-in +# (00-acq-docker-first.conf, written by the install script). See ADR-0020. +[aliases] +"hello-world" = "docker.io/library/hello-world" +"hello" = "docker.io/library/hello-world" diff --git a/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/docker b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/docker new file mode 100755 index 00000000..c4069273 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/docker @@ -0,0 +1,13 @@ +#!/bin/sh +# docker — thin wrapper that routes the docker CLI to the rootless podman engine. +# +# Dropped by the oci-engine kit into /usr/local/bin (ahead of /usr/bin on PATH), +# SHADOWING the base image's bundled docker CLI, which talks to a dead socket in +# this sandbox (no dockerd runs here). This wrapper forwards every argument to +# podman, so `docker run …` and `docker compose …` route to the working engine. +# +# `docker compose` becomes `podman compose`, which dispatches through podman's +# compose provider (podman-compose) — this is what makes docker-compose.yaml +# files usable. Plain `podman` (NOT sudo): the engine runs ROOTLESS as the agent +# user, so the agent invokes it directly. See ADR-0020. +exec podman "$@" diff --git a/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/oci-engine-storage-driver.sh b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/oci-engine-storage-driver.sh new file mode 100644 index 00000000..02e60f31 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/oci-engine-storage-driver.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# oci-engine-storage-driver.sh — shared helper: (re)write the SYSTEM podman +# storage.conf to a driver that works on the current sandbox filesystem. +# +# Sourced (NOT executed) by both: +# - oci-engine-install.sh (create-time, root) — seed a sane default; and +# - oci-engine-grant-devs.sh (every boot, root) — RE-EVALUATE after the device +# grant, so a transient /dev/fuse miss at create is not permanently baked in. +# +# WHY re-evaluate every boot (finding #4): /dev is a devtmpfs re-created on every +# boot (that is why the grant step exists at all). The create-time probe of +# `/dev/fuse` can miss — the node may not be present yet at create — and the old +# logic then wrote `vfs` AND guarded against ever rewriting it, permanently +# pinning the slow driver with no recovery path. This helper instead converges: +# it writes the BEST driver currently possible and will UPGRADE vfs -> overlay on +# a later boot once fuse-overlayfs + /dev/fuse are both available. +# +# It only ever REPLACES a config this kit itself wrote (marked with the sentinel +# comment below); it never clobbers an operator-authored /etc/containers/ +# storage.conf that lacks the sentinel. POSIX sh only; safe to source under set -e. + +# Sentinel marking a storage.conf as kit-owned (safe to overwrite/upgrade). +OCI_ENGINE_STORAGE_SENTINEL='# managed-by: acq oci-engine kit' + +# Resolve the fuse-overlayfs mount program path, or empty if unavailable. +_oci_engine_find_fuse() { + _f="" + for _c in /usr/bin/fuse-overlayfs /usr/local/bin/fuse-overlayfs /bin/fuse-overlayfs; do + if [ -x "$_c" ]; then _f="$_c"; break; fi + done + if [ -z "$_f" ] && command -v fuse-overlayfs >/dev/null 2>&1; then + _f="$(command -v fuse-overlayfs)" + fi + printf '%s' "$_f" + unset _c +} + +# oci_engine_write_storage_conf — write /etc/containers/storage.conf for the best +# driver currently possible. Idempotent and convergent (upgrades vfs->overlay when +# /dev/fuse reappears). Never overwrites a non-kit (operator) storage.conf. +oci_engine_write_storage_conf() { + _conf=/etc/containers/storage.conf + mkdir -p /etc/containers 2>/dev/null || true + + # If a storage.conf exists that this kit did NOT write, leave it alone. + if [ -f "$_conf" ] && ! grep -qF "$OCI_ENGINE_STORAGE_SENTINEL" "$_conf" 2>/dev/null; then + echo "oci-engine: $_conf is operator-owned (no kit sentinel); leaving it untouched." >&2 + return 0 + fi + + _fuse="$(_oci_engine_find_fuse)" + if [ -n "$_fuse" ] && [ -e /dev/fuse ]; then + _want="overlay" + else + _want="vfs" + fi + + # Converge only: if the kit-owned file already selects the desired driver, do + # not rewrite (avoids churn + log noise every boot). + if [ -f "$_conf" ] && grep -q "driver = \"$_want\"" "$_conf" 2>/dev/null; then + return 0 + fi + + if [ "$_want" = "overlay" ]; then + { + printf '%s\n' "$OCI_ENGINE_STORAGE_SENTINEL" + printf '[storage]\ndriver = "overlay"\n[storage.options.overlay]\nmount_program = "%s"\n' "$_fuse" + } > "$_conf" + echo "oci-engine: storage driver set to overlay (fuse-overlayfs=$_fuse)." + else + { + printf '%s\n' "$OCI_ENGINE_STORAGE_SENTINEL" + printf '[storage]\ndriver = "vfs"\n' + } > "$_conf" + echo "oci-engine: storage driver set to vfs (fuse-overlayfs and/or /dev/fuse unavailable this boot)." >&2 + fi + unset _conf _fuse _want +} diff --git a/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-grant-devs.sh b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-grant-devs.sh new file mode 100755 index 00000000..7d5c614e --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-grant-devs.sh @@ -0,0 +1,108 @@ +#!/bin/sh +# oci-engine-grant-devs.sh — grant the agent the device nodes rootless podman +# needs, and re-evaluate the storage driver — re-applied on EVERY sandbox start. +# +# Runs as root (uid 0) in the kit's `startup` phase. /dev is a devtmpfs re-created +# on every boot, so a create-time grant would be lost after a restart — this step +# re-applies the grant each start (idempotent, cheap). See README.md and the +# isolation ADR-0020. +# +# GATED ON PODMAN (finding #3): if podman is NOT installed (the install fell soft +# — unreachable mirror, unsupported base — or the base had its own engine and the +# kit early-outed), this step does NOTHING. Widening /dev/net/tun and /dev/fuse +# access for a container engine that does not exist is pointless and needlessly +# enlarges the in-guest attack surface (/dev/net/tun in particular is egress- +# relevant — it enables in-guest tunnelling). No engine, no grant. +# +# WHAT + WHY (ADR-0020): +# /dev/net/tun — rootless podman's network backend (netavark/pasta or +# slirp4netns) must open it to set up container networking. It is root-only +# (crw------- root root) on the default image; we group-scope it to the agent +# (root:agent, 0660) so the unprivileged agent can bring up networking. +# /dev/fuse — the fuse-overlayfs storage driver mounts layers through it. +# Same root:agent 0660 group-scoping. +# +# This is INSIDE the sandbox (the security boundary) only — it opens no new host +# attack surface. It is a group-scope, not world-writable (0666): only the agent +# (in the `agent` group) gains access, not every uid in the guest. +# +# It also RE-EVALUATES the system storage driver each boot (finding #4): because +# /dev is recreated per boot, a transient /dev/fuse miss at create must not +# permanently pin the slow vfs driver — the shared helper converges to overlay +# once fuse-overlayfs + /dev/fuse are both present. +# +# Best-effort per device: a missing node (e.g. /dev/net/tun absent because the +# kernel module is not present) is a clear message, not a hard failure — podman +# then falls back to a networking/storage mode that does not need that node. +# +# POSIX sh only (no bashisms). + +set -eu + +# GATE (finding #3): do nothing unless a container engine this grant is FOR +# exists. `podman` is what this kit installs; if it is absent the grant serves no +# purpose and would only widen device access, so skip grant/storage work and +# best-effort revoke any stale grant from an earlier boot. +if ! command -v podman >/dev/null 2>&1; then + for _dev in /dev/net/tun /dev/fuse; do + if [ -e "$_dev" ]; then + chown root:root "$_dev" 2>/dev/null || true + chmod 0600 "$_dev" 2>/dev/null || true + fi + done + unset _dev + echo "oci-engine: podman not installed; skipped device grant + storage re-eval (nothing to enable)." >&2 + exit 0 +fi + +# Root-owned helper installed by oci-engine-install.sh. Do not source helper code +# from the agent-writable staging path on startup. +TRUSTED_STORAGE_HELPER="/usr/local/lib/acq/oci-engine/oci-engine-storage-driver.sh" + +# The unprivileged user the engine runs as. Non-secret; overridable via the +# OCI_ENGINE_AGENT_USER env var (declared in spec.yaml environment) for a base +# image whose agent account is not named "agent". +AGENT_USER="${OCI_ENGINE_AGENT_USER:-agent}" + +# Guard the user name before it reaches chown: a POSIX user name is word-safe +# (letters, digits, . _ -). Refuse anything else rather than risk a surprise +# token in the chown target. +case "$AGENT_USER" in + ""|*[!A-Za-z0-9._-]*) + echo "oci-engine: WARNING: unsafe OCI_ENGINE_AGENT_USER='$AGENT_USER'; using 'agent'." >&2 + AGENT_USER="agent" + ;; +esac + +# Group-scope one device node to the agent (root:, 0660) if it exists. +_grant() { + _dev="$1" + if [ -e "$_dev" ]; then + if chown "root:$AGENT_USER" "$_dev" 2>/dev/null && chmod 0660 "$_dev" 2>/dev/null; then + echo "oci-engine: granted $_dev to group $AGENT_USER (0660)." + else + echo "oci-engine: WARNING: could not adjust permissions on $_dev (rootless podman may be limited)." >&2 + fi + else + echo "oci-engine: note: $_dev absent; skipping (podman will fall back where possible)." >&2 + fi + unset _dev +} + +# /dev/net/tun lives under /dev/net, which may not exist yet; create it so the +# node can be granted (the kernel populates the node when the tun module loads). +[ -d /dev/net ] || mkdir -p /dev/net 2>/dev/null || true + +_grant /dev/net/tun +_grant /dev/fuse + +# Re-evaluate the system storage driver now that this boot's /dev is populated +# (finding #4): converge to overlay+fuse-overlayfs when possible, else vfs. The +# helper only rewrites a kit-owned storage.conf and no-ops when already correct. +if [ -f "$TRUSTED_STORAGE_HELPER" ]; then + # shellcheck source=files/home/oci-engine-config/oci-engine-storage-driver.sh + . "$TRUSTED_STORAGE_HELPER" + oci_engine_write_storage_conf +else + echo "oci-engine: WARNING: trusted storage helper missing; skipping storage re-eval." >&2 +fi diff --git a/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-install.sh b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-install.sh new file mode 100755 index 00000000..65a743a4 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-install.sh @@ -0,0 +1,247 @@ +#!/bin/sh +# oci-engine-install.sh — install + configure a rootless OCI engine (podman). +# +# Runs ONCE at sandbox create time, as root (uid 0), from the kit's `install` +# phase. It installs podman + the rootless prerequisites, selects a storage +# driver that works on an overlay root, installs the Docker-Hub-first registry +# config, and shadows the base image's (dead-socket) docker CLI with a wrapper +# that routes to podman. The engine itself RUNS ROOTLESS as the agent user; only +# this install/config step needs root. See README.md and the isolation ADR-0020 +# ("rootless podman OCI engine") for the design rationale this reproduces. +# +# Idempotent + best-effort: re-running is safe (each step is guarded), and a +# missing package mirror or unsupported base fails soft with a clear message +# rather than aborting sandbox provisioning. +# +# POSIX sh only (no bashisms): the sandbox base shell may be dash/busybox. + +set -eu + +# Package set for the OCI engine + rootless prerequisites. Non-secret; may be +# overridden via the OCI_ENGINE_PKGS env var (declared in spec.yaml environment). +# podman — the daemonless OCI engine +# podman-compose — the `podman compose` / `docker compose` provider +# fuse-overlayfs — lets podman use the `overlay` graph driver on an overlay +# ROOT filesystem (the kernel `overlay` driver refuses to +# stack on overlayfs); without it we fall back to `vfs` +# uidmap — newuidmap/newgidmap setuid helpers rootless podman needs +# passt, slirp4netns — rootless container networking backends +OCI_ENGINE_PKGS="${OCI_ENGINE_PKGS:-podman podman-compose fuse-overlayfs uidmap passt slirp4netns}" + +# short-name resolution mode written into the registries drop-in. DEFAULT is +# "enforcing" (least-privilege / prompt-injection defense per ADR-0020): a single +# search registry (docker.io) still resolves unqualified names deterministically +# to Docker Hub, but interactively-ambiguous short names fail closed instead of +# being silently substituted (image-substitution / typosquatting defense). An +# operator MAY opt into the weaker "permissive" via OCI_ENGINE_SHORT_NAME_MODE; +# any invalid value falls back to "enforcing" (fail-closed) with a warning. +OCI_ENGINE_SHORT_NAME_MODE="${OCI_ENGINE_SHORT_NAME_MODE:-enforcing}" +_mode_lc="$(printf '%s' "$OCI_ENGINE_SHORT_NAME_MODE" | tr '[:upper:]' '[:lower:]')" +case "$_mode_lc" in + enforcing|permissive|disabled) OCI_ENGINE_SHORT_NAME_MODE="$_mode_lc" ;; + *) + echo "oci-engine: WARNING: invalid OCI_ENGINE_SHORT_NAME_MODE='$OCI_ENGINE_SHORT_NAME_MODE'" \ + "(expected enforcing|permissive|disabled); falling back to enforcing" >&2 + OCI_ENGINE_SHORT_NAME_MODE="enforcing" + ;; +esac +unset _mode_lc + +# Validate the package set: it is passed to the package manager UNQUOTED +# (word-splitting is intentional — it is a space-separated list), so each token +# reaches an ELEVATED install verbatim. Two guards, both required: +# +# (a) Charset: package names are word-safe (letters, digits, . _ + -). +# (b) NO token may begin with `-`. A charset check ALONE is insufficient: `-` +# is a legal char in package names, but a LEADING `-` makes the token an +# OPTION, not a package. Without this, OCI_ENGINE_PKGS="podman +# --allow-unauthenticated" (apt), "... --nogpgcheck" (dnf), or +# "... --allow-untrusted" (apk) would word-split straight into the install +# and DISABLE package-signature verification — a supply-chain hole. We +# validate token-by-token so a leading `-` on ANY token is rejected, and +# fail CLOSED (skip OCI setup) rather than run a tampered install. +_pkg_bad=0 +for _pkg in $OCI_ENGINE_PKGS; do + case "$_pkg" in + -*) + echo "oci-engine: WARNING: OCI_ENGINE_PKGS token '$_pkg' looks like an option (leading '-')," \ + "not a package name; refusing (would reach the elevated install)." >&2 + _pkg_bad=1 + ;; + *[!A-Za-z0-9._+-]*) + echo "oci-engine: WARNING: OCI_ENGINE_PKGS token '$_pkg' has unsafe characters; refusing." >&2 + _pkg_bad=1 + ;; + esac +done +if [ "$_pkg_bad" -ne 0 ]; then + echo "oci-engine: skipping OCI setup due to an unsafe OCI_ENGINE_PKGS value." >&2 + exit 0 +fi +unset _pkg _pkg_bad + +# Where the kit staged its config payloads (dropped by files[] as the agent user, +# then installed into root-owned locations here by root). Startup-time root code +# MUST NOT be read from the agent-writable staging path on later boots. +STAGE_DIR="/home/agent/oci-engine-config" +STAGED_GRANT_SCRIPT="/home/agent/oci-engine-grant-devs.sh" +TRUSTED_PARENT="/usr/local/lib/acq" +TRUSTED_DIR="$TRUSTED_PARENT/oci-engine" +TRUSTED_STORAGE_HELPER="$TRUSTED_DIR/oci-engine-storage-driver.sh" +TRUSTED_GRANT_SCRIPT="$TRUSTED_DIR/oci-engine-grant-devs.sh" + +# 0) Install trusted helper/startup payloads into a root-owned path before any +# fail-soft exit. The startup phase executes/sources ONLY these copies, not the +# agent-home staging files, so an agent cannot tamper with root startup code +# between restarts. +if ! mkdir -p "$TRUSTED_DIR" 2>/dev/null \ + || ! chown root:root "$TRUSTED_PARENT" "$TRUSTED_DIR" 2>/dev/null \ + || ! chmod 0755 "$TRUSTED_PARENT" "$TRUSTED_DIR" 2>/dev/null; then + echo "oci-engine: WARNING: could not prepare trusted root startup directory; OCI setup unavailable." >&2 + exit 0 +fi +if [ -f "$STAGE_DIR/oci-engine-storage-driver.sh" ]; then + if ! install -m 0644 "$STAGE_DIR/oci-engine-storage-driver.sh" "$TRUSTED_STORAGE_HELPER"; then + echo "oci-engine: WARNING: could not install trusted storage helper; OCI setup unavailable." >&2 + exit 0 + fi +else + echo "oci-engine: WARNING: staged storage helper missing; cannot configure OCI storage." >&2 + exit 0 +fi +if [ -f "$STAGED_GRANT_SCRIPT" ]; then + if ! install -m 0755 "$STAGED_GRANT_SCRIPT" "$TRUSTED_GRANT_SCRIPT"; then + echo "oci-engine: WARNING: could not install trusted startup grant script; OCI setup unavailable." >&2 + exit 0 + fi +else + echo "oci-engine: WARNING: staged startup grant script missing; startup device grant unavailable." >&2 + exit 0 +fi + +# 1) EARLY-OUT if the base image already provides a FUNCTIONAL container engine. +# +# If the operator picked a base that bundles a working docker/Docker-daemon setup +# (e.g. the msb default `…:shell-docker`), this kit should NOT layer podman on top +# and shadow the working `docker` — that only complicates a setup that already +# works. We probe `docker info` (as the invoking user); a clean exit means a +# usable engine is already present, so we leave the base alone and exit 0. +# +# We deliberately do NOT early-out merely because a `docker` BINARY exists: many +# bases ship a docker CLI whose daemon socket is dead in the sandbox (the exact +# case this kit exists to fix). Only a functional `docker info` counts. If podman +# is already present and working we likewise leave it (the config step below is +# idempotent and will still ensure our drop-ins, but we do not reinstall). +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + echo "oci-engine: a functional 'docker' engine is already present on this base; leaving it as-is." >&2 + echo "oci-engine: (skipping podman install + docker->podman wrapper to avoid shadowing a working engine)." >&2 + # Record the DELIBERATE early-out so it is distinguishable from a fail-soft + # miss (finding #1). state=base-engine means "kit ran, chose to defer". + mkdir -p /var/lib/acq 2>/dev/null || true + printf 'installed_at=%s\nstate=base-engine\npodman=%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" \ + "$(command -v podman || echo none)" \ + > /var/lib/acq/oci-engine-ready 2>/dev/null || true + exit 0 +fi + +# 2) Ensure the podman binary is present (idempotent; distro-detected). +# +# FAIL-SOFT (critical): this is an `install`-phase command, and a non-zero +# install exit FAILS `sbx create` (a dead sandbox) — see the sibling +# zscaler-ca-certificate kit's docs/decisions and this kit's docs/decisions. The +# package-manager step MUST NOT abort the script on failure (an unreachable +# mirror, a locked dpkg, an unsupported base). The top-level `set -eu` would +# otherwise abort on the FIRST non-zero return from apt-get/dnf/apk. So we run the +# whole install in a SUBSHELL under its own `set -e` and swallow its exit: a +# failure just leaves podman absent, which the config step below detects and +# skips (warning, exit 0) so provision continues. +# +# The reference adapter (_acq_msb_ensure_oci) did not have this bug only because +# its `set -e` install block ran inside an outer `if _acq_msb_cli exec … ; then … +# else ; return 0; fi` that caught the failure. As a standalone kit script +# there is no outer catch, so we provide the equivalent catch here. +if ! command -v podman >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + ( set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update + # shellcheck disable=SC2086 # deliberate word-split of the package list + apt-get install -y --no-install-recommends $OCI_ENGINE_PKGS + ) || echo "oci-engine: WARNING: apt-get install failed (mirror unreachable?); OCI may be unavailable." >&2 + elif command -v dnf >/dev/null 2>&1; then + ( set -e + # shellcheck disable=SC2086 + dnf install -y $OCI_ENGINE_PKGS + ) || echo "oci-engine: WARNING: dnf install failed (mirror unreachable?); OCI may be unavailable." >&2 + elif command -v apk >/dev/null 2>&1; then + ( set -e + # shellcheck disable=SC2086 + apk add --no-cache $OCI_ENGINE_PKGS + ) || echo "oci-engine: WARNING: apk add failed (mirror unreachable?); OCI may be unavailable." >&2 + else + echo "oci-engine: no supported package manager (apt-get/dnf/apk); cannot install podman." >&2 + echo "oci-engine: OCI images (docker run / docker compose) will be unavailable." >&2 + exit 0 + fi +fi + +# CONFIG GATE (decoupled from the package step): the storage/registry/wrapper +# config below runs whenever podman is PRESENT at this point — whether this kit +# just installed it OR it was already on the base (installed by other means). It +# is NOT gated on "the kit ran the package install", so a base that already ships +# podman still gets the Docker-Hub-first config and the docker->podman wrapper. +# If podman is still absent (install failed / mirror unreachable), warn and +# exit 0 so `sbx create` succeeds — the sandbox is usable, just without OCI. +if ! command -v podman >/dev/null 2>&1; then + echo "oci-engine: WARNING: podman not present after install; OCI images unavailable." >&2 + echo "oci-engine: the OS package mirror may be unreachable (see README egress note)." >&2 + exit 0 +fi + +# 3) Select a storage driver that works on an overlay ROOT filesystem, via the +# shared helper the every-boot grant step also calls. Writing it here at create +# seeds a sane default; the grant step (root, every boot) RE-EVALUATES it so a +# transient /dev/fuse miss at create is not permanently baked in — see the +# helper's comment and finding #4 in the kit's docs/decisions. +mkdir -p /etc/containers +# shellcheck source=files/home/oci-engine-config/oci-engine-storage-driver.sh +. "$TRUSTED_STORAGE_HELPER" +oci_engine_write_storage_conf + +# 4) Docker-Hub-first registry resolution (ADR-0020). System-level so it applies +# to the rootless agent (read as the lowest-precedence source). Install the +# staged drop-ins verbatim (idempotent: overwrite our own files each run). The +# short-name drop-in is templated with the resolved mode. +mkdir -p /etc/containers/registries.conf.d +if [ -f "$STAGE_DIR/01-acq-shortnames.conf" ]; then + install -m 0644 "$STAGE_DIR/01-acq-shortnames.conf" \ + /etc/containers/registries.conf.d/01-acq-shortnames.conf +fi +printf 'unqualified-search-registries = ["docker.io"]\nshort-name-mode = "%s"\n' \ + "$OCI_ENGINE_SHORT_NAME_MODE" \ + > /etc/containers/registries.conf.d/00-acq-docker-first.conf + +# 5) Shadow the base image's (dead-socket) docker CLI with a wrapper that routes +# to podman. Placed in /usr/local/bin (ahead of /usr/bin on PATH); we never +# touch the base image's /usr/bin/docker. `docker compose` -> `podman compose` +# drives the podman-compose provider. Install the staged wrapper (0755). +if [ -f "$STAGE_DIR/docker" ]; then + mkdir -p /usr/local/bin + install -m 0755 "$STAGE_DIR/docker" /usr/local/bin/docker +fi + +# 6) SUCCESS MARKER (observability — finding #1). Record that the kit reached a +# configured state, so an operator / CI / the host-side scripts/verify can tell +# a genuinely-provisioned sandbox from one where the install fell soft (the +# fail-soft `exit 0` paths above deliberately do NOT touch this marker). This +# is the same durable-marker discipline the msb adapter uses (/var/lib/acq/*). +mkdir -p /var/lib/acq +printf 'installed_at=%s\nstate=configured\npodman=%s\ndocker_wrapper=%s\nshort_name_mode=%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" \ + "$(command -v podman || echo none)" \ + "$([ -x /usr/local/bin/docker ] && echo /usr/local/bin/docker || echo none)" \ + "$OCI_ENGINE_SHORT_NAME_MODE" \ + > /var/lib/acq/oci-engine-ready + +echo "oci-engine: rootless podman installed and configured (short-name-mode=$OCI_ENGINE_SHORT_NAME_MODE)." diff --git a/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-vfs-fallback.sh b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-vfs-fallback.sh new file mode 100755 index 00000000..f42554b8 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-vfs-fallback.sh @@ -0,0 +1,111 @@ +#!/bin/sh +# oci-engine-vfs-fallback.sh — rootless self-test + user-level vfs storage +# fallback for podman. +# +# Runs on EVERY sandbox start as the AGENT user (uid 1000), in the kit's +# `startup` phase, AFTER the root install (create) and the device-node grant. +# It reproduces the reference adapter's recovery path (_acq_msb_ensure_oci, +# rootless self-test + user-level vfs retry): the system storage.conf the install +# step wrote (overlay + fuse-overlayfs) works on most bases, but some bases reject +# the overlay+fuse-overlayfs combo UNDER ROOTLESS — there `podman info` passes but +# a real layer mount (`podman build` / `podman run`) fails. The documented +# recovery is a USER-level ~/.config/containers/storage.conf selecting the `vfs` +# driver (works everywhere, no /dev/fuse; disk-heavy but correct), which takes +# precedence over the system file for the rootless agent. +# +# Why a startup step run as the agent (not folded into the root install): the +# install phase runs as root (uid 0) and cannot cleanly own the agent's +# ~/.config; and /dev is re-created each boot, so the effective rootless storage +# situation is only knowable at start (after the device grant). This step is the +# agent-side complement to the root install + device grant. +# +# FAIL-SOFT: a failed self-test must NEVER abort the boot — the whole point is a +# best-effort recovery. This script always exits 0. +# +# Idempotent: it only writes the user storage.conf when a rootless build FAILS and +# the file does not already select a driver; once the vfs fallback is in place the +# self-test passes and the script no-ops. POSIX sh only. + +set -u + +# If podman is not installed (install failed soft, mirror unreachable), there is +# nothing to self-test. No-op, exit 0. +if ! command -v podman >/dev/null 2>&1; then + echo "oci-engine: podman not installed; skipping rootless self-test." >&2 + exit 0 +fi + +HOME_DIR="${HOME:-/home/agent}" +USER_STORAGE_DIR="$HOME_DIR/.config/containers" +USER_STORAGE_CONF="$USER_STORAGE_DIR/storage.conf" + +# A real LAYER MOUNT self-test: `podman build` FROM scratch opens /dev/fuse and +# mounts a layer, so it catches the rootless overlay+fuse-overlayfs trap that a +# bare `podman info` misses. No registry pull, no egress (FROM scratch). +# +# TIMEOUT (finding #6): this runs on EVERY boot. A wedged storage lock, a stalled +# newuidmap, or a hung mount could otherwise block the boot sequence forever, so +# the build must be bounded by `timeout`. If `timeout` is unavailable, skip the +# self-test/fallback path rather than run `podman build` unbounded on boot. +# OCI_ENGINE_SELFTEST_TIMEOUT (seconds) is overridable via spec.yaml environment. +OCI_ENGINE_SELFTEST_TIMEOUT="${OCI_ENGINE_SELFTEST_TIMEOUT:-120}" +case "$OCI_ENGINE_SELFTEST_TIMEOUT" in + ''|*[!0-9]*) OCI_ENGINE_SELFTEST_TIMEOUT=120 ;; + *[1-9]*) ;; + *) OCI_ENGINE_SELFTEST_TIMEOUT=120 ;; +esac + +if ! command -v timeout >/dev/null 2>&1; then + echo "oci-engine: timeout command unavailable; skipping rootless self-test to avoid an unbounded boot-path build." >&2 + exit 0 +fi + +_oci_build() { + # A timeout kill returns 124 (GNU) and is treated as a failed self-test, which + # triggers the vfs fallback. + timeout "$OCI_ENGINE_SELFTEST_TIMEOUT" podman build -q -t oci-engine-selftest:local "$1" >/dev/null 2>&1 +} + +_oci_selftest() { + _d="$(mktemp -d)" || return 1 + printf 'FROM scratch\nCOPY hi /hi\n' > "$_d/Containerfile" + echo hi > "$_d/hi" + _oci_build "$_d" + _rc=$? + podman rmi -f oci-engine-selftest:local >/dev/null 2>&1 || true + rm -rf "$_d" + return $_rc +} + +if _oci_selftest; then + echo "oci-engine: rootless podman self-test passed (current storage driver works)." + exit 0 +fi + +echo "oci-engine: rootless podman self-test failed with the current storage driver;" \ + "applying a user-level vfs fallback." >&2 + +# Only write the user storage.conf if it does not already name a driver (do not +# clobber an operator's own user config). +if grep -q '^[[:space:]]*driver' "$USER_STORAGE_CONF" 2>/dev/null; then + echo "oci-engine: $USER_STORAGE_CONF already selects a driver; leaving it untouched." >&2 + exit 0 +fi + +mkdir -p "$USER_STORAGE_DIR" 2>/dev/null || { + echo "oci-engine: WARNING: could not create $USER_STORAGE_DIR; cannot apply vfs fallback." >&2 + exit 0 +} +printf '[storage]\ndriver = "vfs"\n' > "$USER_STORAGE_CONF" 2>/dev/null || { + echo "oci-engine: WARNING: could not write $USER_STORAGE_CONF; cannot apply vfs fallback." >&2 + exit 0 +} + +# Re-test with the vfs fallback in place; report but never fail the boot. +if _oci_selftest; then + echo "oci-engine: rootless podman self-test passed after the vfs fallback." +else + echo "oci-engine: WARNING: rootless podman still cannot build after the vfs fallback;" \ + "OCI images may be unavailable in this sandbox." >&2 +fi +exit 0 diff --git a/integrations/isolation/acq-kits/oci-engine/scripts/verify b/integrations/isolation/acq-kits/oci-engine/scripts/verify new file mode 100755 index 00000000..3803c240 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/scripts/verify @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# +# verify — host-side live validation for the oci-engine acq mixin kit. +# +# Creates a throwaway msb sandbox with THIS kit applied through acq's normal +# provision path, and confirms a ROOTLESS podman OCI engine actually works the +# way an agent uses it: +# - podman is present and reports rootless=true (as the agent); +# - a real `podman build` FROM scratch succeeds (stronger than `podman info`: +# info does not open /dev/fuse or mount a layer, so it can pass even when the +# storage mount would fail — the exact /dev/fuse trap ADR-0020 describes); +# - the `docker` -> `podman` wrapper this kit drops resolves on PATH and routes +# to podman. +# +# BACKEND: msb ONLY. msb is acq's default backend, and per ADR-0020 the OCI +# engine behavior is demonstrated on msb; sbx has its own base-image selection +# logic that is out of scope here. +# +# WHY acq (not raw sbx/msb): acq drives create + kit-translate + kit apply exactly +# as a real user would (`acq --backend msb create shell --kit `), +# so this exercises the neutral hybrid/v1 spec end-to-end on the real path. We use +# `acq create` (detached) + `acq exec`, NOT `acq run` (which attaches +# interactively and would hang a non-interactive check). acq's `exec` runs as the +# agent user (uid 1000) with HOME=/home/agent on msb, so the checks below +# genuinely exercise the ROOTLESS path without a manual -u flag. +# +# WHY ACQ_MSB_ENSURE_OCI=0 (important): the msb backend adapter STILL auto-installs +# podman on its own today (the legacy pre-kit path, gated on ACQ_MSB_ENSURE_OCI, +# default on). If we left it on, a plain provision would install podman regardless +# of whether THIS kit did — a false positive that would prove the adapter's work, +# not the kit's. ADR-0030 moves that provisioning OUT of the adapter and into this +# opt-in kit; to prove the kit does the job in isolation we DISABLE the adapter's +# auto-install for this run (ACQ_MSB_ENSURE_OCI=0). When the adapter's auto-install +# is eventually removed, this export becomes a harmless no-op and this script is +# the standing regression test that the kit still provisions podman on its own. +# +# WHY a pinned engine-free base image (important): the msb default base +# (docker/sandbox-templates:shell-docker, a `*-docker` variant) runs a FULL Docker +# Engine (dockerd) inside the sandbox — so `docker info` succeeds there, the kit +# correctly EARLY-OUTS (do not shadow a working engine), and no podman is +# installed. That is right behavior, but it proves nothing about THIS kit. This +# check pins the NON-`-docker` `shell` template (VERIFY_OCI_IMAGE, default below) +# via acq's neutral --image: per the sandbox-templates docs it ships the Docker +# CLI but NO engine (dead socket) — the exact case this kit fixes and which does +# NOT early-out — so the ONLY thing that can produce a working rootless podman is +# this kit. The base still meets acq's msb prerequisite contract (non-root `agent` +# user, sudo, ca-certificates) and reaches an OS package mirror under the active +# egress tier — the kit installs podman from that mirror at create. Override with +# VERIFY_OCI_IMAGE=. +# +# NOTE: this is a LIVE check and needs a sandbox-capable host with `acq` on PATH +# and an msb backend ready (`acq --backend msb version`; `msb doctor`). It is NOT +# run in CI (CI cannot nest sandboxes), like the zscaler-ca-certificate kit's +# verify. The backend-agnostic gate is +# integrations/isolation/acq-kits/validate-kits.py (`make validate-kits`). +# +# It creates a temporary sandbox and removes it at the end (and on interrupt); it +# does not touch your other sandboxes. Set KEEP=1 to retain it for inspection. +# Nothing here is secret — podman needs no credentials to run rootless. +# +# Usage (from anywhere): +# path/to/oci-engine/scripts/verify +# KEEP=1 path/to/oci-engine/scripts/verify # keep the sandbox +# VERIFY_OCI_IMAGE= path/to/.../verify # use a different base + +set -uo pipefail + +KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SPEC_FILE="$KIT_DIR/spec.yaml" +WORK="$(mktemp -d "${HOME:-/tmp}/.oci-engine-verify.XXXXXX")" +AGENT="shell" +KEEP="${KEEP:-0}" + +# Base image for the check. We default to the `shell` sandbox template (NOTE: the +# plain `shell`, NOT `shell-docker`). Per the Docker sandbox-templates docs, the +# non-`-docker` variants ship the Docker *CLI* but NO Docker *Engine*/daemon — so +# `docker info` FAILS there (a dead socket), which is precisely the case this kit +# fixes (shadow the dead-socket docker CLI with a podman-backed wrapper) and which +# does NOT trigger the kit's functional-engine early-out. It also ships the +# non-root `agent` user + sudo the msb prerequisite contract needs, and `apt-get` +# for the kit's podman install. This is a realistic, engine-free base. +# +# Do NOT point this at a `*-docker` variant: those run a full Docker Engine +# (dockerd) inside the sandbox, so `docker info` SUCCEEDS, the kit correctly +# EARLY-OUTS (do not shadow a working engine), and the podman checks below would +# then fail — a false negative. Override with VERIFY_OCI_IMAGE=. +VERIFY_OCI_IMAGE="${VERIFY_OCI_IMAGE:-docker.io/docker/sandbox-templates:shell}" + +# Disable the msb adapter's legacy OCI auto-install so podman is attributable to +# THIS kit, not the adapter (see the header note / ADR-0030). Exported for every +# acq call below. +export ACQ_MSB_ENSURE_OCI=0 + +# The sandbox name acq derives from (agent + workspace basename). Derive it the +# way acq itself does by sourcing acq under ACQ_SOURCE_ONLY (acq resolves its own +# ACQ_SCRIPT_DIR and sources common.sh, which defines derive_name), so cleanup + +# exec address the exact sandbox acq creates. Fall back to a best-effort slug if +# the helper is unavailable. +ACQ_BIN="$(command -v acq || true)" +SBX_NAME="" +if [ -n "$ACQ_BIN" ]; then + SBX_NAME="$( + # shellcheck disable=SC1090 # acq path is discovered from PATH at runtime. + ACQ_SOURCE_ONLY=1 . "$ACQ_BIN" >/dev/null 2>&1 + derive_name "$AGENT" "$WORK" 2>/dev/null + )" +fi +if [ -z "$SBX_NAME" ]; then + SBX_NAME="${AGENT}-$(basename "$WORK" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]\{1,\}/-/g' -e 's/^-*//' -e 's/-*$//')" +fi + +pass=0 +fail=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); } +info() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +cleanup() { + if [ "$KEEP" = "1" ]; then + printf '\nKEEP=1 set; leaving sandbox %s and %s in place.\n' "$SBX_NAME" "$WORK" >&2 + return + fi + acq --backend msb rm "$SBX_NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +# Run a command in the sandbox via acq. acq's exec runs as the agent user (uid +# 1000, HOME=/home/agent) on msb, so this is already the rootless-agent path. +# +# CR-STRIP: msb's exec transport terminates guest output lines with CRLF, so a +# trailing \r rides into the captured value. Left in, it breaks exact-string +# compares (e.g. "/usr/local/bin/docker\r" != "/usr/local/bin/docker") and, when +# printed, the CR overwrites the start of the line. Strip all \r once here so +# every caller sees clean LF/plain text — same fix the quickstart's run_acq uses. +in_sbx() { acq --backend msb exec "$SBX_NAME" -- sh -c "$1" 2>/dev/null | tr -d '\r' || true; } + +info "0. Preconditions" +if ! command -v acq >/dev/null 2>&1; then + echo " acq not on PATH. This live check needs acq + a ready msb backend (skipped in CI)." >&2 + exit 1 +fi +if ! acq --backend msb version >/dev/null 2>&1; then + echo " msb backend not ready (acq --backend msb version failed). Run 'msb doctor'." >&2 + exit 1 +fi +echo " acq present; msb backend responds." +if [ -f "$SPEC_FILE" ]; then ok "kit spec.yaml present"; else bad "spec.yaml missing"; exit 1; fi + +info "1. Backend-agnostic gate (validate-kits) sees this kit" +# A convenience echo; the authoritative gate is `make validate-kits`. We only +# confirm the spec parses as the right kind/name here (cheap, no python dep). +if grep -q '^schemaVersion: "hybrid/v1"' "$SPEC_FILE" \ + && grep -q '^kind: mixin' "$SPEC_FILE" \ + && grep -q '^name: oci-engine' "$SPEC_FILE"; then + ok "spec.yaml is the oci-engine hybrid/v1 mixin" +else + bad "spec.yaml is not the expected hybrid/v1 oci-engine mixin" +fi + +info "2. Create an msb sandbox with the kit applied (adapter OCI auto-install OFF)" +echo " base image: $VERIFY_OCI_IMAGE (engine-free: docker CLI w/ dead socket, no podman)" +echo " creating $SBX_NAME (workspace $WORK; ACQ_MSB_ENSURE_OCI=0)..." +if acq --backend msb create "$AGENT" "$WORK" --image "$VERIFY_OCI_IMAGE" --kit "$KIT_DIR" \ + >"$WORK/acq-create.log" 2>&1; then + ok "sandbox created via acq with --kit" +else + bad "acq --backend msb create --kit failed (exit $?)" + sed 's/^/ | /' "$WORK/acq-create.log" >&2 + exit 1 +fi + +info "3. Kit success marker + podman installed (as the agent)" +# The install step writes /var/lib/acq/oci-engine-ready ONLY on a real success +# (state=configured) or a deliberate base-engine early-out (state=base-engine); +# the fail-soft paths do NOT touch it. Check it first so a "kit applied but did +# nothing" sandbox is legible to CI/operators (finding #1). +marker="$(in_sbx "cat /var/lib/acq/oci-engine-ready 2>/dev/null")" +if [ -n "$marker" ]; then + ok "kit success marker present (/var/lib/acq/oci-engine-ready)" + printf ' %s\n' "$(printf '%s' "$marker" | tr '\n' ';')" +else + bad "kit success marker ABSENT — the install fell soft (see create log below)" + echo " --- oci-engine lines from the create log (why the install did not land) ---" >&2 + grep -i 'oci-engine' "$WORK/acq-create.log" 2>/dev/null | sed 's/^/ | /' >&2 \ + || echo " | (no oci-engine lines captured in the create log)" >&2 +fi +# GATE: if the kit's install phase did not land podman, every later rootless +# check is moot — so this is a hard gate. On failure, dump the create log so the +# install failure (unreachable mirror, unsupported base, etc.) is visible instead +# of cascading into confusing downstream FAILs. The kit's install is fail-soft (it +# exits 0 so `sbx create` succeeds even when the mirror is unreachable), which is +# exactly why a missing podman is otherwise silent from the outside. +pv="$(in_sbx "podman version --format '{{.Client.Version}}' 2>/dev/null || podman --version 2>/dev/null")" +if [ -n "$pv" ]; then + ok "podman present (version: $pv)" +else + bad "podman NOT installed by the kit (see create log below)" + echo " --- oci-engine lines from the create log (why the install did not land) ---" >&2 + grep -i 'oci-engine' "$WORK/acq-create.log" 2>/dev/null | sed 's/^/ | /' >&2 \ + || echo " | (no oci-engine lines captured in the create log)" >&2 + echo " Skipping the remaining rootless checks (nothing to test without podman)." >&2 + info "Summary" + printf ' %d passed, %d failed\n' "$pass" "$fail" + printf ' \033[31mSome checks failed — see notes above.\033[0m\n' + exit 1 +fi + +info "4. Trusted root startup payloads are root-owned" +# The root startup command must not execute or source agent-home staging files on +# later boots. The install step publishes trusted copies under /usr/local/lib. +trusted="$(in_sbx 'for f in /usr/local/lib/acq /usr/local/lib/acq/oci-engine /usr/local/lib/acq/oci-engine/oci-engine-install.sh /usr/local/lib/acq/oci-engine/oci-engine-grant-devs.sh /usr/local/lib/acq/oci-engine/oci-engine-storage-driver.sh; do [ -e "$f" ] || { echo "missing:$f"; continue; }; stat -c "%u:%g %a %n" "$f" 2>/dev/null || echo "stat-failed:$f"; done')" +if printf '%s\n' "$trusted" | grep -q '^missing:'; then + bad "trusted root startup path missing" + printf ' %s\n' "$trusted" >&2 +elif printf '%s\n' "$trusted" | grep -q '^stat-failed:'; then + bad "could not stat trusted root startup path" + printf ' %s\n' "$trusted" >&2 +elif printf '%s\n' "$trusted" | grep -q '^0:0 755 /usr/local/lib/acq$' \ + && printf '%s\n' "$trusted" | grep -q '^0:0 755 /usr/local/lib/acq/oci-engine$' \ + && printf '%s\n' "$trusted" | grep -q '^0:0 755 /usr/local/lib/acq/oci-engine/oci-engine-install\.sh$' \ + && printf '%s\n' "$trusted" | grep -q '^0:0 755 /usr/local/lib/acq/oci-engine/oci-engine-grant-devs\.sh$' \ + && printf '%s\n' "$trusted" | grep -q '^0:0 644 /usr/local/lib/acq/oci-engine/oci-engine-storage-driver\.sh$'; then + ok "trusted startup path and payloads are root-owned under /usr/local/lib/acq/oci-engine" + printf ' %s\n' "$(printf '%s' "$trusted" | tr '\n' ';')" +else + bad "trusted startup path ownership/mode unexpected" + printf ' %s\n' "$trusted" >&2 +fi + +info "5. rootless podman info succeeds as the agent" +# Assert the engine is actually ROOTLESS. podman reports its rootless flag; we +# hard-FAIL if it is not `true`. If the format key differs across podman versions +# and comes back empty, fall back to asserting a non-zero uid inside the sandbox +# (the agent's `id -u` != 0) rather than silently passing. The verify MUST be +# able to fail when the engine is rootful. +if [ "$(in_sbx "podman info >/dev/null 2>&1 && echo ok")" = "ok" ]; then + ok "podman info succeeds as the agent" +else + bad "podman info failed as the agent (storage-driver or rootless setup issue)" + # Surface the actual error so a failure is diagnosable, not a black box. + info_err="$(acq --backend msb exec "$SBX_NAME" -- sh -c 'podman info 2>&1 1>/dev/null' 2>/dev/null | tr -d '\r' | head -n 6)" + [ -n "$info_err" ] && printf ' podman info stderr:\n%s\n' "$(printf '%s\n' "$info_err" | sed 's/^/ | /')" >&2 +fi +rootless="$(in_sbx "podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null")" +if [ "$rootless" = "true" ]; then + ok "podman reports rootless=true" +elif [ -z "$rootless" ]; then + # Format key unavailable on this podman version — fall back to a uid check. + agent_uid="$(in_sbx "id -u")" + if [ -n "$agent_uid" ] && [ "$agent_uid" != "0" ]; then + ok "rootless flag unavailable; agent runs as non-root uid $agent_uid (rootless)" + else + bad "cannot confirm rootless: no rootless flag AND agent uid is '$agent_uid' (expected non-zero)" + fi +else + bad "podman reports rootless=$rootless (expected true — engine is running ROOTFUL)" +fi + +info "6. real layer mount: podman build FROM scratch as the agent (rootless)" +# Stronger than `podman info`: this opens /dev/fuse and mounts a layer, so it +# catches the fuse-overlayfs/vfs storage trap ADR-0020 describes. No registry +# pull, no egress (FROM scratch). +built="$(in_sbx ' + d=$(mktemp -d) || exit 1 + printf "FROM scratch\nCOPY hi /hi\n" > "$d/Containerfile" + echo hi > "$d/hi" + if podman build -q -t oci-engine-verify:local "$d" >/dev/null 2>&1; then echo ok; fi + podman rmi -f oci-engine-verify:local >/dev/null 2>&1 || true + rm -rf "$d" +')" +if [ "$built" = "ok" ]; then + ok "podman build FROM scratch succeeded (rootless layer mount works)" +else + bad "podman build failed rootless (storage driver may not work on this base)" +fi + +info "7. docker -> podman wrapper (dropped by THIS kit) resolves on PATH" +# This is the most kit-ATTRIBUTABLE signal: the adapter's legacy path also aliased +# docker->podman, but with ACQ_MSB_ENSURE_OCI=0 the adapter is out of the picture, +# so a resolving /usr/local/bin/docker proves the kit's file-drop landed. +which_docker="$(in_sbx "command -v docker")" +if [ "$which_docker" = "/usr/local/bin/docker" ]; then + ok "docker resolves to the kit wrapper ($which_docker)" +else + bad "docker does not resolve to /usr/local/bin/docker (got: ${which_docker:-})" +fi +docker_routes="$(in_sbx "docker version --format '{{.Client.Version}}' 2>/dev/null || docker --version 2>/dev/null")" +if [ -n "$docker_routes" ]; then + ok "docker wrapper routes to podman (reported: $docker_routes)" +else + bad "docker wrapper did not produce a version (wrapper may not exec podman)" +fi + +info "Summary" +printf ' %d passed, %d failed\n' "$pass" "$fail" +if [ "$fail" -eq 0 ]; then + printf ' \033[32mAll checks passed.\033[0m\n' +else + printf ' \033[31mSome checks failed — see notes above.\033[0m\n' +fi + +[ "$fail" -eq 0 ] diff --git a/integrations/isolation/acq-kits/oci-engine/spec.yaml b/integrations/isolation/acq-kits/oci-engine/spec.yaml new file mode 100644 index 00000000..307d969f --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/spec.yaml @@ -0,0 +1,228 @@ +# spec.yaml — oci-engine (a neutral acq mixin kit, hybrid/v1) +# +# WHAT oci-engine IS: a rootless OCI container engine (podman) provided as a +# neutral, OPT-IN capability. Agents frequently need to run OCI images inside the +# sandbox (`docker run`, bringing up a docker-compose.yaml). This kit installs +# podman + its rootless prerequisites, configures a storage driver that works on +# an overlay root, grants the device nodes rootless podman needs, writes a +# Docker-Hub-first registry config, and shadows the base image's (dead-socket) +# docker CLI with a wrapper that routes to podman. +# +# WHY A KIT (ADR-0030 — "move the OCI engine out of the msb adapter into a neutral +# opt-in kit"): this provisioning used to live inside the msb backend adapter (in +# the sibling agentic-coding-quickstart repo). ADR-0030 moves it here so it is a +# neutral, backend-agnostic capability every acq backend can apply through the +# shared hybrid/v1 vocabulary, and so it is OFF by default — acq selects this kit +# only when the operator opts in via ACQ_ENABLE_OCI_KIT on the quickstart side. +# +# SECURITY POSTURE (ADR-0020 — "rootless podman OCI engine"): +# - The engine RUNS ROOTLESS as the agent, never root. Only the install/config +# step needs root; the `startup` device-grant step group-scopes /dev/net/tun +# and /dev/fuse to the agent so the unprivileged agent can use them. +# - NO docker-in-docker daemon lifecycle: podman is daemonless (forks runc/crun +# per invocation), so there is no socket and no supervisor to keep alive. +# - Explicit, minimal device/capability handling (the two device nodes above, +# group-scoped 0660 — not world-writable). +# - Deterministic Docker-Hub-first registry resolution with +# short-name-mode = "enforcing" as the DEFAULT (least-privilege / +# prompt-injection defense): unqualified names resolve deterministically to +# Docker Hub, and interactively-ambiguous short names fail closed instead of +# being silently substituted (image-substitution / typosquatting defense). We +# deliberately do NOT default to "permissive". +# +# NEUTRAL (backend-agnostic) hybrid/v1 form, structured like the sibling kits +# (prime-agent / usai-provider). acq's kit-translate layer maps the declarative +# caps/files/commands/environment below onto whichever isolation backend acq has +# active (sbx, msb) — this kit carries no backend-specific config and no backend +# shortcut (podman has no native primitive on either backend; see README parity). +schemaVersion: "hybrid/v1" +kind: mixin +name: oci-engine +displayName: OCI Engine (rootless podman) +description: > + Provides a rootless OCI container engine (podman) inside the sandbox so agents + can run OCI images (docker run / docker compose). Installs podman and the + rootless prerequisites at create time, selects a storage driver that works on + an overlay root, grants the required device nodes on every start, and aliases + docker -> podman. Docker-Hub-first registry resolution with + short-name-mode=enforcing by default (least-privilege / prompt-injection + defense, ADR-0020). Opt-in mixin selected by acq only when ACQ_ENABLE_OCI_KIT + is set; the engine runs rootless as the agent, never as root. + +# EGRESS. Two distinct needs, handled differently on purpose: +# +# 1) The OS package MIRROR (apt/dnf/apk) the create-time install pulls podman +# from. The mirror host is DISTRO-DEPENDENT (archive/ports/security.ubuntu.com, +# *.debian.org, dl-cdn.alpinelinux.org, ...) and is ALREADY in acq's DEFAULT +# balanced egress baseline `core` set (isolation ADR-0002 / balanced.yaml). +# Hardcoding one distro's mirror here would be wrong for the others and +# redundant with the baseline, so it is NOT listed below. Under a narrower tier +# (e.g. ACQ_NETWORK_TIER=strict) the mirror is unreachable and the install +# fails soft (warning; provision continues; OCI unavailable) — the operator +# then widens egress or bakes podman into the base image. +# +# 2) The CONTAINER REGISTRY the kit's PURPOSE requires — pulling images at runtime +# (`docker run` / `docker compose`). Unlike the distro mirror this is NOT +# distro-dependent: it is always Docker Hub (the kit's Docker-Hub-first +# default). We DECLARE these explicitly in caps.network.allow so the runtime +# works even on a `strict` tier that carries no baseline — a kit should name +# the egress its own function needs rather than silently depend on a baseline +# (finding #5). deny-by-default is preserved: these are an allow-LIST. +# +# No entry begins with a leading `*` (YAML would parse a bare leading-`*` host as +# an alias and break the translated spec — the documented alias trap). +caps: + network: + allow: + # Docker Hub registry API (image manifests) + token auth. + - registry-1.docker.io + - auth.docker.io + - index.docker.io + # Docker Hub image blob CDNs (layer downloads are redirected here). + - production.cloudflare.docker.com + - production.cloudfront.docker.com + +files: + # Staged payloads. Backends materialize files[] in the agent home, so root code + # that runs on every boot must not execute or source these paths directly after + # the agent has had a chance to modify them. The install step copies the trusted + # root startup script and helper into /usr/local/lib/acq/oci-engine/ as root-owned + # files; the startup command below executes only that trusted copy. + - path: /home/agent/oci-engine-install.sh + mode: "0755" + source: files/home/oci-engine-install.sh + description: Install + configure rootless podman (create-time, root). Idempotent, fails soft. + - path: /home/agent/oci-engine-grant-devs.sh + mode: "0755" + source: files/home/oci-engine-grant-devs.sh + description: Staged source for the root-owned startup grant script installed under /usr/local/lib/acq/oci-engine/. + - path: /home/agent/oci-engine-vfs-fallback.sh + mode: "0755" + source: files/home/oci-engine-vfs-fallback.sh + description: Rootless self-test; on failure write a user-level vfs storage.conf (every start, agent). Idempotent, fails soft. + # Config payloads staged in the agent home, then installed into root-owned paths + # by the root install script (the stage-in-home-then-move-as-root pattern the + # zscaler-ca-certificate kit uses: uid 1000 cannot write root-owned destinations + # directly). Mode 0644 for config data; 0755 for the wrapper (it is exec'd as a + # program). These are NOT referenced in commands[] argv — the install script + # reads them — so they need no matching command entry. + - path: /home/agent/oci-engine-config/01-acq-shortnames.conf + mode: "0644" + source: files/home/oci-engine-config/01-acq-shortnames.conf + description: podman short-name aliases (hello/hello-world -> docker.io). Installed to /etc/containers/registries.conf.d/. + - path: /home/agent/oci-engine-config/oci-engine-storage-driver.sh + mode: "0644" + source: files/home/oci-engine-config/oci-engine-storage-driver.sh + description: Shared helper sourced by install + grant steps to (re)select the podman storage driver. Convergent vfs->overlay. + - path: /home/agent/oci-engine-config/docker + mode: "0755" + source: files/home/oci-engine-config/docker + description: docker->podman wrapper. Installed to /usr/local/bin/docker (shadows the dead-socket base docker CLI). + +commands: + # INSTALL phase — create-time, once, as root (uid "0"). Publishes the staged + # install script into /usr/local/lib/acq/oci-engine/ and executes that root-owned + # copy. If the base already has a FUNCTIONAL container engine (a clean + # `docker info`), it EARLY-OUTS and leaves the base alone (do not shadow a + # working engine). Otherwise it installs podman + the rootless prerequisites and + # writes the storage/registry config + docker wrapper. The config + wrapper run + # whenever podman ends up present (installed by the kit OR pre-existing), not + # only when the kit ran the install. Idempotent and best-effort (a missing mirror + # or unsupported base exits 0 with a clear message rather than aborting + # provision). + - phase: install + user: "0" + description: Install + configure the rootless podman OCI engine (create-time, root). + command: + - sh + - -c + - | + if mkdir -p /usr/local/lib/acq/oci-engine \ + && chown root:root /usr/local/lib/acq /usr/local/lib/acq/oci-engine \ + && chmod 0755 /usr/local/lib/acq /usr/local/lib/acq/oci-engine \ + && install -m 0755 /home/agent/oci-engine-install.sh /usr/local/lib/acq/oci-engine/oci-engine-install.sh; then + exec sh /usr/local/lib/acq/oci-engine/oci-engine-install.sh + fi + echo "oci-engine: WARNING: could not publish trusted install script; OCI setup unavailable." >&2 + exit 0 + # STARTUP phase — every start, as root (uid "0"). Executes the root-owned copy + # installed under /usr/local/lib/acq/oci-engine/ during create, not an + # agent-writable staging file. GATED ON PODMAN: revokes any stale device grant + # and exits if podman is absent (install fell soft, or the base had its own + # engine and the kit early-outed) — no engine, no device grant (finding #3). + # Otherwise re-grants /dev/net/tun + /dev/fuse to the agent (/dev is a devtmpfs + # re-created each boot, so the create-time grant is lost on restart) AND + # re-evaluates the system storage driver so a transient /dev/fuse miss at create + # is not permanently pinned to vfs (finding #4). Cheap + idempotent; runs to + # completion (NOT background: true). Schema note: `user` is a numeric uid + # (^[0-9]+$); "0" is root, which the chown, /dev/net create, and + # /etc/containers write require. + - phase: startup + user: "0" + description: If podman present, re-grant device nodes + re-evaluate the storage driver (every start, root). + command: + - sh + - -c + - | + if [ -f /usr/local/lib/acq/oci-engine/oci-engine-grant-devs.sh ]; then + exec sh /usr/local/lib/acq/oci-engine/oci-engine-grant-devs.sh + fi + echo "oci-engine: trusted startup script missing; skipping device grant + storage re-eval." >&2 + exit 0 + # STARTUP phase — every start, as the AGENT user (uid "1000"). Reproduces the + # reference adapter's rootless recovery path: run a real rootless layer-mount + # self-test (podman build FROM scratch) and, if it fails, write a user-level + # ~/.config/containers/storage.conf selecting the vfs driver (the documented + # fallback for a base whose overlay+fuse-overlayfs combo is rejected under + # rootless — where `podman info` passes but a layer mount fails). Runs as the + # agent because it owns ~/.config, and after the root device grant above. + # Idempotent (only writes on a failed build when no user driver is set) and + # fails soft (always exits 0 — a failed self-test must not abort the boot). + # NOT background: true (runs to completion). Schema note: `user` is a numeric + # uid (^[0-9]+$); "1000" is the agent (same convention as the sibling kits). + - phase: startup + user: "1000" + description: Rootless self-test; write a user-level vfs storage.conf fallback on failure (every start, agent). + command: + - sh + - /home/agent/oci-engine-vfs-fallback.sh + +# NO publishedPorts / volumes: podman needs neither. It is a CLI engine (no web +# UI to publish) and uses fuse-overlayfs/vfs on the existing overlay root (no +# dedicated block device — the whole point of choosing podman over docker:dind). + +# Non-secret guest env knobs. NO secrets (podman needs none to run rootless). +# OCI_ENGINE_PKGS — override the installed package set / mirror names. +# OCI_ENGINE_SHORT_NAME_MODE — enforcing (default) | permissive | disabled; +# "permissive" REMOVES the typosquatting guardrail +# and is an explicit operator opt-out (ADR-0020). +# OCI_ENGINE_AGENT_USER — the unprivileged user the engine runs as (for a +# base whose agent account is not named "agent"). +# OCI_ENGINE_SELFTEST_TIMEOUT — seconds to bound the per-boot rootless self-test +# build (default 120) so a wedged mount cannot hang +# the boot sequence (finding #6). +environment: + OCI_ENGINE_PKGS: "podman podman-compose fuse-overlayfs uidmap passt slirp4netns" + OCI_ENGINE_SHORT_NAME_MODE: "enforcing" + OCI_ENGINE_AGENT_USER: "agent" + OCI_ENGINE_SELFTEST_TIMEOUT: "120" + +agentContext: | + ## OCI Engine (rootless podman) + + This sandbox has a rootless **podman** OCI engine installed, and `docker` + aliases to it — so `docker run …`, `docker build …`, and `docker compose …` + all work (they route to podman; `docker compose` uses the podman-compose + provider). The engine runs **rootless as you (the agent)**, never as root, and + is daemonless (no dockerd, no socket to start). + + Unqualified image names resolve to **Docker Hub** by default. Short-name + resolution is `enforcing`: an ambiguous unqualified name fails closed rather + than being silently substituted — prefer fully-qualified names (e.g. + `docker.io/library/nginx`) when in doubt. This is a deliberate least-privilege + / prompt-injection defense (ADR-0020), not a bug. + +# No backend_shortcuts / backend_extras: podman has no native primitive on either +# sbx or msb, so this kit is written entirely in the neutral vocabulary (caps / +# files / commands / environment), which acq's kit translation feeds to whichever +# backend is active. See the README parity note. diff --git a/scripts/tests/test_oci_engine_vfs_fallback.py b/scripts/tests/test_oci_engine_vfs_fallback.py new file mode 100644 index 00000000..86b8113a --- /dev/null +++ b/scripts/tests/test_oci_engine_vfs_fallback.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-vfs-fallback.sh" + + +def _fake_podman(fake_bin: Path, podman_log: Path) -> None: + podman = fake_bin / "podman" + podman.write_text( + '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$PODMAN_LOG"\nexit 0\n', + encoding="utf-8", + ) + podman.chmod(0o755) + + +def test_vfs_fallback_skips_build_when_timeout_is_unavailable(tmp_path): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + podman_log = tmp_path / "podman.log" + _fake_podman(fake_bin, podman_log) + + env = os.environ.copy() + env.update( + { + "HOME": str(tmp_path / "home"), + "PATH": str(fake_bin), + "PODMAN_LOG": str(podman_log), + } + ) + + result = subprocess.run(["/bin/sh", str(SCRIPT)], env=env, text=True, capture_output=True, check=False) + + assert result.returncode == 0 + assert "timeout command unavailable" in result.stderr + assert not podman_log.exists() + + +@pytest.mark.parametrize("timeout_value", ["", "0", "00", "000", "abc"]) +def test_vfs_fallback_rejects_invalid_timeout_values(tmp_path, timeout_value): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + podman_log = tmp_path / "podman.log" + timeout_log = tmp_path / "timeout.log" + _fake_podman(fake_bin, podman_log) + timeout = fake_bin / "timeout" + timeout.write_text( + '#!/bin/sh\nprintf \'%s\\n\' "$1" >> "$TIMEOUT_LOG"\nshift\nexec "$@"\n', + encoding="utf-8", + ) + timeout.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "HOME": str(tmp_path / "home"), + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "PODMAN_LOG": str(podman_log), + "TIMEOUT_LOG": str(timeout_log), + "OCI_ENGINE_SELFTEST_TIMEOUT": timeout_value, + } + ) + + result = subprocess.run(["/bin/sh", str(SCRIPT)], env=env, text=True, capture_output=True, check=False) + + assert result.returncode == 0 + assert timeout_log.read_text(encoding="utf-8").splitlines() == ["120"] + assert "build -q -t oci-engine-selftest:local" in podman_log.read_text(encoding="utf-8")