From 83bb2b9abcea4ee4afd09a764b7c302c4ee6df9f Mon Sep 17 00:00:00 2001 From: Navid Mirzaaghazadeh Date: Sat, 8 Nov 2025 20:20:51 +0300 Subject: [PATCH 1/5] implement interactive menu and input prompt for user navigation and target selection --- strix/interface/main.py | 436 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 435 insertions(+), 1 deletion(-) diff --git a/strix/interface/main.py b/strix/interface/main.py index 063dc10..1fa6f52 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -10,12 +10,18 @@ import shutil import sys from pathlib import Path +from typing import Any import litellm from docker.errors import DockerException from rich.console import Console from rich.panel import Panel from rich.text import Text +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Container +from textual.reactive import reactive +from textual.widgets import Input, Static from strix.interface.cli import run_cli from strix.interface.tui import run_tui @@ -238,6 +244,424 @@ async def warm_up_llm() -> None: sys.exit(1) +class InteractiveMenuApp(App): # type: ignore[misc] + """Interactive BIOS-style menu app with arrow key navigation.""" + + CSS = """ + Screen { + align: center middle; + background: #1a1a1a; + } + + #menu-container { + width: 90; + height: auto; + border: solid #22c55e; + padding: 2; + background: #1a1a1a; + } + + #menu-title { + text-align: center; + color: #22c55e; + text-style: bold; + margin-bottom: 1; + } + + #menu-subtitle { + text-align: center; + color: #d4d4d4; + margin-bottom: 2; + } + + #menu-options { + height: auto; + margin: 1; + } + + .menu-item { + padding: 0 1; + margin: 0; + height: 1; + } + + .menu-item.selected { + background: #262626; + } + + #menu-description { + text-align: left; + padding: 1; + margin-top: 2; + border-top: solid #22c55e; + color: #a8a29e; + height: 3; + } + + #menu-footer { + text-align: center; + padding: 1; + margin-top: 1; + color: #a8a29e; + } + """ + + BINDINGS = [ + Binding("up", "move_up", "Move Up", priority=True), + Binding("down", "move_down", "Move Down", priority=True), + Binding("enter", "select", "Select", priority=True), + Binding("q", "quit", "Quit", priority=True), + Binding("escape", "quit", "Quit", priority=True), + ] + + selected_index = reactive(0) + + def __init__(self, menu_options: list[dict[str, Any]]) -> None: + super().__init__() + self.menu_options = menu_options + self.result: int | None = None + self._menu_items: list[Static] = [] + self._description_widget: Static | None = None + + def compose(self) -> ComposeResult: + with Container(id="menu-container"): + yield Static("🦉 STRIX CYBERSECURITY AGENT", id="menu-title") + yield Static("Select a usage scenario:", id="menu-subtitle") + + with Container(id="menu-options"): + for i, option in enumerate(self.menu_options): + checkbox = "[x]" if i == 0 else "[ ]" + item = Static( + f"{checkbox} {i + 1}. {option['title']}", + classes="menu-item", + id=f"item-{i}", + ) + self._menu_items.append(item) + yield item + + yield Static("", id="menu-description") + yield Static("↑/↓: Navigate | Enter: Select | Q/Esc: Quit", id="menu-footer") + + def on_mount(self) -> None: + """Initialize the menu.""" + self._description_widget = self.query_one("#menu-description", Static) + self._update_selection() + + def watch_selected_index(self, selected_index: int) -> None: + """Update selection when index changes.""" + self._update_selection() + + def _update_selection(self) -> None: + """Update the visual selection and description.""" + for i, item_widget in enumerate(self._menu_items): + if i == self.selected_index: + # Update checkbox to [x] and highlight + item_widget.update(f"[x] {i + 1}. {self.menu_options[i]['title']}") + item_widget.add_class("selected") + else: + # Update checkbox to [ ] and remove highlight + item_widget.update(f"[ ] {i + 1}. {self.menu_options[i]['title']}") + item_widget.remove_class("selected") + + # Update description at bottom + if self._description_widget: + selected = self.menu_options[self.selected_index] + desc_text = f"{selected['description']}\nExample: {selected['example']}" + self._description_widget.update(desc_text) + + def action_move_up(self) -> None: + """Move selection up.""" + if self.selected_index > 0: + self.selected_index -= 1 + + def action_move_down(self) -> None: + """Move selection down.""" + if self.selected_index < len(self.menu_options) - 1: + self.selected_index += 1 + + def action_select(self) -> None: + """Select the current option.""" + self.result = self.selected_index + 1 + self.exit(result=self.result) + + def action_quit(self) -> None: + """Quit the menu.""" + self.exit(result=None) + + +class InputPromptApp(App): # type: ignore[misc] + """Input prompt app with same styling as menu.""" + + CSS = """ + Screen { + align: center middle; + background: #1a1a1a; + } + + #input-container { + width: 80; + height: auto; + border: solid #22c55e; + padding: 2; + background: #1a1a1a; + } + + #input-title { + text-align: center; + color: #22c55e; + text-style: bold; + margin-bottom: 1; + } + + #input-label { + text-align: left; + color: #d4d4d4; + margin-bottom: 1; + margin-top: 1; + } + + #input-field { + width: 100%; + margin-bottom: 1; + } + + #input-description { + text-align: left; + padding: 1; + margin-top: 1; + border-top: solid #22c55e; + color: #a8a29e; + height: 2; + } + + #input-footer { + text-align: center; + padding: 1; + margin-top: 1; + color: #a8a29e; + } + """ + + BINDINGS = [ + Binding("escape", "quit", "Quit", priority=True), + ] + + def __init__(self, prompt_text: str, description: str = "", allow_empty: bool = False) -> None: + super().__init__() + self.prompt_text = prompt_text + self.description = description + self.allow_empty = allow_empty + self.result: str | None = None + + def compose(self) -> ComposeResult: + with Container(id="input-container"): + yield Static("🦉 STRIX CYBERSECURITY AGENT", id="input-title") + yield Static(self.prompt_text, id="input-label") + yield Input(placeholder="", id="input-field") + if self.description: + yield Static(self.description, id="input-description") + yield Static("Enter: Submit | Esc: Cancel", id="input-footer") + + def on_mount(self) -> None: + """Focus the input field on mount.""" + input_field = self.query_one("#input-field", Input) + input_field.focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle input submission.""" + value = event.value.strip() + if not value and not self.allow_empty: + return # Don't submit empty values + + self.result = value + self.exit(result=value) + + def action_quit(self) -> None: + """Quit the input prompt.""" + self.exit(result=None) + + +async def prompt_input_async(prompt_text: str, description: str = "", allow_empty: bool = False) -> str | None: + """Prompt for input using textual with same styling as menu.""" + app = InputPromptApp(prompt_text, description, allow_empty) + return await app.run_async() + + +async def show_interactive_menu_async() -> argparse.Namespace: + """Display an interactive menu using textual with arrow key navigation.""" + menu_options = [ + { + "title": "Local codebase analysis", + "description": "Analyze a local directory for security vulnerabilities", + "example": "strix --target ./app-directory", + "targets": [], + "instruction": None, + }, + { + "title": "Repository security review", + "description": "Clone and analyze a GitHub repository", + "example": "strix --target https://github.com/org/repo", + "targets": [], + "instruction": None, + }, + { + "title": "Web application assessment", + "description": "Perform penetration testing on a deployed web application", + "example": "strix --target https://your-app.com", + "targets": [], + "instruction": None, + }, + { + "title": "Multi-target white-box testing", + "description": "Test source code + deployed app simultaneously", + "example": "strix -t https://github.com/org/app -t https://your-app.com", + "targets": [], + "instruction": None, + }, + { + "title": "Test multiple environments", + "description": "Test dev, staging, and production environments simultaneously", + "example": "strix -t https://dev.your-app.com -t https://staging.your-app.com -t https://prod.your-app.com", + "targets": [], + "instruction": None, + }, + { + "title": "Focused testing with instructions", + "description": "Prioritize specific vulnerability types or testing approaches", + "example": "strix --target api.your-app.com --instruction \"Prioritize authentication and authorization testing\"", + "targets": [], + "instruction": None, + }, + { + "title": "Testing with credentials", + "description": "Test with provided credentials, focus on privilege escalation", + "example": "strix --target https://your-app.com --instruction \"Test with credentials: testuser/testpass. Focus on privilege escalation and access control bypasses.\"", + "targets": [], + "instruction": None, + }, + ] + + app = InteractiveMenuApp(menu_options) + choice = await app.run_async() + + console = Console() + + if choice is None: + console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") + sys.exit(0) + + selected_option = menu_options[choice - 1] + + # Create a namespace object with the selected option + args = argparse.Namespace() + args.target = None + args.targets_info = [] + args.instruction = selected_option.get("instruction") + args.run_name = None + args.non_interactive = False + args._menu_selection = selected_option + + # Prompt for target(s) based on selection using textual input + if choice in [4, 5]: # Multi-target scenarios + targets = [] + while True: + target = await prompt_input_async( + "Enter target (empty line to finish)", + f"Target {len(targets) + 1} of multiple targets", + allow_empty=True, + ) + if target is None: + if targets: + break + console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") + sys.exit(0) + if not target: + if targets: + break + continue + targets.append(target) + args.target = targets + else: + target_prompt_text = "Enter target" + target_description = "" + if choice == 1: # Local codebase + target_prompt_text = "Enter local directory path" + target_description = "Example: ./app-directory or /path/to/project" + elif choice == 2: # Repository + target_prompt_text = "Enter repository URL" + target_description = "Example: https://github.com/org/repo or git@github.com:org/repo.git" + elif choice == 3: # Web app + target_prompt_text = "Enter web application URL" + target_description = "Example: https://your-app.com or http://localhost:3000" + elif choice == 6: # Focused testing + target_prompt_text = "Enter target URL" + target_description = "Example: api.your-app.com or https://api.example.com" + elif choice == 7: # With credentials + target_prompt_text = "Enter target URL" + target_description = "Example: https://your-app.com or http://localhost:8080" + + target = await prompt_input_async(target_prompt_text, target_description) + if target is None or not target: + console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") + sys.exit(0) + args.target = [target] + + # For focused testing and credentials, prompt for instruction if not set + if choice == 6 and not args.instruction: + instruction = await prompt_input_async( + "Enter instructions (optional)", + "Prioritize specific vulnerability types or testing approaches", + allow_empty=True, + ) + if instruction: + args.instruction = instruction + elif choice == 7 and not args.instruction: + credentials = await prompt_input_async( + "Enter credentials (format: username/password)", + "Example: admin:password123 or testuser/testpass", + allow_empty=True, + ) + instruction_text = await prompt_input_async( + "Enter additional instructions (optional)", + "Focus on privilege escalation and access control bypasses", + allow_empty=True, + ) + if credentials: + instruction_parts = [f"Test with credentials: {credentials}"] + if instruction_text: + instruction_parts.append(instruction_text) + args.instruction = ". ".join(instruction_parts) + "." + elif instruction_text: + args.instruction = instruction_text + + # Process targets + args.targets_info = [] + for target in args.target: + try: + target_type, target_dict = infer_target_type(target) + + if target_type == "local_code": + display_target = target_dict.get("target_path", target) + else: + display_target = target + + args.targets_info.append( + {"type": target_type, "details": target_dict, "original": display_target} + ) + except ValueError as e: + console.print(f"[bold red]Invalid target '{target}': {e}[/bold red]\n") + sys.exit(1) + + assign_workspace_subdirs(args.targets_info) + + return args + + +def show_interactive_menu() -> argparse.Namespace: + """Display an interactive menu using textual with arrow key navigation.""" + return asyncio.run(show_interactive_menu_async()) + + def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Strix Multi-Agent Cybersecurity Penetration Testing Tool", @@ -270,7 +694,7 @@ def parse_arguments() -> argparse.Namespace: "-t", "--target", type=str, - required=True, + required=False, action="append", help="Target to test (URL, repository, local directory path, or domain name). " "Can be specified multiple times for multi-target scans.", @@ -304,6 +728,12 @@ def parse_arguments() -> argparse.Namespace: args = parser.parse_args() + # If no targets provided, we'll show interactive menu in main() + if not args.target: + args.target = None + args.targets_info = [] + return args + args.targets_info = [] for target in args.target: try: @@ -446,6 +876,10 @@ def main() -> None: args = parse_arguments() + # If no targets provided, show interactive menu + if not args.target: + args = show_interactive_menu() + check_docker_installed() pull_docker_image() From 5be79c41ab7405c7cc568ff5598e6c2763e7c3d3 Mon Sep 17 00:00:00 2001 From: Navid Mirzaaghazadeh Date: Sat, 8 Nov 2025 20:59:01 +0300 Subject: [PATCH 2/5] add configuration management interface for Strix settings --- strix/interface/config_manager.py | 78 ++++++++ strix/interface/main.py | 317 +++++++++++++++++++++++++++++- 2 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 strix/interface/config_manager.py diff --git a/strix/interface/config_manager.py b/strix/interface/config_manager.py new file mode 100644 index 0000000..7a48b03 --- /dev/null +++ b/strix/interface/config_manager.py @@ -0,0 +1,78 @@ +"""Configuration manager for Strix settings.""" + +import os +from pathlib import Path +from typing import Any + +from dotenv import dotenv_values, set_key + + +class ConfigManager: + """Manages Strix configuration stored in ~/.strix/.env""" + + CONFIG_DIR = Path.home() / ".strix" + CONFIG_FILE = CONFIG_DIR / ".env" + + REQUIRED_KEYS = ["STRIX_LLM", "LLM_API_KEY"] + OPTIONAL_KEYS = ["PERPLEXITY_API_KEY", "LLM_API_BASE", "OPENAI_API_BASE", "LITELLM_BASE_URL", "OLLAMA_API_BASE"] + + @classmethod + def ensure_config_dir(cls) -> None: + """Ensure the config directory exists.""" + cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + if not cls.CONFIG_FILE.exists(): + cls.CONFIG_FILE.touch() + + @classmethod + def load_config(cls) -> dict[str, str]: + """Load configuration from ~/.strix/.env file.""" + cls.ensure_config_dir() + + if not cls.CONFIG_FILE.exists(): + return {} + + config = dotenv_values(cls.CONFIG_FILE) + # Filter out None values + return {k: v for k, v in config.items() if v is not None} + + @classmethod + def save_config(cls, config: dict[str, str]) -> None: + """Save configuration to ~/.strix/.env file.""" + cls.ensure_config_dir() + + for key, value in config.items(): + set_key(cls.CONFIG_FILE, key, value) + + @classmethod + def get_value(cls, key: str, default: str = "") -> str: + """Get a configuration value.""" + config = cls.load_config() + return config.get(key, default) + + @classmethod + def set_value(cls, key: str, value: str) -> None: + """Set a configuration value.""" + config = cls.load_config() + config[key] = value + cls.save_config(config) + + @classmethod + def get_all_config(cls) -> dict[str, str]: + """Get all configuration values.""" + return cls.load_config() + + @classmethod + def update_config(cls, updates: dict[str, str]) -> None: + """Update multiple configuration values.""" + config = cls.load_config() + config.update(updates) + cls.save_config(config) + + @classmethod + def apply_to_environment(cls) -> None: + """Apply configuration to current environment.""" + config = cls.load_config() + for key, value in config.items(): + if value: # Only set non-empty values + os.environ[key] = value + diff --git a/strix/interface/main.py b/strix/interface/main.py index 1fa6f52..49008cc 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -24,6 +24,7 @@ from textual.widgets import Input, Static from strix.interface.cli import run_cli +from strix.interface.config_manager import ConfigManager from strix.interface.tui import run_tui from strix.interface.utils import ( assign_workspace_subdirs, @@ -487,7 +488,224 @@ async def prompt_input_async(prompt_text: str, description: str = "", allow_empt return await app.run_async() -async def show_interactive_menu_async() -> argparse.Namespace: +class ConfigurationApp(App): # type: ignore[misc] + """Configuration management app.""" + + CSS = """ + Screen { + align: center middle; + background: #1a1a1a; + } + + #config-container { + width: 90; + height: auto; + border: solid #22c55e; + padding: 2; + background: #1a1a1a; + } + + #config-title { + text-align: center; + color: #22c55e; + text-style: bold; + margin-bottom: 1; + } + + #config-subtitle { + text-align: center; + color: #d4d4d4; + margin-bottom: 2; + } + + .config-field { + margin: 1 0; + } + + .config-label { + color: #d4d4d4; + margin-bottom: 1; + } + + .config-input { + width: 100%; + margin-bottom: 1; + } + + .config-description { + color: #a8a29e; + margin-top: 1; + margin-bottom: 1; + } + + #config-footer { + text-align: center; + padding: 1; + margin-top: 2; + border-top: solid #22c55e; + color: #a8a29e; + } + """ + + BINDINGS = [ + Binding("escape", "quit", "Back to Menu", priority=True), + Binding("ctrl+s", "save", "Save", priority=True), + Binding("up", "move_up", "Move Up", priority=True), + Binding("down", "move_down", "Move Down", priority=True), + ] + + def __init__(self) -> None: + super().__init__() + self.config_manager = ConfigManager() + self._inputs: dict[str, Input] = {} + self._input_order: list[str] = [] + + def compose(self) -> ComposeResult: + config = self.config_manager.get_all_config() + + with Container(id="config-container"): + yield Static("🦉 STRIX CONFIGURATION", id="config-title") + yield Static("Manage your Strix settings", id="config-subtitle") + + # STRIX_LLM + yield Static("Model Name (STRIX_LLM)", classes="config-label") + yield Static("Example: openai/gpt-5, anthropic/claude-3-5-sonnet", classes="config-description") + llm_input = Input( + value=config.get("STRIX_LLM", ""), + placeholder="openai/gpt-5", + id="strix_llm", + classes="config-input", + ) + self._inputs["STRIX_LLM"] = llm_input + self._input_order.append("STRIX_LLM") + yield llm_input + + # LLM_API_KEY + yield Static("LLM API Key (LLM_API_KEY)", classes="config-label") + yield Static("Your API key for the LLM provider", classes="config-description") + api_key_input = Input( + value=config.get("LLM_API_KEY", ""), + placeholder="sk-...", + password=True, + id="llm_api_key", + classes="config-input", + ) + self._inputs["LLM_API_KEY"] = api_key_input + self._input_order.append("LLM_API_KEY") + yield api_key_input + + # PERPLEXITY_API_KEY + yield Static("Perplexity API Key (PERPLEXITY_API_KEY)", classes="config-label") + yield Static("Optional: For web search capabilities", classes="config-description") + perplexity_input = Input( + value=config.get("PERPLEXITY_API_KEY", ""), + placeholder="pplx-...", + password=True, + id="perplexity_api_key", + classes="config-input", + ) + self._inputs["PERPLEXITY_API_KEY"] = perplexity_input + self._input_order.append("PERPLEXITY_API_KEY") + yield perplexity_input + + # LLM_API_BASE (optional) + yield Static("LLM API Base URL (LLM_API_BASE)", classes="config-label") + yield Static("Optional: For local models (e.g., http://localhost:11434)", classes="config-description") + api_base_input = Input( + value=config.get("LLM_API_BASE", ""), + placeholder="http://localhost:11434", + id="llm_api_base", + classes="config-input", + ) + self._inputs["LLM_API_BASE"] = api_base_input + self._input_order.append("LLM_API_BASE") + yield api_base_input + + yield Static("↑/↓: Navigate | Ctrl+S: Save | Esc: Back to Menu", id="config-footer") + + def on_mount(self) -> None: + """Focus the first input on mount.""" + if self._inputs and self._input_order: + first_key = self._input_order[0] + self._inputs[first_key].focus() + + def _get_current_input_index(self) -> int: + """Get the index of the currently focused input.""" + focused = self.focused + if isinstance(focused, Input): + for i, key in enumerate(self._input_order): + if self._inputs[key] == focused: + return i + return 0 + + def action_move_up(self) -> None: + """Move focus to the previous input.""" + current_idx = self._get_current_input_index() + if current_idx > 0: + prev_key = self._input_order[current_idx - 1] + self._inputs[prev_key].focus() + + def action_move_down(self) -> None: + """Move focus to the next input.""" + current_idx = self._get_current_input_index() + if current_idx < len(self._input_order) - 1: + next_key = self._input_order[current_idx + 1] + self._inputs[next_key].focus() + + def action_save(self) -> None: + """Save configuration.""" + updates = {} + for key, input_widget in self._inputs.items(): + value = input_widget.value.strip() + if value: # Only save non-empty values + updates[key] = value + elif key in ["STRIX_LLM", "LLM_API_KEY"]: # Required fields + # Keep existing value if not changed + existing = self.config_manager.get_value(key) + if existing: + updates[key] = existing + + self.config_manager.update_config(updates) + self.config_manager.apply_to_environment() + + # Re-apply litellm settings immediately after saving + import litellm + if updates.get("LLM_API_KEY"): + litellm.api_key = updates["LLM_API_KEY"] + elif "LLM_API_KEY" in updates: # Empty value - clear it + litellm.api_key = None + + if updates.get("LLM_API_BASE"): + litellm.api_base = updates["LLM_API_BASE"] + elif updates.get("OPENAI_API_BASE"): + litellm.api_base = updates["OPENAI_API_BASE"] + elif updates.get("LITELLM_BASE_URL"): + litellm.api_base = updates["LITELLM_BASE_URL"] + elif updates.get("OLLAMA_API_BASE"): + litellm.api_base = updates["OLLAMA_API_BASE"] + elif "LLM_API_BASE" in updates or "OPENAI_API_BASE" in updates: # Empty value - clear it + litellm.api_base = None + + # Show success message + from rich.console import Console + console = Console() + console.print("\n[bold green]✓ Configuration saved successfully![/bold green]\n") + + self.exit(result=True) + + def action_quit(self) -> None: + """Quit without saving.""" + self.exit(result=False) + + +async def show_configuration_async() -> bool: + """Show configuration management screen.""" + app = ConfigurationApp() + result = await app.run_async() + return result is True + + +async def show_interactive_menu_async() -> argparse.Namespace | None: """Display an interactive menu using textual with arrow key navigation.""" menu_options = [ { @@ -539,10 +757,31 @@ async def show_interactive_menu_async() -> argparse.Namespace: "targets": [], "instruction": None, }, + { + "title": "Configuration", + "description": "Manage Strix settings (API keys, model, etc.)", + "example": "Configure STRIX_LLM, LLM_API_KEY, PERPLEXITY_API_KEY", + "targets": [], + "instruction": None, + "is_config": True, + }, ] - app = InteractiveMenuApp(menu_options) - choice = await app.run_async() + while True: + app = InteractiveMenuApp(menu_options) + choice = await app.run_async() + + if choice is None: + return None + + # Check if configuration was selected + if choice == 8: # Configuration option + await show_configuration_async() + # Return to menu after configuration + continue + + # Regular menu option selected + break console = Console() @@ -550,6 +789,59 @@ async def show_interactive_menu_async() -> argparse.Namespace: console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") sys.exit(0) + # Get menu options again (they might have been modified) + menu_options = [ + { + "title": "Local codebase analysis", + "description": "Analyze a local directory for security vulnerabilities", + "example": "strix --target ./app-directory", + "targets": [], + "instruction": None, + }, + { + "title": "Repository security review", + "description": "Clone and analyze a GitHub repository", + "example": "strix --target https://github.com/org/repo", + "targets": [], + "instruction": None, + }, + { + "title": "Web application assessment", + "description": "Perform penetration testing on a deployed web application", + "example": "strix --target https://your-app.com", + "targets": [], + "instruction": None, + }, + { + "title": "Multi-target white-box testing", + "description": "Test source code + deployed app simultaneously", + "example": "strix -t https://github.com/org/app -t https://your-app.com", + "targets": [], + "instruction": None, + }, + { + "title": "Test multiple environments", + "description": "Test dev, staging, and production environments simultaneously", + "example": "strix -t https://dev.your-app.com -t https://staging.your-app.com -t https://prod.your-app.com", + "targets": [], + "instruction": None, + }, + { + "title": "Focused testing with instructions", + "description": "Prioritize specific vulnerability types or testing approaches", + "example": "strix --target api.your-app.com --instruction \"Prioritize authentication and authorization testing\"", + "targets": [], + "instruction": None, + }, + { + "title": "Testing with credentials", + "description": "Test with provided credentials, focus on privilege escalation", + "example": "strix --target https://your-app.com --instruction \"Test with credentials: testuser/testpass. Focus on privilege escalation and access control bypasses.\"", + "targets": [], + "instruction": None, + }, + ] + selected_option = menu_options[choice - 1] # Create a namespace object with the selected option @@ -657,7 +949,7 @@ async def show_interactive_menu_async() -> argparse.Namespace: return args -def show_interactive_menu() -> argparse.Namespace: +def show_interactive_menu() -> argparse.Namespace | None: """Display an interactive menu using textual with arrow key navigation.""" return asyncio.run(show_interactive_menu_async()) @@ -874,6 +1166,23 @@ def main() -> None: if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + # Load configuration from ~/.strix/.env and apply to environment FIRST + # This must happen before any LLM modules are used + ConfigManager.apply_to_environment() + + # Re-apply litellm settings after loading config (in case llm.py was already imported) + config = ConfigManager.get_all_config() + if config.get("LLM_API_KEY"): + litellm.api_key = config["LLM_API_KEY"] + if config.get("LLM_API_BASE"): + litellm.api_base = config["LLM_API_BASE"] + elif config.get("OPENAI_API_BASE"): + litellm.api_base = config["OPENAI_API_BASE"] + elif config.get("LITELLM_BASE_URL"): + litellm.api_base = config["LITELLM_BASE_URL"] + elif config.get("OLLAMA_API_BASE"): + litellm.api_base = config["OLLAMA_API_BASE"] + args = parse_arguments() # If no targets provided, show interactive menu From 00b1fe79944e91a035c0817b49ef11782b95f8dc Mon Sep 17 00:00:00 2001 From: Navid Mirzaaghazadeh Date: Sun, 9 Nov 2025 16:55:16 +0300 Subject: [PATCH 3/5] enhance ConfigManager with ClassVar annotations and improve apply_to_environment method documentation --- strix/interface/config_manager.py | 52 ++- strix/interface/main.py | 725 +----------------------------- strix/interface/menu.py | 533 ++++++++++++++++++++++ strix/interface/ui_constants.py | 194 ++++++++ 4 files changed, 766 insertions(+), 738 deletions(-) create mode 100644 strix/interface/menu.py create mode 100644 strix/interface/ui_constants.py diff --git a/strix/interface/config_manager.py b/strix/interface/config_manager.py index 7a48b03..cd4b761 100644 --- a/strix/interface/config_manager.py +++ b/strix/interface/config_manager.py @@ -2,77 +2,87 @@ import os from pathlib import Path -from typing import Any +from typing import ClassVar from dotenv import dotenv_values, set_key class ConfigManager: """Manages Strix configuration stored in ~/.strix/.env""" - - CONFIG_DIR = Path.home() / ".strix" - CONFIG_FILE = CONFIG_DIR / ".env" - - REQUIRED_KEYS = ["STRIX_LLM", "LLM_API_KEY"] - OPTIONAL_KEYS = ["PERPLEXITY_API_KEY", "LLM_API_BASE", "OPENAI_API_BASE", "LITELLM_BASE_URL", "OLLAMA_API_BASE"] - + + CONFIG_DIR: ClassVar[Path] = Path.home() / ".strix" + CONFIG_FILE: ClassVar[Path] = CONFIG_DIR / ".env" + + REQUIRED_KEYS: ClassVar[list[str]] = ["STRIX_LLM", "LLM_API_KEY"] + OPTIONAL_KEYS: ClassVar[list[str]] = [ + "PERPLEXITY_API_KEY", + "LLM_API_BASE", + "OPENAI_API_BASE", + "LITELLM_BASE_URL", + "OLLAMA_API_BASE", + ] + @classmethod def ensure_config_dir(cls) -> None: """Ensure the config directory exists.""" cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True) if not cls.CONFIG_FILE.exists(): cls.CONFIG_FILE.touch() - + @classmethod def load_config(cls) -> dict[str, str]: """Load configuration from ~/.strix/.env file.""" cls.ensure_config_dir() - + if not cls.CONFIG_FILE.exists(): return {} - + config = dotenv_values(cls.CONFIG_FILE) # Filter out None values return {k: v for k, v in config.items() if v is not None} - + @classmethod def save_config(cls, config: dict[str, str]) -> None: """Save configuration to ~/.strix/.env file.""" cls.ensure_config_dir() - + for key, value in config.items(): set_key(cls.CONFIG_FILE, key, value) - + @classmethod def get_value(cls, key: str, default: str = "") -> str: """Get a configuration value.""" config = cls.load_config() return config.get(key, default) - + @classmethod def set_value(cls, key: str, value: str) -> None: """Set a configuration value.""" config = cls.load_config() config[key] = value cls.save_config(config) - + @classmethod def get_all_config(cls) -> dict[str, str]: """Get all configuration values.""" return cls.load_config() - + @classmethod def update_config(cls, updates: dict[str, str]) -> None: """Update multiple configuration values.""" config = cls.load_config() config.update(updates) cls.save_config(config) - + @classmethod def apply_to_environment(cls) -> None: - """Apply configuration to current environment.""" + """Apply configuration to current environment. + + Only sets values that are not already set in the environment, + allowing environment variables to override config file values. + """ config = cls.load_config() for key, value in config.items(): - if value: # Only set non-empty values + if value and key not in os.environ: + # Only set if not already in environment (env vars take precedence) os.environ[key] = value - diff --git a/strix/interface/main.py b/strix/interface/main.py index 49008cc..fc25e18 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -10,21 +10,16 @@ import shutil import sys from pathlib import Path -from typing import Any import litellm from docker.errors import DockerException from rich.console import Console from rich.panel import Panel from rich.text import Text -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Container -from textual.reactive import reactive -from textual.widgets import Input, Static from strix.interface.cli import run_cli from strix.interface.config_manager import ConfigManager +from strix.interface.menu import show_interactive_menu from strix.interface.tui import run_tui from strix.interface.utils import ( assign_workspace_subdirs, @@ -245,715 +240,6 @@ async def warm_up_llm() -> None: sys.exit(1) -class InteractiveMenuApp(App): # type: ignore[misc] - """Interactive BIOS-style menu app with arrow key navigation.""" - - CSS = """ - Screen { - align: center middle; - background: #1a1a1a; - } - - #menu-container { - width: 90; - height: auto; - border: solid #22c55e; - padding: 2; - background: #1a1a1a; - } - - #menu-title { - text-align: center; - color: #22c55e; - text-style: bold; - margin-bottom: 1; - } - - #menu-subtitle { - text-align: center; - color: #d4d4d4; - margin-bottom: 2; - } - - #menu-options { - height: auto; - margin: 1; - } - - .menu-item { - padding: 0 1; - margin: 0; - height: 1; - } - - .menu-item.selected { - background: #262626; - } - - #menu-description { - text-align: left; - padding: 1; - margin-top: 2; - border-top: solid #22c55e; - color: #a8a29e; - height: 3; - } - - #menu-footer { - text-align: center; - padding: 1; - margin-top: 1; - color: #a8a29e; - } - """ - - BINDINGS = [ - Binding("up", "move_up", "Move Up", priority=True), - Binding("down", "move_down", "Move Down", priority=True), - Binding("enter", "select", "Select", priority=True), - Binding("q", "quit", "Quit", priority=True), - Binding("escape", "quit", "Quit", priority=True), - ] - - selected_index = reactive(0) - - def __init__(self, menu_options: list[dict[str, Any]]) -> None: - super().__init__() - self.menu_options = menu_options - self.result: int | None = None - self._menu_items: list[Static] = [] - self._description_widget: Static | None = None - - def compose(self) -> ComposeResult: - with Container(id="menu-container"): - yield Static("🦉 STRIX CYBERSECURITY AGENT", id="menu-title") - yield Static("Select a usage scenario:", id="menu-subtitle") - - with Container(id="menu-options"): - for i, option in enumerate(self.menu_options): - checkbox = "[x]" if i == 0 else "[ ]" - item = Static( - f"{checkbox} {i + 1}. {option['title']}", - classes="menu-item", - id=f"item-{i}", - ) - self._menu_items.append(item) - yield item - - yield Static("", id="menu-description") - yield Static("↑/↓: Navigate | Enter: Select | Q/Esc: Quit", id="menu-footer") - - def on_mount(self) -> None: - """Initialize the menu.""" - self._description_widget = self.query_one("#menu-description", Static) - self._update_selection() - - def watch_selected_index(self, selected_index: int) -> None: - """Update selection when index changes.""" - self._update_selection() - - def _update_selection(self) -> None: - """Update the visual selection and description.""" - for i, item_widget in enumerate(self._menu_items): - if i == self.selected_index: - # Update checkbox to [x] and highlight - item_widget.update(f"[x] {i + 1}. {self.menu_options[i]['title']}") - item_widget.add_class("selected") - else: - # Update checkbox to [ ] and remove highlight - item_widget.update(f"[ ] {i + 1}. {self.menu_options[i]['title']}") - item_widget.remove_class("selected") - - # Update description at bottom - if self._description_widget: - selected = self.menu_options[self.selected_index] - desc_text = f"{selected['description']}\nExample: {selected['example']}" - self._description_widget.update(desc_text) - - def action_move_up(self) -> None: - """Move selection up.""" - if self.selected_index > 0: - self.selected_index -= 1 - - def action_move_down(self) -> None: - """Move selection down.""" - if self.selected_index < len(self.menu_options) - 1: - self.selected_index += 1 - - def action_select(self) -> None: - """Select the current option.""" - self.result = self.selected_index + 1 - self.exit(result=self.result) - - def action_quit(self) -> None: - """Quit the menu.""" - self.exit(result=None) - - -class InputPromptApp(App): # type: ignore[misc] - """Input prompt app with same styling as menu.""" - - CSS = """ - Screen { - align: center middle; - background: #1a1a1a; - } - - #input-container { - width: 80; - height: auto; - border: solid #22c55e; - padding: 2; - background: #1a1a1a; - } - - #input-title { - text-align: center; - color: #22c55e; - text-style: bold; - margin-bottom: 1; - } - - #input-label { - text-align: left; - color: #d4d4d4; - margin-bottom: 1; - margin-top: 1; - } - - #input-field { - width: 100%; - margin-bottom: 1; - } - - #input-description { - text-align: left; - padding: 1; - margin-top: 1; - border-top: solid #22c55e; - color: #a8a29e; - height: 2; - } - - #input-footer { - text-align: center; - padding: 1; - margin-top: 1; - color: #a8a29e; - } - """ - - BINDINGS = [ - Binding("escape", "quit", "Quit", priority=True), - ] - - def __init__(self, prompt_text: str, description: str = "", allow_empty: bool = False) -> None: - super().__init__() - self.prompt_text = prompt_text - self.description = description - self.allow_empty = allow_empty - self.result: str | None = None - - def compose(self) -> ComposeResult: - with Container(id="input-container"): - yield Static("🦉 STRIX CYBERSECURITY AGENT", id="input-title") - yield Static(self.prompt_text, id="input-label") - yield Input(placeholder="", id="input-field") - if self.description: - yield Static(self.description, id="input-description") - yield Static("Enter: Submit | Esc: Cancel", id="input-footer") - - def on_mount(self) -> None: - """Focus the input field on mount.""" - input_field = self.query_one("#input-field", Input) - input_field.focus() - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle input submission.""" - value = event.value.strip() - if not value and not self.allow_empty: - return # Don't submit empty values - - self.result = value - self.exit(result=value) - - def action_quit(self) -> None: - """Quit the input prompt.""" - self.exit(result=None) - - -async def prompt_input_async(prompt_text: str, description: str = "", allow_empty: bool = False) -> str | None: - """Prompt for input using textual with same styling as menu.""" - app = InputPromptApp(prompt_text, description, allow_empty) - return await app.run_async() - - -class ConfigurationApp(App): # type: ignore[misc] - """Configuration management app.""" - - CSS = """ - Screen { - align: center middle; - background: #1a1a1a; - } - - #config-container { - width: 90; - height: auto; - border: solid #22c55e; - padding: 2; - background: #1a1a1a; - } - - #config-title { - text-align: center; - color: #22c55e; - text-style: bold; - margin-bottom: 1; - } - - #config-subtitle { - text-align: center; - color: #d4d4d4; - margin-bottom: 2; - } - - .config-field { - margin: 1 0; - } - - .config-label { - color: #d4d4d4; - margin-bottom: 1; - } - - .config-input { - width: 100%; - margin-bottom: 1; - } - - .config-description { - color: #a8a29e; - margin-top: 1; - margin-bottom: 1; - } - - #config-footer { - text-align: center; - padding: 1; - margin-top: 2; - border-top: solid #22c55e; - color: #a8a29e; - } - """ - - BINDINGS = [ - Binding("escape", "quit", "Back to Menu", priority=True), - Binding("ctrl+s", "save", "Save", priority=True), - Binding("up", "move_up", "Move Up", priority=True), - Binding("down", "move_down", "Move Down", priority=True), - ] - - def __init__(self) -> None: - super().__init__() - self.config_manager = ConfigManager() - self._inputs: dict[str, Input] = {} - self._input_order: list[str] = [] - - def compose(self) -> ComposeResult: - config = self.config_manager.get_all_config() - - with Container(id="config-container"): - yield Static("🦉 STRIX CONFIGURATION", id="config-title") - yield Static("Manage your Strix settings", id="config-subtitle") - - # STRIX_LLM - yield Static("Model Name (STRIX_LLM)", classes="config-label") - yield Static("Example: openai/gpt-5, anthropic/claude-3-5-sonnet", classes="config-description") - llm_input = Input( - value=config.get("STRIX_LLM", ""), - placeholder="openai/gpt-5", - id="strix_llm", - classes="config-input", - ) - self._inputs["STRIX_LLM"] = llm_input - self._input_order.append("STRIX_LLM") - yield llm_input - - # LLM_API_KEY - yield Static("LLM API Key (LLM_API_KEY)", classes="config-label") - yield Static("Your API key for the LLM provider", classes="config-description") - api_key_input = Input( - value=config.get("LLM_API_KEY", ""), - placeholder="sk-...", - password=True, - id="llm_api_key", - classes="config-input", - ) - self._inputs["LLM_API_KEY"] = api_key_input - self._input_order.append("LLM_API_KEY") - yield api_key_input - - # PERPLEXITY_API_KEY - yield Static("Perplexity API Key (PERPLEXITY_API_KEY)", classes="config-label") - yield Static("Optional: For web search capabilities", classes="config-description") - perplexity_input = Input( - value=config.get("PERPLEXITY_API_KEY", ""), - placeholder="pplx-...", - password=True, - id="perplexity_api_key", - classes="config-input", - ) - self._inputs["PERPLEXITY_API_KEY"] = perplexity_input - self._input_order.append("PERPLEXITY_API_KEY") - yield perplexity_input - - # LLM_API_BASE (optional) - yield Static("LLM API Base URL (LLM_API_BASE)", classes="config-label") - yield Static("Optional: For local models (e.g., http://localhost:11434)", classes="config-description") - api_base_input = Input( - value=config.get("LLM_API_BASE", ""), - placeholder="http://localhost:11434", - id="llm_api_base", - classes="config-input", - ) - self._inputs["LLM_API_BASE"] = api_base_input - self._input_order.append("LLM_API_BASE") - yield api_base_input - - yield Static("↑/↓: Navigate | Ctrl+S: Save | Esc: Back to Menu", id="config-footer") - - def on_mount(self) -> None: - """Focus the first input on mount.""" - if self._inputs and self._input_order: - first_key = self._input_order[0] - self._inputs[first_key].focus() - - def _get_current_input_index(self) -> int: - """Get the index of the currently focused input.""" - focused = self.focused - if isinstance(focused, Input): - for i, key in enumerate(self._input_order): - if self._inputs[key] == focused: - return i - return 0 - - def action_move_up(self) -> None: - """Move focus to the previous input.""" - current_idx = self._get_current_input_index() - if current_idx > 0: - prev_key = self._input_order[current_idx - 1] - self._inputs[prev_key].focus() - - def action_move_down(self) -> None: - """Move focus to the next input.""" - current_idx = self._get_current_input_index() - if current_idx < len(self._input_order) - 1: - next_key = self._input_order[current_idx + 1] - self._inputs[next_key].focus() - - def action_save(self) -> None: - """Save configuration.""" - updates = {} - for key, input_widget in self._inputs.items(): - value = input_widget.value.strip() - if value: # Only save non-empty values - updates[key] = value - elif key in ["STRIX_LLM", "LLM_API_KEY"]: # Required fields - # Keep existing value if not changed - existing = self.config_manager.get_value(key) - if existing: - updates[key] = existing - - self.config_manager.update_config(updates) - self.config_manager.apply_to_environment() - - # Re-apply litellm settings immediately after saving - import litellm - if updates.get("LLM_API_KEY"): - litellm.api_key = updates["LLM_API_KEY"] - elif "LLM_API_KEY" in updates: # Empty value - clear it - litellm.api_key = None - - if updates.get("LLM_API_BASE"): - litellm.api_base = updates["LLM_API_BASE"] - elif updates.get("OPENAI_API_BASE"): - litellm.api_base = updates["OPENAI_API_BASE"] - elif updates.get("LITELLM_BASE_URL"): - litellm.api_base = updates["LITELLM_BASE_URL"] - elif updates.get("OLLAMA_API_BASE"): - litellm.api_base = updates["OLLAMA_API_BASE"] - elif "LLM_API_BASE" in updates or "OPENAI_API_BASE" in updates: # Empty value - clear it - litellm.api_base = None - - # Show success message - from rich.console import Console - console = Console() - console.print("\n[bold green]✓ Configuration saved successfully![/bold green]\n") - - self.exit(result=True) - - def action_quit(self) -> None: - """Quit without saving.""" - self.exit(result=False) - - -async def show_configuration_async() -> bool: - """Show configuration management screen.""" - app = ConfigurationApp() - result = await app.run_async() - return result is True - - -async def show_interactive_menu_async() -> argparse.Namespace | None: - """Display an interactive menu using textual with arrow key navigation.""" - menu_options = [ - { - "title": "Local codebase analysis", - "description": "Analyze a local directory for security vulnerabilities", - "example": "strix --target ./app-directory", - "targets": [], - "instruction": None, - }, - { - "title": "Repository security review", - "description": "Clone and analyze a GitHub repository", - "example": "strix --target https://github.com/org/repo", - "targets": [], - "instruction": None, - }, - { - "title": "Web application assessment", - "description": "Perform penetration testing on a deployed web application", - "example": "strix --target https://your-app.com", - "targets": [], - "instruction": None, - }, - { - "title": "Multi-target white-box testing", - "description": "Test source code + deployed app simultaneously", - "example": "strix -t https://github.com/org/app -t https://your-app.com", - "targets": [], - "instruction": None, - }, - { - "title": "Test multiple environments", - "description": "Test dev, staging, and production environments simultaneously", - "example": "strix -t https://dev.your-app.com -t https://staging.your-app.com -t https://prod.your-app.com", - "targets": [], - "instruction": None, - }, - { - "title": "Focused testing with instructions", - "description": "Prioritize specific vulnerability types or testing approaches", - "example": "strix --target api.your-app.com --instruction \"Prioritize authentication and authorization testing\"", - "targets": [], - "instruction": None, - }, - { - "title": "Testing with credentials", - "description": "Test with provided credentials, focus on privilege escalation", - "example": "strix --target https://your-app.com --instruction \"Test with credentials: testuser/testpass. Focus on privilege escalation and access control bypasses.\"", - "targets": [], - "instruction": None, - }, - { - "title": "Configuration", - "description": "Manage Strix settings (API keys, model, etc.)", - "example": "Configure STRIX_LLM, LLM_API_KEY, PERPLEXITY_API_KEY", - "targets": [], - "instruction": None, - "is_config": True, - }, - ] - - while True: - app = InteractiveMenuApp(menu_options) - choice = await app.run_async() - - if choice is None: - return None - - # Check if configuration was selected - if choice == 8: # Configuration option - await show_configuration_async() - # Return to menu after configuration - continue - - # Regular menu option selected - break - - console = Console() - - if choice is None: - console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") - sys.exit(0) - - # Get menu options again (they might have been modified) - menu_options = [ - { - "title": "Local codebase analysis", - "description": "Analyze a local directory for security vulnerabilities", - "example": "strix --target ./app-directory", - "targets": [], - "instruction": None, - }, - { - "title": "Repository security review", - "description": "Clone and analyze a GitHub repository", - "example": "strix --target https://github.com/org/repo", - "targets": [], - "instruction": None, - }, - { - "title": "Web application assessment", - "description": "Perform penetration testing on a deployed web application", - "example": "strix --target https://your-app.com", - "targets": [], - "instruction": None, - }, - { - "title": "Multi-target white-box testing", - "description": "Test source code + deployed app simultaneously", - "example": "strix -t https://github.com/org/app -t https://your-app.com", - "targets": [], - "instruction": None, - }, - { - "title": "Test multiple environments", - "description": "Test dev, staging, and production environments simultaneously", - "example": "strix -t https://dev.your-app.com -t https://staging.your-app.com -t https://prod.your-app.com", - "targets": [], - "instruction": None, - }, - { - "title": "Focused testing with instructions", - "description": "Prioritize specific vulnerability types or testing approaches", - "example": "strix --target api.your-app.com --instruction \"Prioritize authentication and authorization testing\"", - "targets": [], - "instruction": None, - }, - { - "title": "Testing with credentials", - "description": "Test with provided credentials, focus on privilege escalation", - "example": "strix --target https://your-app.com --instruction \"Test with credentials: testuser/testpass. Focus on privilege escalation and access control bypasses.\"", - "targets": [], - "instruction": None, - }, - ] - - selected_option = menu_options[choice - 1] - - # Create a namespace object with the selected option - args = argparse.Namespace() - args.target = None - args.targets_info = [] - args.instruction = selected_option.get("instruction") - args.run_name = None - args.non_interactive = False - args._menu_selection = selected_option - - # Prompt for target(s) based on selection using textual input - if choice in [4, 5]: # Multi-target scenarios - targets = [] - while True: - target = await prompt_input_async( - "Enter target (empty line to finish)", - f"Target {len(targets) + 1} of multiple targets", - allow_empty=True, - ) - if target is None: - if targets: - break - console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") - sys.exit(0) - if not target: - if targets: - break - continue - targets.append(target) - args.target = targets - else: - target_prompt_text = "Enter target" - target_description = "" - if choice == 1: # Local codebase - target_prompt_text = "Enter local directory path" - target_description = "Example: ./app-directory or /path/to/project" - elif choice == 2: # Repository - target_prompt_text = "Enter repository URL" - target_description = "Example: https://github.com/org/repo or git@github.com:org/repo.git" - elif choice == 3: # Web app - target_prompt_text = "Enter web application URL" - target_description = "Example: https://your-app.com or http://localhost:3000" - elif choice == 6: # Focused testing - target_prompt_text = "Enter target URL" - target_description = "Example: api.your-app.com or https://api.example.com" - elif choice == 7: # With credentials - target_prompt_text = "Enter target URL" - target_description = "Example: https://your-app.com or http://localhost:8080" - - target = await prompt_input_async(target_prompt_text, target_description) - if target is None or not target: - console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") - sys.exit(0) - args.target = [target] - - # For focused testing and credentials, prompt for instruction if not set - if choice == 6 and not args.instruction: - instruction = await prompt_input_async( - "Enter instructions (optional)", - "Prioritize specific vulnerability types or testing approaches", - allow_empty=True, - ) - if instruction: - args.instruction = instruction - elif choice == 7 and not args.instruction: - credentials = await prompt_input_async( - "Enter credentials (format: username/password)", - "Example: admin:password123 or testuser/testpass", - allow_empty=True, - ) - instruction_text = await prompt_input_async( - "Enter additional instructions (optional)", - "Focus on privilege escalation and access control bypasses", - allow_empty=True, - ) - if credentials: - instruction_parts = [f"Test with credentials: {credentials}"] - if instruction_text: - instruction_parts.append(instruction_text) - args.instruction = ". ".join(instruction_parts) + "." - elif instruction_text: - args.instruction = instruction_text - - # Process targets - args.targets_info = [] - for target in args.target: - try: - target_type, target_dict = infer_target_type(target) - - if target_type == "local_code": - display_target = target_dict.get("target_path", target) - else: - display_target = target - - args.targets_info.append( - {"type": target_type, "details": target_dict, "original": display_target} - ) - except ValueError as e: - console.print(f"[bold red]Invalid target '{target}': {e}[/bold red]\n") - sys.exit(1) - - assign_workspace_subdirs(args.targets_info) - - return args - - -def show_interactive_menu() -> argparse.Namespace | None: - """Display an interactive menu using textual with arrow key navigation.""" - return asyncio.run(show_interactive_menu_async()) - - def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Strix Multi-Agent Cybersecurity Penetration Testing Tool", @@ -1162,14 +448,16 @@ def pull_docker_image() -> None: console.print() -def main() -> None: +def main() -> None: # noqa: PLR0912 if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # Load configuration from ~/.strix/.env and apply to environment FIRST # This must happen before any LLM modules are used + # Note: apply_to_environment() only sets values not already in environment, + # allowing env vars to override config file values ConfigManager.apply_to_environment() - + # Re-apply litellm settings after loading config (in case llm.py was already imported) config = ConfigManager.get_all_config() if config.get("LLM_API_KEY"): @@ -1188,6 +476,9 @@ def main() -> None: # If no targets provided, show interactive menu if not args.target: args = show_interactive_menu() + # Handle cancellation - if user quits menu, exit cleanly + if args is None: + sys.exit(0) check_docker_installed() pull_docker_image() diff --git a/strix/interface/menu.py b/strix/interface/menu.py new file mode 100644 index 0000000..7dad2ae --- /dev/null +++ b/strix/interface/menu.py @@ -0,0 +1,533 @@ +"""Interactive menu applications for Strix.""" + +import argparse +import sys +from typing import Any + +import litellm +from rich.console import Console +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.reactive import reactive +from textual.widgets import Input, Static + +from strix.interface.config_manager import ConfigManager +from strix.interface.ui_constants import ( + BINDINGS_CONFIG, + BINDINGS_INPUT, + BINDINGS_MENU, + CONFIG_CSS, + INPUT_CSS, + MENU_CSS, +) +from strix.interface.utils import assign_workspace_subdirs, infer_target_type + + +class InteractiveMenuApp(App): # type: ignore[misc] + """Interactive BIOS-style menu app with arrow key navigation.""" + + CSS = MENU_CSS + BINDINGS = BINDINGS_MENU + + selected_index = reactive(0) + + def __init__(self, menu_options: list[dict[str, Any]]) -> None: + super().__init__() + self.menu_options = menu_options + self.result: int | None = None + self._menu_items: list[Static] = [] + self._description_widget: Static | None = None + + def compose(self) -> ComposeResult: + with Container(id="menu-container"): + yield Static("🦉 STRIX CYBERSECURITY AGENT", id="menu-title") + yield Static("Select a usage scenario:", id="menu-subtitle") + + with Container(id="menu-options"): + for i, option in enumerate(self.menu_options): + checkbox = "[x]" if i == 0 else "[ ]" + item = Static( + f"{checkbox} {i + 1}. {option['title']}", + classes="menu-item", + id=f"item-{i}", + ) + self._menu_items.append(item) + yield item + + yield Static("", id="menu-description") + yield Static("↑/↓: Navigate | Enter: Select | Q/Esc: Quit", id="menu-footer") + + def on_mount(self) -> None: + """Initialize the menu.""" + self._description_widget = self.query_one("#menu-description", Static) + self._update_selection() + + def watch_selected_index(self, _selected_index: int) -> None: + """Update selection when index changes.""" + self._update_selection() + + def _update_selection(self) -> None: + """Update the visual selection and description.""" + for i, item_widget in enumerate(self._menu_items): + if i == self.selected_index: + # Update checkbox to [x] and highlight + item_widget.update(f"[x] {i + 1}. {self.menu_options[i]['title']}") + item_widget.add_class("selected") + else: + # Update checkbox to [ ] and remove highlight + item_widget.update(f"[ ] {i + 1}. {self.menu_options[i]['title']}") + item_widget.remove_class("selected") + + # Update description at bottom + if self._description_widget: + selected = self.menu_options[self.selected_index] + desc_text = f"{selected['description']}\nExample: {selected['example']}" + self._description_widget.update(desc_text) + + def action_move_up(self) -> None: + """Move selection up.""" + if self.selected_index > 0: + self.selected_index -= 1 + + def action_move_down(self) -> None: + """Move selection down.""" + if self.selected_index < len(self.menu_options) - 1: + self.selected_index += 1 + + def action_select(self) -> None: + """Select the current option.""" + self.result = self.selected_index + 1 + self.exit(result=self.result) + + def action_quit(self) -> None: + """Quit the menu.""" + self.exit(result=None) + + +class InputPromptApp(App): # type: ignore[misc] + """Input prompt app with same styling as menu.""" + + CSS = INPUT_CSS + BINDINGS = BINDINGS_INPUT + + def __init__(self, prompt_text: str, description: str = "", allow_empty: bool = False) -> None: + super().__init__() + self.prompt_text = prompt_text + self.description = description + self.allow_empty = allow_empty + self.result: str | None = None + + def compose(self) -> ComposeResult: + with Container(id="input-container"): + yield Static("🦉 STRIX CYBERSECURITY AGENT", id="input-title") + yield Static(self.prompt_text, id="input-label") + yield Input(placeholder="", id="input-field") + if self.description: + yield Static(self.description, id="input-description") + yield Static("Enter: Submit | Esc: Cancel", id="input-footer") + + def on_mount(self) -> None: + """Focus the input field on mount.""" + input_field = self.query_one("#input-field", Input) + input_field.focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle input submission.""" + value = event.value.strip() + if not value and not self.allow_empty: + return # Don't submit empty values + + self.result = value + self.exit(result=value) + + def action_quit(self) -> None: + """Quit the input prompt.""" + self.exit(result=None) + + +class ConfigurationApp(App): # type: ignore[misc] + """Configuration management app.""" + + CSS = CONFIG_CSS + BINDINGS = BINDINGS_CONFIG + + def __init__(self) -> None: + super().__init__() + self.config_manager = ConfigManager() + self._inputs: dict[str, Input] = {} + self._input_order: list[str] = [] + + def compose(self) -> ComposeResult: + config = self.config_manager.get_all_config() + + with Container(id="config-container"): + yield Static("🦉 STRIX CONFIGURATION", id="config-title") + yield Static("Manage your Strix settings", id="config-subtitle") + + # STRIX_LLM + yield Static("Model Name (STRIX_LLM)", classes="config-label") + yield Static( + "Example: openai/gpt-5, anthropic/claude-3-5-sonnet", + classes="config-description", + ) + llm_input = Input( + value=config.get("STRIX_LLM", ""), + placeholder="openai/gpt-5", + id="strix_llm", + classes="config-input", + ) + self._inputs["STRIX_LLM"] = llm_input + self._input_order.append("STRIX_LLM") + yield llm_input + + # LLM_API_KEY + yield Static("LLM API Key (LLM_API_KEY)", classes="config-label") + yield Static("Your API key for the LLM provider", classes="config-description") + api_key_input = Input( + value=config.get("LLM_API_KEY", ""), + placeholder="sk-...", + password=True, + id="llm_api_key", + classes="config-input", + ) + self._inputs["LLM_API_KEY"] = api_key_input + self._input_order.append("LLM_API_KEY") + yield api_key_input + + # PERPLEXITY_API_KEY + yield Static("Perplexity API Key (PERPLEXITY_API_KEY)", classes="config-label") + yield Static("Optional: For web search capabilities", classes="config-description") + perplexity_input = Input( + value=config.get("PERPLEXITY_API_KEY", ""), + placeholder="pplx-...", + password=True, + id="perplexity_api_key", + classes="config-input", + ) + self._inputs["PERPLEXITY_API_KEY"] = perplexity_input + self._input_order.append("PERPLEXITY_API_KEY") + yield perplexity_input + + # LLM_API_BASE + yield Static("LLM API Base URL (LLM_API_BASE)", classes="config-label") + yield Static( + "Optional: For local models (e.g., http://localhost:11434)", + classes="config-description", + ) + api_base_input = Input( + value=config.get("LLM_API_BASE", ""), + placeholder="http://localhost:11434", + id="llm_api_base", + classes="config-input", + ) + self._inputs["LLM_API_BASE"] = api_base_input + self._input_order.append("LLM_API_BASE") + yield api_base_input + + yield Static("↑/↓: Navigate | Ctrl+S: Save | Esc: Back to Menu", id="config-footer") + + def on_mount(self) -> None: + """Focus the first input on mount.""" + if self._inputs and self._input_order: + first_key = self._input_order[0] + self._inputs[first_key].focus() + + def _get_current_input_index(self) -> int: + """Get the index of the currently focused input.""" + focused = self.focused + if isinstance(focused, Input): + for i, key in enumerate(self._input_order): + if self._inputs[key] == focused: + return i + return 0 + + def action_move_up(self) -> None: + """Move focus to the previous input.""" + current_idx = self._get_current_input_index() + if current_idx > 0: + prev_key = self._input_order[current_idx - 1] + self._inputs[prev_key].focus() + + def action_move_down(self) -> None: + """Move focus to the next input.""" + current_idx = self._get_current_input_index() + if current_idx < len(self._input_order) - 1: + next_key = self._input_order[current_idx + 1] + self._inputs[next_key].focus() + + def action_save(self) -> None: + """Save configuration.""" + updates = {} + for key, input_widget in self._inputs.items(): + value = input_widget.value.strip() + if value: # Only save non-empty values + updates[key] = value + elif key in ["STRIX_LLM", "LLM_API_KEY"]: # Required fields + # Keep existing value if not changed + existing = self.config_manager.get_value(key) + if existing: + updates[key] = existing + + self.config_manager.update_config(updates) + self.config_manager.apply_to_environment() + + # Re-apply litellm settings immediately after saving + if updates.get("LLM_API_KEY"): + litellm.api_key = updates["LLM_API_KEY"] + elif "LLM_API_KEY" in updates: # Empty value - clear it + litellm.api_key = None + + if updates.get("LLM_API_BASE"): + litellm.api_base = updates["LLM_API_BASE"] + elif updates.get("OPENAI_API_BASE"): + litellm.api_base = updates["OPENAI_API_BASE"] + elif updates.get("LITELLM_BASE_URL"): + litellm.api_base = updates["LITELLM_BASE_URL"] + elif updates.get("OLLAMA_API_BASE"): + litellm.api_base = updates["OLLAMA_API_BASE"] + elif "LLM_API_BASE" in updates or "OPENAI_API_BASE" in updates: + # Empty value - clear it + litellm.api_base = None + + # Show success message + console = Console() + console.print("\n[bold green]✓ Configuration saved successfully![/bold green]\n") + + self.exit(result=True) + + def action_quit(self) -> None: + """Quit without saving.""" + self.exit(result=False) + + +async def prompt_input_async( + prompt_text: str, description: str = "", allow_empty: bool = False +) -> str | None: + """Prompt for input using textual with same styling as menu.""" + app = InputPromptApp(prompt_text, description, allow_empty) + return await app.run_async() + + +async def show_configuration_async() -> bool: + """Show configuration management screen.""" + app = ConfigurationApp() + result = await app.run_async() + return result is True + + +def _get_menu_options() -> list[dict[str, Any]]: + """Get the menu options list.""" + return [ + { + "title": "Local codebase analysis", + "description": "Analyze a local directory for security vulnerabilities", + "example": "strix --target ./app-directory", + "targets": [], + "instruction": None, + }, + { + "title": "Repository security review", + "description": "Clone and analyze a GitHub repository", + "example": "strix --target https://github.com/org/repo", + "targets": [], + "instruction": None, + }, + { + "title": "Web application assessment", + "description": "Perform penetration testing on a deployed web application", + "example": "strix --target https://your-app.com", + "targets": [], + "instruction": None, + }, + { + "title": "Multi-target white-box testing", + "description": "Test source code + deployed app simultaneously", + "example": "strix -t https://github.com/org/app -t https://your-app.com", + "targets": [], + "instruction": None, + }, + { + "title": "Test multiple environments", + "description": "Test dev, staging, and production environments simultaneously", + "example": ( + "strix -t https://dev.your-app.com " + "-t https://staging.your-app.com " + "-t https://prod.your-app.com" + ), + "targets": [], + "instruction": None, + }, + { + "title": "Focused testing with instructions", + "description": "Prioritize specific vulnerability types or testing approaches", + "example": ( + "strix --target api.your-app.com " + '--instruction "Prioritize authentication and authorization testing"' + ), + "targets": [], + "instruction": None, + }, + { + "title": "Testing with credentials", + "description": "Test with provided credentials, focus on privilege escalation", + "example": ( + "strix --target https://your-app.com " + '--instruction "Test with credentials: testuser/testpass. ' + 'Focus on privilege escalation and access control bypasses."' + ), + "targets": [], + "instruction": None, + }, + { + "title": "Configuration", + "description": "Manage Strix settings (API keys, model, etc.)", + "example": "Configure STRIX_LLM, LLM_API_KEY, PERPLEXITY_API_KEY", + "targets": [], + "instruction": None, + "is_config": True, + }, + ] + + +async def show_interactive_menu_async() -> argparse.Namespace | None: # noqa: PLR0912, PLR0915 + """Display an interactive menu using textual with arrow key navigation.""" + menu_options = _get_menu_options() + + while True: + app = InteractiveMenuApp(menu_options) + choice = await app.run_async() + + if choice is None: + return None + + # Check if configuration was selected + if choice == 8: # Configuration option + await show_configuration_async() + # Return to menu after configuration + continue + + # Regular menu option selected + break + + console = Console() + + if choice is None: + console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") + sys.exit(0) + + # Get menu options again (they might have been modified) + menu_options = _get_menu_options() + selected_option = menu_options[choice - 1] + + # Create a namespace object with the selected option + args = argparse.Namespace() + args.target = None + args.targets_info = [] + args.instruction = selected_option.get("instruction") + args.run_name = None + args.non_interactive = False + args._menu_selection = selected_option + + # Prompt for target(s) based on selection using textual input + if choice in [4, 5]: # Multi-target scenarios + targets = [] + while True: + target = await prompt_input_async( + "Enter target (empty line to finish)", + f"Target {len(targets) + 1} of multiple targets", + allow_empty=True, + ) + if target is None: + if targets: + break + console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") + sys.exit(0) + if not target: + if targets: + break + continue + targets.append(target) + args.target = targets + else: + target_prompt_text = "Enter target" + target_description = "" + if choice == 1: # Local codebase + target_prompt_text = "Enter local directory path" + target_description = "Example: ./app-directory or /path/to/project" + elif choice == 2: # Repository + target_prompt_text = "Enter repository URL" + target_description = ( + "Example: https://github.com/org/repo or git@github.com:org/repo.git" + ) + elif choice == 3: # Web app + target_prompt_text = "Enter web application URL" + target_description = "Example: https://your-app.com or http://localhost:3000" + elif choice == 6: # Focused testing + target_prompt_text = "Enter target URL" + target_description = "Example: api.your-app.com or https://api.example.com" + elif choice == 7: # With credentials + target_prompt_text = "Enter target URL" + target_description = "Example: https://your-app.com or http://localhost:8080" + + target = await prompt_input_async(target_prompt_text, target_description) + if target is None or not target: + console.print("\n[bold yellow]Cancelled.[/bold yellow]\n") + sys.exit(0) + args.target = [target] + + # For focused testing and credentials, prompt for instruction if not set + if choice == 6 and not args.instruction: + instruction = await prompt_input_async( + "Enter instructions (optional)", + "Prioritize specific vulnerability types or testing approaches", + allow_empty=True, + ) + if instruction: + args.instruction = instruction + elif choice == 7 and not args.instruction: + credentials = await prompt_input_async( + "Enter credentials (format: username/password)", + "Example: admin:password123 or testuser/testpass", + allow_empty=True, + ) + instruction_text = await prompt_input_async( + "Enter additional instructions (optional)", + "Focus on privilege escalation and access control bypasses", + allow_empty=True, + ) + if credentials: + instruction_parts = [f"Test with credentials: {credentials}"] + if instruction_text: + instruction_parts.append(instruction_text) + args.instruction = ". ".join(instruction_parts) + "." + elif instruction_text: + args.instruction = instruction_text + + # Process targets + args.targets_info = [] + for target in args.target: + try: + target_type, target_dict = infer_target_type(target) + + if target_type == "local_code": + display_target = target_dict.get("target_path", target) + else: + display_target = target + + args.targets_info.append( + {"type": target_type, "details": target_dict, "original": display_target} + ) + except ValueError as e: + console.print(f"[bold red]Invalid target '{target}': {e}[/bold red]\n") + sys.exit(1) + + assign_workspace_subdirs(args.targets_info) + + return args + + +def show_interactive_menu() -> argparse.Namespace | None: + """Display an interactive menu using textual with arrow key navigation.""" + import asyncio + + return asyncio.run(show_interactive_menu_async()) diff --git a/strix/interface/ui_constants.py b/strix/interface/ui_constants.py new file mode 100644 index 0000000..c28098d --- /dev/null +++ b/strix/interface/ui_constants.py @@ -0,0 +1,194 @@ +"""UI constants for interactive menu applications.""" + +from textual.binding import Binding + + +# CSS for InteractiveMenuApp +MENU_CSS = """ +Screen { + align: center middle; + background: #1a1a1a; +} + +#menu-container { + width: 90; + height: auto; + border: solid #22c55e; + padding: 2; + background: #1a1a1a; +} + +#menu-title { + text-align: center; + color: #22c55e; + text-style: bold; + margin-bottom: 1; +} + +#menu-subtitle { + text-align: center; + color: #d4d4d4; + margin-bottom: 2; +} + +#menu-options { + height: auto; + margin: 1; +} + +.menu-item { + padding: 0 1; + margin: 0; + height: 1; +} + +.menu-item.selected { + background: #262626; +} + +#menu-description { + text-align: left; + padding: 1; + margin-top: 2; + border-top: solid #22c55e; + color: #a8a29e; + height: 3; +} + +#menu-footer { + text-align: center; + padding: 1; + margin-top: 1; + color: #a8a29e; +} +""" + +# CSS for InputPromptApp +INPUT_CSS = """ +Screen { + align: center middle; + background: #1a1a1a; +} + +#input-container { + width: 80; + height: auto; + border: solid #22c55e; + padding: 2; + background: #1a1a1a; +} + +#input-title { + text-align: center; + color: #22c55e; + text-style: bold; + margin-bottom: 1; +} + +#input-label { + text-align: left; + color: #d4d4d4; + margin-bottom: 1; + margin-top: 1; +} + +#input-field { + width: 100%; + margin-bottom: 1; +} + +#input-description { + text-align: left; + padding: 1; + margin-top: 1; + border-top: solid #22c55e; + color: #a8a29e; + height: 2; +} + +#input-footer { + text-align: center; + padding: 1; + margin-top: 1; + color: #a8a29e; +} +""" + +# CSS for ConfigurationApp +CONFIG_CSS = """ +Screen { + align: center middle; + background: #1a1a1a; +} + +#config-container { + width: 90; + height: auto; + border: solid #22c55e; + padding: 2; + background: #1a1a1a; +} + +#config-title { + text-align: center; + color: #22c55e; + text-style: bold; + margin-bottom: 1; +} + +#config-subtitle { + text-align: center; + color: #d4d4d4; + margin-bottom: 2; +} + +.config-field { + margin: 1 0; +} + +.config-label { + color: #d4d4d4; + margin-bottom: 1; +} + +.config-input { + width: 100%; + margin-bottom: 1; +} + +.config-description { + color: #a8a29e; + margin-top: 1; + margin-bottom: 1; +} + +#config-footer { + text-align: center; + padding: 1; + margin-top: 2; + border-top: solid #22c55e; + color: #a8a29e; +} +""" + +# Key bindings for InteractiveMenuApp +BINDINGS_MENU = [ + Binding("up", "move_up", "Move Up", priority=True), + Binding("down", "move_down", "Move Down", priority=True), + Binding("enter", "select", "Select", priority=True), + Binding("q", "quit", "Quit", priority=True), + Binding("escape", "quit", "Quit", priority=True), +] + +# Key bindings for InputPromptApp +BINDINGS_INPUT = [ + Binding("escape", "quit", "Quit", priority=True), +] + +# Key bindings for ConfigurationApp +BINDINGS_CONFIG = [ + Binding("escape", "quit", "Back to Menu", priority=True), + Binding("ctrl+s", "save", "Save", priority=True), + Binding("up", "move_up", "Move Up", priority=True), + Binding("down", "move_down", "Move Down", priority=True), +] From d2853dee20eeb3caa92cf135b3c56fcb530b46e0 Mon Sep 17 00:00:00 2001 From: Navid Mirzaaghazadeh Date: Sun, 9 Nov 2025 16:57:14 +0300 Subject: [PATCH 4/5] remove redundant asyncio import from synchronous menu function --- strix/interface/menu.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/strix/interface/menu.py b/strix/interface/menu.py index 7dad2ae..009708a 100644 --- a/strix/interface/menu.py +++ b/strix/interface/menu.py @@ -1,6 +1,7 @@ """Interactive menu applications for Strix.""" import argparse +import asyncio import sys from typing import Any @@ -528,6 +529,4 @@ async def show_interactive_menu_async() -> argparse.Namespace | None: # noqa: P def show_interactive_menu() -> argparse.Namespace | None: """Display an interactive menu using textual with arrow key navigation.""" - import asyncio - return asyncio.run(show_interactive_menu_async()) From 99c070ee7555508718621b47bea73a3680932ea8 Mon Sep 17 00:00:00 2001 From: Navid Mirzaaghazadeh Date: Sun, 9 Nov 2025 19:20:48 +0300 Subject: [PATCH 5/5] feat: add error handling for non-interactive mode when no targets are provided in CLI --- strix/interface/main.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/strix/interface/main.py b/strix/interface/main.py index fc25e18..32b3a21 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -448,7 +448,7 @@ def pull_docker_image() -> None: console.print() -def main() -> None: # noqa: PLR0912 +def main() -> None: # noqa: PLR0912, PLR0915 if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -473,8 +473,38 @@ def main() -> None: # noqa: PLR0912 args = parse_arguments() - # If no targets provided, show interactive menu + # If no targets provided, show interactive menu (unless non-interactive mode) if not args.target: + if args.non_interactive: + console = Console() + error_text = Text() + error_text.append("❌ ", style="bold red") + error_text.append("NO TARGETS PROVIDED", style="bold red") + error_text.append("\n\n", style="white") + error_text.append( + "Non-interactive mode requires at least one target to be specified.\n", + style="white", + ) + error_text.append( + "Please provide a target using --target or -t option.\n\n", style="white" + ) + error_text.append("Example:\n", style="white") + error_text.append(" strix -n --target https://example.com\n", style="dim white") + error_text.append(" strix -n -t ./local-directory\n", style="dim white") + + panel = Panel( + error_text, + title="[bold red]🛡️ STRIX CONFIGURATION ERROR", + title_align="center", + border_style="red", + padding=(1, 2), + ) + + console.print("\n") + console.print(panel) + console.print() + sys.exit(1) + args = show_interactive_menu() # Handle cancellation - if user quits menu, exit cleanly if args is None: