Initial commit
This commit is contained in:
69
core/config.py
Normal file
69
core/config.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# =========================
|
||||
# CORE PATHS
|
||||
# =========================
|
||||
|
||||
WORKSPACE_ROOT = Path(os.getenv("WORKSPACE_ROOT", Path.cwd())).resolve()
|
||||
|
||||
LOG_DIR = WORKSPACE_ROOT / "logs"
|
||||
TMP_DIR = WORKSPACE_ROOT / "tmp"
|
||||
CONFIG_DIR = WORKSPACE_ROOT / "config"
|
||||
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# =========================
|
||||
# SYSTEM LIMITS
|
||||
# =========================
|
||||
|
||||
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", str(10 * 1024 * 1024)))
|
||||
|
||||
IGNORE_DIRS = set(
|
||||
os.getenv(
|
||||
"IGNORE_DIRS",
|
||||
".git,__pycache__,node_modules,.venv,venv,dist,build"
|
||||
).split(",")
|
||||
)
|
||||
|
||||
|
||||
# =========================
|
||||
# EVENT / DEBUG FLAGS
|
||||
# =========================
|
||||
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
DRY_RUN_DEFAULT = os.getenv("DRY_RUN", "false").lower() == "true"
|
||||
|
||||
|
||||
# =========================
|
||||
# GITEA CONFIG
|
||||
# =========================
|
||||
|
||||
GITEA_URL = os.getenv("GITEA_URL", "http://localhost:3000")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||
GITEA_USER = os.getenv("GITEA_USER", "admin")
|
||||
GITEA_API_BASE = f"{GITEA_URL}/api/v1"
|
||||
|
||||
|
||||
# =========================
|
||||
# GIT CONFIG
|
||||
# =========================
|
||||
|
||||
GIT_AUTHOR_NAME = os.getenv("GIT_AUTHOR_NAME", "CXOS")
|
||||
GIT_AUTHOR_EMAIL = os.getenv("GIT_AUTHOR_EMAIL", "cxos@local")
|
||||
|
||||
|
||||
# =========================
|
||||
# MCP / SERVER CONFIG
|
||||
# =========================
|
||||
|
||||
SERVER_NAME = os.getenv("SERVER_NAME", "CXOS-MCP")
|
||||
API_HOST = os.getenv("API_HOST", "0.0.0.0")
|
||||
API_PORT = int(os.getenv("API_PORT", "5432"))
|
||||
108
core/event_store.py
Normal file
108
core/event_store.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from collections import deque
|
||||
from typing import Optional, Any, Callable, Iterator
|
||||
|
||||
from core.events import Event, bus
|
||||
|
||||
|
||||
class EventStore:
|
||||
def __init__(self, max_size: int = 10_000):
|
||||
self.buffer: deque[Event] = deque(maxlen=max_size)
|
||||
self._cursor: int = 0
|
||||
|
||||
bus.subscribe(self._capture)
|
||||
|
||||
# =========================
|
||||
# CAPTURE
|
||||
# =========================
|
||||
|
||||
def _capture(self, event: Event):
|
||||
event.meta["cursor"] = self._cursor
|
||||
|
||||
self.buffer.append(event)
|
||||
self._cursor += 1
|
||||
|
||||
# =========================
|
||||
# QUERY
|
||||
# =========================
|
||||
|
||||
def query(
|
||||
self,
|
||||
source: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
start_cursor: Optional[int] = None,
|
||||
end_cursor: Optional[int] = None,
|
||||
) -> list[Event]:
|
||||
|
||||
events = list(self.buffer)
|
||||
|
||||
if source is not None:
|
||||
events = [e for e in events if e.source == source]
|
||||
|
||||
if level is not None:
|
||||
events = [e for e in events if e.level == level]
|
||||
|
||||
if name is not None:
|
||||
events = [e for e in events if e.name == name]
|
||||
|
||||
def cursor_of(e: Event) -> int:
|
||||
return e.meta.get("cursor", -1)
|
||||
|
||||
if start_cursor is not None:
|
||||
events = [e for e in events if cursor_of(e) >= start_cursor]
|
||||
|
||||
if end_cursor is not None:
|
||||
events = [e for e in events if cursor_of(e) <= end_cursor]
|
||||
|
||||
return events
|
||||
|
||||
# =========================
|
||||
# PAGINATION
|
||||
# =========================
|
||||
|
||||
def page(self, cursor: int = 0, limit: int = 100) -> dict[str, Any]:
|
||||
events = list(self.buffer)
|
||||
sliced = events[cursor:cursor + limit]
|
||||
|
||||
return {
|
||||
"cursor": cursor,
|
||||
"next_cursor": cursor + len(sliced),
|
||||
"count": len(sliced),
|
||||
"events": sliced
|
||||
}
|
||||
|
||||
# =========================
|
||||
# REPLAY
|
||||
# =========================
|
||||
|
||||
def replay(
|
||||
self,
|
||||
source: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
handler: Optional[Callable[[Event], Any]] = None,
|
||||
):
|
||||
for event in self.query(source, level, name):
|
||||
if handler:
|
||||
handler(event)
|
||||
else:
|
||||
bus.emit(event)
|
||||
|
||||
# =========================
|
||||
# REVERSE DEBUG
|
||||
# =========================
|
||||
|
||||
def replay_reverse(self, handler: Callable[[Event], Any]):
|
||||
for event in reversed(self.buffer):
|
||||
handler(event)
|
||||
|
||||
# =========================
|
||||
# STREAM TAIL
|
||||
# =========================
|
||||
|
||||
def tail(self, last_n: int = 50) -> Iterator[Event]:
|
||||
yield from list(self.buffer)[-last_n:]
|
||||
|
||||
|
||||
# singleton
|
||||
event_store = EventStore()
|
||||
147
core/events.py
Normal file
147
core/events.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
from collections.abc import Awaitable
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
|
||||
# =========================
|
||||
# EVENT MODEL (GRAPH-AWARE)
|
||||
# =========================
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
name: str
|
||||
source: str
|
||||
level: str = "INFO"
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
trace_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
parent_event_id: str | None = None
|
||||
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
|
||||
# =========================
|
||||
# EVENT BUS
|
||||
# =========================
|
||||
|
||||
class EventBus:
|
||||
def __init__(self):
|
||||
self._subscribers: list[Callable[[Event], Any]] = []
|
||||
self._async_subscribers: list[Callable[[Event], Awaitable[Any]]] = []
|
||||
|
||||
self._current_trace: str | None = None
|
||||
self._current_parent: str | None = None
|
||||
|
||||
# -------------------------
|
||||
# SUBSCRIBE
|
||||
# -------------------------
|
||||
|
||||
def subscribe(self, fn: Callable[[Event], Any]):
|
||||
self._subscribers.append(fn)
|
||||
|
||||
def subscribe_async(self, fn: Callable[[Event], Awaitable[Any]]):
|
||||
self._async_subscribers.append(fn)
|
||||
|
||||
# -------------------------
|
||||
# TRACE CONTROL
|
||||
# -------------------------
|
||||
|
||||
def start_trace(self) -> str:
|
||||
trace_id = str(uuid.uuid4())
|
||||
self._current_trace = trace_id
|
||||
self._current_parent = None
|
||||
return trace_id
|
||||
|
||||
def set_parent(self, event_id: str | None):
|
||||
self._current_parent = event_id
|
||||
|
||||
# -------------------------
|
||||
# EMIT (CORE SAFE PATH)
|
||||
# -------------------------
|
||||
|
||||
def emit(self, event: Event):
|
||||
# inject trace
|
||||
if not event.trace_id:
|
||||
event.trace_id = self._current_trace or event.trace_id
|
||||
|
||||
event.parent_event_id = event.parent_event_id or self._current_parent
|
||||
|
||||
# advance causal chain
|
||||
self._current_parent = event.event_id
|
||||
|
||||
# sync subscribers
|
||||
for fn in self._subscribers:
|
||||
try:
|
||||
fn(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# async subscribers (SAFE FIX)
|
||||
if self._async_subscribers:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return # no loop available
|
||||
|
||||
for fn in self._async_subscribers:
|
||||
try:
|
||||
coro = fn(event)
|
||||
|
||||
# IMPORTANT FIX:
|
||||
# ensure it's actually awaitable before scheduling
|
||||
if asyncio.iscoroutine(coro):
|
||||
loop.create_task(coro)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------------
|
||||
# STRICT ASYNC EMIT
|
||||
# -------------------------
|
||||
|
||||
async def emit_async(self, event: Event):
|
||||
for fn in self._subscribers:
|
||||
try:
|
||||
fn(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(
|
||||
*(fn(event) for fn in self._async_subscribers),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# LOG CONVENIENCE
|
||||
# -------------------------
|
||||
|
||||
def log(
|
||||
self,
|
||||
source: str,
|
||||
name: str,
|
||||
level: str = "INFO",
|
||||
data: dict[str, Any] | None = None
|
||||
):
|
||||
self.emit(
|
||||
Event(
|
||||
name=name,
|
||||
source=source,
|
||||
level=level,
|
||||
data=data or {}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# =========================
|
||||
# SINGLETON
|
||||
# =========================
|
||||
|
||||
bus = EventBus()
|
||||
85
core/exception.py
Normal file
85
core/exception.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
from core.events import bus, Event
|
||||
|
||||
|
||||
# -------------------------
|
||||
# BASE ERROR
|
||||
# -------------------------
|
||||
|
||||
@dataclass
|
||||
class CXError(Exception):
|
||||
code: str
|
||||
message: str
|
||||
module: str = "CORE"
|
||||
status: int = 500
|
||||
context: Optional[Any] = None
|
||||
|
||||
# -------------------------
|
||||
# SERIALIZATION
|
||||
# -------------------------
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"error": True,
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"module": self.module,
|
||||
"status": self.status,
|
||||
"context": self.context,
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
# EVENT EMISSION
|
||||
# -------------------------
|
||||
|
||||
def emit(self):
|
||||
bus.emit(Event(
|
||||
name="exception",
|
||||
source=self.module,
|
||||
level="ERROR",
|
||||
data={
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"status": self.status,
|
||||
"context": self.context,
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
# -------------------------
|
||||
# 4xx CLIENT / AI ERRORS
|
||||
# -------------------------
|
||||
|
||||
class CXBadRequest(CXError):
|
||||
def __init__(self, msg, module="CORE"):
|
||||
super().__init__("CX400", msg, module, 400)
|
||||
|
||||
|
||||
class CXNotFound(CXError):
|
||||
def __init__(self, msg, module="FILESYSTEM"):
|
||||
super().__init__("CX404", msg, module, 404)
|
||||
|
||||
|
||||
class CXForbidden(CXError):
|
||||
def __init__(self, msg, module="SECURITY"):
|
||||
super().__init__("CX403", msg, module, 403)
|
||||
|
||||
|
||||
class CXInvalidOperation(CXError):
|
||||
def __init__(self, msg, module="CORE"):
|
||||
super().__init__("CX422", msg, module, 422)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# 5xx SYSTEM ERRORS
|
||||
# -------------------------
|
||||
|
||||
class CXInternalError(CXError):
|
||||
def __init__(self, msg, module="CORE"):
|
||||
super().__init__("CX500", msg, module, 500)
|
||||
|
||||
|
||||
class CXToolFailure(CXError):
|
||||
def __init__(self, msg, module="TOOLS"):
|
||||
super().__init__("CX502", msg, module, 502)
|
||||
62
core/executor.py
Normal file
62
core/executor.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any, TypeVar, Coroutine
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class LoopExecutor:
|
||||
def __init__(self):
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
# -------------------------
|
||||
# START LOOP
|
||||
# -------------------------
|
||||
|
||||
def start(self):
|
||||
if self._loop:
|
||||
return
|
||||
|
||||
def runner():
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.run_forever()
|
||||
|
||||
self._thread = threading.Thread(target=runner, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
while self._loop is None:
|
||||
pass
|
||||
|
||||
# -------------------------
|
||||
# SUBMIT COROUTINE
|
||||
# -------------------------
|
||||
|
||||
def submit(self, coro: Coroutine[Any, Any, T]):
|
||||
if not self._loop:
|
||||
raise RuntimeError("Executor not started")
|
||||
|
||||
return asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
|
||||
# -------------------------
|
||||
# SYNC BRIDGE
|
||||
# -------------------------
|
||||
|
||||
def run_sync(self, coro: Coroutine[Any, Any, T]) -> T:
|
||||
future = self.submit(coro)
|
||||
return future.result()
|
||||
|
||||
# -------------------------
|
||||
# STOP
|
||||
# -------------------------
|
||||
|
||||
def stop(self):
|
||||
if self._loop:
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
self._loop = None
|
||||
|
||||
|
||||
executor = LoopExecutor()
|
||||
125
core/logging_core.py
Normal file
125
core/logging_core.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from core.config import LOG_DIR
|
||||
from core.events import Event, bus
|
||||
|
||||
|
||||
class CXLoggerSink:
|
||||
def __init__(self):
|
||||
logger.remove()
|
||||
|
||||
# =========================
|
||||
# SESSION LOG FILE
|
||||
# =========================
|
||||
|
||||
timestamp = datetime.utcnow().strftime(
|
||||
"%Y-%m-%d_%H-%M-%S"
|
||||
)
|
||||
|
||||
self.log_path = LOG_DIR / (
|
||||
f"MCP-{timestamp}.log"
|
||||
)
|
||||
|
||||
self.log_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
# =========================
|
||||
# CONSOLE LOGGER
|
||||
# =========================
|
||||
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level="DEBUG",
|
||||
colorize=True,
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
format=(
|
||||
"<level>{level:<8}</level> | "
|
||||
"<cyan>{extra[module]:<12}</cyan> | "
|
||||
"{time:YYYY-MM-DD HH:mm:ss} | "
|
||||
"{message}"
|
||||
)
|
||||
)
|
||||
|
||||
# =========================
|
||||
# FILE LOGGER
|
||||
# =========================
|
||||
|
||||
logger.add(
|
||||
str(self.log_path),
|
||||
level="DEBUG",
|
||||
enqueue=True,
|
||||
retention="30 days",
|
||||
compression="zip",
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
format=(
|
||||
"{time:YYYY-MM-DD HH:mm:ss} | "
|
||||
"{level:<8} | "
|
||||
"{extra[module]:<12} | "
|
||||
"{message}"
|
||||
)
|
||||
)
|
||||
|
||||
logger.bind(module="LOGGER").info(
|
||||
f"Session log created: {self.log_path.name}"
|
||||
)
|
||||
|
||||
# =========================
|
||||
# EVENT HANDLER
|
||||
# =========================
|
||||
|
||||
def handle(
|
||||
self,
|
||||
event: Event
|
||||
):
|
||||
module = event.source.upper()
|
||||
level = event.level.lower()
|
||||
|
||||
message = event.name
|
||||
|
||||
if event.data:
|
||||
message += (
|
||||
f" | {event.data}"
|
||||
)
|
||||
|
||||
with logger.contextualize(
|
||||
module=module
|
||||
):
|
||||
log_method = getattr(
|
||||
logger,
|
||||
level,
|
||||
logger.info
|
||||
)
|
||||
|
||||
log_method(message)
|
||||
|
||||
# =========================
|
||||
# OPTIONAL DIRECT ACCESS
|
||||
# =========================
|
||||
|
||||
def get_log_path(self) -> str:
|
||||
return str(self.log_path)
|
||||
|
||||
|
||||
# =========================
|
||||
# SINGLETON
|
||||
# =========================
|
||||
|
||||
cxlog_sink = CXLoggerSink()
|
||||
|
||||
|
||||
# =========================
|
||||
# REGISTER TO EVENT BUS
|
||||
# =========================
|
||||
|
||||
bus.subscribe(
|
||||
cxlog_sink.handle
|
||||
)
|
||||
72
core/metrics.py
Normal file
72
core/metrics.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from collections import defaultdict
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
from core.events import bus, Event
|
||||
|
||||
|
||||
class CXMetrics:
|
||||
def __init__(self):
|
||||
self.counters = defaultdict(int)
|
||||
self.events: list[dict[str, Any]] = []
|
||||
self.max_events = 5000
|
||||
|
||||
# attach to event bus
|
||||
bus.subscribe(self._on_event)
|
||||
|
||||
# -------------------------
|
||||
# EVENT HOOK
|
||||
# -------------------------
|
||||
|
||||
def _on_event(self, event: Event):
|
||||
self.counters[f"event_{event.name}"] += 1
|
||||
self.counters[f"level_{event.level}"] += 1
|
||||
|
||||
self.events.append({
|
||||
"name": event.name,
|
||||
"source": event.source,
|
||||
"level": event.level,
|
||||
"data": event.data,
|
||||
"timestamp": event.timestamp
|
||||
})
|
||||
|
||||
if len(self.events) > self.max_events:
|
||||
self.events = self.events[-self.max_events:]
|
||||
|
||||
# -------------------------
|
||||
# MANUAL COUNTERS
|
||||
# -------------------------
|
||||
|
||||
def inc(self, key: str, value: int = 1):
|
||||
self.counters[key] += value
|
||||
|
||||
def event(self, name: str, data: Optional[dict] = None):
|
||||
bus.emit(Event(
|
||||
name=name,
|
||||
source="metrics",
|
||||
level="INFO",
|
||||
data=data or {}
|
||||
))
|
||||
|
||||
# -------------------------
|
||||
# SNAPSHOT (Prometheus / API)
|
||||
# -------------------------
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return {
|
||||
"counters": dict(self.counters),
|
||||
"recent_events": self.events[-100:],
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
# SIMPLE EXPORT FORMAT (future Prometheus hook)
|
||||
# -------------------------
|
||||
|
||||
def prometheus_format(self) -> str:
|
||||
lines = []
|
||||
for k, v in self.counters.items():
|
||||
lines.append(f"{k} {v}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
metrics = CXMetrics()
|
||||
181
core/safety.py
Normal file
181
core/safety.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
import asyncio
|
||||
|
||||
from core.config import (
|
||||
WORKSPACE_ROOT,
|
||||
MAX_FILE_SIZE,
|
||||
IGNORE_DIRS,
|
||||
DRY_RUN_DEFAULT,
|
||||
)
|
||||
|
||||
from core.events import bus, Event
|
||||
from core.metrics import metrics
|
||||
|
||||
|
||||
# =========================
|
||||
# SAFETY ERROR
|
||||
# =========================
|
||||
|
||||
class SafetyError(Exception):
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
# =========================
|
||||
# SAFETY ENGINE
|
||||
# =========================
|
||||
|
||||
class SafetyEngine:
|
||||
def __init__(self):
|
||||
self.dry_run = DRY_RUN_DEFAULT
|
||||
self.block_destructive = True
|
||||
|
||||
# -------------------------
|
||||
# MODE CONTROL
|
||||
# -------------------------
|
||||
|
||||
def set_dry_run(self, value: bool):
|
||||
self.dry_run = value
|
||||
bus.emit(Event(
|
||||
name="safety_dry_run",
|
||||
source="SAFETY",
|
||||
level="INFO",
|
||||
data={"enabled": value}
|
||||
))
|
||||
|
||||
# -------------------------
|
||||
# PATH SANDBOXING
|
||||
# -------------------------
|
||||
|
||||
def validate_path(self, path: str) -> Path:
|
||||
target = (WORKSPACE_ROOT / path).resolve()
|
||||
|
||||
if not str(target).startswith(str(WORKSPACE_ROOT)):
|
||||
self._violation("PATH_ESCAPE", path)
|
||||
raise SafetyError("PATH_ESCAPE", "Outside workspace")
|
||||
|
||||
for part in target.parts:
|
||||
if part in IGNORE_DIRS:
|
||||
self._violation("IGNORED_PATH", path)
|
||||
raise SafetyError("IGNORED_PATH", f"Blocked directory: {part}")
|
||||
|
||||
return target
|
||||
|
||||
# -------------------------
|
||||
# FILE SIZE GUARD
|
||||
# -------------------------
|
||||
|
||||
def check_file_write(self, path: Path, content: str):
|
||||
if len(content.encode("utf-8")) > MAX_FILE_SIZE:
|
||||
self._violation("FILE_TOO_LARGE", str(path))
|
||||
raise SafetyError("FILE_TOO_LARGE", "Exceeded limit")
|
||||
|
||||
# -------------------------
|
||||
# DESTRUCTIVE OPS
|
||||
# -------------------------
|
||||
|
||||
def allow_action(self, action: str):
|
||||
blocked = {
|
||||
"delete_file",
|
||||
"rm_rf",
|
||||
"format",
|
||||
"wipe",
|
||||
"drop_db",
|
||||
}
|
||||
|
||||
if self.block_destructive and action in blocked:
|
||||
self._violation("DESTRUCTIVE_BLOCK", action)
|
||||
raise SafetyError("DESTRUCTIVE_BLOCK", f"Blocked: {action}")
|
||||
|
||||
# =========================================================
|
||||
# ASYNC SAFE WRAPPER (NEW CORE ENTRYPOINT)
|
||||
# =========================================================
|
||||
|
||||
async def wrap_async(
|
||||
self,
|
||||
tool_name: str,
|
||||
fn: Callable[..., Any | Awaitable[Any]],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
|
||||
bus.emit(Event(
|
||||
name="tool_start",
|
||||
source="SAFETY",
|
||||
level="INFO",
|
||||
data={"tool": tool_name}
|
||||
))
|
||||
|
||||
if self.dry_run:
|
||||
return {
|
||||
"dry_run": True,
|
||||
"tool": tool_name,
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
metrics.inc(f"tool_{tool_name}")
|
||||
metrics.inc("tools_executed")
|
||||
|
||||
bus.emit(Event(
|
||||
name="tool_success",
|
||||
source="SAFETY",
|
||||
level="SUCCESS",
|
||||
data={"tool": tool_name}
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
except SafetyError as e:
|
||||
bus.emit(Event(
|
||||
name="tool_blocked",
|
||||
source="SAFETY",
|
||||
level="ERROR",
|
||||
data={"code": e.code, "message": e.message}
|
||||
))
|
||||
|
||||
metrics.inc("safety_blocks")
|
||||
|
||||
return {"error": True, "code": e.code, "message": e.message}
|
||||
|
||||
except Exception as e:
|
||||
bus.emit(Event(
|
||||
name="tool_crash",
|
||||
source="SAFETY",
|
||||
level="ERROR",
|
||||
data={"tool": tool_name, "error": str(e)}
|
||||
))
|
||||
|
||||
metrics.inc("tool_errors")
|
||||
|
||||
return {"error": True, "code": "UNEXPECTED", "message": str(e)}
|
||||
|
||||
# -------------------------
|
||||
# INTERNAL VIOLATION LOGGER
|
||||
# -------------------------
|
||||
|
||||
def _violation(self, code: str, context: str):
|
||||
bus.emit(Event(
|
||||
name="safety_violation",
|
||||
source="SAFETY",
|
||||
level="ERROR",
|
||||
data={"code": code, "context": context}
|
||||
))
|
||||
|
||||
metrics.inc(f"safety_{code}")
|
||||
|
||||
|
||||
# singleton
|
||||
safety = SafetyEngine()
|
||||
211
core/subprocess.py
Normal file
211
core/subprocess.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.events import bus, Event
|
||||
from core.metrics import metrics
|
||||
from core.safety import safety
|
||||
|
||||
|
||||
# =========================
|
||||
# PROCESS RUNNER
|
||||
# =========================
|
||||
|
||||
def run_command(
|
||||
cmd: list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: int = 60
|
||||
) -> dict[str, Any]:
|
||||
|
||||
# -------------------------
|
||||
# VALIDATION
|
||||
# -------------------------
|
||||
|
||||
if not cmd:
|
||||
raise ValueError(
|
||||
"Command list cannot be empty"
|
||||
)
|
||||
|
||||
for item in cmd:
|
||||
if not isinstance(item, str):
|
||||
raise ValueError(
|
||||
"cmd must contain only strings"
|
||||
)
|
||||
|
||||
executable = cmd[0].lower()
|
||||
|
||||
# -------------------------
|
||||
# SAFETY CHECKS
|
||||
# -------------------------
|
||||
|
||||
blocked_commands = {
|
||||
"rm",
|
||||
"rmdir",
|
||||
"del",
|
||||
"erase",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"mkfs",
|
||||
"format",
|
||||
"diskpart"
|
||||
}
|
||||
|
||||
if executable in blocked_commands:
|
||||
bus.emit(Event(
|
||||
name="process_blocked",
|
||||
source="SUBPROCESS",
|
||||
level="ERROR",
|
||||
data={
|
||||
"cmd": cmd,
|
||||
"reason": "blocked_command"
|
||||
}
|
||||
))
|
||||
|
||||
metrics.inc(
|
||||
"subprocess_blocked"
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"Blocked command: "
|
||||
f"{executable}"
|
||||
),
|
||||
"code": -1
|
||||
}
|
||||
|
||||
resolved_cwd: Path | None = None
|
||||
|
||||
if cwd is not None:
|
||||
resolved_cwd = (
|
||||
safety.validate_path(cwd)
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# START EVENT
|
||||
# -------------------------
|
||||
|
||||
bus.emit(Event(
|
||||
name="process_start",
|
||||
source="SUBPROCESS",
|
||||
level="INFO",
|
||||
data={
|
||||
"cmd": cmd,
|
||||
"cwd": str(resolved_cwd)
|
||||
if resolved_cwd else None,
|
||||
"timeout": timeout
|
||||
}
|
||||
))
|
||||
|
||||
metrics.inc(
|
||||
"subprocess_runs"
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# EXECUTE
|
||||
# -------------------------
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=(
|
||||
str(resolved_cwd)
|
||||
if resolved_cwd
|
||||
else None
|
||||
),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
|
||||
stdout = (
|
||||
result.stdout or ""
|
||||
)
|
||||
|
||||
stderr = (
|
||||
result.stderr or ""
|
||||
)
|
||||
|
||||
ok = (
|
||||
result.returncode == 0
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# COMPLETE EVENT
|
||||
# -------------------------
|
||||
|
||||
bus.emit(Event(
|
||||
name="process_complete",
|
||||
source="SUBPROCESS",
|
||||
level=(
|
||||
"SUCCESS"
|
||||
if ok
|
||||
else "ERROR"
|
||||
),
|
||||
data={
|
||||
"cmd": cmd,
|
||||
"code": result.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr
|
||||
}
|
||||
))
|
||||
|
||||
return {
|
||||
"ok": ok,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"code": result.returncode
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
# TIMEOUT
|
||||
# -------------------------
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
metrics.inc(
|
||||
"subprocess_timeouts"
|
||||
)
|
||||
|
||||
bus.emit(Event(
|
||||
name="process_timeout",
|
||||
source="SUBPROCESS",
|
||||
level="ERROR",
|
||||
data={
|
||||
"cmd": cmd,
|
||||
"timeout": timeout
|
||||
}
|
||||
))
|
||||
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "timeout",
|
||||
"code": -1
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
# UNEXPECTED ERROR
|
||||
# -------------------------
|
||||
|
||||
except Exception as e:
|
||||
metrics.inc(
|
||||
"subprocess_errors"
|
||||
)
|
||||
|
||||
bus.emit(Event(
|
||||
name="process_error",
|
||||
source="SUBPROCESS",
|
||||
level="ERROR",
|
||||
data={
|
||||
"cmd": cmd,
|
||||
"error": str(e)
|
||||
}
|
||||
))
|
||||
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"code": -1
|
||||
}
|
||||
93
core/tools/base.py
Normal file
93
core/tools/base.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from core.events import bus
|
||||
|
||||
|
||||
# =========================
|
||||
# TOOL CONTEXT
|
||||
# =========================
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
user: str | None = None
|
||||
dry_run: bool = False
|
||||
|
||||
# safe mutable metadata container
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# =========================
|
||||
# TOOL CONTRACT
|
||||
# =========================
|
||||
|
||||
class Tool(Protocol):
|
||||
name: str
|
||||
|
||||
def execute(self, payload: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
...
|
||||
|
||||
|
||||
# =========================
|
||||
# BASE TOOL
|
||||
# =========================
|
||||
|
||||
class BaseTool:
|
||||
name: str = "base"
|
||||
|
||||
# -------------------------
|
||||
# LIFECYCLE WRAPPER
|
||||
# -------------------------
|
||||
|
||||
def run(self, payload: dict[str, Any], ctx: ToolContext):
|
||||
bus.log(self.name, "tool_start", "INFO", {"payload": payload})
|
||||
|
||||
try:
|
||||
# attach execution metadata (useful for event graph later)
|
||||
ctx.meta.setdefault("tool", self.name)
|
||||
ctx.meta.setdefault("dry_run", ctx.dry_run)
|
||||
|
||||
if ctx.dry_run:
|
||||
result = self.preview(payload, ctx)
|
||||
else:
|
||||
result = self.execute(payload, ctx)
|
||||
|
||||
bus.log(
|
||||
self.name,
|
||||
"tool_success",
|
||||
"SUCCESS",
|
||||
{
|
||||
"result_type": type(result).__name__,
|
||||
"result": repr(result)[:500] # prevent log explosion
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
bus.log(
|
||||
self.name,
|
||||
"tool_error",
|
||||
"ERROR",
|
||||
{
|
||||
"error_type": type(e).__name__,
|
||||
"error": str(e)
|
||||
}
|
||||
)
|
||||
raise
|
||||
|
||||
# -------------------------
|
||||
# OVERRIDABLE
|
||||
# -------------------------
|
||||
|
||||
def execute(self, payload: dict[str, Any], ctx: ToolContext):
|
||||
raise NotImplementedError("Tool must implement execute()")
|
||||
|
||||
def preview(self, payload: dict[str, Any], ctx: ToolContext):
|
||||
return {
|
||||
"dry_run": True,
|
||||
"tool": self.name,
|
||||
"payload": payload
|
||||
}
|
||||
124
core/tools/registry.py
Normal file
124
core/tools/registry.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.tools.base import BaseTool, ToolContext
|
||||
from core.events import bus
|
||||
|
||||
|
||||
# =========================
|
||||
# TOOL REGISTRY
|
||||
# =========================
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self):
|
||||
self._tools: dict[str, BaseTool] = {}
|
||||
|
||||
# -------------------------
|
||||
# REGISTER
|
||||
# -------------------------
|
||||
|
||||
def register(self, tool: BaseTool):
|
||||
self._tools[tool.name] = tool
|
||||
|
||||
bus.log(
|
||||
"REGISTRY",
|
||||
"tool_registered",
|
||||
"INFO",
|
||||
{
|
||||
"tool": tool.name
|
||||
}
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# RESOLVE
|
||||
# -------------------------
|
||||
|
||||
def get(self, name: str) -> BaseTool:
|
||||
tool = self._tools.get(name)
|
||||
|
||||
if tool is None:
|
||||
bus.log(
|
||||
"REGISTRY",
|
||||
"tool_not_found",
|
||||
"ERROR",
|
||||
{"tool": name}
|
||||
)
|
||||
|
||||
raise ValueError(f"Tool not found: {name}")
|
||||
|
||||
return tool
|
||||
|
||||
# -------------------------
|
||||
# EXECUTE
|
||||
# -------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
name: str,
|
||||
payload: dict[str, Any],
|
||||
ctx: ToolContext
|
||||
):
|
||||
tool = self.get(name)
|
||||
|
||||
# registry metadata
|
||||
ctx.meta.setdefault("registry", True)
|
||||
ctx.meta.setdefault("tool_name", name)
|
||||
|
||||
bus.log(
|
||||
"REGISTRY",
|
||||
"tool_dispatch",
|
||||
"INFO",
|
||||
{
|
||||
"tool": name,
|
||||
"dry_run": ctx.dry_run,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
result = tool.run(payload, ctx)
|
||||
|
||||
bus.log(
|
||||
"REGISTRY",
|
||||
"tool_completed",
|
||||
"SUCCESS",
|
||||
{
|
||||
"tool": name,
|
||||
"result_type": type(result).__name__
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
bus.log(
|
||||
"REGISTRY",
|
||||
"tool_failed",
|
||||
"ERROR",
|
||||
{
|
||||
"tool": name,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__
|
||||
}
|
||||
)
|
||||
raise
|
||||
|
||||
# -------------------------
|
||||
# DISCOVERY (NEW)
|
||||
# -------------------------
|
||||
|
||||
def all_tools(self) -> list[BaseTool]:
|
||||
return list(self._tools.values())
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._tools.keys())
|
||||
|
||||
def exists(self, name: str) -> bool:
|
||||
return name in self._tools
|
||||
|
||||
|
||||
# =========================
|
||||
# SINGLETON
|
||||
# =========================
|
||||
|
||||
registry = ToolRegistry()
|
||||
Reference in New Issue
Block a user
