aiptx 2.0.7__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 (187) hide show
  1. aipt_v2/__init__.py +110 -0
  2. aipt_v2/__main__.py +24 -0
  3. aipt_v2/agents/AIPTxAgent/__init__.py +10 -0
  4. aipt_v2/agents/AIPTxAgent/aiptx_agent.py +211 -0
  5. aipt_v2/agents/__init__.py +46 -0
  6. aipt_v2/agents/base.py +520 -0
  7. aipt_v2/agents/exploit_agent.py +688 -0
  8. aipt_v2/agents/ptt.py +406 -0
  9. aipt_v2/agents/state.py +168 -0
  10. aipt_v2/app.py +957 -0
  11. aipt_v2/browser/__init__.py +31 -0
  12. aipt_v2/browser/automation.py +458 -0
  13. aipt_v2/browser/crawler.py +453 -0
  14. aipt_v2/cli.py +2933 -0
  15. aipt_v2/compliance/__init__.py +71 -0
  16. aipt_v2/compliance/compliance_report.py +449 -0
  17. aipt_v2/compliance/framework_mapper.py +424 -0
  18. aipt_v2/compliance/nist_mapping.py +345 -0
  19. aipt_v2/compliance/owasp_mapping.py +330 -0
  20. aipt_v2/compliance/pci_mapping.py +297 -0
  21. aipt_v2/config.py +341 -0
  22. aipt_v2/core/__init__.py +43 -0
  23. aipt_v2/core/agent.py +630 -0
  24. aipt_v2/core/llm.py +395 -0
  25. aipt_v2/core/memory.py +305 -0
  26. aipt_v2/core/ptt.py +329 -0
  27. aipt_v2/database/__init__.py +14 -0
  28. aipt_v2/database/models.py +232 -0
  29. aipt_v2/database/repository.py +384 -0
  30. aipt_v2/docker/__init__.py +23 -0
  31. aipt_v2/docker/builder.py +260 -0
  32. aipt_v2/docker/manager.py +222 -0
  33. aipt_v2/docker/sandbox.py +371 -0
  34. aipt_v2/evasion/__init__.py +58 -0
  35. aipt_v2/evasion/request_obfuscator.py +272 -0
  36. aipt_v2/evasion/tls_fingerprint.py +285 -0
  37. aipt_v2/evasion/ua_rotator.py +301 -0
  38. aipt_v2/evasion/waf_bypass.py +439 -0
  39. aipt_v2/execution/__init__.py +23 -0
  40. aipt_v2/execution/executor.py +302 -0
  41. aipt_v2/execution/parser.py +544 -0
  42. aipt_v2/execution/terminal.py +337 -0
  43. aipt_v2/health.py +437 -0
  44. aipt_v2/intelligence/__init__.py +194 -0
  45. aipt_v2/intelligence/adaptation.py +474 -0
  46. aipt_v2/intelligence/auth.py +520 -0
  47. aipt_v2/intelligence/chaining.py +775 -0
  48. aipt_v2/intelligence/correlation.py +536 -0
  49. aipt_v2/intelligence/cve_aipt.py +334 -0
  50. aipt_v2/intelligence/cve_info.py +1111 -0
  51. aipt_v2/intelligence/knowledge_graph.py +590 -0
  52. aipt_v2/intelligence/learning.py +626 -0
  53. aipt_v2/intelligence/llm_analyzer.py +502 -0
  54. aipt_v2/intelligence/llm_tool_selector.py +518 -0
  55. aipt_v2/intelligence/payload_generator.py +562 -0
  56. aipt_v2/intelligence/rag.py +239 -0
  57. aipt_v2/intelligence/scope.py +442 -0
  58. aipt_v2/intelligence/searchers/__init__.py +5 -0
  59. aipt_v2/intelligence/searchers/exploitdb_searcher.py +523 -0
  60. aipt_v2/intelligence/searchers/github_searcher.py +467 -0
  61. aipt_v2/intelligence/searchers/google_searcher.py +281 -0
  62. aipt_v2/intelligence/tools.json +443 -0
  63. aipt_v2/intelligence/triage.py +670 -0
  64. aipt_v2/interactive_shell.py +559 -0
  65. aipt_v2/interface/__init__.py +5 -0
  66. aipt_v2/interface/cli.py +230 -0
  67. aipt_v2/interface/main.py +501 -0
  68. aipt_v2/interface/tui.py +1276 -0
  69. aipt_v2/interface/utils.py +583 -0
  70. aipt_v2/llm/__init__.py +39 -0
  71. aipt_v2/llm/config.py +26 -0
  72. aipt_v2/llm/llm.py +514 -0
  73. aipt_v2/llm/memory.py +214 -0
  74. aipt_v2/llm/request_queue.py +89 -0
  75. aipt_v2/llm/utils.py +89 -0
  76. aipt_v2/local_tool_installer.py +1467 -0
  77. aipt_v2/models/__init__.py +15 -0
  78. aipt_v2/models/findings.py +295 -0
  79. aipt_v2/models/phase_result.py +224 -0
  80. aipt_v2/models/scan_config.py +207 -0
  81. aipt_v2/monitoring/grafana/dashboards/aipt-dashboard.json +355 -0
  82. aipt_v2/monitoring/grafana/dashboards/default.yml +17 -0
  83. aipt_v2/monitoring/grafana/datasources/prometheus.yml +17 -0
  84. aipt_v2/monitoring/prometheus.yml +60 -0
  85. aipt_v2/orchestration/__init__.py +52 -0
  86. aipt_v2/orchestration/pipeline.py +398 -0
  87. aipt_v2/orchestration/progress.py +300 -0
  88. aipt_v2/orchestration/scheduler.py +296 -0
  89. aipt_v2/orchestrator.py +2427 -0
  90. aipt_v2/payloads/__init__.py +27 -0
  91. aipt_v2/payloads/cmdi.py +150 -0
  92. aipt_v2/payloads/sqli.py +263 -0
  93. aipt_v2/payloads/ssrf.py +204 -0
  94. aipt_v2/payloads/templates.py +222 -0
  95. aipt_v2/payloads/traversal.py +166 -0
  96. aipt_v2/payloads/xss.py +204 -0
  97. aipt_v2/prompts/__init__.py +60 -0
  98. aipt_v2/proxy/__init__.py +29 -0
  99. aipt_v2/proxy/history.py +352 -0
  100. aipt_v2/proxy/interceptor.py +452 -0
  101. aipt_v2/recon/__init__.py +44 -0
  102. aipt_v2/recon/dns.py +241 -0
  103. aipt_v2/recon/osint.py +367 -0
  104. aipt_v2/recon/subdomain.py +372 -0
  105. aipt_v2/recon/tech_detect.py +311 -0
  106. aipt_v2/reports/__init__.py +17 -0
  107. aipt_v2/reports/generator.py +313 -0
  108. aipt_v2/reports/html_report.py +378 -0
  109. aipt_v2/runtime/__init__.py +53 -0
  110. aipt_v2/runtime/base.py +30 -0
  111. aipt_v2/runtime/docker.py +401 -0
  112. aipt_v2/runtime/local.py +346 -0
  113. aipt_v2/runtime/tool_server.py +205 -0
  114. aipt_v2/runtime/vps.py +830 -0
  115. aipt_v2/scanners/__init__.py +28 -0
  116. aipt_v2/scanners/base.py +273 -0
  117. aipt_v2/scanners/nikto.py +244 -0
  118. aipt_v2/scanners/nmap.py +402 -0
  119. aipt_v2/scanners/nuclei.py +273 -0
  120. aipt_v2/scanners/web.py +454 -0
  121. aipt_v2/scripts/security_audit.py +366 -0
  122. aipt_v2/setup_wizard.py +941 -0
  123. aipt_v2/skills/__init__.py +80 -0
  124. aipt_v2/skills/agents/__init__.py +14 -0
  125. aipt_v2/skills/agents/api_tester.py +706 -0
  126. aipt_v2/skills/agents/base.py +477 -0
  127. aipt_v2/skills/agents/code_review.py +459 -0
  128. aipt_v2/skills/agents/security_agent.py +336 -0
  129. aipt_v2/skills/agents/web_pentest.py +818 -0
  130. aipt_v2/skills/prompts/__init__.py +647 -0
  131. aipt_v2/system_detector.py +539 -0
  132. aipt_v2/telemetry/__init__.py +7 -0
  133. aipt_v2/telemetry/tracer.py +347 -0
  134. aipt_v2/terminal/__init__.py +28 -0
  135. aipt_v2/terminal/executor.py +400 -0
  136. aipt_v2/terminal/sandbox.py +350 -0
  137. aipt_v2/tools/__init__.py +44 -0
  138. aipt_v2/tools/active_directory/__init__.py +78 -0
  139. aipt_v2/tools/active_directory/ad_config.py +238 -0
  140. aipt_v2/tools/active_directory/bloodhound_wrapper.py +447 -0
  141. aipt_v2/tools/active_directory/kerberos_attacks.py +430 -0
  142. aipt_v2/tools/active_directory/ldap_enum.py +533 -0
  143. aipt_v2/tools/active_directory/smb_attacks.py +505 -0
  144. aipt_v2/tools/agents_graph/__init__.py +19 -0
  145. aipt_v2/tools/agents_graph/agents_graph_actions.py +69 -0
  146. aipt_v2/tools/api_security/__init__.py +76 -0
  147. aipt_v2/tools/api_security/api_discovery.py +608 -0
  148. aipt_v2/tools/api_security/graphql_scanner.py +622 -0
  149. aipt_v2/tools/api_security/jwt_analyzer.py +577 -0
  150. aipt_v2/tools/api_security/openapi_fuzzer.py +761 -0
  151. aipt_v2/tools/browser/__init__.py +5 -0
  152. aipt_v2/tools/browser/browser_actions.py +238 -0
  153. aipt_v2/tools/browser/browser_instance.py +535 -0
  154. aipt_v2/tools/browser/tab_manager.py +344 -0
  155. aipt_v2/tools/cloud/__init__.py +70 -0
  156. aipt_v2/tools/cloud/cloud_config.py +273 -0
  157. aipt_v2/tools/cloud/cloud_scanner.py +639 -0
  158. aipt_v2/tools/cloud/prowler_tool.py +571 -0
  159. aipt_v2/tools/cloud/scoutsuite_tool.py +359 -0
  160. aipt_v2/tools/executor.py +307 -0
  161. aipt_v2/tools/parser.py +408 -0
  162. aipt_v2/tools/proxy/__init__.py +5 -0
  163. aipt_v2/tools/proxy/proxy_actions.py +103 -0
  164. aipt_v2/tools/proxy/proxy_manager.py +789 -0
  165. aipt_v2/tools/registry.py +196 -0
  166. aipt_v2/tools/scanners/__init__.py +343 -0
  167. aipt_v2/tools/scanners/acunetix_tool.py +712 -0
  168. aipt_v2/tools/scanners/burp_tool.py +631 -0
  169. aipt_v2/tools/scanners/config.py +156 -0
  170. aipt_v2/tools/scanners/nessus_tool.py +588 -0
  171. aipt_v2/tools/scanners/zap_tool.py +612 -0
  172. aipt_v2/tools/terminal/__init__.py +5 -0
  173. aipt_v2/tools/terminal/terminal_actions.py +37 -0
  174. aipt_v2/tools/terminal/terminal_manager.py +153 -0
  175. aipt_v2/tools/terminal/terminal_session.py +449 -0
  176. aipt_v2/tools/tool_processing.py +108 -0
  177. aipt_v2/utils/__init__.py +17 -0
  178. aipt_v2/utils/logging.py +202 -0
  179. aipt_v2/utils/model_manager.py +187 -0
  180. aipt_v2/utils/searchers/__init__.py +269 -0
  181. aipt_v2/verify_install.py +793 -0
  182. aiptx-2.0.7.dist-info/METADATA +345 -0
  183. aiptx-2.0.7.dist-info/RECORD +187 -0
  184. aiptx-2.0.7.dist-info/WHEEL +5 -0
  185. aiptx-2.0.7.dist-info/entry_points.txt +7 -0
  186. aiptx-2.0.7.dist-info/licenses/LICENSE +21 -0
  187. aiptx-2.0.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import contextlib
