Skip to content

security: verifiable release gates — every leak type, every artifact type - #48

Merged
lmeyerov merged 5 commits into
mainfrom
chore/stop-shipping-internal-notes
Jul 26, 2026
Merged

lmeyerov merged 5 commits into
mainfrom
chore/stop-shipping-internal-notes

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

What was actually happening

Two different files, two different stories — worth separating, since the concern raised was about both:

tracked? in main? in 0.9.0 sdist?
plans/ no — gitignored 0 files 0 entries
plan.md yes, since c35e416 (2025-08-02) yes yes
weekly_report.md yes yes yes

plans/ was never the problem. It was tracked historically, removed long ago, and is gitignored — verified absent from both main and the published 0.9.0 sdist.

The root-level plan.md and weekly_report.md are 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.in says otherwise — so tests/ and .env.example went out too. Wheels were never affected.

Fix

  • Untrack plan.md and weekly_report.md; gitignore both.
    Anchored as /plan.md — an unanchored entry would also ignore the plan skill's plans/<task>/plan.md files, which are meant to be creatable.
  • MANIFEST.in pruning tests, plans, templates, the checked-in test-env-310 virtualenv, and the internal top-level files.
  • Sdist: 550 KB → 425 KB (23% smaller). Verified by building and listing the archive — zero matches for any internal path.

Enforcement

tests/unit/test_packaging_hygiene.py fails if internal material becomes tracked again, or if MANIFEST.in loses its exclusions, or if the .gitignore entry is written unanchored.

It earned its keep immediately: I wrote it expecting only plan.md, and it caught weekly_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.in is 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 how plan.md reached PyPI sixteen times. scripts/ci/check_sdist.py opens the archive that is about to be uploaded.

Two surfaces, two gates:

repository contents release artifacts
gate scripts/ci/secret-detection.sh scripts/ci/check_sdist.py
runs in quality-checks, pre-commit every job running python -m build: install-test + both publish.yml jobs
covers tracked / staged files what is inside the .tar.gz and .whl

The 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 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.

One trap, same shape as the original defect

detect-secrets scan enumerates files through git. In an extracted archive — not a repository — a bare scan exits 0, emits valid JSON, and reports zero findings no matter what the archive contains:

detect-secrets scan               -> 0 finding(s)
detect-secrets scan --all-files   -> 40 finding(s)

That is the same failure class as the gate that started this: a command that cannot fail. --all-files is 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.gz and .whl archives with a real leak planted in each:

leak type caught by
Graphistry personal key id literal gate
Graphistry personal key secret literal gate
internal hostname (*.grph.xyz) literal gate
AWS access key detect-secrets sweep
private key block detect-secrets sweep
keyword-adjacent value detect-secrets sweep
high-entropy string detect-secrets sweep
internal-notes path (plan.md) path list
test-tree path (tests/) path list

Three controls keep the matrix honest:

  1. A clean archive of each type must pass — otherwise "rejects everything" would satisfy every assertion above while blocking all releases.
  2. Attribution: each sweep-caught leak is re-run with the sweep disabled and must then survive. If an AWS key were only ever caught incidentally by the literal gate's shape matching, the sweep could rot away silently and the matrix would stay green.
  3. detect-secrets missing fails the module rather than skipping it. A skipped security test is indistinguishable from a passing one in CI output.

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 with python -m build, and rejected in both the sdist and the wheel by both gates:

louieai-…tar.gz: credential-shaped content:
  src/louieai/__init__.py:265: hard-coded personal_key_id literal [value-shape]
  src/louieai/__init__.py:266: hard-coded internal host literal [internal-host]
louieai-…tar.gz: detect-secrets findings:
  src/louieai/__init__.py:264: new secret detected [AWS Access Key]
… same for the .whl …
exit=1

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

lmeyerov and others added 5 commits July 26, 2026 05:20
`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
@lmeyerov lmeyerov changed the title chore: stop shipping internal notes in the sdist security: verifiable release gates — every leak type, every artifact type Jul 26, 2026
@lmeyerov
lmeyerov merged commit ddd9994 into main Jul 26, 2026
20 checks passed
@lmeyerov
lmeyerov deleted the chore/stop-shipping-internal-notes branch July 26, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant