vigilsec 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.
vigil/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Vigil — AI coding security co-pilot."""
2
+ __version__ = "0.1.0"
vigil/cli.py ADDED
@@ -0,0 +1,175 @@
1
+ """vigil scan <file|dir> [--format terminal|json|sarif] [--severity CRITICAL|HIGH|...]
2
+ vigil init [--global]
3
+ vigil feedback
4
+
5
+ Exit codes (scan):
6
+ 0 — no findings
7
+ 1 — advisory findings only (MEDIUM/LOW/INFO)
8
+ 2 — CRITICAL or HIGH findings present (causes Claude Code to block the write)
9
+ """
10
+ import argparse
11
+ import json as _json
12
+ import sys
13
+ import webbrowser
14
+ from pathlib import Path
15
+
16
+ from .config import load_config
17
+ from .engine import Engine
18
+ from .reporter import report_terminal, report_json, report_sarif
19
+ from .rules import DEFAULT_RULES, Severity, SEVERITY_ORDER
20
+
21
+
22
+ def _find_hook_sh() -> Path | None:
23
+ """Locate plugin/hook.sh relative to this file (works for editable installs)."""
24
+ here = Path(__file__).resolve().parent
25
+ candidates = [
26
+ here.parent.parent / "plugin" / "hook.sh", # editable: src/vigil/ → project root
27
+ Path.home() / ".vigil" / "plugin" / "hook.sh",
28
+ Path("/usr/local/share/vigil/hook.sh"),
29
+ ]
30
+ return next((p for p in candidates if p.exists()), None)
31
+
32
+
33
+ def _run_init(global_install: bool) -> None:
34
+ hook_sh = _find_hook_sh()
35
+ if hook_sh is None:
36
+ print(
37
+ "vigil init: could not locate plugin/hook.sh.\n"
38
+ "Run from the vigil project directory or see plugin/README_INSTALL.md.",
39
+ file=sys.stderr,
40
+ )
41
+ sys.exit(1)
42
+
43
+ # Ensure hook.sh is executable — git clones and zip downloads often strip the bit
44
+ if not hook_sh.stat().st_mode & 0o111:
45
+ hook_sh.chmod(hook_sh.stat().st_mode | 0o755)
46
+ print(f"Fixed execute permission on {hook_sh.name}")
47
+
48
+ settings_path = (
49
+ Path.home() / ".claude" / "settings.json"
50
+ if global_install
51
+ else Path.cwd() / ".claude" / "settings.json"
52
+ )
53
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
54
+
55
+ settings: dict = {}
56
+ if settings_path.exists():
57
+ try:
58
+ settings = _json.loads(settings_path.read_text())
59
+ except (_json.JSONDecodeError, OSError):
60
+ settings = {}
61
+
62
+ post_tool_use: list = settings.setdefault("hooks", {}).setdefault("PostToolUse", [])
63
+ for entry in post_tool_use:
64
+ for h in entry.get("hooks", []):
65
+ if Path(h.get("command", "")).name == hook_sh.name:
66
+ print(f"Vigil hook already installed in {settings_path}")
67
+ return
68
+
69
+ post_tool_use.append({
70
+ "matcher": "Write|Edit|MultiEdit",
71
+ "hooks": [{"type": "command", "command": str(hook_sh)}],
72
+ })
73
+ settings_path.write_text(_json.dumps(settings, indent=2) + "\n")
74
+ print(f"Vigil hook installed → {settings_path}")
75
+ print("Reload Claude Code to activate.")
76
+
77
+
78
+ def main() -> None:
79
+ parser = argparse.ArgumentParser(
80
+ prog="vigil",
81
+ description="AI coding security co-pilot — blocks insecure code at generation time.",
82
+ )
83
+ sub = parser.add_subparsers(dest="command")
84
+
85
+ scan_p = sub.add_parser("scan", help="Scan a file or directory")
86
+ scan_p.add_argument("path", type=Path)
87
+ scan_p.add_argument(
88
+ "--format", choices=["terminal", "json", "sarif"], default="terminal",
89
+ help="Output format (default: terminal)",
90
+ )
91
+ scan_p.add_argument(
92
+ "--severity", choices=[s.value for s in Severity], default=None,
93
+ help="Only report findings at this severity level or higher",
94
+ )
95
+ scan_p.add_argument("--no-color", action="store_true", help="Disable ANSI color output")
96
+
97
+ init_p = sub.add_parser("init", help="Wire the Vigil PostToolUse hook into .claude/settings.json")
98
+ init_p.add_argument(
99
+ "--global", dest="global_install", action="store_true",
100
+ help="Install into ~/.claude/settings.json (user-wide) instead of ./.claude/settings.json",
101
+ )
102
+
103
+ sub.add_parser("feedback", help="Open the Vigil feedback & waitlist page")
104
+
105
+ args = parser.parse_args()
106
+ if args.command is None:
107
+ parser.print_help()
108
+ sys.exit(0)
109
+
110
+ if args.command == "feedback":
111
+ url = "https://thefwss.com/vigil"
112
+ print(f"Opening {url}")
113
+ print("Or email: prem.fwss@gmail.com")
114
+ webbrowser.open(url)
115
+ return
116
+
117
+ if args.command == "init":
118
+ _run_init(args.global_install)
119
+ return
120
+
121
+ path = args.path.resolve()
122
+ config = load_config(path)
123
+ rules = [r for r in DEFAULT_RULES if r.id not in config.disabled_rules]
124
+ engine = Engine(rules=rules, telemetry_enabled=config.telemetry)
125
+
126
+ if path.is_file():
127
+ # Honour exclude_paths for single-file scans too (same logic scan_dir uses)
128
+ if config.exclude_paths and any(part in config.exclude_paths for part in path.parts):
129
+ results = {}
130
+ else:
131
+ findings = engine.scan(path)
132
+ results = {path: findings} if findings else {}
133
+ elif path.is_dir():
134
+ results = engine.scan_dir(path, extra_skip=set(config.exclude_paths))
135
+ else:
136
+ print(f"vigil: path not found: {path}", file=sys.stderr)
137
+ sys.exit(1)
138
+
139
+ all_findings = [f for fs in results.values() for f in fs]
140
+
141
+ # --severity flag takes priority over .vigilrc min_severity
142
+ effective_sev = args.severity or config.min_severity
143
+ if effective_sev:
144
+ min_order = SEVERITY_ORDER[Severity(effective_sev)]
145
+ all_findings = [f for f in all_findings if SEVERITY_ORDER[f.severity] <= min_order]
146
+ results = {
147
+ p: [f for f in fs if SEVERITY_ORDER[f.severity] <= min_order]
148
+ for p, fs in results.items()
149
+ }
150
+ results = {p: fs for p, fs in results.items() if fs}
151
+
152
+ if args.format == "json":
153
+ print(report_json(results))
154
+ elif args.format == "sarif":
155
+ print(report_sarif(results))
156
+ else:
157
+ for _path, file_findings in results.items():
158
+ report_terminal(file_findings, use_color=not args.no_color)
159
+ if all_findings:
160
+ _dim = "\033[2m" if not args.no_color else ""
161
+ _rst = "\033[0m" if not args.no_color else ""
162
+ print(
163
+ f"{_dim}── Vigil · feedback & waitlist → thefwss.com/vigil ──{_rst}",
164
+ file=sys.stderr,
165
+ )
166
+
167
+ if engine.blocking(all_findings):
168
+ sys.exit(2)
169
+ if all_findings:
170
+ sys.exit(1)
171
+ sys.exit(0)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
vigil/config.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+ import tomllib
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class VigilConfig:
9
+ disabled_rules: list[str] = field(default_factory=list)
10
+ min_severity: str | None = None
11
+ exclude_paths: list[str] = field(default_factory=list)
12
+ telemetry: bool = True
13
+
14
+
15
+ def load_config(start: Path) -> VigilConfig:
16
+ """Walk up from start looking for .vigilrc (TOML format).
17
+
18
+ Searches the given path (or its parent if a file) and all ancestor
19
+ directories until the filesystem root. Returns defaults if not found.
20
+ """
21
+ current = start if start.is_dir() else start.parent
22
+ while True:
23
+ candidate = current / ".vigilrc"
24
+ if candidate.is_file():
25
+ try:
26
+ with open(candidate, "rb") as f:
27
+ data = tomllib.load(f)
28
+ except Exception:
29
+ return VigilConfig()
30
+ return VigilConfig(
31
+ disabled_rules=data.get("disabled_rules", []),
32
+ min_severity=data.get("min_severity"),
33
+ exclude_paths=data.get("exclude_paths", []),
34
+ telemetry=data.get("telemetry", True),
35
+ )
36
+ parent = current.parent
37
+ if parent == current:
38
+ break
39
+ current = parent
40
+ return VigilConfig()
vigil/engine.py ADDED
@@ -0,0 +1,68 @@
1
+ from pathlib import Path
2
+ from .rules import DEFAULT_RULES, Finding, Rule, Severity, SEVERITY_ORDER
3
+ from . import telemetry as _telemetry
4
+
5
+
6
+ class Engine:
7
+ def __init__(self, rules: list[Rule] | None = None, telemetry_enabled: bool = True) -> None:
8
+ self.rules = rules if rules is not None else DEFAULT_RULES
9
+ self._telemetry = telemetry_enabled
10
+
11
+ def scan(self, path: Path) -> list[Finding]:
12
+ """Scan a single file. Returns findings sorted by severity (CRITICAL first).
13
+
14
+ Lines containing '# vigil: ignore' are suppressed — same pattern as
15
+ '# noqa' (flake8) and '# nosec' (bandit).
16
+ """
17
+ if not path.is_file():
18
+ return []
19
+ try:
20
+ source_lines = path.read_text(errors="ignore").splitlines()
21
+ except OSError:
22
+ source_lines = []
23
+
24
+ applicable = [r for r in self.rules if r.applies_to(path)]
25
+ findings: list[Finding] = []
26
+ for rule in applicable:
27
+ for f in rule.check(path):
28
+ if (
29
+ f.line
30
+ and f.line <= len(source_lines)
31
+ and "# vigil: ignore" in source_lines[f.line - 1]
32
+ ):
33
+ continue
34
+ findings.append(f)
35
+ sorted_findings = sorted(findings, key=lambda f: SEVERITY_ORDER[f.severity])
36
+ _telemetry.record(sorted_findings, telemetry_enabled=self._telemetry)
37
+ return sorted_findings
38
+
39
+ def scan_dir(
40
+ self,
41
+ root: Path,
42
+ skip: set[str] | None = None,
43
+ extra_skip: set[str] | None = None,
44
+ ) -> dict[Path, list[Finding]]:
45
+ """Recursively scan all scannable files under root.
46
+
47
+ skip: replaces the default skip set when provided.
48
+ extra_skip: merged with the default skip set (use for .vigilrc exclude_paths).
49
+ """
50
+ _default = {".venv", "venv", "node_modules", ".git", "build", "dist", "__pycache__", "Pods"}
51
+ _skip = (skip if skip is not None else _default) | (extra_skip or set())
52
+ results: dict[Path, list[Finding]] = {}
53
+ for path in root.rglob("*"):
54
+ if not path.is_file():
55
+ continue
56
+ if any(s in path.parts for s in _skip):
57
+ continue
58
+ if not any(r.applies_to(path) for r in self.rules):
59
+ continue
60
+ findings = self.scan(path)
61
+ if findings:
62
+ results[path] = findings
63
+ return results
64
+
65
+ @staticmethod
66
+ def blocking(findings: list[Finding]) -> bool:
67
+ """True if any finding is CRITICAL or HIGH — should block the AI write."""
68
+ return any(f.severity in (Severity.CRITICAL, Severity.HIGH) for f in findings)
vigil/reporter.py ADDED
@@ -0,0 +1,119 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+ from .rules import Finding, Severity
5
+
6
+ _SARIF_LEVEL = {
7
+ Severity.CRITICAL: "error",
8
+ Severity.HIGH: "error",
9
+ Severity.MEDIUM: "warning",
10
+ Severity.LOW: "note",
11
+ Severity.INFO: "note",
12
+ }
13
+
14
+ _COLORS = {
15
+ Severity.CRITICAL: "\033[91m",
16
+ Severity.HIGH: "\033[93m",
17
+ Severity.MEDIUM: "\033[94m",
18
+ Severity.LOW: "\033[96m",
19
+ Severity.INFO: "\033[37m",
20
+ }
21
+ _RESET = "\033[0m"
22
+ _BOLD = "\033[1m"
23
+
24
+
25
+ def _c(sev: Severity, text: str, use_color: bool) -> str:
26
+ return f"{_COLORS[sev]}{text}{_RESET}" if use_color else text
27
+
28
+
29
+ def report_terminal(findings: list[Finding], use_color: bool = True) -> None:
30
+ if not findings:
31
+ return
32
+ blocking = [f for f in findings if f.severity in (Severity.CRITICAL, Severity.HIGH)]
33
+ advisory = [f for f in findings if f.severity not in (Severity.CRITICAL, Severity.HIGH)]
34
+
35
+ if blocking:
36
+ label = _c(Severity.CRITICAL, "BLOCKED", use_color)
37
+ print(f"\n{label} — {len(blocking)} CRITICAL/HIGH finding(s):", file=sys.stderr)
38
+ print("─" * 60, file=sys.stderr)
39
+
40
+ for f in findings:
41
+ loc = f"{f.file_path.name}:{f.line}" if f.line else f.file_path.name
42
+ sev_label = _c(f.severity, f"[{f.severity.value}]", use_color)
43
+ print(f"{sev_label} {f.rule_id} — {f.message}", file=sys.stderr)
44
+ print(f" at {loc}", file=sys.stderr)
45
+ if f.snippet:
46
+ print(f" → {f.snippet[:120]}", file=sys.stderr)
47
+ if f.fix:
48
+ print(f" fix: {f.fix}", file=sys.stderr)
49
+ print(file=sys.stderr)
50
+
51
+ if advisory:
52
+ print(
53
+ f"Advisory: {len(advisory)} MEDIUM/LOW/INFO finding(s) — not blocking.",
54
+ file=sys.stderr,
55
+ )
56
+
57
+
58
+ def report_sarif(results: dict[Path, list[Finding]], tool_version: str = "0.1.0") -> str:
59
+ all_findings = [f for fs in results.values() for f in fs]
60
+ rule_index: dict[str, int] = {}
61
+ for f in all_findings:
62
+ if f.rule_id not in rule_index:
63
+ rule_index[f.rule_id] = len(rule_index)
64
+
65
+ sarif_rules = [
66
+ {"id": rid, "name": rid.replace("-", ""), "shortDescription": {"text": rid}}
67
+ for rid in rule_index
68
+ ]
69
+
70
+ sarif_results = []
71
+ for path, findings in results.items():
72
+ for f in findings:
73
+ sarif_results.append({
74
+ "ruleId": f.rule_id,
75
+ "ruleIndex": rule_index[f.rule_id],
76
+ "level": _SARIF_LEVEL[f.severity],
77
+ "message": {"text": f.message},
78
+ "locations": [{
79
+ "physicalLocation": {
80
+ "artifactLocation": {
81
+ "uri": str(path),
82
+ "uriBaseId": "%SRCROOT%",
83
+ },
84
+ "region": {"startLine": f.line or 1},
85
+ }
86
+ }],
87
+ })
88
+
89
+ return json.dumps({
90
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
91
+ "version": "2.1.0",
92
+ "runs": [{
93
+ "tool": {
94
+ "driver": {
95
+ "name": "vigil",
96
+ "version": tool_version,
97
+ "informationUri": "https://github.com/fwss/vigil",
98
+ "rules": sarif_rules,
99
+ }
100
+ },
101
+ "results": sarif_results,
102
+ }],
103
+ }, indent=2)
104
+
105
+
106
+ def report_json(results: dict[Path, list[Finding]]) -> str:
107
+ out = []
108
+ for path, findings in results.items():
109
+ for f in findings:
110
+ out.append({
111
+ "rule_id": f.rule_id,
112
+ "severity": f.severity.value,
113
+ "message": f.message,
114
+ "file": str(path),
115
+ "line": f.line,
116
+ "snippet": f.snippet,
117
+ "fix": f.fix,
118
+ })
119
+ return json.dumps(out, indent=2)
@@ -0,0 +1,103 @@
1
+ from .base import Finding, Rule, Severity, SEVERITY_ORDER
2
+ from .secrets import (
3
+ AwsAccessKeyRule,
4
+ HardcodedPasswordRule,
5
+ HardcodedApiKeyRule,
6
+ HardcodedTokenRule,
7
+ EvalInjectionRule,
8
+ ShellTrueRule,
9
+ OsSystemRule,
10
+ JwtSecretRule,
11
+ PemPrivateKeyRule,
12
+ CredentialUrlRule,
13
+ StripeLiveKeyRule,
14
+ SlackTokenRule,
15
+ GenericProviderKeyRule,
16
+ )
17
+ from .docker import DockerPortExposureRule, DockerComposeEnvSecretRule
18
+ from .dockerfile import DockerfileEnvSecretRule, DockerfileRootUserRule, DockerfileLatestTagRule
19
+ from .nginx import NginxSecurityHeadersRule
20
+ from .trivy import TrivyIacScanRule
21
+ from .deps import PipAuditRule, NpmAuditRule
22
+ from .k8s import K8sSecurityRule
23
+ from .iam import IamWildcardRule
24
+ from .agency import LlmShellExecRule, AutoApprovalBypassRule, UnboundedAgentLoopRule, LlmOutputFileWriteRule
25
+ from .mcp_security import McpToolPoisoningRule, McpDynamicDescriptionRule, McpShellToolRule
26
+ from .prompt_injection import (
27
+ UserInputInSystemPromptRule,
28
+ RawRequestAsLlmContentRule,
29
+ TemplateInjectionInPromptRule,
30
+ UnsanitizedToolOutputRule,
31
+ )
32
+ from .shell import ShellSecretInjectionRule
33
+
34
+ DEFAULT_RULES: list[Rule] = [
35
+ # Secrets — hardcoded credentials
36
+ AwsAccessKeyRule(),
37
+ HardcodedPasswordRule(),
38
+ HardcodedApiKeyRule(),
39
+ HardcodedTokenRule(),
40
+ JwtSecretRule(),
41
+ PemPrivateKeyRule(),
42
+ CredentialUrlRule(),
43
+ StripeLiveKeyRule(),
44
+ SlackTokenRule(),
45
+ GenericProviderKeyRule(),
46
+ # Code injection
47
+ EvalInjectionRule(),
48
+ ShellTrueRule(),
49
+ OsSystemRule(),
50
+ # Docker IaC
51
+ DockerPortExposureRule(),
52
+ DockerComposeEnvSecretRule(),
53
+ # Dockerfile hardening
54
+ DockerfileEnvSecretRule(),
55
+ DockerfileRootUserRule(),
56
+ DockerfileLatestTagRule(),
57
+ # nginx security
58
+ NginxSecurityHeadersRule(),
59
+ # Trivy IaC deep scan
60
+ TrivyIacScanRule(),
61
+ # Dependency CVE scanning
62
+ PipAuditRule(),
63
+ NpmAuditRule(),
64
+ # Kubernetes manifest security
65
+ K8sSecurityRule(),
66
+ # IAM policy wildcards
67
+ IamWildcardRule(),
68
+ # Excessive agency — AI agents without human oversight
69
+ LlmShellExecRule(),
70
+ AutoApprovalBypassRule(),
71
+ UnboundedAgentLoopRule(),
72
+ LlmOutputFileWriteRule(),
73
+ # MCP server security
74
+ McpToolPoisoningRule(),
75
+ McpDynamicDescriptionRule(),
76
+ McpShellToolRule(),
77
+ # Prompt injection in AI-calling code
78
+ UserInputInSystemPromptRule(),
79
+ RawRequestAsLlmContentRule(),
80
+ TemplateInjectionInPromptRule(),
81
+ UnsanitizedToolOutputRule(),
82
+ # Shell script secret leakage
83
+ ShellSecretInjectionRule(),
84
+ ]
85
+
86
+ __all__ = [
87
+ "Finding", "Rule", "Severity", "SEVERITY_ORDER", "DEFAULT_RULES",
88
+ "AwsAccessKeyRule", "HardcodedPasswordRule", "HardcodedApiKeyRule",
89
+ "HardcodedTokenRule", "EvalInjectionRule", "ShellTrueRule", "OsSystemRule",
90
+ "JwtSecretRule", "PemPrivateKeyRule", "CredentialUrlRule",
91
+ "StripeLiveKeyRule", "SlackTokenRule", "GenericProviderKeyRule",
92
+ "DockerPortExposureRule", "DockerComposeEnvSecretRule",
93
+ "DockerfileEnvSecretRule", "DockerfileRootUserRule", "DockerfileLatestTagRule",
94
+ "NginxSecurityHeadersRule",
95
+ "TrivyIacScanRule",
96
+ "PipAuditRule", "NpmAuditRule",
97
+ "K8sSecurityRule",
98
+ "IamWildcardRule",
99
+ "LlmShellExecRule", "AutoApprovalBypassRule", "UnboundedAgentLoopRule", "LlmOutputFileWriteRule",
100
+ "McpToolPoisoningRule", "McpDynamicDescriptionRule", "McpShellToolRule",
101
+ "UserInputInSystemPromptRule", "RawRequestAsLlmContentRule",
102
+ "TemplateInjectionInPromptRule", "UnsanitizedToolOutputRule",
103
+ ]
vigil/rules/agency.py ADDED
@@ -0,0 +1,174 @@
1
+ """VGL-A001–A004: Excessive agency — AI agents without human oversight.
2
+
3
+ These rules catch the patterns that make autonomous AI agents unsafe:
4
+ LLM output piped directly to shell, unbounded loops with no kill switch,
5
+ hardcoded auto-approval bypasses, and LLM content written to disk unchecked.
6
+ """
7
+ from __future__ import annotations
8
+ import re
9
+ from pathlib import Path
10
+ from .base import Finding, Rule, Severity
11
+
12
+ _AI_EXTS = {".py", ".ts", ".js"}
13
+
14
+ _LLM_IMPORT = re.compile(
15
+ r"(?:import|from|require)\s+.*(?:anthropic|openai|langchain|llama|cohere|mistral|google\.generativeai)",
16
+ re.IGNORECASE,
17
+ )
18
+ _LLM_CALL = re.compile(
19
+ r"(?:client\.messages\.create|openai\.chat\.completions|\.chat\.|llm\.invoke|llm\.run|chain\.run|agent\.run|model\.generate)",
20
+ )
21
+
22
+
23
+ class LlmShellExecRule(Rule):
24
+ """VGL-A001: LLM output piped directly into shell execution."""
25
+ id = "VGL-A001"
26
+ severity = Severity.CRITICAL
27
+
28
+ _PAT = re.compile(
29
+ r"(?:subprocess\.(?:run|call|Popen)|os\.system|os\.popen)\s*\([^)]*"
30
+ r"\b(?:response|completion|result|output|content|message)\b[^)]*\.(?:content|text|output)",
31
+ )
32
+
33
+ def applies_to(self, path: Path) -> bool:
34
+ return path.suffix in _AI_EXTS
35
+
36
+ def check(self, path: Path) -> list[Finding]:
37
+ try:
38
+ lines = path.read_text(errors="ignore").splitlines()
39
+ except OSError:
40
+ return []
41
+ findings = []
42
+ for i, line in enumerate(lines, 1):
43
+ if self._PAT.search(line):
44
+ findings.append(Finding(
45
+ rule_id=self.id,
46
+ severity=self.severity,
47
+ message="LLM output passed directly to shell — command injection risk",
48
+ file_path=path,
49
+ line=i,
50
+ snippet=line.strip()[:120],
51
+ fix=(
52
+ "Never pass LLM output directly to shell. Parse structured output, "
53
+ "validate against an allowlist, require human approval, or use a sandboxed executor."
54
+ ),
55
+ ))
56
+ return findings
57
+
58
+
59
+ class AutoApprovalBypassRule(Rule):
60
+ """VGL-A002: Hardcoded auto-approval — disables human-in-the-loop gate."""
61
+ id = "VGL-A002"
62
+ severity = Severity.HIGH
63
+
64
+ _PAT = re.compile(
65
+ r"(?i)\b(auto_approve|skip_approval|bypass_approval|approve_all|skip_confirmation|skip_review)\s*=\s*True",
66
+ )
67
+
68
+ def applies_to(self, path: Path) -> bool:
69
+ return path.suffix in _AI_EXTS
70
+
71
+ def check(self, path: Path) -> list[Finding]:
72
+ try:
73
+ lines = path.read_text(errors="ignore").splitlines()
74
+ except OSError:
75
+ return []
76
+ findings = []
77
+ for i, line in enumerate(lines, 1):
78
+ stripped = line.strip()
79
+ if stripped.startswith("#"): # commented-out code is not live code
80
+ continue
81
+ if self._PAT.search(line):
82
+ findings.append(Finding(
83
+ rule_id=self.id,
84
+ severity=self.severity,
85
+ message=f"Auto-approval hardcoded — HITL gate bypassed: {stripped[:60]}",
86
+ file_path=path,
87
+ line=i,
88
+ snippet=stripped[:120],
89
+ fix=(
90
+ "Require explicit human approval for agent actions. Read approval state "
91
+ "from SSM or a runtime flag — never hardcode True in source."
92
+ ),
93
+ ))
94
+ return findings
95
+
96
+
97
+ class UnboundedAgentLoopRule(Rule):
98
+ """VGL-A003: Unbounded agentic loop — while True with LLM calls, no iteration limit."""
99
+ id = "VGL-A003"
100
+ severity = Severity.HIGH
101
+
102
+ _WHILE_TRUE = re.compile(r"^\s*while\s+True\s*:")
103
+ _ITER_LIMIT = re.compile(
104
+ r"\b(max_iterations|max_turns|max_steps|max_loops|iteration_limit|step_limit)\b",
105
+ )
106
+
107
+ def applies_to(self, path: Path) -> bool:
108
+ return path.suffix in _AI_EXTS
109
+
110
+ def check(self, path: Path) -> list[Finding]:
111
+ try:
112
+ text = path.read_text(errors="ignore")
113
+ except OSError:
114
+ return []
115
+
116
+ # Only flag when the file actually makes LLM calls
117
+ if not _LLM_IMPORT.search(text) and not _LLM_CALL.search(text):
118
+ return []
119
+ if self._ITER_LIMIT.search(text):
120
+ return []
121
+
122
+ lines = text.splitlines()
123
+ findings = []
124
+ for i, line in enumerate(lines, 1):
125
+ if self._WHILE_TRUE.match(line):
126
+ findings.append(Finding(
127
+ rule_id=self.id,
128
+ severity=self.severity,
129
+ message="Unbounded 'while True' loop with LLM calls — no iteration limit found",
130
+ file_path=path,
131
+ line=i,
132
+ snippet=line.strip(),
133
+ fix=(
134
+ "Add max_iterations and a kill-switch check at the top of the loop. "
135
+ "Read the kill switch from SSM so it can be toggled without a deploy."
136
+ ),
137
+ ))
138
+ return findings
139
+
140
+
141
+ class LlmOutputFileWriteRule(Rule):
142
+ """VGL-A004: LLM response written directly to disk without validation."""
143
+ id = "VGL-A004"
144
+ severity = Severity.HIGH
145
+
146
+ _PAT = re.compile(
147
+ r"(?:write_text|\.write)\s*\([^)]*\b(?:llm_output|ai_output|model_output|"
148
+ r"response\.content|completion\.content|message\.content|result\.text)\b",
149
+ )
150
+
151
+ def applies_to(self, path: Path) -> bool:
152
+ return path.suffix in _AI_EXTS
153
+
154
+ def check(self, path: Path) -> list[Finding]:
155
+ try:
156
+ lines = path.read_text(errors="ignore").splitlines()
157
+ except OSError:
158
+ return []
159
+ findings = []
160
+ for i, line in enumerate(lines, 1):
161
+ if self._PAT.search(line):
162
+ findings.append(Finding(
163
+ rule_id=self.id,
164
+ severity=self.severity,
165
+ message="LLM output written to filesystem without validation",
166
+ file_path=path,
167
+ line=i,
168
+ snippet=line.strip()[:120],
169
+ fix=(
170
+ "Validate and sanitize LLM output before writing to disk. "
171
+ "Consider schema validation, content-type checks, or a human review step."
172
+ ),
173
+ ))
174
+ return findings