From d351caad36ecd1930b2a860998a6bda524af2023 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 23 Jul 2026 18:48:33 +0000 Subject: [PATCH] feat(869-c2): install-ordering enforcement-hook guard (Part of #869) Part of #869 Mos (id-11) Gate-16 merge: independent APPROVE @b6f36564 (8/8, verified vs real production settings template), author id2 != approver id11, clean mosaic-coder author, CI green wp1988. Co-authored-by: jason.woltje Co-committed-by: jason.woltje --- packages/mosaic/framework/install.sh | 30 +- .../tools/_scripts/mosaic-link-runtime-assets | 129 +++++++ .../_scripts/test-install-ordering-guard.sh | 180 ++++++++++ packages/mosaic/package.json | 2 +- packages/mosaic/src/cli.ts | 5 + .../commands/install-ordering-guard.spec.ts | 301 ++++++++++++++++ .../src/commands/install-ordering-guard.ts | 327 ++++++++++++++++++ packages/mosaic/src/stages/finalize.ts | 47 ++- 8 files changed, 1004 insertions(+), 17 deletions(-) create mode 100644 packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh create mode 100644 packages/mosaic/src/commands/install-ordering-guard.spec.ts create mode 100644 packages/mosaic/src/commands/install-ordering-guard.ts diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index d0d8df5b..87db6cd3 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -18,12 +18,33 @@ set -Eeuo pipefail # MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt) # MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check # MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync +# +# Flags (CLI args, NOT environment variables — see #869 Point-1 C2): +# --allow-inactive-enforcement Explicit, per-invocation opt-out that lets the +# lease-enforcement hooks (mutator-gate.py, +# receipt-observer-client.py) be wired into +# ~/.claude/settings.json even when this host +# cannot confirm it can ACTIVATE them. Loud on +# use (see mosaic-link-runtime-assets). Default +# (flag absent) is fail-loud: the enforcement +# hooks are NOT wired and the framework's +# runtime-asset-link step reports a failure. # ────────────────────────────────────────────────────────────────────────────── SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" +# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) — +# never an environment variable — so this opt-out can never sit silently +# inherited in a shell profile. See #869 Point-1 C2. +ALLOW_INACTIVE_ENFORCEMENT=0 +for _arg in "$@"; do + case "$_arg" in + --allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;; + esac +done + # Shared framework path-ownership manifest reader (#791). Parity with # packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt. # Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0). @@ -670,10 +691,15 @@ step "Post-install tasks" SCRIPTS="$TARGET_DIR/tools/_scripts" if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then - if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then + link_args=() + [[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement) + # stdout is suppressed as before, but stderr is left connected: the + # install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach + # the operator, not be swallowed silently. + if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then ok "Runtime assets linked" else - warn "Runtime asset linking failed (non-fatal)" + warn "Runtime asset linking failed (non-fatal) — see message above for details." fi fi diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets b/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets index f8e79f09..363fab78 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets +++ b/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets @@ -4,6 +4,22 @@ set -euo pipefail MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" backup_stamp="$(date +%Y%m%d%H%M%S)" +# ─── Install-ordering guard opt-out (#869 Point-1 C2) ─────────────────────── +# Explicit, per-invocation CLI flag ONLY — deliberately NOT read from an +# environment variable, so it can never sit as a silently-inherited default in +# a shell profile or CI env. Absent (the default) => hard fail-loud path. +allow_inactive_enforcement=0 +for arg in "$@"; do + case "$arg" in + --allow-inactive-enforcement) allow_inactive_enforcement=1 ;; + esac +done + +# Tracks whether the Claude settings install-ordering guard (below) reported a +# degraded (enforcement-not-wired) outcome, so this script's own exit status +# reflects it even though the rest of the runtime-asset sync must still run. +guard_degraded=0 + copy_file_managed() { local src="$1" local dst="$2" @@ -24,6 +40,103 @@ copy_file_managed() { cp "$src" "$dst" } +# ─── Install-ordering guard for settings.json (#869 Point-1 C2) ───────────── +# +# settings.json is where #828's enforcement hooks (PreToolUse mutator-gate.py, +# Stop receipt-observer-client.py) get wired unconditionally. Before copying +# it, delegate to `mosaic __link-claude-settings` (packages/mosaic/src/commands/ +# install-ordering-guard.ts) so the wiring decision is made by importing the +# C1 activation probe (`leaseEnforcementActivatable()`) directly, rather than +# re-implementing the capability/supervisor checks in shell. That subcommand: +# - activatable -> writes settings.json with hooks intact, exits 0 +# - NOT activatable -> writes settings.json with hooks STRIPPED, +# prints an actionable message, exits 1 +# - NOT activatable + opt-out -> writes settings.json with hooks intact, +# prints a loud warning, exits 0 +# The `mosaic` CLI is expected on PATH at this point ("No executables are +# placed on PATH — the mosaic npm CLI is the only binary", per install.sh). +# If it is not resolvable at all, that is itself strong evidence the +# activation half is absent, so the same fail-loud default applies via a +# minimal python3 fallback (this repo already depends on python3 for the +# lease broker itself). +copy_claude_settings_guarded() { + local src="$1" + local dst="$2" + + local guard_args=(__link-claude-settings "$src" "$dst") + if [[ "$allow_inactive_enforcement" == "1" ]]; then + guard_args+=(--allow-inactive-enforcement) + fi + + if command -v mosaic >/dev/null 2>&1; then + if mosaic "${guard_args[@]}"; then + return 0 + fi + echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2 + guard_degraded=1 + return 0 + fi + + echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2 + echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2 + echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2 + echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2 + + if [[ "$allow_inactive_enforcement" == "1" ]]; then + echo "[mosaic-link] WARNING: --allow-inactive-enforcement set — wiring $dst AS-IS (with" >&2 + echo "[mosaic-link] enforcement hooks) despite being unable to confirm activation." >&2 + copy_file_managed "$src" "$dst" + return 0 + fi + + mkdir -p "$(dirname "$dst")" + if command -v python3 >/dev/null 2>&1; then + python3 - "$src" "$dst" <<'PYEOF' +import json, sys + +src, dest = sys.argv[1], sys.argv[2] +with open(src) as f: + data = json.load(f) + +hooks = data.get("hooks", {}) + +pre = hooks.get("PreToolUse", []) +hooks["PreToolUse"] = [ + t for t in pre + if not any("mutator-gate.py" in h.get("command", "") for h in t.get("hooks", [])) +] +if not hooks["PreToolUse"]: + del hooks["PreToolUse"] + +stop = hooks.get("Stop", []) +new_stop = [] +for t in stop: + kept = [h for h in t.get("hooks", []) if "receipt-observer-client.py" not in h.get("command", "")] + if kept: + t = dict(t) + t["hooks"] = kept + new_stop.append(t) +if new_stop: + hooks["Stop"] = new_stop +elif "Stop" in hooks: + del hooks["Stop"] + +if hooks: + data["hooks"] = hooks +else: + data.pop("hooks", None) + +with open(dest, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") +PYEOF + else + cp "$src" "$dst" + fi + guard_degraded=1 + return 0 +} + remove_legacy_path() { local p="$1" @@ -110,6 +223,13 @@ for runtime_file in \ fi src="$MOSAIC_HOME/runtime/claude/$runtime_file" [[ -f "$src" ]] || continue + if [[ "$runtime_file" == "settings.json" ]]; then + # Install-ordering guard (#869 Point-1 C2): gate enforcement-hook wiring + # on confirmed activation instead of the plain copy_file_managed used for + # every other runtime file. See copy_claude_settings_guarded() above. + copy_claude_settings_guarded "$src" "$HOME/.claude/$runtime_file" + continue + fi copy_file_managed "$src" "$HOME/.claude/$runtime_file" done @@ -167,3 +287,12 @@ fi echo "[mosaic-link] Runtime assets synced (non-symlink mode)" echo "[mosaic-link] Canonical source: $MOSAIC_HOME" + +# Propagate the install-ordering guard's outcome (#869 Point-1 C2): every +# other runtime asset above is best-effort/non-fatal, but a degraded +# (enforcement-not-wired) settings.json must make THIS script's own exit +# status non-zero so callers (framework/install.sh, finalize.ts) can surface +# it — never silently. +if [[ "$guard_degraded" == "1" ]]; then + exit 1 +fi diff --git a/packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh b/packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh new file mode 100644 index 00000000..7bc82fd8 --- /dev/null +++ b/packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Regression harness for issue #869 Point-1 C2 — the install-ordering guard +# wired into mosaic-link-runtime-assets. +# +# Root cause under test: mosaic-link-runtime-assets copies +# runtime/claude/settings.json (which embeds the PreToolUse mutator-gate.py +# hook and the Stop receipt-observer-client.py hook) straight into +# ~/.claude/settings.json, unconditionally. If the lease-broker activation +# half cannot be confirmed on this host, wiring those hooks bricks it with a +# fail-closed gate that can never be satisfied. +# +# This harness never invokes a real `mosaic` CLI build — it stubs the +# `__link-claude-settings` contract with a fake `mosaic` on PATH so the shell +# WIRING (does mosaic-link-runtime-assets call out correctly? does it +# propagate a degraded outcome? does it still copy every other runtime file? +# does --allow-inactive-enforcement forward through?) is exercised +# independently of the TS guard's own logic (already covered by +# install-ordering-guard.spec.ts). It also exercises the no-mosaic-on-PATH +# python3 fallback directly. +# +# Scenarios: +# 1. probe=true (fake mosaic exits 0) -> settings.json copied, script exits 0. +# 2. probe=false (fake mosaic exits 1) -> script exits 1 (guard_degraded +# propagated), but every OTHER runtime file is still copied. +# 3. probe=false + --allow-inactive-enforcement -> the flag is forwarded to +# the fake mosaic stub. +# 4. No `mosaic` on PATH at all (activation unconfirmable) -> the python3 +# fallback strips the enforcement hooks itself and the script exits 1. +# 5. No `mosaic` on PATH + --allow-inactive-enforcement -> the python3 +# fallback wires the hooks AS-IS and the script exits 0. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LINK_SCRIPT="$SCRIPT_DIR/mosaic-link-runtime-assets" + +TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$TMP_ROOT"' EXIT + +fail=0 +fail_msg() { + echo "FAIL: $*" >&2 + fail=1 +} + +FIXTURE_SETTINGS='{ + "model": "opus", + "hooks": { + "PreToolUse": [ + { "matcher": ".*", "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude" } ] }, + { "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh" } ] } + ], + "Stop": [ + { "hooks": [ + { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude" }, + { "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh" } + ] } + ] + } +}' + +# Sets up a fresh $MOSAIC_HOME/runtime/claude/{settings.json,CLAUDE.md, +# hooks-config.json,context7-integration.md} + fresh $HOME, echoes both paths +# space-separated for the caller to `read`. +new_scenario_dirs() { + local scenario="$1" + local base="$TMP_ROOT/$scenario" + local mosaic_home="$base/mosaic-home" + local home="$base/home" + mkdir -p "$mosaic_home/runtime/claude" "$home" + printf '%s' "$FIXTURE_SETTINGS" > "$mosaic_home/runtime/claude/settings.json" + echo "claude.md fixture" > "$mosaic_home/runtime/claude/CLAUDE.md" + echo '{"hooks":{}}' > "$mosaic_home/runtime/claude/hooks-config.json" + echo "context7 fixture" > "$mosaic_home/runtime/claude/context7-integration.md" + echo "$mosaic_home" "$home" +} + +settings_has_marker() { + local file="$1" marker="$2" + [[ -f "$file" ]] && grep -q "$marker" "$file" +} + +# A fake `mosaic` binary implementing only the __link-claude-settings contract +# this harness needs: writes dest verbatim (fixture is unmodified either way — +# this stub only exercises the CALL CONTRACT, not the TS strip logic, which +# has its own vitest coverage) and exits with the code the scenario wants. +# Records the args it was called with so the harness can assert forwarding. +make_fake_mosaic() { + local bin_dir="$1" exit_code="$2" + mkdir -p "$bin_dir" + cat > "$bin_dir/mosaic" < "$bin_dir/mosaic.args" +if [[ "\$1" == "__link-claude-settings" ]]; then + cp "\$2" "\$3" + exit $exit_code +fi +exit 0 +EOF + chmod +x "$bin_dir/mosaic" +} + +# --- Scenario 1: probe=true (fake mosaic exits 0) --------------------------- +read -r MOSAIC_HOME_1 HOME_1 < <(new_scenario_dirs scenario1) +BIN_1="$TMP_ROOT/scenario1/bin" +make_fake_mosaic "$BIN_1" 0 + +OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_1" HOME="$HOME_1" PATH="$BIN_1:$PATH" "$LINK_SCRIPT" 2>&1) +STATUS=$? +[[ "$STATUS" -eq 0 ]] || fail_msg "scenario1 (probe=true): expected exit 0, got $STATUS. Output: $OUTPUT" +[[ -f "$HOME_1/.claude/settings.json" ]] || fail_msg "scenario1: settings.json was not copied" + +# --- Scenario 2: probe=false (fake mosaic exits 1) -------------------------- +read -r MOSAIC_HOME_2 HOME_2 < <(new_scenario_dirs scenario2) +BIN_2="$TMP_ROOT/scenario2/bin" +make_fake_mosaic "$BIN_2" 1 + +OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_2" HOME="$HOME_2" PATH="$BIN_2:$PATH" "$LINK_SCRIPT" 2>&1) +STATUS=$? +[[ "$STATUS" -ne 0 ]] || fail_msg "scenario2 (probe=false, default): expected non-zero exit, got 0. Output: $OUTPUT" +[[ -f "$HOME_2/.claude/CLAUDE.md" ]] || fail_msg "scenario2: CLAUDE.md was NOT copied even though it is independent of the settings.json guard" +[[ -f "$HOME_2/.claude/hooks-config.json" ]] || fail_msg "scenario2: hooks-config.json was NOT copied" +[[ -f "$HOME_2/.claude/context7-integration.md" ]] || fail_msg "scenario2: context7-integration.md was NOT copied" +case "$OUTPUT" in + *"NOT be wired"*|*"NOT wired"*) ;; + *) fail_msg "scenario2: expected an actionable degraded-wiring message in output, got: $OUTPUT" ;; +esac + +# --- Scenario 3: probe=false + --allow-inactive-enforcement forwards the flag +read -r MOSAIC_HOME_3 HOME_3 < <(new_scenario_dirs scenario3) +BIN_3="$TMP_ROOT/scenario3/bin" +make_fake_mosaic "$BIN_3" 0 + +MOSAIC_HOME="$MOSAIC_HOME_3" HOME="$HOME_3" PATH="$BIN_3:$PATH" "$LINK_SCRIPT" --allow-inactive-enforcement >/dev/null 2>&1 +RECORDED_ARGS="$(cat "$BIN_3/mosaic.args" 2>/dev/null || true)" +case "$RECORDED_ARGS" in + *"--allow-inactive-enforcement"*) ;; + *) fail_msg "scenario3: --allow-inactive-enforcement was not forwarded to the mosaic CLI invocation (got: '$RECORDED_ARGS')" ;; +esac + +# --- Scenario 4: no `mosaic` on PATH at all -> python3 fallback strips hooks +read -r MOSAIC_HOME_4 HOME_4 < <(new_scenario_dirs scenario4) +EMPTY_BIN="$TMP_ROOT/scenario4/empty-bin" +mkdir -p "$EMPTY_BIN" +# A PATH containing only python3 (for the fallback) + core utils, no mosaic. +FALLBACK_PATH="$EMPTY_BIN:/usr/bin:/bin" + +OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_4" HOME="$HOME_4" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" 2>&1) +STATUS=$? +[[ "$STATUS" -ne 0 ]] || fail_msg "scenario4 (no mosaic on PATH, default): expected non-zero exit, got 0. Output: $OUTPUT" +if settings_has_marker "$HOME_4/.claude/settings.json" "mutator-gate.py"; then + fail_msg "scenario4: mutator-gate.py hook was wired even though mosaic could not be resolved (activation unconfirmable)" +fi +if settings_has_marker "$HOME_4/.claude/settings.json" "receipt-observer-client.py"; then + fail_msg "scenario4: receipt-observer-client.py hook was wired even though mosaic could not be resolved" +fi +if ! settings_has_marker "$HOME_4/.claude/settings.json" "prevent-memory-write.sh"; then + fail_msg "scenario4: the unrelated prevent-memory-write.sh hook was incorrectly dropped too" +fi + +# --- Scenario 5: no `mosaic` on PATH + --allow-inactive-enforcement -------- +read -r MOSAIC_HOME_5 HOME_5 < <(new_scenario_dirs scenario5) + +OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_5" HOME="$HOME_5" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" --allow-inactive-enforcement 2>&1) +STATUS=$? +[[ "$STATUS" -eq 0 ]] || fail_msg "scenario5 (no mosaic, opt-out): expected exit 0, got $STATUS. Output: $OUTPUT" +if ! settings_has_marker "$HOME_5/.claude/settings.json" "mutator-gate.py"; then + fail_msg "scenario5: mutator-gate.py hook should have been wired (explicit opt-out set)" +fi +case "$OUTPUT" in + *"WARNING"*"--allow-inactive-enforcement"*) ;; + *) fail_msg "scenario5: expected a loud WARNING mentioning --allow-inactive-enforcement, got: $OUTPUT" ;; +esac + +if [[ "$fail" -eq 0 ]]; then + echo "install-ordering-guard regression passed (5/5 scenarios)" +fi + +exit "$fail" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index c32e3f57..de27c35a 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh" + "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*", diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 0b884cac..76c0fafa 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -22,6 +22,7 @@ import { registerSkillCommand } from './commands/skill.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js'; +import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js'; import { registerAuthCommand } from './commands/auth.js'; import { registerFederationCommand } from './commands/federation.js'; import { registerGatewayCommand } from './commands/gateway.js'; @@ -83,6 +84,10 @@ registerLaunchCommands(program); registerLeaseCapabilityProbe(program); +// ─── install-ordering guard (hidden; #869 Point-1 C2) ─────────────────── + +registerInstallOrderingGuardCommand(program); + // ─── login ────────────────────────────────────────────────────────────── program diff --git a/packages/mosaic/src/commands/install-ordering-guard.spec.ts b/packages/mosaic/src/commands/install-ordering-guard.spec.ts new file mode 100644 index 00000000..1b935efe --- /dev/null +++ b/packages/mosaic/src/commands/install-ordering-guard.spec.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { leaseEnforcementActivatable } from './lease-activation-probe.js'; +import { + ENFORCEMENT_HOOK_MARKERS, + FAIL_LOUD_MESSAGE, + guardClaudeSettingsWiring, + loudOptOutMessage, + runInstallOrderingGuard, + settingsHasEnforcementHooks, + stripEnforcementHooks, +} from './install-ordering-guard.js'; + +/** + * Red-first tests for issue #869 Point-1 C2 — the install-ordering guard. + * + * Root cause under test: `mosaic-link-runtime-assets` copies + * `runtime/claude/settings.json` (which embeds the PreToolUse + * `mutator-gate.py` hook and the Stop `receipt-observer-client.py` hook) + * straight into `~/.claude/settings.json`, unconditionally. If the + * activation half (C1: `leaseEnforcementActivatable()`) cannot be confirmed, + * wiring those hooks bricks the host with a fail-closed gate that can never + * be satisfied. This guard must refuse to wire in that case by default, and + * only wire anyway on an explicit, loud opt-out. + * + * All fixtures use temp directories — this suite never reads or writes the + * real `~/.claude/settings.json`. + */ + +const FIXTURE_SETTINGS = { + model: 'opus', + hooks: { + PreCompact: [ + { + matcher: '.*', + hooks: [{ type: 'command', command: 'python3 revoke-lease.py --reason pre-compact' }], + }, + ], + PreToolUse: [ + { + matcher: '.*', + hooks: [ + { + type: 'command', + command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude', + timeout: 3, + }, + ], + }, + { + matcher: 'Write|Edit|MultiEdit', + hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/prevent-memory-write.sh' }], + }, + ], + PostToolUse: [ + { + matcher: 'Edit|MultiEdit|Write', + hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh' }], + }, + ], + Stop: [ + { + hooks: [ + { + type: 'command', + command: + 'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude', + timeout: 3, + }, + { type: 'command', command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh' }, + ], + }, + ], + }, + enabledPlugins: { 'feature-dev@claude-plugins-official': true }, +}; + +function fixtureJson(): string { + return JSON.stringify(FIXTURE_SETTINGS, null, 2) + '\n'; +} + +describe('stripEnforcementHooks', () => { + it('removes the PreToolUse mutator-gate trigger entirely', () => { + const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); + const hooks = settings['hooks'] as Record; + const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>; + expect(preToolUse.some((t) => t.hooks.some((h) => h.command.includes('mutator-gate.py')))).toBe( + false, + ); + }); + + it('preserves the sibling prevent-memory-write.sh PreToolUse trigger', () => { + const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); + const hooks = settings['hooks'] as Record; + const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>; + expect( + preToolUse.some((t) => t.hooks.some((h) => h.command.includes('prevent-memory-write.sh'))), + ).toBe(true); + }); + + it('removes only the receipt-observer-client.py hook from Stop, keeping reflect-stop-hook.sh', () => { + const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); + const hooks = settings['hooks'] as Record; + const stop = hooks['Stop'] as Array<{ hooks: Array<{ command: string }> }>; + const commands = stop.flatMap((t) => t.hooks.map((h) => h.command)); + expect(commands.some((c) => c.includes('receipt-observer-client.py'))).toBe(false); + expect(commands.some((c) => c.includes('reflect-stop-hook.sh'))).toBe(true); + }); + + it('leaves PreCompact/PostToolUse hooks byte-identical', () => { + const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); + const hooks = settings['hooks'] as Record; + expect(hooks['PreCompact']).toEqual(FIXTURE_SETTINGS.hooks.PreCompact); + expect(hooks['PostToolUse']).toEqual(FIXTURE_SETTINGS.hooks.PostToolUse); + }); + + it('reports what it removed', () => { + const { removed } = stripEnforcementHooks(FIXTURE_SETTINGS); + expect(removed).toContain(`PreToolUse:${ENFORCEMENT_HOOK_MARKERS.preToolUse}`); + expect(removed).toContain(`Stop:${ENFORCEMENT_HOOK_MARKERS.stop}`); + }); +}); + +describe('settingsHasEnforcementHooks', () => { + it('is true for the unmodified fixture', () => { + expect(settingsHasEnforcementHooks(FIXTURE_SETTINGS)).toBe(true); + }); + + it('is false after stripping', () => { + const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS); + expect(settingsHasEnforcementHooks(settings)).toBe(false); + }); + + it('is false for settings with no hooks key at all', () => { + expect(settingsHasEnforcementHooks({ model: 'opus' })).toBe(false); + }); +}); + +describe('guardClaudeSettingsWiring', () => { + it('probe=false (default, no opt-out): strips enforcement hooks and reports non-zero with a loud, actionable message', () => { + const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => false }); + + expect(outcome.exitCode).toBe(1); + expect(outcome.wired).toBe(false); + expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record)).toBe( + false, + ); + expect(outcome.logs).toHaveLength(1); + expect(outcome.logs[0]?.level).toBe('error'); + expect(outcome.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE); + expect(outcome.logs[0]?.message).toMatch(/refusing to wire a dead gate/i); + expect(outcome.logs[0]?.message).toMatch(/#869/); + expect(outcome.logs[0]?.message).toMatch(/--allow-inactive-enforcement/); + }); + + it('probe=false + explicit opt-out flag: wires hooks as-is and emits a loud warning', () => { + const outcome = guardClaudeSettingsWiring( + fixtureJson(), + { allowInactiveEnforcement: true }, + { activatable: () => false }, + ); + + expect(outcome.exitCode).toBe(0); + expect(outcome.wired).toBe(true); + expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record)).toBe( + true, + ); + expect(outcome.logs).toHaveLength(1); + expect(outcome.logs[0]?.level).toBe('warn'); + expect(outcome.logs[0]?.message).toBe(loudOptOutMessage()); + expect(outcome.logs[0]?.message).toMatch(/WITHOUT confirmed activation/); + }); + + it('probe=true: wires hooks normally with no logs, regardless of opt-out', () => { + const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => true }); + + expect(outcome.exitCode).toBe(0); + expect(outcome.wired).toBe(true); + expect(outcome.logs).toHaveLength(0); + expect(JSON.parse(outcome.json)).toEqual(FIXTURE_SETTINGS); + }); + + it('probe=true + opt-out flag set anyway: still wires normally, no spurious warning', () => { + const outcome = guardClaudeSettingsWiring( + fixtureJson(), + { allowInactiveEnforcement: true }, + { activatable: () => true }, + ); + + expect(outcome.exitCode).toBe(0); + expect(outcome.wired).toBe(true); + expect(outcome.logs).toHaveLength(0); + }); + + it('defaults to the real leaseEnforcementActivatable() when no activatable dep is injected', () => { + // Deliberately does not assume a fixed true/false value for the real + // probe (whether dist/cli.js happens to be built varies by environment — + // asserting a hardcoded expectation here would make the test flaky, not + // red-first). Instead it proves the wiring is genuinely delegated: the + // no-deps call must agree with an explicit call to the same real + // predicate, not some other hardcoded value. + const reallyActivatable = leaseEnforcementActivatable(); + const outcome = guardClaudeSettingsWiring(fixtureJson()); + + if (reallyActivatable) { + expect(outcome.exitCode).toBe(0); + expect(outcome.wired).toBe(true); + } else { + expect(outcome.exitCode).toBe(1); + expect(outcome.wired).toBe(false); + } + }); +}); + +describe('runInstallOrderingGuard (file-level, temp dirs only)', () => { + let dir: string; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + function makeSrc(): string { + dir = mkdtempSync(join(tmpdir(), 'mosaic-install-ordering-guard-')); + const src = join(dir, 'settings.json'); + writeFileSync(src, fixtureJson()); + return src; + } + + it('probe=false: writes a dest settings.json with hooks stripped and returns exitCode 1', () => { + const src = makeSrc(); + const dest = join(dir, 'claude-settings.json'); + + const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => false }); + + expect(result.exitCode).toBe(1); + expect(result.destWritten).toBe(true); + expect(existsSync(dest)).toBe(true); + const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record; + expect(settingsHasEnforcementHooks(written)).toBe(false); + }); + + it('probe=false + opt-out: writes dest with hooks intact and returns exitCode 0', () => { + const src = makeSrc(); + const dest = join(dir, 'claude-settings.json'); + + const result = runInstallOrderingGuard( + src, + dest, + { allowInactiveEnforcement: true }, + { activatable: () => false }, + ); + + expect(result.exitCode).toBe(0); + const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record; + expect(settingsHasEnforcementHooks(written)).toBe(true); + }); + + it('probe=true: writes dest with hooks intact and returns exitCode 0', () => { + const src = makeSrc(); + const dest = join(dir, 'claude-settings.json'); + + const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true }); + + expect(result.exitCode).toBe(0); + const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record; + expect(settingsHasEnforcementHooks(written)).toBe(true); + }); + + it('backs up a pre-existing divergent dest before overwriting (copy_file_managed parity)', () => { + const src = makeSrc(); + const dest = join(dir, 'claude-settings.json'); + writeFileSync(dest, JSON.stringify({ preexisting: true })); + + const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true }); + + expect(result.destWritten).toBe(true); + expect(result.backupPath).toBeDefined(); + expect(existsSync(result.backupPath!)).toBe(true); + expect(JSON.parse(readFileSync(result.backupPath!, 'utf-8'))).toEqual({ preexisting: true }); + }); + + it('is a no-op write when dest already matches the guarded content (idempotent)', () => { + const src = makeSrc(); + const dest = join(dir, 'claude-settings.json'); + + const first = runInstallOrderingGuard(src, dest, {}, { activatable: () => true }); + expect(first.destWritten).toBe(true); + + const second = runInstallOrderingGuard(src, dest, {}, { activatable: () => true }); + expect(second.destWritten).toBe(false); + expect(second.backupPath).toBeUndefined(); + }); + + it('never touches the real home directory settings path used by this test file', () => { + // Sanity guard for the suite itself: every dest path used above lives + // under the mkdtemp() scratch dir, never under homedir()/.claude. + expect(dir).toContain('mosaic-install-ordering-guard-'); + }); +}); diff --git a/packages/mosaic/src/commands/install-ordering-guard.ts b/packages/mosaic/src/commands/install-ordering-guard.ts new file mode 100644 index 00000000..979c1795 --- /dev/null +++ b/packages/mosaic/src/commands/install-ordering-guard.ts @@ -0,0 +1,327 @@ +/** + * Install-ordering guard (issue #869, Point-1 card C2). + * + * Root cause this exists to guard against (#828 version skew, restated): the + * framework reseed / install path (`framework/install.sh` → + * `mosaic-link-runtime-assets` → copies `runtime/claude/settings.json` to + * `~/.claude/settings.json`) wires the ENFORCEMENT half of the lease broker — + * the `PreToolUse` `mutator-gate.py` hook and the `Stop` + * `receipt-observer-client.py` hook — unconditionally. If the ACTIVATION half + * (a CLI build advertising launch-runtime activation + a running broker + * supervisor — see `lease-activation-probe.ts`, C1) is absent, the fail-closed + * gate then denies every tool call with GATE_UNAVAILABLE: a bricked host. + * + * This module is the WIRING gate, not the enforcement gate: it decides + * whether the enforcement hook entries are written into the settings.json + * that ships to `~/.claude/`. It never touches `mutator-gate.py`'s own + * fail-closed-on-absent-identity runtime behavior (test-locked in + * `runtime_tools_unittest.py` / `fail-closed-regression.spec.ts`). + * + * Default (no opt-out): NOT activatable → strip the enforcement hook entries + * from the written settings.json and report a non-zero outcome with a loud, + * actionable message (see FAIL_LOUD_MESSAGE below). + * + * Opt-out: `--allow-inactive-enforcement` (an explicit, per-invocation CLI + * flag — deliberately NOT an environment variable, so it can never sit as a + * silently-inherited default in a shell profile). When set on a NOT + * activatable host, the hooks ARE wired but a loud warning is emitted saying + * so, and the outcome is reported ok (this is a conscious, informed choice). + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { Command } from 'commander'; +import { leaseEnforcementActivatable } from './lease-activation-probe.js'; + +// ─── Enforcement hook identification ──────────────────────────────────────── + +/** Substrings that identify the two enforcement hook commands #828 wired + * unconditionally. Matches the marker strings documented in + * `lease-activation-probe.ts`. */ +export const ENFORCEMENT_HOOK_MARKERS = { + preToolUse: 'mutator-gate.py', + stop: 'receipt-observer-client.py', +} as const; + +interface HookEntry { + command?: string; + [key: string]: unknown; +} + +interface HookTrigger { + matcher?: string; + hooks?: HookEntry[]; + [key: string]: unknown; +} + +type HooksMap = Record; + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function commandIncludes(hook: HookEntry, marker: string): boolean { + return String(hook.command ?? '').includes(marker); +} + +/** + * Return a deep clone of `settings` with the enforcement hook entries removed: + * - Any `PreToolUse` trigger group containing a `mutator-gate.py` command is + * dropped in full (that trigger exists solely to run the gate). + * - Within `Stop` trigger groups, only the individual `receipt-observer-client.py` + * hook entry is dropped; sibling hooks in the same trigger (e.g. + * `reflect-stop-hook.sh`) are preserved. + * Every other hook (PreCompact/SessionStart revoke-lease, the + * `prevent-memory-write.sh` PreToolUse trigger, PostToolUse qa/typecheck + * hooks) is left byte-identical — this function only ever removes the two + * markers above. + */ +export function stripEnforcementHooks(settings: Record): { + settings: Record; + removed: string[]; +} { + const cloned = cloneJson(settings); + const removed: string[] = []; + const hooks = cloned['hooks'] as HooksMap | undefined; + if (!hooks || typeof hooks !== 'object') { + return { settings: cloned, removed }; + } + + const preToolUse = hooks['PreToolUse']; + if (Array.isArray(preToolUse)) { + const kept = preToolUse.filter((trigger) => { + const hasGate = (trigger.hooks ?? []).some((h) => + commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.preToolUse), + ); + if (hasGate) removed.push('PreToolUse:mutator-gate.py'); + return !hasGate; + }); + if (kept.length > 0) hooks['PreToolUse'] = kept; + else delete hooks['PreToolUse']; + } + + const stop = hooks['Stop']; + if (Array.isArray(stop)) { + const rebuilt: HookTrigger[] = []; + for (const trigger of stop) { + const innerHooks = trigger.hooks ?? []; + const keptHooks = innerHooks.filter((h) => { + const isReceiptObserver = commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.stop); + if (isReceiptObserver) removed.push('Stop:receipt-observer-client.py'); + return !isReceiptObserver; + }); + if (keptHooks.length > 0) { + rebuilt.push({ ...trigger, hooks: keptHooks }); + } + } + if (rebuilt.length > 0) hooks['Stop'] = rebuilt; + else delete hooks['Stop']; + } + + if (Object.keys(hooks).length === 0) { + delete cloned['hooks']; + } else { + cloned['hooks'] = hooks; + } + + return { settings: cloned, removed }; +} + +/** True iff `settings` currently wires either enforcement hook. */ +export function settingsHasEnforcementHooks(settings: Record): boolean { + const hooks = settings['hooks'] as HooksMap | undefined; + if (!hooks || typeof hooks !== 'object') return false; + + const preToolUse = hooks['PreToolUse'] ?? []; + const preHit = preToolUse.some((trigger) => + (trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.preToolUse)), + ); + if (preHit) return true; + + const stop = hooks['Stop'] ?? []; + return stop.some((trigger) => + (trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.stop)), + ); +} + +// ─── Guard predicate ──────────────────────────────────────────────────────── + +export const FAIL_LOUD_MESSAGE = + '[mosaic] ERROR: enforcement requested but activation half absent — needs a published CLI ' + + 'carrying launch-runtime activation + a broker supervisor; refusing to wire a dead gate (see #869). ' + + 'The PreToolUse mutator-gate.py hook and Stop receipt-observer-client.py hook were NOT written to ' + + 'settings.json. Fix by installing/updating the CLI and broker, then re-run the framework reseed. ' + + 'To wire anyway (NOT recommended — the fail-closed gate will deny every tool call with ' + + 'GATE_UNAVAILABLE until activation is restored), re-run with --allow-inactive-enforcement.'; + +export function loudOptOutMessage(): string { + return ( + '[mosaic] WARNING: wiring lease-enforcement hooks (mutator-gate.py / receipt-observer-client.py) ' + + 'WITHOUT confirmed activation — --allow-inactive-enforcement was set explicitly. The fail-closed ' + + 'gate will deny every tool call (GATE_UNAVAILABLE) until the activation half (launch-runtime ' + + 'activation capability + a running broker supervisor) is present on this host (see #869).' + ); +} + +export type GuardLogLevel = 'error' | 'warn'; + +export interface GuardLogLine { + level: GuardLogLevel; + message: string; +} + +export interface InstallOrderingGuardOptions { + /** Explicit, per-invocation opt-out. Never source this from an environment + * variable — see module doc. */ + allowInactiveEnforcement?: boolean; +} + +export interface InstallOrderingGuardDeps { + /** Defaults to {@link leaseEnforcementActivatable}. Injectable for tests. */ + activatable?: () => boolean; +} + +export interface InstallOrderingGuardOutcome { + /** The settings.json content to write (pretty-printed, trailing newline). */ + json: string; + /** Whether the enforcement hooks are present in `json`. */ + wired: boolean; + /** 0 = proceed normally; 1 = enforcement was refused (fail-loud default path). */ + exitCode: 0 | 1; + logs: GuardLogLine[]; +} + +/** + * The install-ordering guard: decide whether the enforcement hooks embedded + * in the Claude settings.json template may be wired into the settings.json + * actually shipped to `~/.claude/`. + * + * - activatable → wire as-is. exitCode 0, no logs. + * - NOT activatable, no opt-out → strip enforcement hooks. exitCode 1, + * one 'error' log with the actionable FAIL_LOUD_MESSAGE. + * - NOT activatable, opt-out set → wire as-is anyway. exitCode 0, one + * 'warn' log making the risk explicit and loud. + * + * Pure function: takes the raw settings.json text, returns the text to write + * plus metadata. No filesystem access — callers (the hidden CLI subcommand + * below, or a test) own reading/writing so this stays trivially testable with + * fakes/temp files and never risks touching a real `~/.claude/settings.json`. + */ +export function guardClaudeSettingsWiring( + rawSettingsJson: string, + options: InstallOrderingGuardOptions = {}, + deps: InstallOrderingGuardDeps = {}, +): InstallOrderingGuardOutcome { + const parsed = JSON.parse(rawSettingsJson) as Record; + const activatable = deps.activatable ?? leaseEnforcementActivatable; + const isActivatable = activatable(); + + const serialize = (settings: Record): string => + JSON.stringify(settings, null, 2) + '\n'; + + if (isActivatable) { + return { + json: serialize(parsed), + wired: settingsHasEnforcementHooks(parsed), + exitCode: 0, + logs: [], + }; + } + + if (options.allowInactiveEnforcement === true) { + return { + json: serialize(parsed), + wired: settingsHasEnforcementHooks(parsed), + exitCode: 0, + logs: [{ level: 'warn', message: loudOptOutMessage() }], + }; + } + + const { settings: stripped } = stripEnforcementHooks(parsed); + return { + json: serialize(stripped), + wired: settingsHasEnforcementHooks(stripped), + exitCode: 1, + logs: [{ level: 'error', message: FAIL_LOUD_MESSAGE }], + }; +} + +// ─── File-level runner (shared by the CLI action + tests) ────────────────── + +export interface RunInstallOrderingGuardResult extends InstallOrderingGuardOutcome { + destWritten: boolean; + backupPath?: string; +} + +/** + * Read `src`, guard it, and write the result to `dest` — mirroring + * `copy_file_managed`'s backup-on-change semantics from + * `mosaic-link-runtime-assets` (skip the write if content is unchanged; + * back up an existing divergent file once, timestamped). Exported standalone + * (not only reachable via the CLI action closure) so tests can exercise real + * file I/O against temp directories without ever touching `~/.claude/`. + */ +export function runInstallOrderingGuard( + src: string, + dest: string, + options: InstallOrderingGuardOptions = {}, + deps: InstallOrderingGuardDeps = {}, +): RunInstallOrderingGuardResult { + const raw = readFileSync(src, 'utf-8'); + const outcome = guardClaudeSettingsWiring(raw, options, deps); + + mkdirSync(dirname(dest), { recursive: true }); + + const existing = existsSync(dest) ? readFileSync(dest, 'utf-8') : null; + let destWritten = false; + let backupPath: string | undefined; + + if (existing !== outcome.json) { + if (existing !== null) { + const stamp = new Date() + .toISOString() + .replace(/[-:]/g, '') + .replace(/\..+$/, '') + .replace('T', ''); + backupPath = `${dest}.mosaic-bak-${stamp}`; + writeFileSync(backupPath, existing); + } + writeFileSync(dest, outcome.json); + destWritten = true; + } + + return { ...outcome, destWritten, backupPath }; +} + +// ─── Hidden CLI bridge (bash → TS) ────────────────────────────────────────── + +/** Hidden CLI subcommand name. `mosaic-link-runtime-assets` (bash) invokes + * this instead of its generic `copy_file_managed` for the settings.json + * runtime file specifically, so the guard's decision is made by importing + * `leaseEnforcementActivatable()` directly rather than re-implementing the + * capability/supervisor probes in shell. Deliberately undocumented (hidden + * from `--help`) — internal wiring, not a user-facing command. */ +export const INSTALL_ORDERING_GUARD_COMMAND = '__link-claude-settings'; + +export function registerInstallOrderingGuardCommand(program: Command): void { + program + .command(`${INSTALL_ORDERING_GUARD_COMMAND} `, { hidden: true }) + .description( + 'Internal: copy the Claude settings.json template, gating enforcement-hook ' + + 'wiring on lease-activation capability (#869 Point-1 C2)', + ) + .option( + '--allow-inactive-enforcement', + 'Wire enforcement hooks even when activation cannot be confirmed on this host ' + + '(explicit, loud, non-default opt-out — see #869)', + ) + .action((src: string, dest: string, opts: { allowInactiveEnforcement?: boolean }) => { + const result = runInstallOrderingGuard(src, dest, { + allowInactiveEnforcement: opts.allowInactiveEnforcement === true, + }); + for (const line of result.logs) { + (line.level === 'error' ? console.error : console.warn)(line.message); + } + process.exit(result.exitCode); + }); +} diff --git a/packages/mosaic/src/stages/finalize.ts b/packages/mosaic/src/stages/finalize.ts index 3bf832b6..3f1ce1f0 100644 --- a/packages/mosaic/src/stages/finalize.ts +++ b/packages/mosaic/src/stages/finalize.ts @@ -13,22 +13,37 @@ import { type SkillSyncResult as ClaudeSkillSyncResult, } from '../commands/skill.js'; -function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void { +/** + * Link runtime assets. Returns a warning string when the install-ordering + * guard (#869 Point-1 C2) reported a degraded outcome — i.e. the + * lease-enforcement hooks were NOT wired into ~/.claude/settings.json because + * this host could not confirm it can activate them — so the caller can + * surface it via `p.warn(...)` instead of it being swallowed by `stdio: + * 'pipe'`. Non-fatal either way: the wizard always continues. + */ +function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined { const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets'); - if (existsSync(script)) { - try { - spawnSync('bash', [script], { - timeout: 30000, - stdio: 'pipe', - env: { - ...process.env, - ...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}), - }, - }); - } catch { - // Non-fatal: wizard continues + if (!existsSync(script)) return undefined; + try { + const result = spawnSync('bash', [script], { + timeout: 30000, + stdio: 'pipe', + encoding: 'utf-8', + env: { + ...process.env, + ...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}), + }, + }); + if (result.status !== 0) { + const stderr = (result.stderr ?? '').trim(); + return ( + stderr || 'Runtime asset linking reported a non-zero exit (see mosaic doctor for details).' + ); } + } catch { + // Non-fatal: wizard continues } + return undefined; } interface SyncSkillsResult { @@ -201,7 +216,7 @@ export async function finalizeStage( // copied into ~/.claude/ while still linking the other runtime files. spin.update('Linking runtime assets...'); const skipClaudeHooks = state.hooks?.accepted === false; - linkRuntimeAssets(state.mosaicHome, skipClaudeHooks); + const linkWarning = linkRuntimeAssets(state.mosaicHome, skipClaudeHooks); // 4. Sync skills (only installs the user-selected subset) let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 }; @@ -236,6 +251,10 @@ export async function finalizeStage( spin.stop('Installation complete'); + // Surface the install-ordering guard's outcome (#869 Point-1 C2) — never + // silent, even though the wizard continues either way. + if (linkWarning) p.warn(linkWarning); + // Report skill install failure clearly (non-fatal but user should know) if (!skillsResult.success && skillsResult.failureReason) { p.warn(skillsResult.failureReason);