strix-agent 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. strix/__init__.py +0 -0
  2. strix/agents/StrixAgent/__init__.py +4 -0
  3. strix/agents/StrixAgent/strix_agent.py +89 -0
  4. strix/agents/StrixAgent/system_prompt.jinja +404 -0
  5. strix/agents/__init__.py +10 -0
  6. strix/agents/base_agent.py +518 -0
  7. strix/agents/state.py +163 -0
  8. strix/interface/__init__.py +4 -0
  9. strix/interface/assets/tui_styles.tcss +694 -0
  10. strix/interface/cli.py +230 -0
  11. strix/interface/main.py +500 -0
  12. strix/interface/tool_components/__init__.py +39 -0
  13. strix/interface/tool_components/agents_graph_renderer.py +123 -0
  14. strix/interface/tool_components/base_renderer.py +62 -0
  15. strix/interface/tool_components/browser_renderer.py +120 -0
  16. strix/interface/tool_components/file_edit_renderer.py +99 -0
  17. strix/interface/tool_components/finish_renderer.py +31 -0
  18. strix/interface/tool_components/notes_renderer.py +108 -0
  19. strix/interface/tool_components/proxy_renderer.py +255 -0
  20. strix/interface/tool_components/python_renderer.py +34 -0
  21. strix/interface/tool_components/registry.py +72 -0
  22. strix/interface/tool_components/reporting_renderer.py +53 -0
  23. strix/interface/tool_components/scan_info_renderer.py +64 -0
  24. strix/interface/tool_components/terminal_renderer.py +131 -0
  25. strix/interface/tool_components/thinking_renderer.py +29 -0
  26. strix/interface/tool_components/user_message_renderer.py +43 -0
  27. strix/interface/tool_components/web_search_renderer.py +28 -0
  28. strix/interface/tui.py +1274 -0
  29. strix/interface/utils.py +559 -0
  30. strix/llm/__init__.py +15 -0
  31. strix/llm/config.py +20 -0
  32. strix/llm/llm.py +465 -0
  33. strix/llm/memory_compressor.py +212 -0
  34. strix/llm/request_queue.py +87 -0
  35. strix/llm/utils.py +87 -0
  36. strix/prompts/README.md +64 -0
  37. strix/prompts/__init__.py +109 -0
  38. strix/prompts/cloud/.gitkeep +0 -0
  39. strix/prompts/coordination/root_agent.jinja +41 -0
  40. strix/prompts/custom/.gitkeep +0 -0
  41. strix/prompts/frameworks/fastapi.jinja +142 -0
  42. strix/prompts/frameworks/nextjs.jinja +126 -0
  43. strix/prompts/protocols/graphql.jinja +215 -0
  44. strix/prompts/reconnaissance/.gitkeep +0 -0
  45. strix/prompts/technologies/firebase_firestore.jinja +177 -0
  46. strix/prompts/technologies/supabase.jinja +189 -0
  47. strix/prompts/vulnerabilities/authentication_jwt.jinja +147 -0
  48. strix/prompts/vulnerabilities/broken_function_level_authorization.jinja +146 -0
  49. strix/prompts/vulnerabilities/business_logic.jinja +171 -0
  50. strix/prompts/vulnerabilities/csrf.jinja +174 -0
  51. strix/prompts/vulnerabilities/idor.jinja +195 -0
  52. strix/prompts/vulnerabilities/information_disclosure.jinja +222 -0
  53. strix/prompts/vulnerabilities/insecure_file_uploads.jinja +188 -0
  54. strix/prompts/vulnerabilities/mass_assignment.jinja +141 -0
  55. strix/prompts/vulnerabilities/open_redirect.jinja +177 -0
  56. strix/prompts/vulnerabilities/path_traversal_lfi_rfi.jinja +142 -0
  57. strix/prompts/vulnerabilities/race_conditions.jinja +164 -0
  58. strix/prompts/vulnerabilities/rce.jinja +154 -0
  59. strix/prompts/vulnerabilities/sql_injection.jinja +151 -0
  60. strix/prompts/vulnerabilities/ssrf.jinja +135 -0
  61. strix/prompts/vulnerabilities/subdomain_takeover.jinja +155 -0
  62. strix/prompts/vulnerabilities/xss.jinja +169 -0
  63. strix/prompts/vulnerabilities/xxe.jinja +184 -0
  64. strix/runtime/__init__.py +19 -0
  65. strix/runtime/docker_runtime.py +399 -0
  66. strix/runtime/runtime.py +29 -0
  67. strix/runtime/tool_server.py +205 -0
  68. strix/telemetry/__init__.py +4 -0
  69. strix/telemetry/tracer.py +337 -0
  70. strix/tools/__init__.py +64 -0
  71. strix/tools/agents_graph/__init__.py +16 -0
  72. strix/tools/agents_graph/agents_graph_actions.py +621 -0
  73. strix/tools/agents_graph/agents_graph_actions_schema.xml +226 -0
  74. strix/tools/argument_parser.py +121 -0
  75. strix/tools/browser/__init__.py +4 -0
  76. strix/tools/browser/browser_actions.py +236 -0
  77. strix/tools/browser/browser_actions_schema.xml +183 -0
  78. strix/tools/browser/browser_instance.py +533 -0
  79. strix/tools/browser/tab_manager.py +342 -0
  80. strix/tools/executor.py +305 -0
  81. strix/tools/file_edit/__init__.py +4 -0
  82. strix/tools/file_edit/file_edit_actions.py +141 -0
  83. strix/tools/file_edit/file_edit_actions_schema.xml +128 -0
  84. strix/tools/finish/__init__.py +4 -0
  85. strix/tools/finish/finish_actions.py +174 -0
  86. strix/tools/finish/finish_actions_schema.xml +45 -0
  87. strix/tools/notes/__init__.py +14 -0
  88. strix/tools/notes/notes_actions.py +191 -0
  89. strix/tools/notes/notes_actions_schema.xml +150 -0
  90. strix/tools/proxy/__init__.py +20 -0
  91. strix/tools/proxy/proxy_actions.py +101 -0
  92. strix/tools/proxy/proxy_actions_schema.xml +267 -0
  93. strix/tools/proxy/proxy_manager.py +785 -0
  94. strix/tools/python/__init__.py +4 -0
  95. strix/tools/python/python_actions.py +47 -0
  96. strix/tools/python/python_actions_schema.xml +131 -0
  97. strix/tools/python/python_instance.py +172 -0
  98. strix/tools/python/python_manager.py +131 -0
  99. strix/tools/registry.py +196 -0
  100. strix/tools/reporting/__init__.py +6 -0
  101. strix/tools/reporting/reporting_actions.py +63 -0
  102. strix/tools/reporting/reporting_actions_schema.xml +30 -0
  103. strix/tools/terminal/__init__.py +4 -0
  104. strix/tools/terminal/terminal_actions.py +35 -0
  105. strix/tools/terminal/terminal_actions_schema.xml +146 -0
  106. strix/tools/terminal/terminal_manager.py +151 -0
  107. strix/tools/terminal/terminal_session.py +447 -0
  108. strix/tools/thinking/__init__.py +4 -0
  109. strix/tools/thinking/thinking_actions.py +18 -0
  110. strix/tools/thinking/thinking_actions_schema.xml +52 -0
  111. strix/tools/web_search/__init__.py +4 -0
  112. strix/tools/web_search/web_search_actions.py +80 -0
  113. strix/tools/web_search/web_search_actions_schema.xml +83 -0
  114. strix_agent-0.4.0.dist-info/LICENSE +201 -0
  115. strix_agent-0.4.0.dist-info/METADATA +282 -0
  116. strix_agent-0.4.0.dist-info/RECORD +118 -0
  117. strix_agent-0.4.0.dist-info/WHEEL +4 -0
  118. strix_agent-0.4.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,447 @@
1
+ import logging
2
+ import re
3
+ import time
4
+ import uuid
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import libtmux
10
+
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class BashCommandStatus(Enum):
16
+ CONTINUE = "continue"
17
+ COMPLETED = "completed"
18
+ NO_CHANGE_TIMEOUT = "no_change_timeout"
19
+ HARD_TIMEOUT = "hard_timeout"
20
+
21
+
22
+ def _remove_command_prefix(command_output: str, command: str) -> str:
23
+ return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
24
+
25
+
26
+ class TerminalSession:
27
+ POLL_INTERVAL = 0.5
28
+ HISTORY_LIMIT = 10_000
29
+ PS1_END = "]$ "
30
+
31
+ def __init__(self, session_id: str, work_dir: str = "/workspace") -> None:
32
+ self.session_id = session_id
33
+ self.work_dir = str(Path(work_dir).resolve())
34
+ self._closed = False
35
+ self._cwd = self.work_dir
36
+
37
+ self.server: libtmux.Server | None = None
38
+ self.session: libtmux.Session | None = None
39
+ self.window: libtmux.Window | None = None
40
+ self.pane: libtmux.Pane | None = None
41
+
42
+ self.prev_status: BashCommandStatus | None = None
43
+ self.prev_output: str = ""
44
+ self._initialized = False
45
+
46
+ self.initialize()
47
+
48
+ @property
49
+ def PS1(self) -> str: # noqa: N802
50
+ return r"[STRIX_$?]$ "
51
+
52
+ @property
53
+ def PS1_PATTERN(self) -> str: # noqa: N802
54
+ return r"\[STRIX_(\d+)\]"
55
+
56
+ def initialize(self) -> None:
57
+ self.server = libtmux.Server()
58
+
59
+ session_name = f"strix-{self.session_id}-{uuid.uuid4()}"
60
+ self.session = self.server.new_session(
61
+ session_name=session_name,
62
+ start_directory=self.work_dir,
63
+ kill_session=True,
64
+ x=120,
65
+ y=30,
66
+ )
67
+
68
+ self.session.set_option("history-limit", str(self.HISTORY_LIMIT))
69
+ self.session.history_limit = self.HISTORY_LIMIT
70
+
71
+ _initial_window = self.session.active_window
72
+ self.window = self.session.new_window(
73
+ window_name="bash",
74
+ window_shell="/bin/bash",
75
+ start_directory=self.work_dir,
76
+ )
77
+ self.pane = self.window.active_pane
78
+ _initial_window.kill()
79
+
80
+ self.pane.send_keys(f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""')
81
+ time.sleep(0.1)
82
+ self._clear_screen()
83
+
84
+ self.prev_status = None
85
+ self.prev_output = ""
86
+ self._closed = False
87
+
88
+ self._cwd = str(Path(self.work_dir).resolve())
89
+ self._initialized = True
90
+
91
+ assert self.server is not None
92
+ assert self.session is not None
93
+ assert self.window is not None
94
+ assert self.pane is not None
95
+
96
+ def _get_pane_content(self) -> str:
97
+ if not self.pane:
98
+ raise RuntimeError("Terminal session not properly initialized")
99
+ return "\n".join(
100
+ line.rstrip() for line in self.pane.cmd("capture-pane", "-J", "-pS", "-").stdout
101
+ )
102
+
103
+ def _clear_screen(self) -> None:
104
+ if not self.pane:
105
+ raise RuntimeError("Terminal session not properly initialized")
106
+ self.pane.send_keys("C-l", enter=False)
107
+ time.sleep(0.1)
108
+ self.pane.cmd("clear-history")
109
+
110
+ def _is_control_key(self, command: str) -> bool:
111
+ return (
112
+ (command.startswith("C-") and len(command) >= 3)
113
+ or (command.startswith("^") and len(command) >= 2)
114
+ or (command.startswith("S-") and len(command) >= 3)
115
+ or (command.startswith("M-") and len(command) >= 3)
116
+ )
117
+
118
+ def _is_function_key(self, command: str) -> bool:
119
+ if not command.startswith("F") or len(command) > 3:
120
+ return False
121
+ try:
122
+ num_part = command[1:]
123
+ return num_part.isdigit() and 1 <= int(num_part) <= 12
124
+ except (ValueError, IndexError):
125
+ return False
126
+
127
+ def _is_navigation_or_special_key(self, command: str) -> bool:
128
+ navigation_keys = {"Up", "Down", "Left", "Right", "Home", "End"}
129
+ special_keys = {"BSpace", "BTab", "DC", "Enter", "Escape", "IC", "Space", "Tab"}
130
+ page_keys = {"NPage", "PageDown", "PgDn", "PPage", "PageUp", "PgUp"}
131
+
132
+ return command in navigation_keys or command in special_keys or command in page_keys
133
+
134
+ def _is_complex_modifier_key(self, command: str) -> bool:
135
+ return "-" in command and any(
136
+ command.startswith(prefix)
137
+ for prefix in ["C-S-", "C-M-", "S-M-", "M-S-", "M-C-", "S-C-"]
138
+ )
139
+
140
+ def _is_special_key(self, command: str) -> bool:
141
+ _command = command.strip()
142
+
143
+ if not _command:
144
+ return False
145
+
146
+ return (
147
+ self._is_control_key(_command)
148
+ or self._is_function_key(_command)
149
+ or self._is_navigation_or_special_key(_command)
150
+ or self._is_complex_modifier_key(_command)
151
+ )
152
+
153
+ def _matches_ps1_metadata(self, content: str) -> list[re.Match[str]]:
154
+ return list(re.finditer(self.PS1_PATTERN + r"\]\$ ", content))
155
+
156
+ def _get_command_output(
157
+ self,
158
+ command: str,
159
+ raw_command_output: str,
160
+ continue_prefix: str = "",
161
+ ) -> str:
162
+ if self.prev_output:
163
+ command_output = raw_command_output.removeprefix(self.prev_output)
164
+ if continue_prefix:
165
+ command_output = continue_prefix + command_output
166
+ else:
167
+ command_output = raw_command_output
168
+ self.prev_output = raw_command_output
169
+ command_output = _remove_command_prefix(command_output, command)
170
+ return command_output.rstrip()
171
+
172
+ def _combine_outputs_between_matches(
173
+ self,
174
+ pane_content: str,
175
+ ps1_matches: list[re.Match[str]],
176
+ get_content_before_last_match: bool = False,
177
+ ) -> str:
178
+ if len(ps1_matches) == 1:
179
+ if get_content_before_last_match:
180
+ return pane_content[: ps1_matches[0].start()]
181
+ return pane_content[ps1_matches[0].end() + 1 :]
182
+ if len(ps1_matches) == 0:
183
+ return pane_content
184
+
185
+ combined_output = ""
186
+ for i in range(len(ps1_matches) - 1):
187
+ output_segment = pane_content[ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()]
188
+ combined_output += output_segment + "\n"
189
+ combined_output += pane_content[ps1_matches[-1].end() + 1 :]
190
+ return combined_output
191
+
192
+ def _extract_exit_code_from_matches(self, ps1_matches: list[re.Match[str]]) -> int | None:
193
+ if not ps1_matches:
194
+ return None
195
+
196
+ last_match = ps1_matches[-1]
197
+ try:
198
+ return int(last_match.group(1))
199
+ except (ValueError, IndexError):
200
+ return None
201
+
202
+ def _handle_empty_command(
203
+ self,
204
+ cur_pane_output: str,
205
+ ps1_matches: list[re.Match[str]],
206
+ is_command_running: bool,
207
+ timeout: float,
208
+ ) -> dict[str, Any]:
209
+ if not is_command_running:
210
+ raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
211
+ command_output = self._get_command_output("", raw_command_output)
212
+ return {
213
+ "content": command_output,
214
+ "status": "completed",
215
+ "exit_code": 0,
216
+ "working_dir": self._cwd,
217
+ }
218
+
219
+ start_time = time.time()
220
+ last_pane_output = cur_pane_output
221
+
222
+ while True:
223
+ cur_pane_output = self._get_pane_content()
224
+ ps1_matches = self._matches_ps1_metadata(cur_pane_output)
225
+
226
+ if cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0:
227
+ exit_code = self._extract_exit_code_from_matches(ps1_matches)
228
+ raw_command_output = self._combine_outputs_between_matches(
229
+ cur_pane_output, ps1_matches
230
+ )
231
+ command_output = self._get_command_output("", raw_command_output)
232
+ self.prev_status = BashCommandStatus.COMPLETED
233
+ self.prev_output = ""
234
+ self._ready_for_next_command()
235
+ return {
236
+ "content": command_output,
237
+ "status": "completed",
238
+ "exit_code": exit_code or 0,
239
+ "working_dir": self._cwd,
240
+ }
241
+
242
+ elapsed_time = time.time() - start_time
243
+ if elapsed_time >= timeout:
244
+ raw_command_output = self._combine_outputs_between_matches(
245
+ cur_pane_output, ps1_matches
246
+ )
247
+ command_output = self._get_command_output("", raw_command_output)
248
+ return {
249
+ "content": command_output
250
+ + f"\n[Command still running after {timeout}s - showing output so far]",
251
+ "status": "running",
252
+ "exit_code": None,
253
+ "working_dir": self._cwd,
254
+ }
255
+
256
+ if cur_pane_output != last_pane_output:
257
+ last_pane_output = cur_pane_output
258
+
259
+ time.sleep(self.POLL_INTERVAL)
260
+
261
+ def _handle_input_command(
262
+ self, command: str, no_enter: bool, is_command_running: bool
263
+ ) -> dict[str, Any]:
264
+ if not is_command_running:
265
+ return {
266
+ "content": "No command is currently running. Cannot send input.",
267
+ "status": "error",
268
+ "exit_code": None,
269
+ "working_dir": self._cwd,
270
+ }
271
+
272
+ if not self.pane:
273
+ raise RuntimeError("Terminal session not properly initialized")
274
+
275
+ is_special_key = self._is_special_key(command)
276
+ should_add_enter = not is_special_key and not no_enter
277
+ self.pane.send_keys(command, enter=should_add_enter)
278
+
279
+ time.sleep(2)
280
+ cur_pane_output = self._get_pane_content()
281
+ ps1_matches = self._matches_ps1_metadata(cur_pane_output)
282
+ raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
283
+ command_output = self._get_command_output(command, raw_command_output)
284
+
285
+ is_still_running = not (
286
+ cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
287
+ )
288
+
289
+ if is_still_running:
290
+ return {
291
+ "content": command_output,
292
+ "status": "running",
293
+ "exit_code": None,
294
+ "working_dir": self._cwd,
295
+ }
296
+
297
+ exit_code = self._extract_exit_code_from_matches(ps1_matches)
298
+ self.prev_status = BashCommandStatus.COMPLETED
299
+ self.prev_output = ""
300
+ self._ready_for_next_command()
301
+ return {
302
+ "content": command_output,
303
+ "status": "completed",
304
+ "exit_code": exit_code or 0,
305
+ "working_dir": self._cwd,
306
+ }
307
+
308
+ def _execute_new_command(self, command: str, no_enter: bool, timeout: float) -> dict[str, Any]:
309
+ if not self.pane:
310
+ raise RuntimeError("Terminal session not properly initialized")
311
+
312
+ initial_pane_output = self._get_pane_content()
313
+ initial_ps1_matches = self._matches_ps1_metadata(initial_pane_output)
314
+ initial_ps1_count = len(initial_ps1_matches)
315
+
316
+ start_time = time.time()
317
+ last_pane_output = initial_pane_output
318
+
319
+ is_special_key = self._is_special_key(command)
320
+ should_add_enter = not is_special_key and not no_enter
321
+ self.pane.send_keys(command, enter=should_add_enter)
322
+
323
+ while True:
324
+ cur_pane_output = self._get_pane_content()
325
+ ps1_matches = self._matches_ps1_metadata(cur_pane_output)
326
+ current_ps1_count = len(ps1_matches)
327
+
328
+ if cur_pane_output != last_pane_output:
329
+ last_pane_output = cur_pane_output
330
+
331
+ if current_ps1_count > initial_ps1_count or cur_pane_output.rstrip().endswith(
332
+ self.PS1_END.rstrip()
333
+ ):
334
+ exit_code = self._extract_exit_code_from_matches(ps1_matches)
335
+
336
+ get_content_before_last_match = bool(len(ps1_matches) == 1)
337
+ raw_command_output = self._combine_outputs_between_matches(
338
+ cur_pane_output,
339
+ ps1_matches,
340
+ get_content_before_last_match=get_content_before_last_match,
341
+ )
342
+
343
+ command_output = self._get_command_output(command, raw_command_output)
344
+ self.prev_status = BashCommandStatus.COMPLETED
345
+ self.prev_output = ""
346
+ self._ready_for_next_command()
347
+
348
+ return {
349
+ "content": command_output,
350
+ "status": "completed",
351
+ "exit_code": exit_code or 0,
352
+ "working_dir": self._cwd,
353
+ }
354
+
355
+ elapsed_time = time.time() - start_time
356
+ if elapsed_time >= timeout:
357
+ raw_command_output = self._combine_outputs_between_matches(
358
+ cur_pane_output, ps1_matches
359
+ )
360
+ command_output = self._get_command_output(
361
+ command,
362
+ raw_command_output,
363
+ continue_prefix="[Below is the output of the previous command.]\n",
364
+ )
365
+ self.prev_status = BashCommandStatus.CONTINUE
366
+
367
+ timeout_msg = (
368
+ f"\n[Command still running after {timeout}s - showing output so far. "
369
+ "Use C-c to interrupt if needed.]"
370
+ )
371
+ return {
372
+ "content": command_output + timeout_msg,
373
+ "status": "running",
374
+ "exit_code": None,
375
+ "working_dir": self._cwd,
376
+ }
377
+
378
+ time.sleep(self.POLL_INTERVAL)
379
+
380
+ def execute(
381
+ self, command: str, is_input: bool = False, timeout: float = 10.0, no_enter: bool = False
382
+ ) -> dict[str, Any]:
383
+ if not self._initialized:
384
+ raise RuntimeError("Bash session is not initialized")
385
+
386
+ cur_pane_output = self._get_pane_content()
387
+ ps1_matches = self._matches_ps1_metadata(cur_pane_output)
388
+ is_command_running = not (
389
+ cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
390
+ )
391
+
392
+ if command.strip() == "":
393
+ return self._handle_empty_command(
394
+ cur_pane_output, ps1_matches, is_command_running, timeout
395
+ )
396
+
397
+ is_special_key = self._is_special_key(command)
398
+
399
+ if is_input:
400
+ return self._handle_input_command(command, no_enter, is_command_running)
401
+
402
+ if is_special_key and is_command_running:
403
+ return self._handle_input_command(command, no_enter, is_command_running)
404
+
405
+ if is_command_running:
406
+ return {
407
+ "content": (
408
+ "A command is already running. Use is_input=true to send input to it, "
409
+ "or interrupt it first (e.g., with C-c)."
410
+ ),
411
+ "status": "error",
412
+ "exit_code": None,
413
+ "working_dir": self._cwd,
414
+ }
415
+
416
+ return self._execute_new_command(command, no_enter, timeout)
417
+
418
+ def _ready_for_next_command(self) -> None:
419
+ self._clear_screen()
420
+
421
+ def is_running(self) -> bool:
422
+ if self._closed or not self.session:
423
+ return False
424
+ try:
425
+ return self.session.id in [s.id for s in self.server.sessions] if self.server else False
426
+ except (AttributeError, OSError) as e:
427
+ logger.debug("Error checking if session is running: %s", e)
428
+ return False
429
+
430
+ def get_working_dir(self) -> str:
431
+ return self._cwd
432
+
433
+ def close(self) -> None:
434
+ if self._closed:
435
+ return
436
+
437
+ if self.session:
438
+ try:
439
+ self.session.kill()
440
+ except (AttributeError, OSError) as e:
441
+ logger.debug("Error closing terminal session: %s", e)
442
+
443
+ self._closed = True
444
+ self.server = None
445
+ self.session = None
446
+ self.window = None
447
+ self.pane = None
@@ -0,0 +1,4 @@
1
+ from .thinking_actions import think
2
+
3
+
4
+ __all__ = ["think"]
@@ -0,0 +1,18 @@
1
+ from typing import Any
2
+
3
+ from strix.tools.registry import register_tool
4
+
5
+
6
+ @register_tool(sandbox_execution=False)
7
+ def think(thought: str) -> dict[str, Any]:
8
+ try:
9
+ if not thought or not thought.strip():
10
+ return {"success": False, "message": "Thought cannot be empty"}
11
+
12
+ return {
13
+ "success": True,
14
+ "message": f"Thought recorded successfully with {len(thought.strip())} characters",
15
+ }
16
+
17
+ except (ValueError, TypeError) as e:
18
+ return {"success": False, "message": f"Failed to record thought: {e!s}"}
@@ -0,0 +1,52 @@
1
+ <tools>
2
+ <tool name="think">
3
+ <description>Use the tool to think about something. It will not obtain new information or change the
4
+ database. Use it when complex reasoning or some cache memory is needed.</description>
5
+ <details>This tool creates dedicated space for structured thinking during complex tasks,
6
+ particularly useful for:
7
+ - Tool output analysis: When you need to carefully process the output of previous tool calls
8
+ - Policy-heavy environments: When you need to follow detailed guidelines and verify compliance
9
+ - Sequential decision making: When each action builds on previous ones and mistakes are costly
10
+ - Multi-step problem solving: When you need to break down complex problems into manageable steps</details>
11
+ <parameters>
12
+ <parameter name="thought" type="string" required="true">
13
+ <description>The thought or reasoning to record</description>
14
+ </parameter>
15
+ </parameters>
16
+ <returns type="Dict[str, Any]">
17
+ <description>Response containing: - success: Whether the thought was recorded successfully - message: Confirmation message with character count or error details</description>
18
+ </returns>
19
+ <examples>
20
+ # Planning and strategy
21
+ <function=think>
22
+ <parameter=thought>I need to analyze the scan results systematically. First, let me review
23
+ the open ports: 22 (SSH), 80 (HTTP), 443 (HTTPS), and 3306 (MySQL). The MySQL port being
24
+ externally accessible is a high priority security concern. I should check for default
25
+ credentials and version information. For the web services, I need to enumerate
26
+ directories and test for common web vulnerabilities.</parameter>
27
+ </function>
28
+
29
+ # Analysis of tool outputs
30
+ <function=think>
31
+ <parameter=thought>The Nmap scan revealed 15 open ports, but three stand out as concerning:
32
+ - Port 3306 (MySQL) - Database should not be exposed externally
33
+ - Port 5432 (PostgreSQL) - Another database port that's risky when public
34
+ - Port 6379 (Redis) - Often misconfigured and can lead to data exposure
35
+ I should prioritize testing these database services for authentication bypass and
36
+ information disclosure vulnerabilities.</parameter>
37
+ </function>
38
+
39
+ # Decision making and next steps
40
+ <function=think>
41
+ <parameter=thought>Based on the vulnerability scan results, I've identified several critical
42
+ issues that need immediate attention:
43
+ 1. SQL injection in the login form (confirmed with sqlmap)
44
+ 2. Reflected XSS in the search parameter
45
+ 3. Directory traversal in the file upload function
46
+ I should document these findings with proof-of-concept exploits and assign appropriate
47
+ CVSS scores. The SQL injection poses the highest risk due to potential data
48
+ exfiltration.</parameter>
49
+ </function>
50
+ </examples>
51
+ </tool>
52
+ </tools>
@@ -0,0 +1,4 @@
1
+ from .web_search_actions import web_search
2
+
3
+
4
+ __all__ = ["web_search"]
@@ -0,0 +1,80 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ import requests
5
+
6
+ from strix.tools.registry import register_tool
7
+
8
+
9
+ SYSTEM_PROMPT = """You are assisting a cybersecurity agent specialized in vulnerability scanning
10
+ and security assessment running on Kali Linux. When responding to search queries:
11
+
12
+ 1. Prioritize cybersecurity-relevant information including:
13
+ - Vulnerability details (CVEs, CVSS scores, impact)
14
+ - Security tools, techniques, and methodologies
15
+ - Exploit information and proof-of-concepts
16
+ - Security best practices and mitigations
17
+ - Penetration testing approaches
18
+ - Web application security findings
19
+
20
+ 2. Provide technical depth appropriate for security professionals
21
+ 3. Include specific versions, configurations, and technical details when available
22
+ 4. Focus on actionable intelligence for security assessment
23
+ 5. Cite reliable security sources (NIST, OWASP, CVE databases, security vendors)
24
+ 6. When providing commands or installation instructions, prioritize Kali Linux compatibility
25
+ and use apt package manager or tools pre-installed in Kali
26
+ 7. Be detailed and specific - avoid general answers. Always include concrete code examples,
27
+ command-line instructions, configuration snippets, or practical implementation steps
28
+ when applicable
29
+
30
+ Structure your response to be comprehensive yet concise, emphasizing the most critical
31
+ security implications and details."""
32
+
33
+
34
+ @register_tool(sandbox_execution=False)
35
+ def web_search(query: str) -> dict[str, Any]:
36
+ try:
37
+ api_key = os.getenv("PERPLEXITY_API_KEY")
38
+ if not api_key:
39
+ return {
40
+ "success": False,
41
+ "message": "PERPLEXITY_API_KEY environment variable not set",
42
+ "results": [],
43
+ }
44
+
45
+ url = "https://api.perplexity.ai/chat/completions"
46
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
47
+
48
+ payload = {
49
+ "model": "sonar-reasoning",
50
+ "messages": [
51
+ {"role": "system", "content": SYSTEM_PROMPT},
52
+ {"role": "user", "content": query},
53
+ ],
54
+ }
55
+
56
+ response = requests.post(url, headers=headers, json=payload, timeout=300)
57
+ response.raise_for_status()
58
+
59
+ response_data = response.json()
60
+ content = response_data["choices"][0]["message"]["content"]
61
+
62
+ except requests.exceptions.Timeout:
63
+ return {"success": False, "message": "Request timed out", "results": []}
64
+ except requests.exceptions.RequestException as e:
65
+ return {"success": False, "message": f"API request failed: {e!s}", "results": []}
66
+ except KeyError as e:
67
+ return {
68
+ "success": False,
69
+ "message": f"Unexpected API response format: missing {e!s}",
70
+ "results": [],
71
+ }
72
+ except Exception as e: # noqa: BLE001
73
+ return {"success": False, "message": f"Web search failed: {e!s}", "results": []}
74
+ else:
75
+ return {
76
+ "success": True,
77
+ "query": query,
78
+ "content": content,
79
+ "message": "Web search completed successfully",
80
+ }