shellmate-cli 3.0.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.
- shellmate/__init__.py +0 -0
- shellmate/adapters/__init__.py +67 -0
- shellmate/adapters/bash_adapter.sh +7 -0
- shellmate/adapters/ble_adapter.sh +350 -0
- shellmate/adapters/powershell_adapter.ps1 +417 -0
- shellmate/adapters/powershell_predictor.ps1 +97 -0
- shellmate/adapters/zsh_adapter.zsh +8 -0
- shellmate/agents/__init__.py +23 -0
- shellmate/agents/base.py +248 -0
- shellmate/agents/chain_patterns.yaml +98 -0
- shellmate/agents/command_chain.py +322 -0
- shellmate/agents/context_gatherer.py +175 -0
- shellmate/agents/environment.py +363 -0
- shellmate/agents/librarian.py +502 -0
- shellmate/agents/query.py +415 -0
- shellmate/agents/repomix_backend.py +171 -0
- shellmate/agents/watcher.py +266 -0
- shellmate/cache/__init__.py +0 -0
- shellmate/cache/semantic_cache.py +41 -0
- shellmate/cli/__init__.py +0 -0
- shellmate/cli/commands/__init__.py +27 -0
- shellmate/cli/commands/analyze.py +5 -0
- shellmate/cli/commands/config.py +319 -0
- shellmate/cli/commands/configure.py +118 -0
- shellmate/cli/commands/disable.py +11 -0
- shellmate/cli/commands/enable.py +49 -0
- shellmate/cli/commands/init.py +412 -0
- shellmate/cli/commands/install.py +242 -0
- shellmate/cli/commands/migrate.py +147 -0
- shellmate/cli/commands/status.py +31 -0
- shellmate/cli/commands/uninstall.py +78 -0
- shellmate/cli/main.py +114 -0
- shellmate/cli/parser.py +184 -0
- shellmate/cli/version.py +1 -0
- shellmate/completion/__init__.py +4 -0
- shellmate/completion/bash_adapter.py +145 -0
- shellmate/config/__init__.py +33 -0
- shellmate/config/credentials.py +120 -0
- shellmate/config/defaults.py +88 -0
- shellmate/config/manager.py +94 -0
- shellmate/config/paths.py +77 -0
- shellmate/config/secrets.py +66 -0
- shellmate/daemon/__init__.py +0 -0
- shellmate/daemon/lifecycle.py +308 -0
- shellmate/daemon/observability.py +82 -0
- shellmate/daemon/pipeline.py +119 -0
- shellmate/daemon/server.py +605 -0
- shellmate/daemon/session.py +170 -0
- shellmate/daemon/startup_ui.py +43 -0
- shellmate/environment/__init__.py +0 -0
- shellmate/environment/detector.py +130 -0
- shellmate/ipc/__init__.py +0 -0
- shellmate/ipc/client.py +210 -0
- shellmate/ipc/ps_client.py +190 -0
- shellmate/orchestration/__init__.py +3 -0
- shellmate/orchestration/analysis_graph.py +1274 -0
- shellmate/orchestration/state.py +35 -0
- shellmate/parser/__init__.py +0 -0
- shellmate/parser/command_parser.py +111 -0
- shellmate/plugins/__init__.py +0 -0
- shellmate/plugins/base.py +80 -0
- shellmate/plugins/loader.py +41 -0
- shellmate/providers/__init__.py +0 -0
- shellmate/providers/base.py +97 -0
- shellmate/providers/groq_command_guide.py +108 -0
- shellmate/providers/groq_provider.py +127 -0
- shellmate/providers/groq_supervisor.py +78 -0
- shellmate/providers/openrouter_provider.py +149 -0
- shellmate/providers/router.py +272 -0
- shellmate/providers/validate_keys.py +98 -0
- shellmate/providers/xai_provider.py +142 -0
- shellmate/rules/__init__.py +0 -0
- shellmate/rules/catastrophic.yaml +96 -0
- shellmate/rules/catastrophic_matcher.py +167 -0
- shellmate/rules/definitions/__init__.py +0 -0
- shellmate/rules/definitions/file_deletion.yaml +54 -0
- shellmate/rules/definitions/filesystem.yaml +43 -0
- shellmate/rules/definitions/network_execution.yaml +46 -0
- shellmate/rules/definitions/permissions.yaml +54 -0
- shellmate/rules/definitions/process_control.yaml +37 -0
- shellmate/rules/definitions/system_destruction.yaml +67 -0
- shellmate/rules/engine.py +200 -0
- shellmate/sanitizer/__init__.py +0 -0
- shellmate/sanitizer/typo_detector.py +114 -0
- shellmate/ui/__init__.py +10 -0
- shellmate/ui/analysis.py +164 -0
- shellmate/ui/annotations.py +232 -0
- shellmate/ui/banner.py +49 -0
- shellmate/ui/progress.py +126 -0
- shellmate/ui/runtime/__init__.py +15 -0
- shellmate/ui/runtime/console_factory.py +65 -0
- shellmate/ui/runtime/live_scene.py +116 -0
- shellmate/ui/runtime/motion_policy.py +69 -0
- shellmate/ui/runtime/tokens.py +47 -0
- shellmate/ui/scenes/__init__.py +24 -0
- shellmate/ui/scenes/enable.py +316 -0
- shellmate/ui/scenes/help.py +53 -0
- shellmate/ui/scenes/stages.py +128 -0
- shellmate/ui/scenes/status.py +181 -0
- shellmate/ui/status.py +3 -0
- shellmate_cli-3.0.0.dist-info/METADATA +352 -0
- shellmate_cli-3.0.0.dist-info/RECORD +106 -0
- shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
- shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
- shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
- shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
shellmate/agents/base.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("shellmate.agents")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentStatus(Enum):
|
|
13
|
+
STARTING = "starting"
|
|
14
|
+
READY = "ready"
|
|
15
|
+
ERROR = "error"
|
|
16
|
+
STOPPED = "stopped"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AgentRequest:
|
|
21
|
+
request_id: str
|
|
22
|
+
command: str
|
|
23
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
timeout: float = 5.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AgentResponse:
|
|
29
|
+
request_id: str
|
|
30
|
+
agent_name: str
|
|
31
|
+
status: str
|
|
32
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
error: Optional[str] = None
|
|
34
|
+
latency_ms: float = 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Agent(ABC):
|
|
38
|
+
def __init__(self, name: str):
|
|
39
|
+
self.name = name
|
|
40
|
+
self._status = AgentStatus.STARTING
|
|
41
|
+
self._request_queue: asyncio.Queue = asyncio.Queue()
|
|
42
|
+
self._response_futures: Dict[str, asyncio.Future] = {}
|
|
43
|
+
self._running = False
|
|
44
|
+
self._worker_task: Optional[asyncio.Task] = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def status(self) -> AgentStatus:
|
|
48
|
+
return self._status
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def initialize(self) -> None:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def process(self, request: AgentRequest) -> AgentResponse:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def shutdown(self) -> None:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
if self._running:
|
|
64
|
+
return
|
|
65
|
+
self._running = True
|
|
66
|
+
await self.initialize()
|
|
67
|
+
self._status = AgentStatus.READY
|
|
68
|
+
self._worker_task = asyncio.create_task(self._worker())
|
|
69
|
+
logger.info(f"Agent {self.name} started")
|
|
70
|
+
|
|
71
|
+
async def stop(self) -> None:
|
|
72
|
+
if not self._running:
|
|
73
|
+
return
|
|
74
|
+
self._running = False
|
|
75
|
+
if self._worker_task:
|
|
76
|
+
self._worker_task.cancel()
|
|
77
|
+
try:
|
|
78
|
+
await self._worker_task
|
|
79
|
+
except asyncio.CancelledError:
|
|
80
|
+
pass
|
|
81
|
+
await self.shutdown()
|
|
82
|
+
self._status = AgentStatus.STOPPED
|
|
83
|
+
logger.info(f"Agent {self.name} stopped")
|
|
84
|
+
|
|
85
|
+
async def _worker(self) -> None:
|
|
86
|
+
while self._running:
|
|
87
|
+
try:
|
|
88
|
+
request = await asyncio.wait_for(
|
|
89
|
+
self._request_queue.get(), timeout=1.0
|
|
90
|
+
)
|
|
91
|
+
await self._handle_request(request)
|
|
92
|
+
except asyncio.TimeoutError:
|
|
93
|
+
continue
|
|
94
|
+
except asyncio.CancelledError:
|
|
95
|
+
break
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Agent {self.name} worker error: {e}", exc_info=True)
|
|
98
|
+
|
|
99
|
+
async def _handle_request(self, request: AgentRequest) -> None:
|
|
100
|
+
response_future = self._response_futures.get(request.request_id)
|
|
101
|
+
if not response_future:
|
|
102
|
+
logger.warning(f"Agent {self.name}: No response future for request {request.request_id}")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
start_time = asyncio.get_event_loop().time()
|
|
106
|
+
try:
|
|
107
|
+
response = await asyncio.wait_for(
|
|
108
|
+
self.process(request), timeout=request.timeout
|
|
109
|
+
)
|
|
110
|
+
except asyncio.TimeoutError:
|
|
111
|
+
response = AgentResponse(
|
|
112
|
+
request_id=request.request_id,
|
|
113
|
+
agent_name=self.name,
|
|
114
|
+
status="timeout",
|
|
115
|
+
error=f"Agent {self.name} timed out after {request.timeout}s",
|
|
116
|
+
latency_ms=(asyncio.get_event_loop().time() - start_time) * 1000,
|
|
117
|
+
)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Agent {self.name} process error: {e}", exc_info=True)
|
|
120
|
+
response = AgentResponse(
|
|
121
|
+
request_id=request.request_id,
|
|
122
|
+
agent_name=self.name,
|
|
123
|
+
status="error",
|
|
124
|
+
error=str(e),
|
|
125
|
+
latency_ms=(asyncio.get_event_loop().time() - start_time) * 1000,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
response.latency_ms = (asyncio.get_event_loop().time() - start_time) * 1000
|
|
129
|
+
|
|
130
|
+
if not response_future.done():
|
|
131
|
+
response_future.set_result(response)
|
|
132
|
+
|
|
133
|
+
async def send_request(self, command: str, data: Dict[str, Any] = None, timeout: float = 5.0) -> AgentResponse:
|
|
134
|
+
if not self._running or self._status != AgentStatus.READY:
|
|
135
|
+
return AgentResponse(
|
|
136
|
+
request_id=str(uuid.uuid4()),
|
|
137
|
+
agent_name=self.name,
|
|
138
|
+
status="error",
|
|
139
|
+
error=f"Agent {self.name} not ready",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
request_id = str(uuid.uuid4())
|
|
143
|
+
request = AgentRequest(
|
|
144
|
+
request_id=request_id,
|
|
145
|
+
command=command,
|
|
146
|
+
data=data or {},
|
|
147
|
+
timeout=timeout,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
response_future = asyncio.get_running_loop().create_future()
|
|
151
|
+
self._response_futures[request_id] = response_future
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
await self._request_queue.put(request)
|
|
155
|
+
response = await asyncio.wait_for(response_future, timeout=timeout + 1.0)
|
|
156
|
+
return response
|
|
157
|
+
except asyncio.TimeoutError:
|
|
158
|
+
if not response_future.done():
|
|
159
|
+
response_future.cancel()
|
|
160
|
+
return AgentResponse(
|
|
161
|
+
request_id=request_id,
|
|
162
|
+
agent_name=self.name,
|
|
163
|
+
status="timeout",
|
|
164
|
+
error=f"Request timed out after {timeout}s",
|
|
165
|
+
)
|
|
166
|
+
finally:
|
|
167
|
+
self._response_futures.pop(request_id, None)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class AgentManager:
|
|
171
|
+
def __init__(self):
|
|
172
|
+
self._agents: Dict[str, Agent] = {}
|
|
173
|
+
self._running = False
|
|
174
|
+
|
|
175
|
+
def register(self, agent: Agent) -> None:
|
|
176
|
+
self._agents[agent.name] = agent
|
|
177
|
+
|
|
178
|
+
def get(self, name: str) -> Optional[Agent]:
|
|
179
|
+
return self._agents.get(name)
|
|
180
|
+
|
|
181
|
+
async def start_all(self) -> None:
|
|
182
|
+
if self._running:
|
|
183
|
+
return
|
|
184
|
+
self._running = True
|
|
185
|
+
for agent in self._agents.values():
|
|
186
|
+
await agent.start()
|
|
187
|
+
logger.info("All agents started")
|
|
188
|
+
|
|
189
|
+
async def stop_all(self) -> None:
|
|
190
|
+
if not self._running:
|
|
191
|
+
return
|
|
192
|
+
for agent in self._agents.values():
|
|
193
|
+
await agent.stop()
|
|
194
|
+
self._running = False
|
|
195
|
+
logger.info("All agents stopped")
|
|
196
|
+
|
|
197
|
+
async def send_request(self, agent_name: str, command: str, data: Dict[str, Any] = None, timeout: float = 5.0) -> AgentResponse:
|
|
198
|
+
agent = self._agents.get(agent_name)
|
|
199
|
+
if not agent:
|
|
200
|
+
return AgentResponse(
|
|
201
|
+
request_id=str(uuid.uuid4()),
|
|
202
|
+
agent_name=agent_name,
|
|
203
|
+
status="error",
|
|
204
|
+
error=f"Agent {agent_name} not found",
|
|
205
|
+
)
|
|
206
|
+
return await agent.send_request(command, data, timeout)
|
|
207
|
+
|
|
208
|
+
async def send_concurrent(self, requests: Dict[str, tuple], timeout: float = 5.0) -> Dict[str, AgentResponse]:
|
|
209
|
+
tasks = {}
|
|
210
|
+
for agent_name, (command, data) in requests.items():
|
|
211
|
+
agent = self._agents.get(agent_name)
|
|
212
|
+
if agent:
|
|
213
|
+
tasks[agent_name] = asyncio.create_task(
|
|
214
|
+
agent.send_request(command, data, timeout)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
results = {}
|
|
218
|
+
for agent_name, task in tasks.items():
|
|
219
|
+
try:
|
|
220
|
+
results[agent_name] = await asyncio.wait_for(task, timeout=timeout + 1.0)
|
|
221
|
+
except asyncio.TimeoutError:
|
|
222
|
+
results[agent_name] = AgentResponse(
|
|
223
|
+
request_id=str(uuid.uuid4()),
|
|
224
|
+
agent_name=agent_name,
|
|
225
|
+
status="timeout",
|
|
226
|
+
error=f"Request timed out after {timeout}s",
|
|
227
|
+
)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
results[agent_name] = AgentResponse(
|
|
230
|
+
request_id=str(uuid.uuid4()),
|
|
231
|
+
agent_name=agent_name,
|
|
232
|
+
status="error",
|
|
233
|
+
error=str(e),
|
|
234
|
+
)
|
|
235
|
+
return results
|
|
236
|
+
|
|
237
|
+
def get_status(self) -> Dict[str, str]:
|
|
238
|
+
return {name: agent.status.value for name, agent in self._agents.items()}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
_agent_manager: Optional[AgentManager] = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_agent_manager() -> AgentManager:
|
|
245
|
+
global _agent_manager
|
|
246
|
+
if _agent_manager is None:
|
|
247
|
+
_agent_manager = AgentManager()
|
|
248
|
+
return _agent_manager
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
patterns:
|
|
2
|
+
# Terraform workflows
|
|
3
|
+
- name: terraform_workflow
|
|
4
|
+
sequence:
|
|
5
|
+
- pattern: "terraform plan"
|
|
6
|
+
required: true
|
|
7
|
+
- pattern: "terraform apply"
|
|
8
|
+
expected: true
|
|
9
|
+
risk_if_broken: HIGH
|
|
10
|
+
description: "Terraform apply should follow plan"
|
|
11
|
+
|
|
12
|
+
# Git workflows
|
|
13
|
+
- name: git_force_push_workflow
|
|
14
|
+
sequence:
|
|
15
|
+
- pattern: "git (pull|fetch)"
|
|
16
|
+
required: true
|
|
17
|
+
- pattern: "git push.*--force"
|
|
18
|
+
expected: true
|
|
19
|
+
risk_if_broken: CRITICAL
|
|
20
|
+
description: "Force push should follow pull/fetch"
|
|
21
|
+
|
|
22
|
+
- name: git_commit_workflow
|
|
23
|
+
sequence:
|
|
24
|
+
- pattern: "git add"
|
|
25
|
+
required: true
|
|
26
|
+
- pattern: "git commit"
|
|
27
|
+
expected: true
|
|
28
|
+
- pattern: "git push"
|
|
29
|
+
expected: true
|
|
30
|
+
risk_if_broken: MEDIUM
|
|
31
|
+
description: "Standard git workflow: add → commit → push"
|
|
32
|
+
|
|
33
|
+
# Kubernetes workflows
|
|
34
|
+
- name: kubernetes_apply_workflow
|
|
35
|
+
sequence:
|
|
36
|
+
- pattern: "kubectl (create|apply) configmap"
|
|
37
|
+
required: false
|
|
38
|
+
- pattern: "kubectl apply.*deployment"
|
|
39
|
+
expected: true
|
|
40
|
+
risk_if_broken: HIGH
|
|
41
|
+
description: "Apply deployment after configmap"
|
|
42
|
+
|
|
43
|
+
# Docker workflows
|
|
44
|
+
- name: docker_build_push_workflow
|
|
45
|
+
sequence:
|
|
46
|
+
- pattern: "docker build"
|
|
47
|
+
required: true
|
|
48
|
+
- pattern: "docker tag"
|
|
49
|
+
required: true
|
|
50
|
+
- pattern: "docker push"
|
|
51
|
+
expected: true
|
|
52
|
+
risk_if_broken: MEDIUM
|
|
53
|
+
description: "Docker image should be built and tagged before push"
|
|
54
|
+
|
|
55
|
+
- name: docker_compose_workflow
|
|
56
|
+
sequence:
|
|
57
|
+
- pattern: "docker-compose (build|pull)"
|
|
58
|
+
required: true
|
|
59
|
+
- pattern: "docker-compose up"
|
|
60
|
+
expected: true
|
|
61
|
+
risk_if_broken: LOW
|
|
62
|
+
description: "Docker compose up should follow build/pull"
|
|
63
|
+
|
|
64
|
+
# Database workflows
|
|
65
|
+
- name: database_backup_workflow
|
|
66
|
+
sequence:
|
|
67
|
+
- pattern: "(pg_dump|mysqldump|mongodump)"
|
|
68
|
+
required: true
|
|
69
|
+
- pattern: "DROP DATABASE"
|
|
70
|
+
expected: true
|
|
71
|
+
risk_if_broken: CRITICAL
|
|
72
|
+
description: "Database should be backed up before dropping"
|
|
73
|
+
|
|
74
|
+
# Dangerous escalations
|
|
75
|
+
- name: dangerous_escalation_rm
|
|
76
|
+
sequence:
|
|
77
|
+
- pattern: "cd (/|/home|/var|/etc|/usr)"
|
|
78
|
+
required: true
|
|
79
|
+
- pattern: "rm.*-rf"
|
|
80
|
+
dangerous: true
|
|
81
|
+
risk_if_detected: CRITICAL
|
|
82
|
+
description: "rm -rf in sensitive directory"
|
|
83
|
+
|
|
84
|
+
- name: dangerous_escalation_chmod
|
|
85
|
+
sequence:
|
|
86
|
+
- pattern: "chmod (777|666)"
|
|
87
|
+
dangerous: true
|
|
88
|
+
risk_if_detected: CRITICAL
|
|
89
|
+
description: "Overly permissive chmod"
|
|
90
|
+
|
|
91
|
+
- name: dangerous_sudo_escalation
|
|
92
|
+
sequence:
|
|
93
|
+
- pattern: "sudo su"
|
|
94
|
+
required: true
|
|
95
|
+
- pattern: "(rm|del|DROP|truncate)"
|
|
96
|
+
dangerous: true
|
|
97
|
+
risk_if_detected: CRITICAL
|
|
98
|
+
description: "Destructive command after sudo su"
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CommandChainAgent: Tracks command history and detects workflow patterns.
|
|
3
|
+
|
|
4
|
+
Maintains a persistent JSONL history of commands and analyzes patterns
|
|
5
|
+
to detect expected workflows, broken workflows, and dangerous escalations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from shellmate.agents.base import Agent, AgentRequest, AgentResponse
|
|
19
|
+
from shellmate.config.defaults import get_default_config
|
|
20
|
+
from shellmate.daemon.session import get_command_history_file
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CommandChainAgent(Agent):
|
|
24
|
+
"""Tracks command history and detects workflow patterns."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
super().__init__(name="command_chain")
|
|
28
|
+
self.history_file = get_command_history_file()
|
|
29
|
+
self.patterns = []
|
|
30
|
+
self.patterns_file = Path(__file__).parent / "chain_patterns.yaml"
|
|
31
|
+
|
|
32
|
+
async def initialize(self) -> None:
|
|
33
|
+
"""Initialize the agent and load pattern definitions."""
|
|
34
|
+
self._load_patterns()
|
|
35
|
+
|
|
36
|
+
async def shutdown(self) -> None:
|
|
37
|
+
"""Shutdown the agent."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
async def process(self, request: AgentRequest) -> AgentResponse:
|
|
41
|
+
"""Process agent requests by dispatching to appropriate methods."""
|
|
42
|
+
start_time = time.time()
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
if request.command == "append_command":
|
|
46
|
+
data = request.data
|
|
47
|
+
await self.append_command(
|
|
48
|
+
data.get("command", ""),
|
|
49
|
+
data.get("risk", "MEDIUM"),
|
|
50
|
+
data.get("exit_code")
|
|
51
|
+
)
|
|
52
|
+
latency = (time.time() - start_time) * 1000
|
|
53
|
+
return AgentResponse(
|
|
54
|
+
request.request_id,
|
|
55
|
+
self.name,
|
|
56
|
+
status="ok",
|
|
57
|
+
data={"success": True},
|
|
58
|
+
latency_ms=latency,
|
|
59
|
+
)
|
|
60
|
+
elif request.command == "get_chain_patterns":
|
|
61
|
+
data = request.data
|
|
62
|
+
recent_commands = await self.get_recent_commands(
|
|
63
|
+
count=data.get("count", 10),
|
|
64
|
+
minutes=data.get("minutes", 30)
|
|
65
|
+
)
|
|
66
|
+
current_command = data.get("command", "")
|
|
67
|
+
patterns = await self.detect_patterns(recent_commands, current_command)
|
|
68
|
+
latency = (time.time() - start_time) * 1000
|
|
69
|
+
return AgentResponse(
|
|
70
|
+
request.request_id,
|
|
71
|
+
self.name,
|
|
72
|
+
status="ok",
|
|
73
|
+
data={
|
|
74
|
+
"command_chain": recent_commands,
|
|
75
|
+
"chain_patterns": patterns,
|
|
76
|
+
},
|
|
77
|
+
latency_ms=latency,
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
return AgentResponse(
|
|
81
|
+
request.request_id,
|
|
82
|
+
self.name,
|
|
83
|
+
status="error",
|
|
84
|
+
error=f"Unknown command: {request.command}",
|
|
85
|
+
)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
latency = (time.time() - start_time) * 1000
|
|
88
|
+
return AgentResponse(
|
|
89
|
+
request.request_id,
|
|
90
|
+
self.name,
|
|
91
|
+
status="error",
|
|
92
|
+
error=str(e),
|
|
93
|
+
latency_ms=latency,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _load_patterns(self) -> None:
|
|
97
|
+
"""Load workflow patterns from YAML file."""
|
|
98
|
+
if not self.patterns_file.exists():
|
|
99
|
+
self.patterns = []
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
with open(self.patterns_file, "r", encoding="utf-8") as f:
|
|
104
|
+
data = yaml.safe_load(f)
|
|
105
|
+
self.patterns = data.get("patterns", [])
|
|
106
|
+
except Exception:
|
|
107
|
+
self.patterns = []
|
|
108
|
+
|
|
109
|
+
def _redact_secrets(self, command: str) -> str:
|
|
110
|
+
"""Redact secrets from command before storage."""
|
|
111
|
+
config = get_default_config()
|
|
112
|
+
secret_patterns = (
|
|
113
|
+
config.get("v3", {})
|
|
114
|
+
.get("command_history", {})
|
|
115
|
+
.get("secret_patterns", [])
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
redacted = command
|
|
119
|
+
for pattern in secret_patterns:
|
|
120
|
+
try:
|
|
121
|
+
redacted = re.sub(pattern, r"\1***", redacted)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
return redacted
|
|
126
|
+
|
|
127
|
+
async def append_command(
|
|
128
|
+
self, command: str, risk: str, exit_code: Optional[int] = None
|
|
129
|
+
) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Append command to history file (JSONL format).
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
command: The command that was executed
|
|
135
|
+
risk: Risk level (SAFE, LOW, MEDIUM, HIGH, CRITICAL)
|
|
136
|
+
exit_code: Command exit code (optional)
|
|
137
|
+
"""
|
|
138
|
+
entry = {
|
|
139
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
140
|
+
"command": self._redact_secrets(command),
|
|
141
|
+
"risk": risk,
|
|
142
|
+
"exit_code": exit_code,
|
|
143
|
+
"directory": str(Path.cwd()),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Append to JSONL file
|
|
147
|
+
try:
|
|
148
|
+
with open(self.history_file, "a", encoding="utf-8") as f:
|
|
149
|
+
f.write(json.dumps(entry) + "\n")
|
|
150
|
+
except OSError:
|
|
151
|
+
# Fail silently if we can't write to history
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
async def get_recent_commands(
|
|
155
|
+
self, count: int = 10, minutes: Optional[int] = None
|
|
156
|
+
) -> List[Dict[str, Any]]:
|
|
157
|
+
"""
|
|
158
|
+
Query recent commands from history.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
count: Maximum number of commands to return
|
|
162
|
+
minutes: Optional time window in minutes
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
List of command entries
|
|
166
|
+
"""
|
|
167
|
+
if not self.history_file.exists():
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
commands = []
|
|
171
|
+
cutoff = None
|
|
172
|
+
if minutes:
|
|
173
|
+
cutoff = datetime.utcnow() - timedelta(minutes=minutes)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
with open(self.history_file, "r", encoding="utf-8") as f:
|
|
177
|
+
for line in f:
|
|
178
|
+
try:
|
|
179
|
+
entry = json.loads(line.strip())
|
|
180
|
+
timestamp = datetime.fromisoformat(
|
|
181
|
+
entry["timestamp"].replace("Z", "")
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if cutoff and timestamp < cutoff:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
commands.append(entry)
|
|
188
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
189
|
+
continue
|
|
190
|
+
except OSError:
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
# Return last N commands
|
|
194
|
+
return commands[-count:]
|
|
195
|
+
|
|
196
|
+
async def detect_patterns(
|
|
197
|
+
self, recent_commands: List[Dict[str, Any]], current_command: str
|
|
198
|
+
) -> Dict[str, Any]:
|
|
199
|
+
"""
|
|
200
|
+
Detect workflow patterns in command chain.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
recent_commands: List of recent command entries
|
|
204
|
+
current_command: The command being analyzed
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Dictionary with detected, broken, and dangerous_escalations lists
|
|
208
|
+
"""
|
|
209
|
+
detected_patterns = {
|
|
210
|
+
"detected": [],
|
|
211
|
+
"broken": [],
|
|
212
|
+
"dangerous_escalations": [],
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for pattern_def in self.patterns:
|
|
216
|
+
match_result = self._match_pattern(
|
|
217
|
+
pattern_def, recent_commands, current_command
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if match_result["type"] == "expected_workflow":
|
|
221
|
+
detected_patterns["detected"].append(
|
|
222
|
+
{
|
|
223
|
+
"name": pattern_def["name"],
|
|
224
|
+
"description": pattern_def["description"],
|
|
225
|
+
"risk_adjustment": -1, # Lower risk
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
elif match_result["type"] == "broken_workflow":
|
|
229
|
+
detected_patterns["broken"].append(
|
|
230
|
+
{
|
|
231
|
+
"name": pattern_def["name"],
|
|
232
|
+
"description": pattern_def["description"],
|
|
233
|
+
"risk_adjustment": +1, # Higher risk
|
|
234
|
+
"missing_step": match_result["missing_step"],
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
elif match_result["type"] == "dangerous_escalation":
|
|
238
|
+
detected_patterns["dangerous_escalations"].append(
|
|
239
|
+
{
|
|
240
|
+
"name": pattern_def["name"],
|
|
241
|
+
"description": pattern_def["description"],
|
|
242
|
+
"risk_adjustment": +2, # Much higher risk
|
|
243
|
+
"previous_command": match_result.get("previous_command"),
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return detected_patterns
|
|
248
|
+
|
|
249
|
+
def _match_pattern(
|
|
250
|
+
self,
|
|
251
|
+
pattern_def: Dict[str, Any],
|
|
252
|
+
history: List[Dict[str, Any]],
|
|
253
|
+
current: str,
|
|
254
|
+
) -> Dict[str, str]:
|
|
255
|
+
"""
|
|
256
|
+
Match a single pattern definition against history.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
pattern_def: Pattern definition from YAML
|
|
260
|
+
history: List of recent commands
|
|
261
|
+
current: Current command being analyzed
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Dictionary with match type and additional info
|
|
265
|
+
"""
|
|
266
|
+
sequence = pattern_def.get("sequence", [])
|
|
267
|
+
if not sequence:
|
|
268
|
+
return {"type": "no_match"}
|
|
269
|
+
|
|
270
|
+
matched_steps = []
|
|
271
|
+
|
|
272
|
+
# Check if current command matches final step
|
|
273
|
+
final_step = sequence[-1]
|
|
274
|
+
try:
|
|
275
|
+
if not re.search(final_step["pattern"], current):
|
|
276
|
+
return {"type": "no_match"}
|
|
277
|
+
except re.error:
|
|
278
|
+
return {"type": "no_match"}
|
|
279
|
+
|
|
280
|
+
# Check if required prior steps are in history
|
|
281
|
+
for step in sequence[:-1]:
|
|
282
|
+
if not step.get("required", False):
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
found = False
|
|
286
|
+
for entry in reversed(history):
|
|
287
|
+
try:
|
|
288
|
+
if re.search(step["pattern"], entry["command"]):
|
|
289
|
+
matched_steps.append(entry)
|
|
290
|
+
found = True
|
|
291
|
+
break
|
|
292
|
+
except re.error:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
if not found:
|
|
296
|
+
return {
|
|
297
|
+
"type": "broken_workflow",
|
|
298
|
+
"missing_step": step["pattern"],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
# Check for dangerous escalations
|
|
302
|
+
if pattern_def.get("risk_if_detected") == "CRITICAL":
|
|
303
|
+
return {
|
|
304
|
+
"type": "dangerous_escalation",
|
|
305
|
+
"previous_command": (
|
|
306
|
+
matched_steps[0]["command"] if matched_steps else None
|
|
307
|
+
),
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {"type": "expected_workflow"}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# Singleton instance
|
|
314
|
+
_command_chain_agent: Optional[CommandChainAgent] = None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_command_chain_agent() -> CommandChainAgent:
|
|
318
|
+
"""Get the singleton CommandChainAgent instance."""
|
|
319
|
+
global _command_chain_agent
|
|
320
|
+
if _command_chain_agent is None:
|
|
321
|
+
_command_chain_agent = CommandChainAgent()
|
|
322
|
+
return _command_chain_agent
|