pisces-cli 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.
pisces/explain_cmd.py ADDED
@@ -0,0 +1,608 @@
1
+ """
2
+ pisces.explain_cmd
3
+ ~~~~~~~~~~~~~~~~~~
4
+ Implements `pisces explain <file:line>` — AI-powered vulnerability analysis.
5
+
6
+ What it does (in order):
7
+ 1. Parse the "file:line" reference from the user
8
+ 2. Load the Finding from .pisces/cache/findings.json
9
+ 3. Read ±20 lines of surrounding source code for context
10
+ 4. Load the file's AST context (imports, functions, calls)
11
+ 5. Build a structured prompt with all of that context
12
+ 6. Call the configured LLM provider
13
+ 7. Parse and render the explanation as a rich, formatted panel
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from pathlib import Path
20
+
21
+ from rich import box
22
+ from rich.console import Group
23
+ from rich.markdown import Markdown
24
+ from rich.panel import Panel
25
+ from rich.syntax import Syntax
26
+ from rich.table import Table
27
+ from rich.text import Text
28
+
29
+ from pisces.config_manager import load_config
30
+ from pisces.context_manager import ContextManager
31
+ from pisces.llm_client import call_llm
32
+ from pisces.models import FileContext, Finding, LLMRequest
33
+ from pisces.scan_cmd import load_finding_by_ref
34
+ from pisces.tui import (
35
+ console,
36
+ print_error,
37
+ print_finding_detail,
38
+ print_section,
39
+ print_warning,
40
+ spinner,
41
+ )
42
+
43
+ # Number of lines of source context to send to the LLM (above + below the finding)
44
+ _CONTEXT_LINES = 20
45
+
46
+
47
+ # ─────────────────────────────────────────────────────────────
48
+ # Entry point
49
+ # ─────────────────────────────────────────────────────────────
50
+
51
+
52
+ def run_explain(
53
+ ref: str,
54
+ project_root: Path,
55
+ ) -> int:
56
+ """
57
+ Entry point for `pisces explain <ref>`.
58
+ ref format: "path/to/file.py:42"
59
+
60
+ Returns 0 on success, 1 on error.
61
+ """
62
+ print_section("pisces explain")
63
+
64
+ # ── Parse reference ──────────────────────────────────────
65
+ file_path, line_no = _parse_ref(ref, project_root)
66
+ if file_path is None:
67
+ print_error(
68
+ f"Invalid reference: '{ref}'\n Expected format: pisces explain path/to/file.py:42"
69
+ )
70
+ return 1
71
+
72
+ rel_path = str(file_path.relative_to(project_root))
73
+ lookup = f"{rel_path}:{line_no}"
74
+
75
+ # ── Load finding from cache ───────────────────────────────
76
+ finding = load_finding_by_ref(project_root, lookup)
77
+ if finding is None:
78
+ print_warning(
79
+ f"No cached finding at '{lookup}'.\n"
80
+ " Run 'pisces scan .' first to populate the findings cache."
81
+ )
82
+ return 1
83
+
84
+ # ── Load surrounding source lines ─────────────────────────
85
+ source_snippet = _get_source_snippet(file_path, line_no, context=_CONTEXT_LINES)
86
+ if not source_snippet:
87
+ print_warning(f"Could not read source from {rel_path}")
88
+ # Continue anyway — LLM can still help with what we know
89
+
90
+ # ── Load AST file context ─────────────────────────────────
91
+ file_ctx = _get_file_context(project_root, rel_path)
92
+
93
+ # ── Load config ───────────────────────────────────────────
94
+ try:
95
+ cfg = load_config(project_root)
96
+ except RuntimeError as e:
97
+ print_error(str(e))
98
+ return 1
99
+
100
+ provider = cfg.llm.provider
101
+ model = cfg.llm.model
102
+
103
+ console.print(f" [dim]Using[/] [magenta]{provider}[/] / [cyan]{model}[/]")
104
+ console.print()
105
+
106
+ # ── Build and send LLM request ────────────────────────────
107
+ request = _build_explain_request(finding, source_snippet, file_ctx, rel_path)
108
+
109
+ try:
110
+ with spinner(f"Asking {provider} to analyse the vulnerability..."):
111
+ response = call_llm(request, provider=provider, model=model)
112
+ except OSError as e:
113
+ print_error(str(e), hint=f"Set the API key env var for '{provider}'.")
114
+ return 1
115
+ except RuntimeError as e:
116
+ print_error(str(e))
117
+ return 1
118
+
119
+ # ── Parse LLM response ────────────────────────────────────
120
+ try:
121
+ explanation = _parse_llm_response(response.content)
122
+ except ValueError as e:
123
+ print_error(f"Could not parse the LLM response: {e}")
124
+ console.print(response.content)
125
+ return 1
126
+
127
+ # ── Display structured explanation ────────────────────────
128
+ _display_explanation(
129
+ finding=finding,
130
+ explanation=explanation,
131
+ response=response,
132
+ rel_path=rel_path,
133
+ line_no=line_no,
134
+ source_snippet=source_snippet,
135
+ )
136
+
137
+ # ── Token usage ───────────────────────────────────────────
138
+ console.print(
139
+ f"\n [dim]Tokens used:[/] "
140
+ f"[cyan]{response.input_tokens}[/] in / "
141
+ f"[cyan]{response.output_tokens}[/] out"
142
+ )
143
+
144
+ # ── Suggested next steps ──────────────────────────────────
145
+ console.print(
146
+ Panel(
147
+ "[bold]Preview the proposed patch:[/]\n"
148
+ f"[cyan]pisces fix {rel_path}:{line_no} --dry-run[/]\n\n"
149
+ "[bold]Start the remediation workflow:[/]\n"
150
+ f"[green]pisces fix {rel_path}:{line_no}[/]",
151
+ title="[bold magenta]NEXT STEP[/]",
152
+ border_style="magenta",
153
+ padding=(1, 2),
154
+ )
155
+ )
156
+
157
+ return 0
158
+
159
+
160
+ def _display_explanation(
161
+ finding: Finding,
162
+ explanation: dict[str, str],
163
+ response: object,
164
+ rel_path: str,
165
+ line_no: int,
166
+ source_snippet: str,
167
+ ) -> None:
168
+ """Render a structured, readable security analysis in the terminal."""
169
+
170
+ severity = str(finding.severity.value).upper()
171
+
172
+ severity_styles = {
173
+ "CRITICAL": ("bold white on red", "red"),
174
+ "HIGH": ("bold red", "red"),
175
+ "MEDIUM": ("bold yellow", "yellow"),
176
+ "LOW": ("bold green", "green"),
177
+ "INFO": ("bold cyan", "cyan"),
178
+ }
179
+
180
+ severity_style, border_style = severity_styles.get(severity, ("bold white", "cyan"))
181
+
182
+ console.print()
183
+
184
+ # ── Header ────────────────────────────────────────────────
185
+ console.print(
186
+ Panel(
187
+ Text.from_markup(
188
+ f"[bold]SECURITY ANALYSIS[/]\n"
189
+ f"[dim]{rel_path}:{line_no}[/]\n\n"
190
+ f"[{severity_style}] {severity} [/]"
191
+ f" [bold]{finding.rule}[/]\n"
192
+ f"[dim]{finding.description}[/]"
193
+ ),
194
+ border_style=border_style,
195
+ box=box.DOUBLE,
196
+ padding=(1, 2),
197
+ title="[bold magenta]PISCES[/]",
198
+ subtitle="[dim]AI-assisted vulnerability analysis[/]",
199
+ )
200
+ )
201
+
202
+ # ── Summary ───────────────────────────────────────────────
203
+ summary = explanation.get("Summary", "")
204
+ if summary:
205
+ console.print(
206
+ Panel(
207
+ Markdown(summary),
208
+ title="[bold cyan]01 · Executive Summary[/]",
209
+ border_style="cyan",
210
+ padding=(1, 2),
211
+ )
212
+ )
213
+
214
+ # ── Finding metadata ──────────────────────────────────────
215
+ metadata = Table(
216
+ show_header=False,
217
+ box=box.SIMPLE,
218
+ expand=True,
219
+ padding=(0, 1),
220
+ )
221
+ metadata.add_column("Property", style="dim", width=18)
222
+ metadata.add_column("Value", style="bold")
223
+
224
+ metadata.add_row("Severity", f"[{severity_style}]{severity}[/]")
225
+ metadata.add_row("Rule", finding.rule)
226
+ metadata.add_row("Location", f"{rel_path}:{line_no}")
227
+
228
+ if finding.impact:
229
+ metadata.add_row("Potential impact", finding.impact)
230
+
231
+ console.print(
232
+ Panel(
233
+ metadata,
234
+ title="[bold]Finding Details[/]",
235
+ border_style="dim",
236
+ padding=(0, 1),
237
+ )
238
+ )
239
+
240
+ # ── Explanation ───────────────────────────────────────────
241
+ if explanation.get("Vulnerability Explained"):
242
+ console.print(
243
+ Panel(
244
+ Markdown(explanation["Vulnerability Explained"]),
245
+ title="[bold cyan]02 · How the Vulnerability Works[/]",
246
+ border_style="cyan",
247
+ padding=(1, 2),
248
+ )
249
+ )
250
+
251
+ # ── Attack scenario ───────────────────────────────────────
252
+ if explanation.get("Attack Scenario"):
253
+ console.print(
254
+ Panel(
255
+ Markdown(explanation["Attack Scenario"]),
256
+ title="[bold yellow]03 · Attack Scenario[/]",
257
+ border_style="yellow",
258
+ padding=(1, 2),
259
+ )
260
+ )
261
+
262
+ # ── Root cause ────────────────────────────────────────────
263
+ if explanation.get("Root Cause"):
264
+ console.print(
265
+ Panel(
266
+ Markdown(explanation["Root Cause"]),
267
+ title="[bold red]04 · Root Cause[/]",
268
+ border_style="red",
269
+ padding=(1, 2),
270
+ )
271
+ )
272
+
273
+ # ── Recommended fix ───────────────────────────────────────
274
+ fix_text = explanation.get("Recommended Fix", "")
275
+
276
+ if fix_text:
277
+ code_match = re.search(
278
+ r"```(?:python|py)?\s*\n(.*?)```",
279
+ fix_text,
280
+ re.DOTALL | re.IGNORECASE,
281
+ )
282
+
283
+ if code_match:
284
+ before_code = fix_text[: code_match.start()].strip()
285
+ code = code_match.group(1).strip()
286
+ after_code = fix_text[code_match.end() :].strip()
287
+
288
+ fix_content = []
289
+
290
+ if before_code:
291
+ fix_content.append(Markdown(before_code))
292
+
293
+ fix_content.append(
294
+ Syntax(
295
+ code,
296
+ "python",
297
+ theme="monokai",
298
+ line_numbers=True,
299
+ word_wrap=True,
300
+ padding=1,
301
+ )
302
+ )
303
+
304
+ if after_code:
305
+ fix_content.append(Markdown(after_code))
306
+
307
+ console.print(
308
+ Panel(
309
+ Group(*fix_content),
310
+ title="[bold green]05 · Recommended Fix[/]",
311
+ border_style="green",
312
+ padding=(1, 2),
313
+ )
314
+ )
315
+ else:
316
+ console.print(
317
+ Panel(
318
+ Markdown(fix_text),
319
+ title="[bold green]05 · Recommended Fix[/]",
320
+ border_style="green",
321
+ padding=(1, 2),
322
+ )
323
+ )
324
+
325
+ # ── References ────────────────────────────────────────────
326
+ if explanation.get("References"):
327
+ console.print(
328
+ Panel(
329
+ Markdown(explanation["References"]),
330
+ title="[bold blue]06 · References & Standards[/]",
331
+ border_style="blue",
332
+ padding=(1, 2),
333
+ )
334
+ )
335
+
336
+ console.print()
337
+ console.print(
338
+ "[dim]Analysis complete · Review the suggested patch before applying it.[/]",
339
+ justify="center",
340
+ )
341
+ console.print()
342
+
343
+
344
+ # ─────────────────────────────────────────────────────────────
345
+ # Reference parsing
346
+ # ─────────────────────────────────────────────────────────────
347
+
348
+
349
+ def _parse_ref(ref: str, project_root: Path) -> tuple[Path | None, int]:
350
+ """
351
+ Parse a 'file.py:42' reference into (Path, line_number).
352
+
353
+ Handles both:
354
+ - relative: pisces/scanner.py:100
355
+ - absolute: /home/user/project/pisces/scanner.py:100
356
+ """
357
+ # Split on last colon — handles Windows paths like C:\foo\bar.py:10
358
+ match = re.match(r"^(.+):(\d+)$", ref.strip())
359
+ if not match:
360
+ return None, 0
361
+
362
+ file_str = match.group(1)
363
+ line_no = int(match.group(2))
364
+
365
+ # Try as-is, then relative to project_root
366
+ candidates = [
367
+ Path(file_str),
368
+ project_root / file_str,
369
+ ]
370
+ for candidate in candidates:
371
+ if candidate.exists() and candidate.suffix == ".py":
372
+ return candidate.resolve(), line_no
373
+
374
+ return None, 0
375
+
376
+
377
+ # ─────────────────────────────────────────────────────────────
378
+ # Source snippet extraction
379
+ # ─────────────────────────────────────────────────────────────
380
+
381
+
382
+ def _get_source_snippet(
383
+ file_path: Path,
384
+ line_no: int,
385
+ context: int = _CONTEXT_LINES,
386
+ ) -> str:
387
+ """
388
+ Return lines [line_no - context, line_no + context] from *file_path*,
389
+ with line numbers prepended as: ' 42 | source code here'
390
+ """
391
+ try:
392
+ lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
393
+ except OSError:
394
+ return ""
395
+
396
+ start = max(1, line_no - context)
397
+ end = min(len(lines), line_no + context)
398
+
399
+ result_lines = []
400
+ for i in range(start, end + 1):
401
+ marker = "→" if i == line_no else " "
402
+ result_lines.append(f"{marker} {i:4d} | {lines[i - 1]}")
403
+
404
+ return "\n".join(result_lines)
405
+
406
+
407
+ # ─────────────────────────────────────────────────────────────
408
+ # File context loading
409
+ # ─────────────────────────────────────────────────────────────
410
+
411
+
412
+ def _get_file_context(project_root: Path, rel_path: str) -> FileContext | None:
413
+ """Load the FileContext from .pisces/context.json, if available."""
414
+ try:
415
+ cm = ContextManager(project_root)
416
+ cm.load()
417
+ return cm.get_file_context(rel_path)
418
+ except (OSError, RuntimeError, TypeError, ValueError, KeyError):
419
+ return None
420
+
421
+
422
+ # ─────────────────────────────────────────────────────────────
423
+ # Prompt engineering
424
+ # ─────────────────────────────────────────────────────────────
425
+
426
+ _SYSTEM_PROMPT = """\
427
+ You are an expert Python security engineer performing a code security review.
428
+ You will be given:
429
+ 1. A security finding detected by static analysis
430
+ 2. The surrounding source code with line numbers
431
+ 3. File-level context: imports, functions, classes, related calls
432
+
433
+ Your task: produce a structured, thorough security analysis.
434
+
435
+ Respond ONLY in the following format with these exact section headers:
436
+
437
+ ## Summary
438
+ One sentence: what is wrong and why it matters.
439
+
440
+ ## Vulnerability Explained
441
+ 2-4 paragraphs explaining:
442
+ - How this vulnerability works technically
443
+ - What an attacker could do to exploit it
444
+ - Real-world impact (data breach, RCE, privilege escalation, etc.)
445
+
446
+ ## Attack Scenario
447
+ A concrete, step-by-step scenario showing how an attacker would exploit this.
448
+ Be specific to the actual code shown.
449
+
450
+ ## Root Cause
451
+ Identify the exact line(s) and the underlying programming mistake.
452
+
453
+ ## Recommended Fix
454
+ Provide the corrected Python code as a fenced code block (```python ... ```).
455
+ The fix must:
456
+ - Be minimal — change only what is necessary
457
+ - Preserve the original logic and function signature
458
+ - Follow security best practices
459
+ - Include a brief comment explaining WHY each change was made
460
+
461
+ ## References
462
+ 2-3 relevant references: CWE number, OWASP category, or CVE if applicable.
463
+ """
464
+
465
+
466
+ def _build_explain_request(
467
+ finding: Finding,
468
+ source_snippet: str,
469
+ file_ctx: FileContext | None,
470
+ rel_path: str,
471
+ ) -> LLMRequest:
472
+ """Construct the LLMRequest for the explain command."""
473
+
474
+ # Build file context summary (from AST analysis)
475
+ ctx_lines: list[str] = []
476
+ if file_ctx:
477
+ if file_ctx.imports:
478
+ mods = [i.module for i in file_ctx.imports[:15]]
479
+ ctx_lines.append(f"Imports: {', '.join(mods)}")
480
+ if file_ctx.functions:
481
+ fns = [f.name for f in file_ctx.functions[:10]]
482
+ ctx_lines.append(f"Functions defined: {', '.join(fns)}")
483
+ if file_ctx.classes:
484
+ cls = [c.name for c in file_ctx.classes[:5]]
485
+ ctx_lines.append(f"Classes defined: {', '.join(cls)}")
486
+ if file_ctx.security_constructs:
487
+ kinds = [s.kind for s in file_ctx.security_constructs[:8]]
488
+ ctx_lines.append(f"Other security-relevant calls: {', '.join(set(kinds))}")
489
+
490
+ file_ctx_str = (
491
+ "\n".join(ctx_lines)
492
+ if ctx_lines
493
+ else "Context not available (context.json not found or file not indexed)."
494
+ )
495
+
496
+ user_prompt = f"""\
497
+ ## Security Finding
498
+
499
+ - **File:** `{rel_path}`
500
+ - **Line:** {finding.line}
501
+ - **Rule:** `{finding.rule}`
502
+ - **Severity:** {finding.severity.value}
503
+ - **Description:** {finding.description}
504
+ - **Impact:** {finding.impact}
505
+ - **Offending code:** `{finding.code}`
506
+
507
+ ---
508
+
509
+ ## Surrounding Source Code (±{_CONTEXT_LINES} lines, line {finding.line} marked with →)
510
+
511
+ ```python
512
+ {source_snippet if source_snippet else "(source not available)"}
513
+ ```
514
+
515
+ ## File Context (AST Analysis)
516
+
517
+ {file_ctx_str}
518
+
519
+ ---
520
+
521
+ Analyze the finding using the source code and context above.
522
+ Be accurate and specific to the actual code. If the finding is a false
523
+ positive, explain why rather than recommending an unnecessary change.
524
+ """
525
+
526
+ return LLMRequest(
527
+ system_prompt=_SYSTEM_PROMPT,
528
+ user_prompt=user_prompt,
529
+ )
530
+
531
+
532
+ # ─────────────────────────────────────────────────────────────
533
+ # Response parsing
534
+ # ─────────────────────────────────────────────────────────────
535
+
536
+ _SECTION_PATTERN = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
537
+
538
+ _REQUIRED_SECTIONS = (
539
+ "Summary",
540
+ "Vulnerability Explained",
541
+ "Attack Scenario",
542
+ "Root Cause",
543
+ "Recommended Fix",
544
+ "References",
545
+ )
546
+
547
+
548
+ def _parse_llm_response(content: str) -> dict[str, str]:
549
+ """Parse the structured Markdown response into named sections."""
550
+ matches = list(_SECTION_PATTERN.finditer(content))
551
+ sections: dict[str, str] = {}
552
+
553
+ for index, match in enumerate(matches):
554
+ heading = match.group(1).strip()
555
+ start = match.end()
556
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(content)
557
+
558
+ sections[heading] = content[start:end].strip()
559
+
560
+ if not sections:
561
+ raise ValueError("The LLM response contains no Markdown section headings.")
562
+
563
+ return sections
564
+
565
+
566
+ # ─────────────────────────────────────────────────────────────
567
+ # Response display
568
+ # ─────────────────────────────────────────────────────────────
569
+
570
+
571
+ def _display_explanation(
572
+ finding: Finding,
573
+ explanation: dict[str, str],
574
+ response: object,
575
+ rel_path: str,
576
+ line_no: int,
577
+ source_snippet: str,
578
+ ) -> None:
579
+
580
+ console.print()
581
+ finding_data = {
582
+ "rule": finding.rule,
583
+ "file": rel_path,
584
+ "line": line_no,
585
+ "severity": str(finding.severity.value).upper(),
586
+ "description": finding.description,
587
+ "impact": finding.impact or "Not specified",
588
+ "remediation": getattr(finding, "remediation", None) or "See the recommended fix below.",
589
+ }
590
+
591
+ print_finding_detail(finding_data, source_snippet=source_snippet)
592
+
593
+ for heading in _REQUIRED_SECTIONS:
594
+ body = explanation.get(heading)
595
+
596
+ if not body:
597
+ print_warning(f"LLM response omitted section: {heading}")
598
+ continue
599
+
600
+ console.print(
601
+ Panel(
602
+ Markdown(body),
603
+ title=f"[bold cyan]{heading}[/]",
604
+ border_style="cyan",
605
+ expand=True,
606
+ padding=(1, 2),
607
+ )
608
+ )