diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b2eca..98d3b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,10 @@ stable v1.2 release and includes that fix. SSH server Feature, so `gh codespace ssh` can attach as documented. - Codespaces reconcile their post-create default Selections without opening interactive pickers when the orchestrator assigns a pseudo-TTY. -- Docker Boxes can remap `dev` to a non-default PUID/PGID even when lifecycle - adapters mount managed files read-only beneath the Managed home. +- Docker Boxes can remap `dev` to a non-default PUID/PGID even with read-only + managed files beneath the Managed home. Native unprivileged Linux lifecycle + installs now require those IDs to match the invoking account; intentional + NAS/Unraid remapping remains available to root-run rootful runtimes. ### Changed diff --git a/README.md b/README.md index df70ecb..ddbb7fd 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Flags: `--build` (build from source), `--edge` (latest `main`), `--adopt` | `SQUAREBOX_TAG` | latest published stable | Published Release to install (for example `v1.2.1`). Tags use `vMAJOR.MINOR.PATCH[-prerelease]`; build metadata is not published. | | `SQUAREBOX_IMAGE` | value from `release.json` | Optional image-repository override for development/testing. | | `SQUAREBOX_BUILD` | `0` | `1` is equivalent to `--build`. | -| `PUID` / `PGID` | invoking Linux user | Host uid/gid that should own bind-mounted files. Docker/rootful hosts may override these (Unraid/NAS: `99` / `100`); rootless Podman requires the invoking host identity. | +| `PUID` / `PGID` | invoking Linux user | Host uid/gid that should own bind-mounted files. Unprivileged Linux installs must match the invoking account. Root-run rootful runtimes, including Docker or Podman on NAS and Unraid, may override these (typically `99` / `100`); rootless Podman also requires the invoking host identity. | | `SQUAREBOX_RUNTIME` | auto | Force `docker` or `podman`. | | `SQUAREBOX_HOME_VOLUME` | `squarebox-home` | Name of the named volume backing `/home/dev`. | | `SQUAREBOX_EDGE` | `0` | `1` is equivalent to `--edge`. | diff --git a/SECURITY.md b/SECURITY.md index 112d0f9..7c9e209 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,7 +42,9 @@ than a hostile-code sandbox. Host DAC permissions and the declared mounts remain the access boundary. Because the entrypoint is already unprivileged in this mode, rootless Podman rejects PUID/PGID overrides that differ from the invoking host identity; -Docker and rootful runtimes retain their remapping behavior. +the native Linux POSIX lifecycle adapter rejects the same mismatch for every +unprivileged runtime before it mutates host state. Root-run rootful runtimes +retain explicit remapping for administered NAS and Unraid installations. The installer does not install host packages. On legacy Linux installs it may need to repair ownership that an older Squarebox release changed; any host @@ -241,9 +243,62 @@ SSH files as a fallback—or as native PowerShell's current SSH path—exposes t contents read-only to Box processes. The entrypoint validates numeric UID/GID inputs and refuses unsafe Managed-home -dotfile symlinks. Rootless Podman maps keep-id to the image's `dev` account and -leaves host SELinux labels untouched by disabling label separation for the Box; -validate this tradeoff on the target host before using sensitive credentials. +dotfile symlinks. On native Linux, an unprivileged lifecycle install also +requires PUID/PGID to match the invoking account because a different Box owner +cannot safely share its private host-bound lifecycle files. Explicit ownership +remapping is reserved for root-run rootful runtimes, including Docker or Podman +on NAS and Unraid. +Rootless Podman maps keep-id to the image's `dev` account and leaves host SELinux +labels untouched by disabling label separation for the Box; validate this +tradeoff on the target host before using sensitive credentials. + +If an existing POSIX Install identity records IDs that no longer match the +invoking Linux account, adopt the current account without changing channels or +paths: + +```bash +PUID="$(id -u)" PGID="$(id -g)" "$SQUAREBOX_DIR/install.sh" +``` + +An older Box may already have changed the specific host binds that it mounted +beneath `/home/dev`. Stop the recorded Box, then restore only those paths after +validating the recorded install and Workspace identities: + +```bash +set -euo pipefail +case "${SQUAREBOX_DIR:-}" in /*) ;; *) exit 64 ;; esac +[ "$SQUAREBOX_DIR" != / ] && [ ! -L "$SQUAREBOX_DIR" ] || exit 64 +state="$SQUAREBOX_DIR/.squarebox/install-state" +sudo test -f "$state" && sudo test ! -L "$state" || exit 1 +state_value() { + sudo awk -F= -v wanted="$1" ' + $1 == wanted { count++; value = substr($0, index($0, "=") + 1) } + END { if (count != 1) exit 1; print value } + ' "$state" +} +format=$(state_value FORMAT) +recorded_install=$(state_value INSTALL_DIR) +workspace=$(state_value WORKSPACE_DIR) +[ "$format" = 1 ] && [ "$recorded_install" = "$SQUAREBOX_DIR" ] || exit 64 +case "$workspace" in /*) ;; *) exit 64 ;; esac +[ "$workspace" != / ] && sudo test -d "$workspace" \ + && sudo test ! -L "$workspace" || exit 64 + +git_identity="$SQUAREBOX_DIR/.squarebox/identity/git" +lazygit_config="$SQUAREBOX_DIR/.config/lazygit" +starship_config="$SQUAREBOX_DIR/.config/starship.toml" +sudo test -d "$git_identity" && sudo test ! -L "$git_identity" || exit 1 +sudo test -d "$lazygit_config" && sudo test ! -L "$lazygit_config" || exit 1 +sudo test -f "$starship_config" && sudo test ! -L "$starship_config" || exit 1 + +owner="$(id -u):$(id -g)" +sudo chown -R --preserve-root -- "$owner" "$git_identity" "$lazygit_config" +sudo chown --no-dereference -- "$owner" "$starship_config" "$workspace" +PUID="$(id -u)" PGID="$(id -g)" "$SQUAREBOX_DIR/install.sh" +``` + +Do not replace these resolved paths with the home directory, the install root, +or another broad recursive target. ## Safe lifecycle deletion diff --git a/docs/releases/v1.2.1.md b/docs/releases/v1.2.1.md index a410ca7..31110bb 100644 --- a/docs/releases/v1.2.1.md +++ b/docs/releases/v1.2.1.md @@ -52,8 +52,10 @@ same Release. defaults can select terminal multiplexers. Codespaces also include a pinned SSH server Feature so `gh codespace ssh` works with the custom image, and post-create reconciliation remains noninteractive even when given a PTY. -- Docker PUID/PGID remapping remains compatible with read-only managed files - mounted beneath `/home/dev`, including the lifecycle-managed Bash profile. +- Image-level Docker PUID/PGID remapping remains compatible with read-only + managed files beneath `/home/dev`. For the POSIX lifecycle adapter, a native + unprivileged Linux install must use the invoking account IDs; intentional + NAS/Unraid remapping requires a root-run rootful runtime. - xh, just, and mise advance with reviewed amd64 and arm64 checksums. The new optional tools are not forced into an existing Selection. Run diff --git a/install.sh b/install.sh index 461a330..063cdf8 100755 --- a/install.sh +++ b/install.sh @@ -238,8 +238,10 @@ if [ "$HAD_STATE" = 1 ] && [ "$HOME_VOLUME" != "$STATE_HOME_VOLUME" ]; then echo "Error: cannot change the recorded Managed-home name during rebuild; uninstall this identity first." >&2; exit 1 fi -if [ "$(uname -s)" = Linux ]; then - _host_uid="$(id -u)"; _host_gid="$(id -g)" +_host_os="$(uname -s)" +_invoking_uid="$(id -u)"; _invoking_gid="$(id -g)" +if [ "$_host_os" = Linux ]; then + _host_uid="$_invoking_uid"; _host_gid="$_invoking_gid" # The image cannot safely remap dev to uid/gid zero. [ "$_host_uid" -gt 0 ] || _host_uid=1000 [ "$_host_gid" -gt 0 ] || _host_gid=1000 @@ -255,6 +257,12 @@ validate_id() { } validate_id PUID "$PUID"; validate_id PGID "$PGID" PUID="$((10#$PUID))"; PGID="$((10#$PGID))" +if [ "$_host_os" = Linux ] && [ "$_invoking_uid" -ne 0 ] \ + && { [ "$PUID" != "$_invoking_uid" ] || [ "$PGID" != "$_invoking_gid" ]; }; then + echo "Error: an unprivileged Linux install requires PUID/PGID to match the invoking host identity ($_invoking_uid:$_invoking_gid); requested $PUID:$PGID." >&2 + echo " Run the installer as the target account. Only root-run rootful runtimes, including Docker or Podman on NAS and Unraid, may remap ownership." >&2 + exit 64 +fi case "$EDGE:$BUILD" in 0:0|0:1|1:1) ;; *) echo "Error: EDGE/BUILD must be 0 or 1." >&2; exit 64 ;; esac for _path in "$INSTALL_DIR" "$WORKSPACE_DIR" "$GIT_CONFIG_DIR"; do is_absolute_state_path "$_path" || { echo "Error: lifecycle paths must be absolute and normalized (got '$_path')." >&2; exit 64; } diff --git a/tests/test-lifecycle-install-state.sh b/tests/test-lifecycle-install-state.sh index ee45cbb..2fb62b8 100755 --- a/tests/test-lifecycle-install-state.sh +++ b/tests/test-lifecycle-install-state.sh @@ -160,12 +160,71 @@ cat >"$TMP/bin/winpty" <<'EOF' printf '%s|%s|%s\n' "${MSYS_NO_PATHCONV:-}" "${MSYS2_ARG_CONV_EXCL:-}" "$*" >>"$MOCK_RUNTIME/winpty-calls" exec "$@" EOF +cat >"$TMP/bin/id" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + -u) [ -z "${MOCK_ID_UID:-}" ] || { printf '%s\n' "$MOCK_ID_UID"; exit 0; } ;; + -g) [ -z "${MOCK_ID_GID:-}" ] || { printf '%s\n' "$MOCK_ID_GID"; exit 0; } ;; +esac +exec /usr/bin/id "$@" +EOF +cat >"$TMP/bin/uname" <<'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = -s ] && [ -n "${MOCK_UNAME_S:-}" ]; then + printf '%s\n' "$MOCK_UNAME_S" + exit 0 +fi +exec /usr/bin/uname "$@" +EOF ln -s docker "$TMP/bin/podman" chmod +x "$TMP/bin/"* INSTALL="$TMP/custom squarebox=managed" export PATH="$TMP/bin:$PATH" HOME="$TMP/home" SHELL=/bin/bash PRIMARY_HOME="$HOME" + +# A native unprivileged Linux adapter cannot safely manage host-owned private +# config after asking the Box to use a different numeric identity. Reject that +# split before the checkout or any runtime resource exists. +HOST_UID=12345; HOST_GID=23456 +BAD_UID=12346; BAD_GID=23457 +mismatch_index=0 +for requested_identity in "$BAD_UID:$HOST_GID" "$HOST_UID:$BAD_GID"; do + mismatch_index=$((mismatch_index + 1)) + requested_uid=${requested_identity%%:*} + requested_gid=${requested_identity#*:} + MISMATCH_RUNTIME="$TMP/runtime-puid-mismatch-$mismatch_index" + MISMATCH_INSTALL="$TMP/puid-mismatch-$mismatch_index" + mkdir -p "$MISMATCH_RUNTIME" + set +e + HOME="$TMP/home" MOCK_RUNTIME="$MISMATCH_RUNTIME" \ + MOCK_UNAME_S=Linux MOCK_ID_UID="$HOST_UID" MOCK_ID_GID="$HOST_GID" \ + SQUAREBOX_DIR="$MISMATCH_INSTALL" SQUAREBOX_RUNTIME=docker SQUAREBOX_TAG=v1.1.0 \ + PUID="$requested_uid" PGID="$requested_gid" \ + "$ROOT/install.sh" "$TMP/puid-mismatch-$mismatch_index.out" 2>&1 + mismatch_rc=$? + set -e + if [ "$mismatch_rc" -ne 64 ]; then + echo "installer returned $mismatch_rc for an unsafe unprivileged PUID/PGID mismatch; expected 64" >&2 + cat "$TMP/puid-mismatch-$mismatch_index.out" >&2 + exit 1 + fi + grep -q "requires PUID/PGID to match the invoking host identity ($HOST_UID:$HOST_GID)" \ + "$TMP/puid-mismatch-$mismatch_index.out" + grep -q "requested $requested_uid:$requested_gid" "$TMP/puid-mismatch-$mismatch_index.out" + test ! -e "$MISMATCH_INSTALL" + test ! -e "$MISMATCH_RUNTIME/calls" +done + +MATCH_RUNTIME="$TMP/runtime-puid-match" +mkdir -p "$MATCH_RUNTIME" "$TMP/puid-match-home" +HOME="$TMP/puid-match-home" MOCK_RUNTIME="$MATCH_RUNTIME" \ + MOCK_UNAME_S=Linux MOCK_ID_UID="$HOST_UID" MOCK_ID_GID="$HOST_GID" \ + SQUAREBOX_DIR="$TMP/puid-match" SQUAREBOX_RUNTIME=docker SQUAREBOX_TAG=v1.1.0 \ + PUID="$HOST_UID" PGID="$HOST_GID" "$ROOT/install.sh" "$TMP/unsafe-workspace.out" 2>&1; then echo 'installer accepted a root Workspace before recording state' >&2 @@ -189,9 +248,12 @@ GIT_BASH_RUNTIME="$TMP/runtime-git-bash" mkdir -p "$GIT_BASH_HOME" "$GIT_BASH_RUNTIME" (cd "$TMP" && HOME="$GIT_BASH_HOME" MSYSTEM=MINGW64 USERPROFILE='C:/Users/Test' \ SQUAREBOX_DIR="$GIT_BASH_INSTALL" SQUAREBOX_RUNTIME=docker SQUAREBOX_TAG=v1.1.0 \ - MOCK_RUNTIME="$GIT_BASH_RUNTIME" "$ROOT/install.sh" /dev/null) @@ -204,6 +266,26 @@ if (cd "$TMP" && env -u MSYSTEM -u USERPROFILE HOME="$GIT_BASH_HOME" \ fi grep -q 'invalid Install identity' "$TMP/linux-uninstall-drive.out" +# Root-run rootful runtimes may intentionally assign NAS/Unraid ownership, and +# non-Linux Docker Desktop adapters retain their adapter-native Box identity. +ROOTFUL_RUNTIME="$TMP/runtime-rootful-override" +mkdir -p "$ROOTFUL_RUNTIME" "$TMP/rootful-home" +HOME="$TMP/rootful-home" MOCK_RUNTIME="$ROOTFUL_RUNTIME" \ + MOCK_UNAME_S=Linux MOCK_ID_UID=0 MOCK_ID_GID=0 \ + SQUAREBOX_DIR="$TMP/rootful-override" SQUAREBOX_RUNTIME=docker SQUAREBOX_TAG=v1.1.0 \ + PUID=99 PGID=100 "$ROOT/install.sh" "$MOCK_RUNTIME/calls" +set +e +MOCK_UNAME_S=Linux MOCK_ID_UID="$HOST_UID" MOCK_ID_GID="$HOST_GID" \ + "$INSTALL/install.sh" "$TMP/recorded-puid-mismatch.out" 2>&1 +recorded_mismatch_rc=$? +set -e +test "$recorded_mismatch_rc" -eq 64 +grep -q "requires PUID/PGID to match the invoking host identity ($HOST_UID:$HOST_GID)" \ + "$TMP/recorded-puid-mismatch.out" +grep -q "requested $BAD_UID:$BAD_GID" "$TMP/recorded-puid-mismatch.out" +test ! -s "$MOCK_RUNTIME/calls" +test "$git_config_before" = "$(sha256sum "$INSTALL/.squarebox/identity/git/config")" +cmp -s "$STATE" "$TMP/state-recorded-mismatch" +mv "$TMP/state-before-recorded-mismatch" "$STATE" + MOCK_EMPTY_GIT_ID=1 "$INSTALL/install.sh" /dev/null 2>&1; then fi # Rootless Podman maps the invoking host user to the image's dev identity and -# disables SELinux relabeling for this home-mounting development Box. Private -# :Z labels on Workspace/config/SSH paths make those host paths unusable by -# other consumers and do not compose with this mount model. +# disables SELinux relabeling for this home-mounting development Box. Native +# unprivileged identity mismatches are rejected before runtime inspection; the +# runtime-specific check remains the fail-closed control for root invocation. +# Private :Z labels on Workspace/config/SSH paths make those host paths +# unusable by other consumers and do not compose with this mount model. export MOCK_RUNTIME="$TMP/runtime-podman"; mkdir -p "$MOCK_RUNTIME" export MOCK_ROOTLESS=1 MOCK_RELEASE_TAG=v1.1.0 MOCK_MANIFEST_MISSING=0 export HOME="$TMP/podman-home"; mkdir -p "$HOME" @@ -487,7 +598,8 @@ grep -q -- '--userns=keep-id:uid=1000,gid=1000' "$MOCK_RUNTIME/calls" export MOCK_RUNTIME="$TMP/runtime-podman-bad-id"; mkdir -p "$MOCK_RUNTIME" export HOME="$TMP/podman-bad-id-home"; mkdir -p "$HOME" export SQUAREBOX_DIR="$TMP/podman-bad-id" -if PUID=99 PGID=100 "$ROOT/install.sh" "$TMP/podman-bad-id.out" 2>&1; then +if MOCK_UNAME_S=Linux MOCK_ID_UID=0 MOCK_ID_GID=0 PUID=99 PGID=100 \ + "$ROOT/install.sh" "$TMP/podman-bad-id.out" 2>&1; then echo 'rootless Podman accepted an ineffective PUID/PGID remap' >&2; exit 1 fi grep -q 'rootless Podman maps the invoking host identity' "$TMP/podman-bad-id.out" diff --git a/uat-checklist.md b/uat-checklist.md index 1302b90..c38471d 100644 --- a/uat-checklist.md +++ b/uat-checklist.md @@ -22,8 +22,9 @@ Qualification issues: [Linux desktop #126](https://github.com/SquareWaveSystems/ - [ ] Fresh Bash installer: launch, interactive setup, exit, resume, rebuild, uninstall - [ ] Existing v1.1 Managed home upgrade: no repeated prompts; Selections reconcile -- [ ] Host UID/GID other than 1000: Workspace and managed files remain host-owned -- [ ] Non-default Docker PUID/PGID starts with lifecycle-managed read-only files mounted beneath `/home/dev` +- [ ] Genuine host UID/GID other than 1000: default identity, Workspace, and managed files remain host-owned through rebuild and purge +- [ ] Unprivileged Linux PUID/PGID mismatch fails before checkout, config, Install-state, or runtime mutation; a stale recorded identity can be adopted to the current account +- [ ] Root-run rootful Docker/Podman PUID/PGID override starts with lifecycle-managed read-only files mounted beneath `/home/dev` - [ ] Custom install path, Workspace, and Managed-home volume survive rebuild and purge correctly - [ ] Purge refuses an unrelated directory/container/image/volume with a colliding name - [ ] Docker daemon unavailable during uninstall produces a clear nonzero partial-cleanup result