super-skill-cli 0.10.0__tar.gz → 0.11.1__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.
Files changed (74) hide show
  1. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/.claude-plugin/marketplace.json +2 -2
  2. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/.claude-plugin/plugin.json +1 -1
  3. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/CHANGELOG.md +90 -0
  4. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/PKG-INFO +1 -1
  5. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/pyproject.toml +1 -1
  6. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/candidate.py +11 -5
  7. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/capture.py +25 -4
  8. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/cli.py +24 -14
  9. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/evallite.py +10 -2
  10. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/gate.py +40 -5
  11. super_skill_cli-0.11.1/super_skill/minestate.py +69 -0
  12. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/redact.py +33 -6
  13. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/registry.py +35 -12
  14. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/seed.py +6 -0
  15. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_candidate.py +21 -0
  16. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_capture.py +48 -0
  17. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_cli.py +45 -0
  18. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_evallite.py +23 -0
  19. super_skill_cli-0.11.1/tests/test_gate.py +126 -0
  20. super_skill_cli-0.11.1/tests/test_minestate.py +55 -0
  21. super_skill_cli-0.11.1/tests/test_redact.py +162 -0
  22. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_registry.py +42 -0
  23. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_seed.py +15 -0
  24. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/uv.lock +1 -1
  25. super_skill_cli-0.10.0/super_skill/minestate.py +0 -57
  26. super_skill_cli-0.10.0/tests/test_gate.py +0 -54
  27. super_skill_cli-0.10.0/tests/test_minestate.py +0 -48
  28. super_skill_cli-0.10.0/tests/test_redact.py +0 -71
  29. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/.gitignore +0 -0
  30. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/LICENSE +0 -0
  31. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/README.md +0 -0
  32. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/README.zh-CN.md +0 -0
  33. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/codex/README.md +0 -0
  34. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/codex/install.sh +0 -0
  35. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/codex/skills/super-skill/SKILL.md +0 -0
  36. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/codex/skills/super-skill/agents/openai.yaml +0 -0
  37. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/candidates.md +0 -0
  38. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/doctor.md +0 -0
  39. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/mine.md +0 -0
  40. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/seed.md +0 -0
  41. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/status.md +0 -0
  42. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/README.md +0 -0
  43. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case1/PROMPT.md +0 -0
  44. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case1/broken.py +0 -0
  45. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case1/fixed.py +0 -0
  46. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case1/verify_test.py +0 -0
  47. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case2/PROMPT.md +0 -0
  48. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case2/broken.py +0 -0
  49. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case2/fixed.py +0 -0
  50. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case2/verify_test.py +0 -0
  51. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case3/PROMPT.md +0 -0
  52. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case3/broken.py +0 -0
  53. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case3/fixed.py +0 -0
  54. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case3/verify_test.py +0 -0
  55. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/gate2_report.py +0 -0
  56. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/grader.py +0 -0
  57. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/handwritten/SKILL.md +0 -0
  58. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/results.template.json +0 -0
  59. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/hooks/hooks.json +0 -0
  60. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/skills/super-skill/SKILL.md +0 -0
  61. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/__init__.py +0 -0
  62. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/config.py +0 -0
  63. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/doctor.py +0 -0
  64. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/hooks.py +0 -0
  65. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/mine.py +0 -0
  66. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/schemas.py +0 -0
  67. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/skillmd.py +0 -0
  68. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_codex_package.py +0 -0
  69. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_doctor.py +0 -0
  70. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_gate2_harness.py +0 -0
  71. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_hooks.py +0 -0
  72. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_plugin_manifest.py +0 -0
  73. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_skillmd.py +0 -0
  74. {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/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.10.0"
9
+ "version": "0.11.1"
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.10.0",
16
+ "version": "0.11.1",
17
17
  "category": "development"
18
18
  }
19
19
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-skill",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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,96 @@
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.1] - 2026-07-12
7
+
8
+ Follow-up patch from a code review of 0.11.0. Fixes a **Critical ReDoS regression
9
+ the 0.11.0 redaction hardening introduced** plus four gaps. Backward-compatible;
10
+ upgrade recommended over 0.11.0 (`pip install -U super-skill-cli`).
11
+
12
+ ### Security
13
+ - **Fix ReDoS in the redaction `assigned_secret` rule** (regression in 0.11.0) —
14
+ the broadened rule used unbounded `[A-Za-z0-9_]*` runs, so underscore-heavy
15
+ input (snake_case blobs, env dumps) backtracked catastrophically: a ~6 KB
16
+ payload took ~21 s. Since redaction runs on every captured event, a normal
17
+ hook payload could hang `super-skill capture` (worse than the crash 0.11.0 was
18
+ hardening). Runs are now bounded (`{0,64}`); the same payload processes in
19
+ ~18 ms and secrets still redact.
20
+ - **`sk-proj-`/`sk-svcacct-` OpenAI keys are now fully redacted** — the body may
21
+ contain `_`/`-`, and the previous rule stopped at the first such char, leaking
22
+ the tail.
23
+ - **A secret in a keyword-named frontmatter field is now caught** — the gate and
24
+ eval-lite serialized frontmatter with JSON, which quoted the key (`"token":`)
25
+ and broke the `keyword: value` secret pattern; they now use YAML, preserving
26
+ adjacency.
27
+ - **The gate folds uppercase homoglyphs** — the confusables map was lowercase
28
+ only, so `СURL … | bash` (uppercase Cyrillic) slipped through; normalization
29
+ now casefolds first.
30
+
31
+ ### Fixed
32
+ - **`capture` survives deeply-nested JSON** — `json.loads` raises `RecursionError`
33
+ (a `RuntimeError`, not `JSONDecodeError`) on very deep input; the parse guard
34
+ now exits 0 on any error (NFR-3).
35
+ - The WAL append loops on short writes so a very large line can't be truncated.
36
+
37
+ ## [0.11.0] - 2026-07-12
38
+
39
+ Security & reliability hardening from a full production-readiness audit. All fixes
40
+ are backward-compatible; defaults unchanged. **Recommended upgrade** if you wire
41
+ live capture via `hooks-config` — the capture/redaction path had secret-leak and
42
+ crash-safety gaps that this release closes.
43
+
44
+ **Migration**: none required. An existing `mine_state.json` from 0.10.0 is auto-
45
+ handled (the watermark format changed to a session-id set; an old file reads as
46
+ "nothing mined" and self-heals on the next `mine`). **Revert path**: pin the prior
47
+ release with `pip install super-skill-cli==0.10.0`.
48
+
49
+ ### Security
50
+ - **Redaction no longer leaks underscore-delimited env-var secrets** — `DB_PASSWORD=…`,
51
+ `AWS_SECRET_ACCESS_KEY=…`, `SECRET_KEY=…` etc. were written verbatim to the WAL
52
+ because the keyword rule anchored on `\b` (an underscore is a word char). The
53
+ same `redact_text` feeds the eval-lite secret gate, so this closed a leak in
54
+ both paths at once.
55
+ - **Redaction now matches current key formats** — OpenAI `sk-proj-`/`sk-svcacct-`,
56
+ Stripe `sk_live_`/`sk_test_`, GitHub fine-grained PATs `github_pat_`, and GCP
57
+ `AIza…` keys were previously unmatched.
58
+ - **The instruction-layer gate + eval-lite scan the full frontmatter**, not just
59
+ `description` + body — an injection or secret in any other frontmatter field
60
+ (e.g. `instructions:`, `metadata:`) previously shipped to the host unscanned.
61
+ - **The gate normalizes cheap obfuscation** (NFKC + zero-width strip + common
62
+ Cyrillic/Greek homoglyph fold) so `с​url … | bash` and homoglyph variants no
63
+ longer slip a shell pipe past the ASCII rules.
64
+ - **The capture WAL and mine watermark are gitignored** — `events/` and
65
+ `mine_state.json` are no longer swept into the registry's audit history by
66
+ `git add -A`, keeping (redacted-but-private) session content out of the repo.
67
+ - **Redaction depth is capped** so a pathologically nested payload can't
68
+ `RecursionError`; the over-deep subtree is dropped rather than leaked.
69
+
70
+ ### Fixed
71
+ - **`capture` never fails the host session (NFR-3)** — non-object JSON on stdin
72
+ and any WAL write error are now swallowed with exit 0; previously a list/scalar
73
+ payload raised and exited 1.
74
+ - **The WAL tolerates a torn/partial line** — a hook killed mid-append no longer
75
+ bricks every reader (`status`/`mine`/`count`); the bad line is skipped.
76
+ - **Concurrent captures no longer corrupt the WAL** — each event is one atomic
77
+ `O_APPEND` write instead of a buffered write that could interleave across
78
+ processes.
79
+ - **Registry/candidate/watermark writes are atomic** (temp + `os.replace`), and a
80
+ corrupt `meta.json`/`candidate.json` surfaces as a typed error instead of a raw
81
+ traceback that crashed `status`/`list`/`doctor`.
82
+ - **`approve` is crash-idempotent** — a crash between the registry commit and
83
+ marking the candidate approved no longer double-promotes on re-run.
84
+ - **`seed` skips a skill whose frontmatter `name` ≠ its directory name**, so two
85
+ host dirs sharing a name can no longer collapse into one version chain.
86
+ - **The `mine` reminder survives WAL TTL pruning** — the watermark tracks mined
87
+ session ids, so new sessions still count as "unmined" after old ones roll off.
88
+ - **`init` rebuilds a missing/stale `.gitignore`** even when `.git` already exists.
89
+
90
+ ### Changed
91
+ - Docs `02`/`03`/`04` bumped to v1.4 with WS-vs-target implementation notes
92
+ (registry-as-source distribution, WS entity/eval-lite subsets) for doc↔code
93
+ consistency. Test suite grew 127 → 159; `gate.py` and `redact.py` at 100%
94
+ line coverage.
95
+
6
96
  ## [0.10.0] - 2026-07-12
