sanity-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.
Files changed (35) hide show
  1. sanity_guard-0.1.0/.env.example +5 -0
  2. sanity_guard-0.1.0/.gitignore +32 -0
  3. sanity_guard-0.1.0/.sanity/override_log.jsonl +1 -0
  4. sanity_guard-0.1.0/PKG-INFO +67 -0
  5. sanity_guard-0.1.0/README.md +51 -0
  6. sanity_guard-0.1.0/pyproject.toml +34 -0
  7. sanity_guard-0.1.0/s -ExecutionPolicy RemoteSigned) ; (& c/357/200/272UsersJinangDocumentsantigravityblissful-volta.venvScriptsActivate.ps1)" +156 -0
  8. sanity_guard-0.1.0/setup.cfg +4 -0
  9. sanity_guard-0.1.0/src/sanity/__init__.py +3 -0
  10. sanity_guard-0.1.0/src/sanity/__main__.py +3 -0
  11. sanity_guard-0.1.0/src/sanity/checks.py +278 -0
  12. sanity_guard-0.1.0/src/sanity/cli.py +174 -0
  13. sanity_guard-0.1.0/src/sanity/config.py +130 -0
  14. sanity_guard-0.1.0/src/sanity/diff.py +113 -0
  15. sanity_guard-0.1.0/src/sanity/display.py +81 -0
  16. sanity_guard-0.1.0/src/sanity/llm_check.py +111 -0
  17. sanity_guard-0.1.0/src/sanity/models.py +41 -0
  18. sanity_guard-0.1.0/src/sanity/scorer.py +27 -0
  19. sanity_guard-0.1.0/src/sanity/snapshot.py +178 -0
  20. sanity_guard-0.1.0/src/sanity_guard.egg-info/PKG-INFO +67 -0
  21. sanity_guard-0.1.0/src/sanity_guard.egg-info/SOURCES.txt +33 -0
  22. sanity_guard-0.1.0/src/sanity_guard.egg-info/dependency_links.txt +1 -0
  23. sanity_guard-0.1.0/src/sanity_guard.egg-info/entry_points.txt +2 -0
  24. sanity_guard-0.1.0/src/sanity_guard.egg-info/requires.txt +8 -0
  25. sanity_guard-0.1.0/src/sanity_guard.egg-info/top_level.txt +1 -0
  26. sanity_guard-0.1.0/test_groq_api.py +43 -0
  27. sanity_guard-0.1.0/tests/__init__.py +1 -0
  28. sanity_guard-0.1.0/tests/test_checks.py +543 -0
  29. sanity_guard-0.1.0/tests/test_config.py +196 -0
  30. sanity_guard-0.1.0/tests/test_diff.py +114 -0
  31. sanity_guard-0.1.0/tests/test_init.py +81 -0
  32. sanity_guard-0.1.0/tests/test_llm_check.py +208 -0
  33. sanity_guard-0.1.0/tests/test_models.py +48 -0
  34. sanity_guard-0.1.0/tests/test_scorer.py +72 -0
  35. sanity_guard-0.1.0/tests/test_snapshot.py +162 -0
