agent-diff-guard 0.1.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.
- agent_diff_guard-0.1.0/LICENSE +21 -0
- agent_diff_guard-0.1.0/PKG-INFO +122 -0
- agent_diff_guard-0.1.0/README.md +102 -0
- agent_diff_guard-0.1.0/agent_diff_guard/__init__.py +0 -0
- agent_diff_guard-0.1.0/agent_diff_guard/analyze.py +80 -0
- agent_diff_guard-0.1.0/agent_diff_guard/cli.py +158 -0
- agent_diff_guard-0.1.0/agent_diff_guard/git_diff.py +28 -0
- agent_diff_guard-0.1.0/agent_diff_guard/license.py +44 -0
- agent_diff_guard-0.1.0/agent_diff_guard/sarif.py +41 -0
- agent_diff_guard-0.1.0/agent_diff_guard.egg-info/PKG-INFO +122 -0
- agent_diff_guard-0.1.0/agent_diff_guard.egg-info/SOURCES.txt +22 -0
- agent_diff_guard-0.1.0/agent_diff_guard.egg-info/dependency_links.txt +1 -0
- agent_diff_guard-0.1.0/agent_diff_guard.egg-info/entry_points.txt +2 -0
- agent_diff_guard-0.1.0/agent_diff_guard.egg-info/requires.txt +1 -0
- agent_diff_guard-0.1.0/agent_diff_guard.egg-info/top_level.txt +1 -0
- agent_diff_guard-0.1.0/pyproject.toml +31 -0
- agent_diff_guard-0.1.0/setup.cfg +4 -0
- agent_diff_guard-0.1.0/tests/test_analyze.py +42 -0
- agent_diff_guard-0.1.0/tests/test_check.py +81 -0
- agent_diff_guard-0.1.0/tests/test_cli.py +51 -0
- agent_diff_guard-0.1.0/tests/test_cli_licensing.py +22 -0
- agent_diff_guard-0.1.0/tests/test_license.py +59 -0
- agent_diff_guard-0.1.0/tests/test_pro_gating.py +104 -0
- agent_diff_guard-0.1.0/tests/test_sarif.py +46 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wally
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-diff-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Flags risky changes to AI agent configs (prompts, tool permissions, model choice) in a diff.
|
|
5
|
+
Author: Wally
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jwa-wa/agent-diff-guard
|
|
8
|
+
Project-URL: Pro license, https://skitamaze.gumroad.com/l/dkybda
|
|
9
|
+
Keywords: ai-agents,llm-security,prompt-injection,ci,github-actions
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: certifi
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# agent-diff-guard
|
|
22
|
+
|
|
23
|
+
Catches risky changes to an AI agent's config — system prompt, tool permissions, model choice —
|
|
24
|
+
before they merge. A narrow, single-purpose CI check, not a full observability platform: point it
|
|
25
|
+
at a repo and it scans every changed file against your base branch automatically.
|
|
26
|
+
|
|
27
|
+
**[Get a Pro license →](https://skitamaze.gumroad.com/l/dkybda)** — full per-file detail, model
|
|
28
|
+
cost-tier detection, SARIF output for GitHub code scanning.
|
|
29
|
+
|
|
30
|
+
## What it catches
|
|
31
|
+
|
|
32
|
+
- **Safety instructions removed** — a phrase like "must never" / "do not" / "refuse" present
|
|
33
|
+
before a change and missing after it.
|
|
34
|
+
- **Dangerous tool permissions added** — `shell_exec`, `sudo`, `rm -rf`, `exec(`, `eval(`,
|
|
35
|
+
`admin_override` appearing for the first time.
|
|
36
|
+
- **Model swapped to a pricier tier** (Pro) — e.g. haiku → opus, gpt-4o-mini → gpt-4o.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install agent-diff-guard
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### `check` — scans changed files against a git ref (recommended)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
agent-diff-guard check # all files changed vs. HEAD, in the current repo
|
|
50
|
+
agent-diff-guard check --against origin/main # all files changed vs. another ref
|
|
51
|
+
agent-diff-guard check path/to/agent_config.yaml # restrict to one file
|
|
52
|
+
agent-diff-guard check --repo /path/to/repo # run against a repo other than cwd
|
|
53
|
+
agent-diff-guard check --license-key "$LICENSE" # unlock Pro (or set AGENT_DIFF_GUARD_LICENSE)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Exit code is `1` if any file has a HIGH severity finding, `0` otherwise — safe to use as a CI gate.
|
|
57
|
+
The exit code always reflects every scanned file, on the free tier too.
|
|
58
|
+
|
|
59
|
+
Free tier shows full detail (path + findings) for the first 3 files with findings; beyond that it
|
|
60
|
+
collapses into a one-line summary — the CI gate still fails correctly either way, Pro just gives
|
|
61
|
+
the full per-file breakdown.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
agent-diff-guard check --format sarif --license-key "$LICENSE" # Pro: SARIF output
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A Pro license key is verified live against Gumroad on every run. If it's invalid or unreachable,
|
|
68
|
+
the tool falls back to the free tier rather than erroring out, so a network hiccup never breaks
|
|
69
|
+
your CI.
|
|
70
|
+
|
|
71
|
+
### `diff` — compare two file versions directly
|
|
72
|
+
|
|
73
|
+
For comparing arbitrary snapshots outside a git checkout:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
agent-diff-guard diff path/to/before.txt path/to/after.txt
|
|
77
|
+
agent-diff-guard diff path/to/before.txt path/to/after.txt --license-key "$LICENSE"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## GitHub Action
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
- uses: jwa-wa/agent-diff-guard@v1
|
|
84
|
+
with:
|
|
85
|
+
license-key: ${{ secrets.AGENT_DIFF_GUARD_LICENSE }} # optional, omit for free tier
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That's the whole setup for a `pull_request` workflow — it reads the PR's base branch automatically
|
|
89
|
+
and works against a standard (shallow) checkout, no extra config needed.
|
|
90
|
+
|
|
91
|
+
For a `push`-triggered workflow (no PR context), it compares against the previous commit instead;
|
|
92
|
+
your checkout step needs `fetch-depth: 2` (or `0`) for that:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
- uses: actions/checkout@v4
|
|
96
|
+
with:
|
|
97
|
+
fetch-depth: 2
|
|
98
|
+
- uses: jwa-wa/agent-diff-guard@v1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Store your license key as a repo/org secret — the Action passes it through as an environment
|
|
102
|
+
variable, never a command-line argument, so it doesn't end up in job logs.
|
|
103
|
+
|
|
104
|
+
| Input | Default | Purpose |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `base-ref` | PR base branch | Branch to diff against. Empty = compare against previous commit. |
|
|
107
|
+
| `paths` | *(all changed files)* | Space-separated list to restrict the scan to specific files. |
|
|
108
|
+
| `license-key` | *(none, free tier)* | Pro license key, verified live against Gumroad. |
|
|
109
|
+
|
|
110
|
+
## Pricing
|
|
111
|
+
|
|
112
|
+
**Free** — the CI-gate result (pass/fail) always covers every scanned file. Full per-file detail
|
|
113
|
+
for the first 3 files with findings each run.
|
|
114
|
+
|
|
115
|
+
**Pro** — [get a license](https://skitamaze.gumroad.com/l/dkybda):
|
|
116
|
+
- Full per-file detail for every file, not just the first 3.
|
|
117
|
+
- Model cost-tier change detection.
|
|
118
|
+
- SARIF output — findings show up natively in GitHub's Security tab.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# agent-diff-guard
|
|
2
|
+
|
|
3
|
+
Catches risky changes to an AI agent's config — system prompt, tool permissions, model choice —
|
|
4
|
+
before they merge. A narrow, single-purpose CI check, not a full observability platform: point it
|
|
5
|
+
at a repo and it scans every changed file against your base branch automatically.
|
|
6
|
+
|
|
7
|
+
**[Get a Pro license →](https://skitamaze.gumroad.com/l/dkybda)** — full per-file detail, model
|
|
8
|
+
cost-tier detection, SARIF output for GitHub code scanning.
|
|
9
|
+
|
|
10
|
+
## What it catches
|
|
11
|
+
|
|
12
|
+
- **Safety instructions removed** — a phrase like "must never" / "do not" / "refuse" present
|
|
13
|
+
before a change and missing after it.
|
|
14
|
+
- **Dangerous tool permissions added** — `shell_exec`, `sudo`, `rm -rf`, `exec(`, `eval(`,
|
|
15
|
+
`admin_override` appearing for the first time.
|
|
16
|
+
- **Model swapped to a pricier tier** (Pro) — e.g. haiku → opus, gpt-4o-mini → gpt-4o.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install agent-diff-guard
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### `check` — scans changed files against a git ref (recommended)
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
agent-diff-guard check # all files changed vs. HEAD, in the current repo
|
|
30
|
+
agent-diff-guard check --against origin/main # all files changed vs. another ref
|
|
31
|
+
agent-diff-guard check path/to/agent_config.yaml # restrict to one file
|
|
32
|
+
agent-diff-guard check --repo /path/to/repo # run against a repo other than cwd
|
|
33
|
+
agent-diff-guard check --license-key "$LICENSE" # unlock Pro (or set AGENT_DIFF_GUARD_LICENSE)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Exit code is `1` if any file has a HIGH severity finding, `0` otherwise — safe to use as a CI gate.
|
|
37
|
+
The exit code always reflects every scanned file, on the free tier too.
|
|
38
|
+
|
|
39
|
+
Free tier shows full detail (path + findings) for the first 3 files with findings; beyond that it
|
|
40
|
+
collapses into a one-line summary — the CI gate still fails correctly either way, Pro just gives
|
|
41
|
+
the full per-file breakdown.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
agent-diff-guard check --format sarif --license-key "$LICENSE" # Pro: SARIF output
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A Pro license key is verified live against Gumroad on every run. If it's invalid or unreachable,
|
|
48
|
+
the tool falls back to the free tier rather than erroring out, so a network hiccup never breaks
|
|
49
|
+
your CI.
|
|
50
|
+
|
|
51
|
+
### `diff` — compare two file versions directly
|
|
52
|
+
|
|
53
|
+
For comparing arbitrary snapshots outside a git checkout:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
agent-diff-guard diff path/to/before.txt path/to/after.txt
|
|
57
|
+
agent-diff-guard diff path/to/before.txt path/to/after.txt --license-key "$LICENSE"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## GitHub Action
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
- uses: jwa-wa/agent-diff-guard@v1
|
|
64
|
+
with:
|
|
65
|
+
license-key: ${{ secrets.AGENT_DIFF_GUARD_LICENSE }} # optional, omit for free tier
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
That's the whole setup for a `pull_request` workflow — it reads the PR's base branch automatically
|
|
69
|
+
and works against a standard (shallow) checkout, no extra config needed.
|
|
70
|
+
|
|
71
|
+
For a `push`-triggered workflow (no PR context), it compares against the previous commit instead;
|
|
72
|
+
your checkout step needs `fetch-depth: 2` (or `0`) for that:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
- uses: actions/checkout@v4
|
|
76
|
+
with:
|
|
77
|
+
fetch-depth: 2
|
|
78
|
+
- uses: jwa-wa/agent-diff-guard@v1
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Store your license key as a repo/org secret — the Action passes it through as an environment
|
|
82
|
+
variable, never a command-line argument, so it doesn't end up in job logs.
|
|
83
|
+
|
|
84
|
+
| Input | Default | Purpose |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `base-ref` | PR base branch | Branch to diff against. Empty = compare against previous commit. |
|
|
87
|
+
| `paths` | *(all changed files)* | Space-separated list to restrict the scan to specific files. |
|
|
88
|
+
| `license-key` | *(none, free tier)* | Pro license key, verified live against Gumroad. |
|
|
89
|
+
|
|
90
|
+
## Pricing
|
|
91
|
+
|
|
92
|
+
**Free** — the CI-gate result (pass/fail) always covers every scanned file. Full per-file detail
|
|
93
|
+
for the first 3 files with findings each run.
|
|
94
|
+
|
|
95
|
+
**Pro** — [get a license](https://skitamaze.gumroad.com/l/dkybda):
|
|
96
|
+
- Full per-file detail for every file, not just the first 3.
|
|
97
|
+
- Model cost-tier change detection.
|
|
98
|
+
- SARIF output — findings show up natively in GitHub's Security tab.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
SAFETY_KEYWORDS = ["never", "must not", "do not", "refuse"]
|
|
4
|
+
|
|
5
|
+
DANGEROUS_PERMISSIONS = [
|
|
6
|
+
"shell_exec",
|
|
7
|
+
"sudo",
|
|
8
|
+
"rm -rf",
|
|
9
|
+
"exec(",
|
|
10
|
+
"eval(",
|
|
11
|
+
"admin_override",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
# Coarse relative cost tier per model family, not exact pricing - just enough
|
|
15
|
+
# to catch an obvious cheap-to-expensive swap.
|
|
16
|
+
MODEL_COST_TIER = {
|
|
17
|
+
"claude-haiku-4-5": 1,
|
|
18
|
+
"claude-sonnet-5": 2,
|
|
19
|
+
"claude-opus-4-8": 3,
|
|
20
|
+
"gpt-4o-mini": 1,
|
|
21
|
+
"gpt-4o": 2,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Finding:
|
|
27
|
+
severity: str
|
|
28
|
+
message: str
|
|
29
|
+
rule_id: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def analyze(before: str, after: str) -> list[Finding]:
|
|
33
|
+
findings = []
|
|
34
|
+
before_lower = before.lower()
|
|
35
|
+
after_lower = after.lower()
|
|
36
|
+
|
|
37
|
+
for keyword in SAFETY_KEYWORDS:
|
|
38
|
+
if keyword in before_lower and keyword not in after_lower:
|
|
39
|
+
findings.append(
|
|
40
|
+
Finding(
|
|
41
|
+
severity="HIGH",
|
|
42
|
+
message=f"Safety instruction containing '{keyword}' was removed",
|
|
43
|
+
rule_id="safety-keyword-removed",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
for permission in DANGEROUS_PERMISSIONS:
|
|
48
|
+
if permission not in before_lower and permission in after_lower:
|
|
49
|
+
findings.append(
|
|
50
|
+
Finding(
|
|
51
|
+
severity="HIGH",
|
|
52
|
+
message=f"New dangerous permission added: '{permission}'",
|
|
53
|
+
rule_id="dangerous-permission-added",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
before_model = _find_model(before_lower)
|
|
58
|
+
after_model = _find_model(after_lower)
|
|
59
|
+
if (
|
|
60
|
+
before_model
|
|
61
|
+
and after_model
|
|
62
|
+
and before_model != after_model
|
|
63
|
+
and MODEL_COST_TIER[after_model] > MODEL_COST_TIER[before_model]
|
|
64
|
+
):
|
|
65
|
+
findings.append(
|
|
66
|
+
Finding(
|
|
67
|
+
severity="MEDIUM",
|
|
68
|
+
message=f"Model changed from '{before_model}' to pricier '{after_model}'",
|
|
69
|
+
rule_id="model-cost-increase",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return findings
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _find_model(text: str) -> str | None:
|
|
77
|
+
for model in MODEL_COST_TIER:
|
|
78
|
+
if model in text:
|
|
79
|
+
return model
|
|
80
|
+
return None
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .analyze import Finding, analyze
|
|
8
|
+
from .license import verify_license, PRO_PRODUCT_URL
|
|
9
|
+
from .sarif import build_sarif
|
|
10
|
+
from . import git_diff
|
|
11
|
+
|
|
12
|
+
FREE_FILE_DETAIL_LIMIT = 3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_pro(args: argparse.Namespace) -> bool:
|
|
16
|
+
if not args.license_key:
|
|
17
|
+
return False
|
|
18
|
+
if verify_license(args.license_key):
|
|
19
|
+
return True
|
|
20
|
+
print("License key provided but could not be verified - falling back to free tier", file=sys.stderr)
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _visible_findings(findings: list[Finding], pro: bool) -> tuple[list[Finding], int]:
|
|
25
|
+
visible = findings if pro else [f for f in findings if f.severity == "HIGH"]
|
|
26
|
+
return visible, len(findings) - len(visible)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print_findings(visible: list[Finding], hidden_count: int) -> None:
|
|
30
|
+
for finding in visible:
|
|
31
|
+
print(f"{finding.severity}: {finding.message}")
|
|
32
|
+
if hidden_count:
|
|
33
|
+
print(
|
|
34
|
+
f"{hidden_count} additional finding(s) hidden - upgrade to Pro to see them: {PRO_PRODUCT_URL}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _diff(args: argparse.Namespace) -> int:
|
|
39
|
+
pro = _is_pro(args)
|
|
40
|
+
before = Path(args.before).read_text()
|
|
41
|
+
after = Path(args.after).read_text()
|
|
42
|
+
|
|
43
|
+
findings = analyze(before, after)
|
|
44
|
+
visible, hidden_count = _visible_findings(findings, pro)
|
|
45
|
+
_print_findings(visible, hidden_count)
|
|
46
|
+
|
|
47
|
+
return 1 if any(f.severity == "HIGH" for f in findings) else 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check(args: argparse.Namespace) -> int:
|
|
51
|
+
if args.format == "sarif":
|
|
52
|
+
return _check_sarif(args)
|
|
53
|
+
|
|
54
|
+
pro = _is_pro(args)
|
|
55
|
+
repo_dir = Path(args.repo).resolve()
|
|
56
|
+
paths = args.paths or git_diff.changed_files(repo_dir, args.against)
|
|
57
|
+
|
|
58
|
+
exit_code = 0
|
|
59
|
+
detail_shown = 0
|
|
60
|
+
hidden_high_files = 0
|
|
61
|
+
hidden_high_findings = 0
|
|
62
|
+
|
|
63
|
+
for path in paths:
|
|
64
|
+
before = git_diff.read_before(repo_dir, args.against, path)
|
|
65
|
+
after = git_diff.read_after(repo_dir, path)
|
|
66
|
+
|
|
67
|
+
findings = analyze(before, after)
|
|
68
|
+
has_high = any(f.severity == "HIGH" for f in findings)
|
|
69
|
+
if has_high:
|
|
70
|
+
exit_code = 1
|
|
71
|
+
|
|
72
|
+
if not findings:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if pro or detail_shown < FREE_FILE_DETAIL_LIMIT:
|
|
76
|
+
visible, hidden_count = _visible_findings(findings, pro)
|
|
77
|
+
print(f"== {path} ==")
|
|
78
|
+
_print_findings(visible, hidden_count)
|
|
79
|
+
detail_shown += 1
|
|
80
|
+
elif has_high:
|
|
81
|
+
hidden_high_files += 1
|
|
82
|
+
hidden_high_findings += sum(1 for f in findings if f.severity == "HIGH")
|
|
83
|
+
|
|
84
|
+
if hidden_high_files:
|
|
85
|
+
print(
|
|
86
|
+
f"{hidden_high_files} more file(s) with {hidden_high_findings} HIGH finding(s) not shown "
|
|
87
|
+
f"in detail - upgrade to Pro for full per-file detail: {PRO_PRODUCT_URL}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return exit_code
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _check_sarif(args: argparse.Namespace) -> int:
|
|
94
|
+
if not _is_pro(args):
|
|
95
|
+
print(f"SARIF output requires a valid Pro license key. Get one: {PRO_PRODUCT_URL}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
|
|
98
|
+
repo_dir = Path(args.repo).resolve()
|
|
99
|
+
paths = args.paths or git_diff.changed_files(repo_dir, args.against)
|
|
100
|
+
|
|
101
|
+
findings_by_path = {}
|
|
102
|
+
exit_code = 0
|
|
103
|
+
for path in paths:
|
|
104
|
+
before = git_diff.read_before(repo_dir, args.against, path)
|
|
105
|
+
after = git_diff.read_after(repo_dir, path)
|
|
106
|
+
findings = analyze(before, after)
|
|
107
|
+
if findings:
|
|
108
|
+
findings_by_path[path] = findings
|
|
109
|
+
if any(f.severity == "HIGH" for f in findings):
|
|
110
|
+
exit_code = 1
|
|
111
|
+
|
|
112
|
+
print(json.dumps(build_sarif(findings_by_path), indent=2))
|
|
113
|
+
return exit_code
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run(argv: list[str]) -> int:
|
|
117
|
+
parser = argparse.ArgumentParser(
|
|
118
|
+
description="Flag risky changes to an AI agent config (prompt/tool-permissions/model choice)."
|
|
119
|
+
)
|
|
120
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
121
|
+
|
|
122
|
+
diff_parser = subparsers.add_parser("diff", help="Compare two file versions directly")
|
|
123
|
+
diff_parser.add_argument("before", help="Path to the file before the change")
|
|
124
|
+
diff_parser.add_argument("after", help="Path to the file after the change")
|
|
125
|
+
diff_parser.add_argument(
|
|
126
|
+
"--license-key",
|
|
127
|
+
default=os.environ.get("AGENT_DIFF_GUARD_LICENSE", ""),
|
|
128
|
+
help="Pro license key (or set AGENT_DIFF_GUARD_LICENSE). Verified against Gumroad.",
|
|
129
|
+
)
|
|
130
|
+
diff_parser.set_defaults(handler=_diff)
|
|
131
|
+
|
|
132
|
+
check_parser = subparsers.add_parser(
|
|
133
|
+
"check", help="Check files changed vs. a git ref (default: HEAD) - no manual diffing needed"
|
|
134
|
+
)
|
|
135
|
+
check_parser.add_argument(
|
|
136
|
+
"paths", nargs="*", help="Specific file(s) to check (default: all files changed vs. --against)"
|
|
137
|
+
)
|
|
138
|
+
check_parser.add_argument("--against", default="HEAD", help="Git ref to diff against (default: HEAD)")
|
|
139
|
+
check_parser.add_argument("--repo", default=".", help="Path to the git repo (default: current directory)")
|
|
140
|
+
check_parser.add_argument(
|
|
141
|
+
"--license-key",
|
|
142
|
+
default=os.environ.get("AGENT_DIFF_GUARD_LICENSE", ""),
|
|
143
|
+
help="Pro license key (or set AGENT_DIFF_GUARD_LICENSE). Verified against Gumroad.",
|
|
144
|
+
)
|
|
145
|
+
check_parser.add_argument(
|
|
146
|
+
"--format",
|
|
147
|
+
choices=["text", "sarif"],
|
|
148
|
+
default="text",
|
|
149
|
+
help="Output format. 'sarif' requires a valid Pro license key.",
|
|
150
|
+
)
|
|
151
|
+
check_parser.set_defaults(handler=_check)
|
|
152
|
+
|
|
153
|
+
args = parser.parse_args(argv)
|
|
154
|
+
return args.handler(args)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main() -> None:
|
|
158
|
+
sys.exit(run(sys.argv[1:]))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def changed_files(repo_dir: Path, against: str) -> list[str]:
|
|
6
|
+
result = subprocess.run(
|
|
7
|
+
["git", "diff", "--name-only", against],
|
|
8
|
+
cwd=repo_dir,
|
|
9
|
+
capture_output=True,
|
|
10
|
+
text=True,
|
|
11
|
+
check=True,
|
|
12
|
+
)
|
|
13
|
+
return [line for line in result.stdout.splitlines() if line]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_before(repo_dir: Path, against: str, path: str) -> str:
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
["git", "show", f"{against}:{path}"],
|
|
19
|
+
cwd=repo_dir,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
)
|
|
23
|
+
return result.stdout if result.returncode == 0 else ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_after(repo_dir: Path, path: str) -> str:
|
|
27
|
+
file_path = repo_dir / path
|
|
28
|
+
return file_path.read_text() if file_path.exists() else ""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import ssl
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.parse
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
import certifi
|
|
8
|
+
|
|
9
|
+
GUMROAD_VERIFY_URL = "https://api.gumroad.com/v2/licenses/verify"
|
|
10
|
+
PRODUCT_ID = "Wpncwq3CMyYG2AGLxfbD7A=="
|
|
11
|
+
PRO_PRODUCT_URL = "https://skitamaze.gumroad.com/l/dkybda"
|
|
12
|
+
|
|
13
|
+
# Explicit CA bundle, not the system default: some Python installs (notably
|
|
14
|
+
# macOS python.org builds) ship without a working local trust store, which
|
|
15
|
+
# would otherwise make a genuinely valid, paid license fail closed to "invalid".
|
|
16
|
+
_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify_license(license_key: str, product_id: str = PRODUCT_ID) -> bool:
|
|
20
|
+
if not license_key:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
data = urllib.parse.urlencode(
|
|
24
|
+
{
|
|
25
|
+
"product_id": product_id,
|
|
26
|
+
"license_key": license_key,
|
|
27
|
+
"increment_uses_count": "false",
|
|
28
|
+
}
|
|
29
|
+
).encode()
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
with urllib.request.urlopen(
|
|
33
|
+
GUMROAD_VERIFY_URL, data=data, timeout=10, context=_SSL_CONTEXT
|
|
34
|
+
) as response:
|
|
35
|
+
body = json.loads(response.read())
|
|
36
|
+
except urllib.error.HTTPError as error:
|
|
37
|
+
try:
|
|
38
|
+
body = json.loads(error.read())
|
|
39
|
+
except (ValueError, OSError):
|
|
40
|
+
return False
|
|
41
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
return bool(body.get("success"))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from .analyze import Finding
|
|
2
|
+
|
|
3
|
+
SARIF_LEVEL = {"HIGH": "error", "MEDIUM": "warning"}
|
|
4
|
+
|
|
5
|
+
INFORMATION_URI = "https://github.com/agent-diff-guard/agent-diff-guard"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_sarif(findings_by_path: dict[str, list[Finding]]) -> dict:
|
|
9
|
+
results = []
|
|
10
|
+
rule_ids = set()
|
|
11
|
+
|
|
12
|
+
for path, findings in findings_by_path.items():
|
|
13
|
+
for finding in findings:
|
|
14
|
+
rule_ids.add(finding.rule_id)
|
|
15
|
+
results.append(
|
|
16
|
+
{
|
|
17
|
+
"ruleId": finding.rule_id,
|
|
18
|
+
"level": SARIF_LEVEL.get(finding.severity, "warning"),
|
|
19
|
+
"message": {"text": finding.message},
|
|
20
|
+
"locations": [
|
|
21
|
+
{"physicalLocation": {"artifactLocation": {"uri": path}}}
|
|
22
|
+
],
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
"version": "2.1.0",
|
|
28
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
29
|
+
"runs": [
|
|
30
|
+
{
|
|
31
|
+
"tool": {
|
|
32
|
+
"driver": {
|
|
33
|
+
"name": "agent-diff-guard",
|
|
34
|
+
"informationUri": INFORMATION_URI,
|
|
35
|
+
"rules": [{"id": rule_id} for rule_id in sorted(rule_ids)],
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"results": results,
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-diff-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Flags risky changes to AI agent configs (prompts, tool permissions, model choice) in a diff.
|
|
5
|
+
Author: Wally
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jwa-wa/agent-diff-guard
|
|
8
|
+
Project-URL: Pro license, https://skitamaze.gumroad.com/l/dkybda
|
|
9
|
+
Keywords: ai-agents,llm-security,prompt-injection,ci,github-actions
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: certifi
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# agent-diff-guard
|
|
22
|
+
|
|
23
|
+
Catches risky changes to an AI agent's config — system prompt, tool permissions, model choice —
|
|
24
|
+
before they merge. A narrow, single-purpose CI check, not a full observability platform: point it
|
|
25
|
+
at a repo and it scans every changed file against your base branch automatically.
|
|
26
|
+
|
|
27
|
+
**[Get a Pro license →](https://skitamaze.gumroad.com/l/dkybda)** — full per-file detail, model
|
|
28
|
+
cost-tier detection, SARIF output for GitHub code scanning.
|
|
29
|
+
|
|
30
|
+
## What it catches
|
|
31
|
+
|
|
32
|
+
- **Safety instructions removed** — a phrase like "must never" / "do not" / "refuse" present
|
|
33
|
+
before a change and missing after it.
|
|
34
|
+
- **Dangerous tool permissions added** — `shell_exec`, `sudo`, `rm -rf`, `exec(`, `eval(`,
|
|
35
|
+
`admin_override` appearing for the first time.
|
|
36
|
+
- **Model swapped to a pricier tier** (Pro) — e.g. haiku → opus, gpt-4o-mini → gpt-4o.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install agent-diff-guard
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### `check` — scans changed files against a git ref (recommended)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
agent-diff-guard check # all files changed vs. HEAD, in the current repo
|
|
50
|
+
agent-diff-guard check --against origin/main # all files changed vs. another ref
|
|
51
|
+
agent-diff-guard check path/to/agent_config.yaml # restrict to one file
|
|
52
|
+
agent-diff-guard check --repo /path/to/repo # run against a repo other than cwd
|
|
53
|
+
agent-diff-guard check --license-key "$LICENSE" # unlock Pro (or set AGENT_DIFF_GUARD_LICENSE)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Exit code is `1` if any file has a HIGH severity finding, `0` otherwise — safe to use as a CI gate.
|
|
57
|
+
The exit code always reflects every scanned file, on the free tier too.
|
|
58
|
+
|
|
59
|
+
Free tier shows full detail (path + findings) for the first 3 files with findings; beyond that it
|
|
60
|
+
collapses into a one-line summary — the CI gate still fails correctly either way, Pro just gives
|
|
61
|
+
the full per-file breakdown.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
agent-diff-guard check --format sarif --license-key "$LICENSE" # Pro: SARIF output
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A Pro license key is verified live against Gumroad on every run. If it's invalid or unreachable,
|
|
68
|
+
the tool falls back to the free tier rather than erroring out, so a network hiccup never breaks
|
|
69
|
+
your CI.
|
|
70
|
+
|
|
71
|
+
### `diff` — compare two file versions directly
|
|
72
|
+
|
|
73
|
+
For comparing arbitrary snapshots outside a git checkout:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
agent-diff-guard diff path/to/before.txt path/to/after.txt
|
|
77
|
+
agent-diff-guard diff path/to/before.txt path/to/after.txt --license-key "$LICENSE"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## GitHub Action
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
- uses: jwa-wa/agent-diff-guard@v1
|
|
84
|
+
with:
|
|
85
|
+
license-key: ${{ secrets.AGENT_DIFF_GUARD_LICENSE }} # optional, omit for free tier
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That's the whole setup for a `pull_request` workflow — it reads the PR's base branch automatically
|
|
89
|
+
and works against a standard (shallow) checkout, no extra config needed.
|
|
90
|
+
|
|
91
|
+
For a `push`-triggered workflow (no PR context), it compares against the previous commit instead;
|
|
92
|
+
your checkout step needs `fetch-depth: 2` (or `0`) for that:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
- uses: actions/checkout@v4
|
|
96
|
+
with:
|
|
97
|
+
fetch-depth: 2
|
|
98
|
+
- uses: jwa-wa/agent-diff-guard@v1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Store your license key as a repo/org secret — the Action passes it through as an environment
|
|
102
|
+
variable, never a command-line argument, so it doesn't end up in job logs.
|
|
103
|
+
|
|
104
|
+
| Input | Default | Purpose |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `base-ref` | PR base branch | Branch to diff against. Empty = compare against previous commit. |
|
|
107
|
+
| `paths` | *(all changed files)* | Space-separated list to restrict the scan to specific files. |
|
|
108
|
+
| `license-key` | *(none, free tier)* | Pro license key, verified live against Gumroad. |
|
|
109
|
+
|
|
110
|
+
## Pricing
|
|
111
|
+
|
|
112
|
+
**Free** — the CI-gate result (pass/fail) always covers every scanned file. Full per-file detail
|
|
113
|
+
for the first 3 files with findings each run.
|
|
114
|
+
|
|
115
|
+
**Pro** — [get a license](https://skitamaze.gumroad.com/l/dkybda):
|
|
116
|
+
- Full per-file detail for every file, not just the first 3.
|
|
117
|
+
- Model cost-tier change detection.
|
|
118
|
+
- SARIF output — findings show up natively in GitHub's Security tab.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
agent_diff_guard/__init__.py
|
|
5
|
+
agent_diff_guard/analyze.py
|
|
6
|
+
agent_diff_guard/cli.py
|
|
7
|
+
agent_diff_guard/git_diff.py
|
|
8
|
+
agent_diff_guard/license.py
|
|
9
|
+
agent_diff_guard/sarif.py
|
|
10
|
+
agent_diff_guard.egg-info/PKG-INFO
|
|
11
|
+
agent_diff_guard.egg-info/SOURCES.txt
|
|
12
|
+
agent_diff_guard.egg-info/dependency_links.txt
|
|
13
|
+
agent_diff_guard.egg-info/entry_points.txt
|
|
14
|
+
agent_diff_guard.egg-info/requires.txt
|
|
15
|
+
agent_diff_guard.egg-info/top_level.txt
|
|
16
|
+
tests/test_analyze.py
|
|
17
|
+
tests/test_check.py
|
|
18
|
+
tests/test_cli.py
|
|
19
|
+
tests/test_cli_licensing.py
|
|
20
|
+
tests/test_license.py
|
|
21
|
+
tests/test_pro_gating.py
|
|
22
|
+
tests/test_sarif.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
certifi
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_diff_guard
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent-diff-guard"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Flags risky changes to AI agent configs (prompts, tool permissions, model choice) in a diff."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Wally" }]
|
|
9
|
+
keywords = ["ai-agents", "llm-security", "prompt-injection", "ci", "github-actions"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
14
|
+
"Topic :: Security",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
]
|
|
17
|
+
dependencies = ["certifi"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/jwa-wa/agent-diff-guard"
|
|
21
|
+
"Pro license" = "https://skitamaze.gumroad.com/l/dkybda"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
agent-diff-guard = "agent_diff_guard.cli:main"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["setuptools>=61"]
|
|
28
|
+
build-backend = "setuptools.build_meta"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["agent_diff_guard"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from agent_diff_guard.analyze import analyze
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_flags_removed_safety_keyword():
|
|
5
|
+
before = "You must never execute shell commands without user confirmation."
|
|
6
|
+
after = "You can execute shell commands freely."
|
|
7
|
+
|
|
8
|
+
findings = analyze(before, after)
|
|
9
|
+
|
|
10
|
+
assert any(f.severity == "HIGH" and "never" in f.message for f in findings)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_flags_new_dangerous_tool_permission():
|
|
14
|
+
before = "Tools: read_file, search_web"
|
|
15
|
+
after = "Tools: read_file, search_web, shell_exec, sudo"
|
|
16
|
+
|
|
17
|
+
findings = analyze(before, after)
|
|
18
|
+
|
|
19
|
+
assert any(f.severity == "HIGH" and "shell_exec" in f.message for f in findings)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_flags_model_swap_to_pricier_tier():
|
|
23
|
+
before = "model: claude-haiku-4-5"
|
|
24
|
+
after = "model: claude-opus-4-8"
|
|
25
|
+
|
|
26
|
+
findings = analyze(before, after)
|
|
27
|
+
|
|
28
|
+
assert any(
|
|
29
|
+
f.severity == "MEDIUM" and "claude-haiku-4-5" in f.message and "claude-opus-4-8" in f.message
|
|
30
|
+
for f in findings
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_findings_carry_a_stable_rule_id():
|
|
35
|
+
before = "You must never execute shell commands."
|
|
36
|
+
after = "Tools: shell_exec"
|
|
37
|
+
|
|
38
|
+
findings = analyze(before, after)
|
|
39
|
+
|
|
40
|
+
rule_ids = {f.rule_id for f in findings}
|
|
41
|
+
assert "safety-keyword-removed" in rule_ids
|
|
42
|
+
assert "dangerous-permission-added" in rule_ids
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
from agent_diff_guard.cli import run
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _init_repo(repo_dir):
|
|
7
|
+
subprocess.run(["git", "init", "-q"], cwd=repo_dir, check=True)
|
|
8
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo_dir, check=True)
|
|
9
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_dir, check=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _commit_all(repo_dir, message):
|
|
13
|
+
subprocess.run(["git", "add", "."], cwd=repo_dir, check=True)
|
|
14
|
+
subprocess.run(["git", "commit", "-q", "-m", message], cwd=repo_dir, check=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_check_flags_high_severity_against_head(tmp_path, capsys):
|
|
18
|
+
_init_repo(tmp_path)
|
|
19
|
+
config = tmp_path / "agent.md"
|
|
20
|
+
config.write_text("You must never execute shell commands.")
|
|
21
|
+
_commit_all(tmp_path, "init")
|
|
22
|
+
|
|
23
|
+
config.write_text("You can execute shell commands freely.")
|
|
24
|
+
|
|
25
|
+
exit_code = run(["check", "--repo", str(tmp_path)])
|
|
26
|
+
|
|
27
|
+
assert exit_code == 1
|
|
28
|
+
assert "HIGH" in capsys.readouterr().out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_check_scans_multiple_changed_files_and_labels_by_path(tmp_path, capsys):
|
|
32
|
+
_init_repo(tmp_path)
|
|
33
|
+
risky = tmp_path / "risky.md"
|
|
34
|
+
safe = tmp_path / "safe.md"
|
|
35
|
+
risky.write_text("You must never execute shell commands.")
|
|
36
|
+
safe.write_text("Hello world.")
|
|
37
|
+
_commit_all(tmp_path, "init")
|
|
38
|
+
|
|
39
|
+
risky.write_text("You can execute shell commands freely.")
|
|
40
|
+
safe.write_text("Hello world, updated.")
|
|
41
|
+
|
|
42
|
+
exit_code = run(["check", "--repo", str(tmp_path)])
|
|
43
|
+
|
|
44
|
+
out = capsys.readouterr().out
|
|
45
|
+
assert exit_code == 1
|
|
46
|
+
assert "risky.md" in out
|
|
47
|
+
assert "safe.md" not in out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_check_against_explicit_ref(tmp_path, capsys):
|
|
51
|
+
_init_repo(tmp_path)
|
|
52
|
+
config = tmp_path / "agent.md"
|
|
53
|
+
config.write_text("You must never execute shell commands.")
|
|
54
|
+
_commit_all(tmp_path, "c1")
|
|
55
|
+
|
|
56
|
+
config.write_text("You can execute shell commands freely.")
|
|
57
|
+
_commit_all(tmp_path, "c2")
|
|
58
|
+
|
|
59
|
+
exit_code = run(["check", "--repo", str(tmp_path), "--against", "HEAD~1"])
|
|
60
|
+
|
|
61
|
+
assert exit_code == 1
|
|
62
|
+
assert "HIGH" in capsys.readouterr().out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_check_respects_explicit_path_argument(tmp_path, capsys):
|
|
66
|
+
_init_repo(tmp_path)
|
|
67
|
+
risky = tmp_path / "risky.md"
|
|
68
|
+
other = tmp_path / "other.md"
|
|
69
|
+
risky.write_text("You must never execute shell commands.")
|
|
70
|
+
other.write_text("Hello.")
|
|
71
|
+
_commit_all(tmp_path, "init")
|
|
72
|
+
|
|
73
|
+
risky.write_text("You can execute shell commands freely.")
|
|
74
|
+
other.write_text("Hello there.")
|
|
75
|
+
|
|
76
|
+
exit_code = run(["check", "risky.md", "--repo", str(tmp_path)])
|
|
77
|
+
|
|
78
|
+
out = capsys.readouterr().out
|
|
79
|
+
assert exit_code == 1
|
|
80
|
+
assert "risky.md" in out
|
|
81
|
+
assert "other.md" not in out
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
from agent_diff_guard.cli import run
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_exits_nonzero_when_high_severity_finding(tmp_path, capsys):
|
|
7
|
+
before_file = tmp_path / "before.txt"
|
|
8
|
+
after_file = tmp_path / "after.txt"
|
|
9
|
+
before_file.write_text("You must never execute shell commands.")
|
|
10
|
+
after_file.write_text("You can execute shell commands freely.")
|
|
11
|
+
|
|
12
|
+
exit_code = run(["diff", str(before_file), str(after_file)])
|
|
13
|
+
|
|
14
|
+
assert exit_code == 1
|
|
15
|
+
assert "HIGH" in capsys.readouterr().out
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_exits_zero_when_no_findings(tmp_path, capsys):
|
|
19
|
+
before_file = tmp_path / "before.txt"
|
|
20
|
+
after_file = tmp_path / "after.txt"
|
|
21
|
+
before_file.write_text("You are a helpful assistant.")
|
|
22
|
+
after_file.write_text("You are a helpful assistant.")
|
|
23
|
+
|
|
24
|
+
exit_code = run(["diff", str(before_file), str(after_file)])
|
|
25
|
+
|
|
26
|
+
assert exit_code == 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_hides_medium_findings_without_a_license_key(tmp_path, capsys):
|
|
30
|
+
before_file = tmp_path / "before.txt"
|
|
31
|
+
after_file = tmp_path / "after.txt"
|
|
32
|
+
before_file.write_text("model: claude-haiku-4-5")
|
|
33
|
+
after_file.write_text("model: claude-opus-4-8")
|
|
34
|
+
|
|
35
|
+
run(["diff", str(before_file), str(after_file)])
|
|
36
|
+
|
|
37
|
+
out = capsys.readouterr().out
|
|
38
|
+
assert "MEDIUM" not in out
|
|
39
|
+
assert "upgrade to pro" in out.lower()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_shows_medium_findings_with_a_verified_license_key(tmp_path, capsys):
|
|
43
|
+
before_file = tmp_path / "before.txt"
|
|
44
|
+
after_file = tmp_path / "after.txt"
|
|
45
|
+
before_file.write_text("model: claude-haiku-4-5")
|
|
46
|
+
after_file.write_text("model: claude-opus-4-8")
|
|
47
|
+
|
|
48
|
+
with patch("agent_diff_guard.cli.verify_license", return_value=True):
|
|
49
|
+
run(["diff", str(before_file), str(after_file), "--license-key", "VALID-KEY"])
|
|
50
|
+
|
|
51
|
+
assert "MEDIUM" in capsys.readouterr().out
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from agent_diff_guard.cli import _is_pro
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_is_pro_false_without_a_license_key():
|
|
8
|
+
args = argparse.Namespace(license_key="")
|
|
9
|
+
assert _is_pro(args) is False
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_is_pro_true_with_a_verified_license_key():
|
|
13
|
+
args = argparse.Namespace(license_key="VALID-KEY")
|
|
14
|
+
with patch("agent_diff_guard.cli.verify_license", return_value=True):
|
|
15
|
+
assert _is_pro(args) is True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_is_pro_false_and_warns_when_key_fails_verification(capsys):
|
|
19
|
+
args = argparse.Namespace(license_key="BOGUS-KEY")
|
|
20
|
+
with patch("agent_diff_guard.cli.verify_license", return_value=False):
|
|
21
|
+
assert _is_pro(args) is False
|
|
22
|
+
assert "falling back to free tier" in capsys.readouterr().err
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import ssl
|
|
3
|
+
import urllib.error
|
|
4
|
+
from unittest.mock import patch, MagicMock
|
|
5
|
+
|
|
6
|
+
from agent_diff_guard.license import verify_license
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_empty_license_key_is_invalid_without_a_network_call():
|
|
10
|
+
with patch("urllib.request.urlopen") as mock_urlopen:
|
|
11
|
+
assert verify_license("") is False
|
|
12
|
+
mock_urlopen.assert_not_called()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_valid_license_key_returns_true():
|
|
16
|
+
response = MagicMock()
|
|
17
|
+
response.read.return_value = json.dumps({"success": True}).encode()
|
|
18
|
+
response.__enter__.return_value = response
|
|
19
|
+
|
|
20
|
+
with patch("urllib.request.urlopen", return_value=response):
|
|
21
|
+
assert verify_license("VALID-KEY") is True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_invalid_license_key_returns_false_on_http_error():
|
|
25
|
+
error_body = json.dumps({"success": False, "message": "That license does not exist"}).encode()
|
|
26
|
+
http_error = urllib.error.HTTPError(
|
|
27
|
+
url="https://api.gumroad.com/v2/licenses/verify",
|
|
28
|
+
code=404,
|
|
29
|
+
msg="Not Found",
|
|
30
|
+
hdrs=None,
|
|
31
|
+
fp=None,
|
|
32
|
+
)
|
|
33
|
+
http_error.read = lambda: error_body
|
|
34
|
+
|
|
35
|
+
with patch("urllib.request.urlopen", side_effect=http_error):
|
|
36
|
+
assert verify_license("BOGUS-KEY") is False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_network_failure_fails_closed_to_invalid():
|
|
40
|
+
with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("no network")):
|
|
41
|
+
assert verify_license("SOME-KEY") is False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_uses_an_explicit_certifi_ca_bundle_not_the_system_default():
|
|
45
|
+
"""
|
|
46
|
+
Regression test: relying on the system's default SSL trust store fails on
|
|
47
|
+
machines with an incomplete cert install (e.g. macOS python.org builds) and
|
|
48
|
+
silently rejects a genuinely valid, paid license. Must pass an explicit
|
|
49
|
+
certifi-backed SSLContext to urlopen instead of trusting the environment.
|
|
50
|
+
"""
|
|
51
|
+
response = MagicMock()
|
|
52
|
+
response.read.return_value = json.dumps({"success": True}).encode()
|
|
53
|
+
response.__enter__.return_value = response
|
|
54
|
+
|
|
55
|
+
with patch("urllib.request.urlopen", return_value=response) as mock_urlopen:
|
|
56
|
+
verify_license("VALID-KEY")
|
|
57
|
+
|
|
58
|
+
_, kwargs = mock_urlopen.call_args
|
|
59
|
+
assert isinstance(kwargs.get("context"), ssl.SSLContext)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from agent_diff_guard.cli import run
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _init_repo(repo_dir):
|
|
9
|
+
subprocess.run(["git", "init", "-q"], cwd=repo_dir, check=True)
|
|
10
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo_dir, check=True)
|
|
11
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_dir, check=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _commit_all(repo_dir, message):
|
|
15
|
+
subprocess.run(["git", "add", "."], cwd=repo_dir, check=True)
|
|
16
|
+
subprocess.run(["git", "commit", "-q", "-m", message], cwd=repo_dir, check=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _write_risky_files(repo_dir, count):
|
|
20
|
+
files = []
|
|
21
|
+
for i in range(count):
|
|
22
|
+
f = repo_dir / f"agent{i}.md"
|
|
23
|
+
f.write_text("You must never execute shell commands.")
|
|
24
|
+
files.append(f)
|
|
25
|
+
return files
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_free_tier_caps_detail_at_three_files_but_exit_code_covers_all(tmp_path, capsys):
|
|
29
|
+
_init_repo(tmp_path)
|
|
30
|
+
files = _write_risky_files(tmp_path, 4)
|
|
31
|
+
_commit_all(tmp_path, "init")
|
|
32
|
+
|
|
33
|
+
for f in files:
|
|
34
|
+
f.write_text("You can execute shell commands freely.")
|
|
35
|
+
|
|
36
|
+
exit_code = run(["check", "--repo", str(tmp_path)])
|
|
37
|
+
|
|
38
|
+
out = capsys.readouterr().out
|
|
39
|
+
assert exit_code == 1 # CI gate must still fail - nothing hidden from the exit code
|
|
40
|
+
assert out.count("== agent") == 3 # only 3 files get full detail
|
|
41
|
+
assert "1 more file" in out
|
|
42
|
+
assert "upgrade to pro" in out.lower()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_pro_shows_full_detail_for_every_file(tmp_path, capsys):
|
|
46
|
+
_init_repo(tmp_path)
|
|
47
|
+
files = _write_risky_files(tmp_path, 4)
|
|
48
|
+
_commit_all(tmp_path, "init")
|
|
49
|
+
|
|
50
|
+
for f in files:
|
|
51
|
+
f.write_text("You can execute shell commands freely.")
|
|
52
|
+
|
|
53
|
+
with patch("agent_diff_guard.cli.verify_license", return_value=True):
|
|
54
|
+
exit_code = run(["check", "--repo", str(tmp_path), "--license-key", "VALID-KEY"])
|
|
55
|
+
|
|
56
|
+
out = capsys.readouterr().out
|
|
57
|
+
assert exit_code == 1
|
|
58
|
+
assert out.count("== agent") == 4
|
|
59
|
+
assert "more file" not in out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_no_summary_line_when_within_the_free_limit(tmp_path, capsys):
|
|
63
|
+
_init_repo(tmp_path)
|
|
64
|
+
files = _write_risky_files(tmp_path, 2)
|
|
65
|
+
_commit_all(tmp_path, "init")
|
|
66
|
+
|
|
67
|
+
for f in files:
|
|
68
|
+
f.write_text("You can execute shell commands freely.")
|
|
69
|
+
|
|
70
|
+
run(["check", "--repo", str(tmp_path)])
|
|
71
|
+
|
|
72
|
+
out = capsys.readouterr().out
|
|
73
|
+
assert "more file" not in out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_sarif_format_requires_a_verified_license_key(tmp_path, capsys):
|
|
77
|
+
_init_repo(tmp_path)
|
|
78
|
+
files = _write_risky_files(tmp_path, 1)
|
|
79
|
+
_commit_all(tmp_path, "init")
|
|
80
|
+
files[0].write_text("You can execute shell commands freely.")
|
|
81
|
+
|
|
82
|
+
exit_code = run(["check", "--repo", str(tmp_path), "--format", "sarif"])
|
|
83
|
+
|
|
84
|
+
captured = capsys.readouterr()
|
|
85
|
+
assert exit_code == 2
|
|
86
|
+
assert "license" in captured.err.lower()
|
|
87
|
+
assert captured.out == ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_sarif_format_with_verified_license_emits_valid_sarif(tmp_path, capsys):
|
|
91
|
+
_init_repo(tmp_path)
|
|
92
|
+
files = _write_risky_files(tmp_path, 1)
|
|
93
|
+
_commit_all(tmp_path, "init")
|
|
94
|
+
files[0].write_text("You can execute shell commands freely.")
|
|
95
|
+
|
|
96
|
+
with patch("agent_diff_guard.cli.verify_license", return_value=True):
|
|
97
|
+
exit_code = run(["check", "--repo", str(tmp_path), "--format", "sarif", "--license-key", "VALID-KEY"])
|
|
98
|
+
|
|
99
|
+
out = capsys.readouterr().out
|
|
100
|
+
sarif = json.loads(out)
|
|
101
|
+
assert exit_code == 1
|
|
102
|
+
assert sarif["version"] == "2.1.0"
|
|
103
|
+
assert len(sarif["runs"][0]["results"]) == 1
|
|
104
|
+
assert sarif["runs"][0]["results"][0]["ruleId"] == "safety-keyword-removed"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from agent_diff_guard.analyze import Finding
|
|
2
|
+
from agent_diff_guard.sarif import build_sarif
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_build_sarif_maps_high_finding_to_error_level_with_location():
|
|
6
|
+
findings_by_path = {
|
|
7
|
+
"agent.md": [
|
|
8
|
+
Finding(
|
|
9
|
+
severity="HIGH",
|
|
10
|
+
message="Safety instruction containing 'never' was removed",
|
|
11
|
+
rule_id="safety-keyword-removed",
|
|
12
|
+
)
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
sarif = build_sarif(findings_by_path)
|
|
17
|
+
|
|
18
|
+
assert sarif["version"] == "2.1.0"
|
|
19
|
+
assert sarif["runs"][0]["tool"]["driver"]["name"] == "agent-diff-guard"
|
|
20
|
+
result = sarif["runs"][0]["results"][0]
|
|
21
|
+
assert result["ruleId"] == "safety-keyword-removed"
|
|
22
|
+
assert result["level"] == "error"
|
|
23
|
+
assert result["message"]["text"] == "Safety instruction containing 'never' was removed"
|
|
24
|
+
assert result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "agent.md"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_build_sarif_maps_medium_finding_to_warning_level():
|
|
28
|
+
findings_by_path = {
|
|
29
|
+
"agent.md": [
|
|
30
|
+
Finding(
|
|
31
|
+
severity="MEDIUM",
|
|
32
|
+
message="Model changed from 'x' to pricier 'y'",
|
|
33
|
+
rule_id="model-cost-increase",
|
|
34
|
+
)
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
sarif = build_sarif(findings_by_path)
|
|
39
|
+
|
|
40
|
+
assert sarif["runs"][0]["results"][0]["level"] == "warning"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_build_sarif_with_no_findings_has_empty_results():
|
|
44
|
+
sarif = build_sarif({})
|
|
45
|
+
|
|
46
|
+
assert sarif["runs"][0]["results"] == []
|