skills-manager-cli 0.1.0__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.
Files changed (49) hide show
  1. skills_manager/__init__.py +3 -0
  2. skills_manager/adapters.py +62 -0
  3. skills_manager/agents.py +231 -0
  4. skills_manager/checks/__init__.py +58 -0
  5. skills_manager/checks/rules.py +277 -0
  6. skills_manager/cli.py +622 -0
  7. skills_manager/commands/__init__.py +0 -0
  8. skills_manager/commands/agent.py +63 -0
  9. skills_manager/commands/check.py +18 -0
  10. skills_manager/commands/doctor.py +13 -0
  11. skills_manager/commands/filter.py +95 -0
  12. skills_manager/commands/import_cmd.py +41 -0
  13. skills_manager/commands/info.py +84 -0
  14. skills_manager/commands/install.py +72 -0
  15. skills_manager/commands/list_cmd.py +17 -0
  16. skills_manager/commands/prune.py +24 -0
  17. skills_manager/commands/regenerate_config.py +24 -0
  18. skills_manager/commands/scan.py +63 -0
  19. skills_manager/commands/schedule.py +61 -0
  20. skills_manager/commands/sync_cmd.py +32 -0
  21. skills_manager/commands/uninstall.py +70 -0
  22. skills_manager/commands/unregister.py +63 -0
  23. skills_manager/commands/update.py +56 -0
  24. skills_manager/commands/upgrade.py +55 -0
  25. skills_manager/config.py +157 -0
  26. skills_manager/context.py +13 -0
  27. skills_manager/doctor.py +54 -0
  28. skills_manager/execution_log.py +43 -0
  29. skills_manager/home.py +52 -0
  30. skills_manager/importer.py +277 -0
  31. skills_manager/install.py +330 -0
  32. skills_manager/lockfile.py +84 -0
  33. skills_manager/pickers.py +63 -0
  34. skills_manager/ports.py +28 -0
  35. skills_manager/presenters.py +262 -0
  36. skills_manager/registry.py +87 -0
  37. skills_manager/schedule.py +297 -0
  38. skills_manager/security.py +54 -0
  39. skills_manager/source.py +537 -0
  40. skills_manager/sync.py +112 -0
  41. skills_manager/uninstall.py +110 -0
  42. skills_manager/unregister.py +72 -0
  43. skills_manager/update.py +395 -0
  44. skills_manager/upgrade.py +292 -0
  45. skills_manager_cli-0.1.0.dist-info/METADATA +298 -0
  46. skills_manager_cli-0.1.0.dist-info/RECORD +49 -0
  47. skills_manager_cli-0.1.0.dist-info/WHEEL +4 -0
  48. skills_manager_cli-0.1.0.dist-info/entry_points.txt +2 -0
  49. skills_manager_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """skills-manager: a CLI to handle agent skills."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,62 @@
1
+ """Real adapters for ports; tests use the fakes defined here."""
2
+
3
+ import subprocess
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from .ports import ProcessResult
10
+
11
+
12
+ class TyperConsole:
13
+ def echo(self, message: str = "", *, err: bool = False) -> None:
14
+ typer.echo(message, err=err)
15
+
16
+
17
+ class BufferConsole:
18
+ def __init__(self) -> None:
19
+ self.lines: list[str] = []
20
+ self.errors: list[str] = []
21
+
22
+ def echo(self, message: str = "", *, err: bool = False) -> None:
23
+ (self.errors if err else self.lines).append(message)
24
+
25
+
26
+ class SubprocessGitRunner:
27
+ def run(self, args: list[str], cwd: Path) -> ProcessResult:
28
+ completed = subprocess.run(
29
+ ["git", *args], cwd=cwd, capture_output=True, text=True, check=False
30
+ )
31
+ return ProcessResult(completed.returncode, completed.stdout, completed.stderr)
32
+
33
+
34
+ class SubprocessRunner:
35
+ def run(self, args: list[str], cwd: Path) -> ProcessResult:
36
+ completed = subprocess.run(
37
+ list(args), cwd=cwd, capture_output=True, text=True, check=False
38
+ )
39
+ return ProcessResult(completed.returncode, completed.stdout, completed.stderr)
40
+
41
+
42
+ class FakeGitRunner:
43
+ def __init__(self, results: list[ProcessResult] | None = None) -> None:
44
+ self._results = list(results or [ProcessResult(0, "", "")])
45
+ self.calls: list = []
46
+
47
+ def run(self, args: list[str], cwd: Path) -> ProcessResult:
48
+ self.calls.append((list(args), Path(cwd)))
49
+ return self._results[min(len(self.calls) - 1, len(self._results) - 1)]
50
+
51
+
52
+ class SystemClock:
53
+ def now(self) -> datetime:
54
+ return datetime.now()
55
+
56
+
57
+ class FakeClock:
58
+ def __init__(self, moment: datetime) -> None:
59
+ self._moment = moment
60
+
61
+ def now(self) -> datetime:
62
+ return self._moment
@@ -0,0 +1,231 @@
1
+ import os
2
+ import shutil
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .config import Config, load_config, save_config
7
+
8
+ SKILLS_DIR = "skills"
9
+
10
+
11
+ class AgentLinkError(RuntimeError):
12
+ """Raised when a managed symlink cannot be created or removed."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Agent:
17
+ name: str
18
+ base_dir: str
19
+ skills_dir: str
20
+
21
+ def base(self, user_home: Path) -> Path:
22
+ return Path(user_home) / self.base_dir
23
+
24
+ def skills(self, user_home: Path) -> Path:
25
+ return Path(user_home) / self.skills_dir
26
+
27
+
28
+ SUPPORTED_AGENTS: dict[str, Agent] = {
29
+ "claude": Agent("claude", ".claude", ".claude/skills"),
30
+ "opencode": Agent("opencode", ".config/opencode", ".config/opencode/skills"),
31
+ "codex": Agent("codex", ".codex", ".codex/skills"),
32
+ "cursor": Agent("cursor", ".cursor", ".cursor/skills"),
33
+ "gemini": Agent("gemini", ".gemini", ".gemini/skills"),
34
+ }
35
+
36
+
37
+ def detect_agents(user_home: Path | None = None) -> list[Agent]:
38
+ root = Path(user_home) if user_home is not None else Path.home()
39
+ return [agent for agent in SUPPORTED_AGENTS.values() if agent.base(root).is_dir()]
40
+
41
+
42
+ class AgentState:
43
+ """Which agents receive skill links (backed by the config file)."""
44
+
45
+ def __init__(self, home: Path):
46
+ self.home = Path(home)
47
+ self._cached: Config | None = None
48
+
49
+ def _load(self) -> Config:
50
+ if self._cached is None:
51
+ self._cached = load_config(self.home)
52
+ return self._cached
53
+
54
+ def is_enabled(self, name: str) -> bool:
55
+ return name not in self._load().disabled
56
+
57
+ def set_enabled(self, name: str, enabled: bool) -> None:
58
+ config = self._load()
59
+ disabled = set(config.disabled)
60
+ if enabled:
61
+ disabled.discard(name)
62
+ else:
63
+ disabled.add(name)
64
+ updated = Config(config.search_paths, sorted(disabled))
65
+ save_config(self.home, updated)
66
+ self._cached = updated
67
+
68
+
69
+ def installed_skills(home: Path) -> list[str]:
70
+ root = Path(home) / SKILLS_DIR
71
+ if not root.is_dir():
72
+ return []
73
+ return sorted(entry.name for entry in root.iterdir() if entry.is_dir())
74
+
75
+
76
+ def relink_agents(
77
+ home: Path,
78
+ names: list[str],
79
+ user_home: Path | None = None,
80
+ *,
81
+ take_over: bool = False,
82
+ ) -> None:
83
+ """Ensure managed links for skills across every detected agent."""
84
+ root = Path(user_home) if user_home is not None else Path.home()
85
+ for agent in detect_agents(root):
86
+ for name in names:
87
+ relink_skill(agent, home, name, user_home=root, take_over=take_over)
88
+ root = Path(home) / SKILLS_DIR
89
+ if not root.is_dir():
90
+ return []
91
+ return sorted(entry.name for entry in root.iterdir() if entry.is_dir())
92
+
93
+
94
+ def is_linked(
95
+ agent: Agent,
96
+ home: Path,
97
+ name: str,
98
+ user_home: Path | None = None,
99
+ ) -> bool:
100
+ """Return whether the agent folder holds a managed link for a skill."""
101
+ root = Path(user_home) if user_home is not None else Path.home()
102
+ if not agent.base(root).is_dir():
103
+ return False
104
+ link = agent.skills(root) / name
105
+ return bool(link.is_symlink() and _points_into(link, (Path(home) / SKILLS_DIR).resolve()))
106
+
107
+
108
+ def unlink_skills(
109
+ agent: Agent,
110
+ home: Path,
111
+ names: list[str],
112
+ user_home: Path | None = None,
113
+ ) -> None:
114
+ """Remove ``names`` from one agent folder when managed by this tool.
115
+
116
+ Only symlinks pointing into ``<home>/skills`` are removed; anything
117
+ else (real directories, foreign links, missing entries) is left alone.
118
+ """
119
+ root = Path(user_home) if user_home is not None else Path.home()
120
+ skills_root = (Path(home) / SKILLS_DIR).resolve()
121
+ agent_skills = agent.skills(root)
122
+ if not agent_skills.is_dir():
123
+ return
124
+ for name in names:
125
+ link = agent_skills / name
126
+ if link.is_symlink() and _points_into(link, skills_root):
127
+ try:
128
+ link.unlink()
129
+ except OSError as exc:
130
+ raise AgentLinkError(
131
+ f"could not unlink {name!r} for {agent.name}: {exc}"
132
+ ) from exc
133
+
134
+
135
+ def unlink_agents(
136
+ home: Path,
137
+ names: list[str],
138
+ user_home: Path | None = None,
139
+ ) -> None:
140
+ """Remove managed links for skills across every detected agent."""
141
+ root = Path(user_home) if user_home is not None else Path.home()
142
+ for agent in detect_agents(root):
143
+ unlink_skills(agent, home, names, user_home=root)
144
+
145
+
146
+ def _points_into(link: Path, root: Path) -> bool:
147
+ try:
148
+ resolved = link.resolve()
149
+ except OSError:
150
+ return False
151
+ return resolved == root or root in resolved.parents
152
+
153
+
154
+ def reconcile(
155
+ agent: Agent,
156
+ home: Path,
157
+ *,
158
+ enable: bool,
159
+ user_home: Path | None = None,
160
+ ) -> None:
161
+ root = Path(user_home) if user_home is not None else Path.home()
162
+ skills_root = (Path(home) / SKILLS_DIR).resolve()
163
+ agent_skills = agent.skills(root)
164
+
165
+ if enable:
166
+ agent_skills.mkdir(parents=True, exist_ok=True)
167
+ elif not agent_skills.is_dir():
168
+ return
169
+
170
+ for name in installed_skills(home):
171
+ link = agent_skills / name
172
+ if enable:
173
+ if link.is_symlink() or link.exists():
174
+ continue
175
+ try:
176
+ os.symlink(skills_root / name, link)
177
+ except OSError as exc:
178
+ raise AgentLinkError(
179
+ f"could not link {name!r} for {agent.name}: {exc}"
180
+ ) from exc
181
+ elif link.is_symlink() and _points_into(link, skills_root):
182
+ try:
183
+ link.unlink()
184
+ except OSError as exc:
185
+ raise AgentLinkError(
186
+ f"could not unlink {name!r} for {agent.name}: {exc}"
187
+ ) from exc
188
+
189
+
190
+ def relink_skill(
191
+ agent: Agent,
192
+ home: Path,
193
+ name: str,
194
+ user_home: Path | None = None,
195
+ *,
196
+ take_over: bool = False,
197
+ ) -> None:
198
+ """Ensure one managed link exists for an installed skill.
199
+
200
+ A managed link is left alone; a missing link is created. Anything else
201
+ under the skill's name (a real directory, a file, a foreign link) is
202
+ replaced with a managed link only when ``take_over`` is true.
203
+ """
204
+ root = Path(user_home) if user_home is not None else Path.home()
205
+ skills_root = (Path(home) / SKILLS_DIR).resolve()
206
+ if not agent.base(root).is_dir():
207
+ return
208
+ agent_skills = agent.skills(root)
209
+ link = agent_skills / name
210
+ if link.is_symlink() and _points_into(link, skills_root):
211
+ return
212
+ if link.is_symlink() or link.exists():
213
+ if not take_over:
214
+ return
215
+ try:
216
+ if link.is_dir() and not link.is_symlink():
217
+ shutil.rmtree(link)
218
+ else:
219
+ link.unlink()
220
+ except OSError as exc:
221
+ raise AgentLinkError(
222
+ f"could not take over {name!r} for {agent.name}: {exc}"
223
+ ) from exc
224
+ try:
225
+ agent_skills.mkdir(parents=True, exist_ok=True)
226
+ if not link.is_symlink():
227
+ os.symlink(skills_root / name, link)
228
+ except OSError as exc:
229
+ raise AgentLinkError(
230
+ f"could not link {name!r} for {agent.name}: {exc}"
231
+ ) from exc
@@ -0,0 +1,58 @@
1
+ """Deterministic pre-install rule registry (BL-064, consumed by BL-062)."""
2
+
3
+ import fnmatch
4
+ from collections.abc import Callable, Sequence
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ SEVERITIES = ("low", "medium", "high", "critical")
9
+ _RANK = {name: index for index, name in enumerate(SEVERITIES)}
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Finding:
14
+ check_id: str
15
+ severity: str
16
+ path: str
17
+ message: str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Check:
22
+ id: str
23
+ severity: str
24
+ title: str
25
+ run: Callable[[Path], list[Finding]]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Report:
30
+ findings: tuple = ()
31
+ blocked: bool = False
32
+
33
+
34
+ def _excluded(path: str, patterns: Sequence[str]) -> bool:
35
+ return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)
36
+
37
+
38
+ def run_checks(
39
+ tree: Path,
40
+ *,
41
+ checks: Sequence[Check] = (),
42
+ minimum: str = "medium",
43
+ exclude: Sequence[str] = (),
44
+ exclude_paths: Sequence[str] = (),
45
+ ) -> Report:
46
+ """Run every check over ``tree``; block when findings exceed ``minimum``."""
47
+ tree = Path(tree)
48
+ findings: list[Finding] = []
49
+ for check in checks:
50
+ if check.id in exclude:
51
+ continue
52
+ for finding in check.run(tree):
53
+ rel = str(Path(finding.path).as_posix())
54
+ if _excluded(rel, exclude_paths):
55
+ continue
56
+ findings.append(finding)
57
+ blocked = any(_RANK[f.severity] > _RANK[minimum] for f in findings)
58
+ return Report(findings=tuple(findings), blocked=blocked)
@@ -0,0 +1,277 @@
1
+ """V1 deterministic security rules (BL-062) plus expansion pack (BL-065).
2
+
3
+ Pure read-only walks; never executes skill code.
4
+ """
5
+
6
+ import os
7
+ import re
8
+ import tarfile
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ from . import Check, Finding
13
+
14
+ _SCRIPT_SUFFIXES = frozenset(
15
+ {".sh", ".ps1", ".py", ".js", ".bat", ".cmd", ".exe", ".dll", ".so", ".dylib"}
16
+ )
17
+
18
+
19
+ def _walk_skill(root: Path):
20
+ """Yield (rel_posix, path) for every entry; never descend into symlinked dirs.
21
+
22
+ Hidden files ARE scanned (only `.git` is skipped — it is never
23
+ installed, per `copy_local`); malware loves dotfiles.
24
+ """
25
+ root = Path(root)
26
+ try:
27
+ children = sorted(root.iterdir(), key=lambda e: e.name)
28
+ except OSError:
29
+ return
30
+ for child in children:
31
+ if child.name == ".git":
32
+ continue
33
+ yield (child.relative_to(root).as_posix(), child)
34
+ if child.is_dir() and not child.is_symlink():
35
+ yield from _walk_skill_at(root, child)
36
+
37
+
38
+ def _walk_skill_at(root: Path, base: Path):
39
+ try:
40
+ children = sorted(base.iterdir(), key=lambda e: e.name)
41
+ except OSError:
42
+ return
43
+ for child in children:
44
+ if child.name == ".git":
45
+ continue
46
+ yield (child.relative_to(root).as_posix(), child)
47
+ if child.is_dir() and not child.is_symlink():
48
+ yield from _walk_skill_at(root, child)
49
+
50
+
51
+ def _readlink_text(link: Path) -> str | None:
52
+ try:
53
+ return os.readlink(link)
54
+ except OSError:
55
+ return None
56
+
57
+
58
+ def _skl001_symlink_escape(root: Path) -> list[Finding]:
59
+ findings = []
60
+ norm_root = os.path.normpath(str(root))
61
+ for rel, path in _walk_skill(root):
62
+ if not path.is_symlink():
63
+ continue
64
+ target = _readlink_text(path)
65
+ if target is None:
66
+ continue
67
+ absolute = target if os.path.isabs(target) else os.path.normpath(os.path.join(str(path.parent), target))
68
+ absolute = os.path.normpath(absolute)
69
+ if absolute != norm_root and Path(norm_root) not in Path(absolute).parents:
70
+ findings.append(Finding("SKL001", "high", rel, f"symlink escapes the skill tree: {target}"))
71
+ return findings
72
+
73
+
74
+ def _skl002_parent_reference(root: Path) -> list[Finding]:
75
+ findings = []
76
+ for rel, path in _walk_skill(root):
77
+ if not path.is_symlink():
78
+ continue
79
+ target = _readlink_text(path)
80
+ if target is None:
81
+ continue
82
+ segments = [s for s in target.replace("\\", "/").split("/")]
83
+ if ".." in segments:
84
+ findings.append(Finding("SKL002", "medium", rel, f"suspicious parent reference in link target: {target}"))
85
+ return findings
86
+
87
+
88
+ def _skl003_executables(root: Path) -> list[Finding]:
89
+ findings = []
90
+ for rel, path in _walk_skill(root):
91
+ if path.is_symlink() or not path.is_file():
92
+ continue
93
+ try:
94
+ mode = path.stat().st_mode
95
+ except OSError:
96
+ continue
97
+ if mode & 0o111 or path.suffix.lower() in _SCRIPT_SUFFIXES:
98
+ findings.append(Finding("SKL003", "medium", rel, "executable or script file"))
99
+ return findings
100
+
101
+
102
+ _ARCHIVE_SUFFIXES = frozenset({".tar", ".tar.gz", ".tgz", ".zip"})
103
+
104
+
105
+ def _is_archive(path: Path) -> bool:
106
+ name = path.name.lower()
107
+ return name.endswith(".tar.gz") or path.suffix.lower() in _ARCHIVE_SUFFIXES
108
+
109
+
110
+ def _skl004_archive_members(root: Path) -> list[Finding]:
111
+ findings = []
112
+ for rel, path in _walk_skill(root):
113
+ if path.is_symlink() or not path.is_file() or not _is_archive(path):
114
+ continue
115
+ for member, problem in _archive_problems(path):
116
+ findings.append(Finding("SKL004", "high", rel, f"unsafe archive member {member}: {problem}"))
117
+ return findings
118
+
119
+
120
+ def _archive_problems(path: Path):
121
+ """Yield (member, problem) for traversal/link members; skip unreadable archives silently."""
122
+ name = path.name.lower()
123
+ try:
124
+ if name.endswith(".zip"):
125
+ with zipfile.ZipFile(path) as archive:
126
+ members = [(info.filename, False, False) for info in archive.infolist()]
127
+ else:
128
+ with tarfile.open(path, "r") as archive:
129
+ members = [
130
+ (member.name, member.issym() or member.islnk(), False)
131
+ for member in archive.getmembers()
132
+ ]
133
+ except (OSError, tarfile.TarError, zipfile.BadZipFile):
134
+ return
135
+ for member, is_link, _ in members:
136
+ segments = [s for s in member.replace("\\", "/").split("/")]
137
+ if member.startswith("/") or (len(member) > 1 and member[1] == ":"):
138
+ yield member, "absolute path"
139
+ elif ".." in segments:
140
+ yield member, "parent reference"
141
+ elif is_link:
142
+ yield member, "link member"
143
+
144
+
145
+ _READ_CAP = 262144
146
+
147
+
148
+ def _text_of(path: Path) -> str | None:
149
+ """Best-effort text of a file: None when missing/binary/too costly; capped."""
150
+ try:
151
+ with open(path, "rb") as handle:
152
+ head = handle.read(8192)
153
+ if b"\x00" in head:
154
+ return None
155
+ handle.seek(0)
156
+ return handle.read(_READ_CAP).decode("utf-8", errors="ignore")
157
+ except OSError:
158
+ return None
159
+
160
+
161
+ def _text_files(root: Path):
162
+ """Yield (rel, text) for non-symlink regular files with readable text."""
163
+ for rel, path in _walk_skill(root):
164
+ if path.is_symlink() or not path.is_file():
165
+ continue
166
+ text = _text_of(path)
167
+ if text is not None:
168
+ yield rel, text
169
+
170
+
171
+ _SECRET_KEY = re.compile(r"(?im)^\s*[A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASSWD)[A-Z0-9_]*\s*=")
172
+ _AUTH_LINE = re.compile(r"(?im)^[^#\n]*_auth(token)?[^#\n]*=")
173
+
174
+
175
+ def _skl005_hidden_config(root: Path) -> list[Finding]:
176
+ findings = []
177
+ for rel, path in _walk_skill(root):
178
+ if path.is_symlink() or not path.is_file():
179
+ continue
180
+ lowered = path.name.lower()
181
+ if not (lowered.startswith(".env") or lowered == ".npmrc"):
182
+ continue
183
+ text = _text_of(path)
184
+ if text is None:
185
+ continue
186
+ if _SECRET_KEY.search(text) or _AUTH_LINE.search(text):
187
+ findings.append(Finding("SKL005", "high", rel, "secret in hidden config"))
188
+ return findings
189
+
190
+
191
+ _PERSISTENCE = re.compile(
192
+ r"(?i)(shell:startup|schtasks(\.exe)?\b|hk(cu|lm)\\[^\n]*\\run\b|crontab\b|launchagents|launchdaemons|systemd)"
193
+ )
194
+
195
+
196
+ def _skl006_persistence_hooks(root: Path) -> list[Finding]:
197
+ findings = []
198
+ for rel, text in _text_files(root):
199
+ if _PERSISTENCE.search(text):
200
+ findings.append(Finding("SKL006", "high", rel, "persistence hook"))
201
+ return findings
202
+
203
+
204
+ _REGISTRY = re.compile(r"(?i)(\bread\s+add\b|regedit\b|set-itemproperty\s+[^\n]*hk(cu|lm):)")
205
+
206
+
207
+ def _skl007_registry_changes(root: Path) -> list[Finding]:
208
+ findings = []
209
+ for rel, path in _walk_skill(root):
210
+ if path.is_symlink() or not path.is_file():
211
+ continue
212
+ if path.suffix.lower() == ".reg":
213
+ findings.append(Finding("SKL007", "medium", rel, "windows registry file"))
214
+ continue
215
+ text = _text_of(path)
216
+ if text is not None and _REGISTRY.search(text):
217
+ findings.append(Finding("SKL007", "medium", rel, "registry modification command"))
218
+ return findings
219
+
220
+
221
+ _NATIVE = re.compile(r"(?i)(ctypes|dlopen|loadlibrary|cffi|require\s*\(\s*['\"][^'\"]*\.node['\"]\s*\))")
222
+ _NATIVE_SUFFIXES = frozenset({".node", ".pyd"})
223
+
224
+
225
+ def _skl008_native_loading(root: Path) -> list[Finding]:
226
+ findings = []
227
+ for rel, path in _walk_skill(root):
228
+ if path.is_symlink() or not path.is_file():
229
+ continue
230
+ if path.suffix.lower() in _NATIVE_SUFFIXES:
231
+ findings.append(Finding("SKL008", "high", rel, "native module file"))
232
+ continue
233
+ text = _text_of(path)
234
+ if text is not None and _NATIVE.search(text):
235
+ findings.append(Finding("SKL008", "high", rel, "native code loading call"))
236
+ return findings
237
+
238
+
239
+ _ENV_ACCESS = re.compile(
240
+ r"(?i)(os\.environ|process\.env|\bgetenv\b|env\.var|aws_secret_access_key|gh_token|github_token|openai_api_key|anthropic_api_key)"
241
+ )
242
+
243
+
244
+ def _skl009_environment_access(root: Path) -> list[Finding]:
245
+ findings = []
246
+ for rel, text in _text_files(root):
247
+ if _ENV_ACCESS.search(text):
248
+ findings.append(Finding("SKL009", "medium", rel, "environment secret access"))
249
+ return findings
250
+
251
+
252
+ _B64_BLOB = re.compile(r"[A-Za-z0-9+/]{200,}={0,2}")
253
+ _DYNAMIC_EXEC = re.compile(r"(?i)\b(eval|exec)\s*\(")
254
+
255
+
256
+ def _skl010_evasion(root: Path) -> list[Finding]:
257
+ findings = []
258
+ for rel, text in _text_files(root):
259
+ if _B64_BLOB.search(text):
260
+ findings.append(Finding("SKL010", "medium", rel, "possible obfuscated blob"))
261
+ elif _DYNAMIC_EXEC.search(text):
262
+ findings.append(Finding("SKL010", "medium", rel, "dynamic code execution"))
263
+ return findings
264
+
265
+
266
+ RULES = (
267
+ Check("SKL001", "high", "symlink escape", _skl001_symlink_escape),
268
+ Check("SKL002", "medium", "parent reference in link target", _skl002_parent_reference),
269
+ Check("SKL003", "medium", "executable or script file", _skl003_executables),
270
+ Check("SKL004", "high", "unsafe archive member", _skl004_archive_members),
271
+ Check("SKL005", "high", "secret in hidden config", _skl005_hidden_config),
272
+ Check("SKL006", "high", "persistence hook", _skl006_persistence_hooks),
273
+ Check("SKL007", "medium", "registry change", _skl007_registry_changes),
274
+ Check("SKL008", "high", "native code loading", _skl008_native_loading),
275
+ Check("SKL009", "medium", "environment secret access", _skl009_environment_access),
276
+ Check("SKL010", "medium", "possible obfuscation", _skl010_evasion),
277
+ )