diff --git a/.claude/harness/CHANGELOG.md b/.claude/harness/CHANGELOG.md index de360ce..182b6fa 100644 --- a/.claude/harness/CHANGELOG.md +++ b/.claude/harness/CHANGELOG.md @@ -10,3 +10,10 @@ or old harness during a heterogeneous rollout. See - Task 0.6: out-of-band recovery script `.claude/scripts/force-pull-raw.sh` added. - (Earlier) Syncro billing SSOT resolved: `add_line_item` is normal billing; timers are outlier-only (explicit request). + +## 1.1.0 — 2026-06-08 +- Task 1: submodule-safe sync — `sync.sh` now unstages submodule gitlinks (unless + `--with-submodules`), eliminating the manual detach-to-pin dance before /save. +- Task 4: `harness-guard.sh` wired into `sync.sh` pre-commit, WARN-ONLY (logs conflict + markers / unencrypted sops / private keys to .claude/harness/guard.log; does not block + unless HARNESS_GUARD_FATAL=1; SKIP_HARNESS_GUARD=1 bypasses). diff --git a/.claude/harness/VERSION b/.claude/harness/VERSION index 3eefcb9..9084fa2 100644 --- a/.claude/harness/VERSION +++ b/.claude/harness/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/.claude/scripts/harness-guard.sh b/.claude/scripts/harness-guard.sh new file mode 100644 index 0000000..61d6015 --- /dev/null +++ b/.claude/scripts/harness-guard.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Harness commit guard. Inspects STAGED content for footguns before a commit. +# +# Rollout posture: WARN-ONLY by default (logs + prints, never blocks). This is +# deliberate (Task 4): a guard that fails closed can brick every machine's /save. It is +# promoted to blocking only after a clean warn window across the fleet. +# - default -> warn only, exit 0 +# - HARNESS_GUARD_FATAL=1 -> exit 1 on any issue (caller decides to abort) +# - SKIP_HARNESS_GUARD=1 -> bypass entirely (logged) +# Detects: conflict markers, unencrypted SOPS / private-key material, and a staged +# submodule gitlink change (informational). +set -uo pipefail + +ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +cd "$ROOT" +LOG="$ROOT/.claude/harness/guard.log" +mkdir -p "$(dirname "$LOG")" 2>/dev/null || true +ts() { date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "?"; } +warn() { echo "[harness-guard][WARN] $1"; echo "$(ts) WARN $1" >> "$LOG" 2>/dev/null || true; } + +if [ "${SKIP_HARNESS_GUARD:-0}" = "1" ]; then + echo "[harness-guard] bypassed (SKIP_HARNESS_GUARD=1)" + echo "$(ts) BYPASS SKIP_HARNESS_GUARD=1" >> "$LOG" 2>/dev/null || true + exit 0 +fi + +ISSUES=0 +mapfile -t STAGED < <(git diff --cached --name-only --diff-filter=ACM 2>/dev/null) + +for f in "${STAGED[@]}"; do + [ -n "$f" ] || continue + blob=$(git show ":$f" 2>/dev/null) || continue + # 1. Conflict markers + if printf '%s\n' "$blob" | grep -qE '^(<<<<<<< |=======$|>>>>>>> )'; then + warn "conflict markers in staged file: $f"; ISSUES=$((ISSUES + 1)) + fi + # 2. Unencrypted SOPS vault file + case "$f" in + *.sops.yaml|*.sops.json|*.sops.env) + if ! printf '%s\n' "$blob" | grep -qE 'ENC\[|^sops:'; then + warn "possible UNENCRYPTED sops file staged: $f"; ISSUES=$((ISSUES + 1)) + fi ;; + esac + # 3. Private key material + if printf '%s\n' "$blob" | grep -qE -- '-----BEGIN [A-Z ]*PRIVATE KEY-----'; then + warn "private-key material in staged file: $f"; ISSUES=$((ISSUES + 1)) + fi +done + +# 4. Submodule gitlink staged (informational — should only happen with --with-submodules) +if git diff --cached --submodule=short 2>/dev/null | grep -q '^Submodule '; then + warn "submodule gitlink change is staged (intentional only via --with-submodules)" +fi + +if [ "$ISSUES" -gt 0 ]; then + echo "[harness-guard] $ISSUES issue(s) found." + if [ "${HARNESS_GUARD_FATAL:-0}" = "1" ]; then + echo "[harness-guard] FATAL mode -> signalling block." + exit 1 + fi + echo "[harness-guard] WARN-ONLY mode -> not blocking." +fi +exit 0 diff --git a/.claude/scripts/sync.sh b/.claude/scripts/sync.sh index 8538ce8..bd5e78f 100755 --- a/.claude/scripts/sync.sh +++ b/.claude/scripts/sync.sh @@ -323,6 +323,18 @@ if [ -n "$(git status --porcelain)" ]; then purge_garbled_paths git add -A + # Submodule-safe staging (Task 1): `git add -A` stages submodule gitlink (pointer) + # changes. The parent's pinned commit intentionally lags the submodule's main, so + # auto-committing the pointer bumps a possibly-stale gitlink. Unstage every submodule + # gitlink unless the operator opted in with --with-submodules. This eliminates the + # manual "detach submodule to its pin before /save" dance. + if [ "${ADVANCE_SUBMODULES:-0}" != "1" ] && [ -f ".gitmodules" ]; then + while IFS= read -r sm_path; do + [ -n "$sm_path" ] || continue + git reset -q HEAD -- "$sm_path" 2>/dev/null || true + done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' | awk '{print $2}') + fi + # Commit message (Co-Authored-By uses local git user if configured) COMMIT_MSG="sync: auto-sync from $MACHINE at $TIMESTAMP @@ -331,10 +343,19 @@ Machine: $MACHINE Timestamp: $TIMESTAMP" if git diff-index --quiet --cached HEAD -- 2>/dev/null; then - echo -e "${GREEN}[OK]${NC} No stageable changes (submodule internal changes skipped)." + echo -e "${GREEN}[OK]${NC} No stageable changes (submodule pointer + internal changes skipped)." else - git commit -m "$COMMIT_MSG" - echo -e "${GREEN}[OK]${NC} Committed." + # Harness guard (Task 4): WARN-ONLY during rollout — logs footguns (conflict + # markers, unencrypted sops, private-key material) to .claude/harness/guard.log + # but does NOT block unless HARNESS_GUARD_FATAL=1. SKIP_HARNESS_GUARD=1 bypasses. + GUARD_RC=0 + bash .claude/scripts/harness-guard.sh || GUARD_RC=$? + if [ "$GUARD_RC" != "0" ]; then + echo -e "${YELLOW}[WARNING]${NC} harness-guard blocked the commit (HARNESS_GUARD_FATAL set). Staged changes left in place; set SKIP_HARNESS_GUARD=1 to override." + else + git commit -m "$COMMIT_MSG" + echo -e "${GREEN}[OK]${NC} Committed." + fi fi else echo -e "${GREEN}[OK]${NC} No local changes to commit."