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.
Files changed (74) hide show
  1. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/.claude-plugin/marketplace.json +2 -2
  2. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/.claude-plugin/plugin.json +1 -1
  3. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/CHANGELOG.md +59 -0
  4. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/PKG-INFO +1 -1
  5. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/pyproject.toml +1 -1
  6. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/candidate.py +11 -5
  7. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/capture.py +23 -4
  8. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/cli.py +21 -13
  9. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/evallite.py +6 -2
  10. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/gate.py +33 -5
  11. super_skill_cli-0.11.0/super_skill/minestate.py +69 -0
  12. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/redact.py +29 -6
  13. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/registry.py +35 -12
  14. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/seed.py +6 -0
  15. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_candidate.py +21 -0
  16. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_capture.py +48 -0
  17. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_cli.py +37 -0
  18. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_evallite.py +13 -0
  19. super_skill_cli-0.11.0/tests/test_gate.py +120 -0
  20. super_skill_cli-0.11.0/tests/test_minestate.py +55 -0
  21. super_skill_cli-0.11.0/tests/test_redact.py +143 -0
  22. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_registry.py +42 -0
  23. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_seed.py +15 -0
  24. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/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.0}/.gitignore +0 -0
  30. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/LICENSE +0 -0
  31. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/README.md +0 -0
  32. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/README.zh-CN.md +0 -0
  33. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/codex/README.md +0 -0
  34. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/codex/install.sh +0 -0
  35. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/codex/skills/super-skill/SKILL.md +0 -0
  36. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/codex/skills/super-skill/agents/openai.yaml +0 -0
  37. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/candidates.md +0 -0
  38. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/doctor.md +0 -0
  39. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/mine.md +0 -0
  40. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/seed.md +0 -0
  41. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/commands/status.md +0 -0
  42. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/README.md +0 -0
  43. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case1/PROMPT.md +0 -0
  44. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case1/broken.py +0 -0
  45. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case1/fixed.py +0 -0
  46. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case1/verify_test.py +0 -0
  47. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case2/PROMPT.md +0 -0
  48. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case2/broken.py +0 -0
  49. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case2/fixed.py +0 -0
  50. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case2/verify_test.py +0 -0
  51. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case3/PROMPT.md +0 -0
  52. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case3/broken.py +0 -0
  53. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case3/fixed.py +0 -0
  54. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/cases/case3/verify_test.py +0 -0
  55. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/gate2_report.py +0 -0
  56. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/grader.py +0 -0
  57. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/handwritten/SKILL.md +0 -0
  58. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/evals/test-fix-family/results.template.json +0 -0
  59. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/hooks/hooks.json +0 -0
  60. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/skills/super-skill/SKILL.md +0 -0
  61. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/__init__.py +0 -0
  62. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/config.py +0 -0
  63. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/doctor.py +0 -0
  64. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/hooks.py +0 -0
  65. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/mine.py +0 -0
  66. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/schemas.py +0 -0
  67. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/super_skill/skillmd.py +0 -0
  68. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_codex_package.py +0 -0
  69. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_doctor.py +0 -0
  70. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_gate2_harness.py +0 -0
  71. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_hooks.py +0 -0
  72. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_plugin_manifest.py +0 -0
  73. {super_skill_cli-0.10.0 → super_skill_cli-0.11.0}/tests/test_skillmd.py +0 -0
  74. {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.10.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.10.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.10.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.10.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "super-skill-cli"
3
- version = "0.10.0"
3
+ version = "0.11.0"
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,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
- 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
+ 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({ev.session_id for ev in self.iter_events()})
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
- name = event_type or str(data.get("hook_event_name", ""))
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
- etype = EventType(name)
105
- except ValueError:
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"({distinct} distinct sessions captured)"
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, distinct)
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.distinct_sessions())
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.distinct_sessions())
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
- text = f"{parsed.frontmatter.description}\n{parsed.body}"
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 description + body for external-action imperatives.
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
- return scan_text(parsed.frontmatter.description, "description") + scan_text(
101
- parsed.body, "body"
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
- ("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
+ ("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)\b(?:api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b"
34
- r"\s*[:=]\s*['\"]?([^\s'\"]{6,})",
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
- def redact_payload(obj: Any, _path: str = "") -> tuple[Any, list[RedactionMark]]:
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
- 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:
@@ -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")