From 96142e35f63a3ed3c53f277b9bc42631c2ca0400 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 21 Sep 2026 21:59:39 +0000 Subject: [PATCH 01/10] feat(oci-engine): add rootless podman OCI-engine capability kit Add the patterns-side `oci-engine` neutral acq mixin kit (schemaVersion "hybrid/v1", kind mixin, name oci-engine) under integrations/isolation/acq-kits/oci-engine/. It provides a rootless container engine (podman) as a neutral, opt-in capability, implementing the isolation ADR-0030 decision to move that provisioning out of the msb backend adapter (sibling agentic-coding-quickstart repo) and into a backend-agnostic kit. acq applies it only when the operator opts in via ACQ_ENABLE_OCI_KIT on the quickstart side. The kit ports the working msb-adapter logic into the neutral vocabulary: an install-phase script (root, create-time once) installs podman + the rootless prerequisites, selects a storage driver that works on an overlay root (overlay+fuse-overlayfs, else vfs), writes a Docker-Hub-first registry config, and shadows the dead-socket base docker CLI with a docker->podman wrapper; a startup-phase script (root, every start) re-grants /dev/net/tun + /dev/fuse to the agent because /dev is a devtmpfs re-created each boot. Both scripts are idempotent, POSIX sh, and fail soft. Preserves the ADR-0020 security decisions: the engine runs rootless as the agent (never root; only install/config needs root), no docker-in-docker daemon lifecycle, explicit group-scoped (0660, not world-writable) device handling, and Docker-Hub-first registry resolution with short-name-mode="enforcing" as the DEFAULT (least-privilege / prompt-injection defense) rather than permissive. Declares no caps.network.allow: the OS package-mirror host depends on the base distro and is already reachable under acq's balanced egress baseline (ADR-0002), so hardcoding a distro mirror would be wrong and redundant; a strict tier fails the install soft. Ships a host-side scripts/verify live check (rootless podman build FROM scratch + docker->podman wrapper), skipped in CI. Registers the kit in kits.yaml with a prose parity note. Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/kits.yaml | 22 +++ .../isolation/acq-kits/oci-engine/README.md | 112 +++++++++++++ .../oci-engine-config/01-acq-shortnames.conf | 10 ++ .../files/home/oci-engine-config/docker | 13 ++ .../files/home/oci-engine-grant-devs.sh | 65 ++++++++ .../files/home/oci-engine-install.sh | 136 +++++++++++++++ .../acq-kits/oci-engine/scripts/verify | 139 ++++++++++++++++ .../isolation/acq-kits/oci-engine/spec.yaml | 156 ++++++++++++++++++ 8 files changed, 653 insertions(+) create mode 100644 integrations/isolation/acq-kits/oci-engine/README.md create mode 100644 integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/01-acq-shortnames.conf create mode 100755 integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/docker create mode 100755 integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-grant-devs.sh create mode 100755 integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-install.sh create mode 100755 integrations/isolation/acq-kits/oci-engine/scripts/verify create mode 100644 integrations/isolation/acq-kits/oci-engine/spec.yaml diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 2805e347..783cb414 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..df9e02d2 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -0,0 +1,112 @@ +# 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: + +- 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`. + +On **every start** (the `startup` phase, run as root) 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. `/dev` is a devtmpfs +re-created each boot, so this must re-run on restart, not just at create. + +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. + +## 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`). No new host attack surface is opened: this is all inside the sandbox, + which is itself the security boundary. +- **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 + +This kit declares **no `caps.network.allow`**. The install pulls packages from +the OS package **mirror**, whose host depends on the base distro. Under acq's +default **balanced** egress tier (isolation ADR-0002; see +`integrations/isolation/network-tiers/balanced.yaml`) the OS-package-mirror hosts +are already in the curated `core` baseline, so no kit-level egress is needed for +the common case — and hardcoding one distro's mirror host would be wrong for the +others. With `ACQ_NETWORK_TIER=strict` (kit hosts only) or a narrowed base, the +mirror is unreachable and the install fails soft; the operator then widens egress +or bakes the packages 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 sandbox with this kit applied and confirms +rootless podman actually works (a real `podman build` from `scratch` as the +agent, plus that the `docker` -> `podman` wrapper resolves). It needs a +sandbox-capable host (`sbx` installed and logged in) and is **skipped in CI** +(CI cannot nest sandboxes), exactly 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 +``` + +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/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-grant-devs.sh b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-grant-devs.sh new file mode 100755 index 00000000..2f7842f6 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-grant-devs.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# oci-engine-grant-devs.sh — grant the agent the device nodes rootless podman +# needs, 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. +# +# 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. +# +# 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 + +# 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 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..a80813ee --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-install.sh @@ -0,0 +1,136 @@ +#!/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 + +# Charset-guard the package set: it is passed to the package manager unquoted +# (word-splitting is intentional — it is a space-separated list). Package names +# are word-safe (letters, digits, . _ + - and spaces); refuse anything else +# rather than risk a surprise token reaching the elevated install. +case "$OCI_ENGINE_PKGS" in + *[!A-Za-z0-9._+\ -]*) + echo "oci-engine: WARNING: OCI_ENGINE_PKGS contains unsafe characters; skipping OCI setup." >&2 + exit 0 + ;; +esac + +# Where the kit staged its config payloads (dropped by files[] as the agent user, +# then installed into /etc/containers here by root). Same stage-in-home-then- +# move-as-root pattern the zscaler-ca-certificate kit uses. +STAGE_DIR="/home/agent/oci-engine-config" + +# 1) Ensure the podman binary is present (idempotent; distro-detected). +if ! command -v podman >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + 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 + elif command -v dnf >/dev/null 2>&1; then + # shellcheck disable=SC2086 + dnf install -y $OCI_ENGINE_PKGS + elif command -v apk >/dev/null 2>&1; then + # shellcheck disable=SC2086 + apk add --no-cache $OCI_ENGINE_PKGS + 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 + +if ! command -v podman >/dev/null 2>&1; then + echo "oci-engine: WARNING: podman still not on PATH after install; OCI images unavailable." >&2 + echo "oci-engine: the OS package mirror may be unreachable (see README egress note)." >&2 + exit 0 +fi + +# 2) Select a storage driver that works on an overlay ROOT filesystem. Only write +# the config if none already names a driver (idempotent; do not clobber an +# operator file). Prefer overlay+fuse-overlayfs (fast, thin on disk); else vfs +# (works everywhere, no /dev/fuse, but disk-heavy — a full copy per layer). +mkdir -p /etc/containers +if ! grep -q '^[[:space:]]*driver' /etc/containers/storage.conf 2>/dev/null; then + _fuse="" + for _c in /usr/bin/fuse-overlayfs /usr/local/bin/fuse-overlayfs /bin/fuse-overlayfs; do + if [ -x "$_c" ]; then _fuse="$_c"; break; fi + done + if [ -z "$_fuse" ] && command -v fuse-overlayfs >/dev/null 2>&1; then + _fuse="$(command -v fuse-overlayfs)" + fi + if [ -n "$_fuse" ] && [ -e /dev/fuse ]; then + printf '[storage]\ndriver = "overlay"\n[storage.options.overlay]\nmount_program = "%s"\n' \ + "$_fuse" > /etc/containers/storage.conf + else + printf '[storage]\ndriver = "vfs"\n' > /etc/containers/storage.conf + fi + unset _fuse _c +fi + +# 3) 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 + +# 4) 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 + +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/scripts/verify b/integrations/isolation/acq-kits/oci-engine/scripts/verify new file mode 100755 index 00000000..e850b714 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/scripts/verify @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# +# verify — host-side live validation for the oci-engine acq mixin kit. +# +# Creates a throwaway sandbox with this kit applied and confirms that a ROOTLESS +# podman OCI engine actually works the way an agent uses it: +# - `podman version` / `podman info` succeed as the agent user (rootless); +# - a real `podman build` FROM scratch succeeds (this is 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); and +# - the `docker` -> `podman` wrapper resolves on PATH. +# +# NOTE (hybrid/v1): this is a LIVE check and needs a sandbox-capable host with +# `sbx` installed and logged in. It is NOT run in CI (CI cannot nest sandboxes), +# exactly 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 named "oci-engine-verify-*" and removes it at +# the end (and on interrupt); it does not touch your other sandboxes. Set KEEP=1 +# to retain the sandbox 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 + +set -uo pipefail + +KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SPEC_FILE="$KIT_DIR/spec.yaml" +SBX_NAME="oci-engine-verify-$$" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/oci-engine-verify.XXXXXX")" +CREATE_LOG="$WORK/sbx-create.log" +KEEP="${KEEP:-0}" + +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 + sbx rm -f "$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 AS THE AGENT USER (rootless is the whole point). +in_sbx_agent() { sbx exec "$SBX_NAME" -- sh -c "$1" /dev/null || true; } + +info "0. Preconditions" +if ! command -v sbx >/dev/null 2>&1; then + echo " sbx not on PATH. Install sbx and retry (this live check is skipped in CI)." >&2 + exit 1 +fi +echo " sbx version: $(sbx version 2>/dev/null | grep -oE 'v?[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1)" +if [ -f "$SPEC_FILE" ]; then ok "kit spec.yaml present"; else bad "spec.yaml missing"; exit 1; fi + +info "1. sbx kit validate" +if sbx kit validate "$KIT_DIR" >/dev/null 2>&1; then ok "kit validates"; else bad "kit failed validation"; fi + +info "2. Create a sandbox with the kit" +echo " creating $SBX_NAME (workspace $WORK)..." +agent="shell" +if ! sbx create --name "$SBX_NAME" --kit "$KIT_DIR" "$agent" "$WORK" >"$CREATE_LOG" 2>&1; then + agent="opencode" + if sbx create --name "$SBX_NAME" --kit "$KIT_DIR" "$agent" "$WORK" >"$CREATE_LOG" 2>&1; then + ok "sandbox created with --kit (agent: $agent)" + else + bad "sbx create failed (exit $?)" + sed 's/^/ | /' "$CREATE_LOG" >&2 + exit 1 + fi +else + ok "sandbox created with --kit (agent: $agent)" +fi +sleep 2 + +info "3. podman is installed and reports version (as the agent)" +pv="$(in_sbx_agent "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 runnable as the agent"; fi + +info "4. rootless podman info succeeds as the agent" +# rootlessness: podman reports its security rootless flag. We accept a non-empty +# 'true' but do not hard-fail if the format key differs across podman versions — +# the authoritative rootless proof is the build in step 5 running as the agent. +rootless="$(in_sbx_agent "podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null")" +if [ "$(in_sbx_agent "podman info >/dev/null 2>&1 && echo ok")" = "ok" ]; then + ok "podman info succeeds (rootless=${rootless:-unknown})" +else + bad "podman info failed as the agent (storage-driver or rootless setup issue)" +fi + +info "5. 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_agent ' + 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 "6. docker -> podman wrapper resolves on PATH" +which_docker="$(in_sbx_agent "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_agent "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..56a74fc3 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/spec.yaml @@ -0,0 +1,156 @@ +# 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 (deny-by-default): deliberately NO caps.network.allow here. +# +# The install pulls podman + the rootless prerequisites from the OS package +# MIRROR (apt/dnf/apk). The correct mirror host depends on the base distro +# (archive.ubuntu.com / ports.ubuntu.com / security.ubuntu.com for Ubuntu, +# *.debian.org for Debian, dl-cdn.alpinelinux.org for Alpine, ...), and under +# acq's DEFAULT balanced egress baseline (isolation ADR-0002 / balanced.yaml) +# those OS-package-mirror hosts are ALREADY reachable — they are in the curated +# `core` baseline. Hardcoding a single distro's mirror here would be (a) wrong for +# the other distros and (b) redundant with the baseline for the common case. +# +# So this kit adds NO kit-level egress and relies on the balanced baseline. With +# ACQ_NETWORK_TIER=strict (kit hosts only) or a base whose egress is otherwise +# narrowed, the mirror is unreachable and the install fails soft (a clear +# warning; provision continues; OCI is simply unavailable) — the operator then +# widens egress or bakes the packages into the base image. This mirrors the +# fail-soft behavior of the original adapter implementation (ADR-0020/ADR-0030). +# No leading-`*` host would appear here anyway (YAML alias trap), since there are +# no entries. + +files: + # Lifecycle scripts. Mode 0755 (executable); each backend's file-drop + # materializes them at the in-guest path as the agent user. + - 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: Group-scope /dev/net/tun + /dev/fuse to the agent (every start, root). Idempotent. + # Config payloads staged in the agent home, then installed into /etc/containers + # by the root install script (the stage-in-home-then-move-as-root pattern the + # zscaler-ca-certificate kit uses: uid 1000 cannot write the root-owned + # /etc/containers dir 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/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"). Installs podman + the + # rootless prerequisites and writes the storage/registry config + docker + # wrapper. 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 + - /home/agent/oci-engine-install.sh + # STARTUP phase — every start, as root (uid "0"). 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 must be re-applied. Cheap + + # idempotent; runs to completion (NOT background: true — it is not a supervisor + # loop). Schema note: the hybrid/v1 `user` field is a numeric uid (^[0-9]+$); + # "0" is root, which both the chown and the /dev/net directory create require. + - phase: startup + user: "0" + description: Re-grant the rootless-podman device nodes (/dev/net/tun, /dev/fuse) on every start. + command: + - sh + - /home/agent/oci-engine-grant-devs.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"). +environment: + OCI_ENGINE_PKGS: "podman podman-compose fuse-overlayfs uidmap passt slirp4netns" + OCI_ENGINE_SHORT_NAME_MODE: "enforcing" + OCI_ENGINE_AGENT_USER: "agent" + +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. From 3d9010bb71d4f2efe1dd94b91a0a6a1a9959653b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 21 Sep 2026 22:16:44 +0000 Subject: [PATCH 02/10] fix(oci-engine): fail-soft install, rootless vfs fallback, real rootless verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three findings from adversarial review of the oci-engine kit. Fix 1 (blocker) — make the install phase FAIL SOFT. `set -eu` plus bare apt-get/dnf/apk calls aborted the install script on the first non-zero return, which fails `sbx create` (a dead sandbox) — the opposite of the documented fail-soft promise. Run each distro install inside a subshell under its own `set -e` and swallow the exit; a failure now falls through to the single warn-and-exit-0 `command -v podman` re-check so provision continues. Verified by stubbing failing package managers on PATH: the script prints the warning and exits 0. Fix 2 — restore the reference adapter's rootless recovery. Add a second startup step run as the agent (uid 1000), files/home/oci-engine-vfs-fallback.sh: a real rootless layer-mount self-test (podman build FROM scratch) and, on failure, a user-level ~/.config/containers/storage.conf selecting vfs, then retry. Idempotent and fails soft (always exits 0). Covers bases whose overlay+fuse-overlayfs combo is rejected under rootless, where `podman info` passes but a layer mount fails. Fix 3 — make scripts/verify actually prove rootless. Run the agent-phase checks with an explicit `-u agent` (matching the reference), and hard-fail step 4 when podman does not report rootless=true (falling back to a non-zero agent uid assertion when the format key is unavailable) instead of printing PASS regardless. Also add oci-engine/docs/decisions/ recording the ADR-0030 port and the fail-soft + vfs-fallback behavioral choices, matching the other kits. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/oci-engine/README.md | 16 +++- ...rt-oci-engine-failsoft-and-vfs-fallback.md | 75 ++++++++++++++++ .../files/home/oci-engine-install.sh | 41 +++++++-- .../files/home/oci-engine-vfs-fallback.sh | 87 +++++++++++++++++++ .../acq-kits/oci-engine/scripts/verify | 30 +++++-- .../isolation/acq-kits/oci-engine/spec.yaml | 21 +++++ 6 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 integrations/isolation/acq-kits/oci-engine/docs/decisions/port-oci-engine-failsoft-and-vfs-fallback.md create mode 100755 integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-vfs-fallback.sh diff --git a/integrations/isolation/acq-kits/oci-engine/README.md b/integrations/isolation/acq-kits/oci-engine/README.md index df9e02d2..78ab52ef 100644 --- a/integrations/isolation/acq-kits/oci-engine/README.md +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -31,9 +31,23 @@ On **every start** (the `startup` phase, run as root) it group-scopes podman networking and the fuse-overlayfs storage mount work. `/dev` is a devtmpfs re-created each boot, so this must re-run on restart, not just at create. +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). If that 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. +(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. See `docs/decisions/`. ## Rootless / security posture (ADR-0020) 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..32ce4ba4 --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/docs/decisions/port-oci-engine-failsoft-and-vfs-fallback.md @@ -0,0 +1,75 @@ +# 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. 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 index a80813ee..a31c0c98 100755 --- 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 @@ -64,18 +64,40 @@ esac STAGE_DIR="/home/agent/oci-engine-config" # 1) 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 before the +# fail-soft re-check below is ever reached. So we run the whole install in a +# SUBSHELL under its own `set -e` and swallow its exit: any failure inside falls +# through to the single fail-soft exit at the `command -v podman` re-check, which +# prints a clear warning and exits 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 - 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 + ( 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 - # shellcheck disable=SC2086 - dnf install -y $OCI_ENGINE_PKGS + ( 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 - # shellcheck disable=SC2086 - apk add --no-cache $OCI_ENGINE_PKGS + ( 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 @@ -83,6 +105,9 @@ if ! command -v podman >/dev/null 2>&1; then fi fi +# Single fail-soft exit: if podman is still absent (install failed or the mirror +# was unreachable), warn and exit 0 so `sbx create` succeeds — the sandbox is +# usable, it just cannot run OCI images. if ! command -v podman >/dev/null 2>&1; then echo "oci-engine: WARNING: podman still not on PATH after install; OCI images unavailable." >&2 echo "oci-engine: the OS package mirror may be unreachable (see README egress note)." >&2 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..acdd19cb --- /dev/null +++ b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-vfs-fallback.sh @@ -0,0 +1,87 @@ +#!/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). +_oci_selftest() { + _d="$(mktemp -d)" || return 1 + printf 'FROM scratch\nCOPY hi /hi\n' > "$_d/Containerfile" + echo hi > "$_d/hi" + podman build -q -t oci-engine-selftest:local "$_d" >/dev/null 2>&1 + _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 index e850b714..8f81eda9 100755 --- a/integrations/isolation/acq-kits/oci-engine/scripts/verify +++ b/integrations/isolation/acq-kits/oci-engine/scripts/verify @@ -51,7 +51,10 @@ cleanup() { trap cleanup EXIT INT TERM # Run a command in the sandbox AS THE AGENT USER (rootless is the whole point). -in_sbx_agent() { sbx exec "$SBX_NAME" -- sh -c "$1" /dev/null || true; } +# The explicit `-u agent` matches the reference adapter's rootless verify: without +# it, `sbx exec` may run as root and the "rootless" checks would silently exercise +# the wrong path. +in_sbx_agent() { sbx exec "$SBX_NAME" -u agent -- sh -c "$1" /dev/null || true; } info "0. Preconditions" if ! command -v sbx >/dev/null 2>&1; then @@ -86,15 +89,30 @@ pv="$(in_sbx_agent "podman version --format '{{.Client.Version}}' 2>/dev/null || if [ -n "$pv" ]; then ok "podman present (version: $pv)"; else bad "podman not runnable as the agent"; fi info "4. rootless podman info succeeds as the agent" -# rootlessness: podman reports its security rootless flag. We accept a non-empty -# 'true' but do not hard-fail if the format key differs across podman versions — -# the authoritative rootless proof is the build in step 5 running as the agent. -rootless="$(in_sbx_agent "podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null")" +# 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_agent "podman info >/dev/null 2>&1 && echo ok")" = "ok" ]; then - ok "podman info succeeds (rootless=${rootless:-unknown})" + ok "podman info succeeds as the agent" else bad "podman info failed as the agent (storage-driver or rootless setup issue)" fi +rootless="$(in_sbx_agent "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_agent "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 "5. 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 diff --git a/integrations/isolation/acq-kits/oci-engine/spec.yaml b/integrations/isolation/acq-kits/oci-engine/spec.yaml index 56a74fc3..65a4a641 100644 --- a/integrations/isolation/acq-kits/oci-engine/spec.yaml +++ b/integrations/isolation/acq-kits/oci-engine/spec.yaml @@ -80,6 +80,10 @@ files: mode: "0755" source: files/home/oci-engine-grant-devs.sh description: Group-scope /dev/net/tun + /dev/fuse to the agent (every start, root). Idempotent. + - 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 /etc/containers # by the root install script (the stage-in-home-then-move-as-root pattern the # zscaler-ca-certificate kit uses: uid 1000 cannot write the root-owned @@ -118,6 +122,23 @@ commands: command: - sh - /home/agent/oci-engine-grant-devs.sh + # 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 From 1bc9cb64eb2211ac4f0124caf85650da89b94d7b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 22 Sep 2026 05:07:01 +0000 Subject: [PATCH 03/10] test(oci-engine): make scripts/verify prove the kit on msb via acq, adapter OCI off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the kit's host-side live check so it demonstrates the kit — not the msb adapter's legacy auto-install — actually provisions rootless podman. - Switch from raw `sbx create --kit` to the real path: `acq --backend msb create shell --kit ` + `acq exec` (which runs as the agent user), exercising kit-translate + kit apply end-to-end. msb-only, since msb is acq's default backend and the backend that demonstrates OCI behavior (ADR-0020); sbx base-image selection is out of scope. - Export ACQ_MSB_ENSURE_OCI=0 for the run so acq's still-active adapter auto-install cannot mask a broken kit with a false pass. With it off, podman and especially the /usr/local/bin/docker wrapper are attributable to the kit. When the adapter auto-install is removed (ADR-0030), the export becomes a no-op and this stays the standing regression test. - Derive the sandbox name the way acq does (source acq under ACQ_SOURCE_ONLY, call derive_name) for correct exec/cleanup, with a slug fallback. - Keep the rootless assertions (rootless=true, else non-zero agent uid) and the FROM-scratch layer-mount build. Update README's Verifying section to match. Companion quickstart work (add ACQ_ENABLE_OCI_KIT gate, retire the adapter's OCI auto-install, repoint verify-backends) is tracked in the quickstart repo. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/oci-engine/README.md | 30 +++- .../acq-kits/oci-engine/scripts/verify | 154 ++++++++++++------ 2 files changed, 125 insertions(+), 59 deletions(-) diff --git a/integrations/isolation/acq-kits/oci-engine/README.md b/integrations/isolation/acq-kits/oci-engine/README.md index 78ab52ef..3c01cb1f 100644 --- a/integrations/isolation/acq-kits/oci-engine/README.md +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -109,13 +109,29 @@ rootless podman (and `docker` aliased to it) either way. ## Verifying (host-side live check) -`scripts/verify` creates a throwaway sandbox with this kit applied and confirms -rootless podman actually works (a real `podman build` from `scratch` as the -agent, plus that the `docker` -> `podman` wrapper resolves). It needs a -sandbox-capable host (`sbx` installed and logged in) and is **skipped in CI** -(CI cannot nest sandboxes), exactly 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. +`scripts/verify` creates a throwaway **msb** sandbox with this kit applied +**through acq** (`acq --backend msb run --kit … shell …`) 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. + +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 diff --git a/integrations/isolation/acq-kits/oci-engine/scripts/verify b/integrations/isolation/acq-kits/oci-engine/scripts/verify index 8f81eda9..738dc74c 100755 --- a/integrations/isolation/acq-kits/oci-engine/scripts/verify +++ b/integrations/isolation/acq-kits/oci-engine/scripts/verify @@ -2,24 +2,47 @@ # # verify — host-side live validation for the oci-engine acq mixin kit. # -# Creates a throwaway sandbox with this kit applied and confirms that a ROOTLESS -# podman OCI engine actually works the way an agent uses it: -# - `podman version` / `podman info` succeed as the agent user (rootless); -# - a real `podman build` FROM scratch succeeds (this is 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); and -# - the `docker` -> `podman` wrapper resolves on PATH. +# 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. # -# NOTE (hybrid/v1): this is a LIVE check and needs a sandbox-capable host with -# `sbx` installed and logged in. It is NOT run in CI (CI cannot nest sandboxes), -# exactly like the zscaler-ca-certificate kit's verify. The backend-agnostic gate -# is integrations/isolation/acq-kits/validate-kits.py (`make validate-kits`). +# 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. # -# It creates a temporary sandbox named "oci-engine-verify-*" and removes it at -# the end (and on interrupt); it does not touch your other sandboxes. Set KEEP=1 -# to retain the sandbox for inspection. Nothing here is secret — podman needs no -# credentials to run rootless. +# 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. +# +# 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 @@ -29,11 +52,32 @@ set -uo pipefail KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" SPEC_FILE="$KIT_DIR/spec.yaml" -SBX_NAME="oci-engine-verify-$$" -WORK="$(mktemp -d "${TMPDIR:-/tmp}/oci-engine-verify.XXXXXX")" -CREATE_LOG="$WORK/sbx-create.log" +WORK="$(mktemp -d "${HOME:-/tmp}/.oci-engine-verify.XXXXXX")" +AGENT="shell" KEEP="${KEEP:-0}" +# 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="$( + 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)); } @@ -45,47 +89,50 @@ cleanup() { printf '\nKEEP=1 set; leaving sandbox %s and %s in place.\n' "$SBX_NAME" "$WORK" >&2 return fi - sbx rm -f "$SBX_NAME" >/dev/null 2>&1 || true + 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 AS THE AGENT USER (rootless is the whole point). -# The explicit `-u agent` matches the reference adapter's rootless verify: without -# it, `sbx exec` may run as root and the "rootless" checks would silently exercise -# the wrong path. -in_sbx_agent() { sbx exec "$SBX_NAME" -u agent -- sh -c "$1" /dev/null || true; } +# 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. +in_sbx() { acq --backend msb exec "$SBX_NAME" -- sh -c "$1" 2>/dev/null || true; } info "0. Preconditions" -if ! command -v sbx >/dev/null 2>&1; then - echo " sbx not on PATH. Install sbx and retry (this live check is skipped in CI)." >&2 +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 -echo " sbx version: $(sbx version 2>/dev/null | grep -oE 'v?[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1)" +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. sbx kit validate" -if sbx kit validate "$KIT_DIR" >/dev/null 2>&1; then ok "kit validates"; else bad "kit failed validation"; 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 a sandbox with the kit" -echo " creating $SBX_NAME (workspace $WORK)..." -agent="shell" -if ! sbx create --name "$SBX_NAME" --kit "$KIT_DIR" "$agent" "$WORK" >"$CREATE_LOG" 2>&1; then - agent="opencode" - if sbx create --name "$SBX_NAME" --kit "$KIT_DIR" "$agent" "$WORK" >"$CREATE_LOG" 2>&1; then - ok "sandbox created with --kit (agent: $agent)" - else - bad "sbx create failed (exit $?)" - sed 's/^/ | /' "$CREATE_LOG" >&2 - exit 1 - fi +info "2. Create an msb sandbox with the kit applied (adapter OCI auto-install OFF)" +echo " creating $SBX_NAME (workspace $WORK; ACQ_MSB_ENSURE_OCI=0)..." +if acq --backend msb create "$AGENT" "$WORK" --kit "$KIT_DIR" >"$WORK/acq-create.log" 2>&1; then + ok "sandbox created via acq with --kit" else - ok "sandbox created with --kit (agent: $agent)" + bad "acq --backend msb create --kit failed (exit $?)" + sed 's/^/ | /' "$WORK/acq-create.log" >&2 + exit 1 fi -sleep 2 info "3. podman is installed and reports version (as the agent)" -pv="$(in_sbx_agent "podman version --format '{{.Client.Version}}' 2>/dev/null || podman --version 2>/dev/null")" +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 runnable as the agent"; fi info "4. rootless podman info succeeds as the agent" @@ -94,17 +141,17 @@ info "4. rootless podman info succeeds as the agent" # 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_agent "podman info >/dev/null 2>&1 && echo ok")" = "ok" ]; then +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)" fi -rootless="$(in_sbx_agent "podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null")" +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_agent "id -u")" + 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 @@ -118,7 +165,7 @@ info "5. 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_agent ' +built="$(in_sbx ' d=$(mktemp -d) || exit 1 printf "FROM scratch\nCOPY hi /hi\n" > "$d/Containerfile" echo hi > "$d/hi" @@ -132,14 +179,17 @@ else bad "podman build failed rootless (storage driver may not work on this base)" fi -info "6. docker -> podman wrapper resolves on PATH" -which_docker="$(in_sbx_agent "command -v docker")" +info "6. 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_agent "docker version --format '{{.Client.Version}}' 2>/dev/null || docker --version 2>/dev/null")" +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 From dac81a0c48c84c65b402868d888a04e69a636632 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 22 Sep 2026 19:37:31 +0000 Subject: [PATCH 04/10] fix(oci-engine): strip CRLF from msb exec output in verify; show podman info stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit msb's exec transport returns guest stdout as CRLF, so `in_sbx` captured a trailing \r that (a) broke exact-string compares — e.g. "/usr/local/bin/docker\r" != "/usr/local/bin/docker", a false FAIL in the docker-wrapper check — and (b) when printed, moved the cursor to column 0 so the next char overwrote the line (the garbled ") PASS" / ") FAIL" output). Strip \r once in in_sbx (the same fix the quickstart's run_acq uses). Also surface podman's actual stderr when the step-4 `podman info` check fails, so a failure is diagnosable instead of a black box. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/oci-engine/scripts/verify | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/integrations/isolation/acq-kits/oci-engine/scripts/verify b/integrations/isolation/acq-kits/oci-engine/scripts/verify index 738dc74c..ec6aa86c 100755 --- a/integrations/isolation/acq-kits/oci-engine/scripts/verify +++ b/integrations/isolation/acq-kits/oci-engine/scripts/verify @@ -96,7 +96,13 @@ 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. -in_sbx() { acq --backend msb exec "$SBX_NAME" -- sh -c "$1" 2>/dev/null || true; } +# +# 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 @@ -145,6 +151,9 @@ 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 From 329aa88ca2d630fae76f6d68eb39f54e1bc68c37 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 22 Sep 2026 19:39:28 +0000 Subject: [PATCH 05/10] test(oci-engine): pin a neutral base + hard podman gate so verify proves the kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes so verify actually proves the KIT provisions podman, and fails legibly when it does not: - Pin a neutral base image that ships neither docker nor podman (VERIFY_OCI_IMAGE, default docker/sandbox-templates:shell) via acq's --image. The msb default base (…:shell-docker) already bundles a container engine and puts the agent in the docker group, so podman/docker present there is NOT attributable to the kit — defeating ACQ_MSB_ENSURE_OCI=0. On a neutral base the kit is the only thing that can produce a working rootless podman (it installs from the OS mirror at create). Override with VERIFY_OCI_IMAGE=. - Make "podman present" a HARD GATE (step 3): if the kit's fail-soft install did not land podman, dump the oci-engine lines from the create log (the real reason — unreachable mirror, unsupported base) and stop, instead of cascading into confusing rootless FAILs. This is what the last run needed: on …:shell-docker the base had no engine the kit installed, the fail-soft install exited 0, and podman was silently absent. README Verifying section updated to document the neutral-base requirement and the VERIFY_OCI_IMAGE override. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/oci-engine/README.md | 15 +++++- .../acq-kits/oci-engine/scripts/verify | 46 +++++++++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/integrations/isolation/acq-kits/oci-engine/README.md b/integrations/isolation/acq-kits/oci-engine/README.md index 3c01cb1f..4266939e 100644 --- a/integrations/isolation/acq-kits/oci-engine/README.md +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -110,7 +110,7 @@ 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 run --kit … shell …`) and confirms rootless +**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. @@ -128,6 +128,17 @@ 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 a **neutral base image that ships neither +docker nor podman** (`VERIFY_OCI_IMAGE`, default +`docker/sandbox-templates:shell`) via acq's `--image`. The msb *default* base +(`…:shell-docker`) already bundles a container engine and puts the agent in the +`docker` group, so a `docker`/`podman` present there would not prove the kit +installed it. On the neutral base the kit is the only thing that can produce a +working rootless podman — it installs it 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=`. + 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 @@ -136,6 +147,8 @@ 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 diff --git a/integrations/isolation/acq-kits/oci-engine/scripts/verify b/integrations/isolation/acq-kits/oci-engine/scripts/verify index ec6aa86c..b8b4fd1d 100755 --- a/integrations/isolation/acq-kits/oci-engine/scripts/verify +++ b/integrations/isolation/acq-kits/oci-engine/scripts/verify @@ -34,6 +34,17 @@ # 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 NEUTRAL base image (important): the msb default base +# (docker/sandbox-templates:shell-docker) ALREADY ships a container engine and +# puts the agent in the `docker` group — so a `docker`/`podman` present there is +# NOT proof the kit installed it, defeating the whole point of ACQ_MSB_ENSURE_OCI=0. +# This check pins a plain Debian/Ubuntu base that ships NEITHER docker NOR podman +# (VERIFY_OCI_IMAGE, default below) via acq's neutral --image, so the ONLY thing +# that can produce a working rootless podman is this kit. The base must still meet +# acq's msb prerequisite contract (a non-root `agent` user, sudo, ca-certificates) +# and reach 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 @@ -46,7 +57,8 @@ # # Usage (from anywhere): # path/to/oci-engine/scripts/verify -# KEEP=1 path/to/oci-engine/scripts/verify # keep the sandbox +# KEEP=1 path/to/oci-engine/scripts/verify # keep the sandbox +# VERIFY_OCI_IMAGE= path/to/.../verify # use a different base set -uo pipefail @@ -56,6 +68,14 @@ WORK="$(mktemp -d "${HOME:-/tmp}/.oci-engine-verify.XXXXXX")" AGENT="shell" KEEP="${KEEP:-0}" +# Plain base image that ships NEITHER docker NOR podman, so a working engine is +# attributable to the kit (see the header note). Ubuntu 24.04 has the `agent` +# user + sudo the msb prerequisite contract needs on the sandbox-templates line; +# a truly bare ubuntu:24.04 would need acq to synthesize the agent user, which it +# does on a plain OCI base. We default to the shell (NON-docker) sandbox template: +# same family as the msb default, minus the bundled engine. Override as needed. +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. @@ -128,8 +148,10 @@ else fi info "2. Create an msb sandbox with the kit applied (adapter OCI auto-install OFF)" +echo " base image: $VERIFY_OCI_IMAGE (neutral; ships neither docker nor podman)" echo " creating $SBX_NAME (workspace $WORK; ACQ_MSB_ENSURE_OCI=0)..." -if acq --backend msb create "$AGENT" "$WORK" --kit "$KIT_DIR" >"$WORK/acq-create.log" 2>&1; then +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 $?)" @@ -138,8 +160,26 @@ else fi info "3. podman is installed and reports version (as the agent)" +# 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 runnable as the agent"; fi +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. rootless podman info succeeds as the agent" # Assert the engine is actually ROOTLESS. podman reports its rootless flag; we From 3fadd5f8cc9978fda38fd6417fc95e1d6b0c03ad Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 22 Sep 2026 20:01:39 +0000 Subject: [PATCH 06/10] fix(oci-engine): early-out on a functional engine; decouple config from install Live testing on a base that already bundles a container engine (docker/sandbox-templates:*-docker runs a real dockerd) surfaced two issues in the install script; fix both. - Functional-engine early-out: before doing anything, if `docker info` succeeds the base already has a WORKING engine, so leave it alone (do not install podman or shadow the working docker) and exit 0. Probe `docker info` (function), NOT merely 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 and correctly proceeds to install podman. - Decouple config/wrapper from the package step: the storage/registry config and the docker->podman wrapper now run whenever podman is PRESENT at that point (installed by the kit OR pre-existing), not only when the kit ran the package install. Previously a skipped/failed package step also skipped the config, which is why /usr/local/bin/docker was absent on a base without podman. Both paths keep the fail-soft posture (exit 0). Verified via stubbed shells: no-engine+failing-mirror -> fail-soft; functional docker -> early-out; dead-socket docker -> proceeds to install. spec.yaml, README, and the kit's docs/decisions updated to describe the early-out and the decoupling. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/oci-engine/README.md | 35 +++++++++++----- ...rt-oci-engine-failsoft-and-vfs-fallback.md | 23 +++++++++++ .../files/home/oci-engine-install.sh | 40 ++++++++++++++----- .../isolation/acq-kits/oci-engine/spec.yaml | 12 ++++-- 4 files changed, 87 insertions(+), 23 deletions(-) diff --git a/integrations/isolation/acq-kits/oci-engine/README.md b/integrations/isolation/acq-kits/oci-engine/README.md index 4266939e..34a0c7db 100644 --- a/integrations/isolation/acq-kits/oci-engine/README.md +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -9,6 +9,12 @@ sandbox, so it can run OCI images (`docker run`, `docker build`, 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). @@ -26,6 +32,11 @@ At sandbox **create time** (the `install` phase, run once as root) it: 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) 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. `/dev` is a devtmpfs @@ -128,16 +139,20 @@ 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 a **neutral base image that ships neither -docker nor podman** (`VERIFY_OCI_IMAGE`, default -`docker/sandbox-templates:shell`) via acq's `--image`. The msb *default* base -(`…:shell-docker`) already bundles a container engine and puts the agent in the -`docker` group, so a `docker`/`podman` present there would not prove the kit -installed it. On the neutral base the kit is the only thing that can produce a -working rootless podman — it installs it 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=`. +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 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 index 32ce4ba4..91397637 100644 --- 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 @@ -73,3 +73,26 @@ must not abort the boot). - 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. + 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 index a31c0c98..ef78a597 100755 --- 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 @@ -63,6 +63,25 @@ esac # move-as-root pattern the zscaler-ca-certificate kit uses. STAGE_DIR="/home/agent/oci-engine-config" +# 0) 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 + exit 0 +fi + # 1) Ensure the podman binary is present (idempotent; distro-detected). # # FAIL-SOFT (critical): this is an `install`-phase command, and a non-zero @@ -70,11 +89,10 @@ STAGE_DIR="/home/agent/oci-engine-config" # 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 before the -# fail-soft re-check below is ever reached. So we run the whole install in a -# SUBSHELL under its own `set -e` and swallow its exit: any failure inside falls -# through to the single fail-soft exit at the `command -v podman` re-check, which -# prints a clear warning and exits 0 so provision continues. +# 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 … @@ -105,11 +123,15 @@ if ! command -v podman >/dev/null 2>&1; then fi fi -# Single fail-soft exit: if podman is still absent (install failed or the mirror -# was unreachable), warn and exit 0 so `sbx create` succeeds — the sandbox is -# usable, it just cannot run OCI images. +# 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 still not on PATH after install; OCI images unavailable." >&2 + 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 diff --git a/integrations/isolation/acq-kits/oci-engine/spec.yaml b/integrations/isolation/acq-kits/oci-engine/spec.yaml index 65a4a641..8a1cc179 100644 --- a/integrations/isolation/acq-kits/oci-engine/spec.yaml +++ b/integrations/isolation/acq-kits/oci-engine/spec.yaml @@ -100,10 +100,14 @@ files: 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"). Installs podman + the - # rootless prerequisites and writes the storage/registry config + docker - # wrapper. Idempotent and best-effort (a missing mirror or unsupported base - # exits 0 with a clear message rather than aborting provision). + # INSTALL phase — create-time, once, as root (uid "0"). 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). From 0d06d08085371674230826af2f6462d82cebde26 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 00:25:33 +0000 Subject: [PATCH 07/10] fix(oci-engine): address adversarial PR review (marker, sanitizer, gate, driver, egress, timeout) Resolve the six findings from the AI-assisted 5-role adversarial PR review (all confirmed against the diff). Design direction was affirmed; these are implementation hardening. 1. Fail-silent -> observable (finding #1). 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 functional-engine early-out). The fail-soft paths deliberately do NOT write it, so an operator/CI can tell a provisioned sandbox from one where OCI setup fell soft. scripts/verify checks the marker first. 2. Package-manager flag injection (finding #2). The OCI_ENGINE_PKGS sanitizer allowed a leading '-' token, so "podman --allow-unauthenticated" (apt) / --nogpgcheck (dnf) / --allow-untrusted (apk) word-split into the elevated install and disabled signature verification. Now validated token-by-token, rejecting any leading-'-' or non-word-safe token, failing closed. 3. Unconditional device grant (finding #3). oci-engine-grant-devs.sh now GATES on `command -v podman`: no engine, no chown/chmod of /dev/net/tun + /dev/fuse (avoids needlessly widening egress-relevant device access for a capability that does not exist). 4. Storage driver mis-pinned from a boot-transient probe (finding #4). Driver selection moved to a shared helper (oci-engine-config/oci-engine-storage-driver.sh) sourced by both the create install and the every-boot grant step, converging vfs -> overlay once fuse-overlayfs + /dev/fuse are present. Only rewrites a kit-owned storage.conf (sentinel), never an operator's. 5. Missing caps.network for the registry (finding #5). spec.yaml now declares the Docker Hub registry/auth/blob-CDN hosts the kit's purpose needs, so runtime pulls work even on a strict tier. The distro package mirror stays with the balanced baseline (distro-specific, already in core). 6. Unbounded boot-path self-test (finding #6). The per-boot rootless `podman build` self-test now runs under `timeout` (OCI_ENGINE_SELFTEST_TIMEOUT, default 120s) so a wedged mount cannot hang the boot; a timeout counts as a failed self-test and triggers the vfs fallback. Also folds in the verify base-image clarification (engine-free :shell base; `*-docker` variants run dockerd and would trip the early-out). spec.yaml, README, and docs/decisions updated; validator/sh -n/scan-shell clean; sanitizer, marker, and storage-convergence paths verified with stubbed shells. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/oci-engine/README.md | 65 +++++++++----- ...rt-oci-engine-failsoft-and-vfs-fallback.md | 49 ++++++++++ .../oci-engine-storage-driver.sh | 78 ++++++++++++++++ .../files/home/oci-engine-grant-devs.sh | 35 +++++++- .../files/home/oci-engine-install.sh | 90 ++++++++++++------- .../files/home/oci-engine-vfs-fallback.sh | 23 ++++- .../acq-kits/oci-engine/scripts/verify | 60 +++++++++---- .../isolation/acq-kits/oci-engine/spec.yaml | 72 ++++++++++----- 8 files changed, 377 insertions(+), 95 deletions(-) create mode 100644 integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-config/oci-engine-storage-driver.sh diff --git a/integrations/isolation/acq-kits/oci-engine/README.md b/integrations/isolation/acq-kits/oci-engine/README.md index 34a0c7db..5b6fbdbe 100644 --- a/integrations/isolation/acq-kits/oci-engine/README.md +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -37,20 +37,27 @@ package install**: they are applied whenever podman ends up present at that poin — 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) 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. `/dev` is a devtmpfs -re-created each boot, so this must re-run on restart, not just at create. +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**. `/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 does +nothing — it never widens device access 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). If that 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). +and mounts a layer (stronger than `podman info`, which does not). The build is +bounded by `timeout` (`OCI_ENGINE_SELFTEST_TIMEOUT`, default 120s) so a wedged +mount cannot hang the boot. If it 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 @@ -58,7 +65,12 @@ unsupported base image produces a clear warning and leaves provision to continue `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. See `docs/decisions/`. +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) @@ -90,15 +102,26 @@ Nothing in this kit is a secret. podman needs no credentials to run rootless. ## Network egress -This kit declares **no `caps.network.allow`**. The install pulls packages from -the OS package **mirror**, whose host depends on the base distro. Under acq's -default **balanced** egress tier (isolation ADR-0002; see -`integrations/isolation/network-tiers/balanced.yaml`) the OS-package-mirror hosts -are already in the curated `core` baseline, so no kit-level egress is needed for -the common case — and hardcoding one distro's mirror host would be wrong for the -others. With `ACQ_NETWORK_TIER=strict` (kit hosts only) or a narrowed base, the -mirror is unreachable and the install fails soft; the operator then widens egress -or bakes the packages into the base image. +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 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 index 91397637..97acbaa3 100644 --- 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 @@ -96,3 +96,52 @@ Live testing on a base that already bundles a docker CLI (the msb default 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 runs under `timeout` + (`OCI_ENGINE_SELFTEST_TIMEOUT`, default 120s) when available, so a wedged + mount / stalled `newuidmap` cannot hang the boot; a timeout is treated as a + failed self-test and triggers the vfs fallback. + 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 index 2f7842f6..93cca8d9 100755 --- 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 @@ -1,12 +1,19 @@ #!/bin/sh # oci-engine-grant-devs.sh — grant the agent the device nodes rootless podman -# needs, re-applied on EVERY sandbox start. +# 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 @@ -19,6 +26,11 @@ # 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. @@ -27,6 +39,18 @@ 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 entirely. +if ! command -v podman >/dev/null 2>&1; then + echo "oci-engine: podman not installed; skipping device grant + storage re-eval (nothing to enable)." >&2 + exit 0 +fi + +# Directory this kit staged its helpers into (the storage-driver helper lives +# alongside this script's source). +STAGE_DIR="/home/agent/oci-engine-config" + # 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". @@ -63,3 +87,12 @@ _grant() { _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 "$STAGE_DIR/oci-engine-storage-driver.sh" ]; then + # shellcheck source=files/home/oci-engine-storage-driver.sh + . "$STAGE_DIR/oci-engine-storage-driver.sh" + oci_engine_write_storage_conf +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 index ef78a597..ffa1e1e2 100755 --- 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 @@ -47,16 +47,38 @@ case "$_mode_lc" in esac unset _mode_lc -# Charset-guard the package set: it is passed to the package manager unquoted -# (word-splitting is intentional — it is a space-separated list). Package names -# are word-safe (letters, digits, . _ + - and spaces); refuse anything else -# rather than risk a surprise token reaching the elevated install. -case "$OCI_ENGINE_PKGS" in - *[!A-Za-z0-9._+\ -]*) - echo "oci-engine: WARNING: OCI_ENGINE_PKGS contains unsafe characters; skipping OCI setup." >&2 - exit 0 - ;; -esac +# 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 /etc/containers here by root). Same stage-in-home-then- @@ -79,6 +101,13 @@ STAGE_DIR="/home/agent/oci-engine-config" 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 @@ -136,27 +165,15 @@ if ! command -v podman >/dev/null 2>&1; then exit 0 fi -# 2) Select a storage driver that works on an overlay ROOT filesystem. Only write -# the config if none already names a driver (idempotent; do not clobber an -# operator file). Prefer overlay+fuse-overlayfs (fast, thin on disk); else vfs -# (works everywhere, no /dev/fuse, but disk-heavy — a full copy per layer). +# 2) 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 -if ! grep -q '^[[:space:]]*driver' /etc/containers/storage.conf 2>/dev/null; then - _fuse="" - for _c in /usr/bin/fuse-overlayfs /usr/local/bin/fuse-overlayfs /bin/fuse-overlayfs; do - if [ -x "$_c" ]; then _fuse="$_c"; break; fi - done - if [ -z "$_fuse" ] && command -v fuse-overlayfs >/dev/null 2>&1; then - _fuse="$(command -v fuse-overlayfs)" - fi - if [ -n "$_fuse" ] && [ -e /dev/fuse ]; then - printf '[storage]\ndriver = "overlay"\n[storage.options.overlay]\nmount_program = "%s"\n' \ - "$_fuse" > /etc/containers/storage.conf - else - printf '[storage]\ndriver = "vfs"\n' > /etc/containers/storage.conf - fi - unset _fuse _c -fi +# shellcheck source=files/home/oci-engine-storage-driver.sh +. "$STAGE_DIR/oci-engine-storage-driver.sh" +oci_engine_write_storage_conf # 3) Docker-Hub-first registry resolution (ADR-0020). System-level so it applies # to the rootless agent (read as the lowest-precedence source). Install the @@ -180,4 +197,17 @@ if [ -f "$STAGE_DIR/docker" ]; then install -m 0755 "$STAGE_DIR/docker" /usr/local/bin/docker fi +# 5) 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 index acdd19cb..51f19fc2 100755 --- 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 @@ -42,11 +42,32 @@ 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 is bounded by `timeout` when available (best-effort: if `timeout` is +# absent we run unbounded rather than skip the self-test). 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 ;; +esac + +_oci_build() { + # Run podman build, bounded by `timeout` if present. A timeout kill returns + # 124 (GNU) — treated as a failed self-test, which triggers the vfs fallback. + if command -v timeout >/dev/null 2>&1; then + timeout "$OCI_ENGINE_SELFTEST_TIMEOUT" podman build -q -t oci-engine-selftest:local "$1" >/dev/null 2>&1 + else + podman build -q -t oci-engine-selftest:local "$1" >/dev/null 2>&1 + fi +} + _oci_selftest() { _d="$(mktemp -d)" || return 1 printf 'FROM scratch\nCOPY hi /hi\n' > "$_d/Containerfile" echo hi > "$_d/hi" - podman build -q -t oci-engine-selftest:local "$_d" >/dev/null 2>&1 + _oci_build "$_d" _rc=$? podman rmi -f oci-engine-selftest:local >/dev/null 2>&1 || true rm -rf "$_d" diff --git a/integrations/isolation/acq-kits/oci-engine/scripts/verify b/integrations/isolation/acq-kits/oci-engine/scripts/verify index b8b4fd1d..e40eaef4 100755 --- a/integrations/isolation/acq-kits/oci-engine/scripts/verify +++ b/integrations/isolation/acq-kits/oci-engine/scripts/verify @@ -34,16 +34,19 @@ # 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 NEUTRAL base image (important): the msb default base -# (docker/sandbox-templates:shell-docker) ALREADY ships a container engine and -# puts the agent in the `docker` group — so a `docker`/`podman` present there is -# NOT proof the kit installed it, defeating the whole point of ACQ_MSB_ENSURE_OCI=0. -# This check pins a plain Debian/Ubuntu base that ships NEITHER docker NOR podman -# (VERIFY_OCI_IMAGE, default below) via acq's neutral --image, so the ONLY thing -# that can produce a working rootless podman is this kit. The base must still meet -# acq's msb prerequisite contract (a non-root `agent` user, sudo, ca-certificates) -# and reach an OS package mirror under the active egress tier — the kit installs -# podman from that mirror at create. Override with VERIFY_OCI_IMAGE=. +# 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 @@ -68,12 +71,19 @@ WORK="$(mktemp -d "${HOME:-/tmp}/.oci-engine-verify.XXXXXX")" AGENT="shell" KEEP="${KEEP:-0}" -# Plain base image that ships NEITHER docker NOR podman, so a working engine is -# attributable to the kit (see the header note). Ubuntu 24.04 has the `agent` -# user + sudo the msb prerequisite contract needs on the sandbox-templates line; -# a truly bare ubuntu:24.04 would need acq to synthesize the agent user, which it -# does on a plain OCI base. We default to the shell (NON-docker) sandbox template: -# same family as the msb default, minus the bundled engine. Override as needed. +# 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 @@ -148,7 +158,7 @@ else fi info "2. Create an msb sandbox with the kit applied (adapter OCI auto-install OFF)" -echo " base image: $VERIFY_OCI_IMAGE (neutral; ships neither docker nor podman)" +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 @@ -159,7 +169,21 @@ else exit 1 fi -info "3. podman is installed and reports version (as the agent)" +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 diff --git a/integrations/isolation/acq-kits/oci-engine/spec.yaml b/integrations/isolation/acq-kits/oci-engine/spec.yaml index 8a1cc179..025d93c8 100644 --- a/integrations/isolation/acq-kits/oci-engine/spec.yaml +++ b/integrations/isolation/acq-kits/oci-engine/spec.yaml @@ -49,25 +49,38 @@ description: > 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 (deny-by-default): deliberately NO caps.network.allow here. +# EGRESS. Two distinct needs, handled differently on purpose: # -# The install pulls podman + the rootless prerequisites from the OS package -# MIRROR (apt/dnf/apk). The correct mirror host depends on the base distro -# (archive.ubuntu.com / ports.ubuntu.com / security.ubuntu.com for Ubuntu, -# *.debian.org for Debian, dl-cdn.alpinelinux.org for Alpine, ...), and under -# acq's DEFAULT balanced egress baseline (isolation ADR-0002 / balanced.yaml) -# those OS-package-mirror hosts are ALREADY reachable — they are in the curated -# `core` baseline. Hardcoding a single distro's mirror here would be (a) wrong for -# the other distros and (b) redundant with the baseline for the common case. +# 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. # -# So this kit adds NO kit-level egress and relies on the balanced baseline. With -# ACQ_NETWORK_TIER=strict (kit hosts only) or a base whose egress is otherwise -# narrowed, the mirror is unreachable and the install fails soft (a clear -# warning; provision continues; OCI is simply unavailable) — the operator then -# widens egress or bakes the packages into the base image. This mirrors the -# fail-soft behavior of the original adapter implementation (ADR-0020/ADR-0030). -# No leading-`*` host would appear here anyway (YAML alias trap), since there are -# no entries. +# 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: # Lifecycle scripts. Mode 0755 (executable); each backend's file-drop @@ -94,6 +107,10 @@ files: 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 @@ -114,15 +131,18 @@ commands: command: - sh - /home/agent/oci-engine-install.sh - # STARTUP phase — every start, as root (uid "0"). 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 must be re-applied. Cheap + - # idempotent; runs to completion (NOT background: true — it is not a supervisor - # loop). Schema note: the hybrid/v1 `user` field is a numeric uid (^[0-9]+$); - # "0" is root, which both the chown and the /dev/net directory create require. + # STARTUP phase — every start, as root (uid "0"). GATED ON PODMAN: does nothing + # 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: Re-grant the rootless-podman device nodes (/dev/net/tun, /dev/fuse) on every start. + description: If podman present, re-grant device nodes + re-evaluate the storage driver (every start, root). command: - sh - /home/agent/oci-engine-grant-devs.sh @@ -155,10 +175,14 @@ commands: # 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) From 429b0b1345b9c39bb950b22fde0e2472bd6158d0 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 00:49:02 +0000 Subject: [PATCH 08/10] fix(oci-engine): drop trailing blank line to satisfy markdownlint (MD012) The docs/decisions note ended with two consecutive blank lines, tripping MD012/no-multiple-blanks in the Markdown Lint CI check. Trim to a single trailing newline. Co-authored-by: OpenCode Agent --- .../docs/decisions/port-oci-engine-failsoft-and-vfs-fallback.md | 1 - 1 file changed, 1 deletion(-) 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 index 97acbaa3..ae8a4941 100644 --- 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 @@ -144,4 +144,3 @@ direction and flagged six implementation defects, all addressed here: (`OCI_ENGINE_SELFTEST_TIMEOUT`, default 120s) when available, so a wedged mount / stalled `newuidmap` cannot hang the boot; a timeout is treated as a failed self-test and triggers the vfs fallback. - From e38be4d654f2e4e08f56b8fe2f2ead80c6e1f2fb Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 24 Sep 2026 02:11:36 +0000 Subject: [PATCH 09/10] fix(oci-engine): harden trusted root kit scripts Move elevated OCI kit execution through root-owned copies under /usr/local/lib/acq/oci-engine and verify the trusted path ownership. Also revoke stale device grants when podman is absent. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/oci-engine/README.md | 20 +++-- ...rt-oci-engine-failsoft-and-vfs-fallback.md | 13 ++++ .../files/home/oci-engine-grant-devs.sh | 26 +++++-- .../files/home/oci-engine-install.sh | 54 ++++++++++--- .../acq-kits/oci-engine/scripts/verify | 29 ++++++- .../isolation/acq-kits/oci-engine/spec.yaml | 77 ++++++++++++------- 6 files changed, 165 insertions(+), 54 deletions(-) diff --git a/integrations/isolation/acq-kits/oci-engine/README.md b/integrations/isolation/acq-kits/oci-engine/README.md index 5b6fbdbe..48eafb59 100644 --- a/integrations/isolation/acq-kits/oci-engine/README.md +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -40,13 +40,16 @@ 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**. `/dev` is a -devtmpfs re-created each boot, so both must re-run on restart, not just at create; +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 does -nothing — it never widens device access for an engine that does not exist. +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` @@ -85,8 +88,13 @@ engine")** decision, declaratively: 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`). No new host attack surface is opened: this is all inside the sandbox, - which is itself the security boundary. + (`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 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 index ae8a4941..61d4287d 100644 --- 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 @@ -144,3 +144,16 @@ direction and flagged six implementation defects, all addressed here: (`OCI_ENGINE_SELFTEST_TIMEOUT`, default 120s) when available, so a wedged mount / stalled `newuidmap` cannot hang the boot; 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-grant-devs.sh b/integrations/isolation/acq-kits/oci-engine/files/home/oci-engine-grant-devs.sh index 93cca8d9..7d5c614e 100755 --- 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 @@ -41,15 +41,23 @@ 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 entirely. +# 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 - echo "oci-engine: podman not installed; skipping device grant + storage re-eval (nothing to enable)." >&2 + 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 -# Directory this kit staged its helpers into (the storage-driver helper lives -# alongside this script's source). -STAGE_DIR="/home/agent/oci-engine-config" +# 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 @@ -91,8 +99,10 @@ _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 "$STAGE_DIR/oci-engine-storage-driver.sh" ]; then - # shellcheck source=files/home/oci-engine-storage-driver.sh - . "$STAGE_DIR/oci-engine-storage-driver.sh" +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 index ffa1e1e2..65a743a4 100755 --- 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 @@ -81,11 +81,45 @@ fi unset _pkg _pkg_bad # Where the kit staged its config payloads (dropped by files[] as the agent user, -# then installed into /etc/containers here by root). Same stage-in-home-then- -# move-as-root pattern the zscaler-ca-certificate kit uses. +# 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) EARLY-OUT if the base image already provides a FUNCTIONAL container engine. +# 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 @@ -111,7 +145,7 @@ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then exit 0 fi -# 1) Ensure the podman binary is present (idempotent; distro-detected). +# 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 @@ -165,17 +199,17 @@ if ! command -v podman >/dev/null 2>&1; then exit 0 fi -# 2) Select a storage driver that works on an overlay ROOT filesystem, via the +# 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-storage-driver.sh -. "$STAGE_DIR/oci-engine-storage-driver.sh" +# shellcheck source=files/home/oci-engine-config/oci-engine-storage-driver.sh +. "$TRUSTED_STORAGE_HELPER" oci_engine_write_storage_conf -# 3) Docker-Hub-first registry resolution (ADR-0020). System-level so it applies +# 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. @@ -188,7 +222,7 @@ 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 -# 4) Shadow the base image's (dead-socket) docker CLI with a wrapper that routes +# 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). @@ -197,7 +231,7 @@ if [ -f "$STAGE_DIR/docker" ]; then install -m 0755 "$STAGE_DIR/docker" /usr/local/bin/docker fi -# 5) SUCCESS MARKER (observability — finding #1). Record that the kit reached a +# 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 diff --git a/integrations/isolation/acq-kits/oci-engine/scripts/verify b/integrations/isolation/acq-kits/oci-engine/scripts/verify index e40eaef4..3803c240 100755 --- a/integrations/isolation/acq-kits/oci-engine/scripts/verify +++ b/integrations/isolation/acq-kits/oci-engine/scripts/verify @@ -100,6 +100,7 @@ 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 )" @@ -205,7 +206,29 @@ else exit 1 fi -info "4. rootless podman info succeeds as the agent" +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 @@ -234,7 +257,7 @@ else bad "podman reports rootless=$rootless (expected true — engine is running ROOTFUL)" fi -info "5. real layer mount: podman build FROM scratch as the agent (rootless)" +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). @@ -252,7 +275,7 @@ else bad "podman build failed rootless (storage driver may not work on this base)" fi -info "6. docker -> podman wrapper (dropped by THIS kit) resolves on PATH" +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. diff --git a/integrations/isolation/acq-kits/oci-engine/spec.yaml b/integrations/isolation/acq-kits/oci-engine/spec.yaml index 025d93c8..307d969f 100644 --- a/integrations/isolation/acq-kits/oci-engine/spec.yaml +++ b/integrations/isolation/acq-kits/oci-engine/spec.yaml @@ -83,8 +83,11 @@ caps: - production.cloudfront.docker.com files: - # Lifecycle scripts. Mode 0755 (executable); each backend's file-drop - # materializes them at the in-guest path as the agent user. + # 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 @@ -92,17 +95,17 @@ files: - path: /home/agent/oci-engine-grant-devs.sh mode: "0755" source: files/home/oci-engine-grant-devs.sh - description: Group-scope /dev/net/tun + /dev/fuse to the agent (every start, root). Idempotent. + 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 /etc/containers + # 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 the root-owned - # /etc/containers dir 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. + # 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 @@ -117,35 +120,55 @@ files: 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"). 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). + # 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 - - /home/agent/oci-engine-install.sh - # STARTUP phase — every start, as root (uid "0"). GATED ON PODMAN: does nothing - # 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. + - -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 - - /home/agent/oci-engine-grant-devs.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 From 043ad7605e8ddf25577c7f1c6a4fcc17135fc61f Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 1 Oct 2026 06:41:09 +0000 Subject: [PATCH 10/10] fix(oci-engine): require bounded rootless self-test Skip the boot-path rootless podman build self-test when timeout is unavailable, and normalize invalid or zero timeout overrides back to the default so operators cannot accidentally disable the bound. Add regression coverage for missing timeout and invalid timeout values. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/oci-engine/README.md | 7 +- ...rt-oci-engine-failsoft-and-vfs-fallback.md | 10 ++- .../files/home/oci-engine-vfs-fallback.sh | 23 +++--- scripts/tests/test_oci_engine_vfs_fallback.py | 73 +++++++++++++++++++ 4 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 scripts/tests/test_oci_engine_vfs_fallback.py diff --git a/integrations/isolation/acq-kits/oci-engine/README.md b/integrations/isolation/acq-kits/oci-engine/README.md index 48eafb59..9bbe34e0 100644 --- a/integrations/isolation/acq-kits/oci-engine/README.md +++ b/integrations/isolation/acq-kits/oci-engine/README.md @@ -54,8 +54,11 @@ 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`, default 120s) so a wedged -mount cannot hang the boot. If it fails, it writes a **user-level +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 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 index 61d4287d..4c3899a2 100644 --- 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 @@ -140,10 +140,12 @@ direction and flagged six implementation defects, all addressed here: 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 runs under `timeout` - (`OCI_ENGINE_SELFTEST_TIMEOUT`, default 120s) when available, so a wedged - mount / stalled `newuidmap` cannot hang the boot; a timeout is treated as a - failed self-test and triggers the vfs fallback. + `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 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 index 51f19fc2..f42554b8 100755 --- 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 @@ -45,22 +45,25 @@ USER_STORAGE_CONF="$USER_STORAGE_DIR/storage.conf" # # 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 is bounded by `timeout` when available (best-effort: if `timeout` is -# absent we run unbounded rather than skip the self-test). OCI_ENGINE_SELFTEST_TIMEOUT -# (seconds) is overridable via spec.yaml environment. +# 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() { - # Run podman build, bounded by `timeout` if present. A timeout kill returns - # 124 (GNU) — treated as a failed self-test, which triggers the vfs fallback. - if command -v timeout >/dev/null 2>&1; then - timeout "$OCI_ENGINE_SELFTEST_TIMEOUT" podman build -q -t oci-engine-selftest:local "$1" >/dev/null 2>&1 - else - podman build -q -t oci-engine-selftest:local "$1" >/dev/null 2>&1 - fi + # 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() { 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")