Files
ElebobaandClaude Opus 5.5 65acf3abf1
CI / ruff · mypy · pytest (pull_request) Successful in 39m13s
feat(agent): агент логов — фильтр, LLM-страж, планировщик grep (#18)
- 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>
2026-10-17 15:02:07 +03:00

333 lines
14 KiB
Python

"""«📜 Логи»: 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
import logging
from datetime import timedelta
from enum import StrEnum
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, 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
from app.texts import LogsTexts, Texts
log = logging.getLogger(__name__)
TAILS = (50, 200) # lines (L-2)
PERIODS = (5, 60, 24 * 60) # minutes (L-2)
GREP_WINDOW = timedelta(hours=24)
MAX_MESSAGE = 3500 # leave room under Telegram's 4096 for the header and HTML markup
MAX_FILE = 5 * 1024 * 1024 # L-5
class LogsAct(StrEnum):
LIST = "l"
OPEN = "o"
TAIL = "t"
SINCE = "s"
GREP = "g"
AGENT = "a"
class LogsCb(CallbackData, prefix="lg"):
a: LogsAct
id: str = "" # 12-char container id prefix
n: int = 0 # lines for TAIL, minutes for SINCE
class LogsFlow(StatesGroup):
grep = State()
agent = State()
def period(t: LogsTexts, minutes: int) -> str:
return t.hours.format(n=minutes // 60) if minutes >= 60 else t.minutes.format(n=minutes)
def list_keyboard(texts: Texts, containers: list[Container]) -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
for c in containers:
kb.button(text=f"{c.light.value} {c.name}", callback_data=LogsCb(a=LogsAct.OPEN, id=c.short_id))
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
kb.adjust(1)
return kb.as_markup()
def container_keyboard(texts: Texts, short_id: str) -> InlineKeyboardMarkup:
t = texts.logs
kb = InlineKeyboardBuilder()
for n in TAILS:
kb.button(text=t.tail.format(n=n), callback_data=LogsCb(a=LogsAct.TAIL, id=short_id, n=n))
for minutes in PERIODS:
kb.button(text=period(t, minutes), callback_data=LogsCb(a=LogsAct.SINCE, id=short_id, n=minutes))
kb.button(text=t.grep_button, callback_data=LogsCb(a=LogsAct.GREP, id=short_id))
kb.button(text=t.agent_button, callback_data=LogsCb(a=LogsAct.AGENT, id=short_id))
kb.button(text=t.to_list, callback_data=LogsCb(a=LogsAct.LIST))
kb.adjust(2, 3, 2, 1)
return kb.as_markup()
def notes(t: LogsTexts, result: GrepResult) -> list[str]:
out = []
if result.truncated:
out.append(t.truncated.format(n=len(result.lines)))
if result.partial:
out.append(t.partial)
if result.skipped:
out.append(t.skipped.format(n=result.skipped))
return out
def grep_error(t: LogsTexts, e: GrepError) -> str:
template = {
GrepErrorCode.EMPTY: t.err_empty,
GrepErrorCode.QUOTES: t.err_quotes,
GrepErrorCode.FLAG: t.err_flag,
GrepErrorCode.CONTEXT: t.err_context,
GrepErrorCode.EXTRA: t.err_extra,
GrepErrorCode.TOO_LONG: t.err_too_long,
GrepErrorCode.REGEX: t.err_regex,
}[e.code]
return template.format(arg=html.escape(e.arg[:100]))
async def send_result(
msg: Message, texts: Texts, head: str, body: str, footer: list[str], kb: InlineKeyboardMarkup, filename: str
) -> None:
"""Short results inline as <pre>, long ones as a .log document (L-5); the container keyboard rides along."""
t = texts.logs
tail = "".join(f"\n{html.escape(n)}" for n in footer)
if not body:
await msg.answer(f"{head}\n{t.nothing}{tail}", parse_mode=ParseMode.HTML, reply_markup=kb)
return
escaped = html.escape(body)
if len(escaped) <= MAX_MESSAGE:
await msg.answer(f"{head}\n<pre>{escaped}</pre>{tail}", parse_mode=ParseMode.HTML, reply_markup=kb)
return
data = body.encode()[-MAX_FILE:]
await msg.answer_document(
BufferedInputFile(data, filename),
caption=f"{head}\n{t.sent_as_file}{tail}",
parse_mode=ParseMode.HTML,
reply_markup=kb,
)
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
def need_docker() -> DockerClient:
if docker is None:
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()
try:
containers = await need_docker().containers()
except DockerError as e:
log.warning("logs: %s", e)
await message.answer(t.unavailable)
return
await message.answer(t.title if containers else t.empty, reply_markup=list_keyboard(texts, containers))
@router.callback_query(LogsCb.filter())
async def on_action(query: CallbackQuery, callback_data: LogsCb, state: FSMContext, member: User) -> None:
if not isinstance(query.message, Message):
await query.answer()
return
msg = query.message
try:
d = need_docker()
if callback_data.a is LogsAct.LIST:
await state.clear()
containers = await d.containers()
await msg.edit_text(t.title if containers else t.empty, reply_markup=list_keyboard(texts, containers))
await query.answer()
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()
await msg.edit_text(t.container.format(name=c.name), reply_markup=kb)
case LogsAct.TAIL | LogsAct.SINCE:
await query.answer()
if callback_data.a is LogsAct.TAIL:
n = min(max(callback_data.n, 1), max(TAILS))
text = await d.logs(c.id, tail=n)
scope, q = t.tail.format(n=n), {"tail": n}
else:
minutes = min(max(callback_data.n, 1), max(PERIODS))
text = await d.logs(c.id, since=utcnow() - timedelta(minutes=minutes))
scope, q = t.since.format(period=period(t, minutes)), {"since_minutes": minutes}
lines = text.splitlines()
await journal.record(member.tg_id, c.name, LogMode.TAIL, q, len(lines))
head = t.head_tail.format(name=html.escape(c.name), scope=scope, lines=len(lines))
await send_result(msg, texts, head, "\n".join(lines), [], kb, f"{c.name}.log")
return
case LogsAct.GREP:
await state.set_state(LogsFlow.grep)
await state.update_data(logs_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:
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)
return
await query.answer()
@router.message(LogsFlow.grep, F.text, free_input(texts))
async def got_grep(message: Message, state: FSMContext, member: User) -> None:
try:
query = parse_grep(message.text or "")
except GrepError as e:
await message.answer(grep_error(t, e), parse_mode=ParseMode.HTML)
return # stay in the state: fix and resend
data = await state.get_data()
try:
d = need_docker()
c = await d.get(data.get("logs_id", ""))
text = await d.logs(c.id, since=utcnow() - GREP_WINDOW)
except DockerError as e:
log.warning("logs: %s", e)
await message.answer(t.unavailable)
return
await state.clear()
result = await asyncio.to_thread(run, query, text)
await journal.record(member.tg_id, c.name, LogMode.GREP, query.to_dict(), result.matched)
head = t.head_grep.format(
command=html.escape(to_command(query)), name=html.escape(c.name), matched=result.matched
)
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
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