7
97
 
8
98
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: super-skill-cli
3
- Version: 0.10.0
3
+ Version: 0.11.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "super-skill-cli"
3
- version = "0.10.0"
3
+ version = "0.11.1"
4
4
  description = "Personal Agent-Skill package manager: versioned registry, seed import, explain, rollback (M0+WS scope)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- return Candidate.model_validate_json(meta.read_text(encoding="utf-8"))
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
- (cdir / "candidate.json").write_text(
120
- cand.model_dump_json(indent=2), encoding="utf-8"
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,17 @@ 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
- with out.open("a", encoding="utf-8") as f:
57
- f.write(event.model_dump_json() + "\n")
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
+ # Loop on short writes so a very large line isn't silently truncated.
66
+ while data:
67
+ data = data[os.write(fd, data):]
68
+ finally:
69
+ os.close(fd)
58
70
  return event
59
71
 
60
72
  def iter_events(self) -> Iterator[CaptureEvent]:
@@ -66,11 +78,20 @@ class EventLog:
66
78
  continue
67
79
  for line in wal.read_text(encoding="utf-8").splitlines():
68
80
  line = line.strip()
69
- if line:
81
+ if not line:
82
+ continue
83
+ try:
70
84
  yield CaptureEvent.model_validate_json(line)
85
+ except ValidationError:
86
+ # A killed hook can leave a torn final line; one bad record
87
+ # must not brick every reader (status/mine/count). Skip it.
88
+ continue
71
89
 
72
90
  def count(self) -> int:
73
91
  return sum(1 for _ in self.iter_events())
74
92
 
93
+ def session_ids(self) -> set[str]:
94
+ return {ev.session_id for ev in self.iter_events()}
95
+
75
96
  def distinct_sessions(self) -> int:
76
- return len({ev.session_id for ev in self.iter_events()})
97
+ return len(self.session_ids())
@@ -97,37 +97,47 @@ def capture(
97
97
  try:
98
98
  raw = sys.stdin.read()
99
99
  data = json.loads(raw) if raw.strip() else {}
100
- except (json.JSONDecodeError, OSError):
100
+ except Exception:
101
+ # NFR-3: never fail the session. Covers JSONDecodeError, OSError, and
102
+ # RecursionError (deeply-nested JSON — a RuntimeError, not JSONDecodeError).
101
103
  raise typer.Exit(0) from None
102
- name = event_type or str(data.get("hook_event_name", ""))
104
+ # NFR-3: the hook must NEVER fail the host session. Everything past here —
105
+ # non-dict JSON (data.get would raise), an unknown event type, or any WAL
106
+ # write error — is swallowed and exits 0. Capture-never-fails beats
107
+ # capture-complete.
103
108
  try:
104
- etype = EventType(name)
105
- except ValueError:
109
+ if not isinstance(data, dict):
110
+ raise typer.Exit(0)
111
+ name = event_type or str(data.get("hook_event_name", ""))
112
+ etype = EventType(name) # ValueError on unknown type
113
+ session_id = str(data.get("session_id", "unknown"))
114
+ project_id = data.get("cwd")
115
+ EventLog().append(
116
+ etype, session_id, data, project_id=str(project_id) if project_id else None
117
+ )
118
+ except typer.Exit:
119
+ raise
120
+ except Exception:
106
121
  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
122
 
113
123
 
114
124
  @app.command()
115
125
  def mine(min_sessions: int = typer.Option(3, "--min-sessions")) -> None:
116
126
  """Surface recurring task families from captured events (FR-GEN-1 signal)."""
117
127
  log = EventLog()
128
+ session_ids = log.session_ids()
118
129
  families = mine_families(log.iter_events(), min_sessions=min_sessions)
119
- distinct = log.distinct_sessions()
120
130
  if not families:
121
131
  typer.echo(
122
132
  f"no families recurring across >={min_sessions} sessions yet "
123
- f"({distinct} distinct sessions captured)"
133
+ f"({len(session_ids)} distinct sessions captured)"
124
134
  )
125
135
  else:
126
136
  typer.echo(f"{'sessions':>8} {'events':>6} family")
127
137
  for fam in families:
128
138
  typer.echo(f"{fam.session_count:>8} {fam.event_count:>6} {fam.label}")
129
139
  # Mining acknowledges the accumulated sessions: reset the reminder watermark.
130
- minestate.record_mined(log.root, distinct)
140
+ minestate.record_mined(log.root, session_ids)
131
141
 
132
142
 
133
143
  @app.command()
@@ -149,7 +159,7 @@ def status() -> None:
149
159
  log = EventLog(reg.root)
150
160
  typer.echo(f"events : {log.count()}")
151
161
  typer.echo(f"candidates : {len(cands)} ({breakdown})")
152
- unmined = minestate.unmined(reg.root, log.distinct_sessions())
162
+ unmined = minestate.unmined(reg.root, log.session_ids())
153
163
  if unmined >= minestate.reminder_threshold():
154
164
  typer.echo(f"reminder : {unmined} distinct sessions unmined "
155
165
  f"— run `super-skill mine`")
@@ -322,7 +332,7 @@ def candidate_draft(min_sessions: int = typer.Option(3, "--min-sessions")) -> No
322
332
  store = CandidateStore(config.state_root())
323
333
  created = draft_from_families(store, families)
324
334
  # Drafting acts on the mining, so it too clears the status reminder.
325
- minestate.record_mined(log.root, log.distinct_sessions())
335
+ minestate.record_mined(log.root, log.session_ids())
326
336
  if not created:
327
337
  typer.echo("no new candidates (nothing mined, or all already drafted)")
328
338
  return
@@ -17,6 +17,8 @@ from __future__ import annotations
17
17
  import re
18
18
  from dataclasses import dataclass, field
19
19
 
20
+ import yaml
21
+
20
22
  from .redact import redact_text
21
23
  from .skillmd import SkillMdError, parse
22
24
 
@@ -72,8 +74,14 @@ def eval_lite(raw: str) -> EvalReport:
72
74
  except SkillMdError as e:
73
75
  return EvalReport(checks=[EvalCheck("schema", False, str(e))])
74
76
 
75
- # 2. zero credential / PII leak in the instruction text (凭据与 PII 泄漏 = 0)
76
- text = f"{parsed.frontmatter.description}\n{parsed.body}"
77
+ # 2. zero credential / PII leak in the instruction text (凭据与 PII 泄漏 = 0).
78
+ # Scan the FULL frontmatter (extra="allow" ships every field to the host, M6),
79
+ # not just description + body. YAML (not JSON) keeps ``keyword: value``
80
+ # adjacency so a secret in a keyword-named field is caught (v0.11.1 #4).
81
+ fm = yaml.safe_dump(
82
+ parsed.frontmatter.model_dump(), default_flow_style=False, allow_unicode=True
83
+ )
84
+ text = f"{fm}\n{parsed.body}"
77
85
  _, counts = redact_text(text)
78
86
  leaks = {k: c for k, c in counts.items() if k != "home_path"}
79
87
  checks.append(
@@ -17,10 +17,33 @@ land at M1 — v1 candidates carry no scripts, so there is nothing to cross-chec
17
17
  from __future__ import annotations
18
18
 
19
19
  import re
20
+ import unicodedata
20
21
  from dataclasses import dataclass
21
22
 
23
+ import yaml
24
+
22
25
  from .skillmd import parse
23
26
 
27
+ # Cheap-obfuscation defenses (M7): strip zero-width/format chars and fold the
28
+ # common Cyrillic/Greek homoglyphs so ``с​url`` (ZWSP) and ``сurl`` (Cyrillic es)
29
+ # can't slip a shell pipe past the ASCII regexes. Base64-encoded payloads remain
30
+ # out of scope until the M1 LLM-judge layer.
31
+ _ZERO_WIDTH = dict.fromkeys(map(ord, "​‌‍⁠"), None)
32
+ _CONFUSABLES = str.maketrans({
33
+ "а": "a", "с": "c", "ԁ": "d", "е": "e", "ɡ": "g", "һ": "h", "і": "i",
34
+ "ј": "j", "ո": "n", "о": "o", "р": "p", "ѕ": "s", "т": "t", "υ": "u",
35
+ "ν": "v", "х": "x", "у": "y", "α": "a", "ε": "e", "ο": "o", "ρ": "p", "κ": "k",
36
+ })
37
+
38
+
39
+ def _normalize(text: str) -> str:
40
+ """Fold cheap obfuscations before pattern-matching. casefold() runs before the
41
+ confusables map so UPPERCASE homoglyphs (e.g. Cyrillic ``С``) fold too."""
42
+ text = unicodedata.normalize("NFKC", text)
43
+ text = text.translate(_ZERO_WIDTH)
44
+ text = text.casefold()
45
+ return text.translate(_CONFUSABLES)
46
+
24
47
  # (category, pattern). Case-insensitive. Ordered most-severe first.
25
48
  _RULES: list[tuple[str, re.Pattern[str]]] = [
26
49
  (
@@ -84,6 +107,7 @@ class InstructionGateError(RuntimeError):
84
107
 
85
108
  def scan_text(text: str, location: str) -> list[Finding]:
86
109
  findings: list[Finding] = []
110
+ text = _normalize(text)
87
111
  for category, pat in _RULES:
88
112
  for m in pat.finditer(text):
89
113
  snippet = " ".join(m.group(0).split())[:80]
@@ -92,11 +116,22 @@ def scan_text(text: str, location: str) -> list[Finding]:
92
116
 
93
117
 
94
118
  def scan_skill_md(raw: str) -> list[Finding]:
95
- """Scan a SKILL.md's description + body for external-action imperatives.
119
+ """Scan a SKILL.md's frontmatter + body for external-action imperatives.
96
120
 
97
121
  description is scanned separately: §2.4bis forbids imperative / second-person
98
- external-action language there (it would both win routing and inject)."""
122
+ external-action language there (it would both win routing and inject). Every
123
+ OTHER frontmatter field is scanned too (M6): ``extra="allow"`` means fields
124
+ like ``instructions:`` / ``metadata:`` ship verbatim to the host, so they
125
+ need the same scrutiny — scanning only description + body left them a bypass."""
99
126
  parsed = parse(raw)
100
- return scan_text(parsed.frontmatter.description, "description") + scan_text(
101
- parsed.body, "body"
102
- )
127
+ findings = scan_text(parsed.frontmatter.description, "description")
128
+ fm = parsed.frontmatter.model_dump()
129
+ # name is NAME_RE-constrained (safe); description already scanned above.
130
+ extra = {k: v for k, v in fm.items() if k not in ("name", "description") and v is not None}
131
+ if extra:
132
+ # YAML (not JSON) keeps ``keyword: value`` adjacency — JSON quotes the key
133
+ # (``"token":``), which breaks the credential/secret patterns that expect
134
+ # the keyword immediately before ``:`` (v0.11.1 #4).
135
+ dumped = yaml.safe_dump(extra, default_flow_style=False, allow_unicode=True)
136
+ findings += scan_text(dumped, "frontmatter")
137
+ 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,28 @@ _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
- ("openai_key", re.compile(r"sk-[A-Za-z0-9]{20,}"), 0),
27
+ # OpenAI legacy (sk-…) + prefixed project/service keys (sk-proj-…, sk-svcacct-…).
28
+ # The 20+ alnum CORE keeps prose (short hyphenated words) from matching; the
29
+ # trailing _/- -joined segments capture project-key bodies fully (no tail leak).
30
+ ("openai_key", re.compile(r"sk-(?:[a-z]+-)?[A-Za-z0-9]{20,}(?:[_-][A-Za-z0-9]+)*"), 0),
31
+ ("stripe_key", re.compile(r"sk_(?:live|test)_[A-Za-z0-9]{16,}"), 0),
32
+ ("github_pat", re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), 0),
28
33
  ("github_token", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), 0),
29
34
  ("aws_key", re.compile(r"AKIA[0-9A-Z]{16}"), 0),
35
+ ("gcp_key", re.compile(r"AIza[0-9A-Za-z_-]{35}"), 0),
30
36
  ("slack_token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), 0),
31
37
  ("bearer_token", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*"), 0),
38
+ # Match a full identifier token that CONTAINS a sensitive word, up to the
39
+ # assignment. A ``\b`` left-anchor missed ``DB_PASSWORD`` (``_`` is a word
40
+ # char) and a bare keyword missed ``SECRET_KEY`` (the ``_KEY`` suffix broke
41
+ # ``[:=]`` adjacency) — H1. The lookbehind + surrounding identifier runs admit
42
+ # the keyword anywhere inside an env-var-shaped name. The runs are BOUNDED
43
+ # ({0,64}, not *) — an unbounded run over an underscore-heavy blob backtracks
44
+ # catastrophically (ReDoS on the capture hot path); env-var names are short.
32
45
  ("assigned_secret", re.compile(
33
- r"(?i)\b(?:api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b"
34
- r"\s*[:=]\s*['\"]?([^\s'\"]{6,})",
46
+ r"(?i)(?<![A-Za-z0-9])[A-Za-z0-9_]{0,64}"
47
+ r"(?:api[_-]?key|access[_-]?key|secret|password|passwd|pwd|token)"
48
+ r"[A-Za-z0-9_]{0,64}\s*[:=]\s*['\"]?([^\s'\"]{6,})",
35
49
  ), 1),
36
50
  ("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), 0),
37
51
  ]
@@ -66,10 +80,23 @@ def redact_text(text: str) -> tuple[str, dict[str, int]]:
66
80
  return text, counts
67
81
 
68
82
 
69
- def redact_payload(obj: Any, _path: str = "") -> tuple[Any, list[RedactionMark]]:
83
+ # Recursion cap: a pathologically nested payload must not RecursionError (which
84
+ # would blow past capture's guard). Well under CPython's ~1000-frame limit.
85
+ _MAX_DEPTH = 60
86
+
87
+
88
+ def redact_payload(
89
+ obj: Any, _path: str = "", _depth: int = 0
90
+ ) -> tuple[Any, list[RedactionMark]]:
70
91
  """Recursively redact all string leaves in a JSON-like structure, collecting
71
92
  marks that record kind + dotted field path (never the value)."""
72
93
  marks: list[RedactionMark] = []
94
+ if _depth > _MAX_DEPTH:
95
+ # Drop (do not stringify — str() of a deep subtree would itself recurse)
96
+ # the over-deep subtree; safer to lose it than to leak or crash (L18).
97
+ return "[REDACTED:max_depth]", [
98
+ RedactionMark(kind="max_depth", location=_path or "(root)", count=1)
99
+ ]
73
100
  if isinstance(obj, str):
74
101
  red, counts = redact_text(obj)
75
102
  marks.extend(
@@ -81,14 +108,14 @@ def redact_payload(obj: Any, _path: str = "") -> tuple[Any, list[RedactionMark]]
81
108
  out_d: dict[str, Any] = {}
82
109
  for k, v in obj.items():
83
110
  child = f"{_path}.{k}" if _path else str(k)
84
- out_d[k], sub = redact_payload(v, child)
111
+ out_d[k], sub = redact_payload(v, child, _depth + 1)
85
112
  marks.extend(sub)
86
113
  return out_d, marks
87
114
  if isinstance(obj, list):
88
115
  out_l: list[Any] = []
89
116
  for i, v in enumerate(obj):
90
117
  child = f"{_path}[{i}]"
91
- rv, sub = redact_payload(v, child)
118
+ rv, sub = redact_payload(v, child, _depth + 1)
92
119
  out_l.append(rv)
93
120
  marks.extend(sub)
94
121
  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
- gi.write_text(gitignore, encoding="utf-8")
73
- self._commit("chore: initialize super-skill registry")
74
- elif gi.exists() and "candidates/" not in gi.read_text(encoding="utf-8"):
75
- gi.write_text(gitignore, encoding="utf-8")
76
- self._commit("chore: ignore candidates/ scratch dir")
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
- return SkillRecord.model_validate_json(p.read_text(encoding="utf-8"))
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
- p.write_text(rec.model_dump_json(indent=2), encoding="utf-8")
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: