hackagent 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hackagent/__init__.py +12 -0
- hackagent/agent.py +214 -0
- hackagent/api/__init__.py +1 -0
- hackagent/api/agent/__init__.py +1 -0
- hackagent/api/agent/agent_create.py +347 -0
- hackagent/api/agent/agent_destroy.py +140 -0
- hackagent/api/agent/agent_list.py +242 -0
- hackagent/api/agent/agent_partial_update.py +361 -0
- hackagent/api/agent/agent_retrieve.py +235 -0
- hackagent/api/agent/agent_update.py +361 -0
- hackagent/api/apilogs/__init__.py +1 -0
- hackagent/api/apilogs/apilogs_list.py +170 -0
- hackagent/api/apilogs/apilogs_retrieve.py +162 -0
- hackagent/api/attack/__init__.py +1 -0
- hackagent/api/attack/attack_create.py +275 -0
- hackagent/api/attack/attack_destroy.py +146 -0
- hackagent/api/attack/attack_list.py +254 -0
- hackagent/api/attack/attack_partial_update.py +289 -0
- hackagent/api/attack/attack_retrieve.py +247 -0
- hackagent/api/attack/attack_update.py +289 -0
- hackagent/api/checkout/__init__.py +1 -0
- hackagent/api/checkout/checkout_create.py +225 -0
- hackagent/api/generate/__init__.py +1 -0
- hackagent/api/generate/generate_create.py +253 -0
- hackagent/api/judge/__init__.py +1 -0
- hackagent/api/judge/judge_create.py +253 -0
- hackagent/api/key/__init__.py +1 -0
- hackagent/api/key/key_create.py +179 -0
- hackagent/api/key/key_destroy.py +103 -0
- hackagent/api/key/key_list.py +170 -0
- hackagent/api/key/key_retrieve.py +162 -0
- hackagent/api/organization/__init__.py +1 -0
- hackagent/api/organization/organization_create.py +208 -0
- hackagent/api/organization/organization_destroy.py +104 -0
- hackagent/api/organization/organization_list.py +170 -0
- hackagent/api/organization/organization_me_retrieve.py +126 -0
- hackagent/api/organization/organization_partial_update.py +222 -0
- hackagent/api/organization/organization_retrieve.py +163 -0
- hackagent/api/organization/organization_update.py +222 -0
- hackagent/api/prompt/__init__.py +1 -0
- hackagent/api/prompt/prompt_create.py +171 -0
- hackagent/api/prompt/prompt_destroy.py +104 -0
- hackagent/api/prompt/prompt_list.py +185 -0
- hackagent/api/prompt/prompt_partial_update.py +185 -0
- hackagent/api/prompt/prompt_retrieve.py +163 -0
- hackagent/api/prompt/prompt_update.py +185 -0
- hackagent/api/result/__init__.py +1 -0
- hackagent/api/result/result_create.py +175 -0
- hackagent/api/result/result_destroy.py +106 -0
- hackagent/api/result/result_list.py +249 -0
- hackagent/api/result/result_partial_update.py +193 -0
- hackagent/api/result/result_retrieve.py +167 -0
- hackagent/api/result/result_trace_create.py +177 -0
- hackagent/api/result/result_update.py +189 -0
- hackagent/api/run/__init__.py +1 -0
- hackagent/api/run/run_create.py +187 -0
- hackagent/api/run/run_destroy.py +112 -0
- hackagent/api/run/run_list.py +291 -0
- hackagent/api/run/run_partial_update.py +201 -0
- hackagent/api/run/run_result_create.py +177 -0
- hackagent/api/run/run_retrieve.py +179 -0
- hackagent/api/run/run_run_tests_create.py +187 -0
- hackagent/api/run/run_update.py +201 -0
- hackagent/api/user/__init__.py +1 -0
- hackagent/api/user/user_create.py +212 -0
- hackagent/api/user/user_destroy.py +106 -0
- hackagent/api/user/user_list.py +174 -0
- hackagent/api/user/user_me_retrieve.py +126 -0
- hackagent/api/user/user_me_update.py +196 -0
- hackagent/api/user/user_partial_update.py +226 -0
- hackagent/api/user/user_retrieve.py +167 -0
- hackagent/api/user/user_update.py +226 -0
- hackagent/attacks/AdvPrefix/__init__.py +41 -0
- hackagent/attacks/AdvPrefix/completions.py +416 -0
- hackagent/attacks/AdvPrefix/config.py +259 -0
- hackagent/attacks/AdvPrefix/evaluation.py +745 -0
- hackagent/attacks/AdvPrefix/evaluators.py +564 -0
- hackagent/attacks/AdvPrefix/generate.py +711 -0
- hackagent/attacks/AdvPrefix/utils.py +307 -0
- hackagent/attacks/__init__.py +35 -0
- hackagent/attacks/advprefix.py +507 -0
- hackagent/attacks/base.py +106 -0
- hackagent/attacks/strategies.py +906 -0
- hackagent/cli/__init__.py +19 -0
- hackagent/cli/commands/__init__.py +20 -0
- hackagent/cli/commands/agent.py +100 -0
- hackagent/cli/commands/attack.py +417 -0
- hackagent/cli/commands/config.py +301 -0
- hackagent/cli/commands/results.py +327 -0
- hackagent/cli/config.py +249 -0
- hackagent/cli/main.py +515 -0
- hackagent/cli/tui/__init__.py +31 -0
- hackagent/cli/tui/actions_logger.py +200 -0
- hackagent/cli/tui/app.py +288 -0
- hackagent/cli/tui/base.py +137 -0
- hackagent/cli/tui/logger.py +318 -0
- hackagent/cli/tui/views/__init__.py +33 -0
- hackagent/cli/tui/views/agents.py +488 -0
- hackagent/cli/tui/views/attacks.py +624 -0
- hackagent/cli/tui/views/config.py +244 -0
- hackagent/cli/tui/views/dashboard.py +307 -0
- hackagent/cli/tui/views/results.py +1210 -0
- hackagent/cli/tui/widgets/__init__.py +24 -0
- hackagent/cli/tui/widgets/actions.py +346 -0
- hackagent/cli/tui/widgets/logs.py +435 -0
- hackagent/cli/utils.py +276 -0
- hackagent/client.py +286 -0
- hackagent/errors.py +37 -0
- hackagent/logger.py +83 -0
- hackagent/models/__init__.py +109 -0
- hackagent/models/agent.py +223 -0
- hackagent/models/agent_request.py +129 -0
- hackagent/models/api_token_log.py +184 -0
- hackagent/models/attack.py +154 -0
- hackagent/models/attack_request.py +82 -0
- hackagent/models/checkout_session_request_request.py +76 -0
- hackagent/models/checkout_session_response.py +59 -0
- hackagent/models/choice.py +81 -0
- hackagent/models/choice_message.py +67 -0
- hackagent/models/evaluation_status_enum.py +14 -0
- hackagent/models/generate_error_response.py +59 -0
- hackagent/models/generate_request_request.py +212 -0
- hackagent/models/generate_success_response.py +115 -0
- hackagent/models/generic_error_response.py +70 -0
- hackagent/models/message_request.py +67 -0
- hackagent/models/organization.py +102 -0
- hackagent/models/organization_minimal.py +68 -0
- hackagent/models/organization_request.py +71 -0
- hackagent/models/paginated_agent_list.py +123 -0
- hackagent/models/paginated_api_token_log_list.py +123 -0
- hackagent/models/paginated_attack_list.py +123 -0
- hackagent/models/paginated_organization_list.py +123 -0
- hackagent/models/paginated_prompt_list.py +123 -0
- hackagent/models/paginated_result_list.py +123 -0
- hackagent/models/paginated_run_list.py +123 -0
- hackagent/models/paginated_user_api_key_list.py +123 -0
- hackagent/models/paginated_user_profile_list.py +123 -0
- hackagent/models/patched_agent_request.py +128 -0
- hackagent/models/patched_attack_request.py +92 -0
- hackagent/models/patched_organization_request.py +71 -0
- hackagent/models/patched_prompt_request.py +125 -0
- hackagent/models/patched_result_request.py +237 -0
- hackagent/models/patched_run_request.py +138 -0
- hackagent/models/patched_user_profile_request.py +99 -0
- hackagent/models/prompt.py +220 -0
- hackagent/models/prompt_request.py +126 -0
- hackagent/models/result.py +294 -0
- hackagent/models/result_list_evaluation_status.py +14 -0
- hackagent/models/result_request.py +232 -0
- hackagent/models/run.py +233 -0
- hackagent/models/run_list_status.py +12 -0
- hackagent/models/run_request.py +133 -0
- hackagent/models/status_enum.py +12 -0
- hackagent/models/step_type_enum.py +14 -0
- hackagent/models/trace.py +121 -0
- hackagent/models/trace_request.py +94 -0
- hackagent/models/usage.py +75 -0
- hackagent/models/user_api_key.py +201 -0
- hackagent/models/user_api_key_request.py +73 -0
- hackagent/models/user_profile.py +135 -0
- hackagent/models/user_profile_minimal.py +76 -0
- hackagent/models/user_profile_request.py +99 -0
- hackagent/router/__init__.py +25 -0
- hackagent/router/adapters/__init__.py +20 -0
- hackagent/router/adapters/base.py +63 -0
- hackagent/router/adapters/google_adk.py +671 -0
- hackagent/router/adapters/litellm_adapter.py +524 -0
- hackagent/router/adapters/openai_adapter.py +426 -0
- hackagent/router/router.py +969 -0
- hackagent/router/types.py +54 -0
- hackagent/tracking/__init__.py +42 -0
- hackagent/tracking/context.py +163 -0
- hackagent/tracking/decorators.py +299 -0
- hackagent/tracking/tracker.py +441 -0
- hackagent/types.py +54 -0
- hackagent/utils.py +194 -0
- hackagent/vulnerabilities/__init__.py +13 -0
- hackagent/vulnerabilities/prompts.py +81 -0
- hackagent-0.3.1.dist-info/METADATA +122 -0
- hackagent-0.3.1.dist-info/RECORD +183 -0
- hackagent-0.3.1.dist-info/WHEEL +4 -0
- hackagent-0.3.1.dist-info/entry_points.txt +2 -0
- hackagent-0.3.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# Copyright 2025 - AI4I. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Attack Log Viewer Component
|
|
17
|
+
|
|
18
|
+
A reusable Textual widget for displaying live attack execution logs
|
|
19
|
+
with syntax highlighting, auto-scrolling, and filtering capabilities.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from textual.app import ComposeResult
|
|
23
|
+
from textual.containers import Container
|
|
24
|
+
from textual.widgets import Button, RichLog, Static
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AttackLogViewer(Container):
|
|
28
|
+
"""
|
|
29
|
+
A container widget for displaying attack execution logs in real-time.
|
|
30
|
+
|
|
31
|
+
This component provides:
|
|
32
|
+
- Live log streaming with syntax highlighting
|
|
33
|
+
- Color-coded log levels (INFO, WARNING, ERROR)
|
|
34
|
+
- Auto-scroll to latest logs
|
|
35
|
+
- Manual scroll capability
|
|
36
|
+
- Clear logs functionality
|
|
37
|
+
- Export logs to file
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
DEFAULT_CSS = """
|
|
41
|
+
AttackLogViewer {
|
|
42
|
+
border: solid $primary;
|
|
43
|
+
padding: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
AttackLogViewer .log-header {
|
|
47
|
+
dock: top;
|
|
48
|
+
height: 3;
|
|
49
|
+
background: $panel;
|
|
50
|
+
padding: 0 1;
|
|
51
|
+
content-align: center middle;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
AttackLogViewer .log-controls {
|
|
55
|
+
dock: top;
|
|
56
|
+
height: 3;
|
|
57
|
+
background: $surface;
|
|
58
|
+
padding: 0 1;
|
|
59
|
+
layout: horizontal;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
AttackLogViewer RichLog {
|
|
63
|
+
background: $surface;
|
|
64
|
+
border: none;
|
|
65
|
+
padding: 1;
|
|
66
|
+
height: 1fr;
|
|
67
|
+
width: 100%;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
AttackLogViewer Button {
|
|
71
|
+
margin: 0 1;
|
|
72
|
+
}
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
title: str = "Attack Execution Logs",
|
|
78
|
+
show_controls: bool = True,
|
|
79
|
+
max_lines: int = 1000,
|
|
80
|
+
**kwargs,
|
|
81
|
+
):
|
|
82
|
+
"""
|
|
83
|
+
Initialize the log viewer.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
title: Title to display in the header
|
|
87
|
+
show_controls: Whether to show control buttons
|
|
88
|
+
max_lines: Maximum number of log lines to retain
|
|
89
|
+
**kwargs: Additional keyword arguments for Container
|
|
90
|
+
"""
|
|
91
|
+
super().__init__(**kwargs)
|
|
92
|
+
self.log_title = title
|
|
93
|
+
self.show_controls = show_controls
|
|
94
|
+
self.max_lines = max_lines
|
|
95
|
+
self._auto_scroll = True
|
|
96
|
+
self._line_count = 0 # Track line count internally
|
|
97
|
+
self._log_buffer: list[str] = [] # Store log messages for copying
|
|
98
|
+
self._log_file = None # File handle for continuous logging
|
|
99
|
+
self._log_file_path = None # Path to the log file
|
|
100
|
+
self._initialize_log_file()
|
|
101
|
+
|
|
102
|
+
def compose(self) -> ComposeResult:
|
|
103
|
+
"""Compose the log viewer layout."""
|
|
104
|
+
# Header
|
|
105
|
+
yield Static(
|
|
106
|
+
f"[bold cyan]{self.log_title}[/bold cyan]",
|
|
107
|
+
classes="log-header",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Control buttons (optional)
|
|
111
|
+
if self.show_controls:
|
|
112
|
+
with Container(classes="log-controls"):
|
|
113
|
+
yield Button("Clear Logs", id="clear-logs", variant="default")
|
|
114
|
+
yield Button("Auto-scroll: ON", id="toggle-scroll", variant="primary")
|
|
115
|
+
yield Static("", id="log-count")
|
|
116
|
+
yield Static("", id="log-file-path", classes="log-file-info")
|
|
117
|
+
|
|
118
|
+
# Log display area
|
|
119
|
+
rich_log = RichLog(
|
|
120
|
+
highlight=True,
|
|
121
|
+
markup=True,
|
|
122
|
+
max_lines=self.max_lines,
|
|
123
|
+
wrap=True,
|
|
124
|
+
id="attack-log-display",
|
|
125
|
+
)
|
|
126
|
+
yield rich_log
|
|
127
|
+
|
|
128
|
+
def _initialize_log_file(self) -> None:
|
|
129
|
+
"""Initialize log file for continuous writing."""
|
|
130
|
+
try:
|
|
131
|
+
import os
|
|
132
|
+
from datetime import datetime
|
|
133
|
+
|
|
134
|
+
# Create logs directory
|
|
135
|
+
log_dir = os.path.join(os.getcwd(), "logs")
|
|
136
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
# Create log file with timestamp
|
|
139
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
140
|
+
self._log_file_path = os.path.join(log_dir, f"attack_logs_{timestamp}.log")
|
|
141
|
+
self._log_file = open(
|
|
142
|
+
self._log_file_path, "w", buffering=1
|
|
143
|
+
) # Line buffered
|
|
144
|
+
|
|
145
|
+
except Exception:
|
|
146
|
+
# If file creation fails, just continue without file logging
|
|
147
|
+
self._log_file = None
|
|
148
|
+
self._log_file_path = None
|
|
149
|
+
|
|
150
|
+
def on_mount(self) -> None:
|
|
151
|
+
"""Called when the widget is mounted."""
|
|
152
|
+
self.update_log_count(0)
|
|
153
|
+
if self._log_file_path and self.show_controls:
|
|
154
|
+
self._update_log_file_path_display()
|
|
155
|
+
|
|
156
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
157
|
+
"""Handle button press events."""
|
|
158
|
+
if event.button.id == "clear-logs":
|
|
159
|
+
self.clear_logs()
|
|
160
|
+
elif event.button.id == "toggle-scroll":
|
|
161
|
+
self.toggle_auto_scroll()
|
|
162
|
+
|
|
163
|
+
def add_log(self, message: str, level: str = "INFO") -> None:
|
|
164
|
+
"""
|
|
165
|
+
Add a log message to the viewer with appropriate styling.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
message: The log message to display
|
|
169
|
+
level: Log level (INFO, WARNING, ERROR, DEBUG)
|
|
170
|
+
"""
|
|
171
|
+
log_widget = self.query_one("#attack-log-display", RichLog)
|
|
172
|
+
|
|
173
|
+
# Color code based on log level
|
|
174
|
+
level_colors = {
|
|
175
|
+
"DEBUG": "dim",
|
|
176
|
+
"INFO": "cyan",
|
|
177
|
+
"WARNING": "yellow",
|
|
178
|
+
"ERROR": "bold red",
|
|
179
|
+
"CRITICAL": "bold red on white",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
color = level_colors.get(level, "white")
|
|
183
|
+
|
|
184
|
+
# Format the message with color
|
|
185
|
+
if level in ["ERROR", "CRITICAL"]:
|
|
186
|
+
formatted_message = f"[{color}]🔴 {message}[/{color}]"
|
|
187
|
+
elif level == "WARNING":
|
|
188
|
+
formatted_message = f"[{color}]⚠️ {message}[/{color}]"
|
|
189
|
+
elif level == "DEBUG":
|
|
190
|
+
formatted_message = f"[{color}]🔍 {message}[/{color}]"
|
|
191
|
+
else: # INFO and default
|
|
192
|
+
formatted_message = f"[{color}]{message}[/{color}]"
|
|
193
|
+
|
|
194
|
+
# Add to log display
|
|
195
|
+
log_widget.write(formatted_message)
|
|
196
|
+
|
|
197
|
+
# Store in buffer for copying (strip Rich markup)
|
|
198
|
+
plain_message = message # Store the original message without formatting
|
|
199
|
+
log_entry = f"[{level}] {plain_message}"
|
|
200
|
+
self._log_buffer.append(log_entry)
|
|
201
|
+
|
|
202
|
+
# Write to log file immediately
|
|
203
|
+
if self._log_file:
|
|
204
|
+
try:
|
|
205
|
+
self._log_file.write(log_entry + "\n")
|
|
206
|
+
self._log_file.flush() # Ensure it's written immediately
|
|
207
|
+
except Exception:
|
|
208
|
+
pass # Silently continue if file write fails
|
|
209
|
+
|
|
210
|
+
# Auto-scroll to bottom if enabled
|
|
211
|
+
if self._auto_scroll:
|
|
212
|
+
log_widget.scroll_end(animate=False)
|
|
213
|
+
|
|
214
|
+
# Update log count
|
|
215
|
+
self._line_count += 1
|
|
216
|
+
self.update_log_count(self._line_count)
|
|
217
|
+
|
|
218
|
+
def add_step_header(self, step_name: str, step_number: int = 0) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Add a prominent step header to visually separate pipeline steps.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
step_name: Name of the step
|
|
224
|
+
step_number: Step number (0 for no number)
|
|
225
|
+
"""
|
|
226
|
+
log_widget = self.query_one("#attack-log-display", RichLog)
|
|
227
|
+
|
|
228
|
+
# Create a visual separator
|
|
229
|
+
separator = "─" * 60
|
|
230
|
+
if step_number > 0:
|
|
231
|
+
header = f"\n[bold magenta]{separator}\n🎯 STEP {step_number}: {step_name}\n{separator}[/bold magenta]\n"
|
|
232
|
+
else:
|
|
233
|
+
header = f"\n[bold magenta]{separator}\n🎯 {step_name}\n{separator}[/bold magenta]\n"
|
|
234
|
+
|
|
235
|
+
log_widget.write(header)
|
|
236
|
+
|
|
237
|
+
if self._auto_scroll:
|
|
238
|
+
log_widget.scroll_end(animate=False)
|
|
239
|
+
|
|
240
|
+
def clear_logs(self) -> None:
|
|
241
|
+
"""Clear all log messages from the viewer."""
|
|
242
|
+
log_widget = self.query_one("#attack-log-display", RichLog)
|
|
243
|
+
log_widget.clear()
|
|
244
|
+
self._line_count = 0
|
|
245
|
+
self._log_buffer.clear()
|
|
246
|
+
|
|
247
|
+
# Close current log file and create a new one
|
|
248
|
+
if self._log_file:
|
|
249
|
+
try:
|
|
250
|
+
self._log_file.close()
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
self._initialize_log_file()
|
|
255
|
+
if self._log_file_path:
|
|
256
|
+
self._update_log_file_path_display()
|
|
257
|
+
|
|
258
|
+
self.update_log_count(0)
|
|
259
|
+
|
|
260
|
+
def copy_logs(self) -> None:
|
|
261
|
+
"""Copy all log messages to clipboard or save to file."""
|
|
262
|
+
if not self._log_buffer:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
log_text = "\n".join(self._log_buffer)
|
|
266
|
+
|
|
267
|
+
# Try multiple clipboard methods
|
|
268
|
+
copied = False
|
|
269
|
+
|
|
270
|
+
# Method 1: Try subprocess clipboard tools first (more reliable in containers/SSH)
|
|
271
|
+
try:
|
|
272
|
+
import subprocess
|
|
273
|
+
import platform
|
|
274
|
+
|
|
275
|
+
system = platform.system()
|
|
276
|
+
if system == "Linux":
|
|
277
|
+
# Try xclip first, then xsel
|
|
278
|
+
try:
|
|
279
|
+
subprocess.run(
|
|
280
|
+
["xclip", "-selection", "clipboard"],
|
|
281
|
+
input=log_text.encode(),
|
|
282
|
+
check=True,
|
|
283
|
+
stderr=subprocess.DEVNULL,
|
|
284
|
+
timeout=2,
|
|
285
|
+
)
|
|
286
|
+
copied = True
|
|
287
|
+
except (
|
|
288
|
+
FileNotFoundError,
|
|
289
|
+
subprocess.CalledProcessError,
|
|
290
|
+
subprocess.TimeoutExpired,
|
|
291
|
+
):
|
|
292
|
+
try:
|
|
293
|
+
subprocess.run(
|
|
294
|
+
["xsel", "--clipboard", "--input"],
|
|
295
|
+
input=log_text.encode(),
|
|
296
|
+
check=True,
|
|
297
|
+
stderr=subprocess.DEVNULL,
|
|
298
|
+
timeout=2,
|
|
299
|
+
)
|
|
300
|
+
copied = True
|
|
301
|
+
except (
|
|
302
|
+
FileNotFoundError,
|
|
303
|
+
subprocess.CalledProcessError,
|
|
304
|
+
subprocess.TimeoutExpired,
|
|
305
|
+
):
|
|
306
|
+
pass
|
|
307
|
+
elif system == "Darwin": # macOS
|
|
308
|
+
subprocess.run(
|
|
309
|
+
["pbcopy"], input=log_text.encode(), check=True, timeout=2
|
|
310
|
+
)
|
|
311
|
+
copied = True
|
|
312
|
+
elif system == "Windows":
|
|
313
|
+
subprocess.run(["clip"], input=log_text.encode(), check=True, timeout=2)
|
|
314
|
+
copied = True
|
|
315
|
+
|
|
316
|
+
if copied:
|
|
317
|
+
pass
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
# Method 2: Try pyperclip as fallback (if subprocess failed)
|
|
322
|
+
if not copied:
|
|
323
|
+
try:
|
|
324
|
+
import pyperclip
|
|
325
|
+
|
|
326
|
+
pyperclip.copy(log_text)
|
|
327
|
+
copied = True
|
|
328
|
+
except ImportError:
|
|
329
|
+
pass
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
# Fallback: Save to file
|
|
334
|
+
if not copied:
|
|
335
|
+
try:
|
|
336
|
+
import os
|
|
337
|
+
|
|
338
|
+
# Create a more permanent location in the project
|
|
339
|
+
log_dir = os.path.join(os.getcwd(), "logs")
|
|
340
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
341
|
+
|
|
342
|
+
from datetime import datetime
|
|
343
|
+
|
|
344
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
345
|
+
log_file = os.path.join(log_dir, f"attack_logs_{timestamp}.log")
|
|
346
|
+
|
|
347
|
+
with open(log_file, "w") as f:
|
|
348
|
+
f.write(log_text)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
def view_in_pager(self) -> None:
|
|
353
|
+
"""View logs in a pager (less) for easy selection and navigation."""
|
|
354
|
+
if not self._log_buffer:
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
import tempfile
|
|
359
|
+
import subprocess
|
|
360
|
+
import os
|
|
361
|
+
|
|
362
|
+
# Save to temporary file
|
|
363
|
+
log_text = "\n".join(self._log_buffer)
|
|
364
|
+
temp_file = tempfile.NamedTemporaryFile(
|
|
365
|
+
mode="w", suffix=".log", delete=False
|
|
366
|
+
)
|
|
367
|
+
temp_file.write(log_text)
|
|
368
|
+
temp_file.close()
|
|
369
|
+
|
|
370
|
+
# Suspend the TUI and open in pager
|
|
371
|
+
self.app.suspend()
|
|
372
|
+
|
|
373
|
+
# Try less first (with mouse support), fall back to more
|
|
374
|
+
pager = os.environ.get("PAGER", "less")
|
|
375
|
+
if pager == "less":
|
|
376
|
+
# Enable mouse, color, and exit if content fits on screen
|
|
377
|
+
subprocess.run(["less", "-R", "-X", "--mouse", temp_file.name])
|
|
378
|
+
else:
|
|
379
|
+
subprocess.run([pager, temp_file.name])
|
|
380
|
+
|
|
381
|
+
# Clean up
|
|
382
|
+
os.unlink(temp_file.name)
|
|
383
|
+
|
|
384
|
+
# Resume the TUI
|
|
385
|
+
self.app.refresh()
|
|
386
|
+
|
|
387
|
+
except Exception:
|
|
388
|
+
self.app.refresh() # Make sure we resume even on error
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
def toggle_auto_scroll(self) -> None:
|
|
392
|
+
"""Toggle automatic scrolling to latest logs."""
|
|
393
|
+
self._auto_scroll = not self._auto_scroll
|
|
394
|
+
button = self.query_one("#toggle-scroll", Button)
|
|
395
|
+
button.label = f"Auto-scroll: {'ON' if self._auto_scroll else 'OFF'}"
|
|
396
|
+
button.variant = "primary" if self._auto_scroll else "default"
|
|
397
|
+
|
|
398
|
+
def update_log_count(self, count: int) -> None:
|
|
399
|
+
"""
|
|
400
|
+
Update the log count display.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
count: Number of log lines currently displayed
|
|
404
|
+
"""
|
|
405
|
+
if self.show_controls:
|
|
406
|
+
count_widget = self.query_one("#log-count", Static)
|
|
407
|
+
count_widget.update(f"[dim]Lines: {count}/{self.max_lines}[/dim]")
|
|
408
|
+
|
|
409
|
+
def _update_log_file_path_display(self) -> None:
|
|
410
|
+
"""Update the log file path display."""
|
|
411
|
+
if self.show_controls and self._log_file_path:
|
|
412
|
+
try:
|
|
413
|
+
path_widget = self.query_one("#log-file-path", Static)
|
|
414
|
+
path_widget.update(f"[dim]📄 Logs: {self._log_file_path}[/dim]")
|
|
415
|
+
except Exception:
|
|
416
|
+
pass # Widget might not be mounted yet
|
|
417
|
+
|
|
418
|
+
def get_log_text(self) -> str:
|
|
419
|
+
"""
|
|
420
|
+
Get all log text as a plain string (for export).
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
All log messages as plain text
|
|
424
|
+
"""
|
|
425
|
+
return "\n".join(self._log_buffer)
|
|
426
|
+
|
|
427
|
+
def load_logs_from_buffer(self, buffer: list[tuple[str, str]]) -> None:
|
|
428
|
+
"""
|
|
429
|
+
Load logs from a buffer (e.g., from TUILogHandler).
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
buffer: List of (message, level) tuples
|
|
433
|
+
"""
|
|
434
|
+
for message, level in buffer:
|
|
435
|
+
self.add_log(message, level) # add_log will handle line count
|
hackagent/cli/utils.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Copyright 2025 - AI4I. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
CLI Utilities
|
|
17
|
+
|
|
18
|
+
Common utilities for the HackAgent CLI including error handling,
|
|
19
|
+
formatting, and helper functions.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import functools
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict
|
|
26
|
+
|
|
27
|
+
import click
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.panel import Panel
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from rich.text import Text
|
|
32
|
+
from rich.traceback import Traceback
|
|
33
|
+
|
|
34
|
+
from hackagent.errors import ApiError, HackAgentError
|
|
35
|
+
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def handle_errors(func):
|
|
40
|
+
"""Decorator for consistent error handling across CLI commands"""
|
|
41
|
+
|
|
42
|
+
@functools.wraps(func)
|
|
43
|
+
def wrapper(*args, **kwargs):
|
|
44
|
+
try:
|
|
45
|
+
return func(*args, **kwargs)
|
|
46
|
+
except HackAgentError as e:
|
|
47
|
+
console.print(f"[bold red]❌ HackAgent Error: {str(e)}")
|
|
48
|
+
if console._environ.get("HACKAGENT_DEBUG"):
|
|
49
|
+
console.print(Traceback())
|
|
50
|
+
raise click.ClickException(str(e))
|
|
51
|
+
except ApiError as e:
|
|
52
|
+
console.print(f"[bold red]❌ API Error: {str(e)}")
|
|
53
|
+
if console._environ.get("HACKAGENT_DEBUG"):
|
|
54
|
+
console.print(Traceback())
|
|
55
|
+
raise click.ClickException(str(e))
|
|
56
|
+
except ValueError as e:
|
|
57
|
+
console.print(f"[bold red]❌ Configuration Error: {str(e)}")
|
|
58
|
+
raise click.ClickException(str(e))
|
|
59
|
+
except FileNotFoundError as e:
|
|
60
|
+
console.print(f"[bold red]❌ File Not Found: {str(e)}")
|
|
61
|
+
raise click.ClickException(str(e))
|
|
62
|
+
except Exception as e:
|
|
63
|
+
console.print(f"[bold red]❌ Unexpected error: {str(e)}")
|
|
64
|
+
if console._environ.get("HACKAGENT_DEBUG"):
|
|
65
|
+
console.print(Traceback())
|
|
66
|
+
raise click.ClickException(str(e))
|
|
67
|
+
|
|
68
|
+
return wrapper
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_config_file(path: str) -> Dict[str, Any]:
|
|
72
|
+
"""Load configuration from YAML or JSON file"""
|
|
73
|
+
config_path = Path(path)
|
|
74
|
+
|
|
75
|
+
if not config_path.exists():
|
|
76
|
+
raise FileNotFoundError(f"Configuration file not found: {path}")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with open(config_path) as f:
|
|
80
|
+
if config_path.suffix.lower() in [".yaml", ".yml"]:
|
|
81
|
+
try:
|
|
82
|
+
import yaml
|
|
83
|
+
|
|
84
|
+
return yaml.safe_load(f) or {}
|
|
85
|
+
except ImportError:
|
|
86
|
+
raise click.ClickException(
|
|
87
|
+
"PyYAML required for YAML config files. Install with: pip install pyyaml"
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
return json.load(f)
|
|
91
|
+
except json.JSONDecodeError as e:
|
|
92
|
+
raise click.ClickException(f"Invalid JSON in config file {path}: {e}")
|
|
93
|
+
except Exception as e:
|
|
94
|
+
raise click.ClickException(f"Failed to load config file {path}: {e}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def display_results_table(results: Any, title: str = "Results") -> None:
|
|
98
|
+
"""Display results in a formatted table"""
|
|
99
|
+
import pandas as pd
|
|
100
|
+
|
|
101
|
+
if isinstance(results, pd.DataFrame):
|
|
102
|
+
if results.empty:
|
|
103
|
+
console.print(f"[yellow]ℹ️ No {title.lower()} found")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
table = Table(title=title, show_header=True, header_style="bold cyan")
|
|
107
|
+
|
|
108
|
+
# Add columns
|
|
109
|
+
for column in results.columns:
|
|
110
|
+
table.add_column(str(column))
|
|
111
|
+
|
|
112
|
+
# Add rows (limit to first 20 for display)
|
|
113
|
+
display_results = results.head(20)
|
|
114
|
+
for _, row in display_results.iterrows():
|
|
115
|
+
table.add_row(*[str(value) for value in row])
|
|
116
|
+
|
|
117
|
+
console.print(table)
|
|
118
|
+
|
|
119
|
+
if len(results) > 20:
|
|
120
|
+
console.print(f"[dim]... and {len(results) - 20} more rows")
|
|
121
|
+
|
|
122
|
+
elif isinstance(results, list):
|
|
123
|
+
if not results:
|
|
124
|
+
console.print(f"[yellow]ℹ️ No {title.lower()} found")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Try to create table from list of dicts
|
|
128
|
+
if results and isinstance(results[0], dict):
|
|
129
|
+
table = Table(title=title, show_header=True, header_style="bold cyan")
|
|
130
|
+
|
|
131
|
+
# Get all unique keys for columns
|
|
132
|
+
all_keys = set()
|
|
133
|
+
for item in results:
|
|
134
|
+
all_keys.update(item.keys())
|
|
135
|
+
|
|
136
|
+
# Add columns
|
|
137
|
+
for key in sorted(all_keys):
|
|
138
|
+
table.add_column(str(key))
|
|
139
|
+
|
|
140
|
+
# Add rows (limit to first 20)
|
|
141
|
+
for item in results[:20]:
|
|
142
|
+
row_values = []
|
|
143
|
+
for key in sorted(all_keys):
|
|
144
|
+
value = item.get(key, "")
|
|
145
|
+
row_values.append(str(value))
|
|
146
|
+
table.add_row(*row_values)
|
|
147
|
+
|
|
148
|
+
console.print(table)
|
|
149
|
+
|
|
150
|
+
if len(results) > 20:
|
|
151
|
+
console.print(f"[dim]... and {len(results) - 20} more rows")
|
|
152
|
+
else:
|
|
153
|
+
# Simple list display
|
|
154
|
+
for i, item in enumerate(results[:20], 1):
|
|
155
|
+
console.print(f"{i}. {item}")
|
|
156
|
+
|
|
157
|
+
if len(results) > 20:
|
|
158
|
+
console.print(f"[dim]... and {len(results) - 20} more items")
|
|
159
|
+
|
|
160
|
+
else:
|
|
161
|
+
# Fallback to JSON-like display
|
|
162
|
+
console.print_json(data=results)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def display_success(message: str) -> None:
|
|
166
|
+
"""Display success message with formatting"""
|
|
167
|
+
console.print(f"[bold green]✅ {message}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def display_warning(message: str) -> None:
|
|
171
|
+
"""Display warning message with formatting"""
|
|
172
|
+
console.print(f"[bold yellow]⚠️ {message}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def display_error(message: str) -> None:
|
|
176
|
+
"""Display error message with formatting"""
|
|
177
|
+
console.print(f"[bold red]❌ {message}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def display_info(message: str) -> None:
|
|
181
|
+
"""Display info message with formatting"""
|
|
182
|
+
console.print(f"[cyan]ℹ️ {message}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def confirm_action(message: str, default: bool = False) -> bool:
|
|
186
|
+
"""Get user confirmation for dangerous actions"""
|
|
187
|
+
return click.confirm(f"⚠️ {message}", default=default)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_agent_type_enum(agent_type: str):
|
|
191
|
+
"""Convert string agent type to AgentTypeEnum"""
|
|
192
|
+
from hackagent.router.types import AgentTypeEnum
|
|
193
|
+
|
|
194
|
+
# Normalize the input
|
|
195
|
+
normalized = agent_type.upper().replace("-", "_").replace(" ", "_")
|
|
196
|
+
|
|
197
|
+
# Map common variations
|
|
198
|
+
type_mapping = {
|
|
199
|
+
"GOOGLE_ADK": AgentTypeEnum.GOOGLE_ADK,
|
|
200
|
+
"GOOGLE-ADK": AgentTypeEnum.GOOGLE_ADK,
|
|
201
|
+
"ADK": AgentTypeEnum.GOOGLE_ADK,
|
|
202
|
+
"LANGCHAIN": AgentTypeEnum.LANGCHAIN,
|
|
203
|
+
"LANG_CHAIN": AgentTypeEnum.LANGCHAIN,
|
|
204
|
+
"LITELLM": AgentTypeEnum.LITELLM,
|
|
205
|
+
"LITE_LLM": AgentTypeEnum.LITELLM,
|
|
206
|
+
"OPENAI_SDK": AgentTypeEnum.OPENAI_SDK,
|
|
207
|
+
"OPENAI-SDK": AgentTypeEnum.OPENAI_SDK,
|
|
208
|
+
"OPENAI": AgentTypeEnum.OPENAI_SDK,
|
|
209
|
+
"OTHER": AgentTypeEnum.UNKNOWN,
|
|
210
|
+
"UNKNOWN": AgentTypeEnum.UNKNOWN,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if normalized in type_mapping:
|
|
214
|
+
return type_mapping[normalized]
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
return AgentTypeEnum(normalized)
|
|
218
|
+
except ValueError:
|
|
219
|
+
# If the type is not recognized, fallback to UNKNOWN
|
|
220
|
+
console.print(
|
|
221
|
+
f"[yellow]⚠️ Agent type '{agent_type}' not recognized, using 'UNKNOWN'[/yellow]"
|
|
222
|
+
)
|
|
223
|
+
return AgentTypeEnum.UNKNOWN
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def format_duration(seconds: float) -> str:
|
|
227
|
+
"""Format duration in seconds to human readable format"""
|
|
228
|
+
if seconds < 60:
|
|
229
|
+
return f"{seconds:.1f}s"
|
|
230
|
+
elif seconds < 3600:
|
|
231
|
+
minutes = seconds / 60
|
|
232
|
+
return f"{minutes:.1f}m"
|
|
233
|
+
else:
|
|
234
|
+
hours = seconds / 3600
|
|
235
|
+
return f"{hours:.1f}h"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def create_status_panel(title: str, content: str, status: str = "info") -> Panel:
|
|
239
|
+
"""Create a status panel with appropriate styling"""
|
|
240
|
+
style_map = {
|
|
241
|
+
"success": "green",
|
|
242
|
+
"error": "red",
|
|
243
|
+
"warning": "yellow",
|
|
244
|
+
"info": "cyan",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
style = style_map.get(status, "cyan")
|
|
248
|
+
return Panel(
|
|
249
|
+
Text(content, style=style), title=title, border_style=style, padding=(1, 2)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def launch_tui(cli_config, initial_tab: str = "dashboard", initial_data: dict = None):
|
|
254
|
+
"""Launch the TUI application with specified tab and optional initial data
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
cli_config: CLI configuration object
|
|
258
|
+
initial_tab: Which tab to show initially (default: "dashboard")
|
|
259
|
+
initial_data: Initial data to pre-fill in the tab (default: None)
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
from hackagent.cli.tui import HackAgentTUI
|
|
263
|
+
|
|
264
|
+
app = HackAgentTUI(
|
|
265
|
+
cli_config, initial_tab=initial_tab, initial_data=initial_data
|
|
266
|
+
)
|
|
267
|
+
app.run()
|
|
268
|
+
|
|
269
|
+
except ImportError:
|
|
270
|
+
console.print("[bold red]❌ TUI dependencies not installed[/bold red]")
|
|
271
|
+
console.print("\n[cyan]💡 Install with:[/cyan]")
|
|
272
|
+
console.print(" uv add textual")
|
|
273
|
+
raise click.ClickException("TUI dependencies not installed")
|
|
274
|
+
except Exception as e:
|
|
275
|
+
console.print(f"[bold red]❌ TUI failed to start: {e}[/bold red]")
|
|
276
|
+
raise click.ClickException(f"TUI failed to start: {e}")
|