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
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## What this changes

<!-- One or two lines. What skill/util/doc are you adding or editing, and why. -->

## 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

<!-- Anything the reviewer should know. Link the private gtme source if this was extracted from there. -->
52 changes: 52 additions & 0 deletions .github/workflows/leak-scan.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions .leakscan-denylist.example.txt
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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/<name>/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/<name>/`, 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.
202 changes: 202 additions & 0 deletions utils/leak_scan.py
Original file line number Diff line number Diff line change
@@ -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 <base> # scan lines added vs <base> (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"(?<![A-Za-z0-9])" + re.escape(t) + r"(?![A-Za-z0-9])", re.IGNORECASE)
for t in terms]


def _git(args):
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True)


def should_skip(rel):
return rel in SKIP or rel.startswith(SKIP_DIRS) or rel.endswith(SKIP_SUFFIXES)


def units_wholesale(paths):
"""Yield (path, lineno, line) for every line of each file."""
for rel in paths:
if should_skip(rel):
continue
p = ROOT / rel
if not p.exists():
continue
try:
text = p.read_text(encoding="utf-8")
except (UnicodeDecodeError, IsADirectoryError):
continue
for n, line in enumerate(text.splitlines(), 1):
yield rel, n, line


def units_from_diff(diff_args):
"""Yield (path, lineno, line) for ADDED lines in a `git diff` output."""
res = _git(["diff", "--unified=0", "--no-color", *diff_args])
if res.returncode != 0:
sys.stderr.write(f"note: `git diff {' '.join(diff_args)}` failed; "
f"falling back to --all.\n{res.stderr}")
yield from units_wholesale([l for l in _git(["ls-files"]).stdout.splitlines() if l])
return
rel, lineno = None, 0
for line in res.stdout.splitlines():
if line.startswith("+++ b/"):
rel = line[6:]
elif line.startswith("@@"):
m = re.search(r"\+(\d+)", line)
lineno = int(m.group(1)) if m else 0
elif line.startswith("+") and not line.startswith("+++"):
if rel and not should_skip(rel):
yield rel, lineno, line[1:]
lineno += 1


def select_units():
args = sys.argv[1:]
if "--all" in args:
return units_wholesale([l for l in _git(["ls-files"]).stdout.splitlines() if l])
if "--staged" in args:
return units_from_diff(["--cached"])
if "--diff" in args:
i = args.index("--diff")
base = args[i + 1] if i + 1 < len(args) else "origin/main"
return units_from_diff([base])
paths = [a for a in args if not a.startswith("--")]
if paths:
return units_wholesale(paths)
# Default: lines added vs the upstream main branch.
for base in ("origin/main", "main"):
if _git(["rev-parse", "--verify", "--quiet", base]).returncode == 0:
return units_from_diff([base])
sys.stderr.write("note: no origin/main or main ref found; auditing all tracked files.\n")
return units_wholesale([l for l in _git(["ls-files"]).stdout.splitlines() if l])


def find_in_line(line, denylist):
kinds = []
if MONEY_RE.search(line) and PRICING_CONTEXT.search(line):
kinds.append("pricing")
if FATHOM_RE.search(line):
kinds.append("fathom-link")
if INTERNAL_PATH_RE.search(line):
kinds.append("internal-path")
if INTERNAL_FILE_RE.search(line):
kinds.append("internal-file")
for m in EMAIL_RE.finditer(line):
if m.group(1).lower() not in EMAIL_ALLOW_DOMAINS:
kinds.append("email")
break
for rx in denylist:
if rx.search(line):
kinds.append("denylist")
break
return kinds


def main():
denylist = load_denylist()
if not denylist and os.environ.get("CI"):
sys.stderr.write("WARNING: no denylist loaded (LEAKSCAN_DENYLIST secret unset). "
"Structural patterns still active.\n")

total = 0
for rel, n, line in select_units():
if "leakscan:ignore" in line:
continue
for kind in find_in_line(line, denylist):
total += 1
snippet = (line.strip()[:120] + "…") if len(line.strip()) > 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())
Loading