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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ agent-diff-guard = agent_diff_guard.cli:main
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"] == []