Add repo-hygiene-audit skill (Phase 7: repeatable hygiene sweep)

Read-only scripted audit across homelab/pbs/docker/root domains:
git auditability + .git ownership/dubious, .env perms (0660/devprojects),
inline compose secrets, non-git dirs, uv-init stubs, compose naming,
master-vs-main default, dirty trees, worktrees, herbygitea residue, doc
presence. Emits text/json/md findings mapped to remediation-plan phases.

SKILL.md + scripts/audit.py (stdlib-only) + references + packed .skill.
First run: 39 repos, 62 findings, ruff-clean.
This commit is contained in:
Travis Herbranson 2026-05-22 23:09:17 -04:00
commit 236616514f
8 changed files with 1324 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Python
__pycache__/
*.py[cod]
.ruff_cache/
.mypy_cache/
.pytest_cache/
# Virtual environments
.venv/
venv/
env/
# Generated audit reports (timestamped runs are not source)
findings-*.md
findings-*.json
findings-*.txt
# Environment / secrets
.env
.env.local
.env.*.local
# Editors / OS
.idea/
.vscode/
*.swp
*~
.DS_Store

68
CLAUDE.md Normal file
View File

@ -0,0 +1,68 @@
# CLAUDE.md
Agent operating guide for the `repo-hygiene-audit` skill repo. Human onboarding
lives in `README.md` — this file is the concise, gotcha-focused guide.
## What this is
A Claude skill that runs a repeatable, **read-only** hygiene audit across the
homelab / pbs / docker / root project domains. Phase 7 of the
repo-hygiene-remediation project: the durable version of the one-time audit.
## Architecture
- `repo-hygiene-audit/SKILL.md` — the contract: trigger description + the
five-step workflow. Source of truth for what the skill does.
- `repo-hygiene-audit/scripts/audit.py` — the engine. Pure stdlib, single file.
`discover_repos()` → per-repo `CHECKS` list → `Finding` records → renderers
(text / md / json).
- `repo-hygiene-audit/references/``check-catalog.md` (every check + why),
`findings-format.md` (output schema + category→phase map).
- `repo-hygiene-audit.skill` — zip of `SKILL.md scripts/ references/`. Repack
after any change under `repo-hygiene-audit/`.
## Gotchas / non-obvious rules
- **Read-only is the contract.** The script must never write to the trees it
scans — only to `--output`. All git invocations use read subcommands. Don't
add a check that mutates a repo.
- **Each repo = an immediate child of a domain root.** The `root` domain is the
exception: it names explicit repo paths (wiki-vault, wiki-context), not a
scan root.
- **`docker` domain repos are compose stacks** — README expected, CLAUDE.md
intentionally NOT expected (`COMPOSE_STACK_DOMAINS`). Don't "fix" the missing
CLAUDE.md flag suppression there.
- **safe.directory wildcard caveat** is load-bearing in the hints: always steer
toward a *scoped* `safe.directory <path>`, never `*`.
- **Self-reference trap:** `audit.py` literally contains the string
`herbygitea` as a detection pattern. `check_herbygitea_residue` excludes its
own file by resolved path — keep that exclusion if you refactor.
- **Heuristic checks** (`inline-secret`, `uv-init-stub`, `claude-md-bloat`) can
false-positive. They flag *candidates* for human judgement, not certainties.
- **Findings keys are stable** (`domain/repo:check`) on purpose — week-over-week
JSON diffing depends on it. Don't churn check ids casually.
## Commands
```bash
# Run the sweep (default domains)
python repo-hygiene-audit/scripts/audit.py
# Lint (matches Travis's scaffolding ruleset)
ruff check repo-hygiene-audit/scripts/audit.py --select E,F,B,I,UP,N,S,SIM,RET,PTH
# Repack the .skill after editing anything under repo-hygiene-audit/
cd repo-hygiene-audit && zip -r ../repo-hygiene-audit.skill SKILL.md scripts/ references/
```
## Conventions enforced (from the remediation plan)
`.env` target 0660 + group `devprojects`; secrets in `.env` not inline compose;
README = human onboarding, CLAUDE.md = concise agent guide; prefer `main` over
`master`; GitHub-destined repos pushed manually.
## Don't-touch
- Don't add network calls or pip dependencies — stdlib-only is deliberate so it
runs anywhere `python3` exists.
- Don't make the audit remediate. Reporting and fixing are separate steps.

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# repo-hygiene-audit
A Claude Code / Cowork skill that runs a **repeatable, read-only hygiene audit**
across the `homelab`, `pbs`, `docker`, and `root` project domains and reports
the findings, grouped by remediation phase.
It is the durable, repeatable version of the one-time 2026-05-19/20
documentation/repo audit — Phase 7 of the *repo-hygiene-remediation* project.
The original sweep proved that a one-time pass isn't enough; this skill makes
the mechanical part runnable on a cadence so the project folders don't drift
back into inconsistency.
## What it checks
- **Git auditability & ownership** — missing/empty `.git`, dubious ownership,
`.git`-vs-directory owner drift, group drift.
- **Secrets & permissions** — world-readable / wrong-mode `.env` files, secrets
inlined in compose files.
- **Cleanups**`uv init` leftover stubs, `docker-compose.yml``compose.yml`
naming, stale review docs, `herbygitea` SSH-alias residue, stray backup dirs.
- **Git state**`master`-vs-`main` default branch, leftover `docs-audit`
branches, dirty trees, stray worktrees, unpushed commits.
- **Docs** — missing README/CLAUDE.md, oversized CLAUDE.md (likely README clone).
Full catalogue: [`repo-hygiene-audit/references/check-catalog.md`](repo-hygiene-audit/references/check-catalog.md).
## Usage
```bash
cd repo-hygiene-audit
# Terminal triage across all default domains:
python scripts/audit.py
# Phase-mapped markdown (pastes into the remediation plan):
python scripts/audit.py --format md -o findings-$(date +%Y%m%d).md
# Machine-readable, for week-over-week diffing:
python scripts/audit.py --format json -o findings-$(date +%Y%m%d).json
```
No third-party dependencies — `python3` (or `uv run python`) is enough. The
script is **read-only**: it never edits, commits, or pushes the repos it scans.
### Useful flags
| Flag | Purpose |
|------|---------|
| `--format {text,json,md}` | Output format (default `text`) |
| `-o, --output PATH` | Write to a file instead of stdout |
| `--config FILE.json` | Override the domain map |
| `--scan NAME=PATH` | Ad-hoc scan of a directory's children |
| `--only CATEGORY` | Restrict to one or more categories |
| `--fail-on SEVERITY` | Non-zero exit at/above a severity (automation hook) |
## Layout
```
repo-hygiene-audit-skill/
├── README.md # this file (human onboarding)
├── CLAUDE.md # agent operating guide
├── repo-hygiene-audit/ # the skill itself
│ ├── SKILL.md # trigger + workflow
│ ├── scripts/audit.py # read-only check engine
│ └── references/
│ ├── check-catalog.md # every check + rationale
│ └── findings-format.md # output schema + phase mapping
├── repo-hygiene-audit.skill # packaged bundle (zip of the above)
└── sessions/ # session notes
```
## Cadence
Currently **weekly, triggered manually** by Travis (no automation yet). The
`--fail-on` flag is the hook if/when this becomes a scheduled task.

