skill-sentry-cli 2.0.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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""SKILL.md / package parsing and manifest validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from skill_sentry_cli.core.cache.hash_utils import HashError, hash_skill_dir
|
|
14
|
+
|
|
15
|
+
MAX_SKILL_MD_BYTES = 1024 * 1024
|
|
16
|
+
MAX_TEXT_FILE_BYTES = 1024 * 1024
|
|
17
|
+
MAX_TEXT_TOTAL_BYTES = 4 * 1024 * 1024
|
|
18
|
+
FRONTMATTER_MAX_BYTES = 256 * 1024
|
|
19
|
+
|
|
20
|
+
_TEXT_SUFFIXES = frozenset(
|
|
21
|
+
{
|
|
22
|
+
".md",
|
|
23
|
+
".markdown",
|
|
24
|
+
".txt",
|
|
25
|
+
".py",
|
|
26
|
+
".sh",
|
|
27
|
+
".bash",
|
|
28
|
+
".zsh",
|
|
29
|
+
".fish",
|
|
30
|
+
".js",
|
|
31
|
+
".mjs",
|
|
32
|
+
".cjs",
|
|
33
|
+
".ts",
|
|
34
|
+
".tsx",
|
|
35
|
+
".jsx",
|
|
36
|
+
".json",
|
|
37
|
+
".yaml",
|
|
38
|
+
".yml",
|
|
39
|
+
".toml",
|
|
40
|
+
".ini",
|
|
41
|
+
".cfg",
|
|
42
|
+
".ps1",
|
|
43
|
+
".rb",
|
|
44
|
+
".go",
|
|
45
|
+
".rs",
|
|
46
|
+
".java",
|
|
47
|
+
".kt",
|
|
48
|
+
".php",
|
|
49
|
+
".pl",
|
|
50
|
+
".lua",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
55
|
+
_INTERPRETERS = frozenset({"python", "python3", "bash", "sh", "node", "deno", "ruby", "php"})
|
|
56
|
+
_ENTRYPOINT_KEYS = (
|
|
57
|
+
"entrypoint",
|
|
58
|
+
"entry_point",
|
|
59
|
+
"entry",
|
|
60
|
+
"command",
|
|
61
|
+
"run",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SkillParseError(RuntimeError):
|
|
66
|
+
"""The directory is not a usable Agent Skill package."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class PackageIssue:
|
|
71
|
+
severity: str # low | medium | high | critical
|
|
72
|
+
message: str
|
|
73
|
+
file: str = "SKILL.md"
|
|
74
|
+
line: int = 1
|
|
75
|
+
code: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class TextFile:
|
|
80
|
+
path: Path
|
|
81
|
+
relative: str
|
|
82
|
+
text: str
|
|
83
|
+
line_count: int
|
|
84
|
+
truncated: bool = False
|
|
85
|
+
|
|
86
|
+
def line(self, number: int) -> str | None:
|
|
87
|
+
if number < 1 or number > self.line_count:
|
|
88
|
+
return None
|
|
89
|
+
return self.text.splitlines()[number - 1]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class SkillPackage:
|
|
94
|
+
root: Path
|
|
95
|
+
name: str
|
|
96
|
+
description: str
|
|
97
|
+
declared_intent: str
|
|
98
|
+
manifest: dict[str, Any]
|
|
99
|
+
frontmatter: dict[str, Any]
|
|
100
|
+
body: str
|
|
101
|
+
skill_md: str
|
|
102
|
+
files: list[Path]
|
|
103
|
+
text_files: list[TextFile]
|
|
104
|
+
entrypoint: list[str] | None
|
|
105
|
+
entrypoint_declared: bool
|
|
106
|
+
content_hash: str
|
|
107
|
+
issues: list[PackageIssue] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def metadata(self) -> dict[str, Any]:
|
|
111
|
+
return (
|
|
112
|
+
self.frontmatter.get("metadata")
|
|
113
|
+
if isinstance(self.frontmatter.get("metadata"), dict)
|
|
114
|
+
else {}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def declared_network(self) -> list[str]:
|
|
119
|
+
permissions = self.frontmatter.get("permissions")
|
|
120
|
+
values: list[str] = []
|
|
121
|
+
if isinstance(permissions, str):
|
|
122
|
+
values.append(permissions)
|
|
123
|
+
elif isinstance(permissions, list):
|
|
124
|
+
values.extend(str(item) for item in permissions)
|
|
125
|
+
elif isinstance(permissions, dict):
|
|
126
|
+
network = permissions.get("network")
|
|
127
|
+
if isinstance(network, str):
|
|
128
|
+
values.append(network)
|
|
129
|
+
elif isinstance(network, list):
|
|
130
|
+
values.extend(str(item) for item in network)
|
|
131
|
+
for key in ("network", "networks", "allowed_domains"):
|
|
132
|
+
value = self.frontmatter.get(key)
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
values.append(value)
|
|
135
|
+
elif isinstance(value, list):
|
|
136
|
+
values.extend(str(item) for item in value)
|
|
137
|
+
return values
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _split_frontmatter(text: str) -> tuple[str, str, bool]:
|
|
141
|
+
"""Return ``(yaml_text, body, present)`` for a leading YAML block."""
|
|
142
|
+
|
|
143
|
+
if not text.startswith("---"):
|
|
144
|
+
return "", text, False
|
|
145
|
+
first_newline = text.find("\n")
|
|
146
|
+
if first_newline == -1:
|
|
147
|
+
return "", text, False
|
|
148
|
+
if text[:first_newline].strip() != "---":
|
|
149
|
+
return "", text, False
|
|
150
|
+
# Accept CRLF as well as LF. On Windows, checked-out SKILL.md files and
|
|
151
|
+
# Path.write_text() both commonly use CRLF; treating the trailing carriage
|
|
152
|
+
# return as content made every such manifest look like it had no
|
|
153
|
+
# frontmatter (and therefore produced a spurious high-severity issue).
|
|
154
|
+
closing = re.search(r"(?m)^---[ \t]*\r?$", text[first_newline + 1 :])
|
|
155
|
+
if closing is None:
|
|
156
|
+
return "", text, False
|
|
157
|
+
start = first_newline + 1
|
|
158
|
+
end = start + closing.start()
|
|
159
|
+
yaml_text = text[start:end]
|
|
160
|
+
body = text[start + closing.end() :]
|
|
161
|
+
return yaml_text, body.lstrip("\n"), True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
MAX_YAML_DEPTH = 10
|
|
165
|
+
MAX_YAML_EVENTS = 20_000
|
|
166
|
+
|
|
167
|
+
# Windows device names are reserved in every directory and with any extension.
|
|
168
|
+
_RESERVED_DEVICE_NAMES = frozenset(
|
|
169
|
+
{"CON", "PRN", "AUX", "NUL"}
|
|
170
|
+
| {f"COM{index}" for index in range(1, 10)}
|
|
171
|
+
| {f"LPT{index}" for index in range(1, 10)}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _path_name_issue(relative: str) -> str | None:
|
|
176
|
+
"""Return an issue message for reserved device names or alternate data streams."""
|
|
177
|
+
|
|
178
|
+
for component in relative.split("/"):
|
|
179
|
+
base = component.split(".", 1)[0].rstrip(" ").upper()
|
|
180
|
+
if base in _RESERVED_DEVICE_NAMES:
|
|
181
|
+
return f"reserved device name in path: {relative}"
|
|
182
|
+
if ":" in component:
|
|
183
|
+
return f"alternate data stream notation in path: {relative}"
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_yaml_structure(yaml_text: str) -> None:
|
|
188
|
+
"""Reject YAML that is deeply nested, uses aliases, or has too many nodes.
|
|
189
|
+
|
|
190
|
+
Aliases are refused outright: they are the vehicle for "billion laughs"
|
|
191
|
+
expansion, and skill frontmatter has no legitimate need for them. The check
|
|
192
|
+
walks parser events, so nothing is expanded before it is judged.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
depth = 0
|
|
196
|
+
events = 0
|
|
197
|
+
try:
|
|
198
|
+
for event in yaml.parse(yaml_text, Loader=yaml.SafeLoader):
|
|
199
|
+
events += 1
|
|
200
|
+
if events > MAX_YAML_EVENTS:
|
|
201
|
+
raise SkillParseError("frontmatter has too many YAML nodes")
|
|
202
|
+
if isinstance(event, yaml.AliasEvent):
|
|
203
|
+
raise SkillParseError("YAML aliases are not allowed in frontmatter")
|
|
204
|
+
if isinstance(event, (yaml.MappingStartEvent, yaml.SequenceStartEvent)):
|
|
205
|
+
depth += 1
|
|
206
|
+
if depth > MAX_YAML_DEPTH:
|
|
207
|
+
raise SkillParseError(f"frontmatter nests deeper than {MAX_YAML_DEPTH} levels")
|
|
208
|
+
elif isinstance(event, (yaml.MappingEndEvent, yaml.SequenceEndEvent)):
|
|
209
|
+
depth -= 1
|
|
210
|
+
except yaml.YAMLError as exc:
|
|
211
|
+
raise SkillParseError(f"invalid YAML frontmatter: {exc}") from exc
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _safe_yaml(yaml_text: str) -> dict[str, Any]:
|
|
215
|
+
if len(yaml_text.encode("utf-8", "replace")) > FRONTMATTER_MAX_BYTES:
|
|
216
|
+
raise SkillParseError("frontmatter exceeds 256 KiB")
|
|
217
|
+
_check_yaml_structure(yaml_text)
|
|
218
|
+
try:
|
|
219
|
+
loaded = yaml.safe_load(yaml_text)
|
|
220
|
+
except yaml.YAMLError as exc:
|
|
221
|
+
raise SkillParseError(f"invalid YAML frontmatter: {exc}") from exc
|
|
222
|
+
if loaded is None:
|
|
223
|
+
return {}
|
|
224
|
+
if not isinstance(loaded, dict):
|
|
225
|
+
raise SkillParseError("frontmatter must be a YAML mapping")
|
|
226
|
+
return loaded
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _unread_reason(path: Path, remaining: int) -> str:
|
|
230
|
+
"""Why a text candidate was not read, for the finding that reports it."""
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
size = path.stat().st_size
|
|
234
|
+
except OSError:
|
|
235
|
+
return "unreadable"
|
|
236
|
+
if size > MAX_TEXT_FILE_BYTES:
|
|
237
|
+
return f"larger than {MAX_TEXT_FILE_BYTES // (1024 * 1024)} MiB"
|
|
238
|
+
if remaining <= 0:
|
|
239
|
+
return "skill text budget exhausted"
|
|
240
|
+
return "binary or unreadable content"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _read_text(path: Path, relative: str, remaining: int) -> TextFile | None:
|
|
244
|
+
try:
|
|
245
|
+
size = path.stat().st_size
|
|
246
|
+
except OSError:
|
|
247
|
+
return None
|
|
248
|
+
if size > MAX_TEXT_FILE_BYTES or remaining <= 0:
|
|
249
|
+
return None
|
|
250
|
+
limit = min(size, remaining)
|
|
251
|
+
try:
|
|
252
|
+
with open(path, "rb") as handle:
|
|
253
|
+
data = handle.read(limit)
|
|
254
|
+
except OSError:
|
|
255
|
+
return None
|
|
256
|
+
if b"\x00" in data[:4096]:
|
|
257
|
+
return None
|
|
258
|
+
text = data.decode("utf-8", "replace")
|
|
259
|
+
return TextFile(
|
|
260
|
+
path=path,
|
|
261
|
+
relative=relative,
|
|
262
|
+
text=text,
|
|
263
|
+
line_count=len(text.splitlines()) or 1,
|
|
264
|
+
truncated=limit < size,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _is_text(relative: str) -> bool:
|
|
269
|
+
suffix = Path(relative).suffix.lower()
|
|
270
|
+
if suffix in _TEXT_SUFFIXES:
|
|
271
|
+
return True
|
|
272
|
+
# Extensionless scripts are common in skills (run, install, setup), at the
|
|
273
|
+
# root as well as in subdirectories. Binary content is excluded later by
|
|
274
|
+
# the NUL-byte check in _read_text.
|
|
275
|
+
return suffix == ""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _resolve_entrypoint(
|
|
279
|
+
manifest: dict[str, Any], root: Path
|
|
280
|
+
) -> tuple[list[str] | None, bool, PackageIssue | None]:
|
|
281
|
+
declared = False
|
|
282
|
+
raw: Any = None
|
|
283
|
+
for key in _ENTRYPOINT_KEYS:
|
|
284
|
+
if manifest.get(key) is not None:
|
|
285
|
+
raw = manifest[key]
|
|
286
|
+
declared = True
|
|
287
|
+
break
|
|
288
|
+
if not declared:
|
|
289
|
+
scripts = manifest.get("scripts")
|
|
290
|
+
if isinstance(scripts, dict) and scripts:
|
|
291
|
+
# Conventional package manifests map names to commands. Pick
|
|
292
|
+
# ``start``/``run`` when present, otherwise the first entry, but
|
|
293
|
+
# remember that the author declared a launch surface.
|
|
294
|
+
for key in ("start", "run", "main"):
|
|
295
|
+
if key in scripts:
|
|
296
|
+
raw = scripts[key]
|
|
297
|
+
declared = True
|
|
298
|
+
break
|
|
299
|
+
else:
|
|
300
|
+
raw = next(iter(scripts.values()))
|
|
301
|
+
declared = True
|
|
302
|
+
elif isinstance(scripts, str) and scripts.strip():
|
|
303
|
+
raw = scripts
|
|
304
|
+
declared = True
|
|
305
|
+
if not declared:
|
|
306
|
+
return None, False, None
|
|
307
|
+
|
|
308
|
+
if isinstance(raw, dict):
|
|
309
|
+
# e.g. {"command": "python main.py", "args": [...]}
|
|
310
|
+
command = raw.get("command") or raw.get("path")
|
|
311
|
+
args = raw.get("args") or []
|
|
312
|
+
if command is None:
|
|
313
|
+
return None, True, PackageIssue("high", "manifest entrypoint object has no command")
|
|
314
|
+
raw = [command, *args] if isinstance(args, list) else str(command)
|
|
315
|
+
if isinstance(raw, str):
|
|
316
|
+
try:
|
|
317
|
+
tokens = shlex.split(raw, posix=True)
|
|
318
|
+
except ValueError as exc:
|
|
319
|
+
return (
|
|
320
|
+
None,
|
|
321
|
+
True,
|
|
322
|
+
PackageIssue("high", f"manifest entrypoint is not shell-parseable: {exc}"),
|
|
323
|
+
)
|
|
324
|
+
elif isinstance(raw, (list, tuple)) and all(isinstance(item, (str, int)) for item in raw):
|
|
325
|
+
tokens = [str(item) for item in raw]
|
|
326
|
+
else:
|
|
327
|
+
return None, True, PackageIssue("high", "manifest entrypoint must be a string or list")
|
|
328
|
+
if not tokens:
|
|
329
|
+
return None, True, PackageIssue("high", "manifest entrypoint is empty")
|
|
330
|
+
|
|
331
|
+
candidate_index = 0
|
|
332
|
+
if Path(tokens[0]).name in _INTERPRETERS:
|
|
333
|
+
# python scripts/main.py --flag
|
|
334
|
+
if len(tokens) < 2:
|
|
335
|
+
return None, True, PackageIssue("high", "interpreter entrypoint has no script path")
|
|
336
|
+
candidate_index = 1
|
|
337
|
+
candidate = tokens[candidate_index]
|
|
338
|
+
if candidate.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:[\\/]", candidate):
|
|
339
|
+
return (
|
|
340
|
+
None,
|
|
341
|
+
True,
|
|
342
|
+
PackageIssue("high", "manifest entrypoint must be relative to the skill root"),
|
|
343
|
+
)
|
|
344
|
+
rel = Path(candidate)
|
|
345
|
+
if ".." in rel.parts:
|
|
346
|
+
return None, True, PackageIssue("high", "manifest entrypoint escapes the skill root")
|
|
347
|
+
target = (root / rel).resolve()
|
|
348
|
+
try:
|
|
349
|
+
target.relative_to(root.resolve())
|
|
350
|
+
except ValueError:
|
|
351
|
+
return None, True, PackageIssue("high", "manifest entrypoint escapes the skill root")
|
|
352
|
+
if not target.is_file():
|
|
353
|
+
return (
|
|
354
|
+
None,
|
|
355
|
+
True,
|
|
356
|
+
PackageIssue(
|
|
357
|
+
"high",
|
|
358
|
+
f"declared entrypoint does not exist: {candidate}",
|
|
359
|
+
code=candidate,
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
normalised = list(tokens)
|
|
363
|
+
normalised[candidate_index] = str(rel).replace("\\", "/")
|
|
364
|
+
if not normalised[0].startswith((".", "/")):
|
|
365
|
+
# Preserve interpreter names; the sandbox image provides python/sh/node.
|
|
366
|
+
normalised[0] = tokens[0]
|
|
367
|
+
return normalised, True, None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def parse_skill(path: str | Path) -> SkillPackage:
|
|
371
|
+
"""Parse and validate a skill package without executing any of its code."""
|
|
372
|
+
|
|
373
|
+
root = Path(path).expanduser()
|
|
374
|
+
try:
|
|
375
|
+
root = root.resolve(strict=True)
|
|
376
|
+
except OSError as exc:
|
|
377
|
+
raise SkillParseError(f"skill directory does not exist: {path}") from exc
|
|
378
|
+
if not root.is_dir():
|
|
379
|
+
raise SkillParseError(f"not a directory: {root}")
|
|
380
|
+
skill_md_path = root / "SKILL.md"
|
|
381
|
+
if not skill_md_path.is_file() or skill_md_path.is_symlink():
|
|
382
|
+
raise SkillParseError(f"no SKILL.md found in {root}")
|
|
383
|
+
try:
|
|
384
|
+
raw_skill_md = skill_md_path.read_bytes()
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
raise SkillParseError(f"could not read SKILL.md: {exc}") from exc
|
|
387
|
+
if len(raw_skill_md) > MAX_SKILL_MD_BYTES:
|
|
388
|
+
raise SkillParseError("SKILL.md exceeds 1 MiB")
|
|
389
|
+
issues: list[PackageIssue] = []
|
|
390
|
+
try:
|
|
391
|
+
skill_md = raw_skill_md.decode("utf-8")
|
|
392
|
+
except UnicodeDecodeError:
|
|
393
|
+
# Analysed with replacement characters rather than refused: an invalid
|
|
394
|
+
# byte must not hide the rest of the file from the scan.
|
|
395
|
+
skill_md = raw_skill_md.decode("utf-8", "replace")
|
|
396
|
+
issues.append(PackageIssue("high", "SKILL.md is not valid UTF-8"))
|
|
397
|
+
yaml_text, body, present = _split_frontmatter(skill_md)
|
|
398
|
+
manifest: dict[str, Any] = {}
|
|
399
|
+
if not present:
|
|
400
|
+
issues.append(PackageIssue("medium", "SKILL.md has no YAML frontmatter"))
|
|
401
|
+
else:
|
|
402
|
+
try:
|
|
403
|
+
manifest = _safe_yaml(yaml_text)
|
|
404
|
+
except SkillParseError as exc:
|
|
405
|
+
issues.append(PackageIssue("high", str(exc)))
|
|
406
|
+
manifest = {}
|
|
407
|
+
|
|
408
|
+
name_raw = manifest.get("name")
|
|
409
|
+
if isinstance(name_raw, str) and _NAME_RE.match(name_raw.strip()):
|
|
410
|
+
name = name_raw.strip()
|
|
411
|
+
else:
|
|
412
|
+
name = root.name
|
|
413
|
+
if name_raw is None:
|
|
414
|
+
issues.append(PackageIssue("high", "frontmatter is missing required field: name"))
|
|
415
|
+
else:
|
|
416
|
+
issues.append(PackageIssue("high", "frontmatter name is not a valid skill identifier"))
|
|
417
|
+
if not _NAME_RE.match(name):
|
|
418
|
+
name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-") or "unknown-skill"
|
|
419
|
+
|
|
420
|
+
description_raw = manifest.get("description")
|
|
421
|
+
if not isinstance(description_raw, str) or not description_raw.strip():
|
|
422
|
+
description = ""
|
|
423
|
+
issues.append(PackageIssue("high", "frontmatter is missing required field: description"))
|
|
424
|
+
else:
|
|
425
|
+
description = description_raw.strip()
|
|
426
|
+
if len(description) > 4096:
|
|
427
|
+
issues.append(PackageIssue("medium", "description exceeds 4096 characters"))
|
|
428
|
+
description = description[:4096]
|
|
429
|
+
|
|
430
|
+
entrypoint, entrypoint_declared, entry_issue = _resolve_entrypoint(manifest, root)
|
|
431
|
+
if entry_issue is not None:
|
|
432
|
+
issues.append(entry_issue)
|
|
433
|
+
|
|
434
|
+
files: list[Path] = []
|
|
435
|
+
text_files: list[TextFile] = []
|
|
436
|
+
text_budget = MAX_TEXT_TOTAL_BYTES
|
|
437
|
+
for candidate in sorted(root.rglob("*"), key=lambda p: p.relative_to(root).as_posix()):
|
|
438
|
+
if candidate.is_symlink():
|
|
439
|
+
issues.append(
|
|
440
|
+
PackageIssue(
|
|
441
|
+
"high",
|
|
442
|
+
f"symlink is not allowed in a skill package: {candidate.relative_to(root).as_posix()}",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
continue
|
|
446
|
+
name_issue = _path_name_issue(candidate.relative_to(root).as_posix())
|
|
447
|
+
if name_issue is not None:
|
|
448
|
+
# Reported as high, and the file is still analysed: a reserved name
|
|
449
|
+
# must not hide content from the scan.
|
|
450
|
+
issues.append(PackageIssue("high", name_issue))
|
|
451
|
+
if candidate.is_dir():
|
|
452
|
+
continue
|
|
453
|
+
if not candidate.is_file():
|
|
454
|
+
issues.append(
|
|
455
|
+
PackageIssue(
|
|
456
|
+
"medium",
|
|
457
|
+
f"unsupported file type: {candidate.relative_to(root).as_posix()}",
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
continue
|
|
461
|
+
files.append(candidate)
|
|
462
|
+
relative = candidate.relative_to(root).as_posix()
|
|
463
|
+
if relative == "SKILL.md":
|
|
464
|
+
text_files.append(
|
|
465
|
+
TextFile(
|
|
466
|
+
path=candidate,
|
|
467
|
+
relative=relative,
|
|
468
|
+
text=skill_md,
|
|
469
|
+
line_count=len(skill_md.splitlines()) or 1,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
text_budget -= len(raw_skill_md)
|
|
473
|
+
continue
|
|
474
|
+
if _is_text(relative):
|
|
475
|
+
parsed = _read_text(candidate, relative, text_budget)
|
|
476
|
+
if parsed is not None:
|
|
477
|
+
text_files.append(parsed)
|
|
478
|
+
text_budget -= len(parsed.text.encode("utf-8", "replace"))
|
|
479
|
+
else:
|
|
480
|
+
# A script the scan did not read is a gap, not a clean file.
|
|
481
|
+
# The HIGH issue degrades the spec stage, so the verdict cannot be SAFE.
|
|
482
|
+
issues.append(
|
|
483
|
+
PackageIssue(
|
|
484
|
+
"high",
|
|
485
|
+
f"text file not analysed: {relative} ({_unread_reason(candidate, text_budget)})",
|
|
486
|
+
file=relative,
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
package_hash = hash_skill_dir(root)
|
|
492
|
+
except HashError as exc:
|
|
493
|
+
raise SkillParseError(str(exc)) from exc
|
|
494
|
+
|
|
495
|
+
return SkillPackage(
|
|
496
|
+
root=root,
|
|
497
|
+
name=name,
|
|
498
|
+
description=description,
|
|
499
|
+
declared_intent=description,
|
|
500
|
+
manifest=manifest,
|
|
501
|
+
frontmatter=manifest,
|
|
502
|
+
body=body,
|
|
503
|
+
skill_md=skill_md,
|
|
504
|
+
files=files,
|
|
505
|
+
text_files=text_files,
|
|
506
|
+
entrypoint=entrypoint,
|
|
507
|
+
entrypoint_declared=entrypoint_declared,
|
|
508
|
+
content_hash=package_hash.digest,
|
|
509
|
+
issues=issues,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
__all__ = [
|
|
514
|
+
"MAX_SKILL_MD_BYTES",
|
|
515
|
+
"PackageIssue",
|
|
516
|
+
"SkillPackage",
|
|
517
|
+
"SkillParseError",
|
|
518
|
+
"TextFile",
|
|
519
|
+
"parse_skill",
|
|
520
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Typosquat / homograph detection for skill names.
|
|
2
|
+
|
|
3
|
+
The known-name list is intentionally small and local: this is a supply-chain
|
|
4
|
+
warning signal, not a verdict by itself. The matching rules are conservative
|
|
5
|
+
(normalised Levenshtein distance <= 2 for names of at least four characters)
|
|
6
|
+
and exact names are never flagged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from importlib import resources
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_known_names() -> tuple[str, ...]:
|
|
18
|
+
"""Load the curated list from the packaged data file (works from a wheel install)."""
|
|
19
|
+
|
|
20
|
+
raw = (
|
|
21
|
+
resources.files("skill_sentry_cli.data")
|
|
22
|
+
.joinpath("known_skills_v1.json")
|
|
23
|
+
.read_text(encoding="utf-8")
|
|
24
|
+
)
|
|
25
|
+
entries = json.loads(raw)["entries"]
|
|
26
|
+
return tuple(sorted({str(entry["name"]) for entry in entries}))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
KNOWN_SKILL_NAMES: tuple[str, ...] = _load_known_names()
|
|
30
|
+
|
|
31
|
+
_TRANSLATION = str.maketrans(
|
|
32
|
+
{
|
|
33
|
+
"0": "o",
|
|
34
|
+
"1": "l",
|
|
35
|
+
"3": "e",
|
|
36
|
+
"4": "a",
|
|
37
|
+
"5": "s",
|
|
38
|
+
"7": "t",
|
|
39
|
+
"8": "b",
|
|
40
|
+
"9": "g",
|
|
41
|
+
"@": "a",
|
|
42
|
+
"$": "s",
|
|
43
|
+
"!": "i",
|
|
44
|
+
"|": "l",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class TyposquatMatch:
|
|
51
|
+
name: str
|
|
52
|
+
candidate: str
|
|
53
|
+
distance: int
|
|
54
|
+
normalised: str
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def message(self) -> str:
|
|
58
|
+
return (
|
|
59
|
+
f"Skill name {self.name!r} resembles known skill {self.candidate!r} "
|
|
60
|
+
f"(edit distance {self.distance})"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Cyrillic and Greek letters that render like Latin ones: a common homograph trick.
|
|
65
|
+
_CONFUSABLES = str.maketrans(
|
|
66
|
+
{
|
|
67
|
+
"а": "a",
|
|
68
|
+
"е": "e",
|
|
69
|
+
"о": "o",
|
|
70
|
+
"р": "p",
|
|
71
|
+
"с": "c",
|
|
72
|
+
"у": "y",
|
|
73
|
+
"х": "x",
|
|
74
|
+
"і": "i",
|
|
75
|
+
"ј": "j",
|
|
76
|
+
"ѕ": "s",
|
|
77
|
+
"һ": "h",
|
|
78
|
+
"ԁ": "d",
|
|
79
|
+
"ɡ": "g",
|
|
80
|
+
"α": "a",
|
|
81
|
+
"ο": "o",
|
|
82
|
+
"ν": "v",
|
|
83
|
+
"ρ": "p",
|
|
84
|
+
"ι": "i",
|
|
85
|
+
"κ": "k",
|
|
86
|
+
"τ": "t",
|
|
87
|
+
"ε": "e",
|
|
88
|
+
"υ": "u",
|
|
89
|
+
"χ": "x",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fold(value: str) -> str:
|
|
95
|
+
folded = unicodedata.normalize("NFKC", value).lower().translate(_CONFUSABLES)
|
|
96
|
+
folded = folded.translate(_TRANSLATION)
|
|
97
|
+
folded = folded.replace("rn", "m").replace("vv", "w")
|
|
98
|
+
folded = folded.replace("_", "-")
|
|
99
|
+
return folded
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def levenshtein(left: str, right: str, *, max_distance: int = 3) -> int:
|
|
103
|
+
"""Small bounded Levenshtein implementation (no external dependency)."""
|
|
104
|
+
|
|
105
|
+
if left == right:
|
|
106
|
+
return 0
|
|
107
|
+
if abs(len(left) - len(right)) > max_distance:
|
|
108
|
+
return max_distance + 1
|
|
109
|
+
previous = list(range(len(right) + 1))
|
|
110
|
+
for i, left_char in enumerate(left, start=1):
|
|
111
|
+
current = [i]
|
|
112
|
+
best = i
|
|
113
|
+
for j, right_char in enumerate(right, start=1):
|
|
114
|
+
value = min(
|
|
115
|
+
current[j - 1] + 1,
|
|
116
|
+
previous[j] + 1,
|
|
117
|
+
previous[j - 1] + (left_char != right_char),
|
|
118
|
+
)
|
|
119
|
+
current.append(value)
|
|
120
|
+
best = min(best, value)
|
|
121
|
+
if best > max_distance:
|
|
122
|
+
return max_distance + 1
|
|
123
|
+
previous = current
|
|
124
|
+
return previous[-1]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_typosquat(
|
|
128
|
+
name: str, *, known: tuple[str, ...] = KNOWN_SKILL_NAMES
|
|
129
|
+
) -> TyposquatMatch | None:
|
|
130
|
+
"""Return the nearest known name when ``name`` is a likely imitation."""
|
|
131
|
+
|
|
132
|
+
stripped = unicodedata.normalize("NFKC", name.strip())
|
|
133
|
+
lowered = stripped.lower()
|
|
134
|
+
if len(lowered) < 4:
|
|
135
|
+
return None
|
|
136
|
+
folded = _fold(lowered)
|
|
137
|
+
# Only the exact spelling of a known name is the original. A case variant or
|
|
138
|
+
# a homograph that folds onto a known name is an imitation, not the original.
|
|
139
|
+
if stripped in known:
|
|
140
|
+
return None
|
|
141
|
+
for item in known:
|
|
142
|
+
if folded == _fold(item.lower()) and stripped != item:
|
|
143
|
+
return TyposquatMatch(name=name, candidate=item, distance=0, normalised=folded)
|
|
144
|
+
best: TyposquatMatch | None = None
|
|
145
|
+
for candidate in known:
|
|
146
|
+
candidate_lower = candidate.lower()
|
|
147
|
+
candidate_folded = _fold(candidate_lower)
|
|
148
|
+
distance = min(
|
|
149
|
+
levenshtein(lowered, candidate_lower, max_distance=2),
|
|
150
|
+
levenshtein(folded, candidate_folded, max_distance=2),
|
|
151
|
+
)
|
|
152
|
+
if distance > 2:
|
|
153
|
+
continue
|
|
154
|
+
# Very short candidates produce too many false positives.
|
|
155
|
+
if min(len(lowered), len(candidate_lower)) < 5:
|
|
156
|
+
continue
|
|
157
|
+
if best is None or distance < best.distance:
|
|
158
|
+
best = TyposquatMatch(
|
|
159
|
+
name=name,
|
|
160
|
+
candidate=candidate,
|
|
161
|
+
distance=distance,
|
|
162
|
+
normalised=folded,
|
|
163
|
+
)
|
|
164
|
+
return best
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = ["KNOWN_SKILL_NAMES", "TyposquatMatch", "check_typosquat", "levenshtein"]
|