alita-sdk 0.3.449__py3-none-any.whl → 0.3.465__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.

Potentially problematic release.


This version of alita-sdk might be problematic. Click here for more details.

Files changed (74) hide show
  1. alita_sdk/cli/__init__.py +10 -0
  2. alita_sdk/cli/__main__.py +17 -0
  3. alita_sdk/cli/agent/__init__.py +0 -0
  4. alita_sdk/cli/agent/default.py +176 -0
  5. alita_sdk/cli/agent_executor.py +155 -0
  6. alita_sdk/cli/agent_loader.py +197 -0
  7. alita_sdk/cli/agent_ui.py +218 -0
  8. alita_sdk/cli/agents.py +1911 -0
  9. alita_sdk/cli/callbacks.py +576 -0
  10. alita_sdk/cli/cli.py +159 -0
  11. alita_sdk/cli/config.py +164 -0
  12. alita_sdk/cli/formatting.py +182 -0
  13. alita_sdk/cli/input_handler.py +256 -0
  14. alita_sdk/cli/mcp_loader.py +315 -0
  15. alita_sdk/cli/toolkit.py +330 -0
  16. alita_sdk/cli/toolkit_loader.py +55 -0
  17. alita_sdk/cli/tools/__init__.py +36 -0
  18. alita_sdk/cli/tools/approval.py +224 -0
  19. alita_sdk/cli/tools/filesystem.py +905 -0
  20. alita_sdk/cli/tools/planning.py +403 -0
  21. alita_sdk/cli/tools/terminal.py +280 -0
  22. alita_sdk/runtime/clients/client.py +16 -1
  23. alita_sdk/runtime/langchain/constants.py +2 -1
  24. alita_sdk/runtime/langchain/langraph_agent.py +74 -20
  25. alita_sdk/runtime/langchain/utils.py +20 -4
  26. alita_sdk/runtime/toolkits/artifact.py +5 -6
  27. alita_sdk/runtime/toolkits/mcp.py +5 -2
  28. alita_sdk/runtime/toolkits/tools.py +1 -0
  29. alita_sdk/runtime/tools/function.py +19 -6
  30. alita_sdk/runtime/tools/llm.py +65 -7
  31. alita_sdk/runtime/tools/vectorstore_base.py +17 -2
  32. alita_sdk/runtime/utils/mcp_sse_client.py +64 -6
  33. alita_sdk/tools/ado/repos/__init__.py +1 -0
  34. alita_sdk/tools/ado/test_plan/__init__.py +1 -1
  35. alita_sdk/tools/ado/wiki/__init__.py +1 -5
  36. alita_sdk/tools/ado/work_item/__init__.py +1 -5
  37. alita_sdk/tools/base_indexer_toolkit.py +64 -8
  38. alita_sdk/tools/bitbucket/__init__.py +1 -0
  39. alita_sdk/tools/code/sonar/__init__.py +1 -1
  40. alita_sdk/tools/confluence/__init__.py +2 -2
  41. alita_sdk/tools/github/__init__.py +2 -2
  42. alita_sdk/tools/gitlab/__init__.py +2 -1
  43. alita_sdk/tools/gitlab_org/__init__.py +1 -2
  44. alita_sdk/tools/google_places/__init__.py +2 -1
  45. alita_sdk/tools/jira/__init__.py +1 -0
  46. alita_sdk/tools/memory/__init__.py +1 -1
  47. alita_sdk/tools/pandas/__init__.py +1 -1
  48. alita_sdk/tools/postman/__init__.py +2 -1
  49. alita_sdk/tools/pptx/__init__.py +2 -2
  50. alita_sdk/tools/qtest/__init__.py +3 -3
  51. alita_sdk/tools/qtest/api_wrapper.py +1235 -51
  52. alita_sdk/tools/rally/__init__.py +1 -2
  53. alita_sdk/tools/report_portal/__init__.py +1 -0
  54. alita_sdk/tools/salesforce/__init__.py +1 -0
  55. alita_sdk/tools/servicenow/__init__.py +2 -3
  56. alita_sdk/tools/sharepoint/__init__.py +1 -0
  57. alita_sdk/tools/sharepoint/api_wrapper.py +22 -2
  58. alita_sdk/tools/sharepoint/authorization_helper.py +17 -1
  59. alita_sdk/tools/slack/__init__.py +1 -0
  60. alita_sdk/tools/sql/__init__.py +2 -1
  61. alita_sdk/tools/testio/__init__.py +1 -0
  62. alita_sdk/tools/testrail/__init__.py +1 -3
  63. alita_sdk/tools/xray/__init__.py +2 -1
  64. alita_sdk/tools/zephyr/__init__.py +2 -1
  65. alita_sdk/tools/zephyr_enterprise/__init__.py +1 -0
  66. alita_sdk/tools/zephyr_essential/__init__.py +1 -0
  67. alita_sdk/tools/zephyr_scale/__init__.py +1 -0
  68. alita_sdk/tools/zephyr_squad/__init__.py +1 -0
  69. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/METADATA +145 -2
  70. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/RECORD +74 -52
  71. alita_sdk-0.3.465.dist-info/entry_points.txt +2 -0
  72. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/WHEEL +0 -0
  73. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/licenses/LICENSE +0 -0
  74. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,403 @@
1
+ """
2
+ Planning tools for CLI agents.
3
+
4
+ Provides plan management for multi-step task execution with progress tracking.
5
+ Sessions are persisted to $ALITA_DIR/sessions/<session_id>/
6
+ - plan.json: Execution plan with steps
7
+ - memory.db: SQLite database for conversation memory
8
+ - session.json: Session metadata (agent, model, etc.)
9
+ """
10
+
11
+ import os
12
+ import json
13
+ import uuid
14
+ import sqlite3
15
+ from pathlib import Path
16
+ from typing import Optional, List, Dict, Any, Callable
17
+ from langchain_core.tools import BaseTool
18
+ from pydantic import BaseModel, Field
19
+ import logging
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def get_sessions_dir() -> Path:
25
+ """Get the sessions directory path."""
26
+ alita_dir = os.environ.get('ALITA_DIR', os.path.expanduser('~/.alita'))
27
+ return Path(alita_dir) / 'sessions'
28
+
29
+
30
+ def generate_session_id() -> str:
31
+ """Generate a new unique session ID."""
32
+ return uuid.uuid4().hex[:12]
33
+
34
+
35
+ def get_session_dir(session_id: str) -> Path:
36
+ """Get the directory for a specific session."""
37
+ return get_sessions_dir() / session_id
38
+
39
+
40
+ def get_session_memory_path(session_id: str) -> Path:
41
+ """Get the path to the memory database for a session."""
42
+ session_dir = get_session_dir(session_id)
43
+ session_dir.mkdir(parents=True, exist_ok=True)
44
+ return session_dir / "memory.db"
45
+
46
+
47
+ def get_session_metadata_path(session_id: str) -> Path:
48
+ """Get the path to the session metadata file."""
49
+ session_dir = get_session_dir(session_id)
50
+ session_dir.mkdir(parents=True, exist_ok=True)
51
+ return session_dir / "session.json"
52
+
53
+
54
+ def create_session_memory(session_id: str):
55
+ """
56
+ Create a SQLite-based memory saver for the session.
57
+
58
+ Args:
59
+ session_id: The session ID
60
+
61
+ Returns:
62
+ SqliteSaver instance connected to the session's memory.db
63
+ """
64
+ from langgraph.checkpoint.sqlite import SqliteSaver
65
+
66
+ memory_path = get_session_memory_path(session_id)
67
+ conn = sqlite3.connect(str(memory_path), check_same_thread=False)
68
+ logger.debug(f"Created session memory at {memory_path}")
69
+ return SqliteSaver(conn)
70
+
71
+
72
+ def save_session_metadata(session_id: str, metadata: Dict[str, Any]) -> None:
73
+ """
74
+ Save session metadata (agent name, model, etc.).
75
+
76
+ Args:
77
+ session_id: The session ID
78
+ metadata: Dictionary with session metadata
79
+ """
80
+ metadata_path = get_session_metadata_path(session_id)
81
+ metadata['session_id'] = session_id
82
+ metadata_path.write_text(json.dumps(metadata, indent=2))
83
+ logger.debug(f"Saved session metadata to {metadata_path}")
84
+
85
+
86
+ def load_session_metadata(session_id: str) -> Optional[Dict[str, Any]]:
87
+ """
88
+ Load session metadata.
89
+
90
+ Args:
91
+ session_id: The session ID
92
+
93
+ Returns:
94
+ Session metadata dict or None if not found
95
+ """
96
+ metadata_path = get_session_metadata_path(session_id)
97
+ if metadata_path.exists():
98
+ try:
99
+ return json.loads(metadata_path.read_text())
100
+ except Exception as e:
101
+ logger.warning(f"Failed to load session metadata: {e}")
102
+ return None
103
+
104
+
105
+ class PlanStep(BaseModel):
106
+ """A single step in a plan."""
107
+ description: str = Field(description="Step description")
108
+ completed: bool = Field(default=False, description="Whether step is completed")
109
+
110
+
111
+ class PlanState(BaseModel):
112
+ """Current plan state."""
113
+ title: str = Field(default="", description="Plan title")
114
+ steps: List[PlanStep] = Field(default_factory=list, description="List of steps")
115
+ session_id: str = Field(default="", description="Session ID for persistence")
116
+
117
+ def render(self) -> str:
118
+ """Render plan as formatted string with checkboxes."""
119
+ if not self.steps:
120
+ return ""
121
+
122
+ lines = []
123
+ if self.title:
124
+ lines.append(f"📋 {self.title}")
125
+
126
+ for i, step in enumerate(self.steps, 1):
127
+ checkbox = "☑" if step.completed else "☐"
128
+ status = " (completed)" if step.completed else ""
129
+ lines.append(f" {checkbox} {i}. {step.description}{status}")
130
+
131
+ return "\n".join(lines)
132
+
133
+ def to_dict(self) -> Dict[str, Any]:
134
+ """Convert to dictionary for serialization."""
135
+ return {
136
+ "title": self.title,
137
+ "steps": [{"description": s.description, "completed": s.completed} for s in self.steps],
138
+ "session_id": self.session_id
139
+ }
140
+
141
+ @classmethod
142
+ def from_dict(cls, data: Dict[str, Any]) -> "PlanState":
143
+ """Create from dictionary."""
144
+ steps = [PlanStep(**s) for s in data.get("steps", [])]
145
+ return cls(
146
+ title=data.get("title", ""),
147
+ steps=steps,
148
+ session_id=data.get("session_id", "")
149
+ )
150
+
151
+ def save(self) -> Optional[Path]:
152
+ """Save plan state to session file."""
153
+ if not self.session_id:
154
+ return None
155
+
156
+ try:
157
+ session_dir = get_sessions_dir() / self.session_id
158
+ session_dir.mkdir(parents=True, exist_ok=True)
159
+
160
+ plan_file = session_dir / "plan.json"
161
+ plan_file.write_text(json.dumps(self.to_dict(), indent=2))
162
+ logger.debug(f"Saved plan to {plan_file}")
163
+ return plan_file
164
+ except Exception as e:
165
+ logger.warning(f"Failed to save plan: {e}")
166
+ return None
167
+
168
+ @classmethod
169
+ def load(cls, session_id: str) -> Optional["PlanState"]:
170
+ """Load plan state from session file."""
171
+ try:
172
+ plan_file = get_sessions_dir() / session_id / "plan.json"
173
+ if plan_file.exists():
174
+ data = json.loads(plan_file.read_text())
175
+ state = cls.from_dict(data)
176
+ state.session_id = session_id
177
+ logger.debug(f"Loaded plan from {plan_file}")
178
+ return state
179
+ except Exception as e:
180
+ logger.warning(f"Failed to load plan: {e}")
181
+ return None
182
+
183
+
184
+ def list_sessions() -> List[Dict[str, Any]]:
185
+ """List all sessions with their metadata and plans."""
186
+ sessions = []
187
+ sessions_dir = get_sessions_dir()
188
+
189
+ if not sessions_dir.exists():
190
+ return sessions
191
+
192
+ for session_dir in sessions_dir.iterdir():
193
+ if session_dir.is_dir():
194
+ session_info = {
195
+ "session_id": session_dir.name,
196
+ "title": None,
197
+ "steps_total": 0,
198
+ "steps_completed": 0,
199
+ "agent_name": None,
200
+ "model": None,
201
+ "modified": 0,
202
+ "has_memory": False,
203
+ "has_plan": False,
204
+ }
205
+
206
+ # Load session metadata
207
+ metadata_file = session_dir / "session.json"
208
+ if metadata_file.exists():
209
+ try:
210
+ metadata = json.loads(metadata_file.read_text())
211
+ session_info["agent_name"] = metadata.get("agent_name")
212
+ session_info["model"] = metadata.get("model")
213
+ session_info["modified"] = metadata_file.stat().st_mtime
214
+ except Exception:
215
+ pass
216
+
217
+ # Check for memory database
218
+ memory_file = session_dir / "memory.db"
219
+ if memory_file.exists():
220
+ session_info["has_memory"] = True
221
+ # Use memory file mtime if newer
222
+ mem_mtime = memory_file.stat().st_mtime
223
+ if mem_mtime > session_info["modified"]:
224
+ session_info["modified"] = mem_mtime
225
+
226
+ # Load plan info
227
+ plan_file = session_dir / "plan.json"
228
+ if plan_file.exists():
229
+ try:
230
+ data = json.loads(plan_file.read_text())
231
+ session_info["has_plan"] = True
232
+ session_info["title"] = data.get("title", "(untitled)")
233
+ session_info["steps_total"] = len(data.get("steps", []))
234
+ session_info["steps_completed"] = sum(1 for s in data.get("steps", []) if s.get("completed"))
235
+ # Use plan file mtime if newer
236
+ plan_mtime = plan_file.stat().st_mtime
237
+ if plan_mtime > session_info["modified"]:
238
+ session_info["modified"] = plan_mtime
239
+ except Exception:
240
+ pass
241
+
242
+ # Only include sessions that have some content
243
+ if session_info["has_memory"] or session_info["has_plan"]:
244
+ sessions.append(session_info)
245
+
246
+ # Sort by modified time, newest first
247
+ sessions.sort(key=lambda x: x.get("modified", 0), reverse=True)
248
+ return sessions
249
+
250
+
251
+ class UpdatePlanInput(BaseModel):
252
+ """Input for updating the plan."""
253
+ title: str = Field(description="Title for the plan (e.g., 'Test Investigation Plan')")
254
+ steps: List[str] = Field(description="List of step descriptions in order")
255
+
256
+
257
+ class CompleteStepInput(BaseModel):
258
+ """Input for marking a step as complete."""
259
+ step_number: int = Field(description="Step number to mark as complete (1-indexed)")
260
+
261
+
262
+ class UpdatePlanTool(BaseTool):
263
+ """Create or update the execution plan."""
264
+
265
+ name: str = "update_plan"
266
+ description: str = """Create or replace the current execution plan.
267
+
268
+ Use this when:
269
+ - Starting a multi-step task that needs tracking
270
+ - The sequence of activities matters
271
+ - Breaking down a complex task into phases
272
+
273
+ The plan will be displayed to the user and you can mark steps complete as you progress.
274
+ Plans are automatically saved and can be resumed in future sessions.
275
+
276
+ Example:
277
+ update_plan(
278
+ title="API Test Investigation",
279
+ steps=[
280
+ "Reproduce the failing test locally",
281
+ "Capture error logs and stack trace",
282
+ "Identify root cause",
283
+ "Apply fix to test or code",
284
+ "Re-run test suite to verify"
285
+ ]
286
+ )"""
287
+ args_schema: type[BaseModel] = UpdatePlanInput
288
+
289
+ # Reference to shared plan state (set by executor)
290
+ plan_state: Optional[PlanState] = None
291
+ _plan_callback: Optional[Callable] = None
292
+
293
+ def __init__(self, plan_state: Optional[PlanState] = None, plan_callback: Optional[Callable] = None, **kwargs):
294
+ super().__init__(**kwargs)
295
+ self.plan_state = plan_state or PlanState()
296
+ self._plan_callback = plan_callback
297
+
298
+ def _run(self, title: str, steps: List[str]) -> str:
299
+ """Update the plan with new steps."""
300
+ self.plan_state.title = title
301
+ self.plan_state.steps = [PlanStep(description=s) for s in steps]
302
+
303
+ # Auto-save to session
304
+ saved_path = self.plan_state.save()
305
+
306
+ # Notify callback if set (for UI rendering)
307
+ if self._plan_callback:
308
+ self._plan_callback(self.plan_state)
309
+
310
+ result = f"Plan updated:\n\n{self.plan_state.render()}"
311
+ if saved_path:
312
+ result += f"\n\n[dim]Session: {self.plan_state.session_id}[/dim]"
313
+ return result
314
+
315
+
316
+ class CompleteStepTool(BaseTool):
317
+ """Mark a plan step as complete."""
318
+
319
+ name: str = "complete_step"
320
+ description: str = """Mark a step in the current plan as completed.
321
+
322
+ Use this after finishing a step to update the plan progress.
323
+ Step numbers are 1-indexed (first step is 1, not 0).
324
+ Progress is automatically saved.
325
+
326
+ Example:
327
+ complete_step(step_number=1) # Mark first step as done"""
328
+ args_schema: type[BaseModel] = CompleteStepInput
329
+
330
+ # Reference to shared plan state (set by executor)
331
+ plan_state: Optional[PlanState] = None
332
+ _plan_callback: Optional[Callable] = None
333
+
334
+ def __init__(self, plan_state: Optional[PlanState] = None, plan_callback: Optional[Callable] = None, **kwargs):
335
+ super().__init__(**kwargs)
336
+ self.plan_state = plan_state or PlanState()
337
+ self._plan_callback = plan_callback
338
+
339
+ def _run(self, step_number: int) -> str:
340
+ """Mark a step as complete."""
341
+ if not self.plan_state.steps:
342
+ return "No plan exists. Use update_plan first to create a plan."
343
+
344
+ if step_number < 1 or step_number > len(self.plan_state.steps):
345
+ return f"Invalid step number. Plan has {len(self.plan_state.steps)} steps (1-{len(self.plan_state.steps)})."
346
+
347
+ step = self.plan_state.steps[step_number - 1]
348
+ if step.completed:
349
+ return f"Step {step_number} was already completed."
350
+
351
+ step.completed = True
352
+
353
+ # Auto-save to session
354
+ self.plan_state.save()
355
+
356
+ # Notify callback if set (for UI rendering)
357
+ if self._plan_callback:
358
+ self._plan_callback(self.plan_state)
359
+
360
+ # Count progress
361
+ completed = sum(1 for s in self.plan_state.steps if s.completed)
362
+ total = len(self.plan_state.steps)
363
+
364
+ return f"✓ Step {step_number} completed ({completed}/{total} done)\n\n{self.plan_state.render()}"
365
+
366
+
367
+ def get_planning_tools(
368
+ plan_state: Optional[PlanState] = None,
369
+ plan_callback: Optional[Callable] = None,
370
+ session_id: Optional[str] = None
371
+ ) -> tuple[List[BaseTool], PlanState]:
372
+ """
373
+ Get planning tools with shared state.
374
+
375
+ Args:
376
+ plan_state: Optional existing plan state to use
377
+ plan_callback: Optional callback function called when plan changes
378
+ session_id: Optional session ID for persistence. If provided and plan exists,
379
+ will load from disk. If None, generates a new session ID.
380
+
381
+ Returns:
382
+ Tuple of (list of tools, plan state object)
383
+ """
384
+ # Try to load existing session or create new one
385
+ if session_id:
386
+ loaded = PlanState.load(session_id)
387
+ if loaded:
388
+ state = loaded
389
+ logger.info(f"Resumed session {session_id} with plan: {state.title}")
390
+ else:
391
+ state = plan_state or PlanState()
392
+ state.session_id = session_id
393
+ else:
394
+ state = plan_state or PlanState()
395
+ if not state.session_id:
396
+ state.session_id = generate_session_id()
397
+
398
+ tools = [
399
+ UpdatePlanTool(plan_state=state, plan_callback=plan_callback),
400
+ CompleteStepTool(plan_state=state, plan_callback=plan_callback),
401
+ ]
402
+
403
+ return tools, state
@@ -0,0 +1,280 @@
1
+ """
2
+ Terminal command execution tools for CLI agents.
3
+
4
+ Provides secure shell command execution restricted to mounted directories
5
+ with blocked command patterns and path traversal protection.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import shlex
12
+ from pathlib import Path
13
+ from typing import Optional, List, Dict, Any
14
+ from langchain_core.tools import BaseTool
15
+ from pydantic import BaseModel, Field
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # Default blocked command patterns (security)
22
+ DEFAULT_BLOCKED_PATTERNS = [
23
+ # Destructive commands
24
+ r"rm\s+-rf\s+/",
25
+ r"rm\s+-rf\s+~",
26
+ r"rm\s+-rf\s+\*",
27
+ r"rm\s+-rf\s+\.\.",
28
+ r"sudo\s+rm",
29
+ r"mkfs",
30
+ r"dd\s+if=",
31
+ r":\(\)\{\s*:\|:&\s*\};:", # Fork bomb
32
+
33
+ # Privilege escalation
34
+ r"sudo\s+su",
35
+ r"sudo\s+-i",
36
+ r"sudo\s+-s",
37
+ r"chmod\s+777",
38
+ r"chmod\s+-R\s+777",
39
+ r"chown\s+root",
40
+
41
+ # Data exfiltration
42
+ r"curl.*\|.*sh",
43
+ r"wget.*\|.*sh",
44
+ r"curl.*\|.*bash",
45
+ r"wget.*\|.*bash",
46
+ r"nc\s+-e",
47
+ r"/dev/tcp",
48
+
49
+ # System modification
50
+ r"shutdown",
51
+ r"reboot",
52
+ r"init\s+0",
53
+ r"init\s+6",
54
+ r"systemctl\s+stop",
55
+ r"systemctl\s+disable",
56
+ r"launchctl\s+unload",
57
+
58
+ # Path traversal attempts
59
+ r"\.\./\.\./\.\.",
60
+ r"/etc/passwd",
61
+ r"/etc/shadow",
62
+ ]
63
+
64
+
65
+ class TerminalRunCommandInput(BaseModel):
66
+ """Input for running a terminal command."""
67
+ command: str = Field(description="Shell command to execute")
68
+ timeout: int = Field(default=300, description="Timeout in seconds (default: 300)")
69
+
70
+
71
+ class TerminalRunCommandTool(BaseTool):
72
+ """Execute shell commands in the mounted working directory."""
73
+
74
+ name: str = "terminal_run_command"
75
+ description: str = """Execute a shell command in the workspace directory.
76
+
77
+ Use this to run tests, build commands, git operations, package managers, etc.
78
+ Commands are executed in the mounted workspace directory.
79
+
80
+ Examples:
81
+ - Run tests: `npm test`, `pytest`, `go test ./...`
82
+ - Build: `npm run build`, `cargo build`, `make`
83
+ - Git: `git status`, `git diff`, `git log --oneline -10`
84
+ - Package managers: `npm install`, `pip install -r requirements.txt`
85
+
86
+ The command runs with the workspace as the current working directory.
87
+ Returns stdout, stderr, and exit code."""
88
+ args_schema: type[BaseModel] = TerminalRunCommandInput
89
+
90
+ work_dir: str = ""
91
+ blocked_patterns: List[str] = []
92
+
93
+ def __init__(self, work_dir: str, blocked_patterns: Optional[List[str]] = None, **kwargs):
94
+ super().__init__(**kwargs)
95
+ self.work_dir = str(Path(work_dir).resolve())
96
+ self.blocked_patterns = blocked_patterns or DEFAULT_BLOCKED_PATTERNS
97
+
98
+ def _is_command_blocked(self, command: str) -> tuple[bool, str]:
99
+ """Check if command matches any blocked patterns."""
100
+ command_lower = command.lower()
101
+ for pattern in self.blocked_patterns:
102
+ if re.search(pattern, command_lower, re.IGNORECASE):
103
+ return True, pattern
104
+ return False, ""
105
+
106
+ def _validate_paths_in_command(self, command: str) -> tuple[bool, str]:
107
+ """
108
+ Validate that any paths referenced in the command don't escape work_dir.
109
+ This is a best-effort check for obvious path traversal.
110
+ """
111
+ # Check for obvious path traversal patterns
112
+ if "../../../" in command or "/.." in command:
113
+ return False, "Path traversal detected"
114
+
115
+ # Check for absolute paths outside work_dir
116
+ parts = shlex.split(command)
117
+ for part in parts:
118
+ if part.startswith("/") and not part.startswith(self.work_dir):
119
+ # Allow common system paths that are safe to reference
120
+ safe_prefixes = ["/dev/null", "/tmp", "/usr/bin", "/usr/local/bin", "/bin"]
121
+ if not any(part.startswith(p) for p in safe_prefixes):
122
+ return False, f"Absolute path outside workspace: {part}"
123
+
124
+ return True, ""
125
+
126
+ def _run(self, command: str, timeout: int = 300) -> str:
127
+ """Execute the command and return results."""
128
+ # Check if command is blocked
129
+ is_blocked, pattern = self._is_command_blocked(command)
130
+ if is_blocked:
131
+ return f"❌ Command blocked for security reasons.\nMatched pattern: {pattern}\n\nThis command pattern is not allowed. Please use a safer alternative."
132
+
133
+ # Validate paths in command
134
+ path_valid, path_error = self._validate_paths_in_command(command)
135
+ if not path_valid:
136
+ return f"❌ Command rejected: {path_error}\n\nCommands must operate within the workspace directory: {self.work_dir}"
137
+
138
+ try:
139
+ # Execute command in work_dir
140
+ result = subprocess.run(
141
+ command,
142
+ shell=True,
143
+ cwd=self.work_dir,
144
+ capture_output=True,
145
+ text=True,
146
+ timeout=timeout,
147
+ env={**os.environ, "PWD": self.work_dir}
148
+ )
149
+
150
+ output_parts = []
151
+
152
+ if result.stdout:
153
+ output_parts.append(f"stdout:\n{result.stdout}")
154
+
155
+ if result.stderr:
156
+ output_parts.append(f"stderr:\n{result.stderr}")
157
+
158
+ output_parts.append(f"exit_code: {result.returncode}")
159
+
160
+ return "\n\n".join(output_parts)
161
+
162
+ except subprocess.TimeoutExpired:
163
+ return f"❌ Command timed out after {timeout} seconds.\n\nConsider:\n- Breaking into smaller operations\n- Using --timeout flag for longer operations\n- Running in background if appropriate"
164
+ except Exception as e:
165
+ return f"❌ Error executing command: {str(e)}"
166
+
167
+
168
+ def load_blocked_patterns(config_path: Optional[str] = None) -> List[str]:
169
+ """
170
+ Load blocked command patterns from config file.
171
+ Falls back to defaults if file doesn't exist.
172
+
173
+ Args:
174
+ config_path: Path to blocked_commands.txt file
175
+
176
+ Returns:
177
+ List of regex patterns
178
+ """
179
+ patterns = list(DEFAULT_BLOCKED_PATTERNS)
180
+
181
+ if config_path and Path(config_path).exists():
182
+ try:
183
+ content = Path(config_path).read_text()
184
+ for line in content.splitlines():
185
+ line = line.strip()
186
+ # Skip empty lines and comments
187
+ if line and not line.startswith("#"):
188
+ patterns.append(line)
189
+ logger.debug(f"Loaded {len(patterns)} blocked patterns from {config_path}")
190
+ except Exception as e:
191
+ logger.warning(f"Failed to load blocked patterns from {config_path}: {e}")
192
+
193
+ return patterns
194
+
195
+
196
+ def get_terminal_tools(
197
+ work_dir: str,
198
+ blocked_patterns_path: Optional[str] = None
199
+ ) -> List[BaseTool]:
200
+ """
201
+ Get terminal execution tools for the given working directory.
202
+
203
+ Args:
204
+ work_dir: The workspace directory (must be absolute path)
205
+ blocked_patterns_path: Optional path to custom blocked_commands.txt
206
+
207
+ Returns:
208
+ List of terminal tools
209
+ """
210
+ work_dir = str(Path(work_dir).resolve())
211
+
212
+ if not Path(work_dir).is_dir():
213
+ raise ValueError(f"Work directory does not exist: {work_dir}")
214
+
215
+ blocked_patterns = load_blocked_patterns(blocked_patterns_path)
216
+
217
+ return [
218
+ TerminalRunCommandTool(
219
+ work_dir=work_dir,
220
+ blocked_patterns=blocked_patterns
221
+ )
222
+ ]
223
+
224
+
225
+ def create_default_blocked_patterns_file(config_dir: str) -> str:
226
+ """
227
+ Create default blocked_commands.txt file in config directory.
228
+
229
+ Args:
230
+ config_dir: Directory to create the file in (e.g., $ALITA_DIR/security)
231
+
232
+ Returns:
233
+ Path to created file
234
+ """
235
+ security_dir = Path(config_dir) / "security"
236
+ security_dir.mkdir(parents=True, exist_ok=True)
237
+
238
+ blocked_file = security_dir / "blocked_commands.txt"
239
+
240
+ if not blocked_file.exists():
241
+ content = """# Blocked Command Patterns for Alita CLI
242
+ # Each line is a regex pattern. Lines starting with # are comments.
243
+ # These patterns are checked against commands before execution.
244
+
245
+ # === Destructive Commands ===
246
+ rm\\s+-rf\\s+/
247
+ rm\\s+-rf\\s+~
248
+ rm\\s+-rf\\s+\\*
249
+ sudo\\s+rm
250
+ mkfs
251
+ dd\\s+if=
252
+
253
+ # === Privilege Escalation ===
254
+ sudo\\s+su
255
+ sudo\\s+-i
256
+ chmod\\s+777
257
+ chown\\s+root
258
+
259
+ # === Data Exfiltration ===
260
+ curl.*\\|.*sh
261
+ wget.*\\|.*sh
262
+ nc\\s+-e
263
+
264
+ # === System Modification ===
265
+ shutdown
266
+ reboot
267
+ init\\s+0
268
+ systemctl\\s+stop
269
+
270
+ # === Path Traversal ===
271
+ \\.\\./\\.\\./\\.\\.
272
+ /etc/passwd
273
+ /etc/shadow
274
+
275
+ # Add your custom patterns below:
276
+ """
277
+ blocked_file.write_text(content)
278
+ logger.info(f"Created default blocked patterns file: {blocked_file}")
279
+
280
+ return str(blocked_file)