slopguard-cli 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/.gitignore +9 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/PKG-INFO +8 -7
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/README.md +7 -6
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/pyproject.toml +1 -1
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/__init__.py +1 -1
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/cli.py +71 -18
- slopguard_cli-0.4.0/slopguard/precommit.py +121 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/saas/client.py +10 -2
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/saas/credentials.py +8 -2
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/update.py +7 -13
- slopguard_cli-0.4.0/tests/conftest.py +22 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_cli.py +2 -2
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_misc.py +2 -2
- slopguard_cli-0.4.0/tests/test_precommit.py +78 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_saas_auth.py +4 -4
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_saas_client.py +6 -4
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_update.py +8 -20
- slopguard_cli-0.2.0/tests/conftest.py +0 -12
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/.ruff.toml +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/Makefile +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/scripts/generate_seed_data.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/__main__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/config.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/data/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/data/hallucinations_seed.json +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/data/popular_packages.json +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/models.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/parsers/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/parsers/base.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/parsers/npm.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/parsers/python.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/registry/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/registry/base.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/registry/npm.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/registry/pypi.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/report/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/report/json.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/report/terminal.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/saas/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/scoring/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/scoring/engine.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/slopguard/scoring/signals.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/fixtures/.slopguard.yaml +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/fixtures/package.json +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/fixtures/pyproject.toml +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/fixtures/requirements.txt +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_parsers_npm.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_parsers_python.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_registry_npm.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_registry_pypi.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.4.0}/tests/test_scoring_engine.py +0 -0
|
@@ -52,3 +52,12 @@ out/
|
|
|
52
52
|
.env
|
|
53
53
|
.env.*
|
|
54
54
|
!.env.example
|
|
55
|
+
|
|
56
|
+
# Web app local config + build output
|
|
57
|
+
apps/web/.env.local
|
|
58
|
+
apps/web/tsconfig.tsbuildinfo
|
|
59
|
+
apps/web/.next/
|
|
60
|
+
|
|
61
|
+
# Probe daily JSONLs are written by the cron; the directory is tracked
|
|
62
|
+
# via .gitkeep, individual files aren't.
|
|
63
|
+
probe-data/observations/*.jsonl
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slopguard-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Defend developers and AI coding agents against slopsquatting (hallucinated package names).
|
|
5
5
|
Project-URL: Homepage, https://github.com/hariomunknownslab/slopguard
|
|
6
6
|
Project-URL: Repository, https://github.com/hariomunknownslab/slopguard
|
|
@@ -167,18 +167,19 @@ scoring:
|
|
|
167
167
|
CLI flags override the file. See [`docs/usage.md`](docs/usage.md) for the full
|
|
168
168
|
reference.
|
|
169
169
|
|
|
170
|
-
## What it does NOT do
|
|
170
|
+
## What it does NOT do
|
|
171
171
|
|
|
172
|
-
- No
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
- No dashboard, no auth, no accounts, no billing, no telemetry. The
|
|
173
|
+
CLI is fully offline-capable; `slopguard update` is the only
|
|
174
|
+
outbound call beyond the npm + PyPI registry probes, and it just
|
|
175
|
+
fetches a static JSON file from GitHub Pages.
|
|
176
|
+
- No defensive package registration / tarpit.
|
|
175
177
|
- No Cursor / Claude Code / Copilot IDE plugins.
|
|
176
178
|
- No support for crates.io, pkg.go.dev, Maven Central, RubyGems, NuGet —
|
|
177
179
|
Python and JavaScript only.
|
|
178
180
|
- No license scanning, no CVE matching, no SBOM generation.
|
|
179
|
-
- No remote configuration, no SaaS API client.
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
Everything is MIT, free forever. Fork it.
|
|
182
183
|
|
|
183
184
|
## Privacy & trust
|
|
184
185
|
|
|
@@ -129,18 +129,19 @@ scoring:
|
|
|
129
129
|
CLI flags override the file. See [`docs/usage.md`](docs/usage.md) for the full
|
|
130
130
|
reference.
|
|
131
131
|
|
|
132
|
-
## What it does NOT do
|
|
132
|
+
## What it does NOT do
|
|
133
133
|
|
|
134
|
-
- No
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
- No dashboard, no auth, no accounts, no billing, no telemetry. The
|
|
135
|
+
CLI is fully offline-capable; `slopguard update` is the only
|
|
136
|
+
outbound call beyond the npm + PyPI registry probes, and it just
|
|
137
|
+
fetches a static JSON file from GitHub Pages.
|
|
138
|
+
- No defensive package registration / tarpit.
|
|
137
139
|
- No Cursor / Claude Code / Copilot IDE plugins.
|
|
138
140
|
- No support for crates.io, pkg.go.dev, Maven Central, RubyGems, NuGet —
|
|
139
141
|
Python and JavaScript only.
|
|
140
142
|
- No license scanning, no CVE matching, no SBOM generation.
|
|
141
|
-
- No remote configuration, no SaaS API client.
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
Everything is MIT, free forever. Fork it.
|
|
144
145
|
|
|
145
146
|
## Privacy & trust
|
|
146
147
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "slopguard-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Defend developers and AI coding agents against slopsquatting (hallucinated package names)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""SlopGuard CLI — ``scan``
|
|
1
|
+
"""SlopGuard CLI — ``scan``, ``update``, ``login``, ``whoami``, ``logout``, ``version``."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -274,7 +274,10 @@ def scan_cmd(
|
|
|
274
274
|
bool,
|
|
275
275
|
typer.Option(
|
|
276
276
|
"--upload",
|
|
277
|
-
help=
|
|
277
|
+
help=(
|
|
278
|
+
"After scanning, POST the JSON report to a self-hosted "
|
|
279
|
+
"SlopGuard instance. Requires `slopguard login --api-url …` first."
|
|
280
|
+
),
|
|
278
281
|
),
|
|
279
282
|
] = False,
|
|
280
283
|
) -> None:
|
|
@@ -370,14 +373,51 @@ def version_cmd() -> None:
|
|
|
370
373
|
print(__version__)
|
|
371
374
|
|
|
372
375
|
|
|
376
|
+
precommit_app = typer.Typer(
|
|
377
|
+
name="pre-commit",
|
|
378
|
+
help="Manage the git pre-commit hook that blocks HALLUCINATED commits.",
|
|
379
|
+
no_args_is_help=True,
|
|
380
|
+
)
|
|
381
|
+
app.add_typer(precommit_app, name="pre-commit")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@precommit_app.command("install")
|
|
385
|
+
def precommit_install_cmd(
|
|
386
|
+
force: Annotated[
|
|
387
|
+
bool,
|
|
388
|
+
typer.Option("--force", help="Overwrite an existing .git/hooks/pre-commit."),
|
|
389
|
+
] = False,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Install a git pre-commit hook that runs ``slopguard scan`` on staged manifests."""
|
|
392
|
+
from slopguard.precommit import PrecommitError, install
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
path = install(force=force)
|
|
396
|
+
except PrecommitError as exc:
|
|
397
|
+
raise _error_exit(str(exc)) from exc
|
|
398
|
+
Console().print(f"[green]Installed[/green] {path}")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@precommit_app.command("uninstall")
|
|
402
|
+
def precommit_uninstall_cmd() -> None:
|
|
403
|
+
"""Remove the slopguard pre-commit hook (only if we installed it)."""
|
|
404
|
+
from slopguard.precommit import PrecommitError, uninstall
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
removed = uninstall()
|
|
408
|
+
except PrecommitError as exc:
|
|
409
|
+
raise _error_exit(str(exc)) from exc
|
|
410
|
+
Console().print("[green]Removed.[/green]" if removed else "[yellow]Nothing to remove.[/yellow]")
|
|
411
|
+
|
|
412
|
+
|
|
373
413
|
@app.command("update")
|
|
374
414
|
def update_cmd() -> None:
|
|
375
|
-
"""Refresh the local hallucination DB from the
|
|
415
|
+
"""Refresh the local hallucination DB from the public GitHub Pages feed.
|
|
376
416
|
|
|
377
|
-
No auth required.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
next scan
|
|
417
|
+
No auth required. Default source:
|
|
418
|
+
``https://hariomunknownslab.github.io/slopguard/db.json`` (override via
|
|
419
|
+
``SLOPGUARD_DB_URL``). Cached at
|
|
420
|
+
``~/.cache/slopguard/hallucinations_db.json`` for the next scan.
|
|
381
421
|
"""
|
|
382
422
|
from slopguard.update import run
|
|
383
423
|
|
|
@@ -391,12 +431,20 @@ def update_cmd() -> None:
|
|
|
391
431
|
|
|
392
432
|
|
|
393
433
|
def _resolve_api_url(flag_value: str | None) -> str:
|
|
394
|
-
"""API URL resolution order: --api-url > $SLOPGUARD_API_URL
|
|
395
|
-
import os
|
|
434
|
+
"""API URL resolution order: --api-url > $SLOPGUARD_API_URL.
|
|
396
435
|
|
|
397
|
-
|
|
436
|
+
There is no default endpoint. Self-hosters supply their own URL —
|
|
437
|
+
SlopGuard does not run a hosted SaaS.
|
|
438
|
+
"""
|
|
439
|
+
import os
|
|
398
440
|
|
|
399
|
-
|
|
441
|
+
url = flag_value or os.environ.get("SLOPGUARD_API_URL", "")
|
|
442
|
+
if not url:
|
|
443
|
+
raise _error_exit(
|
|
444
|
+
"no API URL set. Pass --api-url https://your.instance/ or set SLOPGUARD_API_URL. "
|
|
445
|
+
"SlopGuard is open-source and self-hosted; there is no hosted SaaS."
|
|
446
|
+
)
|
|
447
|
+
return url
|
|
400
448
|
|
|
401
449
|
|
|
402
450
|
@app.command("login")
|
|
@@ -412,27 +460,32 @@ def login_cmd(
|
|
|
412
460
|
str | None,
|
|
413
461
|
typer.Option(
|
|
414
462
|
"--api-url",
|
|
415
|
-
help="SlopGuard API base URL.
|
|
463
|
+
help="Self-hosted SlopGuard API base URL. Falls back to $SLOPGUARD_API_URL.",
|
|
416
464
|
),
|
|
417
465
|
] = None,
|
|
418
466
|
) -> None:
|
|
419
|
-
"""Authenticate the CLI with a SlopGuard API token.
|
|
467
|
+
"""Authenticate the CLI with a self-hosted SlopGuard API token.
|
|
420
468
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
469
|
+
Run your own instance with ``docker compose up`` from the repo
|
|
470
|
+
root. Mint a token in your dashboard at
|
|
471
|
+
``<your-instance>/app/<org-slug>/tokens`` and paste it here. The
|
|
472
|
+
token is stored at ``~/.config/slopguard/credentials.toml`` with
|
|
473
|
+
mode 0600.
|
|
424
474
|
"""
|
|
425
475
|
from slopguard.saas import Credentials, save_credentials
|
|
426
476
|
from slopguard.saas.client import ApiError, get_me
|
|
427
477
|
|
|
428
|
-
|
|
478
|
+
# Validate the token format BEFORE we worry about the API URL —
|
|
479
|
+
# gives a clearer error if the user pasted gibberish.
|
|
429
480
|
plaintext = token or typer.prompt("API token (input hidden)", hide_input=True)
|
|
430
481
|
if not plaintext.startswith(("sg_live_", "sg_test_")):
|
|
431
482
|
raise _error_exit(
|
|
432
483
|
"token must start with sg_live_ or sg_test_; mint one at "
|
|
433
|
-
|
|
484
|
+
"<your-self-hosted-instance>/app/<org-slug>/tokens"
|
|
434
485
|
)
|
|
435
486
|
|
|
487
|
+
api = _resolve_api_url(api_url)
|
|
488
|
+
|
|
436
489
|
try:
|
|
437
490
|
me = get_me(api, plaintext)
|
|
438
491
|
except ApiError as exc:
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""``slopguard pre-commit install`` — writes a ``.git/hooks/pre-commit``.
|
|
2
|
+
|
|
3
|
+
The hook runs ``slopguard scan`` on the staged manifest files and
|
|
4
|
+
blocks the commit if it finds a HALLUCINATED dependency. Per
|
|
5
|
+
v2 spec § 10.4.
|
|
6
|
+
|
|
7
|
+
The hook itself is tiny — it shells out to ``slopguard scan
|
|
8
|
+
--paths <staged-manifests>`` so the scanner logic stays in one place.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
HOOK_TEMPLATE = """\
|
|
20
|
+
#!/bin/sh
|
|
21
|
+
# Auto-generated by `slopguard pre-commit install`.
|
|
22
|
+
# Aborts the commit on HALLUCINATED package findings in staged manifests.
|
|
23
|
+
# Re-generate by re-running `slopguard pre-commit install`.
|
|
24
|
+
|
|
25
|
+
set -e
|
|
26
|
+
|
|
27
|
+
# Find staged manifest files.
|
|
28
|
+
STAGED=$(git diff --cached --name-only --diff-filter=ACMR | tr '\\n' ' ')
|
|
29
|
+
MANIFESTS=""
|
|
30
|
+
for f in $STAGED; do
|
|
31
|
+
case "$f" in
|
|
32
|
+
package.json|*/package.json) MANIFESTS="$MANIFESTS $f" ;;
|
|
33
|
+
package-lock.json|*/package-lock.json) MANIFESTS="$MANIFESTS $f" ;;
|
|
34
|
+
requirements*.txt|*/requirements*.txt) MANIFESTS="$MANIFESTS $f" ;;
|
|
35
|
+
pyproject.toml|*/pyproject.toml) MANIFESTS="$MANIFESTS $f" ;;
|
|
36
|
+
Pipfile|*/Pipfile) MANIFESTS="$MANIFESTS $f" ;;
|
|
37
|
+
esac
|
|
38
|
+
done
|
|
39
|
+
|
|
40
|
+
# No manifest changes → nothing to do.
|
|
41
|
+
if [ -z "$MANIFESTS" ]; then
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# Scan each manifest. Exit on first HALLUCINATED finding.
|
|
46
|
+
for m in $MANIFESTS; do
|
|
47
|
+
if ! slopguard scan "$m" --fail-on hallucinated; then
|
|
48
|
+
echo ""
|
|
49
|
+
echo "slopguard pre-commit: blocked by HALLUCINATED finding."
|
|
50
|
+
echo " fix: remove the bad package, then re-stage the manifest."
|
|
51
|
+
echo " bypass (NOT recommended): git commit --no-verify"
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
done
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PrecommitError(Exception):
|
|
59
|
+
"""Raised when the hook can't be installed (e.g. not in a git repo)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _git_dir() -> Path:
|
|
63
|
+
"""Return the ``.git/`` directory of the current repo, or raise."""
|
|
64
|
+
try:
|
|
65
|
+
out = subprocess.run(
|
|
66
|
+
["git", "rev-parse", "--git-dir"],
|
|
67
|
+
check=True,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
)
|
|
71
|
+
except FileNotFoundError as exc:
|
|
72
|
+
raise PrecommitError("git not found on PATH") from exc
|
|
73
|
+
except subprocess.CalledProcessError as exc:
|
|
74
|
+
raise PrecommitError("not inside a git repository") from exc
|
|
75
|
+
git_dir = Path(out.stdout.strip())
|
|
76
|
+
if not git_dir.is_absolute():
|
|
77
|
+
git_dir = Path.cwd() / git_dir
|
|
78
|
+
return git_dir
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def install(*, force: bool = False) -> Path:
|
|
82
|
+
"""Write the pre-commit hook into ``.git/hooks/``. Returns its path.
|
|
83
|
+
|
|
84
|
+
If a hook already exists and ``force`` is False, leaves it alone
|
|
85
|
+
and raises ``PrecommitError`` — clobbering a user's existing hook
|
|
86
|
+
silently would be rude.
|
|
87
|
+
"""
|
|
88
|
+
git_dir = _git_dir()
|
|
89
|
+
hook_path = git_dir / "hooks" / "pre-commit"
|
|
90
|
+
if hook_path.exists() and not force:
|
|
91
|
+
body = hook_path.read_text(encoding="utf-8", errors="ignore")
|
|
92
|
+
if "slopguard pre-commit install" in body:
|
|
93
|
+
# Already ours — overwrite is fine.
|
|
94
|
+
pass
|
|
95
|
+
else:
|
|
96
|
+
raise PrecommitError(f"{hook_path} already exists. Re-run with --force to overwrite.")
|
|
97
|
+
hook_path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
hook_path.write_text(HOOK_TEMPLATE, encoding="utf-8")
|
|
99
|
+
# chmod +x
|
|
100
|
+
mode = hook_path.stat().st_mode
|
|
101
|
+
hook_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
102
|
+
return hook_path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def uninstall() -> bool:
|
|
106
|
+
"""Remove the hook if it was installed by us. Returns True if removed."""
|
|
107
|
+
git_dir = _git_dir()
|
|
108
|
+
hook_path = git_dir / "hooks" / "pre-commit"
|
|
109
|
+
if not hook_path.exists():
|
|
110
|
+
return False
|
|
111
|
+
body = hook_path.read_text(encoding="utf-8", errors="ignore")
|
|
112
|
+
if "slopguard pre-commit install" not in body:
|
|
113
|
+
# Not ours — refuse to delete.
|
|
114
|
+
print(
|
|
115
|
+
f"{hook_path} doesn't look like a slopguard-installed hook; "
|
|
116
|
+
"leaving alone. Delete it by hand if you want.",
|
|
117
|
+
file=sys.stderr,
|
|
118
|
+
)
|
|
119
|
+
return False
|
|
120
|
+
os.unlink(hook_path)
|
|
121
|
+
return True
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Tiny HTTP client for the SlopGuard API.
|
|
1
|
+
"""Tiny HTTP client for the SlopGuard API.
|
|
2
|
+
|
|
3
|
+
There is no canonical hosted SlopGuard API — every user runs their own.
|
|
4
|
+
Set ``SLOPGUARD_API_URL`` or pass ``--api-url`` to point at your
|
|
5
|
+
self-hosted instance.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
@@ -7,7 +12,10 @@ from typing import Any
|
|
|
7
12
|
|
|
8
13
|
import httpx
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
# Intentionally empty — no default endpoint. Users must point at their
|
|
16
|
+
# own instance via ``slopguard login --api-url …`` or by setting
|
|
17
|
+
# ``SLOPGUARD_API_URL``.
|
|
18
|
+
DEFAULT_API_URL = ""
|
|
11
19
|
|
|
12
20
|
|
|
13
21
|
class ApiError(Exception):
|
|
@@ -6,14 +6,20 @@ users on the machine cannot read it (spec §6.6).
|
|
|
6
6
|
Layout::
|
|
7
7
|
|
|
8
8
|
[active]
|
|
9
|
-
api_url = "https://api.slopguard.
|
|
9
|
+
api_url = "https://api.slopguard.example"
|
|
10
10
|
token = "sg_live_..."
|
|
11
11
|
org_slug = "acme-security"
|
|
12
12
|
organization_id = "..."
|
|
13
|
-
user_email = "
|
|
13
|
+
user_email = "you@example.com"
|
|
14
14
|
|
|
15
15
|
A future ``slopguard switch`` could let multiple profiles live side-by-
|
|
16
16
|
side; for now only ``[active]`` is read or written.
|
|
17
|
+
|
|
18
|
+
NOTE: The SaaS endpoints these credentials authenticate against were
|
|
19
|
+
deprecated in v0.2 (see apps/cli/slopguard/update.py). The CLI now
|
|
20
|
+
fetches the hallucination DB from GitHub Pages without auth. The
|
|
21
|
+
``saas`` package is kept for backward compatibility with any 0.1.x
|
|
22
|
+
self-hosted SaaS deployments.
|
|
17
23
|
"""
|
|
18
24
|
|
|
19
25
|
from __future__ import annotations
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""``slopguard update`` — refresh the local hallucination DB.
|
|
2
2
|
|
|
3
|
-
Fetches the curated DB from the
|
|
4
|
-
|
|
3
|
+
Fetches the curated DB from the public GitHub Pages site (no API, no
|
|
4
|
+
account, no auth) and writes a seed-shaped file to
|
|
5
5
|
``~/.cache/slopguard/hallucinations_db.json``. The next ``slopguard
|
|
6
6
|
scan`` prefers this cached file over the bundled seed (see
|
|
7
7
|
``slopguard.data.load_hallucination_db``).
|
|
@@ -13,9 +13,7 @@ Updated daily by the probe-cron GitHub Action; each entry goes through
|
|
|
13
13
|
PR review before publication (see probe-data/README.md in the repo).
|
|
14
14
|
|
|
15
15
|
Override the URL with ``SLOPGUARD_DB_URL`` to fetch from a fork or
|
|
16
|
-
mirror.
|
|
17
|
-
0.1.x SaaS endpoint shape — used as a fallback for backward
|
|
18
|
-
compatibility.
|
|
16
|
+
self-hosted mirror.
|
|
19
17
|
"""
|
|
20
18
|
|
|
21
19
|
from __future__ import annotations
|
|
@@ -35,18 +33,14 @@ CACHE_PATH = Path.home() / ".cache" / "slopguard" / "hallucinations_db.json"
|
|
|
35
33
|
def _db_url() -> str:
|
|
36
34
|
"""Where to fetch the published DB.
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
Override the default GitHub Pages URL with ``SLOPGUARD_DB_URL``
|
|
37
|
+
when running against a fork or a self-hosted mirror.
|
|
40
38
|
"""
|
|
41
|
-
|
|
42
|
-
return explicit
|
|
43
|
-
if legacy := os.environ.get("SLOPGUARD_API_URL"):
|
|
44
|
-
return legacy.rstrip("/") + "/v1/hallucinations"
|
|
45
|
-
return DEFAULT_DB_URL
|
|
39
|
+
return os.environ.get("SLOPGUARD_DB_URL") or DEFAULT_DB_URL
|
|
46
40
|
|
|
47
41
|
|
|
48
42
|
def _convert(wire: dict[str, Any]) -> dict[str, Any]:
|
|
49
|
-
"""Map the
|
|
43
|
+
"""Map the public-feed payload to the local seed schema."""
|
|
50
44
|
entries: list[dict[str, Any]] = []
|
|
51
45
|
for row in wire.get("entries", []):
|
|
52
46
|
entries.append(
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared pytest fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
# Force test isolation from the developer's real ~/.cache. If a previous
|
|
10
|
+
# `slopguard update` left an empty/stale cache file, the hallucination DB
|
|
11
|
+
# loader would pick it up and tests asserting on the bundled seed would
|
|
12
|
+
# break. Pointing CACHE_OVERRIDE at /dev/null/missing makes the loader
|
|
13
|
+
# fall back to the seed every time, regardless of host state.
|
|
14
|
+
from slopguard import data as _data
|
|
15
|
+
|
|
16
|
+
_data.CACHE_OVERRIDE = Path("/__slopguard_test_no_cache__")
|
|
17
|
+
_data.load_hallucination_db.cache_clear()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def fixtures_dir() -> Path:
|
|
22
|
+
return Path(__file__).parent / "fixtures"
|
|
@@ -107,7 +107,7 @@ def runner() -> CliRunner:
|
|
|
107
107
|
def test_version_subcommand(runner: CliRunner) -> None:
|
|
108
108
|
result = runner.invoke(app, ["version"])
|
|
109
109
|
assert result.exit_code == 0
|
|
110
|
-
assert result.stdout.strip() == "0.
|
|
110
|
+
assert result.stdout.strip() == "0.4.0"
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def test_scan_fixtures_no_network_terminal(runner: CliRunner, fixtures_dir: Path) -> None:
|
|
@@ -183,4 +183,4 @@ def test_module_main_runs() -> None:
|
|
|
183
183
|
check=False,
|
|
184
184
|
)
|
|
185
185
|
assert result.returncode == 0
|
|
186
|
-
assert result.stdout.strip() == "0.
|
|
186
|
+
assert result.stdout.strip() == "0.4.0"
|
|
@@ -40,8 +40,8 @@ def test_update_writes_cache_when_api_reachable(
|
|
|
40
40
|
from slopguard import update
|
|
41
41
|
|
|
42
42
|
monkeypatch.setattr(update, "CACHE_PATH", tmp_path / "h.json")
|
|
43
|
-
monkeypatch.setenv("
|
|
44
|
-
respx.get("http://
|
|
43
|
+
monkeypatch.setenv("SLOPGUARD_DB_URL", "http://feed.test/db.json")
|
|
44
|
+
respx.get("http://feed.test/db.json").mock(
|
|
45
45
|
return_value=httpx.Response(
|
|
46
46
|
200,
|
|
47
47
|
json={"generated_at": "2026-05-23T22:00:00Z", "count": 0, "entries": []},
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Tests for ``slopguard pre-commit install`` / ``uninstall``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from slopguard.precommit import PrecommitError, install, uninstall
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def in_git_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
16
|
+
"""Create a fresh empty git repo and cd into it."""
|
|
17
|
+
subprocess.run(["git", "init", "--quiet"], cwd=tmp_path, check=True)
|
|
18
|
+
monkeypatch.chdir(tmp_path)
|
|
19
|
+
return tmp_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_install_writes_executable_hook(in_git_repo: Path) -> None:
|
|
23
|
+
path = install()
|
|
24
|
+
assert path.exists()
|
|
25
|
+
assert os.access(path, os.X_OK)
|
|
26
|
+
body = path.read_text()
|
|
27
|
+
assert "slopguard pre-commit install" in body # sentinel
|
|
28
|
+
assert "slopguard scan" in body
|
|
29
|
+
assert "--fail-on hallucinated" in body
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_install_refuses_to_clobber_foreign_hook(in_git_repo: Path) -> None:
|
|
33
|
+
hook = in_git_repo / ".git" / "hooks" / "pre-commit"
|
|
34
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
hook.write_text("#!/bin/sh\necho something-else\n")
|
|
36
|
+
with pytest.raises(PrecommitError, match="already exists"):
|
|
37
|
+
install()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_install_force_overwrites(in_git_repo: Path) -> None:
|
|
41
|
+
hook = in_git_repo / ".git" / "hooks" / "pre-commit"
|
|
42
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
hook.write_text("#!/bin/sh\necho something-else\n")
|
|
44
|
+
install(force=True)
|
|
45
|
+
assert "slopguard pre-commit install" in hook.read_text()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_install_reinstalls_own_hook_without_force(in_git_repo: Path) -> None:
|
|
49
|
+
"""Re-running install on our own hook is fine — it's a refresh."""
|
|
50
|
+
install()
|
|
51
|
+
install() # no PrecommitError
|
|
52
|
+
hook = in_git_repo / ".git" / "hooks" / "pre-commit"
|
|
53
|
+
assert "slopguard pre-commit install" in hook.read_text()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_uninstall_removes_our_hook(in_git_repo: Path) -> None:
|
|
57
|
+
install()
|
|
58
|
+
assert uninstall() is True
|
|
59
|
+
assert not (in_git_repo / ".git" / "hooks" / "pre-commit").exists()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_uninstall_returns_false_when_absent(in_git_repo: Path) -> None:
|
|
63
|
+
assert uninstall() is False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_uninstall_refuses_to_delete_foreign_hook(in_git_repo: Path) -> None:
|
|
67
|
+
hook = in_git_repo / ".git" / "hooks" / "pre-commit"
|
|
68
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
hook.write_text("#!/bin/sh\necho theirs\n")
|
|
70
|
+
assert uninstall() is False
|
|
71
|
+
assert hook.exists()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_outside_git_repo_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
75
|
+
# tmp_path has no .git so git rev-parse fails.
|
|
76
|
+
monkeypatch.chdir(tmp_path)
|
|
77
|
+
with pytest.raises(PrecommitError, match="not inside a git repository"):
|
|
78
|
+
install()
|
|
@@ -39,7 +39,7 @@ def test_credentials_round_trip(tmp_path: Path) -> None:
|
|
|
39
39
|
token="sg_live_" + "x" * 24,
|
|
40
40
|
org_slug="acme",
|
|
41
41
|
organization_id="11111111-1111-1111-1111-111111111111",
|
|
42
|
-
user_email="
|
|
42
|
+
user_email="dev@example.com",
|
|
43
43
|
),
|
|
44
44
|
path=path,
|
|
45
45
|
)
|
|
@@ -85,7 +85,7 @@ def test_login_persists_creds_on_success(
|
|
|
85
85
|
assert token.startswith("sg_live_")
|
|
86
86
|
return saas_client.MeResponse(
|
|
87
87
|
user_id="user-1",
|
|
88
|
-
email="
|
|
88
|
+
email="dev@example.com",
|
|
89
89
|
principal_kind="token",
|
|
90
90
|
active_org=saas_client.MeOrg(
|
|
91
91
|
id="org-1",
|
|
@@ -127,7 +127,7 @@ def test_whoami_with_valid_creds_prints_org(
|
|
|
127
127
|
token="sg_live_" + "x" * 24,
|
|
128
128
|
org_slug="acme",
|
|
129
129
|
organization_id="org-1",
|
|
130
|
-
user_email="
|
|
130
|
+
user_email="dev@example.com",
|
|
131
131
|
),
|
|
132
132
|
path=cred_path,
|
|
133
133
|
)
|
|
@@ -136,7 +136,7 @@ def test_whoami_with_valid_creds_prints_org(
|
|
|
136
136
|
def fake_get_me(api_url: str, token: str, **_: object) -> saas_client.MeResponse:
|
|
137
137
|
return saas_client.MeResponse(
|
|
138
138
|
user_id="user-1",
|
|
139
|
-
email="
|
|
139
|
+
email="dev@example.com",
|
|
140
140
|
principal_kind="token",
|
|
141
141
|
active_org=saas_client.MeOrg(
|
|
142
142
|
id="org-1",
|
|
@@ -19,7 +19,7 @@ from slopguard.saas.client import (
|
|
|
19
19
|
|
|
20
20
|
ME_RESPONSE = {
|
|
21
21
|
"user_id": "user-1",
|
|
22
|
-
"email": "
|
|
22
|
+
"email": "dev@example.com",
|
|
23
23
|
"clerk_id": None,
|
|
24
24
|
"principal_kind": "token",
|
|
25
25
|
"active_organization": {
|
|
@@ -41,8 +41,10 @@ ME_RESPONSE = {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def
|
|
45
|
-
|
|
44
|
+
def test_default_api_url_is_empty() -> None:
|
|
45
|
+
"""No hosted SlopGuard SaaS — DEFAULT_API_URL is intentionally empty.
|
|
46
|
+
Users supply their own via --api-url or $SLOPGUARD_API_URL."""
|
|
47
|
+
assert DEFAULT_API_URL == ""
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
def test_api_error_message_includes_status_and_detail() -> None:
|
|
@@ -57,7 +59,7 @@ def test_get_me_returns_parsed_response() -> None:
|
|
|
57
59
|
me = get_me("http://api.test", "sg_live_" + "x" * 24)
|
|
58
60
|
assert isinstance(me, MeResponse)
|
|
59
61
|
assert isinstance(me.active_org, MeOrg)
|
|
60
|
-
assert me.email == "
|
|
62
|
+
assert me.email == "dev@example.com"
|
|
61
63
|
assert me.active_org.slug == "acme"
|
|
62
64
|
assert me.active_org.role == "owner"
|
|
63
65
|
|
|
@@ -22,7 +22,7 @@ def cache_at_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
|
22
22
|
|
|
23
23
|
@pytest.fixture
|
|
24
24
|
def api_at_test(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
25
|
-
monkeypatch.setenv("
|
|
25
|
+
monkeypatch.setenv("SLOPGUARD_DB_URL", "http://feed.test/db.json")
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
@respx.mock
|
|
@@ -44,9 +44,7 @@ def test_update_writes_cache(
|
|
|
44
44
|
}
|
|
45
45
|
],
|
|
46
46
|
}
|
|
47
|
-
respx.get("http://
|
|
48
|
-
return_value=httpx.Response(200, json=payload)
|
|
49
|
-
)
|
|
47
|
+
respx.get("http://feed.test/db.json").mock(return_value=httpx.Response(200, json=payload))
|
|
50
48
|
|
|
51
49
|
code = update.run()
|
|
52
50
|
assert code == 0
|
|
@@ -67,7 +65,7 @@ def test_update_writes_cache(
|
|
|
67
65
|
def test_update_handles_network_failure(
|
|
68
66
|
cache_at_tmp: Path, api_at_test: None, capsys: pytest.CaptureFixture[str]
|
|
69
67
|
) -> None:
|
|
70
|
-
respx.get("http://
|
|
68
|
+
respx.get("http://feed.test/db.json").mock(side_effect=httpx.ConnectError("nope"))
|
|
71
69
|
code = update.run()
|
|
72
70
|
assert code == 1
|
|
73
71
|
assert not cache_at_tmp.exists()
|
|
@@ -78,7 +76,7 @@ def test_update_handles_network_failure(
|
|
|
78
76
|
def test_update_rejects_malformed_payload(
|
|
79
77
|
cache_at_tmp: Path, api_at_test: None, capsys: pytest.CaptureFixture[str]
|
|
80
78
|
) -> None:
|
|
81
|
-
respx.get("http://
|
|
79
|
+
respx.get("http://feed.test/db.json").mock(
|
|
82
80
|
return_value=httpx.Response(200, json={"entries": [{"name": "x"}]})
|
|
83
81
|
)
|
|
84
82
|
code = update.run()
|
|
@@ -88,34 +86,24 @@ def test_update_rejects_malformed_payload(
|
|
|
88
86
|
|
|
89
87
|
|
|
90
88
|
def test_default_db_url_is_github_pages(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
91
|
-
"""When
|
|
89
|
+
"""When SLOPGUARD_DB_URL is not set, fetch from GitHub Pages."""
|
|
92
90
|
monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
|
|
93
|
-
monkeypatch.delenv("SLOPGUARD_API_URL", raising=False)
|
|
94
91
|
assert update._db_url() == update.DEFAULT_DB_URL
|
|
95
92
|
assert update.DEFAULT_DB_URL.startswith("https://hariomunknownslab.github.io/")
|
|
96
93
|
|
|
97
94
|
|
|
98
|
-
def
|
|
95
|
+
def test_slopguard_db_url_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
96
|
+
"""SLOPGUARD_DB_URL overrides the default Pages URL."""
|
|
99
97
|
monkeypatch.setenv("SLOPGUARD_DB_URL", "https://my.mirror/db.json")
|
|
100
|
-
monkeypatch.setenv("SLOPGUARD_API_URL", "https://legacy/api") # should be ignored
|
|
101
98
|
assert update._db_url() == "https://my.mirror/db.json"
|
|
102
99
|
|
|
103
100
|
|
|
104
|
-
def test_legacy_api_url_still_works(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
105
|
-
"""0.1.x users with SLOPGUARD_API_URL set keep working — we append
|
|
106
|
-
/v1/hallucinations the way the SaaS endpoint expected."""
|
|
107
|
-
monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
|
|
108
|
-
monkeypatch.setenv("SLOPGUARD_API_URL", "https://legacy.example/")
|
|
109
|
-
assert update._db_url() == "https://legacy.example/v1/hallucinations"
|
|
110
|
-
|
|
111
|
-
|
|
112
101
|
@respx.mock
|
|
113
102
|
def test_update_fetches_from_pages_by_default(
|
|
114
103
|
cache_at_tmp: Path, monkeypatch: pytest.MonkeyPatch
|
|
115
104
|
) -> None:
|
|
116
|
-
"""End-to-end: no env
|
|
105
|
+
"""End-to-end: no env override, hits the Pages URL."""
|
|
117
106
|
monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
|
|
118
|
-
monkeypatch.delenv("SLOPGUARD_API_URL", raising=False)
|
|
119
107
|
respx.get(update.DEFAULT_DB_URL).mock(
|
|
120
108
|
return_value=httpx.Response(
|
|
121
109
|
200,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|