Skip to content

Commit bd033cc

Browse files
committed
feat(plugin): add live AI guardrails for real-time rule violation interception
- Add RuleChecker with pattern detection for SQL injection, XSS, hardcoded secrets, eval/exec, and shell injection (strict mode) - Add ViolationRenderer with unicode box formatting and hook output - Wire guardrails into PreToolUse hook for Edit/Write tool calls - Configurable via CODINGBUDDY_GUARDRAIL_LEVEL (strict/normal/off) - False-positive filtering: skip comments, env var refs, placeholders - 50 tests covering all detection patterns and edge cases Closes #1439
1 parent 6c65225 commit bd033cc

5 files changed

Lines changed: 887 additions & 2 deletions

File tree

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
"""Live AI Guardrails — real-time rule violation detection (#1439).
2+
3+
Intercepts Edit/Write tool calls and checks content against known
4+
security violation patterns (SQL injection, XSS, hardcoded secrets, etc.).
5+
Configurable via CODINGBUDDY_GUARDRAIL_LEVEL env var: strict/normal/off.
6+
"""
7+
import os
8+
import re
9+
from dataclasses import dataclass
10+
from typing import List, Optional
11+
12+
13+
@dataclass
14+
class Violation:
15+
"""A single rule violation found in code content."""
16+
17+
rule_id: str
18+
severity: str
19+
message: str
20+
line_content: str
21+
suggested_fix: str
22+
23+
24+
# --- Comment detection ---
25+
26+
_COMMENT_RE = re.compile(r"^\s*(?:#|//|/\*|\*|<!--)")
27+
28+
_VALID_LEVELS = {"strict", "normal", "off"}
29+
30+
31+
def _is_comment(line: str) -> bool:
32+
"""Check if a line is a comment."""
33+
return bool(_COMMENT_RE.match(line))
34+
35+
36+
# --- Placeholder / false-positive filters for secrets ---
37+
38+
_PLACEHOLDER_RE = re.compile(
39+
r"(?:your[-_]|example|placeholder|changeme|xxx|test|dummy|fake|sample|TODO)",
40+
re.IGNORECASE,
41+
)
42+
43+
_ENV_REF_RE = re.compile(
44+
r"(?:os\.environ|process\.env|getenv|ENV\[|config\.|settings\.)",
45+
re.IGNORECASE,
46+
)
47+
48+
49+
# --- Pattern definitions ---
50+
# Each pattern: (compiled_regex, rule_id, severity, message, suggested_fix)
51+
# Patterns are applied per-line after filtering out comments.
52+
53+
_SQL_KEYWORDS = r"(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|MERGE|REPLACE)\b"
54+
55+
# SQL injection: f-string, %-format, or concatenation with SQL keywords
56+
_SQL_FSTRING_RE = re.compile(
57+
rf'f["\'].*{_SQL_KEYWORDS}.*\{{',
58+
re.IGNORECASE,
59+
)
60+
_SQL_PERCENT_RE = re.compile(
61+
rf'["\'].*{_SQL_KEYWORDS}.*%\s*(?:s|d|r)["\'].*%\s*\w',
62+
re.IGNORECASE,
63+
)
64+
_SQL_CONCAT_RE = re.compile(
65+
rf'["\'].*{_SQL_KEYWORDS}.*["\']\s*\+\s*\w',
66+
re.IGNORECASE,
67+
)
68+
69+
_NORMAL_PATTERNS = [
70+
# SEC-001: SQL Injection
71+
(
72+
_SQL_FSTRING_RE,
73+
"SEC-001",
74+
"high",
75+
"SQL injection detected — dynamic SQL via string interpolation",
76+
"Use parameterized queries (e.g., cursor.execute('SELECT ... WHERE id = ?', (id,)))",
77+
),
78+
(
79+
_SQL_PERCENT_RE,
80+
"SEC-001",
81+
"high",
82+
"SQL injection detected — dynamic SQL via %-formatting",
83+
"Use parameterized queries instead of string formatting",
84+
),
85+
(
86+
_SQL_CONCAT_RE,
87+
"SEC-001",
88+
"high",
89+
"SQL injection detected — dynamic SQL via string concatenation",
90+
"Use parameterized queries instead of string concatenation",
91+
),
92+
# SEC-002: XSS
93+
(
94+
re.compile(r"\.\s*innerHTML\s*=", re.IGNORECASE),
95+
"SEC-002",
96+
"high",
97+
"XSS risk — direct innerHTML assignment",
98+
"Use textContent for plain text, or sanitize with DOMPurify before assigning innerHTML",
99+
),
100+
(
101+
re.compile(r"document\.write\s*\("),
102+
"SEC-002",
103+
"high",
104+
"XSS risk — document.write with potentially unsafe content",
105+
"Use DOM APIs (createElement/textContent) instead of document.write",
106+
),
107+
(
108+
re.compile(r"dangerouslySetInnerHTML"),
109+
"SEC-002",
110+
"high",
111+
"XSS risk — dangerouslySetInnerHTML bypasses React's XSS protection",
112+
"Sanitize content with DOMPurify before using dangerouslySetInnerHTML",
113+
),
114+
# SEC-004: Dangerous eval/exec
115+
(
116+
re.compile(r"\beval\s*\("),
117+
"SEC-004",
118+
"high",
119+
"Dangerous eval() call — arbitrary code execution risk",
120+
"Avoid eval(). Use JSON.parse() for data, or ast.literal_eval() for Python literals",
121+
),
122+
(
123+
re.compile(r"\bexec\s*\("),
124+
"SEC-004",
125+
"high",
126+
"Dangerous exec() call — arbitrary code execution risk",
127+
"Avoid exec(). Use safer alternatives like importlib or specific parsers",
128+
),
129+
]
130+
131+
# SEC-003: Hardcoded secrets — handled separately with extra false-positive filtering
132+
_SECRET_KEY_RE = re.compile(
133+
r"""(?:api[_-]?key|secret(?:[_-]?key)?|password|passwd|token|auth[_-]?token|private[_-]?key|access[_-]?key)"""
134+
r"""\s*=\s*["']([^"']{8,})["']""",
135+
re.IGNORECASE,
136+
)
137+
138+
# AWS access key pattern
139+
_AWS_KEY_RE = re.compile(
140+
r"""(?:aws[_-]?access[_-]?key(?:[_-]?id)?|aws[_-]?secret)\s*=\s*["']([A-Za-z0-9/+=]{16,})["']""",
141+
re.IGNORECASE,
142+
)
143+
144+
# Generic variable names containing "secret" but not caught above
145+
_GENERIC_SECRET_RE = re.compile(
146+
r"""(?:secret|credential|private_key)\s*=\s*["']([^"']{8,})["']""",
147+
re.IGNORECASE,
148+
)
149+
150+
# Strict-only patterns
151+
_STRICT_PATTERNS = [
152+
# SEC-005: Shell injection
153+
(
154+
re.compile(r"subprocess\.\w+\(.*shell\s*=\s*True", re.DOTALL),
155+
"SEC-005",
156+
"medium",
157+
"Shell injection risk — subprocess with shell=True",
158+
"Use subprocess.run(cmd_list) without shell=True, passing args as a list",
159+
),
160+
(
161+
re.compile(r"os\.system\s*\("),
162+
"SEC-005",
163+
"medium",
164+
"Shell injection risk — os.system allows arbitrary command execution",
165+
"Use subprocess.run(cmd_list) instead of os.system()",
166+
),
167+
]
168+
169+
170+
class RuleChecker:
171+
"""Checks code content against known security violation patterns."""
172+
173+
def __init__(self) -> None:
174+
raw = os.environ.get("CODINGBUDDY_GUARDRAIL_LEVEL", "normal").lower()
175+
self.level: str = raw if raw in _VALID_LEVELS else "normal"
176+
177+
def check(self, content: str) -> List[Violation]:
178+
"""Check code content for violations.
179+
180+
Args:
181+
content: Code content string (possibly multiline).
182+
183+
Returns:
184+
List of Violation objects found. Empty if level is 'off'.
185+
"""
186+
if self.level == "off":
187+
return []
188+
189+
violations: List[Violation] = []
190+
lines = content.split("\n")
191+
192+
for line in lines:
193+
stripped = line.strip()
194+
if not stripped or _is_comment(stripped):
195+
continue
196+
197+
# Normal patterns (always active)
198+
for regex, rule_id, severity, message, fix in _NORMAL_PATTERNS:
199+
if regex.search(stripped):
200+
violations.append(
201+
Violation(rule_id, severity, message, stripped, fix)
202+
)
203+
204+
# SEC-003: Hardcoded secrets (special handling)
205+
self._check_secrets(stripped, violations)
206+
207+
# Strict-only patterns
208+
if self.level == "strict":
209+
for regex, rule_id, severity, message, fix in _STRICT_PATTERNS:
210+
if regex.search(stripped):
211+
violations.append(
212+
Violation(rule_id, severity, message, stripped, fix)
213+
)
214+
215+
return violations
216+
217+
def _check_secrets(self, line: str, violations: List[Violation]) -> None:
218+
"""Check a line for hardcoded secrets with false-positive filtering."""
219+
# Skip if line references env vars
220+
if _ENV_REF_RE.search(line):
221+
return
222+
223+
# AWS access key IDs (AKIA prefix) — always flag, skip placeholder filter
224+
aws_match = _AWS_KEY_RE.search(line)
225+
if aws_match:
226+
value = aws_match.group(1)
227+
if len(value) >= 16:
228+
violations.append(
229+
Violation(
230+
rule_id="SEC-003",
231+
severity="high",
232+
message="Hardcoded AWS credential detected — must not be in source code",
233+
line_content=line,
234+
suggested_fix="Use environment variables (AWS_ACCESS_KEY_ID) or AWS credentials file",
235+
)
236+
)
237+
return
238+
239+
for regex in (_SECRET_KEY_RE, _GENERIC_SECRET_RE):
240+
match = regex.search(line)
241+
if match:
242+
value = match.group(1)
243+
# Skip empty, very short, or placeholder values
244+
if len(value) < 8:
245+
continue
246+
if _PLACEHOLDER_RE.search(value):
247+
continue
248+
violations.append(
249+
Violation(
250+
rule_id="SEC-003",
251+
severity="high",
252+
message="Hardcoded secret detected — credentials should not be in source code",
253+
line_content=line,
254+
suggested_fix="Use environment variables (os.environ / process.env) or a secrets manager",
255+
)
256+
)
257+
return # One match per line is enough
258+
259+
def check_tool_input(
260+
self, tool_name: str, tool_input: dict
261+
) -> List[Violation]:
262+
"""Check a PreToolUse tool_input for violations.
263+
264+
Only checks Edit and Write tool calls.
265+
266+
Args:
267+
tool_name: Name of the tool being called.
268+
tool_input: The tool_input dict from the hook.
269+
270+
Returns:
271+
List of violations found in the content.
272+
"""
273+
if tool_name not in ("Edit", "Write"):
274+
return []
275+
276+
content: Optional[str] = None
277+
if tool_name == "Edit":
278+
content = tool_input.get("new_string", "")
279+
elif tool_name == "Write":
280+
content = tool_input.get("content", "")
281+
282+
if not content:
283+
return []
284+
285+
return self.check(content)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Violation message renderer for Live AI Guardrails (#1439).
2+
3+
Renders clear, actionable violation messages with unicode box formatting,
4+
rule references, and suggested fixes.
5+
"""
6+
from typing import List
7+
8+
from rule_checker import Violation
9+
10+
11+
# Severity display
12+
_SEVERITY_ICONS = {
13+
"high": "\u2718", # ✘
14+
"medium": "\u26a0", # ⚠
15+
"low": "\u2139", # ℹ
16+
}
17+
18+
19+
class ViolationRenderer:
20+
"""Renders violation messages for terminal and hook output."""
21+
22+
def render(self, violations: List[Violation]) -> str:
23+
"""Render violations as a formatted unicode box message.
24+
25+
Args:
26+
violations: List of Violation objects.
27+
28+
Returns:
29+
Formatted string with box-drawing characters, or empty string.
30+
"""
31+
if not violations:
32+
return ""
33+
34+
count = len(violations)
35+
header = f" CodingBuddy Guardrail \u2502 {count} violation(s) found "
36+
width = max(len(header) + 4, 60)
37+
38+
lines: list[str] = []
39+
lines.append("\u250c" + "\u2500" * (width - 2) + "\u2510")
40+
lines.append("\u2502" + header.center(width - 2) + "\u2502")
41+
lines.append("\u251c" + "\u2500" * (width - 2) + "\u2524")
42+
43+
for i, v in enumerate(violations):
44+
icon = _SEVERITY_ICONS.get(v.severity, "?")
45+
lines.append(
46+
"\u2502"
47+
+ f" {icon} [{v.severity.upper()}] {v.rule_id}: {v.message}".ljust(width - 2)
48+
+ "\u2502"
49+
)
50+
# Truncate long line content
51+
content = v.line_content
52+
if len(content) > width - 12:
53+
content = content[: width - 15] + "..."
54+
lines.append(
55+
"\u2502"
56+
+ f" \u2514\u2500 {content}".ljust(width - 2)
57+
+ "\u2502"
58+
)
59+
lines.append(
60+
"\u2502"
61+
+ f" \u21b3 Fix: {v.suggested_fix}".ljust(width - 2)
62+
+ "\u2502"
63+
)
64+
if i < count - 1:
65+
lines.append(
66+
"\u2502" + " " * (width - 2) + "\u2502"
67+
)
68+
69+
lines.append("\u2514" + "\u2500" * (width - 2) + "\u2518")
70+
return "\n".join(lines)
71+
72+
def render_for_hook(self, violations: List[Violation]) -> str:
73+
"""Render violations as concise additionalContext for PreToolUse hook.
74+
75+
Args:
76+
violations: List of Violation objects.
77+
78+
Returns:
79+
Compact string for hook additionalContext, or empty string.
80+
"""
81+
if not violations:
82+
return ""
83+
84+
parts: list[str] = []
85+
parts.append(
86+
f"[CodingBuddy Guardrail] {len(violations)} violation(s) detected:"
87+
)
88+
for v in violations:
89+
parts.append(
90+
f" - {v.rule_id} ({v.severity.upper()}): {v.message}. "
91+
f"Fix: {v.suggested_fix}"
92+
)
93+
return "\n".join(parts)

0 commit comments

Comments
 (0)