feat(agent): агент логов — фильтр, LLM-страж, планировщик grep (#18)
CI / ruff · mypy · pytest (pull_request) Successful in 39m13s
CI / ruff · mypy · pytest (pull_request) Successful in 39m13s
- prefilter: правила из config/agent_rules.yaml (длина, команды, shell,
секреты и системные файлы, prompt injection) — без LLM
- guard: отдельный вызов, JSON {verdict, reason}; невалидный ответ = attack,
недоступный LLM — «попробуйте позже», а не атака
- planner: только AgentLogQuery (контейнер из белого списка, ≤ 7 д, шаблон
≤ 200 без вложенных квантификаторов, лимит ≤ 500); логи в LLM не идут
- бот: «🤖 Спросить агента» → показывает построенный grep и строки;
атака — вежливый отказ и 🛡 администраторам; квота 30 в сутки
- журнал agent_requests + log_queries (mode=agent)
- при LLM_PROVIDER=mock агент выключен
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+116
-18
@@ -1,4 +1,4 @@
|
||||
"""«📜 Логи»: pick a container, then its tail or your own grep (SPEC L-1…L-3, L-5, L-6). The agent is #18."""
|
||||
"""«📜 Логи»: pick a container, then its tail, your own grep or a question to the agent (SPEC L-1…L-6, L-10…L-17)."""
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
@@ -6,16 +6,19 @@ import logging
|
||||
from datetime import timedelta
|
||||
from enum import StrEnum
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram import Bot, F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from app.access.service import AccessService
|
||||
from app.agent.service import Agent, Outcome
|
||||
from app.bot.menu import Nav, NavAction, free_input
|
||||
from app.db.models import LogMode, User, utcnow
|
||||
from app.db.models import LogMode, Role, User, utcnow
|
||||
from app.logs.grep import GrepError, GrepErrorCode, GrepResult, parse_grep, run, to_command
|
||||
from app.logs.journal import LogJournal
|
||||
from app.runtime.docker import Container, DockerClient, DockerError
|
||||
@@ -47,6 +50,7 @@ class LogsCb(CallbackData, prefix="lg"):
|
||||
|
||||
class LogsFlow(StatesGroup):
|
||||
grep = State()
|
||||
agent = State()
|
||||
|
||||
|
||||
def period(t: LogsTexts, minutes: int) -> str:
|
||||
@@ -122,7 +126,28 @@ async def send_result(
|
||||
)
|
||||
|
||||
|
||||
def build_router(texts: Texts, docker: DockerClient | None, journal: LogJournal) -> Router:
|
||||
async def deliver(
|
||||
msg: Message, texts: Texts, head: str, count_only: bool, result: GrepResult, kb: InlineKeyboardMarkup, name: str
|
||||
) -> None:
|
||||
footer = notes(texts.logs, result)
|
||||
if count_only: # -c: the number in the header is the answer
|
||||
tail = "".join(f"\n{html.escape(n)}" for n in footer)
|
||||
await msg.answer(head + tail, parse_mode=ParseMode.HTML, reply_markup=kb)
|
||||
return
|
||||
await send_result(msg, texts, head, "\n".join(result.lines), footer, kb, f"{name}-grep.log")
|
||||
|
||||
|
||||
def who(user: User) -> str:
|
||||
return f"{user.full_name}" + (f" @{user.username}" if user.username else "") + f" ({user.tg_id})"
|
||||
|
||||
|
||||
def build_router(
|
||||
texts: Texts,
|
||||
docker: DockerClient | None,
|
||||
journal: LogJournal,
|
||||
agent: Agent | None = None,
|
||||
access: AccessService | None = None,
|
||||
) -> Router:
|
||||
router = Router(name="logs")
|
||||
t = texts.logs
|
||||
|
||||
@@ -131,6 +156,24 @@ def build_router(texts: Texts, docker: DockerClient | None, journal: LogJournal)
|
||||
raise DockerError("docker is not configured (DOCKER_URL)")
|
||||
return docker
|
||||
|
||||
async def alert_admins(bot: Bot, member: User, prompt: str, stage: str, reason: str) -> None:
|
||||
"""🛡 to the admins only (SPEC AL-2, L-12). The asker sees just a polite refusal."""
|
||||
if access is None:
|
||||
return
|
||||
text = t.attack_alert.format(
|
||||
who=html.escape(who(member)),
|
||||
stage=stage,
|
||||
reason=html.escape(reason),
|
||||
prompt=html.escape(prompt[:1000]),
|
||||
)
|
||||
for admin in await access.list_members():
|
||||
if admin.role is not Role.ADMIN:
|
||||
continue
|
||||
try:
|
||||
await bot.send_message(admin.tg_id, text, parse_mode=ParseMode.HTML)
|
||||
except TelegramAPIError as e: # one blocked admin must not hide the alert from the others
|
||||
log.warning("attack alert to %s failed: %s", admin.tg_id, e)
|
||||
|
||||
@router.message(F.text == texts.menu.logs)
|
||||
async def open_logs(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
@@ -158,6 +201,8 @@ def build_router(texts: Texts, docker: DockerClient | None, journal: LogJournal)
|
||||
return
|
||||
c = await d.get(callback_data.id)
|
||||
kb = container_keyboard(texts, c.short_id)
|
||||
back = InlineKeyboardBuilder()
|
||||
back.button(text=t.to_container, callback_data=LogsCb(a=LogsAct.OPEN, id=c.short_id))
|
||||
match callback_data.a:
|
||||
case LogsAct.OPEN:
|
||||
await state.clear()
|
||||
@@ -180,16 +225,25 @@ def build_router(texts: Texts, docker: DockerClient | None, journal: LogJournal)
|
||||
case LogsAct.GREP:
|
||||
await state.set_state(LogsFlow.grep)
|
||||
await state.update_data(logs_id=c.short_id)
|
||||
back = InlineKeyboardBuilder()
|
||||
back.button(text=t.to_container, callback_data=LogsCb(a=LogsAct.OPEN, id=c.short_id))
|
||||
await msg.answer(
|
||||
t.ask_grep.format(name=html.escape(c.name)),
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=back.as_markup(),
|
||||
)
|
||||
case LogsAct.AGENT:
|
||||
await query.answer(t.agent_soon, show_alert=True)
|
||||
return
|
||||
if agent is None:
|
||||
await query.answer(t.agent_off, show_alert=True)
|
||||
return
|
||||
left = max(agent.quota - await agent.used_today(member.tg_id, utcnow()), 0)
|
||||
if left == 0:
|
||||
await query.answer(t.agent_quota.format(quota=agent.quota), show_alert=True)
|
||||
return
|
||||
await state.set_state(LogsFlow.agent)
|
||||
await state.update_data(logs_id=c.short_id)
|
||||
await msg.answer(
|
||||
t.agent_ask.format(name=html.escape(c.name), left=left, quota=agent.quota),
|
||||
reply_markup=back.as_markup(),
|
||||
)
|
||||
except DockerError as e:
|
||||
log.warning("logs: %s", e)
|
||||
await query.answer(t.unavailable, show_alert=True)
|
||||
@@ -218,17 +272,61 @@ def build_router(texts: Texts, docker: DockerClient | None, journal: LogJournal)
|
||||
head = t.head_grep.format(
|
||||
command=html.escape(to_command(query)), name=html.escape(c.name), matched=result.matched
|
||||
)
|
||||
body = "" if query.count else "\n".join(result.lines)
|
||||
footer = notes(t, result)
|
||||
if query.count: # -c: the number in the header is the answer
|
||||
await message.answer(
|
||||
head + "".join(f"\n{html.escape(n)}" for n in footer),
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=container_keyboard(texts, c.short_id),
|
||||
)
|
||||
await deliver(message, texts, head, query.count, result, container_keyboard(texts, c.short_id), c.name)
|
||||
|
||||
@router.message(LogsFlow.agent, F.text, free_input(texts))
|
||||
async def got_question(message: Message, state: FSMContext, member: User, bot: Bot) -> None:
|
||||
if agent is None: # pragma: no cover - the button doesn't open this state without an agent
|
||||
await state.clear()
|
||||
return
|
||||
await send_result(
|
||||
message, texts, head, body, footer, container_keyboard(texts, c.short_id), f"{c.name}-grep.log"
|
||||
prompt = message.text or ""
|
||||
data = await state.get_data()
|
||||
await state.clear()
|
||||
try:
|
||||
d = need_docker()
|
||||
current = await d.get(data.get("logs_id", ""))
|
||||
containers = await d.containers()
|
||||
except DockerError as e:
|
||||
log.warning("logs: %s", e)
|
||||
await message.answer(t.unavailable)
|
||||
return
|
||||
await message.answer(t.agent_thinking)
|
||||
answer = await agent.ask(member.tg_id, prompt, [c.name for c in containers], current.name)
|
||||
kb = container_keyboard(texts, current.short_id)
|
||||
match answer.outcome:
|
||||
case Outcome.ATTACK:
|
||||
await message.answer(t.agent_refused, reply_markup=kb)
|
||||
await alert_admins(bot, member, prompt, answer.stage, answer.reason)
|
||||
return
|
||||
case Outcome.QUOTA:
|
||||
await message.answer(t.agent_quota.format(quota=agent.quota), reply_markup=kb)
|
||||
return
|
||||
case Outcome.UNAVAILABLE:
|
||||
await message.answer(t.agent_unavailable, reply_markup=kb)
|
||||
return
|
||||
case Outcome.NOT_UNDERSTOOD:
|
||||
await message.answer(t.agent_not_understood, reply_markup=kb)
|
||||
return
|
||||
q = answer.query
|
||||
assert q is not None
|
||||
target = next(c for c in containers if c.name == q.container) # the validator allowed only these names
|
||||
try:
|
||||
text = await d.logs(target.id, since=utcnow() - timedelta(minutes=q.since_minutes))
|
||||
except DockerError as e:
|
||||
log.warning("logs: %s", e)
|
||||
await message.answer(t.unavailable)
|
||||
return
|
||||
grep = q.to_grep()
|
||||
result = await asyncio.to_thread(run, grep, text, q.limit)
|
||||
await journal.record(member.tg_id, target.name, LogMode.AGENT, q.model_dump(), result.matched)
|
||||
head = t.head_agent.format(
|
||||
command=html.escape(to_command(grep)),
|
||||
name=html.escape(target.name),
|
||||
period=period(t, q.since_minutes),
|
||||
matched=result.matched,
|
||||
)
|
||||
await deliver(
|
||||
message, texts, head, q.count_only, result, container_keyboard(texts, target.short_id), target.name
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
Reference in New Issue
Block a user