vigil-codeintel 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_codeintel-0.1.0.dist-info/METADATA +780 -0
- vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
- vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
- vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
- vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
- vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
- vigil_forensic/__init__.py +224 -0
- vigil_forensic/_git_utils.py +178 -0
- vigil_forensic/_shared.py +510 -0
- vigil_forensic/_stubs.py +156 -0
- vigil_forensic/gate_checks/__init__.py +1 -0
- vigil_forensic/gate_checks/_ast_helpers.py +629 -0
- vigil_forensic/gate_checks/_deployment_detector.py +573 -0
- vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
- vigil_forensic/gate_checks/authority_checks.py +95 -0
- vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
- vigil_forensic/gate_checks/broad_except_checks.py +301 -0
- vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
- vigil_forensic/gate_checks/common.py +253 -0
- vigil_forensic/gate_checks/config_safety_checks.py +704 -0
- vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
- vigil_forensic/gate_checks/conflict_checks.py +193 -0
- vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
- vigil_forensic/gate_checks/context_health_checks.py +289 -0
- vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
- vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
- vigil_forensic/gate_checks/duplication_checks.py +387 -0
- vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
- vigil_forensic/gate_checks/empty_output_checks.py +87 -0
- vigil_forensic/gate_checks/encoding_checks.py +847 -0
- vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
- vigil_forensic/gate_checks/fallback_checks.py +41 -0
- vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
- vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
- vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
- vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
- vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
- vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
- vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
- vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
- vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
- vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
- vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
- vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
- vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
- vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
- vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
- vigil_forensic/gate_checks/hallucination_checks.py +566 -0
- vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
- vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
- vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
- vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
- vigil_forensic/gate_checks/ml_checks.py +318 -0
- vigil_forensic/gate_checks/performance_checks.py +106 -0
- vigil_forensic/gate_checks/project_specific_runner.py +691 -0
- vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
- vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
- vigil_forensic/gate_checks/reliability_checks.py +389 -0
- vigil_forensic/gate_checks/reporting_checks.py +55 -0
- vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
- vigil_forensic/gate_checks/security_injection_checks.py +332 -0
- vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
- vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
- vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
- vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
- vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
- vigil_forensic/gate_checks/test_quality_checks.py +946 -0
- vigil_forensic/gate_checks/testing_checks.py +149 -0
- vigil_forensic/gate_checks/toctou_checks.py +367 -0
- vigil_forensic/gate_checks/type_checking_checks.py +316 -0
- vigil_forensic/gate_models.py +392 -0
- vigil_forensic/gate_packs/__init__.py +1 -0
- vigil_forensic/gate_packs/universal.py +179 -0
- vigil_forensic/gate_profile.json +31 -0
- vigil_forensic/gate_registry.py +21 -0
- vigil_forensic/language_profiles.py +219 -0
- vigil_forensic/meta_findings.py +207 -0
- vigil_forensic/self_audit.py +725 -0
- vigil_forensic/source_analysis.py +175 -0
- vigil_mapper/__init__.py +103 -0
- vigil_mapper/_ast_helpers_minimal.py +229 -0
- vigil_mapper/_extract_imports_impl.py +123 -0
- vigil_mapper/_file_count_guard.py +129 -0
- vigil_mapper/_git_utils.py +178 -0
- vigil_mapper/_runtime_ast.py +438 -0
- vigil_mapper/_runtime_dispatch.py +137 -0
- vigil_mapper/_seed_helpers.py +82 -0
- vigil_mapper/authority_builder.py +1102 -0
- vigil_mapper/cli_entry.py +731 -0
- vigil_mapper/conflict_builder.py +818 -0
- vigil_mapper/data_contract_builder.py +446 -0
- vigil_mapper/findings_builder.py +716 -0
- vigil_mapper/fingerprint.py +53 -0
- vigil_mapper/hotspot_builder.py +539 -0
- vigil_mapper/map_common.py +449 -0
- vigil_mapper/map_errors.py +55 -0
- vigil_mapper/map_models.py +431 -0
- vigil_mapper/map_models_ext.py +206 -0
- vigil_mapper/map_models_findings.py +130 -0
- vigil_mapper/map_storage.py +455 -0
- vigil_mapper/parse_cache.py +795 -0
- vigil_mapper/refactor_boundary_builder.py +266 -0
- vigil_mapper/runtime_builder.py +527 -0
- vigil_mapper/runtime_tracer.py +243 -0
- vigil_mapper/runtime_tracer_entry.py +199 -0
- vigil_mapper/semantic_diff.py +71 -0
- vigil_mapper/source_adapters/__init__.py +109 -0
- vigil_mapper/source_adapters/_base.py +264 -0
- vigil_mapper/source_adapters/_ir.py +156 -0
- vigil_mapper/source_adapters/_lexer.py +309 -0
- vigil_mapper/source_adapters/_patterns.py +212 -0
- vigil_mapper/source_adapters/_treesitter.py +182 -0
- vigil_mapper/source_adapters/go.py +553 -0
- vigil_mapper/source_adapters/java.py +541 -0
- vigil_mapper/source_adapters/javascript.py +626 -0
- vigil_mapper/source_adapters/python.py +325 -0
- vigil_mapper/source_adapters/typescript.py +749 -0
- vigil_mapper/structural_builder.py +586 -0
- vigil_mcp/__init__.py +1 -0
- vigil_mcp/_jobs.py +587 -0
- vigil_mcp/_paths.py +93 -0
- vigil_mcp/forensic_server.py +419 -0
- vigil_mcp/map_server.py +452 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""FastMCP stdio server: forensic-audit
|
|
2
|
+
|
|
3
|
+
Wraps vigil_forensic.run_forensic_audit behind a background-job poll API.
|
|
4
|
+
Resource constraints:
|
|
5
|
+
- At most 2 concurrent jobs (enforced by _jobs.JobRegistry).
|
|
6
|
+
- One thread per job (no pool).
|
|
7
|
+
- run_forensic_audit already enforces workers=1 internally (verified in source).
|
|
8
|
+
- Output truncated/paginated to OUTPUT_CHAR_LIMIT chars (~25 k tokens budget).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
|
|
19
|
+
from vigil_mcp import _jobs
|
|
20
|
+
from vigil_mcp import _paths
|
|
21
|
+
from vigil_forensic import run_forensic_audit
|
|
22
|
+
|
|
23
|
+
_INSTRUCTIONS = """\
|
|
24
|
+
forensic-audit - static code-quality forensic auditor. Finds real bugs, swallowed
|
|
25
|
+
exceptions, security issues, oversized/over-nested code, and cross-file duplication
|
|
26
|
+
across Python/Go/Java/JS/TS. Pure static analysis (tree-sitter/AST) - it never runs
|
|
27
|
+
the project or its tests.
|
|
28
|
+
|
|
29
|
+
WHEN TO USE: when the user asks to audit a project, review code quality, or find
|
|
30
|
+
problems/bugs/smells in a codebase or a set of changes - before committing or merging.
|
|
31
|
+
Not for running tests (it doesn't execute code).
|
|
32
|
+
|
|
33
|
+
WORKFLOW (background job + poll - do not expect an instant answer):
|
|
34
|
+
1. start_forensic_audit(path="") -> leave path empty to auto-detect the project
|
|
35
|
+
root from the current directory; returns {job_id, resolved_path}.
|
|
36
|
+
2. get_forensic_status(job_id) -> poll until status == "done" (usually seconds).
|
|
37
|
+
3. get_forensic_results(job_id) -> returns a COMPACT SUMMARY by default
|
|
38
|
+
(counts by severity + by check_id + top findings). Read this FIRST; it is sized
|
|
39
|
+
to fit the context budget (~3k tokens), so prefer it over the full list.
|
|
40
|
+
4. Only if needed: get_forensic_results(job_id, view="full", severity="HIGH")
|
|
41
|
+
or check_id="..." to drill into specific findings (paginated).
|
|
42
|
+
|
|
43
|
+
INTERPRETING: exit_code 0 = clean, 1 = high/critical findings exist, 2 = error.
|
|
44
|
+
Triage HIGH first. On clean third-party code most findings are size.* (large files)
|
|
45
|
+
and broad_except (real `except: pass` swallows).
|
|
46
|
+
|
|
47
|
+
HUGE REPOS (anti-hang): if the collected file count exceeds max_files (default 800),
|
|
48
|
+
the audit is SKIPPED and the result has meta.skipped_reason="too_many_files" with
|
|
49
|
+
top_subdirs + a suggestion - scan a submodule (start_forensic_audit(path='<dir>/<subdir>'))
|
|
50
|
+
or pass a larger max_files to force a full scan.
|
|
51
|
+
|
|
52
|
+
TUNING (enable / disable checks):
|
|
53
|
+
- DISABLE noisy gates for a project: create <project>/.cortex/disabled_gates.json
|
|
54
|
+
= ["gate_id", ...]; those gates never run (reported in meta.gates_skipped).
|
|
55
|
+
- RUN ONLY specific gates: pass gates="gate_id1,gate_id2" (comma-separated) -
|
|
56
|
+
everything else is skipped. Empty = run all applicable gates.
|
|
57
|
+
- ENABLE an opt-in heuristic gate (e.g. god_object_zones, OFF by default because
|
|
58
|
+
noisy): name it in gates=, e.g. gates="god_object_zones".
|
|
59
|
+
- RAISE the severity floor: severity="HIGH" keeps only HIGH/CRITICAL findings.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
mcp = FastMCP("forensic-audit", instructions=_INSTRUCTIONS)
|
|
63
|
+
|
|
64
|
+
# ~80 k chars keeps well under the 25 k token MCP output limit.
|
|
65
|
+
OUTPUT_CHAR_LIMIT = 80_000
|
|
66
|
+
|
|
67
|
+
# Severity ordering, highest first, used to pick the "top" findings bucket.
|
|
68
|
+
_SEVERITY_ORDER = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO")
|
|
69
|
+
|
|
70
|
+
# Caps for the compact summary view (keep JSON well under the MCP budget).
|
|
71
|
+
_TOP_FINDINGS_CAP = 20
|
|
72
|
+
_BY_CHECK_ID_CAP = 25
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Summary builder (Feature 1 - summary-first forensic results)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _finding_location(f: dict) -> tuple[Any, Any]:
|
|
80
|
+
"""Best-effort (file, line) for a finding across both schemas.
|
|
81
|
+
|
|
82
|
+
Synthetic/test findings carry flat ``file``/``line`` keys; real
|
|
83
|
+
vigil_forensic findings instead carry an ``evidence`` list of
|
|
84
|
+
``{"kind": "file", "path": ..., "detail": "line:N"}``. Prefer the flat
|
|
85
|
+
keys, fall back to the first file-evidence entry.
|
|
86
|
+
"""
|
|
87
|
+
file = f.get("file")
|
|
88
|
+
line = f.get("line")
|
|
89
|
+
if file is None or line is None:
|
|
90
|
+
for ev in f.get("evidence") or []:
|
|
91
|
+
if not isinstance(ev, dict):
|
|
92
|
+
continue
|
|
93
|
+
if file is None and ev.get("path"):
|
|
94
|
+
file = ev.get("path")
|
|
95
|
+
if line is None:
|
|
96
|
+
detail = str(ev.get("detail", ""))
|
|
97
|
+
if detail.startswith("line:"):
|
|
98
|
+
suffix = detail.split("line:", 1)[1].strip()
|
|
99
|
+
line = int(suffix) if suffix.isdigit() else suffix or None
|
|
100
|
+
if file is not None and line is not None:
|
|
101
|
+
break
|
|
102
|
+
return file, line
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _compact_finding(f: dict) -> dict:
|
|
106
|
+
"""Project a finding down to the compact fields shown in the summary.
|
|
107
|
+
|
|
108
|
+
Works for both the flat test schema (``file``/``line``/``message``) and
|
|
109
|
+
the real forensic schema (``evidence``/``summary``/``title``).
|
|
110
|
+
"""
|
|
111
|
+
file, line = _finding_location(f)
|
|
112
|
+
message = f.get("message") or f.get("summary") or f.get("title")
|
|
113
|
+
return {
|
|
114
|
+
"check_id": f.get("check_id"),
|
|
115
|
+
"severity": f.get("severity"),
|
|
116
|
+
"file": file,
|
|
117
|
+
"line": line,
|
|
118
|
+
"message": message,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _build_forensic_summary(result: dict) -> dict:
|
|
123
|
+
"""Build a compact, context-budget-friendly summary of an audit result.
|
|
124
|
+
|
|
125
|
+
Instead of every finding, returns total counts, a per-severity breakdown,
|
|
126
|
+
a per-check_id breakdown (top ``_BY_CHECK_ID_CAP`` by count) and the top
|
|
127
|
+
``_TOP_FINDINGS_CAP`` findings drawn from the highest severity present.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
result: The raw ``run_forensic_audit`` result dict (``findings``,
|
|
131
|
+
``exit_code``, ``meta``, ``errors``).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
A dict with keys: ``total``, ``exit_code``, ``by_severity``,
|
|
135
|
+
``by_check_id``, ``top_findings``, ``meta``, ``errors``, ``hint``.
|
|
136
|
+
"""
|
|
137
|
+
findings = result.get("findings") or []
|
|
138
|
+
total = len(findings)
|
|
139
|
+
|
|
140
|
+
# by_severity: lowercase keys so counts are stable regardless of input case.
|
|
141
|
+
sev_counter: Counter[str] = Counter()
|
|
142
|
+
for f in findings:
|
|
143
|
+
sev = str(f.get("severity", "")).upper()
|
|
144
|
+
sev_counter[sev] += 1
|
|
145
|
+
by_severity = {
|
|
146
|
+
"high": sev_counter.get("HIGH", 0) + sev_counter.get("CRITICAL", 0),
|
|
147
|
+
"medium": sev_counter.get("MEDIUM", 0),
|
|
148
|
+
"low": sev_counter.get("LOW", 0) + sev_counter.get("INFO", 0),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# by_check_id: top N check ids by count.
|
|
152
|
+
check_counter: Counter[str] = Counter(
|
|
153
|
+
str(f.get("check_id", "unknown")) for f in findings
|
|
154
|
+
)
|
|
155
|
+
by_check_id = dict(check_counter.most_common(_BY_CHECK_ID_CAP))
|
|
156
|
+
|
|
157
|
+
# top_findings: drawn from the highest severity actually present.
|
|
158
|
+
top_severity: str | None = None
|
|
159
|
+
for sev in _SEVERITY_ORDER:
|
|
160
|
+
if sev_counter.get(sev, 0) > 0:
|
|
161
|
+
top_severity = sev
|
|
162
|
+
break
|
|
163
|
+
top_findings: list[dict] = []
|
|
164
|
+
if top_severity is not None:
|
|
165
|
+
for f in findings:
|
|
166
|
+
if str(f.get("severity", "")).upper() == top_severity:
|
|
167
|
+
top_findings.append(_compact_finding(f))
|
|
168
|
+
if len(top_findings) >= _TOP_FINDINGS_CAP:
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
hint = (
|
|
172
|
+
"Compact summary. For the full finding list call get_forensic_results "
|
|
173
|
+
"with view='full' (supports severity= and check_id= filters + paging)."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"total": total,
|
|
178
|
+
"exit_code": result.get("exit_code", 2),
|
|
179
|
+
"by_severity": by_severity,
|
|
180
|
+
"by_check_id": by_check_id,
|
|
181
|
+
"top_findings": top_findings,
|
|
182
|
+
# `findings` mirrors top_findings (the compact subset actually shown in
|
|
183
|
+
# the summary). Present so summary payloads still expose a findings
|
|
184
|
+
# list; for the complete, unbounded list use view='full'.
|
|
185
|
+
"findings": top_findings,
|
|
186
|
+
"meta": result.get("meta") or {},
|
|
187
|
+
"errors": result.get("errors") or [],
|
|
188
|
+
"hint": hint,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Serialisation / truncation helpers
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def _paginate_json(data: Any, page: int, page_size_chars: int) -> dict:
|
|
197
|
+
"""Serialise *data* to JSON and return a page slice with metadata."""
|
|
198
|
+
full_json = json.dumps(data, default=str, indent=2)
|
|
199
|
+
total = len(full_json)
|
|
200
|
+
start_char = page * page_size_chars
|
|
201
|
+
end_char = start_char + page_size_chars
|
|
202
|
+
return {
|
|
203
|
+
"payload": full_json[start_char:end_char],
|
|
204
|
+
"truncated": end_char < total,
|
|
205
|
+
"total_chars": total,
|
|
206
|
+
"page": page,
|
|
207
|
+
"total_pages": (total + page_size_chars - 1) // page_size_chars,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _cap_findings(result: dict, max_findings: int = 200) -> dict:
|
|
212
|
+
"""Cap the findings list to avoid unbounded blobs.
|
|
213
|
+
|
|
214
|
+
Adds "findings_truncated" and "total_findings_before_cap" to meta when
|
|
215
|
+
the list was cut.
|
|
216
|
+
"""
|
|
217
|
+
findings = result.get("findings", [])
|
|
218
|
+
if len(findings) <= max_findings:
|
|
219
|
+
return result
|
|
220
|
+
result = dict(result)
|
|
221
|
+
result["findings"] = findings[:max_findings]
|
|
222
|
+
meta = dict(result.get("meta") or {})
|
|
223
|
+
meta["findings_truncated"] = True
|
|
224
|
+
meta["total_findings_before_cap"] = len(findings)
|
|
225
|
+
result["meta"] = meta
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# MCP tools
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
@mcp.tool()
|
|
234
|
+
def start_forensic_audit(
|
|
235
|
+
path: str = "",
|
|
236
|
+
gates: str = "",
|
|
237
|
+
severity: str = "LOW",
|
|
238
|
+
all_languages: bool = True,
|
|
239
|
+
max_files: int = 800,
|
|
240
|
+
) -> dict:
|
|
241
|
+
"""Start a background forensic audit job for the given project path.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
path: Absolute path to the project root directory. When
|
|
245
|
+
empty/omitted the project root is auto-detected by
|
|
246
|
+
walking up from the current working directory looking
|
|
247
|
+
for a ``.git`` / ``pyproject.toml`` / ``package.json``
|
|
248
|
+
marker (falling back to cwd). The chosen directory is
|
|
249
|
+
returned as ``resolved_path``.
|
|
250
|
+
gates: Comma-separated list of gate check_ids to run.
|
|
251
|
+
Empty string means run all applicable gates.
|
|
252
|
+
severity: Minimum severity to include: LOW | MEDIUM | HIGH | CRITICAL.
|
|
253
|
+
all_languages: Reserved; currently always True.
|
|
254
|
+
max_files: Anti-hang ceiling on the collected source-file count
|
|
255
|
+
(default 800). Above it the audit is SKIPPED (gates do
|
|
256
|
+
NOT run) and get_forensic_results reports
|
|
257
|
+
meta.skipped_reason="too_many_files" with top_subdirs +
|
|
258
|
+
a suggestion to scan a submodule; raise it to force a full
|
|
259
|
+
scan of a huge repo.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
{"job_id": str | None, "status": "running" | "busy",
|
|
263
|
+
"resolved_path": str, ...}
|
|
264
|
+
When status is "busy", retry later - the server is at max concurrent jobs.
|
|
265
|
+
|
|
266
|
+
Resource note:
|
|
267
|
+
run_forensic_audit always uses workers=1 internally. This server
|
|
268
|
+
enforces an additional cap of 2 concurrent jobs.
|
|
269
|
+
"""
|
|
270
|
+
# Auto-target: resolve only when no explicit path was given.
|
|
271
|
+
if path:
|
|
272
|
+
resolved_path = path
|
|
273
|
+
else:
|
|
274
|
+
resolved_path = _paths._resolve_project_root(None)
|
|
275
|
+
|
|
276
|
+
gates_list = [g.strip() for g in gates.split(",") if g.strip()] if gates else None
|
|
277
|
+
|
|
278
|
+
def _run() -> dict:
|
|
279
|
+
# run_forensic_audit uses workers=1 internally (verified in source).
|
|
280
|
+
# No additional workers parameter is accepted.
|
|
281
|
+
return run_forensic_audit(
|
|
282
|
+
Path(resolved_path),
|
|
283
|
+
gates=gates_list,
|
|
284
|
+
severity=severity,
|
|
285
|
+
all_languages=all_languages,
|
|
286
|
+
max_files=max_files,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# project_dir enables disk-backed persistence so results survive a server
|
|
290
|
+
# restart; get_forensic_status/results then resolve by job_id from disk.
|
|
291
|
+
started = _jobs.start(_run, project_dir=resolved_path)
|
|
292
|
+
started["resolved_path"] = resolved_path
|
|
293
|
+
return started
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@mcp.tool()
|
|
297
|
+
def get_forensic_status(job_id: str) -> dict:
|
|
298
|
+
"""Poll the status of a forensic audit job.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
job_id: Job ID returned by start_forensic_audit.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
{"job_id": str, "status": "running" | "done" | "error" | "cancelled" | "not_found"}
|
|
305
|
+
"""
|
|
306
|
+
return _jobs.status(job_id)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@mcp.tool()
|
|
310
|
+
def get_forensic_results(
|
|
311
|
+
job_id: str,
|
|
312
|
+
view: str = "summary",
|
|
313
|
+
severity: str = "",
|
|
314
|
+
check_id: str = "",
|
|
315
|
+
page: int = 0,
|
|
316
|
+
page_size_chars: int = OUTPUT_CHAR_LIMIT,
|
|
317
|
+
max_findings: int = 200,
|
|
318
|
+
) -> dict:
|
|
319
|
+
"""Retrieve results of a completed forensic audit.
|
|
320
|
+
|
|
321
|
+
Two views:
|
|
322
|
+
* ``view='summary'`` (default) - a compact summary (total counts,
|
|
323
|
+
by_severity, by_check_id, top HIGH findings) that fits comfortably
|
|
324
|
+
in the MCP context budget. Use this first.
|
|
325
|
+
* ``view='full'`` - the full findings list, capped and paginated.
|
|
326
|
+
Supports ``severity=`` and ``check_id=`` filters to drill in.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
job_id: Job ID returned by start_forensic_audit.
|
|
330
|
+
view: "summary" (default) or "full".
|
|
331
|
+
severity: (full view) keep only findings of this severity, e.g.
|
|
332
|
+
"HIGH". Empty = no filter.
|
|
333
|
+
check_id: (full view) keep only findings with this check_id.
|
|
334
|
+
Empty = no filter.
|
|
335
|
+
page: Zero-based page index (full view).
|
|
336
|
+
page_size_chars: Max chars per page (default 80 000 ≈ 25 k tokens).
|
|
337
|
+
max_findings: Cap on the findings list before pagination (default 200).
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
dict with keys:
|
|
341
|
+
"job_id", "status", "view",
|
|
342
|
+
"exit_code" (0=clean, 1=high/critical findings, 2=error),
|
|
343
|
+
"payload" (JSON string - summary dict or full result),
|
|
344
|
+
"truncated" (bool), "total_chars", "page", "total_pages".
|
|
345
|
+
"""
|
|
346
|
+
r = _jobs.result(job_id)
|
|
347
|
+
status = r.get("status")
|
|
348
|
+
|
|
349
|
+
if status in ("running", "not_found"):
|
|
350
|
+
return {"job_id": job_id, "status": status, "payload": None,
|
|
351
|
+
"truncated": False, "total_chars": 0}
|
|
352
|
+
|
|
353
|
+
if status == "cancelled":
|
|
354
|
+
return {"job_id": job_id, "status": "cancelled", "payload": None,
|
|
355
|
+
"truncated": False, "total_chars": 0}
|
|
356
|
+
|
|
357
|
+
if status == "error":
|
|
358
|
+
return {"job_id": job_id, "status": "error",
|
|
359
|
+
"error": r.get("error"), "payload": None,
|
|
360
|
+
"truncated": False, "total_chars": 0}
|
|
361
|
+
|
|
362
|
+
# status == "done"
|
|
363
|
+
audit_result = r.get("result") or {}
|
|
364
|
+
if not isinstance(audit_result, dict):
|
|
365
|
+
audit_result = {}
|
|
366
|
+
exit_code = audit_result.get("exit_code", 2)
|
|
367
|
+
|
|
368
|
+
if view == "full":
|
|
369
|
+
# Apply optional severity / check_id filters before capping.
|
|
370
|
+
findings = audit_result.get("findings") or []
|
|
371
|
+
if severity:
|
|
372
|
+
sev_u = severity.upper()
|
|
373
|
+
findings = [f for f in findings if str(f.get("severity", "")).upper() == sev_u]
|
|
374
|
+
if check_id:
|
|
375
|
+
findings = [f for f in findings if f.get("check_id") == check_id]
|
|
376
|
+
filtered = dict(audit_result)
|
|
377
|
+
filtered["findings"] = findings
|
|
378
|
+
|
|
379
|
+
capped = _cap_findings(filtered, max_findings=max_findings)
|
|
380
|
+
page_data = _paginate_json(capped, page=page, page_size_chars=page_size_chars)
|
|
381
|
+
return {
|
|
382
|
+
"job_id": job_id,
|
|
383
|
+
"status": "done",
|
|
384
|
+
"view": "full",
|
|
385
|
+
"exit_code": exit_code,
|
|
386
|
+
**page_data,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# Default: compact summary view.
|
|
390
|
+
summary = _build_forensic_summary(audit_result)
|
|
391
|
+
page_data = _paginate_json(summary, page=page, page_size_chars=page_size_chars)
|
|
392
|
+
return {
|
|
393
|
+
"job_id": job_id,
|
|
394
|
+
"status": "done",
|
|
395
|
+
"view": "summary",
|
|
396
|
+
"exit_code": exit_code,
|
|
397
|
+
**page_data,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@mcp.tool()
|
|
402
|
+
def cancel_forensic_audit(job_id: str) -> dict:
|
|
403
|
+
"""Cancel a running forensic audit job.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
job_id: Job ID returned by start_forensic_audit.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
{"job_id": str, "cancelled": bool, ...}
|
|
410
|
+
"""
|
|
411
|
+
return _jobs.cancel(job_id)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def main() -> None:
|
|
415
|
+
mcp.run()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
main()
|