agentguards 1.0.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.
Files changed (32) hide show
  1. agentguards-1.0.0/PKG-INFO +74 -0
  2. agentguards-1.0.0/README.md +52 -0
  3. agentguards-1.0.0/agentguard/__init__.py +3 -0
  4. agentguards-1.0.0/agentguard/cli.py +211 -0
  5. agentguards-1.0.0/agentguard/config.py +38 -0
  6. agentguards-1.0.0/agentguard/deps/__init__.py +1 -0
  7. agentguards-1.0.0/agentguard/deps/parsers.py +127 -0
  8. agentguards-1.0.0/agentguard/deps/registry.py +131 -0
  9. agentguards-1.0.0/agentguard/deps/scoring.py +96 -0
  10. agentguards-1.0.0/agentguard/inference/__init__.py +1 -0
  11. agentguards-1.0.0/agentguard/inference/norms.py +44 -0
  12. agentguards-1.0.0/agentguard/inference/tenant_fields.py +148 -0
  13. agentguards-1.0.0/agentguard/logging.py +24 -0
  14. agentguards-1.0.0/agentguard/models/__init__.py +1 -0
  15. agentguards-1.0.0/agentguard/models/baseline.py +46 -0
  16. agentguards-1.0.0/agentguard/models/findings.py +51 -0
  17. agentguards-1.0.0/agentguard/models/fingerprint.py +61 -0
  18. agentguards-1.0.0/agentguard/parsing/__init__.py +1 -0
  19. agentguards-1.0.0/agentguard/parsing/git_diff.py +97 -0
  20. agentguards-1.0.0/agentguard/parsing/python_django.py +396 -0
  21. agentguards-1.0.0/agentguard/parsing/treesitter.py +62 -0
  22. agentguards-1.0.0/agentguard/reporting/__init__.py +1 -0
  23. agentguards-1.0.0/agentguard/reporting/github.py +57 -0
  24. agentguards-1.0.0/agentguard/reporting/json_report.py +23 -0
  25. agentguards-1.0.0/agentguard/reporting/markdown.py +44 -0
  26. agentguards-1.0.0/agentguard/reporting/sarif.py +71 -0
  27. agentguards-1.0.0/agentguard/rules/__init__.py +1 -0
  28. agentguards-1.0.0/agentguard/rules/auth_rules.py +163 -0
  29. agentguards-1.0.0/agentguard/rules/engine.py +93 -0
  30. agentguards-1.0.0/agentguard/rules/tenant_rules.py +172 -0
  31. agentguards-1.0.0/agentguard/suppress.py +77 -0
  32. agentguards-1.0.0/pyproject.toml +71 -0
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentguards
3
+ Version: 1.0.0
4
+ Summary: GitHub Action that detects authorization and tenant-isolation regressions in Python/Django pull requests
5
+ Author: AgentGuard Team
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: loguru>=0.7.0
9
+ Requires-Dist: pydantic-settings>=2.4.0
10
+ Requires-Dist: pydantic>=2.10.0
11
+ Requires-Dist: rich>=13.0.0
12
+ Requires-Dist: tree-sitter-python>=0.24.0
13
+ Requires-Dist: tree-sitter>=0.24.0
14
+ Requires-Dist: typer>=0.12.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy>=1.11.0; extra == 'dev'
17
+ Requires-Dist: pre-commit>=3.8.0; extra == 'dev'
18
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # AgentGuard
24
+
25
+ AgentGuard is a GitHub Action that detects authorization and tenant-isolation regressions in Python/Django pull requests, especially AI-generated ones.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install agentguard
31
+ ```
32
+
33
+ ## Dev setup
34
+
35
+ ```bash
36
+ # Clone the repo
37
+ cd agentguard
38
+
39
+ # Install with uv (recommended)
40
+ uv sync --dev
41
+
42
+ # Or with pip
43
+ pip install -e ".[dev]"
44
+ ```
45
+
46
+ ## Test commands
47
+
48
+ ```bash
49
+ # Run all tests
50
+ pytest
51
+
52
+ # Run with coverage
53
+ pytest --cov=src/agentguard
54
+
55
+ # Lint
56
+ ruff check src/ tests/
57
+
58
+ # Format
59
+ ruff format src/ tests/
60
+
61
+ # Type check
62
+ mypy --strict src/
63
+ ```
64
+
65
+ ## CLI usage
66
+
67
+ ```bash
68
+ agentguard version
69
+ agentguard scan --base main --head feature-branch --format json --mode observe --output report.json
70
+ ```
71
+
72
+ ## GitHub Action
73
+
74
+ See `.github/workflows/ci.yml` for the CI pipeline and `action.yml` for the composite action.
@@ -0,0 +1,52 @@
1
+ # AgentGuard
2
+
3
+ AgentGuard is a GitHub Action that detects authorization and tenant-isolation regressions in Python/Django pull requests, especially AI-generated ones.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install agentguard
9
+ ```
10
+
11
+ ## Dev setup
12
+
13
+ ```bash
14
+ # Clone the repo
15
+ cd agentguard
16
+
17
+ # Install with uv (recommended)
18
+ uv sync --dev
19
+
20
+ # Or with pip
21
+ pip install -e ".[dev]"
22
+ ```
23
+
24
+ ## Test commands
25
+
26
+ ```bash
27
+ # Run all tests
28
+ pytest
29
+
30
+ # Run with coverage
31
+ pytest --cov=src/agentguard
32
+
33
+ # Lint
34
+ ruff check src/ tests/
35
+
36
+ # Format
37
+ ruff format src/ tests/
38
+
39
+ # Type check
40
+ mypy --strict src/
41
+ ```
42
+
43
+ ## CLI usage
44
+
45
+ ```bash
46
+ agentguard version
47
+ agentguard scan --base main --head feature-branch --format json --mode observe --output report.json
48
+ ```
49
+
50
+ ## GitHub Action
51
+
52
+ See `.github/workflows/ci.yml` for the CI pipeline and `action.yml` for the composite action.
@@ -0,0 +1,3 @@
1
+ """AgentGuard - authorization and tenant-isolation regression detection for Python/Django PRs."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,211 @@
1
+ """Typer CLI entrypoint for AgentGuard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from agentguard.config import AgentGuardConfig, Mode
10
+ from agentguard.logging import setup_logging
11
+
12
+ from agentguard.models.baseline import Baseline, TenantModelMap
13
+ from agentguard.models.findings import Confidence, Finding, RuleId, Severity
14
+ from agentguard.models.fingerprint import AuthFingerprint
15
+
16
+ app = typer.Typer(
17
+ name="agentguard",
18
+ help="AgentGuard: detect authorization and tenant-isolation regressions in PRs.",
19
+ )
20
+
21
+
22
+ @app.command()
23
+ def version() -> None:
24
+ """Print the AgentGuard version."""
25
+ from agentguard import __version__
26
+
27
+ typer.echo(f"agentguard {__version__}")
28
+
29
+
30
+ @app.command()
31
+ def scan(
32
+ base: str = typer.Option("main", "--base", "-b", help="Base git ref"),
33
+ head: str = typer.Option("HEAD", "--head", "-H", help="Head git ref"),
34
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json, sarif, markdown"),
35
+ mode: str = typer.Option("observe", "--mode", "-m", help="Scan mode: observe, warn, enforce"),
36
+ output: str | None = typer.Option(None, "--output", "-o", help="Output file path"),
37
+ ) -> int:
38
+ """Scan a git diff for authorization and tenant-isolation regressions."""
39
+ from agentguard.config import AgentGuardConfig, Mode
40
+ from agentguard.logging import setup_logging
41
+
42
+ setup_logging()
43
+ config = AgentGuardConfig(mode=Mode(mode))
44
+
45
+ from agentguard.parsing.git_diff import resolve_base_head, list_changed_files, get_file_at_ref
46
+ from agentguard.parsing.python_django import DjangoExtractor
47
+ from agentguard.rules.engine import Engine, RuleContext
48
+ from agentguard.rules.auth_rules import AuthGuardRemoved, AuthGuardWeakened, NewEndpointUnGuarded
49
+ from agentguard.rules.tenant_rules import TenantFilterRemoved, NewQueryWithoutTenantFilter, PKLookupOnTenantModel
50
+ from agentguard.models.baseline import Baseline, TenantModelMap
51
+ from agentguard.models.findings import Finding
52
+ from agentguard.models.findings import Confidence
53
+ from agentguard.models.fingerprint import AuthFingerprint
54
+ from agentguard.rules.engine import Engine, RuleContext
55
+ from agentguard.rules.auth_rules import AuthGuardRemoved, AuthGuardWeakened, NewEndpointUnGuarded
56
+ from agentguard.rules.tenant_rules import TenantFilterRemoved, NewQueryWithoutTenantFilter, PKLookupOnTenantModel
57
+ from agentguard.reporting.json_report import JSONReport
58
+ from agentguard.reporting.sarif import SARIFReport
59
+ from agentguard.reporting.markdown import MarkdownReport
60
+
61
+ try:
62
+ base_sha, head_sha = resolve_base_head(base, head)
63
+ except ValueError as exc:
64
+ typer.echo(f"Error resolving refs: {exc}", err=True)
65
+ return 2
66
+
67
+ changed_files = list_changed_files(base_sha, head_sha)
68
+
69
+ extractor = DjangoExtractor()
70
+ base_fps: list[AuthFingerprint] = []
71
+ head_fps: list[AuthFingerprint] = []
72
+
73
+ for cf in changed_files:
74
+ if cf.status == "deleted":
75
+ content = get_file_at_ref(base_sha, cf.path)
76
+ if content:
77
+ import os
78
+ import tempfile
79
+
80
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
81
+ f.write(content)
82
+ tmp = f.name
83
+ base_fps.extend(extractor.extract_file(tmp))
84
+ os.unlink(tmp)
85
+ elif cf.status == "added":
86
+ content = get_file_at_ref(head_sha, cf.path)
87
+ if content:
88
+ import os
89
+ import tempfile
90
+
91
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
92
+ f.write(content)
93
+ tmp = f.name
94
+ head_fps.extend(extractor.extract_file(tmp))
95
+ os.unlink(tmp)
96
+ else: # modified, renamed
97
+ base_content = get_file_at_ref(base_sha, cf.path)
98
+ head_content = get_file_at_ref(head_sha, cf.path)
99
+ if base_content:
100
+ import os
101
+ import tempfile
102
+
103
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
104
+ f.write(base_content)
105
+ tmp = f.name
106
+ base_fps.extend(extractor.extract_file(tmp))
107
+ os.unlink(tmp)
108
+ if head_content:
109
+ import os
110
+ import tempfile
111
+
112
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
113
+ f.write(head_content)
114
+ tmp = f.name
115
+ head_fps.extend(extractor.extract_file(tmp))
116
+ os.unlink(tmp)
117
+
118
+ context = RuleContext(
119
+ baseline=Baseline(),
120
+ tenant_model_map=TenantModelMap(),
121
+ base_fingerprints={fp.endpoint_id: fp for fp in base_fps},
122
+ head_fingerprints={fp.endpoint_id: fp for fp in head_fps},
123
+ )
124
+
125
+ rules = [
126
+ AuthGuardRemoved(),
127
+ AuthGuardWeakened(),
128
+ NewEndpointUnGuarded(),
129
+ TenantFilterRemoved(),
130
+ NewQueryWithoutTenantFilter(),
131
+ PKLookupOnTenantModel(),
132
+ ]
133
+
134
+ engine = Engine(rules)
135
+ findings = engine.run(base_fps, head_fps, context)
136
+
137
+ # Format output
138
+ report_format = format.lower()
139
+ if report_format == "sarif":
140
+ report = SARIFReport(findings).render()
141
+ elif report_format == "markdown":
142
+ report = MarkdownReport(findings).render()
143
+ else:
144
+ report = JSONReport(findings).render()
145
+
146
+ if output:
147
+ Path(output).write_text(report)
148
+ else:
149
+ typer.echo(report)
150
+
151
+ # Exit code logic
152
+ has_findings = len(findings) > 0
153
+ has_critical_or_high = any(
154
+ f.severity in (Severity.HIGH, Severity.CRITICAL) for f in findings
155
+ )
156
+
157
+ if config.mode == Mode.ENFORCE and has_findings:
158
+ return 1
159
+ if config.mode == Mode.WARN and has_critical_or_high:
160
+ return 1
161
+ return 0
162
+
163
+
164
+ @app.command()
165
+ def schemas(
166
+ output_dir: str = typer.Option(
167
+ "docs/schemas", "--output-dir", "-o", help="Output directory for JSON Schema files"
168
+ ),
169
+ ) -> None:
170
+ """Export JSON Schema for all AgentGuard data models."""
171
+ from agentguard.models.baseline import Baseline, TenantModelMap, TenantModelMapEntry
172
+ from agentguard.models.fingerprint import AuthFingerprint, GuardFact, QueryFact
173
+ from agentguard.models.findings import Confidence, Finding, RuleId
174
+
175
+ # Only export schemas for Pydantic v2 models; skip plain Enums
176
+ pydantic_models = {
177
+ "AuthFingerprint": AuthFingerprint,
178
+ "GuardFact": GuardFact,
179
+ "QueryFact": QueryFact,
180
+ "Finding": Finding,
181
+ "Baseline": Baseline,
182
+ "TenantModelMap": TenantModelMap,
183
+ "TenantModelMapEntry": TenantModelMapEntry,
184
+ }
185
+
186
+ out = Path(output_dir)
187
+ out.mkdir(parents=True, exist_ok=True)
188
+
189
+ for name, model_cls in pydantic_models.items():
190
+ schema = model_cls.model_json_schema()
191
+ schema_file = out / f"{name}.json"
192
+ schema_file.write_text(__import__("json").dumps(schema, indent=2))
193
+ typer.echo(f"Wrote {schema_file}")
194
+
195
+ # Export enum string values
196
+ enum_values = {
197
+ "Severity": list(Severity.__members__.values()),
198
+ "Confidence": list(Confidence.__members__.values()),
199
+ "RuleId": list(RuleId.__members__.values()),
200
+ }
201
+ enum_dir = out / "enums"
202
+ enum_dir.mkdir(parents=True, exist_ok=True)
203
+ for name, values in enum_values.items():
204
+ enum_file = enum_dir / f"{name}.txt"
205
+ enum_file.write_text("
206
+ ".join(v.value for v in values))
207
+ typer.echo(f"Wrote {enum_file}")
208
+
209
+
210
+ if __name__ == "__main__":
211
+ app()
@@ -0,0 +1,38 @@
1
+ """AgentGuard configuration using pydantic-settings."""
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import Field
6
+ from pydantic_settings import BaseSettings
7
+
8
+
9
+ class Mode(StrEnum):
10
+ """Scan mode: observe (report only), warn (fail on findings), enforce (block build)."""
11
+
12
+ OBSERVE = "observe"
13
+ WARN = "warn"
14
+ ENFORCE = "enforce"
15
+
16
+
17
+ class AgentGuardConfig(BaseSettings):
18
+ """AgentGuard runtime configuration."""
19
+
20
+ mode: Mode = Field(default=Mode.OBSERVE, description="Scan mode: observe, warn, or enforce")
21
+ max_file_size_bytes: int = Field(
22
+ default=1_000_000, description="Maximum file size to parse in bytes"
23
+ )
24
+ parse_timeout_seconds: float = Field(
25
+ default=10.0, description="Per-file parse timeout in seconds"
26
+ )
27
+ enable_dependency_guard: bool = Field(
28
+ default=False, description="Enable dependency guard scanning"
29
+ )
30
+ offline_fallback: bool = Field(
31
+ default=True, description="Fail open to REVIEW when network is unavailable"
32
+ )
33
+
34
+ model_config = {
35
+ "env_prefix": "AGENTGUARD_",
36
+ "env_file": ".env",
37
+ "extra": "ignore",
38
+ }
@@ -0,0 +1 @@
1
+ """AgentGuard dependency guard module."""
@@ -0,0 +1,127 @@
1
+ """Dependency file parsers for requirements, pyproject, lockfiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import tomllib
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ @dataclass
13
+ class Dependency:
14
+ """A single dependency with name and version."""
15
+
16
+ name: str
17
+ version: str | None = None
18
+ source_file: str = ""
19
+ line: int = 0
20
+
21
+
22
+ def parse_requirements_txt(file_path: str) -> list[Dependency]:
23
+ """Parse a requirements*.txt file."""
24
+ deps: list[Dependency] = []
25
+ path = Path(file_path)
26
+ if not path.exists():
27
+ return deps
28
+ for i, line in enumerate(path.read_text().splitlines(), 1):
29
+ line = line.strip()
30
+ if not line or line.startswith("#") or line.startswith("-"):
31
+ continue
32
+ # Handle package==version, package>=version, package~=version
33
+ match = re.match(r"^([a-zA-Z0-9_.\-]+)\s*(?:==|>=|<=|~=|>|<|!)\s*(.+)", line)
34
+ if match:
35
+ deps.append(
36
+ Dependency(
37
+ name=match.group(1), version=match.group(2), source_file=file_path, line=i
38
+ )
39
+ )
40
+ else:
41
+ # Bare package name
42
+ match = re.match(r"^([a-zA-Z0-9_.\-]+)", line)
43
+ if match:
44
+ deps.append(Dependency(name=match.group(1), source_file=file_path, line=i))
45
+ return deps
46
+
47
+
48
+ def parse_pyproject_toml(file_path: str) -> list[Dependency]:
49
+ """Parse a pyproject.toml file for dependencies."""
50
+ deps: list[Dependency] = []
51
+ path = Path(file_path)
52
+ if not path.exists():
53
+ return deps
54
+ try:
55
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
56
+ except Exception:
57
+ return deps
58
+
59
+ # Check [project] dependencies
60
+ project = data.get("project", {})
61
+ for dep_str in project.get("dependencies", []):
62
+ match = re.match(r"^([a-zA-Z0-9_.\-]+)\s*(?:==|>=|<=|~=|>|<|!)\s*(.+)", dep_str)
63
+ if match:
64
+ deps.append(
65
+ Dependency(name=match.group(1), version=match.group(2), source_file=file_path)
66
+ )
67
+ else:
68
+ match = re.match(r"^([a-zA-Z0-9_.\-]+)", dep_str)
69
+ if match:
70
+ deps.append(Dependency(name=match.group(1), source_file=file_path))
71
+
72
+ # Check [tool.poetry] dependencies
73
+ poetry = data.get("tool", {}).get("poetry", {})
74
+ for name, ver in poetry.get("dependencies", {}).items():
75
+ if name == "python":
76
+ continue
77
+ version = ver if isinstance(ver, str) else None
78
+ deps.append(Dependency(name=name, version=version, source_file=file_path))
79
+
80
+ return deps
81
+
82
+
83
+ def parse_lockfile(file_path: str) -> list[Dependency]:
84
+ """Parse a lockfile (poetry.lock, package-lock.json, etc.)."""
85
+ deps: list[Dependency] = []
86
+ path = Path(file_path)
87
+ if not path.exists():
88
+ return deps
89
+
90
+ name = path.name
91
+ if name == "poetry.lock":
92
+ return _parse_poetry_lock(path)
93
+ if name in ("package-lock.json", "package.json"):
94
+ return _parse_npm_lock(path)
95
+ if name in ("pnpm-lock.yaml", "yarn.lock"):
96
+ # Basic parsing - just return empty for now
97
+ return deps
98
+ return deps
99
+
100
+
101
+ def _parse_poetry_lock(path: Path) -> list[Dependency]:
102
+ deps: list[Dependency] = []
103
+ try:
104
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
105
+ except Exception:
106
+ return deps
107
+ for pkg in data.get("package", []):
108
+ deps.append(
109
+ Dependency(name=pkg.get("name", ""), version=pkg.get("version"), source_file=str(path))
110
+ )
111
+ return deps
112
+
113
+
114
+ def _parse_npm_lock(path: Path) -> list[Dependency]:
115
+ deps: list[Dependency] = []
116
+ try:
117
+ data = json.loads(path.read_text(encoding="utf-8"))
118
+ except Exception:
119
+ return deps
120
+ packages = data.get("packages", {})
121
+ for pkg_path, pkg_info in packages.items():
122
+ if not pkg_path:
123
+ continue
124
+ pkg_name = pkg_path.split("/")[-1] if "/" in pkg_path else pkg_path
125
+ version = pkg_info.get("version")
126
+ deps.append(Dependency(name=pkg_name, version=version, source_file=str(path)))
127
+ return deps
@@ -0,0 +1,131 @@
1
+ """Registry clients for PyPI and npm with caching and timeouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass
14
+ class RegistryInfo:
15
+ """Information about a package from a registry."""
16
+
17
+ name: str
18
+ exists: bool
19
+ first_publish: str | None = None
20
+ release_count: int = 0
21
+ download_count: int | None = None
22
+ maintainer_count: int = 0
23
+ recent_maintainer_changes: bool = False
24
+ has_install_scripts: bool = False
25
+
26
+
27
+ @dataclass
28
+ class PackageVerdict:
29
+ """Verdict for a dependency package."""
30
+
31
+ name: str
32
+ verdict: str # ALLOW, REVIEW, BLOCK_HALLUCINATION, AUTO_REMEDIATE
33
+ reasons: list[str]
34
+ suggested_alternative: str | None = None
35
+ registry_info: RegistryInfo | None = None
36
+
37
+
38
+ class PackageRegistry:
39
+ """Base class for package registry clients with caching."""
40
+
41
+ def __init__(self, cache_dir: str | None = None, timeout: float = 10.0) -> None:
42
+ self._timeout = timeout
43
+ self._cache: dict[str, tuple[float, RegistryInfo]] = {}
44
+ self._cache_dir = Path(cache_dir) if cache_dir else None
45
+ if self._cache_dir:
46
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
47
+
48
+ def _get_from_cache(self, name: str) -> RegistryInfo | None:
49
+ if name not in self._cache:
50
+ return None
51
+ timestamp, info = self._cache[name]
52
+ if time.time() - timestamp > 3600: # 1 hour cache
53
+ del self._cache[name]
54
+ return None
55
+ return info
56
+
57
+ def _put_in_cache(self, name: str, info: RegistryInfo) -> None:
58
+ self._cache[name] = (time.time(), info)
59
+
60
+ def lookup(self, name: str) -> RegistryInfo:
61
+ """Look up a package in the registry."""
62
+ cached = self._get_from_cache(name)
63
+ if cached:
64
+ return cached
65
+ info = self._fetch(name)
66
+ self._put_in_cache(name, info)
67
+ return info
68
+
69
+ def _fetch(self, name: str) -> RegistryInfo:
70
+ raise NotImplementedError
71
+
72
+
73
+ class PyPIRegistry(PackageRegistry):
74
+ """PyPI JSON API client."""
75
+
76
+ def _fetch(self, name: str) -> RegistryInfo:
77
+ url = f"https://pypi.org/pypi/{name}/json"
78
+ try:
79
+ req = urllib.request.Request(url, headers={"User-Agent": "AgentGuard/0.1"})
80
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310
81
+ data = json.loads(resp.read())
82
+ except urllib.error.HTTPError as e:
83
+ if e.code == 404:
84
+ return RegistryInfo(name=name, exists=False)
85
+ return RegistryInfo(name=name, exists=False)
86
+ except Exception: # noqa: S110
87
+ return RegistryInfo(name=name, exists=False)
88
+
89
+ info = data.get("info", {})
90
+ releases = data.get("releases", {})
91
+ return RegistryInfo(
92
+ name=name,
93
+ exists=True,
94
+ first_publish=info.get("first_upload"),
95
+ release_count=len(releases),
96
+ maintainer_count=len(info.get("maintainers", [])),
97
+ )
98
+
99
+
100
+ class NPMRegistry(PackageRegistry):
101
+ """npm registry client."""
102
+
103
+ def _fetch(self, name: str) -> RegistryInfo:
104
+ url = f"https://registry.npmjs.org/{name}"
105
+ try:
106
+ req = urllib.request.Request(url, headers={"User-Agent": "AgentGuard/0.1"})
107
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310
108
+ data = json.loads(resp.read())
109
+ except urllib.error.HTTPError as e:
110
+ if e.code == 404:
111
+ return RegistryInfo(name=name, exists=False)
112
+ return RegistryInfo(name=name, exists=False)
113
+ except Exception: # noqa: S110
114
+ return RegistryInfo(name=name, exists=False)
115
+
116
+ time_data = data.get("time", {})
117
+ versions = data.get("versions", {})
118
+ first_publish = time_data.get("created")
119
+ maintainers = data.get("maintainers", [])
120
+
121
+ return RegistryInfo(
122
+ name=name,
123
+ exists=True,
124
+ first_publish=first_publish,
125
+ release_count=len(versions),
126
+ maintainer_count=len(maintainers),
127
+ has_install_scripts=any(
128
+ "scripts" in v and ("preinstall" in v["scripts"] or "postinstall" in v["scripts"])
129
+ for v in versions.values()
130
+ ),
131
+ )