codevanta 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 (65) hide show
  1. codevanta/__init__.py +1 -0
  2. codevanta/analysis/__init__.py +1 -0
  3. codevanta/analysis/binary_diff.py +1168 -0
  4. codevanta/analysis/callgraph.py +542 -0
  5. codevanta/analysis/cfg.py +1201 -0
  6. codevanta/analysis/dataflow.py +762 -0
  7. codevanta/analysis/debugger.py +289 -0
  8. codevanta/analysis/debuginfo.py +304 -0
  9. codevanta/analysis/decompile.py +1094 -0
  10. codevanta/analysis/decompilers/__init__.py +250 -0
  11. codevanta/analysis/decompilers/angr_backend.py +251 -0
  12. codevanta/analysis/decompilers/base.py +278 -0
  13. codevanta/analysis/decompilers/ghidra_backend.py +316 -0
  14. codevanta/analysis/dynamic.py +756 -0
  15. codevanta/analysis/emulate.py +1349 -0
  16. codevanta/analysis/evidence_bundle.py +736 -0
  17. codevanta/analysis/function_hash.py +562 -0
  18. codevanta/analysis/graph_export.py +441 -0
  19. codevanta/analysis/html_view.py +695 -0
  20. codevanta/analysis/jump_tables.py +546 -0
  21. codevanta/analysis/solver.py +715 -0
  22. codevanta/analysis/symbolic.py +1809 -0
  23. codevanta/analysis/trace.py +646 -0
  24. codevanta/analysis/type_propagation.py +1055 -0
  25. codevanta/analysis/type_recovery.py +1214 -0
  26. codevanta/analysis/vtables.py +572 -0
  27. codevanta/analysis/xrefs.py +434 -0
  28. codevanta/api/__init__.py +1 -0
  29. codevanta/cli/__init__.py +1 -0
  30. codevanta/cli/main.py +3038 -0
  31. codevanta/core/__init__.py +31 -0
  32. codevanta/core/disassembler.py +121 -0
  33. codevanta/core/entropy.py +237 -0
  34. codevanta/core/functions.py +176 -0
  35. codevanta/core/loader.py +1351 -0
  36. codevanta/core/report.py +306 -0
  37. codevanta/core/strings.py +109 -0
  38. codevanta/evidence/__init__.py +7 -0
  39. codevanta/evidence/confidence.py +36 -0
  40. codevanta/evidence/models.py +43 -0
  41. codevanta/evidence/provenance.py +37 -0
  42. codevanta/gui/__init__.py +46 -0
  43. codevanta/gui/layout.py +1384 -0
  44. codevanta/gui/server.py +1305 -0
  45. codevanta/ir/__init__.py +6 -0
  46. codevanta/ir/adapters/__init__.py +31 -0
  47. codevanta/ir/base.py +31 -0
  48. codevanta/ir/instruction.py +42 -0
  49. codevanta/mcp_server/__init__.py +5 -0
  50. codevanta/mcp_server/server.py +710 -0
  51. codevanta/mcp_server/tools.py +1364 -0
  52. codevanta/plugins/__init__.py +748 -0
  53. codevanta/setup/__init__.py +28 -0
  54. codevanta/setup/apply.py +22 -0
  55. codevanta/setup/clients.py +285 -0
  56. codevanta/setup/plan.py +79 -0
  57. codevanta/setup/verify.py +43 -0
  58. codevanta/storage/__init__.py +1 -0
  59. codevanta/storage/session.py +514 -0
  60. codevanta-0.1.0.dist-info/METADATA +688 -0
  61. codevanta-0.1.0.dist-info/RECORD +65 -0
  62. codevanta-0.1.0.dist-info/WHEEL +5 -0
  63. codevanta-0.1.0.dist-info/entry_points.txt +2 -0
  64. codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. codevanta-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,710 @@
