From fh-meta
Scans git-tracked (public) files for operator-private tokens that should live only in gitignored files — real usernames, absolute home paths, companion-store names, company asset names. Reports file:line + matched token + severity, so a public/private split stays clean before publish. Triggered by "public surface audit", "did I leak anything", "check tracked files for private tokens", "private token scan", "public-surface-audit".
How this skill is triggered — by the user, by Claude, or both
Slash command
/fh-meta:public-surface-auditsonnetThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Scans the git-tracked file set (the public surface) for operator-private tokens that were supposed
Scans the git-tracked file set (the public surface) for operator-private tokens that were supposed
to stay in gitignored files (e.g. CLAUDE.local.md, companion store). After a public/private split,
a front-door fix is not enough — a leaked username or absolute home path anywhere in the tracked set
breaks the "public repo = model-agnostic methodology only" invariant.
While
marketplace-gateCheck 5 answers "is this repo broadly safe to publish?" (API keys, internal domains, license),public-surface-auditanswers a narrower question: "did any operator-private token survive the public/private split into a tracked file?" It scansgit ls-filesonly — gitignored files likeCLAUDE.local.mdare intentionally out of scope (they are the correct home for these tokens).
/public-surface-audit/public-surface-audit --target <repo path>/public-surface-audit --json (machine-parseable verdict for hook-gating — see Step 5)This skill scans only git ls-files (committed/staged tracked files). Gitignored files are
deliberately excluded — CLAUDE.local.md, the companion store, and local session data are the
correct home for operator-private tokens, so finding them there is not a leak.
REPO_PATH="${ARGUMENTS#--target }"
REPO_PATH="${REPO_PATH:-$(pwd)}"
git -C "$REPO_PATH" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| { echo "Not a git repo — public-surface-audit scans git-tracked files only. Aborting."; exit 1; }
echo "Target: $REPO_PATH"
git -C "$REPO_PATH" ls-files | wc -l | xargs echo "Tracked files:"
The patterns are themselves operator-private — your real username and employer name must not be
hardcoded here, on the public surface, or this skill would leak exactly what it hunts. So the literal
values live in a gitignored source you supply (.claude/rules/.public-surface-patterns, or a
section of CLAUDE.local.md) — one severity<TAB>regex per line. This SKILL.md carries only
placeholders; the scan reads the gitignored file, never literals from this table. The skill dogfoods
its own rule.
| # | Token class | Severity | Placeholder (real value goes in the gitignored source) | Why private |
|---|---|---|---|---|
| 1 | Real username | HIGH | <your-unix-username> | Personal identity — must not appear on public surface |
| 2 | Company / employer asset name | HIGH | <company-asset> (alternation OK) | Company-confidential, leak-forbidden |
| 3 | Operator absolute home path | MED | /Users/[a-z0-9_-]+/ (generic — carries no literal name) | Machine-bound, leaks username + local layout |
| 4 | Companion-store name | LOW | <companion-store-name> | Private companion store — methodology should not name it |
| 5 | Operator-private script / handoff name | LOW | <private-script>, <private-dir>/ | Operator-specific wiring, belongs in CLAUDE.local.md |
Severity meaning:
Setup: put your real values in the gitignored pattern source (one
severity<TAB>regexper line); the scan reads that file, never literals from this SKILL.md. If the source is absent, the scan reports NOT CONFIGURED — not CLEAN. A missing pattern file must never masquerade as a clean bill of health (that would be a silent failure: "nothing scanned" misread as "nothing leaked"). To declare "I genuinely have no private tokens", create the file empty — an empty file is an explicit CLEAN, an absent file is unconfigured.
Some tracked files legitimately reference otherwise-private tokens — the scan must not flag these as
leaks. Maintain an allowlist of file path :: token pairs. A match is suppressed only when both
the file and the token are on the allowlist row.
| Tracked file | Allowed tokens | Reason |
|---|---|---|
.gitignore | companion-store name, sync-script name | Must name what it ignores |
your sync script (e.g. scripts/<sync-script>) | companion-store name, operator-dir names, home path | The sync script's whole job is the companion store |
| an install-template rules file | home path, companion-store name | Install template — the install path is its content |
| a doc describing the companion-store pattern | the *-be pattern (no literal store name) | Documents the pattern generically |
Allowlist rule: a hit on file F matching token T is suppressed iff a row exists with file == F
and T in that row's allowed tokens. Everything else is reported. Keep the allowlist tight — when in
doubt, report and let the user confirm. Genuinely model-agnostic mentions (the *-be companion
pattern without the literal store name) should not require allowlisting because they do not match a
literal private token.
For each pattern in Step 1, grep the tracked set, then drop allowlisted hits.
cd "$REPO_PATH" || exit 1
# Build the tracked-file list once.
git ls-files > /tmp/_psa_tracked.txt
# Load your real patterns from the gitignored source (one "severity<TAB>regex" per line).
PATTERN_SRC="${PSA_PATTERNS:-.claude/rules/.public-surface-patterns}"
# Absent file ≠ CLEAN. An absent file is unconfigured (silent-failure risk); an EMPTY file is an
# explicit "no tokens to protect" → CLEAN. Distinguish the two.
[ -e "$PATTERN_SRC" ] || { echo "⚪ NOT CONFIGURED: no pattern source at $PATTERN_SRC. Create it (empty = explicit CLEAN) before trusting any verdict. Not scanning."; exit 2; }
# One grep pass per pattern row; the regex comes from the file, never hardcoded here.
while IFS=$'\t' read -r severity regex; do
[ -z "$regex" ] && continue
grep -nIE "$regex" $(cat /tmp/_psa_tracked.txt) 2>/dev/null | sed "s/^/[$severity] /"
done < "$PATTERN_SRC"
For each pattern, run grep -nIE "<regex>" $(git ls-files):
-n → line numbers (required for file:line output)-I → skip binary files-E → extended regex (alternation in the pattern table)Then remove any hit whose file + matched token is on the Step 2 allowlist. Do this for every
pattern row before producing the report — do not stop at the first HIT.
Binary / generated carve-out: -I already skips binaries. Additionally note (do not auto-suppress)
hits inside generated artifacts (e.g. paper/*.html exported from a private source) — these are real
leaks on the public surface and must be reported, but the fix is "regenerate from a sanitized source",
not "edit the HTML by hand". Flag them with a (generated artifact) note.
A scan that flags its own placeholders erodes trust. Two value-shape classes are never real leaks
and are dropped before the report — imported from gstack-redact's canonical-example allowlist, scoped
in PSA's direction to the matched token (not the whole line, so a real leak sharing a substring still
reports):
<your-unix-username>,
<company-asset>, {project}). PSA dogfoods these in Step 1; the scan must not report them as leaks.EXAMPLE, dummy,
changeme, REDACTED, xxxx, AWS-doc keys like AKIAIOSFODNN7EXAMPLE). A high-entropy example is
not a secret.# FP-hygiene tests the MATCHED TOKEN only — never the whole line. A line-level `grep -v` would
# suppress a real leak that merely *mentions* an example (e.g. `user=<realname> # see EXAMPLE.md`),
# violating PSA's "allowlist tight" rule. So extract the matched span per hit and drop it only when
# the span is *entirely* a placeholder/example (anchored ^…$).
PLACEHOLDER='^(<[a-z0-9_-]+>|\{project\}|EXAMPLE|dummy|changeme|REDACTED|xxxx)$'
grep -nIE "$regex" $(cat /tmp/_psa_tracked.txt) 2>/dev/null | while IFS= read -r hit; do
tok=$(printf '%s' "$hit" | grep -oiE "$regex" | head -1)
printf '%s' "$tok" | grep -qiE "$PLACEHOLDER" && continue # token IS a placeholder → drop
printf '%s\n' "$hit"
done
This differs from the Step 2 allowlist: Step 2 suppresses by file::token legitimacy, Step 3b by token value-shape. Both run — Step 2 then Step 3b. Keep it tight (PSA's "allowlist tight" rule): if a token only contains an example substring but is otherwise a real private value, it still reports.
public-surface-audit — Operator-Private Token Scan
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Target: {REPO_PATH} | Tracked files scanned: {N}
🔴 HIGH ({count})
{file}:{line} → {matched token} [class: username | company asset]
🟠 MED ({count})
{file}:{line} → {matched token} [class: absolute home path]
🟡 LOW ({count})
{file}:{line} → {matched token} [class: companion-store | private wiring]
Allowlist-suppressed: {count} hit(s) (legitimate references — not leaks)
Verdict:
⚪ NOT CONFIGURED — pattern source absent (nothing scanned — NOT a clean result; set up first)
🟢 CLEAN — pattern source present (incl. empty), 0 HIGH + 0 MED + 0 LOW (after allowlist)
🟡 REVIEW — 0 HIGH + 0 MED, LOW-only (drift, not a breach)
🔴 LEAK — 1+ HIGH or 1+ MED (block publish / fix before commit)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Per HIGH/MED hit, append a one-line prescription:
CLAUDE.local.md (or regenerate the artifact from a
sanitized source); never edit-in-place if it is a generated file.~-anchored path, or a {project}
placeholder; absolute home paths are also a portability bug for external clones.*-be companion pattern") instead of the literal name, unless the file is the
.gitignore / sync script that must name it (allowlist those).Simplification guard: 🟢 CLEAN → collapse the report to one line: "public surface clean — 0 private tokens in {N} tracked files (X allowlist-suppressed)." Do not print empty severity buckets.
--json) — Hook-Gateable VerdictBy default PSA prints the Step 4 human report. With --json, emit a machine-parseable verdict so a
pre-publish / pre-push hook can gate on counts mechanically — turning PSA from advisory into
enforceable (FH's "enforcement is a hook, not a prompt" principle). Imported from gstack-redact --json.
{
"target": "{REPO_PATH}",
"tracked_files": 0,
"findings": [
{"file": "path", "line": 42, "token": "<matched>", "severity": "HIGH", "class": "username"}
],
"counts": {"HIGH": 0, "MED": 0, "LOW": 0, "suppressed": 0},
"verdict": "CLEAN"
}
verdict is one of CLEAN | REVIEW | LEAK | NOT_CONFIGURED (same thresholds as Step 4). verdict is
authoritative — never gate on counts alone: a counts-only check (HIGH==0 && MED==0) misreads
NOT_CONFIGURED (which also has zero counts) as a pass. A caller blocks when verdict is LEAK or
NOT_CONFIGURED — an unconfigured scan is not a pass (the same silent-failure guard as the human path:
absence ≠ CLEAN).
| Situation | Connected skill |
|---|---|
| Broader pre-publish repo readiness (README, license, API keys) | /marketplace-gate (Check 5 Public Safety is the wide net; this skill is the private-token detail) |
| A leak is a recurring process gap, not a one-off | log via field-harvest → candidate #rule-candidate |
| Where should the leaked content actually live? | /asset-placement-gate (hub vs project vs CLAUDE.local.md) |
| Phantom refs / stale links on the same surface | /phantom-quench (forward axis — orthogonal to this leak axis) |
Usable standalone — no hub clone required.
.gitignore allowlist needs → Step 2 allowlist may be empty; every hit is then reported.Step 1 pattern list confirmed (defaults shown / user-adapted)
+ Step 2 allowlist applied
+ Step 3 scan run for every pattern over git ls-files (tracked only — gitignored excluded)
+ Step 4 report output: per-hit file:line + token + severity, plus overall verdict
+ "public-surface-audit Complete" declaration output
Verdict: CLEAN (0 tokens after allowlist) | REVIEW (LOW-only — drift, prescriptions noted) | LEAK (1+ HIGH or 1+ MED — block publish, prescriptions attached).
CLAUDE.local.md is correct
placement, not a leak — scanning it would produce false LEAK verdicts and erode trust in the skill.gstack-redact offers --auto-redact (rewrite + diff). PSA's
philosophy is report + prescribe; the human decides where the line goes — auto-redacting a HIGH
(username/company) hit would pre-empt that judgment, and auto-editing a generated artifact is explicitly
wrong (regenerate from source). If ever imported, restrict to the MED absolute-home-path class only
(mechanically safe: /Users/<user>/ → ~/ or {project}), never HIGH, never generated files.Step 3b (FP hygiene) and Step 5 (--json) were imported from garrytan/gstack gstack-redact
(lib/redact-engine.ts) during a hands-on sister-asset cross-audit (2026-06-06; see
tracks/_audit/session_2026_06_06_gstack_sister_handson.md). They are adapted to PSA's operator-IP
ontology — gstack-redact's generic secret/PII classes (AWS / PEM / JWT / hostname) stay out of PSA's
scope (orthogonal coverage: PSA = operator-IP leak, redact = generic secret). The reverse direction
(PSA's operator private-codename + bare-username classes, which gstack-redact structurally cannot
detect) is a candidate contribution back to gstack.
npx claudepluginhub chrono-meta/forge-harness --plugin fh-metaGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.