skill-sanitizer
Scan a skill directory for things that must not leak publicly, BEFORE you share
or git push it. Reports BLOCK (hard-stop: real secrets, private keys, client
names, your own identity) and WARN (review: PII, local paths, internal hosts,
high-entropy strings, embedded service code).
When to use
- Before publishing / open-sourcing / zipping a skill to share.
- As a pre-push or CI gate over a skill folder.
- Auditing a folder someone handed you.
When NOT to use
- Not a runtime secret-rotation guard.
- Not a full-repo replacement for git-secrets/trufflehog (this is skill-folder scoped).
- It is report-only: it never modifies your files.
How to run
python scanner.py <skill_dir>
Common variants:
python scanner.py <skill_dir> --format json # pretty JSON to stdout
python scanner.py <skill_dir> --config .sanitizer.local.json
python scanner.py <skill_dir> --warn-ok # WARN-only => exit 0
python scanner.py <skill_dir> --no-entropy # disable the entropy backstop
The scanner prints a human report to stderr and, on the last stdout line, exactly:
RESULT_JSON={"version":"1.0","scanned_root":"...","findings":[{"file","line","category","severity","snippet","suggestion"}],"counts":{"block","warn","files_scanned"},"service_plugins_suggested":[...],"suppression":{"suppressed_files","ignore_patterns","dangerous_ignore_globs","ignore_file_suppressed_files","ignore_line_suppressed_lines","ignore_line_suppressed_findings"},"scan_errors":[...],"blocked":true|false,"exit_code":0|1|2|3}
snippet is always REDACTED (first 4 + last 2 chars) — the full secret is never
emitted to the report.
Exit codes
0clean (no BLOCK, no WARN) — safe to share. Also0for WARN-only with--warn-ok.1at least one BLOCK finding (secret / private key / client name / your own identity) — HARD FAIL pre-push.2no BLOCK but >=1 WARN (PII / path / host / entropy / plugin-split) — review consciously.3scan-integrity / usage error: bad path, malformed config, a path that could not be scanned (unreadable, permission, Windows MAX_PATH), OR a dangerous wholesale.sanitizerignoreglob (*,*.*,*.py,**, …) that could hide everything. Fail-closed — a wholesale ignore glob FAILS the gate by default so a naiveif exit==0: passCI check cannot be silently bypassed. Explicit named-file excludes (e.g. a skill ignoring its ownSKILL.md) stay0. An incomplete or integrity-compromised scan is never reported as clean.
Precedence when several apply: 1 (BLOCK) > 3 (scan-integrity) > 2 (WARN) > 0.
Fail-closed: any unreadable/binary file is byte-scanned for known-prefix BLOCK
patterns; UTF-16 (BOM or NUL-interleaved) files are decoded properly and
NUL-stripped before regex so they cannot bypass detection. The tool never
crashes and never silently passes.
How to read findings
The agent should:
- Locate the target skill dir.
- Load the local config if present (
.sanitizer.local.json). - Run the scanner.
- Present BLOCK findings FIRST (with redacted previews), then WARN.
- Propose the concrete fix printed with each finding.
Severities:
- BLOCK — known-prefix provider keys (
sk-,ghp_,AKIA…, etc.), private keys, secret-named env assignments with real values, connection strings with passwords, client/proper-noun dictionary terms, and your OWN auto-seeded identity (username/email/hostname/git name). Must be removed before sharing. - WARN — emails, phones, local machine paths, internal hosts/IPs,
high-entropy unknown strings, and
plugin-split:<service>advice.
Remediation guidance
- Secrets → remove and rotate the credential; reference it via an env var
(
os.environ[...]) or a.env.exampleplaceholder. Never commit the real value. - Local paths → replace
C:\Users\<you>//home/<you>with~,$HOME,%USERPROFILE%, or a<USER>/<project-root>token. - PII → replace personal emails with a reserved
example.comaddress; drop phone numbers, or allowlist your own contact in the local config. - Client / proper-noun names → genericize the name, or (if intentional and public) add it to your local allowlist.
- Service-specific code (
plugin-split) → the skill embeds code for a specific service (e.g. Jira). Extract that code into a dedicated<service>service plugin and depend on it, so this generic skill stays portable. Example: movejira/*logic into anatlassian-jiraplugin.
External local config (private dictionary — never committed)
The shipped skill ships ONLY generic patterns. Anything private (client names, project codenames, your own contact details) lives in an EXTERNAL, gitignored file that you keep locally:
.sanitizer.local.json # in the scanned dir, or any parent, or $HOME
The scanner walks up from the target dir to the filesystem root, then $HOME,
and loads the first .sanitizer.local.json it finds (cwd wins). The repo ships
ONLY .sanitizer.example.json as a template — the real local file must NEVER be
committed. Add this line to your .gitignore:
.sanitizer.local.json
Config shape (all keys optional, plain stdlib JSON):
{
"block_terms": ["Globex", "ProjectZephyr"],
"warn_terms": ["northwind"],
"client_names": ["AcmeCorp"],
"project_codenames": ["Bluebird"],
"own_username": "youruser",
"own_emails": ["you@example.com"],
"allowlist": { "emails": ["you@example.com"] },
"entropy": { "enabled": true, "min_len": 20, "base64_bits": 4.0, "hex_bits": 3.0 }
}
block_terms / client_names / project_codenames / own_username →
word-boundary, case-insensitive BLOCK. warn_terms → WARN.
Suppression channels (READ THIS BEFORE TRUSTING A CLEAN VERDICT)
The scanner has THREE ways findings can be hidden. A "clean" result is only
trustworthy once you have reviewed what, if anything, was suppressed. The
scanner ALWAYS reports suppression in both the human report (a SUPPRESSION
section) and RESULT_JSON (a "suppression" object), so the consuming agent
MUST read and relay it.
.sanitizerignore(gitignore-style globs at the scan root) — drops whole files before scanning. Counted assuppressed_files; the activeignore_patternsare echoed back. A dangerous wholesale glob (*,*.*,*.py,**, …) that could mask code prints a loudWARNINGAND forces a non-zero exit3(scan-integrity failure) — a.sanitizerignoreof*will never yield a silent green "Safe to share", and aif exit==0: passCI gate cannot be bypassed by it.# sanitizer: ignore-file— an ANCHORED standalone comment in the first ~10 lines of a file (the comment's content must START with the token; a bare substring buried in code/data does NOT count). Skips that whole file. Counted asignore_file_suppressed_files.# sanitizer: ignore-line— appended to a single line; drops all findings on that line. Counted asignore_line_suppressed_linesplusignore_line_suppressed_findings(how many findings were actually hidden) so a CI/agent can tell "genuinely clean" from "a directive hid something".
The consuming agent should:
- Read
RESULT_JSON.suppression(andscan_errors) BEFORE reporting "clean". - Treat any
dangerous_ignore_globs, nonzeroignore_line_suppressed_findings, or nonemptyscan_errorsas a reason to NOT report "safe to share" until the hidden content / unscanned paths have been reviewed. - Echo the suppression counts to the user alongside the verdict.
Unscanned paths / scan errors
If a path cannot be descended or read (Windows MAX_PATH, permissions), the
scanner does NOT report clean: it records the path under scan_errors, prints a
SCAN ERRORS section, and exits 3 (scan error) so the incompleteness is
surfaced. On Windows it retries with the \\?\ extended-length prefix first.
Self-clean guarantee (auto-seed + dogfood)
Even with NO config, the scanner derives your identity at runtime (in-memory
only, never written to disk) from $USERNAME/$USER, git config user.name/ user.email, and the hostname, and flags any of those as BLOCK — so your own
name/email/machine path is ALWAYS caught out of the box. The shipped skill
contains only generic regexes and reserved-example fixtures; running the
scanner on its own skill folder produces zero BLOCK findings (its own first
test / dogfood gate).