agent-diff-guard 0.1.0__py3-none-any.whl

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.
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,12 @@
1
+ agent_diff_guard/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ agent_diff_guard/analyze.py,sha256=rNk4Jujt-ozVwUeP9T8EZV_UOcY17h56Vrp06yrpNzE,2121
3
+ agent_diff_guard/cli.py,sha256=NnXqpi_hkx2L-V5jE_KpEonuSK7It9KCxABLtiwxgzE,5400
4
+ agent_diff_guard/git_diff.py,sha256=nSgjKPz4U6LPZQc17MGS3UCyAeG83Ec7P4B_W4Q8aiI,770
5
+ agent_diff_guard/license.py,sha256=UFQdfCKgLYxPdIc00gYpYIQzhSrS6atu34KtPRarAyM,1370
6
+ agent_diff_guard/sarif.py,sha256=rrgeY3AR6vkHvrzzHqJccbzRwNdqLgEH_60D8k8jvIs,1344
7
+ agent_diff_guard-0.1.0.dist-info/licenses/LICENSE,sha256=ITHwfuJ2PzIdOttUb260qcqFtr2AzAJl3bAJiJdkmGo,1062
8
+ agent_diff_guard-0.1.0.dist-info/METADATA,sha256=emgcCIP0P50pI1ZWHYh0h-6lGGrqrGGelHz6SjN2xQo,4672
9
+ agent_diff_guard-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
10
+ agent_diff_guard-0.1.0.dist-info/entry_points.txt,sha256=2Tr-rjYpUFWAYnj-9Wa96a8JsPyGQ4priWnwRA9PcR4,63
11
+ agent_diff_guard-0.1.0.dist-info/top_level.txt,sha256=ehM7z1UtZd1JjQ0k5bOzlwj7vBsszns-1-NNm5wdHBE,17
12
+ agent_diff_guard-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-diff-guard = agent_diff_guard.cli:main
@@ -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 @@
1
+ agent_diff_guard