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.
- agentguards-1.0.0/PKG-INFO +74 -0
- agentguards-1.0.0/README.md +52 -0
- agentguards-1.0.0/agentguard/__init__.py +3 -0
- agentguards-1.0.0/agentguard/cli.py +211 -0
- agentguards-1.0.0/agentguard/config.py +38 -0
- agentguards-1.0.0/agentguard/deps/__init__.py +1 -0
- agentguards-1.0.0/agentguard/deps/parsers.py +127 -0
- agentguards-1.0.0/agentguard/deps/registry.py +131 -0
- agentguards-1.0.0/agentguard/deps/scoring.py +96 -0
- agentguards-1.0.0/agentguard/inference/__init__.py +1 -0
- agentguards-1.0.0/agentguard/inference/norms.py +44 -0
- agentguards-1.0.0/agentguard/inference/tenant_fields.py +148 -0
- agentguards-1.0.0/agentguard/logging.py +24 -0
- agentguards-1.0.0/agentguard/models/__init__.py +1 -0
- agentguards-1.0.0/agentguard/models/baseline.py +46 -0
- agentguards-1.0.0/agentguard/models/findings.py +51 -0
- agentguards-1.0.0/agentguard/models/fingerprint.py +61 -0
- agentguards-1.0.0/agentguard/parsing/__init__.py +1 -0
- agentguards-1.0.0/agentguard/parsing/git_diff.py +97 -0
- agentguards-1.0.0/agentguard/parsing/python_django.py +396 -0
- agentguards-1.0.0/agentguard/parsing/treesitter.py +62 -0
- agentguards-1.0.0/agentguard/reporting/__init__.py +1 -0
- agentguards-1.0.0/agentguard/reporting/github.py +57 -0
- agentguards-1.0.0/agentguard/reporting/json_report.py +23 -0
- agentguards-1.0.0/agentguard/reporting/markdown.py +44 -0
- agentguards-1.0.0/agentguard/reporting/sarif.py +71 -0
- agentguards-1.0.0/agentguard/rules/__init__.py +1 -0
- agentguards-1.0.0/agentguard/rules/auth_rules.py +163 -0
- agentguards-1.0.0/agentguard/rules/engine.py +93 -0
- agentguards-1.0.0/agentguard/rules/tenant_rules.py +172 -0
- agentguards-1.0.0/agentguard/suppress.py +77 -0
- 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,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
|
+
)
|