diff --git a/.github/workflows/mise.yml b/.github/workflows/mise.yml index 2dbb7c61..d239942a 100644 --- a/.github/workflows/mise.yml +++ b/.github/workflows/mise.yml @@ -18,7 +18,7 @@ jobs: container: archlinux:latest steps: - name: Install prerequisites - run: pacman -Syu --noconfirm mise git zsh lua bun jq python python-yaml + run: pacman -Syu --noconfirm mise git zsh lua bun jq python python-yaml diffutils - name: Checkout code uses: actions/checkout@v7 @@ -123,8 +123,23 @@ jobs: sudo apt-get update sudo apt-get install --yes sudo bubblewrap - # Use the packaged bubblewrap/AppArmor policy; never weaken host namespace - # restrictions or run this suite as root. Missing support must fail loudly. + # Ubuntu 24.04 blocks unprivileged user namespaces via AppArmor + # (kernel.apparmor_restrict_unprivileged_userns=1). Grant them to bwrap only, + # through a profile for /usr/bin/bwrap; the sysctl stays on for everything else. + # The suite still runs as the non-root runner. Missing support must fail loudly. + - name: Allow user namespaces for bubblewrap only + run: | + sudo tee /etc/apparmor.d/bwrap >/dev/null <<'EOF' + abi , + include + profile bwrap /usr/bin/bwrap flags=(unconfined) { + userns, + include if exists + } + EOF + sudo apparmor_parser -r /etc/apparmor.d/bwrap + bwrap --unshare-user --uid 0 --ro-bind / / true + - name: Test isolated sudoers policy as the runner account run: bash modules/system/tests/test_sudoers.sh @@ -152,7 +167,8 @@ jobs: - name: Dry-run macos run: | cd ~ - MISE_AUTO_ENV=true mise bootstrap --dry-run --yes + # The runner image ships ~/.bashrc; bin/dotfiles sets such conflicts aside, raw mise refuses. + MISE_AUTO_ENV=true mise bootstrap --dry-run --yes --force-dotfiles - name: Tests (all loaded macos modules) run: | diff --git a/README.md b/README.md index d226272b..d739b37e 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ cd ~ && It requires a non-root Linux account, installed `visudo`, bubblewrap, permitted unprivileged user/PID namespaces, and an existing `/etc/sudoers.d`. It maps a disposable policy directory and does not invoke real sudo or write a live grant. Run it on a suitable Linux executor; namespace restrictions are a reported failure, not proof that the policy passed. -The workflow defines a dedicated `system-sudoers` Ubuntu job that installs packaged sudo/bubblewrap and runs the same test script directly as the non-root runner account, outside the wildcard suites. It does not weaken host namespace restrictions. This route covers isolated sudoers safety, not full Omarchy/CachyOS provisioning or desktop runtime; consult the workflow results for execution evidence. +The workflow defines a dedicated `system-sudoers` Ubuntu job that installs packaged sudo/bubblewrap, loads an AppArmor profile that grants user namespaces to `/usr/bin/bwrap` only (Ubuntu 24.04 otherwise blocks them for unprivileged programs; the host-wide restriction stays on), and runs the same test script directly as the non-root runner account, outside the wildcard suites. This route covers isolated sudoers safety, not full Omarchy/CachyOS provisioning or desktop runtime; consult the workflow results for execution evidence. diff --git a/bin/dotfiles b/bin/dotfiles index 4389b4f4..fee3f2d0 100755 --- a/bin/dotfiles +++ b/bin/dotfiles @@ -117,10 +117,12 @@ detect_profile() { } validate_purpose_target() { - [ ! -L "$PURPOSE_DIR" ] && { [ ! -e "$PURPOSE_DIR" ] || [ -d "$PURPOSE_DIR" ]; } || + if ! { [ ! -L "$PURPOSE_DIR" ] && { [ ! -e "$PURPOSE_DIR" ] || [ -d "$PURPOSE_DIR" ]; }; }; then die "unsafe purpose directory $PURPOSE_DIR; use a real directory" - [ ! -L "$PURPOSE_FILE" ] && { [ ! -e "$PURPOSE_FILE" ] || [ -f "$PURPOSE_FILE" ]; } || + fi + if ! { [ ! -L "$PURPOSE_FILE" ] && { [ ! -e "$PURPOSE_FILE" ] || [ -f "$PURPOSE_FILE" ]; }; }; then die "unsafe purpose target $PURPOSE_FILE; use a regular, non-symlink file" + fi } read_purpose() { @@ -318,8 +320,9 @@ validate_miserc_target() { local rc="$MISE_DIR/miserc.local.toml" { [ ! -e "$MISE_DIR" ] || [ -d "$MISE_DIR" ]; } || die "unsafe mise configuration directory $MISE_DIR" - [ ! -L "$rc" ] && { [ ! -e "$rc" ] || [ -f "$rc" ]; } || + if ! { [ ! -L "$rc" ] && { [ ! -e "$rc" ] || [ -f "$rc" ]; }; }; then die "unsafe mise discovery target $rc; use a regular, non-symlink file" + fi } write_miserc() { diff --git a/modules/base/tests/test_bin_dotfiles.sh b/modules/base/tests/test_bin_dotfiles.sh index ad71d340..3705a371 100755 --- a/modules/base/tests/test_bin_dotfiles.sh +++ b/modules/base/tests/test_bin_dotfiles.sh @@ -279,7 +279,8 @@ expect_no_provisioning new_case purpose-set-without-mise-or-checkout mkdir -p "$C/path" "$MISE_DIR/conf.d" for cmd in bash cmp cat mkdir mktemp mv rm; do - ln -s "$(command -v "$cmd")" "$C/path/$cmd" + tool=$(command -v "$cmd") || fail "test needs $cmd on PATH" + [ -z "$tool" ] || ln -s "$tool" "$C/path/$cmd" done echo retained >"$MISE_DIR/conf.d/user.toml" echo '[settings]' >"$MISE_DIR/config.toml" diff --git a/modules/clangd/bootstrap.sh b/modules/clangd/bootstrap.sh new file mode 100755 index 00000000..19bf6373 --- /dev/null +++ b/modules/clangd/bootstrap.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -uo pipefail + +# Homebrew's llvm is keg-only; link its clangd into ~/.local/bin. A [dotfiles] entry +# cannot do this: its source only exists once llvm is installed, so every dry run fails. +dst="$HOME/.local/bin/clangd" +prefix="$(brew --prefix llvm 2>/dev/null)" || prefix="" +src="${prefix:+$prefix/bin/clangd}" + +if [ -z "$src" ] || [ ! -x "$src" ]; then + echo "clangd: brew llvm not installed, skipped" +elif [ -e "$dst" ] && [ ! -L "$dst" ]; then + echo "clangd: $dst is not a link, skipped" +elif [ "$(readlink "$dst" 2>/dev/null)" != "$src" ]; then + mkdir -p "${dst%/*}" && ln -sfn "$src" "$dst" || echo "clangd: linking $dst failed, skipped" +fi +exit 0 diff --git a/modules/clangd/mise.macos.toml b/modules/clangd/mise.macos.toml index bcd84651..039e6622 100644 --- a/modules/clangd/mise.macos.toml +++ b/modules/clangd/mise.macos.toml @@ -1,5 +1,6 @@ [bootstrap.packages] "brew:llvm" = "latest" -[dotfiles] -"~/.local/bin/clangd" = { source = "/opt/homebrew/opt/llvm/bin/clangd" } +[tasks."bootstrap:clangd"] +description = "Link Homebrew llvm's clangd into ~/.local/bin" +run = "./bootstrap.sh" diff --git a/modules/git/tests/test_global_config.py b/modules/git/tests/test_global_config.py index 432bc08b..b1f3d5db 100755 --- a/modules/git/tests/test_global_config.py +++ b/modules/git/tests/test_global_config.py @@ -37,7 +37,8 @@ def setUp(self): self.fail("real Git is required by the Git module test gate") self.temp = tempfile.TemporaryDirectory(prefix="dotfiles-git-") self.addCleanup(self.temp.cleanup) - self.home = Path(self.temp.name) / "home" + # macOS TMPDIR is a /var -> /private/var alias; a real HOME is canonical. + self.home = Path(self.temp.name).resolve(strict=True) / "home" self.home.mkdir(mode=0o700) self.config = self.home / ".gitconfig" self.git_dir = self.home / ".config/git" diff --git a/modules/hammerspoon/bootstrap.sh b/modules/hammerspoon/bootstrap.sh index 7a6782a5..57c0eb98 100755 --- a/modules/hammerspoon/bootstrap.sh +++ b/modules/hammerspoon/bootstrap.sh @@ -9,7 +9,7 @@ tmp=$(mktemp -d) || { echo "hammerspoon: could not create temp dir, skipped"; ex transaction="" committed=0 # Invoked indirectly by the EXIT trap below. -# shellcheck disable=SC2317 +# shellcheck disable=SC2317,SC2329 cleanup() { trap '' HUP INT TERM local name failed=0 diff --git a/modules/k9s/bootstrap.sh b/modules/k9s/bootstrap.sh index 4c2eaafd..38083436 100755 --- a/modules/k9s/bootstrap.sh +++ b/modules/k9s/bootstrap.sh @@ -9,7 +9,7 @@ rendered=$HOME/.local/state/omarchy/current/theme/k9s.yaml if [ -f "$HOME/.local/state/omarchy/current/theme.name" ] && { [ ! -f "$rendered" ] || [ "$here/k9s.yaml.tpl" -nt "$rendered" ]; }; then if command -v omarchy-theme-refresh >/dev/null 2>&1; then - # shellcheck source=../omarchy/hypr-instance.sh + # shellcheck source=../omarchy/hypr-instance.sh source-path=SCRIPTDIR . "$here/../omarchy/hypr-instance.sh" hypr_live_sig if [ -n "$HYPR_SIG" ]; then