1
+ """CODEVANTA MCP server (Model Context Protocol).
2
+
3
+ ``stdio`` is the default transport, so existing client configurations keep
4
+ working. ``sse`` and ``streamable-http`` are also available for clients that
5
+ speak HTTP; they bind a local TCP port and are opt-in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ from typing import Annotated, Any, Literal, cast
12
+
13
+ from mcp.server.mcpserver import MCPServer
14
+ from mcp.server.mcpserver.exceptions import ToolError
15
+ from mcp.server.transport_security import TransportSecuritySettings
16
+ from pydantic import Field
17
+
18
+ from codevanta.core.report import AnalysisError
19
+
20
+ from . import tools
21
+
22
+ SERVER_NAME = "CODEVANTA"
23
+ SERVER_VERSION = "0.1.0"
24
+
25
+ #: Transports the pinned ``mcp`` SDK can serve. ``MCPServer.run`` declares
26
+ #: ``Literal["stdio", "sse", "streamable-http"]`` and raises ``ValueError`` for
27
+ #: anything else, so this tuple is the SDK's list - not a CODEVANTA invention.
28
+ #: ``stdio`` is first because it is the default.
29
+ SUPPORTED_TRANSPORTS: tuple[str, ...] = ("stdio", "sse", "streamable-http")
30
+
31
+ #: Transports that bind a TCP socket, i.e. that can be reached from off-box.
32
+ HTTP_TRANSPORTS: frozenset[str] = frozenset({"sse", "streamable-http"})
33
+
34
+ #: Default bind address for the HTTP transports; loopback keeps the tools local.
35
+ DEFAULT_HOST = "127.0.0.1"
36
+ DEFAULT_PORT = 8000
37
+
38
+ #: The hosts the pinned MCP SDK hardens by itself. ``MCPServer.sse_app`` and
39
+ #: ``MCPServer.streamable_http_app`` compare their ``host`` argument with exactly
40
+ #: these strings before enabling DNS-rebinding protection, so ``127.0.0.2`` used
41
+ #: to bind without protection while ``"[::1]"`` did not bind at all. CODEVANTA
42
+ #: therefore never relies on that detection: it normalises the host once and
43
+ #: passes explicit :class:`TransportSecuritySettings` for a loopback bind (see
44
+ #: :func:`transport_security`).
45
+ SDK_HARDENED_HOSTS: tuple[str, ...] = ("127.0.0.1", "localhost", "::1")
46
+
47
+ HttpTransport = Literal["sse", "streamable-http"]
48
+
49
+ INSTRUCTIONS = (
50
+ "CODEVANTA performs static analysis of PE and ELF binaries and never executes "
51
+ "targets. Every result carries evidence: check 'source', 'method', and "
52
+ "'confidence' before relying on a finding. Exports and function symbols are "
53
+ "high confidence; the entry point is a medium-confidence code candidate, not a "
54
+ "proven function boundary. All addresses are absolute virtual addresses (VA)."
55
+ )
56
+
57
+
58
+ #: ``codevanta_diff`` flags are forwarded to :func:`codevanta.mcp_server.tools.diff`
59
+ #: exactly as the client sent them. ``Any`` stops the MCP argument model from
60
+ #: rewriting the JSON first (``deep=1`` became ``True``, ``threshold=true`` became
61
+ #: ``1.0`` and ``threshold="0.9"`` became ``0.9``), so ``_check_bool`` and
62
+ #: ``_check_threshold`` make the decision and name the offending argument, while
63
+ #: ``Annotated`` keeps the published input schema advertising the documented type.
64
+ RawBooleanFlag = Annotated[Any, Field(json_schema_extra={"type": "boolean"})]
65
+ RawNumberFlag = Annotated[Any, Field(json_schema_extra={"type": "number"})]
66
+
67
+
68
+ def build_server() -> MCPServer:
69
+ """Create the MCP server and register the analysis tools."""
70
+ server = MCPServer(
71
+ name=SERVER_NAME,
72
+ version=SERVER_VERSION,
73
+ instructions=INSTRUCTIONS,
74
+ )
75
+
76
+ @server.tool(
77
+ description=(
78
+ "Load a PE or ELF binary and return format, architecture, sections, "
79
+ "imports, exports, and symbols."
80
+ )
81
+ )
82
+ def codevanta_info(path: str) -> dict:
83
+ return tools.binary_info(path)
84
+
85
+ @server.tool(
86
+ description="List function candidates with detection source and confidence."
87
+ )
88
+ def codevanta_functions(path: str, limit: int = 200) -> dict:
89
+ return tools.functions(path, limit=limit)
90
+
91
+ @server.tool(
92
+ description=(
93
+ "Extract ASCII and UTF-16LE strings with file offset and mapped "
94
+ "virtual address."
95
+ )
96
+ )
97
+ def codevanta_strings(path: str, min_length: int = 4, limit: int = 200) -> dict:
98
+ return tools.strings(path, min_length=min_length, limit=limit)
99
+
100
+ @server.tool(
101
+ description="Disassemble the entry point, a virtual address, or a named function."
102
+ )
103
+ def codevanta_disasm(
104
+ path: str,
105
+ address: int | None = None,
106
+ function: str | None = None,
107
+ count: int = 50,
108
+ ) -> dict:
109
+ return tools.disasm(path, address=address, function=function, count=count)
110
+
111
+ @server.tool(
112
+ description="Produce the full phase 1 JSON report with provenance and evidence."
113
+ )
114
+ def codevanta_analyze(
115
+ path: str,
116
+ string_limit: int = 5000,
117
+ include_xrefs: bool = False,
118
+ include_cfg: bool = False,
119
+ include_callgraph: bool = False,
120
+ ) -> dict:
121
+ return tools.analyze(
122
+ path,
123
+ string_limit=string_limit,
124
+ include_xrefs=include_xrefs,
125
+ include_cfg=include_cfg,
126
+ include_callgraph=include_callgraph,
127
+ )
128
+
129
+ @server.tool(
130
+ description=(
131
+ "Per-section Shannon entropy sampled from file bytes with a packing "
132
+ "triage hint: sections at or above the threshold (default 7.2 bits/byte) "
133
+ "are flagged as possibly packed — a heuristic, not proof. Sections "
134
+ "without file bytes carry entropy null with a warning."
135
+ )
136
+ )
137
+ def codevanta_entropy(
138
+ path: str,
139
+ threshold: float = 7.2,
140
+ max_sample_bytes: int = 65_536,
141
+ ) -> dict:
142
+ return tools.entropy(path, threshold=threshold, max_sample_bytes=max_sample_bytes)
143
+
144
+ @server.tool(
145
+ description=(
146
+ "List direct call, jump, and conditional-branch cross references from "
147
+ "executable sections. Each reference carries evidence; indirect "
148
+ "transfers are not resolved."
149
+ )
150
+ )
151
+ def codevanta_xrefs(
152
+ path: str,
153
+ limit: int = 200,
154
+ max_bytes: int = 4_000_000,
155
+ ) -> dict:
156
+ return tools.xrefs(path, limit=limit, max_bytes=max_bytes)
157
+
158
+ @server.tool(
159
+ description=(
160
+ "Build a heuristic linear-sweep control-flow graph (basic blocks, "
161
+ "successors, terminators) for the entry point, an address, or a named "
162
+ "function. Block boundaries are heuristic, not proven."
163
+ )
164
+ )
165
+ def codevanta_cfg(
166
+ path: str,
167
+ address: int | None = None,
168
+ function: str | None = None,
169
+ max_instructions: int = 2000,
170
+ max_blocks: int = 500,
171
+ ) -> dict:
172
+ return tools.cfg(
173
+ path,
174
+ address=address,
175
+ function=function,
176
+ max_instructions=max_instructions,
177
+ max_blocks=max_blocks,
178
+ )
179
+
180
+ @server.tool(
181
+ description=(
182
+ "Decompile one function to C-like text with a pluggable backend. "
183
+ "backend='auto' (default) prefers a real decompiler -- Ghidra, then "
184
+ "angr -- when one is installed and falls back to the built-in "
185
+ "mnemonic-level pseudo-C renderer, listing every skipped backend in "
186
+ "'warnings'; backend='native' always works; backend='angr' and "
187
+ "backend='ghidra' are strict and fail with a clear error naming the "
188
+ "backend and its install hint (never an ImportError) when that "
189
+ "backend is not available. 'backend' reports the provider that "
190
+ "actually produced 'text', which differs from the request under "
191
+ "'auto', and 'blocks' is how many basic blocks the text covers. "
192
+ "Control flow, argument count and types are inferred from "
193
+ "mnemonics, so the text is low-confidence evidence and nothing is "
194
+ "executed. Check 'warnings' before relying on a fallback rendering."
195
+ )
196
+ )
197
+ def codevanta_decompile(
198
+ path: str,
199
+ address: int,
200
+ backend: str = "auto",
201
+ max_instructions: int = 2000,
202
+ max_blocks: int = 500,
203
+ ) -> dict:
204
+ try:
205
+ return tools.decompile(
206
+ path,
207
+ address,
208
+ backend=backend,
209
+ max_instructions=max_instructions,
210
+ max_blocks=max_blocks,
211
+ )
212
+ except AnalysisError as exc:
213
+ # An unavailable backend or a bad address is caller-correctable, but
214
+ # the framework would otherwise replace the text with a generic
215
+ # "Error executing tool" message that hides which argument failed.
216
+ raise ToolError(str(exc)) from exc
217
+
218
+ @server.tool(
219
+ description=(
220
+ "Build an inter-function call graph. Edges come from direct calls "
221
+ "only, plus calls through a pointer slot that resolves to a known "
222
+ "import (the target node is labelled with the import name) or to a "
223
+ "statically known slot whose callee is unknown. Jumps, conditional "
224
+ "branches, and unresolved indirect calls produce no edges. The "
225
+ "caller of a call site is attributed heuristically to the nearest "
226
+ "function candidate at or below the call address in the same "
227
+ "section, so caller attribution is not proven. Check 'truncated' "
228
+ "and 'warnings' before assuming the graph is complete."
229
+ )
230
+ )
231
+ def codevanta_callgraph(
232
+ path: str,
233
+ limit_functions: int = 1000,
234
+ limit_edges: int = 5000,
235
+ ) -> dict:
236
+ return tools.callgraph(
237
+ path,
238
+ limit_functions=limit_functions,
239
+ limit_edges=limit_edges,
240
+ )
241
+
242
+ @server.tool(
243
+ description=(
244
+ "Compare two binaries structurally: functions (matched by address "
245
+ "first, then by name after moving), sections, imports and exports. "
246
+ "Nothing is executed. With deep=true, address-matched functions are "
247
+ "also compared by a normalized-instruction fingerprint; equal "
248
+ "digests are shape equality, not byte equality. With semantic=true "
249
+ "the functions that address and name matching left unpaired are "
250
+ "scored by instruction-shape overlap, and the best pairs reaching "
251
+ "threshold (0.0..1.0, default 0.9) are reported as one 'changed' "
252
+ "entry carrying 'similarity', so a moved function is no longer a "
253
+ "removal plus an addition; that score is shape evidence, not proof "
254
+ "of equivalence. The threshold is validated even when semantic is "
255
+ "false. Arguments are never coerced: deep and semantic must be real "
256
+ "booleans and threshold a real number, so deep=1, semantic=1, "
257
+ "threshold=true and threshold='0.9' are rejected and the error names "
258
+ "the offending argument. Check 'warnings' before assuming the "
259
+ "comparison is complete."
260
+ )
261
+ )
262
+ def codevanta_diff(
263
+ left: str,
264
+ right: str,
265
+ max_functions: int = 5000,
266
+ deep: RawBooleanFlag = False,
267
+ semantic: RawBooleanFlag = False,
268
+ threshold: RawNumberFlag = 0.9,
269
+ ) -> dict:
270
+ try:
271
+ return tools.diff(
272
+ left,
273
+ right,
274
+ max_functions=max_functions,
275
+ deep=deep,
276
+ semantic=semantic,
277
+ threshold=threshold,
278
+ )
279
+ except AnalysisError as exc:
280
+ # AnalysisError is an anticipated, caller-correctable failure, but the
281
+ # framework would collapse it into a generic "Error executing tool"
282
+ # crash message that hides which argument is wrong. Re-raising it as a
283
+ # ToolError keeps the text (e.g. "deep must be a boolean") in the
284
+ # is_error result the client receives.
285
+ raise ToolError(str(exc)) from exc
286
+
287
+ @server.tool(
288
+ description=(
289
+ "Decode a jump table at an absolute virtual address. mode='absolute' "
290
+ "reads consecutive pointer-sized virtual addresses; mode='relative' "
291
+ "reads signed 32-bit offsets added to 'base' (required in that "
292
+ "mode). Scanning stops at the first slot that is not a usable code "
293
+ "target and explains why in 'warnings'; a zero pointer also ends an "
294
+ "absolute table. Entries are shape evidence, not proof that a "
295
+ "dispatcher indexes the table."
296
+ )
297
+ )
298
+ def codevanta_jump_table(
299
+ path: str,
300
+ address: int,
301
+ mode: str = "absolute",
302
+ base: int | None = None,
303
+ entries: int = 256,
304
+ executable_only: bool = True,
305
+ ) -> dict:
306
+ return tools.jump_table(
307
+ path,
308
+ address,
309
+ mode=mode,
310
+ base=base,
311
+ entries=entries,
312
+ executable_only=executable_only,
313
+ )
314
+
315
+ @server.tool(
316
+ description=(
317
+ "Return a SHA-256 fingerprint of the normalized instruction shape of "
318
+ "the function starting at an address. Numeric literals are masked, "
319
+ "so equal digests mean equal mnemonic/operand structure, not byte "
320
+ "equality, and different digests are not a semantic difference. "
321
+ "'truncated' means the digest covers only a prefix of the region."
322
+ )
323
+ )
324
+ def codevanta_function_hash(
325
+ path: str,
326
+ address: int,
327
+ max_instructions: int = 2000,
328
+ ) -> dict:
329
+ return tools.function_hash(
330
+ path,
331
+ address,
332
+ max_instructions=max_instructions,
333
+ )
334
+
335
+ @server.tool(
336
+ description=(
337
+ "Compare two function regions as multisets of normalized instruction "
338
+ "shapes and return a 'score' in 0..1 (shared / union token "
339
+ "occurrences). Numeric literals, frame offsets and branch targets are "
340
+ "masked, so a relocated or recompiled copy usually scores 1.0, while "
341
+ "two structurally similar functions can score 1.0 without sharing "
342
+ "meaning. The score is shape evidence at MEDIUM confidence, not "
343
+ "semantic equivalence: different code can score high and a rewrite can "
344
+ "score low. 'warnings' means a side was truncated to the instruction "
345
+ "budget, so only prefixes were compared."
346
+ )
347
+ )
348
+ def codevanta_similarity(
349
+ path_a: str,
350
+ address_a: int,
351
+ path_b: str,
352
+ address_b: int,
353
+ max_instructions: int = 2000,
354
+ ) -> dict:
355
+ return tools.similarity(
356
+ path_a,
357
+ address_a,
358
+ path_b,
359
+ address_b,
360
+ max_instructions=max_instructions,
361
+ )
362
+
363
+ @server.tool(
364
+ description=(
365
+ "Suggest struct fields for the memory a function accesses around a "
366
+ "base register, starting at an address, a named function, or the "
367
+ "entry point. Only '[reg]', '[reg + 0xNN]' and '[reg - 0xNN]' "
368
+ "operands are read; index/scale/segment forms are skipped. Width "
369
+ "comes from the Intel size keyword, direction from the operand "
370
+ "position (cmp/test/call/jmp/push are always reads). 'alignment' is "
371
+ "the gcd of the absolute non-zero offsets and 'array_groups' lists "
372
+ "runs of at least three "
373
+ "equally spaced fields; 'truncated' means max_fields dropped "
374
+ "offsets. Fields are medium-confidence heuristics from operand "
375
+ "shapes, not verified types, and carry no signedness or purpose."
376
+ )
377
+ )
378
+ def codevanta_struct(
379
+ path: str,
380
+ address: int | None = None,
381
+ function: str | None = None,
382
+ max_instructions: int = 2000,
383
+ max_fields: int = 64,
384
+ include_stack: bool = False,
385
+ ) -> dict:
386
+ return tools.struct(
387
+ path,
388
+ address=address,
389
+ function=function,
390
+ max_instructions=max_instructions,
391
+ max_fields=max_fields,
392
+ include_stack=include_stack,
393
+ )
394
+
395
+ @server.tool(
396
+ description=(
397
+ "Replay a saved dynamic-session trace against a binary and map every "
398
+ "recorded hit onto the discovered function candidates. 'trace_path' "
399
+ "is a capture the generated gdb or Frida script already printed: "
400
+ "nothing is launched and no target is executed. 'provider' picks the "
401
+ "format; 'auto' detects it from the file and 'provider_resolved' "
402
+ "reports the choice, while a capture that cannot be placed, or that "
403
+ "contradicts an explicit provider, is an error instead of a guess. "
404
+ "The work is bounded (8 MB and 100000 lines read, plus 'max_hits'): "
405
+ "'truncated' means a bound or the session's own limit cut the parse, "
406
+ "and malformed lines land in 'warnings' rather than failing the "
407
+ "call. Hits outside every candidate stay unattributed. A hit is "
408
+ "evidence of one run, not proof that the code always executes."
409
+ )
410
+ )
411
+ def codevanta_trace(
412
+ path: str,
413
+ trace_path: str,
414
+ provider: Literal["auto", "gdb", "frida"] = "auto",
415
+ max_hits: int | None = None,
416
+ ) -> dict:
417
+ try:
418
+ return tools.trace(path, trace_path, provider=provider, max_hits=max_hits)
419
+ except AnalysisError as exc:
420
+ # A missing capture, an unreadable one, an unresolvable provider, a
421
+ # provider mismatch or a bad max_hits is the caller's to fix, but the
422
+ # framework would otherwise replace the reason with a generic
423
+ # "Error executing tool" message that hides which argument failed.
424
+ raise ToolError(str(exc)) from exc
425
+
426
+ @server.tool(
427
+ description=(
428
+ "Extract DWARF debug-info functions and named type layouts from an ELF "
429
+ "with producer metadata (highest-grade symbol evidence). Requires the "
430
+ "optional pyelftools extra: pip install -e \".[dwarf]\"."
431
+ )
432
+ )
433
+ def codevanta_dwarf(path: str, limit: int = 500) -> dict:
434
+ return tools.dwarf(path, limit=limit)
435
+
436
+ @server.tool(
437
+ description=(
438
+ "Discover C++ vtable candidates (runs of consecutive code pointers in "
439
+ "data sections) and resolve thunk chains from slot targets — indirect "
440
+ "cross-references at medium confidence, clearly labelled as heuristic."
441
+ )
442
+ )
443
+ def codevanta_vtable(
444
+ path: str,
445
+ address: int | None = None,
446
+ scan: bool = False,
447
+ min_slots: int = 3,
448
+ max_slots: int = 256,
449
+ max_tables: int = 64,
450
+ resolve_thunks: bool = True,
451
+ ) -> dict:
452
+ return tools.vtable(
453
+ path,
454
+ address=address,
455
+ scan=scan,
456
+ min_slots=min_slots,
457
+ max_slots=max_slots,
458
+ max_tables=max_tables,
459
+ resolve_thunks=resolve_thunks,
460
+ )
461
+
462
+ @server.tool(
463
+ description=(
464
+ "Analyze a binary and persist its fingerprint, function count, and "
465
+ "string count as a new session in the local SQLite session database."
466
+ )
467
+ )
468
+ def codevanta_session_save(
469
+ path: str,
470
+ db: str | None = None,
471
+ notes: str | None = None,
472
+ ) -> dict:
473
+ return tools.session_save(path, db=db, notes=notes)
474
+
475
+ @server.tool(
476
+ description="List persisted analysis sessions, newest first."
477
+ )
478
+ def codevanta_session_list(db: str | None = None, limit: int = 20) -> dict:
479
+ return tools.session_list(db=db, limit=limit)
480
+
481
+ @server.tool(
482
+ description=(
483
+ "Explore the paths through a function with the unicorn branch-decision "
484
+ "replay. Honest limits: this is a unicorn exploration, not a symbolic "
485
+ "executor -- the observed side of a branch is the real emulation "
486
+ "result, the other side is forced by rewriting the guarding flag or "
487
+ "counter ('seeded') and is assumed feasible, never proven: no path "
488
+ "predicate, no constraint solving, no symbolic variables. Values are "
489
+ "concrete, x86/x86-64 only, and the optional unicorn extra is "
490
+ "required (pip install 'codevanta[emulation]'). The file is read and "
491
+ "only ever executed inside the emulator, never on the host. Seeded "
492
+ "directions are flagged per path and drop the summary to low "
493
+ "confidence; a path is evidence about one concrete run, not a "
494
+ "reachability proof."
495
+ )
496
+ )
497
+ def codevanta_symbolic(
498
+ path: str,
499
+ address: int,
500
+ max_paths: int = 64,
501
+ max_steps: int = 2000,
502
+ ) -> dict:
503
+ return tools.symbolic(
504
+ path,
505
+ address,
506
+ max_paths=max_paths,
507
+ max_steps=max_steps,
508
+ )
509
+
510
+ @server.tool(
511
+ description=(
512
+ "Recover definition-use chains, constants and reaching definitions "
513
+ "for the function at an address: one forward pass over the recursive "
514
+ "CFG in which register writes become definitions that kill earlier "
515
+ "ones and reads resolve against what is still known. Only provable "
516
+ "shapes fold into 'constant_registers' (immediates, rip-relative "
517
+ "loads, copies, add/sub/xor of two known values). Honest limits: "
518
+ "the pass is a linearization, not a fixpoint -- joins take the "
519
+ "highest-addressed predecessor -- so the summary is sound one "
520
+ "definition at a time, not a dataflow proof. Undefined reads are "
521
+ "reported in 'warnings'. The binary is read, never executed."
522
+ )
523
+ )
524
+ def codevanta_dataflow(
525
+ path: str,
526
+ address: int,
527
+ max_instructions: int = 2000,
528
+ max_blocks: int = 500,
529
+ ) -> dict:
530
+ return tools.dataflow(
531
+ path,
532
+ address,
533
+ max_instructions=max_instructions,
534
+ max_blocks=max_blocks,
535
+ )
536
+
537
+ return server
538
+
539
+
540
+ def normalise_host(host: str) -> str:
541
+ """Return ``host`` as the bare bind address uvicorn and the SDK expect.
542
+
543
+ The rule, applied in this order:
544
+
545
+ 1. surrounding whitespace is stripped;
546
+ 2. one pair of surrounding IPv6 brackets is dropped, because ``[::1]`` is
547
+ ``Host``-header/URL notation rather than a bindable address - uvicorn
548
+ refused ``--host "[::1]"`` with "invalid IP address";
549
+ 3. ``localhost`` (in any case) becomes ``127.0.0.1``, the literal the SDK
550
+ recognises as loopback, so neither the bind nor the ``Host`` allow-list
551
+ depends on what a resolver returns for ``localhost``.
552
+
553
+ Anything else is returned unchanged. Normalising once means the loopback
554
+ guard, the ``Host``/``Origin`` allow-list and the ``bind()`` call all see the
555
+ same string. This function does not validate: a blank or remote result is
556
+ returned as-is and rejected by :func:`run`.
557
+
558
+ Args:
559
+ host: Requested bind host, possibly bracketed or ``localhost``.
560
+
561
+ Returns:
562
+ The bare address to bind, or ``""`` when ``host`` is blank.
563
+ """
564
+ candidate = host.strip()
565
+ if len(candidate) > 2 and candidate.startswith("[") and candidate.endswith("]"):
566
+ candidate = candidate[1:-1]
567
+ if candidate.lower() == "localhost":
568
+ return "127.0.0.1"
569
+ return candidate
570
+
571
+
572
+ def is_loopback_host(host: str) -> bool:
573
+ """Return True when ``host`` is an address only reachable from this machine.
574
+
575
+ The check runs on :func:`normalise_host` output, so ``[::1]``, `` 127.0.0.1 ``
576
+ and ``LOCALHOST`` behave like their bare lower-case forms, and ``127.0.0.2``
577
+ is loopback because the whole of ``127.0.0.0/8`` is. Everything else -
578
+ including the wildcards ``0.0.0.0`` and ``::``, and any hostname such as
579
+ ``example.com`` or ``localhost.evil.com`` - is treated as remote: a wildcard
580
+ listens on every interface and a hostname may resolve to a routable address.
581
+
582
+ Accepting ``127.0.0.2`` is only safe because :func:`run` also passes explicit
583
+ :class:`TransportSecuritySettings`; the SDK's own hardening knows three
584
+ literal hosts (:data:`SDK_HARDENED_HOSTS`), not the whole loopback range.
585
+ """
586
+ if not isinstance(host, str):
587
+ return False
588
+ candidate = normalise_host(host)
589
+ if not candidate:
590
+ return False
591
+ try:
592
+ return ipaddress.ip_address(candidate).is_loopback
593
+ except ValueError:
594
+ return False
595
+
596
+
597
+ def transport_security(host: str) -> TransportSecuritySettings | None:
598
+ """Return DNS-rebinding protection for the loopback bind ``host``.
599
+
600
+ The SDK builds its own settings only when its ``host`` argument is literally
601
+ one of :data:`SDK_HARDENED_HOSTS`, which left ``127.0.0.2`` - accepted by
602
+ :func:`is_loopback_host` - serving without any ``Host``/``Origin`` check.
603
+ Passing the settings explicitly closes that gap: ``MCPServer.sse_app`` and
604
+ ``MCPServer.streamable_http_app`` only fall back to their own detection when
605
+ ``transport_security is None``, and the pinned SDK's ``run`` forwards the
606
+ keyword to both HTTP transports.
607
+
608
+ The lists describe the *actual* bind address in the SDK's own ``:<port>``
609
+ wildcard shape, so ``Host: 127.0.0.2:8000`` and ``Origin:
610
+ http://127.0.0.2:8000`` pass while ``Host: attacker.example`` and ``Origin:
611
+ http://attacker.example`` get the SDK's ``421``/``403``. Only ``http://``
612
+ origins are listed, matching the SDK's defaults for its own three hosts, and
613
+ a request without an ``Origin`` header - every non-browser MCP client - is
614
+ always accepted.
615
+
616
+ A non-loopback bind returns ``None``: the SDK then leaves protection off,
617
+ which is exactly what ``allow_remote=True`` promises - front a remote bind
618
+ with your own authentication and ``Host``/``Origin`` validation.
619
+
620
+ Args:
621
+ host: Bind host, normalised or not.
622
+
623
+ Returns:
624
+ Settings for :meth:`MCPServer.run`, or ``None`` for a remote bind.
625
+ """
626
+ if not is_loopback_host(host):
627
+ return None
628
+ bind_host = normalise_host(host)
629
+ authority = f"[{bind_host}]" if ":" in bind_host else bind_host
630
+ return TransportSecuritySettings(
631
+ enable_dns_rebinding_protection=True,
632
+ allowed_hosts=[authority, f"{authority}:*"],
633
+ allowed_origins=[f"http://{authority}", f"http://{authority}:*"],
634
+ )
635
+
636
+
637
+ def run(
638
+ transport: str = "stdio",
639
+ host: str = DEFAULT_HOST,
640
+ port: int = DEFAULT_PORT,
641
+ *,
642
+ allow_remote: bool = False,
643
+ ) -> None:
644
+ """Serve the CODEVANTA tools over MCP.
645
+
646
+ ``transport="stdio"`` is the default and behaves exactly as before: the
647
+ server speaks JSON-RPC over stdin/stdout and ``host``/``port`` are unused,
648
+ so no existing client configuration changes meaning. ``"sse"`` and
649
+ ``"streamable-http"`` bind ``host:port`` instead.
650
+
651
+ The HTTP transports are network-exposed and these tools read arbitrary
652
+ local files, so a non-loopback ``host`` is refused unless ``allow_remote``
653
+ is set.
654
+
655
+ ``host`` is normalised once through :func:`normalise_host` - whitespace
656
+ stripped, surrounding IPv6 brackets dropped, ``localhost`` mapped to
657
+ ``127.0.0.1`` - and that single value is used for the loopback guard, the
658
+ ``Host``/``Origin`` allow-list and the bind. Two consequences of the SDK's
659
+ own behaviour are worth spelling out, because the pinned SDK hardens only
660
+ the three literal strings in :data:`SDK_HARDENED_HOSTS`:
661
+
662
+ * ``127.0.0.2`` (and the rest of ``127.0.0.0/8``) is accepted *and* hardened:
663
+ :func:`transport_security` passes explicit
664
+ :class:`TransportSecuritySettings` naming the real bind address, instead
665
+ of relying on the SDK's detection, which does not fire for it.
666
+ * ``"[::1]"`` is un-bracketed to ``::1`` before binding, so it binds and is
667
+ hardened; the bracketed form used to fail inside uvicorn.
668
+
669
+ A non-loopback bind (``allow_remote=True``) gets no automatic hardening -
670
+ front it with your own authentication and ``Host``/``Origin`` validation.
671
+
672
+ Args:
673
+ transport: One of :data:`SUPPORTED_TRANSPORTS`.
674
+ host: Bind address for the HTTP transports (default loopback).
675
+ port: Bind port for the HTTP transports; ``0`` picks a free port.
676
+ allow_remote: Permit a non-loopback ``host``.
677
+
678
+ Raises:
679
+ AnalysisError: Unknown transport, malformed host/port, or a
680
+ non-loopback host without ``allow_remote``.
681
+ """
682
+ if transport not in SUPPORTED_TRANSPORTS:
683
+ supported = ", ".join(SUPPORTED_TRANSPORTS)
684
+ raise AnalysisError(
685
+ f"unknown MCP transport {transport!r}; supported transports: {supported}"
686
+ )
687
+
688
+ if transport == "stdio":
689
+ # Identical to the previous no-argument behaviour.
690
+ build_server().run(transport="stdio")
691
+ return
692
+
693
+ if not isinstance(host, str) or not host.strip():
694
+ raise AnalysisError(f"invalid MCP bind host {host!r}; expected a hostname or IP address")
695
+ bind_host = normalise_host(host)
696
+ if not is_loopback_host(bind_host) and not allow_remote:
697
+ raise AnalysisError(
698
+ f"refusing to bind the MCP server to non-loopback host {host!r}: these tools "
699
+ "read local files. Pass allow_remote=True to override."
700
+ )
701
+ if isinstance(port, bool) or not isinstance(port, int) or not 0 <= port <= 65535:
702
+ raise AnalysisError(f"invalid MCP port {port!r}; expected an integer in 0..65535")
703
+
704
+ build_server().run(
705
+ transport=cast("HttpTransport", transport),
706
+ host=bind_host,
707
+ port=port,
708
+ transport_security=transport_security(bind_host),
709
+ )
710
+