BIN
repo-hygiene-audit.skill Normal file

Binary file not shown.

129
repo-hygiene-audit/SKILL.md Normal file
View File

@ -0,0 +1,129 @@
---
name: repo-hygiene-audit
description: >
Repeatable hygiene audit across the homelab / pbs / docker / root project
domains. Runs scripted, read-only checks for git auditability and .git
ownership drift, .env file permissions, inline secrets in compose files,
non-git directories, uv-init leftover stubs, docker-compose.yml -> compose.yml
naming, branch and master-vs-main default-branch state, dirty trees, stray
worktrees, and herbygitea SSH-alias residue — then emits a findings report
mapped to the remediation-plan phases. Use when asked to audit repos, run a
repo-hygiene sweep, check project folders for drift, do the weekly/recurring
guided review, or verify repos against conventions before a commit/merge.
---
# Repo Hygiene Audit
Makes the one-time 2026-05-19/20 documentation/repo audit **repeatable** so the
project folders don't silently drift back into inconsistency. The mechanical
checks are scripted (`scripts/audit.py`); the judgement calls (semantic doc
freshness, policy decisions) stay with the human/agent driving the review.
This skill is **read-only against the trees it scans.** It never edits, commits,
or pushes anything in the audited repos — it reports. Remediation is a separate,
deliberate step the operator drives.
## When to run
- The recurring guided review (current cadence: **weekly, manually triggered**).
- After landing a batch of new repos or scaffolds, to confirm they start clean.
- Before a commit/merge sweep, to catch state that should be resolved first.
- Whenever asked to "audit repos", "check for drift", "repo hygiene sweep".
## The method
The original audit scored docs on a five-dimension rubric — **accuracy
(heaviest) → freshness → internal contradiction → completeness → role fit** —
and verified documented commands/paths against the real tree (a confidently
wrong doc is worse than a missing one). That semantic scoring is the human part.
This skill automates the **mechanical enumeration** underneath it: sweep every
repo in every domain and flag the objective signals that drift produces.
Domains (the four the audit covered):
| Domain | Roots scanned |
|--------|---------------|
| `homelab` | `/opt/projects/homelab/*` |
| `pbs` | `/opt/projects/pbs/*` |
| `docker` | `/opt/projects/docker/*`, `~/projects/docker/*` (compose stacks) |
| `root` | `/opt/projects/wiki-vault`, `/opt/projects/wiki-context` |
`docker` repos are treated as compose stacks: README expected, **no per-stack
CLAUDE.md** (per the locked doc-role convention).
## Workflow
### Step 1 — Run the scripted sweep
```bash
cd repo-hygiene-audit
# Human-readable, all domains (default config):
python scripts/audit.py
# Phase-mapped markdown you can paste into the remediation plan:
python scripts/audit.py --format md -o findings-$(date +%Y%m%d).md
# Machine-readable for diffing week-over-week:
python scripts/audit.py --format json -o findings-$(date +%Y%m%d).json
```
No third-party dependencies — plain `python3` (or `uv run python`) is enough.
### Step 2 — Triage by phase
Findings are grouped into the categories that map onto the remediation plan's
phases (see `references/findings-format.md`):
- **SECURITY** (Phase 2) — world-readable / wrong-mode `.env`, inline compose secrets.
- **AUDITABILITY** (Phase 3) — missing `.git`, dubious ownership, `.git`/dir owner drift, non-git dirs with content.
- **CLEANUP** (Phase 4) — uv-init stubs, compose naming, stale review docs, herbygitea residue, stray backup dirs.
- **GIT_STATE** — master-vs-main default, dirty trees, leftover `docs-audit` branches, unpushed commits, stray worktrees.
- **DOCS** — missing README/CLAUDE.md, oversized CLAUDE.md (possible README clone).
Start at `high`, work down. The `INVENTORY` block also lists every repo (even
clean ones) so you can confirm the sweep saw what you expected.
### Step 3 — Semantic doc pass (human judgement)
For each repo with stale-looking docs, apply the five-dimension rubric by hand:
read README + CLAUDE.md against the actual tree. The script flags *candidates*
(missing docs, bloated CLAUDE.md, stale review artifacts); it can't judge
whether the prose is *accurate*. That's the reviewer's call.
### Step 4 — Decide remediation, don't auto-fix
The hint on each finding is the suggested remediation, **not** an action the
skill takes. Sequence fixes through the normal flow (and the manual-push rule
for GitHub-destined repos). For the safe.directory caveat specifically: prefer a
**scoped** `safe.directory <path>` entry over the `*` wildcard, which disables
the dubious-ownership safety check everywhere.
### Step 5 — Persist the run
Save the markdown report alongside the project plan (or as a vault session note)
so the next run can diff against it. Drift shows up as new findings; remediation
shows up as findings that disappear.
## Scripted checks
`scripts/audit.py` is the engine. Each check is read-only and resilient (one
repo erroring never aborts the sweep). Full catalogue with rationale in
`references/check-catalog.md`. Selected flags:
| Flag | Purpose |
|------|---------|
| `--format {text,json,md}` | output format (default `text`) |
| `-o, --output PATH` | write report to a file instead of stdout |
| `--config FILE.json` | override the domain map (`{domain: {scan:[], repos:[]}}`) |
| `--scan NAME=PATH` | ad-hoc: scan immediate children of PATH as repos |
| `--repo NAME=PATH` | ad-hoc: treat PATH itself as one repo |
| `--only CATEGORY` | restrict output to one or more categories |
| `--fail-on SEVERITY` | exit non-zero if any finding at/above that severity (CI/automation hook) |
## Available resources
| Resource | When to load | Purpose |
|----------|--------------|---------|
| `scripts/audit.py` | Every run | The read-only check engine |
| `references/check-catalog.md` | Interpreting a finding | What each check looks for + why it matters |
| `references/findings-format.md` | Wiring output into the plan | Output schema + category→phase mapping |

View File

@ -0,0 +1,76 @@
# Check catalogue
Every check `scripts/audit.py` runs, what it looks for, the finding it emits,
and why it matters. All checks are read-only. Each finding carries a `check`
id (stable, dedup-friendly), a `category`, a `severity`, a `detail`, and a
remediation `hint`.
The conventions these checks enforce come from the repo-hygiene-remediation
plan's *Locked Decisions*: `.env` target mode **0660** + group **devprojects**;
secrets live in `.env`, not inline in compose; README = human onboarding,
CLAUDE.md = concise agent guide (not a README clone); docker compose stacks get
README only; prefer `main` over `master`; GitHub-destined repos are pushed
manually.
## AUDITABILITY (maps to Phase 3 — unblock un-auditable repos)
| check id | Looks for | Severity | Why |
|----------|-----------|----------|-----|
| `no-git` | A directory with real (non-dotfile) content but no `.git` | high | Can't be diffed, reviewed, or doc-audited until it's under version control. |
| `no-commits` | A `.git` exists but `HEAD` has zero commits | high | Everything is untracked; needs an initial commit pass. |
| `dubious-ownership` | `git` refuses the repo with "detected dubious ownership" | high | Repo is unusable to the current user. Fix ownership, or add a **scoped** `safe.directory <path>` — never the `*` wildcard, which disables the safety check globally. |
| `git-owner-drift` | `.git` owner differs from the directory owner (e.g. travadmin vs herbyadmin) | medium | Symptom of mixed-user operations; precedes dubious-ownership breakage. |
| `group-drift` | Directory group is not `devprojects` | low | Breaks the shared-group access model the dev tree relies on. |
## SECURITY (maps to Phase 2 — security remediation)
| check id | Looks for | Severity | Why |
|----------|-----------|----------|-----|
| `env-world-readable:<file>` | `.env` (or `.env.<env>`) readable by `other` | high | Secrets exposed to any local account. |
| `env-mode:<file>` | `.env` mode not in {0600, 0660} | medium | Doesn't meet the 0660/0600 target. |
| `env-group:<file>` | `.env` group is not `devprojects` | low | Group-access model drift. |
| `inline-secret:<file>` | A compose file assigns a secret-looking key (SECRET/PASSWORD/TOKEN/KEY/JWT/ENCRYPTION/SALT…) to a **literal** value (long hex/base64, not `${VAR}` and not a placeholder) | high | Secrets belong in a gitignored `.env`, not committed in compose. Heuristic — verify before acting; rotate if the repo was ever public. |
`.env.example` / `.env.sample` / `.env.template` / `.env.dist` are ignored
(they're meant to be committed and world-readable).
## CLEANUP (maps to Phase 4 — cross-repo cleanups)
| check id | Looks for | Severity | Why |
|----------|-----------|----------|-----|
| `uv-init-stub` | `main.py`/`hello.py` + `pyproject.toml` + (`uv.lock` or `.python-version`) in a repo with no `src/` and ≤2 `.py` files | low | Classic `uv init` residue left in non-Python repos; not on any build path. |
| `compose-legacy-name` | `docker-compose.yml` present, no `compose.yml` | low | Modern Compose prefers `compose.yml`. |
| `compose-rename-midflight` | Both `docker-compose.yml` and `compose.yml` present | medium | A rename was started but not finished; `git rm` the legacy file. |
| `stale-review-doc` | `GEMINI.md` or `*review*.md` at repo root | low | Stray review artifacts; move into a `reviews/` subdir or delete. |
| `herbygitea-residue` | The string `herbygitea` in any tracked file (the removed SSH alias) | medium | Dead remote/host alias; will break on next use. |
| `stray-dir` | Directory name ending `.broken` / `.backup-<date>` / `.bak` / `.old` | low | Scratch/backup cruft sitting in a domain folder. |
## GIT_STATE (review before commit/merge)
| check id | Looks for | Severity | Why |
|----------|-----------|----------|-----|
| `master-default` | `master` branch exists, no `main` | medium | House convention is `main`. |
| `docs-audit-branch` | A leftover `docs-audit*` branch | low | Residue from the one-time sweep; merge/push then delete. |
| `dirty-tree` | Uncommitted changes in the working tree | low | Sequence the doc/work commit deliberately; don't audit over churn. |
| `extra-worktrees` | More than one attached worktree | info | Possible stale worktrees to prune. |
| `no-upstream` | Current branch has no upstream | info | Expected for unpushed audit work; informational. |
| `unpushed-commits` | Commits ahead of upstream | info | Push when ready (manual for GitHub-destined repos). |
## DOCS (audit follow-up)
| check id | Looks for | Severity | Why |
|----------|-----------|----------|-----|
| `missing-readme` | No `README.md` in a git repo | medium | No human onboarding doc. |
| `missing-claude` | No `CLAUDE.md` in a git repo (skipped for the `docker` domain) | medium | No agent operating guide. Compose stacks intentionally get README only. |
| `claude-md-bloat` | `CLAUDE.md` > 200 lines | low | Likely a README clone — violates the role split (CLAUDE.md should be a concise, gotcha-focused agent guide). |
`claude-md-bloat` and the missing-doc checks flag **candidates** for the
semantic five-dimension review; they can't judge whether the prose is accurate.
## Extending
Add a `check_*(rep: RepoReport) -> None` function that appends `Finding`
objects, then register it in the `CHECKS` list. Keep it read-only and wrap
risky I/O — the orchestrator already catches exceptions per check so one bad
repo can't abort the sweep, but checks should fail soft on their own where they
can.

View File

@ -0,0 +1,909 @@
#!/usr/bin/env python3
"""repo-hygiene-audit — mechanical hygiene sweep across project domains.
Read-only. Scans a set of domain roots, treating each immediate subdirectory
as a repo (plus any explicit repo paths), and runs the mechanical checks that
surfaced findings in the 2026-05-19/20 documentation/repo audit:
* git auditability + .git ownership drift (+ dubious-ownership detection)
* .env file permissions (target 0660, group devprojects)
* inline secrets committed in compose files
* missing / broken .git, non-git directories holding real content
* uv-init leftover stubs (hello.py/main.py + pyproject/uv.lock/.python-version)
* docker-compose.yml -> compose.yml naming drift
* branch state + master-vs-main default-branch mismatch
* dirty working trees, zero-commit repos, unpushed commits, stray worktrees
* herbygitea SSH-alias residue
* stale review docs (GEMINI.md / *-review-*.md) and doc presence/role split
This script NEVER writes to the trees it scans. The only filesystem write is
the optional findings report written to --output.
Pure standard library (no pip deps) so it runs anywhere uv/python3 is present.
"""
from __future__ import annotations
import argparse
import datetime as dt
import grp
import json
import os
import pwd
import re
import socket
import subprocess
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
# A domain either scans the immediate children of one or more roots ("scan"),
# or names explicit repo paths ("repos"), or both. Override with --config.
DEFAULT_DOMAINS: dict[str, dict[str, list[str]]] = {
"homelab": {"scan": ["/opt/projects/homelab"]},
"pbs": {"scan": ["/opt/projects/pbs"]},
"docker": {"scan": ["/opt/projects/docker", "/home/herbyadmin/projects/docker"]},
"root": {"repos": ["/opt/projects/wiki-vault", "/opt/projects/wiki-context"]},
}
# Conventions from the repo-hygiene-remediation plan (Locked Decisions).
EXPECTED_GROUP = "devprojects"
ENV_MODE_OK = {0o600, 0o660}
PREFERRED_DEFAULT_BRANCH = "main"
# Repos in the "docker" domain are compose stacks: README only, no CLAUDE.md.
COMPOSE_STACK_DOMAINS = {"docker"}
# Directory names we never descend into / never treat as a repo.
SKIP_NAMES = {".git", "__pycache__", "node_modules", ".venv", "venv"}
STRAY_DIR_RE = re.compile(r"\.broken$|\.backup-\d|\.bak$|\.old$")
# Secret-ish keys for the inline-compose-secret heuristic.
SECRET_KEY_RE = re.compile(
r"(SECRET|PASSWORD|PASSWD|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|JWT|"
r"ENCRYPTION|SALT|CREDENTIAL)",
re.IGNORECASE,
)
# A literal value that looks like a real secret (long hex / base64-ish),
# as opposed to an env reference (${FOO}) or an obvious placeholder.
SECRET_VAL_RE = re.compile(r"^[A-Za-z0-9+/=_-]{16,}$")
PLACEHOLDER_RE = re.compile(
r"^(changeme|change_me|example|placeholder|your[_-]|xxx+|todo|none|null|"
r"true|false|secret|password)$",
re.IGNORECASE,
)
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
# Category -> project phase (for the markdown / phase-mapped report).
CATEGORY_PHASE = {
"SECURITY": "Phase 2 — Security remediation",
"AUDITABILITY": "Phase 3 — Unblock un-auditable repos",
"CLEANUP": "Phase 4 — Cross-repo cleanups",
"GIT_STATE": "Git state (review before commit/merge)",
"DOCS": "Docs (audit follow-up)",
}
# --------------------------------------------------------------------------- #
# Data model
# --------------------------------------------------------------------------- #
@dataclass
class Finding:
domain: str
repo: str
path: str
category: str
severity: str
check: str
detail: str
hint: str = ""
@property
def key(self) -> str:
return f"{self.domain}/{self.repo}:{self.check}"
@dataclass
class RepoReport:
domain: str
name: str
path: str
is_git: bool = False
owner: str = ""
git_owner: str = ""
group: str = ""
branch: str = ""
findings: list[Finding] = field(default_factory=list)
# --------------------------------------------------------------------------- #
# Low-level helpers (all read-only)
# --------------------------------------------------------------------------- #
def run_git(repo: Path, *args: str) -> tuple[int, str, str]:
"""Run a git command against repo, never prompting. Returns (rc, out, err)."""
env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GIT_PAGER="cat", PAGER="cat")
try:
# git is resolved from PATH and all args are internally constructed
# (no user-supplied shell input) — read-only subcommands only.
proc = subprocess.run( # noqa: S603
["git", "-C", str(repo), *args], # noqa: S607
capture_output=True,
text=True,
env=env,
timeout=30,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc: # pragma: no cover
return 1, "", str(exc)
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
def owner_of(path: Path) -> str:
try:
return pwd.getpwuid(path.stat().st_uid).pw_name
except (KeyError, OSError):
try:
return str(path.stat().st_uid)
except OSError:
return "?"
def group_of(path: Path) -> str:
try:
return grp.getgrgid(path.stat().st_gid).gr_name
except (KeyError, OSError):
try:
return str(path.stat().st_gid)
except OSError:
return "?"
def has_real_content(repo: Path) -> bool:
"""True if the dir holds anything other than dotfiles/skip dirs."""
try:
for child in repo.iterdir():
if child.name in SKIP_NAMES:
continue
if child.name.startswith("."):
continue
return True
except OSError:
return False
return False
# --------------------------------------------------------------------------- #
# Individual checks — each appends Finding objects to rep.findings
# --------------------------------------------------------------------------- #
def check_git_and_ownership(rep: RepoReport) -> None:
repo = Path(rep.path)
rep.owner = owner_of(repo)
rep.group = group_of(repo)
git_path = repo / ".git"
rep.is_git = git_path.exists()
if rep.group not in (EXPECTED_GROUP, "?"):
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "AUDITABILITY", "low",
"group-drift",
f"directory group is '{rep.group}', expected '{EXPECTED_GROUP}'",
f"chgrp -R {EXPECTED_GROUP} <repo> (and set the setgid bit)",
)
)
if not rep.is_git:
if has_real_content(repo):
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "AUDITABILITY", "high",
"no-git",
"directory holds real content but has no .git",
"git init + initial commit (gitignore .env from the start), "
"then doc it",
)
)
return
rep.git_owner = owner_of(git_path)
if rep.git_owner != rep.owner:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "AUDITABILITY", "medium",
"git-owner-drift",
f".git owned by '{rep.git_owner}' but dir owned by '{rep.owner}'",
"chown -R the repo to one user; prefer a scoped "
"safe.directory entry over the '*' wildcard",
)
)
# Dubious-ownership shows up on any git invocation's stderr.
rc, _out, err = run_git(repo, "rev-parse", "--is-inside-work-tree")
if "dubious ownership" in err.lower():
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "AUDITABILITY", "high",
"dubious-ownership",
"git refuses the repo: detected dubious ownership",
"fix ownership (chown) OR add a SCOPED "
"'safe.directory <path>' — avoid the wildcard, it disables "
"the safety check everywhere",
)
)
def check_branch_state(rep: RepoReport) -> None:
if not rep.is_git:
return
repo = Path(rep.path)
rc, head, _ = run_git(repo, "symbolic-ref", "--short", "HEAD")
if rc == 0:
rep.branch = head
else:
rc2, desc, _ = run_git(repo, "rev-parse", "--short", "HEAD")
rep.branch = f"(detached @ {desc})" if rc2 == 0 else "(unknown)"
# zero-commit repo (everything untracked)
rc, count, _ = run_git(repo, "rev-list", "--count", "HEAD")
if rc != 0:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "AUDITABILITY", "high",
"no-commits",
"git repo with no commits yet (everything untracked)",
"make an initial commit pass, then audit docs",
)
)
return # nothing more to inspect on an empty repo
# default-branch naming: prefer main over master
_, branches_raw, _ = run_git(repo, "branch", "--format=%(refname:short)")
branches = [b.strip() for b in branches_raw.splitlines() if b.strip()]
if "master" in branches and "main" not in branches:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "GIT_STATE", "medium",
"master-default",
f"default branch is 'master' (preferred: "
f"'{PREFERRED_DEFAULT_BRANCH}')",
"git branch -m master main (+ update remote default if pushed)",
)
)
# leftover docs-audit branches from the one-time sweep
audit_branches = [b for b in branches if b.startswith("docs-audit")]
if audit_branches:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "GIT_STATE", "low",
"docs-audit-branch",
f"leftover audit branch(es): {', '.join(audit_branches)}",
"merge/push then delete once Travis has reviewed",
)
)
# dirty working tree
_, porcelain, _ = run_git(repo, "status", "--porcelain")
dirty = [ln for ln in porcelain.splitlines() if ln.strip()]
if dirty:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "GIT_STATE", "low",
"dirty-tree",
f"{len(dirty)} uncommitted change(s) in the working tree",
"commit / stash before auditing or sequencing a doc commit",
)
)
# stray worktrees
_, wt_raw, _ = run_git(repo, "worktree", "list", "--porcelain")
worktrees = [ln for ln in wt_raw.splitlines() if ln.startswith("worktree ")]
if len(worktrees) > 1:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "GIT_STATE", "info",
"extra-worktrees",
f"{len(worktrees)} worktrees attached",
"git worktree prune / remove if any are stale",
)
)
# unpushed / no-upstream
rc_up, _, _ = run_git(repo, "rev-parse", "--abbrev-ref", "@{u}")
if rc_up != 0:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "GIT_STATE", "info",
"no-upstream",
f"branch '{rep.branch}' has no upstream configured",
"expected for unpushed audit work; push when Travis is ready",
)
)
else:
_, ahead, _ = run_git(repo, "rev-list", "--count", "@{u}..HEAD")
if ahead.isdigit() and int(ahead) > 0:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "GIT_STATE", "info",
"unpushed-commits",
f"{ahead} commit(s) ahead of upstream (unpushed)",
"push when ready (manual for GitHub-destined repos)",
)
)
def _iter_env_files(repo: Path) -> list[Path]:
found: list[Path] = []
if not repo.is_dir():
return found
bases = [repo, *[d for d in repo.iterdir() if d.is_dir()]]
for base in bases:
if base.name in SKIP_NAMES:
continue
try:
for f in base.iterdir():
if not f.is_file():
continue
n = f.name
if n == ".env" or (n.startswith(".env.") and not n.endswith(
(".example", ".sample", ".template", ".dist")
)):
found.append(f)
except OSError:
continue
return found
def check_env_perms(rep: RepoReport) -> None:
for env in _iter_env_files(Path(rep.path)):
try:
st = env.stat()
except OSError:
continue
mode = st.st_mode & 0o777
grpname = group_of(env)
rel = env.relative_to(rep.path)
world_readable = bool(mode & 0o004)
if world_readable:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "SECURITY", "high",
f"env-world-readable:{rel}",
f"{rel} is world-readable (mode {oct(mode)})",
"chmod 0660 (or 0600) the .env file",
)
)
elif mode not in ENV_MODE_OK:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "SECURITY", "medium",
f"env-mode:{rel}",
f"{rel} mode is {oct(mode)} (target 0660 or 0600)",
"chmod 0660 the .env file",
)
)
if grpname not in (EXPECTED_GROUP, "?") and not world_readable:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "SECURITY", "low",
f"env-group:{rel}",
f"{rel} group is '{grpname}', expected '{EXPECTED_GROUP}'",
f"chgrp {EXPECTED_GROUP} the .env file",
)
)
def check_compose(rep: RepoReport) -> None:
repo = Path(rep.path)
legacy = list(repo.glob("docker-compose.y*ml"))
modern = list(repo.glob("compose.y*ml"))
if legacy and modern:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "CLEANUP", "medium",
"compose-rename-midflight",
"both docker-compose.yml and compose.yml present (rename "
"mid-flight)",
"git rm the legacy docker-compose.yml, keep compose.yml",
)
)
elif legacy:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "CLEANUP", "low",
"compose-legacy-name",
f"legacy '{legacy[0].name}' naming (prefer compose.yml)",
"git mv docker-compose.yml compose.yml",
)
)
# inline-secret heuristic across whatever compose files exist
for cf in legacy + modern:
try:
lines = cf.read_text(errors="replace").splitlines()
except OSError:
continue
hits: list[str] = []
for ln in lines:
stripped = ln.strip()
if not stripped or stripped.startswith("#"):
continue
# only key: value or key=value forms
m = re.match(r"-?\s*([A-Za-z0-9_]+)\s*[:=]\s*(.+)$", stripped)
if not m:
continue
key, val = m.group(1), m.group(2).strip().strip("\"'")
if not SECRET_KEY_RE.search(key):
continue
if "${" in val or val.startswith("$"):
continue # env reference — the right pattern
if PLACEHOLDER_RE.match(val):
continue
if SECRET_VAL_RE.match(val):
hits.append(key)
if hits:
uniq = sorted(set(hits))
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "SECURITY", "high",
f"inline-secret:{cf.name}",
f"{cf.name} appears to inline secret literal(s): "
f"{', '.join(uniq)}",
"extract to .env (gitignored); rotate if ever public",
)
)
def check_uv_stub(rep: RepoReport) -> None:
repo = Path(rep.path)
entry_stub = (repo / "main.py").is_file() or (repo / "hello.py").is_file()
has_pyproject = (repo / "pyproject.toml").is_file()
has_uv_residue = (repo / "uv.lock").is_file() or (
repo / ".python-version"
).is_file()
if not (entry_stub and has_pyproject and has_uv_residue):
return
# Real Python project? src/ layout or a package dir or many .py files.
if (repo / "src").is_dir():
return
py_files = [
p
for p in repo.rglob("*.py")
if SKIP_NAMES.isdisjoint(set(p.parts)) and not p.name.startswith("test")
]
if len(py_files) > 2:
return # looks like a genuine Python codebase
residue = [
n
for n in ("main.py", "hello.py", "pyproject.toml", "uv.lock",
".python-version")
if (repo / n).exists()
]
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "CLEANUP", "low",
"uv-init-stub",
"uv-init leftover stub in a non-Python repo: "
+ ", ".join(residue),
"sweep-remove the uv-init residue if it's not on the build path",
)
)
def check_stale_review_docs(rep: RepoReport) -> None:
repo = Path(rep.path)
stale: list[str] = []
for f in repo.iterdir() if repo.is_dir() else []:
if not f.is_file():
continue
n = f.name
if n == "GEMINI.md" or re.search(r"review.*\.md$", n, re.IGNORECASE):
if n.upper() in {"README.MD", "CLAUDE.MD"}:
continue
stale.append(n)
if stale:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "CLEANUP", "low",
"stale-review-doc",
"stray review artifact(s) at repo root: " + ", ".join(
sorted(stale)
),
"remove or move into a reviews/ subdir",
)
)
def check_herbygitea_residue(rep: RepoReport) -> None:
repo = Path(rep.path)
# This skill's own tree (script + docs) names the alias as a detection
# string; never let the audit flag itself.
skill_tree = Path(__file__).resolve().parents[2]
if repo.resolve() == skill_tree:
return
hits: list[str] = []
for p in repo.rglob("*"):
if not p.is_file():
continue
if not SKIP_NAMES.isdisjoint(set(p.parts)):
continue
if p.stat().st_size > 1_000_000:
continue
try:
text = p.read_text(errors="ignore")
except OSError:
continue
if "herbygitea" in text:
hits.append(str(p.relative_to(repo)))
if len(hits) >= 10:
break
if hits:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "CLEANUP", "medium",
"herbygitea-residue",
"references the removed 'herbygitea' SSH alias in: "
+ ", ".join(hits[:10]),
"update remotes/config/scripts to the canonical gitea host",
)
)
def check_docs(rep: RepoReport) -> None:
if not rep.is_git:
return # non-git dirs are handled by the auditability check first
repo = Path(rep.path)
has_readme = (repo / "README.md").is_file()
has_claude = (repo / "CLAUDE.md").is_file()
if not has_readme:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "DOCS", "medium",
"missing-readme",
"no README.md (human onboarding doc)",
"add a README — human onboarding per the doc-role split",
)
)
# docker compose stacks intentionally get README only, no CLAUDE.md.
if not has_claude and rep.domain not in COMPOSE_STACK_DOMAINS:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "DOCS", "medium",
"missing-claude",
"no CLAUDE.md (agent operating guide)",
"add a CLAUDE.md from the canonical skeleton",
)
)
# doc-role-split heuristic: an oversized CLAUDE.md is often a README clone.
if has_claude:
try:
n_lines = len(
(repo / "CLAUDE.md").read_text(errors="replace").splitlines()
)
except OSError:
n_lines = 0
if n_lines > 200:
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "DOCS", "low",
"claude-md-bloat",
f"CLAUDE.md is {n_lines} lines — may be a README clone "
"(role split: CLAUDE.md = concise agent guide)",
"trim to a gotcha-focused agent guide; move onboarding "
"prose to README",
)
)
def check_stray_dir(rep: RepoReport) -> None:
if STRAY_DIR_RE.search(rep.name):
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "CLEANUP", "low",
"stray-dir",
"looks like a backup/broken scratch directory",
"archive or delete once the live copy is confirmed good",
)
)
CHECKS = [
check_stray_dir,
check_git_and_ownership,
check_branch_state,
check_env_perms,
check_compose,
check_uv_stub,
check_stale_review_docs,
check_herbygitea_residue,
check_docs,
]
# --------------------------------------------------------------------------- #
# Orchestration
# --------------------------------------------------------------------------- #
def discover_repos(domains: dict[str, dict[str, list[str]]]) -> list[RepoReport]:
repos: list[RepoReport] = []
for domain, spec in domains.items():
for root in spec.get("scan", []):
root_p = Path(root)
if not root_p.is_dir():
continue
for child in sorted(root_p.iterdir()):
if not child.is_dir():
continue
if child.name in SKIP_NAMES or child.name.startswith("."):
continue
repos.append(RepoReport(domain, child.name, str(child)))
for repo_path in spec.get("repos", []):
p = Path(repo_path)
if p.is_dir():
repos.append(RepoReport(domain, p.name, str(p)))
return repos
def audit(domains: dict[str, dict[str, list[str]]]) -> list[RepoReport]:
reports = discover_repos(domains)
for rep in reports:
for check in CHECKS:
try:
check(rep)
except Exception as exc: # noqa: BLE001 — never let one repo abort the sweep
rep.findings.append(
Finding(
rep.domain, rep.name, rep.path, "DOCS", "info",
f"check-error:{check.__name__}",
f"check {check.__name__} raised: {exc}",
"audit.py bug — report it",
)
)
return reports
# --------------------------------------------------------------------------- #
# Reporting
# --------------------------------------------------------------------------- #
def all_findings(reports: list[RepoReport]) -> list[Finding]:
out: list[Finding] = []
for r in reports:
out.extend(r.findings)
out.sort(
key=lambda f: (
SEVERITY_ORDER.get(f.severity, 9),
f.category,
f.domain,
f.repo,
)
)
return out
def summarize(findings: list[Finding]) -> dict[str, dict[str, int]]:
by_sev: dict[str, int] = {}
by_cat: dict[str, int] = {}
for f in findings:
by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
by_cat[f.category] = by_cat.get(f.category, 0) + 1
return {"by_severity": by_sev, "by_category": by_cat}
def render_text(reports: list[RepoReport], findings: list[Finding]) -> str:
lines: list[str] = []
lines.append("repo-hygiene-audit — findings")
lines.append(f"host: {socket.gethostname()} scanned: {len(reports)} repos")
summary = summarize(findings)
sev = summary["by_severity"]
lines.append(
"severity: "
+ ", ".join(f"{k}={sev.get(k, 0)}" for k in ("high", "medium", "low", "info"))
+ f" total={len(findings)}"
)
lines.append("=" * 70)
# inventory line per repo (so clean repos are visible too)
lines.append("\nINVENTORY")
for r in sorted(reports, key=lambda x: (x.domain, x.name)):
flag = "" if r.is_git else " [NON-GIT]"
n = len(r.findings)
own = f"{r.owner}:{r.group}"
gown = f" git={r.git_owner}" if r.git_owner and r.git_owner != r.owner else ""
lines.append(
f" {r.domain:<8} {r.name:<32} {own:<22} "
f"branch={r.branch or '-':<14}{gown} findings={n}{flag}"
)
if not findings:
lines.append("\nNo findings. Trees are clean. ")
return "\n".join(lines)
for cat in ("SECURITY", "AUDITABILITY", "CLEANUP", "GIT_STATE", "DOCS"):
cat_f = [f for f in findings if f.category == cat]
if not cat_f:
continue
lines.append(f"\n{cat} ({CATEGORY_PHASE.get(cat, '')}) [{len(cat_f)}]")
lines.append("-" * 70)
for f in cat_f:
lines.append(f" [{f.severity:<6}] {f.domain}/{f.repo}: {f.detail}")
if f.hint:
lines.append(f" -> {f.hint}")
return "\n".join(lines)
def render_markdown(reports: list[RepoReport], findings: list[Finding]) -> str:
now = dt.datetime.now().strftime("%Y-%m-%d %H:%M")
out: list[str] = []
out.append("# Repo-hygiene audit findings")
out.append("")
out.append(f"_Generated {now} on {socket.gethostname()}"
f"{len(reports)} repos scanned._")
out.append("")
summary = summarize(findings)
sev = summary["by_severity"]
out.append(
"**Severity:** "
+ ", ".join(f"{k} {sev.get(k, 0)}" for k in ("high", "medium", "low", "info"))
+ f" (total {len(findings)})"
)
out.append("")
out.append("Findings are grouped by the remediation-plan phase they map to, "
"as `- [ ]` items so they paste straight into the project plan.")
out.append("")
for cat in ("SECURITY", "AUDITABILITY", "CLEANUP", "GIT_STATE", "DOCS"):
cat_f = [f for f in findings if f.category == cat]
if not cat_f:
continue
out.append(f"## {CATEGORY_PHASE.get(cat, cat)}")
out.append("")
for f in cat_f:
hint = f" — _{f.hint}_" if f.hint else ""
out.append(
f"- [ ] `{f.domain}/{f.repo}` ({f.severity}): {f.detail}{hint}"
)
out.append("")
out.append("## Inventory")
out.append("")
out.append("| Domain | Repo | Owner:Group | Git | Branch | Findings |")
out.append("|---|---|---|---|---|---|")
for r in sorted(reports, key=lambda x: (x.domain, x.name)):
out.append(
f"| {r.domain} | {r.name} | {r.owner}:{r.group} | "
f"{'yes' if r.is_git else 'NO'} | {r.branch or '-'} | "
f"{len(r.findings)} |"
)
out.append("")
return "\n".join(out)
def render_json(reports: list[RepoReport], findings: list[Finding]) -> str:
payload = {
"generated": dt.datetime.now().isoformat(timespec="seconds"),
"host": socket.gethostname(),
"repos_scanned": len(reports),
"summary": summarize(findings),
"inventory": [
{
"domain": r.domain,
"repo": r.name,
"path": r.path,
"is_git": r.is_git,
"owner": r.owner,
"git_owner": r.git_owner,
"group": r.group,
"branch": r.branch,
"finding_count": len(r.findings),
}
for r in reports
],
"findings": [asdict(f) | {"key": f.key} for f in findings],
}
return json.dumps(payload, indent=2)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def load_domains(args: argparse.Namespace) -> dict[str, dict[str, list[str]]]:
if args.config:
return json.loads(Path(args.config).read_text())
if args.scan or args.repo:
domains: dict[str, dict[str, list[str]]] = {}
for item in args.scan or []:
name, _, path = item.partition("=")
domains.setdefault(name or "custom", {}).setdefault("scan", []).append(
path or name
)
for item in args.repo or []:
name, _, path = item.partition("=")
domains.setdefault(name or "custom", {}).setdefault("repos", []).append(
path or name
)
return domains
return DEFAULT_DOMAINS
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(
description="Read-only repo hygiene audit across project domains."
)
ap.add_argument(
"--format", choices=("text", "json", "md"), default="text",
help="output format (default: text)",
)
ap.add_argument(
"--output", "-o", help="write report to this path (default: stdout)"
)
ap.add_argument(
"--config",
help="JSON file mapping domain -> {scan:[...], repos:[...]}",
)
ap.add_argument(
"--scan", action="append", metavar="NAME=PATH",
help="scan immediate children of PATH as repos (repeatable)",
)
ap.add_argument(
"--repo", action="append", metavar="NAME=PATH",
help="treat PATH itself as a repo (repeatable)",
)
ap.add_argument(
"--only", action="append", metavar="CATEGORY",
help="only emit these categories (SECURITY/AUDITABILITY/CLEANUP/"
"GIT_STATE/DOCS, repeatable)",
)
ap.add_argument(
"--fail-on", choices=("high", "medium", "low", "info"), default=None,
help="exit non-zero if any finding at/above this severity exists",
)
args = ap.parse_args(argv)
domains = load_domains(args)
reports = audit(domains)
findings = all_findings(reports)
if args.only:
wanted = {c.upper() for c in args.only}
findings = [f for f in findings if f.category in wanted]
renderers = {"text": render_text, "json": render_json, "md": render_markdown}
out = renderers[args.format](reports, findings)
if args.output:
Path(args.output).write_text(out + "\n")
print(f"wrote {args.format} report -> {args.output} "
f"({len(findings)} findings)", file=sys.stderr)
else:
print(out)
if args.fail_on:
threshold = SEVERITY_ORDER[args.fail_on]
if any(SEVERITY_ORDER.get(f.severity, 9) <= threshold for f in findings):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,39 @@
# Session — build repo-hygiene-audit skill (2026-05-22)
Phase 7 of repo-hygiene-remediation (trellis thread "dev-setup-and-hygiene" #272):
make the 2026-05-19/20 audit repeatable. Decisions going in: weekly cadence,
manual trigger, full skill with scripted checks.
## What was built
- New repo `pbs/repo-hygiene-audit-skill` following the repo-per-skill house
style (a-review-skill / session-notes-skill / Claude-Code-Scaffolding-Skill).
- `repo-hygiene-audit/SKILL.md` — trigger description + 5-step workflow over the
4 domains.
- `repo-hygiene-audit/scripts/audit.py` — pure-stdlib, read-only check engine.
- `references/check-catalog.md`, `references/findings-format.md`.
## Checks implemented
git auditability + .git ownership/dubious + group drift; `.env` perms (0660 /
devprojects target); inline compose secrets; non-git dirs with content;
uv-init stubs; docker-compose.yml→compose.yml naming; master-vs-main default;
leftover docs-audit branches; dirty trees; stray worktrees; unpushed/no-upstream;
herbygitea residue; stale review docs; missing/bloated docs.
## First-run results (39 repos, 4 domains)
high=11, medium=19, low=23, info=9 (total 62). Matched the known audit backlog:
root-owned non-git docker stacks (authelia/traefik), world-readable .env
(trellis-mcp, vault-mcp, ob1-deploy, python-uv .env.local), master defaults
(authentik, pbsii, zero-check-pipeline, docker-container, ssh-login-alerter,
hunyuan3d-sunnie, instamesh-docker), uv-init stubs (zero-check-pipeline,
wiki-context), GEMINI.md in work-index-dashboard, herbygitea residue in
workstation-ansible/CLAUDE.md + sshkm. Ruff clean.
## Open for Travis
- Remote: not chosen. Commit is local-only. Likely GitHub-destined (manual push).
- Second deliverable: canonical CLAUDE.md skeleton folded into
Claude-Code-Scaffolding-Skill (template only; not wired into scaffold.py).
- Cadence automation deferred (`--fail-on` is the hook when ready).