Containers qualification runbook

For the owner running the Linux qualification kit from a retained administrative console, not an ACC agent session. This portable runbook describes the order and recovery checks. alex-agent and the paths below are examples; substitute the selected enrolled account and reviewed paths.

Keep ACC_CONTAINERS off, both configured and running. A passing collection is only a scoped phase-1 result, with production_qualified: false. It does not enable containers or approve production rollout.

Before starting: ship and deploy matching code

The owner kit and the read-only-rootfs mount fixes are on main. Use one reviewed, committed main checkout containing the kit, current image fixes and matching launcher. Do not assemble an unreviewed mix of branches.

Ship and deploy launcher changes first, through the normal reviewed release process. The kit does not update helpers or restart ACC services. Its preflight compares the installed helpers with the reviewed source byte-for-byte; a newer checkout beside an old deployed launcher is a hard failure, not a reason to relax the check or copy a helper out of an agent worktree.

Verify the root-owned bundle and all its ancestors before executing its code. They must have no agent/group/other write access, including ACL grants. Check ownership before asking git to read the checkout's configuration. The clean bundle must contain no untracked/ignored files or Python bytecode caches.

From the owner's administrative Bash console:

sudo -i
set -euo pipefail
umask 077
cd /
REVIEWED=/srv/acc/reviewed/phase1
REVIEWED_COMMIT=REPLACE_WITH_REVIEWED_COMMIT
KIT="$REVIEWED/scripts/containers-qualification"
ACCOUNT=alex-agent
EVIDENCE=/root/containers-qualification-example
mkdir -m 0700 "$EVIDENCE"
PLAN="$EVIDENCE/plan.json"
STATE=/var/lib/acc/containers-qualification-kit

namei -l "$REVIEWED"
test -z "$(find "$REVIEWED" \( ! -user root -o -perm /022 \) -print -quit)"
test "$(git -C "$REVIEWED" rev-parse HEAD)" = "$REVIEWED_COMMIT"
test -z "$(git -C "$REVIEWED" status --porcelain --ignored)"
cmp "$REVIEWED/deploy/containers/acc-containers" /usr/local/sbin/acc-containers
cmp "$REVIEWED/deploy/containers/acc-container-exec" /srv/acc/containers/acc-container-exec
sha256sum "$KIT"/*.sh "$KIT/kit.py" "$KIT/manifest.json" \
  "$REVIEWED"/deploy/containers/{verify_image_archive.py,storage.conf,acc-container-exec,acc-containers} \
  "$REVIEWED"/deploy/containers/{take_archive.py,setup-build-account,setup_build_account.py} \
  "$REVIEWED"/deploy/apparmor/{acc-podman,acc-podman-runtime} \
  "$REVIEWED/scripts/container_qualification/image_store.py" \
  "$REVIEWED/scripts/qualify_containers.py" > "$EVIDENCE/reviewed-source.sha256"
cat "$KIT/manifest.json"

Replace the commit placeholder with the independently reviewed commit, not whatever HEAD happens to be. For a bundle without git metadata, compare the complete file hashes with the list made during review instead. namei/find do not replace the ACL review. Run each stage separately and stop on any error. If a failed block exits the shell, return through the retained owner console and re-establish its variables before inspecting evidence.

The hyphenated scripts/containers-qualification/ is the owner kit. The existing underscored scripts/container_qualification/ contains the lower-level harness; its local/user-manager mode cannot replace trusted-launcher qualification.

1. Prepare the account

Create/enroll the non-root *-agent account through the normal owner account workflow first. Its registered broker must already allow the plan's repository and workspace root. The separate acc control account and protected grants directory must also be provisioned by the normal launcher installation.

Stop this account's other native and container sessions. Resolve residual workloads by their exact recorded IDs, never with pattern kills. A quiet account is a qualification prerequisite: process sampling cannot exclude a short-lived writer between samples. Do not use the kit to create accounts, install packages, edit subordinate IDs or repair delegation.

getent passwd "$ACCOUNT"
id "$ACCOUNT"
QUAL_UID=$(id -u "$ACCOUNT")
AGENT_HOME=$(getent passwd "$ACCOUNT" | cut -d: -f6)
loginctl show-user "$ACCOUNT" -p Linger -p State
systemctl status "user@$QUAL_UID.service" \
  "acc-broker@$ACCOUNT.service" acc-containers.socket acc-containers-reconcile.timer

Session qualification needs a usable /run/user/UID outside broker NoNewPrivileges; the separate image builder creates its own private runtime. Missing runtime/account prerequisites need a separate reviewed owner action. Record that baseline: setup cannot undo changes made beforehand. The kit can enable missing linger during setup, and explicitly waits for the user manager to start; do not substitute a sleep after enable-linger.

2. Build as the dedicated build account

Security review 8 SR8-3 requires rynhult-build, never the selected session account or root. From the retained owner console, follow the one-time account setup and validation in deploy/containers/README.md first. The builder runs no sessions, has no supplementary groups, linger, active user manager or scheduled jobs, and needs read/traverse access to the protected reviewed checkout. Never add it to an agent group. Confirm ps -u rynhult-build shows no unrelated work.

Start from /, not the owner's inaccessible /root working directory. Use the reviewed helper with a new output directory for every attempt:

BUILD_OUTPUT=/var/lib/rynhult-build/qualification-attempt-01
runuser -u rynhult-build -- /usr/bin/env -i PATH=/usr/bin:/bin \
  HOME=/var/lib/rynhult-build LANG=C.UTF-8 \
  "$REVIEWED/deploy/containers/build-image" "$BUILD_OUTPUT" \
  2>&1 | tee "$EVIDENCE/build.log"

No login shell, user manager or session runtime is required for the builder. The helper supplies --cgroup-manager=cgroupfs and a private throwaway store and runtime; it builds and saves acc-session.oci.tar, its checksum, image.txt and review/, then cleans its store. It never uses the session account's store. If cleanup reports residual storage, preserve the exact diagnostic and follow its reviewed build-account cleanup instructions; do not conceal the residue.

Review the Containerfile, pinned inputs, filesystem inventory and licenses using deploy/containers/README.md in the reviewed checkout. That reference describes inspection without executing image code. Builds download pinned build inputs; the kit itself never pulls an image.

3. Archive, review and pin

The helper already saved the OCI archive as rynhult-build. Review its output privately and record the full image/config ID and independently reviewed archive SHA-256, not just a tag. Do not source builder-owned files or infer approval from a checksum beside the archive.

After approving those exact bytes, set the pins from the review record. Transfer to the existing private root evidence directory without following builder-owned links, then verify the copy:

BUILD_ARCHIVE="$BUILD_OUTPUT/acc-session.oci.tar"
IMAGE_ID=sha256:REPLACE_WITH_REVIEWED_64_HEX_CONFIG_DIGEST
ARCHIVE_SHA256=REPLACE_WITH_INDEPENDENTLY_REVIEWED_64_HEX_ARCHIVE_SHA256
ARCHIVE="$EVIDENCE/reviewed.oci.tar"
/usr/bin/python3 -I "$REVIEWED/deploy/containers/take_archive.py" \
  "$BUILD_ARCHIVE" "$ARCHIVE"
/usr/bin/python3 -I "$REVIEWED/deploy/containers/verify_image_archive.py" \
  "$ARCHIVE" "$ARCHIVE_SHA256" "$IMAGE_ID"
stat -c '%U %G %a %n' "$ARCHIVE"

Expected: verification succeeds and the new copy is root root 600. Stop on any failure. The session account must have no local image/layers shadowing the protected store. The dedicated builder does not put them there; review any residue from older attempts separately. Keep the archive separately; do not manually import it or create the store before kit setup.

Never kill catatonit, rootless Podman's pause process. Doing so leaves a stale pause.pid and later commands fail with "invalid internal status". With this account's workloads stopped, podman system migrate as that account is the reviewed reset before the census. It is not a build step. Do not delete PID files or kill whatever PID a stale file happens to name.

4. Prepare the baseline policy and plan

Before preflight, the owner must provide a protected root-owned version-1 /etc/acc/containers/policy.json with an images.default object. On a fresh installation, review deploy/containers/policy.example.json as the starting shape; create the policy as root, mode 0644, under protected ancestors. Preserve every existing entry and provider mapping. Do not blindly overwrite a policy.

Do not manually add qualification-phase1: prepare preflight refuses an existing entry. Setup snapshots the baseline, updates its default image pin, and adds the separate qualification entry with the reviewed image, network: none and providers: {"codex": "/usr/bin/python3"}. Rollback restores the baseline. Do not mix the older manual image-import/policy mutations into a kit-managed attempt.

(set -o noclobber; cat "$KIT/plan.example.json" > "$PLAN")
chmod 0600 "$PLAN"
sudoedit "$PLAN"
/usr/bin/podman --version
/usr/bin/crun --version

Replace every placeholder. The plan has exactly these eight string fields:

Field Required value
account The selected enrolled alex-agent account
archive The protected $ARCHIVE path, not the builder's writable output
archive_sha256 The reviewed 64-hex archive checksum
image The reviewed sha256: image/config ID
podman_version, crun_version Exact reviewed installed numeric versions, not assumed supported versions
repo An allowed clean, committed repository, for example /srv/acc/workspaces/demo-api
root Its existing broker-allowed root, for example /srv/acc/workspaces

Enter actual values in JSON, not shell variable names. No provider keys, credentials or live-session data belong in the plan.

5. Run prepare preflight

"$KIT/preflight.sh" --plan "$PLAN" --prepare \
  | tee "$EVIDENCE/preflight-prepare.json"

This is read-only: it does not run podman info, create storage or start a service. Exit 0 permits only explicitly marked reversible setup gaps, such as missing linger, reviewed profiles, restricted-userns setting or the new image store. It is not GO. Any missing row blocks setup.

Resolve each hard prerequisite separately, then repeat only this read-only preflight. In particular, a helper hash mismatch requires shipping/deploying the reviewed launcher, not skipping that row. Wrong ownership, stale Podman state, absent delegation/bus, unsupported runtime versions and competing account processes are not reasons to weaken the checks.

6. Setup once, then strict preflight

"$KIT/setup.sh" --plan "$PLAN" 2>&1 | tee "$EVIDENCE/setup.log"
"$KIT/preflight.sh" --plan "$PLAN" \
  | tee "$EVIDENCE/preflight-ready.json"

The second preflight has no --prepare: every row must pass. Setup journals changes before making them, verifies the copied archive and layers, publishes the protected store and independent pinned rootfs, strips unsafe modes and capabilities, and provisions only its reviewed reversible settings. Root never executes image code. Smoke-test existing host workloads after AppArmor/sysctl changes, including Docker stacks, Chrome and native Codex.

Setup refuses existing kit state, image store or import runroot, including dangling symlinks. It is not an upgrade command and cannot be repeated to repair a failed attempt. Do not change reviewed source, policy or image files after setup; collection and uninstall depend on their recorded state.

7. Collect once and preserve evidence

"$KIT/collect.sh" 2>&1 | tee "$EVIDENCE/collect.log"

Collection first captures a root-owned host proof via the agent and cleans its exact temporary namespace unit, then runs the trusted-launcher driver as acc. The real pinned CLIs use synthetic loopback transports, not live credentials. It requires all mandatory rows in both session generations.

A nonzero exit stops the block. From the retained console, inspect the report even after failure; do not rerun collect. Ordinary probe/driver failures retain report.json. If no report exists, preserve the manifest and command output and investigate rather than inventing a result.

/usr/bin/python3 -I -B -m json.tool "$STATE/report.json"
sha256sum "$STATE/report.json"
install -m 0600 "$STATE/report.json" "$EVIDENCE/report.json"
install -m 0600 "$STATE/manifest.json" "$EVIDENCE/manifest-before-uninstall.json"

Exit 0 means the requested step passed; 1 means preflight blocked or collection NO-GO; 2 means unsafe input/state, wrong identity or a failed setup/uninstall. Missing, malformed or skipped mandatory checks remain NO-GO. Even a scoped pass leaves credentials/refresh, broker/host restart, hostile simultaneous staging, disk exhaustion/quotas, Internet/LAN policy, GPU, WSL and Mac qualification open.

8. Uninstall between attempts

Preserve results, stop qualification workloads using their exact recorded session/unit IDs, and resolve residual processes. From the same protected reviewed kit used for this attempt:

"$KIT/uninstall.sh" 2>&1 | tee "$EVIDENCE/uninstall.log"
/usr/bin/python3 -I -B -m json.tool "$STATE/manifest.json"
install -m 0600 "$STATE/manifest.json" "$EVIDENCE/manifest-uninstalled.json"
sha256sum "$EVIDENCE/report.json" "$EVIDENCE/manifest-uninstalled.json"

Uninstall prevalidates all files, metadata, settings and inventoried trees before rollback. It restores only its changes, never kills unknown processes and refuses unknown additions, mounts, drift, incomplete cleanup or pending operations. On refusal, retain state for a reviewed owner recovery action. Never clear pending, force removal, delete the journal or overwrite edits to retry.

The canonical kit directory retains the root-only report, staged archive and uninstalled tombstone. Driver checkouts/run folders also remain for review. Before another attempt, have the owner review and retire that completed kit state into protected evidence storage; a successful uninstall alone does not make setup reusable. Do not move a failed/pending journal aside to evade refusal. Only then deploy any newly reviewed changes, start a new evidence directory and plan, and repeat the sequence. Do not modify the old reviewed bundle while its attempt still needs rollback.

Keep the archive, independent pins, source hashes, both preflights, command logs, report, before/after manifests and exact recorded units together in the private owner evidence directory and its protected backup, not /tmp, an agent worktree or the public wiki. Preserve original evidence before producing any sanitized review copy.

Common failure modes

Symptom Action
Build has no systemd login session Use the dedicated build account's build-image helper, which supplies cgroupfs and a private runtime; do not change production runtime settings.
"Invalid internal status", stale pause.pid, or census names catatonit Stop the selected account's workloads and run its podman system migrate. Never kill catatonit or trust a stale PID.
Session bus appears after Podman uses systemd Leave the account's legitimate session bus running. The reviewed census allows only /usr/bin/dbus-daemon --session, /usr/bin/dbus-broker or /usr/bin/dbus-broker-launch, with every UID matching the account, in exactly its user@<uid>.service/app.slice/dbus.service or session.slice/dbus.service. Lookalikes elsewhere still fail.
Installed helper differs from reviewed source Ship and deploy the reviewed launcher changes first. The kit does not install them; no hash-check bypass or in-place helper patch.
namespace_unshare exits 125 / "Error: setting up the process" Use the current diagnostics and run from /. Podman's namespace child may not enter an inherited /root; inspect checks.image_store and its bounded podman/podman_debug output rather than assuming the cause.
held_rootfs_run fails on /proc/PID/fd, /etc/mtab or another missing target Use the reviewed probe and matching image/launcher fixes. The probe checks the protected rootfs path against its held inode and live container root; the image must ship static mount targets and the launcher must supply reviewed tmpfs parents for dynamic binds. Rebuild, review and repin after image changes. Never make the protected rootfs writable or weaken namespace/seccomp checks.

The owner kit README and manifest define the current evidence contract. Host-specific attempts and internal security reviews remain private; neither a staged attempt nor this runbook grants production approval. See Operations and Security for the surrounding boundary.

This page is generated from docs/wiki/Internal/Containers-Qualification-Runbook.md; the same page is under Guide inside Rynhult.