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.
- skills_manager/__init__.py +3 -0
- skills_manager/adapters.py +62 -0
- skills_manager/agents.py +231 -0
- skills_manager/checks/__init__.py +58 -0
- skills_manager/checks/rules.py +277 -0
- skills_manager/cli.py +622 -0
- skills_manager/commands/__init__.py +0 -0
- skills_manager/commands/agent.py +63 -0
- skills_manager/commands/check.py +18 -0
- skills_manager/commands/doctor.py +13 -0
- skills_manager/commands/filter.py +95 -0
- skills_manager/commands/import_cmd.py +41 -0
- skills_manager/commands/info.py +84 -0
- skills_manager/commands/install.py +72 -0
- skills_manager/commands/list_cmd.py +17 -0
- skills_manager/commands/prune.py +24 -0
- skills_manager/commands/regenerate_config.py +24 -0
- skills_manager/commands/scan.py +63 -0
- skills_manager/commands/schedule.py +61 -0
- skills_manager/commands/sync_cmd.py +32 -0
- skills_manager/commands/uninstall.py +70 -0
- skills_manager/commands/unregister.py +63 -0
- skills_manager/commands/update.py +56 -0
- skills_manager/commands/upgrade.py +55 -0
- skills_manager/config.py +157 -0
- skills_manager/context.py +13 -0
- skills_manager/doctor.py +54 -0
- skills_manager/execution_log.py +43 -0
- skills_manager/home.py +52 -0
- skills_manager/importer.py +277 -0
- skills_manager/install.py +330 -0
- skills_manager/lockfile.py +84 -0
- skills_manager/pickers.py +63 -0
- skills_manager/ports.py +28 -0
- skills_manager/presenters.py +262 -0
- skills_manager/registry.py +87 -0
- skills_manager/schedule.py +297 -0
- skills_manager/security.py +54 -0
- skills_manager/source.py +537 -0
- skills_manager/sync.py +112 -0
- skills_manager/uninstall.py +110 -0
- skills_manager/unregister.py +72 -0
- skills_manager/update.py +395 -0
- skills_manager/upgrade.py +292 -0
- skills_manager_cli-0.1.0.dist-info/METADATA +298 -0
- skills_manager_cli-0.1.0.dist-info/RECORD +49 -0
- skills_manager_cli-0.1.0.dist-info/WHEEL +4 -0
- skills_manager_cli-0.1.0.dist-info/entry_points.txt +2 -0
- skills_manager_cli-0.1.0.dist-info/licenses/LICENSE +21 -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
|
skills_manager/agents.py
ADDED
|
@@ -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
|
+
)
|