5
+ import signal
6
+ import sys
7
+ import threading
8
+ from typing import Any
9
+
10
+ from .terminal_session import TerminalSession
11
+
12
+
13
+ class TerminalManager:
14
+ def __init__(self) -> None:
15
+ self.sessions: dict[str, TerminalSession] = {}
16
+ self._lock = threading.Lock()
17
+ self.default_terminal_id = "default"
18
+ self.default_timeout = 30.0
19
+
20
+ self._register_cleanup_handlers()
21
+
22
+ def execute_command(
23
+ self,
24
+ command: str,
25
+ is_input: bool = False,
26
+ timeout: float | None = None,
27
+ terminal_id: str | None = None,
28
+ no_enter: bool = False,
29
+ ) -> dict[str, Any]:
30
+ if terminal_id is None:
31
+ terminal_id = self.default_terminal_id
32
+
33
+ session = self._get_or_create_session(terminal_id)
34
+
35
+ try:
36
+ result = session.execute(command, is_input, timeout or self.default_timeout, no_enter)
37
+
38
+ return {
39
+ "content": result["content"],
40
+ "command": command,
41
+ "terminal_id": terminal_id,
42
+ "status": result["status"],
43
+ "exit_code": result.get("exit_code"),
44
+ "working_dir": result.get("working_dir"),
45
+ }
46
+
47
+ except RuntimeError as e:
48
+ return {
49
+ "error": str(e),
50
+ "command": command,
51
+ "terminal_id": terminal_id,
52
+ "content": "",
53
+ "status": "error",
54
+ "exit_code": None,
55
+ "working_dir": None,
56
+ }
57
+ except OSError as e:
58
+ return {
59
+ "error": f"System error: {e}",
60
+ "command": command,
61
+ "terminal_id": terminal_id,
62
+ "content": "",
63
+ "status": "error",
64
+ "exit_code": None,
65
+ "working_dir": None,
66
+ }
67
+
68
+ def _get_or_create_session(self, terminal_id: str) -> TerminalSession:
69
+ with self._lock:
70
+ if terminal_id not in self.sessions:
71
+ self.sessions[terminal_id] = TerminalSession(terminal_id)
72
+ return self.sessions[terminal_id]
73
+
74
+ def close_session(self, terminal_id: str | None = None) -> dict[str, Any]:
75
+ if terminal_id is None:
76
+ terminal_id = self.default_terminal_id
77
+
78
+ with self._lock:
79
+ if terminal_id not in self.sessions:
80
+ return {
81
+ "terminal_id": terminal_id,
82
+ "message": f"Terminal '{terminal_id}' not found",
83
+ "status": "not_found",
84
+ }
85
+
86
+ session = self.sessions.pop(terminal_id)
87
+
88
+ try:
89
+ session.close()
90
+ except (RuntimeError, OSError) as e:
91
+ return {
92
+ "terminal_id": terminal_id,
93
+ "error": f"Failed to close terminal '{terminal_id}': {e}",
94
+ "status": "error",
95
+ }
96
+ else:
97
+ return {
98
+ "terminal_id": terminal_id,
99
+ "message": f"Terminal '{terminal_id}' closed successfully",
100
+ "status": "closed",
101
+ }
102
+
103
+ def list_sessions(self) -> dict[str, Any]:
104
+ with self._lock:
105
+ session_info: dict[str, dict[str, Any]] = {}
106
+ for tid, session in self.sessions.items():
107
+ session_info[tid] = {
108
+ "is_running": session.is_running(),
109
+ "working_dir": session.get_working_dir(),
110
+ }
111
+
112
+ return {"sessions": session_info, "total_count": len(session_info)}
113
+
114
+ def cleanup_dead_sessions(self) -> None:
115
+ with self._lock:
116
+ dead_sessions: list[str] = []
117
+ for tid, session in self.sessions.items():
118
+ if not session.is_running():
119
+ dead_sessions.append(tid)
120
+
121
+ for tid in dead_sessions:
122
+ session = self.sessions.pop(tid)
123
+ with contextlib.suppress(Exception):
124
+ session.close()
125
+
126
+ def close_all_sessions(self) -> None:
127
+ with self._lock:
128
+ sessions_to_close = list(self.sessions.values())
129
+ self.sessions.clear()
130
+
131
+ for session in sessions_to_close:
132
+ with contextlib.suppress(Exception):
133
+ session.close()
134
+
135
+ def _register_cleanup_handlers(self) -> None:
136
+ atexit.register(self.close_all_sessions)
137
+
138
+ signal.signal(signal.SIGTERM, self._signal_handler)
139
+ signal.signal(signal.SIGINT, self._signal_handler)
140
+
141
+ if hasattr(signal, "SIGHUP"):
142
+ signal.signal(signal.SIGHUP, self._signal_handler)
143
+
144
+ def _signal_handler(self, _signum: int, _frame: Any) -> None:
145
+ self.close_all_sessions()
146
+ sys.exit(0)
147
+
148
+
149
+ _terminal_manager = TerminalManager()
150
+
151
+
152
+ def get_terminal_manager() -> TerminalManager:
153
+ return _terminal_manager
@@ -0,0 +1,449 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import time
6
+ import uuid
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import libtmux
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class BashCommandStatus(Enum):
18
+ CONTINUE = "continue"
19
+ COMPLETED = "completed"
20
+ NO_CHANGE_TIMEOUT = "no_change_timeout"
21
+ HARD_TIMEOUT = "hard_timeout"
22
+
23
+
24
+ def _remove_command_prefix(command_output: str, command: str) -> str:
25
+ return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
26
+
27
+
28
+ class TerminalSession:
29
+ POLL_INTERVAL = 0.5
30
+ HISTORY_LIMIT = 10_000
31
+ PS1_END = "]$ "
32
+
33
+ def __init__(self, session_id: str, work_dir: str = "/workspace") -> None:
34
+ self.session_id = session_id
35
+ self.work_dir = str(Path(work_dir).resolve())
36
+ self._closed = False
37
+ self._cwd = self.work_dir
38
+
39
+ self.server: libtmux.Server | None = None
40
+ self.session: libtmux.Session | None = None
41
+ self.window: libtmux.Window | None = None
42
+ self.pane: libtmux.Pane | None = None
43
+
44
+ self.prev_status: BashCommandStatus | None = None
45
+ self.prev_output: str = ""
46
+ self._initialized = False
47
+
48
+ self.initialize()
49
+
50
+ @property
51
+ def PS1(self) -> str: # noqa: N802
52
+ return r"[AIPTX_$?]$ "
53
+
54
+ @property
55
+ def PS1_PATTERN(self) -> str: # noqa: N802
56
+ return r"\[AIPTX_(\d+)\]"
57
+
58
+ def initialize(self) -> None:
59
+ self.server = libtmux.Server()
60
+
61
+ session_name = f"aiptx-{self.session_id}-{uuid.uuid4()}"
62
+ self.session = self.server.new_session(
63
+ session_name=session_name,
64
+ start_directory=self.work_dir,
65
+ kill_session=True,
66
+ x=120,
67
+ y=30,
68
+ )
69
+
70
+ self.session.set_option("history-limit", str(self.HISTORY_LIMIT))
71
+ self.session.history_limit = self.HISTORY_LIMIT
72
+
73
+ _initial_window = self.session.active_window
74
+ self.window = self.session.new_window(
75
+ window_name="bash",
76
+ window_shell="/bin/bash",
77
+ start_directory=self.work_dir,
78
+ )
79
+ self.pane = self.window.active_pane
80
+ _initial_window.kill()
81
+
82
+ self.pane.send_keys(f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""')
83
+ time.sleep(0.1)
84
+ self._clear_screen()
85
+
86
+ self.prev_status = None
87
+ self.prev_output = ""
88
+ self._closed = False
89
+
90
+ self._cwd = str(Path(self.work_dir).resolve())
91
+ self._initialized = True
92
+
93
+ assert self.server is not None
94
+ assert self.session is not None
95
+ assert self.window is not None
96
+ assert self.pane is not None
97
+
98
+ def _get_pane_content(self) -> str:
99
+ if not self.pane:
100
+ raise RuntimeError("Terminal session not properly initialized")
101
+ return "\n".join(
102
+ line.rstrip() for line in self.pane.cmd("capture-pane", "-J", "-pS", "-").stdout
103
+ )
104
+
105
+ def _clear_screen(self) -> None:
106
+ if not self.pane:
107
+ raise RuntimeError("Terminal session not properly initialized")
108
+ self.pane.send_keys("C-l", enter=False)
109
+ time.sleep(0.1)
110
+ self.pane.cmd("clear-history")
111
+
112
+ def _is_control_key(self, command: str) -> bool:
113
+ return (
114
+ (command.startswith("C-") and len(command) >= 3)
115
+ or (command.startswith("^") and len(command) >= 2)
116
+ or (command.startswith("S-") and len(command) >= 3)
117
+ or (command.startswith("M-") and len(command) >= 3)
118
+ )
119
+
120
+ def _is_function_key(self, command: str) -> bool:
121
+ if not command.startswith("F") or len(command) > 3:
122
+ return False
123
+ try:
124
+ num_part = command[1:]
125
+ return num_part.isdigit() and 1 <= int(num_part) <= 12
126
+ except (ValueError, IndexError):
127
+ return False
128
+
129
+ def _is_navigation_or_special_key(self, command: str) -> bool:
130
+ navigation_keys = {"Up", "Down", "Left", "Right", "Home", "End"}
131
+ special_keys = {"BSpace", "BTab", "DC", "Enter", "Escape", "IC", "Space", "Tab"}
132
+ page_keys = {"NPage", "PageDown", "PgDn", "PPage", "PageUp", "PgUp"}
133
+
134
+ return command in navigation_keys or command in special_keys or command in page_keys
135
+
136
+ def _is_complex_modifier_key(self, command: str) -> bool:
137
+ return "-" in command and any(
138
+ command.startswith(prefix)
139
+ for prefix in ["C-S-", "C-M-", "S-M-", "M-S-", "M-C-", "S-C-"]
140
+ )
141
+
142
+ def _is_special_key(self, command: str) -> bool:
143
+ _command = command.strip()
144
+
145
+ if not _command:
146
+ return False
147
+
148
+ return (
149
+ self._is_control_key(_command)
150
+ or self._is_function_key(_command)
151
+ or self._is_navigation_or_special_key(_command)
152
+ or self._is_complex_modifier_key(_command)
153
+ )
154
+
155
+ def _matches_ps1_metadata(self, content: str) -> list[re.Match[str]]:
156
+ return list(re.finditer(self.PS1_PATTERN + r"\]\$ ", content))
157
+
158
+ def _get_command_output(
159
+ self,
160
+ command: str,
161
+ raw_command_output: str,
162
+ continue_prefix: str = "",
163
+ ) -> str:
164
+ if self.prev_output:
165
+ command_output = raw_command_output.removeprefix(self.prev_output)
166
+ if continue_prefix:
167
+ command_output = continue_prefix + command_output
168
+ else:
169
+ command_output = raw_command_output
170
+ self.prev_output = raw_command_output
171
+ command_output = _remove_command_prefix(command_output, command)
172
+ return command_output.rstrip()
173
+
174
+ def _combine_outputs_between_matches(
175
+ self,
176
+ pane_content: str,
177
+ ps1_matches: list[re.Match[str]],
178
+ get_content_before_last_match: bool = False,
179
+ ) -> str:
180
+ if len(ps1_matches) == 1:
181
+ if get_content_before_last_match:
182
+ return pane_content[: ps1_matches[0].start()]
183
+ return pane_content[ps1_matches[0].end() + 1 :]
184
+ if len(ps1_matches) == 0:
185
+ return pane_content
186
+
187
+ combined_output = ""
188
+ for i in range(len(ps1_matches) - 1):
189
+ output_segment = pane_content[ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()]
190
+ combined_output += output_segment + "\n"
191
+ combined_output += pane_content[ps1_matches[-1].end() + 1 :]
192
+ return combined_output
193
+
194
+ def _extract_exit_code_from_matches(self, ps1_matches: list[re.Match[str]]) -> int | None:
195
+ if not ps1_matches:
196
+ return None
197
+
198
+ last_match = ps1_matches[-1]
199
+ try:
200
+ return int(last_match.group(1))
201
+ except (ValueError, IndexError):
202
+ return None
203
+
204
+ def _handle_empty_command(
205
+ self,
206
+ cur_pane_output: str,
207
+ ps1_matches: list[re.Match[str]],
208
+ is_command_running: bool,
209
+ timeout: float,
210
+ ) -> dict[str, Any]:
211
+ if not is_command_running:
212
+ raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
213
+ command_output = self._get_command_output("", raw_command_output)
214
+ return {
215
+ "content": command_output,
216
+ "status": "completed",
217
+ "exit_code": 0,
218
+ "working_dir": self._cwd,
219
+ }
220
+
221
+ start_time = time.time()
222
+ last_pane_output = cur_pane_output
223
+
224
+ while True:
225
+ cur_pane_output = self._get_pane_content()
226
+ ps1_matches = self._matches_ps1_metadata(cur_pane_output)
227
+
228
+ if cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0:
229
+ exit_code = self._extract_exit_code_from_matches(ps1_matches)
230
+ raw_command_output = self._combine_outputs_between_matches(
231
+ cur_pane_output, ps1_matches
232
+ )
233
+ command_output = self._get_command_output("", raw_command_output)
234
+ self.prev_status = BashCommandStatus.COMPLETED
235
+ self.prev_output = ""
236
+ self._ready_for_next_command()
237
+ return {
238
+ "content": command_output,
239
+ "status": "completed",
240
+ "exit_code": exit_code or 0,
241
+ "working_dir": self._cwd,
242
+ }
243
+
244
+ elapsed_time = time.time() - start_time
245
+ if elapsed_time >= timeout:
246
+ raw_command_output = self._combine_outputs_between_matches(
247
+ cur_pane_output, ps1_matches
248
+ )
249
+ command_output = self._get_command_output("", raw_command_output)
250
+ return {
251
+ "content": command_output
252
+ + f"\n[Command still running after {timeout}s - showing output so far]",
253
+ "status": "running",
254
+ "exit_code": None,
255
+ "working_dir": self._cwd,
256
+ }
257
+
258
+ if cur_pane_output != last_pane_output:
259
+ last_pane_output = cur_pane_output
260
+
261
+ time.sleep(self.POLL_INTERVAL)
262
+
263
+ def _handle_input_command(
264
+ self, command: str, no_enter: bool, is_command_running: bool
265
+ ) -> dict[str, Any]:
266
+ if not is_command_running:
267
+ return {
268
+ "content": "No command is currently running. Cannot send input.",
269
+ "status": "error",
270
+ "exit_code": None,
271
+ "working_dir": self._cwd,
272
+ }
273
+
274
+ if not self.pane:
275
+ raise RuntimeError("Terminal session not properly initialized")
276
+
277
+ is_special_key = self._is_special_key(command)
278
+ should_add_enter = not is_special_key and not no_enter
279
+ self.pane.send_keys(command, enter=should_add_enter)
280
+
281
+ time.sleep(2)
282
+ cur_pane_output = self._get_pane_content()
283
+ ps1_matches = self._matches_ps1_metadata(cur_pane_output)
284
+ raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
285
+ command_output = self._get_command_output(command, raw_command_output)
286
+
287
+ is_still_running = not (
288
+ cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
289
+ )
290
+
291
+ if is_still_running:
292
+ return {
293
+ "content": command_output,
294
+ "status": "running",
295
+ "exit_code": None,
296
+ "working_dir": self._cwd,
297
+ }
298
+
299
+ exit_code = self._extract_exit_code_from_matches(ps1_matches)
300
+ self.prev_status = BashCommandStatus.COMPLETED
301
+ self.prev_output = ""
302
+ self._ready_for_next_command()
303
+ return {
304
+ "content": command_output,
305
+ "status": "completed",
306
+ "exit_code": exit_code or 0,
307
+ "working_dir": self._cwd,
308
+ }
309
+
310
+ def _execute_new_command(self, command: str, no_enter: bool, timeout: float) -> dict[str, Any]:
311
+ if not self.pane:
312
+ raise RuntimeError("Terminal session not properly initialized")
313
+
314
+ initial_pane_output = self._get_pane_content()
315
+ initial_ps1_matches = self._matches_ps1_metadata(initial_pane_output)
316
+ initial_ps1_count = len(initial_ps1_matches)
317
+
318
+ start_time = time.time()
319
+ last_pane_output = initial_pane_output
320
+
321
+ is_special_key = self._is_special_key(command)
322
+ should_add_enter = not is_special_key and not no_enter
323
+ self.pane.send_keys(command, enter=should_add_enter)
324
+
325
+ while True:
326
+ cur_pane_output = self._get_pane_content()
327
+ ps1_matches = self._matches_ps1_metadata(cur_pane_output)
328
+ current_ps1_count = len(ps1_matches)
329
+
330
+ if cur_pane_output != last_pane_output:
331
+ last_pane_output = cur_pane_output
332
+
333
+ if current_ps1_count > initial_ps1_count or cur_pane_output.rstrip().endswith(
334
+ self.PS1_END.rstrip()
335
+ ):
336
+ exit_code = self._extract_exit_code_from_matches(ps1_matches)
337
+
338
+ get_content_before_last_match = bool(len(ps1_matches) == 1)
339
+ raw_command_output = self._combine_outputs_between_matches(
340
+ cur_pane_output,
341
+ ps1_matches,
342
+ get_content_before_last_match=get_content_before_last_match,
343
+ )
344
+
345
+ command_output = self._get_command_output(command, raw_command_output)
346
+ self.prev_status = BashCommandStatus.COMPLETED
347
+ self.prev_output = ""
348
+ self._ready_for_next_command()
349
+
350
+ return {
351
+ "content": command_output,
352
+ "status": "completed",
353
+ "exit_code": exit_code or 0,
354
+ "working_dir": self._cwd,
355
+ }
356
+
357
+ elapsed_time = time.time() - start_time
358
+ if elapsed_time >= timeout:
359
+ raw_command_output = self._combine_outputs_between_matches(
360
+ cur_pane_output, ps1_matches
361
+ )
362
+ command_output = self._get_command_output(
363
+ command,
364
+ raw_command_output,
365
+ continue_prefix="[Below is the output of the previous command.]\n",
366
+ )
367
+ self.prev_status = BashCommandStatus.CONTINUE
368
+
369
+ timeout_msg = (
370
+ f"\n[Command still running after {timeout}s - showing output so far. "
371
+ "Use C-c to interrupt if needed.]"
372
+ )
373
+ return {
374
+ "content": command_output + timeout_msg,
375
+ "status": "running",
376
+ "exit_code": None,
377
+ "working_dir": self._cwd,
378
+ }
379
+
380
+ time.sleep(self.POLL_INTERVAL)
381
+
382
+ def execute(
383
+ self, command: str, is_input: bool = False, timeout: float = 10.0, no_enter: bool = False
384
+ ) -> dict[str, Any]:
385
+ if not self._initialized:
386
+ raise RuntimeError("Bash session is not initialized")
387
+
388
+ cur_pane_output = self._get_pane_content()
389
+ ps1_matches = self._matches_ps1_metadata(cur_pane_output)
390
+ is_command_running = not (
391
+ cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
392
+ )
393
+
394
+ if command.strip() == "":
395
+ return self._handle_empty_command(
396
+ cur_pane_output, ps1_matches, is_command_running, timeout
397
+ )
398
+
399
+ is_special_key = self._is_special_key(command)
400
+
401
+ if is_input:
402
+ return self._handle_input_command(command, no_enter, is_command_running)
403
+
404
+ if is_special_key and is_command_running:
405
+ return self._handle_input_command(command, no_enter, is_command_running)
406
+
407
+ if is_command_running:
408
+ return {
409
+ "content": (
410
+ "A command is already running. Use is_input=true to send input to it, "
411
+ "or interrupt it first (e.g., with C-c)."
412
+ ),
413
+ "status": "error",
414
+ "exit_code": None,
415
+ "working_dir": self._cwd,
416
+ }
417
+
418
+ return self._execute_new_command(command, no_enter, timeout)
419
+
420
+ def _ready_for_next_command(self) -> None:
421
+ self._clear_screen()
422
+
423
+ def is_running(self) -> bool:
424
+ if self._closed or not self.session:
425
+ return False
426
+ try:
427
+ return self.session.id in [s.id for s in self.server.sessions] if self.server else False
428
+ except (AttributeError, OSError) as e:
429
+ logger.debug("Error checking if session is running: %s", e)
430
+ return False
431
+
432
+ def get_working_dir(self) -> str:
433
+ return self._cwd
434
+
435
+ def close(self) -> None:
436
+ if self._closed:
437
+ return
438
+
439
+ if self.session:
440
+ try:
441
+ self.session.kill()
442
+ except (AttributeError, OSError) as e:
443
+ logger.debug("Error closing terminal session: %s", e)
444
+
445
+ self._closed = True
446
+ self.server = None
447
+ self.session = None
448
+ self.window = None
449
+ self.pane = None