diff --git a/packages/mosaic/framework/fleet/bin/mosaic b/packages/mosaic/framework/fleet/bin/mosaic index ee681691..f1beace0 100755 --- a/packages/mosaic/framework/fleet/bin/mosaic +++ b/packages/mosaic/framework/fleet/bin/mosaic @@ -16,8 +16,11 @@ # database. The final component may be npm's own bin symlink into # lib/node_modules; that indirection is npm's layout, not a plant. # b. $HOME/.npm-global/bin/mosaic — ONLY when the passwd lookup -# fails. Untrusted: every path component must be a non-symlink or -# the candidate is refused without execution. +# fails, and then only when the candidate is a trusted-shape +# absolute path: relative HOME and parent-escape (..) components +# are refused outright, and every remaining component must be a +# non-symlink (secure descriptor traversal). Refused candidates +# are never executed. # 2. Worktree build is DEV OPT-IN: used only when MOSAIC_CLI_WORKTREE is # explicitly set. Health-checked via --version; ANY doubt (absent, # unreadable, or failing) falls back to the shipped npm mosaic with a @@ -53,8 +56,8 @@ real_home() { getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6 } -# Refuse when any component of the candidate path is a symlink. Used only for -# the untrusted $HOME fallback candidate (contract 1b). +# True when any component of an ABSOLUTE candidate path is a symlink. Only +# ever called after fallback_candidate_usable's absolute-shape check. path_has_symlink_component() { local path="$1" dir base acc="" part dir="$(dirname -- "$path")" @@ -68,6 +71,32 @@ path_has_symlink_component() { return 1 } +# Reject the untrusted $HOME fallback candidate unless it is a trusted-shape +# absolute path: absolute, no parent-escape (..) components, and no symlink +# components anywhere on the path. Every rejection is named on stderr so the +# typed failure explains itself. This is the launcher's descriptor guard; the +# suite's mutation control (guard bypassed) must plant-exec, proving the guard +# is what stands between a hostile HOME and code execution. +fallback_candidate_usable() { + local candidate="$1" + case "$candidate" in + /*) ;; + *) + echo "mosaic: refusing \$HOME candidate $candidate: relative path is untrusted without a passwd home" >&2 + return 1 + ;; + esac + if printf '%s' "$candidate" | grep -qE '(^|/)\\.\\.(/|$)'; then + echo "mosaic: refusing \$HOME candidate $candidate: parent-escape component" >&2 + return 1 + fi + if path_has_symlink_component "$candidate"; then + echo "mosaic: refusing \$HOME candidate $candidate: symlink component (untrusted without a passwd home)" >&2 + return 1 + fi + return 0 +} + # Print shipped candidates in contract order. Refusals are reported on stderr # so the typed failure names the cause. shipped_candidates() { @@ -80,9 +109,7 @@ shipped_candidates() { # passwd lookup failed: the only fallback is $HOME, descriptor-guarded. if [ -n "${HOME:-}" ]; then home_candidate="$HOME/.npm-global/bin/mosaic" - if path_has_symlink_component "$home_candidate"; then - echo "mosaic: refusing \$HOME candidate $home_candidate: symlink component (untrusted without a passwd home)" >&2 - else + if fallback_candidate_usable "$home_candidate"; then printf '%s\n' "$home_candidate" fi fi diff --git a/packages/mosaic/framework/fleet/bin/test-mosaic-launcher.sh b/packages/mosaic/framework/fleet/bin/test-mosaic-launcher.sh index 4b6e559e..8cf221f7 100755 --- a/packages/mosaic/framework/fleet/bin/test-mosaic-launcher.sh +++ b/packages/mosaic/framework/fleet/bin/test-mosaic-launcher.sh @@ -93,8 +93,10 @@ set -e [ "$rc" = "127" ] || fail "A5 typed failure rc: got $rc, want 127" case "$err" in *"no runnable CLI"*) ;; *) fail "A5 typed failure message missing: '$err'" ;; esac -# A6 — secure descriptor traversal: passwd lookup fails and a symlink-planted -# $HOME/.npm-global is refused without execution. +# A6 — secure descriptor traversal, ABSOLUTE symlink plant (corrected per +# rev-code-02 B3: the symlink points at $PLANT/.npm-global so the candidate +# resolves EXACTLY to the planted executable). passwd lookup fails and a +# symlink-planted $HOME/.npm-global is refused without execution. PLANT="$WORK/planted-target" mkdir -p "$PLANT/.npm-global/bin" cat >"$PLANT/.npm-global/bin/mosaic" <&1)" rc=$? @@ -112,4 +114,49 @@ set -e case "$err" in *"symlink component"*) ;; *) fail "A6 refusal diagnostic missing: '$err'" ;; esac [ ! -e "$WORK/planted-sentinel" ] || fail "A6 planted mosaic EXECUTED" +# A6b — mutation control (rev-code-02 B3): a copy of the launcher with the +# descriptor guard bypassed MUST execute the plant under the identical hostile +# arm. If the mutant stays clean, the plant path is wrong and A6 proves +# nothing. +MUTANT="$WORK/mutant-mosaic" +sed 's/if fallback_candidate_usable "\$home_candidate"; then/if true; then/' "$LAUNCHER" >"$MUTANT" +chmod +x "$MUTANT" +[ "$(grep -c 'if true; then' "$MUTANT")" -eq 1 ] || fail "A6b mutant not created (guard call not replaced)" +set +e +mout="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)" +mrc=$? +set -e +[ "$mrc" = "0" ] || fail "A6b mutant did not execute the plant (rc $mrc, out '$mout') - A6 proves nothing" +[ -e "$WORK/planted-sentinel" ] || fail "A6b mutant ran but sentinel absent - plant path wrong, A6 proves nothing" + +# A7 — relative-HOME hostile arm (rev-code-02 B2): a relative HOME whose name +# is a symlink in the launcher CWD must be refused outright, never resolved +# against the working directory. +CWD_SANDBOX="$WORK/cwd-sandbox" +REL_PLANT="$WORK/relative-plant" +mkdir -p "$CWD_SANDBOX" "$REL_PLANT/.npm-global/bin" +cat >"$REL_PLANT/.npm-global/bin/mosaic" <&1)" +rrc=$? +set -e +[ "$rrc" = "127" ] || fail "A7 relative HOME was followed (rc $rrc, out '$rout')" +case "$rout" in *"relative path"*) ;; *) fail "A7 relative-refusal diagnostic missing: '$rout'" ;; esac +[ ! -e "$WORK/relative-sentinel" ] || fail "A7 relative plant EXECUTED" + +# A7b — mutation control for the absolute-shape check: the same mutant (guard +# bypassed) MUST execute the relative plant under the identical arm. +set +e +rmout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)" +rmrc=$? +set -e +[ "$rmrc" = "0" ] || fail "A7b mutant did not execute the relative plant (rc $rmrc, out '$rmout') - A7 proves nothing" +[ -e "$WORK/relative-sentinel" ] || fail "A7b mutant ran but relative sentinel absent - arm wrong, A7 proves nothing" + echo "mosaic launcher suite: all arms passed" diff --git a/packages/mosaic/framework/framework-manifest.txt b/packages/mosaic/framework/framework-manifest.txt index c7e36de8..4e6906cc 100644 --- a/packages/mosaic/framework/framework-manifest.txt +++ b/packages/mosaic/framework/framework-manifest.txt @@ -46,8 +46,12 @@ systemd/** templates/** tools/** # Fleet: only the framework-seeded fleet subtrees are framework-owned. +# fleet/bin is exact-entry on purpose (T110 B1): the estate's fleet/bin carries +# operator-owned executables this package does not ship; a subtree glob here +# would make keep-mode update prune them. fleet/README.md -fleet/bin/** +fleet/bin/mosaic +fleet/bin/test-mosaic-launcher.sh fleet/examples/** fleet/profiles/** fleet/roles/** diff --git a/packages/mosaic/src/framework/manifest-parity.spec.ts b/packages/mosaic/src/framework/manifest-parity.spec.ts index 5cf910e8..3cb19725 100644 --- a/packages/mosaic/src/framework/manifest-parity.spec.ts +++ b/packages/mosaic/src/framework/manifest-parity.spec.ts @@ -148,6 +148,26 @@ describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => { const manifest = loadManifest(FRAMEWORK_ROOT); expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort()); }); + + it('fleet/bin ownership is exact and does not prune existing executables (T110 B1)', () => { + // fleet/bin carries estate executables this package does not ship. A + // subtree glob here would classify them framework-owned and keep-mode + // update would prune them. The manifest must own EXACTLY the two shipped + // launcher files and nothing else in fleet/bin, on BOTH resolvers. + const manifest = loadManifest(FRAMEWORK_ROOT); + const expected: Array<[string, string]> = [ + ['fleet/bin/mosaic', 'framework'], + ['fleet/bin/test-mosaic-launcher.sh', 'framework'], + ['fleet/bin/seat-up.sh', 'operator'], // shipped-by-estate, unshipped here + ['fleet/bin/launch-seat.sh', 'operator'], + ['fleet/bin', 'operator'], // the directory itself is unlisted + ]; + for (const [path, want] of expected) { + const ts = resolveOwnership(manifest, path); + expect(ts).toBe(want); + expect(bashResolve(path)).toBe(want); + } + }); }); /**