From 9d5641fe6585a61894f7b7427a68c794e5efe76d Mon Sep 17 00:00:00 2001 From: godosa Date: Wed, 7 Oct 2026 07:44:16 +0200 Subject: Denylist guard at commit time (pre-commit + commit-msg hooks); drop publish_snapshot Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JEAjUkQRrCYX5MZhWxdtj2 --- .githooks/commit-msg | 5 ++ .githooks/pre-commit | 5 ++ CHANGES.md | 1 + CLAUDE.md | 2 + docs/manual.md | 17 ++--- scripts/denylist_check.py | 158 ++++++++++++++++++++++++++++++++++++++++ scripts/publish_snapshot.py | 159 ----------------------------------------- tests/test_denylist_check.py | 145 +++++++++++++++++++++++++++++++++++++ tests/test_publish_snapshot.py | 122 ------------------------------- 9 files changed, 325 insertions(+), 289 deletions(-) create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100755 scripts/denylist_check.py delete mode 100755 scripts/publish_snapshot.py create mode 100644 tests/test_denylist_check.py delete mode 100644 tests/test_publish_snapshot.py diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..1d0bf6f --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,5 @@ +#!/bin/sh +# Denylist guard (scripts/denylist_check.py): commit message. Enable: git config core.hooksPath .githooks +s="$(git rev-parse --show-toplevel)/scripts/denylist_check.py" +[ -f "$s" ] || exit 0 +exec python3 "$s" msg "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e5b89ec --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# Denylist guard (scripts/denylist_check.py): staged lines + paths. Enable: git config core.hooksPath .githooks +s="$(git rev-parse --show-toplevel)/scripts/denylist_check.py" +[ -f "$s" ] || exit 0 +exec python3 "$s" staged diff --git a/CHANGES.md b/CHANGES.md index 5f02c1e..e108b07 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,5 @@ # Changes (newest first) +- 2026-10-07 Denylist guard at commit time: `.githooks/pre-commit` (staged lines + paths) and `.githooks/commit-msg` run `scripts/denylist_check.py` against the git-ignored `publish-denylist.local` (worktree top + main tree, plus `git config denylist.file` lists; `word` / `!token` exemption / `#`, case-insensitive; output `word #` only; no list → warning). Enable per clone: `git config core.hooksPath .githooks`; audit: `denylist_check.py tree`. `scripts/publish_snapshot.py` removed (history is public now). Projects: nothing. - 2026-10-07 Tests use generic fixtures (/h/u, 10.0.0.x) instead of real home-dir and LAN paths (publish-scan clean). Projects: nothing. - 2026-10-06 Tool path is now `/projects/public/workflow` (was `/projects/workflow`): docs, shared CLAUDE.md, skills, wf-worker agent, messages. `wf projects` / `wf usage --report` scan the grandparent when the parent holds no project. Projects: point `~/.claude` symlinks/settings, `wf-res.service` and any script calling `wf.py` at the new path. diff --git a/CLAUDE.md b/CLAUDE.md index d588d7f..9c499c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,8 @@ This repo = the tool: `shared/CLAUDE.md` (symlinked as `/projects/CLAUDE.md`, lo + `wf.py` / `wf_res.py` / `wflib/`. Reference: `docs/design.md`, `docs/resource-ledger.md`. No personal data here (project names, tasks, paths under home): the workflow's own tasks live in a separate private wf project; publishable as is. +Public by default: never put private names in files or commit messages; the denylist hooks +(`.githooks`, `scripts/denylist_check.py`; enable: `git config core.hooksPath .githooks`) refuse them. ## Everything here is live Workers run the main tree's `master` the moment it changes. So: diff --git a/docs/manual.md b/docs/manual.md index 2b4594b..26661b6 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -212,14 +212,15 @@ wf add "Cave seams. Close the slit beside the lintel." -p 1 -e '<1h' --model son model that fits. A sonnet task without an exact Done line comes back as a handback. - **Updating the tool**: `git -C /projects/public/workflow pull`. Read the top of `CHANGES.md`: each line ends with "Projects: …", which says what a project must do (usually nothing). -- **Publishing a public copy**: keep the tool repo private (its history names your projects) and - share a fresh-history snapshot instead. Put your private words (project names, home paths), one - per line, in `publish-denylist.local` in the tool repo (git-ignored), then run - `python3 scripts/publish_snapshot.py [DEST]` (default `~/src/wf-public`). It copies master's - tree without `inbox.md`, `.worktrees/`, `__pycache__/`, `out/`, refuses if any denylist word is - in a path or file, and commits once per run (first: "initial public snapshot"; later: the new - `CHANGES.md` lines). It never pushes and never adds a remote: review DEST, then add the public - remote and `git push` there yourself. +- **Public by default**: the tool repo's history is public, so never put private names (project + names, home paths, hosts) in files or commit messages. A denylist guard checks each commit: put + your private words, one per line (`#` comments, `!token` = exemption), in `publish-denylist.local` + in the tool repo (git-ignored; extra lists via `git config --add denylist.file `) and enable + the hooks once per clone with `git config core.hooksPath .githooks`. The pre-commit hook scans + staged lines and paths, the commit-msg hook the message, both case-insensitive; a hit names the + place and `word #` only and refuses the commit (`git commit --no-verify` for a false hit). + No list → a warning, the commit goes through. Audit the whole tree with + `python3 scripts/denylist_check.py tree`. ## 5. Troubleshooting 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 ` (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 #` 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: