diff --git a/.abcd/development/intents/drafts/itd-74-name-banlist.md b/.abcd/development/intents/drafts/itd-74-name-banlist.md new file mode 100644 index 00000000..d0e5c3c6 --- /dev/null +++ b/.abcd/development/intents/drafts/itd-74-name-banlist.md @@ -0,0 +1,39 @@ +--- +id: itd-74 +slug: name-banlist +spec_id: null +kind: standalone +suggested_kind: null +reclassification_history: [] +--- + +# abcd Keeps the Names You Ban Out of Everything You Publish + +## Press Release + +> **Name a thing once as off-limits; abcd keeps it out of every published surface — and keeps the truly private ones off the machine's commits entirely.** A repo abcd configures often must not name certain things in what it publishes: a specific agent harness (so the surface stays host-agnostic), a partner's product, or — most sensitively — a *private* project whose very name is confidential. abcd manages this as a two-layer banlist. The **public banlist** is enforced deterministically in CI: named tokens in user-facing content (README, `docs/`, the shipped artefact) fail the build, with a per-line escape hatch for the rare deliberate mention. The **private banlist** is enforced by a **local, untracked guard** — because a name that must never appear *anywhere public* cannot be written into public CI config to ban it there. Its patterns live only on the developer's machine; a pre-commit guard refuses to stage any content that matches, so the string never enters tracked history in the first place. +> +> "The names I can't afford to leak are exactly the ones I can't put in a public linter rule," said Dev, a maintainer. "abcd solved that by splitting it: public names get a CI gate, private names get a local guard whose list never leaves my machine. I stopped worrying that a stray paste would ship a name I'd promised to keep quiet." + +## Why This Matters + +Two failure modes share one root. First, a tool that claims to be *host-agnostic* undermines itself the moment its published docs name a specific harness — the naming dates the content and couples the surface. Second, and worse, a private collaborator's or project's name leaking into a public repo is a confidentiality breach that a history rewrite alone cannot fully undo (merged-PR diffs and cached views persist server-side). Both are cheap to prevent and expensive to remediate. The lesson learned the hard way on abcd's own repo: **prevent at authoring time, and never let the sensitive string reach a public artefact — including the linter config meant to catch it.** + +The design tension is the interesting part: a deterministic CI gate is the right tool for *public* banned names, but it is the *wrong* place for a *private* one, because the rule would have to contain the very string it forbids. Splitting enforcement by sensitivity — public names in CI, private names in a local untracked guard — resolves it without compromise. + +## What It Looks Like + +- **`abcd` manages both layers as first-class config.** A public banlist (patterns + per-token severity + the allow-context escape) compiles into the deterministic docs-currency lint family; a private banlist is scaffolded as an untracked, per-machine file plus a committed guard hook that reads it. The literal private strings never enter tracked content or CI config. +- **Wired into install.** `abcd ahoy` scaffolds both surfaces for any repo it configures: the public family in the docs-lint config, the guard hook in the repo's committed hooks, and a gitignored banlist stub with instructions. A repo becomes name-safe by being abcd-managed, not by hand-rolling hooks. +- **Verbs to maintain it.** Add, list, and remove banned patterns; the public ones are visible and reviewable, the private ones addressed by reference (never printed into a shared artefact). +- **Reports what it cannot enforce.** The private guard is local by construction, so abcd states plainly that CI cannot enforce it — the guard protects the machine that opted in, and the public flip is gated on a from-scratch name scan. + +## Dogfood (already running on this repo) + +The concrete prototype this intent generalises is live in abcd-cli itself: a `harness/*` banned-token family in the docs-currency lint (public agent-harness names, blocker, README + `docs/` scanned), a committed `.githooks/pre-commit` guard that reads an untracked banlist for the private name, and the gitignored banlist that holds it. The feature is to lift that from a hand-wired arrangement into an abcd capability every managed repo inherits. Relates to the host-agnostic documentation principle and to the install surface (`ahoy`). + +## Open Questions + +- Config shape: one banlist file with a public/private split, or two files with different visibility and tooling? +- Whether the public family belongs in the docs-lint config verbatim or in a dedicated `names` rule with richer reporting. +- How `ahoy` seeds the private banlist without ever suggesting example private strings that could themselves leak. diff --git a/.abcd/docs-lint.json b/.abcd/docs-lint.json index cc7b723d..3ff6f5b6 100644 --- a/.abcd/docs-lint.json +++ b/.abcd/docs-lint.json @@ -1,5 +1,5 @@ { - "roots": ["docs"], + "roots": ["docs", "README.md"], "banned_tokens": [ {"id":"present_tense/previously","pattern":"(?i)\\bpreviously\\b","severity":"blocker","allow_context":["(?i) if this legitimately describes an external/deprecated thing."}, {"id":"present_tense/formerly","pattern":"(?i)\\bformerly\\b","severity":"blocker","allow_context":["(?i) if this legitimately describes an external/deprecated thing."}, @@ -9,7 +9,15 @@ {"id":"present_tense/renamed-from","pattern":"(?i)\\brenamed from\\b","severity":"blocker","allow_context":["(?i) if this legitimately describes an external/deprecated thing."}, {"id":"present_tense/no-longer","pattern":"(?i)\\bno longer\\b","severity":"warn","allow_context":["(?i) if naming it is genuinely necessary."}, + {"id":"harness/codex","pattern":"(?i)\\bcodex\\b","severity":"blocker","allow_context":["(?i) if naming it is genuinely necessary."}, + {"id":"harness/gemini","pattern":"(?i)\\bgemini\\b","severity":"blocker","allow_context":["(?i) if naming it is genuinely necessary."}, + {"id":"harness/repoprompt","pattern":"(?i)\\brepo[ -]?prompt\\b","severity":"blocker","allow_context":["(?i) if naming it is genuinely necessary."}, + {"id":"harness/specstory","pattern":"(?i)\\bspecstory\\b","severity":"blocker","allow_context":["(?i) if naming it is genuinely necessary."}, + {"id":"harness/ralph","pattern":"(?i)\\bralph\\b","severity":"blocker","allow_context":["(?i) if naming it is genuinely necessary."}, + {"id":"harness/flow-next","pattern":"(?i)\\bflow-next\\b","severity":"blocker","allow_context":["(?i) if naming it is genuinely necessary."} ], "rules": { "links_resolve": {"enabled": true, "severity": "blocker"}, diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..6ec030b7 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Local name guard — run on every clone via the global ~/.githooks dispatcher +# (which chains the repo's committed .githooks/). +# +# Refuses to commit any name listed in an UNTRACKED banlist. The banlist lives +# only on this machine (.abcd/.work.local/ is gitignored), so a name that must +# never enter tracked or public content — e.g. a private repo/project name — is +# enforced here WITHOUT the name ever appearing in a committed file or in public +# CI config (which a repo may be, or become). No-op when the banlist is absent +# (fresh clones, CI), so it never blocks a machine that has not opted in. +# +# One pattern (extended-regex, case-insensitive) per line; blank lines and lines +# beginning with '#' are ignored. Companion to the public docs-lint harness-name +# gate: that one bans *public* harness names in CI; this one bans *private* names +# locally, keeping their literal string out of every published artifact. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +banlist=".abcd/.work.local/private-names.txt" +[ -f "$banlist" ] || exit 0 + +# Only added lines of staged content matter (skip the +++ file headers). +added="$(git diff --cached --unified=0 --diff-filter=ACM | grep -E '^\+' | grep -vE '^\+\+\+' || true)" +[ -n "$added" ] || exit 0 + +rc=0 +while IFS= read -r pat; do + case "$pat" in '' | \#*) continue ;; esac + if printf '%s\n' "$added" | grep -iqE -- "$pat"; then + echo "pre-commit: blocked — staged content matches a banned name (pattern in $banlist)." >&2 + echo " reword to a generic term before committing; the banlist is untracked and local." >&2 + rc=1 + fi +done < "$banlist" + +exit $rc diff --git a/internal/core/lint/lint_test.go b/internal/core/lint/lint_test.go index f31ee557..92e3ffe4 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -93,6 +93,40 @@ func TestBannedTokens(t *testing.T) { } } +// TestDocsLintHarnessNameGate guards the real .abcd/docs-lint.json harness-name +// family (the prevention gate): a specific agent-harness name in user-facing +// content is a blocker, and the docs-lint:allow comment on the same line +// suppresses it. Loading the actual config means deleting the family (or dropping +// its blocker severity) fails this test. +func TestDocsLintHarnessNameGate(t *testing.T) { + cfg, err := LoadConfig(filepath.Join("..", "..", "..", ".abcd", "docs-lint.json")) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + root := t.TempDir() + writeFile(t, root, "docs/named.md", "# t\n\nRun this in Claude Code.\n") + writeFile(t, root, "docs/allowed.md", "# t\n\n Claude Code is named deliberately.\n") + writeFile(t, root, "docs/clean.md", "# t\n\nUse the agent harness.\n") + + fs, err := Lint(cfg, root) + if err != nil { + t.Fatal(err) + } + if !hasFinding(fs, filepath.Join("docs", "named.md"), "harness/claude-code", 3) { + t.Fatalf("expected harness/claude-code blocker on docs/named.md:3: %+v", fs) + } + for _, f := range fs { + if f.RuleID == "harness/claude-code" { + if f.Severity != "blocker" { + t.Errorf("harness/claude-code severity = %q, want blocker", f.Severity) + } + if f.File != filepath.Join("docs", "named.md") { + t.Errorf("harness gate fired outside named.md (allow-context/clean leaked): %+v", f) + } + } + } +} + func TestNoGitMetadata(t *testing.T) { root := t.TempDir() writeFile(t, root, "rec/bad.md", "---\nid: x\nupdated: 2026-01-01\nauthor: someone\n---\n# Title\n")