devops-copilot 0.1.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.
- app/__init__.py +1 -0
- app/cli/__init__.py +1 -0
- app/cli/config.py +41 -0
- app/cli/main.py +251 -0
- app/core/config.py +19 -0
- app/core/database/__init__.py +24 -0
- app/core/security.py +18 -0
- app/main.py +31 -0
- app/modules/__init__.py +1 -0
- app/modules/auth/__init__.py +9 -0
- app/modules/auth/router.py +101 -0
- app/modules/auth/schema.py +34 -0
- app/modules/auth/service.py +91 -0
- app/modules/chat/__init__.py +1 -0
- app/modules/chat/agent.py +443 -0
- app/modules/chat/models.py +48 -0
- app/modules/chat/router.py +362 -0
- app/modules/chat/schema.py +39 -0
- app/modules/guardrails/__init__.py +1 -0
- app/modules/guardrails/schema.py +22 -0
- app/modules/guardrails/service.py +121 -0
- app/modules/knowledge/__init__.py +1 -0
- app/modules/knowledge/service.py +123 -0
- app/modules/servers/__init__.py +1 -0
- app/modules/servers/models.py +30 -0
- app/modules/servers/router.py +67 -0
- app/modules/servers/schema.py +45 -0
- app/modules/servers/service.py +164 -0
- devops_copilot-0.1.0.dist-info/METADATA +81 -0
- devops_copilot-0.1.0.dist-info/RECORD +33 -0
- devops_copilot-0.1.0.dist-info/WHEEL +5 -0
- devops_copilot-0.1.0.dist-info/entry_points.txt +2 -0
- devops_copilot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import jwt
|
|
2
|
+
from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect, Query
|
|
3
|
+
from typing import List
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
from sqlmodel import select
|
|
6
|
+
from langchain_core.messages import HumanMessage, AIMessage
|
|
7
|
+
|
|
8
|
+
from app.core.config import settings
|
|
9
|
+
from app.core.database import get_session, async_session_maker
|
|
10
|
+
from app.modules.auth import User
|
|
11
|
+
from app.modules.auth.service import get_current_user
|
|
12
|
+
from app.modules.servers.models import Server
|
|
13
|
+
from app.modules.servers.service import servers_service
|
|
14
|
+
from app.modules.chat.models import ChatSession, ChatMessage, AgentAction
|
|
15
|
+
from app.modules.chat.schema import (
|
|
16
|
+
ChatSessionCreate,
|
|
17
|
+
ChatSessionResponse,
|
|
18
|
+
ChatMessageResponse,
|
|
19
|
+
AgentActionResponse
|
|
20
|
+
)
|
|
21
|
+
from app.modules.chat.agent import (
|
|
22
|
+
create_agent_executor,
|
|
23
|
+
StreamingCallbackHandler,
|
|
24
|
+
active_websocket,
|
|
25
|
+
active_session_id,
|
|
26
|
+
active_owner_id,
|
|
27
|
+
decrypt_data,
|
|
28
|
+
asyncssh
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
router = APIRouter()
|
|
32
|
+
|
|
33
|
+
# --- REST Endpoints for Chat History ---
|
|
34
|
+
|
|
35
|
+
@router.post("/sessions", response_model=ChatSessionResponse, status_code=status.HTTP_201_CREATED)
|
|
36
|
+
async def create_session(
|
|
37
|
+
payload: ChatSessionCreate,
|
|
38
|
+
current_user: User = Depends(get_current_user),
|
|
39
|
+
session: AsyncSession = Depends(get_session)
|
|
40
|
+
):
|
|
41
|
+
"""Create a new chat session thread for the logged-in administrator."""
|
|
42
|
+
new_session = ChatSession(title=payload.title, user_id=current_user.id)
|
|
43
|
+
session.add(new_session)
|
|
44
|
+
await session.commit()
|
|
45
|
+
await session.refresh(new_session)
|
|
46
|
+
return new_session
|
|
47
|
+
|
|
48
|
+
@router.get("/sessions", response_model=List[ChatSessionResponse])
|
|
49
|
+
async def list_sessions(
|
|
50
|
+
current_user: User = Depends(get_current_user),
|
|
51
|
+
session: AsyncSession = Depends(get_session)
|
|
52
|
+
):
|
|
53
|
+
"""Retrieve all chat sessions belonging to the current administrator."""
|
|
54
|
+
statement = select(ChatSession).where(ChatSession.user_id == current_user.id).order_by(ChatSession.created_at.desc())
|
|
55
|
+
result = await session.exec(statement)
|
|
56
|
+
return result.all()
|
|
57
|
+
|
|
58
|
+
@router.get("/sessions/{session_id}/messages", response_model=List[ChatMessageResponse])
|
|
59
|
+
async def get_messages(
|
|
60
|
+
session_id: int,
|
|
61
|
+
current_user: User = Depends(get_current_user),
|
|
62
|
+
session: AsyncSession = Depends(get_session)
|
|
63
|
+
):
|
|
64
|
+
"""Retrieve historical messages for a specific chat session thread."""
|
|
65
|
+
# Verify session ownership
|
|
66
|
+
sess_statement = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == current_user.id)
|
|
67
|
+
sess_result = await session.exec(sess_statement)
|
|
68
|
+
if not sess_result.first():
|
|
69
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found")
|
|
70
|
+
|
|
71
|
+
msg_statement = select(ChatMessage).where(ChatMessage.session_id == session_id).order_by(ChatMessage.created_at.asc())
|
|
72
|
+
msg_result = await session.exec(msg_statement)
|
|
73
|
+
return msg_result.all()
|
|
74
|
+
|
|
75
|
+
@router.delete("/sessions/{session_id}")
|
|
76
|
+
async def delete_session(
|
|
77
|
+
session_id: int,
|
|
78
|
+
current_user: User = Depends(get_current_user),
|
|
79
|
+
session: AsyncSession = Depends(get_session)
|
|
80
|
+
):
|
|
81
|
+
"""Delete a chat session and all its associated messages and actions."""
|
|
82
|
+
# Verify session ownership
|
|
83
|
+
sess_statement = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == current_user.id)
|
|
84
|
+
sess_result = await session.exec(sess_statement)
|
|
85
|
+
chat_session = sess_result.first()
|
|
86
|
+
if not chat_session:
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
89
|
+
detail="Chat session not found"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Delete messages
|
|
93
|
+
msg_statement = select(ChatMessage).where(ChatMessage.session_id == session_id)
|
|
94
|
+
msg_result = await session.exec(msg_statement)
|
|
95
|
+
for msg in msg_result.all():
|
|
96
|
+
await session.delete(msg)
|
|
97
|
+
|
|
98
|
+
# Delete actions
|
|
99
|
+
act_statement = select(AgentAction).where(AgentAction.session_id == session_id)
|
|
100
|
+
act_result = await session.exec(act_statement)
|
|
101
|
+
for act in act_result.all():
|
|
102
|
+
await session.delete(act)
|
|
103
|
+
|
|
104
|
+
await session.delete(chat_session)
|
|
105
|
+
await session.commit()
|
|
106
|
+
return {"status": "success", "message": f"Chat session {session_id} deleted successfully"}
|
|
107
|
+
|
|
108
|
+
# --- WebSocket Real-Time Chat & Execution Tunnel ---
|
|
109
|
+
|
|
110
|
+
@router.websocket("/ws")
|
|
111
|
+
async def websocket_endpoint(
|
|
112
|
+
websocket: WebSocket,
|
|
113
|
+
token: str = Query(...)
|
|
114
|
+
):
|
|
115
|
+
"""
|
|
116
|
+
Establish a WebSocket connection for real-time DevOps chat and live execution logging.
|
|
117
|
+
Validates the admin's JWT token query parameter prior to connection acceptance.
|
|
118
|
+
"""
|
|
119
|
+
# 1. Manual JWT Token Verification for Handshake
|
|
120
|
+
try:
|
|
121
|
+
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
|
122
|
+
username = payload.get("sub")
|
|
123
|
+
if not username:
|
|
124
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
async with async_session_maker() as session:
|
|
128
|
+
statement = select(User).where(User.username == username)
|
|
129
|
+
result = await session.exec(statement)
|
|
130
|
+
user = result.first()
|
|
131
|
+
if not user or not user.is_active:
|
|
132
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
133
|
+
return
|
|
134
|
+
except Exception:
|
|
135
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Accept client WebSocket connection
|
|
139
|
+
await websocket.accept()
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
while True:
|
|
143
|
+
# Receive inbound JSON payloads
|
|
144
|
+
data = await websocket.receive_json()
|
|
145
|
+
msg_type = data.get("type")
|
|
146
|
+
|
|
147
|
+
if msg_type == "message":
|
|
148
|
+
session_id = data.get("session_id")
|
|
149
|
+
content = data.get("content")
|
|
150
|
+
|
|
151
|
+
if not session_id or not content:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# 2. Load conversation history for memory injection
|
|
155
|
+
async with async_session_maker() as db_session:
|
|
156
|
+
# Verify session ownership
|
|
157
|
+
sess_stmt = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == user.id)
|
|
158
|
+
sess = await db_session.exec(sess_stmt)
|
|
159
|
+
if not sess.first():
|
|
160
|
+
await websocket.send_json({"type": "error", "data": "Unauthorized session access"})
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
# Record user message in DB
|
|
164
|
+
user_msg = ChatMessage(session_id=session_id, sender="user", content=content)
|
|
165
|
+
db_session.add(user_msg)
|
|
166
|
+
await db_session.commit()
|
|
167
|
+
|
|
168
|
+
# Fetch history
|
|
169
|
+
history_stmt = select(ChatMessage).where(ChatMessage.session_id == session_id).order_by(ChatMessage.created_at.asc())
|
|
170
|
+
history_result = await db_session.exec(history_stmt)
|
|
171
|
+
db_messages = history_result.all()
|
|
172
|
+
|
|
173
|
+
chat_history = []
|
|
174
|
+
# Map history database records to LangChain Human/AI message formats
|
|
175
|
+
for msg in db_messages[:-1]: # Exclude current message since it is input
|
|
176
|
+
if msg.sender == "user":
|
|
177
|
+
chat_history.append(HumanMessage(content=msg.content))
|
|
178
|
+
else:
|
|
179
|
+
chat_history.append(AIMessage(content=msg.content))
|
|
180
|
+
|
|
181
|
+
# 3. Setup context variables for the SSH execution tools
|
|
182
|
+
ws_token = active_websocket.set(websocket)
|
|
183
|
+
sess_token = active_session_id.set(session_id)
|
|
184
|
+
owner_token = active_owner_id.set(user.id)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
# Stream tokens via custom callback handler
|
|
188
|
+
callback = StreamingCallbackHandler(websocket)
|
|
189
|
+
agent_executor = create_agent_executor(callbacks=[callback])
|
|
190
|
+
|
|
191
|
+
# Invoke agent compiled StateGraph using messages schema
|
|
192
|
+
inputs = {
|
|
193
|
+
"messages": chat_history + [HumanMessage(content=content)]
|
|
194
|
+
}
|
|
195
|
+
agent_response = await agent_executor.ainvoke(inputs)
|
|
196
|
+
|
|
197
|
+
# Extract final text from output message list
|
|
198
|
+
ai_text = agent_response["messages"][-1].content
|
|
199
|
+
|
|
200
|
+
# Record final agent response in database
|
|
201
|
+
async with async_session_maker() as db_session:
|
|
202
|
+
ai_msg = ChatMessage(session_id=session_id, sender="ai", content=ai_text)
|
|
203
|
+
db_session.add(ai_msg)
|
|
204
|
+
await db_session.commit()
|
|
205
|
+
|
|
206
|
+
# Notify client execution complete
|
|
207
|
+
await websocket.send_json({
|
|
208
|
+
"type": "finished",
|
|
209
|
+
"data": ai_text
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
except Exception as e:
|
|
213
|
+
await websocket.send_json({"type": "error", "data": f"Agent Execution Error: {str(e)}"})
|
|
214
|
+
finally:
|
|
215
|
+
# Clean context variables
|
|
216
|
+
active_websocket.reset(ws_token)
|
|
217
|
+
active_session_id.reset(sess_token)
|
|
218
|
+
active_owner_id.reset(owner_token)
|
|
219
|
+
|
|
220
|
+
elif msg_type == "approve" or msg_type == "reject":
|
|
221
|
+
action_id = data.get("action_id")
|
|
222
|
+
if not action_id:
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
async with async_session_maker() as db_session:
|
|
226
|
+
# Fetch queued action
|
|
227
|
+
act_stmt = select(AgentAction).where(AgentAction.id == action_id)
|
|
228
|
+
act_result = await db_session.exec(act_stmt)
|
|
229
|
+
action = act_result.first()
|
|
230
|
+
|
|
231
|
+
if not action or action.status != "pending":
|
|
232
|
+
await websocket.send_json({"type": "error", "data": "Action not found or already processed"})
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Verify session ownership
|
|
236
|
+
sess_stmt = select(ChatSession).where(ChatSession.id == action.session_id, ChatSession.user_id == user.id)
|
|
237
|
+
sess = await db_session.exec(sess_stmt)
|
|
238
|
+
if not sess.first():
|
|
239
|
+
await websocket.send_json({"type": "error", "data": "Unauthorized session access"})
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
if msg_type == "reject":
|
|
243
|
+
action.status = "rejected"
|
|
244
|
+
await db_session.commit()
|
|
245
|
+
await websocket.send_json({
|
|
246
|
+
"type": "stdout",
|
|
247
|
+
"data": f"\n[Action {action_id} Rejected by User]\n"
|
|
248
|
+
})
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# If approved, update status
|
|
252
|
+
action.status = "approved"
|
|
253
|
+
await db_session.commit()
|
|
254
|
+
|
|
255
|
+
# Load targeted server config
|
|
256
|
+
server_stmt = select(Server).where(Server.id == action.server_id)
|
|
257
|
+
srv_result = await db_session.exec(server_stmt)
|
|
258
|
+
server = srv_result.first()
|
|
259
|
+
|
|
260
|
+
await websocket.send_json({
|
|
261
|
+
"type": "stdout",
|
|
262
|
+
"data": f"\n[Action {action_id} Approved] Running command on {server.name}...\n"
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
# 4. Decrypt and execute approved write command
|
|
266
|
+
credential = decrypt_data(server.encrypted_credential)
|
|
267
|
+
conn_args = {
|
|
268
|
+
"host": server.ip_address,
|
|
269
|
+
"port": server.ssh_port,
|
|
270
|
+
"username": server.ssh_username,
|
|
271
|
+
"known_hosts": None
|
|
272
|
+
}
|
|
273
|
+
if server.ssh_auth_type == "password":
|
|
274
|
+
conn_args["password"] = credential
|
|
275
|
+
else:
|
|
276
|
+
conn_args["client_keys"] = [asyncssh.import_private_key(credential)]
|
|
277
|
+
|
|
278
|
+
stdout_accumulated = []
|
|
279
|
+
stderr_accumulated = []
|
|
280
|
+
exit_status = -1
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
# Connect and execute process, streaming stdout/stderr line-by-line
|
|
284
|
+
async with asyncssh.connect(**conn_args) as conn:
|
|
285
|
+
async with conn.create_process(action.command) as process:
|
|
286
|
+
async for line in process.stdout:
|
|
287
|
+
if isinstance(line, bytes):
|
|
288
|
+
line = line.decode("utf-8", errors="replace")
|
|
289
|
+
stdout_accumulated.append(line)
|
|
290
|
+
await websocket.send_json({"type": "stdout", "data": line})
|
|
291
|
+
async for line in process.stderr:
|
|
292
|
+
if isinstance(line, bytes):
|
|
293
|
+
line = line.decode("utf-8", errors="replace")
|
|
294
|
+
stderr_accumulated.append(line)
|
|
295
|
+
await websocket.send_json({"type": "stderr", "data": line})
|
|
296
|
+
exit_status = await process.wait()
|
|
297
|
+
exit_code = exit_status.exit_status if exit_status.exit_status is not None else 0
|
|
298
|
+
|
|
299
|
+
await websocket.send_json({
|
|
300
|
+
"type": "stdout",
|
|
301
|
+
"data": f"[Process finished with code {exit_code}]\n"
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
stdout_str = "".join(stdout_accumulated)
|
|
305
|
+
stderr_str = "".join(stderr_accumulated)
|
|
306
|
+
|
|
307
|
+
# Auto-index approved command output into RAG knowledge base
|
|
308
|
+
try:
|
|
309
|
+
from app.modules.knowledge.service import index_command_output
|
|
310
|
+
index_command_output(server.name, action.command, stdout_str, stderr_str, exit_code)
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
# 5. Invoke agent loop with the execution output
|
|
315
|
+
ws_token = active_websocket.set(websocket)
|
|
316
|
+
sess_token = active_session_id.set(action.session_id)
|
|
317
|
+
owner_token = active_owner_id.set(user.id)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
callback = StreamingCallbackHandler(websocket)
|
|
321
|
+
agent_executor = create_agent_executor(callbacks=[callback])
|
|
322
|
+
|
|
323
|
+
# Feed output back to agent reasoning chain
|
|
324
|
+
resume_prompt = (
|
|
325
|
+
f"User approved execution of action '{action_id}'.\n"
|
|
326
|
+
f"Command '{action.command}' executed on server '{server.name}'.\n"
|
|
327
|
+
f"Exit Code: {exit_code}\n"
|
|
328
|
+
f"Stdout:\n{stdout_str}\n"
|
|
329
|
+
f"Stderr:\n{stderr_str}\n\n"
|
|
330
|
+
f"Please analyze these outputs and provide your final response to the user."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Invoke the agent graph with the resume message
|
|
334
|
+
agent_response = await agent_executor.ainvoke({
|
|
335
|
+
"messages": [HumanMessage(content=resume_prompt)]
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
ai_text = agent_response["messages"][-1].content
|
|
339
|
+
|
|
340
|
+
# Save final summary response in database
|
|
341
|
+
async with async_session_maker() as db_session:
|
|
342
|
+
ai_msg = ChatMessage(session_id=action.session_id, sender="ai", content=ai_text)
|
|
343
|
+
db_session.add(ai_msg)
|
|
344
|
+
await db_session.commit()
|
|
345
|
+
|
|
346
|
+
await websocket.send_json({
|
|
347
|
+
"type": "finished",
|
|
348
|
+
"data": ai_text
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
except Exception as e:
|
|
352
|
+
await websocket.send_json({"type": "error", "data": f"Error during agent completion: {str(e)}"})
|
|
353
|
+
finally:
|
|
354
|
+
active_websocket.reset(ws_token)
|
|
355
|
+
active_session_id.reset(sess_token)
|
|
356
|
+
active_owner_id.reset(owner_token)
|
|
357
|
+
|
|
358
|
+
except Exception as e:
|
|
359
|
+
await websocket.send_json({"type": "error", "data": f"SSH Execution Failed: {str(e)}"})
|
|
360
|
+
|
|
361
|
+
except WebSocketDisconnect:
|
|
362
|
+
pass
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
class ChatSessionCreate(BaseModel):
|
|
5
|
+
"""Payload to instantiate a new chat session."""
|
|
6
|
+
title: str = Field(..., min_length=1, max_length=100)
|
|
7
|
+
|
|
8
|
+
class ChatSessionResponse(BaseModel):
|
|
9
|
+
"""Metadata response representing a chat session."""
|
|
10
|
+
id: int
|
|
11
|
+
title: str
|
|
12
|
+
user_id: int
|
|
13
|
+
created_at: datetime
|
|
14
|
+
|
|
15
|
+
class Config:
|
|
16
|
+
from_attributes = True
|
|
17
|
+
|
|
18
|
+
class ChatMessageResponse(BaseModel):
|
|
19
|
+
"""Metadata response representing a single message in a session thread."""
|
|
20
|
+
id: int
|
|
21
|
+
session_id: int
|
|
22
|
+
sender: str
|
|
23
|
+
content: str
|
|
24
|
+
created_at: datetime
|
|
25
|
+
|
|
26
|
+
class Config:
|
|
27
|
+
from_attributes = True
|
|
28
|
+
|
|
29
|
+
class AgentActionResponse(BaseModel):
|
|
30
|
+
"""Metadata response representing a queued server command approval."""
|
|
31
|
+
id: str
|
|
32
|
+
session_id: int
|
|
33
|
+
server_id: int
|
|
34
|
+
command: str
|
|
35
|
+
status: str
|
|
36
|
+
created_at: datetime
|
|
37
|
+
|
|
38
|
+
class Config:
|
|
39
|
+
from_attributes = True
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This makes the rag folder a Python package
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
class SecurityCheckRequest(BaseModel):
|
|
4
|
+
"""Input payload to check a proposed bash command for security threats."""
|
|
5
|
+
command: str = Field(..., min_length=1)
|
|
6
|
+
|
|
7
|
+
class SecurityCheckResponse(BaseModel):
|
|
8
|
+
"""Result of the semantic safety check against the dangerous commands database."""
|
|
9
|
+
safe: bool
|
|
10
|
+
reason: str
|
|
11
|
+
similarity: float
|
|
12
|
+
|
|
13
|
+
class BlacklistPatternCreate(BaseModel):
|
|
14
|
+
"""Payload to register a new forbidden pattern inside the semantic guardrail database."""
|
|
15
|
+
pattern: str = Field(..., min_length=3)
|
|
16
|
+
reason: str = Field(..., min_length=3)
|
|
17
|
+
|
|
18
|
+
class BlacklistPatternResponse(BaseModel):
|
|
19
|
+
"""Details of a registered dangerous command pattern in the vector store."""
|
|
20
|
+
id: str
|
|
21
|
+
pattern: str
|
|
22
|
+
reason: str
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import chromadb
|
|
3
|
+
from typing import List, Tuple
|
|
4
|
+
|
|
5
|
+
# Initialize ChromaDB persistent client pointing to data volume
|
|
6
|
+
# Will resolve to /app/data/chroma_db inside Docker containers
|
|
7
|
+
client = chromadb.PersistentClient(path="data/chroma_db")
|
|
8
|
+
|
|
9
|
+
# Fetch or create the security guardrail collection
|
|
10
|
+
collection = client.get_or_create_collection(name="security_blacklist")
|
|
11
|
+
|
|
12
|
+
async def init_and_seed_db():
|
|
13
|
+
"""
|
|
14
|
+
Checks if the security blacklist collection has documents.
|
|
15
|
+
If empty, populates it with default dangerous command templates and reasons.
|
|
16
|
+
"""
|
|
17
|
+
if collection.count() == 0:
|
|
18
|
+
default_seeds = [
|
|
19
|
+
{
|
|
20
|
+
"pattern": "rm -rf /",
|
|
21
|
+
"reason": "Recursive deletion of the root directory. Results in absolute OS loss."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"pattern": "rm -rf /etc",
|
|
25
|
+
"reason": "Recursive deletion of configuration directories. Breaks system configuration."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"pattern": "mkfs.ext4 /dev/sda",
|
|
29
|
+
"reason": "Formatting raw storage drive. Destroys filesystem structure and partition data."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"pattern": "dd if=/dev/zero of=/dev/sda",
|
|
33
|
+
"reason": "Zero-writing raw block device. Wipes partition tables and master boot records."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"pattern": "iptables -F",
|
|
37
|
+
"reason": "Flushing active firewall rules. Exposes the server to external connection attempts."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"pattern": "cat /etc/shadow",
|
|
41
|
+
"reason": "Exposing system shadow user file. Leaks hashed credentials database."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"pattern": "chmod 777 -R /",
|
|
45
|
+
"reason": "Recursive permissive access modifier. Allows unprivileged write and execution globally."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"pattern": "reboot",
|
|
49
|
+
"reason": "Forces hard system restart. Terminates running containers and processes instantly."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"pattern": "shutdown -h now",
|
|
53
|
+
"reason": "Forces host system shutdown. Takes the server offline."
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
ids = [f"seed_{i}" for i in range(len(default_seeds))]
|
|
58
|
+
documents = [item["pattern"] for item in default_seeds]
|
|
59
|
+
metadatas = [{"reason": item["reason"]} for item in default_seeds]
|
|
60
|
+
|
|
61
|
+
# Sync add to collection
|
|
62
|
+
collection.add(
|
|
63
|
+
ids=ids,
|
|
64
|
+
documents=documents,
|
|
65
|
+
metadatas=metadatas
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def is_command_safe(command: str) -> Tuple[bool, str, float]:
|
|
69
|
+
"""
|
|
70
|
+
Performs semantic similarity check of proposed shell command against blacklist.
|
|
71
|
+
Returns (is_safe, reason, similarity_score).
|
|
72
|
+
"""
|
|
73
|
+
# Fetch closest match (n_results=1)
|
|
74
|
+
results = collection.query(
|
|
75
|
+
query_texts=[command],
|
|
76
|
+
n_results=1
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if results and results["documents"] and len(results["documents"][0]) > 0:
|
|
80
|
+
matched_doc = results["documents"][0][0]
|
|
81
|
+
distance = results["distances"][0][0] # L2 distance metric
|
|
82
|
+
metadata = results["metadatas"][0][0]
|
|
83
|
+
|
|
84
|
+
# Convert L2 distance to Cosine Similarity score: 1.0 - (L2 / 2.0)
|
|
85
|
+
# ChromaDB ONNX embeddings are L2-normalized, making this conversion mathematically valid.
|
|
86
|
+
similarity = 1.0 - (distance / 2.0)
|
|
87
|
+
|
|
88
|
+
# Flag commands with a similarity index higher than 0.75 (L2 distance < 0.5)
|
|
89
|
+
if similarity > 0.75:
|
|
90
|
+
return (
|
|
91
|
+
False,
|
|
92
|
+
f"Blocked: Command matches dangerous pattern '{matched_doc}'. Reason: {metadata['reason']}",
|
|
93
|
+
similarity
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return True, "No critical security threats detected.", similarity
|
|
97
|
+
|
|
98
|
+
return True, "Guardrail threat database is empty.", 0.0
|
|
99
|
+
|
|
100
|
+
def add_blacklist_pattern(pattern: str, reason: str) -> dict:
|
|
101
|
+
"""Adds a new custom forbidden pattern to the ChromaDB vector database."""
|
|
102
|
+
pattern_id = f"custom_{uuid.uuid4().hex}"
|
|
103
|
+
collection.add(
|
|
104
|
+
ids=[pattern_id],
|
|
105
|
+
documents=[pattern],
|
|
106
|
+
metadatas=[{"reason": reason}]
|
|
107
|
+
)
|
|
108
|
+
return {"id": pattern_id, "pattern": pattern, "reason": reason}
|
|
109
|
+
|
|
110
|
+
def get_all_blacklist_patterns() -> List[dict]:
|
|
111
|
+
"""Retrieves all registered dangerous patterns inside the RAG collection."""
|
|
112
|
+
results = collection.get()
|
|
113
|
+
patterns = []
|
|
114
|
+
if results and "ids" in results:
|
|
115
|
+
for i in range(len(results["ids"])):
|
|
116
|
+
patterns.append({
|
|
117
|
+
"id": results["ids"][i],
|
|
118
|
+
"pattern": results["documents"][i],
|
|
119
|
+
"reason": results["metadatas"][i]["reason"]
|
|
120
|
+
})
|
|
121
|
+
return patterns
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This makes the knowledge folder a Python package
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import chromadb
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
# Shared ChromaDB persistent client (same data volume as guardrails)
|
|
6
|
+
client = chromadb.PersistentClient(path="data/chroma_db")
|
|
7
|
+
|
|
8
|
+
# --- 3 Separate Collections ---
|
|
9
|
+
command_history = client.get_or_create_collection(name="command_history")
|
|
10
|
+
server_logs = client.get_or_create_collection(name="server_logs")
|
|
11
|
+
server_configs = client.get_or_create_collection(name="server_configs")
|
|
12
|
+
|
|
13
|
+
CHUNK_SIZE = 500
|
|
14
|
+
CHUNK_OVERLAP = 50
|
|
15
|
+
|
|
16
|
+
def _chunk_text(text: str) -> List[str]:
|
|
17
|
+
"""Split text into overlapping chunks for better retrieval."""
|
|
18
|
+
if len(text) <= CHUNK_SIZE:
|
|
19
|
+
return [text]
|
|
20
|
+
chunks = []
|
|
21
|
+
start = 0
|
|
22
|
+
while start < len(text):
|
|
23
|
+
end = start + CHUNK_SIZE
|
|
24
|
+
chunks.append(text[start:end])
|
|
25
|
+
start = end - CHUNK_OVERLAP
|
|
26
|
+
return chunks
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def index_command_output(
|
|
30
|
+
server_name: str, command: str, stdout: str, stderr: str, exit_code: int
|
|
31
|
+
):
|
|
32
|
+
"""Auto-index SSH command execution results into command_history collection."""
|
|
33
|
+
doc = (
|
|
34
|
+
f"Server: {server_name}\n"
|
|
35
|
+
f"Command: {command}\n"
|
|
36
|
+
f"Exit Code: {exit_code}\n"
|
|
37
|
+
f"Stdout:\n{stdout}\n"
|
|
38
|
+
f"Stderr:\n{stderr}"
|
|
39
|
+
)
|
|
40
|
+
metadata = {
|
|
41
|
+
"server_name": server_name,
|
|
42
|
+
"command": command,
|
|
43
|
+
"exit_code": exit_code,
|
|
44
|
+
"source": "command_history",
|
|
45
|
+
}
|
|
46
|
+
chunks = _chunk_text(doc)
|
|
47
|
+
for i, chunk in enumerate(chunks):
|
|
48
|
+
command_history.add(
|
|
49
|
+
ids=[f"cmd_{uuid.uuid4().hex[:12]}"],
|
|
50
|
+
documents=[chunk],
|
|
51
|
+
metadatas=[{**metadata, "chunk_index": i}],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def index_log(server_name: str, log_source: str, content: str):
|
|
56
|
+
"""Index server log output into server_logs collection."""
|
|
57
|
+
metadata = {
|
|
58
|
+
"server_name": server_name,
|
|
59
|
+
"log_source": log_source,
|
|
60
|
+
"source": "server_logs",
|
|
61
|
+
}
|
|
62
|
+
chunks = _chunk_text(content)
|
|
63
|
+
for i, chunk in enumerate(chunks):
|
|
64
|
+
server_logs.add(
|
|
65
|
+
ids=[f"log_{uuid.uuid4().hex[:12]}"],
|
|
66
|
+
documents=[chunk],
|
|
67
|
+
metadatas=[{**metadata, "chunk_index": i}],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def index_config(server_name: str, file_path: str, content: str):
|
|
72
|
+
"""Index a server config file into server_configs collection."""
|
|
73
|
+
metadata = {
|
|
74
|
+
"server_name": server_name,
|
|
75
|
+
"file_path": file_path,
|
|
76
|
+
"source": "server_configs",
|
|
77
|
+
}
|
|
78
|
+
chunks = _chunk_text(content)
|
|
79
|
+
for i, chunk in enumerate(chunks):
|
|
80
|
+
server_configs.add(
|
|
81
|
+
ids=[f"cfg_{uuid.uuid4().hex[:12]}"],
|
|
82
|
+
documents=[chunk],
|
|
83
|
+
metadatas=[{**metadata, "chunk_index": i}],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Map collection name -> collection object
|
|
88
|
+
_COLLECTIONS = {
|
|
89
|
+
"command_history": command_history,
|
|
90
|
+
"server_logs": server_logs,
|
|
91
|
+
"server_configs": server_configs,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def search(
|
|
96
|
+
query: str,
|
|
97
|
+
collection_names: Optional[List[str]] = None,
|
|
98
|
+
n_results: int = 3,
|
|
99
|
+
) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Semantic search across one or more knowledge collections.
|
|
102
|
+
Returns formatted string of top results for LLM consumption.
|
|
103
|
+
"""
|
|
104
|
+
targets = collection_names or list(_COLLECTIONS.keys())
|
|
105
|
+
all_results = []
|
|
106
|
+
|
|
107
|
+
for name in targets:
|
|
108
|
+
col = _COLLECTIONS.get(name)
|
|
109
|
+
if not col or col.count() == 0:
|
|
110
|
+
continue
|
|
111
|
+
results = col.query(query_texts=[query], n_results=min(n_results, col.count()))
|
|
112
|
+
if results and results["documents"]:
|
|
113
|
+
for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
|
|
114
|
+
all_results.append(
|
|
115
|
+
f"[{meta.get('source', name)}] "
|
|
116
|
+
f"Server: {meta.get('server_name', '?')} | "
|
|
117
|
+
f"{doc[:300]}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if not all_results:
|
|
121
|
+
return "No relevant knowledge found in the database."
|
|
122
|
+
|
|
123
|
+
return "\n---\n".join(all_results)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .models import Server
|