1515import sys
1616from enum import Enum
1717from typing import Dict , List , Callable , Optional
18- import logfire
1918from rich .console import Console
2019from rich .layout import Layout
2120from rich .panel import Panel
2221from rich .box import ROUNDED
2322from prompt_toolkit .application import Application
24- from prompt_toolkit .widgets import TextArea , Frame , Label
23+ from prompt_toolkit .widgets import TextArea , Frame , Label , RadioList
2524from prompt_toolkit .layout import Layout as PTKLayout
2625from prompt_toolkit .key_binding import KeyBindings
2726from prompt_toolkit .styles import Style
3837from deadend_cli .core .agents .judge import JudgeOutput
3938from deadend_cli .core .utils .network import check_target_alive
4039from deadend_cli .core .models import ModelRegistry
40+ from pydantic_ai import DeferredToolRequests
4141from .console import console_printer
4242
4343# Defining Agent modes
@@ -60,13 +60,8 @@ def print_pydantic_model(obj: BaseModel, title: str = "Agent Output") -> None:
6060
6161 # Add each field to the table
6262 for field_name , field_value in obj .model_dump ().items ():
63- # Format the value for display
64- if isinstance (field_value , str ) and len (field_value ) > 100 :
65- # Truncate long strings
66- display_value = field_value [:100 ] + "..." if len (field_value ) > 100 else field_value
67- else :
68- display_value = str (field_value )
69-
63+ # Display the full value without truncation
64+ display_value = str (field_value )
7065 table .add_row (field_name , display_value )
7166
7267 # Create a panel with the table
@@ -187,6 +182,77 @@ def startup(self):
187182 self .console .print ("[bold green] Starting Agent Mode: Chat interface.[/bold green]" )
188183 self .console .print ("Type '/help' for commands, '/quit' to exit." )
189184
185+ async def ask_for_approval_panel (self , title : str = "Tool Approval Required" ) -> Optional [str ]:
186+ """Prompt for tool execution approval using Prompt Toolkit with choices.
187+
188+ Args:
189+ title: Title text to display with the confirmation dialog.
190+
191+ Returns:
192+ 'yes' for approval, 'no' for rejection, or None if cancelled
193+ """
194+ try :
195+ style = Style .from_dict ({
196+ "frame.border" : "ansiyellow" ,
197+ "radio-checked" : "ansigreen" ,
198+ "radio-unchecked" : "ansired" ,
199+ })
200+
201+ # Define approval choices
202+ choices = [
203+ ("yes" , "Approve tool execution" ),
204+ ("no" , "Deny tool execution" ),
205+ ]
206+
207+ # Create radio list for choices, default to yes
208+ radio_list = RadioList (choices , default = "yes" )
209+ # Set default selection to "yes"
210+ radio_list .current_value = "yes"
211+
212+ # Create confirmation prompt text
213+ prompt_text = Label (text = "Do you approve these tool executions?" , style = "ansiwhite" )
214+
215+ # Create footer with instructions
216+ footer_text = "Commands: ↑/↓=Navigate | Space=Toggle | Enter=Submit | Ctrl+C=Cancel"
217+ footer = Label (text = footer_text , style = "ansiblack" )
218+
219+ root_container = HSplit ([
220+ Label (text = title , style = "bold ansiyellow" ),
221+ prompt_text ,
222+ Frame (body = radio_list ),
223+ footer ,
224+ ])
225+
226+ kb = KeyBindings ()
227+
228+ @kb .add ("c-c" )
229+ def _ (event ): # type: ignore[no-redef]
230+ event .app .exit (result = None ) # Cancel without selection
231+
232+ # Override RadioList's Enter behavior to exit with result
233+ def custom_enter_handler (event ):
234+ radio_list ._handle_enter ()
235+ selected_value = radio_list .current_value
236+ event .app .exit (result = selected_value )
237+
238+ # Replace RadioList's enter binding
239+ radio_list .control .key_bindings .add ("enter" )(custom_enter_handler )
240+
241+ app = Application (
242+ layout = PTKLayout (container = root_container ),
243+ key_bindings = kb ,
244+ full_screen = False ,
245+ mouse_support = False ,
246+ style = style ,
247+ min_redraw_interval = 0.01 ,
248+ )
249+ app .layout .focus (radio_list )
250+
251+ return await app .run_async ()
252+ except KeyboardInterrupt :
253+ console_printer .print ("\n [yellow]Approval cancelled.[/yellow]" )
254+ return None
255+
190256 async def ask_with_ptk_panel (
191257 self ,
192258 title : str = "Prompt" ,
@@ -222,7 +288,7 @@ async def ask_with_ptk_panel(
222288 # Create footer with available commands
223289 footer_text = "Commands: Ctrl+C=Exit | Ctrl+I=Interrupt | Enter=Submit | /help=Help | /clear=Clear | /new-target=New Target"
224290 footer = Label (text = footer_text , style = "ansiblack" )
225-
291+
226292 root_container = HSplit ([
227293 Label (text = title , style = "bold ansicyan" ),
228294 Frame (body = input_field ),
@@ -236,17 +302,17 @@ def _accept_handler(_buff):
236302 input_field .accept_handler = _accept_handler
237303
238304 @kb .add ("c-c" )
239- def _ (event ): # type: ignore[no-redef]
305+ def _ (event ):
240306 event .app .exit (result = None )
241307
242308 @kb .add ("c-i" )
243- def _ (event ): # type: ignore[no-redef]
309+ def _ (event ):
244310 if interrupt_callback :
245311 interrupt_callback ()
246312 event .app .exit (result = "__INTERRUPT__" )
247313
248314 @kb .add ("enter" )
249- def _ (event ): # type: ignore[no-redef]
315+ def _ (event ):
250316 text = input_field .text
251317 if text .startswith ("/" ):
252318 # Handle commands
@@ -311,7 +377,7 @@ async def chat_interface(
311377 # Settings up sandbox
312378 try :
313379 sandbox_manager = sandbox_setup ()
314- sandbox_id = sandbox_manager .create_sandbox ("kali_deadend " )
380+ sandbox_id = sandbox_manager .create_sandbox ("xoxruns/sandboxed_kali" , network_name = "host " )
315381 sandbox = sandbox_manager .get_sandbox (sandbox_id = sandbox_id )
316382 except Exception as e :
317383 console_printer .print (f"[yellow]Sandbox manager could not be started : { e } . Continuing without sandbox.[/yellow]" )
@@ -327,14 +393,19 @@ async def chat_interface(
327393 code_indexer_db = rag_db ,
328394 sandbox = sandbox
329395 )
396+
397+ # Set up approval callback to use Prompt Toolkit
398+ async def approval_callback ():
399+ return await chat_interface .ask_for_approval_panel ("Tool Execution Approval Required" )
400+
401+ workflow_agent .set_approval_callback (approval_callback )
330402 # Setup available agents
331403 available_agents = {
332404 'webapp_recon' : "Expert cybersecurity agent that enumerates a web target to understand the architecture and understand the endpoints and where an attack vector could be tested." ,
333405 # 'planner_agent': 'Expert cybersecurity agent that plans what is the next step to do',
334406 'router_agent' : 'Router agent, expert that routes to the specific agent needed to achieve the next step of the plan.'
335407 }
336408 workflow_agent .register_agents (available_agents )
337- workflow_agent .register_sandbox_runner ()
338409 # Check if the provided target is reachable before proceeding
339410 alive = False
340411 while not alive :
@@ -486,79 +557,75 @@ def interrupt_agent():
486557 if not user_prompt :
487558 break
488559
489- # Reset interruption flag
560+ # Reset interruption flag and workflow state for new execution
490561 agent_interrupted = False
491-
492- # Reset workflow state for new execution
493562 workflow_agent .reset_workflow_state ()
494563
495- # Handle workflow execution with yielded messages
496564 judge_output = None
497-
498- # Show running status
499- with console_printer .status ("[bold blue]Agent workflow running..." , spinner = "dots2" ):
500- try :
501- async for item in workflow_agent .start_workflow (
502- prompt = user_prompt ,
503- target = target ,
504- validation_type = None ,
505- validation_format = None
506- ):
507- # Check if this is the final result (JudgeOutput)
508- if isinstance (item , JudgeOutput ):
509- judge_output = item
510-
511- # Check if this is a Pydantic BaseModel object
512- if isinstance (item , BaseModel ):
513- # Special handling for RouterOutput - print as simple text
514- if type (item ).__name__ == "RouterOutput" :
515- console_printer .print (f"[cyan]Router:[/cyan] { item .next_agent_name } " )
516- console_printer .print (f"[cyan]Reasoning:[/cyan] { item .reasoning } " )
517- else :
518- # Determine the type of model for better title
519- model_type = type (item ).__name__
520- print_pydantic_model (item , f"{ model_type } Output" )
521- # Check if this is a list of tasks (from PlannerOutput)
522- elif isinstance (item , list ) and len (item ) > 0 and hasattr (item [0 ], 'goal' ):
523- # Create a table for tasks
524- task_table = Table (show_header = True , header_style = "bold magenta" , box = box .ROUNDED )
525- task_table .add_column ("Step" , style = "cyan" , no_wrap = True )
526- task_table .add_column ("Goal" , style = "white" )
527- task_table .add_column ("Status" , style = "yellow" )
528- task_table .add_column ("Output" , style = "green" )
529-
530- for i , task in enumerate (item , 1 ):
531- task_table .add_row (
532- str (i ),
533- task .goal ,
534- task .status ,
535- task .output
536- )
537-
538- task_panel = Panel (
539- task_table ,
540- title = "[bold green]Planned Tasks[/bold green]" ,
541- border_style = "green" ,
542- box = box .ROUNDED
543- )
544- console_printer .print (task_panel )
565+ try :
566+ async for item in workflow_agent .start_workflow (
567+ prompt = user_prompt ,
568+ target = target ,
569+ validation_type = None ,
570+ validation_format = None
571+ ):
572+ # Skip printing DeferredToolRequests objects
573+ if hasattr (item , 'output' ) and isinstance (item .output , DeferredToolRequests ):
574+ continue
575+
576+ # Special handling for RequesterOutput - print just the reasoning
577+ if isinstance (item , RequesterOutput ):
578+ console_printer .print (f"[bold green]Requester Analysis:[/bold green] { item .reasoning } " )
579+ continue
580+
581+ # Check if this is the final result (JudgeOutput)
582+ if isinstance (item , JudgeOutput ):
583+ judge_output = item
584+
585+ # Check if this is a Pydantic BaseModel object
586+ if isinstance (item , BaseModel ):
587+ # Special handling for RouterOutput - print as simple text
588+ if type (item ).__name__ == "RouterOutput" :
589+ console_printer .print (f"[cyan]Router:[/cyan] \
590+ { item .next_agent_name } " )
591+ console_printer .print (f"[cyan]Reasoning:[/cyan] { item .reasoning } " )
545592 else :
546- # Print regular string messages
547- console_printer .print (item )
548-
549- # Check for interruption
550- if agent_interrupted or workflow_agent .interrupted :
551- break
593+ # Determine the type of model for better title
594+ model_type = type (item ).__name__
595+ print_pydantic_model (item , f"{ model_type } Output" )
596+
597+ elif isinstance (item , list ) and len (item ) > 0 and hasattr (item [0 ], 'goal' ):
598+ # Create a simple text display for tasks without truncation
599+ tasks_text = "[bold green]Planned Tasks[/bold green]\n \n "
600+ for i , task in enumerate (item , 1 ):
601+ tasks_text += f"[cyan]Step { i } :[/cyan]\n "
602+ tasks_text += f"[white]Goal:[/white] { task .goal } \n "
603+ tasks_text += f"[yellow]Status:[/yellow] { task .status } \n "
604+ tasks_text += f"[green]Output:[/green]\n { task .output } \n \n "
605+
606+ task_panel = Panel (
607+ tasks_text .strip (),
608+ title = "Tasks Overview" ,
609+ border_style = "green" ,
610+ box = box .ROUNDED
611+ )
612+ console_printer .print (task_panel )
613+ else :
614+ # Print regular string messages
615+ console_printer .print (item )
552616
553- # Small delay to allow for interruption
554- await asyncio .sleep (0.1 )
617+ # Check for interruption
618+ if agent_interrupted or workflow_agent .interrupted :
619+ break
555620
556- except InterruptedError as e :
557- console_printer .print (f"[yellow]Workflow interrupted: { e } [/yellow]" )
558- judge_output = None
559- except Exception as e :
560- console_printer .print (f"[red]Workflow error: { e } [/red]" )
561- judge_output = None
621+ # Small delay to allow for interruption
622+ await asyncio .sleep (0.1 )
623+ except InterruptedError as e :
624+ console_printer .print (f"[yellow]Workflow interrupted: { e } [/yellow]" )
625+ judge_output = None
626+ except Exception as e :
627+ console_printer .print (f"[red]Workflow error: { e } [/red]" )
628+ judge_output = None
562629
563630 # Check if agent was interrupted
564631 if agent_interrupted or workflow_agent .interrupted :
@@ -585,8 +652,7 @@ def interrupt_agent():
585652 console_printer .print ("\n [bold cyan]Solution:[/bold cyan]" )
586653 console_printer .print (f"{ judge_output .output .solution } " )
587654
588- # Reset user prompt to ask for a new one in the next iteration
589- user_prompt = None
655+ user_prompt = None
590656 except KeyboardInterrupt :
591657 console_printer .print ("\n [yellow]Received Ctrl+C. Exiting gracefully...[/yellow]" )
592658 console_printer .print ("[green]Thank you for using Deadend CLI![/green]" )
0 commit comments