Skip to content
Merged
Show file tree
Hide file tree
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
39 changes: 39 additions & 0 deletions .abcd/development/intents/drafts/itd-74-name-banlist.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 10 additions & 2 deletions .abcd/docs-lint.json
Original file line number Diff line number Diff line change
@@ -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)<!--\\s*docs-lint:\\s*allow\\b"],"message":"change-narration in a doc body; docs state present reality only (git holds history). Rephrase, or add <!-- docs-lint: allow --> if this legitimately describes an external/deprecated thing."},
{"id":"present_tense/formerly","pattern":"(?i)\\bformerly\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"change-narration in a doc body; docs state present reality only (git holds history). Rephrase, or add <!-- docs-lint: allow --> if this legitimately describes an external/deprecated thing."},
Expand All @@ -9,7 +9,15 @@
{"id":"present_tense/renamed-from","pattern":"(?i)\\brenamed from\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"change-narration in a doc body; docs state present reality only (git holds history). Rephrase, or add <!-- docs-lint: allow --> if this legitimately describes an external/deprecated thing."},
{"id":"present_tense/no-longer","pattern":"(?i)\\bno longer\\b","severity":"warn","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"possible change-narration; if this states present reality (e.g. 'wait no longer than'), ignore, otherwise rephrase to present tense. Advisory (non-blocking) because it collides with legitimate present-state prose."},
{"id":"present_tense/deprecated","pattern":"(?i)\\bdeprecated\\b","severity":"warn","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"possible change-narration; documenting a currently-deprecated external API is present reality, so this is advisory (non-blocking). Rephrase if it narrates a change to abcd itself."},
{"id":"present_tense/migrated-from","pattern":"(?i)\\bmigrated from\\b","severity":"warn","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"possible change-narration; 'records migrated from X are validated' is present-state provenance, so this is advisory (non-blocking). Rephrase if it narrates a one-off migration event."}
{"id":"present_tense/migrated-from","pattern":"(?i)\\bmigrated from\\b","severity":"warn","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"possible change-narration; 'records migrated from X are validated' is present-state provenance, so this is advisory (non-blocking). Rephrase if it narrates a one-off migration event."},

{"id":"harness/claude-code","pattern":"(?i)\\bclaude[ -]?code\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"names a specific agent harness in user-facing content; abcd's published surface stays host-agnostic. Use a generic term (an MCP host, the agent harness, the plugin surface), or add <!-- docs-lint: allow --> if naming it is genuinely necessary."},
{"id":"harness/codex","pattern":"(?i)\\bcodex\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"names a specific agent harness in user-facing content; abcd's published surface stays host-agnostic. Use a generic term, or add <!-- docs-lint: allow --> if naming it is genuinely necessary."},
{"id":"harness/gemini","pattern":"(?i)\\bgemini\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"names a specific agent harness in user-facing content; abcd's published surface stays host-agnostic. Use a generic term, or add <!-- docs-lint: allow --> if naming it is genuinely necessary."},
{"id":"harness/repoprompt","pattern":"(?i)\\brepo[ -]?prompt\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"names a specific bundled tool in user-facing content; abcd's published surface stays host-agnostic. Use a generic term (an MCP oracle), or add <!-- docs-lint: allow --> if naming it is genuinely necessary."},
{"id":"harness/specstory","pattern":"(?i)\\bspecstory\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"names a specific bundled tool in user-facing content; abcd's published surface stays host-agnostic. Use a generic term (a hosted transcript service), or add <!-- docs-lint: allow --> if naming it is genuinely necessary."},
{"id":"harness/ralph","pattern":"(?i)\\bralph\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"names a specific bundled tool in user-facing content; abcd's published surface stays host-agnostic. Use a generic term (an autonomous run loop), or add <!-- docs-lint: allow --> if naming it is genuinely necessary."},
{"id":"harness/flow-next","pattern":"(?i)\\bflow-next\\b","severity":"blocker","allow_context":["(?i)<!--\\s*docs-lint:\\s*allow\\b"],"message":"names a specific bundled tool in user-facing content; abcd's published surface stays host-agnostic. Use a generic term (a spec/task backend), or add <!-- docs-lint: allow --> if naming it is genuinely necessary."}
],
"rules": {
"links_resolve": {"enabled": true, "severity": "blocker"},
Expand Down
36 changes: 36 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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/<name>).
#
# 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
34 changes: 34 additions & 0 deletions internal/core/lint/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<!-- docs-lint: allow --> 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")
Expand Down
Loading