@@ -0,0 +1,5 @@
1
+ # Sanity — required environment variables
2
+ # Copy this file to .env and fill in your API key
3
+ #
4
+ # Get a free Groq API key at: https://console.groq.com
5
+ GROQ_API_KEY=your_groq_api_key_here # sanity: ignore
@@ -0,0 +1,32 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ *.egg
9
+ .eggs/
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+ ENV/
15
+
16
+ # IDE
17
+ .vscode/
18
+ .idea/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # OS
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # Testing
30
+ .pytest_cache/
31
+ htmlcov/
32
+ .coverage
@@ -0,0 +1 @@
1
+ {"timestamp": 1787317708.6539652, "files_changed": ["danger.sql"], "overridden_checks": ["dangerous_commands"]}
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: sanity-guard
3
+ Version: 0.1.0
4
+ Summary: Pre-commit risk checker for AI-generated code changes
5
+ Author: Jinang
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: click>=8.1
10
+ Requires-Dist: gitpython>=3.1
11
+ Requires-Dist: python-dotenv>=1.0
12
+ Requires-Dist: groq>=0.9
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.4; extra == "dev"
15
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
16
+
17
+ # Sanity - Pre-commit Risk Checker
18
+
19
+ Sanity acts as a pre-commit risk checker for AI-generated code changes. It evaluates code additions for dangerous statements, exposed secrets, and scope drift, and safely blocks risky commits.
20
+
21
+ ## Safety Nets & Snapshots
22
+ Whenever a commit is allowed (whether deemed safe, or overridden via `--force`), Sanity automatically creates a lightweight local git tag (e.g. `sanity-snapshot-20260823-153012`) pointing to the commit *before* the new changes. This ensures that even if an AI agent makes a destructive commit, you can easily view your history and roll it back using `sanity rollback <number>`.
23
+
24
+ > **Note**: `sanity-snapshot-*` tags are intended strictly as local safety nets. Git naturally keeps tags local during a standard `git push`, so they won't clutter your remote repository unless you explicitly run `git push --tags`.
25
+
26
+ ## What it catches
27
+
28
+ - **Mass deletions** -- flags commits that delete far more code than they add
29
+ - **Leaked secrets** -- detects API keys, AWS credentials, private keys
30
+ - **Dangerous commands** -- catches `rm -rf`, `DROP TABLE`, force-push scripts
31
+ - **Scope creep** -- uses LLM analysis to verify changes match the stated task
32
+ - **Auto-snapshots** -- every commit gets a safety snapshot for easy rollback
33
+
34
+ ## Quick start
35
+
36
+ ```bash
37
+ # Install
38
+ pip install -e .
39
+
40
+ # Set up your Groq API key (free tier)
41
+ cp .env.example .env
42
+ # Edit .env with your key from https://console.groq.com
43
+
44
+ # Install the pre-commit hook in your repo
45
+ sanity init
46
+
47
+ # Manually check staged changes
48
+ sanity check
49
+
50
+ # Rollback to a previous safe state
51
+ sanity rollback
52
+ ```
53
+
54
+ ## Commands
55
+
56
+ | Command | Description |
57
+ |---------|-------------|
58
+ | `sanity init` | Install pre-commit hook in current repo |
59
+ | `sanity check` | Run safety checks on staged diff |
60
+ | `sanity rollback` | Revert to a previous safe snapshot |
61
+ | `sanity config` | Configure thresholds and toggle checks |
62
+
63
+ ## Requirements
64
+
65
+ - Python 3.11+
66
+ - Git
67
+ - Groq API key (free, no credit card needed)
@@ -0,0 +1,51 @@
1
+ # Sanity - Pre-commit Risk Checker
2
+
3
+ Sanity acts as a pre-commit risk checker for AI-generated code changes. It evaluates code additions for dangerous statements, exposed secrets, and scope drift, and safely blocks risky commits.
4
+
5
+ ## Safety Nets & Snapshots
6
+ Whenever a commit is allowed (whether deemed safe, or overridden via `--force`), Sanity automatically creates a lightweight local git tag (e.g. `sanity-snapshot-20260823-153012`) pointing to the commit *before* the new changes. This ensures that even if an AI agent makes a destructive commit, you can easily view your history and roll it back using `sanity rollback <number>`.
7
+
8
+ > **Note**: `sanity-snapshot-*` tags are intended strictly as local safety nets. Git naturally keeps tags local during a standard `git push`, so they won't clutter your remote repository unless you explicitly run `git push --tags`.
9
+
10
+ ## What it catches
11
+
12
+ - **Mass deletions** -- flags commits that delete far more code than they add
13
+ - **Leaked secrets** -- detects API keys, AWS credentials, private keys
14
+ - **Dangerous commands** -- catches `rm -rf`, `DROP TABLE`, force-push scripts
15
+ - **Scope creep** -- uses LLM analysis to verify changes match the stated task
16
+ - **Auto-snapshots** -- every commit gets a safety snapshot for easy rollback
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ # Install
22
+ pip install -e .
23
+
24
+ # Set up your Groq API key (free tier)
25
+ cp .env.example .env
26
+ # Edit .env with your key from https://console.groq.com
27
+
28
+ # Install the pre-commit hook in your repo
29
+ sanity init
30
+
31
+ # Manually check staged changes
32
+ sanity check
33
+
34
+ # Rollback to a previous safe state
35
+ sanity rollback
36
+ ```
37
+
38
+ ## Commands
39
+
40
+ | Command | Description |
41
+ |---------|-------------|
42
+ | `sanity init` | Install pre-commit hook in current repo |
43
+ | `sanity check` | Run safety checks on staged diff |
44
+ | `sanity rollback` | Revert to a previous safe snapshot |
45
+ | `sanity config` | Configure thresholds and toggle checks |
46
+
47
+ ## Requirements
48
+
49
+ - Python 3.11+
50
+ - Git
51
+ - Groq API key (free, no credit card needed)
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sanity-guard"
7
+ version = "0.1.0"
8
+ description = "Pre-commit risk checker for AI-generated code changes"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Jinang"}]
13
+
14
+ dependencies = [
15
+ "click>=8.1",
16
+ "gitpython>=3.1",
17
+ "python-dotenv>=1.0",
18
+ "groq>=0.9",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=7.4",
24
+ "pytest-cov>=4.1",
25
+ ]
26
+
27
+ [project.scripts]
28
+ sanity = "sanity.cli:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,156 @@
1
+ warning: in the working copy of 'src/sanity/checks.py', LF will be replaced by CRLF the next time Git touches it
2
+ diff --git a/src/sanity/checks.py b/src/sanity/checks.py
3
+ index b4193af..1421f14 100644
4
+ --- a/src/sanity/checks.py
5
+ +++ b/src/sanity/checks.py
6
+ @@ -1,4 +1,6 @@
7
+ +import math
8
+ import re
9
+ +from collections import Counter
10
+ 
11
+ from sanity.config import IGNORE_MARKER, SanityConfig
12
+ from sanity.models import CheckResult, DiffResult
13
+ @@ -72,6 +74,7 @@ def check_deletion_ratio(diff: DiffResult, threshold: float = 3.0) -> CheckResul
14
+ SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
15
+ ("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),
16
+ ("AWS secret key", re.compile(r"(?i)(aws_secret_access_key|aws_secret)\s*[=:]\s*\S{20,}")),
17
+ + ("Stripe API key", re.compile(r"(?i)(sk_live_|sk_test_|pk_live_|pk_test_)[A-Za-z0-9]{20,}")),
18
+ ("Private key header", re.compile(r"-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----")),
19
+ ("Generic API key assignment", re.compile(r"(?i)(api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),
20
+ ("Generic secret assignment", re.compile(r"(?i)(secret[_-]?key|secret)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),
21
+ @@ -127,13 +130,45 @@ def check_dangerous_commands(
22
+ diff: DiffResult,
23
+ config: SanityConfig | None = None,
24
+ ) -> CheckResult:
25
+ - added_lines = _extract_added_lines(diff, config)
26
+ + cfg = config or SanityConfig()
27
+ findings: list[str] = []
28
+ 
29
+ - for file_path, line in added_lines:
30
+ - for pattern_name, pattern in DANGEROUS_PATTERNS:
31
+ - if pattern.search(line):
32
+ - findings.append(f"{pattern_name} in {file_path}")
33
+ + standard_patterns = [p for p in DANGEROUS_PATTERNS if p[0] != "DELETE without WHERE"]
34
+ +
35
+ + for f in diff.files:
36
+ + if cfg.is_excluded(f.path):
37
+ + continue
38
+ +
39
+ + lines = f.diff_text.splitlines()
40
+ + for i, line in enumerate(lines):
41
+ + if line.startswith("+") and not line.startswith("+++"):
42
+ + content = line[1:]
43
+ + if IGNORE_MARKER in content:
44
+ + continue
45
+ +
46
+ + for pattern_name, pattern in standard_patterns:
47
+ + if pattern.search(content):
48
+ + findings.append(f"{pattern_name} in {f.path}")
49
+ +
50
+ + match = re.search(r"(?i)\bDELETE\s+FROM\s+\w+", content)
51
+ + if match:
52
+ + has_where = False
53
+ + if re.search(r"(?i)\bWHERE\b", content[match.end():]):
54
+ + has_where = True
55
+ + else:
56
+ + for j in range(i + 1, min(i + 10, len(lines))):
57
+ + next_line = lines[j]
58
+ + if next_line.startswith(("-", "---")):
59
+ + continue
60
+ + if next_line.startswith(("+", " ")):
61
+ + next_content = next_line[1:]
62
+ + if re.search(r"(?i)\bWHERE\b", next_content):
63
+ + has_where = True
64
+ + break
65
+ + if ";" in next_content:
66
+ + break
67
+ + if not has_where:
68
+ + findings.append(f"DELETE without WHERE in {f.path}")
69
+ 
70
+ if findings:
71
+ detail = "; ".join(findings[:5])
72
+ @@ -153,6 +188,74 @@ def check_dangerous_commands(
73
+ )
74
+ 
75
+ 
76
+ +def shannon_entropy(s: str) -> float:
77
+ + if not s:
78
+ + return 0.0
79
+ + freqs = Counter(s)
80
+ + entropy = 0.0
81
+ + for count in freqs.values():
82
+ + p = count / len(s)
83
+ + entropy -= p * math.log2(p)
84
+ + return entropy
85
+ +
86
+ +
87
+ +def looks_like_high_entropy_secret(s: str, threshold: float = 3.8) -> bool:
88
+ + if len(s) < 20:
89
+ + return False
90
+ + if " " in s:
91
+ + return False
92
+ +
93
+ + lower = s.lower()
94
+ + placeholders = ["your", "here", "xxx", "changeme", "example", "placeholder", "insert", "dummy", "key", "token"]
95
+ + if any(p in lower for p in placeholders):
96
+ + return False
97
+ +
98
+ + return shannon_entropy(s) > threshold
99
+ +
100
+ +
101
+ +def check_high_entropy_secrets(
102
+ + diff: DiffResult,
103
+ + config: SanityConfig | None = None,
104
+ +) -> CheckResult:
105
+ + cfg = config or SanityConfig()
106
+ + added_lines = _extract_added_lines(diff, cfg)
107
+ + findings: list[str] = []
108
+ +
109
+ + threshold = cfg.entropy_threshold
110
+ +
111
+ + pattern = re.compile(
112
+ + r"(?i)(?:['\"]?)\b([a-z0-9_]*(?:key|token|secret|password|credential|api|auth)[a-z0-9_]*)\b(?:['\"]?)\s*[=:]\s*(['\"])([^'\"]{20,})\2"
113
+ + )
114
+ +
115
+ + for file_path, line in added_lines:
116
+ + if any(p.search(line) for _, p in SECRET_PATTERNS):
117
+ + continue
118
+ +
119
+ + for match in pattern.finditer(line):
120
+ + val = match.group(3)
121
+ + if looks_like_high_entropy_secret(val, threshold):
122
+ + var_name = match.group(1)
123
+ + findings.append(f"High entropy secret ({var_name}) in {file_path}")
124
+ + break
125
+ +
126
+ + if findings:
127
+ + detail = "; ".join(findings[:5])
128
+ + extra = f" (+{len(findings) - 5} more)" if len(findings) > 5 else ""
129
+ + return CheckResult(
130
+ + check_name="high_entropy_secrets",
131
+ + passed=False,
132
+ + severity="HIGH",
133
+ + reason=f"Potential high-entropy secrets detected: {detail}{extra}",
134
+ + )
135
+ +
136
+ + return CheckResult(
137
+ + check_name="high_entropy_secrets",
138
+ + passed=True,
139
+ + severity="LOW",
140
+ + reason="No high-entropy secrets detected.",
141
+ + )
142
+ +
143
+ +
144
+ def run_all_checks(
145
+ diff: DiffResult,
146
+ config: SanityConfig | None = None,
147
+ @@ -166,6 +269,9 @@ def run_all_checks(
148
+ if "secret_detection" not in cfg.disabled_checks:
149
+ results.append(check_secrets(diff, config=cfg))
150
+ 
151
+ + if "high_entropy_secrets" not in cfg.disabled_checks:
152
+ + results.append(check_high_entropy_secrets(diff, config=cfg))
153
+ +
154
+ if "dangerous_commands" not in cfg.disabled_checks:
155
+ results.append(check_dangerous_commands(diff, config=cfg))
156
+ 
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Sanity — pre-commit risk checker for AI-generated code."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from sanity.cli import main
2
+
3
+ main()
@@ -0,0 +1,278 @@
1
+ import math
2
+ import re
3
+ from collections import Counter
4
+
5
+ from sanity.config import IGNORE_MARKER, SanityConfig
6
+ from sanity.models import CheckResult, DiffResult
7
+
8
+
9
+ def _extract_added_lines(
10
+ diff: DiffResult,
11
+ config: SanityConfig | None = None,
12
+ ) -> list[tuple[str, str]]:
13
+ """Extract added lines from diff, returning (file_path, line_content) pairs.
14
+
15
+ Skips files matching exclusion patterns and lines with the inline ignore marker.
16
+ """
17
+ results = []
18
+ for f in diff.files:
19
+ if config and config.is_excluded(f.path):
20
+ continue
21
+ for line in f.diff_text.splitlines():
22
+ if line.startswith("+") and not line.startswith("+++"):
23
+ content = line[1:]
24
+ if IGNORE_MARKER in content:
25
+ continue
26
+ results.append((f.path, content))
27
+ return results
28
+
29
+
30
+ def check_deletion_ratio(diff: DiffResult, threshold: float = 3.0) -> CheckResult:
31
+ if diff.total_added == 0 and diff.total_deleted == 0:
32
+ return CheckResult(
33
+ check_name="deletion_ratio",
34
+ passed=True,
35
+ severity="LOW",
36
+ reason="No line changes to evaluate.",
37
+ )
38
+
39
+ if diff.total_added == 0:
40
+ if diff.total_deleted > 0:
41
+ return CheckResult(
42
+ check_name="deletion_ratio",
43
+ passed=False,
44
+ severity="HIGH",
45
+ reason=f"Pure deletion: {diff.total_deleted} lines deleted with 0 lines added.",
46
+ )
47
+ return CheckResult(
48
+ check_name="deletion_ratio",
49
+ passed=True,
50
+ severity="LOW",
51
+ reason="No meaningful changes.",
52
+ )
53
+
54
+ ratio = diff.total_deleted / diff.total_added
55
+ if ratio > threshold:
56
+ return CheckResult(
57
+ check_name="deletion_ratio",
58
+ passed=False,
59
+ severity="HIGH",
60
+ reason=(
61
+ f"Deletion ratio {ratio:.1f}x exceeds threshold {threshold}x "
62
+ f"({diff.total_deleted} deleted / {diff.total_added} added)."
63
+ ),
64
+ )
65
+
66
+ return CheckResult(
67
+ check_name="deletion_ratio",
68
+ passed=True,
69
+ severity="LOW",
70
+ reason=f"Deletion ratio {ratio:.1f}x is within threshold {threshold}x.",
71
+ )
72
+
73
+
74
+ SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
75
+ ("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),
76
+ ("AWS secret key", re.compile(r"(?i)(aws_secret_access_key|aws_secret)\s*[=:]\s*\S{20,}")),
77
+ ("Stripe API key", re.compile(r"(?i)(sk_live_|sk_test_|pk_live_|pk_test_)[A-Za-z0-9]{20,}")),
78
+ ("Private key header", re.compile(r"-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----")),
79
+ ("Generic API key assignment", re.compile(r"(?i)(api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),
80
+ ("Generic secret assignment", re.compile(r"(?i)(secret[_-]?key|secret)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),
81
+ ("Generic token assignment", re.compile(r"(?i)(auth[_-]?token|access[_-]?token|token)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),
82
+ ("Password assignment", re.compile(r"(?i)(password|passwd|pwd)\s*[=:]\s*['\"]?[^\s'\"]{8,}['\"]?")),
83
+ ("Connection string", re.compile(r"(?i)(mysql|postgres|mongodb|redis)://\S+:\S+@")),
84
+ ]
85
+
86
+
87
+ def check_secrets(
88
+ diff: DiffResult,
89
+ config: SanityConfig | None = None,
90
+ ) -> CheckResult:
91
+ added_lines = _extract_added_lines(diff, config)
92
+ findings: list[str] = []
93
+
94
+ for file_path, line in added_lines:
95
+ for pattern_name, pattern in SECRET_PATTERNS:
96
+ if pattern.search(line):
97
+ findings.append(f"{pattern_name} in {file_path}")
98
+ break # one finding per line is enough
99
+
100
+ if findings:
101
+ detail = "; ".join(findings[:5])
102
+ extra = f" (+{len(findings) - 5} more)" if len(findings) > 5 else ""
103
+ return CheckResult(
104
+ check_name="secret_detection",
105
+ passed=False,
106
+ severity="HIGH",
107
+ reason=f"Potential secrets detected: {detail}{extra}",
108
+ )
109
+
110
+ return CheckResult(
111
+ check_name="secret_detection",
112
+ passed=True,
113
+ severity="LOW",
114
+ reason="No secrets detected in added lines.",
115
+ )
116
+
117
+
118
+ DANGEROUS_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
119
+ ("rm -rf", re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|--recursive\s+--force|-[a-zA-Z]*f[a-zA-Z]*r)\b")), # sanity: ignore
120
+ ("DROP TABLE", re.compile(r"(?i)\bDROP\s+TABLE\b")), # sanity: ignore
121
+ ("DELETE without WHERE", re.compile(r"(?i)\bDELETE\s+FROM\s+\w+\s*;")),
122
+ ("TRUNCATE", re.compile(r"(?i)\bTRUNCATE\s+(TABLE\s+)?\w+")),
123
+ ("git force push", re.compile(r"git\s+push\s+.*(-f|--force)\b")),
124
+ ("os.remove", re.compile(r"\bos\s*\.\s*remove\s*\(")),
125
+ ("shutil.rmtree", re.compile(r"\bshutil\s*\.\s*rmtree\s*\(")),
126
+ ]
127
+
128
+
129
+ def check_dangerous_commands(
130
+ diff: DiffResult,
131
+ config: SanityConfig | None = None,
132
+ ) -> CheckResult:
133
+ cfg = config or SanityConfig()
134
+ findings: list[str] = []
135
+
136
+ standard_patterns = [p for p in DANGEROUS_PATTERNS if p[0] != "DELETE without WHERE"]
137
+
138
+ for f in diff.files:
139
+ if cfg.is_excluded(f.path):
140
+ continue
141
+
142
+ lines = f.diff_text.splitlines()
143
+ for i, line in enumerate(lines):
144
+ if line.startswith("+") and not line.startswith("+++"):
145
+ content = line[1:]
146
+ if IGNORE_MARKER in content:
147
+ continue
148
+
149
+ for pattern_name, pattern in standard_patterns:
150
+ if pattern.search(content):
151
+ findings.append(f"{pattern_name} in {f.path}")
152
+
153
+ match = re.search(r"(?i)\bDELETE\s+FROM\s+\w+", content)
154
+ if match:
155
+ has_where = False
156
+ if re.search(r"(?i)\bWHERE\b", content[match.end():]):
157
+ has_where = True
158
+ else:
159
+ for j in range(i + 1, min(i + 10, len(lines))):
160
+ next_line = lines[j]
161
+ if next_line.startswith(("-", "---")):
162
+ continue
163
+ if next_line.startswith(("+", " ")):
164
+ next_content = next_line[1:]
165
+ if re.search(r"(?i)\bWHERE\b", next_content):
166
+ has_where = True
167
+ break
168
+ if ";" in next_content:
169
+ break
170
+ if not has_where:
171
+ findings.append(f"DELETE without WHERE in {f.path}")
172
+
173
+ if findings:
174
+ detail = "; ".join(findings[:5])
175
+ extra = f" (+{len(findings) - 5} more)" if len(findings) > 5 else ""
176
+ return CheckResult(
177
+ check_name="dangerous_commands",
178
+ passed=False,
179
+ severity="HIGH",
180
+ reason=f"Dangerous commands detected: {detail}{extra}",
181
+ )
182
+
183
+ return CheckResult(
184
+ check_name="dangerous_commands",
185
+ passed=True,
186
+ severity="LOW",
187
+ reason="No dangerous commands detected in added lines.",
188
+ )
189
+
190
+
191
+ def shannon_entropy(s: str) -> float:
192
+ if not s:
193
+ return 0.0
194
+ freqs = Counter(s)
195
+ entropy = 0.0
196
+ for count in freqs.values():
197
+ p = count / len(s)
198
+ entropy -= p * math.log2(p)
199
+ return entropy
200
+
201
+
202
+ def looks_like_high_entropy_secret(s: str, threshold: float = 3.8) -> bool:
203
+ if len(s) < 20:
204
+ return False
205
+ if " " in s:
206
+ return False
207
+
208
+ lower = s.lower()
209
+ placeholders = ["your", "here", "xxx", "changeme", "example", "placeholder", "insert", "dummy", "key", "token"]
210
+ if any(p in lower for p in placeholders):
211
+ return False
212
+
213
+ return shannon_entropy(s) > threshold
214
+
215
+
216
+ def check_high_entropy_secrets(
217
+ diff: DiffResult,
218
+ config: SanityConfig | None = None,
219
+ ) -> CheckResult:
220
+ cfg = config or SanityConfig()
221
+ added_lines = _extract_added_lines(diff, cfg)
222
+ findings: list[str] = []
223
+
224
+ threshold = cfg.entropy_threshold
225
+
226
+ pattern = re.compile(
227
+ r"(?i)(?:['\"]?)\b([a-z0-9_]*(?:key|token|secret|password|credential|api|auth)[a-z0-9_]*)\b(?:['\"]?)\s*[=:]\s*(['\"])([^'\"]{20,})\2"
228
+ )
229
+
230
+ for file_path, line in added_lines:
231
+ if any(p.search(line) for _, p in SECRET_PATTERNS):
232
+ continue
233
+
234
+ for match in pattern.finditer(line):
235
+ val = match.group(3)
236
+ if looks_like_high_entropy_secret(val, threshold):
237
+ var_name = match.group(1)
238
+ findings.append(f"High entropy secret ({var_name}) in {file_path}")
239
+ break
240
+
241
+ if findings:
242
+ detail = "; ".join(findings[:5])
243
+ extra = f" (+{len(findings) - 5} more)" if len(findings) > 5 else ""
244
+ return CheckResult(
245
+ check_name="high_entropy_secrets",
246
+ passed=False,
247
+ severity="HIGH",
248
+ reason=f"Potential high-entropy secrets detected: {detail}{extra}",
249
+ )
250
+
251
+ return CheckResult(
252
+ check_name="high_entropy_secrets",
253
+ passed=True,
254
+ severity="LOW",
255
+ reason="No high-entropy secrets detected.",
256
+ )
257
+
258
+
259
+ def run_all_checks(
260
+ diff: DiffResult,
261
+ config: SanityConfig | None = None,
262
+ ) -> list[CheckResult]:
263
+ cfg = config or SanityConfig()
264
+ results: list[CheckResult] = []
265
+
266
+ if "deletion_ratio" not in cfg.disabled_checks:
267
+ results.append(check_deletion_ratio(diff, threshold=cfg.deletion_ratio_threshold))
268
+
269
+ if "secret_detection" not in cfg.disabled_checks:
270
+ results.append(check_secrets(diff, config=cfg))
271
+
272
+ if "high_entropy_secrets" not in cfg.disabled_checks:
273
+ results.append(check_high_entropy_secrets(diff, config=cfg))
274
+
275
+ if "dangerous_commands" not in cfg.disabled_checks:
276
+ results.append(check_dangerous_commands(diff, config=cfg))
277
+
278
+ return results