diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..de575d5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# Every change to this public repo is reviewed by Yusuf before merge. +# This is the human half of the contribution gate; the leak-scan workflow is the machine half. +# Keeps client data, pricing, and internal references out of public history. +* @itsyusufahmed diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..075bde6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## What this changes + + + +## Sanitization checklist + +> This repo is **public**. Everything merged here is world-readable, forever. +> Tick every box. If one doesn't apply, tick it and note why. + +- [ ] No **client or prospect names** (or logos, domains, or identifying details) +- [ ] No **pricing, margins, retainers, deal sizes, or GMV figures** +- [ ] No **staff names** or internal team references +- [ ] No **real call transcripts, prospect lists, or PII** — examples are generic/fabricated +- [ ] No **internal repo paths** or references into the private workspace's client, prospect, or team folders +- [ ] No **secrets / API keys** — config reads from env vars only +- [ ] `python utils/leak_scan.py` passes locally + +## Notes + + diff --git a/.github/workflows/leak-scan.yml b/.github/workflows/leak-scan.yml new file mode 100644 index 0000000..d73aaac --- /dev/null +++ b/.github/workflows/leak-scan.yml @@ -0,0 +1,52 @@ +name: leak-scan + +# Blocks any pricing, client/staff names, internal paths, or PII from entering +# this public repo. Gates the lines a change ADDS (not already-public history), +# so it must see the base ref — hence fetch-depth: 0. +# Make this a required status check on PRs to main (branch protection). + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Load denylist from secret + env: + LEAKSCAN_DENYLIST: ${{ secrets.LEAKSCAN_DENYLIST }} + run: | + if [ -n "$LEAKSCAN_DENYLIST" ]; then + printf '%s\n' "$LEAKSCAN_DENYLIST" > .leakscan-denylist.txt + echo "Denylist loaded." + else + echo "::warning::LEAKSCAN_DENYLIST secret not set — structural patterns only." + fi + + - name: Determine base ref + id: base + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" + elif [ -n "${{ github.event.before }}" ] && \ + [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT" + else + echo "ref=HEAD^" >> "$GITHUB_OUTPUT" + fi + + - name: Run leak scan (added lines only) + env: + CI: "true" + run: python utils/leak_scan.py --diff "${{ steps.base.outputs.ref }}" diff --git a/.gitignore b/.gitignore index 9eee0b1..c299a71 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,8 @@ out/ .claude/settings.local.json .claude/plans/ .claude/memory/ + +# Leak-scan denylist — real client/staff names live here, never committed. +# The committed .example version is the template; the real values also go in +# the LEAKSCAN_DENYLIST GitHub Actions secret. +.leakscan-denylist.txt diff --git a/.leakscan-denylist.example.txt b/.leakscan-denylist.example.txt new file mode 100644 index 0000000..4ac1ebb --- /dev/null +++ b/.leakscan-denylist.example.txt @@ -0,0 +1,19 @@ +# Leak-scan denylist — TEMPLATE (this file is committed; the real one is not) +# +# Copy this to .leakscan-denylist.txt (gitignored) for local scans, AND paste the +# same real values into the LEAKSCAN_DENYLIST GitHub Actions secret for CI. +# +# One literal string per line. Lines starting with # are comments. +# Matching is case-insensitive and word-boundary aware (a short name won't +# match inside a longer word). +# +# Put here anything that must NEVER appear in this public repo: +# - client and prospect names +# - staff names +# - product names / codenames tied to a specific account +# - any specific pricing figure you want hard-blocked +# +# Examples (replace with real values in the private copy): +# AcmeClientName +# SomeProspectName +# 12.34 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bb510c3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +These are the Zevenue GTM skills we use in production, published for anyone running outbound. Contributions from the Zevenue team and the community are welcome. + +## The one rule that matters + +**This repo is public. Treat everything in it as world-readable, forever.** + +Nothing here may contain client or prospect names, staff names, pricing, margins, retainers, deal sizes, real call data, internal file paths, or secrets. A skill is only ready for this repo once it is fully generic - it should read like a framework, not like an account. + +## Where to build (Zevenue team) + +Build and iterate skills in the **private** workspace first, where real client context is safe to work with. Port a skill here only once it's sanitized - the public version should carry the *method*, not the *account it was tuned on*. + +Practically: develop privately → strip everything account-specific → open a PR here with generic examples. + +## What stays out of this repo + +Keep these in the private workspace, not here: + +- Anything with client/prospect names, logos, or identifying details +- Anything with pricing, margins, retainers, deal sizes, or GMV figures +- Reply/messaging skills tuned to one named account +- Sales collateral or proposal generators with embedded deal data +- Bookkeeping, finance, or compensation workflows +- Real call transcripts, prospect lists, or any PII + +When in doubt, leave it out and ask Yusuf. + +## How to contribute + +1. **Branch** off `main` (`feat/skill-name` or `fix/...`). +2. **Build or edit** the skill under `.claude/skills//SKILL.md`. Keep the input/output contract explicit. Use generic, fabricated examples. +3. **Scan locally** before you push: + ```bash + python utils/leak_scan.py + ``` + Fix anything it flags. If a flagged line is genuinely safe, add a trailing `# leakscan:ignore`. +4. **Open a PR.** Fill in the sanitization checklist in the PR template. +5. The **leak-scan** check runs automatically and must pass. **Yusuf reviews** every PR (CODEOWNERS) before it merges. + +`main` is protected - changes land only through a reviewed PR. That review is the gate that keeps this repo clean. + +## Skill conventions + +- One skill per directory under `.claude/skills//`, defined in `SKILL.md`. +- State inputs and outputs explicitly; skills should be reproducible, not magic prompts. +- API-backed skills read credentials from environment variables (see `.env.example`) - never hardcode keys. +- If a skill needs a Python helper, put it in `utils/` and add deps to `requirements.txt`. + +## Questions + +Open an issue or reach Yusuf at yusuf@zevenue.com. diff --git a/utils/leak_scan.py b/utils/leak_scan.py new file mode 100644 index 0000000..0019092 --- /dev/null +++ b/utils/leak_scan.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Leak scanner for the public gtm-skills repo. + +This repo is PUBLIC. Nothing here may contain client/prospect names, staff names, +pricing, margins, retainers, internal file paths, or real call data. This script +is the machine gate that enforces that on every pull request (see +.github/workflows/leak-scan.yml) and can be run locally before you push. + +It gates what a change ADDS, not what is already public — so it scans added lines +(against a base ref) by default, not the whole repo's history. + +Two detection layers: + 1. Generic structural patterns (committed, safe to be public): money-in-pricing + context, Fathom share links, internal path/file references, raw emails. + 2. A denylist of literal strings (client names, staff names, specific figures) + loaded from a file that is NEVER committed. Locally that file is + .leakscan-denylist.txt (gitignored). In CI it is written from the + LEAKSCAN_DENYLIST repository secret. The sensitive strings live only there. + +Usage: + python utils/leak_scan.py # scan lines added vs origin/main + python utils/leak_scan.py --staged # scan staged added lines (pre-commit) + python utils/leak_scan.py --diff # scan lines added vs (CI) + python utils/leak_scan.py --all # audit every tracked file, wholesale + python utils/leak_scan.py path/a path/b # scan specific files, wholesale + +Suppress a known-safe line with a trailing comment: # leakscan:ignore +Exit code 0 = clean, 1 = findings. +""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# Files the scanner must never scan (they legitimately contain the patterns/terms). +SKIP = { + "utils/leak_scan.py", + ".leakscan-denylist.txt", + ".leakscan-denylist.example.txt", +} +SKIP_DIRS = (".git/",) +SKIP_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".pdf", ".ico", ".zip", ".pyc") + +# Emails allowed to appear (public contact, common example domains). +EMAIL_ALLOW_DOMAINS = { + "zevenue.com", "example.com", "example.org", "acme.com", + "prospect.com", "company.com", "stripe.com", "notion.so", +} + +MONEY_RE = re.compile(r"\$\s?\d[\d,]*(?:\.\d+)?\s?[kKmM]?\b") +# Money is only a leak when it sits next to pricing/deal language. Bare "per month" +# is intentionally excluded — these are outbound skills full of generic "$50/month +# subscriber" examples, and a gate that cries wolf gets ignored. Real monthly +# retainers are caught by the denylist (specific figures) + CODEOWNERS review. +PRICING_CONTEXT = re.compile( + r"per\s*lead|/\s*lead|per-lead|retainer|base\s*fee|margin|\bmrr\b|\barr\b|" + r"committed|cost\s*per|deal\s*size|upfront|\bgmv\b|per\s*request|/\s*request", + re.IGNORECASE, +) +FATHOM_RE = re.compile(r"fathom\.video/share/", re.IGNORECASE) +INTERNAL_PATH_RE = re.compile( + r"\b(?:internal/|context/clients/|context/prospects/|context/team/|past-clients/)", +) +INTERNAL_FILE_RE = re.compile( + r"\b(?:financials|strategy|staff|compensation)\.md\b", re.IGNORECASE +) +EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})") + + +def load_denylist(): + """Literal strings that must never appear. Loaded from a gitignored file.""" + path = Path(os.environ.get("LEAKSCAN_DENYLIST_FILE", ROOT / ".leakscan-denylist.txt")) + if not path.exists(): + return [] + terms = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#"): + terms.append(line) + # Word-boundary, case-insensitive — a short name won't match inside a longer word. + return [re.compile(r"(? 120 else line.strip() + print(f" {rel}:{n} [{kind}] {snippet}") + + print() + if total: + print(f"✗ leak-scan: {total} potential leak(s) found. " + f"Sanitize, or add '# leakscan:ignore' to a confirmed-safe line.") + return 1 + print("✓ leak-scan: clean.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())