diff --git a/cli/securelens/repl.py b/cli/securelens/repl.py index dfc403b..9faeb90 100644 --- a/cli/securelens/repl.py +++ b/cli/securelens/repl.py @@ -5,35 +5,36 @@ Post-scan Q&A loop — the "Gemini CLI feel". After a scan completes, the user drops into this loop where they can: - Ask natural-language questions about the scan results - - Use slash commands (/export, /files, /model, /clear, /help) + - Use slash commands (/export, /files, /score, /model, /clear, /help, /exit) - Ctrl+C to exit The AI is given full scan context at the start of the conversation and remembers the entire chat history during the session. """ -import asyncio import json -import sys from dataclasses import dataclass, field -from pathlib import Path from typing import Optional from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.prompt import Prompt from securelens.ai import call_ai from securelens.ai.prompts import chat_prompt -from securelens.output import console, print_ai_response, print_info, print_error, print_success from securelens.output.exporters import save_json, save_markdown -console_out = Console() +console = Console() HELP_TEXT = """ [bold cyan]Available commands:[/bold cyan] [bold]/help[/bold] Show this help message - [bold]/files[/bold] List files that were scanned - [bold]/score[/bold] Show the security score + [bold]/files[/bold] List files that were analyzed + [bold]/score[/bold] Show the current security score + [bold]/issues[/bold] Show all found issues (summary) + [bold]/issues critical[/bold] Filter issues by severity (critical/high/medium/low) [bold]/export markdown[/bold] Save the report as a Markdown file [bold]/export json[/bold] Save the report as a JSON file [bold]/model [/bold] Switch AI model (e.g. /model gpt-4o-mini) @@ -44,6 +45,7 @@ Or just type a question in plain English, e.g.: [dim]> How do I fix the SQL injection?[/dim] [dim]> What's the most critical issue?[/dim] [dim]> Show me all issues in auth.py[/dim] + [dim]> Give me a step-by-step remediation plan[/dim] """ @@ -51,7 +53,7 @@ Or just type a question in plain English, e.g.: class ReplContext: target: str scan_result: object # LocalScanResult or WebScanResult - target_type: str # "code" | "web" + target_type: str # "code" | "web" | "github" api_key: str model: str conversation_history: list = field(default_factory=list) @@ -61,19 +63,21 @@ async def run_repl(ctx: ReplContext) -> None: """ Enter the interactive REPL. Blocks until the user exits. """ - # Build initial scan context string (injected into every AI prompt) + # Build scan context string once — injected into every AI prompt scan_ctx_str = _build_scan_context(ctx) - console_out.print() - console_out.print("[bold cyan]💬 Ask a follow-up[/bold cyan] [dim](or press Ctrl+C / type /exit to quit)[/dim]") - console_out.print("[dim]Type /help for available commands[/dim]") - console_out.print() + console.print() + console.rule("[bold cyan] SecureLens AI Chat [/bold cyan]", style="cyan") + console.print( + "[dim]Ask anything about the scan results. " + "Type [bold]/help[/bold] for commands, [bold]Ctrl+C[/bold] to exit.[/dim]\n" + ) while True: try: - user_input = _prompt_user() + user_input = Prompt.ask("[bold cyan]>[/bold cyan]") except (KeyboardInterrupt, EOFError): - console_out.print("\n[dim]Goodbye![/dim]\n") + console.print("\n[dim]Goodbye![/dim]\n") break user_input = user_input.strip() @@ -89,44 +93,52 @@ async def run_repl(ctx: ReplContext) -> None: # ── AI response ───────────────────────────────────────────────────── if not ctx.api_key: - print_error("No API key configured. Run `securelens configure` to set one.") + console.print( + "\n [bold red]✗ No API key configured.[/bold red] " + "Run [cyan]securelens configure[/cyan] to set one.\n" + ) continue - prompt = chat_prompt(ctx.target, scan_ctx_str, user_input) - console_out.print("[dim] Thinking...[/dim]") - response = await call_ai( - prompt=prompt, - api_key=ctx.api_key, - model=ctx.model, - temperature=0.5, - conversation_history=ctx.conversation_history, - ) + # Show a thinking indicator + with console.status("[dim]Thinking...[/dim]", spinner="dots"): + prompt = chat_prompt(ctx.target, scan_ctx_str, user_input) + response = await call_ai( + prompt=prompt, + api_key=ctx.api_key, + model=ctx.model, + temperature=0.5, + conversation_history=ctx.conversation_history, + ) if response: - # Save to history for multi-turn context + # Append to history for multi-turn context (cap at 20 turns to avoid token bloat) ctx.conversation_history.append({"role": "user", "content": user_input}) ctx.conversation_history.append({"role": "assistant", "content": response}) - print_ai_response(response) + if len(ctx.conversation_history) > 40: + ctx.conversation_history = ctx.conversation_history[-40:] + + # Render AI response as Markdown (handles code blocks, bullets, headers) + console.print() + console.print(Panel( + Markdown(response), + border_style="dim cyan", + padding=(0, 1), + )) + console.print() else: - print_error("No response from AI. Check your API key and network connection.") + console.print( + "\n [bold red]✗ No response from AI.[/bold red] " + "Check your API key and network connection.\n" + ) -def _prompt_user() -> str: - """Read a line from stdin with a styled prompt.""" - sys.stdout.write("[dim bold cyan]>[/dim bold cyan] ") - sys.stdout.flush() - # Use input() — rich.prompt not used here to keep it simple - try: - from rich.prompt import Prompt - return Prompt.ask("[bold cyan]>[/bold cyan]") - except Exception: - return input("> ") - +# ── Scan context builder ────────────────────────────────────────────────────── def _build_scan_context(ctx: ReplContext) -> str: - """Serialize the scan result into a compact string for the AI context.""" + """Serialize the scan result into a compact JSON string for AI context.""" result = ctx.scan_result - if ctx.target_type == "code": + + if ctx.target_type in ("code", "github"): vulns = [ { "file": v.file_path, @@ -146,6 +158,7 @@ def _build_scan_context(ctx: ReplContext) -> str: "vulnerabilities": vulns, "ai_summary": result.ai_summary, }, indent=2) + else: # web issues = [ {"layer": i.layer, "severity": i.severity, "issue": i.issue, "fix": i.fix} @@ -162,57 +175,119 @@ def _build_scan_context(ctx: ReplContext) -> str: }, indent=2) +# ── Slash command dispatcher ────────────────────────────────────────────────── + async def _handle_slash_command(cmd: str, ctx: ReplContext) -> bool: """ - Process a slash command. Returns True if the REPL should exit. + Handle a slash command. Returns True if the REPL should exit. """ - parts = cmd.split() + parts = cmd.strip().split(maxsplit=2) command = parts[0].lower() if command == "/exit": - console_out.print("\n[dim]Goodbye![/dim]\n") + console.print("\n[dim]Goodbye![/dim]\n") return True elif command == "/help": - console_out.print(HELP_TEXT) + console.print(HELP_TEXT) elif command == "/clear": - console_out.clear() + console.clear() elif command == "/files": result = ctx.scan_result - if ctx.target_type == "code" and hasattr(result, "files_triaged"): - console_out.print("\n[bold]Files analyzed:[/bold]") - for f in result.files_triaged: - console_out.print(f" [dim]• {f}[/dim]") - console_out.print() + if ctx.target_type in ("code", "github") and hasattr(result, "files_triaged"): + if result.files_triaged: + console.print("\n[bold]Files analyzed:[/bold]") + for f in result.files_triaged: + console.print(f" [dim]• {f}[/dim]") + else: + console.print("\n [dim]No files were analyzed.[/dim]") + console.print() else: - print_info("File list not available for web scans.") + console.print("\n [dim]File list not available for web scans.[/dim]\n") elif command == "/score": r = ctx.scan_result - score = r.score - grade = r.grade - console_out.print(f"\n [bold]Score:[/bold] {score}/100 [bold]Grade:[/bold] {grade}\n") + from securelens.output import GRADE_COLOR + grade_color = GRADE_COLOR.get(r.grade, "white") + console.print( + f"\n Score: [{grade_color}]{r.score}/100 Grade: {r.grade}[/{grade_color}]\n" + ) + + elif command == "/issues": + severity_filter = parts[1].strip().lower() if len(parts) > 1 else None + _print_issues_summary(ctx, severity_filter) elif command == "/model": if len(parts) < 2: - print_info(f"Current model: {ctx.model}") - print_info("Usage: /model e.g. /model gpt-4o-mini") + console.print(f"\n [dim]Current model: {ctx.model}[/dim]") + console.print(" [dim]Usage: /model e.g. /model gpt-4o-mini[/dim]\n") else: ctx.model = parts[1] - print_success(f"Model switched to: {ctx.model}") + console.print(f"\n [bold green]✓ Model switched to: {ctx.model}[/bold green]\n") elif command == "/export": fmt = parts[1].lower() if len(parts) > 1 else "markdown" + target_type = "code" if ctx.target_type in ("code", "github") else "web" if fmt == "json": - path = save_json(ctx.scan_result, ctx.target_type) - print_success(f"JSON report saved to: {path}") + path = save_json(ctx.scan_result, target_type) + console.print(f"\n [bold green]✓ JSON report saved:[/bold green] [dim]{path}[/dim]\n") else: - path = save_markdown(ctx.scan_result, ctx.target_type) - print_success(f"Markdown report saved to: {path}") + path = save_markdown(ctx.scan_result, target_type) + console.print(f"\n [bold green]✓ Markdown report saved:[/bold green] [dim]{path}[/dim]\n") else: - print_error(f"Unknown command: {command}. Type /help for available commands.") + console.print( + f"\n [bold red]✗ Unknown command: {command}[/bold red] " + "Type [cyan]/help[/cyan] for available commands.\n" + ) return False + + +def _print_issues_summary(ctx: ReplContext, severity_filter: Optional[str] = None) -> None: + """Print a compact list of all issues, optionally filtered by severity.""" + result = ctx.scan_result + from securelens.output import SEVERITY_COLOR + + if ctx.target_type in ("code", "github"): + issues = result.vulnerabilities + if not issues: + console.print("\n [bold green]✓ No vulnerabilities found.[/bold green]\n") + return + + filtered = issues + if severity_filter: + filtered = [v for v in issues if v.severity.lower() == severity_filter] + if not filtered: + console.print(f"\n [dim]No {severity_filter} issues found.[/dim]\n") + return + + console.print(f"\n [bold]{len(filtered)} issue(s):[/bold]") + for i, v in enumerate(filtered, 1): + color = SEVERITY_COLOR.get(v.severity, "white") + loc = v.file_path + if v.line_number: + loc += f":{v.line_number}" + console.print(f" [{color}][{i}] {v.severity}[/{color}] {v.issue} [dim]{loc}[/dim]") + console.print() + + else: # web + issues = result.issues + if not issues: + console.print("\n [bold green]✓ No issues found.[/bold green]\n") + return + + filtered = issues + if severity_filter: + filtered = [i for i in issues if i.severity.lower() == severity_filter] + if not filtered: + console.print(f"\n [dim]No {severity_filter} issues found.[/dim]\n") + return + + console.print(f"\n [bold]{len(filtered)} issue(s):[/bold]") + for i, issue in enumerate(filtered, 1): + color = SEVERITY_COLOR.get(issue.severity, "white") + console.print(f" [{color}][{i}] {issue.severity}[/{color}] {issue.issue} [dim]{issue.layer}[/dim]") + console.print()