devguard-scanner 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.
Files changed (67) hide show
  1. devguard/__init__.py +5 -0
  2. devguard/ai/__init__.py +0 -0
  3. devguard/ai/investigator.py +380 -0
  4. devguard/ai/prompts.py +113 -0
  5. devguard/ai/providers/__init__.py +0 -0
  6. devguard/ai/providers/anthropic.py +163 -0
  7. devguard/ai/providers/openai_compat.py +157 -0
  8. devguard/ai/registry.py +160 -0
  9. devguard/ai/run.py +119 -0
  10. devguard/ai/sdk.py +102 -0
  11. devguard/ai/testing.py +126 -0
  12. devguard/ai/tools.py +389 -0
  13. devguard/analyzers/__init__.py +0 -0
  14. devguard/analyzers/base.py +135 -0
  15. devguard/analyzers/carriers.py +345 -0
  16. devguard/analyzers/command.py +343 -0
  17. devguard/analyzers/content.py +240 -0
  18. devguard/analyzers/filesystem.py +142 -0
  19. devguard/analyzers/hidden.py +273 -0
  20. devguard/analyzers/imports.py +102 -0
  21. devguard/analyzers/jsonc.py +221 -0
  22. devguard/analyzers/surfaces/__init__.py +0 -0
  23. devguard/analyzers/surfaces/ai_assistants.py +318 -0
  24. devguard/analyzers/surfaces/autoload.py +142 -0
  25. devguard/analyzers/surfaces/common.py +242 -0
  26. devguard/analyzers/surfaces/deps.py +159 -0
  27. devguard/analyzers/surfaces/devcontainer.py +195 -0
  28. devguard/analyzers/surfaces/env.py +53 -0
  29. devguard/analyzers/surfaces/git.py +236 -0
  30. devguard/analyzers/surfaces/npm.py +304 -0
  31. devguard/analyzers/surfaces/python_pkg.py +138 -0
  32. devguard/analyzers/surfaces/vscode.py +515 -0
  33. devguard/cli.py +302 -0
  34. devguard/core/__init__.py +0 -0
  35. devguard/core/config.py +147 -0
  36. devguard/core/filetype.py +313 -0
  37. devguard/core/models.py +195 -0
  38. devguard/core/redact.py +72 -0
  39. devguard/core/safefs.py +280 -0
  40. devguard/core/scoring.py +60 -0
  41. devguard/core/service.py +211 -0
  42. devguard/correlate/__init__.py +0 -0
  43. devguard/correlate/chains.py +160 -0
  44. devguard/mcp/__init__.py +0 -0
  45. devguard/mcp/server.py +442 -0
  46. devguard/report/__init__.py +0 -0
  47. devguard/report/escape.py +31 -0
  48. devguard/report/markdown.py +242 -0
  49. devguard/report/sarif.py +96 -0
  50. devguard/report/terminal.py +47 -0
  51. devguard/report/writer.py +52 -0
  52. devguard/rules/__init__.py +0 -0
  53. devguard/rules/catalog.py +96 -0
  54. devguard/rules/content/ai_instructions.yaml +111 -0
  55. devguard/rules/content/cross_language.yaml +228 -0
  56. devguard/rules/content/docs.yaml +63 -0
  57. devguard/rules/content/javascript.yaml +171 -0
  58. devguard/rules/content/obfuscation.yaml +138 -0
  59. devguard/rules/content/python.yaml +144 -0
  60. devguard/rules/content/shell.yaml +109 -0
  61. devguard/rules/content/windows.yaml +95 -0
  62. devguard/rules/data/vscode_settings.yaml +81 -0
  63. devguard_scanner-0.1.0.dist-info/METADATA +84 -0
  64. devguard_scanner-0.1.0.dist-info/RECORD +67 -0
  65. devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
  66. devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
  67. devguard_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,102 @@
1
+ """Static file-to-file references for multi-hop correlation (COR-003).
2
+
3
+ Only literal relative paths are resolved; nothing is evaluated. Besides imports this records files that
4
+ code reads (e.g. a loader that reads `./assets/x.woff` and evals it) because payloads are often stored
5
+ in a second, innocent-looking file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from devguard.analyzers.base import AnalysisContext, FileView
13
+ from devguard.core.models import Finding
14
+ from devguard.core.safefs import normalize_rel
15
+
16
+ _JS_PATTERNS = [
17
+ re.compile(r"""\brequire\s*\(\s*['"`](\.{1,2}/[^'"`\n]+)['"`]\s*\)"""),
18
+ re.compile(r"""\bimport\s+(?:[^'"`;]*?\s+from\s+)?['"`](\.{1,2}/[^'"`\n]+)['"`]"""),
19
+ re.compile(r"""\bimport\s*\(\s*['"`](\.{1,2}/[^'"`\n]+)['"`]\s*\)"""),
20
+ re.compile(r"""\bexport\s+[^'"`;]*?\s+from\s+['"`](\.{1,2}/[^'"`\n]+)['"`]"""),
21
+ re.compile(
22
+ r"""\b(?:readFileSync|readFile|createReadStream)\s*\(\s*(?:path\.(?:join|resolve)\(\s*__dirname\s*,\s*)?['"`](\.{0,2}/?[\w@][^'"`\n]*\.\w+)['"`]"""
23
+ ),
24
+ ]
25
+ _PY_PATTERNS = [
26
+ re.compile(r"^\s*from\s+(\.+)([\w.]*)\s+import\s+([\w, ]+)", re.MULTILINE),
27
+ re.compile(r"""\b(?:open|run_path|exec_file)\s*\(\s*['"]([^'"\n]+\.\w+)['"]"""),
28
+ ]
29
+ _SH_PATTERN = re.compile(
30
+ r"(?:^|[;&|(]\s*|\bthen\s+|\bdo\s+)(?:source|\.|bash|sh|zsh|node|python3?|pwsh|powershell)\s+['\"]?([\w./-]+\.\w+)",
31
+ re.MULTILINE,
32
+ )
33
+ _JS_EXTS = (
34
+ "",
35
+ ".js",
36
+ ".cjs",
37
+ ".mjs",
38
+ ".ts",
39
+ ".tsx",
40
+ ".jsx",
41
+ ".json",
42
+ "/index.js",
43
+ "/index.ts",
44
+ "/index.cjs",
45
+ "/index.mjs",
46
+ )
47
+
48
+
49
+ class ImportCollector:
50
+ name = "imports"
51
+
52
+ def applies(self, fv: FileView) -> bool:
53
+ return fv.text is not None and fv.lang in ("javascript", "python", "shell", "powershell", "batch")
54
+
55
+ def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
56
+ assert fv.text is not None
57
+ text = fv.text[: ctx.config.limits.max_regex_text]
58
+ refs: set[str] = set()
59
+ if fv.lang == "javascript":
60
+ for pat in _JS_PATTERNS:
61
+ for m in pat.finditer(text):
62
+ refs |= _resolve(ctx, fv.parent, m.group(1), _JS_EXTS)
63
+ elif fv.lang == "python":
64
+ for m in _PY_PATTERNS[0].finditer(text):
65
+ dots, mod, names = m.group(1), m.group(2), m.group(3)
66
+ base = fv.parent
67
+ for _ in range(len(dots) - 1):
68
+ base = base.rsplit("/", 1)[0] if "/" in base else ""
69
+ mod_path = mod.replace(".", "/")
70
+ cands = [mod_path] if mod_path else [n.strip() for n in names.split(",")]
71
+ for c in cands:
72
+ refs |= _resolve(ctx, base, c, (".py", "/__init__.py"))
73
+ for m in _PY_PATTERNS[1].finditer(text):
74
+ refs |= _resolve(ctx, fv.parent, m.group(1), ("",))
75
+ # Scripts usually run with the repository (or package) root as working directory, not their own dir.
76
+ sh_bases = [fv.parent, _repo_root_of(fv.rel), ""]
77
+ for m in _SH_PATTERN.finditer(text):
78
+ for base in sh_bases:
79
+ found = _resolve(ctx, base, m.group(1), ("",))
80
+ if found:
81
+ refs |= found
82
+ break
83
+ refs.discard(fv.rel)
84
+ if refs:
85
+ ctx.imports[fv.rel] = sorted(refs)
86
+ return []
87
+
88
+
89
+ def _resolve(ctx: AnalysisContext, base: str, spec: str, exts: tuple[str, ...]) -> set[str]:
90
+ spec = spec.split("?", 1)[0]
91
+ for ext in exts:
92
+ cand = normalize_rel(f"{base}/{spec}{ext}" if base else f"{spec}{ext}")
93
+ if cand and ctx.inventory.exists(cand):
94
+ return {cand}
95
+ return set()
96
+
97
+
98
+ def _repo_root_of(rel: str) -> str:
99
+ """Directory containing `.git` for files inside it (hooks), else ""."""
100
+ if "/.git/" in f"/{rel}":
101
+ return rel.split(".git/", 1)[0].rstrip("/")
102
+ return ""
@@ -0,0 +1,221 @@
1
+ """Small JSONC parser (JSON + comments + trailing commas) that keeps line numbers and duplicate keys.
2
+
3
+ Duplicate keys matter: VS Code uses the last value, so an attacker can put a harmless value first
4
+ and a malicious one later (TC-01.6). Never evaluates anything; depth and size are bounded (SAFE-007).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ class JsoncError(ValueError):
14
+ def __init__(self, message: str, line: int) -> None:
15
+ super().__init__(f"{message} (line {line})")
16
+ self.line = line
17
+
18
+
19
+ @dataclass
20
+ class Node:
21
+ value: Any # dict[str, Node] | list[Node] | str | int | float | bool | None
22
+ line: int
23
+ duplicates: list[tuple[str, int, int]] = field(default_factory=list) # (key, first line, dup line)
24
+
25
+ def get(self, key: str) -> Node | None:
26
+ if isinstance(self.value, dict):
27
+ return self.value.get(key)
28
+ return None
29
+
30
+ def items(self) -> list[tuple[str, Node]]:
31
+ return list(self.value.items()) if isinstance(self.value, dict) else []
32
+
33
+ def elements(self) -> list[Node]:
34
+ return list(self.value) if isinstance(self.value, list) else []
35
+
36
+ def list_at(self, key: str) -> list[Node]:
37
+ """Elements of the array under `key`, or [] if missing / not an array."""
38
+ child = self.get(key)
39
+ return child.elements() if child is not None else []
40
+
41
+ def str_value(self) -> str | None:
42
+ return self.value if isinstance(self.value, str) else None
43
+
44
+ def plain(self) -> Any:
45
+ if isinstance(self.value, dict):
46
+ return {k: v.plain() for k, v in self.value.items()}
47
+ if isinstance(self.value, list):
48
+ return [v.plain() for v in self.value]
49
+ return self.value
50
+
51
+
52
+ MAX_DEPTH = 64
53
+ _ESC = {'"': '"', "\\": "\\", "/": "/", "b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t"}
54
+
55
+
56
+ class _Parser:
57
+ def __init__(self, text: str) -> None:
58
+ self.s = text
59
+ self.i = 0
60
+ self.line = 1
61
+ self.n = len(text)
62
+ self.duplicates: list[tuple[str, int, int]] = []
63
+
64
+ def error(self, msg: str) -> JsoncError:
65
+ return JsoncError(msg, self.line)
66
+
67
+ def ws(self) -> None:
68
+ s, n = self.s, self.n
69
+ while self.i < n:
70
+ c = s[self.i]
71
+ if c == "\n":
72
+ self.line += 1
73
+ self.i += 1
74
+ elif c in " \t\r\ufeff":
75
+ self.i += 1
76
+ elif s.startswith("//", self.i):
77
+ end = s.find("\n", self.i)
78
+ self.i = n if end == -1 else end
79
+ elif s.startswith("/*", self.i):
80
+ end = s.find("*/", self.i + 2)
81
+ if end == -1:
82
+ raise self.error("unterminated comment")
83
+ self.line += s.count("\n", self.i, end)
84
+ self.i = end + 2
85
+ else:
86
+ break
87
+
88
+ def parse(self) -> Node:
89
+ self.ws()
90
+ node = self.value(0)
91
+ self.ws()
92
+ if self.i != self.n:
93
+ raise self.error("trailing content")
94
+ return node
95
+
96
+ def value(self, depth: int) -> Node:
97
+ if depth > MAX_DEPTH:
98
+ raise self.error("nesting too deep")
99
+ self.ws()
100
+ if self.i >= self.n:
101
+ raise self.error("unexpected end of input")
102
+ c = self.s[self.i]
103
+ line = self.line
104
+ if c == "{":
105
+ return self.obj(depth)
106
+ if c == "[":
107
+ return self.arr(depth)
108
+ if c == '"':
109
+ return Node(self.string(), line)
110
+ if self.s.startswith("true", self.i):
111
+ self.i += 4
112
+ return Node(True, line)
113
+ if self.s.startswith("false", self.i):
114
+ self.i += 5
115
+ return Node(False, line)
116
+ if self.s.startswith("null", self.i):
117
+ self.i += 4
118
+ return Node(None, line)
119
+ return Node(self.number(), line)
120
+
121
+ def obj(self, depth: int) -> Node:
122
+ node = Node({}, self.line)
123
+ key_lines: dict[str, int] = {}
124
+ self.i += 1
125
+ while True:
126
+ self.ws()
127
+ if self.i < self.n and self.s[self.i] == "}":
128
+ self.i += 1
129
+ return node
130
+ if self.i >= self.n or self.s[self.i] != '"':
131
+ raise self.error("expected string key")
132
+ key_line = self.line
133
+ key = self.string()
134
+ self.ws()
135
+ if self.i >= self.n or self.s[self.i] != ":":
136
+ raise self.error("expected ':'")
137
+ self.i += 1
138
+ val = self.value(depth + 1)
139
+ if key in key_lines:
140
+ self.duplicates.append((key, key_lines[key], key_line))
141
+ key_lines[key] = key_line
142
+ node.value[key] = val # last one wins, like VS Code
143
+ self.ws()
144
+ if self.i < self.n and self.s[self.i] == ",":
145
+ self.i += 1
146
+ continue
147
+ if self.i < self.n and self.s[self.i] == "}":
148
+ self.i += 1
149
+ return node
150
+ raise self.error("expected ',' or '}'")
151
+
152
+ def arr(self, depth: int) -> Node:
153
+ node = Node([], self.line)
154
+ self.i += 1
155
+ while True:
156
+ self.ws()
157
+ if self.i < self.n and self.s[self.i] == "]":
158
+ self.i += 1
159
+ return node
160
+ node.value.append(self.value(depth + 1))
161
+ self.ws()
162
+ if self.i < self.n and self.s[self.i] == ",":
163
+ self.i += 1
164
+ continue
165
+ if self.i < self.n and self.s[self.i] == "]":
166
+ self.i += 1
167
+ return node
168
+ raise self.error("expected ',' or ']'")
169
+
170
+ def string(self) -> str:
171
+ self.i += 1
172
+ out: list[str] = []
173
+ s = self.s
174
+ while self.i < self.n:
175
+ c = s[self.i]
176
+ if c == '"':
177
+ self.i += 1
178
+ return "".join(out)
179
+ if c == "\\":
180
+ self.i += 1
181
+ if self.i >= self.n:
182
+ break
183
+ e = s[self.i]
184
+ if e == "u":
185
+ hexs = s[self.i + 1 : self.i + 5]
186
+ try:
187
+ out.append(chr(int(hexs, 16)))
188
+ except ValueError as exc:
189
+ raise self.error("bad \\u escape") from exc
190
+ self.i += 5
191
+ continue
192
+ out.append(_ESC.get(e, e))
193
+ self.i += 1
194
+ continue
195
+ if c == "\n":
196
+ self.line += 1
197
+ out.append(c)
198
+ self.i += 1
199
+ raise self.error("unterminated string")
200
+
201
+ def number(self) -> int | float:
202
+ start = self.i
203
+ while self.i < self.n and self.s[self.i] in "+-0123456789.eExXabcdefABCDEF":
204
+ self.i += 1
205
+ tok = self.s[start : self.i]
206
+ if not tok:
207
+ raise self.error(f"unexpected character {self.s[start]!r}")
208
+ try:
209
+ return int(tok)
210
+ except ValueError:
211
+ try:
212
+ return float(tok)
213
+ except ValueError as exc:
214
+ raise self.error(f"bad number {tok!r}") from exc
215
+
216
+
217
+ def parse_jsonc(text: str) -> tuple[Node, list[tuple[str, int, int]]]:
218
+ """Parse JSONC. Returns (root node, duplicate keys as (key, first line, duplicate line))."""
219
+ p = _Parser(text)
220
+ root = p.parse()
221
+ return root, p.duplicates
File without changes
@@ -0,0 +1,318 @@
1
+ """AI coding-assistant configuration: hooks, MCP servers, permissions (TC-03).
2
+
3
+ Instruction files (CLAUDE.md, AGENTS.md, .cursorrules, ...) are covered by the `ai-instructions`
4
+ content-rule pack and by the hidden-Unicode analyzer, which treats them as high-risk.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import re
11
+ from pathlib import PurePosixPath
12
+
13
+ from devguard.analyzers.base import AnalysisContext, FileView
14
+ from devguard.analyzers.jsonc import Node
15
+ from devguard.analyzers.surfaces.common import (
16
+ args_text,
17
+ assess_and_trigger,
18
+ command_text,
19
+ env_findings,
20
+ env_pairs_from_node,
21
+ load_jsonc,
22
+ workspace_root_of,
23
+ )
24
+ from devguard.core.models import Finding, Severity
25
+ from devguard.core.redact import make_excerpt
26
+ from devguard.rules.catalog import build_finding, rule
27
+
28
+ R_HOOK = rule(
29
+ "DG-AI-CLAUDE-HOOK-001",
30
+ "Claude Code project config runs shell commands",
31
+ "medium",
32
+ "high",
33
+ "ai-config",
34
+ "TC-03.1",
35
+ "Project-level Claude Code settings define hooks or helper commands (statusLine, apiKeyHelper) that "
36
+ "run shell commands automatically during agent sessions.",
37
+ "Teams use hooks for formatting and linting. Check every command and the scripts it calls.",
38
+ "Do not start an AI assistant session in this repository until the hook commands are reviewed.",
39
+ )
40
+ R_PERMS = rule(
41
+ "DG-AI-PERMISSIONS-001",
42
+ "Project config grants an AI assistant broad permissions",
43
+ "medium",
44
+ "high",
45
+ "ai-config",
46
+ "TC-03.1",
47
+ "The project's assistant settings pre-approve broad tool use (for example unrestricted Bash, "
48
+ "bypassPermissions mode, or auto-accept), so the assistant can run commands without asking.",
49
+ )
50
+ R_MCP_AUTO = rule(
51
+ "DG-AI-MCP-AUTOAPPROVE-001",
52
+ "Project auto-approves its own MCP servers",
53
+ "medium",
54
+ "high",
55
+ "ai-config",
56
+ "TC-03.2",
57
+ "The project settings enable the repository's MCP servers automatically, skipping the approval prompt.",
58
+ )
59
+ R_MCP_SERVER = rule(
60
+ "DG-AI-MCP-SERVER-001",
61
+ "Project defines an MCP server that starts a local command",
62
+ "low",
63
+ "high",
64
+ "ai-config",
65
+ "TC-03.2",
66
+ "An MCP configuration in the repository starts a program when the assistant loads the project. "
67
+ "MCP servers run with the user's privileges.",
68
+ "Many projects ship MCP configs for real tools; verify the command, package names, and versions.",
69
+ )
70
+ R_MCP_REMOTE = rule(
71
+ "DG-AI-MCP-REMOTE-001",
72
+ "Project connects the assistant to a remote MCP server",
73
+ "low",
74
+ "medium",
75
+ "ai-config",
76
+ "TC-03.2",
77
+ "An MCP configuration points the assistant at a remote server. Its tools and responses can steer "
78
+ "the assistant, and data from the session may be sent to it.",
79
+ )
80
+
81
+ MCP_CONFIG_FILES = (
82
+ ".mcp.json",
83
+ ".cursor/mcp.json",
84
+ ".vscode/mcp.json",
85
+ ".windsurf/mcp.json",
86
+ ".roo/mcp.json",
87
+ ".kiro/settings/mcp.json",
88
+ ".amazonq/mcp.json",
89
+ ".gemini/settings.json",
90
+ ".claude/settings.json",
91
+ ".claude/settings.local.json",
92
+ ".codeium/windsurf/mcp_config.json",
93
+ "mcp.json",
94
+ )
95
+
96
+ INSTRUCTION_PATTERNS = (
97
+ "CLAUDE.md",
98
+ "CLAUDE.local.md",
99
+ "AGENTS.md",
100
+ "GEMINI.md",
101
+ ".cursorrules",
102
+ ".windsurfrules",
103
+ ".clinerules",
104
+ "*.mdc",
105
+ "copilot-instructions.md",
106
+ "*.instructions.md",
107
+ "*.prompt.md",
108
+ "SKILL.md",
109
+ )
110
+ INSTRUCTION_DIRS = (
111
+ ".claude/",
112
+ ".cursor/rules/",
113
+ ".windsurf/rules/",
114
+ ".clinerules/",
115
+ ".github/instructions/",
116
+ ".github/prompts/",
117
+ ".roo/rules/",
118
+ ".kiro/steering/",
119
+ )
120
+
121
+ _BROAD_ALLOW = re.compile(
122
+ r"^(Bash|Bash\(\s*\*?\s*\)|Bash\(\s*(sh|bash|zsh|curl|wget|python3?|node|npx|"
123
+ r"powershell|pwsh)\s*(:\s*\*)?\s*\)|\*|WebFetch|mcp__\*)$"
124
+ )
125
+ _PUBLIC_HTTP = re.compile(r"^http://(?!localhost|127\.|\[::1\])", re.IGNORECASE)
126
+
127
+
128
+ def is_ai_instruction_file(rel: str) -> bool:
129
+ name = PurePosixPath(rel).name
130
+ padded = f"/{rel}"
131
+ if any(fnmatch.fnmatchcase(name, p) for p in INSTRUCTION_PATTERNS):
132
+ return True
133
+ return any(f"/{d}" in padded for d in INSTRUCTION_DIRS) and name.endswith((".md", ".mdc", ".txt"))
134
+
135
+
136
+ def _matches_config(rel: str) -> bool:
137
+ padded = f"/{rel}"
138
+ return any(padded.endswith("/" + c) for c in MCP_CONFIG_FILES)
139
+
140
+
141
+ def mcp_servers_findings(
142
+ servers: Node, fv: FileView, ctx: AnalysisContext, base: list[str], *, client: str
143
+ ) -> list[Finding]:
144
+ out: list[Finding] = []
145
+ for name, server in servers.items():
146
+ if not isinstance(server.value, dict):
147
+ continue
148
+ label = make_excerpt(name, limit=60)
149
+ cmd = command_text(server.get("command"))
150
+ if cmd:
151
+ full = " ".join([cmd, *args_text(server.get("args"))])
152
+ out.append(
153
+ build_finding(
154
+ R_MCP_SERVER.id,
155
+ [fv.evidence_at_line(server.line)],
156
+ detail=f"{client} server {label!r}: {make_excerpt(full, limit=160)}",
157
+ )
158
+ )
159
+ out += assess_and_trigger(
160
+ ctx,
161
+ fv,
162
+ full,
163
+ server.line,
164
+ kind="mcp-server",
165
+ label=name,
166
+ autorun=True,
167
+ context=f"MCP server {label!r} ({client})",
168
+ base_dirs=base,
169
+ )
170
+ for key in ("url", "serverUrl", "httpUrl"):
171
+ u = server.get(key)
172
+ if u is not None and isinstance(u.value, str):
173
+ sev = Severity.MEDIUM if _PUBLIC_HTTP.match(u.value) else None
174
+ out.append(
175
+ build_finding(
176
+ R_MCP_REMOTE.id,
177
+ [fv.evidence_at_line(u.line)],
178
+ severity=sev,
179
+ detail=f"{client} server {label!r} → {make_excerpt(u.value, limit=120)}",
180
+ )
181
+ )
182
+ if (env := server.get("env")) is not None:
183
+ out += env_findings(
184
+ ctx, fv, env_pairs_from_node(env), context=f"MCP server {label!r} env", autorun=True, base_dirs=base
185
+ )
186
+ return out
187
+
188
+
189
+ class AIAssistantConfigAnalyzer:
190
+ name = "ai-assistant-config"
191
+
192
+ def applies(self, fv: FileView) -> bool:
193
+ return fv.text is not None and _matches_config(fv.rel)
194
+
195
+ def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
196
+ out: list[Finding] = []
197
+ root = load_jsonc(fv, ctx, out)
198
+ if root is None or not isinstance(root.value, dict):
199
+ return out
200
+ base = [workspace_root_of(fv.rel)]
201
+ client = _client_name(fv.rel)
202
+ for key in ("mcpServers", "servers"):
203
+ if (servers := root.get(key)) is not None:
204
+ out += mcp_servers_findings(servers, fv, ctx, base, client=client)
205
+ if fv.rel.endswith((".claude/settings.json", ".claude/settings.local.json")):
206
+ out += self._claude(root, fv, ctx, base)
207
+ if fv.rel.endswith(".gemini/settings.json"):
208
+ out += self._gemini(root, fv)
209
+ return out
210
+
211
+ def _claude(self, root: Node, fv: FileView, ctx: AnalysisContext, base: list[str]) -> list[Finding]:
212
+ out: list[Finding] = []
213
+ hooks = root.get("hooks")
214
+ for event, groups in hooks.items() if hooks else []:
215
+ for group in groups.elements():
216
+ for h in group.list_at("hooks"):
217
+ cmd = command_text(h.get("command"))
218
+ if cmd:
219
+ out += self._claude_command(fv, ctx, base, cmd, h.line, f"hook {event}")
220
+ for key in ("statusLine", "fileSuggestion"):
221
+ node = root.get(key)
222
+ if node is not None and (cmd := command_text(node.get("command"))):
223
+ out += self._claude_command(fv, ctx, base, cmd, node.line, key)
224
+ for key in ("apiKeyHelper", "awsAuthRefresh", "awsCredentialExport", "otelHeadersHelper"):
225
+ node = root.get(key)
226
+ if node is not None and (cmd := command_text(node)):
227
+ out += self._claude_command(fv, ctx, base, cmd, node.line, key)
228
+ perms = root.get("permissions")
229
+ if perms is not None:
230
+ allow = perms.get("allow")
231
+ broad = [
232
+ e.value
233
+ for e in (allow.elements() if allow else [])
234
+ if isinstance(e.value, str) and _BROAD_ALLOW.match(e.value.strip())
235
+ ]
236
+ if broad:
237
+ out.append(
238
+ build_finding(
239
+ R_PERMS.id,
240
+ [fv.evidence_at_line(allow.line if allow else None)],
241
+ detail=f"Allowed without prompting: {', '.join(broad[:8])}.",
242
+ )
243
+ )
244
+ mode = perms.get("defaultMode")
245
+ if mode is not None and mode.value == "bypassPermissions":
246
+ out.append(
247
+ build_finding(
248
+ R_PERMS.id,
249
+ [fv.evidence_at_line(mode.line)],
250
+ severity=Severity.HIGH,
251
+ detail="defaultMode is bypassPermissions.",
252
+ )
253
+ )
254
+ for key in ("enableAllProjectMcpServers", "enabledMcpjsonServers"):
255
+ node = root.get(key)
256
+ if node is not None and node.value not in (False, None, []):
257
+ out.append(build_finding(R_MCP_AUTO.id, [fv.evidence_at_line(node.line)], detail=f"{key} is set."))
258
+ if (env := root.get("env")) is not None:
259
+ out += env_findings(
260
+ ctx, fv, env_pairs_from_node(env), context="Claude Code settings env", autorun=True, base_dirs=base
261
+ )
262
+ return out
263
+
264
+ def _claude_command(
265
+ self, fv: FileView, ctx: AnalysisContext, base: list[str], cmd: str, line: int | None, what: str
266
+ ) -> list[Finding]:
267
+ out = [build_finding(R_HOOK.id, [fv.evidence_at_line(line)], detail=f"{what}: {make_excerpt(cmd, limit=160)}")]
268
+ out += assess_and_trigger(
269
+ ctx,
270
+ fv,
271
+ cmd,
272
+ line,
273
+ kind="claude-hook",
274
+ label=what,
275
+ autorun=True,
276
+ context=f"Claude Code {what}",
277
+ base_dirs=base,
278
+ )
279
+ return out
280
+
281
+ def _gemini(self, root: Node, fv: FileView) -> list[Finding]:
282
+ out: list[Finding] = []
283
+ for key in ("autoAccept", "yolo"):
284
+ node = root.get(key)
285
+ if node is not None and node.value is True:
286
+ out.append(build_finding(R_PERMS.id, [fv.evidence_at_line(node.line)], detail=f"{key} is true."))
287
+ tools = root.get("tools")
288
+ if tools is not None and (allowed := tools.get("allowed")) is not None:
289
+ broad = [
290
+ e.value
291
+ for e in allowed.elements()
292
+ if isinstance(e.value, str) and re.match(r"^run_shell_command(\(\s*\))?$", e.value)
293
+ ]
294
+ if broad:
295
+ out.append(
296
+ build_finding(
297
+ R_PERMS.id,
298
+ [fv.evidence_at_line(allowed.line)],
299
+ detail=f"Allowed without prompting: {', '.join(broad)}.",
300
+ )
301
+ )
302
+ return out
303
+
304
+
305
+ def _client_name(rel: str) -> str:
306
+ for marker, name in (
307
+ (".cursor/", "Cursor"),
308
+ (".vscode/", "VS Code"),
309
+ (".gemini/", "Gemini CLI"),
310
+ (".windsurf/", "Windsurf"),
311
+ (".roo/", "Roo"),
312
+ (".kiro/", "Kiro"),
313
+ (".amazonq/", "Amazon Q"),
314
+ (".claude/", "Claude Code"),
315
+ ):
316
+ if marker in f"/{rel}":
317
+ return name
318
+ return "MCP client (.mcp.json)"