Files
ironclaw/docker
Henry Park 7b41f0da18 fix(docker): repair home ownership so a root-written file cannot brick boot (#7924)
* fix(docker): repair home ownership so a root-written file cannot brick boot

A hosted instance crash-looped on every restart with:

    Error: could not resolve LLM environment fallback: Provider
    provider_registry request failed: failed to read provider registry
    overlay `/data/ironclaw-reborn/providers.json`: Permission denied (os
    error 13)

while `config.toml` in the same directory loaded fine — so this was one
file's ownership, not directory traversal. The provider-registry overlay is
fail-closed by design (an unreadable explicit overlay must not silently fall
back to compiled-in defaults), so a single root-owned file ends the boot
rather than degrading it.

Root cause is the interaction between two deliberate choices. #7723 changed
the runtime image from `USER ironclaw` to `USER root`, because sshd must
start as root before the entrypoint drops via `gosu` — so for the first time
a root process can write to the persistent volume. And the root pass's
`chown` is non-recursive, because start-sshd.sh refuses to run unless it owns
`$IRONCLAW_REBORN_HOME/ssh`. Together they mean the entrypoint fixes the home
DIRECTORY and leaves its CONTENTS untouched, and a volume outlives the image
that wrote it.

Repair ownership of the home's contents in the root pass, excluding `ssh`.
Recursing into `ssh` would trade this crash loop for a broken SSH listener,
which is why the exclusion is explicit rather than incidental.

Reproduced before fixing, in debian:bookworm-slim, seeding a volume the way
the failing instance looks:

    BEFORE  -rw-r--r-- ironclaw ironclaw  config.toml
            -rw------- root     root      providers.json
    AFTER entrypoint (root pass ran):  unchanged
    uid 1000 reading providers.json:   Permission denied

With the fix, providers.json and nested files become ironclaw-owned and
readable, while `ssh/` and its host key stay root:root.

Regression test `home_contents_ownership` asserts both halves — that every
top-level home entry is handed to chown, and that `ssh` is NOT. It fails
without the fix and passes with it. The chown stub records argv rather than
executing, so the test pins what the root pass asks for.

Scope note: this is a robustness fix, not a diagnosis of one box. I could not
inspect the instance, so I have not proven how its providers.json became
root-owned — only that this mechanism produces exactly the logged error and
that `USER root` is what makes root-owned files possible at all. A persistent
volume shared across image versions with differing runtime users should not be
able to brick boot regardless of which write created the file.

Also applies to the 1.3 line, which has carried `USER root` since #7723.

Verified: entrypoint self-tests pass on debian:bookworm-slim as a non-root
user (as CI runs them); the real-sshd regression harness still reports 16/16
against the release/1.3.1 baseline; a full entrypoint -> start-sshd -> real
SSH login still succeeds as agent:1000 with the ssh state dir root-owned;
shellcheck clean apart from two pre-existing SC2016 notices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(docker): follow a symlinked home and pin the repair's real properties

Review of the first cut found three defects, two of which meant the fix or its
test did not do what they claimed.

**The repair silently did nothing when the home is a symlink.** `find` defaults
to a physical walk, so `find "$IRONCLAW_REBORN_HOME" -mindepth 1` matches
nothing when the start path is a symlink -- no error, no repair, and the boot
loop persists with no diagnostic. Verified in debian:bookworm-slim:
`find /link -mindepth 1 -maxdepth 1` returns 0 entries, `find -H /link` returns
2. Now uses `-H`, which follows the start path only, so symlinks planted
*inside* the home are still never followed; combined with `chown -h`, a
planted symlink cannot redirect ownership outside the home. Confirmed: a
`planted -> /etc` symlink leaves /etc and /etc/passwd root-owned.

**The test did not pin recursion.** It asserted the top-level `nested` entry
appeared in the recorded argv, which stays true even with a non-recursive
chown -- while `nested/deep.db` would keep its old ownership and still brick
startup. It now asserts the nested file itself.

**The test swallowed the entrypoint's exit status** with `) || true`, so a
run that failed after the ownership commands could still pass on argv alone.
The run is now required to succeed.

Also dropped a `! -user ironclaw` filter added in the first cut as a
steady-state optimization. Its own tests caught why it was wrong: the
predicate resolves the name at find time and errors with
`find: 'ironclaw' is not the name of a known user` wherever it does not
resolve, which under `set -e` aborts the entire boot. That trades a rare
ownership repair for a guaranteed crash, and no review finding asked for it.

Each property is now proven discriminating by mutation:
- drop `-H`      -> FAIL symlinked_home_ownership
- top-level only -> FAIL home_contents_ownership (nested/deep.db)
- stop pruning ssh -> FAIL home_contents_ownership (ssh chowned)

Verified: entrypoint self-tests pass on debian:bookworm-slim as a non-root
user; the real-sshd harness still reports 16/16 against the release/1.3.1
baseline; shellcheck clean apart from two pre-existing SC2016 notices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 03:18:13 +00:00
..