Two asks: a release cut, and two residual staging gaps the August hardening left open.
Ask 1 — cut a release
PyPI skillopt latest is 0.2.0, released 2026-07-02, with no release since (checked today; pyproject.toml at main still says 0.2.0). Since that wheel was built, main merged:
So pip install skillopt today installs an adopt() with none of #241's checks and a webui that binds 0.0.0.0 by default — while the fixes have been merged for a month. #117 (closed) already showed the practical cost of this exact lag: a wheel-content bug fixed at HEAD that kept biting every fresh install. A 0.2.1 / 0.3.0 cut would make the installable artifact match the hardened tree.
Ask 2 — two residual gaps at HEAD (main @ 79124b37, skillopt_sleep/staging.py blob 615c5ac8cf4c70ccb93e0283e8f7b1439890b6e2)
(a) the legacy SKILL/CLAUDE pair adopt still has no roots containment
_live_target_within_roots (L368 — its own docstring: "the manifest is not a trust boundary") is called on exactly one path: the per-skill adoption (L1508). The legacy-pair block at the bottom of the file (L~3058-3130) validates pins, symlinks/hardlinks, and realpath identity — but never checks the live target against the roots recorded when the night was staged, and _planned_live_directories (L3069) will create parent directories for an absent target at any absolute *.md path the manifest names. The per-skill path gained exactly this containment in #241; the legacy pair didn't. Suggested: re-derive legacy live targets from current config the way the per-skill path does, and run the same _live_target_within_roots check there.
(b) staging has no provenance marker — a staging tree that arrives with a cloned repo is indistinguishable from engine output
Staging lives at <project>/.skillopt-sleep/staging (L829) — project-relative. Nothing about a staging night records that the local engine produced it: the manifest format, file layout, and sha256 pins are all self-consistent and documented, and adopt verifies self-consistency only. #241's threat model (from the PR body) was locally generated, then tampered — "it needs local write access to the staging manifest". The repo-shipped vector needs no local tampering: a cloned project can carry a ready-made .skillopt-sleep/staging/<night>/ and a README/CONTRIBUTING line like "run skillopt-sleep adopt to apply our pre-staged tuned skills". On the current 0.2.0 wheel that is an arbitrary-destination write of repo-authored bytes (verified below); at HEAD the per-skill path confines it to the skills roots, but the legacy pair (gap a) does not.
Suggested: a provenance marker the repo cannot forge — a state-side registry of staged nights (outside the project, e.g. under the sleep state dir), or an HMAC of the manifest keyed by a sleep-state secret — so adopt rejects staging that did not originate from the local engine.
Reproduction on the current wheel (0.2.0)
A scratch clone containing only .skillopt-sleep/staging/20260920-120000/{manifest.json, proposed_SKILL.md, proposed_CLAUDE.md}, with the manifest's live_skill_path set to ../../../../../staged-outside-repo.md, then the product's own documented apply command in that directory:
$ skillopt-sleep adopt
[sleep] adopted from <clone>\.skillopt-sleep\staging\20260920-120000
-> ../../../../../staged-outside-repo.md
-> ./CLAUDE.md
Result: the repo-authored bytes land at the drive root (the overlong ../ chain clamps there on Windows — destination independent of clone depth and username), plus the clone's own CLAUDE.md is overwritten. With an absolute live_skill_path under <home>\.claude\skills\<name>\SKILL.md, the bytes land in the user-global skills root that Claude Code auto-loads in every future session, every project. The wheel's adopt() core (staging.py L137-161 in 0.2.0) is makedirs + shutil.copy2 on the manifest value — no path safety, no pins, no authorship check. At HEAD these specific writes are contained by #241 on the per-skill path (which is why the release cut matters first); the residual is gaps (a) and (b) above.
Happy to help with either a release checklist or a PR for the legacy containment + provenance marker.
Two asks: a release cut, and two residual staging gaps the August hardening left open.
Ask 1 — cut a release
PyPI
skilloptlatest is 0.2.0, released 2026-07-02, with no release since (checked today;pyproject.tomlatmainstill says0.2.0). Since that wheel was built,mainmerged:_safe_live_path, sha256 pins, realpath identity, symlink/junction rejection, per-skill roots containment,So
pip install skillopttoday installs anadopt()with none of #241's checks and a webui that binds0.0.0.0by default — while the fixes have been merged for a month. #117 (closed) already showed the practical cost of this exact lag: a wheel-content bug fixed at HEAD that kept biting every fresh install. A 0.2.1 / 0.3.0 cut would make the installable artifact match the hardened tree.Ask 2 — two residual gaps at HEAD (
main@79124b37,skillopt_sleep/staging.pyblob615c5ac8cf4c70ccb93e0283e8f7b1439890b6e2)(a) the legacy SKILL/CLAUDE pair adopt still has no roots containment
_live_target_within_roots(L368 — its own docstring: "the manifest is not a trust boundary") is called on exactly one path: the per-skill adoption (L1508). The legacy-pair block at the bottom of the file (L~3058-3130) validates pins, symlinks/hardlinks, and realpath identity — but never checks the live target against the roots recorded when the night was staged, and_planned_live_directories(L3069) will create parent directories for an absent target at any absolute*.mdpath the manifest names. The per-skill path gained exactly this containment in #241; the legacy pair didn't. Suggested: re-derive legacy live targets from current config the way the per-skill path does, and run the same_live_target_within_rootscheck there.(b) staging has no provenance marker — a staging tree that arrives with a cloned repo is indistinguishable from engine output
Staging lives at
<project>/.skillopt-sleep/staging(L829) — project-relative. Nothing about a staging night records that the local engine produced it: the manifest format, file layout, and sha256 pins are all self-consistent and documented, andadoptverifies self-consistency only. #241's threat model (from the PR body) was locally generated, then tampered — "it needs local write access to the staging manifest". The repo-shipped vector needs no local tampering: a cloned project can carry a ready-made.skillopt-sleep/staging/<night>/and a README/CONTRIBUTING line like "runskillopt-sleep adoptto apply our pre-staged tuned skills". On the current 0.2.0 wheel that is an arbitrary-destination write of repo-authored bytes (verified below); at HEAD the per-skill path confines it to the skills roots, but the legacy pair (gap a) does not.Suggested: a provenance marker the repo cannot forge — a state-side registry of staged nights (outside the project, e.g. under the sleep state dir), or an HMAC of the manifest keyed by a sleep-state secret — so
adoptrejects staging that did not originate from the local engine.Reproduction on the current wheel (0.2.0)
A scratch clone containing only
.skillopt-sleep/staging/20260920-120000/{manifest.json, proposed_SKILL.md, proposed_CLAUDE.md}, with the manifest'slive_skill_pathset to../../../../../staged-outside-repo.md, then the product's own documented apply command in that directory:Result: the repo-authored bytes land at the drive root (the overlong
../chain clamps there on Windows — destination independent of clone depth and username), plus the clone's ownCLAUDE.mdis overwritten. With an absolutelive_skill_pathunder<home>\.claude\skills\<name>\SKILL.md, the bytes land in the user-global skills root that Claude Code auto-loads in every future session, every project. The wheel'sadopt()core (staging.py L137-161 in 0.2.0) ismakedirs+shutil.copy2on the manifest value — no path safety, no pins, no authorship check. At HEAD these specific writes are contained by #241 on the per-skill path (which is why the release cut matters first); the residual is gaps (a) and (b) above.Happy to help with either a release checklist or a PR for the legacy containment + provenance marker.