Skip to content

fix(api): route slot image reads through the rootful podman seam - #1937

Open
thinmintdev wants to merge 5 commits into
mainfrom
fix/podman-ro-introspection-verbs
Open

fix(api): route slot image reads through the rootful podman seam#1937
thinmintdev wants to merge 5 commits into
mainfrom
fix/podman-ro-introspection-verbs

Conversation

@thinmintdev

Copy link
Copy Markdown
Contributor

Important

SUDOERS + INSTALLER CHANGE — operator review required. This PR widens a root sudo seam (hal0-podman-ro) from zero caller-supplied argv to three verbs that each accept one validated positional operand. Please review the validation regexes and the argument doctrine in the wrapper header before merging. Do not automerge.

Refs #1889.

The bug

ContainerProvider.image_present / running_image / running_argv shelled out to a bare podman as the unprivileged hal0 service user, which has no subuid ranges at all, so they read hal0-api's own rootless image store. Slots run rootful podman (Quadlet units under /etc/containers/systemd/, root's store), so that store by construction never holds a slot image.

On every standard install (reproduced on ct151, ct150 and ct105/rc.5 — pre-existing, not an rc.6 regression):

The hal0-podman-ro seam and its is_hal0_service_user() gate already existed — cbc8e94d wired them into /api/system-info, which answers correctly — but the wrapper exposed only the argument-free images verb, and its header doctrine forbade caller args reaching podman's argv. The three call sites that need a specific image ref or container name therefore could not use it.

The privilege boundary

The doctrine is refined, not abandoned, and lands exactly where hal0-systemctl's slot/agent verbs have had it since P3-perms: a caller-supplied value may reach podman's argv only as a single positional operand validated on the root side against a closed regex, for a verb whose podman subcommand, flags and --format string are literals in the wrapper.

verb operand root-side validation
image-exists <ref> OCI image reference ^([A-Za-z0-9]+([.-][A-Za-z0-9]+)*(:[0-9]{1,5})?/)?[A-Za-z0-9]+([._-][A-Za-z0-9]+)*(/…)*(:tag)?(@sha256:[0-9a-f]{64})?$, length ≤ 512
container-image <slot-token> slot instance token ^[A-Za-z0-9_-]{1,64}$ — byte-identical to hal0-systemctl's validate_slot_id
container-argv <slot-token> slot instance token same

Deliberate design points:

  • The container verbs do not take a container name. They take the bare instance token and the wrapper assembles hal0-slot-<token> itself, root-side. The caller can therefore only ever address a hal0 slot container — never an arbitrary one, and never a podman option.
  • A validated ref cannot contain whitespace, a shell metacharacter, a leading - (no flag smuggling), .., or a second argv word. Each verb rejects a second argv word outright.
  • Exec arrays only. No shell, no eval, no word splitting, no wildcards, no caller-supplied --format. rm/run/build/exec/pull remain unreachable.
  • The image-ref regex is structure-strict, case-permissive: the security properties come from the structure, and podman itself rejects uppercase repo names, so folding case here would only convert a podman error into a wrapper rejection — while risking a false reject, which is literally the bug this PR fixes (a rejected ref degrades to image_status: "missing" again).
  • The sudoers grant is unchanged (hal0 ALL=(root) NOPASSWD: /usr/lib/hal0/bin/hal0-podman-ro). A bare command path in sudoers already permits any argv; the wrapper is the control surface. Enumerating verbs in sudoers instead would be a second, silently-drifting copy of the verb list, and sudoers wildcards are a known footgun. Only the explanatory comment changed.

Exit-code contract

0 = podman answered (empty stdout on a container verb is a real "no such container"); 64 = wrapper rejected the argument or verb; 65 = podman absent. This is what lets the Python side tell "the seam did not answer" from "the answer is negative". The named-object reads deliberately do not silently fall back to the rootless store: a rootless answer about a named image is not a stale answer, it is an answer about a different object — that conflation is #1889. The fallback survives only for the dev/CI case where the process is not the hal0 service user (no grant exists there, and the operator's own store is the right one).

Files changed

