@hoanghainh/komagu 2.6.1 → 2.7.0

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.
@@ -0,0 +1,464 @@
1
+ """MCP tools that wrap local Komagu analysis. None of them apply patches or call an LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Dict, List, Optional, Tuple
11
+
12
+ from operate.devsecops.leak_check import CanaryFileError, load_canaries, scan_paths
13
+ from operate.inventory.api_inventory import (
14
+ diff_api_inventory,
15
+ load_code_routes,
16
+ load_openapi_spec,
17
+ load_traffic_log,
18
+ )
19
+ from operate.remediator.runner import RemediationRunner
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parents[2]
22
+ BIN_KOMAGU = REPO_ROOT / "bin" / "komagu"
23
+
24
+ LIST_CAP = 40
25
+ DIFF_CAP = 1200
26
+ STDERR_CAP = 2000
27
+ MAX_PATHS = 32
28
+ SCAN_TIMEOUT_MAX = 600
29
+ LANGS = ("en", "ja", "all")
30
+ FORMATS = ("md", "html", "sarif", "all")
31
+
32
+ INSTRUCTIONS = (
33
+ "Komagu tools run on this machine and do not call an LLM. "
34
+ "scan writes a Tier 1 audit under out (no --headless-ai). "
35
+ "read_audit summarizes an existing audit directory. "
36
+ "leak_check looks for secrets in audit output and returns masked hits. "
37
+ "inventory compares code routes with an OpenAPI document. "
38
+ "fix_preview returns a dry-run remediation plan and never modifies source. "
39
+ "Applying patches, starting the edge gateway, and live cross-user tests stay on the komagu CLI."
40
+ )
41
+
42
+
43
+ class ToolError(Exception):
44
+ """A tool argument or expected input problem the caller can correct."""
45
+
46
+
47
+ class ToolSpec:
48
+ """One advertised MCP tool."""
49
+
50
+ def __init__(
51
+ self,
52
+ name: str,
53
+ description: str,
54
+ input_schema: Dict[str, Any],
55
+ handler: Callable[[Dict[str, Any]], Dict[str, Any]],
56
+ read_only: bool,
57
+ ) -> None:
58
+ self.name = name
59
+ self.description = description
60
+ self.input_schema = input_schema
61
+ self.handler = handler
62
+ self.read_only = read_only
63
+
64
+ def descriptor(self) -> Dict[str, Any]:
65
+ """MCP tools/list entry."""
66
+ return {
67
+ "name": self.name,
68
+ "description": self.description,
69
+ "inputSchema": self.input_schema,
70
+ "annotations": {
71
+ "readOnlyHint": self.read_only,
72
+ "destructiveHint": False,
73
+ "openWorldHint": False,
74
+ },
75
+ }
76
+
77
+
78
+ class ToolRegistry:
79
+ """Name lookup for the tools this server exposes."""
80
+
81
+ def __init__(self, tools: List[ToolSpec]) -> None:
82
+ self._tools = {tool.name: tool for tool in tools}
83
+
84
+ def descriptors(self) -> List[Dict[str, Any]]:
85
+ """Tools in a stable order."""
86
+ return [self._tools[name].descriptor() for name in sorted(self._tools)]
87
+
88
+ def call(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
89
+ """Run one tool. Unknown names raise KeyError."""
90
+ tool = self._tools.get(name)
91
+ if tool is None:
92
+ raise KeyError(name)
93
+ return tool.handler(arguments)
94
+
95
+
96
+ def build_registry() -> ToolRegistry:
97
+ """The five local analysis tools."""
98
+ return ToolRegistry(
99
+ [
100
+ ToolSpec(
101
+ "scan",
102
+ "Run a Tier 1 Komagu audit of a local directory and write reports under out. "
103
+ "Does not call a model. Check exitCode: 0 finished, 2 the target or arguments were rejected. "
104
+ "The audit summary lists finding ids, categories, and locations, not source snippets.",
105
+ {
106
+ "type": "object",
107
+ "properties": {
108
+ "target": {"type": "string", "description": "Directory to audit."},
109
+ "out": {"type": "string", "description": "Directory for audit artifacts. Created if needed."},
110
+ "lang": {
111
+ "type": "string",
112
+ "enum": list(LANGS),
113
+ "description": "Report language when format renders Markdown or HTML. Default en.",
114
+ },
115
+ "format": {
116
+ "type": "string",
117
+ "enum": list(FORMATS),
118
+ "description": "md, html, sarif, or all. Default sarif.",
119
+ },
120
+ "timeout_seconds": {
121
+ "type": "integer",
122
+ "minimum": 1,
123
+ "maximum": SCAN_TIMEOUT_MAX,
124
+ "description": "Scan process timeout. Default 180.",
125
+ },
126
+ },
127
+ "required": ["target", "out"],
128
+ "additionalProperties": False,
129
+ },
130
+ tool_scan,
131
+ read_only=False,
132
+ ),
133
+ ToolSpec(
134
+ "read_audit",
135
+ "Summarize an existing Komagu audit directory (stack profile, coverage, finding metadata). "
136
+ "Does not include source snippets.",
137
+ {
138
+ "type": "object",
139
+ "properties": {
140
+ "audit_dir": {"type": "string", "description": "Directory that contains findings.json."},
141
+ },
142
+ "required": ["audit_dir"],
143
+ "additionalProperties": False,
144
+ },
145
+ tool_read_audit,
146
+ read_only=True,
147
+ ),
148
+ ToolSpec(
149
+ "leak_check",
150
+ "Scan audit output for secrets and client canary strings. Hit values are masked. "
151
+ "exitCode 0 is clean, 1 is a suspected leak, 2 means the scan was incomplete.",
152
+ {
153
+ "type": "object",
154
+ "properties": {
155
+ "paths": {
156
+ "type": "array",
157
+ "items": {"type": "string"},
158
+ "description": "Files or directories to scan. At most 32.",
159
+ },
160
+ "canary_file": {
161
+ "type": "string",
162
+ "description": "Optional UTF-8 file with one canary string per line, kept outside the repository.",
163
+ },
164
+ },
165
+ "required": ["paths"],
166
+ "additionalProperties": False,
167
+ },
168
+ tool_leak_check,
169
+ read_only=True,
170
+ ),
171
+ ToolSpec(
172
+ "inventory",
173
+ "Compare code routes with an OpenAPI document and an optional gateway log. "
174
+ "Returns shadow, zombie, missing, and dormant counts. Does not write a file.",
175
+ {
176
+ "type": "object",
177
+ "properties": {
178
+ "routes": {"type": "string", "description": "LSA routes JSON file."},
179
+ "openapi": {"type": "string", "description": "OpenAPI JSON or YAML file."},
180
+ "traffic_log": {"type": "string", "description": "Optional Kong JSON-lines or NGINX combined log."},
181
+ },
182
+ "required": ["routes", "openapi"],
183
+ "additionalProperties": False,
184
+ },
185
+ tool_inventory,
186
+ read_only=True,
187
+ ),
188
+ ToolSpec(
189
+ "fix_preview",
190
+ "Dry-run remediation plan for findings.json. Never writes source or a patch file. "
191
+ "appliedCount is always 0. Pass findings after scan, or point target at a tree that already has findings.json.",
192
+ {
193
+ "type": "object",
194
+ "properties": {
195
+ "target": {"type": "string", "description": "Code directory the findings refer to."},
196
+ "findings": {"type": "string", "description": "Optional findings.json or SARIF file."},
197
+ },
198
+ "required": ["target"],
199
+ "additionalProperties": False,
200
+ },
201
+ tool_fix_preview,
202
+ read_only=True,
203
+ ),
204
+ ]
205
+ )
206
+
207
+
208
+ def _check_arguments(arguments: Dict[str, Any], allowed: Tuple[str, ...], required: Tuple[str, ...]) -> None:
209
+ if not isinstance(arguments, dict):
210
+ raise ToolError("arguments must be an object")
211
+ unknown = sorted(set(arguments) - set(allowed))
212
+ if unknown:
213
+ raise ToolError("unknown arguments: " + ", ".join(unknown))
214
+ missing = [key for key in required if key not in arguments]
215
+ if missing:
216
+ raise ToolError("missing arguments: " + ", ".join(missing))
217
+
218
+
219
+ def _as_path(value: Any, label: str) -> Path:
220
+ if not isinstance(value, str) or not value.strip():
221
+ raise ToolError(f"{label} must be a non-empty string")
222
+ if "\x00" in value:
223
+ raise ToolError(f"{label} contains a null byte")
224
+ return Path(value).expanduser().resolve()
225
+
226
+
227
+ def _require_dir(value: Any, label: str) -> Path:
228
+ path = _as_path(value, label)
229
+ if not path.is_dir():
230
+ raise ToolError(f"{label} is not a directory: {path}")
231
+ return path
232
+
233
+
234
+ def _require_file(value: Any, label: str) -> Path:
235
+ path = _as_path(value, label)
236
+ if not path.is_file():
237
+ raise ToolError(f"{label} is not a file: {path}")
238
+ return path
239
+
240
+
241
+ def _read_json(path: Path) -> Any:
242
+ if not path.is_file():
243
+ return None
244
+ try:
245
+ return json.loads(path.read_text(encoding="utf-8"))
246
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
247
+ return {"_error": f"{path.name}: {exc}"}
248
+
249
+
250
+ def _cap(items: Any, cap: int = LIST_CAP) -> Tuple[List[Any], bool]:
251
+ if not isinstance(items, list):
252
+ return [], False
253
+ return items[:cap], len(items) > cap
254
+
255
+
256
+ def summarize_audit(audit_dir: Path) -> Dict[str, Any]:
257
+ """Finding metadata and artifact names from one audit directory."""
258
+ artifacts = sorted(entry.name for entry in audit_dir.iterdir() if entry.is_file())
259
+ profile = _read_json(audit_dir / "stack-profile.json")
260
+ findings_doc = _read_json(audit_dir / "findings.json")
261
+ coverage = _read_json(audit_dir / "coverage.json")
262
+
263
+ stacks: List[Any] = []
264
+ if isinstance(profile, dict):
265
+ for stack in profile.get("stacks") or []:
266
+ if isinstance(stack, dict) and stack.get("id"):
267
+ stacks.append(stack.get("id"))
268
+ elif isinstance(stack, str):
269
+ stacks.append(stack)
270
+
271
+ findings: List[Any] = []
272
+ summary = None
273
+ if isinstance(findings_doc, dict):
274
+ raw = findings_doc.get("findings")
275
+ if isinstance(raw, list):
276
+ findings = raw
277
+ summary = findings_doc.get("summary")
278
+ elif isinstance(findings_doc, list):
279
+ findings = findings_doc
280
+
281
+ listed: List[Dict[str, Any]] = []
282
+ for item in findings[:LIST_CAP]:
283
+ if not isinstance(item, dict):
284
+ continue
285
+ location = item.get("location") if isinstance(item.get("location"), dict) else {}
286
+ listed.append(
287
+ {
288
+ "finding_id": item.get("finding_id"),
289
+ "category": item.get("category"),
290
+ "severity": item.get("severity"),
291
+ "title": item.get("title"),
292
+ "file": location.get("file"),
293
+ "start_line": location.get("start_line"),
294
+ "status": item.get("status"),
295
+ }
296
+ )
297
+
298
+ return {
299
+ "auditDir": str(audit_dir),
300
+ "artifacts": artifacts,
301
+ "stacks": stacks,
302
+ "coverage": coverage if isinstance(coverage, dict) else None,
303
+ "summary": summary if isinstance(summary, dict) else None,
304
+ "findings": listed,
305
+ "findingsTotal": len(findings),
306
+ "findingsTruncated": len(findings) > LIST_CAP,
307
+ }
308
+
309
+
310
+ def tool_scan(arguments: Dict[str, Any]) -> Dict[str, Any]:
311
+ """Tier 1 scan in a child process so its logs never touch the MCP stdout stream."""
312
+ _check_arguments(arguments, ("target", "out", "lang", "format", "timeout_seconds"), ("target", "out"))
313
+ target = _require_dir(arguments["target"], "target")
314
+ out = _as_path(arguments["out"], "out")
315
+ if out.exists() and not out.is_dir():
316
+ raise ToolError(f"out is not a directory: {out}")
317
+ lang = arguments.get("lang", "en")
318
+ fmt = arguments.get("format", "sarif")
319
+ if lang not in LANGS:
320
+ raise ToolError("lang must be en, ja, or all")
321
+ if fmt not in FORMATS:
322
+ raise ToolError("format must be md, html, sarif, or all")
323
+ timeout = arguments.get("timeout_seconds", 180)
324
+ if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= SCAN_TIMEOUT_MAX:
325
+ raise ToolError(f"timeout_seconds must be an integer from 1 to {SCAN_TIMEOUT_MAX}")
326
+
327
+ command = [
328
+ sys.executable,
329
+ str(BIN_KOMAGU),
330
+ "--no-banner",
331
+ "scan",
332
+ str(target),
333
+ "--out",
334
+ str(out),
335
+ "--lang",
336
+ lang,
337
+ "--format",
338
+ fmt,
339
+ ]
340
+ try:
341
+ completed = subprocess.run(
342
+ command,
343
+ cwd=str(REPO_ROOT),
344
+ capture_output=True,
345
+ text=True,
346
+ timeout=timeout,
347
+ check=False,
348
+ )
349
+ except subprocess.TimeoutExpired as exc:
350
+ raise ToolError(f"scan timed out after {timeout} seconds") from exc
351
+ except OSError as exc:
352
+ raise ToolError(f"could not start scan: {exc}") from exc
353
+
354
+ audit = summarize_audit(out) if out.is_dir() else None
355
+ payload: Dict[str, Any] = {
356
+ "exitCode": completed.returncode,
357
+ "target": str(target),
358
+ "out": str(out),
359
+ "format": fmt,
360
+ "lang": lang,
361
+ "audit": audit,
362
+ }
363
+ if completed.returncode != 0:
364
+ payload["stderrTail"] = (completed.stderr or completed.stdout or "")[-STDERR_CAP:]
365
+ return payload
366
+
367
+
368
+ def tool_read_audit(arguments: Dict[str, Any]) -> Dict[str, Any]:
369
+ """Read finding metadata already on disk."""
370
+ _check_arguments(arguments, ("audit_dir",), ("audit_dir",))
371
+ audit_dir = _require_dir(arguments["audit_dir"], "audit_dir")
372
+ return summarize_audit(audit_dir)
373
+
374
+
375
+ def tool_leak_check(arguments: Dict[str, Any]) -> Dict[str, Any]:
376
+ """Delegate to the same scanner as komagu leak-check. Values stay masked."""
377
+ _check_arguments(arguments, ("paths", "canary_file"), ("paths",))
378
+ raw_paths = arguments["paths"]
379
+ if not isinstance(raw_paths, list) or not raw_paths:
380
+ raise ToolError("paths must be a non-empty array of strings")
381
+ if len(raw_paths) > MAX_PATHS:
382
+ raise ToolError(f"paths accepts at most {MAX_PATHS} entries")
383
+ paths = [str(_as_path(item, "paths entry")) for item in raw_paths]
384
+ canaries: Tuple[str, ...] = ()
385
+ if "canary_file" in arguments and arguments["canary_file"] is not None:
386
+ canary_file = _require_file(arguments["canary_file"], "canary_file")
387
+ try:
388
+ canaries = load_canaries(str(canary_file))
389
+ except CanaryFileError as exc:
390
+ raise ToolError(str(exc)) from exc
391
+ result = scan_paths(paths, canaries)
392
+ hits, truncated = _cap([dataclasses.asdict(hit) for hit in result.hits])
393
+ return {
394
+ "exitCode": result.exit_code,
395
+ "filesScanned": result.files_scanned,
396
+ "canariesLoaded": result.canaries_loaded,
397
+ "hits": hits,
398
+ "hitsTruncated": truncated,
399
+ "notScanned": [{"path": path, "reason": reason} for path, reason in result.unscanned],
400
+ }
401
+
402
+
403
+ def tool_inventory(arguments: Dict[str, Any]) -> Dict[str, Any]:
404
+ """In-process inventory diff. Nothing is written."""
405
+ _check_arguments(arguments, ("routes", "openapi", "traffic_log"), ("routes", "openapi"))
406
+ routes_file = _require_file(arguments["routes"], "routes")
407
+ openapi_file = _require_file(arguments["openapi"], "openapi")
408
+ traffic_file: Optional[Path] = None
409
+ if arguments.get("traffic_log"):
410
+ traffic_file = _require_file(arguments["traffic_log"], "traffic_log")
411
+
412
+ code_routes = load_code_routes(routes_file)
413
+ spec_endpoints = load_openapi_spec(openapi_file)
414
+ traffic_data = load_traffic_log(traffic_file) if traffic_file else {}
415
+ diff_doc = diff_api_inventory(
416
+ code_routes=code_routes,
417
+ spec_endpoints=spec_endpoints,
418
+ traffic_data=traffic_data,
419
+ routes_file_name=routes_file.name,
420
+ openapi_file_name=openapi_file.name,
421
+ traffic_file_name=traffic_file.name if traffic_file else None,
422
+ )
423
+ payload = dict(diff_doc)
424
+ for key in ("shadow_apis", "zombie_apis", "documented_missing_code", "dormant_code"):
425
+ capped, truncated = _cap(payload.get(key))
426
+ payload[key] = capped
427
+ payload[key + "_truncated"] = truncated
428
+ return payload
429
+
430
+
431
+ def tool_fix_preview(arguments: Dict[str, Any]) -> Dict[str, Any]:
432
+ """Dry-run only. output_patch is never set, so no patch file is written."""
433
+ _check_arguments(arguments, ("target", "findings"), ("target",))
434
+ target = _require_dir(arguments["target"], "target")
435
+ findings: Optional[Path] = None
436
+ if arguments.get("findings"):
437
+ findings = _require_file(arguments["findings"], "findings")
438
+ runner = RemediationRunner(
439
+ target_dir=target,
440
+ findings_path=findings,
441
+ dry_run=True,
442
+ output_patch=None,
443
+ scan_sca=False,
444
+ query_osv=False,
445
+ )
446
+ try:
447
+ report = runner.run()
448
+ except FileNotFoundError as exc:
449
+ raise ToolError(str(exc)) from exc
450
+ if report.applied_count != 0 or not report.dry_run:
451
+ raise ToolError("refusing to return a preview that modified source")
452
+
453
+ plans: List[Dict[str, Any]] = []
454
+ for plan in report.plans[:LIST_CAP]:
455
+ item = plan.to_dict()
456
+ if plan.diff:
457
+ item["diff"] = plan.diff[:DIFF_CAP]
458
+ item["diffTruncated"] = len(plan.diff) > DIFF_CAP
459
+ plans.append(item)
460
+ body = report.to_dict()
461
+ body["plans"] = plans
462
+ body["plansTruncated"] = len(report.plans) > LIST_CAP
463
+ body["modifiedSource"] = False
464
+ return body