strix-agent 0.4.0__py3-none-any.whl → 0.6.2__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 (117) hide show
  1. strix/agents/StrixAgent/strix_agent.py +3 -3
  2. strix/agents/StrixAgent/system_prompt.jinja +30 -26
  3. strix/agents/base_agent.py +159 -75
  4. strix/agents/state.py +5 -2
  5. strix/config/__init__.py +12 -0
  6. strix/config/config.py +172 -0
  7. strix/interface/assets/tui_styles.tcss +195 -230
  8. strix/interface/cli.py +16 -41
  9. strix/interface/main.py +151 -74
  10. strix/interface/streaming_parser.py +119 -0
  11. strix/interface/tool_components/__init__.py +4 -0
  12. strix/interface/tool_components/agent_message_renderer.py +190 -0
  13. strix/interface/tool_components/agents_graph_renderer.py +54 -38
  14. strix/interface/tool_components/base_renderer.py +68 -36
  15. strix/interface/tool_components/browser_renderer.py +106 -91
  16. strix/interface/tool_components/file_edit_renderer.py +117 -36
  17. strix/interface/tool_components/finish_renderer.py +43 -10
  18. strix/interface/tool_components/notes_renderer.py +63 -38
  19. strix/interface/tool_components/proxy_renderer.py +133 -92
  20. strix/interface/tool_components/python_renderer.py +121 -8
  21. strix/interface/tool_components/registry.py +19 -12
  22. strix/interface/tool_components/reporting_renderer.py +196 -28
  23. strix/interface/tool_components/scan_info_renderer.py +22 -19
  24. strix/interface/tool_components/terminal_renderer.py +270 -90
  25. strix/interface/tool_components/thinking_renderer.py +8 -6
  26. strix/interface/tool_components/todo_renderer.py +225 -0
  27. strix/interface/tool_components/user_message_renderer.py +26 -19
  28. strix/interface/tool_components/web_search_renderer.py +7 -6
  29. strix/interface/tui.py +907 -262
  30. strix/interface/utils.py +236 -4
  31. strix/llm/__init__.py +6 -2
  32. strix/llm/config.py +8 -5
  33. strix/llm/dedupe.py +217 -0
  34. strix/llm/llm.py +209 -356
  35. strix/llm/memory_compressor.py +6 -5
  36. strix/llm/utils.py +17 -8
  37. strix/runtime/__init__.py +12 -3
  38. strix/runtime/docker_runtime.py +121 -202
  39. strix/runtime/tool_server.py +55 -95
  40. strix/skills/README.md +64 -0
  41. strix/skills/__init__.py +110 -0
  42. strix/{prompts → skills}/frameworks/nextjs.jinja +26 -0
  43. strix/skills/scan_modes/deep.jinja +145 -0
  44. strix/skills/scan_modes/quick.jinja +63 -0
  45. strix/skills/scan_modes/standard.jinja +91 -0
  46. strix/telemetry/README.md +38 -0
  47. strix/telemetry/__init__.py +7 -1
  48. strix/telemetry/posthog.py +137 -0
  49. strix/telemetry/tracer.py +194 -54
  50. strix/tools/__init__.py +11 -4
  51. strix/tools/agents_graph/agents_graph_actions.py +20 -21
  52. strix/tools/agents_graph/agents_graph_actions_schema.xml +8 -8
  53. strix/tools/browser/browser_actions.py +10 -6
  54. strix/tools/browser/browser_actions_schema.xml +6 -1
  55. strix/tools/browser/browser_instance.py +96 -48
  56. strix/tools/browser/tab_manager.py +121 -102
  57. strix/tools/context.py +12 -0
  58. strix/tools/executor.py +63 -4
  59. strix/tools/file_edit/file_edit_actions.py +6 -3
  60. strix/tools/file_edit/file_edit_actions_schema.xml +45 -3
  61. strix/tools/finish/finish_actions.py +80 -105
  62. strix/tools/finish/finish_actions_schema.xml +121 -14
  63. strix/tools/notes/notes_actions.py +6 -33
  64. strix/tools/notes/notes_actions_schema.xml +50 -46
  65. strix/tools/proxy/proxy_actions.py +14 -2
  66. strix/tools/proxy/proxy_actions_schema.xml +0 -1
  67. strix/tools/proxy/proxy_manager.py +28 -16
  68. strix/tools/python/python_actions.py +2 -2
  69. strix/tools/python/python_actions_schema.xml +9 -1
  70. strix/tools/python/python_instance.py +39 -37
  71. strix/tools/python/python_manager.py +43 -31
  72. strix/tools/registry.py +73 -12
  73. strix/tools/reporting/reporting_actions.py +218 -31
  74. strix/tools/reporting/reporting_actions_schema.xml +256 -8
  75. strix/tools/terminal/terminal_actions.py +2 -2
  76. strix/tools/terminal/terminal_actions_schema.xml +6 -0
  77. strix/tools/terminal/terminal_manager.py +41 -30
  78. strix/tools/thinking/thinking_actions_schema.xml +27 -25
  79. strix/tools/todo/__init__.py +18 -0
  80. strix/tools/todo/todo_actions.py +568 -0
  81. strix/tools/todo/todo_actions_schema.xml +225 -0
  82. strix/utils/__init__.py +0 -0
  83. strix/utils/resource_paths.py +13 -0
  84. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/METADATA +90 -65
  85. strix_agent-0.6.2.dist-info/RECORD +134 -0
  86. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/WHEEL +1 -1
  87. strix/llm/request_queue.py +0 -87
  88. strix/prompts/README.md +0 -64
  89. strix/prompts/__init__.py +0 -109
  90. strix_agent-0.4.0.dist-info/RECORD +0 -118
  91. /strix/{prompts → skills}/cloud/.gitkeep +0 -0
  92. /strix/{prompts → skills}/coordination/root_agent.jinja +0 -0
  93. /strix/{prompts → skills}/custom/.gitkeep +0 -0
  94. /strix/{prompts → skills}/frameworks/fastapi.jinja +0 -0
  95. /strix/{prompts → skills}/protocols/graphql.jinja +0 -0
  96. /strix/{prompts → skills}/reconnaissance/.gitkeep +0 -0
  97. /strix/{prompts → skills}/technologies/firebase_firestore.jinja +0 -0
  98. /strix/{prompts → skills}/technologies/supabase.jinja +0 -0
  99. /strix/{prompts → skills}/vulnerabilities/authentication_jwt.jinja +0 -0
  100. /strix/{prompts → skills}/vulnerabilities/broken_function_level_authorization.jinja +0 -0
  101. /strix/{prompts → skills}/vulnerabilities/business_logic.jinja +0 -0
  102. /strix/{prompts → skills}/vulnerabilities/csrf.jinja +0 -0
  103. /strix/{prompts → skills}/vulnerabilities/idor.jinja +0 -0
  104. /strix/{prompts → skills}/vulnerabilities/information_disclosure.jinja +0 -0
  105. /strix/{prompts → skills}/vulnerabilities/insecure_file_uploads.jinja +0 -0
  106. /strix/{prompts → skills}/vulnerabilities/mass_assignment.jinja +0 -0
  107. /strix/{prompts → skills}/vulnerabilities/open_redirect.jinja +0 -0
  108. /strix/{prompts → skills}/vulnerabilities/path_traversal_lfi_rfi.jinja +0 -0
  109. /strix/{prompts → skills}/vulnerabilities/race_conditions.jinja +0 -0
  110. /strix/{prompts → skills}/vulnerabilities/rce.jinja +0 -0
  111. /strix/{prompts → skills}/vulnerabilities/sql_injection.jinja +0 -0
  112. /strix/{prompts → skills}/vulnerabilities/ssrf.jinja +0 -0
  113. /strix/{prompts → skills}/vulnerabilities/subdomain_takeover.jinja +0 -0
  114. /strix/{prompts → skills}/vulnerabilities/xss.jinja +0 -0
  115. /strix/{prompts → skills}/vulnerabilities/xxe.jinja +0 -0
  116. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/entry_points.txt +0 -0
  117. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info/licenses}/LICENSE +0 -0
