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.
Files changed (96) hide show
  1. skill_sentry_cli/__init__.py +5 -0
  2. skill_sentry_cli/__main__.py +4 -0
  3. skill_sentry_cli/cli.py +918 -0
  4. skill_sentry_cli/core/__init__.py +1 -0
  5. skill_sentry_cli/core/anchors/__init__.py +5 -0
  6. skill_sentry_cli/core/anchors/credential_flow.py +440 -0
  7. skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
  8. skill_sentry_cli/core/cache/__init__.py +35 -0
  9. skill_sentry_cli/core/cache/hash_utils.py +206 -0
  10. skill_sentry_cli/core/cache/sqlite_store.py +925 -0
  11. skill_sentry_cli/core/config.py +299 -0
  12. skill_sentry_cli/core/contracts.py +551 -0
  13. skill_sentry_cli/core/decisions/__init__.py +44 -0
  14. skill_sentry_cli/core/decisions/base.py +174 -0
  15. skill_sentry_cli/core/decisions/calibration.py +325 -0
  16. skill_sentry_cli/core/decisions/laya.py +439 -0
  17. skill_sentry_cli/core/decisions/mapping.py +218 -0
  18. skill_sentry_cli/core/decisions/mock.py +118 -0
  19. skill_sentry_cli/core/decisions/questions.py +118 -0
  20. skill_sentry_cli/core/decisions/report.py +65 -0
  21. skill_sentry_cli/core/decisions/selector.py +151 -0
  22. skill_sentry_cli/core/engine.py +1412 -0
  23. skill_sentry_cli/core/evasion/__init__.py +27 -0
  24. skill_sentry_cli/core/evasion/detector.py +210 -0
  25. skill_sentry_cli/core/exit_codes.py +13 -0
  26. skill_sentry_cli/core/models/__init__.py +20 -0
  27. skill_sentry_cli/core/models/_json.py +147 -0
  28. skill_sentry_cli/core/models/base.py +50 -0
  29. skill_sentry_cli/core/models/gemma_cloud.py +240 -0
  30. skill_sentry_cli/core/models/gemma_local.py +273 -0
  31. skill_sentry_cli/core/models/image_utils.py +112 -0
  32. skill_sentry_cli/core/models/mock.py +152 -0
  33. skill_sentry_cli/core/models/selector.py +123 -0
  34. skill_sentry_cli/core/parser/__init__.py +29 -0
  35. skill_sentry_cli/core/parser/skill_parser.py +520 -0
  36. skill_sentry_cli/core/parser/typosquat.py +167 -0
  37. skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
  38. skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
  39. skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
  40. skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
  41. skill_sentry_cli/core/reports.py +114 -0
  42. skill_sentry_cli/core/sandbox/__init__.py +74 -0
  43. skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
  44. skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
  45. skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
  46. skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
  47. skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
  48. skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
  49. skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
  50. skill_sentry_cli/core/sandbox/fake_http.py +194 -0
  51. skill_sentry_cli/core/sandbox/instructions.py +170 -0
  52. skill_sentry_cli/core/sandbox/masquerade.py +265 -0
  53. skill_sentry_cli/core/sandbox/monitor.py +278 -0
  54. skill_sentry_cli/core/sandbox/runner.py +776 -0
  55. skill_sentry_cli/core/sandbox/sidecar.py +327 -0
  56. skill_sentry_cli/core/semantic/__init__.py +9 -0
  57. skill_sentry_cli/core/semantic/analyzer.py +443 -0
  58. skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
  59. skill_sentry_cli/core/semantic/ast_python.py +156 -0
  60. skill_sentry_cli/core/semantic/candidates.py +78 -0
  61. skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
  62. skill_sentry_cli/core/semantic/prompts.py +61 -0
  63. skill_sentry_cli/core/verdict/__init__.py +24 -0
  64. skill_sentry_cli/core/verdict/fusion.py +392 -0
  65. skill_sentry_cli/core/verdict/markdown_report.py +211 -0
  66. skill_sentry_cli/core/verdict/sarif_report.py +139 -0
  67. skill_sentry_cli/core/verdict/status.py +35 -0
  68. skill_sentry_cli/core/verdict/stix_report.py +132 -0
  69. skill_sentry_cli/core/version.py +38 -0
  70. skill_sentry_cli/core/vision/__init__.py +8 -0
  71. skill_sentry_cli/core/vision/image_extractor.py +91 -0
  72. skill_sentry_cli/core/vision/vision_scanner.py +274 -0
  73. skill_sentry_cli/data/__init__.py +1 -0
  74. skill_sentry_cli/data/known_skills_v1.json +276 -0
  75. skill_sentry_cli/data/sandbox_image.json +4 -0
  76. skill_sentry_cli/rules/credential_rules.toml +94 -0
  77. skill_sentry_cli/server/__init__.py +5 -0
  78. skill_sentry_cli/server/app.py +129 -0
  79. skill_sentry_cli/server/backend.py +63 -0
  80. skill_sentry_cli/server/demo_assets.py +62 -0
  81. skill_sentry_cli/server/demo_backend.py +475 -0
  82. skill_sentry_cli/server/demo_data.py +814 -0
  83. skill_sentry_cli/server/demo_mode.py +40 -0
  84. skill_sentry_cli/server/errors.py +48 -0
  85. skill_sentry_cli/server/live_backend.py +576 -0
  86. skill_sentry_cli/server/routes.py +191 -0
  87. skill_sentry_cli/skill/SKILL.md +67 -0
  88. skill_sentry_cli/skill/scripts/audit.sh +48 -0
  89. skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
  90. skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
  91. skill_sentry_cli/ui/dist/index.html +14 -0
  92. skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
  93. skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
  94. skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
  95. skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
  96. 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"]