super-skill-cli 0.10.0__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/.claude-plugin/marketplace.json +2 -2
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/.claude-plugin/plugin.json +1 -1
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/CHANGELOG.md +59 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/PKG-INFO +1 -1
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/pyproject.toml +1 -1
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/candidate.py +11 -5
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/capture.py +23 -4
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/cli.py +21 -13
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/evallite.py +6 -2
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/gate.py +33 -5
- super_skill_cli-0.11.0/super_skill/minestate.py +69 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/redact.py +29 -6
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/registry.py +35 -12
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/seed.py +6 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_candidate.py +21 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_capture.py +48 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_cli.py +37 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_evallite.py +13 -0
- super_skill_cli-0.11.0/tests/test_gate.py +120 -0
- super_skill_cli-0.11.0/tests/test_minestate.py +55 -0
- super_skill_cli-0.11.0/tests/test_redact.py +143 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_registry.py +42 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_seed.py +15 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/uv.lock +1 -1
- super_skill_cli-0.10.0/super_skill/minestate.py +0 -57
- super_skill_cli-0.10.0/tests/test_gate.py +0 -54
- super_skill_cli-0.10.0/tests/test_minestate.py +0 -48
- super_skill_cli-0.10.0/tests/test_redact.py +0 -71
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/.gitignore +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/LICENSE +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/README.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/README.zh-CN.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/codex/README.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/codex/install.sh +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/codex/skills/super-skill/SKILL.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/codex/skills/super-skill/agents/openai.yaml +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/candidates.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/doctor.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/mine.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/seed.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/status.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/README.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case1/PROMPT.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case1/broken.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case1/fixed.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case1/verify_test.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case2/PROMPT.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case2/broken.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case2/fixed.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case2/verify_test.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case3/PROMPT.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case3/broken.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case3/fixed.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case3/verify_test.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/gate2_report.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/grader.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/handwritten/SKILL.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/results.template.json +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/hooks/hooks.json +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/skills/super-skill/SKILL.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/__init__.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/config.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/doctor.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/hooks.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/mine.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/schemas.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/skillmd.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_codex_package.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_doctor.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_gate2_harness.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_hooks.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_plugin_manifest.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_skillmd.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_target_adapter.py +0 -0
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "super-skill: personal Agent-Skill package manager (versioned registry, rollback, mining).",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.11.0"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "super-skill",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Git-backed versioned registry for your Claude Code skills: seed import, explain/provenance, one-command rollback, integrity doctor, and opportunity mining. Wraps the `super-skill` CLI (pip/uv installable as super-skill-cli).",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.11.0",
|
|
17
17
|
"category": "development"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-skill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Personal Agent-Skill package manager: a git-backed, versioned registry for your Claude Code skills with seed import, provenance/explain, one-command rollback, integrity doctor, and opportunity mining from captured sessions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss",
|
|
@@ -3,6 +3,65 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); this project uses semantic versioning.
|
|
5
5
|
|
|
6
|
+
## [0.11.0] - 2026-07-12
|
|
7
|
+
|
|
8
|
+
Security & reliability hardening from a full production-readiness audit. All fixes
|
|
9
|
+
are backward-compatible; defaults unchanged. **Recommended upgrade** if you wire
|
|
10
|
+
live capture via `hooks-config` — the capture/redaction path had secret-leak and
|
|
11
|
+
crash-safety gaps that this release closes.
|
|
12
|
+
|
|
13
|
+
**Migration**: none required. An existing `mine_state.json` from 0.10.0 is auto-
|
|
14
|
+
handled (the watermark format changed to a session-id set; an old file reads as
|
|
15
|
+
"nothing mined" and self-heals on the next `mine`). **Revert path**: pin the prior
|
|
16
|
+
release with `pip install super-skill-cli==0.10.0`.
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
- **Redaction no longer leaks underscore-delimited env-var secrets** — `DB_PASSWORD=…`,
|
|
20
|
+
`AWS_SECRET_ACCESS_KEY=…`, `SECRET_KEY=…` etc. were written verbatim to the WAL
|
|
21
|
+
because the keyword rule anchored on `\b` (an underscore is a word char). The
|
|
22
|
+
same `redact_text` feeds the eval-lite secret gate, so this closed a leak in
|
|
23
|
+
both paths at once.
|
|
24
|
+
- **Redaction now matches current key formats** — OpenAI `sk-proj-`/`sk-svcacct-`,
|
|
25
|
+
Stripe `sk_live_`/`sk_test_`, GitHub fine-grained PATs `github_pat_`, and GCP
|
|
26
|
+
`AIza…` keys were previously unmatched.
|
|
27
|
+
- **The instruction-layer gate + eval-lite scan the full frontmatter**, not just
|
|
28
|
+
`description` + body — an injection or secret in any other frontmatter field
|
|
29
|
+
(e.g. `instructions:`, `metadata:`) previously shipped to the host unscanned.
|
|
30
|
+
- **The gate normalizes cheap obfuscation** (NFKC + zero-width strip + common
|
|
31
|
+
Cyrillic/Greek homoglyph fold) so `сurl … | bash` and homoglyph variants no
|
|
32
|
+
longer slip a shell pipe past the ASCII rules.
|
|
33
|
+
- **The capture WAL and mine watermark are gitignored** — `events/` and
|
|
34
|
+
`mine_state.json` are no longer swept into the registry's audit history by
|
|
35
|
+
`git add -A`, keeping (redacted-but-private) session content out of the repo.
|
|
36
|
+
- **Redaction depth is capped** so a pathologically nested payload can't
|
|
37
|
+
`RecursionError`; the over-deep subtree is dropped rather than leaked.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **`capture` never fails the host session (NFR-3)** — non-object JSON on stdin
|
|
41
|
+
and any WAL write error are now swallowed with exit 0; previously a list/scalar
|
|
42
|
+
payload raised and exited 1.
|
|
43
|
+
- **The WAL tolerates a torn/partial line** — a hook killed mid-append no longer
|
|
44
|
+
bricks every reader (`status`/`mine`/`count`); the bad line is skipped.
|
|
45
|
+
- **Concurrent captures no longer corrupt the WAL** — each event is one atomic
|
|
46
|
+
`O_APPEND` write instead of a buffered write that could interleave across
|
|
47
|
+
processes.
|
|
48
|
+
- **Registry/candidate/watermark writes are atomic** (temp + `os.replace`), and a
|
|
49
|
+
corrupt `meta.json`/`candidate.json` surfaces as a typed error instead of a raw
|
|
50
|
+
traceback that crashed `status`/`list`/`doctor`.
|
|
51
|
+
- **`approve` is crash-idempotent** — a crash between the registry commit and
|
|
52
|
+
marking the candidate approved no longer double-promotes on re-run.
|
|
53
|
+
- **`seed` skips a skill whose frontmatter `name` ≠ its directory name**, so two
|
|
54
|
+
host dirs sharing a name can no longer collapse into one version chain.
|
|
55
|
+
- **The `mine` reminder survives WAL TTL pruning** — the watermark tracks mined
|
|
56
|
+
session ids, so new sessions still count as "unmined" after old ones roll off.
|
|
57
|
+
- **`init` rebuilds a missing/stale `.gitignore`** even when `.git` already exists.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
- Docs `02`/`03`/`04` bumped to v1.4 with WS-vs-target implementation notes
|
|
61
|
+
(registry-as-source distribution, WS entity/eval-lite subsets) for doc↔code
|
|
62
|
+
consistency. Test suite grew 127 → 159; `gate.py` and `redact.py` at 100%
|
|
63
|
+
line coverage.
|
|
64
|
+
|
|
6
65
|
## [0.10.0] - 2026-07-12
|
|
7
66
|
|
|
8
67
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: super-skill-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Personal Agent-Skill package manager: versioned registry, seed import, explain, rollback (M0+WS scope).
|
|
5
5
|
Project-URL: Homepage, https://github.com/sdsrss/super-skill
|
|
6
6
|
Project-URL: Repository, https://github.com/sdsrss/super-skill
|
|
@@ -10,11 +10,12 @@ candidate -> gate (human approve) -> promote, never bypassed.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import os
|
|
13
14
|
import re
|
|
14
15
|
from datetime import datetime
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
17
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
18
19
|
|
|
19
20
|
from . import config
|
|
20
21
|
from .evallite import EvalError, eval_lite
|
|
@@ -95,7 +96,10 @@ class CandidateStore:
|
|
|
95
96
|
meta = self._cdir(cand_id) / "candidate.json"
|
|
96
97
|
if not meta.exists():
|
|
97
98
|
return None
|
|
98
|
-
|
|
99
|
+
try:
|
|
100
|
+
return Candidate.model_validate_json(meta.read_text(encoding="utf-8"))
|
|
101
|
+
except ValidationError as e:
|
|
102
|
+
raise CandidateError(f"{cand_id}: corrupt candidate.json ({e})") from e
|
|
99
103
|
|
|
100
104
|
def list(self) -> list[Candidate]:
|
|
101
105
|
if not self.dir.exists():
|
|
@@ -116,9 +120,11 @@ class CandidateStore:
|
|
|
116
120
|
def save(self, cand: Candidate) -> None:
|
|
117
121
|
cdir = self._cdir(cand.candidate_id)
|
|
118
122
|
cdir.mkdir(parents=True, exist_ok=True)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
123
|
+
# Atomic write so a crash can't leave a truncated candidate.json (M12).
|
|
124
|
+
p = cdir / "candidate.json"
|
|
125
|
+
tmp = p.with_suffix(".json.tmp")
|
|
126
|
+
tmp.write_text(cand.model_dump_json(indent=2), encoding="utf-8")
|
|
127
|
+
os.replace(tmp, p)
|
|
122
128
|
|
|
123
129
|
|
|
124
130
|
def draft_from_families(
|
|
@@ -7,10 +7,13 @@ events are TTL-bounded (FR-CAP-6); structured products live elsewhere.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import os
|
|
10
11
|
import uuid
|
|
11
12
|
from collections.abc import Iterator
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
14
17
|
from . import config
|
|
15
18
|
from .redact import redact_payload, redact_text
|
|
16
19
|
from .schemas import CaptureEvent, EventType
|
|
@@ -53,8 +56,15 @@ class EventLog:
|
|
|
53
56
|
day = event.timestamp.strftime("%Y-%m-%d")
|
|
54
57
|
out = self.events_dir / day / "events.jsonl"
|
|
55
58
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
# One atomic append per event: a single O_APPEND os.write, not a buffered
|
|
60
|
+
# writer that can split a large line into multiple write() calls and let
|
|
61
|
+
# two concurrent captures interleave into a corrupt line (H4).
|
|
62
|
+
data = (event.model_dump_json() + "\n").encode("utf-8")
|
|
63
|
+
fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
64
|
+
try:
|
|
65
|
+
os.write(fd, data)
|
|
66
|
+
finally:
|
|
67
|
+
os.close(fd)
|
|
58
68
|
return event
|
|
59
69
|
|
|
60
70
|
def iter_events(self) -> Iterator[CaptureEvent]:
|
|
@@ -66,11 +76,20 @@ class EventLog:
|
|
|
66
76
|
continue
|
|
67
77
|
for line in wal.read_text(encoding="utf-8").splitlines():
|
|
68
78
|
line = line.strip()
|
|
69
|
-
if line:
|
|
79
|
+
if not line:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
70
82
|
yield CaptureEvent.model_validate_json(line)
|
|
83
|
+
except ValidationError:
|
|
84
|
+
# A killed hook can leave a torn final line; one bad record
|
|
85
|
+
# must not brick every reader (status/mine/count). Skip it.
|
|
86
|
+
continue
|
|
71
87
|
|
|
72
88
|
def count(self) -> int:
|
|
73
89
|
return sum(1 for _ in self.iter_events())
|
|
74
90
|
|
|
91
|
+
def session_ids(self) -> set[str]:
|
|
92
|
+
return {ev.session_id for ev in self.iter_events()}
|
|
93
|
+
|
|
75
94
|
def distinct_sessions(self) -> int:
|
|
76
|
-
return len(
|
|
95
|
+
return len(self.session_ids())
|
|
@@ -99,35 +99,43 @@ def capture(
|
|
|
99
99
|
data = json.loads(raw) if raw.strip() else {}
|
|
100
100
|
except (json.JSONDecodeError, OSError):
|
|
101
101
|
raise typer.Exit(0) from None
|
|
102
|
-
|
|
102
|
+
# NFR-3: the hook must NEVER fail the host session. Everything past here —
|
|
103
|
+
# non-dict JSON (data.get would raise), an unknown event type, or any WAL
|
|
104
|
+
# write error — is swallowed and exits 0. Capture-never-fails beats
|
|
105
|
+
# capture-complete.
|
|
103
106
|
try:
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
if not isinstance(data, dict):
|
|
108
|
+
raise typer.Exit(0)
|
|
109
|
+
name = event_type or str(data.get("hook_event_name", ""))
|
|
110
|
+
etype = EventType(name) # ValueError on unknown type
|
|
111
|
+
session_id = str(data.get("session_id", "unknown"))
|
|
112
|
+
project_id = data.get("cwd")
|
|
113
|
+
EventLog().append(
|
|
114
|
+
etype, session_id, data, project_id=str(project_id) if project_id else None
|
|
115
|
+
)
|
|
116
|
+
except typer.Exit:
|
|
117
|
+
raise
|
|
118
|
+
except Exception:
|
|
106
119
|
raise typer.Exit(0) from None
|
|
107
|
-
session_id = str(data.get("session_id", "unknown"))
|
|
108
|
-
project_id = data.get("cwd")
|
|
109
|
-
EventLog().append(
|
|
110
|
-
etype, session_id, data, project_id=str(project_id) if project_id else None
|
|
111
|
-
)
|
|
112
120
|
|
|
113
121
|
|
|
114
122
|
@app.command()
|
|
115
123
|
def mine(min_sessions: int = typer.Option(3, "--min-sessions")) -> None:
|
|
116
124
|
"""Surface recurring task families from captured events (FR-GEN-1 signal)."""
|
|
117
125
|
log = EventLog()
|
|
126
|
+
session_ids = log.session_ids()
|
|
118
127
|
families = mine_families(log.iter_events(), min_sessions=min_sessions)
|
|
119
|
-
distinct = log.distinct_sessions()
|
|
120
128
|
if not families:
|
|
121
129
|
typer.echo(
|
|
122
130
|
f"no families recurring across >={min_sessions} sessions yet "
|
|
123
|
-
f"({
|
|
131
|
+
f"({len(session_ids)} distinct sessions captured)"
|
|
124
132
|
)
|
|
125
133
|
else:
|
|
126
134
|
typer.echo(f"{'sessions':>8} {'events':>6} family")
|
|
127
135
|
for fam in families:
|
|
128
136
|
typer.echo(f"{fam.session_count:>8} {fam.event_count:>6} {fam.label}")
|
|
129
137
|
# Mining acknowledges the accumulated sessions: reset the reminder watermark.
|
|
130
|
-
minestate.record_mined(log.root,
|
|
138
|
+
minestate.record_mined(log.root, session_ids)
|
|
131
139
|
|
|
132
140
|
|
|
133
141
|
@app.command()
|
|
@@ -149,7 +157,7 @@ def status() -> None:
|
|
|
149
157
|
log = EventLog(reg.root)
|
|
150
158
|
typer.echo(f"events : {log.count()}")
|
|
151
159
|
typer.echo(f"candidates : {len(cands)} ({breakdown})")
|
|
152
|
-
unmined = minestate.unmined(reg.root, log.
|
|
160
|
+
unmined = minestate.unmined(reg.root, log.session_ids())
|
|
153
161
|
if unmined >= minestate.reminder_threshold():
|
|
154
162
|
typer.echo(f"reminder : {unmined} distinct sessions unmined "
|
|
155
163
|
f"— run `super-skill mine`")
|
|
@@ -322,7 +330,7 @@ def candidate_draft(min_sessions: int = typer.Option(3, "--min-sessions")) -> No
|
|
|
322
330
|
store = CandidateStore(config.state_root())
|
|
323
331
|
created = draft_from_families(store, families)
|
|
324
332
|
# Drafting acts on the mining, so it too clears the status reminder.
|
|
325
|
-
minestate.record_mined(log.root, log.
|
|
333
|
+
minestate.record_mined(log.root, log.session_ids())
|
|
326
334
|
if not created:
|
|
327
335
|
typer.echo("no new candidates (nothing mined, or all already drafted)")
|
|
328
336
|
return
|
|
@@ -14,6 +14,7 @@ gate (§2.4bis) is a separate hard gate run first in ``candidate.approve``.
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import json
|
|
17
18
|
import re
|
|
18
19
|
from dataclasses import dataclass, field
|
|
19
20
|
|
|
@@ -72,8 +73,11 @@ def eval_lite(raw: str) -> EvalReport:
|
|
|
72
73
|
except SkillMdError as e:
|
|
73
74
|
return EvalReport(checks=[EvalCheck("schema", False, str(e))])
|
|
74
75
|
|
|
75
|
-
# 2. zero credential / PII leak in the instruction text (凭据与 PII 泄漏 = 0)
|
|
76
|
-
|
|
76
|
+
# 2. zero credential / PII leak in the instruction text (凭据与 PII 泄漏 = 0).
|
|
77
|
+
# Scan the FULL frontmatter (extra="allow" ships every field to the host, M6),
|
|
78
|
+
# not just description + body.
|
|
79
|
+
fm = json.dumps(parsed.frontmatter.model_dump(), default=str, ensure_ascii=False)
|
|
80
|
+
text = f"{fm}\n{parsed.body}"
|
|
77
81
|
_, counts = redact_text(text)
|
|
78
82
|
leaks = {k: c for k, c in counts.items() if k != "home_path"}
|
|
79
83
|
checks.append(
|
|
@@ -16,11 +16,31 @@ land at M1 — v1 candidates carry no scripts, so there is nothing to cross-chec
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import json
|
|
19
20
|
import re
|
|
21
|
+
import unicodedata
|
|
20
22
|
from dataclasses import dataclass
|
|
21
23
|
|
|
22
24
|
from .skillmd import parse
|
|
23
25
|
|
|
26
|
+
# Cheap-obfuscation defenses (M7): strip zero-width/format chars and fold the
|
|
27
|
+
# common Cyrillic/Greek homoglyphs so ``сurl`` (ZWSP) and ``сurl`` (Cyrillic es)
|
|
28
|
+
# can't slip a shell pipe past the ASCII regexes. Base64-encoded payloads remain
|
|
29
|
+
# out of scope until the M1 LLM-judge layer.
|
|
30
|
+
_ZERO_WIDTH = dict.fromkeys(map(ord, ""), None)
|
|
31
|
+
_CONFUSABLES = str.maketrans({
|
|
32
|
+
"а": "a", "b": "b", "с": "c", "ԁ": "d", "е": "e", "ɡ": "g", "һ": "h", "і": "i",
|
|
33
|
+
"ј": "j", "ⅼ": "l", "ո": "n", "о": "o", "р": "p", "ѕ": "s", "т": "t", "υ": "u",
|
|
34
|
+
"ν": "v", "х": "x", "у": "y", "α": "a", "ε": "e", "ο": "o", "ρ": "p", "κ": "k",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize(text: str) -> str:
|
|
39
|
+
"""Fold cheap obfuscations before pattern-matching."""
|
|
40
|
+
text = unicodedata.normalize("NFKC", text)
|
|
41
|
+
text = text.translate(_ZERO_WIDTH)
|
|
42
|
+
return text.translate(_CONFUSABLES)
|
|
43
|
+
|
|
24
44
|
# (category, pattern). Case-insensitive. Ordered most-severe first.
|
|
25
45
|
_RULES: list[tuple[str, re.Pattern[str]]] = [
|
|
26
46
|
(
|
|
@@ -84,6 +104,7 @@ class InstructionGateError(RuntimeError):
|
|
|
84
104
|
|
|
85
105
|
def scan_text(text: str, location: str) -> list[Finding]:
|
|
86
106
|
findings: list[Finding] = []
|
|
107
|
+
text = _normalize(text)
|
|
87
108
|
for category, pat in _RULES:
|
|
88
109
|
for m in pat.finditer(text):
|
|
89
110
|
snippet = " ".join(m.group(0).split())[:80]
|
|
@@ -92,11 +113,18 @@ def scan_text(text: str, location: str) -> list[Finding]:
|
|
|
92
113
|
|
|
93
114
|
|
|
94
115
|
def scan_skill_md(raw: str) -> list[Finding]:
|
|
95
|
-
"""Scan a SKILL.md's
|
|
116
|
+
"""Scan a SKILL.md's frontmatter + body for external-action imperatives.
|
|
96
117
|
|
|
97
118
|
description is scanned separately: §2.4bis forbids imperative / second-person
|
|
98
|
-
external-action language there (it would both win routing and inject).
|
|
119
|
+
external-action language there (it would both win routing and inject). Every
|
|
120
|
+
OTHER frontmatter field is scanned too (M6): ``extra="allow"`` means fields
|
|
121
|
+
like ``instructions:`` / ``metadata:`` ship verbatim to the host, so they
|
|
122
|
+
need the same scrutiny — scanning only description + body left them a bypass."""
|
|
99
123
|
parsed = parse(raw)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
124
|
+
findings = scan_text(parsed.frontmatter.description, "description")
|
|
125
|
+
fm = parsed.frontmatter.model_dump()
|
|
126
|
+
# name is NAME_RE-constrained (safe); description already scanned above.
|
|
127
|
+
extra = {k: v for k, v in fm.items() if k not in ("name", "description") and v is not None}
|
|
128
|
+
if extra:
|
|
129
|
+
findings += scan_text(json.dumps(extra, default=str, ensure_ascii=False), "frontmatter")
|
|
130
|
+
return findings + scan_text(parsed.body, "body")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Mine watermark (D#67): remember WHICH distinct sessions had been mined last,
|
|
2
|
+
so ``status``/``mine`` can nudge once enough new sessions accumulate — "you
|
|
3
|
+
solved X across N unmined sessions, run mine".
|
|
4
|
+
|
|
5
|
+
It stores the SET of mined session ids, not a count (M13): the WAL is TTL-bounded,
|
|
6
|
+
so an absolute count went silently wrong once old sessions rolled off — new
|
|
7
|
+
sessions could no longer exceed the stale high-water count. A set is robust:
|
|
8
|
+
unmined = current session ids not in the mined set.
|
|
9
|
+
|
|
10
|
+
Deliberately tiny: one JSON file in the state root, overwritten (never appended)
|
|
11
|
+
each mine. Missing/corrupt reads as the empty set so a fresh or hand-broken state
|
|
12
|
+
degrades to "everything is unmined" rather than crashing a read-only status.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
_FILE = "mine_state.json"
|
|
24
|
+
_DEFAULT_THRESHOLD = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reminder_threshold() -> int:
|
|
28
|
+
"""Distinct unmined sessions before status nudges (env-overridable)."""
|
|
29
|
+
raw = os.environ.get("SUPER_SKILL_MINE_REMINDER")
|
|
30
|
+
if raw is None:
|
|
31
|
+
return _DEFAULT_THRESHOLD
|
|
32
|
+
try:
|
|
33
|
+
return int(raw)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return _DEFAULT_THRESHOLD
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _path(root: Path) -> Path:
|
|
39
|
+
return root / _FILE
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mined_sessions(root: Path) -> set[str]:
|
|
43
|
+
"""Set of session ids mined last (empty set if absent/corrupt)."""
|
|
44
|
+
p = _path(root)
|
|
45
|
+
if not p.exists():
|
|
46
|
+
return set()
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
49
|
+
return set(data["mined_session_ids"])
|
|
50
|
+
except (ValueError, KeyError, TypeError):
|
|
51
|
+
return set()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def record_mined(root: Path, session_ids: Iterable[str]) -> None:
|
|
55
|
+
"""Persist the set of session ids seen at this mine as the new watermark."""
|
|
56
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
p = _path(root)
|
|
58
|
+
# Atomic write so a crash can't leave a truncated watermark (M12); a corrupt
|
|
59
|
+
# read already degrades to empty, but atomicity keeps the good value intact.
|
|
60
|
+
fd, tmp = tempfile.mkstemp(dir=root, suffix=".tmp")
|
|
61
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
62
|
+
json.dump({"mined_session_ids": sorted(set(session_ids))}, f)
|
|
63
|
+
os.replace(tmp, p)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def unmined(root: Path, current_sessions: Iterable[str]) -> int:
|
|
67
|
+
"""How many currently-captured sessions have not been mined yet. Robust to
|
|
68
|
+
WAL TTL pruning: pruned-and-mined sessions simply don't appear in current."""
|
|
69
|
+
return len(set(current_sessions) - mined_sessions(root))
|
|
@@ -24,14 +24,24 @@ _PATTERNS: list[tuple[str, re.Pattern[str], int]] = [
|
|
|
24
24
|
re.DOTALL,
|
|
25
25
|
), 0),
|
|
26
26
|
("anthropic_key", re.compile(r"sk-ant-[A-Za-z0-9_-]{16,}"), 0),
|
|
27
|
-
(
|
|
27
|
+
# OpenAI legacy (sk-…) + prefixed project/service keys (sk-proj-…, sk-svcacct-…).
|
|
28
|
+
("openai_key", re.compile(r"sk-(?:[a-z]+-)?[A-Za-z0-9]{20,}"), 0),
|
|
29
|
+
("stripe_key", re.compile(r"sk_(?:live|test)_[A-Za-z0-9]{16,}"), 0),
|
|
30
|
+
("github_pat", re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), 0),
|
|
28
31
|
("github_token", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), 0),
|
|
29
32
|
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}"), 0),
|
|
33
|
+
("gcp_key", re.compile(r"AIza[0-9A-Za-z_-]{35}"), 0),
|
|
30
34
|
("slack_token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), 0),
|
|
31
35
|
("bearer_token", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*"), 0),
|
|
36
|
+
# Match a full identifier token that CONTAINS a sensitive word, up to the
|
|
37
|
+
# assignment. A ``\b`` left-anchor missed ``DB_PASSWORD`` (``_`` is a word
|
|
38
|
+
# char) and a bare keyword missed ``SECRET_KEY`` (the ``_KEY`` suffix broke
|
|
39
|
+
# ``[:=]`` adjacency) — H1. The lookbehind + surrounding ``[A-Za-z0-9_]*``
|
|
40
|
+
# admit the keyword anywhere inside an env-var-shaped name.
|
|
32
41
|
("assigned_secret", re.compile(
|
|
33
|
-
r"(?i)
|
|
34
|
-
r"
|
|
42
|
+
r"(?i)(?<![A-Za-z0-9])[A-Za-z0-9_]*"
|
|
43
|
+
r"(?:api[_-]?key|access[_-]?key|secret|password|passwd|pwd|token)"
|
|
44
|
+
r"[A-Za-z0-9_]*\s*[:=]\s*['\"]?([^\s'\"]{6,})",
|
|
35
45
|
), 1),
|
|
36
46
|
("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), 0),
|
|
37
47
|
]
|
|
@@ -66,10 +76,23 @@ def redact_text(text: str) -> tuple[str, dict[str, int]]:
|
|
|
66
76
|
return text, counts
|
|
67
77
|
|
|
68
78
|
|
|
69
|
-
|
|
79
|
+
# Recursion cap: a pathologically nested payload must not RecursionError (which
|
|
80
|
+
# would blow past capture's guard). Well under CPython's ~1000-frame limit.
|
|
81
|
+
_MAX_DEPTH = 60
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def redact_payload(
|
|
85
|
+
obj: Any, _path: str = "", _depth: int = 0
|
|
86
|
+
) -> tuple[Any, list[RedactionMark]]:
|
|
70
87
|
"""Recursively redact all string leaves in a JSON-like structure, collecting
|
|
71
88
|
marks that record kind + dotted field path (never the value)."""
|
|
72
89
|
marks: list[RedactionMark] = []
|
|
90
|
+
if _depth > _MAX_DEPTH:
|
|
91
|
+
# Drop (do not stringify — str() of a deep subtree would itself recurse)
|
|
92
|
+
# the over-deep subtree; safer to lose it than to leak or crash (L18).
|
|
93
|
+
return "[REDACTED:max_depth]", [
|
|
94
|
+
RedactionMark(kind="max_depth", location=_path or "(root)", count=1)
|
|
95
|
+
]
|
|
73
96
|
if isinstance(obj, str):
|
|
74
97
|
red, counts = redact_text(obj)
|
|
75
98
|
marks.extend(
|
|
@@ -81,14 +104,14 @@ def redact_payload(obj: Any, _path: str = "") -> tuple[Any, list[RedactionMark]]
|
|
|
81
104
|
out_d: dict[str, Any] = {}
|
|
82
105
|
for k, v in obj.items():
|
|
83
106
|
child = f"{_path}.{k}" if _path else str(k)
|
|
84
|
-
out_d[k], sub = redact_payload(v, child)
|
|
107
|
+
out_d[k], sub = redact_payload(v, child, _depth + 1)
|
|
85
108
|
marks.extend(sub)
|
|
86
109
|
return out_d, marks
|
|
87
110
|
if isinstance(obj, list):
|
|
88
111
|
out_l: list[Any] = []
|
|
89
112
|
for i, v in enumerate(obj):
|
|
90
113
|
child = f"{_path}[{i}]"
|
|
91
|
-
rv, sub = redact_payload(v, child)
|
|
114
|
+
rv, sub = redact_payload(v, child, _depth + 1)
|
|
92
115
|
out_l.append(rv)
|
|
93
116
|
marks.extend(sub)
|
|
94
117
|
return out_l, marks
|
|
@@ -9,10 +9,11 @@ without changing the entity shapes (docs/03 §3 M1, docs/02 §7.7).
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import os
|
|
12
13
|
import subprocess
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
16
17
|
|
|
17
18
|
from . import config
|
|
18
19
|
from .schemas import (
|
|
@@ -29,6 +30,11 @@ from .skillmd import content_hash, parse
|
|
|
29
30
|
|
|
30
31
|
_GIT_ID = ["-c", "user.name=super-skill", "-c", "user.email=super-skill@localhost"]
|
|
31
32
|
|
|
33
|
+
# Untracked by the registry git backend: pre-promotion scratch (candidates/,
|
|
34
|
+
# locks/), the capture WAL + mine watermark (events/, mine_state.json — private
|
|
35
|
+
# session content that must not enter audit history, M9), and atomic-write temps.
|
|
36
|
+
_GITIGNORE = "locks/\ncandidates/\nevents/\nmine_state.json\n*.tmp\n"
|
|
37
|
+
|
|
32
38
|
|
|
33
39
|
class SkillRecord(BaseModel):
|
|
34
40
|
"""Per-skill aggregate persisted as meta.json."""
|
|
@@ -63,17 +69,16 @@ class Registry:
|
|
|
63
69
|
# ---- lifecycle ---------------------------------------------------------
|
|
64
70
|
def init(self) -> None:
|
|
65
71
|
self.skills_root.mkdir(parents=True, exist_ok=True)
|
|
66
|
-
# candidates/ holds pre-promotion draft scratch — kept out of tracked
|
|
67
|
-
# history so only approve (the Publisher path) writes registry state.
|
|
68
|
-
gitignore = "locks/\ncandidates/\n"
|
|
69
|
-
gi = self.root / ".gitignore"
|
|
70
72
|
if not (self.root / ".git").exists():
|
|
71
73
|
self._git("init", "-q")
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
gi = self.root / ".gitignore"
|
|
75
|
+
# Rewrite whenever missing or stale — unconditionally, not gated on ``.git``
|
|
76
|
+
# existing (a crash between ``git init`` and the first write, or a manual
|
|
77
|
+
# ``git init``, used to leave it un-created and candidates/ tracked, M10).
|
|
78
|
+
if not gi.exists() or gi.read_text(encoding="utf-8") != _GITIGNORE:
|
|
79
|
+
gi.write_text(_GITIGNORE, encoding="utf-8")
|
|
80
|
+
# Idempotent: no-op when nothing is staged (already-initialized re-run).
|
|
81
|
+
self._commit("chore: initialize super-skill registry (.gitignore)")
|
|
77
82
|
|
|
78
83
|
def _git(self, *args: str) -> str:
|
|
79
84
|
proc = subprocess.run(
|
|
@@ -113,7 +118,13 @@ class Registry:
|
|
|
113
118
|
p = self._meta_path(skill_id)
|
|
114
119
|
if not p.exists():
|
|
115
120
|
return None
|
|
116
|
-
|
|
121
|
+
try:
|
|
122
|
+
return SkillRecord.model_validate_json(p.read_text(encoding="utf-8"))
|
|
123
|
+
except ValidationError as e:
|
|
124
|
+
# A truncated/corrupt meta.json (crash mid-write, hand-edit) must
|
|
125
|
+
# surface as RegistryError so status/list/doctor report it cleanly
|
|
126
|
+
# rather than dying on a raw pydantic traceback (M12).
|
|
127
|
+
raise RegistryError(f"{skill_id}: corrupt meta.json ({e})") from e
|
|
117
128
|
|
|
118
129
|
def list_skills(self) -> list[SkillRecord]:
|
|
119
130
|
if not self.skills_root.exists():
|
|
@@ -145,6 +156,14 @@ class Registry:
|
|
|
145
156
|
"""Register a new immutable version; optionally make it the active pointer."""
|
|
146
157
|
parsed = parse(raw)
|
|
147
158
|
rec = self.get(skill_id) or SkillRecord(skill=Skill(skill_id=skill_id, scope=scope))
|
|
159
|
+
# Idempotent promotion (M8): re-adding content identical to the current
|
|
160
|
+
# active version is a no-op. Guards the approve crash window (candidate
|
|
161
|
+
# left 'pending' after commit) from double-promoting on re-run.
|
|
162
|
+
new_hash = content_hash(raw)
|
|
163
|
+
if make_active and rec.skill.active_version:
|
|
164
|
+
cur = rec.versions.get(rec.skill.active_version)
|
|
165
|
+
if cur is not None and cur.artifact_hash == new_hash:
|
|
166
|
+
return cur
|
|
148
167
|
version = rec.next_version()
|
|
149
168
|
parents = [rec.skill.active_version] if rec.skill.active_version else []
|
|
150
169
|
sv = SkillVersion(
|
|
@@ -205,7 +224,11 @@ class Registry:
|
|
|
205
224
|
def _write(self, rec: SkillRecord) -> None:
|
|
206
225
|
p = self._meta_path(rec.skill.skill_id)
|
|
207
226
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
-
|
|
227
|
+
# Atomic write: a crash mid-write must never leave a truncated meta.json
|
|
228
|
+
# (M12). Write to a sibling temp file, then os.replace (atomic rename).
|
|
229
|
+
tmp = p.with_suffix(".json.tmp")
|
|
230
|
+
tmp.write_text(rec.model_dump_json(indent=2), encoding="utf-8")
|
|
231
|
+
os.replace(tmp, p)
|
|
209
232
|
|
|
210
233
|
# ---- distribution ------------------------------------------------------
|
|
211
234
|
def materialize(self, skill_id: str, host_dir: Path) -> Path:
|
|
@@ -47,6 +47,12 @@ def seed_from_host(reg: Registry, host_dir: Path) -> SeedReport:
|
|
|
47
47
|
continue
|
|
48
48
|
|
|
49
49
|
skill_id = parsed.frontmatter.name
|
|
50
|
+
# agentskills.io: frontmatter name must match the dir name. If it doesn't,
|
|
51
|
+
# skip rather than silently versioning one skill under another's id — two
|
|
52
|
+
# dirs sharing a name would otherwise collapse into one version chain (M11).
|
|
53
|
+
if skill_id != d.name:
|
|
54
|
+
report.skipped.append((d.name, f"frontmatter name {skill_id!r} != dir name"))
|
|
55
|
+
continue
|
|
50
56
|
existing = reg.get(skill_id)
|
|
51
57
|
if (
|
|
52
58
|
existing is not None
|
|
@@ -135,6 +135,27 @@ def test_approve_blocked_by_eval_writes_nothing(tmp_path):
|
|
|
135
135
|
assert not (host / "dependency-resolution").exists()
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
def test_approve_is_crash_idempotent(tmp_path):
|
|
139
|
+
"""P1-4 / M8: a crash between the registry commit and marking the candidate
|
|
140
|
+
approved leaves it 'pending'; re-running approve must NOT double-promote."""
|
|
141
|
+
store = CandidateStore(root=tmp_path / "state")
|
|
142
|
+
reg = Registry(root=tmp_path / "state")
|
|
143
|
+
host = tmp_path / "host"
|
|
144
|
+
draft_from_families(store, [_fam("dependency resolution")])
|
|
145
|
+
sv1 = approve(store, reg, "dependency-resolution", host)
|
|
146
|
+
# simulate crash: version promoted + committed, but candidate.save never ran
|
|
147
|
+
cand = store.get("dependency-resolution")
|
|
148
|
+
cand.status = "pending"
|
|
149
|
+
cand.version = None
|
|
150
|
+
cand.skill_id = None
|
|
151
|
+
store.save(cand)
|
|
152
|
+
# re-run approve on the same (unedited) SKILL.md must be idempotent
|
|
153
|
+
sv2 = approve(store, reg, "dependency-resolution", host)
|
|
154
|
+
rec = reg.get("dependency-resolution")
|
|
155
|
+
assert list(rec.versions) == ["v1"], f"double-promoted: {list(rec.versions)}"
|
|
156
|
+
assert sv2.version == sv1.version
|
|
157
|
+
|
|
158
|
+
|
|
138
159
|
def test_approve_unknown_candidate_raises(tmp_path):
|
|
139
160
|
store = CandidateStore(root=tmp_path / "state")
|
|
140
161
|
reg = Registry(root=tmp_path / "state")
|