From bdfb7605005393ae21dc7d131ee7f82ddd1e1a0b Mon Sep 17 00:00:00 2001 From: lukisch <55554266+lukisch@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:11:09 +0200 Subject: [PATCH] fix(guards): block worktree-rewriting git in OneDrive repos; sync segment splitter Brings library/guards.py up to the canonical .SYNC/hooks/guards.py: - check_onedrive_git(): pull/rebase/reset --hard|--keep|--merge/checkout -B|-f|-- / switch -C/restore/merge/branch -f in OneDrive paths are blocked unless the command carries ONEDRIVE_GIT_OK=1; unknown target directory fails closed (T-20260924-532136326: pull --rebase + checkout -B deleted 6039 files in a OneDrive research repo). - _segmente(): real command separators (force-push fix T-20260913-235210967) that this copy was still missing. Tests: hook-master pytest green; .SYNC/hooks/test_guards.py all cases pass against this file. Co-Authored-By: Claude Opus 5.5 (1M context) --- library/guards.py | 147 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/library/guards.py b/library/guards.py index 7188e6d..6e32910 100644 --- a/library/guards.py +++ b/library/guards.py @@ -25,6 +25,7 @@ import json import re +import shlex import sys # ---------------------------------------------------------------- bach.db ---- @@ -91,6 +92,145 @@ def repl(m): return _HEREDOC.sub(repl, command) +_TRENNER = {";", "&", "&&", "|", "||", "\n"} + + +def _segmente(text: str) -> list[str]: + """Zerlegt eine Kommandozeile an ECHTEN Trennern in Einzelkommandos. + + Warum nicht per Zeichenklasse: ';' und '&' treten auch auf, ohne ein + Kommando zu beenden -- in Umleitungen wie 2>&1 und in zitierten + Argumenten wie 'HEAD:refs/heads/a;b'. Wer dort trennt, verliert das + Force-Flag aus dem Blick und laesst einen echten Force-Push durch + (T-20260913-235210967). + + Faellt shlex aus (unbalancierte Anfuehrungszeichen o. ae.), wird die + ganze Zeile als EIN Segment zurueckgegeben: dann greift wieder das + breite Verhalten von vorher, das lieber zu viel blockt als zu wenig. + """ + try: + segments = [] + # Zeilenweise, BEVOR shlex laeuft: shlex zaehlt den Zeilenumbruch zum + # Whitespace und liefert ihn nie als Token. Wuerde man die Tokens + # zeilenuebergreifend zusammenfuegen, stuende das naechste Kommando + # nicht mehr am Segmentanfang -- und _CMD_POS verlangt genau das. + for zeile in text.splitlines(): + # posix=False ist der Kern: shlex laesst die Anfuehrungszeichen am + # Token stehen. Nur ein NACKTES ';' oder '&' ist damit ein Trenner -- + # ein zitiertes bleibt "';'" und trennt nicht. Mit posix=True wurde + # aus dem gueltigen Refnamen 'git push origin & --force' ein Trenner, + # und der Force-Push rutschte durch (zweiter Review-Blocker). + lex = shlex.shlex(zeile, posix=False, punctuation_chars=True) + lex.whitespace_split = True + current = [] + vorheriges = "" + for token in lex: + # Ein per Backslash maskierter Trenner beendet KEIN Kommando -- + # shlex liefert ihn als zwei Tokens ('\' und ';'), und wer dort + # trennt, verliert das Force-Flag aus dem Segment + # (dritter Review-Blocker). + escaped = vorheriges.endswith("\\") + vorheriges = token + if token in _TRENNER and not escaped: + seg = " ".join(current).strip() + if seg: + segments.append(seg) + current = [] + else: + # Erst NACH der Trenner-Entscheidung entquoten: der Mustertext + # soll 'git push "-f"' wie 'git push -f' sehen. + current.append(token.strip("'\"")) + continue + if escaped: + current.append(token) + seg = " ".join(current).strip() + if seg: + segments.append(seg) + return segments + except Exception: + return [text] + + +# ------------------------------------------------------- git in OneDrive ---- +# Am 2026-07-26 loeschte ein `git pull --rebase` mit anschliessendem +# `git checkout -B main origin/main` im OneDrive-Arbeitsbaum des CRM-Projekts +# 6039 Dateien (T-20260924-532136326): Der Rebase checkt zuerst den Remote-Stand +# aus und entfernt dabei alles, was nur im lokalen Commit steht; das Zuruecksetzen +# des Branches machte den Verlust endgueltig. Forschungsprojekte behalten ihr .git +# bewusst in OneDrive (Plan-D-Ausnahme), deshalb sind dort alle Git-Operationen, +# die den Arbeitsbaum umschreiben oder einen Branch versetzen, nur mit +# ausdruecklicher Freigabe erlaubt: `ONEDRIVE_GIT_OK=1 git ...` im Befehl. +# Fail-closed: Ist das Zielverzeichnis nicht bestimmbar, wird geblockt. +ONEDRIVE_OK = re.compile(r"(?:^|[\s;&|(])ONEDRIVE_GIT_OK=1\b") +_ONEDRIVE_PATH = re.compile(r"(?:^|[\\/])onedrive(?:[\\/]|$)", re.IGNORECASE) +_RISKY_SUB = re.compile( + r"^(?:" + r"pull\b|" + r"rebase\b(?!\s+--(?:abort|quit)\b)|" + r"reset\b.*\s--(?:hard|keep|merge)\b|" + r"checkout\b.*(?:\s-[A-Za-z]*[Bf]\b|\s--force\b|\s--\s|\s--$|\s\.(?:\s|$))|" + r"switch\b.*\s(?:-[A-Za-z]*[Cf]\b|--force-create\b|--discard-changes\b|--force\b)|" + r"restore\b|" + r"merge\b(?!\s+--(?:abort|quit)\b)|" + r"branch\b.*\s(?:-f\b|--force\b)" + r")" +) + + +def _onedrive_join(base: str, path: str) -> str: + path = path.strip("'\"") + if re.match(r"^(?:[A-Za-z]:[\\/]|/|~)", path) or not base: + return path + return base.rstrip("\\/") + "/" + path + + +def check_onedrive_git(command: str, cwd: str) -> str: + """Blockt arbeitsbaum-/branch-umschreibende Git-Befehle in OneDrive-Pfaden.""" + if not command or ONEDRIVE_OK.search(command) or "git" not in command: + return "" + current = cwd or "" + for seg in _segmente(executable_text(command)): + try: + lex = shlex.shlex(seg, posix=False) + lex.whitespace_split = True + words = [w.strip("'\"") for w in lex] # posix=False: Backslashes in Windows-Pfaden bleiben + except ValueError: + words = seg.split() + while words and re.match(r"^\w+=", words[0]): + words = words[1:] + if not words: + continue + if words[0] == "cd" and len(words) > 1: + current = _onedrive_join(current, words[1]) + continue + if words[0] not in ("git", "git.exe"): + continue + target, i = current, 1 + while i < len(words) and words[i].startswith("-"): + if words[i] in ("-C", "-c") and i + 1 < len(words): + if words[i] == "-C": + target = _onedrive_join(target, words[i + 1]) + i += 2 + elif words[i].startswith(("--git-dir=", "--work-tree=")): + target = _onedrive_join(target, words[i].split("=", 1)[1]) + i += 1 + else: + i += 1 + sub = " ".join(words[i:]) + if not _RISKY_SUB.search(sub): + continue + if target and not _ONEDRIVE_PATH.search(target.replace("\\", "/")): + continue + where = target or "unbekanntes Verzeichnis (fail-closed)" + return ( + "ONEDRIVE-GIT-GUARD: 'git " + sub[:80] + "' in " + where + " blockiert. " + "In OneDrive-Repos koennen pull/rebase/reset/checkout/merge Dateien loeschen " + "(CRM-Verlust 26.07.2026). Erst sichern (git branch rescue/ HEAD), " + "dann bewusst freigeben mit 'ONEDRIVE_GIT_OK=1 git ...'." + ) + return "" + + def check_bach_db(command: str) -> str: """Gibt eine Begründung zurück, wenn blockiert werden soll — sonst ''.""" if not (MENTIONS_SQLITE.search(command) and MENTIONS_BACH_DB.search(command)): @@ -108,7 +248,8 @@ def check_bach_db(command: str) -> str: def check_git(command: str) -> str: - if DESTRUCTIVE_GIT.search(executable_text(command)): + text = executable_text(command) + if any(DESTRUCTIVE_GIT.search(seg) for seg in _segmente(text)): return f"GIT-GUARD: Destruktive Operation erkannt und blockiert. Befehl: {command}" return "" @@ -123,7 +264,9 @@ def main() -> int: if not isinstance(command, str) or not command: return 0 - for reason in (check_bach_db(command), check_git(command)): + cwd = payload.get("cwd", "") + for reason in (check_bach_db(command), check_git(command), + check_onedrive_git(command, cwd if isinstance(cwd, str) else "")): if reason: print(reason, file=sys.stderr) return 2