fleet: split-home-safe mosaic launcher (T110, P5-RM-009 stack side) #1480
@@ -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
|
||||
|
||||
@@ -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" <<SH
|
||||
@@ -103,7 +105,7 @@ touch "$WORK/planted-sentinel"
|
||||
echo "0.0.0-planted"
|
||||
SH
|
||||
chmod +x "$PLANT/.npm-global/bin/mosaic"
|
||||
ln -s "$PLANT" "$SEAT_HOME/.npm-global"
|
||||
ln -s "$PLANT/.npm-global" "$SEAT_HOME/.npm-global"
|
||||
set +e
|
||||
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&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" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/relative-sentinel"
|
||||
echo "0.0.0-relative-planted"
|
||||
SH
|
||||
chmod +x "$REL_PLANT/.npm-global/bin/mosaic"
|
||||
ln -s "$REL_PLANT" "$CWD_SANDBOX/relative-home"
|
||||
set +e
|
||||
rout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&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"
|
||||
|
||||
@@ -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/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user