file change
installer/wrappers/hal0-podman-ro 3 new read verbs + 2 side-effect-free validator probes (check-image-ref, check-slot-token), 2 validators, exit-code contract, rewritten argument doctrine
packaging/sudoers/hal0-podman-ro comment only — documents the new surface and why argv is not enumerated in sudoers
installer/install.sh seam #5 comment updated to the new surface
installer/lib/preflight.sh hal0-podman-ro gains a help probe — the grant is now load-bearing for slot status, so presence-only checking was no longer enough
src/hal0/system/seam_check.py same probe, kept in lock-step (the #1465 inventory)
src/hal0/providers/podman_introspect.py image_exists / container_image / container_argv (tri-state), is_valid_image_ref / is_valid_slot_token mirrors
src/hal0/providers/container.py the three methods route through the seam first; shared _decode_argv_json helper
tests/installer/test_podman_ro_validation.py new — 220 tests
tests/providers/test_container_podman_ro_routing.py new — 10 tests
tests/providers/test_podman_introspect.py +26 tests

Test evidence

The wrapper tests run the real bash wrapper (no root, no sudo, no podman, no provisioned box — the hal0-systemctl drop-in suite's posture) over ~40 malicious argv shapes: command chaining (;/&&/||/|), substitution ($(…)/backticks), flag smuggling (--rm, -v/:/host, --format={{.Config}}, bare -), traversal (../../../etc/shadow, foo/../../etc/passwd), malformed digests, control bytes and RTL-override unicode, plus 12 legitimate refs that must not be rejected. They also assert parity between the wrapper and the Python mirrors — a mirror that drifts looser turns a fast rejection into an opaque rc 64; one that drifts stricter silently re-creates #1889.

tests/installer/test_podman_ro_validation.py ....... 220 passed
tests/providers/test_podman_introspect.py ........... 47 passed
tests/providers/test_container_podman_ro_routing.py . 10 passed

tests/installer tests/providers tests/slot_view tests/install tests/system
                                                     1811 passed, 1 skipped
tests/api/test_slots_container_state.py tests/api/test_slots_image_pull.py
tests/api/test_system_info_route.py tests/updater/test_wrapper_refresh.py
                                                       48 passed

make lint                                            All checks passed
uv run ruff format --check src tests                 1149 files already formatted
uv run python scripts/check_sunset.py                scar markers 192 <= baseline 192

The issue's "test to promote" is test_slot_view_reports_present_for_a_running_slot, which drives the real ContainerProvider through slot_view's TTL cache helper — the exact path that produces image_status. The seam-routing tests booby-trap subprocess.run so a test cannot pass by silently falling through to the rootless read.

Not covered / follow-ups

  • The full tests/api suite was not run locally (it exceeds a 10-minute local budget); the relevant slot/system-info files were. CI covers the rest.
  • shellcheck is not installed on this box, so tests/installer/test_platform_gate_hardening.py skipped its shellcheck arm as it does on main. bash -n passes and is asserted by a test.
  • Verifying the fix end-to-end on a provisioned box (image_status: "present" for a running slot on ct151) needs a deploy and is left to RC validation.

🤖 Generated with Claude Code

`ContainerProvider.image_present` / `running_image` / `running_argv` shelled
out to a bare `podman` as the unprivileged `hal0` service user, which has no
subuid ranges, so they read hal0-api's own ROOTLESS image store. Slots run
ROOTFUL podman (Quadlet units under /etc/containers/systemd/, root's store),
so that store by construction never holds a slot image. On every standard
install `GET /api/slots` therefore reported `image_status: "missing"` for
every running, healthy slot, `actual_image` was always null, and the #663
image-drift detector could never fire.

The `hal0-podman-ro` seam and its `is_hal0_service_user()` gate already
existed (cbc8e94 wired them into /api/system-info) but exposed only the
argument-free `images` verb, so the three call sites that need a *specific*
image ref or container name could not use it.

This adds three argument-taking read verbs, with the argument validated on
the ROOT side of the privilege boundary before exec — the same posture
hal0-systemctl's slot/agent verbs have had since P3-perms:

  * `image-exists <ref>` — ref matched against the OCI reference grammar
    (optional host[:port]/, path segments with single ._- separators, optional
    :tag, optional @sha256:<64 hex>) and length-capped at 512. A validated ref
    cannot contain whitespace, a shell metacharacter, a leading '-', '..', or
    a second argv word.
  * `container-image <slot-token>` / `container-argv <slot-token>` — these
    take the slot's bare instance token (the hal0-systemctl validate_slot_id
    charset), NOT a container name: `hal0-slot-<token>` is assembled root-side,
    so the caller can only ever address a hal0 slot container.

Every podman subcommand, flag and --format string remains a literal in the
wrapper; no shell is evaluated; rm/run/build/exec/pull are still unreachable.
Two side-effect-free validator probes (`check-image-ref`, `check-slot-token`)
exist so the regexes can be exercised without podman, root, or a provisioned
box. A documented exit-code contract (0 = podman answered, 64 = rejected,
65 = no podman) lets the Python side distinguish "the seam did not answer"
from "the answer is negative" — the named-object reads deliberately do NOT
silently fall back to the rootless store, because a rootless answer about a
named image is not a stale answer, it is an answer about a different object.

podman_introspect mirrors both regexes so the unprivileged side fails fast
instead of burning a sudo round-trip; a shell test asserts the mirrors and the
wrapper agree. podman-ro also gains a `help` probe in preflight_seams and
seam_check, since a silently-missing grant is no longer cosmetic.

Refs #1889

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a06f74e7e0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread installer/wrappers/hal0-podman-ro Outdated
# false reject on a legitimate ref; a false reject is precisely the failure
# mode #1889 is about (it degrades to image_status="missing" again).
_REF_HOST='[A-Za-z0-9]+([.-][A-Za-z0-9]+)*(:[0-9]{1,5})?'
_REF_PATH='[A-Za-z0-9]+([._-][A-Za-z0-9]+)*(/[A-Za-z0-9]+([._-][A-Za-z0-9]+)*)*'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept valid separators in image repository names

The repository-component regex rejects valid container image names containing a double underscore or a run of hyphens, such as registry.example/team/model__gpu:v1 or team/model--gpu:v1; the standard distribution-reference grammar permits __ and one-or-more - as separators. On an installed box, these references are rejected before the rootful seam runs, so image_exists() returns None and ContainerProvider.image_present() falls back to the unrelated rootless store, reporting a rootful image as missing and recreating #1889 for valid custom images. The Python mirror and tests should be updated with the wrapper regex.

Useful? React with 👍 / 👎.

Comment thread installer/wrappers/hal0-podman-ro Outdated
Comment on lines +139 to +140
run_podman image inspect --format '{{.Id}}' -- "$1"
if (( PODMAN_RC == 0 )); then echo present; else echo missing; fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Podman inspection failures as seam failures

When rootful Podman returns any nonzero status—not only when the image is absent, but also for storage corruption, locking, permission, or runtime failures—this branch prints missing and lets the wrapper exit 0. image_exists() consequently treats an operational failure as an authoritative negative, so the slot API reports a locally present image as missing instead of degrading to an unknown/fallback result. Only the documented not-found outcome should produce missing; other Podman failures need to propagate as a nonzero seam result.

Useful? React with 👍 / 👎.

Comment on lines +2631 to +2633
seam_ref = podman_introspect.container_image(token)
if seam_ref:
return seam_ref

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize image references before reporting drift

For a slot declared with a valid shorthand or tagless image such as alpine:latest or ghcr.io/team/model, Podman commonly returns a canonical ImageName such as docker.io/library/alpine:latest or one with the implicit :latest tag. Returning that value directly makes the existing literal _image_mismatch() comparison mark an unchanged running slot as drifted now that this commit finally makes running_image() reachable on production boxes. Normalize equivalent references before exposing the value to the drift comparison, or compare immutable image identity instead.

Useful? React with 👍 / 👎.

Three P2 findings, all real — each one would have re-created #1889 for a
subset of slots rather than fixing it.

1. The image-ref regex rejected valid repository names. The distribution
   reference grammar's separator set is `"." | "_" | "__" | "-"+`, so
   `team/model__gpu` and `team/model--gpu` are legal; the regex accepted only
   single `.`/`_`/`-`. A ref this wrongly rejects never reaches the rootful
   seam, so image_present falls back to the unrelated rootless store and the
   image reports missing — exactly the bug being fixed. Both the wrapper and
   the Python mirror now use the grammar's separator set (with `__` ordered
   first in the Python copy, whose `re` is leftmost-first rather than POSIX
   leftmost-longest).

2. An operational podman failure was reported as a negative answer. `podman
   inspect` collapses "not found" and store-corruption/lock/permission
   failures into a single rc 125, so the wrapper printed `missing` and exited
   0 for both, and image_exists() treated a broken store as authoritative.
   The presence probes now use `podman image exists` / `podman container
   exists`, whose contract is rc 0 = yes, rc 1 = no, anything else = error,
   and the wrapper grew rc 66 for "podman ran but failed" — which the Python
   side already reads as "the seam did not answer", degrading rather than
   lying. The container verbs do both podman calls inside one sudo hop.

3. Drift comparison would have cried wolf on every correctly running slot.
   podman reports a canonical `docker.io/library/alpine:latest` while hal0
   profiles declare the shorthand an operator types, and `_image_mismatch`
   was a literal string compare. That was harmless only because running_image
   returned None on every deployed box; this branch makes it reachable, which
   would have turned an inert detector into a lying one. Both sides are now
   canonicalised (implicit `docker.io/`+`library/` registry, implicit
   `:latest`) before comparison, splitting the tag on the last colon after
   the last slash so a registry port is not amputated. Digest-vs-tag is
   compared on the repository alone: resolving whether a tag and a digest
   name the same image needs a registry round-trip this hot path will never
   make, and guessing "drifted" is the cry-wolf failure #663 forbids.

Refs #1889
@thinmintdev

Copy link
Copy Markdown
Contributor Author

Codex review triage — all three P2s accepted and fixed in 62b7d78

Each finding was real, and each would have re-created #1889 for a subset of slots rather than fixing it. None were dismissed.

1. __ / -+ separators rejected — CONFIRMED, fixed. The distribution-reference grammar's separator set is "." | "_" | "__" | "-"+; my regex accepted only the single-character forms, so team/model__gpu:v1 and team/model--gpu:v1 were rejected before the seam ran. The failure mode is precisely the bug this PR fixes: a rejected ref falls back to the rootless store and reports a present image as missing. Wrapper and Python mirror both now use the grammar's set — with __ ordered first in the Python copy, since re is leftmost-first rather than POSIX leftmost-longest and _|__ would otherwise match only the first underscore. The three refs from the finding are now in both the legitimate-ref fixtures and the wrapper↔mirror parity test.

2. podman failure reported as a negative — CONFIRMED, fixed. podman inspect collapses "not found" and store-corruption / lock / permission failures into a single rc 125, so the old arm printed missing and exited 0 for both. Fixed at the root: the presence probes now use podman image exists / podman container exists, whose contract is rc 0 = yes, rc 1 = no, anything else = error — the only podman verbs that can make this distinction. New wrapper exit code 66 = "podman ran but failed operationally", which _seam_read already treats as "the seam did not answer", so the caller degrades instead of lying. The container verbs run both podman calls inside one sudo hop, so this costs no extra round-trip. Verified against real podman: podman image exists nosuch → rc 1, podman container exists nosuch → rc 1.

3. Drift would cry wolf on every correctly running slot — CONFIRMED, fixed. Good catch on the second-order effect: _image_mismatch was a literal string compare, harmless only because running_image() returned None on every deployed box. Making it reachable would have traded an inert detector for a lying one. Added canonical_image_ref() — implicit docker.io/ (+ library/ for a bare single component, using the standard "first component is a registry iff it has ./: or is localhost" heuristic) and implicit :latest — applied to both sides before comparison. The tag is split on the last colon after the last slash, so localhost:5000/foo is not amputated into a false match. Digest-vs-tag compares the repository alone: resolving whether a tag and a digest name the same image needs a registry round-trip this hot path will never make, and guessing "drifted" is the cry-wolf failure #663's contract forbids.

Verification

tests/installer/test_podman_ro_validation.py     226 passed  (+6)
tests/providers/...                              857 passed  (+21)
tests/installer providers slot_view install system
  + api/{slots_container_state,slots_image_pull,slots_routes,system_info_route}
                                                1974 passed, 1 skipped
make lint / ruff format --check                 clean

New coverage: the three previously-rejected refs, rc 66 → None (never "missing") for both image_exists and container_image, structural assertions that the presence probes use exists rather than inspect and that no --format is ever read from argv, 11 canonical_image_ref cases, 5 no-false-drift pairs, 4 real-drift pairs (including the registry-port case), and the digest-vs-tag rule.

Still not for automerge — SUDOERS + INSTALLER change, operator review required.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62b7d7867d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hal0/providers/container.py Outdated
Comment on lines +2979 to +2981
has_registry = bool(slash) and ("." in first or ":" in first or first == "localhost")
if not has_registry:
name = f"docker.io/library/{name}" if not slash else f"docker.io/{name}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize Docker Hub's implicit library namespace

When a profile explicitly declares a single-component Docker Hub path such as docker.io/alpine, this branch treats the registry-qualified name as complete and produces docker.io/alpine:latest; Podman normalizes that reference to docker.io/library/alpine:latest, so _image_mismatch() reports drift for an unchanged container. Add the library/ namespace when the registry is Docker Hub and the repository portion has only one component.

Useful? React with 👍 / 👎.

Comment on lines +2595 to +2596
seam_answer = podman_introspect.image_exists(image)
if seam_answer is not None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict rootless fallback to non-service-user calls

When the API is running as hal0 and the rootful seam fails, image_exists() returns None, causing this method to fall through to the unrelated rootless store and usually return False. Thus a missing grant, validation rejection, or operational Podman failure still becomes image_status="missing" for a potentially present rootful image. Fresh evidence is that the new wrapper now correctly maps operational failures to rc 66/None, but this fallback immediately collapses that distinction; only dev/CI calls outside the service account should use the rootless fallback.

Useful? React with 👍 / 👎.

…issing"

Second Codex round, two more P2s, both real.

1. The rootless fallback undid the rc-66 distinction the previous commit
   introduced. On a provisioned box the rootless store definitionally cannot
   hold a slot image, so falling through to it after a seam failure turned
   "podman is broken" / "the grant is missing" / "the ref was rejected" back
   into an authoritative-looking `image_status: "missing"`. The fallback is
   now gated on `is_hal0_service_user()` — it runs only off the service
   account, where the operator's own store IS the store slots use — and the
   seam-failed-as-hal0 path logs a warning so an operator gets a diagnosable
   signal instead of a silent wrong answer. `running_image` / `running_argv`
   are gated the same way; there the answer was already None either way, so
   this only drops a wasted spawn from the status hot path and removes the
   chance of answering about a same-named container that is not this slot's.

   `image_present` still has to answer False there: its contract is a bool
   and `image_status` has no "unknown" member. Surfacing a distinct unknown
   state is an API-schema change, deliberately left out of scope.

2. `canonical_image_ref` missed Docker Hub's implicit `library/` namespace
   for an explicitly-qualified `docker.io/alpine`, which podman reports as
   `docker.io/library/alpine` — so an unchanged slot declared that way would
   have been flagged as drifted. The namespace rule now applies to the
   repository portion however the registry got there, after the registry
   rule rather than inside it.

Refs #1889
@thinmintdev

Copy link
Copy Markdown
Contributor Author

Codex round 2 — both P2s accepted and fixed in 6d6c4de

4. Rootless fallback collapsed the rc-66 distinction — CONFIRMED, fixed. This was the sharpest finding on the PR: the previous commit added rc 66 precisely so an operational podman failure would not read as "missing", and then the fallback in image_present immediately threw that away. On a provisioned box the rootless store definitionally cannot hold a slot image, so consulting it after a seam failure converts "podman is broken" / "the grant is missing" / "the ref was rejected" back into an authoritative-looking image_status: "missing".

The fallback is now gated on is_hal0_service_user() — it runs only off the service account, where the operator's own store genuinely is the store slots use — and the seam-failed-as-hal0 path emits hal0.podman_ro.image_present_unanswered at WARNING so an operator gets a diagnosable signal rather than a silent wrong answer. running_image / running_argv are gated the same way; there the answer was already None either way, so it drops a wasted subprocess spawn from the status hot path and removes the chance of answering about a same-named container that is not this slot's.

One honest limitation, called out rather than papered over: image_present must still answer False on that path, because its contract is a bool and the image_status enum has no "unknown" member. Surfacing a distinct unknown state is an API-schema change and is deliberately out of scope for a fix PR — flagging it here as a follow-up rather than smuggling it in.

5. Docker Hub's implicit library/ namespace — CONFIRMED, fixed. docker.io/alpine was treated as already-qualified and canonicalised to docker.io/alpine:latest, while podman reports docker.io/library/alpine:latest — so a slot declared that way would have been flagged as drifted while running the correct image. The namespace rule now applies to the repository portion however the registry got there, sequenced after the registry rule instead of nested inside it, so alpine, docker.io/alpine and docker.io/library/alpine all canonicalise identically.

Verification

tests/installer tests/providers tests/slot_view tests/install tests/system
  + api/{slots_container_state,slots_image_pull,slots_routes,system_info_route}
                                          1984 passed, 1 skipped  (+10)
make lint / ruff format --check           clean

New coverage: no_rootless (a booby-trapped subprocess.run that raises if reached) asserts the rootless store is never touched as the service user for all three methods; the warning is asserted via caplog; the dev/CI fallback is re-pinned with an explicit is_hal0_service_user() == False; and docker.io/alpine / docker.io/alpine:3.19 are added to the canonicalisation and no-false-drift tables.

Still not for automerge — SUDOERS + INSTALLER change, operator review required.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d6c4decff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread installer/wrappers/hal0-podman-ro Outdated
# rootless one and #1889 comes straight back for that image. `model__gpu` and
# `model--gpu` are both legal repository names.
_REF_SEP='(__|[._]|-+)'
_REF_HOST="[A-Za-z0-9]+(([.]|-+)[A-Za-z0-9]+)*(:[0-9]{1,5})?"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept bracketed IPv6 registry addresses

Fresh evidence beyond the separator fix is that this host expression still rejects valid bracketed IPv6 registries, such as [2001:db8::1]:5000/team/model:v1. Because image_pin is honored verbatim, an installed service can launch such an image successfully, but image_exists() rejects the same reference before invoking the rootful seam; image_present() then reports it as missing. Extend both the wrapper regex and its Python mirror to accept bracketed IPv6 hosts.

Useful? React with 👍 / 👎.

if bool(running_digest) != bool(declared_digest):
return _split_image_tag(running_repo)[0] != _split_image_tag(declared_repo)[0]

return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare matching digests before tag text

When both references are digest-pinned, this unconditional return reports drift unless their complete strings match. Valid equivalent forms such as ghcr.io/team/model:v1@sha256:<digest> and ghcr.io/team/model@sha256:<same-digest> therefore produce image_mismatch=true, even though the repository and immutable digest identify the same image. The both-digest branch should compare the repository with any tag removed and then compare the digest values.

Useful? React with 👍 / 👎.

Comment thread src/hal0/system/seam_check.py Outdated
# cosmetic — probe it. `help` prints usage and touches nothing.
SeamSpec(
"hal0-podman-ro",
probe=("help",),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Probe a newly added verb instead of legacy help

On a box where the best-effort wrapper refresh fails and leaves the pre-#1889 wrapper installed, this probe still exits successfully because that old wrapper already implements help, even though it rejects all three new verbs. hal0 doctor therefore reports the seam as healthy while image_status remains missing and actual_image remains unavailable. Probe a release-specific, side-effect-free capability such as check-slot-token <known-token>, or validate that the help output advertises the new verbs.

Useful? React with 👍 / 👎.

…ests

Third Codex round, three P2s, all real.

1. The seam probe could not tell a current wrapper from a stale one. `help`
   exists on the PRE-#1889 wrapper too, so a box whose best-effort wrapper
   refresh failed would keep the one-verb wrapper, probe green, and report
   the seam healthy from `hal0 doctor` and from install.sh's post-install
   assertion — while every new verb was rejected and image_status stayed
   "missing". That is exactly the undiagnosable-green failure #1465 exists to
   prevent. Both probe inventories now use `check-slot-token hal0probe`,
   which is release-specific (the old wrapper answers rc 64) and still
   side-effect-free: it validates the token and prints the container name it
   would build, touching neither podman nor the filesystem. preflight's
   probe runner now splits its hardcoded probe line into argv words, since a
   verb-only probe cannot make this distinction.

2. Bracketed IPv6 registry literals were rejected. `[2001:db8::1]:5000/team/
   model:v1` is a legal reference host, and a rejection here is the #1889
   failure mode again — the ref never reaches the rootful seam and the image
   reports missing. The bracket body is hex-and-colons only, so it still
   cannot carry a path, whitespace, a metacharacter or a second word.

3. Two digest-pinned refs compared as whole strings. A digest IS the
   immutable image id, so `repo:v1@sha256:D` and `repo@sha256:D` name the
   same image and the tag text alongside is noise; the old code reported
   drift for them. When both sides carry a digest the comparison is now
   repository-without-tag plus digest. The one-sided case is unchanged
   (repository only — resolving a tag against a digest needs a registry
   round-trip this hot path will never make).

Refs #1889
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@thinmintdev

Copy link
Copy Markdown
Contributor Author

Codex round 3 — all three P2s accepted and fixed in 8290fdf

6. The probe could not tell a current wrapper from a stale one — CONFIRMED, fixed. The best finding of the three, and a self-inflicted one: I added a help probe two commits ago specifically because the grant became load-bearing, but help exists on the pre-#1889 wrapper too. A box whose best-effort wrapper refresh failed would keep the one-verb wrapper, probe green, and report the seam healthy from both hal0 doctor and install.sh's post-install assertion — while every new verb was rejected and image_status stayed "missing". That is precisely the undiagnosable-green failure #1465 exists to prevent, so shipping it inside a #1465-adjacent inventory would have been embarrassing.

Both inventories now probe check-slot-token hal0probe: release-specific (the old wrapper answers rc 64) and still side-effect-free — it validates the token and prints the container name it would build, touching neither podman nor the filesystem. _preflight_seam now splits its hardcoded probe line into argv words, since a verb-only probe structurally cannot make this distinction. The seam_check.py and preflight.sh copies stay in lock-step, with a comment on each pointing at the other.

7. Bracketed IPv6 registries rejected — CONFIRMED, fixed. [2001:db8::1]:5000/team/model:v1 is a legal reference host, and the failure mode is #1889 verbatim: the ref never reaches the rootful seam and a present image reports missing. Added as a host alternative in the wrapper and the mirror. The bracket body is [0-9A-Fa-f:]{2,45} — hex and colons only — so it cannot carry a path, whitespace, a metacharacter or a second word; four hostile bracket shapes ([2001:db8::1;id]:5000/foo, [../etc]/foo, []/foo, [2001:db8::1]extra/foo) are pinned as rejected, and both IPv6 forms go through the wrapper↔mirror parity test.

8. Two digest-pinned refs compared as whole strings — CONFIRMED, fixed. A digest is the immutable image id, so repo:v1@sha256:D and repo@sha256:D name the same image and the tag text alongside it is noise; the old code called that drift. When both sides carry a digest the comparison is now repository-without-tag plus digest. The one-sided case is unchanged (repository only — resolving a tag against a digest needs a registry round-trip this hot path will never make).

Verification

tests/installer providers slot_view install system updater
  + api/{slots_container_state,slots_image_pull,slots_routes,system_info_route}
                                          2270 passed, 1 skipped
bash -n installer/lib/preflight.sh        ok
make lint / ruff format --check           clean

Prior rounds' CI on 6d6c4de was fully green (python 3.12, γ-suite, ui, sunset, CodeQL ×4) before these commits landed.

Still not for automerge — SUDOERS + INSTALLER change, operator review required.

@thinmintdev

Copy link
Copy Markdown
Contributor Author

Independent security review by a second agent (posted as a comment: GitHub will not let this account formally approve its own PR). Verdict: APPROVE — 0 blocking findings.

Independent security review — APPROVE (no blocking findings)

Adversarial review of the privilege boundary only. I did not write this code and re-ran every claim rather than reading the PR body for them. No validation bypass found. 4 non-blocking findings, all hardening/plumbing, none of which lets a caller escape the intended object or reach a non-read podman subcommand.

Note this does not clear the PR's own operator gate — it is a sudo-seam change and still needs the operator's sign-off, not automerge.

Design claims verified

1. Root-side validation. ~120 hostile inputs beyond the suite's set, run against the real installer/wrappers/hal0-podman-ro (check-image-ref, and re-confirmed through the live image-exists verb). All rc 64:

  • chaining/substitution: alpine;id, alpine&&id, alpine|id, alpine`id`, alpine$(id), <(id), >x
  • flag smuggling: -, --, -rm, --rm, -v/:/host, --format={{.Config}}
  • whitespace/IFS: leading space, trailing space, embedded space, \t, \n, \r, $IFS
  • traversal / shape: .., ../../../etc/shadow, foo/../../etc/passwd, foo/.., /abs, abc/, a//b, empty
  • control bytes and unicode: \x01, \x7f, \x1b[31m, U+202E RTL override, invalid UTF-8 \xc0\x80
  • digest edges: bare @sha256:<64>, 2-hex, 63-hex, uppercase hex, sha512:, trailing g
  • IPv6 bracket abuse: []/a, [/etc/passwd]/a, [2001:db8::1]extra/foo, [2001:db8::1;id]:5000/foo

Arity is enforced per verb ([[ $# -eq 1 ]]): zero args and a second argv word both rc 64 on all three verbs, and on the two probes. Unknown verb rc 64. images ignores trailing argv (literal exec array), so it cannot be widened by extra words.

No shell interpolation anywhere: every podman call is a quoted exec array, set -euo pipefail, no eval, no unquoted expansion, no globbing. [[ "$x" =~ $RE ]] has the RHS correctly unquoted (a quoted RHS would silently become a literal match — worth keeping in mind if anyone "tidies" it later).

No ReDoS. _REF_SEP=(__|[._]|-+) under a * is the classic shape, so I timed the adversarial partitions: a+500×-, (a-)×250+!, (a__)×160+!, (/a)×250+!, (a.)×250+!, 45-char IPv6 body — all ≤ 3 ms. The 512-byte cap is applied before the regex (verified: 513 chars rejects without touching the engine). A 1 MB slot token is also safe.

2. Container verbs cannot address an arbitrary container. Confirmed. The token charset admits -, --all, -rm, but the value is only ever concatenated into hal0-slot-<token> root-side and passed after --; check-slot-token --all prints hal0-slot---all. There is no code path where the token becomes a bare argv word. 64 chars accepted, 65 rejected. .///:/@/whitespace all rejected, so the assembled name can never be a path, an ID, or a second word.

3. Sudoers byte-unchanged. diff <(git show main:packaging/sudoers/hal0-podman-ro | grep -v '^#') <(git show HEAD:… | grep -v '^#') is empty — comments only, grant line identical. Agree with the reasoning for not enumerating argv in sudoers.

4. Exit-code contract + fallback gate. Wrapper side is right: image exists/container exists (rc 0/1/other) rather than inspect (125 for everything) is the correct primitive, and I confirmed real podman returns 125 for a malformed-but-regex-valid ref (ALPINE → wrapper rc 66, not a false "missing"). The is_hal0_service_user() gate is asserted in both directions by the routing tests, and the rootless fallback is provably unreachable on the service account (see finding 2 below for the one place the distinction is dropped).

5. Parity test really runs the wrapper. Mutated _REF_IPV6 in the wrapper in a scratch worktree → 4 tests red, including both test_python_image_ref_mirror_agrees_with_the_wrapper[…] cases. Not a self-referential test.

6. Codex round-3 #6 — probe is release-specific. Confirmed against the real old wrapper from main:

$ ./w-old check-slot-token hal0probe   -> hal0-podman-ro: bad cmd: check-slot-token   rc=64
$ ./w-new check-slot-token hal0probe   -> hal0-slot-hal0probe                          rc=0
$ ./w-old help                         -> rc=0     # why `help` was insufficient

7. Revert-and-confirm-red. git checkout main -- src/hal0/providers/container.py38 failed, 6 passed, with test_slot_view_reports_present_for_a_running_slot among them. The booby-trap is real (monkeypatch.setattr(container_mod.subprocess, "run", _boom) raising AssertionError), so a seam test cannot pass by falling through to the rootless read.

8. No hostile ref reaches a non-read subcommand. The only podman invocations in the file are images, image exists, container exists, inspect --format <literal>. No TOCTOU between validation and exec — same in-process string, no filesystem lookup in between. Wrapper stderr (which does echo the rejected operand verbatim) is never logged by the Python side: _seam_read reads stdout only and preflight discards both, so there is no log-injection path.

Suites: tests/installer/test_podman_ro_validation.py tests/providers tests/system/test_seam_check.py900 passed.


Findings (non-blocking)

1. [low] Root-side regex semantics are caller-influenced by locale, and the test hides it.
glibc range expressions are collation-based outside the C locale, and sudo's default env_keep carries LANG/LC_* through to the wrapper. So the authoritative root-side validator behaves differently depending on an environment variable the caller supplies:

LC_ALL=C           check-image-ref alpiné  -> rc 64
LC_ALL=en_US.UTF-8 check-image-ref alpiné  -> rc 0     # also ABC, ⅰmage

The Python mirror uses re with ASCII-only [A-Za-z0-9] and rejects all three, so this is a genuine wrapper-looser-than-mirror parity break on any box whose service environment carries a UTF-8 LANG. The suite cannot see it because _ENV = {"PATH": "/usr/bin:/bin"} strips the locale, which is exactly why "alpiné" sits in MALICIOUS_REFS and passes.

I brute-forced all 255 single bytes under both locales and the accepted set is identical and ASCII-alnum-only in each, so nothing structural leaks: no metacharacter, no space, no /, no leading -, no ... The blast radius is multibyte letters only, which podman rejects anyway. Not an escalation — but a root-side allow-list whose meaning depends on caller-supplied env is worth closing on principle.

Suggested: export LC_ALL=C immediately after set -euo pipefail (also stabilises podman's output), plus one parity case that runs the wrapper with LANG=en_US.UTF-8 in _ENV so the property is actually pinned.

2. [low] rc 64 / 65 / 66 all collapse to None one layer up, so the wrapper's careful distinction never reaches the API.
_seam_read returns None for every non-zero rc, and on the service account image_present then logs and returns False. Net effect: an operational podman failure still surfaces as image_status: "missing" — the shape Codex flagged. This PR does materially improve it (no rootless conflation, an operator-visible warning, a seam probe in preflight/doctor that catches the missing-grant case), and the docstring is honest that an unknown state is an API-schema change. Please file the follow-up so the rc 66 path the wrapper works hard to produce doesn't stay decorative. Evidence: image-exists ALPINE → podman 125 → wrapper rc 66 → image_exists() Noneimage_present() False.

3. [nit] container_read uses podman inspect, not podman container inspect.
Gated by container exists, and the name is always hal0-slot-*, so I could not construct an abuse — but --type container (or container inspect) removes the object-type ambiguity by construction rather than by argument, which is the doctrine the rest of this file follows.

4. [nit] TOCTOU between container exists and inspect.
If the slot is torn down between the two calls, inspect fails → rc 66 → None → "no actual_image". The fail-direction is the safe one (unknown, never false drift), so this is a note, not a request.

Assembly of hal0-slot-<token> root-side, -- before every operand, the exec-array-only rule, and the "structure-strict, case-permissive" reasoning are all correct calls. Approving on the security boundary.

Security review hardening on #1889 (APPROVE, 0 blocking).

Finding 1 [low] — the wrapper's `[[ =~ ]]` validators were locale-sensitive.
bash bracket expressions use the current locale's collation, so under a UTF-8
locale `[A-Za-z0-9]` collates accented letters INTO the range: with
LC_ALL=en_US.UTF-8, `check-image-ref alpiné` returned rc 0 while the Python
mirror rejected it — the wrapper LOOSER than its mirror, the dangerous
direction of a parity break. It was reachable in production because sudo's
default `env_keep` passes LANG/LC_* straight through from the calling process;
the test suite could not see it because its `_ENV` strips locale vars
entirely. `export LC_ALL=C` now runs immediately after `set -euo pipefail`,
before any validator can, so every character class means exactly the ASCII
bytes it spells in any caller's environment.

Reproduced first: `env -i PATH=… LC_ALL=en_US.UTF-8 hal0-podman-ro
check-image-ref alpiné` → rc 0 before, rc 64 after.

Finding 3 [nit] — `container_read` used a bare `podman inspect`, which also
resolves images, volumes, networks and pods, so a name collision could return
a different object's fields. Now `podman container inspect`: type-safety by
construction, even though the name is already pinned to the hal0-slot- prefix.

Tests: 7 new cases, red before the fix. Unicode refs and tokens must be
rejected under a UTF-8 locale; the whole ref corpus must reach an IDENTICAL
verdict in both locales AND still agree with the Python mirror under UTF-8;
a structural assertion pins `export LC_ALL=C` ahead of the first validator
(the locale cases prove nothing on a box where en_US.UTF-8 is not generated,
since bash silently falls back to C there); and every `run_podman … inspect`
must name its object type.

Refs #1889
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@thinmintdev

Copy link
Copy Markdown
Contributor Author

Security review hardening — both findings addressed in 73ae6fcf

Re: the review at #1937 (comment) (APPROVE, 0 blocking). Finding 1 was a genuine hole rather than a hardening nit, and the test suite structurally could not have caught it.

Finding 1 [low] — locale-sensitive validators. Reproduced first, then fixed.

env -i PATH=/usr/bin:/bin                     …/hal0-podman-ro check-image-ref alpiné  → rc 64
env -i PATH=/usr/bin:/bin LC_ALL=en_US.UTF-8  …/hal0-podman-ro check-image-ref alpiné  → rc 0   ← accepted
env -i PATH=/usr/bin:/bin LANG=en_US.UTF-8    …/hal0-podman-ro check-image-ref alpiné  → rc 0   ← accepted
python -c "…is_valid_image_ref('alpiné')"                                              → False

bash's [[ =~ ]] bracket expressions use the current locale's collation, so under a UTF-8 locale [A-Za-z0-9] collates accented letters into the range. That made the wrapper looser than its mirror — the dangerous direction of a parity break — and it was reachable in production, since sudo's default env_keep passes LANG/LC_* straight through from the calling process. Your read of why the suite was blind to it is exactly right: _ENV is {"PATH": …} only, so every existing case ran under an implicit C locale.

export LC_ALL=C now sits immediately after set -euo pipefail, before any validator can run, so every character class means exactly the ASCII bytes it spells regardless of the caller's environment. Post-fix: rc 64 under both locale vars.

Finding 3 [nit] — bare podman inspect. Switched to podman container inspect. The bare form also resolves images, volumes, networks and pods, so a name collision could have returned a different object's fields; the name is already pinned to the hal0-slot- prefix, but type-safety by construction is strictly better. Error text updated to match.

Tests — 7 new cases, all red before the fix:

  • unicode refs (alpiné, ALPINÉ, café/img:tag) and tokens (braîn, tokén) must be rejected under LANG + LC_ALL = en_US.UTF-8
  • the entire ref corpus (legitimate + malicious) must reach an identical verdict in both locales, and still agree with the Python mirror under UTF-8 — so this cannot regress for one input class while passing for another
  • a structural assertion that export LC_ALL=C is present and ordered ahead of the first validator. This one carries weight: on a box where en_US.UTF-8 is not generated bash silently falls back to C, so the behavioural cases alone would be a false green on CI
  • test_inspect_calls_are_type_qualified — every run_podman … inspect must name its object type
tests/installer/test_podman_ro_validation.py        313 passed  (+7)
tests/installer providers slot_view install system updater
  + api/{slots_container_state,slots_image_pull,slots_routes,system_info_route}
                                                   2341 passed, 1 skipped
make lint / ruff format --check / check_sunset.py  clean
bash -n on the wrapper                             ok

Still not for automerge — SUDOERS + INSTALLER change, operator merge required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant