Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 145 additions & 2 deletions library/guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import json
import re
import shlex
import sys

# ---------------------------------------------------------------- bach.db ----
Expand Down Expand Up @@ -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/<datum> 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)):
Expand All @@ -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 ""

Expand All @@ -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
Expand Down
Loading