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,206 @@
1
+ """Deterministic package hashing used by the verdict cache.
2
+
3
+ The algorithm is documented in ``docs/03-database.md``: every regular file is
4
+ visited in sorted POSIX-relative-path order and contributes its path, Unix mode
5
+ bits and the SHA-256 of its bytes. Directory entries are included as well so
6
+ adding or removing an empty directory invalidates the cache.
7
+
8
+ Symlinks are rejected. A skill must never be able to make the scanner hash (or
9
+ later read) a file outside the package root, and following links would also
10
+ make a package's identity depend on mutable host state.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import fnmatch
16
+ import hashlib
17
+ import os
18
+ import stat
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ DEFAULT_IGNORE_PATTERNS: tuple[str, ...] = (
23
+ ".git",
24
+ ".git/*",
25
+ ".hg",
26
+ ".svn",
27
+ "__pycache__",
28
+ "__pycache__/*",
29
+ "*.pyc",
30
+ ".pytest_cache",
31
+ ".pytest_cache/*",
32
+ ".ruff_cache",
33
+ ".ruff_cache/*",
34
+ ".mypy_cache",
35
+ ".mypy_cache/*",
36
+ ".venv",
37
+ ".venv/*",
38
+ "venv",
39
+ "venv/*",
40
+ "node_modules",
41
+ "node_modules/*",
42
+ ".DS_Store",
43
+ "Thumbs.db",
44
+ )
45
+
46
+ MAX_FILES = 5_000
47
+ MAX_TOTAL_BYTES = 64 * 1024 * 1024
48
+ CHUNK_SIZE = 128 * 1024
49
+ IGNORE_FILE = ".skillsentryignore"
50
+
51
+
52
+ class HashError(RuntimeError):
53
+ """The package cannot be hashed safely or exceeds a hard limit."""
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class SkillHash:
58
+ digest: str
59
+ file_count: int
60
+ total_bytes: int
61
+ root: Path
62
+
63
+ @property
64
+ def short(self) -> str:
65
+ return self.digest[:12]
66
+
67
+
68
+ def hash_bytes(data: bytes) -> str:
69
+ """Return the lower-case SHA-256 hex digest of ``data``."""
70
+
71
+ return hashlib.sha256(data).hexdigest()
72
+
73
+
74
+ def _load_ignore_patterns(root: Path) -> tuple[str, ...]:
75
+ patterns = list(DEFAULT_IGNORE_PATTERNS)
76
+ ignore_file = root / IGNORE_FILE
77
+ if ignore_file.is_file() and not ignore_file.is_symlink():
78
+ try:
79
+ text = ignore_file.read_text(encoding="utf-8", errors="replace")
80
+ except OSError as exc: # pragma: no cover - unusual filesystem error
81
+ raise HashError(f"could not read {IGNORE_FILE}: {exc}") from exc
82
+ for line in text.splitlines():
83
+ line = line.strip()
84
+ if not line or line.startswith("#"):
85
+ continue
86
+ patterns.append(line)
87
+ return tuple(patterns)
88
+
89
+
90
+ def _ignored(rel: str, patterns: tuple[str, ...]) -> bool:
91
+ # A pattern matching any parent directory excludes the whole subtree.
92
+ parts = rel.split("/")
93
+ candidates = ["/".join(parts[: i + 1]) for i in range(len(parts))]
94
+ return any(
95
+ fnmatch.fnmatchcase(candidate, pattern) for pattern in patterns for candidate in candidates
96
+ )
97
+
98
+
99
+ def _mode(path: Path) -> int:
100
+ """Return stable permission bits.
101
+
102
+ Windows does not expose Unix execute bits meaningfully; masking the mode to
103
+ ``0o777`` keeps hashes stable across checkouts while still changing when a
104
+ POSIX file becomes executable.
105
+ """
106
+
107
+ try:
108
+ return stat.S_IMODE(path.stat(follow_symlinks=False).st_mode)
109
+ except OSError as exc:
110
+ raise HashError(f"could not stat {path}: {exc}") from exc
111
+
112
+
113
+ def hash_skill_dir(path: str | Path) -> SkillHash:
114
+ """Hash a skill directory without following symlinks.
115
+
116
+ Symlinks are hashed as links (name and target), never followed. Raises
117
+ :class:`HashError` for a missing directory, unreadable files, or packages
118
+ over the file/size caps.
119
+ """
120
+
121
+ root = Path(path).expanduser()
122
+ try:
123
+ root = root.resolve(strict=True)
124
+ except OSError as exc:
125
+ raise HashError(f"skill directory does not exist: {path}") from exc
126
+ if not root.is_dir():
127
+ raise HashError(f"not a directory: {root}")
128
+
129
+ patterns = _load_ignore_patterns(root)
130
+ digest = hashlib.sha256()
131
+ file_count = 0
132
+ total_bytes = 0
133
+
134
+ def walk(directory: Path, prefix: str) -> None:
135
+ nonlocal file_count, total_bytes
136
+ try:
137
+ entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
138
+ except OSError as exc:
139
+ raise HashError(f"could not read {directory}: {exc}") from exc
140
+
141
+ for entry in entries:
142
+ rel = f"{prefix}/{entry.name}" if prefix else entry.name
143
+ if _ignored(rel, patterns):
144
+ continue
145
+ entry_path = Path(entry.path)
146
+ if entry.is_symlink():
147
+ # Never followed. The link and its target text are part of the
148
+ # key, and the parser reports the link as a HIGH finding, so a
149
+ # skill with a link is still scanned and reported.
150
+ digest.update(b"link\0")
151
+ digest.update(rel.encode("utf-8", "surrogateescape"))
152
+ digest.update(b"\0")
153
+ digest.update(os.readlink(entry_path).encode("utf-8", "surrogateescape"))
154
+ digest.update(b"\n")
155
+ continue
156
+ try:
157
+ if entry.is_dir(follow_symlinks=False):
158
+ digest.update(b"dir\0")
159
+ digest.update(rel.encode("utf-8", "surrogateescape"))
160
+ digest.update(f"\0{_mode(entry_path):o}\n".encode())
161
+ walk(entry_path, rel)
162
+ continue
163
+ if not entry.is_file(follow_symlinks=False):
164
+ raise HashError(f"unsupported file type in skill package: {rel}")
165
+ except OSError as exc:
166
+ raise HashError(f"could not inspect {rel}: {exc}") from exc
167
+
168
+ file_count += 1
169
+ if file_count > MAX_FILES:
170
+ raise HashError(f"skill package exceeds {MAX_FILES} files")
171
+ size = entry.stat(follow_symlinks=False).st_size
172
+ total_bytes += size
173
+ if total_bytes > MAX_TOTAL_BYTES:
174
+ raise HashError(f"skill package exceeds {MAX_TOTAL_BYTES // (1024 * 1024)} MiB")
175
+
176
+ digest.update(b"file\0")
177
+ digest.update(rel.encode("utf-8", "surrogateescape"))
178
+ digest.update(f"\0{_mode(entry_path):o}\0".encode())
179
+ file_digest = hashlib.sha256()
180
+ try:
181
+ with open(entry_path, "rb") as handle:
182
+ while chunk := handle.read(CHUNK_SIZE):
183
+ file_digest.update(chunk)
184
+ except OSError as exc:
185
+ raise HashError(f"could not read {rel}: {exc}") from exc
186
+ digest.update(file_digest.digest())
187
+ digest.update(b"\n")
188
+
189
+ walk(root, "")
190
+ return SkillHash(
191
+ digest=digest.hexdigest(),
192
+ file_count=file_count,
193
+ total_bytes=total_bytes,
194
+ root=root,
195
+ )
196
+
197
+
198
+ __all__ = [
199
+ "DEFAULT_IGNORE_PATTERNS",
200
+ "MAX_FILES",
201
+ "MAX_TOTAL_BYTES",
202
+ "HashError",
203
+ "SkillHash",
204
+ "hash_bytes",
205
+ "hash_skill_dir",
206
+ ]