security: verifiable release gates — every leak type, every artifact type - #48
Merged
Merged
Conversation
`plan.md` and `weekly_report.md` were tracked working notes, and setuptools' default sdist sweeps in top-level files and the whole test tree, so both — plus `tests/` and `.env.example` — were published in every release through 0.9.0. Wheels were never affected; this is the source archive only. `plans/` was never involved: it is gitignored, absent from `main`, and absent from the 0.9.0 sdist. It had been tracked historically and was removed long ago. - Untrack `plan.md` and `weekly_report.md`, and gitignore them. Anchored as `/plan.md` so the plan skill's `plans/<task>/plan.md` convention still works — an unanchored entry would ignore those too. - Add MANIFEST.in pruning tests, plans, templates, and the checked-in virtualenv, and excluding the internal top-level files. Sdist drops 550 KB -> 425 KB. - Add tests/unit/test_packaging_hygiene.py, which fails if internal material becomes tracked again or MANIFEST.in loses its exclusions. It immediately caught `weekly_report.md`, which I had missed. Nothing was force-pushed; both files were ordinary tracked files predating this work by a year.
`tests/unit/test_packaging_hygiene.py` asserts that MANIFEST.in has the right exclusions, but a rule is not an artifact. What actually ships depends on setuptools defaults, the build backend, and which files happen to be tracked — so the only reliable check is to open the archive. scripts/ci/check_sdist.py does two things: - rejects internal paths (top-level notes, tests/, plans/, templates/, the checked-in virtualenv, .env*, .secrets.baseline) - runs the deterministic credential gate over every text member The second matters because this repo published live credentials inside plan.md and src/louieai/_client.py for ~12 months across 16 releases. PyPI uploads are immutable and yanking does not delete files, so the last chance to catch that is immediately before upload. Wired into ci.yml's install-test job, which already runs `python -m build`, and into publish.yml before both the TestPyPI and PyPI steps. Verified against the real regression: with MANIFEST.in removed, the screener exits 1 and names .env.example, .secrets.baseline, templates/, test-env-310/, and the tests tree — the exact set 0.9.0 shipped. With it restored, clean.
…act types The artifact screen checked forbidden paths and Graphistry key shapes. An AWS key, a private key block, a keyword-adjacent value or a high-entropy string in a shipped file passed it. Nothing proved it rejected anything at all. Screen (scripts/ci/check_sdist.py): - add a detect-secrets sweep of the extracted archive, compared against .secrets.baseline, covering the generic classes the deterministic gate misses. - normalize archive paths back to repository paths before comparing. A wheel ships louieai/ where the repo has src/louieai/, and PKG-INFO / METADATA are generated from README.md; without the mapping every artifact finding looks new. Unrecognised paths are left alone, so they fail closed rather than being quietly mapped onto an allowlisted path. - report findings relative to the archive root, not /tmp/tmpXXXX/. detect-secrets enumerates files through git. In an extracted archive — not a repository — a bare `scan` exits 0 with valid JSON and zero findings regardless of contents. --all-files is mandatory there. That is the same shape as the defect this whole effort started from, so it gets its own regression test. Proof (tests/unit/security/test_release_artifact_gates.py, 28 cases): builds real .tar.gz and .whl archives with each leak type planted — Graphistry key id, key secret, internal hostname, AWS key, private key block, keyword-adjacent value, high-entropy string, internal-notes path, test-tree path — and asserts a non-zero exit for every combination. Controls: a clean archive of each type must pass, so "rejects everything" cannot pass for "catches everything"; and each sweep-caught leak is re-run with the sweep disabled and must then survive, so a rotted sweep cannot hide behind the literal gate. detect-secrets missing fails the module rather than skipping it. Wiring: install detect-secrets in every job that builds, and assert in test that each workflow screens at least as often as it runs `python -m build`. Verified end to end: real literals planted in src/louieai/__init__.py, built with `python -m build`, rejected in both the sdist and the wheel by both gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sWGq1dy2e7ayy3FnFEUpn
`_KEY_ID = "K3PQ7RTX2M"` reads to detect-secrets' KeywordDetector as a keyword/value pair, so the new test file tripped the repository's own secret gate. `_SHAPE_10` / `_SHAPE_16` describe what the fixtures are without the keyword. This slipped through locally because `detect-secrets scan` enumerates files through git: a full scan run before `git add` cannot see a new file. The pre-commit path materializes the git index and does catch it — as does CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sWGq1dy2e7ayy3FnFEUpn
`check_generic_secrets` returned "clean" when the baseline or the comparer was absent, and `main` skipped the literal gate entirely if the checker script was missing. Both are the original defect in a different costume: a gate reporting success because it could not run. Every unavailable prerequisite now fails the screen and names what is missing. Disabling the sweep for gate-attribution tests moves to an explicit `--no-sweep` flag, so "the sweep is off" is a deliberate argument rather than a side effect of a bad path. A test asserts a missing baseline exits 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sWGq1dy2e7ayy3FnFEUpn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was actually happening
Two different files, two different stories — worth separating, since the concern raised was about both:
main?plans/plan.mdc35e416(2025-08-02)weekly_report.mdplans/was never the problem. It was tracked historically, removed long ago, and is gitignored — verified absent from bothmainand the published 0.9.0 sdist.The root-level
plan.mdandweekly_report.mdare ordinary tracked files predating this work by a year. Nothing was force-pushed.They shipped because setuptools' default sdist sweeps in top-level files and the whole test tree unless a
MANIFEST.insays otherwise — sotests/and.env.examplewent out too. Wheels were never affected.Fix
plan.mdandweekly_report.md; gitignore both.Anchored as
/plan.md— an unanchored entry would also ignore the plan skill'splans/<task>/plan.mdfiles, which are meant to be creatable.MANIFEST.inpruningtests,plans,templates, the checked-intest-env-310virtualenv, and the internal top-level files.Enforcement
tests/unit/test_packaging_hygiene.pyfails if internal material becomes tracked again, or ifMANIFEST.inloses its exclusions, or if the.gitignoreentry is written unanchored.It earned its keep immediately: I wrote it expecting only
plan.md, and it caughtweekly_report.md, which I had missed. Nothing had been checking this, which is why it shipped for a year.Release gates: every leak type, every artifact type
MANIFEST.inis a rule, not an artifact. What actually ships is decided by setuptools defaults and which files happen to be tracked, so a clean repo can still build a dirty archive — which is howplan.mdreached PyPI sixteen times.scripts/ci/check_sdist.pyopens the archive that is about to be uploaded.Two surfaces, two gates:
scripts/ci/secret-detection.shscripts/ci/check_sdist.pyquality-checks, pre-commitpython -m build:install-test+ bothpublish.ymljobs.tar.gzand.whlThe screen was incomplete
It checked forbidden paths and Graphistry key shapes. An AWS key, a private key block, a keyword-adjacent value or a high-entropy string in a shipped file passed it. Now it also sweeps the extracted archive with detect-secrets, compared against
.secrets.baseline.Archive paths are normalized back to repository paths before comparison — a wheel ships
louieai/where the repo hassrc/louieai/, andPKG-INFO/METADATAare generated fromREADME.md; without the mapping every artifact finding looks new. Unrecognised paths are left alone, so they fail closed rather than being quietly mapped onto an allowlisted path.One trap, same shape as the original defect
detect-secrets scanenumerates files through git. In an extracted archive — not a repository — a barescanexits 0, emits valid JSON, and reports zero findings no matter what the archive contains:That is the same failure class as the gate that started this: a command that cannot fail.
--all-filesis mandatory here, and a regression test pins it.Proof that it rejects
tests/unit/security/test_release_artifact_gates.py— 28 cases, a full matrix. It builds real.tar.gzand.whlarchives with a real leak planted in each:*.grph.xyz)plan.md)tests/)Three controls keep the matrix honest:
Plus a wiring test: each workflow must screen at least as often as it runs
python -m build.Verified end to end
Real literals planted in
src/louieai/__init__.py, built withpython -m build, and rejected in both the sdist and the wheel by both gates:The current release artifacts screen clean.
Validation
638 passed, 5 skipped; lint, format, mypy, both secret gates,
uv lock --check, strict docs all pass.Takes effect next release; 0.9.0 on PyPI is unaffected and contains no credentials (verified before tagging).
🤖 Generated with Claude Code
https://claude.ai/code/session_01MyiG851jxkDkqB5u9gnpox