diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 07:44:16 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 07:44:16 +0200 |
| commit | 9d5641fe6585a61894f7b7427a68c794e5efe76d (patch) | |
| tree | 79ebae3131df1c50542420db9e5ecb44e3d22076 /scripts | |
| parent | 81d4e80fd5aabe4e80f58e960affa795cf7d34ec (diff) | |
| download | workflow-9d5641fe6585a61894f7b7427a68c794e5efe76d.tar.gz workflow-9d5641fe6585a61894f7b7427a68c794e5efe76d.zip | |
Denylist guard at commit time (pre-commit + commit-msg hooks); drop publish_snapshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEAjUkQRrCYX5MZhWxdtj2
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/denylist_check.py | 158 | ||||
| -rwxr-xr-x | scripts/publish_snapshot.py | 159 |
2 files changed, 158 insertions, 159 deletions
diff --git a/scripts/denylist_check.py b/scripts/denylist_check.py new file mode 100755 index 0000000..08cf985 --- /dev/null +++ b/scripts/denylist_check.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Denylist guard for a public repo: refuse private words in commits (git hooks in .githooks/). + +Usage: denylist_check.py staged | msg FILE | tree [REF] + staged added lines + paths of the index (pre-commit hook) + msg FILE commit message, '#' lines skipped (commit-msg hook) + tree every tracked file of REF (default HEAD): manual audit + +Lists (merged): publish-denylist.local in the worktree top and in the main tree (git-ignored), +plus every `git config denylist.file <path>` (e.g. a global list). Lines: `word` = case-insensitive +substring; `!token` = exemption (a hit inside an occurrence of token does not count); `#` comments. +Output names the place and `word #<line>` only, never the word. Exit 0 clean, 1 hits. +No list at all → warning, exit 0. Bypass once: `git commit --no-verify`. +Enable in a clone: `git config core.hooksPath .githooks`. +""" +import re +import subprocess +import sys +from pathlib import Path + +NAME = "publish-denylist.local" + + +def git(*args): + r = subprocess.run(["git", "-c", "core.quotePath=false", *args], capture_output=True) + if r.returncode: + raise SystemExit(f"denylist-check: git {args[0]} failed: {r.stderr.decode(errors='replace').strip()}") + return r.stdout.decode("utf-8", errors="replace") + + +def parse(text): + """([(lineno, word lowercased)], [exempt token lowercased]).""" + words, exempt = [], [] + for n, line in enumerate(text.splitlines(), 1): + w = line.strip() + if not w or w.startswith("#"): + continue + if w.startswith("!"): + if w[1:]: + exempt.append(w[1:].lower()) + else: + words.append((n, w.lower())) + return words, exempt + + +def _exempt_spans(low, exempt): + spans = [] + for t in exempt: + spans += [(m.start(), m.end()) for m in re.finditer(re.escape(t), low)] + return spans + + +def hits(text, words, exempt): + """Line numbers (in the list) of words found in text outside exempted tokens.""" + low = text.lower() + spans = None + out = [] + for n, w in words: + for m in re.finditer(re.escape(w), low): + if spans is None: + spans = _exempt_spans(low, exempt) + if not any(a <= m.start() and m.end() <= b for a, b in spans): + out.append(n) + break + return out + + +def load_lists(): + """[(label, words, exempt)] of every existing list; label '' for the repo list.""" + top = Path(git("rev-parse", "--show-toplevel").strip()) + common = Path(git("rev-parse", "--path-format=absolute", "--git-common-dir").strip()) + cands = [("", top / NAME), ("", common.parent / NAME)] + r = subprocess.run(["git", "config", "--get-all", "denylist.file"], capture_output=True, text=True) + cands += [(Path(p).name + " ", Path(p).expanduser()) for p in r.stdout.splitlines() if p.strip()] + seen, lists = set(), [] + for label, p in cands: + if not p.is_file() or p.resolve() in seen: + continue + seen.add(p.resolve()) + lists.append((label, *parse(p.read_text(errors="replace")))) + return lists + + +def scan(items, lists): + """items = [(place, text)] → ['place: <label>word #n']; words in place (a path) masked as ***.""" + allw = sorted({w for _, words, _ in lists for _, w in words}, key=len, reverse=True) + mask = re.compile("|".join(map(re.escape, allw)), re.I) if allw else None + out = [] + for place, text in items: + for label, words, exempt in lists: + out += [f"{mask.sub('***', place)}: {label}word #{n}" for n in hits(text, words, exempt)] + return out + + +def staged_items(): + items, path, line = [], None, 0 + for name in git("diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR").split("\0"): + if name: + items.append((f"path {name}", name)) + for raw in git("diff", "--cached", "-U0", "--no-color", "--no-ext-diff", "--diff-filter=ACMR").splitlines(): + if raw.startswith("+++ "): + path = raw[6:] if raw.startswith("+++ b/") else raw[4:].strip('"') + elif raw.startswith("@@"): + m = re.match(r"@@ -\S+ \+(\d+)", raw) + line = int(m.group(1)) if m else 0 + elif raw.startswith("+") and path: + items.append((f"{path}:{line}", raw[1:])) + line += 1 + return items + + +def msg_items(file): + text = Path(file).read_text(errors="replace") + items = [] + for n, l in enumerate(text.splitlines(), 1): + if l.startswith("# ------------------------ >8"): + break + if not l.startswith("#"): + items.append((f"commit message:{n}", l)) + return items + + +def tree_items(ref): + items = [] + for name in git("ls-tree", "-r", "-z", "--name-only", ref).split("\0"): + if not name: + continue + items.append((f"path {name}", name)) + data = subprocess.run(["git", "cat-file", "blob", f"{ref}:{name}"], capture_output=True).stdout + if b"\0" in data[:8000]: + continue + for n, l in enumerate(data.decode("utf-8", errors="replace").splitlines(), 1): + items.append((f"{name}:{n}", l)) + return items + + +def main(argv): + if not argv or argv[0] not in ("staged", "msg", "tree") or (argv[0] == "msg" and len(argv) != 2): + print(__doc__.split("\n\n")[1], file=sys.stderr) + return 2 + lists = load_lists() + if not lists: + print(f"denylist-check: warning: no {NAME} (nor denylist.file): nothing checked", file=sys.stderr) + return 0 + mode = argv[0] + items = staged_items() if mode == "staged" else msg_items(argv[1]) if mode == "msg" \ + else tree_items(argv[1] if len(argv) > 1 else "HEAD") + found = scan(items, lists) + if found: + print("denylist-check: private word found (fix, or `git commit --no-verify` if a false hit):", + file=sys.stderr) + print("\n".join(found), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/publish_snapshot.py b/scripts/publish_snapshot.py deleted file mode 100755 index bcbb002..0000000 --- a/scripts/publish_snapshot.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -"""Export a scrubbed snapshot of the tool repo into a separate local git repo (fresh history). - -Usage: publish_snapshot.py [DEST] [--src REPO] [--ref REF] [--denylist FILE] - -- DEST (default ~/src/wf-public): created and `git init`ed on the first run. -- The tree of REF (default master) is copied; inbox.md, .worktrees/, __pycache__/, out/ are dropped. -- Denylist (default <src>/publish-denylist.local, git-ignored): one private word per line, `#` comments; - matched case-insensitively against every path and file content. Any match → nothing is written, exit 1. -- First run → one commit 'initial public snapshot'; later runs → one commit whose message is the - CHANGES.md lines new since the last snapshot; no change → no commit. -- Never pushes, never adds a remote: pushing is a manual step (docs/manual.md). -""" -import argparse -import io -import os -import shutil -import subprocess -import sys -import tarfile -from pathlib import Path - -EXCLUDE_NAMES = {"inbox.md", ".worktrees", "__pycache__", "out"} -DENYLIST_NAME = "publish-denylist.local" - - -class Fail(Exception): - pass - - -def git(cwd, *args, data=None): - r = subprocess.run(["git", *args], cwd=cwd, input=data, capture_output=True) - if r.returncode: - raise Fail(f"git {' '.join(args)}: {r.stderr.decode(errors='replace').strip()}") - return r.stdout - - -def excluded(path): - parts = path.split("/") - return any(p in EXCLUDE_NAMES for p in parts) - - -def read_tree(src, ref): - """{relative path: (bytes, mode)} of REF's tree, excluded paths dropped.""" - tar = tarfile.open(fileobj=io.BytesIO(git(src, "archive", "--format=tar", ref))) - files = {} - for m in tar.getmembers(): - if not (m.isfile() or m.issym()) or excluded(m.name): - continue - if m.issym(): - files[m.name] = (m.linkname.encode(), "link") - else: - files[m.name] = (tar.extractfile(m).read(), m.mode) - return files - - -def load_denylist(path): - if not path.is_file(): - raise Fail(f"no denylist at {path} (one private word per line; git-ignored) — refusing to publish") - words = [w.strip() for w in path.read_text().splitlines()] - words = [w for w in words if w and not w.startswith("#")] - if not words: - raise Fail(f"denylist {path} is empty — refusing to publish") - return words - - -def scan(files, words): - """Lines 'path[:line]: word' for every denylist hit.""" - low = [w.lower() for w in words] - hits = [] - for path in sorted(files): - for w, wl in zip(words, low): - if wl in path.lower(): - hits.append(f"{path}: {w} (path)") - text = files[path][0].decode("utf-8", errors="ignore").lower() - if not any(wl in text for wl in low): - continue - for n, line in enumerate(text.splitlines(), 1): - for w, wl in zip(words, low): - if wl in line: - hits.append(f"{path}:{n}: {w}") - return hits - - -def changes_lines(data): - return [l for l in data.decode("utf-8", errors="replace").splitlines() if l.startswith("- ")] - - -def write_tree(dest, files): - for p in dest.iterdir(): - if p.name == ".git": - continue - shutil.rmtree(p) if p.is_dir() and not p.is_symlink() else p.unlink() - for path, (data, mode) in files.items(): - f = dest / path - f.parent.mkdir(parents=True, exist_ok=True) - if mode == "link": - os.symlink(data.decode(), f) - else: - f.write_bytes(data) - os.chmod(f, 0o755 if mode & 0o111 else 0o644) - - -def publish(src, dest, ref, denylist): - words = load_denylist(denylist) - files = read_tree(src, ref) - hits = scan(files, words) - if hits: - raise Fail("denylist matches, nothing published:\n" + "\n".join(hits)) - first = not (dest / ".git").exists() - if first: - if dest.exists() and any(dest.iterdir()): - raise Fail(f"{dest} exists, is not empty and not a git repo") - dest.mkdir(parents=True, exist_ok=True) - git(dest, "init", "-q", "-b", "master") - old_changes = [] - else: - old = dest / "CHANGES.md" - old_changes = changes_lines(old.read_bytes()) if old.is_file() else [] - write_tree(dest, files) - git(dest, "add", "-A") - if not first and not git(dest, "status", "--porcelain").strip(): - return "nothing new: no commit" - if first: - msg = "initial public snapshot" - else: - new = [l for l in changes_lines(files.get("CHANGES.md", (b"", 0))[0]) if l not in set(old_changes)] - msg = "public snapshot\n\n" + ("\n".join(new) if new else "- (no new CHANGES.md lines)") - ident = [] # a fresh dest has no identity of its own: commit as the source repo's user - for key in ("user.name", "user.email"): - r = subprocess.run(["git", "config", key], cwd=src, capture_output=True, text=True) - if r.stdout.strip(): - ident += ["-c", f"{key}={r.stdout.strip()}"] - git(dest, *ident, "commit", "-q", "-F", "-", data=msg.encode()) - sha = git(dest, "rev-parse", "--short", "HEAD").decode().strip() - return f"committed {sha} in {dest} ({'first' if first else 'update'}; not pushed — see docs/manual.md)" - - -def main(argv=None): - here = Path(__file__).resolve().parent.parent - ap = argparse.ArgumentParser(prog="publish_snapshot.py", description=__doc__.splitlines()[0]) - ap.add_argument("dest", nargs="?", default=str(Path.home() / "src" / "wf-public"), - help="target repo (default ~/src/wf-public)") - ap.add_argument("--src", default=str(here), help="tool repo (default: this script's repo)") - ap.add_argument("--ref", default="master", help="source ref (default master)") - ap.add_argument("--denylist", help=f"private-word file (default <src>/{DENYLIST_NAME})") - a = ap.parse_args(argv) - src = Path(a.src).expanduser().resolve() - deny = Path(a.denylist).expanduser() if a.denylist else src / DENYLIST_NAME - try: - print(publish(src, Path(a.dest).expanduser().resolve(), a.ref, deny)) - except Fail as e: - print(f"wf: {e}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) |