@@ -1,10 +1,9 @@
1
1
  <tools>
2
2
  <tool name="create_note">
3
- <description>Create a personal note for TODOs, side notes, plans, and organizational purposes during
4
- the scan.</description>
5
- <details>Use this tool for quick reminders, action items, planning thoughts, and organizational notes
6
- rather than formal vulnerability reports or detailed findings. This is your personal notepad
7
- for keeping track of tasks, ideas, and things to remember or follow up on.</details>
3
+ <description>Create a personal note for observations, findings, and research during the scan.</description>
4
+ <details>Use this tool for documenting discoveries, observations, methodology notes, and questions.
5
+ This is your personal notepad for recording information you want to remember or reference later.
6
+ For tracking actionable tasks, use the todo tool instead.</details>
8
7
  <parameters>
9
8
  <parameter name="title" type="string" required="true">
10
9
  <description>Title of the note</description>
@@ -13,49 +12,66 @@
13
12
  <description>Content of the note</description>
14
13
  </parameter>
15
14
  <parameter name="category" type="string" required="false">
16
- <description>Category to organize the note (default: "general", "findings", "methodology", "todo", "questions", "plan")</description>
15
+ <description>Category to organize the note (default: "general", "findings", "methodology", "questions", "plan")</description>
17
16
  </parameter>
18
17
  <parameter name="tags" type="string" required="false">
19
18
  <description>Tags for categorization</description>
20
19
  </parameter>
21
- <parameter name="priority" type="string" required="false">
22
- <description>Priority level of the note ("low", "normal", "high", "urgent")</description>
23
- </parameter>
24
20
  </parameters>
25
21
  <returns type="Dict[str, Any]">
26
22
  <description>Response containing: - note_id: ID of the created note - success: Whether the note was created successfully</description>
27
23
  </returns>
28
24
  <examples>
29
- # Create a TODO reminder
25
+ # Document an interesting finding
30
26
  <function=create_note>
31
- <parameter=title>TODO: Check SSL Certificate Details</parameter>
32
- <parameter=content>Remember to verify SSL certificate validity and check for weak ciphers
33
- on the HTTPS service discovered on port 443. Also check for certificate
34
- transparency logs.</parameter>
35
- <parameter=category>todo</parameter>
36
- <parameter=tags>["ssl", "certificate", "followup"]</parameter>
37
- <parameter=priority>normal</parameter>
38
- </function>
27
+ <parameter=title>Authentication Bypass Findings</parameter>
28
+ <parameter=content>Discovered multiple authentication bypass vectors in the login system:
39
29
 
40
- # Planning note
41
- <function=create_note>
42
- <parameter=title>Scan Strategy Planning</parameter>
43
- <parameter=content>Plan for next phase: 1) Complete subdomain enumeration 2) Test discovered
44
- web apps for OWASP Top 10 3) Check database services for default creds
45
- 4) Review any custom applications for business logic flaws</parameter>
46
- <parameter=category>plan</parameter>
47
- <parameter=tags>["planning", "strategy", "next_steps"]</parameter>
30
+ 1. SQL Injection in username field
31
+ - Payload: admin'--
32
+ - Result: Full authentication bypass
33
+ - Endpoint: POST /api/v1/auth/login
34
+
35
+ 2. JWT Token Weakness
36
+ - Algorithm confusion attack possible (RS256 -> HS256)
37
+ - Token expiration is 24 hours but no refresh rotation
38
+ - Token stored in localStorage (XSS risk)
39
+
40
+ 3. Password Reset Flow
41
+ - Reset tokens are only 6 digits (brute-forceable)
42
+ - No rate limiting on reset attempts
43
+ - Token valid for 48 hours
44
+
45
+ Next Steps:
46
+ - Extract full database via SQL injection
47
+ - Test JWT manipulation attacks
48
+ - Attempt password reset brute force</parameter>
49
+ <parameter=category>findings</parameter>
50
+ <parameter=tags>["auth", "sqli", "jwt", "critical"]</parameter>
48
51
  </function>
49
52
 
50
- # Side note for later investigation
53
+ # Methodology note
51
54
  <function=create_note>
52
- <parameter=title>Interesting Directory Found</parameter>
53
- <parameter=content>Found /backup/ directory that might contain sensitive files. Low priority
54
- for now but worth checking if time permits. Directory listing seems
55
- disabled.</parameter>
56
- <parameter=category>findings</parameter>
57
- <parameter=tags>["directory", "backup", "low_priority"]</parameter>
58
- <parameter=priority>low</parameter>
55
+ <parameter=title>API Endpoint Mapping Complete</parameter>
56
+ <parameter=content>Completed comprehensive API enumeration using multiple techniques:
57
+
58
+ Discovered Endpoints:
59
+ - /api/v1/auth/* - Authentication endpoints (login, register, reset)
60
+ - /api/v1/users/* - User management (profile, settings, admin)
61
+ - /api/v1/orders/* - Order management (IDOR vulnerability confirmed)
62
+ - /api/v1/admin/* - Admin panel (403 but may be bypassable)
63
+ - /api/internal/* - Internal APIs (should not be exposed)
64
+
65
+ Methods Used:
66
+ - Analyzed JavaScript bundles for API calls
67
+ - Bruteforced common paths with ffuf
68
+ - Reviewed OpenAPI/Swagger documentation at /api/docs
69
+ - Monitored traffic during normal application usage
70
+
71
+ Priority Targets:
72
+ The /api/internal/* endpoints are high priority as they appear to lack authentication checks based on error message differences.</parameter>
73
+ <parameter=category>methodology</parameter>
74
+ <parameter=tags>["api", "enumeration", "recon"]</parameter>
59
75
  </function>
60
76
  </examples>
61
77
  </tool>
@@ -84,9 +100,6 @@
84
100
  <parameter name="tags" type="string" required="false">
85
101
  <description>Filter by tags (returns notes with any of these tags)</description>
86
102
  </parameter>
87
- <parameter name="priority" type="string" required="false">
88
- <description>Filter by priority level</description>
89
- </parameter>
90
103
  <parameter name="search" type="string" required="false">
91
104
  <description>Search query to find in note titles and content</description>
92
105
  </parameter>
@@ -100,11 +113,6 @@
100
113
  <parameter=category>findings</parameter>
101
114
  </function>
102
115
 
103
- # List high priority items
104
- <function=list_notes>
105
- <parameter=priority>high</parameter>
106
- </function>
107
-
108
116
  # Search for SQL injection related notes
109
117
  <function=list_notes>
110
118
  <parameter=search>SQL injection</parameter>
@@ -132,9 +140,6 @@
132
140
  <parameter name="tags" type="string" required="false">
133
141
  <description>New tags for the note</description>
134
142
  </parameter>
135
- <parameter name="priority" type="string" required="false">
136
- <description>New priority level</description>
137
- </parameter>
138
143
  </parameters>
139
144
  <returns type="Dict[str, Any]">
140
145
  <description>Response containing: - success: Whether the note was updated successfully</description>
@@ -143,7 +148,6 @@
143
148
  <function=update_note>
144
149
  <parameter=note_id>note_123</parameter>
145
150
  <parameter=content>Updated content with new findings...</parameter>
146
- <parameter=priority>urgent</parameter>
147
151
  </function>
148
152
  </examples>
149
153
  </tool>
@@ -2,8 +2,6 @@ from typing import Any, Literal
2
2
 
3
3
  from strix.tools.registry import register_tool
4
4
 
5
- from .proxy_manager import get_proxy_manager
6
-
7
5
 
8
6
  RequestPart = Literal["request", "response"]
9
7
 
@@ -27,6 +25,8 @@ def list_requests(
27
25
  sort_order: Literal["asc", "desc"] = "desc",
28
26
  scope_id: str | None = None,
29
27
  ) -> dict[str, Any]:
28
+ from .proxy_manager import get_proxy_manager
29
+
30
30
  manager = get_proxy_manager()
31
31
  return manager.list_requests(
32
32
  httpql_filter, start_page, end_page, page_size, sort_by, sort_order, scope_id
@@ -41,6 +41,8 @@ def view_request(
41
41
  page: int = 1,
42
42
  page_size: int = 50,
43
43
  ) -> dict[str, Any]:
44
+ from .proxy_manager import get_proxy_manager
45
+
44
46
  manager = get_proxy_manager()
45
47
  return manager.view_request(request_id, part, search_pattern, page, page_size)
46
48
 
@@ -53,6 +55,8 @@ def send_request(
53
55
  body: str = "",
54
56
  timeout: int = 30,
55
57
  ) -> dict[str, Any]:
58
+ from .proxy_manager import get_proxy_manager
59
+
56
60
  if headers is None:
57
61
  headers = {}
58
62
  manager = get_proxy_manager()
@@ -64,6 +68,8 @@ def repeat_request(
64
68
  request_id: str,
65
69
  modifications: dict[str, Any] | None = None,
66
70
  ) -> dict[str, Any]:
71
+ from .proxy_manager import get_proxy_manager
72
+
67
73
  if modifications is None:
68
74
  modifications = {}
69
75
  manager = get_proxy_manager()
@@ -78,6 +84,8 @@ def scope_rules(
78
84
  scope_id: str | None = None,
79
85
  scope_name: str | None = None,
80
86
  ) -> dict[str, Any]:
87
+ from .proxy_manager import get_proxy_manager
88
+
81
89
  manager = get_proxy_manager()
82
90
  return manager.scope_rules(action, allowlist, denylist, scope_id, scope_name)
83
91
 
@@ -89,6 +97,8 @@ def list_sitemap(
89
97
  depth: Literal["DIRECT", "ALL"] = "DIRECT",
90
98
  page: int = 1,
91
99
  ) -> dict[str, Any]:
100
+ from .proxy_manager import get_proxy_manager
101
+
92
102
  manager = get_proxy_manager()
93
103
  return manager.list_sitemap(scope_id, parent_id, depth, page)
94
104
 
@@ -97,5 +107,7 @@ def list_sitemap(
97
107
  def view_sitemap_entry(
98
108
  entry_id: str,
99
109
  ) -> dict[str, Any]:
110
+ from .proxy_manager import get_proxy_manager
111
+
100
112
  manager = get_proxy_manager()
101
113
  return manager.view_sitemap_entry(entry_id)
@@ -1,4 +1,3 @@
1
- <?xml version="1.0" ?>
2
1
  <tools>
3
2
  <tool name="list_requests">
4
3
  <description>List and filter proxy requests using HTTPQL with pagination.</description>
@@ -16,17 +16,24 @@ if TYPE_CHECKING:
16
16
  from collections.abc import Callable
17
17
 
18
18
 
19
+ CAIDO_PORT = 48080 # Fixed port inside container
20
+
21
+
19
22
  class ProxyManager:
20
23
  def __init__(self, auth_token: str | None = None):
21
24
  host = "127.0.0.1"
22
- port = os.getenv("CAIDO_PORT", "56789")
23
- self.base_url = f"http://{host}:{port}/graphql"
24
- self.proxies = {"http": f"http://{host}:{port}", "https": f"http://{host}:{port}"}
25
+ self.base_url = f"http://{host}:{CAIDO_PORT}/graphql"
26
+ self.proxies = {
27
+ "http": f"http://{host}:{CAIDO_PORT}",
28
+ "https": f"http://{host}:{CAIDO_PORT}",
29
+ }
25
30
  self.auth_token = auth_token or os.getenv("CAIDO_API_TOKEN")
26
- self.transport = RequestsHTTPTransport(
31
+
32
+ def _get_client(self) -> Client:
33
+ transport = RequestsHTTPTransport(
27
34
  url=self.base_url, headers={"Authorization": f"Bearer {self.auth_token}"}
28
35
  )
29
- self.client = Client(transport=self.transport, fetch_schema_from_transport=False)
36
+ return Client(transport=transport, fetch_schema_from_transport=False)
30
37
 
31
38
  def list_requests(
32
39
  self,
@@ -85,7 +92,7 @@ class ProxyManager:
85
92
  }
86
93
 
87
94
  try:
88
- result = self.client.execute(query, variable_values=variables)
95
+ result = self._get_client().execute(query, variable_values=variables)
89
96
  data = result.get("requestsByOffset", {})
90
97
  nodes = [edge["node"] for edge in data.get("edges", [])]
91
98
 
@@ -132,7 +139,9 @@ class ProxyManager:
132
139
  return {"error": f"Invalid part '{part}'. Use 'request' or 'response'"}
133
140
 
134
141
  try:
135
- result = self.client.execute(gql(queries[part]), variable_values={"id": request_id})
142
+ result = self._get_client().execute(
143
+ gql(queries[part]), variable_values={"id": request_id}
144
+ )
136
145
  request_data = result.get("request", {})
137
146
 
138
147
  if not request_data:
@@ -430,7 +439,9 @@ class ProxyManager:
430
439
  }
431
440
 
432
441
  def _handle_scope_list(self) -> dict[str, Any]:
433
- result = self.client.execute(gql("query { scopes { id name allowlist denylist indexed } }"))
442
+ result = self._get_client().execute(
443
+ gql("query { scopes { id name allowlist denylist indexed } }")
444
+ )
434
445
  scopes = result.get("scopes", [])
435
446
  return {"scopes": scopes, "count": len(scopes)}
436
447
 
@@ -438,7 +449,7 @@ class ProxyManager:
438
449
  if not scope_id:
439
450
  return self._handle_scope_list()
440
451
 
441
- result = self.client.execute(
452
+ result = self._get_client().execute(
442
453
  gql(
443
454
  "query GetScope($id: ID!) { scope(id: $id) { id name allowlist denylist indexed } }"
444
455
  ),
@@ -467,7 +478,7 @@ class ProxyManager:
467
478
  }
468
479
  """)
469
480
 
470
- result = self.client.execute(
481
+ result = self._get_client().execute(
471
482
  mutation,
472
483
  variable_values={
473
484
  "input": {
@@ -507,7 +518,7 @@ class ProxyManager:
507
518
  }
508
519
  """)
509
520
 
510
- result = self.client.execute(
521
+ result = self._get_client().execute(
511
522
  mutation,
512
523
  variable_values={
513
524
  "id": scope_id,
@@ -530,7 +541,7 @@ class ProxyManager:
530
541
  if not scope_id:
531
542
  return {"error": "scope_id required for delete"}
532
543
 
533
- result = self.client.execute(
544
+ result = self._get_client().execute(
534
545
  gql("mutation DeleteScope($id: ID!) { deleteScope(id: $id) { deletedId } }"),
535
546
  variable_values={"id": scope_id},
536
547
  )
@@ -607,7 +618,7 @@ class ProxyManager:
607
618
  }
608
619
  }
609
620
  """)
610
- result = self.client.execute(
621
+ result = self._get_client().execute(
611
622
  query, variable_values={"parentId": parent_id, "depth": depth}
612
623
  )
613
624
  data = result.get("sitemapDescendantEntries", {})
@@ -624,7 +635,7 @@ class ProxyManager:
624
635
  }
625
636
  }
626
637
  """)
627
- result = self.client.execute(query, variable_values={"scopeId": scope_id})
638
+ result = self._get_client().execute(query, variable_values={"scopeId": scope_id})
628
639
  data = result.get("sitemapRootEntries", {})
629
640
 
630
641
  all_nodes = [edge["node"] for edge in data.get("edges", [])]
@@ -731,7 +742,7 @@ class ProxyManager:
731
742
  }
732
743
  """)
733
744
 
734
- result = self.client.execute(query, variable_values={"id": entry_id})
745
+ result = self._get_client().execute(query, variable_values={"id": entry_id})
735
746
  entry = result.get("sitemapEntry")
736
747
 
737
748
  if not entry:
@@ -780,6 +791,7 @@ _PROXY_MANAGER: ProxyManager | None = None
780
791
 
781
792
 
782
793
  def get_proxy_manager() -> ProxyManager:
794
+ global _PROXY_MANAGER # noqa: PLW0603
783
795
  if _PROXY_MANAGER is None:
784
- return ProxyManager()
796
+ _PROXY_MANAGER = ProxyManager()
785
797
  return _PROXY_MANAGER
@@ -2,8 +2,6 @@ from typing import Any, Literal
2
2
 
3
3
  from strix.tools.registry import register_tool
4
4
 
5
- from .python_manager import get_python_session_manager
6
-
7
5
 
8
6
  PythonAction = Literal["new_session", "execute", "close", "list_sessions"]
9
7
 
@@ -15,6 +13,8 @@ def python_action(
15
13
  timeout: int = 30,
16
14
  session_id: str | None = None,
17
15
  ) -> dict[str, Any]:
16
+ from .python_manager import get_python_session_manager
17
+
18
18
  def _validate_code(action_name: str, code: str | None) -> None:
19
19
  if not code:
20
20
  raise ValueError(f"code parameter is required for {action_name} action")
@@ -1,4 +1,3 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
1
  <tools>
3
2
  <tool name="python_action">
4
3
  <description>Perform Python actions using persistent interpreter sessions for cybersecurity tasks.</description>
@@ -55,6 +54,7 @@
55
54
  - Print statements and stdout are captured
56
55
  - Variables persist between executions in the same session
57
56
  - Imports, function definitions, etc. persist in the session
57
+ - IMPORTANT (multiline): Put real line breaks in your code. Do NOT emit literal "\n" sequences — use actual newlines.
58
58
  - IPython magic commands are fully supported (%pip, %time, %whos, %%writefile, etc.)
59
59
  - Line magics (%) and cell magics (%%) work as expected
60
60
  6. CLOSE: Terminates the session completely and frees memory
@@ -73,6 +73,14 @@
73
73
  print("Security analysis session started")</parameter>
74
74
  </function>
75
75
 
76
+ <function=python_action>
77
+ <parameter=action>execute</parameter>
78
+ <parameter=code>import requests
79
+ url = "https://example.com"
80
+ resp = requests.get(url, timeout=10)
81
+ print(resp.status_code)</parameter>
82
+ </function>
83
+
76
84
  # Analyze security data in the default session
77
85
  <function=python_action>
78
86
  <parameter=action>execute</parameter>
@@ -1,5 +1,4 @@
1
1
  import io
2
- import signal
3
2
  import sys
4
3
  import threading
5
4
  from typing import Any
@@ -57,28 +56,6 @@ class PythonInstance:
57
56
  }
58
57
  return None
59
58
 
60
- def _setup_execution_environment(self, timeout: int) -> tuple[Any, io.StringIO, io.StringIO]:
61
- stdout_capture = io.StringIO()
62
- stderr_capture = io.StringIO()
63
-
64
- def timeout_handler(signum: int, frame: Any) -> None:
65
- raise TimeoutError(f"Code execution timed out after {timeout} seconds")
66
-
67
- old_handler = signal.signal(signal.SIGALRM, timeout_handler)
68
- signal.alarm(timeout)
69
-
70
- sys.stdout = stdout_capture
71
- sys.stderr = stderr_capture
72
-
73
- return old_handler, stdout_capture, stderr_capture
74
-
75
- def _cleanup_execution_environment(
76
- self, old_handler: Any, old_stdout: Any, old_stderr: Any
77
- ) -> None:
78
- signal.signal(signal.SIGALRM, old_handler)
79
- sys.stdout = old_stdout
80
- sys.stderr = old_stderr
81
-
82
59
  def _truncate_output(self, content: str, max_length: int, suffix: str) -> str:
83
60
  if len(content) > max_length:
84
61
  return content[:max_length] + suffix
@@ -142,27 +119,52 @@ class PythonInstance:
142
119
  return session_error
143
120
 
144
121
  with self._execution_lock:
145
- old_stdout, old_stderr = sys.stdout, sys.stderr
122
+ result_container: dict[str, Any] = {}
123
+ stdout_capture = io.StringIO()
124
+ stderr_capture = io.StringIO()
125
+ cancelled = threading.Event()
146
126
 
147
- try:
148
- old_handler, stdout_capture, stderr_capture = self._setup_execution_environment(
149
- timeout
150
- )
127
+ old_stdout, old_stderr = sys.stdout, sys.stderr
151
128
 
129
+ def _run_code() -> None:
152
130
  try:
131
+ sys.stdout = stdout_capture
132
+ sys.stderr = stderr_capture
153
133
  execution_result = self.shell.run_cell(code, silent=False, store_history=True)
154
- signal.alarm(0)
134
+ result_container["execution_result"] = execution_result
135
+ result_container["stdout"] = stdout_capture.getvalue()
136
+ result_container["stderr"] = stderr_capture.getvalue()
137
+ except (KeyboardInterrupt, SystemExit) as e:
138
+ result_container["error"] = e
139
+ except Exception as e: # noqa: BLE001
140
+ result_container["error"] = e
141
+ finally:
142
+ if not cancelled.is_set():
143
+ sys.stdout = old_stdout
144
+ sys.stderr = old_stderr
145
+
146
+ exec_thread = threading.Thread(target=_run_code, daemon=True)
147
+ exec_thread.start()
148
+ exec_thread.join(timeout=timeout)
149
+
150
+ if exec_thread.is_alive():
151
+ cancelled.set()
152
+ sys.stdout, sys.stderr = old_stdout, old_stderr
153
+ return self._handle_execution_error(
154
+ TimeoutError(f"Code execution timed out after {timeout} seconds")
155
+ )
155
156
 
156
- return self._format_execution_result(
157
- execution_result, stdout_capture.getvalue(), stderr_capture.getvalue()
158
- )
157
+ if "error" in result_container:
158
+ return self._handle_execution_error(result_container["error"])
159
159
 
160
- except (TimeoutError, KeyboardInterrupt, SystemExit) as e:
161
- signal.alarm(0)
162
- return self._handle_execution_error(e)
160
+ if "execution_result" in result_container:
161
+ return self._format_execution_result(
162
+ result_container["execution_result"],
163
+ result_container.get("stdout", ""),
164
+ result_container.get("stderr", ""),
165
+ )
163
166
 
164
- finally:
165
- self._cleanup_execution_environment(old_handler, old_stdout, old_stderr)
167
+ return self._handle_execution_error(RuntimeError("Unknown execution error"))
166
168
 
167
169
  def close(self) -> None:
168
170
  self.is_running = False
@@ -1,33 +1,41 @@
1
1
  import atexit
2
2
  import contextlib
3
- import signal
4
- import sys
5
3
  import threading
6
4
  from typing import Any
7
5
 
6
+ from strix.tools.context import get_current_agent_id
7
+
8
8
  from .python_instance import PythonInstance
9
9
 
10
10
 
11
11
  class PythonSessionManager:
12
12
  def __init__(self) -> None:
13
- self.sessions: dict[str, PythonInstance] = {}
13
+ self._sessions_by_agent: dict[str, dict[str, PythonInstance]] = {}
14
14
  self._lock = threading.Lock()
15
15
  self.default_session_id = "default"
16
16
 
17
17
  self._register_cleanup_handlers()
18
18
 
19
+ def _get_agent_sessions(self) -> dict[str, PythonInstance]:
20
+ agent_id = get_current_agent_id()
21
+ with self._lock:
22
+ if agent_id not in self._sessions_by_agent:
23
+ self._sessions_by_agent[agent_id] = {}
24
+ return self._sessions_by_agent[agent_id]
25
+
19
26
  def create_session(
20
27
  self, session_id: str | None = None, initial_code: str | None = None, timeout: int = 30
21
28
  ) -> dict[str, Any]:
22
29
  if session_id is None:
23
30
  session_id = self.default_session_id
24
31
 
32
+ sessions = self._get_agent_sessions()
25
33
  with self._lock:
26
- if session_id in self.sessions:
34
+ if session_id in sessions:
27
35
  raise ValueError(f"Python session '{session_id}' already exists")
28
36
 
29
37
  session = PythonInstance(session_id)
30
- self.sessions[session_id] = session
38
+ sessions[session_id] = session
31
39
 
32
40
  if initial_code:
33
41
  result = session.execute_code(initial_code, timeout)
@@ -51,11 +59,12 @@ class PythonSessionManager:
51
59
  if not code:
52
60
  raise ValueError("No code provided for execution")
53
61
 
62
+ sessions = self._get_agent_sessions()
54
63
  with self._lock:
55
- if session_id not in self.sessions:
64
+ if session_id not in sessions:
56
65
  raise ValueError(f"Python session '{session_id}' not found")
57
66
 
58
- session = self.sessions[session_id]
67
+ session = sessions[session_id]
59
68
 
60
69
  result = session.execute_code(code, timeout)
61
70
  result["message"] = f"Code executed in session '{session_id}'"
@@ -65,11 +74,12 @@ class PythonSessionManager:
65
74
  if session_id is None:
66
75
  session_id = self.default_session_id
67
76
 
77
+ sessions = self._get_agent_sessions()
68
78
  with self._lock:
69
- if session_id not in self.sessions:
79
+ if session_id not in sessions:
70
80
  raise ValueError(f"Python session '{session_id}' not found")
71
81
 
72
- session = self.sessions.pop(session_id)
82
+ session = sessions.pop(session_id)
73
83
 
74
84
  session.close()
75
85
  return {
@@ -79,9 +89,10 @@ class PythonSessionManager:
79
89
  }
80
90
 
81
91
  def list_sessions(self) -> dict[str, Any]:
92
+ sessions = self._get_agent_sessions()
82
93
  with self._lock:
83
94
  session_info = {}
84
- for sid, session in self.sessions.items():
95
+ for sid, session in sessions.items():
85
96
  session_info[sid] = {
86
97
  "is_running": session.is_running,
87
98
  "is_alive": session.is_alive(),
@@ -89,40 +100,41 @@ class PythonSessionManager:
89
100
 
90
101
  return {"sessions": session_info, "total_count": len(session_info)}
91
102
 
103
+ def cleanup_agent(self, agent_id: str) -> None:
104
+ with self._lock:
105
+ sessions = self._sessions_by_agent.pop(agent_id, {})
106
+
107
+ for session in sessions.values():
108
+ with contextlib.suppress(Exception):
109
+ session.close()
110
+
92
111
  def cleanup_dead_sessions(self) -> None:
93
112
  with self._lock:
94
- dead_sessions = []
95
- for sid, session in self.sessions.items():
96
- if not session.is_alive():
97
- dead_sessions.append(sid)
113
+ for sessions in self._sessions_by_agent.values():
114
+ dead_sessions = []
115
+ for sid, session in sessions.items():
116
+ if not session.is_alive():
117
+ dead_sessions.append(sid)
98
118
 
99
- for sid in dead_sessions:
100
- session = self.sessions.pop(sid)
101
- with contextlib.suppress(Exception):
102
- session.close()
119
+ for sid in dead_sessions:
120
+ session = sessions.pop(sid)
121
+ with contextlib.suppress(Exception):
122
+ session.close()
103
123
 
104
124
  def close_all_sessions(self) -> None:
105
125
  with self._lock:
106
- sessions_to_close = list(self.sessions.values())
107
- self.sessions.clear()
126
+ all_sessions: list[PythonInstance] = []
127
+ for sessions in self._sessions_by_agent.values():
128
+ all_sessions.extend(sessions.values())
129
+ self._sessions_by_agent.clear()
108
130
 
109
- for session in sessions_to_close:
131
+ for session in all_sessions:
110
132
  with contextlib.suppress(Exception):
111
133
  session.close()
112
134
 
113
135
  def _register_cleanup_handlers(self) -> None:
114
136
  atexit.register(self.close_all_sessions)
115
137
 
116
- signal.signal(signal.SIGTERM, self._signal_handler)
117
- signal.signal(signal.SIGINT, self._signal_handler)
118
-
119
- if hasattr(signal, "SIGHUP"):
120
- signal.signal(signal.SIGHUP, self._signal_handler)
121
-
122
- def _signal_handler(self, _signum: int, _frame: Any) -> None:
123
- self.close_all_sessions()
124
- sys.exit(0)
125
-
126
138
 
127
139
  _python_session_manager = PythonSessionManager()
128
140