slopsentry 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.
slopsentry/hook.py ADDED
@@ -0,0 +1,310 @@
1
+ """Claude Code PreToolUse hook: check the packages in Bash install commands.
2
+
3
+ The hook fails open. Anything it can't parse, a registry outage or a crash allows the
4
+ command; only a scored warn or block produces output.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import re
11
+ import shlex
12
+ import sys
13
+ import traceback
14
+ from collections.abc import Callable
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from slopsentry.registry import PyPIClient, normalize_name
20
+ from slopsentry.scoring import Report, score, verdict_for
21
+ from slopsentry.spec import InvalidPackageSpec, parse_package_name
22
+
23
+ HOOK_TIMEOUT = 5.0
24
+
25
+ SEPARATORS = {"&&", "||", ";", "|", "&", "|&", ";;", "(", ")", "\n"}
26
+ # Characters shlex would otherwise split words on, which appear in URLs and specs.
27
+ EXTRA_WORDCHARS = ":@%+,^![]{}$#"
28
+
29
+ ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
30
+ PIP = re.compile(r"^pip(3(\.\d+)?)?$")
31
+ PYTHON = re.compile(r"^python(3(\.\d+)?)?$")
32
+
33
+ # Options that take a value, per tool. Their values are never package names.
34
+ PIP_VALUE_OPTS = {
35
+ "-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-t", "--target",
36
+ "--platform", "--python-version", "--implementation", "--abi", "--root", "--prefix",
37
+ "--src", "--upgrade-strategy", "-C", "--config-settings", "--global-option",
38
+ "--no-binary", "--only-binary", "--progress-bar", "--root-user-action", "--report",
39
+ "--group", "-i", "--index-url", "--extra-index-url", "-f", "--find-links", "--log",
40
+ "--proxy", "--retries", "--timeout", "--exists-action", "--trusted-host", "--cert",
41
+ "--client-cert", "--cache-dir", "--python", "--keyring-provider", "--use-feature",
42
+ "--use-deprecated", "--resume-retries",
43
+ } # fmt: skip
44
+ UV_VALUE_OPTS = {
45
+ "-r", "--requirements", "--requirement", "-e", "--editable", "-c", "--constraints",
46
+ "--constraint", "--overrides", "-b", "--build-constraints", "--group", "--optional",
47
+ "--extra", "--tag", "--branch", "--rev", "-m", "--marker", "--index", "--default-index",
48
+ "-i", "--index-url", "--extra-index-url", "-f", "--find-links", "-p", "--python",
49
+ "--python-version", "--python-platform", "-t", "--target", "--prefix", "--package",
50
+ "--script", "--directory", "--project", "--config-file", "--cache-dir", "-P",
51
+ "--upgrade-package", "--reinstall-package", "--no-binary-package", "--no-build-package",
52
+ "--index-strategy", "--keyring-provider", "--resolution", "--prerelease",
53
+ "--exclude-newer", "--link-mode", "--color", "--refresh-package", "--bounds", "-C",
54
+ "--config-setting", "--allow-insecure-host",
55
+ } # fmt: skip
56
+ POETRY_VALUE_OPTS = {
57
+ "-G", "--group", "-E", "--extras", "--source", "--python", "--platform", "--markers",
58
+ "-C", "--directory", "-P", "--project",
59
+ } # fmt: skip
60
+
61
+ # A custom index means packages may legitimately be missing from PyPI: skip the command.
62
+ PIP_INDEX_OPTS = {"-i", "--index-url", "--extra-index-url", "-f", "--find-links"}
63
+ UV_INDEX_OPTS = PIP_INDEX_OPTS | {"--index", "--default-index"}
64
+ POETRY_INDEX_OPTS = {"--source"}
65
+
66
+ SUDO_VALUE_OPTS = {"-u", "-g", "-C", "-D", "-p", "-U", "-r", "-t", "-T"}
67
+ ENV_VALUE_OPTS = {"-u", "--unset", "-C", "--chdir", "-S", "--split-string"}
68
+
69
+ ARCHIVE_SUFFIXES = (".whl", ".tar.gz", ".tgz", ".tar.bz2", ".zip")
70
+
71
+ ClientFactory = Callable[[], PyPIClient]
72
+
73
+
74
+ def read_event(raw: str) -> str | None:
75
+ """Return the Bash command from a PreToolUse event, or None for anything else."""
76
+ try:
77
+ event = json.loads(raw)
78
+ except ValueError:
79
+ return None
80
+ if not isinstance(event, dict) or event.get("tool_name") != "Bash":
81
+ return None
82
+ tool_input = event.get("tool_input")
83
+ if not isinstance(tool_input, dict):
84
+ return None
85
+ command = tool_input.get("command")
86
+ return command if isinstance(command, str) else None
87
+
88
+
89
+ def _tokenize(command: str) -> list[str]:
90
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
91
+ lexer.wordchars += EXTRA_WORDCHARS
92
+ # shlex ends a word at `#`; bash only starts a comment at the start of a word.
93
+ lexer.commenters = ""
94
+ lexer.whitespace = " \t\r" # newline separates commands, so keep it as a token
95
+ return list(lexer)
96
+
97
+
98
+ def _segments(tokens: list[str]) -> list[list[str]]:
99
+ """Split tokens into simple commands, dropping redirections and their targets."""
100
+ segments: list[list[str]] = [[]]
101
+ skip_next = in_comment = False
102
+ for tok in tokens:
103
+ if in_comment:
104
+ in_comment = tok != "\n"
105
+ if not in_comment:
106
+ segments.append([])
107
+ elif skip_next:
108
+ skip_next = False
109
+ elif tok.startswith("#"):
110
+ in_comment = True
111
+ elif tok in SEPARATORS:
112
+ segments.append([])
113
+ elif set(tok) <= set("<>&"):
114
+ # Redirection like `> log` or `2>&1`: drop its target and any fd number before it.
115
+ skip_next = True
116
+ if segments[-1] and segments[-1][-1].isdigit():
117
+ segments[-1].pop()
118
+ else:
119
+ segments[-1].append(tok)
120
+ return [s for s in segments if s]
121
+
122
+
123
+ def _strip_prefixes(words: list[str]) -> list[str]:
124
+ """Drop leading VAR=value assignments, `env` and `sudo` (with their options)."""
125
+ i = 0
126
+ while i < len(words):
127
+ word = words[i]
128
+ if ASSIGNMENT.match(word):
129
+ i += 1
130
+ elif word in ("sudo", "env"):
131
+ value_opts = SUDO_VALUE_OPTS if word == "sudo" else ENV_VALUE_OPTS
132
+ i += 1
133
+ while i < len(words) and words[i].startswith("-"):
134
+ i += 2 if words[i] in value_opts else 1
135
+ else:
136
+ break
137
+ return words[i:]
138
+
139
+
140
+ def _skip_options(words: list[str], value_opts: set[str]) -> list[str]:
141
+ """Drop leading options so the subcommand comes first."""
142
+ i = 0
143
+ while i < len(words) and words[i].startswith("-"):
144
+ i += 2 if words[i] in value_opts else 1
145
+ return words[i:]
146
+
147
+
148
+ def _install_args(words: list[str]) -> tuple[list[str], set[str], set[str], bool] | None:
149
+ """If WORDS is a supported install command, return (args, value opts, index opts, poetry)."""
150
+ if not words:
151
+ return None
152
+ program = os.path.basename(words[0])
153
+ rest = words[1:]
154
+
155
+ if PYTHON.match(program):
156
+ if rest[:2] != ["-m", "pip"]:
157
+ return None
158
+ program, rest = "pip", rest[2:]
159
+
160
+ if PIP.match(program):
161
+ rest = _skip_options(rest, PIP_VALUE_OPTS)
162
+ if rest[:1] == ["install"]:
163
+ return rest[1:], PIP_VALUE_OPTS, PIP_INDEX_OPTS, False
164
+ elif program == "uv":
165
+ rest = _skip_options(rest, UV_VALUE_OPTS)
166
+ if rest[:1] == ["add"]:
167
+ return rest[1:], UV_VALUE_OPTS, UV_INDEX_OPTS, False
168
+ if rest[:2] == ["pip", "install"]:
169
+ return rest[2:], UV_VALUE_OPTS, UV_INDEX_OPTS, False
170
+ elif program == "poetry":
171
+ rest = _skip_options(rest, POETRY_VALUE_OPTS)
172
+ if rest[:1] == ["add"]:
173
+ return rest[1:], POETRY_VALUE_OPTS, POETRY_INDEX_OPTS, True
174
+ return None
175
+
176
+
177
+ def _uses_custom_index(args: list[str], index_opts: set[str]) -> bool:
178
+ for arg in args:
179
+ if arg.split("=", 1)[0] in index_opts:
180
+ return True
181
+ # Attached short form, e.g. -ihttps://example.com/simple
182
+ if not arg.startswith("--") and len(arg) > 2 and arg[:2] in index_opts:
183
+ return True
184
+ return False
185
+
186
+
187
+ def _is_local_or_url(arg: str) -> bool:
188
+ return (
189
+ arg.startswith((".", "/", "~", "git+", "file:"))
190
+ or "/" in arg
191
+ or "\\" in arg
192
+ or "://" in arg
193
+ or arg.endswith(ARCHIVE_SUFFIXES)
194
+ )
195
+
196
+
197
+ def _package_names(args: list[str], value_opts: set[str], poetry: bool) -> list[str]:
198
+ names = []
199
+ i = 0
200
+ while i < len(args):
201
+ arg = args[i]
202
+ i += 1
203
+ if arg.startswith("-"):
204
+ if arg in value_opts:
205
+ i += 1 # skip the option's value (-r file, -e path, --group dev, ...)
206
+ continue
207
+ if i < len(args) and args[i] == "@":
208
+ i += 2 # `pkg @ url` direct reference: installed from the URL, not PyPI
209
+ continue
210
+ if _is_local_or_url(arg):
211
+ continue
212
+ if "@" in arg:
213
+ if not poetry:
214
+ continue # PEP 508 `pkg@url` direct reference
215
+ arg = arg.split("@", 1)[0] # poetry's `pkg@^1.0` version constraint
216
+ try:
217
+ names.append(parse_package_name(arg))
218
+ except InvalidPackageSpec:
219
+ continue
220
+ return names
221
+
222
+
223
+ def extract_packages(command: str) -> list[str]:
224
+ """Return the PyPI package names that COMMAND would install, de-duplicated in order."""
225
+ try:
226
+ tokens = _tokenize(command)
227
+ except ValueError:
228
+ return [] # unbalanced quotes and the like: can't tell, so allow
229
+
230
+ names: dict[str, str] = {}
231
+ for segment in _segments(tokens):
232
+ found = _install_args(_strip_prefixes(segment))
233
+ if found is None:
234
+ continue
235
+ args, value_opts, index_opts, poetry = found
236
+ if _uses_custom_index(args, index_opts):
237
+ continue
238
+ for name in _package_names(args, value_opts, poetry):
239
+ names.setdefault(normalize_name(name), name)
240
+ return list(names.values())
241
+
242
+
243
+ async def _score_all(packages: list[str], client_factory: ClientFactory) -> list[Report]:
244
+ client = client_factory()
245
+ try:
246
+ return list(await asyncio.gather(*(score(p, client) for p in packages)))
247
+ finally:
248
+ await client.aclose()
249
+
250
+
251
+ def _describe(report: Report) -> str:
252
+ verdict = verdict_for(report.total)
253
+ reasons = "; ".join(f"{r.check}: {r.detail}" for r in report.reasons if r.status != "error")
254
+ return f"- {report.package}: {verdict.upper()}, score {report.total} ({reasons})"
255
+
256
+
257
+ def decide(reports: list[Report]) -> dict[str, Any] | None:
258
+ """Turn reports into Claude Code hook output, or None to allow silently.
259
+
260
+ Registry errors score 0 points, so an unverifiable package is allowed here.
261
+ """
262
+ verdicts = [verdict_for(r.total) for r in reports]
263
+ flagged = [r for r, v in zip(reports, verdicts, strict=True) if v != "pass"]
264
+ if not flagged:
265
+ return None
266
+
267
+ decision = "deny" if "block" in verdicts else "ask"
268
+ action = "blocked" if decision == "deny" else "flagged"
269
+ lines = [f"SlopSentry {action} {len(flagged)} of {len(reports)} packages:"]
270
+ lines += [_describe(r) for r in flagged]
271
+ if decision == "deny":
272
+ lines.append(
273
+ "These may be hallucinated or lookalike packages. Check the names; "
274
+ "if one is intended, ask the user to confirm and install it themselves."
275
+ )
276
+ return {
277
+ "hookSpecificOutput": {
278
+ "hookEventName": "PreToolUse",
279
+ "permissionDecision": decision,
280
+ "permissionDecisionReason": "\n".join(lines),
281
+ }
282
+ }
283
+
284
+
285
+ def run_hook(raw: str, client_factory: ClientFactory) -> dict[str, Any] | None:
286
+ command = read_event(raw)
287
+ if command is None:
288
+ return None
289
+ packages = extract_packages(command)
290
+ if not packages:
291
+ return None # nothing to check: no registry client is created
292
+ return decide(asyncio.run(_score_all(packages, client_factory)))
293
+
294
+
295
+ def log_path() -> Path:
296
+ return Path.home() / ".slopsentry" / "hook.log"
297
+
298
+
299
+ def log_exception(debug: bool) -> None:
300
+ """Record the exception being handled. Never raises: the hook must still allow."""
301
+ entry = f"[{datetime.now(timezone.utc).isoformat()}] {traceback.format_exc()}"
302
+ try:
303
+ path = log_path()
304
+ path.parent.mkdir(parents=True, exist_ok=True)
305
+ with path.open("a", encoding="utf-8") as log:
306
+ log.write(entry)
307
+ except OSError:
308
+ pass
309
+ if debug:
310
+ sys.stderr.write(entry)
slopsentry/registry.py ADDED
@@ -0,0 +1,62 @@
1
+ """Registry API clients (PyPI for now), with a per-client cache."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ PYPI_URL = "https://pypi.org/pypi/{name}/json"
9
+
10
+
11
+ def normalize_name(name: str) -> str:
12
+ """Normalise a PyPI project name per PEP 503: lowercase, runs of -_. become -."""
13
+ return re.sub(r"[-_.]+", "-", name).lower()
14
+
15
+
16
+ class RegistryError(Exception):
17
+ """The registry could not be reached or returned an unexpected response."""
18
+
19
+
20
+ class PyPIClient:
21
+ def __init__(self, http: httpx.AsyncClient | None = None, timeout: float = 10.0) -> None:
22
+ self._http = http or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
23
+ self._cache: dict[str, dict[str, Any] | None] = {}
24
+ # Errors are remembered too, so several checks don't each wait out the same outage.
25
+ self._errors: dict[str, RegistryError] = {}
26
+
27
+ async def get_project(self, name: str) -> dict[str, Any] | None:
28
+ """Return the project's JSON metadata, or None if it does not exist."""
29
+ key = normalize_name(name)
30
+ if key in self._cache:
31
+ return self._cache[key]
32
+ if key in self._errors:
33
+ raise self._errors[key]
34
+
35
+ try:
36
+ data = await self._fetch(key)
37
+ except RegistryError as exc:
38
+ self._errors[key] = exc
39
+ raise
40
+ self._cache[key] = data
41
+ return data
42
+
43
+ async def _fetch(self, key: str) -> dict[str, Any] | None:
44
+ try:
45
+ response = await self._http.get(PYPI_URL.format(name=key))
46
+ except httpx.HTTPError as exc:
47
+ raise RegistryError(f"could not reach PyPI: {exc}") from exc
48
+
49
+ if response.status_code == 404:
50
+ return None
51
+ if response.status_code != 200:
52
+ raise RegistryError(f"PyPI returned HTTP {response.status_code}")
53
+ try:
54
+ data = response.json()
55
+ except ValueError as exc:
56
+ raise RegistryError("PyPI returned a response that is not valid JSON") from exc
57
+ if not isinstance(data, dict):
58
+ raise RegistryError("PyPI returned an unexpected JSON shape")
59
+ return data
60
+
61
+ async def aclose(self) -> None:
62
+ await self._http.aclose()
slopsentry/scoring.py ADDED
@@ -0,0 +1,81 @@
1
+ """Run every check, total the points, and turn the total into a verdict."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass
5
+
6
+ from slopsentry.checks import CheckResult
7
+ from slopsentry.checks.age import check_age
8
+ from slopsentry.checks.exists import check_exists
9
+ from slopsentry.checks.lookalike import check_lookalike
10
+ from slopsentry.registry import PyPIClient
11
+
12
+ # Verdict thresholds. Tune here: total < WARN_AT passes, total >= BLOCK_AT blocks.
13
+ WARN_AT = 30
14
+ BLOCK_AT = 70
15
+
16
+ Check = Callable[[str, PyPIClient], Awaitable[CheckResult]]
17
+
18
+ ALL_CHECKS: list[tuple[str, Check]] = [
19
+ ("exists", check_exists),
20
+ ("age", check_age),
21
+ ("lookalike", check_lookalike),
22
+ ]
23
+
24
+ REASON_STATUSES = ("warn", "fail", "error")
25
+
26
+
27
+ def verdict_for(total: int) -> str:
28
+ if total >= BLOCK_AT:
29
+ return "block"
30
+ if total >= WARN_AT:
31
+ return "warn"
32
+ return "pass"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class NamedResult:
37
+ check: str
38
+ status: str
39
+ detail: str
40
+ points: int
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Report:
45
+ package: str
46
+ results: list[NamedResult]
47
+
48
+ @property
49
+ def total(self) -> int:
50
+ return sum(r.points for r in self.results)
51
+
52
+ @property
53
+ def has_errors(self) -> bool:
54
+ return any(r.status == "error" for r in self.results)
55
+
56
+ @property
57
+ def verdict(self) -> str:
58
+ verdict = verdict_for(self.total)
59
+ # An unverified package must not read as a pass.
60
+ if verdict == "pass" and self.has_errors:
61
+ return "warn"
62
+ return verdict
63
+
64
+ @property
65
+ def reasons(self) -> list[NamedResult]:
66
+ """Results worth showing the user, with repeated details (e.g. one outage) collapsed."""
67
+ seen: set[str] = set()
68
+ reasons = []
69
+ for r in self.results:
70
+ if r.status in REASON_STATUSES and r.detail not in seen:
71
+ seen.add(r.detail)
72
+ reasons.append(r)
73
+ return reasons
74
+
75
+
76
+ async def score(package: str, client: PyPIClient) -> Report:
77
+ results = []
78
+ for name, check in ALL_CHECKS:
79
+ status, detail, points = await check(package, client)
80
+ results.append(NamedResult(name, status, detail, points))
81
+ return Report(package, results)
slopsentry/spec.py ADDED
@@ -0,0 +1,18 @@
1
+ """Parse package arguments like `requests==2.31.0` or `requests[socks]` into a name."""
2
+
3
+ from packaging.requirements import InvalidRequirement, Requirement
4
+
5
+
6
+ class InvalidPackageSpec(ValueError):
7
+ """The argument is not a valid package name or requirement."""
8
+
9
+
10
+ def parse_package_name(spec: str) -> str:
11
+ """Return the project name from a requirement string, dropping extras, versions and markers."""
12
+ if not spec.strip():
13
+ raise InvalidPackageSpec("package name is empty")
14
+ try:
15
+ return Requirement(spec).name
16
+ except InvalidRequirement as exc:
17
+ reason = str(exc).splitlines()[0]
18
+ raise InvalidPackageSpec(f"invalid package spec {spec!r}: {reason}") from exc