super-skill-cli 0.9.2__py3-none-any.whl

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/config.py ADDED
@@ -0,0 +1,25 @@
1
+ """Path resolution. All roots are env-overridable so tests never touch the real
2
+ ~/.super-skill state or the host's ~/.claude/skills directory."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def state_root() -> Path:
11
+ """Registry + control state (git-backed in WS). docs/02 §4.1."""
12
+ return Path(os.environ.get("SUPER_SKILL_HOME", "~/.super-skill")).expanduser()
13
+
14
+
15
+ def host_skills_dir() -> Path:
16
+ """Claude Code personal skills dir — v1 direct distribution target (R-SCOPE-3)."""
17
+ return Path(os.environ.get("SUPER_SKILL_HOST_SKILLS", "~/.claude/skills")).expanduser()
18
+
19
+
20
+ def registry_dir() -> Path:
21
+ return state_root() / "registry"
22
+
23
+
24
+ def skills_dir() -> Path:
25
+ return registry_dir() / "skills"
super_skill/doctor.py ADDED
@@ -0,0 +1,119 @@
1
+ """Registry integrity self-check and repair (`super-skill doctor [--fix]`).
2
+
3
+ `check_registry` is read-only: it verifies the WS registry is internally
4
+ consistent and, when a host dir is given, in sync with it. The highest-value
5
+ check is content-hash integrity — a version's on-disk SKILL.md must still hash to
6
+ the `artifact_hash` recorded when it was promoted, catching tampering,
7
+ corruption, or a hand-edit that bypassed the registry.
8
+
9
+ `repair` fixes the mechanically-fixable issues: git is the WS backend, so a
10
+ tampered/missing version file is restored from HEAD (which holds the committed,
11
+ correct content), and host drift is re-materialized from the active version.
12
+ Issues that need judgment (a dangling active pointer, a name mismatch) are left
13
+ for the user. Repair always re-verifies afterwards and reports what actually
14
+ remains — an attempted fix is not a fix (see the doctor exit-code lesson).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .registry import Registry, RegistryError
23
+ from .skillmd import content_hash
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DoctorIssue:
28
+ skill_id: str
29
+ severity: str # "error" (integrity broken) | "warn" (drift / cosmetic)
30
+ message: str
31
+ kind: str = "" # machine-dispatchable: see check_registry
32
+ version: str | None = None
33
+
34
+
35
+ def check_registry(reg: Registry, host_dir: Path | None = None) -> list[DoctorIssue]:
36
+ issues: list[DoctorIssue] = []
37
+ for rec in reg.list_skills():
38
+ sid = rec.skill.skill_id
39
+ active = rec.skill.active_version
40
+
41
+ if active is not None and active not in rec.versions:
42
+ issues.append(
43
+ DoctorIssue(sid, "error", f"active pointer {active!r} not in versions",
44
+ kind="dangling_active")
45
+ )
46
+
47
+ for ver, sv in rec.versions.items():
48
+ try:
49
+ raw = reg.version_text(sid, ver)
50
+ except RegistryError:
51
+ issues.append(
52
+ DoctorIssue(sid, "error", f"{ver}: SKILL.md file missing",
53
+ kind="file_missing", version=ver)
54
+ )
55
+ continue
56
+ if content_hash(raw) != sv.artifact_hash:
57
+ issues.append(
58
+ DoctorIssue(sid, "error", f"{ver}: content hash mismatch (tampered/corrupt)",
59
+ kind="hash_mismatch", version=ver)
60
+ )
61
+ if sv.frontmatter.name != sid:
62
+ issues.append(
63
+ DoctorIssue(sid, "warn",
64
+ f"{ver}: frontmatter name {sv.frontmatter.name!r} != skill_id",
65
+ kind="name_mismatch", version=ver)
66
+ )
67
+
68
+ if host_dir is not None and active is not None and active in rec.versions:
69
+ host_md = host_dir / sid / "SKILL.md"
70
+ if not host_md.exists():
71
+ issues.append(
72
+ DoctorIssue(sid, "warn", "active version not materialized to host",
73
+ kind="host_missing")
74
+ )
75
+ elif content_hash(host_md.read_text(encoding="utf-8")) != content_hash(
76
+ reg.version_text(sid, active)
77
+ ):
78
+ issues.append(
79
+ DoctorIssue(sid, "warn",
80
+ "host SKILL.md differs from active version (edited/stale)",
81
+ kind="host_drift")
82
+ )
83
+
84
+ return issues
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RepairAction:
89
+ issue: DoctorIssue
90
+ action: str # what was attempted
91
+ ok: bool
92
+
93
+
94
+ def repair(
95
+ reg: Registry, host_dir: Path | None = None
96
+ ) -> tuple[list[RepairAction], list[DoctorIssue]]:
97
+ """Fix the mechanically-fixable issues, then RE-VERIFY.
98
+
99
+ Returns (actions attempted, issues still present after re-check). The caller
100
+ decides exit status from the *remaining* issues, never from attempts."""
101
+ actions: list[RepairAction] = []
102
+ for issue in check_registry(reg, host_dir):
103
+ if issue.kind in ("hash_mismatch", "file_missing") and issue.version is not None:
104
+ rel = f"registry/skills/{issue.skill_id}/versions/{issue.version}/SKILL.md"
105
+ try:
106
+ reg._git("checkout", "HEAD", "--", rel)
107
+ actions.append(RepairAction(issue, f"restored {rel} from git HEAD", True))
108
+ except RegistryError as e:
109
+ actions.append(RepairAction(issue, f"git restore failed: {e}", False))
110
+ elif issue.kind in ("host_missing", "host_drift") and host_dir is not None:
111
+ try:
112
+ reg.materialize(issue.skill_id, host_dir)
113
+ actions.append(RepairAction(issue, "re-materialized active version to host", True))
114
+ except RegistryError as e:
115
+ actions.append(RepairAction(issue, f"materialize failed: {e}", False))
116
+ # dangling_active / name_mismatch need judgment — left for the user.
117
+
118
+ remaining = check_registry(reg, host_dir)
119
+ return actions, remaining
@@ -0,0 +1,93 @@
1
+ """eval-lite: the deterministic hard-gate layer (docs/04 §1.6, FR-EVAL-2).
2
+
3
+ At personal scale (n=1–3, corpus <~150 cases) the deterministic layer is THE
4
+ hard gate; the statistical layer (precision/recall, +5pp, bootstrap CI) is
5
+ diagnostic only and needs a real corpus, so it is not run here. The four-arm
6
+ protocol degrades to a No Skill / Skill two-arm — and at WS there is neither a
7
+ golden corpus nor an agent harness to run it, so that arm is honestly labelled
8
+ ``Insufficient Evidence`` rather than skipped or faked (docs/04 §1.6 统计诚实).
9
+
10
+ What is checked here, without an LLM: structure/schema, zero credential/PII leak
11
+ in the SKILL.md text, and the agentskills body token budget. The instruction
12
+ gate (§2.4bis) is a separate hard gate run first in ``candidate.approve``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+
20
+ from .redact import redact_text
21
+ from .skillmd import SkillMdError, parse
22
+
23
+ # agentskills.io: SKILL.md body should stay under ~5000 tokens. We have no
24
+ # tokenizer at WS, so approximate (~0.75 words/token) — deliberately conservative.
25
+ TOKEN_BUDGET = 5000
26
+ _WORDS_PER_TOKEN = 0.75
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class EvalCheck:
31
+ name: str
32
+ passed: bool
33
+ detail: str = ""
34
+
35
+
36
+ @dataclass
37
+ class EvalReport:
38
+ checks: list[EvalCheck] = field(default_factory=list)
39
+ # The No Skill/Skill two-arm needs a corpus + harness we do not have at WS.
40
+ insufficient_evidence: bool = True
41
+
42
+ @property
43
+ def passed(self) -> bool:
44
+ """Deterministic hard gate: every check must pass (一票否决)."""
45
+ return all(c.passed for c in self.checks)
46
+
47
+ def failures(self) -> list[EvalCheck]:
48
+ return [c for c in self.checks if not c.passed]
49
+
50
+
51
+ class EvalError(RuntimeError):
52
+ """Raised when the deterministic gate fails. Carries the report."""
53
+
54
+ def __init__(self, report: EvalReport) -> None:
55
+ self.report = report
56
+ names = ", ".join(c.name for c in report.failures())
57
+ super().__init__(f"eval-lite deterministic gate failed: {names}")
58
+
59
+
60
+ def _approx_tokens(text: str) -> int:
61
+ return int(len(re.findall(r"\S+", text)) / _WORDS_PER_TOKEN)
62
+
63
+
64
+ def eval_lite(raw: str) -> EvalReport:
65
+ """Run the deterministic hard-gate checks on a candidate SKILL.md."""
66
+ checks: list[EvalCheck] = []
67
+
68
+ # 1. structure / schema
69
+ try:
70
+ parsed = parse(raw)
71
+ checks.append(EvalCheck("schema", True, "frontmatter valid"))
72
+ except SkillMdError as e:
73
+ return EvalReport(checks=[EvalCheck("schema", False, str(e))])
74
+
75
+ # 2. zero credential / PII leak in the instruction text (凭据与 PII 泄漏 = 0)
76
+ text = f"{parsed.frontmatter.description}\n{parsed.body}"
77
+ _, counts = redact_text(text)
78
+ leaks = {k: c for k, c in counts.items() if k != "home_path"}
79
+ checks.append(
80
+ EvalCheck(
81
+ "no_secret_leak",
82
+ not leaks,
83
+ "clean" if not leaks else f"found {leaks}",
84
+ )
85
+ )
86
+
87
+ # 3. agentskills body token budget
88
+ tok = _approx_tokens(parsed.body)
89
+ checks.append(
90
+ EvalCheck("token_budget", tok < TOKEN_BUDGET, f"~{tok} tokens (limit {TOKEN_BUDGET})")
91
+ )
92
+
93
+ return EvalReport(checks=checks, insufficient_evidence=True)
super_skill/gate.py ADDED
@@ -0,0 +1,102 @@
1
+ """Instruction-layer adversarial gate (docs/04 §2.4bis) — WS rule-based form.
2
+
3
+ v1's ONLY mandatory security gate. v1 candidates are pure text with no scripts,
4
+ so the host Agent treats the SKILL.md body + description as TRUSTED instructions
5
+ and runs them in the user's real session — outside any sandbox, with full
6
+ credentials. A poisoned imperative distilled from captured content (dependency
7
+ errors, READMEs, issues, web pages) is exactly the code×instruction seam that
8
+ MalSkillBench shows single-tool scanners miss.
9
+
10
+ This pass scans body + description for external-action imperatives. Per §2.4bis a
11
+ T1 pure-text skill needs *higher*, not lower, injection scrutiny, so it errs
12
+ toward flagging: a false positive costs one human edit; a miss ships a
13
+ `curl | bash` to the host. The cross-modal (script↔manifest) and LLM-judge layers
14
+ land at M1 — v1 candidates carry no scripts, so there is nothing to cross-check.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass
21
+
22
+ from .skillmd import parse
23
+
24
+ # (category, pattern). Case-insensitive. Ordered most-severe first.
25
+ _RULES: list[tuple[str, re.Pattern[str]]] = [
26
+ (
27
+ "pipe_to_shell",
28
+ re.compile(
29
+ r"(?i)\b(?:curl|wget|fetch)\b[^\n|]{0,200}\|\s*"
30
+ r"(?:bash|sh|zsh|python3?|node|ruby|perl|eval)\b"
31
+ ),
32
+ ),
33
+ (
34
+ "run_undeclared_script",
35
+ re.compile(
36
+ r"(?i)\beval\s*\(|\bexec\s*\(|\bos\.system\b|\bsubprocess\.\w+"
37
+ r"|\bchmod\s+\+x\b|\bsudo\b|\bnpx\s+\S|"
38
+ r"(?:run|execute)\s+(?:this|the\s+following|the\s+attached)\s+\w*\s*script"
39
+ ),
40
+ ),
41
+ (
42
+ "credential_access",
43
+ re.compile(
44
+ r"(?i)~/\.ssh|~/\.aws|\bid_rsa\b|\.env\b|\bprivate[_-]?key\b"
45
+ r"|\b(?:API[_-]?KEY|SECRET[_-]?KEY|ACCESS[_-]?KEY|AWS_SECRET_ACCESS_KEY)\b"
46
+ r"|\b(?:password|passwd)\b"
47
+ ),
48
+ ),
49
+ (
50
+ "network_fetch",
51
+ re.compile(
52
+ r"(?i)\b(?:curl|wget)\b"
53
+ r"|\b(?:download|fetch|retrieve)\b[^\n]{0,60}https?://"
54
+ ),
55
+ ),
56
+ (
57
+ "prompt_injection_override",
58
+ re.compile(
59
+ r"(?i)ignore\s+(?:all\s+)?(?:previous|prior|above)"
60
+ r"|disregard\s+(?:the\s+)?(?:above|previous|prior)"
61
+ r"|forget\s+(?:all\s+)?(?:previous|prior)"
62
+ r"|you\s+are\s+now\b|override\s+[^\n]{0,40}instructions"
63
+ ),
64
+ ),
65
+ ]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Finding:
70
+ category: str
71
+ location: str # "description" | "body"
72
+ snippet: str
73
+
74
+
75
+ class InstructionGateError(RuntimeError):
76
+ """Raised when the gate blocks a promotion. Carries the findings so callers
77
+ can report exactly what tripped it."""
78
+
79
+ def __init__(self, findings: list[Finding]) -> None:
80
+ self.findings = findings
81
+ cats = ", ".join(sorted({f.category for f in findings}))
82
+ super().__init__(f"instruction-layer gate blocked: {cats}")
83
+
84
+
85
+ def scan_text(text: str, location: str) -> list[Finding]:
86
+ findings: list[Finding] = []
87
+ for category, pat in _RULES:
88
+ for m in pat.finditer(text):
89
+ snippet = " ".join(m.group(0).split())[:80]
90
+ findings.append(Finding(category=category, location=location, snippet=snippet))
91
+ return findings
92
+
93
+
94
+ def scan_skill_md(raw: str) -> list[Finding]:
95
+ """Scan a SKILL.md's description + body for external-action imperatives.
96
+
97
+ description is scanned separately: §2.4bis forbids imperative / second-person
98
+ external-action language there (it would both win routing and inject)."""
99
+ parsed = parse(raw)
100
+ return scan_text(parsed.frontmatter.description, "description") + scan_text(
101
+ parsed.body, "body"
102
+ )
super_skill/hooks.py ADDED
@@ -0,0 +1,33 @@
1
+ """Claude Code hooks config generation (docs/03 WS: capture from day 1).
2
+
3
+ Produces the ``hooks`` block that wires the six host events to
4
+ ``super-skill capture`` so real sessions accumulate into the WAL. We only
5
+ *generate* the config — writing it into ``~/.claude/settings.json`` is the
6
+ user's call (user-global config is §5 hard-AUTH), so ``super-skill hooks-config``
7
+ prints it for the user to merge, rather than editing settings behind their back.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ # Events without a per-tool matcher fire once; PreToolUse/PostToolUse fire
15
+ # per tool call and take a matcher.
16
+ _EVENTS_NO_MATCHER = ("SessionStart", "UserPromptSubmit", "Stop", "SessionEnd")
17
+ _EVENTS_WITH_MATCHER = ("PreToolUse", "PostToolUse")
18
+ ALL_EVENTS = _EVENTS_NO_MATCHER + _EVENTS_WITH_MATCHER
19
+
20
+ DEFAULT_COMMAND = "super-skill capture"
21
+
22
+
23
+ def hooks_settings(command: str = DEFAULT_COMMAND) -> dict[str, Any]:
24
+ """Return a settings.json fragment: every host event piped to ``command``.
25
+
26
+ capture reads the hook JSON on stdin and never fails the session (NFR-3)."""
27
+ run = {"hooks": [{"type": "command", "command": command}]}
28
+ hooks: dict[str, list[dict[str, Any]]] = {}
29
+ for ev in _EVENTS_NO_MATCHER:
30
+ hooks[ev] = [dict(run)]
31
+ for ev in _EVENTS_WITH_MATCHER:
32
+ hooks[ev] = [{"matcher": "*", **run}]
33
+ return {"hooks": hooks}
super_skill/mine.py ADDED
@@ -0,0 +1,101 @@
1
+ """Coarse opportunity mining (docs/03 WS: "surface you solved X 3 times").
2
+
3
+ Clusters captured events into task families by keyword bigrams shared across
4
+ DISTINCT sessions, surfacing families that recur >=3 sessions — the FR-GEN-1
5
+ primary signal. This is the WS heuristic; the M2 Opportunity Miner with weighted
6
+ scoring (frequency/failure/verifier availability/...) is later.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections import defaultdict
13
+ from collections.abc import Iterable
14
+ from dataclasses import dataclass, field
15
+
16
+ from .schemas import CaptureEvent
17
+
18
+ _STOP = frozenset(
19
+ "the a an and or of to in on for with without into from at by is are was were be this"
20
+ " that these those it its as not no yes do did use used run running file files code"
21
+ " error add change fix fixed test tests value values case cases new get set".split()
22
+ )
23
+
24
+ # Hook-envelope keys the CLI dumps wholesale into payload. They are metadata, not
25
+ # task content, and dominate families ("userpromptsubmit x", cwd-derived slugs) if
26
+ # mined — coarse mining should read content, not the envelope.
27
+ _ENVELOPE_KEYS = frozenset(
28
+ "hook_event_name session_id event_id transcript_path cwd permission_mode"
29
+ " timestamp consent_scope tool_name".split()
30
+ )
31
+
32
+ # Redaction leaves ``[REDACTED:kind]`` / ``~`` placeholders; drop the whole
33
+ # placeholder so "redacted" and the kind name don't become mined tokens.
34
+ _REDACTION_RE = re.compile(r"\[REDACTED:[^\]]*\]")
35
+
36
+
37
+ def _tokens(text: str) -> list[str]:
38
+ text = _REDACTION_RE.sub(" ", text)
39
+ out = []
40
+ for t in re.sub(r"[^a-z0-9/_.\- ]", " ", text.lower()).split():
41
+ t = t.strip("-./_")
42
+ if len(t) >= 3 and not t.isdigit() and t not in _STOP:
43
+ out.append(t)
44
+ return out
45
+
46
+
47
+ def _event_text(ev: CaptureEvent) -> str:
48
+ parts: list[str] = []
49
+
50
+ def walk(o: object) -> None:
51
+ if isinstance(o, str):
52
+ parts.append(o)
53
+ elif isinstance(o, dict):
54
+ for k, v in o.items():
55
+ if k not in _ENVELOPE_KEYS: # skip envelope metadata, mine content
56
+ walk(v)
57
+ elif isinstance(o, list):
58
+ for v in o:
59
+ walk(v)
60
+
61
+ walk(ev.payload)
62
+ return " ".join(parts)
63
+
64
+
65
+ @dataclass
66
+ class OpportunityFamily:
67
+ label: str
68
+ session_count: int
69
+ event_count: int
70
+ projects: set[str] = field(default_factory=set)
71
+
72
+
73
+ def mine_families(
74
+ events: Iterable[CaptureEvent], *, min_sessions: int = 3
75
+ ) -> list[OpportunityFamily]:
76
+ """Return recurring keyword-bigram families sorted by session recurrence."""
77
+ bg_sessions: dict[str, set[str]] = defaultdict(set)
78
+ bg_events: dict[str, int] = defaultdict(int)
79
+ bg_projects: dict[str, set[str]] = defaultdict(set)
80
+
81
+ for ev in events:
82
+ toks = _tokens(_event_text(ev))
83
+ grams = {f"{toks[i]} {toks[i + 1]}" for i in range(len(toks) - 1)}
84
+ for g in grams:
85
+ bg_sessions[g].add(ev.session_id)
86
+ bg_events[g] += 1
87
+ if ev.project_id:
88
+ bg_projects[g].add(ev.project_id)
89
+
90
+ families = [
91
+ OpportunityFamily(
92
+ label=g,
93
+ session_count=len(s),
94
+ event_count=bg_events[g],
95
+ projects=bg_projects[g],
96
+ )
97
+ for g, s in bg_sessions.items()
98
+ if len(s) >= min_sessions
99
+ ]
100
+ families.sort(key=lambda f: (-f.session_count, -f.event_count))
101
+ return families
@@ -0,0 +1,57 @@
1
+ """Mine watermark (D#67): remember how many distinct sessions had been captured
2
+ the last time the user mined, so ``status``/``mine`` can nudge once enough new
3
+ sessions have accumulated — "you solved X across N unmined sessions, run mine".
4
+
5
+ Deliberately tiny: one JSON file in the state root, overwritten (never appended)
6
+ each mine. Missing/corrupt reads as 0 so a fresh or hand-broken state degrades to
7
+ "everything is unmined" rather than crashing a read-only status.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ _FILE = "mine_state.json"
17
+ _DEFAULT_THRESHOLD = 3
18
+
19
+
20
+ def reminder_threshold() -> int:
21
+ """Distinct unmined sessions before status nudges (env-overridable)."""
22
+ raw = os.environ.get("SUPER_SKILL_MINE_REMINDER")
23
+ if raw is None:
24
+ return _DEFAULT_THRESHOLD
25
+ try:
26
+ return int(raw)
27
+ except ValueError:
28
+ return _DEFAULT_THRESHOLD
29
+
30
+
31
+ def _path(root: Path) -> Path:
32
+ return root / _FILE
33
+
34
+
35
+ def mined_sessions(root: Path) -> int:
36
+ """Distinct-session count recorded at the last mine (0 if absent/corrupt)."""
37
+ p = _path(root)
38
+ if not p.exists():
39
+ return 0
40
+ try:
41
+ data = json.loads(p.read_text(encoding="utf-8"))
42
+ return int(data["mined_sessions"])
43
+ except (ValueError, KeyError, TypeError):
44
+ return 0
45
+
46
+
47
+ def record_mined(root: Path, session_count: int) -> None:
48
+ """Persist the current distinct-session count as the new watermark."""
49
+ root.mkdir(parents=True, exist_ok=True)
50
+ _path(root).write_text(
51
+ json.dumps({"mined_sessions": session_count}), encoding="utf-8"
52
+ )
53
+
54
+
55
+ def unmined(root: Path, current_sessions: int) -> int:
56
+ """Distinct sessions captured since the last mine (clamped at 0)."""
57
+ return max(0, current_sessions - mined_sessions(root))
super_skill/redact.py ADDED
@@ -0,0 +1,95 @@
1
+ """Regex redaction (WS degraded form of FR-CAP-2).
2
+
3
+ Runs BEFORE anything is written to the WAL: secret VALUES never reach disk — only
4
+ the kind and the field where one was found are recorded (FR-CAP-2, §8 SAFETY).
5
+ This is the WS regex pass; the full M2 double-redaction + allowlist is later.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from .schemas import RedactionMark
16
+
17
+ _HOME = os.path.expanduser("~")
18
+
19
+ # (kind, pattern, group). Ordered specific -> generic; earlier wins on overlap.
20
+ # group 0 = redact whole match; group N = redact only that capture, keep context.
21
+ _PATTERNS: list[tuple[str, re.Pattern[str], int]] = [
22
+ ("private_key", re.compile(
23
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
24
+ re.DOTALL,
25
+ ), 0),
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),
28
+ ("github_token", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), 0),
29
+ ("aws_key", re.compile(r"AKIA[0-9A-Z]{16}"), 0),
30
+ ("slack_token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), 0),
31
+ ("bearer_token", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*"), 0),
32
+ ("assigned_secret", re.compile(
33
+ r"(?i)\b(?:api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b"
34
+ r"\s*[:=]\s*['\"]?([^\s'\"]{6,})",
35
+ ), 1),
36
+ ("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), 0),
37
+ ]
38
+
39
+ # Private home path -> ~ (not a secret, but FR-CAP-2 lists 私有路径).
40
+ _HOME_PATTERNS: list[re.Pattern[str]] = [
41
+ re.compile(re.escape(_HOME)),
42
+ re.compile(r"/home/[^/\s'\"]+"),
43
+ re.compile(r"/Users/[^/\s'\"]+"),
44
+ ]
45
+
46
+
47
+ def redact_text(text: str) -> tuple[str, dict[str, int]]:
48
+ """Return (redacted_text, {kind: count}). Secret values are replaced with a
49
+ ``[REDACTED:kind]`` token; home paths collapse to ``~``."""
50
+ counts: dict[str, int] = {}
51
+
52
+ def _make_repl(kind: str, group: int, token: str) -> Callable[[re.Match[str]], str]:
53
+ def _r(m: re.Match[str]) -> str:
54
+ counts[kind] = counts.get(kind, 0) + 1
55
+ return token if group == 0 else m.group(0).replace(m.group(group), token)
56
+
57
+ return _r
58
+
59
+ for kind, pat, group in _PATTERNS:
60
+ text = pat.sub(_make_repl(kind, group, f"[REDACTED:{kind}]"), text)
61
+
62
+ # home paths collapse to ~ (private path, not a secret value)
63
+ for pat in _HOME_PATTERNS:
64
+ text = pat.sub(_make_repl("home_path", 0, "~"), text)
65
+
66
+ return text, counts
67
+
68
+
69
+ def redact_payload(obj: Any, _path: str = "") -> tuple[Any, list[RedactionMark]]:
70
+ """Recursively redact all string leaves in a JSON-like structure, collecting
71
+ marks that record kind + dotted field path (never the value)."""
72
+ marks: list[RedactionMark] = []
73
+ if isinstance(obj, str):
74
+ red, counts = redact_text(obj)
75
+ marks.extend(
76
+ RedactionMark(kind=k, location=_path or "(root)", count=c)
77
+ for k, c in counts.items()
78
+ )
79
+ return red, marks
80
+ if isinstance(obj, dict):
81
+ out_d: dict[str, Any] = {}
82
+ for k, v in obj.items():
83
+ child = f"{_path}.{k}" if _path else str(k)
84
+ out_d[k], sub = redact_payload(v, child)
85
+ marks.extend(sub)
86
+ return out_d, marks
87
+ if isinstance(obj, list):
88
+ out_l: list[Any] = []
89
+ for i, v in enumerate(obj):
90
+ child = f"{_path}[{i}]"
91
+ rv, sub = redact_payload(v, child)
92
+ out_l.append(rv)
93
+ marks.extend(sub)
94
+ return out_l, marks
95
+ return obj, marks